Files
msd-core/scripts/lint-allow-test-rule-refs.cjs
Jakub Zych 6cfa0c55d2 refactor: drop 12 runtimes, keep Claude, Codex, OpenCode, Cursor, ZCode, Antigravity
Removes kilo, kimi, kimi-code, copilot, windsurf, augment, trae, qwen, hermes,
cline, codebuddy and pi end to end: capability descriptors, installer branches
and converters (bin/install.js 14.9k -> 11.2k lines), TypeScript converters,
hook surfaces and runtime homes, review lanes qwen/kimi-code, the two pi
migrations, Kimi payload normalization in the hook guards, dead hostBehaviors
vocabulary, launcher home probes, fixtures, runtime-specific tests and the
prose that presented them as supported.

Installer output for the six kept runtimes is byte-identical to before the
prune. The Kimi tool-vocabulary tests in workflow-guard, read-guard and
read-injection-scanner are left in place pending a decision.
2026-10-06 20:02:40 +02:00

873 lines
40 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* lint-allow-test-rule-refs.cjs — enforce that NEW `allow-test-rule:` exemption
* comments carry a tracking-issue reference, AND that the number of exemptions
* that ACTUALLY suppress a detected violation only ever ratchets down.
*
* ## Why
*
* `allow-test-rule:` is an inline comment that disables the `no-source-grep`
* ESLint rule at a specific site. Per ADR-456
* (docs/adr/456-test-rigor-architecture.md) every NEW exemption must carry a
* `#NNN` issue reference or an https:// URL so the decision is traceable.
*
* ## What "compliant" means (citation check)
*
* A compliant `allow-test-rule:` comment is one whose reason text (everything
* after the colon) contains either:
* - a `#\d+` token (e.g. `// allow-test-rule: see #1234`)
* - an https?:// URL
*
* Any other comment is an OFFENDER.
*
* ## Grandfathering (citation check)
*
* All pre-existing untracked exemptions are recorded in
* scripts/lint-allow-test-rule-refs.allowlist.json (seeded at gate introduction
* time). The identity ratchet (scripts/lib/allowlist-ratchet.cjs) means:
* - A NEW non-compliant comment not in the allowlist → gate fails.
* - A previously-offending comment that is now compliant → allowlist entry is
* STALE and must be pruned (ratchet-down; the baseline only ever shrinks).
*
* ## Offender identifiers (citation check)
*
* Identifiers are stable cross-rename-safe strings of the form:
* `<repo-relative-path> :: <trimmed-reason>`
*
* e.g. `tests/foo.test.cjs :: source-text-is-the-product`
*
* If a file has multiple non-compliant comments with the SAME reason text, only
* one identifier is recorded (deduped via Set).
*
* ## Effective exemptions vs. unverified markers (#3520 / epic #3464 phase 5)
*
* A marker-bearing FILE is not the same thing as a marker that suppresses
* anything. Historically this gate counted "distinct files containing marker
* text" (294 at one baseline), but only a handful of those markers actually
* sit next to a violation the `no-source-grep` rule can detect — the rest are
* "counted" only because they contain the literal substring, which made the
* old ceiling ~98% noise and free to bump.
*
* This gate now reports and ratchets TWO separate numbers instead:
*
* 1. **Effective exemption SITES** — a site (one flagged read+search pair)
* where a marker is adjacent enough (per the rule's own
* `isSuppressedAt` site-scoped adjacency predicate) to suppress a
* violation the rule actually detects there. Tightly ratcheted via
* `assertTightCeiling` (both directions — growth past ceiling fails,
* and slack beyond `grace` fails, forcing the ceiling to track the
* real high-water mark).
*
* 2. **Unverified marker-bearing FILES** — files that carry an
* `allow-test-rule:` marker but for which no detectable violation was
* found adjacent to any marker in the file (rule-coverage gap, not
* necessarily a vestigial marker — see "Known limits" below). Reported
* with its own ceiling so it cannot silently balloon, but NOT tightly
* ratcheted: dropping below the ceiling never fails the gate, because
* shrinking this pool means widening rule coverage file-by-file with
* evidence, not deleting markers on the strength of this metric alone.
*
* A file with >=1 effective site counts ONLY as effective, never also as
* unverified (test-matrix.md #3520 row 5) — no double counting.
*
* ## Scan scope: every glob that registers the rule, not just `tests/*.test.cjs`
*
* `local/no-source-grep` is registered by `eslint.config.mjs` on several
* glob blocks, not only `tests/** /*.cjs`: also `scripts/** /*.cjs`,
* `eslint-rules/** /*.cjs`, `bin/lib/** /*.cjs`,
* `examples/** /*.cjs`, `msd-core/bin/** /*.cjs`, `vscode/*.js`,
* and `.opencode/plugins/*.js`. A prior version of
* this script only walked `tests/** /*.test.cjs`, which silently dropped
* every non-`.test.cjs` file under `tests/` AND every file under every one
* of those other blocks from BOTH reported numbers — a marker there would
* vanish rather than trip a ceiling. `deriveNoSourceGrepGlobs` (below) reads
* the authoritative glob list directly out of `eslint.config.mjs` at run
* time (dynamic `import()`, since this is a `.cjs` script and the config is
* ESM) so the scan set cannot drift from what ESLint itself actually lints.
*
* ## Single source of truth for detection
*
* This script does NOT reimplement the `no-source-grep` rule's AST walk or
* its site-scoped adjacency predicate — doing so would be the
* generative-fix-divergence defect class this repo has shipped before.
* Instead it drives the REAL rule through ESLint's `Linter` API with its
* `neutralizeSuppression` diagnostic option (see the rule's `meta.schema`
* doc comment) to enumerate every candidate violation SITE in a file
* regardless of markers, then classifies each site by calling the rule's own
* exported `isSuppressedAt` predicate against that file's real markers (also
* obtained via the rule's exported `collectMarkerAndCommentLines`) — the
* exact function the rule itself calls at report time. See
* `eslint-rules/no-source-grep.cjs`.
*
* ## Known limits (do not read "effective" as "all exemptions")
*
* The effective-sites count is only as good as `no-source-grep`'s own
* detection. Real source-text-search violations behind identifier
* indirection (a path bound to a separate `const` the rule never resolves
* back to its literal), dynamic paths, non-`.js`-family extensions (`.sh`),
* and array/object round-trips are documented, accepted blind spots of the
* rule (see its own "Known limits" in 40-design.md) and remain invisible to
* BOTH numbers here. This is stated explicitly in this script's own `ok`
* output so the effective count can never be read as "all exemptions are
* accounted for."
*
* The two identifier-indirection sites this paragraph used to name --
* tests/security-prompt-injection.security.test.cjs and
* tests/check-update-config-dir.test.cjs -- were rewritten behaviorally in
* #3523 and no longer read source at all, so there is currently no KNOWN
* site sitting in the blind spot. That is emphatically not the same as the
* blind spot being closed: the rule still cannot see a read behind a path
* identifier, so a new one can be added tomorrow and neither number here
* would move. Closing it is a separate, measured phase of epic #3464.
*
* ## Linter scope: marker-bearing files only (#3464 perf follow-up)
*
* The byte-based `walkGlobs` walk above still reads EVERY file in every
* `local/no-source-grep`-registered glob (~1200 files) — that walk is cheap
* (raw `fs.readFileSync`, no parsing) and stays repo-wide so no marker can
* hide from the inventory. But driving the real ESLint `Linter` over all
* ~1200 files to classify effective/live sites was the actual cost (~12s+
* under CI load, enough to blow the test harness's spawn timeout). Only
* files containing the literal `allow-test-rule:` substring can possibly
* produce a genuine directive (`extractGenuineMarkerReasons` requires that
* substring to be present) or an "effective"/"unverified" classification —
* so `main` filters to marker-bearing files (a plain `String#includes` scan
* over content already in memory from the walk, deliberately NOT a shell
* `grep`: `tests/security-prompt-injection.security.test.cjs` contains a
* literal NUL byte that a shell grep pipeline handles differently than
* Node's own string/buffer handling does) BEFORE calling `classifySites`,
* narrowing the Linter pass from ~1200 files to the ~294 that actually carry
* a marker.
*
* This means the "live violations" figure this script reports now only
* covers marker-bearing files. A live (unsuppressed) violation in a file
* with NO marker at all is still caught — just not by this script: it is
* exactly what `local/no-source-grep` itself flags when `npm run lint` /
* `npm run lint:ci` runs across the same globs, and that run fails the build
* independently. This script re-checking unmarked files for live violations
* would be pure duplicate work with no distinct consequence (the marker
* inventory and the citation/ratchet checks — the reason this script exists
* — only ever concern marker-bearing files in the first place). If a live
* violation IS found in a marker-bearing file, it still fails this gate
* exactly as before this change.
*
* See docs/adr/456-test-rigor-architecture.md for the full policy.
*/
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');
const { Linter } = require('eslint');
const { assertWithinAllowlist, assertTightCeiling } = require('./lib/allowlist-ratchet.cjs');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const noSourceGrepRule = require('../eslint-rules/no-source-grep.cjs');
const ROOT = path.join(__dirname, '..');
const ESLINT_CONFIG_PATH = path.join(ROOT, 'eslint.config.mjs');
const TESTS_DIR = process.env.MSD_LINT_ALLOW_TEST_RULE_TESTS_DIR || path.join(ROOT, 'tests');
// Overrides the ROOT that non-`tests/` glob base directories (scripts/,
// eslint-rules/, bin/lib/, examples/, msd-core/bin/, vscode/,
// .opencode/plugins/ — see deriveNoSourceGrepGlobs) are
// resolved under. TESTS_DIR already has its own override (above) for the
// `tests/**/*.cjs` block; this is the equivalent knob for every OTHER block
// that registers `local/no-source-grep`, so sandbox fixtures can exercise
// the widened scan scope (#3464 phase 5 BLOCKER fix) without the test suite
// re-scanning this repo's real scripts/eslint-rules/etc. trees on every run.
const EXTRA_ROOT = process.env.MSD_LINT_ALLOW_TEST_RULE_EXTRA_ROOT || ROOT;
const ALLOWLIST_PATH =
process.env.MSD_LINT_ALLOW_TEST_RULE_ALLOWLIST ||
path.join(__dirname, 'lint-allow-test-rule-refs.allowlist.json');
const EFFECTIVE_CEILING_PATH =
process.env.MSD_LINT_ALLOW_TEST_RULE_EFFECTIVE_CEILING ||
path.join(__dirname, 'lint-allow-test-rule-refs.effective-ceiling.json');
const UNVERIFIED_CEILING_PATH =
process.env.MSD_LINT_ALLOW_TEST_RULE_UNVERIFIED_CEILING ||
path.join(__dirname, 'lint-allow-test-rule-refs.unverified-ceiling.json');
/** Matches a compliant issue reference or URL */
const ISSUE_REF_RE = /#\d+|https?:\/\//;
/**
* Extract the "genuine directive" reason text(s) from a file's real AST
* comments (never string/template literals, never source code — only actual
* `//` or `/* * /` comment tokens, obtained the same way the rule itself does
* via `sourceCode.getAllComments()`).
*
* A prior version of this scanned raw file TEXT line-by-line for the
* substring `allow-test-rule:` anywhere on a line. That was safe while the
* scan was confined to `tests/*.test.cjs` (real test files essentially never
* discuss the marker syntax in prose), but widening the scan to
* `scripts/** /*.cjs` and `eslint-rules/** /*.cjs` (#3464 phase 5 BLOCKER fix)
* means it now also walks the RULE's own implementation and THIS script —
* files that must document `// allow-test-rule: <reason> (#NNN)` as example
* syntax in prose, JSDoc, and the rule's own message string. A raw
* substring scan cannot tell "a real directive" apart from "a sentence that
* mentions the directive" or "a string literal that quotes it" — so a
* directly-widened raw scan produced dozens of false "uncited exemption"
* failures on this repo's own documentation.
*
* Fixed here by requiring a match to be BOTH (a) inside a real comment token
* (excludes string/template literals and code entirely — mirrors row 11's
* "marker text inside a string literal is not a directive" contract) AND
* (b) the FIRST non-whitespace content of that comment's line, once an
* optional JSDoc continuation-line `*` prefix is stripped (excludes prose
* that merely MENTIONS `allow-test-rule:` mid-sentence, e.g. "Matches a
* `allow-test-rule: <reason>` directive..."). This is deliberately STRICTER
* than the rule's own `MARKER_COMMENT_RE` (which matches the substring
* anywhere in a comment's value, by design, so a marker's suppression reach
* cannot be defeated by trailing prose on the same comment) — citation
* tracking asks a narrower question ("is this meant as a real directive
* that needs a tracking ref") than suppression does, so the two are allowed
* to diverge.
*
* @param {{value: string, loc: {start: {line: number}}}[]} allComments
* @returns {string[]} raw reason text for every genuine directive line found
*/
function extractGenuineMarkerReasons(allComments) {
const reasons = [];
for (const comment of allComments) {
for (const rawLine of comment.value.split('\n')) {
const trimmed = rawLine.replace(/^\s*\*\s?/, '').trim();
if (!/^allow-test-rule:/.test(trimmed)) continue;
const reason = trimmed.slice('allow-test-rule:'.length).trim();
if (reason) reasons.push(reason);
}
}
return reasons;
}
/**
* Derive the authoritative set of glob patterns that `eslint.config.mjs`
* actually registers `local/no-source-grep` on (#3464 phase 5 BLOCKER fix).
*
* Historically this script hardcoded `tests/** /*.test.cjs` as "the" scan
* set, which silently missed every non-`.test.cjs` file under `tests/`
* (`tests/helpers/**`, `tests/qa/**`, `tests/fixtures/**`, ...) AND every one
* of the other config blocks the rule is registered on (`scripts/** /*.cjs`,
* `eslint-rules/** /*.cjs`, `bin/lib/** /*.cjs`,
* `examples/** /*.cjs`, `msd-core/bin/** /*.cjs`, `vscode/*.js`,
* `.opencode/plugins/*.js`). A marker in any of those
* would vanish from BOTH reported numbers instead of tripping anything.
*
* Rather than hand-maintain a second copy of that glob list (the exact
* generative-fix-divergence class this script's own doc comment above
* already warns about for the rule's AST walk), this dynamically imports
* the REAL flat config and reads which config objects set
* `rules['local/no-source-grep']` to something other than `'off'`/`0`,
* collecting their `files` globs. If `eslint.config.mjs` ever adds, removes,
* or narrows a block that registers this rule, this list moves with it
* automatically — there is no second list to fall out of sync.
*
* @returns {Promise<string[]>} sorted, deduped glob patterns
*/
async function deriveNoSourceGrepGlobs() {
const mod = await import(pathToFileURL(ESLINT_CONFIG_PATH).href);
const config = mod.default;
if (!Array.isArray(config)) {
throw new Error(
`lint-allow-test-rule-refs: expected eslint.config.mjs's default export to be an array, got ${typeof config}`
);
}
const globs = new Set();
for (const entry of config) {
if (!entry || typeof entry !== 'object' || !entry.rules || !Array.isArray(entry.files)) continue;
if (!Object.prototype.hasOwnProperty.call(entry.rules, 'local/no-source-grep')) continue;
const ruleValue = entry.rules['local/no-source-grep'];
const level = Array.isArray(ruleValue) ? ruleValue[0] : ruleValue;
if (level === 'off' || level === 0) continue;
for (const glob of entry.files) globs.add(glob);
}
if (globs.size === 0) {
throw new Error(
'lint-allow-test-rule-refs: derived zero glob patterns for local/no-source-grep from eslint.config.mjs ' +
'— this would silently scan nothing; the config shape likely changed and this script needs updating.'
);
}
return [...globs].sort();
}
/**
* The literal (non-wildcard) leading path segments of a glob — the
* directory that must actually be walked on disk to find candidate files.
* e.g. `tests/** /*.cjs` -> `tests`, `.opencode/plugins/*.js` -> `.opencode/plugins`.
*
* The LAST segment is always excluded from consideration even when it has
* no wildcard of its own (e.g. the single-file fixture globs
* `tests/_ff_lint_violation.cjs` / `tests/_ff_lint_clean.cjs`, #1279/#2126):
* it is the filename pattern to MATCH against directory entries, never a
* directory to descend INTO. Treating a fully-literal glob's whole path as
* a "directory" would try to `readdirSync` a plain file and silently find
* nothing (caught by `walkGlobs`'s existing not-a-directory guard) — quietly
* dropping that glob from the scan entirely, the same silent-omission defect
* class this fix exists to eliminate.
*
* @param {string} glob
* @returns {string}
*/
function globBaseDir(glob) {
const segments = glob.split('/');
const literalSegments = [];
for (let i = 0; i < segments.length - 1; i++) {
if (/[*?[\]{}]/.test(segments[i])) break;
literalSegments.push(segments[i]);
}
return literalSegments.join('/');
}
/**
* Compile a glob pattern (only `*` and `**` wildcards appear in this repo's
* config — no `?`, `{}`, or `[]` are used on any `local/no-source-grep`
* block) into an anchored RegExp matched against a POSIX-slash repo-relative
* path, mirroring minimatch/eslint's own glob semantics for those two
* operators: `** /` matches zero or more path segments, a bare `*` matches
* within a single segment only.
*
* @param {string} glob
* @returns {RegExp}
*/
function globToRegExp(glob) {
let re = '';
for (let i = 0; i < glob.length; i++) {
const c = glob[i];
if (c === '*' && glob[i + 1] === '*') {
i++; // consume second '*'
if (glob[i + 1] === '/') {
re += '(?:.*/)?';
i++; // consume the following '/'
} else {
re += '.*';
}
} else if (c === '*') {
re += '[^/]*';
} else if ('.+^${}()|[]\\'.includes(c)) {
re += `\\${c}`;
} else {
re += c;
}
}
return new RegExp(`^${re}$`);
}
/**
* Recursively read every file matching ANY of `globs`, once, deduped by
* absolute path.
*
* The citation check, the total marker-file inventory, and the
* effective-site classification all need the same file set and content —
* walking twice would be the generative-fix-divergence class of bug (two
* scans that can silently drift apart), so every classifier below consumes
* this single walk's output.
*
* Each glob's literal base directory (`globBaseDir`) is resolved to an
* actual directory on disk: the `tests` base uses `testsDir` (the existing
* `MSD_LINT_ALLOW_TEST_RULE_TESTS_DIR` override contract, unchanged), every
* other base is resolved under `extraRoot` (defaults to `ROOT`, overridable
* via `MSD_LINT_ALLOW_TEST_RULE_EXTRA_ROOT` so sandbox fixtures can exercise
* the widened scan scope in isolation). Matching itself is always done
* against the glob's own repo-relative path string (e.g. `tests/foo.cjs`,
* `scripts/bar.cjs`), never against the physical scan location, so a
* sandboxed base directory is matched exactly as if it WERE that
* repo-relative path.
*
* @param {string[]} globs
* @param {{testsDir: string, extraRoot: string, root: string}} opts
* @returns {{relpath: string, full: string, content: string}[]}
*/
function walkGlobs(globs, { testsDir, extraRoot, root }) {
const compiled = globs.map((glob) => ({
baseDir: globBaseDir(glob),
regex: globToRegExp(glob),
}));
const seen = new Map(); // full path -> {relpath, full, content}
for (const { baseDir, regex } of compiled) {
const scanRoot = baseDir === 'tests' ? testsDir : path.join(extraRoot, baseDir);
const scan = (current, relParts) => {
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
// Base directory does not exist (e.g. an isolated sandbox EXTRA_ROOT
// that only sets up the fixture-relevant subtree) — nothing to scan.
return;
}
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
scan(full, [...relParts, entry.name]);
continue;
}
if (!entry.isFile()) continue;
const matchPath = [baseDir, ...relParts, entry.name].join('/');
if (!regex.test(matchPath)) continue;
if (seen.has(full)) continue;
let content;
try {
content = fs.readFileSync(full, 'utf8');
} catch {
// skip unreadable files (e.g. binary)
continue;
}
const relpath = path.relative(root, full).split(path.sep).join('/');
seen.set(full, { relpath, full, content });
}
};
scan(scanRoot, []);
}
return [...seen.values()];
}
/**
* Collect offender identifiers (files with an UNCITED allow-test-rule marker)
* from a per-file map of genuine directive reasons (see
* `extractGenuineMarkerReasons`).
*
* @param {Map<string, string[]>} markerReasonsByFile relpath -> reasons
* @returns {string[]} sorted, deduped list of `<relpath> :: <reason>` strings
*/
function collectUncitedOffenders(markerReasonsByFile) {
const offenders = new Set();
for (const [relpath, reasons] of markerReasonsByFile) {
for (const reason of reasons) {
if (ISSUE_REF_RE.test(reason)) continue; // compliant — skip
offenders.add(`${relpath} :: ${reason}`);
}
}
return [...offenders].sort();
}
/**
* Files carrying at least one genuine allow-test-rule directive, cited or
* not — the universe that effective/unverified partitions.
*
* @param {Map<string, string[]>} markerReasonsByFile relpath -> reasons
* @returns {string[]} sorted, deduped list of relpaths
*/
function collectExemptionFiles(markerReasonsByFile) {
const marked = [];
for (const [relpath, reasons] of markerReasonsByFile) {
if (reasons.length > 0) marked.push(relpath);
}
return marked.sort();
}
/**
* ESLint eslintrc-format config fragment that runs ONLY `no-source-grep`,
* with suppression neutralized so every candidate site is reported
* regardless of a marker being present. Shared by every file classified
* below.
*
* Deliberately eslintrc-shaped (`parserOptions`/`env`), not flat-config
* (`languageOptions`/`plugins`+`files`) (#3464 phase 5 follow-up). Flat
* config's `Linter#verify` still validates that the filename resolves to an
* ancestor of the Linter's `cwd` even for a config with no `files` glob
* (i.e. one meant to apply universally) — any filename that only resolves
* via a leading `..` (exactly what `walkGlobs`' `path.relative(ROOT, full)`
* produces for a sandbox fixture outside ROOT, e.g. in CI's `/tmp`) trips
* flat config's "No matching configuration found" error, indistinguishable
* from this script's own defensive throw for a real misconfiguration
* (`classifySiteLines`, `configProblem` below). eslintrc-format
* `Linter#verify` performs no such cwd/ancestor check at all — the config
* passed here fully determines what runs, `filename` is used only for
* per-file diagnostics/overrides, never resolved against `cwd` — so
* classification behaves identically for repo files and out-of-tree sandbox
* fixtures. `sourceType`/`ecmaVersion`/node globals mirror the
* `languageOptions` on every `eslint.config.mjs` block that registers
* `local/no-source-grep` (see module doc comment above); verified to
* produce byte-identical `Linter#verify` messages against flat config for
* real repo files before this change shipped.
*/
const NEUTRALIZED_CONFIG = {
parserOptions: { ecmaVersion: 2022, sourceType: 'commonjs' },
env: { node: true },
rules: { 'local/no-source-grep': ['error', { neutralizeSuppression: true }] },
};
/**
* Build an eslintrc-format `Linter` with `local/no-source-grep` registered
* directly via `defineRule` (no plugin-name resolution, no flat-config
* `files` matching) — see `NEUTRALIZED_CONFIG` doc comment for why this
* sidesteps the cwd/ancestor constraint entirely.
*
* @returns {import('eslint').Linter}
*/
function makeLinter() {
const linter = new Linter({ configType: 'eslintrc' });
linter.defineRule('local/no-source-grep', noSourceGrepRule);
return linter;
}
/**
* For one file, drive the REAL `no-source-grep` rule via ESLint's `Linter`
* API (suppression neutralized) to enumerate every candidate violation site,
* then classify each site with the rule's own exported `isSuppressedAt`
* predicate against that file's real markers (obtained via the rule's own
* exported `collectMarkerAndCommentLines`, fed by the SAME parse the
* enumeration pass just produced via `linter.getSourceCode()`).
*
* This does not hand-roll a second AST walk or a second adjacency
* arithmetic: both the enumeration (the rule's Program:exit walk) and the
* classification (`isSuppressedAt`) are the rule's own code, imported and
* invoked directly. Returns per-site LINE NUMBERS (not just counts) so a
* consumer (notably the parity test in
* tests/lint-allow-test-rule-refs.test.cjs) can compare this classification
* site-by-site against the real rule's independent report/no-report outcome.
*
* Also returns `markerReasons` (see `extractGenuineMarkerReasons`) computed
* from the SAME parse, so the citation check and exemption-file inventory
* (`main`, via `classifySites`) never re-parse a file a second time just to
* find its markers.
*
* @param {import('eslint').Linter} linter
* @param {{relpath: string, content: string}} file
* @returns {{effectiveLines: number[], liveLines: number[], markerReasons: string[]}}
*/
function classifySiteLines(linter, file) {
let messages;
try {
messages = linter.verify(file.content, NEUTRALIZED_CONFIG, file.relpath);
} catch (err) {
// An unparseable file must NOT be silently classified as "zero sites"
// (indistinguishable from a genuinely clean file) — that is exactly the
// false-clean failure mode this counter exists to eliminate, and it
// would be inconsistent with the sibling "No matching configuration
// found" case just below, which correctly throws. Fail loudly with the
// file path and the underlying error instead. If a specific file
// genuinely cannot be parsed for a known, accepted reason, exempt it
// explicitly by name with a comment here — never blanket-swallow.
throw new Error(
`lint-allow-test-rule-refs: failed to parse/verify ${file.relpath}: ` +
`${err && err.message ? err.message : err}`
);
}
// A parse error does NOT throw a JS exception from linter.verify() -- ESLint
// catches it internally and reports it as a `fatal: true` message instead,
// with `sourceCode` left unset (getSourceCode() below would return null).
// This is the same silent-clean failure mode the catch block above guards
// against, just reached via a different ESLint API shape: without this
// check a genuinely unparseable file would either crash on the next line
// (`sourceCode.getAllComments()` on null) or, before that line existed,
// silently fall through the `sites.length === 0` early return below and be
// reported as zero violations -- indistinguishable from a clean file. Fail
// loudly with the file path and message instead, matching the sibling
// "No matching configuration found" case just below.
const fatalProblem = messages.find((m) => m.fatal);
if (fatalProblem) {
throw new Error(
`lint-allow-test-rule-refs: failed to parse/verify ${file.relpath}: ` +
`${fatalProblem.message} (line ${fatalProblem.line}, column ${fatalProblem.column})`
);
}
// Defensive: "No matching configuration found for ..." was ESLint's
// flat-config-specific signal that it could not match ANY configuration to
// a filename (notably, when the Linter's cwd was not an ancestor of the
// file -- exactly what an out-of-tree sandbox fixture path hit, #3464
// phase 5 follow-up). `makeLinter`/`NEUTRALIZED_CONFIG` now use
// eslintrc-format `Linter#verify`, which performs no cwd/ancestor
// resolution at all, so this specific message should never fire in
// practice. Kept as a defensive throw (rather than deleted) in case a
// future ESLint version emits an equivalent `ruleId === null` signal for
// eslintrc-format verification too -- silently reporting zero rule
// messages would be indistinguishable from a genuinely clean file, exactly
// the kind of silently-wrong number this counter exists to avoid.
const configProblem = messages.find(
(m) => m.ruleId === null && /No matching configuration found/.test(m.message)
);
if (configProblem) {
throw new Error(
`lint-allow-test-rule-refs: ESLint could not lint ${file.relpath} (${configProblem.message}).`
);
}
const sourceCode = linter.getSourceCode();
const allComments = sourceCode.getAllComments();
const markerReasons = extractGenuineMarkerReasons(allComments);
const sites = messages.filter((m) => m.messageId === 'noSourceGrep');
if (sites.length === 0) return { effectiveLines: [], liveLines: [], markerReasons };
// The rule's own suppression check (isSuppressed, called from
// reportUnlessSuppressed) is an OR over TWO lines: the search call's own
// line, and the line of the readFileSync() call that originated the
// tracked value (a marker adjacent to EITHER half suppresses). In
// neutralizeSuppression mode the rule emits a paired
// `noSourceGrepDiagnosticReadLine` message at the SAME node carrying that
// read line, so this classification can replicate the identical two-line
// OR check the rule performs internally -- via the rule's OWN
// `isSuppressedAt` predicate, called exactly as `reportUnlessSuppressed`
// does, rather than only checking the search line (which would
// misclassify the documented "marker adjacent to the read, not the
// search" placement style as unsuppressed).
const readLineByPos = new Map();
for (const m of messages) {
if (m.messageId !== 'noSourceGrepDiagnosticReadLine') continue;
readLineByPos.set(`${m.line}:${m.column}`, m.message === '' ? null : Number(m.message));
}
const { markerLines, commentLineSet } = noSourceGrepRule.collectMarkerAndCommentLines(allComments);
const effectiveLines = [];
const liveLines = [];
for (const site of sites) {
const readLine = readLineByPos.get(`${site.line}:${site.column}`) ?? null;
const suppressedBySearchLine = noSourceGrepRule.isSuppressedAt({
markerLines,
violationLine: site.line,
commentLineSet,
lines: sourceCode.lines,
});
const suppressedByReadLine =
readLine !== null &&
noSourceGrepRule.isSuppressedAt({
markerLines,
violationLine: readLine,
commentLineSet,
lines: sourceCode.lines,
});
if (suppressedBySearchLine || suppressedByReadLine) effectiveLines.push(site.line);
else liveLines.push(site.line);
}
return { effectiveLines, liveLines, markerReasons };
}
/**
* Count-only view of classifySiteLines, used by the aggregate walk below.
*
* @param {import('eslint').Linter} linter
* @param {{relpath: string, content: string}} file
* @returns {{effectiveSites: number, liveSites: number, markerReasons: string[]}}
*/
function classifyFile(linter, file) {
const { effectiveLines, liveLines, markerReasons } = classifySiteLines(linter, file);
return { effectiveSites: effectiveLines.length, liveSites: liveLines.length, markerReasons };
}
/**
* Convenience wrapper for tests: classify a single in-memory code string
* without a caller having to construct a Linter instance or a file object.
*
* @param {string} code
* @param {string} [relpath] - filename passed to ESLint (affects nothing but
* diagnostics; the eslintrc-format `Linter` built by `makeLinter` never
* resolves it against a base path, so this may be any string, in-tree or
* not). Defaults to a generic test filename.
* @returns {{effectiveLines: number[], liveLines: number[]}}
*/
function classifyCode(code, relpath = 'tests/fixture.test.cjs') {
return classifySiteLines(makeLinter(), { relpath, content: code });
}
/**
* Classify every file's violation sites into effective (suppressed by a real
* marker) vs. live (unsuppressed — should be zero in a clean tree), and
* derive which files count as "effective" (>=1 effective site).
*
* @param {{relpath: string, content: string}[]} files
* Also returns `markerReasonsByFile` (relpath -> genuine directive reasons,
* see `extractGenuineMarkerReasons`), collected from the SAME per-file parse
* this function already performs, so `main` can derive the citation check
* and exemption-file inventory without a second file-content scan.
*
* @returns {{
* effectiveSiteCount: number,
* effectiveFiles: string[],
* liveSiteCount: number,
* liveFiles: string[],
* markerReasonsByFile: Map<string, string[]>,
* }}
*/
function classifySites(files) {
const linter = makeLinter();
let effectiveSiteCount = 0;
let liveSiteCount = 0;
const effectiveFiles = new Set();
const liveFiles = new Set();
const markerReasonsByFile = new Map();
for (const file of files) {
const { effectiveSites, liveSites, markerReasons } = classifyFile(linter, file);
if (effectiveSites > 0) {
effectiveSiteCount += effectiveSites;
effectiveFiles.add(file.relpath);
}
if (liveSites > 0) {
liveSiteCount += liveSites;
liveFiles.add(file.relpath);
}
if (markerReasons.length > 0) {
markerReasonsByFile.set(file.relpath, markerReasons);
}
}
return {
effectiveSiteCount,
effectiveFiles: [...effectiveFiles].sort(),
liveSiteCount,
liveFiles: [...liveFiles].sort(),
markerReasonsByFile,
};
}
/**
* @param {string[]} [argv] - CLI-style argv (excluding the node binary and
* script path). Defaults to `process.argv.slice(2)` ONLY when omitted
* (`undefined`) — the sole change from the prior hardcoded read, so the
* CLI entrypoint below (`runMain(main)`, which calls `main()` with no
* args) is unaffected. Passing an explicit array (including `[]`) lets a
* caller — notably `tests/lint-allow-test-rule-refs.test.cjs`'s
* "repo baseline passes" row, #4060 — drive this function directly,
* in-process, with no subprocess and thus no `spawnSync` `timeout` to
* race against CI-load contention.
*/
async function main(argv = process.argv.slice(2)) {
const args = argv;
const unknown = args.filter((a) => a !== '--help');
if (unknown.length > 0) {
throw new ExitError(2, `lint-allow-test-rule-refs: unknown argument(s): ${unknown.join(', ')}`);
}
const globs = await deriveNoSourceGrepGlobs();
const files = walkGlobs(globs, { testsDir: TESTS_DIR, extraRoot: EXTRA_ROOT, root: ROOT });
const known = JSON.parse(fs.readFileSync(ALLOWLIST_PATH, 'utf8'));
const effectiveCeiling = JSON.parse(fs.readFileSync(EFFECTIVE_CEILING_PATH, 'utf8'));
const unverifiedCeiling = JSON.parse(fs.readFileSync(UNVERIFIED_CEILING_PATH, 'utf8'));
// Narrow the expensive Linter pass to marker-bearing files only (see
// "Linter scope" doc comment above) — a plain in-memory substring scan
// over content the walk already read, never a shell grep (NUL-byte
// fixture, see comment). This is a strict superset filter: a file with no
// `allow-test-rule:` substring at all cannot produce any genuine directive
// (extractGenuineMarkerReasons requires the substring) or count toward
// effective/unverified, so nothing is lost by skipping it here.
const markerBearingFiles = files.filter((f) => f.content.includes('allow-test-rule:'));
// classifySites does one Linter parse per marker-bearing file and returns
// both the effective/live site classification AND (as a byproduct of that
// SAME parse) every file's genuine marker reasons — the citation check and
// exemption-file inventory below consume that instead of re-scanning file
// content a second time.
const { effectiveSiteCount, effectiveFiles, liveSiteCount, liveFiles, markerReasonsByFile } =
classifySites(markerBearingFiles);
const current = collectUncitedOffenders(markerReasonsByFile);
const exemptionFiles = collectExemptionFiles(markerReasonsByFile);
// Row 5 (#3520 test-matrix): a file with >=1 effective site counts ONLY as
// effective, never also as unverified — no double counting.
const effectiveFileSet = new Set(effectiveFiles);
const unverifiedFiles = exemptionFiles.filter((f) => !effectiveFileSet.has(f));
const failures = [];
const { novel } = assertWithinAllowlist({
label: 'allow-test-rule-refs',
current,
known,
fail: (msg) => failures.push(msg),
pruneHint: 'edit scripts/lint-allow-test-rule-refs.allowlist.json',
});
// Effective exemption SITES: tightly ratcheted, both directions — the
// metric this gate actually governs.
assertTightCeiling({
label: 'allow-test-rule-effective-sites',
actualMax: effectiveSiteCount,
ceiling: effectiveCeiling.maxSites,
grace: effectiveCeiling.grace,
fail: (msg) => failures.push(`${msg}\n(edit scripts/lint-allow-test-rule-refs.effective-ceiling.json)`),
});
// Unverified marker-bearing FILES: a loose upper bound only — growth past
// the ceiling fails, but a drop below it never does (test-matrix.md row 9;
// shrinking this pool is a rule-coverage improvement, not something to
// ratchet on deletion).
if (unverifiedFiles.length > unverifiedCeiling.maxFiles) {
failures.push(
`[allow-test-rule-unverified-markers] ${unverifiedFiles.length} unverified marker-bearing ` +
`file(s) exceeds ceiling ${unverifiedCeiling.maxFiles} (marker-bearing files with no ` +
`detectable violation grew — audit the new markers before raising the ceiling; this is ` +
`growth of the UNVERIFIED pool, distinct from the effective-sites ratchet above).\n` +
`(edit scripts/lint-allow-test-rule-refs.unverified-ceiling.json)`
);
}
// Live (unsuppressed) violations: this only covers MARKER-BEARING files
// (see "Linter scope" doc comment above) — a live violation in a file with
// NO marker at all is not re-checked here because `npx eslint .` /
// `npm run lint` / `npm run lint:ci` already fail the build on those via
// `local/no-source-grep` directly. Surfacing the marker-bearing subset
// here makes it visible in this gate's own output rather than only in a
// separate lint pass, without duplicating the repo-wide check.
if (liveSiteCount > 0) {
failures.push(
`[allow-test-rule-live-violations] ${liveSiteCount} unsuppressed no-source-grep ` +
`violation(s) found in ${liveFiles.length} marker-bearing file(s) (unmarked files are ` +
`already enforced separately by npm run lint / npm run lint:ci; this gate only re-checks ` +
`files that carry an allow-test-rule: marker) ` +
`— fix at the source or add a site-scoped // allow-test-rule: <reason> (#NNN):\n` +
liveFiles.map((f) => ` - ${f}`).join('\n')
);
}
if (failures.length > 0) {
for (const msg of failures) process.stderr.write(`${msg}\n`);
if (novel.length > 0) {
process.stderr.write(
'\nNew allow-test-rule exemption without an issue ref — add `see #NNN` per ADR-456' +
' (docs/adr/456-test-rigor-architecture.md).\n'
);
}
throw new ExitError(1);
}
console.log(
`ok lint-allow-test-rule-refs: ${current.length} grandfathered exemption(s) tracked, ` +
`no novel untracked offenders; ` +
`effective exemptions: ${effectiveSiteCount}/${effectiveCeiling.maxSites} site(s) ` +
`across ${effectiveFiles.length} file(s) (ratcheted); ` +
`unverified markers: ${unverifiedFiles.length}/${unverifiedCeiling.maxFiles} file(s) ` +
`(tracked, not ratcheted); ` +
`live violations in marker-bearing files: ${liveSiteCount} (unmarked files are enforced ` +
`separately by npm run lint / npm run lint:ci, not re-checked here). ` +
`Known limit: "effective" is bounded by no-source-grep's own detection — ` +
`identifier-indirection, dynamic paths, .sh files, and array/object round-trips are ` +
`real blind spots this count cannot see, so it is never "all exemptions."`
);
}
// Exported for tests/lint-allow-test-rule-refs.test.cjs (unit-level
// classification checks and the parity/teeth structural guard) — never
// re-implemented in the test file, imported directly like the CLI itself
// does. `runMain(main)` only fires when this file is executed as the CLI
// entrypoint (`node scripts/lint-allow-test-rule-refs.cjs` /
// `runNode([SCRIPT])`), not when it is `require()`d for its exports.
module.exports = {
deriveNoSourceGrepGlobs,
globBaseDir,
globToRegExp,
walkGlobs,
collectUncitedOffenders,
collectExemptionFiles,
classifySiteLines,
classifyFile,
classifySites,
classifyCode,
main,
};
if (require.main === module) {
runMain(main);
}