Adds scripts/lint-resolution-provenance.cjs — a registry + ratchet CI guard that locks in the agent-skills configured_empty/not_configured contract tests so they cannot be silently removed, and establishes a registration point for future config-interpreting read verbs (ADR-1411 P4). Design rationale: - REGISTRY (one entry: agent-skills → src/init.cts → tests/agent-skills.test.cjs) is the canonical registration site; new verbs are added here. - For each registered verb, the guard asserts its test file contains BOTH a `configured_empty` assertion AND a `not_configured` assertion — proving the configured-empty-vs-not-configured contract is explicitly tested. - NOT a universal static detector (intractable / false positives) — mirrors the no-adhoc-markdown-parsing grandfather pattern. - Uses scripts/lib/allowlist-ratchet.cjs (assertWithinAllowlist) so stale allowlist entries fail (ratchet-down) and novel offenders always fail. - checkRegistry() is factored as a pure exported function tested in tests/lint-resolution-provenance.test.cjs without shelling out. - Wired into lint:ci (package.json) and lint step name updated in test.yml. - CONTEXT.md ### Resolution Convention extended with P4 guard sentence. - Allowlist starts empty ([]) — agent-skills already has its tests. Closes #1417 Part of #1411 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
193 lines
6.9 KiB
JavaScript
193 lines
6.9 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* lint-resolution-provenance.cjs — CI guard for Resolution Provenance contracts.
|
|
*
|
|
* ## Purpose (ADR-1411 P4 / #1417)
|
|
*
|
|
* This is a REGRESSION-LOCK and REGISTRATION RATCHET, NOT a universal static
|
|
* detector (which is intractable given false positives from config-reading
|
|
* helpers that don't consume a `reason`).
|
|
*
|
|
* The guard maintains a REGISTRY of config-interpreting read verbs that MUST
|
|
* carry provenance — each entry names the verb, its source file, and its test
|
|
* file. For every registered verb, the guard asserts that its test file
|
|
* contains BOTH a `configured_empty` assertion AND a `not_configured`
|
|
* assertion, proving that the configured-empty-vs-not-configured contract is
|
|
* explicitly tested (not silently open-to-defaults).
|
|
*
|
|
* ## Registration protocol
|
|
*
|
|
* When adding a NEW config-interpreting read verb:
|
|
* 1. Add an entry to REGISTRY below: { verb, sourceFile, testFile }.
|
|
* 2. Add a `configured_empty` test and a `not_configured` test to testFile.
|
|
* 3. If the test coverage cannot land in the same PR, add the verb to
|
|
* scripts/lint-resolution-provenance.allowlist.json to grandfather it —
|
|
* but the allowlist MUST shrink over time (stale entries fail).
|
|
*
|
|
* See docs/adr/1411-resolution-provenance.md and #1417.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { assertWithinAllowlist } = require('./lib/allowlist-ratchet.cjs');
|
|
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const ALLOWLIST_PATH = path.join(__dirname, 'lint-resolution-provenance.allowlist.json');
|
|
|
|
/**
|
|
* Registry of config-interpreting read verbs that must carry provenance.
|
|
* Each entry: { verb, sourceFile, testFile }
|
|
*
|
|
* - verb: Short stable name for this verb (used in error messages and the
|
|
* allowlist).
|
|
* - sourceFile: Path (relative to ROOT) to the source implementation.
|
|
* - testFile: Path (relative to ROOT) to the test file that MUST contain
|
|
* both a `configured_empty` assertion and a `not_configured`
|
|
* assertion.
|
|
*
|
|
* Seed: agent-skills (P2/P3 fix, #1415/#1416) is the founding member.
|
|
*/
|
|
const REGISTRY = [
|
|
{
|
|
verb: 'agent-skills',
|
|
sourceFile: 'src/init.cts',
|
|
testFile: 'tests/agent-skills.test.cjs',
|
|
},
|
|
];
|
|
|
|
// Markers that MUST appear in every registered verb's test file.
|
|
const MARKER_CONFIGURED_EMPTY = 'configured_empty';
|
|
const MARKER_NOT_CONFIGURED = 'not_configured';
|
|
|
|
/**
|
|
* Pure check logic — factored out for unit testing without I/O.
|
|
*
|
|
* @param {object} opts
|
|
* @param {Array<{verb: string, sourceFile: string, testFile: string}>} opts.registry
|
|
* The REGISTRY to check (or an injected subset for tests).
|
|
* @param {string[]} opts.allowlist
|
|
* Array of verb names to grandfather (stale entries fail).
|
|
* @param {function(string): string} opts.readFile
|
|
* Reads a file path and returns its content. Injected for testability;
|
|
* callers pass `(p) => fs.readFileSync(p, 'utf8')`.
|
|
* @param {function(string): void} opts.fail
|
|
* Callback invoked with a descriptive failure message.
|
|
* @returns {{ ok: boolean }}
|
|
*/
|
|
function checkRegistry({ registry, allowlist, readFile, fail }) {
|
|
const allowlistSet = new Set(allowlist);
|
|
const offenders = []; // verbs that ARE failing (for ratchet: stale check)
|
|
let anyFail = false;
|
|
|
|
for (const entry of registry) {
|
|
const { verb, testFile } = entry;
|
|
const resolvedTestFile = path.isAbsolute(testFile) ? testFile : path.join(ROOT, testFile);
|
|
|
|
// Grandfathered? Check markers anyway to detect when it's been fixed.
|
|
let content;
|
|
try {
|
|
content = readFile(resolvedTestFile);
|
|
} catch (err) {
|
|
fail(
|
|
`[resolution-provenance] Cannot read test file for verb "${verb}" (${testFile}): ${err.message}\n` +
|
|
` Register the verb's test file correctly, or remove the registry entry.`
|
|
);
|
|
anyFail = true;
|
|
offenders.push(verb);
|
|
continue;
|
|
}
|
|
|
|
const hasConfiguredEmpty = content.includes(MARKER_CONFIGURED_EMPTY);
|
|
const hasNotConfigured = content.includes(MARKER_NOT_CONFIGURED);
|
|
|
|
if (!hasConfiguredEmpty || !hasNotConfigured) {
|
|
offenders.push(verb);
|
|
|
|
if (allowlistSet.has(verb)) {
|
|
// Grandfathered — tolerate but don't report.
|
|
continue;
|
|
}
|
|
|
|
const missing = [];
|
|
if (!hasConfiguredEmpty) missing.push(`\`configured_empty\``);
|
|
if (!hasNotConfigured) missing.push(`\`not_configured\``);
|
|
|
|
fail(
|
|
`[resolution-provenance] verb "${verb}" (${testFile}) is missing contract test marker(s):\n` +
|
|
` Missing: ${missing.join(', ')}\n` +
|
|
` Each registered config-interpreting read verb must have both a\n` +
|
|
` \`configured_empty\` assertion and a \`not_configured\` assertion in its\n` +
|
|
` test file to prove the configured-empty-vs-not-configured contract is\n` +
|
|
` tested (ADR-1411 P4 / #1417).\n` +
|
|
` Add the missing test(s) or grandfather the verb in\n` +
|
|
` scripts/lint-resolution-provenance.allowlist.json.`
|
|
);
|
|
anyFail = true;
|
|
}
|
|
}
|
|
|
|
// Ratchet: stale allowlist entries (verb is compliant but still grandfathered)
|
|
// must be pruned so the allowlist only ever shrinks.
|
|
const offenderSet = new Set(offenders);
|
|
const staleEntries = [];
|
|
for (const v of allowlistSet) {
|
|
if (!offenderSet.has(v)) {
|
|
staleEntries.push(v);
|
|
}
|
|
}
|
|
|
|
// Also verify stale entries via assertWithinAllowlist for consistent messaging.
|
|
const ratchetFailures = [];
|
|
assertWithinAllowlist({
|
|
label: 'resolution-provenance',
|
|
current: offenders,
|
|
known: allowlist,
|
|
fail: (msg) => ratchetFailures.push(msg),
|
|
pruneHint: 'edit scripts/lint-resolution-provenance.allowlist.json',
|
|
});
|
|
|
|
// Only report stale entries from the ratchet (novel offenders are already
|
|
// reported above with more actionable messages).
|
|
if (staleEntries.length > 0) {
|
|
for (const msg of ratchetFailures) {
|
|
// Only surface the stale-entry message (it contains "stale" or "no longer").
|
|
if (msg.includes('stale') || msg.includes('no longer')) {
|
|
fail(msg);
|
|
anyFail = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { ok: !anyFail };
|
|
}
|
|
|
|
function main() {
|
|
const allowlist = JSON.parse(fs.readFileSync(ALLOWLIST_PATH, 'utf8'));
|
|
|
|
const failures = [];
|
|
const { ok } = checkRegistry({
|
|
registry: REGISTRY,
|
|
allowlist,
|
|
readFile: (filePath) => fs.readFileSync(filePath, 'utf8'),
|
|
fail: (msg) => failures.push(msg),
|
|
});
|
|
|
|
if (!ok) {
|
|
for (const msg of failures) process.stderr.write(`${msg}\n`);
|
|
throw new ExitError(1);
|
|
}
|
|
|
|
console.log(
|
|
`ok lint-resolution-provenance: ${REGISTRY.length} registered verb(s), all carry configured_empty + not_configured contract tests`
|
|
);
|
|
}
|
|
|
|
module.exports = { checkRegistry, REGISTRY };
|
|
|
|
// Only run the CLI check when executed directly, not when imported by tests
|
|
// (keeps the unit tests hermetic — importing checkRegistry must not run main).
|
|
if (require.main === module) runMain(main);
|