feat(#3309): add health-diagnostic rule-table lint guard
Enforces ADR-3180 §8.2's 1:1 rule-code invariant (every code unique, every severity a property of the Rule) and §8.5's fixture-proof invariant (every code has a describe()/test() block naming it, verified statically against tests/health-diagnostic-rules/*.test.cjs and tests/health-diagnostic.test.cjs) for the new RULES table. Adapted from the design doc's original plan of separate tests/fixtures/health-diagnostic/<code>.* files: implementation used inline temp-dir fixtures instead (mirrors tests/planning-snapshot.test.cjs), so coverage is checked statically against test-file structure, mirroring lint-fix-has-regression-test.cjs's house style. Wired into lint:ci adjacent to lint-planning-snapshot-bypass-drift.cjs, its closest sibling. Passes clean against the real tree: 31 codes, all unique, all covered.
This commit is contained in:
250
scripts/lint-health-diagnostic-rule-table.cjs
Normal file
250
scripts/lint-health-diagnostic-rule-table.cjs
Normal file
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* lint-health-diagnostic-rule-table.cjs — gate: enforces ADR-3180 §8.2's 1:1
|
||||
* rule-code invariant and §8.5's fixture-proof invariant for
|
||||
* `src/health-diagnostic.cts`'s RULES table (Phase 11, #3309).
|
||||
*
|
||||
* ## What this enforces
|
||||
*
|
||||
* 1. (§8.2 rule 1 — 1:1 code invariant) Every `rule.code` in RULES (exported
|
||||
* from the compiled `gsd-core/bin/lib/health-diagnostic.cjs`) is unique,
|
||||
* and every rule's `severity` is one of `SEVERITY`'s values. The severity
|
||||
* check exists only to confirm the compiled artifact was not hand-edited
|
||||
* to bypass the `Rule.severity` required field TypeScript already
|
||||
* enforces at compile time — "severity is a property of the RULE, never
|
||||
* the emit call."
|
||||
* 2. (§8.5 — fixture-proof invariant) Every code in RULES has a paired test:
|
||||
* the code string (e.g. `'W001'`) appears as a literal AND within a
|
||||
* `describe(`/`test(` block whose title also names that exact code, in
|
||||
* one of the health-diagnostic test files
|
||||
* (`tests/health-diagnostic-rules/*.test.cjs`,
|
||||
* `tests/health-diagnostic.test.cjs`). A mere comment/string mention
|
||||
* outside a titled block does not count as coverage.
|
||||
*
|
||||
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
|
||||
* ("The lint guard (§8.2 1:1 invariant + §8.5 fixture proof)").
|
||||
*
|
||||
* ## Deviation from the design doc's original plan
|
||||
*
|
||||
* The design doc assumed fixtures would live as separate files at
|
||||
* `tests/fixtures/health-diagnostic/<code>.*`. That did not happen during
|
||||
* implementation — all 8 rule-group test files
|
||||
* (`tests/health-diagnostic-rules/*.test.cjs`) build fixtures INLINE via
|
||||
* real temp directories (`createTempDir()` from `tests/helpers.cjs`) and a
|
||||
* real, non-mocked `buildPlanningSnapshot(tmpCwd)` call (see
|
||||
* `tests/health-diagnostic-rules/root-existence.test.cjs`). This guard
|
||||
* therefore verifies the fixture-proof invariant STATICALLY against the
|
||||
* test files' own text — mirroring `scripts/lint-fix-has-regression-test.cjs`'s
|
||||
* house style — rather than dynamically re-running fixture-building code
|
||||
* this guard does not own.
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const COMPILED_MODULE_REL = 'gsd-core/bin/lib/health-diagnostic.cjs';
|
||||
const COMPILED_MODULE_PATH = path.join(REPO_ROOT, COMPILED_MODULE_REL);
|
||||
const TEST_GROUP_DIR = path.join(REPO_ROOT, 'tests', 'health-diagnostic-rules');
|
||||
const SKELETON_TEST_FILE = path.join(REPO_ROOT, 'tests', 'health-diagnostic.test.cjs');
|
||||
|
||||
// Matches `describe(`/`test(`/`it(` calls whose first argument is a string
|
||||
// literal, capturing that literal as the block's title. Line/regex-based
|
||||
// (not full AST) per this repo's existing lint-guard house style
|
||||
// (scripts/lint-planning-snapshot-bypass-drift.cjs's scanCode precedent).
|
||||
const TITLED_BLOCK_RE = /\b(describe|test|it)\(\s*(['"`])((?:\\.|(?!\2)[^\\])*)\2/g;
|
||||
|
||||
/**
|
||||
* Load the compiled health-diagnostic module. Throws a clear ExitError
|
||||
* (not a raw MODULE_NOT_FOUND) if `npm run build:lib` has not run.
|
||||
*/
|
||||
function loadCompiledModule(compiledPath = COMPILED_MODULE_PATH) {
|
||||
if (!fs.existsSync(compiledPath)) {
|
||||
throw new ExitError(
|
||||
2,
|
||||
`lint-health-diagnostic-rule-table: compiled artifact not found at ${COMPILED_MODULE_REL}.\n` +
|
||||
'Run `npm run build:lib` first.',
|
||||
);
|
||||
}
|
||||
return require(compiledPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.2 rule 1 — 1:1 code invariant: every rule.code is unique, and every
|
||||
* rule's severity is a member of SEVERITY's values.
|
||||
*
|
||||
* @param {Array<{code: string, severity: string}>} rules
|
||||
* @param {Record<string, string>} severity SEVERITY export (code -> value)
|
||||
* @returns {{duplicates: Array<{code: string, count: number}>, badSeverities: Array<{code: string, severity: unknown}>}}
|
||||
*/
|
||||
function checkOneToOneInvariant(rules, severity) {
|
||||
const severityValues = new Set(Object.values(severity));
|
||||
const counts = new Map();
|
||||
const badSeverities = [];
|
||||
|
||||
for (const rule of rules) {
|
||||
counts.set(rule.code, (counts.get(rule.code) || 0) + 1);
|
||||
if (!severityValues.has(rule.severity)) {
|
||||
badSeverities.push({ code: rule.code, severity: rule.severity });
|
||||
}
|
||||
}
|
||||
|
||||
const duplicates = [...counts.entries()]
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([code, count]) => ({ code, count }));
|
||||
|
||||
return { duplicates, badSeverities };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts every `describe(`/`test(`/`it(` block title found in `text`.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function extractTitledBlocks(text) {
|
||||
const titles = [];
|
||||
TITLED_BLOCK_RE.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = TITLED_BLOCK_RE.exec(text)) !== null) {
|
||||
titles.push(match[3]);
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff `code` appears verbatim, as a whole token, inside at least one of
|
||||
* `titles`. Whole-token match guards against a shorter code accidentally
|
||||
* substring-matching inside an unrelated longer token.
|
||||
*
|
||||
* @param {string} code
|
||||
* @param {string[]} titles
|
||||
*/
|
||||
function codeAppearsInTitle(code, titles) {
|
||||
const codeRe = new RegExp(`(?:^|[^A-Za-z0-9])${code}(?:$|[^A-Za-z0-9])`);
|
||||
return titles.some((title) => codeRe.test(`|${title}|`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates every health-diagnostic test file this guard scans for §8.5
|
||||
* fixture-proof coverage.
|
||||
*
|
||||
* @param {string} repoRoot
|
||||
* @returns {string[]} absolute paths, sorted
|
||||
*/
|
||||
function findHealthDiagnosticTestFiles(repoRoot = REPO_ROOT) {
|
||||
const groupDir = path.join(repoRoot, 'tests', 'health-diagnostic-rules');
|
||||
const files = [];
|
||||
if (fs.existsSync(groupDir)) {
|
||||
for (const entry of fs.readdirSync(groupDir)) {
|
||||
if (entry.endsWith('.test.cjs')) {
|
||||
files.push(path.join(groupDir, entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
const skeletonTestFile = path.join(repoRoot, 'tests', 'health-diagnostic.test.cjs');
|
||||
if (fs.existsSync(skeletonTestFile)) {
|
||||
files.push(skeletonTestFile);
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.5 — fixture-proof invariant: for every code in `rules`, confirm at
|
||||
* least one test file in `testFiles` has a `describe(`/`test(`/`it(` block
|
||||
* whose title names that exact code.
|
||||
*
|
||||
* @param {Array<{code: string}>} rules
|
||||
* @param {string[]} testFiles absolute paths to *.test.cjs files to scan
|
||||
* @returns {{uncovered: string[], testFilesScanned: string[]}}
|
||||
*/
|
||||
function checkFixtureProofInvariant(rules, testFiles) {
|
||||
const allTitles = [];
|
||||
for (const file of testFiles) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
allTitles.push(...extractTitledBlocks(text));
|
||||
}
|
||||
|
||||
const uncovered = [];
|
||||
for (const rule of rules) {
|
||||
if (!codeAppearsInTitle(rule.code, allTitles)) {
|
||||
uncovered.push(rule.code);
|
||||
}
|
||||
}
|
||||
|
||||
return { uncovered, testFilesScanned: testFiles };
|
||||
}
|
||||
|
||||
function formatRepoRelative(absPath) {
|
||||
return path.relative(REPO_ROOT, absPath).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { RULES, SEVERITY } = loadCompiledModule();
|
||||
|
||||
const { duplicates, badSeverities } = checkOneToOneInvariant(RULES, SEVERITY);
|
||||
|
||||
const testFiles = findHealthDiagnosticTestFiles(REPO_ROOT);
|
||||
const { uncovered } = checkFixtureProofInvariant(RULES, testFiles);
|
||||
|
||||
const problems = [];
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
const list = duplicates.map((d) => ` ${d.code} (${d.count} occurrences)`).join('\n');
|
||||
problems.push(
|
||||
`§8.2 rule 1 violated: ${duplicates.length} duplicated rule code(s) in RULES ` +
|
||||
`(${COMPILED_MODULE_REL}):\n${list}\n` +
|
||||
' remedy: codes are append-only and 1:1 with a single Rule — rename or remove the duplicate.',
|
||||
);
|
||||
}
|
||||
|
||||
if (badSeverities.length > 0) {
|
||||
const list = badSeverities
|
||||
.map((b) => ` ${b.code}: severity=${JSON.stringify(b.severity)}`)
|
||||
.join('\n');
|
||||
problems.push(
|
||||
`§8.2 rule 3 violated: ${badSeverities.length} rule(s) with a severity not in SEVERITY's values:\n${list}\n` +
|
||||
' remedy: severity is a property of the RULE — set it to SEVERITY.ERROR/WARNING/INFO.',
|
||||
);
|
||||
}
|
||||
|
||||
if (uncovered.length > 0) {
|
||||
const scannedList = testFiles.map(formatRepoRelative).join('\n ');
|
||||
problems.push(
|
||||
`§8.5 violated: ${uncovered.length} rule code(s) with no describe()/test() block naming them ` +
|
||||
`(a comment or bare string mention does not count):\n ${uncovered.join(', ')}\n\n` +
|
||||
` Searched these test files:\n ${scannedList}\n\n` +
|
||||
' remedy: add or extend a describe()/test() title in the matching ' +
|
||||
'tests/health-diagnostic-rules/<group>.test.cjs file so the block title ' +
|
||||
`names the code verbatim (e.g. describe('${uncovered[0]} — ...', () => { ... })), ` +
|
||||
'and drive the rule to fire against a real fixture built via createTempDir() + buildPlanningSnapshot() ' +
|
||||
'(see tests/health-diagnostic-rules/root-existence.test.cjs).',
|
||||
);
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
throw new ExitError(1, `${problems.join('\n\n')}\n`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`lint-health-diagnostic-rule-table: PASS — ${RULES.length} rule code(s), all unique, ` +
|
||||
`all severities valid, all covered by a titled test block across ${testFiles.length} test file(s).`,
|
||||
);
|
||||
}
|
||||
|
||||
runMain(main);
|
||||
|
||||
module.exports = {
|
||||
loadCompiledModule,
|
||||
checkOneToOneInvariant,
|
||||
extractTitledBlocks,
|
||||
codeAppearsInTitle,
|
||||
findHealthDiagnosticTestFiles,
|
||||
checkFixtureProofInvariant,
|
||||
COMPILED_MODULE_PATH,
|
||||
TEST_GROUP_DIR,
|
||||
SKELETON_TEST_FILE,
|
||||
};
|
||||
Reference in New Issue
Block a user