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>
This commit is contained in:
1
scripts/lint-resolution-provenance.allowlist.json
Normal file
1
scripts/lint-resolution-provenance.allowlist.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
192
scripts/lint-resolution-provenance.cjs
Normal file
192
scripts/lint-resolution-provenance.cjs
Normal file
@@ -0,0 +1,192 @@
|
||||
#!/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);
|
||||
Reference in New Issue
Block a user