@adrkit/core
Advanced tools
@@ -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", |
+15
-1
@@ -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 @@ |
+24
-5
| 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 @@ |
+49
-6
@@ -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)); |
+29
-0
@@ -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
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
374136
2.87%9017
2.09%102
15.91%