* 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:
@@ -217,6 +217,153 @@ test('a failed git diff is an error, not an empty change set', () => {
|
||||
assert.match(r.errors.join('\n'), /changedPaths must be an array/);
|
||||
});
|
||||
|
||||
// ─── Transform attribution (#2757 defect 1) ──────────────────────────────────
|
||||
//
|
||||
// A `kind: 'derived'` rule's bytes can legitimately move for a second reason the
|
||||
// `sources`-only design cannot express: the TRANSFORM code that generates the
|
||||
// derived artifact changed, not the source it derives from. Replays the #2566
|
||||
// shape verbatim: 16 emitted `agents/*.toml` moved, the diff touches
|
||||
// `src/runtime-artifact-conversion.cts` and zero `agents/*.md`.
|
||||
|
||||
test('a derived path explained only by a transform change is attributed (#2566 shape)', () => {
|
||||
const moved = {};
|
||||
const base = {};
|
||||
const agentNames = [
|
||||
'gsd-planner', 'gsd-executor', 'gsd-verifier', 'gsd-code-reviewer',
|
||||
'gsd-security-auditor', 'gsd-nyquist-auditor', 'gsd-doc-writer', 'gsd-roadmapper',
|
||||
'gsd-phase-researcher', 'gsd-pattern-mapper', 'gsd-plan-checker', 'gsd-debugger',
|
||||
'gsd-ui-checker', 'gsd-eval-planner', 'gsd-framework-selector', 'gsd-code-fixer',
|
||||
];
|
||||
assert.equal(agentNames.length, 16, 'the #2566 reproduction is 16 emitted .toml files');
|
||||
for (const name of agentNames) {
|
||||
base[`agents/${name}.toml`] = `before-${name}`;
|
||||
moved[`agents/${name}.toml`] = `after-${name}`;
|
||||
}
|
||||
|
||||
// Before the fix, `agents-toml-derived` has no `transforms` field: this must fail.
|
||||
const withoutTransformChange = diffEmitted({
|
||||
baseline: mf(base),
|
||||
current: mf(moved),
|
||||
// Deliberately NOT `src/runtime-artifact-conversion.cts` — proves the negative
|
||||
// (an unrelated source change does not accidentally attribute).
|
||||
changedPaths: ['README.md'],
|
||||
});
|
||||
assert.equal(withoutTransformChange.unattributable.length, 16);
|
||||
assert.ok(!withoutTransformChange.ok);
|
||||
|
||||
// The actual #2566 shape: the diff touches the transform, not any agents/*.md.
|
||||
const withTransformChange = diffEmitted({
|
||||
baseline: mf(base),
|
||||
current: mf(moved),
|
||||
changedPaths: ['src/runtime-artifact-conversion.cts'],
|
||||
});
|
||||
assert.equal(
|
||||
withTransformChange.unattributable.length, 0,
|
||||
'a transform-only change must attribute every moved derived path',
|
||||
);
|
||||
assert.equal(withTransformChange.attributed.length, 16);
|
||||
for (const rec of withTransformChange.attributed) {
|
||||
assert.equal(rec.via, 'src/runtime-artifact-conversion.cts');
|
||||
assert.equal(rec.ruleId, 'agents-toml-derived');
|
||||
}
|
||||
assert.ok(withTransformChange.ok);
|
||||
});
|
||||
|
||||
test('an identity-classified agent .md moved by a transform-only change is unattributable (#2757 defect 2, pre-fix shape)', () => {
|
||||
// Reproduces the maintainer's follow-up: codex's agents/*.md hash moves without any
|
||||
// agents/*.md in the diff. Whether this attributes now depends entirely on whether
|
||||
// `agents-verbatim` has been reclassified to `derived` with `transforms` declared —
|
||||
// this test asserts the REAL, current behavior of the shipped table, so it doubles
|
||||
// as the defect-2 regression once the fix lands (the id in the rule table has not
|
||||
// changed, only `kind`/`transforms`, so this same test proves both "was broken" and
|
||||
// "is fixed" depending on which commit runs it).
|
||||
const r = diffEmitted({
|
||||
baseline: { codex: { 'agents/gsd-nyquist-auditor.md': 'before' } },
|
||||
current: { codex: { 'agents/gsd-nyquist-auditor.md': 'after' } },
|
||||
changedPaths: ['src/runtime-artifact-conversion.cts'],
|
||||
});
|
||||
assert.equal(r.unattributable.length, 0, 'a declared transform must explain the moved identity-family path');
|
||||
assert.equal(r.attributed[0].ruleId, 'agents-verbatim');
|
||||
assert.equal(r.attributed[0].via, 'src/runtime-artifact-conversion.cts');
|
||||
assert.ok(r.ok);
|
||||
});
|
||||
|
||||
test('a moved derived path with neither source nor transform in the diff still fails, named', () => {
|
||||
const r = diffEmitted({
|
||||
baseline: mf({ 'agents/gsd-planner.toml': 'before' }),
|
||||
current: mf({ 'agents/gsd-planner.toml': 'after' }),
|
||||
changedPaths: ['docs/README.md'],
|
||||
});
|
||||
assert.equal(r.unattributable.length, 1);
|
||||
assert.equal(r.unattributable[0].rel, 'agents/gsd-planner.toml');
|
||||
assert.deepEqual(r.unattributable[0].expectedSources, ['agents/gsd-planner.md']);
|
||||
assert.ok(
|
||||
r.unattributable[0].expectedTransforms.includes('src/runtime-artifact-conversion.cts'),
|
||||
'the message must be able to say what transform WOULD have explained it too',
|
||||
);
|
||||
const msg = formatReport(r);
|
||||
assert.ok(msg.includes('agents/gsd-planner.toml'));
|
||||
assert.ok(msg.includes('src/runtime-artifact-conversion.cts'), 'the transform hint must appear in the report');
|
||||
});
|
||||
|
||||
test('an unrelated src file does not attribute a moved derived path (transforms list stays narrow)', () => {
|
||||
// The review's own risk: a transform list that is too broad silently excuses real
|
||||
// ripples. src/state-document.cts has nothing to do with agent conversion.
|
||||
const r = diffEmitted({
|
||||
baseline: mf({ 'agents/gsd-planner.toml': 'before' }),
|
||||
current: mf({ 'agents/gsd-planner.toml': 'after' }),
|
||||
changedPaths: ['src/state-document.cts'],
|
||||
});
|
||||
assert.equal(r.unattributable.length, 1, 'an unrelated src/*.cts file must NOT excuse the ripple');
|
||||
assert.ok(!r.ok);
|
||||
});
|
||||
|
||||
test('bin/install.js alone does not attribute a moved agent artifact (deliberate exclusion)', () => {
|
||||
// bin/install.js implements the final splice (injectEffortFrontmatter,
|
||||
// generateCodexAgentToml) but is deliberately excluded from AGENT_TRANSFORM_SRCS —
|
||||
// at 13k+ lines spanning every installer concern, including it would be the blanket
|
||||
// escape hatch ADR-2719 warns against. This proves the exclusion holds in the
|
||||
// shipped table, not just in the design doc.
|
||||
const r = diffEmitted({
|
||||
baseline: mf({ 'agents/gsd-planner.toml': 'before' }),
|
||||
current: mf({ 'agents/gsd-planner.toml': 'after' }),
|
||||
changedPaths: ['bin/install.js'],
|
||||
});
|
||||
assert.equal(r.unattributable.length, 1, 'bin/install.js alone must not attribute — it is not a declared transform');
|
||||
assert.ok(!r.ok);
|
||||
});
|
||||
|
||||
test('a moved path with a source match wins over an also-present transform match (deterministic via)', () => {
|
||||
const r = diffEmitted({
|
||||
baseline: mf({ 'agents/gsd-planner.toml': 'before' }),
|
||||
current: mf({ 'agents/gsd-planner.toml': 'after' }),
|
||||
changedPaths: ['agents/gsd-planner.md', 'src/runtime-artifact-conversion.cts'],
|
||||
});
|
||||
assert.equal(r.unattributable.length, 0);
|
||||
assert.equal(r.attributed[0].via, 'agents/gsd-planner.md', 'sources are checked before transforms');
|
||||
});
|
||||
|
||||
test('a transform-explained converter ripple still needs no ack (transforms are a first-class attribution, not a workaround)', () => {
|
||||
// Contrast with 'a converter change fails without an ack and passes with one' above:
|
||||
// THAT test simulates a family with NO transforms declared, so it correctly still
|
||||
// requires an ack. agents-toml-derived DOES declare a transform, so the equivalent
|
||||
// ripple must attribute directly, with no ack needed at all.
|
||||
const moved = {};
|
||||
const base = {};
|
||||
for (let i = 0; i < 5; i++) {
|
||||
base[`agents/gsd-fixture-${i}.toml`] = `h${i}`;
|
||||
moved[`agents/gsd-fixture-${i}.toml`] = `x${i}`;
|
||||
}
|
||||
const r = diffEmitted({
|
||||
baseline: mf(base),
|
||||
current: mf(moved),
|
||||
changedPaths: ['src/runtime-artifact-conversion.cts'],
|
||||
});
|
||||
assert.equal(r.unattributable.length, 0);
|
||||
assert.equal(r.acked.length, 0, 'no ack was needed — the transform explains it directly');
|
||||
assert.ok(r.ok);
|
||||
});
|
||||
|
||||
test('an unattributable-by-table path surfaces as an error', () => {
|
||||
const r = diffEmitted({
|
||||
baseline: mf({ 'totally/unknown/thing.md': 'aaa' }),
|
||||
|
||||
@@ -41,11 +41,13 @@ const {
|
||||
COMMANDS_SRC,
|
||||
CLINE_BODY_SRC,
|
||||
KIMI_ROOT_AGENT_SRC,
|
||||
AGENT_TRANSFORM_SRCS,
|
||||
stripSkillPrefix,
|
||||
matchRules,
|
||||
attributeEmittedPath,
|
||||
loadManifests,
|
||||
assertTotality,
|
||||
assertNoIdentityTransforms,
|
||||
} = require('./helpers/emitted-provenance.cjs');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
@@ -275,6 +277,94 @@ test('spot-check: install-time state is exempt with an empty source list', () =>
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Transforms (#2757): derived rules can declare a transform-code source ───
|
||||
|
||||
test('agents-toml-derived declares AGENT_TRANSFORM_SRCS as transforms', () => {
|
||||
const got = attributeEmittedPath('agents/gsd-planner.toml', 'codex');
|
||||
assert.equal(got.kind, 'derived');
|
||||
assert.deepEqual(got.sources, ['agents/gsd-planner.md']);
|
||||
assert.deepEqual(got.transforms, AGENT_TRANSFORM_SRCS);
|
||||
});
|
||||
|
||||
test('agents-verbatim is reclassified to derived with the same transforms, sources unchanged', () => {
|
||||
const got = attributeEmittedPath('agents/gsd-planner.md', 'claude');
|
||||
assert.equal(got.kind, 'derived', 'no runtime emits a byte-identical copy — see #2757 design doc');
|
||||
assert.deepEqual(got.sources, ['agents/gsd-planner.md'], 'sources must be unchanged by the reclassification');
|
||||
assert.deepEqual(got.transforms, AGENT_TRANSFORM_SRCS);
|
||||
});
|
||||
|
||||
test('a rule with no transforms field still returns an empty array, never undefined', () => {
|
||||
const got = attributeEmittedPath('gsd-core/workflows/plan-phase.md', 'claude');
|
||||
assert.deepEqual(got.transforms, [], 'absence of transforms must be a stable empty array, not undefined');
|
||||
});
|
||||
|
||||
test('every declared transform path exists in the repo', () => {
|
||||
// Same philosophy as "every attributed source exists in the repo": a transform path
|
||||
// that does not exist is proof the rule (or the design's own suggested files) is
|
||||
// wrong — this is exactly how src/agent-tools-contract.cts was ruled out during
|
||||
// design: it does not exist in this tree.
|
||||
const missing = [];
|
||||
for (const rule of PROVENANCE_RULES) {
|
||||
for (const t of rule.transforms || []) {
|
||||
if (!fs.existsSync(path.join(REPO_ROOT, t))) missing.push(`${rule.id}: ${t}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(missing, [], `declared transform paths that do not exist:\n ${missing.join('\n ')}`);
|
||||
});
|
||||
|
||||
test('identity rules never declare a non-empty transforms list (real table)', () => {
|
||||
const violators = PROVENANCE_RULES.filter(
|
||||
(r) => r.kind === 'identity' && Array.isArray(r.transforms) && r.transforms.length > 0,
|
||||
);
|
||||
assert.deepEqual(violators.map((r) => r.id), [], 'an identity copy can only move when its source moves');
|
||||
});
|
||||
|
||||
test('assertNoIdentityTransforms rejects an identity rule declaring transforms, naming it', () => {
|
||||
const corrupted = PROVENANCE_RULES.map((r) => (
|
||||
r.id === 'scripts-verbatim' ? { ...r, transforms: ['scripts/build-hooks.js'] } : r
|
||||
));
|
||||
assert.throws(
|
||||
() => assertNoIdentityTransforms(corrupted),
|
||||
(err) => err.message.includes('scripts-verbatim') && err.message.includes('identity'),
|
||||
'the offending rule id must be named',
|
||||
);
|
||||
});
|
||||
|
||||
test('assertNoIdentityTransforms does not throw when transforms is absent or empty', () => {
|
||||
const emptyArray = PROVENANCE_RULES.map((r) => (
|
||||
r.id === 'scripts-verbatim' ? { ...r, transforms: [] } : r
|
||||
));
|
||||
assert.doesNotThrow(() => assertNoIdentityTransforms(emptyArray), 'an empty transforms array is legal on identity');
|
||||
assert.doesNotThrow(() => assertNoIdentityTransforms(PROVENANCE_RULES), 'no transforms key at all is legal on identity');
|
||||
});
|
||||
|
||||
test('assertNoIdentityTransforms names only the offending rule, not unrelated valid ones', () => {
|
||||
const corrupted = PROVENANCE_RULES.map((r) => (
|
||||
r.id === 'scripts-verbatim' ? { ...r, transforms: ['scripts/build-hooks.js'] } : r
|
||||
));
|
||||
assert.throws(
|
||||
() => assertNoIdentityTransforms(corrupted),
|
||||
(err) => !err.message.includes('gsd-core-verbatim'),
|
||||
'an unrelated valid identity rule must not be named',
|
||||
);
|
||||
});
|
||||
|
||||
test('a derived rule may declare an empty transforms array with no special meaning', () => {
|
||||
const withEmpty = PROVENANCE_RULES.map((r) => (
|
||||
r.id === 'agents-toml-derived' ? { ...r, transforms: [] } : r
|
||||
));
|
||||
assert.doesNotThrow(() => assertNoIdentityTransforms(withEmpty));
|
||||
const got = attributeEmittedPath('agents/gsd-planner.toml', 'codex', withEmpty);
|
||||
assert.deepEqual(got.transforms, []);
|
||||
});
|
||||
|
||||
test('reclassifying agents-verbatim does not change totality byRule counts', () => {
|
||||
// The match set is a function of (pattern, roots), not kind — asserting the count
|
||||
// is unchanged proves the reclassification touched classification only.
|
||||
const { byRule } = assertTotality(manifests());
|
||||
assert.ok(byRule.get('agents-verbatim') > 0, 'agents-verbatim must still match its real family');
|
||||
});
|
||||
|
||||
// ─── Negative space: the guard must fail loud ────────────────────────────────
|
||||
|
||||
test('unmatched path fails loud and names the path', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -83,6 +83,66 @@ const INSTALLER_SRC = 'bin/install.js';
|
||||
/** Source file holding the Kimi root-agent literal (runtime-artifact-layout.cts:303). */
|
||||
const KIMI_ROOT_AGENT_SRC = 'src/runtime-artifact-layout.cts';
|
||||
|
||||
/**
|
||||
* Transform sources for the per-runtime agent-content pipeline (#2757).
|
||||
*
|
||||
* `runtime-artifact-conversion.cts` rewrites frontmatter (quoting, dropping `tools:`/
|
||||
* `color:`), reformats the tools list, and rewrites the hardcoded `.claude/` self-
|
||||
* reference to each runtime's own home (`applyAgentPathRewrites`,
|
||||
* `normalizeAgentBodyForRuntime`, the `convertClaudeAgentTo*Agent` family).
|
||||
* `install-effort-resolver.cts` (+ the `model-catalog.cts` primitives it calls)
|
||||
* resolves the `reasoning_effort` value that both Claude's `.md` (`effort:` frontmatter,
|
||||
* injected by `injectEffortFrontmatter` in bin/install.js) and Codex's `.toml`
|
||||
* (`model_reasoning_effort`) embed — "the same config-driven precedence chain" per
|
||||
* bin/install.js's own #443 comment.
|
||||
*
|
||||
* Verified empirically (#2757), not assumed: installing every runtime for a sample
|
||||
* agent and diffing the output against the raw repo `.md` (after normalizing the
|
||||
* install-time HOME/version substitutions the golden fixtures already normalize) shows
|
||||
* NO runtime — including Claude — emits a byte-identical copy. Every one rewrites at
|
||||
* least the frontmatter and the `.claude/skills` self-reference; Claude additionally
|
||||
* gets `effort:` injected. This is why `agents-verbatim` below is `derived`, not
|
||||
* `identity`, despite its (retained, historical) id.
|
||||
*
|
||||
* Deliberately excludes `bin/install.js`: that file also implements the final splice
|
||||
* (`injectEffortFrontmatter`, `generateCodexAgentToml`), but at 13k+ lines spanning
|
||||
* hooks, MCP config, uninstall, and every other installer concern, declaring it here
|
||||
* would be the blanket escape hatch ADR-2719 warns against — almost any PR touches SOME
|
||||
* line of it. A change localized to those two functions and not reachable through the
|
||||
* three files below stays unattributable and falls to the drift-ack file, which is the
|
||||
* documented escape hatch for exactly that case.
|
||||
*
|
||||
* ── Known follow-up, NOT included here on purpose (#2757 review) ────────────
|
||||
* The issue text also named `src/agent-tools-contract.cts` and
|
||||
* `src/agent-install-check.cts` (both touched by PR #2566, "derive Codex agent sandbox
|
||||
* from the tool"). Verified against THIS tree and excluded on the evidence:
|
||||
* - `src/agent-tools-contract.cts` does not exist on `next` — #2566 ADDS it (+119/-0).
|
||||
* A nonexistent path here would immediately fail the
|
||||
* "every declared transform path exists in the repo" hygiene test in
|
||||
* emitted-provenance.test.cjs, which exists precisely to catch a rule (or a
|
||||
* suggested transform, as here) that cites a file that doesn't back real content.
|
||||
* - `src/agent-install-check.cts` exists today and is READ-ONLY (`getAgentsDir`,
|
||||
* `checkAgentsInstalled` — no fs.writeFileSync, no content transform); #2566
|
||||
* nearly doubles it (+112/-1). Whether the addition becomes a real content-writer,
|
||||
* a larger read-only diagnostic surface, or a helper called BY an already-declared
|
||||
* file cannot be determined without reading #2566's actual diff, which review is
|
||||
* explicit should not be fetched here. Declaring it on a line-count guess risks
|
||||
* the exact false-attribution failure mode this whole table exists to prevent — a
|
||||
* rule that looks fixed while resolving to the wrong causal story.
|
||||
* Interim safety net: if #2566 lands and either file becomes a real transform without
|
||||
* this list being updated, the differential (Phase 3) will correctly flag the moved
|
||||
* agent artifact as unattributable — that is the guard working, not a regression — and
|
||||
* unblocks via `tests/emitted-drift-ack.json` until this list is verified and extended
|
||||
* using the SAME method used for the three files above: build, install every runtime,
|
||||
* diff the output against the raw repo source, confirm which file's absence/presence
|
||||
* changes the bytes.
|
||||
*/
|
||||
const AGENT_TRANSFORM_SRCS = [
|
||||
'src/runtime-artifact-conversion.cts',
|
||||
'src/install-effort-resolver.cts',
|
||||
'src/model-catalog.cts',
|
||||
];
|
||||
|
||||
/**
|
||||
* A `sources` entry ending in `/` is a PREFIX, not a file: it means "any repo path
|
||||
* under this directory legitimately explains this emitted path". Used where an
|
||||
@@ -139,6 +199,17 @@ function nativePluginDescriptor(runtime) {
|
||||
// `roots` — emitted prefixes this rule applies under (null = match `rel` whole)
|
||||
// `pattern` — matched against the root-stripped tail (or whole `rel` when roots is null)
|
||||
// `sources` — (match, ctx) => string[] of repo-relative paths; [] only for `synthesized`
|
||||
// `transforms` — OPTIONAL string[] of repo paths implementing the TRANSFORM that
|
||||
// produces this rule's emitted bytes (#2757). A `derived`/`code-derived`
|
||||
// artifact's bytes can move for a second reason `sources` alone cannot
|
||||
// express: the transform code changed, not the source it derives from.
|
||||
// Phase 3 (emitted-diff.cjs) attributes a moved path if the diff
|
||||
// satisfies EITHER `sources` OR `transforms`, reusing the same
|
||||
// `sourceSatisfiedBy` matcher for both so exact/prefix semantics stay
|
||||
// identical. `kind: 'identity'` rules MUST NOT declare a non-empty
|
||||
// `transforms` — enforced by `assertNoIdentityTransforms` below — because
|
||||
// an identity copy's bytes can only move when its source moves; that is
|
||||
// what makes it an identity.
|
||||
//
|
||||
// Rule ORDER CARRIES NO SEMANTICS. Exactly-one matching is enforced, so rules are
|
||||
// mutually exclusive by construction and the table reads correctly in any order.
|
||||
@@ -163,8 +234,21 @@ const PROVENANCE_RULES = [
|
||||
sources: (m) => [`scripts/${m[0]}`],
|
||||
},
|
||||
{
|
||||
// Historical id — retained even though, per #2757, this is no longer identity.
|
||||
// Nothing else in the repo keys off this string (checked), and the `sources`
|
||||
// shape below is unchanged, so renaming it would only widen the diff.
|
||||
id: 'agents-verbatim',
|
||||
kind: 'identity',
|
||||
// #2757 (was `identity`): measured against origin/next's own committed fixtures,
|
||||
// the SAME emitted agents/<name>.md hashes DIFFERENTLY per runtime (e.g. codex vs
|
||||
// claude for gsd-nyquist-auditor.md) — impossible for a true verbatim copy.
|
||||
// Verified empirically by installing every runtime and diffing the output against
|
||||
// the raw repo source: no runtime reproduces it byte-for-byte. Every one rewrites
|
||||
// frontmatter quoting and the hardcoded `.claude/` self-reference
|
||||
// (src/runtime-artifact-conversion.cts); Claude additionally gets an `effort:`
|
||||
// line injected (src/install-effort-resolver.cts + src/model-catalog.cts). See
|
||||
// the #2757 design doc for the alternatives considered (per-runtime split,
|
||||
// per-runtime `kind`) and why this wholesale reclassification was chosen instead.
|
||||
kind: 'derived',
|
||||
roots: ['agents'],
|
||||
// Excludes `gsd.md`: that is Kimi's ROOT agent, built from a code literal and
|
||||
// NOT a repo agent file. Without the exclusion it matched here and resolved to
|
||||
@@ -176,6 +260,7 @@ const PROVENANCE_RULES = [
|
||||
// resolved to a file that does not exist. Same false-attribution class.
|
||||
pattern: /^(?!gsd\.md$)(?!.*\.agent\.md$)[^/]+\.md$/,
|
||||
sources: (m) => [`agents/${m[0]}`],
|
||||
transforms: AGENT_TRANSFORM_SRCS,
|
||||
},
|
||||
{
|
||||
id: 'copilot-agent-rename',
|
||||
@@ -194,6 +279,9 @@ const PROVENANCE_RULES = [
|
||||
// from the same agents/<name>.md source.
|
||||
pattern: /^([^/]+)\.toml$/,
|
||||
sources: (m) => [`agents/${m[1]}.md`],
|
||||
// #2757 (issue text, PR #2566): a change to the conversion/effort code can move
|
||||
// every emitted .toml without touching any agents/*.md. See AGENT_TRANSFORM_SRCS.
|
||||
transforms: AGENT_TRANSFORM_SRCS,
|
||||
},
|
||||
{
|
||||
id: 'agents-subagent-derived',
|
||||
@@ -414,7 +502,7 @@ function matchRules(rel, runtime, rules = PROVENANCE_RULES) {
|
||||
/**
|
||||
* Resolve the provenance of one emitted path.
|
||||
* @throws when the path matches zero or more than one rule.
|
||||
* @returns {{ruleId: string, kind: string, sources: string[]}}
|
||||
* @returns {{ruleId: string, kind: string, sources: string[], transforms: string[]}}
|
||||
*/
|
||||
function attributeEmittedPath(rel, runtime, rules = PROVENANCE_RULES) {
|
||||
const hits = matchRules(rel, runtime, rules);
|
||||
@@ -435,9 +523,44 @@ function attributeEmittedPath(rel, runtime, rules = PROVENANCE_RULES) {
|
||||
ruleId: rule.id,
|
||||
kind: rule.kind,
|
||||
sources: rule.sources(match, { rel, runtime }),
|
||||
// #2757: always an array, never undefined, so callers (Phase 3's diffEmitted)
|
||||
// never need a defensive `|| []`.
|
||||
transforms: Array.isArray(rule.transforms) ? rule.transforms : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Invariant (#2757): a `kind: 'identity'` rule may not declare a non-empty
|
||||
* `transforms`. An identity copy's bytes can only move when its source moves — that
|
||||
* is what makes it an identity; a transforms list on an identity rule would silently
|
||||
* readmit the exact false-attribution risk defect 2 found (a rule that is total and
|
||||
* "passes" while resolving to the wrong causal story).
|
||||
*
|
||||
* Called once at module load against the real PROVENANCE_RULES (fails fast on a
|
||||
* future authoring mistake) and exported so tests can drive it against an injected
|
||||
* corrupted table, matching this module's existing injectable-table convention.
|
||||
*
|
||||
* @param {Array} rules rule table (injectable)
|
||||
* @throws when any identity rule declares a non-empty transforms array
|
||||
*/
|
||||
function assertNoIdentityTransforms(rules = PROVENANCE_RULES) {
|
||||
const violators = rules.filter(
|
||||
(r) => r.kind === 'identity' && Array.isArray(r.transforms) && r.transforms.length > 0,
|
||||
);
|
||||
if (violators.length) {
|
||||
throw new Error(
|
||||
`emitted-provenance: identity rule(s) [${violators.map((r) => r.id).join(', ')}] ` +
|
||||
'declare a non-empty "transforms" — an identity copy\'s bytes can only move when ' +
|
||||
'its source moves, which is what makes it an identity. Reclassify the rule\'s ' +
|
||||
'"kind" (e.g. to "derived") if it legitimately needs a transforms list.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fail fast: a malformed table crashes at require time rather than passing silently
|
||||
// until some test happens to exercise the corrupted rule.
|
||||
assertNoIdentityTransforms(PROVENANCE_RULES);
|
||||
|
||||
// ─── Fixture loading ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -549,6 +672,7 @@ module.exports = {
|
||||
PROVENANCE_RULES,
|
||||
SKILLS_ROOTS,
|
||||
KIMI_ROOT_AGENT_SRC,
|
||||
AGENT_TRANSFORM_SRCS,
|
||||
SOURCE_PREFIX_SUFFIX,
|
||||
HOOKS_ROOTS,
|
||||
COMMANDS_SRC,
|
||||
@@ -560,6 +684,7 @@ module.exports = {
|
||||
assertSafeRelPath,
|
||||
matchRules,
|
||||
attributeEmittedPath,
|
||||
assertNoIdentityTransforms,
|
||||
loadManifests,
|
||||
assertTotality,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user