Files
msd-core/scripts/lint-test-file-count.cjs
Tom Boucher 596540f864 feat(#3227): publish machine-readable state contract at step boundaries (#3824)
* feat(#3227): publish machine-readable state contract at step boundaries

Adds src/state-contract.cts, a best-effort publisher that writes
.planning/state.json (contract 1.0.0) at 11 step-boundary commands, so
external tools read a versioned contract instead of parsing STATE.md and
ROADMAP.md heuristically.

Composes existing owners rather than re-deriving: phase rows come from a
new locateProgressTable extracted from deriveProgressFromRoadmap (so the
snapshot can never disagree with GSD's own progress counters), milestone
identity from getMilestoneInfo, and next from classifyProject. Owners are
required lazily to avoid the state -> state-contract -> smart-entry ->
state require cycle.

Also fixes a pre-existing defect in scripts/lint-test-file-count.cjs
(maintainer-approved as a second concern): testEffectivePrefix never
stripped the suite qualifier, so 65 dotted test files counted against no
module and 9 mis-bucketed into a shorter one. Allowlist re-baselined for
the 74 files the gate can now see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3227): backfill PR number into the changeset fragment

pr:0 -> pr:3824 now that the PR exists. Doc-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#3227): shape hostile-name fixtures away from the scan corpus

The two hostile-input fixtures used a literal phrase from
scripts/prompt-injection-scan.sh's corpus, so CI's Security Scan redded on
this file. These tests assert that an arbitrary phase name round-trips into
state.json as inert data -- the property holds for any string, so the
injection flavor is illustrative, not load-bearing.

Reshaped to a hyphenated fake instruction tag, which stays hostile-looking
while matching none of the scanner's patterns. Allowlisting the file was
rejected: that mechanism is for suites whose subject IS injection defense,
and it would blind the scanner to this whole file permanently.
See DEFECT.PROMPT-INJECTION-SCAN-COLLISION.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3227): ratchet the state-contract mutation floor to its measured score

The module was registered at minScore 50, the ratchet's minimum permitted
floor for a newly-registered module whose score had not been measured. This
PR's own Stryker shard measured 66.25% (run 32769289750, job 97565813640),
so the floor moves to floor(measured) - 1 = 65, per the rule the registry
documents.

66.25 is below TARGET_MUTATION_SCORE (80), so this stays a ratchet
candidate: raise as the tests improve, never lower.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 17:56:02 -04:00

269 lines
10 KiB
JavaScript

#!/usr/bin/env node
/**
* lint-test-file-count.cjs — max 2 test files per production module.
*
* Scans sdk/src/query/, sdk/src/, gsd-core/bin/lib/, bin/ for production
* modules, then counts matching test files in tests/ and sdk/src (recursive). Cap is 2
* (primary + one integration). Over-limit clusters must be in the allowlist with the
* EXACT set of test filenames grandfathered (identity ratchet via allowlist-ratchet.cjs).
* Adding a new test file to a capped module is a novel offender; removing one requires
* pruning the allowlist entry (stale). --json emits structured output.
*
* Verdicts: OK_UNDER_LIMIT | OK_IN_ALLOWLIST | FAIL_EXCEEDS_LIMIT |
* FAIL_NOVEL_FILES | FAIL_STALE_ALLOWLIST
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { assertWithinAllowlist } = require('./lib/allowlist-ratchet.cjs');
const { runMain } = require('./lib/cli-exit.cjs');
const ROOT = path.join(__dirname, '..');
const PROD_DIRS = [
path.join(ROOT, 'sdk', 'src', 'query'),
path.join(ROOT, 'sdk', 'src'),
path.join(ROOT, 'gsd-core', 'bin', 'lib'),
path.join(ROOT, 'bin'),
];
const TEST_DIRS = [
path.join(ROOT, 'tests'),
path.join(ROOT, 'sdk', 'src'),
];
const ALLOWLIST_PATH = path.join(__dirname, 'lint-test-file-count.allowlist.json');
const MAX_FILES = 2;
const Verdict = Object.freeze({
OK_UNDER_LIMIT: 'OK_UNDER_LIMIT',
OK_IN_ALLOWLIST: 'OK_IN_ALLOWLIST',
FAIL_EXCEEDS_LIMIT: 'FAIL_EXCEEDS_LIMIT',
FAIL_NOVEL_FILES: 'FAIL_NOVEL_FILES',
FAIL_STALE_ALLOWLIST: 'FAIL_STALE_ALLOWLIST',
});
function isTestFile(name) {
return name.endsWith('.test.ts') || name.endsWith('.test.cjs');
}
function listFiles(dir, pred) {
try {
return fs.readdirSync(dir, { withFileTypes: true })
.filter(e => e.isFile() && pred(e.name))
.map(e => path.join(dir, e.name));
} catch (_) { return []; }
}
function findTestFilesRecursive(dir) {
const out = [];
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch (_) { return out; }
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) out.push(...findTestFilesRecursive(full));
else if (isTestFile(e.name)) out.push(full);
}
return out;
}
function prodPrefix(filename) {
return filename.replace(/\.(cjs|ts|js)$/, '');
}
// Strip .test.{cjs,ts} and .integration.test.ts, then strip issue stamps.
function testEffectivePrefix(testName) {
const bare = testName
.replace(/\.integration\.test\.(ts|cjs)$/, '')
.replace(/\.test\.(ts|cjs)$/, '')
// #3227: strip a trailing suite/kind qualifier (`.unit`, `.property`,
// `.security`, `.slow`, `.install`, `.rule`, `.regression`, `.qa`, ...).
// A production prefix comes from `prodPrefix`, which strips only the file
// extension, so it never contains a dot — any surviving trailing `.<word>`
// is a qualifier. Without this, `frontmatter.property` matched no module at
// all (65 files counted against nothing repo-wide) and `state-contract.unit`
// matched the SHORTER `state` module through the `prefix + '-'` rule
// (9 files mis-bucketed).
.replace(/\.[a-z0-9]+$/i, '');
const m = bare.match(/^(?:feat|bug|enh|fix)-\d+(?:-\d+)*-(.+)$/);
return m ? m[1] : bare;
}
function collectProdPrefixes() {
const map = new Map();
for (const dir of PROD_DIRS) {
for (const f of listFiles(dir, n =>
!isTestFile(n) &&
!/\.(generated|md|json)(\.|$)/.test(n) &&
/\.(ts|cjs|js)$/.test(n)
)) {
const prefix = prodPrefix(path.basename(f));
if (!map.has(prefix)) map.set(prefix, f);
}
}
return map;
}
function collectAllTestFiles() {
const seen = new Set();
const all = [];
for (const dir of TEST_DIRS) {
for (const f of findTestFilesRecursive(dir)) {
if (!seen.has(f)) { seen.add(f); all.push(f); }
}
}
return all;
}
function buildTestMap(prodPrefixes, allTestFiles) {
const map = new Map([...prodPrefixes.keys()].map(p => [p, []]));
for (const tf of allTestFiles) {
const ep = testEffectivePrefix(path.basename(tf));
// fs.readdirSync order (and therefore prodPrefixes' Map insertion order,
// which is built from it in collectProdPrefixes) is NOT stable across
// platforms/filesystems — e.g. ext4 hash order on Linux CI differs from
// HFS+/APFS on macOS. When one module's name is a hyphen-extension of
// another's (e.g. `verify` and `verify-command-grounding`), first-match-
// wins bucketing is therefore platform-dependent. Scan every candidate
// and keep the longest (most specific) matching prefix, so the bucket a
// test file lands in is decided by specificity, never iteration order.
let bestPrefix = null;
for (const prefix of prodPrefixes.keys()) {
if (ep === prefix || ep.startsWith(prefix + '-')) {
if (bestPrefix === null || prefix.length > bestPrefix.length) {
bestPrefix = prefix;
}
}
}
if (bestPrefix !== null) {
map.get(bestPrefix).push(tf);
}
}
return map;
}
function loadAllowlist() {
try { return JSON.parse(fs.readFileSync(ALLOWLIST_PATH, 'utf-8')).modules || {}; }
catch (_) { return {}; }
}
/**
* Evaluate one module's test files against the allowlist.
*
* For modules under the default cap (≤ MAX_FILES): simple count check.
* For modules with an allowlist entry: identity check via assertWithinAllowlist.
* - count now ≤ MAX_FILES → FAIL_STALE_ALLOWLIST (all known files are stale; prune entry)
* - novel files (in current but not in known) → FAIL_NOVEL_FILES
* - stale files (in known but not in current) → FAIL_STALE_ALLOWLIST
* - exact match → OK_IN_ALLOWLIST
* For modules over the cap with no allowlist entry: FAIL_EXCEEDS_LIMIT.
*
* Returns { verdict, prefix, count, knownFiles, novel, stale, files }
*/
function evaluateLint({ prefix, testFiles, allowlist }) {
const count = testFiles.length;
const entry = allowlist[prefix];
const currentNames = testFiles.map(f => path.basename(f));
if (entry !== undefined) {
const knownFiles = Array.isArray(entry.files) ? entry.files : [];
// If the module is now at or under the default cap, the entire allowlist entry is
// stale and must be removed — this is a ratchet-DOWN failure, not a hint.
// All known files are stale (the whole entry can go).
if (count <= MAX_FILES) {
return {
verdict: Verdict.FAIL_STALE_ALLOWLIST,
prefix, count,
knownFiles,
novel: [],
stale: knownFiles.slice().sort(),
files: testFiles,
};
}
// Identity check via assertWithinAllowlist
const messages = [];
const { novel, stale } = assertWithinAllowlist({
label: prefix,
current: currentNames,
known: knownFiles,
fail: (msg) => messages.push(msg),
pruneHint: 'scripts/lint-test-file-count.allowlist.json',
});
if (novel.length > 0) {
return { verdict: Verdict.FAIL_NOVEL_FILES, prefix, count, knownFiles, novel, stale, files: testFiles };
}
if (stale.length > 0) {
return { verdict: Verdict.FAIL_STALE_ALLOWLIST, prefix, count, knownFiles, novel, stale, files: testFiles };
}
return { verdict: Verdict.OK_IN_ALLOWLIST, prefix, count, knownFiles, novel: [], stale: [], files: testFiles };
}
if (count <= MAX_FILES) {
return { verdict: Verdict.OK_UNDER_LIMIT, prefix, count, knownFiles: null, novel: [], stale: [], files: testFiles };
}
return { verdict: Verdict.FAIL_EXCEEDS_LIMIT, prefix, count, knownFiles: null, novel: [], stale: [], files: testFiles };
}
function run() {
const jsonMode = process.argv.includes('--json');
const prodPrefixes = collectProdPrefixes();
const allTestFiles = collectAllTestFiles();
const testMap = buildTestMap(prodPrefixes, allTestFiles);
const allowlist = loadAllowlist();
const results = [];
for (const [prefix, files] of testMap) {
if (files.length === 0) continue;
results.push(evaluateLint({ prefix, testFiles: files, allowlist }));
}
const failures = results.filter(r =>
r.verdict === Verdict.FAIL_EXCEEDS_LIMIT ||
r.verdict === Verdict.FAIL_NOVEL_FILES ||
r.verdict === Verdict.FAIL_STALE_ALLOWLIST);
if (jsonMode) {
console.log(JSON.stringify({ ok: failures.length === 0, results, failures, hints: [] }, null, 2));
return failures.length > 0 ? 1 : 0;
}
if (failures.length === 0) {
const inAllowlist = results.filter(r => r.verdict === Verdict.OK_IN_ALLOWLIST).length;
console.log(`ok lint-test-file-count: ${results.length} module(s) checked, 0 failures` +
(inAllowlist > 0 ? `, ${inAllowlist} allowlisted` : ''));
return 0;
}
process.stderr.write(`\nERROR lint-test-file-count: ${failures.length} module(s) exceed the test-file limit\n\n`);
for (const f of failures) {
if (f.verdict === Verdict.FAIL_EXCEEDS_LIMIT) {
process.stderr.write(` ${f.prefix}: ${f.count} files (limit ${MAX_FILES}) — not in allowlist\n`);
for (const tf of f.files) process.stderr.write(` ${path.relative(ROOT, tf)}\n`);
} else if (f.verdict === Verdict.FAIL_NOVEL_FILES) {
process.stderr.write(` ${f.prefix}: ${f.novel.length} NEW test file(s) not in allowlist\n`);
for (const n of f.novel) process.stderr.write(` + ${n}\n`);
} else if (f.verdict === Verdict.FAIL_STALE_ALLOWLIST) {
if (f.count <= MAX_FILES) {
const staleList = f.stale.join(', ');
process.stderr.write(` "${f.prefix}": now at ${f.count} file(s) (≤ ${MAX_FILES}) — remove its entry from the allowlist (stale: ${staleList})\n`);
} else {
process.stderr.write(` ${f.prefix}: ${f.stale.length} allowlisted file(s) no longer present — prune allowlist\n`);
for (const s of f.stale) process.stderr.write(` - ${s}\n`);
}
}
}
process.stderr.write('\nFix: consolidate test files per module (one primary + one integration).\n');
process.stderr.write('Or update scripts/lint-test-file-count.allowlist.json with PR justification.\n\n');
return 1;
}
module.exports = {
Verdict, evaluateLint, testEffectivePrefix, prodPrefix,
_collectProdPrefixes: collectProdPrefixes,
_collectAllTestFiles: collectAllTestFiles,
_buildTestMap: buildTestMap,
_loadAllowlist: loadAllowlist,
};
if (require.main === module) runMain(run);