* test(#2724): delete golden-install-parity fixtures, test, and generator Removes the 19 committed path->hash manifests, the two per-file size baselines, tests/golden-install-parity.test.cjs, and scripts/gen-golden-install-parity-zcode.cjs. These were pure functions of the source tree (ADR-2719); the differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the sole gate for emitted-artifact propagation. tests/fixtures/install-tree/*.json and tests/golden-install-tree.test.cjs are unchanged (ADR-2719 section 7 exception). Follow-up commits fix the resulting bookkeeping: scripts/ci-test-scope.cjs's existence guard, .gitattributes, package.json scripts, the emitted-provenance totality guard's IO, the differential check's baseline acquisition, CI wiring to publish/restore the baseline artifact, and docs. * refactor(#2724): make the differential attribution check self-sufficient Three fixes required to delete the golden fixtures without breaking CI: - scripts/ci-test-scope.cjs: remove tests/golden-install-parity.test.cjs from the three rules that named it. #2759's missingRuleTestFiles guard hard-throws at module load if a rule names a test file absent from disk, which would break the changes job on every PR the moment the fixture-deletion commit landed. - tests/helpers/emitted-provenance.cjs: loadManifests() read the committed golden fixture directory. With that directory deleted at every future ref, this would throw at module load forever, taking the Phase 2 totality guard down with it. Rebuilt from real installer spawns (MANIFEST_FAMILIES + runMinimalInstall + buildParityManifest), the same shape emitted-runtime.cjs's currentManifests() already uses. - tests/emitted-attribution.test.cjs / tests/helpers/emitted-runtime.cjs: the real-tree test's baseline acquisition swaps from baselineManifestsAtRef(base) (git show at a ref that no longer carries fixtures) to resolveBaseline()'s documented precedence: env, then the on-disk cache, then an in-job build. The build fallback (buildBaselineAtRef, new) checks out base into a throwaway git worktree and runs the new scripts/gen-emitted-baseline.cjs there -- no npm ci needed, since bin/install.js and the test helper shells are Node-builtins-only. That script also publishes the baseline artifact from CI's push-to-next job (wired in a follow-up commit). * refactor(#2724): retire the merge-driver bridge and per-file size baselines The Phase 1 bridge (#2721) is retired now that the artifacts it guarded are deleted: scripts/git-merge-regen-driver.cjs, its test, and the 'setup:merge-driver' npm script are removed, and the .gitattributes merge=gsd-regen/linguist-generated block for the three deleted-path globs is dropped. tests/fixtures/install-tree/*.json keeps its normal merge behavior, unchanged (ADR-2719 section 7). scripts/update-size-baseline.cjs and its test are removed: their sole purpose was regenerating tests/workflow-size-baseline.json and tests/agent-size-baseline.json, both deleted. The 'size:baseline' npm script and its step in 'regen:derived' go with it. The per-file baseline describe blocks in tests/workflow-size-budget.test.cjs and tests/agent-size-budget.test.cjs are removed for the same reason; the independent loose-tier hard caps are untouched. The differential attribution check's size ratchet (tests/emitted-diff.cjs, already shipped in #2723) is the replacement anti-creep mechanism. 'npm run gen:golden' is replaced by 'npm run gen:install-tree', which keeps regenerating tests/fixtures/install-tree/*.json (the one artifact family ADR-2719 section 7 keeps committed); tests/golden-install-tree.test.cjs's error messages point at the new command name. tests/golden-parity-single-source.test.cjs's anti-divergence guard (#2266) is retargeted from the two deleted golden-parity consumers to their two replacements (tests/helpers/emitted-runtime.cjs and tests/helpers/emitted-provenance.cjs), which import buildParityManifest the same way — the divergence risk the guard exists for is unchanged. Also wires CI: a new publish-emitted-baseline job runs scripts/gen-emitted-baseline.cjs after a push to next and caches the result keyed on the sha; the test and test-full jobs restore that cache on pull_request events, keyed on the PR's base sha, and export GSD_EMITTED_BASELINE for tests/emitted-attribution.test.cjs's real-tree test to pick up. * docs(#2724): flip ADR-2719 to Accepted and update contributor docs Status: Proposed -> Accepted. Regenerated docs/adr/README.md index. CONTRIBUTING.md, docs/TESTING-SUITES.md, and CONTEXT.md (RULESET. EMITTED_ATTRIBUTION, RULESET.WORKFLOW_SIZE_BUDGET, RULESET. AGENT_SIZE_BUDGET, and the Emitted Artifact Provenance glossary entry) no longer point at the deleted golden-install-parity fixtures, size baselines, gen:golden, UPDATE_GOLDEN, or the setup:merge-driver / git-merge-regen-driver.cjs bridge. Editing shipped content now requires zero manual fixture regeneration, documented against the differential attribution check instead of the deleted commands. * docs(#2724): add changeset for removed golden-parity commands * fix(#2724): drop stale scripts/update-size-baseline.cjs glossary ref check-glossary-refs.cjs verifies every backtick-wrapped scripts/*.cjs token in CONTEXT.md resolves to a real file. The RULESET. EMITTED_ATTRIBUTION rewrite named the deleted script inside backticks, which the checker reads as a live reference, not historical prose. * test(#2724): retarget ci-test-scope tests off the deleted golden test tests/ci-test-scope.test.cjs asserted specific RULES entries select tests/golden-install-parity.test.cjs, and that every rule selecting it also selects both emitted gates. Both premises broke when the golden test was deleted (#2724): the deleted filename never re-appears in targeted_tests, and there was no longer a third file for the gates to travel alongside. Retargeted the two selection describe blocks to assert tests/emitted-provenance.test.cjs directly (the drift guard the golden gate's rules were retargeted to), and simplified the third block to assert the two emitted gates always travel together, without reference to the golden filename. * docs(#2724): repoint two contributor how-to guides at the differential check Both guides told contributors to regenerate a baseline against tests/golden-install-parity.test.cjs, which #2724 deletes. Repointed at the differential attribution check (tests/emitted-attribution.test.cjs, ADR-2719), which needs no manual regeneration step. * fix(#2724): repair phase6-capstone-conformance's deleted-baseline read An independent orthogonal review caught a real regression this branch introduced into a test file the branch's diff never touched: tests/phase6-capstone-conformance.test.cjs read tests/workflow-size-baseline.json (deleted earlier in this branch) with no fallback, so the whole suite would throw ENOENT the moment this branch landed. The test's actual intent — prove the host-loop workflow files are real, tracked, non-empty docs — is preserved by asserting the live byte count via the same shared counter (scripts/workflow-size.cjs) the size guards already use, instead of a committed snapshot. Also, from the same review: a stale doc comment in scripts/workflow-size.cjs still named the deleted scripts/update-size-baseline.cjs as a consumer, and buildBaselineAtRef's cleanup in tests/helpers/emitted-runtime.cjs left two fs.rmSync calls unguarded against masking the primary result/error, inconsistent with the try/catch already wrapping the git cleanup beside them. Both fixed. A doc comment was added to baselineFamilyNamesAtRef explaining why it (and its siblings) are kept despite having no production caller post-cutover — they still answer real questions about refs that predate the cutover. * fix(#2724): repair three real regressions found by remote verification 1. tests/emitted-provenance.test.cjs's two hostile-input tests (non-object manifest, unreadable fixture) drove loadManifests(tmp) and monkeypatched fs.readFileSync, both premised on the deleted fixture-directory read this branch already replaced with real installer spawns -- the negative assertions silently stopped firing. loadManifests() now accepts injected {families, install, build, clean} (defaulting to production values), giving the tests a real seam to drive a bad build result and a build failure through the ACTUAL loader instead of a reimplementation, and added coverage that clean() still runs on both paths. 2. .github/workflows/test.yml's two 'Export GSD_EMITTED_BASELINE' steps hardcoded shell: bash, which is wrong on windows-latest (native pwsh) and on test-full's macos-latest legs (native zsh per that job's own matrix) -- the repo's H1 shell policy (tests/policy-shell-pinning .test.cjs) caught it. Replaced the inline bash script with scripts/ci-export-emitted-baseline-env.cjs, a plain Node script: a bare 'node <path>' command line has no shell-specific syntax, so it runs correctly under bash, zsh, and pwsh without a shell override. tests/phase6-capstone-conformance.test.cjs's deleted-baseline read (caught by the same remote run, at a commit prior to this one) was already fixed in d0c3b1242 and is not touched here; verified still passing after these changes. * fix(#2724): revive ADR-1610's new-file size cap inside the differential An isolated review caught a real regression: deleting tests/workflow-size-baseline.json silently dropped NEW_FILE_CAP (ADR-1610 Decision point 3, the Codex project_doc_max_bytes anchor) with no successor. tests/helpers/emitted-diff.cjs's size ratchet already 'continue's past any file absent from sizeBaseline -- exactly the files this cap exists to bound -- so a brand-new workflow file sized 32,769-40,960 bytes passed CI clean and shipped, then risked silent truncation at the Codex anchor at runtime. ADR-1610 is Accepted and never referenced anywhere in this branch. Fix: NEW_FILE_CAP=32768 revived inside emitted-diff.cjs's own size-ratchet loop, keyed off the SAME hasOwnProperty(sizeBaseline, name) signal the growth check already computes -- 'new' is exactly 'present in sizeCurrent, absent from sizeBaseline'. Not ack-able, matching the tier hard caps it sits beside: the fix is extraction, not an acknowledgment entry. Documented, disclosed narrowing: the pure differential module cannot see XL_WORKFLOWS/LARGE_WORKFLOWS tiering (tests/workflow-size-budget.test.cjs's classification), so a legitimately large new file must extract rather than tier in, one release earlier than an existing file would need to. ADR-1610 itself is left unamended -- this restores its decision rather than re-litigating it. Also fixes a stale comment plus a redundant real 19-installer-spawn assertion left over from the pre-injection-seam version of tests/emitted-provenance.test.cjs's build-failure test, and annotates 3 of 4 stale golden-fixture citations in docs/reference/host-integration-capability-matrix.md as superseded (the 4th is an accurate historical PR narrative, left alone). * fix(#2724): repair three red CI defects on the golden-fixture cutover Windows-only provenance false attribution (defect A): the `hooks-built` provenance rule attributed `hooks/<name>.cmd` to itself. Those shims are Windows-only installer output (ensureCodexHooksJsonSessionStart / ensureCodexHooksJsonEvent, both in src/runtime-hooks-surface.cts) wrapping the same-named `.js` hook — no `.cmd` file is ever tracked in the repo, so the self-attribution resolved to a path that exists on no platform. Only windows-latest ever emits the key, so this only failed there. Fixed by special-casing `.cmd` inside the SAME `hooks-built` rule (not a dedicated rule) — a dedicated rule would match zero paths, and therefore report as a dead rule, on every non-Windows lane of the same totality guard. `sources` already supported per-match functions; `transforms` is extended to support the same shape so the attribution can vary by match within one rule. Baseline bootstrap was structurally impossible (defect B): `buildBaselineAtRef` ran `scripts/gen-emitted-baseline.cjs` from INSIDE the base-ref worktree, but that script is new in this PR and therefore absent at any base ref that predates it — every call failed closed with "Cannot find module". Fixed by running the PR checkout's own generator against the worktree via a new `--dir` parameter, decoupling "which copy of the script runs" from "which tree it measures" (`currentManifests`/`currentSizes` gained a `repoRoot` override, threaded down to `runMinimalInstall`'s new `installScript` override). This is not just a bootstrap fix: a differential needs ONE measurement schema applied to both sides, or the two stop being comparable the moment that schema evolves — running each side's own copy would silently reintroduce that risk. Verified locally end-to-end against real origin/next: resolves a valid {version, sha, manifests, sizes} artifact with the correct sha and no leaked worktree. Changeset placeholder (defect C): `pr: 0` -> `pr: 2767`, which is what let docs-lint evaluate the fragment for the first time; it already passes (docs/TESTING-SUITES.md and friends already document the removed scripts). Also fixed while in this file: an eslint no-unused-vars warning surfaced by the changed lint run (unused `cleanup` import in tests/emitted-provenance.test.cjs). Added regression coverage for both A and B: a cross-platform spot-check that drives the real hooks-built rule against `.cmd` keys directly (not through a real Windows install), and a real-tree test that drives buildBaselineAtRef against a base ref verified (via git cat-file) to lack the generator, both skipping honestly rather than false-passing when their precondition does not hold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W5kQs6ZufZDySC6zDJfYP6 * fix(#2724): repair false .cmd byte-provenance and a permanently-skipping regression test Two isolated-review findings on PR #2767: - `hooks-built`'s `.cmd` branch attributed the Windows shim's bytes to the wrapped `hooks/<name>.js` script, asserting a byte-provenance link that does not exist — traced against buildCodexHookWindowsShimIR (src/runtime-hooks-surface.cts), only the script's NAME (a literal in that same file) flows into the .cmd bytes, never its content. Point `sources` at HOOKS_WINDOWS_SHIM_SRC instead, matching the code-derived convention used elsewhere in the table. Since `sources` is checked before `transforms` in the differential, the wrong mapping silently excused any .cmd byte movement caused by editing the wrapped .js file. - The `buildBaselineAtRef` regression test skipped unless a resolvable base ref still lacked scripts/gen-emitted-baseline.cjs — true only until this PR merges, after which every base ref carries the file and the test skips forever with zero ongoing coverage. Rebuilt hermetically: synthesize the missing-generator condition in-place via git plumbing (a throwaway commit, child of HEAD, with just that one file removed from a scratch index), never touching the real working tree, HEAD, or index, and never depending on ambient history or remotes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W5kQs6ZufZDySC6zDJfYP6 * fix(#2724): tolerate the remote runner's dubious-ownership git mount in the emitted baseline path The runner container mounts the repo at a path owned by a different uid than the process running the suite, so git's dubious-ownership protection refuses every git operation there. GitHub Actions never hits this because actions/checkout registers the workspace as safe automatically; this runner's container does not. buildBaselineAtRef is the production build-fallback the sole remaining emitted gate depends on (resolveBaseline's in-job-build leg), not just a test helper, so the fix is in the shared git() wrapper (emitted-runtime.cjs) that every caller — resolveChangedPaths, resolveBase, buildBaselineAtRef's worktree add/remove/prune, and the hermetic regression test added in the prior commit — funnels through, plus gen-emitted-baseline.cjs's own rev-parse (now reusing that same wrapper instead of a second execFileSync, so the fix has one source of truth). Each call declares -c safe.directory=<the exact directory it already operates on>, never the * wildcard. Audited every other helper on this surface (emitted-diff.cjs, emitted-baseline.cjs, install-shared.cjs) for the same gap: none of them shell out to git at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W5kQs6ZufZDySC6zDJfYP6 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1510 lines
67 KiB
JavaScript
1510 lines
67 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* emitted-attribution.test.cjs — the differential attribution check (#2723/#2724,
|
|
* ADR-2719 §1/§3/§4/§5/§6, epic #2719 Phases 3-4).
|
|
*
|
|
* This is the SOLE gate for emitted-artifact propagation (#2724, Phase 4 cutover).
|
|
* `tests/golden-install-parity.test.cjs` — the committed path->hash fixtures it dual-ran
|
|
* beside during Phase 3 — is deleted. The dual-run window it ran on real PRs (#2412,
|
|
* #2566, #2728) surfaced two real defects (#2750, #2760), both now fixed and merged; no
|
|
* disagreement between the two checks was ever observed once both landed correctly.
|
|
*
|
|
* The law: every emitted path whose hash moved between `next` HEAD and PR HEAD must be
|
|
* attributable — through the Phase 2 table — to a path the PR actually changed.
|
|
* Unattributable deltas fail with the paths NAMED. The only way through is a committed
|
|
* acknowledgment, never a flag (a contributor facing a red gate sets a flag, which is
|
|
* what UPDATE_GOLDEN=1 used to be, before #2724 removed it).
|
|
*
|
|
* Structure: the pure law is exercised against synthetic manifests, which is what makes
|
|
* the four failing-first criteria practical to assert at all — and then the final test
|
|
* runs that same law against the REAL tree: 19 actual installer spawns for the current
|
|
* side, `resolveBaseline()` (env / cache / in-job build) for the baseline side, real
|
|
* `git diff` for the changed paths, and the real `tests/emitted-drift-ack.json`.
|
|
*
|
|
* That last test is load-bearing. Without it this file would be interface-only — every
|
|
* assertion true of hand-built inputs and none of the repo — which is the
|
|
* promised-but-not-built failure this epic keeps finding in its own predecessors.
|
|
* Verified by injecting an uncommitted edit to a shipped workflow: emitted output moves
|
|
* but the path never appears in `git diff origin/next...HEAD`, and the check names all
|
|
* 18 affected emitted paths.
|
|
*/
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { execFileSync } = require('node:child_process');
|
|
const fc = require('fast-check');
|
|
|
|
const { cleanup, createTempDir } = require('./helpers.cjs');
|
|
const { BUILD_SCRIPT } = require('./helpers/install-shared.cjs');
|
|
const {
|
|
resolveChangedPaths,
|
|
resolveBase,
|
|
baseRefCandidates,
|
|
buildBaselineAtRef,
|
|
currentManifests,
|
|
currentSizes,
|
|
readAckFile,
|
|
baselineFamilyNamesAtRef,
|
|
MANIFEST_FAMILIES,
|
|
MINIMUM_MANIFEST_FAMILIES,
|
|
REGISTRY_SIGNAL_PATHS,
|
|
FAMILY_REASON,
|
|
touchesRuntimeRegistry,
|
|
reconcileFamilies,
|
|
safeDirArgs,
|
|
} = require('./helpers/emitted-runtime.cjs');
|
|
|
|
const { EXPECTED_MANIFEST_COUNT, loadManifests } = require('./helpers/emitted-provenance.cjs');
|
|
const {
|
|
ACK_VERSION,
|
|
NEW_FILE_CAP,
|
|
sourceSatisfiedBy,
|
|
parseAck,
|
|
diffEmitted,
|
|
formatReport,
|
|
} = require('./helpers/emitted-diff.cjs');
|
|
|
|
const {
|
|
BASELINE_ENV,
|
|
BASELINE_VERSION,
|
|
DEFAULT_CACHE_PATH,
|
|
resolveBaseline,
|
|
} = require('./helpers/emitted-baseline.cjs');
|
|
|
|
const SHA_A = 'a'.repeat(40);
|
|
const SHA_B = 'b'.repeat(40);
|
|
|
|
/** This checkout's own root — used to build a synthetic commit in-place (see
|
|
* `buildBaselineAtRef resolves a baseline via the in-job build...` below). */
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
|
|
/** A real emitted key + its real source, so rows assert the shape production uses. */
|
|
const WORKFLOW_KEY = 'gsd-core/workflows/plan-phase.md';
|
|
const WORKFLOW_SRC = 'gsd-core/workflows/plan-phase.md';
|
|
const SKILL_KEY = 'skills/gsd-add-tests/SKILL.md';
|
|
const SKILL_SRC = 'commands/gsd/add-tests.md';
|
|
|
|
const mf = (obj) => ({ claude: obj });
|
|
|
|
// ─── Attribution: the conservation law ───────────────────────────────────────
|
|
|
|
test('unchanged hashes are not reported', () => {
|
|
const r = diffEmitted({
|
|
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
|
current: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
|
changedPaths: [],
|
|
});
|
|
assert.equal(r.moved, 0);
|
|
assert.equal(r.attributed.length, 0);
|
|
assert.equal(r.unattributable.length, 0);
|
|
assert.ok(r.ok);
|
|
});
|
|
|
|
test('a moved hash whose source changed is attributed', () => {
|
|
const r = diffEmitted({
|
|
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
|
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
|
|
changedPaths: [WORKFLOW_SRC],
|
|
});
|
|
assert.equal(r.moved, 1);
|
|
assert.equal(r.unattributable.length, 0);
|
|
assert.equal(r.attributed.length, 1);
|
|
assert.equal(r.attributed[0].via, WORKFLOW_SRC);
|
|
assert.ok(r.ok);
|
|
});
|
|
|
|
test('a trailing-slash source entry matches by prefix, segment-aware', () => {
|
|
// Kimi's root agent aggregates all of agents/ — a Phase 2 prefix source.
|
|
assert.equal(sourceSatisfiedBy('agents/', new Set(['agents/gsd-planner.md'])), 'agents/gsd-planner.md');
|
|
// Hostile: a bare startsWith would over-attribute here. It must NOT match.
|
|
assert.equal(sourceSatisfiedBy('agents/', new Set(['agentsfoo/x.md'])), null);
|
|
// Exact entries compare exactly.
|
|
assert.equal(sourceSatisfiedBy('a/b.md', new Set(['a/b.md'])), 'a/b.md');
|
|
assert.equal(sourceSatisfiedBy('a/b.md', new Set(['a/b.md.bak'])), null);
|
|
|
|
const r = diffEmitted({
|
|
baseline: { kimi: { 'agents/gsd.yaml': 'aaa' } },
|
|
current: { kimi: { 'agents/gsd.yaml': 'bbb' } },
|
|
changedPaths: ['agents/gsd-planner.md'],
|
|
});
|
|
assert.equal(r.unattributable.length, 0, 'prefix source should attribute');
|
|
assert.equal(r.attributed[0].via, 'agents/gsd-planner.md');
|
|
});
|
|
|
|
test('a moved hash nothing explains is unattributable and named', () => {
|
|
const r = diffEmitted({
|
|
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
|
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
|
|
changedPaths: ['README.md'],
|
|
});
|
|
assert.equal(r.unattributable.length, 1);
|
|
const u = r.unattributable[0];
|
|
assert.equal(u.rel, WORKFLOW_KEY);
|
|
assert.equal(u.runtime, 'claude');
|
|
assert.equal(u.ruleId, 'gsd-core-verbatim');
|
|
assert.deepEqual(u.expectedSources, [WORKFLOW_SRC]);
|
|
assert.ok(!r.ok);
|
|
|
|
// ADR-2719 §1 sells the design on this message — it is a deliverable.
|
|
const msg = formatReport(r);
|
|
assert.match(msg, /changed that nothing in this diff explains/);
|
|
assert.ok(msg.includes(WORKFLOW_KEY));
|
|
assert.ok(msg.includes(WORKFLOW_SRC), 'the message must say what WOULD have explained it');
|
|
});
|
|
|
|
test('synthesized paths are exempt from attribution', () => {
|
|
const r = diffEmitted({
|
|
baseline: mf({ 'gsd-core/VERSION': 'aaa' }),
|
|
current: mf({ 'gsd-core/VERSION': 'bbb' }),
|
|
changedPaths: [],
|
|
});
|
|
assert.equal(r.unattributable.length, 0, 'install-time state can never be unexplained');
|
|
assert.equal(r.attributed[0].via, '<synthesized: exempt>');
|
|
assert.ok(r.ok);
|
|
});
|
|
|
|
test('code-derived paths attribute to their emitting source file', () => {
|
|
// Phase 2 deliberately refused to mark these exempt; this is why.
|
|
const r = diffEmitted({
|
|
baseline: { cline: { '.clinerules/gsd.md': 'aaa' } },
|
|
current: { cline: { '.clinerules/gsd.md': 'bbb' } },
|
|
changedPaths: ['src/runtime-hooks-surface.cts'],
|
|
});
|
|
assert.equal(r.unattributable.length, 0);
|
|
assert.equal(r.attributed[0].via, 'src/runtime-hooks-surface.cts');
|
|
|
|
const blind = diffEmitted({
|
|
baseline: { cline: { '.clinerules/gsd.md': 'aaa' } },
|
|
current: { cline: { '.clinerules/gsd.md': 'bbb' } },
|
|
changedPaths: ['README.md'],
|
|
});
|
|
assert.equal(blind.unattributable.length, 1, 'had these been exempt, this ripple would be invisible forever');
|
|
});
|
|
|
|
test('an added emitted key is a ripple too', () => {
|
|
const r = diffEmitted({
|
|
baseline: mf({}),
|
|
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
|
|
changedPaths: ['README.md'],
|
|
});
|
|
assert.equal(r.moved, 1);
|
|
assert.equal(r.unattributable.length, 1);
|
|
assert.equal(r.unattributable[0].change, 'added');
|
|
});
|
|
|
|
test('a removed emitted key is reported', () => {
|
|
const r = diffEmitted({
|
|
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
|
current: mf({}),
|
|
changedPaths: [WORKFLOW_SRC],
|
|
});
|
|
assert.equal(r.removed.length, 1);
|
|
assert.equal(r.removed[0].change, 'removed');
|
|
assert.equal(r.unattributable.length, 0, 'the deletion is explained by the source change');
|
|
});
|
|
|
|
test('moved hashes with no changed paths are all unattributable', () => {
|
|
const r = diffEmitted({
|
|
baseline: mf({ [WORKFLOW_KEY]: 'aaa', [SKILL_KEY]: 'ccc' }),
|
|
current: mf({ [WORKFLOW_KEY]: 'bbb', [SKILL_KEY]: 'ddd' }),
|
|
changedPaths: [],
|
|
});
|
|
assert.equal(r.unattributable.length, 2, 'emitted output moving with zero source changes is a real finding');
|
|
assert.ok(!r.ok);
|
|
});
|
|
|
|
test('a failed git diff is an error, not an empty change set', () => {
|
|
// Treating a git failure as "nothing changed" would make everything unattributable —
|
|
// a failure storm that reads exactly like a real finding.
|
|
const r = diffEmitted({ baseline: mf({}), current: mf({}), changedPaths: null });
|
|
assert.ok(!r.ok);
|
|
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' }),
|
|
current: mf({ 'totally/unknown/thing.md': 'bbb' }),
|
|
changedPaths: [],
|
|
});
|
|
assert.ok(!r.ok);
|
|
assert.match(r.errors.join('\n'), /no rule matches/);
|
|
assert.equal(r.unattributable.length, 0, 'a table hole is an error, not a silent skip');
|
|
});
|
|
|
|
// ─── Acknowledgment file ─────────────────────────────────────────────────────
|
|
|
|
test('an acked ripple passes and is echoed', () => {
|
|
const ack = { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'converter change, #2723' } } };
|
|
const r = diffEmitted({
|
|
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
|
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
|
|
changedPaths: ['README.md'],
|
|
ack,
|
|
});
|
|
assert.equal(r.unattributable.length, 0);
|
|
assert.equal(r.acked.length, 1);
|
|
assert.equal(r.acked[0].reason, 'converter change, #2723');
|
|
assert.ok(r.ok);
|
|
});
|
|
|
|
test('a stale ack entry fails', () => {
|
|
// An ack that outlives its ripple pre-clears the NEXT one on that path.
|
|
const ack = { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: { reason: 'old' } } };
|
|
const r = diffEmitted({
|
|
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
|
current: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
|
changedPaths: [],
|
|
ack,
|
|
});
|
|
assert.deepEqual(r.staleAcks, [WORKFLOW_KEY]);
|
|
assert.ok(!r.ok);
|
|
assert.match(formatReport(r), /stale acknowledgment/);
|
|
});
|
|
|
|
test('an ack without a reason fails', () => {
|
|
for (const bad of [{ reason: '' }, { reason: ' ' }, {}, null, 42]) {
|
|
const r = diffEmitted({
|
|
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
|
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
|
|
changedPaths: [],
|
|
ack: { version: ACK_VERSION, paths: { [WORKFLOW_KEY]: bad } },
|
|
});
|
|
assert.ok(!r.ok, `${JSON.stringify(bad)} must be rejected`);
|
|
assert.match(r.errors.join('\n'), /has no non-empty "reason"/);
|
|
}
|
|
});
|
|
|
|
test('an absent ack file means no acks', () => {
|
|
const r = diffEmitted({
|
|
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
|
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
|
|
changedPaths: [WORKFLOW_SRC],
|
|
ack: null,
|
|
});
|
|
assert.equal(r.errors.length, 0);
|
|
assert.ok(r.ok, 'the healthy steady state is no ack file at all');
|
|
});
|
|
|
|
test('a live ack and a stale ack together: only the stale one is named', () => {
|
|
const ack = {
|
|
version: ACK_VERSION,
|
|
paths: {
|
|
[WORKFLOW_KEY]: { reason: 'live ripple' },
|
|
[SKILL_KEY]: { reason: 'stale' },
|
|
},
|
|
};
|
|
const r = diffEmitted({
|
|
baseline: mf({ [WORKFLOW_KEY]: 'aaa', [SKILL_KEY]: 'ccc' }),
|
|
current: mf({ [WORKFLOW_KEY]: 'bbb', [SKILL_KEY]: 'ccc' }),
|
|
changedPaths: [],
|
|
ack,
|
|
});
|
|
assert.deepEqual(r.staleAcks, [SKILL_KEY], 'the live one must not be named');
|
|
});
|
|
|
|
test('non-object ack JSON is rejected, not treated as empty', () => {
|
|
// Reading these as "no acks" would SILENTLY DISARM the gate — indistinguishable
|
|
// from a healthy run, which is the worst failure available here.
|
|
for (const bad of [0, 'a string', [], true]) {
|
|
const { errors } = parseAck(bad);
|
|
assert.ok(errors.length > 0, `${JSON.stringify(bad)} must be rejected`);
|
|
assert.match(errors.join('\n'), /must be a JSON object/);
|
|
}
|
|
assert.deepEqual(parseAck(null).errors, [], 'absent is legal');
|
|
assert.deepEqual(parseAck({}).errors, [], 'empty object is legal');
|
|
assert.equal(parseAck({ version: 99, paths: {} }).errors.length, 1, 'version drift is caught');
|
|
});
|
|
|
|
// ─── The acceptance criteria, failing-first ──────────────────────────────────
|
|
|
|
test('a ripple names the unexplained path and not the explained one', () => {
|
|
// #2723 AC: "edit one source file, corrupt an unrelated emitted file, assert the
|
|
// check names the unattributable paths."
|
|
const r = diffEmitted({
|
|
baseline: mf({ [WORKFLOW_KEY]: 'aaa', [SKILL_KEY]: 'ccc' }),
|
|
current: mf({ [WORKFLOW_KEY]: 'bbb', [SKILL_KEY]: 'ddd' }),
|
|
changedPaths: [WORKFLOW_SRC], // only the workflow source was edited
|
|
});
|
|
assert.equal(r.unattributable.length, 1);
|
|
assert.equal(r.unattributable[0].rel, SKILL_KEY, 'the unrelated emitted file is the finding');
|
|
assert.equal(r.attributed.length, 1);
|
|
assert.equal(r.attributed[0].rel, WORKFLOW_KEY, 'the explained one must NOT be reported');
|
|
assert.ok(!r.ok);
|
|
});
|
|
|
|
test('a converter change fails without an ack and passes with one', () => {
|
|
// #2723 AC: "simulate a legitimate converter change: assert it fails without an ack
|
|
// entry and passes with one." A converter edit moves emitted bytes for files whose
|
|
// sources nobody touched — ADR-2264's "~5% git cannot review".
|
|
const moved = {};
|
|
const base = {};
|
|
for (let i = 0; i < 25; i++) {
|
|
base[`skills/gsd-cmd-${i}/SKILL.md`] = `h${i}`;
|
|
moved[`skills/gsd-cmd-${i}/SKILL.md`] = `x${i}`;
|
|
}
|
|
const changedPaths = ['src/runtime-artifact-conversion.cts'];
|
|
|
|
const without = diffEmitted({ baseline: mf(base), current: mf(moved), changedPaths });
|
|
assert.equal(without.unattributable.length, 25);
|
|
assert.ok(!without.ok, 'a converter change must not pass silently');
|
|
|
|
const paths = {};
|
|
for (const rel of Object.keys(moved)) paths[rel] = { reason: 'converter rewrite, ADR-2719' };
|
|
const withAck = diffEmitted({
|
|
baseline: mf(base), current: mf(moved), changedPaths,
|
|
ack: { version: ACK_VERSION, paths },
|
|
});
|
|
assert.equal(withAck.unattributable.length, 0);
|
|
assert.equal(withAck.acked.length, 25);
|
|
assert.ok(withAck.ok);
|
|
});
|
|
|
|
test('growth is reported with its exact byte delta and needs an ack', () => {
|
|
// ADR-2719 must-have 6, added by an /adr-phase-coverage audit precisely because
|
|
// scope item 5 promised it and no criterion asserted it.
|
|
const sizeBaseline = { 'verify-work.md': 10000, 'plan-phase.md': 8000 };
|
|
const sizeCurrent = { 'verify-work.md': 11247, 'plan-phase.md': 8000 };
|
|
|
|
const without = diffEmitted({
|
|
baseline: mf({}), current: mf({}), changedPaths: [], sizeBaseline, sizeCurrent,
|
|
});
|
|
assert.equal(without.grown.length, 1);
|
|
assert.deepEqual(without.grown[0], {
|
|
name: 'verify-work.md', from: 10000, to: 11247, delta: 1247, acked: false,
|
|
});
|
|
assert.ok(!without.ok, 'unacked growth must block');
|
|
assert.match(formatReport(without), /verify-work\.md grew 1247 bytes/);
|
|
|
|
const withAck = diffEmitted({
|
|
baseline: mf({}), current: mf({}), changedPaths: [], sizeBaseline, sizeCurrent,
|
|
ack: { version: ACK_VERSION, paths: { 'verify-work.md': { reason: 'new UAT section' } } },
|
|
});
|
|
assert.equal(withAck.grown[0].acked, true);
|
|
assert.ok(withAck.ok);
|
|
});
|
|
|
|
test('an ack consumed by size growth alone is not reported as stale', () => {
|
|
// Ordering regression: stale-ack detection must run AFTER the size pass. Computing it
|
|
// between the hash pass and the size pass reports a legitimate growth ack as stale —
|
|
// a false failure that would push contributors to delete the very ack that is working.
|
|
const r = diffEmitted({
|
|
baseline: mf({}),
|
|
current: mf({}),
|
|
changedPaths: [],
|
|
sizeBaseline: { 'verify-work.md': 10000 },
|
|
sizeCurrent: { 'verify-work.md': 11247 },
|
|
ack: { version: ACK_VERSION, paths: { 'verify-work.md': { reason: 'new UAT section' } } },
|
|
});
|
|
assert.deepEqual(r.staleAcks, [], 'a growth-consumed ack is live, not stale');
|
|
assert.equal(r.grown[0].acked, true);
|
|
assert.ok(r.ok);
|
|
});
|
|
|
|
test('shrinkage is reported but needs no ack', () => {
|
|
const r = diffEmitted({
|
|
baseline: mf({}), current: mf({}), changedPaths: [],
|
|
sizeBaseline: { 'a.md': 9000 }, sizeCurrent: { 'a.md': 8000 },
|
|
});
|
|
assert.deepEqual(r.shrunk, [{ name: 'a.md', from: 9000, to: 8000, delta: 1000 }]);
|
|
assert.ok(r.ok, 'shrinkage is not creep — gating it would punish what the ratchet wants');
|
|
});
|
|
|
|
// ─── New-file cap (ADR-1610 Decision point 3, revived after #2724) ───────────
|
|
//
|
|
// tests/workflow-size-baseline.json used to double as the "has this file been
|
|
// baselined before" signal a NEW_FILE_CAP check keyed off. #2724 deleted it without
|
|
// reviving that check anywhere — a brand-new workflow/agent file (present in
|
|
// sizeCurrent, absent from sizeBaseline) got zero size scrutiny at all, silently
|
|
// loosening the bound from 32768 (ADR-1610) to whichever tier cap it happened to
|
|
// fall under (DEFAULT_CAP = 40960, nearly 8 KiB looser) with nothing in CI to say so.
|
|
// A file in that gap risks silent truncation at the Codex `project_doc_max_bytes`
|
|
// anchor. Not ack-able — same as the tier hard caps, the fix is extraction.
|
|
|
|
test('a brand-new file at exactly the cap is accepted (limit)', () => {
|
|
const r = diffEmitted({
|
|
baseline: mf({}), current: mf({}), changedPaths: [],
|
|
sizeBaseline: {}, sizeCurrent: { 'new-workflow.md': NEW_FILE_CAP },
|
|
});
|
|
assert.deepEqual(r.newFileCapExceeded, []);
|
|
assert.ok(r.ok, `exactly ${NEW_FILE_CAP} bytes must be accepted`);
|
|
});
|
|
|
|
test('a brand-new file one byte over the cap is rejected (limit+1)', () => {
|
|
const r = diffEmitted({
|
|
baseline: mf({}), current: mf({}), changedPaths: [],
|
|
sizeBaseline: {}, sizeCurrent: { 'new-workflow.md': NEW_FILE_CAP + 1 },
|
|
});
|
|
assert.deepEqual(r.newFileCapExceeded, [
|
|
{ name: 'new-workflow.md', bytes: NEW_FILE_CAP + 1, cap: NEW_FILE_CAP },
|
|
]);
|
|
assert.ok(!r.ok, `${NEW_FILE_CAP + 1} bytes must be rejected`);
|
|
assert.match(formatReport(r), /new-workflow\.md is 32769 bytes/);
|
|
});
|
|
|
|
test('a brand-new file one byte under the cap is accepted (limit-1)', () => {
|
|
const r = diffEmitted({
|
|
baseline: mf({}), current: mf({}), changedPaths: [],
|
|
sizeBaseline: {}, sizeCurrent: { 'new-workflow.md': NEW_FILE_CAP - 1 },
|
|
});
|
|
assert.deepEqual(r.newFileCapExceeded, []);
|
|
assert.ok(r.ok, `${NEW_FILE_CAP - 1} bytes must be accepted`);
|
|
});
|
|
|
|
test('the new-file cap is not ack-able (extraction, not acknowledgment, is the fix)', () => {
|
|
const r = diffEmitted({
|
|
baseline: mf({}), current: mf({}), changedPaths: [],
|
|
sizeBaseline: {}, sizeCurrent: { 'new-workflow.md': NEW_FILE_CAP + 1 },
|
|
ack: { version: ACK_VERSION, paths: { 'new-workflow.md': { reason: 'trying to bypass it' } } },
|
|
});
|
|
assert.equal(r.newFileCapExceeded.length, 1, 'an ack entry must not exempt the new-file cap');
|
|
assert.ok(!r.ok);
|
|
});
|
|
|
|
test('an existing (baselined) file is governed by growth, not the new-file cap', () => {
|
|
// A file already IN sizeBaseline is not "new" even if it happens to sit above
|
|
// NEW_FILE_CAP — that is the tier hard cap's job, not this one's.
|
|
const r = diffEmitted({
|
|
baseline: mf({}), current: mf({}), changedPaths: [],
|
|
sizeBaseline: { 'old.md': NEW_FILE_CAP + 5000 },
|
|
sizeCurrent: { 'old.md': NEW_FILE_CAP + 5000 },
|
|
});
|
|
assert.deepEqual(r.newFileCapExceeded, []);
|
|
assert.deepEqual(r.grown, []);
|
|
assert.ok(r.ok);
|
|
});
|
|
|
|
// ─── Baseline resolution + staleness ─────────────────────────────────────────
|
|
|
|
const goodBaseline = (sha) => ({
|
|
version: BASELINE_VERSION,
|
|
sha,
|
|
manifests: { claude: { [WORKFLOW_KEY]: 'aaa' } },
|
|
sizes: { 'plan-phase.md': 100 },
|
|
});
|
|
|
|
test('a stale baseline cache key is detected, not used', () => {
|
|
// ADR-2719 §5: the one thing that has to be exactly right.
|
|
const r = resolveBaseline({
|
|
expectedSha: SHA_A,
|
|
env: {},
|
|
cachePath: 'cache.json',
|
|
readJson: () => goodBaseline(SHA_B),
|
|
});
|
|
assert.ok(!r.ok);
|
|
assert.match(r.errors.join('\n'), /STALE baseline/);
|
|
assert.ok(r.errors.join('\n').includes(SHA_B) && r.errors.join('\n').includes(SHA_A));
|
|
});
|
|
|
|
test('a matching baseline sha is accepted', () => {
|
|
const r = resolveBaseline({
|
|
expectedSha: SHA_A, env: {}, cachePath: 'cache.json',
|
|
readJson: () => goodBaseline(SHA_A),
|
|
});
|
|
assert.ok(r.ok);
|
|
assert.equal(r.sha, SHA_A);
|
|
assert.equal(r.via, 'cache:cache.json');
|
|
assert.deepEqual(r.sizeBaseline, { 'plan-phase.md': 100 });
|
|
});
|
|
|
|
test('an unavailable baseline fails explicitly rather than skipping', () => {
|
|
// ADR-2719 §6 — in node:test a bare `return` is a PASS, which would fail the gate open.
|
|
const r = resolveBaseline({
|
|
expectedSha: SHA_A, env: {}, cachePath: 'cache.json',
|
|
readJson: () => null,
|
|
});
|
|
assert.ok(!r.ok);
|
|
assert.equal(r.via, 'none');
|
|
assert.match(r.errors.join('\n'), /bare `return` is a PASS/);
|
|
});
|
|
|
|
test('a malformed baseline is rejected', () => {
|
|
for (const bad of [0, 'str', [], true]) {
|
|
const r = resolveBaseline({
|
|
expectedSha: SHA_A, env: {}, cachePath: 'c.json', readJson: () => bad,
|
|
});
|
|
assert.ok(!r.ok, `${JSON.stringify(bad)} must be rejected`);
|
|
}
|
|
const noSha = resolveBaseline({
|
|
expectedSha: SHA_A, env: {}, cachePath: 'c.json',
|
|
readJson: () => ({ version: BASELINE_VERSION, manifests: {} }),
|
|
});
|
|
assert.match(noSha.errors.join('\n'), /must be a 40-hex commit sha/);
|
|
});
|
|
|
|
test('baseline resolution precedence is explicit and reported', () => {
|
|
// env wins over cache…
|
|
const viaEnv = resolveBaseline({
|
|
expectedSha: SHA_A,
|
|
env: { [BASELINE_ENV]: '/tmp/from-cache-restore.json' },
|
|
cachePath: 'cache.json',
|
|
readJson: (p) => (p === '/tmp/from-cache-restore.json' ? goodBaseline(SHA_A) : goodBaseline(SHA_B)),
|
|
});
|
|
assert.ok(viaEnv.ok);
|
|
assert.equal(viaEnv.via, `env:${BASELINE_ENV}`);
|
|
|
|
// …and an explicitly-pointed-at stale baseline is a hard stop, not a fall-through:
|
|
// the operator said "use this one".
|
|
const envStale = resolveBaseline({
|
|
expectedSha: SHA_A,
|
|
env: { [BASELINE_ENV]: '/tmp/x.json' },
|
|
cachePath: 'cache.json',
|
|
readJson: () => goodBaseline(SHA_B),
|
|
buildFallback: () => goodBaseline(SHA_A),
|
|
});
|
|
assert.ok(!envStale.ok, 'an explicit stale baseline must not silently fall through');
|
|
|
|
// a stale CACHE, by contrast, falls through to the build fallback
|
|
const viaBuild = resolveBaseline({
|
|
expectedSha: SHA_A, env: {}, cachePath: 'cache.json',
|
|
readJson: () => goodBaseline(SHA_B),
|
|
buildFallback: () => goodBaseline(SHA_A),
|
|
});
|
|
assert.ok(viaBuild.ok);
|
|
assert.equal(viaBuild.via, 'build');
|
|
});
|
|
|
|
test('base-ref candidates are ordered most-specific first and de-duplicated', () => {
|
|
// The gate went red on its first matrix run because it hard-depended on
|
|
// `origin/next`, which cannot exist in the gsd-test container (shallow clone +
|
|
// base/head merge, no remote-tracking refs). Candidate order is the fix, so it is
|
|
// pinned rather than left implicit.
|
|
assert.deepEqual(
|
|
baseRefCandidates({ GSD_EMITTED_BASE: 'abc123', GITHUB_BASE_REF: 'next' }),
|
|
['abc123', 'origin/next', 'next'],
|
|
'an explicit override wins, then the Actions base ref, then the defaults',
|
|
);
|
|
assert.deepEqual(
|
|
baseRefCandidates({ GITHUB_BASE_REF: 'release/1.9' }),
|
|
['origin/release/1.9', 'release/1.9', 'origin/next', 'next'],
|
|
'a non-next base ref is honored before falling back',
|
|
);
|
|
assert.deepEqual(
|
|
baseRefCandidates({}),
|
|
['origin/next', 'next'],
|
|
'with no env, the repo defaults are the only candidates',
|
|
);
|
|
// De-duplication matters: GITHUB_BASE_REF=next must not produce origin/next twice.
|
|
const dupes = baseRefCandidates({ GITHUB_BASE_REF: 'next' });
|
|
assert.equal(new Set(dupes).size, dupes.length);
|
|
});
|
|
|
|
test('an unreadable baseline surfaces an error', () => {
|
|
const r = resolveBaseline({
|
|
expectedSha: SHA_A, env: {}, cachePath: 'c.json',
|
|
readJson: () => { throw new Error('injected read failure'); },
|
|
});
|
|
assert.ok(!r.ok);
|
|
assert.match(r.errors.join('\n'), /injected read failure/);
|
|
});
|
|
|
|
// ─── buildBaselineAtRef: the in-job build must bootstrap without its own generator ──
|
|
|
|
test(
|
|
'buildBaselineAtRef resolves a baseline via the in-job build even when the generator '
|
|
+ 'script is absent at the ref (#2767 regression)',
|
|
{ timeout: 300_000 },
|
|
(t) => {
|
|
// Mirrors "differential attribution over the real tree": install output is
|
|
// platform-specific on Windows, and this drives the same heavy worktree +
|
|
// build:lib + 19-installer pipeline.
|
|
if (process.platform === 'win32') {
|
|
t.skip('emitted parity is asserted on macOS + Linux; Windows install output is platform-specific');
|
|
return;
|
|
}
|
|
|
|
// Hermetic by construction (#2767 review finding B). This test used to resolve a
|
|
// real base ref (typically `origin/next`) and skip unless that ref, checked via
|
|
// `git cat-file -e`, still LACKED scripts/gen-emitted-baseline.cjs — the file THIS
|
|
// PR adds. That was true only until this PR merged: after merge every resolvable
|
|
// base ref carries the file, the precondition is permanently false, and the test
|
|
// would skip forever, losing all regression value silently (a skip reads as green).
|
|
// It also depended on `origin/next` being resolvable at all, which the gsd-test
|
|
// runner's shallow clone + base/head merge does not guarantee (no remote-tracking
|
|
// refs) — the same non-hermetic-history failure mode "baseline families are
|
|
// enumerated from the ref, not from the current registry" (above) was rewritten to
|
|
// avoid, by building its own throwaway git repo instead of reaching for this
|
|
// repo's history.
|
|
//
|
|
// That precedent doesn't directly transplant here: `buildBaselineAtRef` needs a
|
|
// REAL, buildable gsd-core tree (`npm run build:lib`, the compiled `bin/lib/*.cjs`,
|
|
// `node_modules`) to produce a real manifest — a minimal from-scratch repo has none
|
|
// of that. So instead of a from-scratch repo, this synthesizes the missing-generator
|
|
// condition IN-PLACE with git plumbing: read this checkout's own HEAD tree into a
|
|
// scratch index (a temp `GIT_INDEX_FILE`, never the real `.git/index`), remove just
|
|
// `scripts/gen-emitted-baseline.cjs` from that index, write the resulting tree, and
|
|
// commit it as a child of HEAD. The result is one loose commit object — a real,
|
|
// buildable tree identical to HEAD's except missing the one file under test — that
|
|
// is never referenced by any branch, tag, or ref, so it is not checked out, not
|
|
// pushed, and needs no cleanup beyond the scratch index directory itself. The real
|
|
// working tree, HEAD, and index of this checkout are never touched.
|
|
const tmpIndexDir = createTempDir('emitted-baseline-synth-index-');
|
|
t.after(() => cleanup(tmpIndexDir));
|
|
const tmpIndexFile = path.join(tmpIndexDir, 'index');
|
|
const gitEnv = {
|
|
...process.env,
|
|
GIT_INDEX_FILE: tmpIndexFile,
|
|
GIT_AUTHOR_NAME: 'GSD Test', GIT_AUTHOR_EMAIL: 'test@example.invalid',
|
|
GIT_COMMITTER_NAME: 'GSD Test', GIT_COMMITTER_EMAIL: 'test@example.invalid',
|
|
};
|
|
// `-c safe.directory=<REPO_ROOT>` via the shared `safeDirArgs` (emitted-runtime.cjs):
|
|
// the remote runner mounts this repo at a path owned by a different uid, and git's
|
|
// dubious-ownership protection refuses every operation there otherwise — this test
|
|
// proved that the hard way (#2767 review) when its first `git rev-parse HEAD` failed
|
|
// closed. Reusing the SAME helper `buildBaselineAtRef` now uses (below) rather than
|
|
// hand-rolling the flag here keeps the fix from silently diverging per call site.
|
|
const run = (...args) => execFileSync('git', [...safeDirArgs(REPO_ROOT), ...args], {
|
|
cwd: REPO_ROOT, encoding: 'utf8', timeout: 30_000, env: gitEnv, stdio: ['ignore', 'pipe', 'pipe'],
|
|
}).trim();
|
|
|
|
const headSha = run('rev-parse', 'HEAD');
|
|
run('read-tree', 'HEAD');
|
|
run('update-index', '--force-remove', 'scripts/gen-emitted-baseline.cjs');
|
|
const syntheticTree = run('write-tree');
|
|
const syntheticSha = run(
|
|
'commit-tree', syntheticTree, '-p', headSha, '-m',
|
|
'synthetic: missing scripts/gen-emitted-baseline.cjs (#2767 test fixture — unreferenced, never pushed)',
|
|
);
|
|
|
|
// Precondition, ASSERTED not assumed: the synthetic commit truly lacks the file —
|
|
// otherwise this test would prove nothing.
|
|
assert.throws(
|
|
() => execFileSync('git', [...safeDirArgs(REPO_ROOT), 'cat-file', '-e', `${syntheticSha}:scripts/gen-emitted-baseline.cjs`], {
|
|
cwd: REPO_ROOT, encoding: 'utf8', timeout: 30_000, stdio: 'pipe',
|
|
}),
|
|
/./,
|
|
'the synthetic ref must genuinely lack the generator script for this test to prove anything',
|
|
);
|
|
|
|
// The actual regression assertion: this must NOT throw "Cannot find module", and
|
|
// must produce a well-formed baseline artifact measuring the SYNTHETIC ref, not the
|
|
// caller's own tree. Before the #2767 fix, `buildBaselineAtRef` unconditionally ran
|
|
// `<worktreeDir>/scripts/gen-emitted-baseline.cjs` — the checked-out WORKTREE'S OWN
|
|
// copy — which fails closed with `Cannot find module` for exactly this ref shape.
|
|
const artifact = buildBaselineAtRef(syntheticSha, { cwd: REPO_ROOT });
|
|
assert.equal(artifact.version, BASELINE_VERSION);
|
|
assert.equal(
|
|
artifact.sha, syntheticSha,
|
|
'the artifact must report the REF\'s sha, not the caller checkout\'s',
|
|
);
|
|
assert.ok(artifact.manifests && typeof artifact.manifests === 'object');
|
|
assert.ok(
|
|
Object.keys(artifact.manifests).length >= MINIMUM_MANIFEST_FAMILIES,
|
|
`expected at least ${MINIMUM_MANIFEST_FAMILIES} manifest families, got ${Object.keys(artifact.manifests).length}`,
|
|
);
|
|
assert.ok(artifact.sizes && Object.keys(artifact.sizes).length > 0, 'sizes must be non-empty');
|
|
},
|
|
);
|
|
|
|
test('readAckFile: absent is legal, malformed and unreadable are not', () => {
|
|
const tmp = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gsd-ack-'));
|
|
try {
|
|
const ackPath = path.join(tmp, 'emitted-drift-ack.json');
|
|
|
|
// Absent == no acks. The healthy steady state.
|
|
assert.equal(readAckFile(ackPath), null);
|
|
|
|
// Present and valid.
|
|
fs.writeFileSync(ackPath, JSON.stringify({ version: ACK_VERSION, paths: {} }));
|
|
assert.deepEqual(readAckFile(ackPath), { version: ACK_VERSION, paths: {} });
|
|
|
|
// Present but empty — must NOT be read as absent.
|
|
fs.writeFileSync(ackPath, '');
|
|
assert.throws(() => readAckFile(ackPath), /present but empty/);
|
|
|
|
// Present but not JSON.
|
|
fs.writeFileSync(ackPath, '{not json');
|
|
assert.throws(() => readAckFile(ackPath), /not valid JSON/);
|
|
|
|
// Unreadable: monkeypatch the fs method, restore in `finally`. NEVER chmod 0o000 —
|
|
// root bypasses mode bits, so the test would silently pass with zero coverage in
|
|
// root Docker/CI. This exercises the SUT (readAckFile), not fs itself.
|
|
fs.writeFileSync(ackPath, JSON.stringify({ version: ACK_VERSION, paths: {} }));
|
|
const orig = fs.readFileSync;
|
|
try {
|
|
fs.readFileSync = () => { throw new Error('injected ack read failure'); };
|
|
assert.throws(() => readAckFile(ackPath), /injected ack read failure/);
|
|
} finally {
|
|
fs.readFileSync = orig;
|
|
}
|
|
// Restoration is real, not assumed.
|
|
assert.deepEqual(readAckFile(ackPath), { version: ACK_VERSION, paths: {} });
|
|
} finally {
|
|
cleanup(tmp);
|
|
}
|
|
});
|
|
|
|
test('formatReport truncation is exact at limit-1 / limit / limit+1', () => {
|
|
// sampleLimit gates a real branch. CLAUDE.md's boundary rule applies to it like any
|
|
// other limit; the earlier suite named a test "limit+1" that tested no numeric limit
|
|
// at all, which is worse than no coverage because it reads as covered.
|
|
const build = (n) => {
|
|
const baseline = {}; const current = {};
|
|
for (let i = 0; i < n; i++) {
|
|
const k = `gsd-core/workflows/w${String(i).padStart(3, '0')}.md`;
|
|
baseline[k] = 'a'; current[k] = 'b';
|
|
}
|
|
return diffEmitted({ baseline: mf(baseline), current: mf(current), changedPaths: [] });
|
|
};
|
|
|
|
const at19 = formatReport(build(19), { sampleLimit: 20 });
|
|
assert.ok(at19.includes('w018.md'), 'limit-1 lists every path');
|
|
assert.ok(!at19.includes('…and'), 'limit-1 must not truncate');
|
|
|
|
const at20 = formatReport(build(20), { sampleLimit: 20 });
|
|
assert.ok(at20.includes('w019.md'), 'at the limit the last path is listed');
|
|
assert.ok(!at20.includes('…and'), 'exactly at the limit must not truncate');
|
|
|
|
const at21 = formatReport(build(21), { sampleLimit: 20 });
|
|
assert.ok(at21.includes('…and 1 more'), 'limit+1 truncates and says how many were hidden');
|
|
assert.ok(!at21.includes('w020.md'), 'the 21st path is not listed');
|
|
});
|
|
|
|
// ─── Independence + purity ───────────────────────────────────────────────────
|
|
|
|
test('the differential covers every runtime present in either manifest', () => {
|
|
const baseline = { claude: { [WORKFLOW_KEY]: 'a' }, kimi: { [WORKFLOW_KEY]: 'a' } };
|
|
const current = { claude: { [WORKFLOW_KEY]: 'b' }, opencode: { [WORKFLOW_KEY]: 'c' } };
|
|
const r = diffEmitted({ baseline, current, changedPaths: [] });
|
|
const seen = new Set([...r.unattributable, ...r.attributed, ...r.removed].map((x) => x.runtime));
|
|
assert.deepEqual([...seen].sort(), ['claude', 'kimi', 'opencode'],
|
|
'a runtime present on only one side must still be evaluated');
|
|
});
|
|
|
|
test('diff is pure and repeatable', () => {
|
|
const args = {
|
|
baseline: mf({ [WORKFLOW_KEY]: 'aaa' }),
|
|
current: mf({ [WORKFLOW_KEY]: 'bbb' }),
|
|
// 3 elements in deliberately unsorted order, so an in-place sort would be visible.
|
|
changedPaths: ['zzz/last.md', WORKFLOW_SRC, 'aaa/first.md'],
|
|
};
|
|
const frozen = JSON.stringify(args);
|
|
const a = diffEmitted(args);
|
|
const b = diffEmitted(args);
|
|
assert.deepEqual(b, a);
|
|
assert.equal(JSON.stringify(args), frozen, 'inputs must not be mutated');
|
|
});
|
|
|
|
// ─── Property: conservation ──────────────────────────────────────────────────
|
|
|
|
test('property: every moved key lands in exactly one bucket', () => {
|
|
// The conservation law itself. A key silently dropped from all three buckets is a
|
|
// hole in the very invariant ADR-2719 asserts — and it is the failure a hand-written
|
|
// example set is least likely to find.
|
|
const keys = [WORKFLOW_KEY, SKILL_KEY, 'agents/gsd-planner.md', 'scripts/lib/cli-exit.cjs'];
|
|
const sources = { [WORKFLOW_KEY]: WORKFLOW_SRC, [SKILL_KEY]: SKILL_SRC,
|
|
'agents/gsd-planner.md': 'agents/gsd-planner.md', 'scripts/lib/cli-exit.cjs': 'scripts/lib/cli-exit.cjs' };
|
|
|
|
fc.assert(
|
|
fc.property(
|
|
fc.subarray(keys, { minLength: 1 }), // which keys move
|
|
fc.subarray(keys), // which sources the PR changed
|
|
fc.subarray(keys), // which keys are acked
|
|
(movedKeys, changedKeys, ackedKeys) => {
|
|
const baseline = {}; const current = {};
|
|
for (const k of keys) { baseline[k] = 'h'; current[k] = movedKeys.includes(k) ? 'x' : 'h'; }
|
|
const ackPaths = {};
|
|
for (const k of ackedKeys) ackPaths[k] = { reason: 'property' };
|
|
|
|
const r = diffEmitted({
|
|
baseline: mf(baseline),
|
|
current: mf(current),
|
|
changedPaths: changedKeys.map((k) => sources[k]),
|
|
ack: { version: ACK_VERSION, paths: ackPaths },
|
|
});
|
|
|
|
if (r.errors.length) return false;
|
|
const bucketed = [
|
|
...r.attributed.map((x) => x.rel),
|
|
...r.unattributable.map((x) => x.rel),
|
|
...r.acked.map((x) => x.rel),
|
|
];
|
|
// exactly-once, and exactly the moved set — no key invented, none dropped
|
|
return bucketed.length === movedKeys.length
|
|
&& new Set(bucketed).size === bucketed.length
|
|
&& movedKeys.every((k) => bucketed.includes(k));
|
|
},
|
|
),
|
|
{ numRuns: 400 },
|
|
);
|
|
});
|
|
|
|
// ─── Family reconciliation (#2723 correction) ────────────────────────────────
|
|
//
|
|
// #2723 shipped `EXPECTED_MANIFEST_COUNT = 19` asserted against BOTH the baseline (built
|
|
// at the base ref) and the current tree (built at PR HEAD). Those sides legitimately
|
|
// differ by one family whenever a PR adds or removes a runtime, so no value satisfied
|
|
// both: 19 rejected the current side, 20 rejected the baseline side. Every runtime-adding
|
|
// PR was hard-blocked — found by tracing #2005 (Qoder) through the gate.
|
|
//
|
|
// Driven at PURE-FUNCTION altitude on purpose. The real-tree test below skips wherever no
|
|
// base ref exists (the gsd-test runner shallow-clones, so `origin/*` is absent), so a
|
|
// regression written at that altitude would silently skip on the very runner that has to
|
|
// prove RED.
|
|
|
|
const ALL_FAMILIES = MANIFEST_FAMILIES.map((f) => f.name);
|
|
const REGISTRY_CHANGE = ['tests/helpers/install-shared.cjs'];
|
|
// The shape a shipping caller passes: repo-relative POSIX paths from `git diff --name-only`.
|
|
const CONTENT_ONLY_CHANGE = ['gsd-core/workflows/plan-phase.md'];
|
|
|
|
const derivedOf = (names) => names.map((name) => ({ name, runtime: name, scope: 'global' }));
|
|
const manifestsOf = (names) => Object.fromEntries(names.map((n) => [n, { 'some/emitted/path': 'hash' }]));
|
|
|
|
/** Build a fully-consistent reconciliation input, then override one facet per test. */
|
|
function reconcileWith({ derivedNames = ALL_FAMILIES, fixtureNames, baselineNames, currentNames, ...rest }) {
|
|
return reconcileFamilies({
|
|
derived: derivedOf(derivedNames),
|
|
fixtures: fixtureNames || derivedNames,
|
|
baseline: manifestsOf(baselineNames || derivedNames),
|
|
current: manifestsOf(currentNames || derivedNames),
|
|
changedPaths: CONTENT_ONLY_CHANGE,
|
|
...rest,
|
|
});
|
|
}
|
|
|
|
const codesOf = (r) => r.errors.map((e) => e.code);
|
|
|
|
test('reason codes are a frozen, locked set', () => {
|
|
assert.deepEqual(Object.keys(FAMILY_REASON).sort(), [
|
|
'ADDED_UNATTRIBUTED', 'BAD_CHANGED_PATHS', 'BASELINE_UNUSABLE', 'BELOW_FLOOR',
|
|
'CURRENT_UNUSABLE', 'DERIVED_UNUSABLE', 'DROPPED_UNATTRIBUTED',
|
|
'FIXTURES_UNUSABLE', 'FIXTURE_WITHOUT_RUNTIME', 'MISSING_CLAUDE_LOCAL',
|
|
'RUNTIME_WITHOUT_FIXTURE',
|
|
]);
|
|
assert.ok(Object.isFrozen(FAMILY_REASON));
|
|
});
|
|
|
|
test('passes when every family signal agrees', () => {
|
|
assert.deepEqual(reconcileWith({}), { ok: true, errors: [] });
|
|
});
|
|
|
|
test('the count export agrees with the derived family set (divergence guard)', () => {
|
|
// The #2723 defect was two surfaces carrying independent notions of this number.
|
|
assert.equal(EXPECTED_MANIFEST_COUNT, MANIFEST_FAMILIES.length);
|
|
assert.ok(EXPECTED_MANIFEST_COUNT >= MINIMUM_MANIFEST_FAMILIES);
|
|
});
|
|
|
|
// ── The deadlock itself ──────────────────────────────────────────────────────
|
|
|
|
test('permits an added family attributed to a runtime-registry change', () => {
|
|
const withQoder = [...ALL_FAMILIES, 'qoder'];
|
|
const r = reconcileWith({
|
|
derivedNames: withQoder,
|
|
baselineNames: ALL_FAMILIES, // base ref predates the new runtime
|
|
currentNames: withQoder,
|
|
changedPaths: REGISTRY_CHANGE,
|
|
});
|
|
assert.deepEqual(r, { ok: true, errors: [] });
|
|
});
|
|
|
|
test('rejects an added family with no runtime-registry change, naming it', () => {
|
|
const withQoder = [...ALL_FAMILIES, 'qoder'];
|
|
const r = reconcileWith({
|
|
derivedNames: withQoder,
|
|
baselineNames: ALL_FAMILIES,
|
|
currentNames: withQoder,
|
|
changedPaths: CONTENT_ONLY_CHANGE,
|
|
});
|
|
assert.equal(r.ok, false);
|
|
assert.deepEqual(r.errors, [{ code: FAMILY_REASON.ADDED_UNATTRIBUTED, family: 'qoder' }]);
|
|
});
|
|
|
|
test('permits a dropped family attributed to a runtime-registry change', () => {
|
|
const without = ALL_FAMILIES.filter((n) => n !== 'trae');
|
|
const r = reconcileWith({
|
|
derivedNames: without,
|
|
baselineNames: ALL_FAMILIES,
|
|
currentNames: without,
|
|
changedPaths: REGISTRY_CHANGE,
|
|
minimum: 18,
|
|
});
|
|
assert.deepEqual(r, { ok: true, errors: [] });
|
|
});
|
|
|
|
test('rejects a silently dropped family, naming it', () => {
|
|
const without = ALL_FAMILIES.filter((n) => n !== 'trae');
|
|
const r = reconcileWith({
|
|
derivedNames: without,
|
|
baselineNames: ALL_FAMILIES,
|
|
currentNames: without,
|
|
changedPaths: CONTENT_ONLY_CHANGE,
|
|
minimum: 18,
|
|
});
|
|
assert.equal(r.ok, false);
|
|
assert.deepEqual(r.errors, [{ code: FAMILY_REASON.DROPPED_UNATTRIBUTED, family: 'trae' }]);
|
|
});
|
|
|
|
test('attribution is the ONLY permission path, symmetrically', () => {
|
|
// No ack-style bypass on either side: a one-sided escape hatch would make removals
|
|
// easier to wave through than additions, and the drift-ack file covers unattributable
|
|
// emitted-PATH deltas, not family churn.
|
|
const without = ALL_FAMILIES.filter((n) => n !== 'trae');
|
|
const added = [...ALL_FAMILIES, 'qoder'];
|
|
for (const [names, baselineNames, code, family] of [
|
|
[without, ALL_FAMILIES, FAMILY_REASON.DROPPED_UNATTRIBUTED, 'trae'],
|
|
[added, ALL_FAMILIES, FAMILY_REASON.ADDED_UNATTRIBUTED, 'qoder'],
|
|
]) {
|
|
const r = reconcileWith({
|
|
derivedNames: names, baselineNames, currentNames: names,
|
|
changedPaths: CONTENT_ONLY_CHANGE, minimum: 18,
|
|
});
|
|
assert.equal(r.ok, false);
|
|
assert.deepEqual(r.errors, [{ code, family }]);
|
|
}
|
|
});
|
|
|
|
test('an add and a drop together are permitted when attributed', () => {
|
|
const swapped = [...ALL_FAMILIES.filter((n) => n !== 'trae'), 'qoder'];
|
|
const r = reconcileWith({
|
|
derivedNames: swapped,
|
|
baselineNames: ALL_FAMILIES,
|
|
currentNames: swapped,
|
|
changedPaths: REGISTRY_CHANGE,
|
|
});
|
|
assert.deepEqual(r, { ok: true, errors: [] });
|
|
});
|
|
|
|
test('an EQUAL-COUNT membership swap is caught in both directions', () => {
|
|
// 19 in, 19 out — invisible to any count-based check. This is why the contract is
|
|
// set-based rather than numeric.
|
|
const swapped = [...ALL_FAMILIES.filter((n) => n !== 'trae'), 'qoder'];
|
|
const r = reconcileWith({
|
|
derivedNames: swapped,
|
|
baselineNames: ALL_FAMILIES,
|
|
currentNames: swapped,
|
|
changedPaths: CONTENT_ONLY_CHANGE,
|
|
});
|
|
assert.equal(swapped.length, ALL_FAMILIES.length, 'the swap must leave the totals equal');
|
|
assert.equal(r.ok, false);
|
|
assert.deepEqual(r.errors.slice().sort((a, b) => a.code.localeCompare(b.code)), [
|
|
{ code: FAMILY_REASON.ADDED_UNATTRIBUTED, family: 'qoder' },
|
|
{ code: FAMILY_REASON.DROPPED_UNATTRIBUTED, family: 'trae' },
|
|
]);
|
|
});
|
|
|
|
// ── Single-tree drift ────────────────────────────────────────────────────────
|
|
|
|
test('rejects a fixture with no registered runtime, naming it', () => {
|
|
const r = reconcileWith({ fixtureNames: [...ALL_FAMILIES, 'ghost'] });
|
|
assert.equal(r.ok, false);
|
|
assert.deepEqual(r.errors, [{ code: FAMILY_REASON.FIXTURE_WITHOUT_RUNTIME, family: 'ghost' }]);
|
|
});
|
|
|
|
test('rejects a registered runtime with no fixture, naming it', () => {
|
|
const r = reconcileWith({
|
|
derivedNames: [...ALL_FAMILIES, 'qoder'],
|
|
fixtureNames: ALL_FAMILIES,
|
|
baselineNames: [...ALL_FAMILIES, 'qoder'],
|
|
currentNames: [...ALL_FAMILIES, 'qoder'],
|
|
changedPaths: REGISTRY_CHANGE,
|
|
});
|
|
assert.equal(r.ok, false);
|
|
assert.deepEqual(r.errors, [{ code: FAMILY_REASON.RUNTIME_WITHOUT_FIXTURE, family: 'qoder' }]);
|
|
});
|
|
|
|
// ── The absolute floor: limit-1 / limit / limit+1 ────────────────────────────
|
|
|
|
test('floor is enforced at limit-1 / limit / limit+1', () => {
|
|
const eighteen = ALL_FAMILIES.filter((n) => n !== 'trae'); // limit-1
|
|
const twenty = [...ALL_FAMILIES, 'qoder']; // limit+1
|
|
|
|
const below = reconcileWith({
|
|
derivedNames: eighteen, baselineNames: eighteen, currentNames: eighteen,
|
|
});
|
|
assert.equal(below.ok, false);
|
|
assert.ok(codesOf(below).includes(FAMILY_REASON.BELOW_FLOOR));
|
|
|
|
assert.deepEqual(reconcileWith({}), { ok: true, errors: [] }); // limit == 19
|
|
|
|
const above = reconcileWith({
|
|
derivedNames: twenty, baselineNames: twenty, currentNames: twenty,
|
|
});
|
|
assert.deepEqual(above, { ok: true, errors: [] });
|
|
});
|
|
|
|
test('a uniformly shrunken universe fails on the floor', () => {
|
|
// The Goodhart move the old literal permitted: drop a runtime AND its fixture together
|
|
// and lower the constant, and 18 === 18 passes over a smaller world.
|
|
const eighteen = ALL_FAMILIES.filter((n) => n !== 'trae');
|
|
const r = reconcileWith({
|
|
derivedNames: eighteen, fixtureNames: eighteen,
|
|
baselineNames: eighteen, currentNames: eighteen,
|
|
changedPaths: REGISTRY_CHANGE,
|
|
});
|
|
assert.equal(r.ok, false);
|
|
assert.deepEqual(codesOf(r), [FAMILY_REASON.BELOW_FLOOR]);
|
|
});
|
|
|
|
// ── #2086: claude-local is pinned by name on both sides ──────────────────────
|
|
|
|
test('a missing claude-local family is named on either side', () => {
|
|
const noLocal = ALL_FAMILIES.filter((n) => n !== 'claude-local');
|
|
const missingCurrent = reconcileWith({
|
|
currentNames: noLocal, changedPaths: REGISTRY_CHANGE,
|
|
});
|
|
assert.ok(codesOf(missingCurrent).includes(FAMILY_REASON.MISSING_CLAUDE_LOCAL));
|
|
|
|
const missingBaseline = reconcileWith({
|
|
baselineNames: noLocal, changedPaths: REGISTRY_CHANGE,
|
|
});
|
|
assert.ok(codesOf(missingBaseline).includes(FAMILY_REASON.MISSING_CLAUDE_LOCAL));
|
|
});
|
|
|
|
// ── Hostile / malformed input: explicit failure, never a quiet ok ────────────
|
|
|
|
test('unusable baseline and current are rejected explicitly, not read as empty', () => {
|
|
for (const bad of [null, undefined, [], 'nope', 0]) {
|
|
const r = reconcileFamilies({
|
|
derived: derivedOf(ALL_FAMILIES), fixtures: ALL_FAMILIES,
|
|
baseline: bad, current: manifestsOf(ALL_FAMILIES), changedPaths: [],
|
|
});
|
|
assert.deepEqual(r, { ok: false, errors: [{ code: FAMILY_REASON.BASELINE_UNUSABLE }] });
|
|
}
|
|
for (const bad of [null, undefined, [], 'nope', 0]) {
|
|
const r = reconcileFamilies({
|
|
derived: derivedOf(ALL_FAMILIES), fixtures: ALL_FAMILIES,
|
|
baseline: manifestsOf(ALL_FAMILIES), current: bad, changedPaths: [],
|
|
});
|
|
assert.deepEqual(r, { ok: false, errors: [{ code: FAMILY_REASON.CURRENT_UNUSABLE }] });
|
|
}
|
|
});
|
|
|
|
test('a non-array changedPaths is an explicit error, never a silent "no registry change"', () => {
|
|
for (const bad of [null, undefined, 'tests/helpers/install-shared.cjs', {}, 7]) {
|
|
const r = reconcileWith({ changedPaths: bad });
|
|
assert.deepEqual(r, { ok: false, errors: [{ code: FAMILY_REASON.BAD_CHANGED_PATHS }] });
|
|
}
|
|
});
|
|
|
|
test('malformed derived and fixtures inputs fail with a verdict, not a TypeError', () => {
|
|
// Every input is gated. An unhandled throw here would read as an infrastructure fault
|
|
// rather than a gate verdict, which is how a propagation check goes quiet.
|
|
for (const bad of [null, undefined, 'nope', {}, [{ nope: 1 }], [null]]) {
|
|
const r = reconcileFamilies({
|
|
derived: bad, fixtures: ALL_FAMILIES,
|
|
baseline: manifestsOf(ALL_FAMILIES), current: manifestsOf(ALL_FAMILIES),
|
|
changedPaths: [],
|
|
});
|
|
assert.deepEqual(r, { ok: false, errors: [{ code: FAMILY_REASON.DERIVED_UNUSABLE }] });
|
|
}
|
|
for (const bad of [null, undefined, 'nope', {}, [1], [null]]) {
|
|
const r = reconcileFamilies({
|
|
derived: derivedOf(ALL_FAMILIES), fixtures: bad,
|
|
baseline: manifestsOf(ALL_FAMILIES), current: manifestsOf(ALL_FAMILIES),
|
|
changedPaths: [],
|
|
});
|
|
assert.deepEqual(r, { ok: false, errors: [{ code: FAMILY_REASON.FIXTURES_UNUSABLE }] });
|
|
}
|
|
});
|
|
|
|
// ── Registry attribution ─────────────────────────────────────────────────────
|
|
|
|
test('each registry-signal path independently attributes a family change', () => {
|
|
for (const p of [...REGISTRY_SIGNAL_PATHS, 'capabilities/qoder/capability.json']) {
|
|
assert.equal(touchesRuntimeRegistry([p]), true, `${p} should attribute`);
|
|
}
|
|
// Narrow on purpose: surfaces that merely accompany a runtime addition must NOT
|
|
// excuse an unattributed family delta.
|
|
for (const p of ['src/runtime-name-policy.cts', 'gsd-core/bin/lib/capability-registry.cjs']) {
|
|
assert.equal(touchesRuntimeRegistry([p]), false, `${p} must NOT attribute on its own`);
|
|
}
|
|
});
|
|
|
|
test('backslash-separated registry paths normalize unconditionally', () => {
|
|
// Path separators normalize on every platform — backslash paths arrive on Linux too.
|
|
assert.equal(touchesRuntimeRegistry(['tests\\helpers\\install-shared.cjs']), true);
|
|
assert.equal(touchesRuntimeRegistry(['capabilities\\qoder\\capability.json']), true);
|
|
});
|
|
|
|
test('near-miss paths do not attribute a family change', () => {
|
|
for (const p of [
|
|
'capabilities/qoder/other.json',
|
|
'capabilities/capability.json',
|
|
'tests/helpers/install-shared.cjs.bak',
|
|
'docs/tests/helpers/install-shared.cjs',
|
|
'gsd-core/workflows/plan-phase.md',
|
|
]) {
|
|
assert.equal(touchesRuntimeRegistry([p]), false, `${p} should NOT attribute`);
|
|
}
|
|
assert.equal(touchesRuntimeRegistry([]), false);
|
|
});
|
|
|
|
// ── The baseline must come from the REF, not from HEAD's registry ────────────
|
|
|
|
test('baseline families are enumerated from the ref, not from the current registry', (t) => {
|
|
// Regression: enumerating the baseline from MANIFEST_FAMILIES (imported at module load,
|
|
// so it describes PR HEAD) makes a REMOVED runtime invisible — the name is already gone
|
|
// from the current registry, so the base ref is never asked for it, and the dropped-
|
|
// family check can never fire in production even though its unit tests pass.
|
|
//
|
|
// Built as its own git repo rather than reaching for this repo's history. The gsd-test
|
|
// runner shallow-clones base+head, so `rev-list --max-parents=0` there returns the
|
|
// GRAFTED boundary commit — a recent one carrying every fixture — not a true root. (This
|
|
// repo also has two root commits locally.) A history-dependent assertion passes on a full
|
|
// clone and fails in the runner, which is exactly what it did.
|
|
const repo = createTempDir('emitted-baseline-ref');
|
|
t.after(() => cleanup(repo));
|
|
// No `safeDirArgs` needed here (unlike the #2767 fix above): `repo` is a directory
|
|
// this same process just created with `mkdtempSync` + `git init`, so its owner is
|
|
// always the uid running the test regardless of container — it is never the
|
|
// externally-mounted repo path the dubious-ownership check reacts to.
|
|
const run = (...args) => execFileSync('git', args, { cwd: repo, encoding: 'utf8', timeout: 30_000 });
|
|
|
|
run('init', '--quiet', '-b', 'main');
|
|
run('config', 'user.email', 'test@example.invalid');
|
|
run('config', 'user.name', 'Test');
|
|
const fixtureDir = path.join(repo, ...'tests/fixtures/golden-install-parity'.split('/'));
|
|
fs.mkdirSync(fixtureDir, { recursive: true });
|
|
|
|
// Deliberately includes a family that is NOT in today's registry. This is the real
|
|
// discriminator: a registry-derived implementation can never report it, because the name
|
|
// does not exist in MANIFEST_FAMILIES — which is precisely how a REMOVED runtime went
|
|
// invisible and made the dropped-family check unreachable in production.
|
|
const atRefOnly = 'zzz-retired-runtime';
|
|
const committed = ['claude', 'claude-local', atRefOnly];
|
|
for (const name of committed) {
|
|
fs.writeFileSync(path.join(fixtureDir, `${name}.json`), JSON.stringify({ 'a/b': 'hash' }));
|
|
}
|
|
run('add', '-A');
|
|
run('commit', '--quiet', '-m', 'fixtures');
|
|
|
|
assert.ok(
|
|
!ALL_FAMILIES.includes(atRefOnly),
|
|
'the probe family must be absent from the current registry for this test to discriminate',
|
|
);
|
|
assert.deepEqual(
|
|
baselineFamilyNamesAtRef('HEAD', { cwd: repo }).slice().sort(),
|
|
committed.slice().sort(),
|
|
'the baseline must report what the REF carries, including a family the current registry lacks',
|
|
);
|
|
|
|
// A ref that cannot be resolved yields nothing rather than throwing, which is the
|
|
// post-cutover signal to fall back to resolveBaseline's cache path.
|
|
assert.deepEqual(baselineFamilyNamesAtRef('refs/heads/no-such-ref-2723', { cwd: repo }), []);
|
|
|
|
// Deliberately NOT asserted against the ambient checkout. Reading this repo's own HEAD is
|
|
// not guaranteed inside the runner container — it returned [] there, which is this
|
|
// function's documented behavior when git cannot read the ref, not a defect. Asserting on
|
|
// it tests the checkout rather than the code, and the temp repo above already proves the
|
|
// property that matters: the family set follows the REF. The ambient path is covered by
|
|
// the real-tree test, which skips explicitly when no base ref is resolvable.
|
|
//
|
|
// A git failure is never silently permissive downstream: baselineManifestsAtRef returns
|
|
// null on an empty family set, and the real-tree test asserts the baseline is non-empty.
|
|
});
|
|
|
|
// ── Independence / purity ────────────────────────────────────────────────────
|
|
|
|
test('reconciliation is pure across repeated calls', () => {
|
|
const args = {
|
|
derivedNames: [...ALL_FAMILIES, 'qoder'],
|
|
baselineNames: ALL_FAMILIES,
|
|
currentNames: [...ALL_FAMILIES, 'qoder'],
|
|
changedPaths: CONTENT_ONLY_CHANGE,
|
|
};
|
|
assert.deepEqual(reconcileWith(args), reconcileWith(args));
|
|
});
|
|
|
|
// ── Property: the reported delta is exactly the set difference ───────────────
|
|
|
|
test('property: reported added/dropped are exactly the set differences', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.uniqueArray(fc.string({ minLength: 1, maxLength: 6 }).filter((s) => !/^\s*$/.test(s)), { minLength: 0, maxLength: 5 }),
|
|
fc.uniqueArray(fc.integer({ min: 0, max: ALL_FAMILIES.length - 2 }), { minLength: 0, maxLength: 4 }),
|
|
(rawAdds, dropIdx) => {
|
|
const added = rawAdds.filter((s) => !ALL_FAMILIES.includes(s));
|
|
// never drop claude-local: it has its own dedicated assertion
|
|
const dropped = dropIdx
|
|
.map((i) => ALL_FAMILIES[i])
|
|
.filter((n) => n !== 'claude-local');
|
|
const current = [...ALL_FAMILIES.filter((n) => !dropped.includes(n)), ...added];
|
|
|
|
const r = reconcileFamilies({
|
|
derived: derivedOf(current),
|
|
fixtures: current,
|
|
baseline: manifestsOf(ALL_FAMILIES),
|
|
current: manifestsOf(current),
|
|
changedPaths: CONTENT_ONLY_CHANGE,
|
|
minimum: 0,
|
|
});
|
|
|
|
const reportedAdded = r.errors
|
|
.filter((e) => e.code === FAMILY_REASON.ADDED_UNATTRIBUTED).map((e) => e.family).sort();
|
|
const reportedDropped = r.errors
|
|
.filter((e) => e.code === FAMILY_REASON.DROPPED_UNATTRIBUTED).map((e) => e.family).sort();
|
|
|
|
assert.deepEqual(reportedAdded, [...new Set(added)].sort());
|
|
assert.deepEqual(reportedDropped, [...new Set(dropped)].sort());
|
|
return true;
|
|
},
|
|
),
|
|
{ numRuns: 200, seed: 2723 },
|
|
);
|
|
});
|
|
|
|
// ─── The real thing: the law, run against the actual tree ───────────────────
|
|
//
|
|
// Everything above exercises the pure law against synthetic input, which is what makes
|
|
// the acceptance criteria practical to assert at all. This block is what stops the
|
|
// phase from being interface-only: it builds the CURRENT emitted manifests for real
|
|
// (one installer spawn per runtime), resolves the BASELINE via `resolveBaseline()`,
|
|
// resolves the changed paths with real git, reads the real ack file, and runs the
|
|
// conservation law over all of it.
|
|
//
|
|
// Baseline source note (#2724, post-cutover): the committed golden fixtures this test
|
|
// used to read via `git show origin/next:<fixture>` are deleted. `resolveBaseline()`'s
|
|
// documented precedence takes over: `GSD_EMITTED_BASELINE` env (CI's PR-lane cache
|
|
// restore, keyed on the PR's base sha) -> the on-disk cache at
|
|
// `.gsd-cache/emitted-baseline.json` (populated by CI's push-to-next publish step,
|
|
// scripts/gen-emitted-baseline.cjs) -> an in-job build (a throwaway `git worktree`
|
|
// checked out at `base`, running the same script there — slow but never absent). Never
|
|
// the working-tree fixtures, which would be whatever this PR's author regenerated;
|
|
// comparing against those would be vacuous.
|
|
|
|
test('differential attribution over the real tree', { timeout: 900_000 }, async (t) => {
|
|
if (process.platform === 'win32') {
|
|
// Mirrors the golden harness: install output is platform-specific on Windows
|
|
// (backslash paths), so parity is asserted on macOS + Linux. An explicit t.skip,
|
|
// never a bare `return` — in node:test that would be a PASS (ADR-2719 §6).
|
|
t.skip('emitted parity is asserted on macOS + Linux; Windows install output is platform-specific');
|
|
return;
|
|
}
|
|
|
|
// hooks/dist is gitignored and built (DEFECT.HOOKS-DIST-SCOPED-CI): the scoped CI
|
|
// lane does not run build:hooks, so a real install there would emit no hooks/ dir.
|
|
// Build idempotently, exactly as the golden harness does.
|
|
execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe', timeout: 120_000 });
|
|
|
|
// The base ref is not universally available. The gsd-test runner shallow-clones and
|
|
// merges base+head, so no `origin/*` remote-tracking ref exists in the container —
|
|
// this test went red on its first matrix run for exactly that reason, which is the
|
|
// resolver doing its job and the dependency being wrong.
|
|
//
|
|
// An explicit t.skip is the ADR-sanctioned response for a genuine environmental
|
|
// skip: it is REPORTED as skipped, unlike a bare `return`, which node:test scores as
|
|
// a PASS (ADR-2719 §6). Hard-failing instead would make the suite permanently red
|
|
// wherever a base ref cannot exist by construction, which is not a propagation
|
|
// finding — it is a statement about the checkout.
|
|
const resolved = resolveBase();
|
|
if (!resolved) {
|
|
t.skip(
|
|
'no base ref resolvable — tried ' + baseRefCandidates().join(', ') +
|
|
'. The differential gate did NOT run here. It binds in the CI test lanes, which ' +
|
|
'fetch the base ref explicitly; set GSD_EMITTED_BASE=<ref|sha> to run it elsewhere.',
|
|
);
|
|
return;
|
|
}
|
|
const { ref: base, sha: baseSha } = resolved;
|
|
assert.match(baseSha, /^[0-9a-f]{40}$/);
|
|
|
|
// Phase 4 (#2724): the golden fixtures this used to read via `baselineManifestsAtRef`
|
|
// (git show <base>:<fixture>) are deleted, so the baseline now comes through
|
|
// `resolveBaseline()`'s documented precedence: GSD_EMITTED_BASELINE env (CI's PR-lane
|
|
// cache restore) -> the on-disk cache (CI's push-to-next publish step) -> an in-job
|
|
// build at `base` (a throwaway git worktree + scripts/gen-emitted-baseline.cjs) ->
|
|
// explicit failure. The build fallback is deliberately the slow path — it exists so a
|
|
// cache miss degrades rather than fails outright (ADR-2719 §5).
|
|
const readBaselineJson = (p) => (fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null);
|
|
const resolvedBaseline = resolveBaseline({
|
|
expectedSha: baseSha,
|
|
readJson: readBaselineJson,
|
|
buildFallback: () => buildBaselineAtRef(base),
|
|
});
|
|
assert.ok(
|
|
resolvedBaseline.ok,
|
|
`no usable emitted baseline for ${base}@${baseSha.slice(0, 12)} (tried env, ` +
|
|
`${DEFAULT_CACHE_PATH}, and an in-job build):\n ${(resolvedBaseline.errors || []).join('\n ')}`,
|
|
);
|
|
const baseline = resolvedBaseline.baseline;
|
|
assert.ok(baseline && Object.keys(baseline).length > 0, `resolved baseline via ${resolvedBaseline.via} has no families`);
|
|
|
|
const changedPaths = resolveChangedPaths(base);
|
|
const ack = readAckFile();
|
|
const current = currentManifests();
|
|
|
|
// Reconcile the family SET across three independent signals, rather than asserting one
|
|
// count against both sides. The baseline is built at the base ref and the current tree
|
|
// at PR HEAD, so the two legitimately differ by a family whenever a PR adds or removes
|
|
// a runtime — a single shared literal could satisfy neither side at once (#2723), and
|
|
// a count cannot see a membership swap that leaves the total unchanged either way.
|
|
const familyVerdict = reconcileFamilies({
|
|
derived: MANIFEST_FAMILIES,
|
|
fixtures: loadManifests().map((m) => m.file.replace(/\.json$/, '')),
|
|
baseline,
|
|
current,
|
|
changedPaths,
|
|
});
|
|
assert.ok(
|
|
familyVerdict.ok,
|
|
'emitted manifest family set is not reconciled:\n ' +
|
|
familyVerdict.errors
|
|
.map((e) => (e.family ? `${e.code}: ${e.family}` : e.code))
|
|
.join('\n '),
|
|
);
|
|
|
|
const result = diffEmitted({
|
|
baseline,
|
|
current,
|
|
changedPaths,
|
|
ack,
|
|
sizeBaseline: resolvedBaseline.sizeBaseline,
|
|
sizeCurrent: currentSizes(),
|
|
});
|
|
|
|
assert.ok(
|
|
result.ok,
|
|
`emitted-attribution failed against ${base}@${baseSha.slice(0, 12)}:\n\n${formatReport(result)}`,
|
|
);
|
|
});
|