Sign In

@adrkit/core

Package Overview
Dependencies
Maintainers
1
Versions
8
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@adrkit/core - npm Package Compare versions

Comparing version
0.5.0
to
0.6.0
+17
-0
dist/load/corpus.d.ts

@@ -18,2 +18,19 @@ import { type Adr } from '../schema/adr.schema.js';

export declare function normalizeDisplayPath(path: string, cwd?: string): string;
/**
* Compare two paths by their normalized display form, by code unit.
*
* Every corpus ordering goes through here rather than repeating the compound
* `compareCodeUnits(normalizeDisplayPath(…), normalizeDisplayPath(…))` per call site,
* so a future sort cannot quietly normalize one side and not the other, and so the
* locale-independence of the whole family is settled in one place (#115).
*/
export declare function compareByDisplayPath(a: string, b: string, cwd?: string): number;
/**
* Discovery order is a determinism contract, not display polish: it survives into
* `lintCorpus`'s `records` whenever two records share an id (the `frontmatter.id`
* tiebreak is then a no-op over a stable sort), and `checkChanges` reads whichever
* duplicate landed later as that id's canonical record. Ordering by `compareCodeUnits`
* rather than the runtime's ICU locale is what keeps `governing` / `activeProposals` /
* `governedBy` identical for byte-identical corpus files (#115).
*/
export declare function discoverAdrFiles(dir?: string, cwd?: string): Promise<string[]>;

@@ -20,0 +37,0 @@ /**

@@ -36,2 +36,29 @@ /**

truncated: boolean;
/**
* Bytes decoded and handed to the scanner.
*
* Not `min(fileBytes, MARKER_HEADER_WINDOW_BYTES)`: a truncated window is cut back to
* its last complete line, so the extent depends on where that line ends and cannot be
* derived from the constants alone. Absent unless `state` is `scanned` — a file that was
* never opened has no extent, and `0` would report a measurement rather than its absence
* (`0` is itself a real answer: a window holding no line terminator scanned nothing).
*
* Note the `unreadable` edge: an I/O error raised *after* some bytes were read also
* reports `unreadable`, and therefore no extent, even though bytes were read (#108).
*/
scannedBytes?: number;
/**
* The file's size in bytes, so the size of the unscanned remainder is
* `fileBytes - scannedBytes` without the caller knowing the window constant. Absent
* unless `state` is `scanned`.
*
* Taken by `fstat` on the open handle *before* the read loop, so the two numbers are two
* observations of a file that can change between them: an append can yield
* `scannedBytes > fileBytes`, and a truncation can make a fully read file look partial.
* This is not the check/open race ADR-0021 records — that one is path substitution before
* `open`, which cannot produce this — but a content mutation behind an already-open
* handle. Left unreconciled rather than clamped, because agreeing the two numbers would
* hide a file that changed underneath the scan.
*/
fileBytes?: number;
}

@@ -38,0 +65,0 @@ /**

@@ -23,2 +23,24 @@ /**

}
/**
* {@link completeLinePrefix}'s cut, measured on the raw bytes: the extent of the
* complete lines within `bytes[0, limit)`, or `0` when the window holds no line
* terminator at all.
*
* Internal to `@adrkit/core`: `markers/index.ts` does not re-export it, and it is not
* public API. It exists so the filesystem reader can report the number it cut at
* without decoding twice.
*
* The byte search is equivalent to the string search rather than an approximation of
* it. `\n` (0x0A) and `\r` (0x0D) are single-byte code points, and every UTF-8
* continuation byte is >= 0x80, so neither terminator can occur inside a multi-byte
* sequence. That equivalence is a rule spanning two representations and nothing in the
* type system holds it, so it is pinned by test ("the byte cut and the text cut cannot
* drift apart") rather than left to this comment.
*
* Measured here rather than by re-encoding the decoded prefix, for the same reason the
* truncation flag is observed rather than inferred (see
* {@link scanBoundedSourceMarkerWindow}): `TextDecoder` may drop a BOM or expand one
* invalid byte into U+FFFD, so a re-encoded length is not the length that was read.
*/
export declare function completeLineByteExtent(bytes: Uint8Array, limit: number): number;
export declare function headerWindow(source: string): HeaderWindow;

@@ -25,0 +47,0 @@ export interface ScanSourceMarkersResult {

+1
-1
{
"name": "@adrkit/core",
"version": "0.5.0",
"version": "0.6.0",
"description": "Pure ADR parsing, validation, migration, and affects resolution for adrkit.",

@@ -5,0 +5,0 @@ "type": "module",

@@ -64,4 +64,18 @@ # @adrkit/core

`unreadable`, or `out-of-tree`, so "found no markers" is never confused with
"could not look."
"could not look." A `scanned` result also carries `scannedBytes` and `fileBytes`.
`scannedBytes` is the prefix handed to the scanner — not the number of bytes pulled
from the handle, which also includes a truncation probe byte and any partial trailing
line the cut then discards — and it is the cut actually taken rather than
`min(fileBytes, 8192)`, because the window is cut back to its last complete line.
`fileBytes - scannedBytes` is how much of a truncated file went unscanned, which
`truncated` alone does not say. Both are omitted for a state that never opened the
file, so a `0` extent means a window with no line terminator, never "not measured."
The two are separate observations, not one: `fileBytes` comes from an `fstat` taken
before the read loop, so a file written concurrently can report `scannedBytes >
fileBytes` (appended) or look partial when it was read whole (truncated). They are
left unreconciled deliberately — agreeing them would hide a file that changed
underneath the scan — so a consumer differencing them should clamp at `0` rather than
assume the remainder is non-negative.
Its `path` argument is repo-relative to `cwd`. Absolute paths and paths that climb

@@ -68,0 +82,0 @@ out of the tree are `out-of-tree`. Every symlink is refused as `unreadable`

@@ -125,3 +125,3 @@ // @adr 0022 — this file resolves the inbound edge, so ADR-0022's `affects` also names it

function uniqueSorted(values: readonly string[]): string[] {
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
return [...new Set(values)].sort(compareCodeUnits);
}

@@ -128,0 +128,0 @@

import { readdir, readFile, stat } from 'node:fs/promises';
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { AdrFrontmatter, type Adr } from '../schema/adr.schema.ts';
import { compareCodeUnits } from '../ordering/index.ts';
import { parseFrontmatter } from '../parse/frontmatter.ts';

@@ -47,2 +48,22 @@

/**
* Compare two paths by their normalized display form, by code unit.
*
* Every corpus ordering goes through here rather than repeating the compound
* `compareCodeUnits(normalizeDisplayPath(…), normalizeDisplayPath(…))` per call site,
* so a future sort cannot quietly normalize one side and not the other, and so the
* locale-independence of the whole family is settled in one place (#115).
*/
export function compareByDisplayPath(a: string, b: string, cwd = process.cwd()): number {
return compareCodeUnits(normalizeDisplayPath(a, cwd), normalizeDisplayPath(b, cwd));
}
/**
* Discovery order is a determinism contract, not display polish: it survives into
* `lintCorpus`'s `records` whenever two records share an id (the `frontmatter.id`
* tiebreak is then a no-op over a stable sort), and `checkChanges` reads whichever
* duplicate landed later as that id's canonical record. Ordering by `compareCodeUnits`
* rather than the runtime's ICU locale is what keeps `governing` / `activeProposals` /
* `governedBy` identical for byte-identical corpus files (#115).
*/
export async function discoverAdrFiles(dir = 'docs/adr', cwd = process.cwd()): Promise<string[]> {

@@ -54,3 +75,3 @@ const absoluteDir = toAbsolutePath(dir, cwd);

.map((entry) => join(absoluteDir, entry.name))
.sort((a, b) => normalizeDisplayPath(a, cwd).localeCompare(normalizeDisplayPath(b, cwd)));
.sort((a, b) => compareByDisplayPath(a, b, cwd));
}

@@ -112,3 +133,3 @@

await collectSkippedMarkdown(toAbsolutePath(dir, cwd), 0, found);
return found.sort((a, b) => normalizeDisplayPath(a.path, cwd).localeCompare(normalizeDisplayPath(b.path, cwd)));
return found.sort((a, b) => compareByDisplayPath(a.path, b.path, cwd));
}

@@ -140,5 +161,3 @@

return Array.from(new Set(expanded)).sort((a, b) =>
normalizeDisplayPath(a, cwd).localeCompare(normalizeDisplayPath(b, cwd)),
);
return Array.from(new Set(expanded)).sort((a, b) => compareByDisplayPath(a, b, cwd));
}

@@ -145,0 +164,0 @@

@@ -18,3 +18,7 @@ /**

import { compareCodeUnits } from '../ordering/index.ts';
import { MARKER_HEADER_WINDOW_BYTES, scanBoundedSourceMarkerWindow } from './scan.ts';
import {
MARKER_HEADER_WINDOW_BYTES,
completeLineByteExtent,
scanBoundedSourceMarkerWindow,
} from './scan.ts';
import { mapConcurrent } from './pool.ts';

@@ -45,2 +49,29 @@ import type { SourceMarker } from './types.ts';

truncated: boolean;
/**
* Bytes decoded and handed to the scanner.
*
* Not `min(fileBytes, MARKER_HEADER_WINDOW_BYTES)`: a truncated window is cut back to
* its last complete line, so the extent depends on where that line ends and cannot be
* derived from the constants alone. Absent unless `state` is `scanned` — a file that was
* never opened has no extent, and `0` would report a measurement rather than its absence
* (`0` is itself a real answer: a window holding no line terminator scanned nothing).
*
* Note the `unreadable` edge: an I/O error raised *after* some bytes were read also
* reports `unreadable`, and therefore no extent, even though bytes were read (#108).
*/
scannedBytes?: number;
/**
* The file's size in bytes, so the size of the unscanned remainder is
* `fileBytes - scannedBytes` without the caller knowing the window constant. Absent
* unless `state` is `scanned`.
*
* Taken by `fstat` on the open handle *before* the read loop, so the two numbers are two
* observations of a file that can change between them: an append can yield
* `scannedBytes > fileBytes`, and a truncation can make a fully read file look partial.
* This is not the check/open race ADR-0021 records — that one is path substitution before
* `open`, which cannot produce this — but a content mutation behind an already-open
* handle. Left unreconciled rather than clamped, because agreeing the two numbers would
* hide a file that changed underneath the scan.
*/
fileBytes?: number;
}

@@ -207,3 +238,4 @@

// a concurrent process can still replace the approved path before it is opened.
if (!(await handle.stat()).isFile()) return refuse('unreadable');
const opened = await handle.stat();
if (!opened.isFile()) return refuse('unreadable');

@@ -219,7 +251,18 @@ const buffer = new Uint8Array(MARKER_HEADER_WINDOW_BYTES + 1);

const truncated = bytesRead > MARKER_HEADER_WINDOW_BYTES;
const source = new TextDecoder().decode(
buffer.subarray(0, Math.min(bytesRead, MARKER_HEADER_WINDOW_BYTES)),
);
// The scanner discards a severed line, so the extent it actually read is the cut
// itself, not the window. Cutting here rather than only inside the scanner keeps the
// reported number and the scanned text the same computation, so they cannot drift.
const scannedBytes = truncated
? completeLineByteExtent(buffer, MARKER_HEADER_WINDOW_BYTES)
: bytesRead;
const source = new TextDecoder().decode(buffer.subarray(0, scannedBytes));
const scan = scanBoundedSourceMarkerWindow(source, normalizedPath, truncated);
return { path: normalizedPath, state: 'scanned', markers: scan.markers, truncated: scan.truncated };
return {
path: normalizedPath,
state: 'scanned',
markers: scan.markers,
truncated: scan.truncated,
scannedBytes,
fileBytes: opened.size,
};
} catch (error) {

@@ -226,0 +269,0 @@ return refuse(scanStateForError(error));

@@ -134,2 +134,31 @@ /**

/**
* {@link completeLinePrefix}'s cut, measured on the raw bytes: the extent of the
* complete lines within `bytes[0, limit)`, or `0` when the window holds no line
* terminator at all.
*
* Internal to `@adrkit/core`: `markers/index.ts` does not re-export it, and it is not
* public API. It exists so the filesystem reader can report the number it cut at
* without decoding twice.
*
* The byte search is equivalent to the string search rather than an approximation of
* it. `\n` (0x0A) and `\r` (0x0D) are single-byte code points, and every UTF-8
* continuation byte is >= 0x80, so neither terminator can occur inside a multi-byte
* sequence. That equivalence is a rule spanning two representations and nothing in the
* type system holds it, so it is pinned by test ("the byte cut and the text cut cannot
* drift apart") rather than left to this comment.
*
* Measured here rather than by re-encoding the decoded prefix, for the same reason the
* truncation flag is observed rather than inferred (see
* {@link scanBoundedSourceMarkerWindow}): `TextDecoder` may drop a BOM or expand one
* invalid byte into U+FFFD, so a re-encoded length is not the length that was read.
*/
export function completeLineByteExtent(bytes: Uint8Array, limit: number): number {
for (let index = Math.min(limit, bytes.length) - 1; index >= 0; index -= 1) {
const byte = bytes[index];
if (byte === 0x0a || byte === 0x0d) return index + 1;
}
return 0;
}
export function headerWindow(source: string): HeaderWindow {

@@ -136,0 +165,0 @@ const bytes = new Uint8Array(MARKER_HEADER_WINDOW_BYTES);

@@ -0,1 +1,4 @@

// Runtime-acyclic: `../ordering/index.ts` imports `Finding` from here type-only.
import { compareCodeUnits } from '../ordering/index.ts';
export const IMPORT_FINDING_RULES = [

@@ -25,3 +28,3 @@ 'import-incomplete',

function compareOptional(a: string | undefined, b: string | undefined): number {
return (a ?? '').localeCompare(b ?? '');
return compareCodeUnits(a ?? '', b ?? '');
}

@@ -32,3 +35,3 @@

(a, b) =>
a.rule.localeCompare(b.rule) ||
compareCodeUnits(a.rule, b.rule) ||
compareOptional(a.id, b.id) ||

@@ -38,3 +41,3 @@ compareOptional(a.pattern, b.pattern) ||

compareOptional(a.field, b.field) ||
a.message.localeCompare(b.message),
compareCodeUnits(a.message, b.message),
);

@@ -41,0 +44,0 @@ }

@@ -6,2 +6,3 @@ import { stat } from 'node:fs/promises';

expandRecordInputs,
compareByDisplayPath,
discoverSkippedMarkdownFiles,

@@ -13,2 +14,3 @@ normalizeDisplayPath,

import { FrontmatterError } from '../parse/frontmatter.ts';
import { compareCodeUnits } from '../ordering/index.ts';
import { validateParsedAdr } from './contract.ts';

@@ -95,3 +97,3 @@ import { validateCorpusInvariants } from './corpus-invariants.ts';

}
return [...directories].sort((a, b) => normalizeDisplayPath(a, cwd).localeCompare(normalizeDisplayPath(b, cwd)));
return [...directories].sort((a, b) => compareByDisplayPath(a, b, cwd));
}

@@ -135,4 +137,8 @@

findings: sortFindings(findings),
records: [...records].sort((a, b) => a.frontmatter.id.localeCompare(b.frontmatter.id)),
// `records` is a public surface of `LintCorpusResult` and the input `checkChanges`
// reads, so its order is locale-independent too. For equal ids this comparison is a
// no-op and the stable sort preserves `discoverAdrFiles`' order, which is what makes
// the duplicate-id decision in `toGoverningDecisions` deterministic (#115).
records: [...records].sort((a, b) => compareCodeUnits(a.frontmatter.id, b.frontmatter.id)),
};
}

Sorry, the diff of this file is too big to display