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')); + }); +});