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:
4
scripts/lint-allow-test-rule-refs.ceiling.json
Normal file
4
scripts/lint-allow-test-rule-refs.ceiling.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"maxFiles": 314,
|
||||
"grace": 3
|
||||
}
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user