@adrkit/core
Advanced tools
| 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'; |
+35
-13
@@ -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. */ |
+1
-1
| { | ||
| "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", |
+17
-4
@@ -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. |
+104
-56
@@ -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, |
+152
-23
@@ -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)); | ||
| } |
+100
-10
@@ -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
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
363699
6.42%92
4.55%8832
6.46%88
17.33%