Files
msd-core/scripts/lint-resolution-provenance.cjs
Tom Boucher 8040a6bac0 chore(#1417): add resolution-provenance CI guard (Resolution Provenance P4) (#1428)
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>
2026-06-18 08:50:22 -04:00

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);