From 0c413bbc9c10c8a501c7c5eadd90a643418c593c Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 9 Aug 2026 19:35:34 -0400 Subject: [PATCH] chore(#3059): close the ESLint glob-coverage escape and guard it (#3277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(#3059): close the ESLint glob-coverage escape and guard it 62 tracked source files matched no `files:` glob in eslint.config.mjs, so ESLint skipped them entirely while `eslint .` still exited 0 — including all 26 files under hooks/, the enforcement machinery itself. Covers 56 of them (eslint-rules/, hooks/, bin/lib/, pi/, examples/, vscode/, the plugin shims, root *.mjs) and allowlists the 6 deliberate must-not-compile brand-typing fixtures with a recorded reason each. hooks/** is covered with n/no-process-exit deliberately off: a hook's whole contract is its exit code, several exits are load-bearing stdin-timeout guards where nothing else terminates the process, and ADR-0012/0174 scope the no-process-exit convention to the Command Routing Hub. bin/lib/ui-safety-gate.cjs is dual-mode, so it keeps the rule live and takes two targeted disables in its require.main===module tail instead. Adds scripts/lint-eslint-glob-coverage.cjs + a node:test drift guard so the class cannot regrow: allowlist entries require a non-empty reason, the list ratchets down only (a stale entry fails), and a tracked-count floor means a broken `git ls-files` fails rather than reporting clean. Closes #3059 * chore(#3059): apply review findings — correct the changeset count, add parser properties Isolated adversarial review, confirmed by rebuilding a byte-for-byte replica of the pre-change eslint.config.mjs: the changeset claimed 44 previously- unlinted files. The real figure is 56 (56 covered + 6 allowlisted = 62). That was user-facing CHANGELOG text and was wrong; corrected, along with three consequential figures in the design record. CLAUDE.md requires a fast-check property test for parsers and budget limits, and listTrackedSourceFiles is a parser. The standards review called this "satisfied in spirit"; it is not. Adds three properties driving the real exported parser through an injected execFile: extension totality/soundness including a trailing terminator, backslash-normalization totality, and CRLF/LF equivalence — the invariant the repo's recurring CRLF defect class breaks. Also de-duplicates the anchor rows onto one shared resolver, kept deliberately independent of the guard's own resolveFileCoverage so an anchor still fails if that resolution regresses, and records in the guard's header why the bin/install.js family is NOT allowlisted: it resolves to 2 rules under ADR-1703, so an entry would trip the allowlist_stale ratchet. * fix(#3059): make the coverage guard's git call container-safe The remote runner reported the guard degrading to `git_failed` on both Node lanes: fatal: detected dubious ownership in repository at '/work' The runner executes in a container where the repo is owned by a different UID, so git refuses to operate on it. The guard's degraded-verdict path worked exactly as designed — it reported the failure instead of throwing or falsely reporting clean — but a guard that cannot run in CI is not a gate. `git ls-files` is now invoked as `git -c safe.directory=* ls-files`. `-c` scopes the override to the single invocation and mutates no config file, and the wildcard is appropriate because this command only enumerates tracked paths in the repository it is already executing inside. Adds a regression test that captures the argv through the injected execFile seam and asserts `-c safe.directory=*` precedes `ls-files`, so the container case is pinned behaviorally rather than by reading the script's source. * chore(#3059): backfill changeset PR number pr:0 placeholder replaced with the real number now that #3277 exists. --------- Co-authored-by: sim --- .changeset/silly-ravens-hum.md | 5 + bin/lib/ui-safety-gate.cjs | 2 + docs/README.md | 1 + .../resolve-eslint-coverage-findings.md | 93 ++++ eslint-rules/no-crlf-fragile-split.cjs | 3 - eslint-rules/no-path-literal-in-assert.cjs | 2 +- .../require-userprofile-with-home.cjs | 2 - eslint.config.mjs | 59 ++- hooks/gsd-check-update-worker.js | 2 +- hooks/gsd-cursor-pre-tool.js | 3 - hooks/gsd-cursor-subagent-stop.js | 5 +- hooks/gsd-prompt-guard.js | 1 - .../lint-eslint-glob-coverage.allowlist.json | 26 + scripts/lint-eslint-glob-coverage.cjs | 340 +++++++++++++ tests/eslint-glob-coverage.test.cjs | 450 ++++++++++++++++++ 15 files changed, 980 insertions(+), 14 deletions(-) create mode 100644 .changeset/silly-ravens-hum.md create mode 100644 docs/how-to/resolve-eslint-coverage-findings.md create mode 100644 scripts/lint-eslint-glob-coverage.allowlist.json create mode 100644 scripts/lint-eslint-glob-coverage.cjs create mode 100644 tests/eslint-glob-coverage.test.cjs diff --git a/.changeset/silly-ravens-hum.md b/.changeset/silly-ravens-hum.md new file mode 100644 index 000000000..4fcca4daa --- /dev/null +++ b/.changeset/silly-ravens-hum.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3277 +--- +**ESLint now actually runs on 56 previously-unlinted source files** — a file matching no `files:` glob was not linted-and-clean, it was skipped entirely while `eslint .` still exited 0. All of `hooks/` and `eslint-rules/` sat in that blind spot. A new drift guard fails the build if any tracked source file resolves to zero rules without a recorded reason, so the class cannot silently regrow. (#3059) diff --git a/bin/lib/ui-safety-gate.cjs b/bin/lib/ui-safety-gate.cjs index 1620dd1de..112ec1b71 100644 --- a/bin/lib/ui-safety-gate.cjs +++ b/bin/lib/ui-safety-gate.cjs @@ -97,11 +97,13 @@ if (require.main === module) { process.stdin.on('end', () => { const input = chunks.join(''); const result = checkUiPresence(input); + // eslint-disable-next-line n/no-process-exit -- CLI entry point (require.main===module): async stdin handler, nothing else terminates the process (#3059) process.exit(result.hasUI ? 0 : 1); }); process.stdin.on('error', (err) => { process.stderr.write(`ERROR: ui-safety-gate.cjs stdin read failed: ${err.message}\n`); + // eslint-disable-next-line n/no-process-exit -- CLI entry point (require.main===module): async stdin handler, nothing else terminates the process (#3059) process.exit(2); }); } diff --git a/docs/README.md b/docs/README.md index e04611459..b7dfaba5c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md) - [Discuss a phase](how-to/discuss-a-phase.md) — capture implementation decisions before planning begins - [Resolve edge-coverage findings](how-to/resolve-edge-coverage-findings.md) — turn the spec phase's surfaced domain-boundary edges into covered, dismissed, or backstopped spec decisions - [Resolve prohibition findings](how-to/resolve-prohibition-findings.md) — turn the spec phase's surfaced must-NOT constraints into resolved, dismissed, or deferred spec decisions +- [Resolve an ESLint glob-coverage finding](how-to/resolve-eslint-coverage-findings.md) — bring a source file that matches no lint rule under coverage, or record a reasoned exemption - [Plan a phase](how-to/plan-a-phase.md) — run research, decompose work, and verify plan quality - [Execute a phase](how-to/execute-a-phase.md) — run plans in parallel waves with fresh-context subagents - [Verify and ship](how-to/verify-and-ship.md) — walk through completed work, diagnose failures, and create the PR diff --git a/docs/how-to/resolve-eslint-coverage-findings.md b/docs/how-to/resolve-eslint-coverage-findings.md new file mode 100644 index 000000000..bc57def35 --- /dev/null +++ b/docs/how-to/resolve-eslint-coverage-findings.md @@ -0,0 +1,93 @@ +# How to resolve an ESLint glob-coverage finding + +**Goal:** Turn a `lint-eslint-glob-coverage` failure into either real lint coverage or a recorded, reasoned exemption — so a source file can never again sit in the tree matching no rule at all, linted by nothing, while `eslint .` exits 0. + +**Prerequisites:** A failing `tests/eslint-glob-coverage.test.cjs`, or a non-zero `node scripts/lint-eslint-glob-coverage.cjs`. You do not invoke the guard separately in CI — it runs as part of the test suite. + +--- + +## Why this guard exists + +ESLint assigns rules by `files:` glob. A file matching **no** glob is not "linted and clean" — it is **not linted at all**, and ESLint reports nothing about it. There is no warning, no summary line, no exit code. The failure is perfectly silent. + +That is not hypothetical. Before this guard, 62 tracked source files resolved to zero rules, including all 26 files under `hooks/` — the enforcement machinery itself. One earlier escape hid a real Windows portability defect (a bare `npm` invocation without `{ shell: true }`) in a shared test helper: a rule the repo bans as an **error** in sibling files, surviving purely because the helper was unreachable by the glob. + +The guard checks rule **reachability**, not rule **severity**. A file covered entirely by `warn` rules passes here; that is a different gate (`#1885` F17, `--max-warnings 0`). + +--- + +## Read a finding + +Each violation names one path and one `kind`: + +| `kind` | What it means | +|---|---| +| `uncovered` | The file matches no `files:` glob. Nothing lints it. | +| `allowlist_missing_reason` | An allowlist entry has no `reason` key. | +| `allowlist_empty_reason` | An allowlist entry's `reason` is empty or whitespace. | +| `allowlist_stale` | An allowlisted file **now resolves to rules** — the exemption is obsolete. | +| `allowlist_missing_path` | An allowlist entry names a path that is no longer tracked. | +| `allowlist_duplicate` | The same path is listed twice (usually a merge artifact). | +| `tracked_count_below_floor` | Fewer than 500 tracked source files were found — the guard refuses to report "clean" from an obviously broken file list. | +| `git_failed` | `git ls-files` failed or timed out. The guard degrades to a failure rather than a vacuous pass. | + +An `uncovered` finding has exactly two legitimate resolutions. Pick the first one unless you can write down a permanent reason for the second. + +--- + +## Cover it — bring the file under a rule block + +**Choose this whenever the file is first-party source that should be linted.** This is the correct resolution in almost every case, and it is what closed 56 of the original 62. + +Open `eslint.config.mjs` and add the path to the `files:` array of the block whose ruleset fits: + +- CommonJS Node code (`scripts/`, `eslint-rules/`, `bin/lib/`, `pi/`, `examples/`, `vscode/`) → the **CommonJS Node** block. +- Test code → the `tests/**/*.cjs` block. +- TypeScript sources → the `src/**/*.cts` block. + +Then run `npx eslint .` and **fix whatever it surfaces**. Do not downgrade a rule's severity to make a new file pass — that converts a real finding into a silent one, which is the failure this guard exists to end. + +If the file needs a genuinely different ruleset, add a new block with a comment saying why. `hooks/**` is the worked example: it is covered like other CommonJS Node code, but with `n/no-process-exit` deliberately `off`, because a hook's entire contract is its exit code and several of its exits are load-bearing (a `setTimeout` stdin guard where nothing else would terminate the process). That comment names the evidence, so the next reader does not have to re-derive it. + +--- + +## Allowlist it — record a permanent reason + +**Choose this only when the file must never be linted**, and say why. Add an entry to `scripts/lint-eslint-glob-coverage.allowlist.json`: + +```json +{ + "path": "tests/fixtures/brand-typing/bad-calibrated-as-sample-basis.cts", + "reason": "Deliberate MUST-NOT-COMPILE type-error fixture (#3059): type-aware linting would fail by design; it exists to prove the compiler rejects it." +} +``` + +The `reason` is **structurally required** — an entry with a missing, empty, or whitespace-only reason fails the guard. That is deliberate. The easy way to make a coverage metric look good is to add an exemption, so an exemption has to cost you a sentence you are willing to sign. + +⚠️ **"Not linted yet" is not a reason.** An allowlist entry meaning *we will get to it* is a TODO wearing an exemption's badge, and it will outlive everyone who remembers it. If the file should eventually be linted, lint it now. + +A file that is **explicitly ignored** — the `gsd-core/bin/lib/**` tsc artifacts under ADR-457 — needs no allowlist entry. Ignored is a recorded decision; unmatched is an accident. The guard already tells them apart. + +Note that `bin/install.js` also needs no entry: it resolves to two rules under ADR-1703's deliberately minimal coverage, which is non-empty and therefore passes. + +--- + +## Delete a stale entry + +`allowlist_stale` means an allowlisted file now resolves to rules — someone widened a glob and the exemption became dead weight. **Delete the entry.** The allowlist only ratchets down; it can never quietly accumulate. + +--- + +## Verify + +```bash +node scripts/lint-eslint-glob-coverage.cjs +``` + +A pass prints the count it actually checked: + +``` +ok lint-eslint-glob-coverage: 1175 tracked source file(s), 0 escapes +``` + +Read that number. If it is implausibly small, the guard's floor should have caught it — but a count that merely *shrank* is worth a second look before you trust the "0 escapes". diff --git a/eslint-rules/no-crlf-fragile-split.cjs b/eslint-rules/no-crlf-fragile-split.cjs index a55a07eb8..30ba887c7 100644 --- a/eslint-rules/no-crlf-fragile-split.cjs +++ b/eslint-rules/no-crlf-fragile-split.cjs @@ -238,9 +238,6 @@ const rule = { // Check if preceded by \r? or \r (look back in the raw pattern string) // "preceded by" means the two chars before the current \\ are \r or \r? const before2 = pattern.slice(Math.max(0, i - 2), i); // up to 2 chars before \\ - const safeByPrefix = - before2.endsWith('\\r?') || // \r?\n (but \r? is 3 chars, before is 2 — need to check before3) - before2.endsWith('\\r'); // \r\n // Re-check with a wider window for \r?\n (pattern chars: \r?\n = 5 chars) const before3 = pattern.slice(Math.max(0, i - 3), i); diff --git a/eslint-rules/no-path-literal-in-assert.cjs b/eslint-rules/no-path-literal-in-assert.cjs index 7b6a8ef4a..66e22e759 100644 --- a/eslint-rules/no-path-literal-in-assert.cjs +++ b/eslint-rules/no-path-literal-in-assert.cjs @@ -118,7 +118,7 @@ const rule = { // Suppressed if the receiver is already a valid POSIX normalizer. if (isPosixNormalizerCall(receiverNode)) return false; - let probe = unwrapString(receiverNode); + const probe = unwrapString(receiverNode); if (isPathReturningCall(probe)) return true; // Peel one non-normalizer method chain (.replace / .replaceAll / .split().join()). diff --git a/eslint-rules/require-userprofile-with-home.cjs b/eslint-rules/require-userprofile-with-home.cjs index 0852daaf2..ea5ba57e4 100644 --- a/eslint-rules/require-userprofile-with-home.cjs +++ b/eslint-rules/require-userprofile-with-home.cjs @@ -40,8 +40,6 @@ const rule = { }, create(context) { - const sourceCode = context.sourceCode ?? context.getSourceCode(); - /** Collected HOME assignment nodes */ const homeAssignments = []; diff --git a/eslint.config.mjs b/eslint.config.mjs index 232e6f3cd..eb63f1827 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -323,8 +323,22 @@ export default tseslint.config( // ── gsd-core/bin/**/*.cjs + scripts/**/*.cjs ─────────────────────────── // CommonJS Node files: js.recommended + eslint-plugin-n + local plugin rules + // eslint-rules/**, bin/lib/**, pi/**, examples/**, vscode/*.js, .kilo/plugins/*.js, + // and .opencode/plugins/*.js were previously unmatched by every glob in this config + // (drift guard scripts/lint-eslint-glob-coverage.cjs, #3059). All are CommonJS + // (require/module.exports); folded into this block rather than duplicated. { - files: ['gsd-core/bin/**/*.cjs', 'scripts/**/*.cjs'], + files: [ + 'gsd-core/bin/**/*.cjs', + 'scripts/**/*.cjs', + 'eslint-rules/**/*.cjs', + 'bin/lib/**/*.cjs', + 'pi/**/*.cjs', + 'examples/**/*.cjs', + 'vscode/*.js', + '.kilo/plugins/*.js', + '.opencode/plugins/*.js', + ], plugins: { n: pluginN, local: localPlugin, @@ -357,6 +371,49 @@ export default tseslint.config( }, }, + // ── hooks/**/*.js — enforcement hooks (#3059) ────────────────────────────── + { + files: ['hooks/**/*.js', 'hooks/**/*.cjs'], + plugins: { n: pluginN, local: localPlugin }, + languageOptions: { sourceType: 'commonjs', globals: { ...globals.node } }, + rules: { + ...js.configs.recommended.rules, + 'no-var': 'error', + 'prefer-const': 'warn', + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' }], + 'no-empty': ['warn', { allowEmptyCatch: true }], + 'no-useless-escape': 'warn', + 'n/no-path-concat': 'error', + // n/no-process-exit is deliberately OFF for hooks ONLY. + // + // A hook is a standalone process whose ENTIRE contract is its exit code: the + // harness reads exit 2 as "deny". `process.exitCode = N; return;` is not + // equivalent — it lets execution continue past the denial, and several exits + // here are load-bearing in a way that makes that a behavior change, not a + // refactor: + // - stdin-timeout guards (e.g. hooks/gsd-read-guard.js, gsd-cursor-subagent-stop.js) + // fire from a setTimeout where NOTHING else terminates the process if stdin + // never closes; + // - hooks/gsd-worktree-path-guard.js exits from a nested `if` whose fallthrough + // would otherwise reach a different unconditional exit; + // - hooks/gsd-write-guard.js:159-175 documents that pipe writes are async on + // Windows, so it deliberately does fs.writeSync(1/2, ...) BEFORE process.exit(2) + // to avoid truncation. + // ADR-0012 and ADR-0174 scope the "never calls process.exit" convention to the + // Command Routing Hub (src/command-routing-hub.cts), not to hooks. Rewriting 89 + // call sites in enforcement hooks to satisfy a rule aimed at libraries would trade + // a real behavior risk for a cosmetic win. See .gsd/phase/chore-3059-eslint-glob-coverage-guard/40-design.md. + 'n/no-process-exit': 'off', + }, + }, + + // ── root *.mjs config files (#3059) ──────────────────────────────────────── + { + files: ['*.mjs'], + languageOptions: { sourceType: 'module', globals: { ...globals.node } }, + rules: { ...js.configs.recommended.rules }, + }, + // ── tests/**/*.test.cjs ───────────────────────────────────────────────────── { files: ['tests/**/*.cjs'], diff --git a/hooks/gsd-check-update-worker.js b/hooks/gsd-check-update-worker.js index 6fff93474..41e2bb022 100644 --- a/hooks/gsd-check-update-worker.js +++ b/hooks/gsd-check-update-worker.js @@ -63,7 +63,7 @@ try { // (e.g., gsd-intel-*.js) must be ignored to avoid permanent stale warnings (#1750) // MANAGED_HOOKS is imported from ./managed-hooks-registry.cjs above. -let staleHooks = []; +const staleHooks = []; if (configDir) { // #3023: the bundle's directory name is runtime-descriptor-driven (pi stages // it as `gsd-hooks/`), so deriving it as `/hooks` silently scanned diff --git a/hooks/gsd-cursor-pre-tool.js b/hooks/gsd-cursor-pre-tool.js index a608255c0..d0d96e4c2 100644 --- a/hooks/gsd-cursor-pre-tool.js +++ b/hooks/gsd-cursor-pre-tool.js @@ -22,9 +22,6 @@ 'use strict'; -const fs = require('fs'); -const path = require('path'); - const WRITE_TOOL_RE = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/i; const PATH_KEY_RE = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i; const PLANNING_PATH_RE = /(^|[\\/])\.planning([\\/]|$)/; diff --git a/hooks/gsd-cursor-subagent-stop.js b/hooks/gsd-cursor-subagent-stop.js index fa5bb6826..48a3eecb7 100644 --- a/hooks/gsd-cursor-subagent-stop.js +++ b/hooks/gsd-cursor-subagent-stop.js @@ -20,13 +20,14 @@ 'use strict'; -let raw = ''; const stdinTimeout = setTimeout(() => { process.exit(0); }, 10000); process.stdin.setEncoding('utf8'); -process.stdin.on('data', (chunk) => { raw += chunk; }); +// Drain stdin (puts the stream into flowing mode so 'end' fires); the +// payload itself is unused — this hook responds unconditionally. +process.stdin.on('data', () => {}); process.stdin.on('end', () => { clearTimeout(stdinTimeout); try { diff --git a/hooks/gsd-prompt-guard.js b/hooks/gsd-prompt-guard.js index 7116e8f1f..02d0c5fc1 100644 --- a/hooks/gsd-prompt-guard.js +++ b/hooks/gsd-prompt-guard.js @@ -11,7 +11,6 @@ // The goal is to surface suspicious content so the orchestrator can inspect it, // not to create false-positive deadlocks. -const fs = require('fs'); const path = require('path'); // Prompt injection patterns (subset of security.cjs patterns, inlined for hook independence) diff --git a/scripts/lint-eslint-glob-coverage.allowlist.json b/scripts/lint-eslint-glob-coverage.allowlist.json new file mode 100644 index 000000000..eb6d24360 --- /dev/null +++ b/scripts/lint-eslint-glob-coverage.allowlist.json @@ -0,0 +1,26 @@ +[ + { + "path": "tests/fixtures/brand-typing/bad-calibrated-as-sample-basis.cts", + "reason": "Deliberate MUST-NOT-COMPILE type-error fixture (#3059): type-aware linting would fail by design; it exists to prove the compiler rejects it." + }, + { + "path": "tests/fixtures/brand-typing/bad-double-calibration.cts", + "reason": "Deliberate MUST-NOT-COMPILE type-error fixture (#3059): type-aware linting would fail by design; it exists to prove the compiler rejects it." + }, + { + "path": "tests/fixtures/brand-typing/bad-raw-against-budget.cts", + "reason": "Deliberate MUST-NOT-COMPILE type-error fixture (#3059): type-aware linting would fail by design; it exists to prove the compiler rejects it." + }, + { + "path": "tests/fixtures/brand-typing/bad-rebrand-calibrated-as-raw.cts", + "reason": "Deliberate MUST-NOT-COMPILE type-error fixture (#3059): type-aware linting would fail by design; it exists to prove the compiler rejects it." + }, + { + "path": "tests/fixtures/brand-typing/bad-unbranded-number-as-raw.cts", + "reason": "Deliberate MUST-NOT-COMPILE type-error fixture (#3059): type-aware linting would fail by design; it exists to prove the compiler rejects it." + }, + { + "path": "tests/fixtures/brand-typing/ok-correct-composition.cts", + "reason": "Positive-control counterpart to the bad-* must-not-compile fixtures (#3059); kept in the same exemption so the pair stays together." + } +] diff --git a/scripts/lint-eslint-glob-coverage.cjs b/scripts/lint-eslint-glob-coverage.cjs new file mode 100644 index 000000000..2ae672bb0 --- /dev/null +++ b/scripts/lint-eslint-glob-coverage.cjs @@ -0,0 +1,340 @@ +#!/usr/bin/env node +'use strict'; + +/** + * lint-eslint-glob-coverage.cjs — ESLint `files:` glob coverage drift guard (#3059). + * + * ## What this enforces + * + * `eslint.config.mjs` is a flat config: a tracked source file is only linted + * if it matches at least one config object's `files:` glob (or a global, + * files-less block). A file matching NO glob resolves to zero reachable + * rules and `eslint .` silently exits 0 on it — the #3059 defect class (62 + * tracked source files found with this shape). This guard walks every + * tracked `.cjs/.cts/.js/.mjs` source file, resolves its real ESLint config, + * and fails when a file resolves to zero rules and isn't a deliberately + * exempted, reasoned allowlist entry. + * + * This checks rule REACHABILITY (does at least one rule apply to this file + * at all, any severity), not rule SEVERITY (whether an applicable rule is + * `error` vs `warn` vs `off`) — severity coverage is a separate gate, #1885 + * F17. A file with one `off` rule reachable still counts as "covered" here: + * it proves the file was deliberately targeted by a `files:` glob, which is + * the thing #3059 is about. + * + * ## Goodhart rationale + * + * A coverage metric is trivially gameable by padding the allowlist instead + * of fixing the glob, so every knob here is built to resist that: + * - Every allowlist entry MUST carry a non-empty `reason` — an unreasoned + * entry is indistinguishable from "quietly made the metric look better". + * - The allowlist only ratchets DOWN: an entry whose path now resolves to + * >=1 rule (`allowlist_stale`) is a failure, forcing prompt removal + * rather than letting stale exemptions accumulate as free cover for + * future accidental escapes at the same path. + * - A tracked-file-count floor (`tracked_count_below_floor`) exists so a + * broken or empty `git ls-files` (e.g. wrong cwd, detached worktree) + * can't report a vacuous "0 escapes out of 0 checked" clean run. + * + * ## Ignored vs. unmatched (the discrimination this script makes) + * + * ESLint's `ignores:` blocks are the one legitimate "this file is not meant + * to be linted" decision (e.g. the ADR-457 tsc-emitted `.cjs` artifacts + * under `gsd-core/bin/lib/`) and must NOT be reported as uncovered. But + * `ESLint#isPathIgnored` cannot be trusted uniformly across extensions: + * for ESLint's default-lintable extensions (`.js`/`.mjs`/`.cjs`), it + * reports `true` only when the path matches an explicit `ignores:` glob — + * verified empirically: an unmatched top-level `.cjs` probe file reports + * `isPathIgnored() === false` with an empty resolved rule set, not `true`. + * For `.cts` (and any other non-default extension), flat config requires an + * EXPLICIT `files:` glob match to be linted at all; a `.cts` file matching + * no `files:` glob ALSO reports `isPathIgnored() === true` — indistinguishable, + * via this API, from a genuine `ignores:` entry (verified with a scratch + * `.cts` fixture under `tests/fixtures/`). This repo's `ignores:` list never + * contains a `.cts` path (ADR-457 retires only the emitted `.cjs`; the + * `.cts` source is always meant to stay linted), so for `.cts` specifically + * an `isPathIgnored() === true` verdict can only mean "matches no `files:` + * glob" and is therefore treated as UNCOVERED, not ignored. See + * `resolveFileCoverage` below. + * + * ## Why `bin/install.js` is NOT in the allowlist + * + * The allowlist below is exclusively for files that resolve to ZERO rules — + * it is a registry of accepted escapes, not a general-purpose "reasons for + * how a file is configured" log. The `bin/install.js` / `bin/gsd-mcp-server.js` + * / `scripts/build-hooks.js` family is deliberately covered by a minimal, + * 2-rule block in `eslint.config.mjs` per ADR-1703 (targeting only the + * portability defect surface, not a full style sweep of ~12k lines of + * generated code) — see the comment at that block in `eslint.config.mjs`. + * Because 2 rules is non-empty, that family already passes this guard + * without needing an allowlist entry, and adding one anyway would itself be + * flagged `allowlist_stale` (see the ratchet above). The allowlist exemption + * surface deliberately cannot be used to re-state a decision that is already + * recorded in the config; #3059 is the guard, ADR-1703 is the decision. + */ + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const ALLOWLIST_PATH = path.join(__dirname, 'lint-eslint-glob-coverage.allowlist.json'); + +const SOURCE_EXT_RE = /\.(cjs|cts|js|mjs)$/; + +// ESLint's flat-config default-lintable extensions: a file with one of these +// extensions that matches no `files:` glob still reports `isPathIgnored() +// === false` (with an empty resolved rule set) — so for these extensions, +// `isPathIgnored() === true` reliably means an explicit `ignores:` match. +const DEFAULT_LINTABLE_EXT_RE = /\.(js|mjs|cjs)$/; + +const MIN_TRACKED_SOURCE_FILES = 500; + +/** + * Runs `git ls-files` and returns the tracked source files (repo-relative, + * POSIX-normalized, filtered to SOURCE_EXT_RE). Never throws: a git failure + * (non-zero exit or timeout) produces a degraded `{ ok: false }` result so + * callers can turn it into a `git_failed` violation instead of crashing. + * + * @param {object} [opts] + * @param {Function} [opts.execFile] - injectable sync exec function with the + * `execFileSync(cmd, args, options)` signature; defaults to + * `child_process.execFileSync`. + */ +function listTrackedSourceFiles({ execFile } = {}) { + const run = execFile || require('child_process').execFileSync; + let stdout; + try { + // The test runner and CI execute inside a container where this repo is + // owned by a different UID than the running user; bare `git ls-files` + // then refuses with "detected dubious ownership" unless the path is in + // `safe.directory`, and this guard degrades to a `git_failed` violation + // — exactly where it must run to be a useful gate (#3059). `-c + // safe.directory=*` scopes the override to THIS invocation only (it + // never writes to any config file, global or local), and the wildcard + // is fine here because this command only ever enumerates tracked paths + // in the repo the guard is already executing inside. + stdout = run('git', ['-c', 'safe.directory=*', 'ls-files'], { + cwd: ROOT, + encoding: 'utf8', + // 30s — `git ls-files` on this tree returns ~1175 paths in <1s; 30s is + // the CLAUDE.md git ceiling, generous for a cold index. + timeout: 30000, + }); + } catch (err) { + return { ok: false, files: [], error: (err && err.message) || String(err) }; + } + + const files = String(stdout) + // CRLF-safe: `git ls-files` output may carry \r\n on a Windows checkout + // or a git config with core.autocrlf set (DEFECT.CRLF class). + .split(/\r?\n/) + .filter((line) => line.length > 0) + // Unconditional backslash normalization (never path.sep-gated): the + // guard's own process may run on any platform regardless of what + // produced the tracked-file listing. + .map((line) => line.replace(/\\/g, '/')) + .filter((line) => SOURCE_EXT_RE.test(line)); + + return { ok: true, files }; +} + +/** Loads and parses the allowlist JSON file. */ +function loadAllowlist() { + const raw = fs.readFileSync(ALLOWLIST_PATH, 'utf8'); + return JSON.parse(raw); +} + +/** + * Resolves the ESLint coverage verdict for a single tracked file. See the + * "Ignored vs. unmatched" header section for the extension-dependent + * discrimination rationale. + * + * @param {import('eslint').ESLint} eslint + * @param {string} relPath - repo-relative POSIX path + * @returns {Promise<{ ignored: boolean, ruleCount: number }>} + */ +async function resolveFileCoverage(eslint, relPath) { + const absPath = path.join(ROOT, relPath); + const ignored = await eslint.isPathIgnored(absPath); + + if (ignored) { + if (DEFAULT_LINTABLE_EXT_RE.test(relPath)) { + // .js/.mjs/.cjs: isPathIgnored() only reports true for an explicit + // `ignores:` glob match — a recorded decision. + return { ignored: true, ruleCount: 0 }; + } + // .cts (or any non-default extension): isPathIgnored() can't + // distinguish "explicit ignores: match" from "no files: glob matched + // it at all" — and this repo's ignores: list never targets .cts, so + // treat it as the latter (uncovered), never as ignored. + return { ignored: false, ruleCount: 0 }; + } + + const config = await eslint.calculateConfigForFile(absPath); + const ruleCount = config && config.rules ? Object.keys(config.rules).length : 0; + return { ignored: false, ruleCount }; +} + +/** Builds the default real-ESLint-backed resolveConfig function. */ +function createDefaultResolveConfig() { + const { ESLint } = require('eslint'); + const eslint = new ESLint({ cwd: ROOT }); + return (relPath) => resolveFileCoverage(eslint, relPath); +} + +/** + * The pure coverage predicate. All I/O is injectable via `deps` so tests + * never need a temp repo, a real subprocess, or a real ESLint instance: + * + * @param {object} [deps] + * @param {string[] | { ok: boolean, files?: string[], error?: string }} [deps.trackedFiles] + * Either a plain array of tracked source paths (success shorthand), or a + * `listTrackedSourceFiles()`-shaped result object (so a git failure can be + * injected directly). Defaults to a real `listTrackedSourceFiles()` call. + * @param {(relPath: string) => Promise<{ignored:boolean,ruleCount:number}> | {ignored:boolean,ruleCount:number}} [deps.resolveConfig] + * Per-file coverage resolver. Defaults to a real ESLint instance. + * @param {Array<{path:string,reason?:string}>} [deps.allowlist] - defaults to + * the real `loadAllowlist()`. + * @param {number} [deps.minTrackedFiles] - defaults to MIN_TRACKED_SOURCE_FILES. + * @returns {Promise<{ ok: boolean, escapes: Array<{path:string}>, violations: Array<{kind:string,path:string|null,detail:string}>, checked: number }>} + */ +async function checkGlobCoverage(deps = {}) { + const minTrackedFiles = + typeof deps.minTrackedFiles === 'number' ? deps.minTrackedFiles : MIN_TRACKED_SOURCE_FILES; + + const violations = []; + + let trackedResult; + if (deps.trackedFiles === undefined) { + trackedResult = listTrackedSourceFiles(); + } else if (Array.isArray(deps.trackedFiles)) { + trackedResult = { ok: true, files: deps.trackedFiles }; + } else { + trackedResult = deps.trackedFiles; + } + + if (!trackedResult.ok) { + violations.push({ + kind: 'git_failed', + path: null, + detail: trackedResult.error || 'listTrackedSourceFiles() failed', + }); + return { ok: false, escapes: [], violations, checked: 0 }; + } + + const trackedFiles = trackedResult.files; + + if (trackedFiles.length < minTrackedFiles) { + violations.push({ + kind: 'tracked_count_below_floor', + path: null, + detail: `tracked source file count ${trackedFiles.length} is below the floor of ${minTrackedFiles} — a broken or empty git ls-files must never report a vacuous clean run`, + }); + } + + const trackedSet = new Set(trackedFiles); + const allowlist = deps.allowlist === undefined ? loadAllowlist() : deps.allowlist; + + const seenAllowlistPaths = new Set(); + const allowlistPathSet = new Set(); + for (const entry of allowlist) { + const entryPath = entry && entry.path; + + if (seenAllowlistPaths.has(entryPath)) { + violations.push({ + kind: 'allowlist_duplicate', + path: entryPath, + detail: 'path appears more than once in the allowlist', + }); + } else { + seenAllowlistPaths.add(entryPath); + } + allowlistPathSet.add(entryPath); + + if (!Object.prototype.hasOwnProperty.call(entry, 'reason')) { + violations.push({ + kind: 'allowlist_missing_reason', + path: entryPath, + detail: 'allowlist entry is missing a "reason" key', + }); + } else if (typeof entry.reason !== 'string' || entry.reason.trim() === '') { + violations.push({ + kind: 'allowlist_empty_reason', + path: entryPath, + detail: 'allowlist entry "reason" is empty or whitespace-only', + }); + } + + if (!trackedSet.has(entryPath)) { + violations.push({ + kind: 'allowlist_missing_path', + path: entryPath, + detail: 'allowlisted path is not a tracked source file', + }); + } + } + + const resolveConfig = deps.resolveConfig || createDefaultResolveConfig(); + + const escapes = []; + let checked = 0; + + for (const file of trackedFiles) { + checked += 1; + const result = await resolveConfig(file); + const isAllowlisted = allowlistPathSet.has(file); + + if (result.ignored) { + // A recorded ESLint `ignores:` decision — never an escape, regardless + // of allowlist membership. + continue; + } + + if (result.ruleCount === 0) { + if (isAllowlisted) continue; + escapes.push({ path: file }); + violations.push({ + kind: 'uncovered', + path: file, + detail: 'resolves to 0 reachable ESLint rules and is not allowlisted', + }); + } else if (isAllowlisted) { + violations.push({ + kind: 'allowlist_stale', + path: file, + detail: 'allowlisted path now resolves to >=1 ESLint rule — prune the entry, the allowlist only ratchets down', + }); + } + } + + return { ok: violations.length === 0, escapes, violations, checked }; +} + +if (require.main === module) { + checkGlobCoverage() + .then((result) => { + if (result.violations.length > 0) { + console.error( + `lint-eslint-glob-coverage: ${result.violations.length} violation(s) across ${result.checked} tracked source file(s) (${result.escapes.length} uncovered escape(s))` + ); + for (const v of result.violations) { + console.error(` [${v.kind}] ${v.path === null ? '(n/a)' : v.path}${v.detail ? ' — ' + v.detail : ''}`); + } + process.exitCode = 1; + } else { + console.log(`ok lint-eslint-glob-coverage: ${result.checked} tracked source file(s), 0 escapes`); + } + }) + .catch((err) => { + console.error(err && err.stack ? err.stack : String(err)); + process.exitCode = 1; + }); +} + +module.exports = { + checkGlobCoverage, + listTrackedSourceFiles, + loadAllowlist, + SOURCE_EXT_RE, + MIN_TRACKED_SOURCE_FILES, +}; diff --git a/tests/eslint-glob-coverage.test.cjs b/tests/eslint-glob-coverage.test.cjs new file mode 100644 index 000000000..834cf523c --- /dev/null +++ b/tests/eslint-glob-coverage.test.cjs @@ -0,0 +1,450 @@ +'use strict'; + +/** + * eslint-glob-coverage.test.cjs + * + * Coverage for scripts/lint-eslint-glob-coverage.cjs (#3059): every unit row + * uses injected deps (no temp repos, no real subprocess, no chmod). The + * ANCHOR rows and the real-tree row drive the actual ESLint config against + * eslint.config.mjs to prove the discrimination logic holds outside the + * mocked seams. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); +const fc = require('./helpers/fast-check-setup.cjs'); + +const { + checkGlobCoverage, + listTrackedSourceFiles, + loadAllowlist, + SOURCE_EXT_RE, + MIN_TRACKED_SOURCE_FILES, +} = require('../scripts/lint-eslint-glob-coverage.cjs'); + +const ROOT = path.join(__dirname, '..'); + +/** A resolveConfig stub that reports every file as covered by exactly one rule. */ +function coveredResolver() { + return () => ({ ignored: false, ruleCount: 1 }); +} + +/** A resolveConfig stub driven by a Map. */ +function mapResolver(entries) { + return (relPath) => entries.get(relPath) || { ignored: false, ruleCount: 0 }; +} + +describe('eslint-glob-coverage: real tree (expected end state)', () => { + test('the current tree resolves clean against the guard (drives the guard to green)', async () => { + const result = await checkGlobCoverage(); + assert.equal( + result.ok, + true, + `expected 0 violations, got ${result.violations.length}: ${JSON.stringify(result.violations, null, 2)}` + ); + }); +}); + +describe('eslint-glob-coverage: uncovered detection', () => { + test('an unmatched file is reported uncovered; a matched file is not', async () => { + const resolver = mapResolver( + new Map([ + ['scripts/covered.cjs', { ignored: false, ruleCount: 3 }], + ['scripts/escaped.cjs', { ignored: false, ruleCount: 0 }], + ]) + ); + const result = await checkGlobCoverage({ + trackedFiles: ['scripts/covered.cjs', 'scripts/escaped.cjs'], + resolveConfig: resolver, + allowlist: [], + minTrackedFiles: 0, + }); + assert.equal(result.ok, false); + assert.deepEqual(result.escapes, [{ path: 'scripts/escaped.cjs' }]); + const kinds = result.violations.map((v) => v.path); + assert.ok(!kinds.includes('scripts/covered.cjs')); + }); +}); + +describe('eslint-glob-coverage: allowlist reason validation', () => { + test('non-empty reason passes; empty/whitespace reason and missing reason each fail', async () => { + const resolver = mapResolver( + new Map([ + ['a.cjs', { ignored: false, ruleCount: 0 }], + ['b.cjs', { ignored: false, ruleCount: 0 }], + ['c.cjs', { ignored: false, ruleCount: 0 }], + ['d.cjs', { ignored: false, ruleCount: 0 }], + ]) + ); + const result = await checkGlobCoverage({ + trackedFiles: ['a.cjs', 'b.cjs', 'c.cjs', 'd.cjs'], + resolveConfig: resolver, + minTrackedFiles: 0, + allowlist: [ + { path: 'a.cjs', reason: 'a real reason' }, + { path: 'b.cjs', reason: '' }, + { path: 'c.cjs', reason: ' ' }, + { path: 'd.cjs' }, + ], + }); + const byPath = (p) => result.violations.filter((v) => v.path === p).map((v) => v.kind); + assert.deepEqual(byPath('a.cjs'), []); + assert.deepEqual(byPath('b.cjs'), ['allowlist_empty_reason']); + assert.deepEqual(byPath('c.cjs'), ['allowlist_empty_reason']); + assert.deepEqual(byPath('d.cjs'), ['allowlist_missing_reason']); + }); +}); + +describe('eslint-glob-coverage: allowlist ratchet', () => { + test('a stale entry (path now resolves to >=1 rule) reports allowlist_stale', async () => { + const resolver = mapResolver(new Map([['now-covered.cjs', { ignored: false, ruleCount: 2 }]])); + const result = await checkGlobCoverage({ + trackedFiles: ['now-covered.cjs'], + resolveConfig: resolver, + minTrackedFiles: 0, + allowlist: [{ path: 'now-covered.cjs', reason: 'stale — should be pruned' }], + }); + assert.equal(result.ok, false); + const kinds = result.violations.filter((v) => v.path === 'now-covered.cjs').map((v) => v.kind); + assert.deepEqual(kinds, ['allowlist_stale']); + }); + + test('an allowlist path not in the tracked list reports allowlist_missing_path', async () => { + const result = await checkGlobCoverage({ + trackedFiles: ['tracked.cjs'], + resolveConfig: coveredResolver(), + minTrackedFiles: 0, + allowlist: [{ path: 'ghost.cjs', reason: 'not actually tracked' }], + }); + assert.equal(result.ok, false); + const kinds = result.violations.filter((v) => v.path === 'ghost.cjs').map((v) => v.kind); + assert.deepEqual(kinds, ['allowlist_missing_path']); + }); + + test('a duplicate entry reports allowlist_duplicate', async () => { + const result = await checkGlobCoverage({ + trackedFiles: ['dup.cjs'], + resolveConfig: mapResolver(new Map([['dup.cjs', { ignored: false, ruleCount: 0 }]])), + minTrackedFiles: 0, + allowlist: [ + { path: 'dup.cjs', reason: 'first' }, + { path: 'dup.cjs', reason: 'second' }, + ], + }); + assert.equal(result.ok, false); + const kinds = result.violations.filter((v) => v.path === 'dup.cjs').map((v) => v.kind); + assert.ok(kinds.includes('allowlist_duplicate')); + }); +}); + +describe('eslint-glob-coverage: tracked-count floor boundary', () => { + function fakeTrackedFiles(n) { + const files = []; + for (let i = 0; i < n; i += 1) files.push(`scripts/fake-${i}.cjs`); + return files; + } + + test('499 tracked files (limit-1) fails tracked_count_below_floor', async () => { + const files = fakeTrackedFiles(MIN_TRACKED_SOURCE_FILES - 1); + const result = await checkGlobCoverage({ + trackedFiles: files, + resolveConfig: coveredResolver(), + allowlist: [], + }); + assert.equal(result.ok, false); + assert.ok(result.violations.some((v) => v.kind === 'tracked_count_below_floor')); + }); + + test('500 tracked files (limit) passes', async () => { + const files = fakeTrackedFiles(MIN_TRACKED_SOURCE_FILES); + const result = await checkGlobCoverage({ + trackedFiles: files, + resolveConfig: coveredResolver(), + allowlist: [], + }); + assert.equal(result.ok, true); + assert.ok(!result.violations.some((v) => v.kind === 'tracked_count_below_floor')); + }); + + test('501 tracked files (limit+1) passes', async () => { + const files = fakeTrackedFiles(MIN_TRACKED_SOURCE_FILES + 1); + const result = await checkGlobCoverage({ + trackedFiles: files, + resolveConfig: coveredResolver(), + allowlist: [], + }); + assert.equal(result.ok, true); + assert.ok(!result.violations.some((v) => v.kind === 'tracked_count_below_floor')); + }); +}); + +describe('eslint-glob-coverage: never a vacuous clean', () => { + test('an empty git ls-files result never reports clean (fails the floor)', async () => { + const result = await checkGlobCoverage({ + trackedFiles: [], + resolveConfig: coveredResolver(), + allowlist: [], + }); + assert.equal(result.ok, false); + assert.ok(result.violations.some((v) => v.kind === 'tracked_count_below_floor')); + }); + + test('listTrackedSourceFiles itself returns ok:true with zero files on empty stdout (not a throw)', () => { + const result = listTrackedSourceFiles({ execFile: () => '' }); + assert.deepEqual(result, { ok: true, files: [] }); + }); +}); + +describe('eslint-glob-coverage: container safe.directory (#3059)', () => { + test('git invocation passes -c safe.directory=* before ls-files, so a container-owned repo ("dubious ownership") never degrades the guard to git_failed', () => { + let capturedArgs; + const result = listTrackedSourceFiles({ + execFile: (cmd, args) => { + capturedArgs = args; + return 'scripts/a.cjs\n'; + }, + }); + assert.equal(result.ok, true); + assert.deepEqual( + capturedArgs.slice(0, 3), + ['-c', 'safe.directory=*', 'ls-files'], + 'the safe.directory override must precede the ls-files subcommand' + ); + }); +}); + +describe('eslint-glob-coverage: git failure handling', () => { + test('an injected git failure reports git_failed without throwing', async () => { + const result = await checkGlobCoverage({ + trackedFiles: { ok: false, files: [], error: 'git ls-files: exit 128' }, + resolveConfig: coveredResolver(), + allowlist: [], + }); + assert.equal(result.ok, false); + assert.deepEqual( + result.violations.map((v) => v.kind), + ['git_failed'] + ); + }); + + test('listTrackedSourceFiles degrades (does not throw) when execFile throws', () => { + const boom = () => { + throw new Error('git: command not found'); + }; + const result = listTrackedSourceFiles({ execFile: boom }); + assert.equal(result.ok, false); + assert.equal(result.files.length, 0); + assert.match(result.error, /git: command not found/); + }); +}); + +describe('eslint-glob-coverage: path normalization', () => { + test('a backslash path from git ls-files normalizes before allowlist matching', () => { + const result = listTrackedSourceFiles({ + execFile: () => 'scripts\\weird-windows-path.cjs\nsrc\\normal.cts\n', + }); + assert.equal(result.ok, true); + assert.deepEqual(result.files.sort(), ['scripts/weird-windows-path.cjs', 'src/normal.cts'].sort()); + }); + + test('CRLF git ls-files output parses with no phantom paths', () => { + const result = listTrackedSourceFiles({ + execFile: () => 'scripts/a.cjs\r\nscripts/b.cjs\r\n', + }); + assert.equal(result.ok, true); + assert.deepEqual(result.files, ['scripts/a.cjs', 'scripts/b.cjs']); + }); +}); + +describe('eslint-glob-coverage: listTrackedSourceFiles parser properties (fast-check)', () => { + // Generators for `git ls-files`-style path-like lines. Segments avoid '.', + // '\n', '\r' so extension/line boundaries stay unambiguous; the generators + // below deliberately introduce the shapes the parser must handle: source + // extensions, non-source extensions, no extension, backslashes, spaces, + // and empty lines. + const pathSegment = fc.stringMatching(/^[A-Za-z0-9_\- ]{1,12}$/); + const sourceExt = fc.constantFrom('cjs', 'cts', 'js', 'mjs'); + const nonSourceExt = fc.constantFrom('md', 'json', 'txt'); + + const sourcePath = fc + .tuple(fc.array(pathSegment, { minLength: 1, maxLength: 3 }), sourceExt) + .map(([segs, ext]) => `${segs.join('/')}.${ext}`); + + const nonSourcePath = fc + .tuple(fc.array(pathSegment, { minLength: 1, maxLength: 3 }), nonSourceExt) + .map(([segs, ext]) => `${segs.join('/')}.${ext}`); + + const noExtPath = fc.array(pathSegment, { minLength: 1, maxLength: 3 }).map((segs) => segs.join('/')); + + const backslashPath = fc + .tuple(fc.array(pathSegment, { minLength: 1, maxLength: 3 }), sourceExt) + .map(([segs, ext]) => `${segs.join('\\')}.${ext}`); + + const spacedPath = fc + .tuple(fc.array(pathSegment, { minLength: 1, maxLength: 2 }), sourceExt) + .map(([segs, ext]) => `${segs.join(' / ')} file.${ext}`); + + const emptyPath = fc.constant(''); + + const anyPathLine = fc.oneof(sourcePath, nonSourcePath, noExtPath, backslashPath, spacedPath, emptyPath); + const terminator = fc.constantFrom('\n', '\r\n'); + + // (1) Extension totality / soundness: every returned path ends in a + // source extension, is traceable back to a generated input line (modulo + // backslash->slash normalization), and no returned entry is empty. Each + // generated line carries its OWN terminator (mixing \n and \r\n within a + // single stdout blob), including the last line — this also exercises "a + // trailing terminator produces no phantom empty entry" on every run, + // since every line (including the last) is terminator-suffixed. + test('property: extension totality — every returned path is source-extensioned, traceable, non-empty', () => { + fc.assert( + fc.property( + fc.array(fc.tuple(anyPathLine, terminator), { maxLength: 20 }), + (lines) => { + const stdout = lines.map(([p, t]) => p + t).join(''); + const result = listTrackedSourceFiles({ execFile: () => stdout }); + assert.equal(result.ok, true); + + const normalizedInputs = new Set( + lines.map(([p]) => p.replace(/\\/g, '/')).filter((p) => p.length > 0) + ); + + for (const file of result.files) { + assert.notEqual(file, '', 'no returned entry is the empty string'); + assert.match(file, SOURCE_EXT_RE, `${file} must end in a source extension`); + assert.ok(normalizedInputs.has(file), `${file} must be traceable to a generated input line`); + } + + // Exact-equality corollary: the parser's own filter/normalize + // pipeline applied to the same inputs must reproduce the result. + const expected = lines + .map(([p]) => p) + .filter((p) => p.length > 0) + .map((p) => p.replace(/\\/g, '/')) + .filter((p) => SOURCE_EXT_RE.test(p)); + assert.deepEqual(result.files, expected); + } + ) + ); + }); + + // (2) Separator normalization is total: any generated path containing a + // backslash never survives into the output with a backslash intact. + test('property: separator normalization is total — no returned path contains a backslash', () => { + fc.assert( + fc.property(fc.array(backslashPath, { minLength: 0, maxLength: 15 }), (paths) => { + const stdout = paths.map((p) => `${p}\n`).join(''); + const result = listTrackedSourceFiles({ execFile: () => stdout }); + assert.equal(result.ok, true); + for (const file of result.files) { + assert.ok(!file.includes('\\'), `${file} must not contain a backslash`); + } + }) + ); + }); + + // (3) CRLF/LF equivalence: the same set of path lines, terminated + // uniformly with LF vs. uniformly with CRLF, must parse to the same + // result — the invariant the recurring CRLF defect class breaks. + test('property: CRLF and LF inputs describing the same path set yield the same result', () => { + fc.assert( + fc.property(fc.array(anyPathLine, { maxLength: 20 }), (lines) => { + const lfStdout = lines.map((p) => `${p}\n`).join(''); + const crlfStdout = lines.map((p) => `${p}\r\n`).join(''); + + const lfResult = listTrackedSourceFiles({ execFile: () => lfStdout }); + const crlfResult = listTrackedSourceFiles({ execFile: () => crlfStdout }); + + assert.equal(lfResult.ok, true); + assert.equal(crlfResult.ok, true); + assert.deepEqual(lfResult.files, crlfResult.files); + }) + ); + }); +}); + +describe('eslint-glob-coverage: SOURCE_EXT_RE filtering', () => { + test('listTrackedSourceFiles filters to cjs/cts/js/mjs only', () => { + const result = listTrackedSourceFiles({ + execFile: () => + ['scripts/a.cjs', 'src/b.cts', 'hooks/c.js', 'gsd-core/d.mjs', 'README.md', 'docs/e.txt'].join('\n'), + }); + assert.equal(result.ok, true); + assert.deepEqual( + result.files.sort(), + ['gsd-core/d.mjs', 'hooks/c.js', 'scripts/a.cjs', 'src/b.cts'].sort() + ); + }); + + test('SOURCE_EXT_RE matches the four source extensions and rejects others', () => { + assert.equal(SOURCE_EXT_RE.test('foo.cjs'), true); + assert.equal(SOURCE_EXT_RE.test('foo.cts'), true); + assert.equal(SOURCE_EXT_RE.test('foo.js'), true); + assert.equal(SOURCE_EXT_RE.test('foo.mjs'), true); + assert.equal(SOURCE_EXT_RE.test('foo.ts'), false); + assert.equal(SOURCE_EXT_RE.test('foo.md'), false); + }); +}); + +describe('eslint-glob-coverage: ANCHOR rows (real ESLint against real config)', () => { + // One shared resolver for the anchor rows: the anchors deliberately re-resolve + // the config themselves rather than importing the guard's resolveFileCoverage, + // so an anchor still fails if the guard's own resolution regresses. + const { ESLint } = require('eslint'); + const anchorEslint = new ESLint({ cwd: ROOT }); + async function rulesFor(relPath) { + const config = await anchorEslint.calculateConfigForFile(path.join(ROOT, relPath)); + return config && config.rules; + } + + test('tests/*.test.cjs has local/no-unbounded-spawn reachable', async () => { + const rules = await rulesFor('tests/eslint-glob-coverage.test.cjs'); + assert.ok(rules, 'expected a resolved rule set'); + assert.ok( + Object.prototype.hasOwnProperty.call(rules, 'local/no-unbounded-spawn'), + 'expected local/no-unbounded-spawn to be reachable on a tests/*.test.cjs file' + ); + }); + + test('scripts/*.cjs has n/no-path-concat reachable', async () => { + const rules = await rulesFor('scripts/lint-eslint-glob-coverage.cjs'); + assert.ok(rules, 'expected a resolved rule set'); + assert.ok( + Object.prototype.hasOwnProperty.call(rules, 'n/no-path-concat'), + 'expected n/no-path-concat to be reachable on a scripts/*.cjs file' + ); + }); + + test('src/*.cts has at least one @typescript-eslint/* rule reachable', async () => { + const rules = await rulesFor('src/milestone.cts'); + assert.ok(rules, 'expected a resolved rule set'); + const tsRules = Object.keys(rules).filter((r) => r.startsWith('@typescript-eslint/')); + assert.ok(tsRules.length > 0, 'expected at least one @typescript-eslint/* rule reachable on src/*.cts'); + }); +}); + +describe('eslint-glob-coverage: ignored vs. unmatched discrimination (real ESLint)', () => { + test('an ADR-457 tsc artifact under gsd-core/bin/lib/*.cjs is not reported as uncovered', async () => { + const result = await checkGlobCoverage({ + trackedFiles: ['gsd-core/bin/lib/milestone.cjs'], + allowlist: [], + minTrackedFiles: 0, + }); + assert.equal(result.ok, true); + assert.deepEqual(result.escapes, []); + }); + + test('bin/install.js is not reported as uncovered and is not in the real allowlist', async () => { + const result = await checkGlobCoverage({ + trackedFiles: ['bin/install.js'], + allowlist: [], + minTrackedFiles: 0, + }); + assert.equal(result.ok, true); + assert.deepEqual(result.escapes, []); + + const realAllowlist = loadAllowlist(); + assert.ok(!realAllowlist.some((entry) => entry.path === 'bin/install.js')); + }); +});