diff --git a/tests/emitted-attribution.test.cjs b/tests/emitted-attribution.test.cjs index e98709f0d..10297471e 100644 --- a/tests/emitted-attribution.test.cjs +++ b/tests/emitted-attribution.test.cjs @@ -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' }), diff --git a/tests/emitted-provenance.test.cjs b/tests/emitted-provenance.test.cjs index f3b26d648..837a3e6c6 100644 --- a/tests/emitted-provenance.test.cjs +++ b/tests/emitted-provenance.test.cjs @@ -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', () => { diff --git a/tests/helpers/emitted-diff.cjs b/tests/helpers/emitted-diff.cjs index 7c8ca2f6c..1c5e5e661 100644 --- a/tests/helpers/emitted-diff.cjs +++ b/tests/helpers/emitted-diff.cjs @@ -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 diff --git a/tests/helpers/emitted-provenance.cjs b/tests/helpers/emitted-provenance.cjs index a07e00971..25d0c5796 100644 --- a/tests/helpers/emitted-provenance.cjs +++ b/tests/helpers/emitted-provenance.cjs @@ -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/.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/.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, };