* fix(#2757): let derived provenance rules attribute a transform change The Phase 2 provenance table (#2722) could only explain a moved emitted path via its `sources`, so a `kind: 'derived'` artifact whose TRANSFORM code changed (not its source) was unattributable by construction: 16 emitted agents/*.toml moved by PR #2566's runtime-artifact-conversion.cts change, with zero agents/*.md in the diff. Adds an optional per-rule `transforms: string[]`. A moved emitted path now attributes via its source OR its transform, reusing sourceSatisfiedBy so exact/prefix matching stays identical for both. Declared narrowly on agents-toml-derived and agents-verbatim (the two families verified to route through the same conversion pipeline) as src/runtime-artifact-conversion.cts, src/install-effort-resolver.cts, and src/model-catalog.cts — never bin/install.js, which would be a blanket escape hatch spanning every installer concern. Also corrects agents-verbatim from kind:'identity' to kind:'derived': measured against origin/next's own fixtures, the same agents/<name>.md hashes differently per runtime (e.g. codex vs claude), which a true verbatim copy cannot do. Verified empirically by installing every runtime and diffing against the raw repo source — none reproduce it byte-for-byte; every one rewrites frontmatter and the hardcoded .claude/ self-reference, and claude additionally gets an effort: line injected. A new assertNoIdentityTransforms invariant rejects an identity rule that declares a non-empty transforms list. Closes #2757 * docs(#2757): flag PR #2566's transform-role files for later verification src/agent-tools-contract.cts (added by #2566, absent on next) and src/agent-install-check.cts (modified +112/-1 by #2566, read-only today) were reviewed for prospective inclusion in AGENT_TRANSFORM_SRCS. Excluded for now: the nonexistent file would fail this fix's own "every declared transform path exists" hygiene test, and the modified file's future role cannot be verified without the unmerged PR's diff. Documents the reasoning, the interim ack-file safety net, and the verification method to apply once these files stabilize, so the next PR touching them has a pre-scoped one-line fix rather than a silent gap. Related to #2757
349 lines
14 KiB
JavaScript
349 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Differential emitted-artifact attribution — the conservation law (ADR-2719 §1,
|
|
* issue #2723, epic #2719 Phase 3).
|
|
*
|
|
* Given the emitted manifests at `next` HEAD and at PR HEAD, plus the repo paths the
|
|
* PR actually changed, decide which moved emitted paths are EXPLAINED by the diff and
|
|
* which are not. Unattributable deltas are a hard failure that names them; the only
|
|
* way through is a committed acknowledgment (`tests/emitted-drift-ack.json`).
|
|
*
|
|
* ── Why this module is pure ──────────────────────────────────────────────────
|
|
* No fs, no git, no installer, no clock. The naive shape — one integration test that
|
|
* builds 19 manifests at each end and asserts — cannot practically exercise the four
|
|
* failing-first criteria #2723 requires, so in practice they would not get written,
|
|
* which is precisely how a phase ships promised-but-not-built. Keeping the law pure
|
|
* makes every criterion a millisecond-scale table test, and makes the Stryker gate
|
|
* able to bite (a 20-branch pure function is mutation-testable; a 40-minute
|
|
* integration test is not).
|
|
*
|
|
* The expensive part — obtaining the manifests — lives in emitted-baseline.cjs.
|
|
*
|
|
* ── What this does NOT do ────────────────────────────────────────────────────
|
|
* It never re-derives a byte (ADR-2719 §1 is explicit that asserting
|
|
* `emitted == transform(source)` is the tautology ADR-2264's Amendment rejected).
|
|
* It constrains which keys may move. It also never mutates repo state: no
|
|
* regeneration, no auto-ack. `UPDATE_GOLDEN=1` is exactly the escape hatch this
|
|
* design removes.
|
|
*/
|
|
|
|
const { attributeEmittedPath } = require('./emitted-provenance.cjs');
|
|
|
|
/** Ack schema version. Pinned from day one: contributors hand-write this file, so its
|
|
* shape is public the moment it ships (Hyrum). Loosening later is easy; tightening is not. */
|
|
const ACK_VERSION = 1;
|
|
|
|
/**
|
|
* Does a changed repo path satisfy a provenance `sources` entry?
|
|
*
|
|
* A trailing `/` marks a PREFIX (Phase 2's SOURCE_PREFIX_SUFFIX contract) — e.g. Kimi's
|
|
* root agent aggregates all of `agents/`. Prefix matching is SEGMENT-AWARE on purpose:
|
|
* a bare `startsWith('agents/')` would also accept `agentsfoo/x.md` under a source of
|
|
* `agents`, and silently over-attribute. Exact entries compare exactly.
|
|
*/
|
|
function sourceSatisfiedBy(source, changedSet) {
|
|
if (source.endsWith('/')) {
|
|
for (const changed of changedSet) {
|
|
if (changed.startsWith(source)) return changed;
|
|
}
|
|
return null;
|
|
}
|
|
return changedSet.has(source) ? source : null;
|
|
}
|
|
|
|
/**
|
|
* Normalize + validate the acknowledgment document.
|
|
*
|
|
* Rejects a document that parses but is not a plain object. Treating `0` / `"s"` / `[]` /
|
|
* `null` / `true` as "no acks" would SILENTLY DISARM the gate — the single worst failure
|
|
* available here, because it looks identical to a healthy run.
|
|
*
|
|
* @returns {{ entries: Map<string, {reason: string, runtime?: string}>, errors: string[] }}
|
|
*/
|
|
function parseAck(doc, { source = 'emitted-drift-ack.json' } = {}) {
|
|
const errors = [];
|
|
const entries = new Map();
|
|
|
|
if (doc === null || doc === undefined) return { entries, errors }; // absent == no acks (legal)
|
|
|
|
if (typeof doc !== 'object' || Array.isArray(doc)) {
|
|
errors.push(
|
|
`${source}: must be a JSON object, got ${Array.isArray(doc) ? 'array' : typeof doc}`,
|
|
);
|
|
return { entries, errors };
|
|
}
|
|
|
|
if (doc.version !== undefined && doc.version !== ACK_VERSION) {
|
|
errors.push(`${source}: unsupported version ${JSON.stringify(doc.version)} (expected ${ACK_VERSION})`);
|
|
}
|
|
|
|
const paths = doc.paths;
|
|
if (paths === undefined) return { entries, errors }; // `{}` or `{version:1}` == no acks
|
|
if (paths === null || typeof paths !== 'object' || Array.isArray(paths)) {
|
|
errors.push(`${source}: "paths" must be an object of <emitted path> -> { reason }`);
|
|
return { entries, errors };
|
|
}
|
|
|
|
for (const [rel, value] of Object.entries(paths)) {
|
|
const reason = value && typeof value === 'object' ? value.reason : value;
|
|
if (typeof reason !== 'string' || reason.trim() === '') {
|
|
// "name them AND say why" is the contract (ADR-2719 §3). An ack with no reason
|
|
// is a silent regeneration wearing a declaration's clothes.
|
|
errors.push(`${source}: ack for "${rel}" has no non-empty "reason"`);
|
|
continue;
|
|
}
|
|
entries.set(rel, {
|
|
reason: reason.trim(),
|
|
runtime: value && typeof value === 'object' ? value.runtime : undefined,
|
|
});
|
|
}
|
|
|
|
return { entries, errors };
|
|
}
|
|
|
|
/**
|
|
* The conservation law.
|
|
*
|
|
* @param {object} opts
|
|
* @param {object} opts.baseline { [runtime]: { [rel]: hash } } at `next` HEAD
|
|
* @param {object} opts.current { [runtime]: { [rel]: hash } } at PR HEAD
|
|
* @param {string[]} opts.changedPaths repo paths the PR changed (git diff --name-only)
|
|
* @param {object} [opts.ack] parsed emitted-drift-ack.json document (or null)
|
|
* @param {object} [opts.sizeBaseline] { [name]: bytes } workflow/agent sizes at next
|
|
* @param {object} [opts.sizeCurrent] { [name]: bytes } workflow/agent sizes at PR HEAD
|
|
*
|
|
* @returns {{
|
|
* moved: number, attributed: Array, unattributable: Array, acked: Array,
|
|
* removed: Array, grown: Array, shrunk: Array, staleAcks: string[], errors: string[], ok: boolean
|
|
* }}
|
|
*/
|
|
function diffEmitted({
|
|
baseline,
|
|
current,
|
|
changedPaths,
|
|
ack = null,
|
|
sizeBaseline = null,
|
|
sizeCurrent = null,
|
|
} = {}) {
|
|
const errors = [];
|
|
|
|
if (!baseline || typeof baseline !== 'object' || Array.isArray(baseline)) {
|
|
errors.push('baseline manifest set must be an object keyed by runtime');
|
|
}
|
|
if (!current || typeof current !== 'object' || Array.isArray(current)) {
|
|
errors.push('current manifest set must be an object keyed by runtime');
|
|
}
|
|
if (!Array.isArray(changedPaths)) {
|
|
// NOT the same as an empty array. A failed `git diff` must never be read as
|
|
// "nothing changed" — that would make every moved hash unattributable and produce
|
|
// a failure storm that reads like a real finding.
|
|
errors.push('changedPaths must be an array (a failed git diff is an error, not an empty set)');
|
|
}
|
|
if (errors.length) {
|
|
return {
|
|
moved: 0, attributed: [], unattributable: [], acked: [], removed: [],
|
|
grown: [], shrunk: [], staleAcks: [], errors, ok: false,
|
|
};
|
|
}
|
|
|
|
const changedSet = new Set(changedPaths);
|
|
const { entries: ackEntries, errors: ackErrors } = parseAck(ack);
|
|
errors.push(...ackErrors);
|
|
|
|
const attributed = [];
|
|
const unattributable = [];
|
|
const acked = [];
|
|
const removed = [];
|
|
const usedAcks = new Set();
|
|
let moved = 0;
|
|
|
|
const runtimes = new Set([...Object.keys(baseline), ...Object.keys(current)]);
|
|
|
|
for (const runtime of [...runtimes].sort()) {
|
|
const before = baseline[runtime] || {};
|
|
const after = current[runtime] || {};
|
|
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
|
|
for (const rel of [...keys].sort()) {
|
|
const had = Object.prototype.hasOwnProperty.call(before, rel);
|
|
const has = Object.prototype.hasOwnProperty.call(after, rel);
|
|
|
|
if (had && has && before[rel] === after[rel]) continue; // unchanged — ignored
|
|
|
|
const change = !had ? 'added' : (!has ? 'removed' : 'modified');
|
|
moved++;
|
|
|
|
let attribution;
|
|
try {
|
|
attribution = attributeEmittedPath(rel, runtime);
|
|
} catch (err) {
|
|
// A path the Phase 2 table cannot resolve is surfaced, never silently skipped —
|
|
// otherwise a table hole becomes a blind spot in the differential too.
|
|
errors.push(`${runtime}: ${rel}: ${err.message}`);
|
|
continue;
|
|
}
|
|
|
|
const record = { runtime, rel, change, ruleId: attribution.ruleId, kind: attribution.kind };
|
|
|
|
if (change === 'removed') removed.push(record);
|
|
|
|
// Synthesized paths carry no repo source by definition, so a delta in them can
|
|
// never be "unexplained by the diff" — exempt, but still counted and reported.
|
|
if (attribution.kind === 'synthesized') {
|
|
attributed.push({ ...record, via: '<synthesized: exempt>' });
|
|
continue;
|
|
}
|
|
|
|
// Sources are checked before transforms so `via` is deterministic when a moved
|
|
// path is explained by both at once — the SOURCE is the more specific, more
|
|
// legible story ("the agent file changed") and is what a reviewer expects to
|
|
// see first, not an accident of iteration order.
|
|
let via = null;
|
|
for (const source of attribution.sources) {
|
|
const hit = sourceSatisfiedBy(source, changedSet);
|
|
// `!== null`, not truthiness: an exact match returns the source string, and an
|
|
// empty-string source would return '' — falsy, so a real match would be
|
|
// silently discarded. Unreachable with today's rules (every source is a
|
|
// non-empty template) but it is a footgun for the next rule author.
|
|
if (hit !== null) { via = hit; break; }
|
|
}
|
|
// #2757: a `derived`/`code-derived` artifact's bytes can also move because the
|
|
// TRANSFORM code that generates them changed, not the source it derives from —
|
|
// `sources` alone cannot express that. Reuses `sourceSatisfiedBy` unchanged so
|
|
// exact/prefix semantics stay identical for both lists.
|
|
if (via === null) {
|
|
for (const transform of attribution.transforms) {
|
|
const hit = sourceSatisfiedBy(transform, changedSet);
|
|
if (hit !== null) { via = hit; break; }
|
|
}
|
|
}
|
|
|
|
if (via !== null) {
|
|
attributed.push({ ...record, via });
|
|
} else if (ackEntries.has(rel)) {
|
|
usedAcks.add(rel);
|
|
acked.push({ ...record, reason: ackEntries.get(rel).reason });
|
|
} else {
|
|
unattributable.push({
|
|
...record,
|
|
expectedSources: attribution.sources,
|
|
expectedTransforms: attribution.transforms,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Size ratchet, folded into the same machine (ADR-2719 §4, must-have 6) ──
|
|
// NOTE: stale-ack detection is computed AFTER this block, not before. An ack may be
|
|
// consumed by either a hash move or a size growth, so computing it earlier would
|
|
// report a size-growth ack as stale.
|
|
const grown = [];
|
|
const shrunk = [];
|
|
if (sizeBaseline && sizeCurrent) {
|
|
for (const name of Object.keys(sizeCurrent).sort()) {
|
|
if (!Object.prototype.hasOwnProperty.call(sizeBaseline, name)) continue;
|
|
const from = sizeBaseline[name];
|
|
const to = sizeCurrent[name];
|
|
if (to > from) {
|
|
// Growth needs the SAME acknowledgment. Anti-creep survives without pinning a
|
|
// number: "verify-work.md grew 1,247 bytes" beats a number moving in a 93-line map.
|
|
const isAcked = ackEntries.has(name);
|
|
if (isAcked) usedAcks.add(name);
|
|
grown.push({ name, from, to, delta: to - from, acked: isAcked });
|
|
} else if (to < from) {
|
|
// Shrinkage is not creep — reported, never gated.
|
|
shrunk.push({ name, from, to, delta: from - to });
|
|
}
|
|
}
|
|
}
|
|
|
|
const unackedGrowth = grown.filter((g) => !g.acked);
|
|
|
|
// An ack that outlives the ripple it explained is future blindness: it would silently
|
|
// pre-clear a NEW ripple on the same path. It must be deleted when the ripple is.
|
|
// Computed here, once, after BOTH the hash pass and the size pass have consumed acks.
|
|
const staleAcks = [...ackEntries.keys()].filter((rel) => !usedAcks.has(rel)).sort();
|
|
|
|
const ok = errors.length === 0
|
|
&& unattributable.length === 0
|
|
&& unackedGrowth.length === 0
|
|
&& staleAcks.length === 0;
|
|
|
|
return {
|
|
moved,
|
|
attributed,
|
|
unattributable,
|
|
acked,
|
|
removed,
|
|
grown,
|
|
shrunk,
|
|
staleAcks,
|
|
errors,
|
|
ok,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Render a report as the failure message ADR-2719 §1 specifies — it sells the whole
|
|
* design on this text, so it is a deliverable, not a detail.
|
|
*/
|
|
function formatReport(result, { sampleLimit = 20 } = {}) {
|
|
const parts = [];
|
|
|
|
if (result.errors.length) {
|
|
parts.push(`${result.errors.length} error(s):\n ${result.errors.slice(0, sampleLimit).join('\n ')}`);
|
|
}
|
|
|
|
if (result.unattributable.length) {
|
|
const list = result.unattributable.slice(0, sampleLimit)
|
|
.map((u) => {
|
|
// #2757: a rule may explain a moved path via its source OR its transform
|
|
// code; name whichever possibilities exist so the message tells the whole
|
|
// story, not just half of it.
|
|
const expected = [
|
|
u.expectedSources.length ? `a change under ${u.expectedSources.join(' or ')}` : null,
|
|
(u.expectedTransforms && u.expectedTransforms.length)
|
|
? `a transform change under ${u.expectedTransforms.join(' or ')}`
|
|
: null,
|
|
].filter(Boolean).join(', or ');
|
|
return ` ${u.runtime}: ${u.rel}\n rule ${u.ruleId}; expected ${expected}`;
|
|
});
|
|
parts.push(
|
|
`${result.unattributable.length} emitted path(s) changed that nothing in this diff explains:\n${list.join('\n')}` +
|
|
(result.unattributable.length > sampleLimit
|
|
? `\n …and ${result.unattributable.length - sampleLimit} more`
|
|
: '') +
|
|
'\n\nIf this ripple is intended, record it in tests/emitted-drift-ack.json naming each\n' +
|
|
'path and why. Do NOT regenerate anything to silence this.',
|
|
);
|
|
}
|
|
|
|
const unackedGrowth = result.grown.filter((g) => !g.acked);
|
|
if (unackedGrowth.length) {
|
|
const list = unackedGrowth.slice(0, sampleLimit)
|
|
.map((g) => ` ${g.name} grew ${g.delta} bytes (${g.from} -> ${g.to})`);
|
|
parts.push(
|
|
`${unackedGrowth.length} file(s) grew without an acknowledgment:\n${list.join('\n')}`,
|
|
);
|
|
}
|
|
|
|
if (result.staleAcks.length) {
|
|
parts.push(
|
|
`${result.staleAcks.length} stale acknowledgment(s) — the ripple they explained is gone, ` +
|
|
'so they must be deleted (an ack that outlives its ripple pre-clears the next one):\n ' +
|
|
result.staleAcks.slice(0, sampleLimit).join('\n '),
|
|
);
|
|
}
|
|
|
|
return parts.join('\n\n');
|
|
}
|
|
|
|
module.exports = {
|
|
ACK_VERSION,
|
|
sourceSatisfiedBy,
|
|
parseAck,
|
|
diffEmitted,
|
|
formatReport,
|
|
};
|