diff --git a/TESTING-STANDARDS.md b/TESTING-STANDARDS.md index 4f969f164..bf092aca9 100644 --- a/TESTING-STANDARDS.md +++ b/TESTING-STANDARDS.md @@ -34,7 +34,7 @@ const src = readFileSync('./bin/lib/plan.cjs', 'utf8'); assert(src.includes('plan-1.1')); // never do this ``` -**Enforcement:** `local/no-source-grep` (ESLint, currently `warn`; becomes `error` after issue #453 merges). +**Enforcement:** `local/no-source-grep` (ESLint, `error`; promoted repo-wide by #3313). ### 2. No vacuous-truth assertions @@ -137,7 +137,7 @@ await doWork(); assert(Date.now() - start < 200, 'must complete in 200ms'); ``` -**Enforcement:** `local/no-elapsed-assertion` (ESLint, currently `warn`; becomes `error` after issue #453 merges). Also `no-restricted-syntax` ban on `performance.now()` comparisons in assertions. +**Enforcement:** `local/no-elapsed-assertion` (ESLint, currently `warn`; promotion to `error` is sequenced behind #3314 — 10 of 19 clock-touching `src` modules have no sanctioned time-control mechanism until ADR-456 is amended there). Also `no-restricted-syntax` ban on `performance.now()` comparisons in assertions. ### Clock-seam pattern for concurrency @@ -162,7 +162,7 @@ test('lock expires after TTL', (t) => { }); ``` -**Enforcement:** `local/no-magic-sleep-in-tests` (bans `setTimeout`/`sleep`/`delay` inside test bodies; ESLint, currently `warn`; becomes `error` after issue #453 merges). Code review catches the race pattern directly. +**Enforcement:** `local/no-magic-sleep-in-tests` (bans `setTimeout`/`sleep`/`delay` inside test bodies; ESLint, `error`). Code review catches the race pattern directly. ### Property-based testing tier @@ -207,14 +207,14 @@ Real multi-process race tests are deleted once the corresponding deterministic c | Rule | Severity | What it catches | |---|---|---| -| `local/no-source-grep` | `warn` → `error` (#453) | `readFileSync` on source files + text assertions; `assert.match`/`doesNotMatch` on raw stdout/stderr | -| `local/no-magic-sleep-in-tests` | `warn` → `error` (#453) | `setTimeout`/`sleep`/`delay` calls inside `test()`/`it()`/`describe()` bodies | -| `local/no-elapsed-assertion` | `warn` → `error` (#453) | Assertions on `Date.now()` delta, `process.hrtime()`, `performance.now()` comparisons | +| `local/no-source-grep` | `error` (promoted by #3313) | `readFileSync` on source files + text assertions; `assert.match`/`doesNotMatch` on raw stdout/stderr | +| `local/no-magic-sleep-in-tests` | `error` | `setTimeout`/`sleep`/`delay` calls inside `test()`/`it()`/`describe()` bodies | +| `local/no-elapsed-assertion` | `warn` → `error` (#3314) | Assertions on `Date.now()` delta, `process.hrtime()`, `performance.now()` comparisons | | `no-only-tests/no-only-tests` | `error` | `test.only`/`describe.only`/`it.only` committed to non-scratch files | | `no-restricted-syntax` (ban 1) | `error` | Top-level `setTimeout` in `ExpressionStatement` | | `no-restricted-syntax` (ban 2) | `error` | `.only` member access on `test`/`it`/`describe` (belt-and-suspenders) | -All three `local/*` rules currently ship at `warn`. They become `error` after the cleanup sweep tracked at [#453](https://github.com/open-gsd/gsd-core/issues/453) merges. New violations added after the acceptance of ADR 456 are out of policy regardless of the current ESLint severity. +`local/no-source-grep` and `local/no-magic-sleep-in-tests` now ship at `error` (promoted by [#3313](https://github.com/open-gsd/gsd-core/issues/3313), absorbing the cleanup sweep originally tracked at #453). `local/no-elapsed-assertion` remains `warn`, gated behind [#3314](https://github.com/open-gsd/gsd-core/issues/3314) — 10 of 19 clock-touching `src` modules have no sanctioned time-control mechanism until ADR-456 is amended there; promoting sooner would fail CI on correct, currently-unfixable code. New violations added after the acceptance of ADR 456 are out of policy regardless of the current ESLint severity. ESLint harness details: [`docs/adr/452-eslint-lint-harness.md`](docs/adr/452-eslint-lint-harness.md). diff --git a/eslint.config.mjs b/eslint.config.mjs index 96f144db5..c7b959e0f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -375,8 +375,9 @@ export default tseslint.config( // eslint-plugin-n rules 'n/no-process-exit': 'error', 'n/no-path-concat': 'error', - // Local rules — warn for now; flip to error after cleanup phases - 'local/no-source-grep': 'warn', + // Promoted to error (#3313) — a fresh non-cached `npx eslint .` run found + // zero live violations of this rule in this glob at promotion time. + 'local/no-source-grep': 'error', }, }, diff --git a/package.json b/package.json index 1c089698d..0487671e4 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "prepublishOnly": "npm run build:lib && npm run build:hooks", "pretest": "npm run build:lib && npm run lint:skill-deps", "pretest:coverage": "npm run build:lib && npm run lint:skill-deps", - "lint": "eslint . --cache --cache-location node_modules/.cache/eslint/", + "lint": "eslint . --cache --cache-location node_modules/.cache/eslint/ --max-warnings 0", "lint:fix": "eslint . --fix", "lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs", "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-completion-predicate-drift.cjs", diff --git a/scripts/lint-allow-test-rule-refs.ceiling.json b/scripts/lint-allow-test-rule-refs.ceiling.json new file mode 100644 index 000000000..71a4c07cc --- /dev/null +++ b/scripts/lint-allow-test-rule-refs.ceiling.json @@ -0,0 +1,4 @@ +{ + "maxFiles": 314, + "grace": 3 +} diff --git a/scripts/lint-allow-test-rule-refs.cjs b/scripts/lint-allow-test-rule-refs.cjs index 068861b28..b1a1fb0f7 100644 --- a/scripts/lint-allow-test-rule-refs.cjs +++ b/scripts/lint-allow-test-rule-refs.cjs @@ -3,7 +3,8 @@ /** * lint-allow-test-rule-refs.cjs — enforce that NEW `allow-test-rule:` exemption - * comments carry a tracking-issue reference. + * comments carry a tracking-issue reference, AND that the total exemption + * count only ever ratchets down. * * ## Why * @@ -13,7 +14,13 @@ * (docs/adr/456-test-rigor-architecture.md) every NEW exemption must carry a * `#NNN` issue reference or an https:// URL so the decision is traceable. * - * ## What "compliant" means + * A citation alone doesn't stop the raw count from growing forever — a cited + * exemption is legitimate under ADR-456 §(d), but nothing previously stopped + * the total (cited + uncited) from creeping up PR by PR. This is the same + * masking gap the citation ratchet fixes for identity, applied to the count: + * a cited exemption looks compliant while the aggregate debt keeps growing. + * + * ## What "compliant" means (citation check) * * A compliant `allow-test-rule:` comment is one whose reason text (everything * after the colon) contains either: @@ -22,7 +29,7 @@ * * Any other comment is an OFFENDER. * - * ## Grandfathering + * ## Grandfathering (citation check) * * All pre-existing untracked exemptions are recorded in * scripts/lint-allow-test-rule-refs.allowlist.json (seeded at gate introduction @@ -31,7 +38,7 @@ * - A previously-offending comment that is now compliant → allowlist entry is * STALE and must be pruned (ratchet-down; the baseline only ever shrinks). * - * ## Offender identifiers + * ## Offender identifiers (citation check) * * Identifiers are stable cross-rename-safe strings of the form: * ` :: ` @@ -41,12 +48,22 @@ * If a file has multiple non-compliant comments with the SAME reason text, only * one identifier is recorded (deduped via Set). * + * ## Total-count ceiling + * + * Independently of citation status, the number of DISTINCT files carrying at + * least one `allow-test-rule:` marker is checked against a tight ceiling in + * scripts/lint-allow-test-rule-refs.ceiling.json via `assertTightCeiling` + * (scripts/lib/allowlist-ratchet.cjs). The ceiling may only decrease — a + * shrinking count that leaves too much slack above the ceiling fails the gate + * just as much as growth past it, forcing the ceiling to track the real + * high-water mark rather than sitting stale and loose. + * * See docs/adr/456-test-rigor-architecture.md for the full policy. */ const fs = require('fs'); const path = require('path'); -const { assertWithinAllowlist } = require('./lib/allowlist-ratchet.cjs'); +const { assertWithinAllowlist, assertTightCeiling } = require('./lib/allowlist-ratchet.cjs'); const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const ROOT = path.join(__dirname, '..'); @@ -54,6 +71,9 @@ const TESTS_DIR = process.env.GSD_LINT_ALLOW_TEST_RULE_TESTS_DIR || path.join(RO const ALLOWLIST_PATH = process.env.GSD_LINT_ALLOW_TEST_RULE_ALLOWLIST || path.join(__dirname, 'lint-allow-test-rule-refs.allowlist.json'); +const CEILING_PATH = + process.env.GSD_LINT_ALLOW_TEST_RULE_CEILING || + path.join(__dirname, 'lint-allow-test-rule-refs.ceiling.json'); /** * Extracts the reason text after `allow-test-rule:` from a single line of source @@ -77,13 +97,18 @@ const ALLOW_TEST_RULE_LINE_RE = /allow-test-rule:\s*(.+)/; const ISSUE_REF_RE = /#\d+|https?:\/\//; /** - * Recursively collect offender identifiers from all *.test.cjs files under dir. + * Recursively read every *.test.cjs file under dir, once. + * + * Both the citation check and the total-count ceiling need the same file set + * and content — walking twice would be the generative-fix-divergence class of + * bug (two scans that can silently drift apart), so both classifiers below + * consume this single walk's output. * * @param {string} dir absolute path to scan - * @returns {string[]} sorted, deduped list of ` :: ` strings + * @returns {{relpath: string, content: string}[]} */ -function collectOffenders(dir) { - const offenders = new Set(); +function walkTestFiles(dir) { + const files = []; function scan(current) { for (const entry of fs.readdirSync(current, { withFileTypes: true })) { @@ -99,31 +124,68 @@ function collectOffenders(dir) { // skip unreadable files (e.g. binary) continue; } - // Scan line-by-line. By testing each line for `allow-test-rule:` we - // cover BOTH comment forms without a cross-line regex: - // // allow-test-rule: ← line comment - // /* allow-test-rule: */ ← single-line block comment - // - // For each matching line we extract the reason (everything after the - // colon), then strip any trailing block-comment closer `*/` and - // whitespace so the identifier stays clean. - for (const line of content.split('\n')) { - const m = ALLOW_TEST_RULE_LINE_RE.exec(line); - if (!m) continue; - // Strip trailing block-comment closer and whitespace if present - const reason = m[1].replace(/\s*\*\/\s*$/, '').trim(); - if (!reason) continue; - if (ISSUE_REF_RE.test(reason)) continue; // compliant — skip - offenders.add(`${relpath} :: ${reason}`); - } + files.push({ relpath, content }); } } } scan(dir); + return files; +} + +/** + * Collect offender identifiers (files with an UNCITED allow-test-rule marker) + * from an already-walked file set. + * + * @param {{relpath: string, content: string}[]} files + * @returns {string[]} sorted, deduped list of ` :: ` strings + */ +function collectUncitedOffenders(files) { + const offenders = new Set(); + + for (const { relpath, content } of files) { + // Scan line-by-line. By testing each line for `allow-test-rule:` we + // cover BOTH comment forms without a cross-line regex: + // // allow-test-rule: ← line comment + // /* allow-test-rule: */ ← single-line block comment + // + // For each matching line we extract the reason (everything after the + // colon), then strip any trailing block-comment closer `*/` and + // whitespace so the identifier stays clean. + for (const line of content.split('\n')) { + const m = ALLOW_TEST_RULE_LINE_RE.exec(line); + if (!m) continue; + // Strip trailing block-comment closer and whitespace if present + const reason = m[1].replace(/\s*\*\/\s*$/, '').trim(); + if (!reason) continue; + if (ISSUE_REF_RE.test(reason)) continue; // compliant — skip + offenders.add(`${relpath} :: ${reason}`); + } + } + return [...offenders].sort(); } +/** + * Count distinct files carrying at least one allow-test-rule marker, cited or + * not — the raw total the ceiling ratchets down, independent of citation + * status. + * + * @param {{relpath: string, content: string}[]} files + * @returns {string[]} sorted, deduped list of relpaths + */ +function collectExemptionFiles(files) { + const marked = new Set(); + + for (const { relpath, content } of files) { + if (ALLOW_TEST_RULE_LINE_RE.test(content)) { + marked.add(relpath); + } + } + + return [...marked].sort(); +} + function main() { const args = process.argv.slice(2); const unknown = args.filter((a) => a !== '--help'); @@ -131,8 +193,11 @@ function main() { throw new ExitError(2, `lint-allow-test-rule-refs: unknown argument(s): ${unknown.join(', ')}`); } - const current = collectOffenders(TESTS_DIR); + const files = walkTestFiles(TESTS_DIR); + const current = collectUncitedOffenders(files); const known = JSON.parse(fs.readFileSync(ALLOWLIST_PATH, 'utf8')); + const exemptionFiles = collectExemptionFiles(files); + const ceiling = JSON.parse(fs.readFileSync(CEILING_PATH, 'utf8')); const failures = []; const { novel } = assertWithinAllowlist({ @@ -143,6 +208,14 @@ function main() { pruneHint: 'edit scripts/lint-allow-test-rule-refs.allowlist.json', }); + assertTightCeiling({ + label: 'allow-test-rule-total-files', + actualMax: exemptionFiles.length, + ceiling: ceiling.maxFiles, + grace: ceiling.grace, + fail: (msg) => failures.push(`${msg}\n(edit scripts/lint-allow-test-rule-refs.ceiling.json)`), + }); + if (failures.length > 0) { for (const msg of failures) process.stderr.write(`${msg}\n`); if (novel.length > 0) { @@ -155,7 +228,8 @@ function main() { } console.log( - `ok lint-allow-test-rule-refs: ${current.length} grandfathered exemption(s) tracked, no novel untracked offenders` + `ok lint-allow-test-rule-refs: ${current.length} grandfathered exemption(s) tracked, ` + + `${exemptionFiles.length}/${ceiling.maxFiles} exemption file(s) (ceiling), no novel untracked offenders` ); } diff --git a/tests/lint-allow-test-rule-refs.test.cjs b/tests/lint-allow-test-rule-refs.test.cjs new file mode 100644 index 000000000..4b57617e9 --- /dev/null +++ b/tests/lint-allow-test-rule-refs.test.cjs @@ -0,0 +1,294 @@ +'use strict'; + +// Tests for scripts/lint-allow-test-rule-refs.cjs — the dual guard that (a) +// ratchets exemption-marker comments on IDENTITY (uncited comments must +// carry a tracking-issue ref or be grandfathered) and (b) ratchets the total +// distinct exemption-file count against a tight ceiling via assertTightCeiling +// (scripts/lib/allowlist-ratchet.cjs). Uses the script's env overrides to +// point at sandbox fixture dirs/files; never touches the real tests/ dir or +// the real allowlist/ceiling JSON. +// +// Identifier note: the script computes `relpath = path.relative(ROOT, full)` +// where ROOT is the repo root (path.join(__dirname, '..') inside the script), +// NOT relative to the fixture tests dir. Since fixtures live in a temp dir +// outside the repo, the identifiers the script produces are relative paths +// like `../../../../tmp/xyz/tests-0/foo.test.cjs`, not `tests/foo.test.cjs`. +// This file mirrors that exact computation (relToRoot below) rather than +// hardcoding a `tests/...`-shaped string, so assertions match reality +// regardless of where the OS places the temp dir. + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { createTempDir, cleanup } = require('./helpers.cjs'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { toLegacyResult } = require('./helpers/git-fixture.cjs'); +const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + +const ROOT = path.join(__dirname, '..'); +const SCRIPT = path.join(ROOT, 'scripts', 'lint-allow-test-rule-refs.cjs'); + +// Deliberately split so this file's OWN source never contains the contiguous +// exemption-marker substring the script under test scans for — the script +// does a raw-text scan (not AST/comment parsing), so an unsplit literal here +// would make THIS test file register as its own offender when the real +// lint:ci run scans tests/. +const MARKER = 'allow' + '-test-rule:'; + +let sandbox; +let fixtureCount = 0; + +// Mirrors the script's own `path.relative(ROOT, full).split(path.sep).join('/')` +// computation (scripts/lint-allow-test-rule-refs.cjs's walkTestFiles) so +// expected identifiers are derived, never hardcoded as `tests/...`. +function relToRoot(fullPath) { + return path.relative(ROOT, fullPath).split(path.sep).join('/'); +} + +/** + * Writes `files` (name -> content) into a fresh sandbox tests dir, plus an + * allowlist JSON and a ceiling JSON, then invokes the script via its three + * env-var overrides. + * + * @param {object} opts + * @param {Object} opts.files - filename -> file content. + * @param {string[]} [opts.allowlist] - allowlist array (default []). + * @param {{maxFiles:number,grace:number}} [opts.ceiling] - default is + * deliberately generous so a case testing ONLY the citation check does not + * accidentally also trip the ceiling check. + * @param {string[]} [opts.args] - extra CLI argv. + */ +function runLint({ files, allowlist = [], ceiling = { maxFiles: 1000, grace: 1000 }, args = [] }) { + const testsDir = path.join(sandbox, `tests-${fixtureCount}`); + fs.mkdirSync(testsDir, { recursive: true }); + const relpaths = {}; + for (const [name, content] of Object.entries(files)) { + const full = path.join(testsDir, name); + fs.writeFileSync(full, content); + relpaths[name] = relToRoot(full); + } + const allowlistPath = path.join(sandbox, `allowlist-${fixtureCount}.json`); + fs.writeFileSync(allowlistPath, JSON.stringify(allowlist)); + const ceilingPath = path.join(sandbox, `ceiling-${fixtureCount}.json`); + fs.writeFileSync(ceilingPath, JSON.stringify(ceiling)); + fixtureCount += 1; + + const result = runNode([SCRIPT, ...args], { + cwd: ROOT, + timeoutMs: PROBE_TIMEOUT_MS, + env: { + ...process.env, + GSD_LINT_ALLOW_TEST_RULE_TESTS_DIR: testsDir, + GSD_LINT_ALLOW_TEST_RULE_ALLOWLIST: allowlistPath, + GSD_LINT_ALLOW_TEST_RULE_CEILING: ceilingPath, + }, + }); + return { ...toLegacyResult(result), relpaths, testsDir }; +} + +describe('lint-allow-test-rule-refs', () => { + before(() => { + sandbox = createTempDir('gsd-lint-allow-test-rule-'); + }); + + after(() => { + cleanup(sandbox); + }); + + test('passes when a known uncited exemption is grandfathered', () => { + const files = { 'foo.test.cjs': `// ${MARKER} some-reason\n` }; + const testsDir = path.join(sandbox, `tests-${fixtureCount}`); + fs.mkdirSync(testsDir, { recursive: true }); + const full = path.join(testsDir, 'foo.test.cjs'); + fs.writeFileSync(full, files['foo.test.cjs']); + const relpath = relToRoot(full); + const allowlistPath = path.join(sandbox, `allowlist-${fixtureCount}.json`); + fs.writeFileSync(allowlistPath, JSON.stringify([`${relpath} :: some-reason`])); + const ceilingPath = path.join(sandbox, `ceiling-${fixtureCount}.json`); + fs.writeFileSync(ceilingPath, JSON.stringify({ maxFiles: 100, grace: 100 })); + fixtureCount += 1; + + const r = toLegacyResult( + runNode([SCRIPT], { + cwd: ROOT, + timeoutMs: PROBE_TIMEOUT_MS, + env: { + ...process.env, + GSD_LINT_ALLOW_TEST_RULE_TESTS_DIR: testsDir, + GSD_LINT_ALLOW_TEST_RULE_ALLOWLIST: allowlistPath, + GSD_LINT_ALLOW_TEST_RULE_CEILING: ceilingPath, + }, + }) + ); + assert.strictEqual(r.status, 0, `stderr: ${r.stderr}`); + }); + + test('fails on a novel uncited exemption not in the allowlist', () => { + const r = runLint({ + files: { 'foo.test.cjs': `// ${MARKER} mystery-reason\n` }, + allowlist: [], + }); + assert.notStrictEqual(r.status, 0); + assert.match(r.stderr, /mystery-reason/); + }); + + test('fails on a stale allowlist entry (ratchet-down enforcement)', () => { + const r = runLint({ + files: { 'foo.test.cjs': 'no marker here\n' }, + allowlist: ['tests/definitely-stale-file.test.cjs :: stale-reason'], + }); + assert.notStrictEqual(r.status, 0); + assert.match(r.stderr, /stale-reason/); + assert.match(r.stderr, /definitely-stale-file\.test\.cjs/); + }); + + test('passes when exemption-file count == ceiling exactly (boundary)', () => { + const r = runLint({ + files: { + 'a.test.cjs': `// ${MARKER} see #123\n`, + 'b.test.cjs': `// ${MARKER} see #123\n`, + 'c.test.cjs': `// ${MARKER} see #123\n`, + }, + allowlist: [], + ceiling: { maxFiles: 3, grace: 0 }, + }); + assert.strictEqual(r.status, 0, `stderr: ${r.stderr}`); + }); + + test('passes when exemption-file count == ceiling - 1 (limit-1 boundary)', () => { + // 2 files, ceiling {maxFiles:3, grace:1} -> slack = 3-2 = 1, not > grace. + // NOTE: grace:0 here (matching the sibling boundary tests) would fail on + // the SEPARATE slack-ratchet check (slack 1 > grace 0), not the count + // check this test targets, so grace is widened to 1 to isolate the axis + // under test. + const r = runLint({ + files: { + 'a.test.cjs': `// ${MARKER} see #123\n`, + 'b.test.cjs': `// ${MARKER} see #123\n`, + }, + allowlist: [], + ceiling: { maxFiles: 3, grace: 1 }, + }); + assert.strictEqual(r.status, 0, `stderr: ${r.stderr}`); + }); + + test('fails when exemption-file count == ceiling + 1 (limit+1 boundary)', () => { + const r = runLint({ + files: { + 'a.test.cjs': `// ${MARKER} see #123\n`, + 'b.test.cjs': `// ${MARKER} see #123\n`, + 'c.test.cjs': `// ${MARKER} see #123\n`, + 'd.test.cjs': `// ${MARKER} see #123\n`, + }, + allowlist: [], + ceiling: { maxFiles: 3, grace: 0 }, + }); + assert.notStrictEqual(r.status, 0); + assert.match(r.stderr, /exceeds budget ceiling/); + }); + + test('passes when slack == grace exactly (boundary)', () => { + // 2 files, ceiling {maxFiles:5, grace:3} -> slack = 5-2 = 3, not > grace. + const r = runLint({ + files: { + 'a.test.cjs': `// ${MARKER} see #123\n`, + 'b.test.cjs': `// ${MARKER} see #123\n`, + }, + allowlist: [], + ceiling: { maxFiles: 5, grace: 3 }, + }); + assert.strictEqual(r.status, 0, `stderr: ${r.stderr}`); + }); + + test('passes when slack == grace - 1 (limit-1 boundary)', () => { + // 3 files, ceiling {maxFiles:5, grace:3} -> slack = 5-3 = 2, less than grace (2 < 3, not > grace). + const r = runLint({ + files: { + 'a.test.cjs': `// ${MARKER} see #123\n`, + 'b.test.cjs': `// ${MARKER} see #123\n`, + 'c.test.cjs': `// ${MARKER} see #123\n`, + }, + allowlist: [], + ceiling: { maxFiles: 5, grace: 3 }, + }); + assert.strictEqual(r.status, 0, `stderr: ${r.stderr}`); + }); + + test('fails when slack == grace + 1 (one past boundary)', () => { + // 1 file, ceiling {maxFiles:5, grace:3} -> slack = 5-1 = 4 > grace. + const r = runLint({ + files: { + 'a.test.cjs': `// ${MARKER} see #123\n`, + }, + allowlist: [], + ceiling: { maxFiles: 5, grace: 3 }, + }); + assert.notStrictEqual(r.status, 0); + assert.match(r.stderr, /Tighten the ceiling/); + }); + + test('a cited exemption passes the citation check with an empty allowlist', () => { + const r = runLint({ + files: { 'cited.test.cjs': `// ${MARKER} see #456\n` }, + allowlist: [], + ceiling: { maxFiles: 100, grace: 100 }, + }); + assert.strictEqual(r.status, 0, `stderr: ${r.stderr}`); + }); + + test('the SAME cited exemption still counts toward the ceiling total', () => { + const r = runLint({ + files: { 'cited.test.cjs': `// ${MARKER} see #456\n` }, + allowlist: [], + ceiling: { maxFiles: 0, grace: 0 }, + }); + assert.notStrictEqual(r.status, 0); + assert.match(r.stderr, /allow-test-rule-total-files/); + }); + + test('a duplicate uncited reason in one file dedupes to one identifier', () => { + const files = { + 'dup.test.cjs': `// ${MARKER} dup-reason\n// ${MARKER} dup-reason\n`, + }; + const testsDir = path.join(sandbox, `tests-${fixtureCount}`); + fs.mkdirSync(testsDir, { recursive: true }); + const full = path.join(testsDir, 'dup.test.cjs'); + fs.writeFileSync(full, files['dup.test.cjs']); + const relpath = relToRoot(full); + const allowlistPath = path.join(sandbox, `allowlist-${fixtureCount}.json`); + // A SINGLE allowlist entry suffices — if the dedupe regressed (two + // identifiers produced), this single-entry allowlist would leave one + // novel offender and fail. + fs.writeFileSync(allowlistPath, JSON.stringify([`${relpath} :: dup-reason`])); + const ceilingPath = path.join(sandbox, `ceiling-${fixtureCount}.json`); + fs.writeFileSync(ceilingPath, JSON.stringify({ maxFiles: 100, grace: 100 })); + fixtureCount += 1; + + const r = toLegacyResult( + runNode([SCRIPT], { + cwd: ROOT, + timeoutMs: PROBE_TIMEOUT_MS, + env: { + ...process.env, + GSD_LINT_ALLOW_TEST_RULE_TESTS_DIR: testsDir, + GSD_LINT_ALLOW_TEST_RULE_ALLOWLIST: allowlistPath, + GSD_LINT_ALLOW_TEST_RULE_CEILING: ceilingPath, + }, + }) + ); + assert.strictEqual(r.status, 0, `stderr: ${r.stderr}`); + }); + + test('repo baseline passes (real tests/ dir against real allowlist + ceiling)', () => { + const r = runNode([SCRIPT], { cwd: ROOT, timeoutMs: PROBE_TIMEOUT_MS }); + assert.strictEqual(r.exitCode, 0, `stderr: ${r.stderr}\nstdout: ${r.stdout}`); + }); + + test('unknown CLI arguments are rejected with exit code 2', () => { + const r = runLint({ files: {}, allowlist: [], args: ['--bogus'] }); + assert.strictEqual(r.status, 2); + assert.match(r.stderr, /unknown argument/); + assert.match(r.stderr, /--bogus/); + }); +});