Files
msd-core/scripts/lint-eslint-glob-coverage.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

341 lines
14 KiB
JavaScript

#!/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 `msd-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/msd-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,
};