Merge branch 'next' into fix/2665-test-env-base-config-location-vars
This commit is contained in:
278
scripts/lib/drift-scan.cjs
Normal file
278
scripts/lib/drift-scan.cjs
Normal file
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Shared anti-divergence scanner machinery (epic #3180, ADR-3180 Decision 4).
|
||||
*
|
||||
* Extracted from `scripts/lint-plan-count-drift.cjs` (#3183) so that
|
||||
* `scripts/lint-milestone-window-drift.cjs` (#3184) and any future
|
||||
* `scripts/lint-<derivation>-drift.cjs` guard consume ONE tree-walk /
|
||||
* root-confinement / literal-tokenizer / sanitizer implementation instead of
|
||||
* each copying it verbatim — the exact generative-fix-divergence class this
|
||||
* epic exists to remove, now applied to the guards themselves (see
|
||||
* `.gsd/phase/refactor-3184-milestone-window-single-owner/40-design.md`,
|
||||
* "Rejected: let the new drift guard copy Phase 1's tree-walk /
|
||||
* root-confinement / sanitizer").
|
||||
*
|
||||
* `lint-plan-count-drift.cjs` re-exports every symbol it exported before this
|
||||
* extraction (`readRegexLiteralAt`, `MAX_REGEX_LITERAL_LEN`, `isInsideRoot`,
|
||||
* `sanitizeForReport`), so `tests/plan-count-single-owner.test.cjs` — the
|
||||
* ReDoS and root-confinement regression net for this exact machinery —
|
||||
* continues to pass unchanged and is the regression net for this move too.
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
// Longest regex literal this scanner will consider, in characters. Real
|
||||
// plan/summary filename filters (and milestone-window regex literals) are far
|
||||
// shorter; the bound is what keeps the scan linear. The tokenizer restarts at
|
||||
// every `/` on the line (so that a literal preceded by a stray unpaired `/`
|
||||
// is still found, matching the previous regex's "find anywhere" behaviour),
|
||||
// which without a per-literal bound would be quadratic on a pathological
|
||||
// line. With it the whole-line cost is O(n * MAX_REGEX_LITERAL_LEN) with no
|
||||
// backtracking at all.
|
||||
const MAX_REGEX_LITERAL_LEN = 400;
|
||||
|
||||
// Directory names this scanner never descends into or reports out of —
|
||||
// `.git` (repo internals, e.g. a persisted CI token in `.git/config`),
|
||||
// `node_modules` (thousands of third-party files, none of them authored
|
||||
// source), `dist` (build output). Named once and used at BOTH skip sites
|
||||
// below: the cheap `entry.name` fast path in `walk`, and the resolved-path
|
||||
// component check in `isUnderSkippedDir` — a symlink whose OWN name is not
|
||||
// in this set but whose target resolves through a directory that IS (e.g.
|
||||
// `src/g -> ../.git`, `src/nm -> ../node_modules`) must still be skipped, or
|
||||
// the name-only check is a trivial bypass.
|
||||
const SKIP_DIR_NAMES = new Set(['node_modules', 'dist', '.git']);
|
||||
|
||||
/**
|
||||
* Read the JS regex literal starting at `line[start]` (which must be `/`).
|
||||
* Returns `{ text, end }` — `text` includes the delimiters and any trailing
|
||||
* flags, `end` is the index one past the literal — or null if no literal
|
||||
* closes within MAX_REGEX_LITERAL_LEN characters.
|
||||
*
|
||||
* Single left-to-right pass, no backtracking. It models the two constructs a
|
||||
* backtracking pattern gets wrong, which is why this is a tokenizer and not a
|
||||
* regex:
|
||||
* - `\x` escapes consume BOTH characters, so an escaped `\/` never
|
||||
* terminates the literal;
|
||||
* - inside a `[...]` character class a bare `/` does NOT terminate, so
|
||||
* `/PLAN[\\/].*\.md$/` is one literal rather than two fragments. The
|
||||
* previous regex silently MISSED every re-derivation using a
|
||||
* cross-platform path-separator class for exactly this reason.
|
||||
*/
|
||||
function readRegexLiteralAt(line, start) {
|
||||
if (line[start] !== '/') return null;
|
||||
const limit = Math.min(line.length, start + MAX_REGEX_LITERAL_LEN);
|
||||
let inClass = false;
|
||||
for (let i = start + 1; i < limit; i++) {
|
||||
const ch = line[i];
|
||||
if (ch === '\\') {
|
||||
i++; // escape consumes the next character, whatever it is
|
||||
continue;
|
||||
}
|
||||
if (ch === '\r' || ch === '\n') return null; // a literal cannot span lines
|
||||
if (ch === '[') {
|
||||
inClass = true;
|
||||
} else if (ch === ']') {
|
||||
inClass = false;
|
||||
} else if (ch === '/' && !inClass) {
|
||||
// Trailing flags are bounded by the SAME `limit` as the literal body
|
||||
// itself (not `line.length`) — a literal followed by an unbounded run
|
||||
// of lowercase letters must not make `text` grow past
|
||||
// MAX_REGEX_LITERAL_LEN either.
|
||||
let end = i + 1;
|
||||
while (end < limit && line[end] >= 'a' && line[end] <= 'z') end++;
|
||||
return { text: line.slice(start, end), end };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Symlinks report `isDirectory()`/`isFile()` as false on the Dirent from
|
||||
// `readdirSync`, so a symlinked `.cts` (or a symlinked directory containing
|
||||
// one) was previously invisible to this scanner — an evasion of a guard
|
||||
// whose stated design principle (ADR-3180 Decision 4a) is whole-repo
|
||||
// discovery with no allowlist. Resolve each entry with `fs.statSync` (which
|
||||
// follows symlinks) to classify it, skipping broken links. `ctx.visitedRealDirs`
|
||||
// guards against a symlink cycle sending `walk` into infinite recursion.
|
||||
//
|
||||
// Every sibling drift guard in `scripts/` that does NOT import this module
|
||||
// (`lint-phase-id-drift.cjs`, `lint-package-identity-drift.cjs`,
|
||||
// `lint-portable-timeout.cjs`, `lint-test-file-count.cjs`,
|
||||
// `lint-allow-test-rule-refs.cjs`) uses the `Dirent` classification straight
|
||||
// off `readdirSync` and does NOT follow symlinks at all. This scanner follows
|
||||
// them so a symlinked source file cannot evade ADR-3180 Decision 4a's
|
||||
// whole-repo discovery — root confinement (`isInsideRoot` below) is the price
|
||||
// of doing so: without it, a symlink planted anywhere under a scan directory
|
||||
// could walk this scanner out to read and report arbitrary files elsewhere on
|
||||
// disk.
|
||||
//
|
||||
// DIRECTORY vs FILE symlinks are confined to two DIFFERENT roots, tracked as
|
||||
// `ctx.scanDirRoot` (the realpath of the current top-level scan-dir entry,
|
||||
// e.g. `<realRoot>/src`) vs `ctx.realRoot` (the whole repo):
|
||||
// - a DIRECTORY symlink is descended ONLY if its resolved realpath is
|
||||
// inside `ctx.scanDirRoot` — NOT merely inside `ctx.realRoot`. Without
|
||||
// this, `src/up -> ..` (or `-> <realRoot>`) resolves inside the repo
|
||||
// root and `walk` descends the ENTIRE repo, reporting violations under
|
||||
// paths like `tests/not-src.cts` or `docs/other.cts` — files the caller's
|
||||
// scan-dir list scopes it OUT of. This is a deliberate, fail-CLOSED
|
||||
// trade-off: a directory symlink pointing elsewhere INSIDE the repo (but
|
||||
// outside the scan directory) is simply not followed. The alternative —
|
||||
// descending it — is exactly the whole-repo sweep this rule exists to
|
||||
// prevent, and a fork PR could use that sweep to redden `lint:ci` on
|
||||
// files this guard was never meant to read. The narrower rule is worth
|
||||
// more than the missed edge case.
|
||||
// - a FILE symlink is still scanned if its resolved realpath is inside
|
||||
// `ctx.realRoot` (the whole repo, not just the scan directory) — this is
|
||||
// what keeps `src/alias.cts -> vendor/real.cts` covered (test (f)): an
|
||||
// aliased file genuinely is part of the compiled surface even when its
|
||||
// real target lives outside `src/`, and it is still reported under its
|
||||
// canonical (real) path.
|
||||
//
|
||||
// A resolved path is inside a root only if it IS that root or begins with
|
||||
// root + separator — a plain `startsWith(root)` would also accept a sibling
|
||||
// directory whose name merely starts with the root's name (`/repo-evil`).
|
||||
function isInsideRoot(realPath, realRoot) {
|
||||
return realPath === realRoot || realPath.startsWith(realRoot + path.sep);
|
||||
}
|
||||
|
||||
// True when `realPath` (already confirmed inside `realRoot` by `isInsideRoot`)
|
||||
// resolves THROUGH a skip-list directory anywhere along its path relative to
|
||||
// the root — not just when `realPath` itself IS one. This is what closes the
|
||||
// symlink bypass the `entry.name` fast path alone cannot: `walk` tests
|
||||
// `entry.name` (the symlink's OWN name in its parent directory), but a
|
||||
// symlink named something innocuous can still RESOLVE into `.git` /
|
||||
// `node_modules` / `dist` (`src/g -> ../.git`, `src/leak.cts ->
|
||||
// ../.git/config`, `src/nm -> ../node_modules`) — `isInsideRoot` alone admits
|
||||
// all three, because every one of those real paths is still under the root.
|
||||
function isUnderSkippedDir(realPath, realRoot) {
|
||||
const rel = path.relative(realRoot, realPath);
|
||||
return rel.split(path.sep).some((segment) => SKIP_DIR_NAMES.has(segment));
|
||||
}
|
||||
|
||||
// `ctx.scanExt` is a `Set` of file extensions (e.g. `.cts`/`.ts`/`.mts`) the
|
||||
// caller wants reported — threaded through `ctx` rather than as a positional
|
||||
// parameter so recursive `walk(full, acc, ctx)` calls stay unchanged.
|
||||
function walk(dir, acc, ctx) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return acc;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (SKIP_DIR_NAMES.has(entry.name)) continue; // cheap fast path
|
||||
let stat;
|
||||
try {
|
||||
stat = entry.isSymbolicLink() ? fs.statSync(full) : entry;
|
||||
} catch {
|
||||
continue; // broken symlink target
|
||||
}
|
||||
let realPath;
|
||||
try {
|
||||
realPath = fs.realpathSync(full);
|
||||
} catch {
|
||||
continue; // broken symlink target (race, or a link stat() followed but realpath cannot)
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
// Directories (symlinked or real) are confined to the CURRENT scan
|
||||
// directory root, not merely the repo root — see the comment above
|
||||
// `isInsideRoot` for why (`src/up -> ..` whole-repo sweep).
|
||||
if (!isInsideRoot(realPath, ctx.scanDirRoot)) continue;
|
||||
if (isUnderSkippedDir(realPath, ctx.realRoot)) continue; // symlink resolves through a skipped dir
|
||||
if (ctx.visitedRealDirs.has(realPath)) continue; // symlink cycle guard
|
||||
ctx.visitedRealDirs.add(realPath);
|
||||
walk(full, acc, ctx);
|
||||
} else if (stat.isFile() && ctx.scanExt.has(path.extname(entry.name))) {
|
||||
// Files are confined to the whole repo root — a symlinked FILE whose
|
||||
// real target lives outside the scan directory but inside the repo
|
||||
// (e.g. `src/alias.cts -> vendor/real.cts`) is still part of the
|
||||
// compiled surface and must be scanned.
|
||||
if (!isInsideRoot(realPath, ctx.realRoot)) continue;
|
||||
if (isUnderSkippedDir(realPath, ctx.realRoot)) continue; // symlink resolves through a skipped dir
|
||||
if (ctx.visitedRealFiles.has(realPath)) continue; // two symlinks, same real file
|
||||
ctx.visitedRealFiles.add(realPath);
|
||||
acc.push(realPath);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-repo tree-walk driver shared by every `lint-<derivation>-drift.cjs`
|
||||
* guard. Resolves `root`, walks each of `scanDirs` filtered to `scanExt`
|
||||
* (symlink-following, root-confined, cycle-guarded — see `walk` above), reads
|
||||
* each discovered file, and calls `onFile(relPath, text)` for it — `relPath`
|
||||
* is repo-relative and already the file's canonical (real) path, so a
|
||||
* per-file exemption keyed on `relPath` matches consistently regardless of
|
||||
* which symlink reached it.
|
||||
*
|
||||
* `onFile` returns an array of violation objects (or an empty array / null /
|
||||
* undefined for "no violations in this file"); `scanTree` flattens them all
|
||||
* into one returned array. Pure I/O orchestration — detection logic lives
|
||||
* entirely in the caller's `onFile`.
|
||||
*/
|
||||
function scanTree({ root, scanDirs, scanExt, onFile }) {
|
||||
const violations = [];
|
||||
let realRoot;
|
||||
try {
|
||||
realRoot = fs.realpathSync(root);
|
||||
} catch {
|
||||
return violations; // root itself does not exist / is unreadable
|
||||
}
|
||||
for (const dir of scanDirs) {
|
||||
const scanDirPath = path.join(root, dir);
|
||||
let scanDirRoot;
|
||||
try {
|
||||
scanDirRoot = fs.realpathSync(scanDirPath);
|
||||
} catch {
|
||||
continue; // scan directory itself does not exist / is unreadable
|
||||
}
|
||||
const ctx = { realRoot, scanDirRoot, scanExt, visitedRealDirs: new Set(), visitedRealFiles: new Set() };
|
||||
for (const file of walk(scanDirPath, [], ctx)) {
|
||||
const rel = path.relative(realRoot, file);
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(file, 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const found = onFile(rel, text);
|
||||
if (found && found.length > 0) violations.push(...found);
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
// A reported fragment AND a reported file path are both attacker-controlled
|
||||
// source text on a fork PR (a repo can legally track a filename containing
|
||||
// control bytes, so the path is exactly as attacker-controlled as the
|
||||
// fragment), and both are written straight to a CI log. Replace C0/C1
|
||||
// control bytes (ANSI escapes included) with a visible \xNN, AND the
|
||||
// non-Latin-1 formatting/bidi/line-separator codepoints below with \uNNNN, so
|
||||
// a crafted literal or filename cannot rewrite the terminal rendering of the
|
||||
// report or hide/reorder its own text:
|
||||
// - U+200B-U+200F: zero-width space/joiners and directional marks
|
||||
// - U+2028/U+2029: Unicode LINE SEPARATOR / PARAGRAPH SEPARATOR (line
|
||||
// breaks a `\n`-only log scan would not catch)
|
||||
// - U+202A-U+202E: bidi embedding/override controls (RLO etc.)
|
||||
// - U+2066-U+2069: bidi isolate controls
|
||||
function sanitizeForReport(text) {
|
||||
return text
|
||||
// eslint-disable-next-line no-control-regex -- the control range IS the target
|
||||
.replace(/[\x00-\x1f\x7f-\x9f]/g, (c) => '\\x' + c.charCodeAt(0).toString(16).padStart(2, '0'))
|
||||
.replace(/[\u200B-\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069]/g, (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SKIP_DIR_NAMES,
|
||||
isInsideRoot,
|
||||
isUnderSkippedDir,
|
||||
walk,
|
||||
readRegexLiteralAt,
|
||||
MAX_REGEX_LITERAL_LEN,
|
||||
sanitizeForReport,
|
||||
scanTree,
|
||||
};
|
||||
300
scripts/lint-milestone-window-drift.cjs
Normal file
300
scripts/lint-milestone-window-drift.cjs
Normal file
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Anti-divergence drift guard for the milestone-WINDOWING seam
|
||||
* (epic #3180, issue #3184, ADR-3180 "Planning Semantic Model Single Owner").
|
||||
*
|
||||
* `src/roadmap-parser.cts` is the SINGLE canonical owner of "where does a
|
||||
* given milestone's ROADMAP section begin and end" — `computeMilestoneSectionEnd`,
|
||||
* `locateMilestoneHeadings`, and `isMilestoneBoundedInRoadmap`. Every other
|
||||
* module that hand-rolls a heading-level quantifier together with the
|
||||
* milestone-boundary shape (a non-Phase heading carrying a version token or a
|
||||
* shipped/active marker) is a re-derivation that can silently drift from the
|
||||
* owner — the exact defect class #2562 fixed in one copy and never reached
|
||||
* the other two (design doc: `currentMilestoneRawRanges::computeSectionEnd`
|
||||
* carried a "keep in sync" comment that was already evidence the risk was
|
||||
* known, not controlled).
|
||||
*
|
||||
* Per ADR-3180 Decision 4(a) this guard discovers call sites by SCANNING THE
|
||||
* WHOLE `src/` TREE, not by consulting an allowlist of known files — an
|
||||
* allowlist only measures re-derivations in files someone remembered to
|
||||
* list. Exemptions below are FUNCTION-SCOPED with a written reason, never a
|
||||
* bare file allowlist, mirroring `lint-plan-count-drift.cjs`'s precedent.
|
||||
*
|
||||
* Detection is intentionally NARROW, mirroring the plan-count-drift and
|
||||
* phase-id-drift precedents: a line is a re-derivation when it carries BOTH,
|
||||
* in ONE source line:
|
||||
* (a) a markdown heading-level quantifier token — `#{N,M}`, e.g. `#{1,3}`,
|
||||
* `#{2,3}`, `#{2,4}` — inside either a regex literal or a
|
||||
* quoted/backticked string, AND
|
||||
* (b) a milestone-window token: either the negative-lookahead phase
|
||||
* exclusion (`(?!Phase` / `(?!Phase\s+\S)`) or the milestone
|
||||
* boundary-marker set — a `v\d+\.\d+`-shaped version token appearing
|
||||
* together with any of the ✅ 📋 🚧 shipped/active markers.
|
||||
* Token (b) is deliberately narrow: a PHASE-heading regex (`#{2,4}\s*Phase`)
|
||||
* carries (a) alone, constantly, throughout this codebase (phase-numbering,
|
||||
* plan-index, wave-scheduling call sites) and must NOT be flagged — it asks
|
||||
* "where is phase N's heading", a different, already-single-owned question
|
||||
* (#2121). Only a line that ALSO carries the milestone-boundary shape — the
|
||||
* one `computeMilestoneSectionEnd`/`locateMilestoneHeadings` compute — is a
|
||||
* candidate re-derivation of THIS derivation.
|
||||
*
|
||||
* Both `(a)` and `(b)` must be readable through JS regex-literal AND
|
||||
* string/template-literal escaping: the two pre-#3184 `state.cts`
|
||||
* re-derivations this design is modelled on were
|
||||
* `new RegExp(\`^#{1,3}\\s+(?!Phase\\s+\\S)...\`)` — i.e. the SAME source
|
||||
* text as a real `/.../ ` regex literal, just doubly backslash-escaped
|
||||
* because it lives inside a template literal. `HEADING_QUANTIFIER_RE` and
|
||||
* `PHASE_LOOKAHEAD_RE` match either escaping level unchanged (no backslash
|
||||
* appears inside `#{`/`}`/`(?!Phase`'s literal characters); `VERSION_TOKEN_RE`
|
||||
* explicitly tolerates ONE or TWO backslashes before each `d`/`.` for exactly
|
||||
* this reason. Regex-LITERAL boundaries (used only to extract a reportable
|
||||
* `found` fragment, never for detection itself, which tests the raw line) are
|
||||
* located via the shared `readRegexLiteralAt` tokenizer
|
||||
* (`scripts/lib/drift-scan.cjs`) — a single left-to-right, no-backtracking
|
||||
* pass — never a backtracking "find the regex literal" regex (CodeQL js/redos
|
||||
* runs on `lint:ci`; see that module's own header for the full rationale).
|
||||
* `readStringLiteralAt` below is the same style, written locally for
|
||||
* quoted/backticked strings (not shared — `lint-plan-count-drift.cjs` has no
|
||||
* equivalent need, since its own literal-bearing shape is regex-only).
|
||||
*
|
||||
* Owner file (exempt by construction): `src/roadmap-parser.cts` — it not only
|
||||
* DEFINES this grammar but composes `#{1,3}` with `(?!Phase...)`/marker
|
||||
* alternations at several internal call sites (`computeMilestoneSectionEnd`,
|
||||
* `locateMilestoneHeadings`, `extractCurrentMilestoneScoped`'s
|
||||
* `anyMilestonePattern`/`anyMilestoneOrDetails`) that are the canonical
|
||||
* implementation, not copies of it.
|
||||
*
|
||||
* The tree-walk / root-confinement / regex-literal-tokenizer / sanitizer
|
||||
* machinery is SHARED with `scripts/lint-plan-count-drift.cjs` via
|
||||
* `scripts/lib/drift-scan.cjs` (ADR-3180 Decision 4, design doc's own
|
||||
* "Rejected: let the new drift guard copy Phase 1's tree-walk /
|
||||
* root-confinement / sanitizer") — see that module for the `isInsideRoot`
|
||||
* case-sensitivity note, the `walk` symlink-confinement rationale, and the
|
||||
* `readRegexLiteralAt` ReDoS-avoidance rationale.
|
||||
*
|
||||
* KNOWN, ACCEPTED limits of a per-line textual scan (same tradeoff the
|
||||
* sibling drift guards document): a re-derivation whose `(a)`/`(b)` tokens
|
||||
* are split across two DIFFERENT lines with no single line carrying both is
|
||||
* not caught by this narrow shape, nor is one routed through dynamic
|
||||
* dispatch. That is left to code review and the design's identity test
|
||||
* (ADR-3180 Decision 4b/4c), not this regex.
|
||||
*/
|
||||
|
||||
const path = require('node:path');
|
||||
const driftScan = require('./lib/drift-scan.cjs');
|
||||
const { readRegexLiteralAt, MAX_REGEX_LITERAL_LEN, sanitizeForReport, scanTree } = driftScan;
|
||||
|
||||
// (a) A markdown heading-level quantifier: `#{N,M}` — e.g. `#{1,3}`,
|
||||
// `#{2,3}`, `#{2,4}`. Bounded to 1-2 digit levels (real Markdown headings
|
||||
// never exceed level 6) so this stays a small, fixed, linear test — no
|
||||
// unbounded quantifier, nothing for CodeQL js/redos to flag.
|
||||
const HEADING_QUANTIFIER_RE = /#\{\d{1,2},\d{1,2}\}/;
|
||||
|
||||
// (b1) The negative-lookahead phase exclusion `computeMilestoneSectionEnd`/
|
||||
// `locateMilestoneHeadings` use to skip `### Phase N: …` headings while
|
||||
// scanning for the NEXT milestone boundary.
|
||||
const PHASE_LOOKAHEAD_RE = /\(\?!Phase\b/;
|
||||
|
||||
// (b2) A `v\d+\.\d+`-shaped version token, tolerant of ONE or TWO backslash
|
||||
// escaping levels (a bare regex literal carries `\d`/`\.` with a single
|
||||
// backslash; a template-literal regex SOURCE string carries the SAME source
|
||||
// text doubly-escaped, `\\d`/`\\.`, because the template literal's own
|
||||
// backslash must itself be escaped in the .cts source) and an OPTIONAL
|
||||
// capturing group immediately around the digit run (`v(\d+)\.\d+`, the shape
|
||||
// `roadmap-command-router.cts`'s `MILESTONE_RE` actually uses to capture the
|
||||
// major version number).
|
||||
const VERSION_TOKEN_RE = /v\(?\\{1,2}d\+\)?\\{1,2}\.\\{1,2}d\+/;
|
||||
|
||||
// (b2) The milestone shipped/active marker set `isClosedMilestoneHeading`/
|
||||
// `computeMilestoneSectionEnd` test for. `(b)` fires when this appears on the
|
||||
// SAME line as a VERSION_TOKEN_RE match — a version token alone is not
|
||||
// milestone-boundary-specific (plenty of non-heading code compares version
|
||||
// strings), and a marker alone is not either (it can appear in unrelated
|
||||
// prose-matching code); together, on one line, they are the boundary shape.
|
||||
const MARKER_EMOJI_RE = /[✅📋🚧]/u;
|
||||
|
||||
// Authored TypeScript source only (the generated bin/lib/*.cjs mirror it).
|
||||
const SCAN_DIRS = ['src'];
|
||||
const SCAN_EXT = new Set(['.cts', '.ts', '.mts']);
|
||||
|
||||
// The canonical owner defines the grammar; it is exempt by construction (see
|
||||
// header comment for why its OWN internal composition of these tokens is not
|
||||
// a re-derivation).
|
||||
const OWNER_FILE = path.join('src', 'roadmap-parser.cts');
|
||||
|
||||
// Per ADR-3180 Decision 4(a): NOT a bare file allowlist — each entry below is
|
||||
// scoped to the SPECIFIC function asking a documented, DIFFERENT question, so
|
||||
// an unrelated re-derivation added anywhere else in these same files is still
|
||||
// caught. Mirrors `lint-plan-count-drift.cjs`'s FUNCTION_SCOPED_EXEMPTIONS
|
||||
// mechanism.
|
||||
//
|
||||
// - roadmap-command-router.cts checkW021: `MILESTONE_RE` CLASSIFIES a
|
||||
// single heading LINE as "is this a milestone heading, and if so what is
|
||||
// its major version" for the W021 phase/milestone-prefix-mismatch check
|
||||
// — it is a per-line classifier consumed one line at a time via
|
||||
// `content.split('\n')`, with no concept of a section END at all. It
|
||||
// never computes "where does this milestone's content stop" — the
|
||||
// question `computeMilestoneSectionEnd` answers — so it cannot diverge
|
||||
// from that computation; it answers a narrower, different question this
|
||||
// derivation does not own.
|
||||
// - verify.cts checkMilestonePrefixMismatches: `sectionRx` ENUMERATES
|
||||
// every milestone heading in the document to build a list of
|
||||
// `{version, start, end}` sections (each section's `end` is provisionally
|
||||
// "rest of document" until the NEXT heading is found, then backfilled) —
|
||||
// it is answering "what are ALL the milestone sections", to check every
|
||||
// phase against its OWN enclosing milestone, not "where does THIS ONE
|
||||
// milestone (the current/asserted one) end" — `computeMilestoneSectionEnd`
|
||||
// takes a single heading and returns a single boundary; this function
|
||||
// never calls anything with that shape. (Design brief named this
|
||||
// `cmdValidateConsistency` — the code actually lives in the sibling
|
||||
// function `checkMilestonePrefixMismatches`, called from
|
||||
// `cmdValidateHealth`; `cmdValidateConsistency` itself does not contain
|
||||
// `sectionRx`. Exempted here under its ACTUAL containing function.) Also:
|
||||
// `sectionRx` (`/^#{1,3}\s+(?:\[[^\]]{1,200}\]\s*)?.*v(\d+\.\d+)/gim`)
|
||||
// does not itself carry token (b) as this guard defines it (no
|
||||
// `(?!Phase` lookahead, no marker-emoji pairing) — this exemption
|
||||
// currently documents intent rather than suppressing a live match.
|
||||
const FUNCTION_SCOPED_EXEMPTIONS = new Map([
|
||||
[path.join('src', 'roadmap-command-router.cts'), new Set(['checkW021'])],
|
||||
[path.join('src', 'verify.cts'), new Set(['checkMilestonePrefixMismatches'])],
|
||||
]);
|
||||
|
||||
// Optional `export ` modifier, mirroring `lint-plan-count-drift.cjs`'s
|
||||
// TOP_LEVEL_FUNCTION_RE — only a column-0 top-level `function` declaration
|
||||
// updates the current-function tracker; a nested/arrow function does not
|
||||
// reset it, matching every FUNCTION_SCOPED_EXEMPTIONS entry above (all
|
||||
// top-level `function` declarations).
|
||||
const TOP_LEVEL_FUNCTION_RE = /^(?:export\s+)?function\s+([A-Za-z0-9_]+)\s*\(/;
|
||||
|
||||
/**
|
||||
* Read the quoted or backtick-delimited string/template literal starting at
|
||||
* `line[start]` (which must be `'`, `"`, or `` ` ``). Returns `{ text, end }`
|
||||
* — `text` includes both delimiters, `end` is the index one past the literal
|
||||
* — or null if no matching close quote is found within MAX_REGEX_LITERAL_LEN
|
||||
* characters. Same single left-to-right, no-backtracking, escape-aware style
|
||||
* as the shared `readRegexLiteralAt` (`\x` escapes consume both characters,
|
||||
* so an escaped quote never terminates the literal early) — written locally
|
||||
* because `lint-plan-count-drift.cjs` has no equivalent need (its literal
|
||||
* shape is regex-only), so it does not belong in the shared module.
|
||||
*/
|
||||
function readStringLiteralAt(line, start) {
|
||||
const quote = line[start];
|
||||
if (quote !== "'" && quote !== '"' && quote !== '`') return null;
|
||||
const limit = Math.min(line.length, start + MAX_REGEX_LITERAL_LEN);
|
||||
for (let i = start + 1; i < limit; i++) {
|
||||
const ch = line[i];
|
||||
if (ch === '\\') {
|
||||
i++; // escape consumes the next character, whatever it is
|
||||
continue;
|
||||
}
|
||||
if (ch === '\r' || ch === '\n') return null; // a literal cannot span lines in this per-line scan
|
||||
if (ch === quote) return { text: line.slice(start, i + 1), end: i + 1 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first literal (regex OR quoted/backtick string) on `line` whose text
|
||||
* contains a HEADING_QUANTIFIER_RE match — the "smoking gun" fragment worth
|
||||
* reporting, mirroring `findRegexLiteralMdMatch`'s role in the sibling guard.
|
||||
* Falls back to a bounded, trimmed slice of the raw line when the tokens are
|
||||
* not both inside one located literal (not currently reachable against this
|
||||
* repo — see the header comment's per-file audit — but a fail-safe rather
|
||||
* than a thrown error if a future line splits them).
|
||||
*/
|
||||
function extractFragment(line) {
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
let literal = null;
|
||||
if (ch === '/') literal = readRegexLiteralAt(line, i);
|
||||
else if (ch === "'" || ch === '"' || ch === '`') literal = readStringLiteralAt(line, i);
|
||||
if (!literal) continue;
|
||||
if (HEADING_QUANTIFIER_RE.test(literal.text)) return literal.text;
|
||||
i = literal.end - 1; // resume scanning just past this literal
|
||||
}
|
||||
return line.trim().slice(0, MAX_REGEX_LITERAL_LEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: find every unsanctioned milestone-window re-derivation in `text`.
|
||||
* `relPath` is the repo-relative path, used both to report file:line and to
|
||||
* apply the narrow, function-scoped exemptions above.
|
||||
* Returns [{ line, found }].
|
||||
*/
|
||||
function findMilestoneWindowDrift(text, relPath) {
|
||||
const out = [];
|
||||
const lines = text.split('\n');
|
||||
const exemptFunctions = FUNCTION_SCOPED_EXEMPTIONS.get(relPath) || null;
|
||||
let currentFunction = null;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const fnMatch = TOP_LEVEL_FUNCTION_RE.exec(line);
|
||||
if (fnMatch) currentFunction = fnMatch[1];
|
||||
|
||||
if (!HEADING_QUANTIFIER_RE.test(line)) continue;
|
||||
const isMilestoneWindowToken = PHASE_LOOKAHEAD_RE.test(line) || (VERSION_TOKEN_RE.test(line) && MARKER_EMOJI_RE.test(line));
|
||||
if (!isMilestoneWindowToken) continue;
|
||||
|
||||
if (exemptFunctions && exemptFunctions.has(currentFunction)) continue;
|
||||
|
||||
out.push({ line: i + 1, found: extractFragment(line) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the authored source tree and return every unsanctioned re-derivation,
|
||||
* each annotated with the repo-relative file path.
|
||||
*/
|
||||
function scanRepo(root) {
|
||||
return scanTree({
|
||||
root,
|
||||
scanDirs: SCAN_DIRS,
|
||||
scanExt: SCAN_EXT,
|
||||
onFile(rel, text) {
|
||||
// `rel` is already the REAL (canonical) path (scanTree resolves
|
||||
// symlinks before calling onFile), so this comparison — and
|
||||
// FUNCTION_SCOPED_EXEMPTIONS above, also keyed on `rel` — match
|
||||
// consistently regardless of which symlink reached the file.
|
||||
if (rel === OWNER_FILE) return [];
|
||||
return findMilestoneWindowDrift(text, rel).map((d) => ({ file: rel, ...d }));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
const root = path.join(__dirname, '..');
|
||||
const violations = scanRepo(root);
|
||||
if (violations.length === 0) {
|
||||
process.stdout.write('ok milestone-window-drift: no unsanctioned milestone-window re-derivations outside roadmap-parser.cts\n');
|
||||
return;
|
||||
}
|
||||
process.stderr.write('milestone-window-drift: independent re-derivation(s) of milestone-window bounding found.\n');
|
||||
process.stderr.write('Use src/roadmap-parser.cjs `computeMilestoneSectionEnd` / `locateMilestoneHeadings` /\n');
|
||||
process.stderr.write('`isMilestoneBoundedInRoadmap` instead of re-deriving the milestone heading/boundary regex:\n');
|
||||
for (const d of violations) {
|
||||
// `d.file` is exactly as attacker-controlled as `d.found`: a repo can
|
||||
// legally track a filename containing control bytes / bidi overrides,
|
||||
// and it is a fork-PR-authored value reaching a CI log the same way the
|
||||
// matched literal does — sanitize it at the same reporting boundary.
|
||||
process.stderr.write(` ${sanitizeForReport(d.file)}:${d.line} ${sanitizeForReport(d.found)}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = {
|
||||
findMilestoneWindowDrift,
|
||||
scanRepo,
|
||||
HEADING_QUANTIFIER_RE,
|
||||
PHASE_LOOKAHEAD_RE,
|
||||
VERSION_TOKEN_RE,
|
||||
MARKER_EMOJI_RE,
|
||||
OWNER_FILE,
|
||||
FUNCTION_SCOPED_EXEMPTIONS,
|
||||
readStringLiteralAt,
|
||||
extractFragment,
|
||||
};
|
||||
@@ -47,18 +47,14 @@
|
||||
* across two DIFFERENT lines with no single line carrying both, is not
|
||||
* caught by this narrow shape. That is left to code review, not this regex.
|
||||
*
|
||||
* `isInsideRoot`'s root-confinement check (used by the symlink-following
|
||||
* `walk`, below) is an EXACT string comparison, deliberately not
|
||||
* case-normalized. On a case-insensitive filesystem (macOS default; not CI,
|
||||
* which is ubuntu) a symlink whose target is a case-VARIANT of an in-root
|
||||
* path — a path the OS itself would still resolve to the same file — is
|
||||
* REJECTED by this exact comparison and silently left unscanned. This is a
|
||||
* fail-CLOSED miss (a re-derivation goes unreported), never an escape (never
|
||||
* a wrongly-admitted outside-root read), so it is left as-is: making the
|
||||
* comparison case-insensitive would WEAKEN confinement (a resolved path
|
||||
* merely case-differing from a sibling-of-root name, `/repo-Evil` vs
|
||||
* `/repo-evil`, could then be wrongly admitted) to fix a gap that only ever
|
||||
* under-reports on a platform this guard is not gated on.
|
||||
* The tree-walk / root-confinement / regex-literal-tokenizer / sanitizer
|
||||
* machinery below is SHARED with `scripts/lint-milestone-window-drift.cjs`
|
||||
* (#3184) via `scripts/lib/drift-scan.cjs` — see that module for the
|
||||
* `isInsideRoot` case-sensitivity note, the `walk` symlink-confinement
|
||||
* rationale, and the `readRegexLiteralAt` tokenizer's ReDoS-avoidance
|
||||
* rationale. It is deliberately NOT duplicated here a second time (ADR-3180
|
||||
* Decision 4's own "Rejected" list: "let the new drift guard copy Phase 1's
|
||||
* tree-walk / root-confinement / sanitizer").
|
||||
*
|
||||
* A regex literal longer than MAX_REGEX_LITERAL_LEN (400) characters is not
|
||||
* read, and is therefore not caught. That bound is what keeps the scan
|
||||
@@ -74,8 +70,9 @@
|
||||
* survive, and the reason the detector is now a tokenizer.
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const driftScan = require('./lib/drift-scan.cjs');
|
||||
const { readRegexLiteralAt, MAX_REGEX_LITERAL_LEN, isInsideRoot, sanitizeForReport, scanTree } = driftScan;
|
||||
|
||||
// A `.filter(` call on the line — the shape every current re-derivation uses
|
||||
// to turn a directory listing into a plan-or-summary subset. Kept as its own
|
||||
@@ -96,15 +93,6 @@ const FILENAME_TEST_RE = /\.(?:filter|test|match|exec|endsWith|startsWith|includ
|
||||
// closing quote must match).
|
||||
const PLAN_SUMMARY_LITERAL_RE = /(['"])-?(?:PLAN|SUMMARY)\.md\1/;
|
||||
|
||||
// Longest regex literal this scanner will consider, in characters. Real
|
||||
// plan/summary filename filters are far shorter; the bound is what keeps the
|
||||
// scan linear. The tokenizer restarts at every `/` on the line (so that a
|
||||
// literal preceded by a stray unpaired `/` is still found, matching the
|
||||
// previous regex's "find anywhere" behaviour), which without a per-literal
|
||||
// bound would be quadratic on a pathological line. With it the whole-line
|
||||
// cost is O(n * MAX_REGEX_LITERAL_LEN) with no backtracking at all.
|
||||
const MAX_REGEX_LITERAL_LEN = 400;
|
||||
|
||||
// The two tokens that, appearing together INSIDE one regex literal, make it a
|
||||
// plan/summary filename filter. `\.md` is matched as literal source text, not
|
||||
// as a pattern, so there is nothing here to backtrack.
|
||||
@@ -115,17 +103,6 @@ const ESCAPED_MD_TOKEN = '\\.md';
|
||||
const SCAN_DIRS = ['src'];
|
||||
const SCAN_EXT = new Set(['.cts', '.ts', '.mts']);
|
||||
|
||||
// Directory names this scanner never descends into or reports out of —
|
||||
// `.git` (repo internals, e.g. a persisted CI token in `.git/config`),
|
||||
// `node_modules` (thousands of third-party files, none of them authored
|
||||
// source), `dist` (build output). Named once and used at BOTH skip sites
|
||||
// below: the cheap `entry.name` fast path in `walk`, and the resolved-path
|
||||
// component check in `isUnderSkippedDir` — a symlink whose OWN name is not
|
||||
// in this set but whose target resolves through a directory that IS (e.g.
|
||||
// `src/g -> ../.git`, `src/nm -> ../node_modules`) must still be skipped, or
|
||||
// the name-only check is a trivial bypass.
|
||||
const SKIP_DIR_NAMES = new Set(['node_modules', 'dist', '.git']);
|
||||
|
||||
// The canonical owner defines the grammar; it is exempt by construction.
|
||||
const OWNER_FILE = path.join('src', 'plan-scan.cts');
|
||||
|
||||
@@ -197,50 +174,6 @@ const FUNCTION_SCOPED_EXEMPTIONS = new Map([
|
||||
// FUNCTION_SCOPED_EXEMPTIONS entry above to take effect.
|
||||
const TOP_LEVEL_FUNCTION_RE = /^(?:export\s+)?function\s+([A-Za-z0-9_]+)\s*\(/;
|
||||
|
||||
/**
|
||||
* Read the JS regex literal starting at `line[start]` (which must be `/`).
|
||||
* Returns `{ text, end }` — `text` includes the delimiters and any trailing
|
||||
* flags, `end` is the index one past the literal — or null if no literal
|
||||
* closes within MAX_REGEX_LITERAL_LEN characters.
|
||||
*
|
||||
* Single left-to-right pass, no backtracking. It models the two constructs a
|
||||
* backtracking pattern gets wrong, which is why this is a tokenizer and not a
|
||||
* regex:
|
||||
* - `\x` escapes consume BOTH characters, so an escaped `\/` never
|
||||
* terminates the literal;
|
||||
* - inside a `[...]` character class a bare `/` does NOT terminate, so
|
||||
* `/PLAN[\\/].*\.md$/` is one literal rather than two fragments. The
|
||||
* previous regex silently MISSED every re-derivation using a
|
||||
* cross-platform path-separator class for exactly this reason.
|
||||
*/
|
||||
function readRegexLiteralAt(line, start) {
|
||||
if (line[start] !== '/') return null;
|
||||
const limit = Math.min(line.length, start + MAX_REGEX_LITERAL_LEN);
|
||||
let inClass = false;
|
||||
for (let i = start + 1; i < limit; i++) {
|
||||
const ch = line[i];
|
||||
if (ch === '\\') {
|
||||
i++; // escape consumes the next character, whatever it is
|
||||
continue;
|
||||
}
|
||||
if (ch === '\r' || ch === '\n') return null; // a literal cannot span lines
|
||||
if (ch === '[') {
|
||||
inClass = true;
|
||||
} else if (ch === ']') {
|
||||
inClass = false;
|
||||
} else if (ch === '/' && !inClass) {
|
||||
// Trailing flags are bounded by the SAME `limit` as the literal body
|
||||
// itself (not `line.length`) — a literal followed by an unbounded run
|
||||
// of lowercase letters must not make `text` grow past
|
||||
// MAX_REGEX_LITERAL_LEN either.
|
||||
let end = i + 1;
|
||||
while (end < limit && line[end] >= 'a' && line[end] <= 'z') end++;
|
||||
return { text: line.slice(start, end), end };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The regex literal on `line` that mentions PLAN or SUMMARY together with an
|
||||
* escaped `.md` suffix — e.g. `/-PLAN\.md$/`, `/^PLAN-\d+.*\.md$/i`,
|
||||
@@ -265,113 +198,6 @@ function findRegexLiteralMdMatch(line) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Symlinks report `isDirectory()`/`isFile()` as false on the Dirent from
|
||||
// `readdirSync`, so a symlinked `src/*.cts` (or a symlinked directory
|
||||
// containing one) was previously invisible to this scanner — an evasion of a
|
||||
// guard whose stated design principle (ADR-3180 Decision 4a) is whole-repo
|
||||
// discovery with no allowlist. Resolve each entry with `fs.statSync` (which
|
||||
// follows symlinks) to classify it, skipping broken links. `ctx.visitedRealDirs`
|
||||
// guards against a symlink cycle sending `walk` into infinite recursion.
|
||||
//
|
||||
// Every sibling drift guard in `scripts/` (`lint-phase-id-drift.cjs`,
|
||||
// `lint-package-identity-drift.cjs`, `lint-portable-timeout.cjs`,
|
||||
// `lint-test-file-count.cjs`, `lint-allow-test-rule-refs.cjs`) uses the
|
||||
// `Dirent` classification straight off `readdirSync` and does NOT follow
|
||||
// symlinks at all. This guard follows them so a symlinked `src/*.cts` cannot
|
||||
// evade ADR-3180 Decision 4a's whole-repo discovery — root confinement
|
||||
// (`isInsideRoot` below) is the price of doing so: without it, a symlink
|
||||
// planted anywhere under `src/` could walk this scanner out to read and
|
||||
// report arbitrary files elsewhere on disk.
|
||||
//
|
||||
// DIRECTORY vs FILE symlinks are confined to two DIFFERENT roots, tracked as
|
||||
// `ctx.scanDirRoot` (the realpath of the current top-level SCAN_DIRS entry,
|
||||
// e.g. `<realRoot>/src`) vs `ctx.realRoot` (the whole repo):
|
||||
// - a DIRECTORY symlink is descended ONLY if its resolved realpath is
|
||||
// inside `ctx.scanDirRoot` — NOT merely inside `ctx.realRoot`. Without
|
||||
// this, `src/up -> ..` (or `-> <realRoot>`) resolves inside the repo
|
||||
// root and `walk` descends the ENTIRE repo, reporting violations under
|
||||
// paths like `tests/not-src.cts` or `docs/other.cts` — files the header
|
||||
// above says the scan is scoped OUT of (`SCAN_DIRS`). This is a
|
||||
// deliberate, fail-CLOSED trade-off: a directory symlink pointing
|
||||
// elsewhere INSIDE the repo (but outside the scan directory) is simply
|
||||
// not followed. The alternative — descending it — is exactly the
|
||||
// whole-repo sweep this rule exists to prevent, and a fork PR could use
|
||||
// that sweep to redden `lint:ci` on files this guard was never meant to
|
||||
// read. The narrower rule is worth more than the missed edge case.
|
||||
// - a FILE symlink is still scanned if its resolved realpath is inside
|
||||
// `ctx.realRoot` (the whole repo, not just the scan directory) — this is
|
||||
// what keeps `src/alias.cts -> vendor/real.cts` covered (test (f)): an
|
||||
// aliased file genuinely is part of the compiled surface even when its
|
||||
// real target lives outside `src/`, and it is still reported under its
|
||||
// canonical (real) path.
|
||||
//
|
||||
// A resolved path is inside a root only if it IS that root or begins with
|
||||
// root + separator — a plain `startsWith(root)` would also accept a sibling
|
||||
// directory whose name merely starts with the root's name (`/repo-evil`).
|
||||
function isInsideRoot(realPath, realRoot) {
|
||||
return realPath === realRoot || realPath.startsWith(realRoot + path.sep);
|
||||
}
|
||||
|
||||
// True when `realPath` (already confirmed inside `realRoot` by `isInsideRoot`)
|
||||
// resolves THROUGH a skip-list directory anywhere along its path relative to
|
||||
// the root — not just when `realPath` itself IS one. This is what closes the
|
||||
// symlink bypass the `entry.name` fast path alone cannot: `walk` tests
|
||||
// `entry.name` (the symlink's OWN name in its parent directory), but a
|
||||
// symlink named something innocuous can still RESOLVE into `.git` /
|
||||
// `node_modules` / `dist` (`src/g -> ../.git`, `src/leak.cts ->
|
||||
// ../.git/config`, `src/nm -> ../node_modules`) — `isInsideRoot` alone admits
|
||||
// all three, because every one of those real paths is still under the root.
|
||||
function isUnderSkippedDir(realPath, realRoot) {
|
||||
const rel = path.relative(realRoot, realPath);
|
||||
return rel.split(path.sep).some((segment) => SKIP_DIR_NAMES.has(segment));
|
||||
}
|
||||
|
||||
function walk(dir, acc, ctx) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return acc;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (SKIP_DIR_NAMES.has(entry.name)) continue; // cheap fast path
|
||||
let stat;
|
||||
try {
|
||||
stat = entry.isSymbolicLink() ? fs.statSync(full) : entry;
|
||||
} catch {
|
||||
continue; // broken symlink target
|
||||
}
|
||||
let realPath;
|
||||
try {
|
||||
realPath = fs.realpathSync(full);
|
||||
} catch {
|
||||
continue; // broken symlink target (race, or a link stat() followed but realpath cannot)
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
// Directories (symlinked or real) are confined to the CURRENT scan
|
||||
// directory root, not merely the repo root — see the comment above
|
||||
// `isInsideRoot` for why (`src/up -> ..` whole-repo sweep).
|
||||
if (!isInsideRoot(realPath, ctx.scanDirRoot)) continue;
|
||||
if (isUnderSkippedDir(realPath, ctx.realRoot)) continue; // symlink resolves through a skipped dir
|
||||
if (ctx.visitedRealDirs.has(realPath)) continue; // symlink cycle guard
|
||||
ctx.visitedRealDirs.add(realPath);
|
||||
walk(full, acc, ctx);
|
||||
} else if (stat.isFile() && SCAN_EXT.has(path.extname(entry.name))) {
|
||||
// Files are confined to the whole repo root — a symlinked FILE whose
|
||||
// real target lives outside the scan directory but inside the repo
|
||||
// (e.g. `src/alias.cts -> vendor/real.cts`) is still part of the
|
||||
// compiled surface and must be scanned.
|
||||
if (!isInsideRoot(realPath, ctx.realRoot)) continue;
|
||||
if (isUnderSkippedDir(realPath, ctx.realRoot)) continue; // symlink resolves through a skipped dir
|
||||
if (ctx.visitedRealFiles.has(realPath)) continue; // two symlinks, same real file
|
||||
ctx.visitedRealFiles.add(realPath);
|
||||
acc.push(realPath);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: find every unsanctioned plan/summary-filter re-derivation in `text`.
|
||||
* `relPath` is the repo-relative path, used both to report file:line and to
|
||||
@@ -405,62 +231,19 @@ function findPlanCountDrift(text, relPath) {
|
||||
* each annotated with the repo-relative file path.
|
||||
*/
|
||||
function scanRepo(root) {
|
||||
const violations = [];
|
||||
let realRoot;
|
||||
try {
|
||||
realRoot = fs.realpathSync(root);
|
||||
} catch {
|
||||
return violations; // root itself does not exist / is unreadable
|
||||
}
|
||||
for (const dir of SCAN_DIRS) {
|
||||
const scanDirPath = path.join(root, dir);
|
||||
let scanDirRoot;
|
||||
try {
|
||||
scanDirRoot = fs.realpathSync(scanDirPath);
|
||||
} catch {
|
||||
continue; // scan directory itself does not exist / is unreadable
|
||||
}
|
||||
const ctx = { realRoot, scanDirRoot, visitedRealDirs: new Set(), visitedRealFiles: new Set() };
|
||||
for (const file of walk(scanDirPath, [], ctx)) {
|
||||
// `file` is already the REAL path (walk pushes realPath, not the
|
||||
// symlink path), so `rel` is the file's single canonical location
|
||||
// regardless of which symlink reached it — this is what makes
|
||||
// FUNCTION_SCOPED_EXEMPTIONS/OWNER_FILE, which are keyed on the
|
||||
// repo-relative path, match consistently.
|
||||
const rel = path.relative(realRoot, file);
|
||||
if (rel === OWNER_FILE) continue;
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(file, 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const d of findPlanCountDrift(text, rel)) {
|
||||
violations.push({ file: rel, ...d });
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
// Both a `found` fragment AND a reported file path are attacker-controlled
|
||||
// source text on a fork PR (a repo can legally track a filename containing
|
||||
// control bytes, so the path is exactly as attacker-controlled as the
|
||||
// fragment — see the call sites in main() below), and both are written
|
||||
// straight to a CI log. Replace C0/C1 control bytes (ANSI escapes included)
|
||||
// with a visible \xNN, AND the non-Latin-1 formatting/bidi/line-separator
|
||||
// codepoints below with \uNNNN, so a crafted literal or filename cannot
|
||||
// rewrite the terminal rendering of the report or hide/reorder its own text:
|
||||
// - U+200B-U+200F: zero-width space/joiners and directional marks
|
||||
// - U+2028/U+2029: Unicode LINE SEPARATOR / PARAGRAPH SEPARATOR (line
|
||||
// breaks a `\n`-only log scan would not catch)
|
||||
// - U+202A-U+202E: bidi embedding/override controls (RLO etc.)
|
||||
// - U+2066-U+2069: bidi isolate controls
|
||||
function sanitizeForReport(text) {
|
||||
return text
|
||||
// eslint-disable-next-line no-control-regex -- the control range IS the target
|
||||
.replace(/[\x00-\x1f\x7f-\x9f]/g, (c) => '\\x' + c.charCodeAt(0).toString(16).padStart(2, '0'))
|
||||
.replace(/[\u200B-\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069]/g, (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
|
||||
return scanTree({
|
||||
root,
|
||||
scanDirs: SCAN_DIRS,
|
||||
scanExt: SCAN_EXT,
|
||||
onFile(rel, text) {
|
||||
// `rel` is already the REAL (canonical) path (scanTree resolves
|
||||
// symlinks before calling onFile), so this comparison — and
|
||||
// FUNCTION_SCOPED_EXEMPTIONS above, also keyed on `rel` — match
|
||||
// consistently regardless of which symlink reached the file.
|
||||
if (rel === OWNER_FILE) return [];
|
||||
return findPlanCountDrift(text, rel).map((d) => ({ file: rel, ...d }));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"milestone-helper.test.cjs",
|
||||
"milestone-prefixed-convention.test.cjs",
|
||||
"milestone-summary.test.cjs",
|
||||
"milestone-window-single-owner.test.cjs",
|
||||
"milestone.test.cjs"
|
||||
],
|
||||
"issue": "TBD"
|
||||
|
||||
Reference in New Issue
Block a user