Sign In

@adrkit/core

Package Overview
Dependencies
Maintainers
1
Versions
9
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.4.0
to
0.5.0
+27
dist/check/decisions.d.ts
import { type AffectsMatch } from '../affects/index.js';
import type { MarkerDeclaration } from '../markers/types.js';
import type { Adr, Status } from '../schema/adr.schema.js';
import { type DecisionBucket } from '../status/bucket.js';
export interface GoverningDecision {
recordId: string;
title: string;
/** The record's status; a matched record is not necessarily accepted. */
status: Status;
/** Which governance bucket this record belongs to. */
bucket: DecisionBucket;
/** The successor, when this record was superseded. */
supersededBy?: string;
/** The record's own `affects` matchers that fired — the outbound edge. */
firedMatchers: AffectsMatch['firedMatchers'];
/** Source locations that declared this record — the inbound edge. */
declaredBy?: MarkerDeclaration[];
}
/** Turn resolver matches into status-carrying decisions. */
export declare function toGoverningDecisions(records: readonly Adr[], matches: readonly AffectsMatch[]): GoverningDecision[];
export interface BucketedDecisions<T extends GoverningDecision = GoverningDecision> {
governing: T[];
activeProposals: T[];
history: T[];
}
/** Partition decisions into the three buckets, preserving input order. */
export declare function bucketDecisions<T extends GoverningDecision>(decisions: readonly T[]): BucketedDecisions<T>;
/** Map items with a hard concurrency ceiling while preserving input order. */
export declare function mapConcurrent<T, R>(items: readonly T[], concurrency: number, task: (item: T, index: number) => Promise<R>): Promise<R[]>;
import { type AffectsMatch } from '../affects/index.ts';
import type { MarkerDeclaration } from '../markers/types.ts';
import type { Adr, Status } from '../schema/adr.schema.ts';
import { decisionBucketFor, type DecisionBucket } from '../status/bucket.ts';
export interface GoverningDecision {
recordId: string;
title: string;
/** The record's status; a matched record is not necessarily accepted. */
status: Status;
/** Which governance bucket this record belongs to. */
bucket: DecisionBucket;
/** The successor, when this record was superseded. */
supersededBy?: string;
/** The record's own `affects` matchers that fired — the outbound edge. */
firedMatchers: AffectsMatch['firedMatchers'];
/** Source locations that declared this record — the inbound edge. */
declaredBy?: MarkerDeclaration[];
}
/** Turn resolver matches into status-carrying decisions. */
export function toGoverningDecisions(
records: readonly Adr[],
matches: readonly AffectsMatch[],
): GoverningDecision[] {
const byId = new Map(records.map((record) => [record.frontmatter.id, record]));
return matches.map((match) => {
const frontmatter = byId.get(match.recordId)?.frontmatter;
const status: Status = frontmatter?.status ?? 'draft';
return {
recordId: match.recordId,
title: frontmatter?.title ?? '',
status,
bucket: decisionBucketFor(status),
...(frontmatter?.supersededBy ? { supersededBy: frontmatter.supersededBy } : {}),
firedMatchers: match.firedMatchers,
};
});
}
export interface BucketedDecisions<T extends GoverningDecision = GoverningDecision> {
governing: T[];
activeProposals: T[];
history: T[];
}
/** Partition decisions into the three buckets, preserving input order. */
export function bucketDecisions<T extends GoverningDecision>(
decisions: readonly T[],
): BucketedDecisions<T> {
const buckets: BucketedDecisions<T> = { governing: [], activeProposals: [], history: [] };
for (const decision of decisions) buckets[decision.bucket].push(decision);
return buckets;
}
/** Map items with a hard concurrency ceiling while preserving input order. */
export async function mapConcurrent<T, R>(
items: readonly T[],
concurrency: number,
task: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(items.length);
let next = 0;
const worker = async (): Promise<void> => {
while (next < items.length) {
const index = next;
next += 1;
const item = items[index] as T;
results[index] = await task(item, index);
}
};
await Promise.all(
Array.from({ length: Math.min(concurrency, items.length) }, () => worker()),
);
return results;
}
+21
-32

@@ -1,5 +0,7 @@

import type { Adr, Status } from '../schema/adr.schema.js';
import { type AffectsMatch, type FiredMatcher, type ResolutionSnapshots } from '../affects/index.js';
import { type DecisionBucket } from '../status/bucket.js';
import type { Adr } from '../schema/adr.schema.js';
import { type ResolutionSnapshots } from '../affects/index.js';
import type { SourceMarkerBatchScan } from '../markers/read.js';
import { type Finding } from '../validate/findings.js';
import { type GoverningDecision } from './decisions.js';
export { bucketDecisions, toGoverningDecisions, type BucketedDecisions, type GoverningDecision, } from './decisions.js';
/**

@@ -16,20 +18,17 @@ * The full result of `lintCorpus` — records, findings, and the checked count.

}
export interface GoverningDecision {
recordId: string;
title: string;
/**
* The record's status. Present so no consumer has to assume a matched record is
* `accepted` — a `rejected` or `superseded` record can match a path just as easily.
*/
status: Status;
/** Which of the three buckets this record falls into, per `decisionBucketFor`. */
bucket: DecisionBucket;
/** The successor, when this record was superseded. Lets a reader follow the chain. */
supersededBy?: string;
/** The record's own `affects` matchers that fired against the path — the outbound edge. */
firedMatchers: FiredMatcher[];
export interface MarkerScanReport {
totalCandidates: number;
limit: number;
/** `truncated` is a subset of `scanned`, not a mutually exclusive scan state. */
counts: Record<'scanned' | 'absent' | 'unreadable' | 'out-of-tree' | 'truncated' | 'skipped', number>;
absentPaths: string[];
unreadablePaths: string[];
outOfTreePaths: string[];
truncatedPaths: string[];
skippedPaths: string[];
}
/**
* The stable structure `adr check --json` emits and the `@adrkit/ci` Action consumes.
* Deterministic and pure: identical `(lint, changedFiles, snapshots)` → identical output.
* Deterministic and pure: identical `(lint, changedFiles, snapshots, markerScans)`
* produces identical output.
*/

@@ -51,2 +50,4 @@ export interface CheckOutcome {

findings: Finding[];
/** Present when the caller supplied the pre-scanned marker boundary. */
markerScan?: MarkerScanReport;
ok: boolean;

@@ -62,18 +63,6 @@ }

log?: string;
/** Marker I/O performed by the caller; `checkChanges` only resolves these values. */
markerScans?: SourceMarkerBatchScan;
}
/**
* Turn resolver matches into status-carrying decisions. A match whose record is not in
* `records` (dropped by lint as malformed) cannot be classified, so it is reported with
* the neutral `draft` status and lands in `activeProposals` rather than silently
* claiming to govern.
*/
export declare function toGoverningDecisions(records: readonly Adr[], matches: readonly AffectsMatch[]): GoverningDecision[];
export interface BucketedDecisions<T extends GoverningDecision = GoverningDecision> {
governing: T[];
activeProposals: T[];
history: T[];
}
/** Partition decisions into the three buckets, preserving the input order within each. */
export declare function bucketDecisions<T extends GoverningDecision>(decisions: readonly T[]): BucketedDecisions<T>;
/**
* The single, neutral "resolve governing decisions + validate changed records"

@@ -80,0 +69,0 @@ * implementation, called by both `adr check` (CLI) and the `@adrkit/ci` Action so

@@ -9,4 +9,4 @@ /**

export { MARKER_HEADER_WINDOW_BYTES, scanSourceMarkers, type ScanSourceMarkersResult } from './scan.js';
export { readSourceMarkers, type MarkerScanState, type SourceMarkerScan } from './read.js';
export { MARKER_SCAN_CONCURRENCY, MARKER_SCAN_FILE_CAP, readSourceMarkers, readSourceMarkersBatch, type MarkerScanState, type SourceMarkerBatchScan, type SourceMarkerScan, } from './read.js';
export { mergeSourceDeclarations, resolveSourceMarkers, type ExplainedDecision, type ResolveSourceMarkersInput, type ResolveSourceMarkersResult, } from './resolve.js';
export type { MarkerDeclaration, MarkerMatch, SourceMarker } from './types.js';

@@ -6,9 +6,9 @@ /**

* every grammar rule can be tested as text. No network, no credentials, no traversal:
* one regular file beneath the working tree, at most
* bounded regular files beneath the working tree, at most
* {@link MARKER_HEADER_WINDOW_BYTES} bytes plus one truncation sentinel, opened
* read-only and non-blocking.
*
* "No traversal" is enforced here rather than asserted — confinement is checked twice,
* once lexically before any I/O and once on the real path so a symlink cannot walk out
* of the tree.
* "No traversal" is enforced here rather than asserted — confinement is checked
* lexically before I/O and on the real path, while symlink components are refused
* before their descendants or targets are resolved.
*/

@@ -31,3 +31,3 @@ import type { SourceMarker } from './types.js';

export interface SourceMarkerScan {
/** The path as the caller supplied it, echoed so output reads back what was asked. */
/** Normalized repo-relative path used for matching and output. */
path: string;

@@ -40,13 +40,35 @@ state: MarkerScanState;

/**
* Scan one source file for `@adr` markers.
* Maximum number of unique paths one `check` run will scan.
*
* Reads one byte past the window so truncation is observed rather than inferred, and
* never reads more than that however large the file is.
* Set to GitHub's `pulls.listFiles` ceiling rather than below it. The Action refuses to
* evaluate a changed-file list that reached that ceiling, so every diff it does
* evaluate contributes at most 2,999 current/head-side paths, and the Action passes
* exactly those paths to this reader. A rename's previous path still participates in
* `affects` matching but is not a file whose contents can be scanned. The cap therefore
* cannot silently drop a current file: marker-only governance is complete for any PR
* the Action answers at all. Its remaining job is to bound a local `adr check` handed a
* runaway glob.
*
* `path` is repo-relative to `cwd` — the same contract `resolveAffects` matches its
* globs against. An absolute or traversing argument is not a stricter form of that
* contract but a different one, and it is refused rather than read. `affects`
* resolution keeps its pre-marker behavior for a raw argument, including broad globs;
* this boundary ensures an outside file's contents cannot add an inbound edge.
* `@adrkit/ci` asserts `MARKER_SCAN_FILE_CAP >= LIST_FILES_CAP`; core cannot import the
* provider constant, because the dependency runs the other way.
*/
export declare const MARKER_SCAN_FILE_CAP = 3000;
/** Maximum number of marker file reads in flight at once. */
export declare const MARKER_SCAN_CONCURRENCY = 16;
/** Pre-scanned marker input passed across the pure `checkChanges` boundary. */
export interface SourceMarkerBatchScan {
scans: SourceMarkerScan[];
skippedPaths: string[];
limit: number;
totalCandidates: number;
}
export declare function readSourceMarkers(path: string, cwd?: string): Promise<SourceMarkerScan>;
/**
* Scan a deterministic, bounded set of source paths with fixed concurrency.
*
* Paths are normalized, deduplicated, and sorted before the first
* {@link MARKER_SCAN_FILE_CAP} are chosen.
* Anything beyond the cap is returned verbatim in `skippedPaths`, never silently
* discarded. The working-tree real path is resolved once for the entire batch.
*/
export declare function readSourceMarkersBatch(paths: readonly string[], cwd?: string): Promise<SourceMarkerBatchScan>;

@@ -9,5 +9,5 @@ /**

import type { Adr } from '../schema/adr.schema.js';
import { type GoverningDecision } from '../check/index.js';
import { type GoverningDecision } from '../check/decisions.js';
import { type Finding } from '../validate/findings.js';
import type { MarkerDeclaration, MarkerMatch, SourceMarker } from './types.js';
import type { MarkerMatch, SourceMarker } from './types.js';
export interface ResolveSourceMarkersInput {

@@ -21,7 +21,4 @@ records: readonly Adr[];

}
/** The decision shape emitted only by `adr explain`, which is the marker-aware surface. */
export interface ExplainedDecision extends GoverningDecision {
/** The source files that declared this record through an inbound marker. */
declaredBy?: MarkerDeclaration[];
}
/** Compatibility name retained for consumers of the explain-only v0.4.0 contract. */
export type ExplainedDecision = GoverningDecision;
/** Bind markers to the records they name, and report the ones that bind to nothing. */

@@ -28,0 +25,0 @@ export declare function resolveSourceMarkers(input: ResolveSourceMarkersInput): ResolveSourceMarkersResult;

@@ -5,3 +5,4 @@ /**

* Pure text in, markers out. No filesystem, no clock, no per-language parser: the
* marker is the first content on a dedicated comment line.
* marker is the first content on a dedicated comment line, that line is not inside a
* fenced block, and in a markdown file the introducer is one markdown actually hides.
*/

@@ -41,4 +42,6 @@ import type { SourceMarker } from './types.js';

* `path` is echoed onto each marker verbatim — the caller owns the repo-relative,
* forward-slash form, because it is the string the user will read back.
* forward-slash form, because it is the string the user will read back. Its extension
* also selects the introducer set, since `#` is a comment in a shell script and a
* heading in markdown; nothing else about the scan depends on it.
*/
export declare function scanSourceMarkers(source: string, path: string): ScanSourceMarkersResult;

@@ -12,4 +12,4 @@ /**

*
* This module is deliberately types-only, so importing these contracts cannot pull
* marker runtime code into the committed `packages/ci/dist` bundle.
* This module is deliberately types-only, so a type-only consumer cannot pull marker
* runtime code into an otherwise unrelated bundle.
*/

@@ -16,0 +16,0 @@ /** One `@adr <ref>` marker found in a source file's header window. */

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

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

@@ -28,2 +28,3 @@ # @adrkit/core

readSourceMarkers,
readSourceMarkersBatch,
resolveAffects,

@@ -55,3 +56,8 @@ resolveSourceMarkers,

content on a dedicated comment line, which keeps documentation prose and string
literals in their common inline forms from becoming declarations.
literals in their common inline forms from becoming declarations. A line inside a
` ``` ` or `~~~` fence is an example rather than a declaration, and `path`
selects the introducer set: a markdown extension (`.md`, `.mdx`, `.markdown`)
accepts only `<!--` and `{/*`, because `#` and `*` are markdown's own heading and
list syntax. Two files with identical bytes and different extensions can
therefore scan differently.
`readSourceMarkers` wraps the scanner with a bounded read and reports `state` as

@@ -62,7 +68,14 @@ `scanned`, `absent`,

Its `path` argument is repo-relative to `cwd`. Absolute paths, paths that climb
out of the tree, and symlinks resolving outside it are refused as `out-of-tree`
without being opened; non-regular files are refused as `unreadable`, so a FIFO
Its `path` argument is repo-relative to `cwd`. Absolute paths and paths that climb
out of the tree are `out-of-tree`. Every symlink is refused as `unreadable`
without opening its target; non-regular files are also `unreadable`, so a FIFO
cannot block the read.
`readSourceMarkersBatch(paths, cwd)` is the impure boundary for `checkChanges`.
It normalizes, deduplicates, and sorts paths; scans the first 3,000 with at most
16 reads in flight; resolves the working-tree root once; and returns every
skipped path. Pass that `SourceMarkerBatchScan` through `markerScans` to receive
marker-aware decisions and a deterministic `markerScan` report without adding
filesystem access to `checkChanges`.
The published ESM artifacts run on Node.js 22 or newer. Development in the

@@ -69,0 +82,0 @@ adrkit repository uses Bun.

@@ -1,9 +0,26 @@

// @adr 0021 — this file carries the inbound-edge field but is not a *defining* file of
// that decision, so ADR-0021's `affects` patterns deliberately do not name it. This is
// the case the marker exists for, dogfooded on adrkit's own corpus.
import type { Adr, Status } from '../schema/adr.schema.ts';
import { resolveAffects, type AffectsMatch, type FiredMatcher, type ResolutionSnapshots } from '../affects/index.ts';
import { decisionBucketFor, type DecisionBucket } from '../status/bucket.ts';
// @adr 0022 — this file resolves the inbound edge, so ADR-0022's `affects` also names it
// via `packages/core/src/check/**` and the marker is a second, redundant route to the
// same record. It is kept because it is the repository's own working example of the
// declaration rendering; it does not demonstrate the marker-only case, which needs a
// file the corpus reaches by no pattern at all.
import type { Adr } from '../schema/adr.schema.ts';
import { resolveAffects, type ResolutionSnapshots } from '../affects/index.ts';
import { mergeSourceDeclarations, resolveSourceMarkers } from '../markers/resolve.ts';
import type { SourceMarkerBatchScan } from '../markers/read.ts';
import { compareCodeUnits } from '../ordering/index.ts';
import { sortFindings, type Finding } from '../validate/findings.ts';
import {
bucketDecisions,
toGoverningDecisions,
type BucketedDecisions,
type GoverningDecision,
} from './decisions.ts';
export {
bucketDecisions,
toGoverningDecisions,
type BucketedDecisions,
type GoverningDecision,
} from './decisions.ts';
/**

@@ -21,16 +38,15 @@ * The full result of `lintCorpus` — records, findings, and the checked count.

export interface GoverningDecision {
recordId: string;
title: string;
/**
* The record's status. Present so no consumer has to assume a matched record is
* `accepted` — a `rejected` or `superseded` record can match a path just as easily.
*/
status: Status;
/** Which of the three buckets this record falls into, per `decisionBucketFor`. */
bucket: DecisionBucket;
/** The successor, when this record was superseded. Lets a reader follow the chain. */
supersededBy?: string;
/** The record's own `affects` matchers that fired against the path — the outbound edge. */
firedMatchers: FiredMatcher[];
export interface MarkerScanReport {
totalCandidates: number;
limit: number;
/** `truncated` is a subset of `scanned`, not a mutually exclusive scan state. */
counts: Record<
'scanned' | 'absent' | 'unreadable' | 'out-of-tree' | 'truncated' | 'skipped',
number
>;
absentPaths: string[];
unreadablePaths: string[];
outOfTreePaths: string[];
truncatedPaths: string[];
skippedPaths: string[];
}

@@ -40,3 +56,4 @@

* The stable structure `adr check --json` emits and the `@adrkit/ci` Action consumes.
* Deterministic and pure: identical `(lint, changedFiles, snapshots)` → identical output.
* Deterministic and pure: identical `(lint, changedFiles, snapshots, markerScans)`
* produces identical output.
*/

@@ -58,2 +75,4 @@ export interface CheckOutcome {

findings: Finding[];
/** Present when the caller supplied the pre-scanned marker boundary. */
markerScan?: MarkerScanReport;
ok: boolean;

@@ -70,2 +89,4 @@ }

log?: string;
/** Marker I/O performed by the caller; `checkChanges` only resolves these values. */
markerScans?: SourceMarkerBatchScan;
}

@@ -111,38 +132,47 @@

/**
* Turn resolver matches into status-carrying decisions. A match whose record is not in
* `records` (dropped by lint as malformed) cannot be classified, so it is reported with
* the neutral `draft` status and lands in `activeProposals` rather than silently
* claiming to govern.
*/
export function toGoverningDecisions(
records: readonly Adr[],
matches: readonly AffectsMatch[],
): GoverningDecision[] {
const byId = new Map(records.map((record) => [record.frontmatter.id, record]));
return matches.map((match) => {
const frontmatter = byId.get(match.recordId)?.frontmatter;
const status: Status = frontmatter?.status ?? 'draft';
return {
recordId: match.recordId,
title: frontmatter?.title ?? '',
status,
bucket: decisionBucketFor(status),
...(frontmatter?.supersededBy ? { supersededBy: frontmatter.supersededBy } : {}),
firedMatchers: match.firedMatchers,
};
});
}
function markerScanReport(batch: SourceMarkerBatchScan): MarkerScanReport {
const absentPaths: string[] = [];
const unreadablePaths: string[] = [];
const outOfTreePaths: string[] = [];
const truncatedPaths: string[] = [];
let scanned = 0;
export interface BucketedDecisions<T extends GoverningDecision = GoverningDecision> {
governing: T[];
activeProposals: T[];
history: T[];
for (const scan of batch.scans) {
if (scan.state === 'scanned') scanned += 1;
if (scan.state === 'absent') absentPaths.push(scan.path);
if (scan.state === 'unreadable') unreadablePaths.push(scan.path);
if (scan.state === 'out-of-tree') outOfTreePaths.push(scan.path);
if (scan.truncated) truncatedPaths.push(scan.path);
}
return {
totalCandidates: batch.totalCandidates,
limit: batch.limit,
counts: {
scanned,
absent: absentPaths.length,
unreadable: unreadablePaths.length,
'out-of-tree': outOfTreePaths.length,
truncated: truncatedPaths.length,
skipped: batch.skippedPaths.length,
},
absentPaths: absentPaths.sort(compareCodeUnits),
unreadablePaths: unreadablePaths.sort(compareCodeUnits),
outOfTreePaths: outOfTreePaths.sort(compareCodeUnits),
truncatedPaths: truncatedPaths.sort(compareCodeUnits),
skippedPaths: [...batch.skippedPaths].sort(compareCodeUnits),
};
}
/** Partition decisions into the three buckets, preserving the input order within each. */
export function bucketDecisions<T extends GoverningDecision>(decisions: readonly T[]): BucketedDecisions<T> {
const buckets: BucketedDecisions<T> = { governing: [], activeProposals: [], history: [] };
for (const decision of decisions) buckets[decision.bucket].push(decision);
return buckets;
function cappedScanFinding(report: MarkerScanReport): Finding | undefined {
if (report.skippedPaths.length === 0) return undefined;
const shown = report.skippedPaths.slice(0, 10);
const remaining = report.skippedPaths.length - shown.length;
const suffix = remaining > 0 ? `, and ${remaining} more (see markerScan.skippedPaths)` : '';
return {
rule: 'marker-scan-capped',
severity: 'warn',
message: `Marker scan reached the ${report.limit}-file cap and skipped ${shown.join(', ')}${suffix}`,
field: 'marker',
};
}

@@ -169,3 +199,13 @@

const governedBy = toGoverningDecisions(input.lint.records, resolution.matches);
const markerResolution = input.markerScans
? resolveSourceMarkers({
records: input.lint.records,
markers: input.markerScans.scans.flatMap((scan) => scan.markers),
})
: { matches: [], findings: [] };
const governedBy = mergeSourceDeclarations(
toGoverningDecisions(input.lint.records, resolution.matches),
input.lint.records,
markerResolution.matches,
);
const buckets = bucketDecisions(governedBy);

@@ -179,3 +219,10 @@

);
const findings = sortFindings([...resolution.findings, ...changedRecordFindings]);
const markerScan = input.markerScans ? markerScanReport(input.markerScans) : undefined;
const capped = markerScan ? cappedScanFinding(markerScan) : undefined;
const findings = sortFindings([
...resolution.findings,
...markerResolution.findings,
...(capped ? [capped] : []),
...changedRecordFindings,
]);
const ok = !changedRecordFindings.some((finding) => finding.severity === 'error');

@@ -191,4 +238,5 @@

findings,
...(markerScan ? { markerScan } : {}),
ok,
};
}

@@ -10,4 +10,12 @@ /**

export { MARKER_HEADER_WINDOW_BYTES, scanSourceMarkers, type ScanSourceMarkersResult } from './scan.ts';
export { readSourceMarkers, type MarkerScanState, type SourceMarkerScan } from './read.ts';
export {
MARKER_SCAN_CONCURRENCY,
MARKER_SCAN_FILE_CAP,
readSourceMarkers,
readSourceMarkersBatch,
type MarkerScanState,
type SourceMarkerBatchScan,
type SourceMarkerScan,
} from './read.ts';
export {
mergeSourceDeclarations,

@@ -14,0 +22,0 @@ resolveSourceMarkers,

@@ -6,14 +6,16 @@ /**

* every grammar rule can be tested as text. No network, no credentials, no traversal:
* one regular file beneath the working tree, at most
* bounded regular files beneath the working tree, at most
* {@link MARKER_HEADER_WINDOW_BYTES} bytes plus one truncation sentinel, opened
* read-only and non-blocking.
*
* "No traversal" is enforced here rather than asserted — confinement is checked twice,
* once lexically before any I/O and once on the real path so a symlink cannot walk out
* of the tree.
* "No traversal" is enforced here rather than asserted — confinement is checked
* lexically before I/O and on the real path, while symlink components are refused
* before their descendants or targets are resolved.
*/
import { constants, open, realpath } from 'node:fs/promises';
import { constants, lstat, open, realpath } from 'node:fs/promises';
import { isAbsolute, relative, resolve, sep } from 'node:path';
import { compareCodeUnits } from '../ordering/index.ts';
import { MARKER_HEADER_WINDOW_BYTES, scanBoundedSourceMarkerWindow } from './scan.ts';
import { mapConcurrent } from './pool.ts';
import type { SourceMarker } from './types.ts';

@@ -37,3 +39,3 @@

export interface SourceMarkerScan {
/** The path as the caller supplied it, echoed so output reads back what was asked. */
/** Normalized repo-relative path used for matching and output. */
path: string;

@@ -46,2 +48,30 @@ state: MarkerScanState;

/**
* Maximum number of unique paths one `check` run will scan.
*
* Set to GitHub's `pulls.listFiles` ceiling rather than below it. The Action refuses to
* evaluate a changed-file list that reached that ceiling, so every diff it does
* evaluate contributes at most 2,999 current/head-side paths, and the Action passes
* exactly those paths to this reader. A rename's previous path still participates in
* `affects` matching but is not a file whose contents can be scanned. The cap therefore
* cannot silently drop a current file: marker-only governance is complete for any PR
* the Action answers at all. Its remaining job is to bound a local `adr check` handed a
* runaway glob.
*
* `@adrkit/ci` asserts `MARKER_SCAN_FILE_CAP >= LIST_FILES_CAP`; core cannot import the
* provider constant, because the dependency runs the other way.
*/
export const MARKER_SCAN_FILE_CAP = 3000;
/** Maximum number of marker file reads in flight at once. */
export const MARKER_SCAN_CONCURRENCY = 16;
/** Pre-scanned marker input passed across the pure `checkChanges` boundary. */
export interface SourceMarkerBatchScan {
scans: SourceMarkerScan[];
skippedPaths: string[];
limit: number;
totalCandidates: number;
}
function scanStateForError(error: unknown): MarkerScanState {

@@ -65,2 +95,52 @@ const code = typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : '';

/**
* Inspect each lexical component beneath `root` without following a symlink.
* Checking only the leaf is insufficient when a PR replaces a directory with a
* symlink while the provider still lists the directory's former children as deleted.
*/
async function lstatWithoutSymlink(
root: string,
target: string,
): Promise<Awaited<ReturnType<typeof lstat>> | undefined> {
const segments = relative(root, target).split(sep);
let current = root;
let info: Awaited<ReturnType<typeof lstat>> | undefined;
for (const segment of segments) {
current = resolve(current, segment);
info = await lstat(current);
if (info.isSymbolicLink()) return undefined;
}
return info;
}
/**
* Normalize a caller path once, before it becomes marker identity.
*
* A backslash is a separator only where the platform says so. On POSIX it is an
* ordinary filename character, and rewriting it would make `src/we\ird.ts` scan
* `src/we/ird.ts` — a different file, reported as `scanned` rather than `absent`, with
* the wrong file's markers attributed to the path the caller never named.
*/
function normalizeMarkerPath(path: string): string {
const forward = sep === '\\' ? path.replace(/\\/g, '/') : path;
let start = 0;
while (forward.startsWith('./', start)) start += 2;
return forward.slice(start);
}
interface PreparedRoot {
root: string;
realRoot?: string;
errorState?: MarkerScanState;
}
async function prepareRoot(cwd: string): Promise<PreparedRoot> {
const root = resolve(cwd);
try {
return { root, realRoot: await realpath(root) };
} catch (error) {
return { root, errorState: scanStateForError(error) };
}
}
/**
* `O_NONBLOCK` so the open cannot hang. A FIFO opened for reading with no writer

@@ -73,5 +153,5 @@ * blocks forever, which would wedge `adr explain` on `mkfifo src/pipe` instead of

* initializer the bundler cannot prove side-effect-free keeps this module — and its
* `node:fs/promises` import — alive in the `@adrkit/ci` bundle, which never scans a
* marker. Measured, not assumed: as a constant it added three lines to both entry
* points; as a function `packages/ci/dist` rebuilds byte-identical.
* `node:fs/promises` import — alive in unrelated bundles. The governing Action now
* imports this boundary intentionally; the queue Action still proves it tree-shakes
* away.
*/

@@ -94,22 +174,37 @@ function readFlags(): number {

*/
export async function readSourceMarkers(path: string, cwd = process.cwd()): Promise<SourceMarkerScan> {
const refuse = (state: MarkerScanState): SourceMarkerScan => ({ path, state, markers: [], truncated: false });
async function readWithPreparedRoot(path: string, prepared: PreparedRoot): Promise<SourceMarkerScan> {
const normalizedPath = normalizeMarkerPath(path);
const refuse = (state: MarkerScanState): SourceMarkerScan => ({
path: normalizedPath,
state,
markers: [],
truncated: false,
});
if (prepared.errorState || !prepared.realRoot) return refuse(prepared.errorState ?? 'unreadable');
// Lexically, before any I/O — so a path that leaves the tree is refused without the
// reply distinguishing whether the file it named happens to exist.
const root = resolve(cwd);
if (isAbsolute(path) || !isInsideRoot(root, resolve(root, path))) return refuse('out-of-tree');
const candidate = resolve(prepared.root, normalizedPath);
if (isAbsolute(normalizedPath) || !isInsideRoot(prepared.root, candidate)) return refuse('out-of-tree');
let handle: Awaited<ReturnType<typeof open>> | undefined;
try {
// Then again on the real paths. A symlink inside the tree pointing outside it is
// lexically indistinguishable from an ordinary file, and following one would let
// an out-of-tree file's marker be reported as this repository's.
const realRoot = await realpath(root);
const target = await realpath(resolve(realRoot, path));
if (!isInsideRoot(realRoot, target)) return refuse('out-of-tree');
// Refuse any symlink component before resolving or probing its descendants. This
// gives valid, broken, and out-of-tree symlinks the same result, so CI cannot use
// marker scanning as an existence or permission oracle for the target.
const link = await lstatWithoutSymlink(prepared.root, candidate);
if (!link || !link.isFile()) return refuse('unreadable');
handle = await open(target, readFlags());
// With every component proven not to be a symlink, the canonical location is the
// canonical root plus the lexical remainder. It is derived rather than asked of
// `realpath`, which rewrites a backslash *inside* a POSIX filename into a separator:
// `realpath('src/we\ird.ts')` answers `src/we/ird.ts`, so the scan would open a
// different file and report its markers under the path the caller named.
const target = resolve(prepared.realRoot, relative(prepared.root, candidate));
if (!isInsideRoot(prepared.realRoot, target)) return refuse('out-of-tree');
handle = await open(candidate, readFlags());
// Check the opened handle rather than a path before `open`, so the file-type check
// applies to the object we read. This does not close the `realpath` -> `open` race:
// applies to the object we read. This does not close the `lstat` -> `open` race:
// a concurrent process can still replace the approved path before it is opened.

@@ -130,4 +225,4 @@ if (!(await handle.stat()).isFile()) return refuse('unreadable');

);
const scan = scanBoundedSourceMarkerWindow(source, path, truncated);
return { path, state: 'scanned', markers: scan.markers, truncated: scan.truncated };
const scan = scanBoundedSourceMarkerWindow(source, normalizedPath, truncated);
return { path: normalizedPath, state: 'scanned', markers: scan.markers, truncated: scan.truncated };
} catch (error) {

@@ -139,1 +234,35 @@ return refuse(scanStateForError(error));

}
export async function readSourceMarkers(path: string, cwd = process.cwd()): Promise<SourceMarkerScan> {
return readWithPreparedRoot(path, await prepareRoot(cwd));
}
/**
* Scan a deterministic, bounded set of source paths with fixed concurrency.
*
* Paths are normalized, deduplicated, and sorted before the first
* {@link MARKER_SCAN_FILE_CAP} are chosen.
* Anything beyond the cap is returned verbatim in `skippedPaths`, never silently
* discarded. The working-tree real path is resolved once for the entire batch.
*/
export async function readSourceMarkersBatch(
paths: readonly string[],
cwd = process.cwd(),
): Promise<SourceMarkerBatchScan> {
const candidates = [...new Set(paths.map(normalizeMarkerPath))].sort(compareCodeUnits);
const selected = candidates.slice(0, MARKER_SCAN_FILE_CAP);
const skippedPaths = candidates.slice(MARKER_SCAN_FILE_CAP);
const prepared = selected.length > 0 ? await prepareRoot(cwd) : undefined;
const scans = prepared
? await mapConcurrent(selected, MARKER_SCAN_CONCURRENCY, (path) =>
readWithPreparedRoot(path, prepared),
)
: [];
return {
scans,
skippedPaths,
limit: MARKER_SCAN_FILE_CAP,
totalCandidates: candidates.length,
};
}

@@ -10,3 +10,4 @@ /**

import type { Adr } from '../schema/adr.schema.ts';
import { toGoverningDecisions, type GoverningDecision } from '../check/index.ts';
import { toGoverningDecisions, type GoverningDecision } from '../check/decisions.ts';
import { compareCodeUnits } from '../ordering/index.ts';
import { sortFindings, type Finding } from '../validate/findings.ts';

@@ -25,7 +26,4 @@ import type { MarkerDeclaration, MarkerMatch, SourceMarker } from './types.ts';

/** The decision shape emitted only by `adr explain`, which is the marker-aware surface. */
export interface ExplainedDecision extends GoverningDecision {
/** The source files that declared this record through an inbound marker. */
declaredBy?: MarkerDeclaration[];
}
/** Compatibility name retained for consumers of the explain-only v0.4.0 contract. */
export type ExplainedDecision = GoverningDecision;

@@ -72,3 +70,3 @@ /**

function compareDeclarations(a: MarkerDeclaration, b: MarkerDeclaration): number {
return a.path.localeCompare(b.path) || a.line - b.line || a.ref.localeCompare(b.ref);
return compareCodeUnits(a.path, b.path) || a.line - b.line || compareCodeUnits(a.ref, b.ref);
}

@@ -104,3 +102,3 @@

.map(([recordId, declaredBy]) => ({ recordId, declaredBy: declaredBy.sort(compareDeclarations) }))
.sort((a, b) => a.recordId.localeCompare(b.recordId));
.sort((a, b) => compareCodeUnits(a.recordId, b.recordId));

@@ -138,3 +136,3 @@ return { matches, findings: sortFindings(findings) };

return [...merged, ...markerOnly].sort((a, b) => a.recordId.localeCompare(b.recordId));
return [...merged, ...markerOnly].sort((a, b) => compareCodeUnits(a.recordId, b.recordId));
}

@@ -5,3 +5,4 @@ /**

* Pure text in, markers out. No filesystem, no clock, no per-language parser: the
* marker is the first content on a dedicated comment line.
* marker is the first content on a dedicated comment line, that line is not inside a
* fenced block, and in a markdown file the introducer is one markdown actually hides.
*/

@@ -37,4 +38,71 @@

*/
const COMMENT_INTRODUCERS = ['//', '/*', '*', '#', '--', ';', '%', '<!--', '"""', "'''"] as const;
const COMMENT_INTRODUCERS = [
'//',
'/*',
'{/*',
'*',
'#',
'--',
';',
'%',
'<!--',
'"""',
"'''",
] as const;
/**
* Markdown's comment syntax, and all of it.
*
* The list above is a union of what *source languages* use to hide a line from their
* own output. Markdown is not one of them: `#` opens a heading, `*` and `-` open list
* items, and every one of those renders. Lending markdown the source-language set is
* what let `* @adr 0012 explains this` — a sentence a reader can see — declare a
* decision, which is the same defect class as a fenced example and not a different
* rule. What markdown genuinely hides is an HTML comment; the `{/*` expression comment
* is MDX's, and MDX rejects `<!-- -->` outright, so without it that dialect would have
* no way to declare at all.
*
* This is a subset of the introducers above, chosen by file extension. It is a
* statement about which constructs are comments in a format, not a parse of its
* content: no line is read differently, only fewer line-leads count.
*/
const MARKDOWN_COMMENT_INTRODUCERS = ['{/*', '<!--'] as const;
/** Extensions whose comment syntax is markdown's rather than a source language's. */
const MARKDOWN_EXTENSIONS = ['.md', '.mdx', '.markdown'] as const;
function isMarkdownPath(path: string): boolean {
const lower = path.toLowerCase();
return MARKDOWN_EXTENSIONS.some((extension) => lower.endsWith(extension));
}
/**
* A fenced block is where a file *shows* the marker syntax, so a marker inside one is
* an example rather than a declaration. Three of the four marker-looking lines left in
* this repository after the dedicated-line rule landed were exactly that: documentation
* of the feature, claiming to live under the decision it was illustrating.
*
* CommonMark-lite, and deliberately still a line-lead rule rather than a parser. A
* fence is a run of three or more backticks or tildes as the line's first content
* (after at most three spaces). It closes only on a line of at least as many of the
* *same* character and nothing else, so a longer fence closes a shorter one and never
* the reverse, and ``` and ~~~ do not close each other. A backtick fence's info string
* may not itself contain a backtick. An unclosed fence runs to the end of the window,
* which is what stops an example at the end of a file from un-fencing everything above
* it.
*
* The fence must lead the physical line for the same reason the marker must: that is
* the one thing this scanner can know without knowing the language. A fence nested
* inside a block-comment continuation (` * ``` `) is therefore not detected, and is
* recorded as a known cost rather than chased with a parser.
*/
const FENCE_LINE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
interface OpenFence {
/** The fence character, ` or ~. The other one cannot close it. */
char: string;
/** The opening run's length. A closer must be at least this long. */
length: number;
}
/** Characters that can appear inside an `AdrRef` — digits, ULID letters, `-`, and the log `:`. */

@@ -77,9 +145,7 @@ function isRefChar(char: string): boolean {

function dedicatedMarkerIndex(line: string, firstLine: boolean): number | undefined {
// A decoded UTF-8 BOM is metadata, not comment content. `TextDecoder` removes it
// for the filesystem path; accepting it here keeps the pure string API equivalent.
let commentStart = firstLine && line.charCodeAt(0) === 0xfeff ? 1 : 0;
function dedicatedMarkerIndex(line: string, introducers: readonly string[]): number | undefined {
let commentStart = 0;
while (commentStart < line.length && isSpace(line[commentStart] ?? '')) commentStart += 1;
for (const introducer of COMMENT_INTRODUCERS) {
for (const introducer of introducers) {
if (!line.startsWith(introducer, commentStart)) continue;

@@ -132,9 +198,31 @@ let markerStart = commentStart + introducer.length;

const markers: SourceMarker[] = [];
const introducers = isMarkdownPath(path) ? MARKDOWN_COMMENT_INTRODUCERS : COMMENT_INTRODUCERS;
let fence: OpenFence | null = null;
const lines = window.text.split(/\r\n|[\r\n]/);
for (const [index, line] of lines.entries()) {
const markerStart = dedicatedMarkerIndex(line, index === 0);
// A decoded UTF-8 BOM is metadata, not content. `TextDecoder` removes it for the
// filesystem path; removing it here keeps the pure string API equivalent, and does
// so before the fence test so it cannot hide an opening fence either.
const content = index === 0 && line.charCodeAt(0) === 0xfeff ? line.slice(1) : line;
const fenceMatch = FENCE_LINE.exec(content);
if (fenceMatch) {
const char = fenceMatch[1]![0]!;
const length = fenceMatch[1]!.length;
const info = fenceMatch[2]!;
if (fence) {
if (char === fence.char && length >= fence.length && info.trim() === '') fence = null;
} else if (!(char === '`' && info.includes('`'))) {
fence = { char, length };
}
continue;
}
// Inside a fence the file is showing the syntax, not using it.
if (fence) continue;
const markerStart = dedicatedMarkerIndex(content, introducers);
if (markerStart === undefined) continue;
for (const ref of readRefs(line.slice(markerStart + MARKER_TOKEN.length))) {
for (const ref of readRefs(content.slice(markerStart + MARKER_TOKEN.length))) {
const { id, log } = parseAdrRef(ref);

@@ -167,3 +255,5 @@ markers.push({ path, ref, id, ...(log ? { log } : {}), line: index + 1 });

* `path` is echoed onto each marker verbatim — the caller owns the repo-relative,
* forward-slash form, because it is the string the user will read back.
* forward-slash form, because it is the string the user will read back. Its extension
* also selects the introducer set, since `#` is a comment in a shell script and a
* heading in markdown; nothing else about the scan depends on it.
*/

@@ -170,0 +260,0 @@ export function scanSourceMarkers(source: string, path: string): ScanSourceMarkersResult {

@@ -12,4 +12,4 @@ /**

*
* This module is deliberately types-only, so importing these contracts cannot pull
* marker runtime code into the committed `packages/ci/dist` bundle.
* This module is deliberately types-only, so a type-only consumer cannot pull marker
* runtime code into an otherwise unrelated bundle.
*/

@@ -16,0 +16,0 @@

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