* 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>
269 lines
10 KiB
JavaScript
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);
|