feat(#3313): allow-test-rule total-file-count ratchet, F17 promotions (#3326)

Extends lint-allow-test-rule-refs.cjs with a second, independent check
alongside the existing uncited-citation identity ratchet: the total
number of distinct test files carrying any allow-test-rule marker
(cited or not) is now checked against a tight ceiling via the
previously-unwired assertTightCeiling primitive (allowlist-ratchet.cjs,
0 prior callers). A cited exemption is legitimate under ADR-456 but
nothing stopped the raw total from growing forever - this closes that
gap without duplicating the file walk (both checks consume one shared
walkTestFiles pass).

Ceiling introduced at the exact measured high-water mark (314 files,
grace 3) rather than a padded estimate, per "budgets may only
decrease."

Also lands the two F17 pieces (absorbed from the now-closed #1885)
that had no precondition:
- --max-warnings 0 added to lint/lint:ci
- local/no-source-grep promoted warn->error in the scripts/bin/
  eslint-rules glob block (already error in the tests/ glob)

Both promotions were pre-verified against a zero-warning tree (fresh
non-cached eslint run) before flipping, per the maintainer's clean-
tree-first decision.

Not included: local/no-elapsed-assertion promotion, which stays warn
pending #3314 (H2) - 10 of 19 clock-touching src modules have no
sanctioned time-control mechanism until ADR-456 is amended there.

H1 of epic #3053, absorbing #1885 F17.

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-10 12:23:01 -04:00
committed by GitHub
parent aceea3ce4a
commit 5339dd60e5
6 changed files with 411 additions and 38 deletions

View File

@@ -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).

View File

@@ -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',
},
},

View File

@@ -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",

View File

@@ -0,0 +1,4 @@
{
"maxFiles": 314,
"grace": 3
}

View File

@@ -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:
* `<repo-relative-path> :: <trimmed-reason>`
@@ -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 `<relpath> :: <reason>` 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: <reason> ← line comment
// /* allow-test-rule: <reason> */ ← 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 `<relpath> :: <reason>` 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: <reason> ← line comment
// /* allow-test-rule: <reason> */ ← 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`
);
}

View File

@@ -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<string,string>} 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/);
});
});