Files
msd-core/scripts/lint-resolution-provenance.cjs
Tom Boucher 3eb1cede26 fix(#1880): distinguish a corrupt config from an absent one (epic #1879 Phase 1) (#2688)
* test(#1880): prove corrupt config is indistinguishable from absent

Failing-first. Encodes the issue's runtime repro: a trailing comma in
.planning/config.json currently yields source:builtin-defaults with
degraded:false - byte-identical to the file not existing - and the user's
entire configuration is silently discarded.

Asserts on the typed surface (CONFIG_REASON, _warnedUnusableConfig) rather
than diagnostic prose, per the ADR-1411 amendment's test-methodology clause
and CONTRIBUTING.md's raw-text-matching rule. IO failure is injected by
monkeypatching fs.readFileSync and restoring in t.after(), never chmod 0o000
(root bypasses mode bits).

Refs #1879

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

* fix(#1880): distinguish a corrupt config from an absent one

loadConfigResolved wrapped the read, the JSON.parse and the entire config
build in one try with one catch, so ENOENT, EACCES and SyntaxError all fell
through to the same defaults and the branches returned degraded:false -
actively asserting health over discarded configuration. A single trailing
comma in .planning/config.json silently replaced the user's whole config,
reporting source:builtin-defaults degraded:false, byte-identical to having
no config file at all.

ConfigResolution now carries a machine-readable reason. Genuine absence keeps
degraded:false / not_configured; a file that exists but cannot be used sets
degraded:true with config_unparseable or config_unreadable. The same split
applies to the root config and to ~/.gsd/defaults.json.

Control flow is deliberately unchanged. preflight_check reports cyclomatic
141 / cognitive 196 and 93 dependents on this function, with the guidance
that small edits beat one big one, so faults are CAPTURED at the existing
read sites and stamped onto the returns rather than the try/catch being
restructured.

Also carries the ADR-1411 amendment's wiring clause: loadConfig returns
.config alone to ~51 call sites and would never see the new field, so an
unusable file emits a deduplicated stderr diagnostic keyed on resolved path
plus errno. Without it the reason would be an unreachable field and the user
whose config was discarded would still get no signal - the actual defect.

Registers the config-loader seam in lint-resolution-provenance, which until
now guarded only agent-skills.

Caller audit: ConfigResolution.degraded has exactly one consumer outside this
module, cmdAgentSkills (src/init.cts:2259), which destructures
{config, source, degraded} - adding a field does not break it. Its --json IR
now reports degraded:true for a corrupt config, which is the intended fix and
the one observable behavior change.

Closes #1880

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

* fix(#1880): degrade when any config on the path is unusable, not just the last

Two defects found by isolated adversarial review of the first cut.

BLOCKER: the success-path return did not consult configFault. A corrupt ROOT
config whose workstream override happened to parse returned degraded:false /
reason:resolved - the root's settings silently dropped, which is the exact
failure this issue closes, reappearing for any project using workstreams. The
stderr diagnostic fired, so the out-of-band half worked while the in-band half
reported a clean resolve; a --json consumer saw health.

MAJOR: reason was derived from Object.keys(parsed) - the root+workstream MERGE
- so an empty workstream file inheriting a non-empty root reported resolved
despite carrying no settings. Emptiness is now judged on the file actually
read, snapshotted before normalizeLegacyKeys mutates it.

Also: corrects the ConfigResolution JSDoc, which still described the pre-#1880
degraded contract; adds a fast-check property asserting a PRESENT file is
never reported not_configured whatever its bytes (CONTRIBUTING.md parser
rule); and asserts the literal enum values so the provenance lint's
configured_empty/not_configured markers check real assertions rather than
incidental prose in test titles.

Refs #1879

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

* fix(#1880): reject valid JSON that is not a config object at the read seam

The fast-check property added in the previous commit failed on both node
lanes: a config.json containing 0, "str", [], null or true is valid JSON, so
it parsed "ok", then threw downstream in normalizeLegacyKeys, and the outer
catch reported not_configured - a PRESENT file reported as absent, which is
precisely the collapse this issue exists to close. The property asserts a
present file is never not_configured, and it caught it.

_readConfigFile now validates shape, not just parseability (ADR-227: check
the semantic shape at a trust boundary, not merely the type). A non-object
JSON document is an unusable config, reported config_unparseable.

Adds named regression cases for each non-object form alongside the property,
so the class is documented and not only randomly sampled.

Refs #1879

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

* chore(#1880): backfill changeset pr number (pr:0 -> 2688)

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

* test(#2452): record a fetch-time shallow failure instead of crashing

This guard failed CI on ubuntu-24 while passing on ubuntu-22 and
windows-24 for the same commit, and passed on other PRs. Not a flake and not
caused by the change under test - a real fragility in the test.

runnerDiff ran the base fetch OUTSIDE its try and only guarded the diff, so it
assumed the failure mode is always 'fetch succeeds, diff reports no merge
base'. At a shallow boundary that lands short of the merge base, git can
instead fail during the FETCH ('unable to parse commit' - the boundary
commit's parent is not available). Which stage git fails at is version and
transport dependent, so on some runners the error escaped runnerDiff and
crashed the test rather than being recorded as the ok:false the assertions
expect. Both stages mean the same thing for what this guard protects: a
shallow base ref cannot resolve the three-dot diff.

Also drops two assert.match calls against git's stderr prose. 'no merge base'
and 'unable to parse commit' are the same condition reported at different
stages, and CONTRIBUTING prohibits raw text matching on subprocess output.
The typed outcome (ok === false) is the contract; the tests now assert that
plus the presence of a cause.

Found while investigating the red lane on #2688; fixed here per the no-defer
rule rather than filed.

Refs #1879

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:09:13 -04:00

202 lines
7.3 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',
},
{
// Registered by #1880 (ADR-1411 amendment "corrupt is not absent"). Until
// this entry existed the config-loader seam carried the provenance contract
// with nothing guarding it, so a regression that collapsed configured_empty
// back into not_configured would have shipped silently.
verb: 'config-loader',
sourceFile: 'src/config-loader.cts',
testFile: 'tests/config-loader.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);