* 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
This commit is contained in:
@@ -195,6 +195,10 @@ function diffEmitted({
|
||||
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);
|
||||
@@ -204,6 +208,16 @@ function diffEmitted({
|
||||
// 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 });
|
||||
@@ -211,7 +225,11 @@ function diffEmitted({
|
||||
usedAcks.add(rel);
|
||||
acked.push({ ...record, reason: ackEntries.get(rel).reason });
|
||||
} else {
|
||||
unattributable.push({ ...record, expectedSources: attribution.sources });
|
||||
unattributable.push({
|
||||
...record,
|
||||
expectedSources: attribution.sources,
|
||||
expectedTransforms: attribution.transforms,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,7 +297,18 @@ function formatReport(result, { sampleLimit = 20 } = {}) {
|
||||
|
||||
if (result.unattributable.length) {
|
||||
const list = result.unattributable.slice(0, sampleLimit)
|
||||
.map((u) => ` ${u.runtime}: ${u.rel}\n rule ${u.ruleId}; expected a change under ${u.expectedSources.join(' or ')}`);
|
||||
.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
|
||||
|
||||
Reference in New Issue
Block a user