* test(#2596): failing-first suite for worktree-wave scope conformance Binds the advisory diff-vs-declared-scope check to behavior before it exists: the pure coverage predicate, the SUMMARY-artifact exemption and its parity with the rescue walker, the gauntlet integration (never flips ok, degrades on a git failure, survives a later block), the manifest normalizer's files_modified handling, and the --files negative-input matrix on record-agent/create. Refs #2596 * enhance(#2596): warn when a wave branch commits outside its declared scope The worktree-wave merge gauntlet validated branch, base, deletions, SUMMARY rescue and a clean worktree, but never compared a plan branch's actual committed diff against the files_modified the plan declared — so an executor that committed outside its brief merged into shared phase state silently. Adds an advisory scope-conformance check: when the manifest entry carries a declared scope, the gauntlet diffs HEAD...<branch> and appends one structured warning per path outside it. It never flips ok and never blocks the merge; promotion to a hard gate is a separate, disclosed change. With no declared scope no git subprocess is spent at all. Refs #2596 * docs(#2596): document the advisory worktree-wave scope-conformance check Records the optional --files flag on worktree record-agent/create, the advisory warnings channel cleanup-wave now emits, and its two deliberate noise limits (SUMMARY-artifact exemption, literal-prefix glob matching). Wires execute-phase to pass the plan's already-parsed PLAN_FILES. Refs #2596 * fix(#2596): close review findings on the scope-conformance advisory - share one path normalizer between the SUMMARY-artifact predicate and the scope comparison so the exemption and the check cannot drift - wire --files into the orchestrator-worktree dispatch, which created a worktree but never declared its scope, so the advisory silently did not apply on that backend; ADR-1239 requires both adapters share one check - correct the now-false blockquote claiming the check does not exist yet - add the fast-check property tests the repo requires for parser logic - add the record-agent/create parity test that Generative Fix Divergence requires for two surfaces implementing one rule Refs #2596 * fix(#2596): keep execute-phase.md under the frozen pre-phase-6 byte ceiling The one-sentence note added with the --files flag pushed execute-phase.md to 93708 bytes, past the ADR-857 PRE_PHASE6 cap of 93600 — the tightest of the three workflow size gates, and a hard cap an acknowledgment cannot clear. It failed three tests plus the differential attribution check. Condense the note to a one-line pointer (93543, 57 B of headroom); the full explanation already lives in docs/CLI-TOOLS.md and the dispatch step. The flag itself stays in the command, because the orchestrator reads this workflow at runtime and cannot pick it up from docs/. Acknowledge the remaining 143 B of growth by appending to the existing execute-phase.md fragment rather than adding a second one — the ack lint rejects two sources naming the same path. Refs #2596 * fix(#2596): make the execute-phase.md edit net-negative, not merely under the cap The size gate on this file is two assertions, not one: bytes < 93600 AND bytes <= 93400. The base is exactly 93400, so the file is at its budget and any growth trips the margin assertion — the previous fix cleared the ceiling but not that. Move the --files explanation to per-plan-worktree-gate.md, which already owns PLAN_FILES and carries no cap, and reclaim the rest from two clauses in the sentence being edited: the cleanup-wave rules phrasing, and a 'non-zero exit' the very next sentence already states. execute-phase.md ends at 93392, eight bytes below base. The flag itself stays in the command — the orchestrator reads this workflow at runtime and cannot pick it up from docs/. With no growth left, the acknowledgment is unnecessary and its byte delta was no longer true, so the shared ack fragment is restored byte-identical to base. Refs #2596 * docs(#2596): add the how-to for interpreting scope-conformance warnings The docs for this change were entirely Reference — the flag and the warning codes — with the task-oriented quadrant empty. Adds the page that answers the question an operator actually has when the advisory fires: what the two codes mean, that nothing is blocked so there is no failure to hunt for, how to tell whether the executor over-reached or the plan under-declared, and the three ways the check legitimately stays silent so an absence of warnings is not mistaken for proof of conformance. Refs #2596 * chore(#2596): backfill changeset pr number to 3264 --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -6466,3 +6466,634 @@ test('bug-3542: stash pushed in main checkout is visible inside a linked worktre
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── #2596: advisory diff-vs-declared-scope conformance at worktree-wave merge ─
|
||||
//
|
||||
// The wave-cleanup gauntlet validated branch, base, deletions, SUMMARY rescue and
|
||||
// a clean worktree — but never compared the branch's ACTUAL committed diff against
|
||||
// the scope the plan declared in `files_modified`. An executor that committed
|
||||
// outside its brief merged silently. This is the advisory-first check: it warns,
|
||||
// it does not block (promotion to a gate is a separate, disclosed change).
|
||||
|
||||
const {
|
||||
WAVE_CLEANUP_WARNING,
|
||||
planWaveScopeConformance,
|
||||
isSummaryArtifactRelPath,
|
||||
normalizeCleanupManifest,
|
||||
} = require(WORKTREE_SAFETY_PATH);
|
||||
|
||||
describe('#2596 scope conformance — planWaveScopeConformance (pure)', () => {
|
||||
const BR = 'worktree-agent-a1';
|
||||
const codesOf = (warnings) => warnings.map((w) => w.code);
|
||||
const pathsOf = (warnings) => warnings.map((w) => w.path);
|
||||
|
||||
test('exact path match is in scope', () => {
|
||||
assert.deepEqual(planWaveScopeConformance(['src/a.ts'], ['src/a.ts'], BR), []);
|
||||
});
|
||||
|
||||
test('declared directory covers a file beneath it', () => {
|
||||
assert.deepEqual(planWaveScopeConformance(['src/foo/bar.ts'], ['src/foo'], BR), []);
|
||||
});
|
||||
|
||||
test('prefix sibling is out of scope (the boundary is /, not startsWith)', () => {
|
||||
const warnings = planWaveScopeConformance(['src/foobar.ts'], ['src/foo'], BR);
|
||||
assert.deepEqual(codesOf(warnings), [WAVE_CLEANUP_WARNING.SCOPE_OUT_OF_DECLARED]);
|
||||
assert.deepEqual(pathsOf(warnings), ['src/foobar.ts']);
|
||||
assert.equal(warnings[0].branch, BR);
|
||||
});
|
||||
|
||||
test('trailing slash on a declared directory is normalized', () => {
|
||||
assert.deepEqual(planWaveScopeConformance(['src/foo/bar.ts'], ['src/foo/'], BR), []);
|
||||
});
|
||||
|
||||
test('leading ./ is normalized on both sides', () => {
|
||||
assert.deepEqual(planWaveScopeConformance(['./src/a.ts'], ['./src/a.ts'], BR), []);
|
||||
});
|
||||
|
||||
test('backslash separators normalize unconditionally (not only on win32)', () => {
|
||||
assert.deepEqual(planWaveScopeConformance(['src\\a.ts'], ['src/a.ts'], BR), []);
|
||||
assert.deepEqual(planWaveScopeConformance(['src/a.ts'], ['src\\a.ts'], BR), []);
|
||||
});
|
||||
|
||||
test('glob literal prefix covers paths beneath it', () => {
|
||||
assert.deepEqual(planWaveScopeConformance(['src/a/b.ts'], ['src/**/*.ts'], BR), []);
|
||||
});
|
||||
|
||||
test('glob matching is literal-prefix only — the documented over-accept', () => {
|
||||
// `src/**/*.ts` also covers `src/a/b.json`. Deliberate: for an advisory a false
|
||||
// alarm costs more than a miss, and this mirrors the submodule-intersection
|
||||
// gate's own glob-prefix handling rather than inventing a second matcher.
|
||||
assert.deepEqual(planWaveScopeConformance(['src/a/b.json'], ['src/**/*.ts'], BR), []);
|
||||
});
|
||||
|
||||
test('a pattern with no literal prefix suppresses warnings rather than crying wolf', () => {
|
||||
assert.deepEqual(planWaveScopeConformance(['src/a.ts'], ['*.md'], BR), []);
|
||||
});
|
||||
|
||||
test('an undeclared sibling file is out of scope', () => {
|
||||
const warnings = planWaveScopeConformance(['src/b.ts'], ['src/a.ts'], BR);
|
||||
assert.deepEqual(pathsOf(warnings), ['src/b.ts']);
|
||||
});
|
||||
|
||||
test('an all-blank declared list is unknown, not empty — no warnings', () => {
|
||||
assert.deepEqual(planWaveScopeConformance(['src/b.ts'], ['', ' ', './', '/'], BR), []);
|
||||
});
|
||||
|
||||
test('an absent, empty or non-array declared list yields no warnings', () => {
|
||||
assert.deepEqual(planWaveScopeConformance(['src/b.ts'], undefined, BR), []);
|
||||
assert.deepEqual(planWaveScopeConformance(['src/b.ts'], [], BR), []);
|
||||
assert.deepEqual(planWaveScopeConformance(['src/b.ts'], 'src/b.ts', BR), []);
|
||||
});
|
||||
|
||||
test('a committed SUMMARY artifact is never a scope violation', () => {
|
||||
assert.deepEqual(
|
||||
planWaveScopeConformance(['.planning/q1-SUMMARY.md'], ['src/a.ts'], BR),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('every out-of-scope path gets its own warning, in diff order', () => {
|
||||
const warnings = planWaveScopeConformance(
|
||||
['src/a.ts', 'z/second.ts', 'a/first.ts'],
|
||||
['src/a.ts'],
|
||||
BR,
|
||||
);
|
||||
assert.deepEqual(pathsOf(warnings), ['z/second.ts', 'a/first.ts']);
|
||||
assert.deepEqual(codesOf(warnings), [
|
||||
WAVE_CLEANUP_WARNING.SCOPE_OUT_OF_DECLARED,
|
||||
WAVE_CLEANUP_WARNING.SCOPE_OUT_OF_DECLARED,
|
||||
]);
|
||||
});
|
||||
|
||||
// CLAUDE.md → TEST RULES: a parser needs at least one property test.
|
||||
// Both are deterministic — seed pinned, run count bounded — per the repo's
|
||||
// "property tests must be reproducible" rule. The two segment alphabets are
|
||||
// disjoint so the negative property can never accidentally build a path that
|
||||
// IS covered, and neither alphabet contains `.planning`, so the SUMMARY
|
||||
// exemption cannot mask a result.
|
||||
test('property: any path beneath a declared literal directory is always in scope', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.constantFrom('src', 'lib', 'core', 'deep'), { minLength: 1, maxLength: 4 }),
|
||||
fc.array(fc.constantFrom('a', 'b', 'c', 'd.ts'), { minLength: 1, maxLength: 3 }),
|
||||
(declaredSegments, tailSegments) => {
|
||||
const declared = declaredSegments.join('/');
|
||||
const changed = `${declared}/${tailSegments.join('/')}`;
|
||||
return planWaveScopeConformance([changed], [declared], BR).length === 0;
|
||||
},
|
||||
),
|
||||
{ numRuns: 250, seed: 2596 },
|
||||
);
|
||||
});
|
||||
|
||||
test('property: a path sharing no prefix with any declared path always warns exactly once', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.constantFrom('src', 'lib', 'core'), { minLength: 1, maxLength: 3 }),
|
||||
fc.array(fc.constantFrom('zeta', 'yankee', 'xray.ts'), { minLength: 1, maxLength: 3 }),
|
||||
(declaredSegments, changedSegments) => {
|
||||
const declared = declaredSegments.join('/');
|
||||
const changed = changedSegments.join('/');
|
||||
const warnings = planWaveScopeConformance([changed], [declared], BR);
|
||||
return warnings.length === 1
|
||||
&& warnings[0].path === changed
|
||||
&& warnings[0].code === WAVE_CLEANUP_WARNING.SCOPE_OUT_OF_DECLARED;
|
||||
},
|
||||
),
|
||||
{ numRuns: 250, seed: 2596 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2596 SUMMARY-artifact predicate and its parity with the walker', () => {
|
||||
const nodeFs = require('node:fs');
|
||||
|
||||
test('a .planning SUMMARY is recognized', () => {
|
||||
assert.equal(isSummaryArtifactRelPath('.planning/q1-SUMMARY.md'), true);
|
||||
});
|
||||
|
||||
test('a nested .planning SUMMARY is recognized', () => {
|
||||
assert.equal(isSummaryArtifactRelPath('.planning/phases/3/x-SUMMARY.md'), true);
|
||||
});
|
||||
|
||||
test('SUMMARY.md outside .planning is not exempt', () => {
|
||||
assert.equal(isSummaryArtifactRelPath('docs/SUMMARY.md'), false);
|
||||
});
|
||||
|
||||
test('the SUMMARY suffix must terminate the name', () => {
|
||||
assert.equal(isSummaryArtifactRelPath('.planning/SUMMARY.md.bak'), false);
|
||||
});
|
||||
|
||||
test('the .planning boundary is a whole path segment', () => {
|
||||
assert.equal(isSummaryArtifactRelPath('.planningx/q-SUMMARY.md'), false);
|
||||
});
|
||||
|
||||
test('parity: the SUMMARY walker and the scope-exemption predicate agree', (t) => {
|
||||
// The rescue walker roots at <worktree>/.planning and accepts *SUMMARY.md.
|
||||
// The scope advisory must exempt exactly that set, or it would warn on the
|
||||
// very artifacts the rescue path exists to carry. Drive the PRODUCTION
|
||||
// walker (no findSummaryFiles override) over a real temp tree and
|
||||
// cross-check every collected path against the predicate.
|
||||
const tmp = createTempDir('wt-2596-summary-parity');
|
||||
t.after(() => cleanup(tmp));
|
||||
|
||||
const planning = path.join(tmp, '.planning', 'phases', '3');
|
||||
nodeFs.mkdirSync(planning, { recursive: true });
|
||||
nodeFs.writeFileSync(path.join(planning, 'p3-SUMMARY.md'), 'x');
|
||||
nodeFs.writeFileSync(path.join(tmp, '.planning', 'SUMMARY.md'), 'x');
|
||||
// Decoys the walker must skip and the predicate must reject.
|
||||
nodeFs.writeFileSync(path.join(planning, 'notes.md'), 'x');
|
||||
nodeFs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
|
||||
nodeFs.writeFileSync(path.join(tmp, 'docs', 'SUMMARY.md'), 'x');
|
||||
|
||||
const copiedFrom = [];
|
||||
executeWorktreeWaveCleanupPlan(
|
||||
{
|
||||
ok: true,
|
||||
repoRoot: '/repo/main',
|
||||
action: 'cleanup_wave',
|
||||
discovery: 'manifest',
|
||||
entries: [{
|
||||
agent_id: 'a1',
|
||||
worktree_path: tmp,
|
||||
branch: 'worktree-agent-a1',
|
||||
expected_base: 'abc123',
|
||||
}],
|
||||
},
|
||||
{
|
||||
// No findSummaryFiles override: this exercises the production walker.
|
||||
execGit: (args) => {
|
||||
const key = args.join(' ');
|
||||
if (key === `-C ${tmp} rev-parse --abbrev-ref HEAD`) {
|
||||
return { exitCode: 0, stdout: 'worktree-agent-a1', stderr: '', timedOut: false };
|
||||
}
|
||||
if (key === 'merge-base HEAD worktree-agent-a1') {
|
||||
return { exitCode: 0, stdout: 'abc123', stderr: '', timedOut: false };
|
||||
}
|
||||
// cat-file -e → non-zero, so every found SUMMARY is rescued (copied).
|
||||
if (args.includes('cat-file')) {
|
||||
return { exitCode: 128, stdout: '', stderr: '', timedOut: false };
|
||||
}
|
||||
return { exitCode: 0, stdout: '', stderr: '', timedOut: false };
|
||||
},
|
||||
existsSync: () => false,
|
||||
readFileSync: () => 'x',
|
||||
mkdirSync: () => {},
|
||||
copyFileSync: (src) => { copiedFrom.push(src); },
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(copiedFrom.length, 2,
|
||||
'the walker must find exactly the two .planning SUMMARY artifacts, not the docs/ decoy');
|
||||
for (const abs of copiedFrom) {
|
||||
const rel = abs.slice(tmp.length).replace(/^[/\\]/, '').replace(/\\/g, '/');
|
||||
assert.equal(
|
||||
isSummaryArtifactRelPath(rel), true,
|
||||
`a path the SUMMARY walker collects must be exempt from the scope advisory: ${rel}`,
|
||||
);
|
||||
}
|
||||
// Negative direction: paths the walker skips must also fail the predicate.
|
||||
assert.equal(isSummaryArtifactRelPath('docs/SUMMARY.md'), false);
|
||||
assert.equal(isSummaryArtifactRelPath('.planning/phases/3/notes.md'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2596 scope conformance — executeWorktreeWaveCleanupPlan integration', () => {
|
||||
const WT = '/repo/.claude/worktrees/agent-a1';
|
||||
const BR = 'worktree-agent-a1';
|
||||
const SCOPE_DIFF = `diff --name-only HEAD...${BR}`;
|
||||
const noSummaries = { findSummaryFiles: () => [] };
|
||||
|
||||
function makeGit(overrides = {}) {
|
||||
const calls = [];
|
||||
const git = (args) => {
|
||||
const key = args.join(' ');
|
||||
calls.push(key);
|
||||
if (Object.prototype.hasOwnProperty.call(overrides, key)) return overrides[key];
|
||||
if (key === `-C ${WT} rev-parse --abbrev-ref HEAD`) {
|
||||
return { exitCode: 0, stdout: BR, stderr: '', timedOut: false };
|
||||
}
|
||||
if (key === `merge-base HEAD ${BR}`) {
|
||||
return { exitCode: 0, stdout: 'abc123', stderr: '', timedOut: false };
|
||||
}
|
||||
return { exitCode: 0, stdout: '', stderr: '', timedOut: false };
|
||||
};
|
||||
return { git, calls };
|
||||
}
|
||||
|
||||
const planWith = (entry) => ({
|
||||
ok: true,
|
||||
repoRoot: '/repo/main',
|
||||
action: 'cleanup_wave',
|
||||
discovery: 'manifest',
|
||||
entries: [{ agent_id: 'a1', worktree_path: WT, branch: BR, expected_base: 'abc123', ...entry }],
|
||||
});
|
||||
|
||||
test('no declared scope issues no scope git call at all', () => {
|
||||
const { git, calls } = makeGit();
|
||||
const result = executeWorktreeWaveCleanupPlan(planWith({}), { execGit: git, ...noSummaries });
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.deepEqual(result.entries[0].warnings, []);
|
||||
assert.equal(
|
||||
calls.includes(SCOPE_DIFF), false,
|
||||
'an entry with no declared files_modified must not spend a git subprocess',
|
||||
);
|
||||
});
|
||||
|
||||
test('an in-scope branch produces no advisory', () => {
|
||||
const { git } = makeGit({
|
||||
[SCOPE_DIFF]: { exitCode: 0, stdout: 'src/a.ts\nsrc/b.ts\n', stderr: '', timedOut: false },
|
||||
});
|
||||
const result = executeWorktreeWaveCleanupPlan(
|
||||
planWith({ files_modified: ['src/a.ts', 'src/b.ts'] }),
|
||||
{ execGit: git, ...noSummaries },
|
||||
);
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(result.entries[0].warnings, []);
|
||||
assert.deepEqual(result.warnings, []);
|
||||
});
|
||||
|
||||
test('an out-of-scope path warns without blocking the merge', () => {
|
||||
const { git, calls } = makeGit({
|
||||
[SCOPE_DIFF]: { exitCode: 0, stdout: 'src/a.ts\nsecrets/creds.json\n', stderr: '', timedOut: false },
|
||||
});
|
||||
const result = executeWorktreeWaveCleanupPlan(
|
||||
planWith({ files_modified: ['src/a.ts'] }),
|
||||
{ execGit: git, ...noSummaries },
|
||||
);
|
||||
assert.equal(result.ok, true, 'the advisory must NOT flip ok — it is not a gate');
|
||||
assert.equal(result.reason, 'ok');
|
||||
assert.equal(result.entries[0].status, 'merged_removed');
|
||||
assert.deepEqual(result.entries[0].warnings, [
|
||||
{ code: WAVE_CLEANUP_WARNING.SCOPE_OUT_OF_DECLARED, branch: BR, path: 'secrets/creds.json' },
|
||||
]);
|
||||
assert.deepEqual(result.warnings, result.entries[0].warnings);
|
||||
// Negative proof: the merge/remove/delete sequence still ran.
|
||||
assert.ok(calls.some((c) => c.startsWith(`merge ${BR}`)), 'merge must still run');
|
||||
assert.ok(calls.includes(`worktree remove ${WT} --force`), 'remove must still run');
|
||||
assert.ok(calls.includes(`branch -D ${BR}`), 'branch delete must still run');
|
||||
});
|
||||
|
||||
test('every out-of-scope path gets its own warning, in diff order', () => {
|
||||
const { git } = makeGit({
|
||||
[SCOPE_DIFF]: { exitCode: 0, stdout: 'z/two.ts\nsrc/a.ts\na/one.ts\n', stderr: '', timedOut: false },
|
||||
});
|
||||
const result = executeWorktreeWaveCleanupPlan(
|
||||
planWith({ files_modified: ['src/a.ts'] }),
|
||||
{ execGit: git, ...noSummaries },
|
||||
);
|
||||
assert.deepEqual(result.entries[0].warnings.map((w) => w.path), ['z/two.ts', 'a/one.ts']);
|
||||
});
|
||||
|
||||
test('a committed SUMMARY artifact in the diff is not a scope violation', () => {
|
||||
const { git } = makeGit({
|
||||
[SCOPE_DIFF]: { exitCode: 0, stdout: 'src/a.ts\n.planning/q1-SUMMARY.md\n', stderr: '', timedOut: false },
|
||||
});
|
||||
const result = executeWorktreeWaveCleanupPlan(
|
||||
planWith({ files_modified: ['src/a.ts'] }),
|
||||
{ execGit: git, ...noSummaries },
|
||||
);
|
||||
assert.deepEqual(result.entries[0].warnings, []);
|
||||
});
|
||||
|
||||
test('a failed scope diff degrades to an advisory, never a block', () => {
|
||||
const { git } = makeGit({
|
||||
[SCOPE_DIFF]: { exitCode: 128, stdout: '', stderr: 'fatal: bad revision', timedOut: false },
|
||||
});
|
||||
const result = executeWorktreeWaveCleanupPlan(
|
||||
planWith({ files_modified: ['src/a.ts'] }),
|
||||
{ execGit: git, ...noSummaries },
|
||||
);
|
||||
assert.equal(result.ok, true, 'a broken advisory check must never become a gate');
|
||||
assert.equal(result.entries[0].status, 'merged_removed');
|
||||
assert.deepEqual(result.entries[0].warnings, [
|
||||
{ code: WAVE_CLEANUP_WARNING.SCOPE_CHECK_UNAVAILABLE, branch: BR, path: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a timed-out scope diff degrades to an advisory', () => {
|
||||
const execGit = makeFaultyGit({
|
||||
faults: [{
|
||||
kind: 'timeout',
|
||||
when: (args) => args[0] === 'diff' && !args.includes('--diff-filter=D'),
|
||||
}],
|
||||
passthrough: makeGit().git,
|
||||
});
|
||||
const result = executeWorktreeWaveCleanupPlan(
|
||||
planWith({ files_modified: ['src/a.ts'] }),
|
||||
{ execGit, ...noSummaries },
|
||||
);
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(result.entries[0].warnings.map((w) => w.code), [
|
||||
WAVE_CLEANUP_WARNING.SCOPE_CHECK_UNAVAILABLE,
|
||||
]);
|
||||
});
|
||||
|
||||
test('CRLF diff output yields clean paths and no phantom warning', () => {
|
||||
const { git } = makeGit({
|
||||
[SCOPE_DIFF]: { exitCode: 0, stdout: 'src/a.ts\r\nsecrets/x.json\r\n\r\n', stderr: '', timedOut: false },
|
||||
});
|
||||
const result = executeWorktreeWaveCleanupPlan(
|
||||
planWith({ files_modified: ['src/a.ts'] }),
|
||||
{ execGit: git, ...noSummaries },
|
||||
);
|
||||
assert.deepEqual(result.entries[0].warnings.map((w) => w.path), ['secrets/x.json']);
|
||||
});
|
||||
|
||||
test('scope warnings survive a later block', () => {
|
||||
const { git } = makeGit({
|
||||
[SCOPE_DIFF]: { exitCode: 0, stdout: 'secrets/x.json\n', stderr: '', timedOut: false },
|
||||
[`-C ${WT} status --porcelain --untracked-files=all`]: { exitCode: 0, stdout: '?? scratch.txt', stderr: '', timedOut: false },
|
||||
});
|
||||
const result = executeWorktreeWaveCleanupPlan(
|
||||
planWith({ files_modified: ['src/a.ts'] }),
|
||||
{ execGit: git, ...noSummaries },
|
||||
);
|
||||
assert.equal(result.ok, false, 'the dirty-worktree block still blocks');
|
||||
assert.equal(result.entries[0].status, 'blocked');
|
||||
assert.equal(result.entries[0].reason, 'worktree_dirty');
|
||||
assert.deepEqual(
|
||||
result.entries[0].warnings.map((w) => w.path), ['secrets/x.json'],
|
||||
'an advisory recorded before the block is still true and must survive it',
|
||||
);
|
||||
});
|
||||
|
||||
test('warnings are attributed per entry and aggregated at the top level', () => {
|
||||
const WT2 = '/repo/.claude/worktrees/agent-a2';
|
||||
const BR2 = 'worktree-agent-a2';
|
||||
const execGit = (args) => {
|
||||
const key = args.join(' ');
|
||||
if (key === `-C ${WT} rev-parse --abbrev-ref HEAD`) return { exitCode: 0, stdout: BR, stderr: '', timedOut: false };
|
||||
if (key === `-C ${WT2} rev-parse --abbrev-ref HEAD`) return { exitCode: 0, stdout: BR2, stderr: '', timedOut: false };
|
||||
if (key.startsWith('merge-base HEAD ')) return { exitCode: 0, stdout: 'abc123', stderr: '', timedOut: false };
|
||||
if (key === `diff --name-only HEAD...${BR}`) return { exitCode: 0, stdout: 'src/a.ts\n', stderr: '', timedOut: false };
|
||||
if (key === `diff --name-only HEAD...${BR2}`) return { exitCode: 0, stdout: 'src/rogue.ts\n', stderr: '', timedOut: false };
|
||||
return { exitCode: 0, stdout: '', stderr: '', timedOut: false };
|
||||
};
|
||||
const result = executeWorktreeWaveCleanupPlan({
|
||||
ok: true,
|
||||
repoRoot: '/repo/main',
|
||||
action: 'cleanup_wave',
|
||||
discovery: 'manifest',
|
||||
entries: [
|
||||
{ agent_id: 'a1', worktree_path: WT, branch: BR, expected_base: 'abc123', files_modified: ['src/a.ts'] },
|
||||
{ agent_id: 'a2', worktree_path: WT2, branch: BR2, expected_base: 'abc123', files_modified: ['src/b.ts'] },
|
||||
],
|
||||
}, { execGit, ...noSummaries });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(result.entries[0].warnings, []);
|
||||
assert.deepEqual(result.entries[1].warnings, [
|
||||
{ code: WAVE_CLEANUP_WARNING.SCOPE_OUT_OF_DECLARED, branch: BR2, path: 'src/rogue.ts' },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
result.warnings, result.entries[1].warnings,
|
||||
'the top-level aggregate carries every entry warning, tagged with its branch',
|
||||
);
|
||||
});
|
||||
|
||||
test('WAVE_CLEANUP_WARNING is a frozen, locked code set', () => {
|
||||
assert.deepEqual(
|
||||
Object.keys(WAVE_CLEANUP_WARNING).sort(),
|
||||
['SCOPE_CHECK_UNAVAILABLE', 'SCOPE_OUT_OF_DECLARED'],
|
||||
);
|
||||
assert.equal(Object.isFrozen(WAVE_CLEANUP_WARNING), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2596 files_modified on the cleanup manifest', () => {
|
||||
const base = {
|
||||
agent_id: 'a1',
|
||||
worktree_path: '/repo/.claude/worktrees/agent-a1',
|
||||
branch: 'worktree-agent-a1',
|
||||
expected_base: 'abc123',
|
||||
};
|
||||
const only = (manifest) => normalizeCleanupManifest(manifest).entries[0];
|
||||
const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
||||
|
||||
test('a files_modified array is carried through', () => {
|
||||
assert.deepEqual(
|
||||
only({ worktrees: [{ ...base, files_modified: ['src/a.ts'] }] }).files_modified,
|
||||
['src/a.ts'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a scalar files_modified is dropped, not coerced', () => {
|
||||
assert.equal(has(only({ worktrees: [{ ...base, files_modified: 'src/a.ts' }] }), 'files_modified'), false);
|
||||
});
|
||||
|
||||
test('non-string files_modified elements are dropped', () => {
|
||||
assert.deepEqual(
|
||||
only({ worktrees: [{ ...base, files_modified: [1, null, {}, 'src/a.ts'] }] }).files_modified,
|
||||
['src/a.ts'],
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty files_modified is treated as unknown (field omitted)', () => {
|
||||
assert.equal(has(only({ worktrees: [{ ...base, files_modified: [] }] }), 'files_modified'), false);
|
||||
});
|
||||
|
||||
test('an absent files_modified leaves the entry shape unchanged', () => {
|
||||
assert.deepEqual(
|
||||
Object.keys(only({ worktrees: [base] })).sort(),
|
||||
['agent_id', 'allowed_bases', 'branch', 'expected_base', 'worktree_path'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2596 --files on the record-agent and create verbs', () => {
|
||||
// process.exitCode is global; restore it so a failure-path exit code does not
|
||||
// leak into the runner's own exit status.
|
||||
function withExitCode2596(fn) {
|
||||
const saved = process.exitCode;
|
||||
try { return fn(); } finally { process.exitCode = saved; }
|
||||
}
|
||||
|
||||
const baseArgs = [
|
||||
'--manifest', 'manifest.json',
|
||||
'--agent-id', 'a1',
|
||||
'--path', '/repo/.claude/worktrees/agent-a1',
|
||||
'--branch', 'worktree-agent-a1',
|
||||
'--base', 'abc123',
|
||||
];
|
||||
const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
||||
|
||||
function recordAgent(extraArgs = []) {
|
||||
let written = null;
|
||||
const result = withExitCode2596(() => cmdWorktreeRecordAgent('/repo/main', [...baseArgs, ...extraArgs], {
|
||||
readFile: () => '{"orchestrator_root":"/repo/main","worktrees":[]}',
|
||||
writeFile: (_p, c) => { written = c; },
|
||||
write: () => {},
|
||||
writeErr: () => {},
|
||||
}));
|
||||
return { result, entry: written ? JSON.parse(written).worktrees[0] : null };
|
||||
}
|
||||
|
||||
test('--files records the declared scope', () => {
|
||||
const { result, entry } = recordAgent(['--files', 'src/a.ts src/b.ts']);
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(entry.files_modified, ['src/a.ts', 'src/b.ts']);
|
||||
});
|
||||
|
||||
test('omitting --files leaves the manifest shape unchanged', () => {
|
||||
const { result, entry } = recordAgent();
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(
|
||||
Object.keys(entry).sort(),
|
||||
['agent_id', 'branch', 'expected_base', 'worktree_path'],
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty --files writes no field', () => {
|
||||
const { result, entry } = recordAgent(['--files', '']);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(has(entry, 'files_modified'), false);
|
||||
});
|
||||
|
||||
test('a whitespace-only --files writes no field', () => {
|
||||
const { result, entry } = recordAgent(['--files', ' \t ']);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(has(entry, 'files_modified'), false);
|
||||
});
|
||||
|
||||
test('--files splits on any whitespace run', () => {
|
||||
const { entry } = recordAgent(['--files', 'a.ts b.ts\tc.ts\nd.ts']);
|
||||
assert.deepEqual(entry.files_modified, ['a.ts', 'b.ts', 'c.ts', 'd.ts']);
|
||||
});
|
||||
|
||||
test('a trailing --files with no value is treated as absent', () => {
|
||||
const { result, entry } = recordAgent(['--files']);
|
||||
assert.equal(result.ok, true, 'a valueless flag must not crash the verb');
|
||||
assert.equal(has(entry, 'files_modified'), false);
|
||||
});
|
||||
|
||||
test('a flag-shaped --files value is not re-parsed as a flag', () => {
|
||||
const { entry } = recordAgent(['--files', '--branch']);
|
||||
assert.deepEqual(entry.files_modified, ['--branch']);
|
||||
assert.equal(entry.branch, 'worktree-agent-a1', 'the real --branch value must be untouched');
|
||||
});
|
||||
|
||||
test('shell metacharacters in --files are inert data', () => {
|
||||
const hostile = 'a.ts; rm -rf / && $(whoami) `id` "q" \'p\'';
|
||||
const { entry } = recordAgent(['--files', hostile]);
|
||||
assert.deepEqual(entry.files_modified, [
|
||||
'a.ts;', 'rm', '-rf', '/', '&&', '$(whoami)', '`id`', '"q"', "'p'",
|
||||
]);
|
||||
});
|
||||
|
||||
test('a traversal-shaped --files value is inert data, never dereferenced', () => {
|
||||
const readPaths = [];
|
||||
const result = withExitCode2596(() => cmdWorktreeRecordAgent('/repo/main', [...baseArgs, '--files', '../../etc/passwd'], {
|
||||
readFile: (p) => { readPaths.push(p); return '{"orchestrator_root":"/repo/main","worktrees":[]}'; },
|
||||
writeFile: () => {},
|
||||
write: () => {},
|
||||
writeErr: () => {},
|
||||
}));
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(
|
||||
readPaths, [path.resolve('/repo/main', 'manifest.json')],
|
||||
'the only path opened is the manifest — a declared-scope value is compared, never read',
|
||||
);
|
||||
});
|
||||
|
||||
test('a duplicate --files takes the first occurrence, like every other flag', () => {
|
||||
const { entry } = recordAgent(['--files', 'a.ts', '--files', 'b.ts']);
|
||||
assert.deepEqual(entry.files_modified, ['a.ts']);
|
||||
});
|
||||
|
||||
test('worktree create records the declared scope too', () => {
|
||||
let written = null;
|
||||
const result = withExitCode2596(() => cmdWorktreeCreate('/repo/main', [
|
||||
...baseArgs, '--root', '/repo', '--files', 'src/a.ts',
|
||||
], {
|
||||
readFile: () => '{"orchestrator_root":"/repo/main","worktrees":[]}',
|
||||
writeFile: (_p, c) => { written = c; },
|
||||
write: () => {},
|
||||
writeErr: () => {},
|
||||
execGit: () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }),
|
||||
}));
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(JSON.parse(written).worktrees[0].files_modified, ['src/a.ts']);
|
||||
});
|
||||
|
||||
// #2596 parity (CLAUDE.md → Generative Fix Divergence): `record-agent` and
|
||||
// `create` each decide independently whether to write `files_modified`.
|
||||
// Assert the two surfaces agree on every input class — including the blank
|
||||
// cases where BOTH must omit the field — so a change to one that is not
|
||||
// mirrored in the other fails here instead of shipping a backend-dependent
|
||||
// advisory.
|
||||
test('parity: record-agent and create agree on files_modified for every --files input', () => {
|
||||
const createEntry = (extraArgs) => {
|
||||
let written = null;
|
||||
withExitCode2596(() => cmdWorktreeCreate('/repo/main', [...baseArgs, '--root', '/repo', ...extraArgs], {
|
||||
readFile: () => '{"orchestrator_root":"/repo/main","worktrees":[]}',
|
||||
writeFile: (_p, c) => { written = c; },
|
||||
write: () => {},
|
||||
writeErr: () => {},
|
||||
execGit: () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }),
|
||||
}));
|
||||
return written ? JSON.parse(written).worktrees[0] : null;
|
||||
};
|
||||
|
||||
for (const extraArgs of [
|
||||
['--files', 'src/a.ts src/b.ts'],
|
||||
['--files', 'src/a.ts'],
|
||||
['--files', ''],
|
||||
['--files', ' \t '],
|
||||
['--files'],
|
||||
[],
|
||||
]) {
|
||||
const fromRecord = recordAgent(extraArgs).entry;
|
||||
const fromCreate = createEntry(extraArgs);
|
||||
assert.equal(
|
||||
has(fromRecord, 'files_modified'), has(fromCreate, 'files_modified'),
|
||||
`record-agent and create disagree on WHETHER to write files_modified for ${JSON.stringify(extraArgs)}`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
fromRecord.files_modified, fromCreate.files_modified,
|
||||
`record-agent and create disagree on the files_modified VALUE for ${JSON.stringify(extraArgs)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user