Merge branch 'next' into feat/1346-enhance-verify-phase-project-a-check-vio
This commit is contained in:
@@ -28,7 +28,8 @@ function parseWorkflowSteps(content) {
|
||||
name: match[1],
|
||||
// After #3797 architectural fix, callsites use gsd_run
|
||||
readsRuntimeConfig: body.includes('RUNTIME=$(gsd_run query config-get runtime --default claude'),
|
||||
codexWorktreeGuard: body.includes('Codex execute-phase worktree isolation is unsupported'),
|
||||
// #1521: guard generalized from Codex-specific to all non-Claude runtimes
|
||||
codexWorktreeGuard: body.includes('git worktree isolation') && body.includes('unsupported on runtime'),
|
||||
worktreeDispatchGuidance: body.includes('isolation="worktree"'),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* dispatched Plan/Execute via Agent(run_in_background=true). On Claude Code a
|
||||
* backgrounded agent has no Agent/Task tool, so it cannot spawn the nested
|
||||
* subagents (worktree executors, plan-checker, verifier). The workflows must
|
||||
* now resolve the runtime and run inline on Claude Code.
|
||||
* now resolve the runtime and run inline everywhere except Codex, which is the
|
||||
* only supported runtime where a backgrounded agent can still nest subagents.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
@@ -24,23 +25,47 @@ describe('bug-853 — manager/autonomous gate background dispatch by runtime', (
|
||||
assert.ok(matches.length >= 2, 'manager.md must resolve runtime for both plan and execute dispatch');
|
||||
});
|
||||
|
||||
test('manager.md documents why Claude Code cannot background-dispatch', () => {
|
||||
assert.match(MANAGER, /backgrounded agent has no `Agent`\/`Task` tool/);
|
||||
test('manager.md documents why most runtimes cannot background-dispatch', () => {
|
||||
// Accept both old singular form (backgrounded agent has no) and new plural form (backgrounded agents have no)
|
||||
assert.match(MANAGER, /backgrounded agents? ha(?:s|ve) no `Agent`\/`Task` tool/);
|
||||
});
|
||||
|
||||
test('manager.md runs plan/execute inline on Claude Code', () => {
|
||||
assert.match(MANAGER, /If `RUNTIME` is `claude`[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/);
|
||||
assert.match(MANAGER, /If `RUNTIME` is `claude`[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/);
|
||||
test('manager.md gates background dispatch on codex and runs plan/execute inline otherwise', () => {
|
||||
// Codex takes the background path
|
||||
assert.match(MANAGER, /If `RUNTIME` is `codex`[\s\S]{0,400}?run_in_background=true/);
|
||||
// Inline is the default/else branch for plan — anchored on the explicit non-Codex label
|
||||
assert.match(
|
||||
MANAGER,
|
||||
/Otherwise \(Claude Code or any other non-Codex runtime\)[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/,
|
||||
);
|
||||
// Inline is the default/else branch for execute — anchored on the explicit non-Codex label
|
||||
assert.match(
|
||||
MANAGER,
|
||||
/Otherwise \(Claude Code or any other non-Codex runtime\)[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/,
|
||||
);
|
||||
});
|
||||
|
||||
test('autonomous.md gates interactive background dispatch by runtime', () => {
|
||||
const autoRuntimeMatches = AUTONOMOUS.match(/config-get runtime/g) || [];
|
||||
assert.ok(autoRuntimeMatches.length >= 2, 'autonomous.md must resolve runtime in both 3b (plan) and 3c (execute) interactive branches');
|
||||
assert.match(AUTONOMOUS, /backgrounded agent has no `Agent`\/`Task` tool/);
|
||||
// Accept both old singular form (backgrounded agent has no) and new plural form (backgrounded agents have no)
|
||||
assert.match(AUTONOMOUS, /backgrounded agents? ha(?:s|ve) no `Agent`\/`Task` tool/);
|
||||
});
|
||||
|
||||
test('autonomous.md runs plan/execute inline on Claude Code in interactive mode', () => {
|
||||
assert.match(AUTONOMOUS, /On Claude Code \(`RUNTIME` is `claude`\)[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/);
|
||||
assert.match(AUTONOMOUS, /On Claude Code \(`RUNTIME` is `claude`\)[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/);
|
||||
test('autonomous.md gates interactive background dispatch on codex; runs plan/execute inline otherwise', () => {
|
||||
// Codex block: run_in_background=true appears within the codex branch and gsd-plan-phase is nearby
|
||||
assert.match(AUTONOMOUS, /If `RUNTIME` is `codex`[\s\S]{0,1200}?run_in_background=true[\s\S]{0,600}?gsd-plan-phase/);
|
||||
// Codex block: run_in_background=true appears within the codex branch and gsd-execute-phase is nearby
|
||||
assert.match(AUTONOMOUS, /If `RUNTIME` is `codex`[\s\S]{0,3000}?run_in_background=true[\s\S]{0,200}?gsd-execute-phase/);
|
||||
// Inline is the otherwise/else branch for plan — anchored on the explicit non-Codex label
|
||||
assert.match(
|
||||
AUTONOMOUS,
|
||||
/Otherwise \(Claude Code or any other non-Codex runtime\)[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/,
|
||||
);
|
||||
// Inline is the otherwise/else branch for execute — anchored on the explicit non-Codex label
|
||||
assert.match(
|
||||
AUTONOMOUS,
|
||||
/Otherwise \(Claude Code or any other non-Codex runtime\)[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,29 @@ describe('_computePathPrefix', () => {
|
||||
});
|
||||
assert.equal(prefix, '/opt/custom-cursor/');
|
||||
});
|
||||
|
||||
test('isWindowsHost tripwire — Windows paths collapse to $HOME/ same as POSIX (no-op today)', () => {
|
||||
// Documents CURRENT behavior: isWindowsHost is accepted but not branched on.
|
||||
// Both win32=true and win32=false return '$HOME/.cursor/' for a home-relative target.
|
||||
// If a future Windows-specific branch is added, this tripwire fails and forces
|
||||
// an explicit decision about what to return on Windows.
|
||||
const withWindows = conversion._computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: false,
|
||||
isWindowsHost: true,
|
||||
resolvedTarget: 'C:/Users/matte/.cursor',
|
||||
homeDir: 'C:/Users/matte',
|
||||
});
|
||||
const withoutWindows = conversion._computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: false,
|
||||
isWindowsHost: false,
|
||||
resolvedTarget: 'C:/Users/matte/.cursor',
|
||||
homeDir: 'C:/Users/matte',
|
||||
});
|
||||
assert.equal(withWindows, '$HOME/.cursor/');
|
||||
assert.strictEqual(withWindows, withoutWindows);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -231,6 +254,52 @@ describe('rewriteStagedCommandBodies', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error-path: applyRuntimeContentRewritesForCommandsInPlace must rm the tempDir
|
||||
// on any exception and NOT leave an orphaned gsd-cmd-rewrites-* directory.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applyRuntimeContentRewritesForCommandsInPlace — error-path tempDir cleanup', () => {
|
||||
test('rmSync is called on the tempDir when readFileSync throws (deterministic monkeypatch)', () => {
|
||||
// Asserting the injected error propagates proves the throw happens AFTER the tempDir is
|
||||
// created (the function creates tempDir, then reads .md), so the catch's rmSync cleanup
|
||||
// is genuinely exercised — deterministic on every platform/uid.
|
||||
const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-error-path-'));
|
||||
fs.writeFileSync(path.join(stagedDir, 'x.md'), '# test\n');
|
||||
|
||||
const before = new Set(
|
||||
fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-'))
|
||||
);
|
||||
|
||||
const origReadFileSync = fs.readFileSync;
|
||||
let leaked = [];
|
||||
try {
|
||||
fs.readFileSync = () => { throw new Error('injected read failure'); };
|
||||
|
||||
assert.throws(
|
||||
() => conversion.applyRuntimeContentRewritesForCommandsInPlace(stagedDir, 'cursor', '/tmp/x/', false),
|
||||
/injected read failure/,
|
||||
);
|
||||
|
||||
// Restore before any further fs use so the snapshot read is trustworthy.
|
||||
fs.readFileSync = origReadFileSync;
|
||||
|
||||
const after = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-'));
|
||||
leaked = after.filter(n => !before.has(n));
|
||||
assert.deepStrictEqual(leaked, [], `tempDir not cleaned up on error: ${leaked.join(',')}`);
|
||||
} finally {
|
||||
// Idempotent restore — guard against early-throw paths above.
|
||||
fs.readFileSync = origReadFileSync;
|
||||
// Clean up the staged dir created for this test.
|
||||
cleanup(stagedDir);
|
||||
// Clean up any genuinely leaked gsd-cmd-rewrites-* dirs so the runner stays clean.
|
||||
for (const n of leaked) {
|
||||
cleanup(path.join(os.tmpdir(), n));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Guard: runtime-artifact-layout no longer exports getInstallExports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
135
tests/fix-1515-codex-runtime-default.test.cjs
Normal file
135
tests/fix-1515-codex-runtime-default.test.cjs
Normal file
@@ -0,0 +1,135 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Regression tests for bug #1515: Codex install with runtime-neutral
|
||||
* .planning/config.json resolves runtime as 'claude' and enables worktree
|
||||
* isolation (unsafe for Codex).
|
||||
*
|
||||
* Root causes:
|
||||
* A) config-get reads in workflows lacked --raw → output JSON-quoted →
|
||||
* every comparison like [ "$RUNTIME" = "codex" ] failed silently.
|
||||
* B) The conversion engine emitted --default claude for every runtime →
|
||||
* neutral Codex config fell back to claude default.
|
||||
*
|
||||
* All tests assert on the SUT's RETURN VALUE (engine output), not raw file reads,
|
||||
* except the integration test (test 4) which is explicitly the source↔engine
|
||||
* parity guard and carries the allow-test-rule exemption.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const fc = require('fast-check');
|
||||
const conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests: engine stamps codex-specific defaults into emitted workflows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('codex emit stamps its own runtime default into the runtime-resolution line', () => {
|
||||
const line =
|
||||
'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n';
|
||||
const out = conversion._applyRuntimeRewrites(line, 'codex', '$HOME/.codex/', true, undefined);
|
||||
assert.ok(
|
||||
out.includes('config-get runtime --default codex --raw'),
|
||||
`Expected 'config-get runtime --default codex --raw' in output; got:\n${out}`,
|
||||
);
|
||||
assert.ok(
|
||||
out.includes('|| echo "codex")'),
|
||||
`Expected '|| echo "codex")' in output; got:\n${out}`,
|
||||
);
|
||||
assert.ok(
|
||||
!out.includes('--default claude'),
|
||||
`Expected '--default claude' to be fully rewritten; got:\n${out}`,
|
||||
);
|
||||
assert.ok(
|
||||
!out.includes('echo "claude"'),
|
||||
`Expected 'echo "claude"' to be fully rewritten; got:\n${out}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('codex emit defaults workflow.use_worktrees to false', () => {
|
||||
const line =
|
||||
'USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true")\n';
|
||||
const out = conversion._applyRuntimeRewrites(line, 'codex', '$HOME/.codex/', true, undefined);
|
||||
assert.ok(
|
||||
out.includes('config-get workflow.use_worktrees --default false --raw'),
|
||||
`Expected 'config-get workflow.use_worktrees --default false --raw' in output; got:\n${out}`,
|
||||
);
|
||||
assert.ok(
|
||||
out.includes('|| echo "false")'),
|
||||
`Expected '|| echo "false")' in output; got:\n${out}`,
|
||||
);
|
||||
assert.ok(
|
||||
!out.includes('|| echo "true")'),
|
||||
`Expected '|| echo "true")' to be fully rewritten; got:\n${out}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('claude runtime does NOT rewrite the runtime default — stamping is non-claude-scoped (#1521 inversion)', () => {
|
||||
// #1521 generalizes stamping to ALL non-Claude runtimes. The negative case
|
||||
// (no stamping) is now the 'claude' runtime, not other non-Claude runtimes.
|
||||
const line =
|
||||
'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n';
|
||||
const out = conversion._applyRuntimeRewrites(line, 'claude', '$HOME/.claude/', true, undefined);
|
||||
assert.ok(
|
||||
out.includes('--default claude --raw'),
|
||||
`Expected claude output to preserve '--default claude --raw'; got:\n${out}`,
|
||||
);
|
||||
assert.ok(
|
||||
!out.includes('--default codex'),
|
||||
`Expected claude output NOT to contain '--default codex'; got:\n${out}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration / parity guard: real source ↔ engine output for codex (all surfaces)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('regression: every edited workflow gets codex-stamped (source↔engine parity, all surfaces) (#1515)', () => {
|
||||
// allow-test-rule: emitted workflow runtime-resolution shell block is the runtime contract surface (#1515) — asserts on engine-transformed output of the real source
|
||||
const WORKFLOWS = ['execute-phase.md', 'autonomous.md', 'manager.md', 'diagnose-issues.md', 'quick.md'];
|
||||
const CLAUDE_RUNTIME = 'config-get runtime --default claude --raw 2>/dev/null || echo "claude"';
|
||||
const CODEX_RUNTIME = 'config-get runtime --default codex --raw 2>/dev/null || echo "codex"';
|
||||
const TRUE_WT = 'config-get workflow.use_worktrees --raw 2>/dev/null || echo "true"';
|
||||
const FALSE_WT = 'config-get workflow.use_worktrees --default false --raw 2>/dev/null || echo "false"';
|
||||
for (const wf of WORKFLOWS) {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'workflows', wf), 'utf8');
|
||||
const out = conversion._applyRuntimeRewrites(src, 'codex', '$HOME/.codex/', true, undefined);
|
||||
// No un-stamped claude/true resolution line may survive codex emit on ANY surface.
|
||||
assert.ok(!out.includes(CLAUDE_RUNTIME), `${wf}: residual un-stamped runtime read — engine regex no longer matches source line (parity drift)`);
|
||||
assert.ok(!out.includes(TRUE_WT), `${wf}: residual un-stamped use_worktrees read — parity drift`);
|
||||
// If the source HAS such a read, the codex form must be present.
|
||||
if (src.includes(CLAUDE_RUNTIME)) assert.ok(out.includes(CODEX_RUNTIME), `${wf}: runtime read not stamped to codex`);
|
||||
if (src.includes(TRUE_WT)) assert.ok(out.includes(FALSE_WT), `${wf}: use_worktrees read not defaulted to false`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property tests (RULESET.TESTS.property-based-testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('property: runtime stamping applies for ALL non-claude runtimes; only claude leaves --default claude unchanged (#1521)', () => {
|
||||
// #1521: generalised from codex-only to all non-claude runtimes.
|
||||
// Use the canonical list from the conversion module to avoid hand-rolled array drift.
|
||||
const { NON_CLAUDE_RUNTIMES } = conversion;
|
||||
const RUNTIMES = ['claude', ...NON_CLAUDE_RUNTIMES];
|
||||
const line = 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n';
|
||||
fc.assert(fc.property(fc.constantFrom(...RUNTIMES), (rt) => {
|
||||
const out = conversion._applyRuntimeRewrites(line, rt, `$HOME/.${rt}/`, true, undefined);
|
||||
return rt === 'claude'
|
||||
? out.includes('--default claude --raw') && !out.includes('--default codex')
|
||||
: out.includes(`--default ${rt} --raw`) && !out.includes('--default claude');
|
||||
}));
|
||||
});
|
||||
|
||||
test('property: codex stamping is idempotent on resolution lines (#1515)', () => {
|
||||
fc.assert(fc.property(fc.constantFrom('runtime', 'use_worktrees'), (which) => {
|
||||
const line = which === 'runtime'
|
||||
? 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n'
|
||||
: 'USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true")\n';
|
||||
const once = conversion._applyRuntimeRewrites(line, 'codex', '$HOME/.codex/', true, undefined);
|
||||
const twice = conversion._applyRuntimeRewrites(once, 'codex', '$HOME/.codex/', true, undefined);
|
||||
return once === twice;
|
||||
}));
|
||||
});
|
||||
228
tests/fix-1521-non-claude-runtime-default-resolution.test.cjs
Normal file
228
tests/fix-1521-non-claude-runtime-default-resolution.test.cjs
Normal file
@@ -0,0 +1,228 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Regression tests for #1521: every non-Claude runtime stamps its own runtime
|
||||
* identity + workflow.use_worktrees=false into emitted workflows.
|
||||
*
|
||||
* GSD's worktree isolation relies on Claude Code's isolation="worktree" spawn
|
||||
* parameter, which no other runtime honors. #1519 (Codex-only fix) is
|
||||
* generalized here to ALL non-Claude runtimes.
|
||||
*
|
||||
* All tests assert on the SUT's RETURN VALUE (engine output), not raw file reads,
|
||||
* except the parity integration test which carries the allow-test-rule exemption.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const fc = require('fast-check');
|
||||
const conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
|
||||
|
||||
// #1521: use the canonical list from the conversion module rather than a hand-rolled
|
||||
// local array that can drift from the real runtime set.
|
||||
const { NON_CLAUDE_RUNTIMES: NON_CLAUDE } = conversion;
|
||||
const WORKFLOWS = [
|
||||
'execute-phase.md', 'autonomous.md', 'manager.md', 'diagnose-issues.md', 'quick.md',
|
||||
];
|
||||
|
||||
const CLAUDE_RUNTIME_LINE = 'config-get runtime --default claude --raw 2>/dev/null || echo "claude"';
|
||||
const TRUE_WT_LINE = 'config-get workflow.use_worktrees --raw 2>/dev/null || echo "true"';
|
||||
const FALSE_WT_LINE = 'config-get workflow.use_worktrees --default false --raw 2>/dev/null || echo "false"';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parity across ALL non-Claude runtimes × all 5 workflows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('parity: every non-Claude runtime stamps its own runtime default and use_worktrees=false on all workflows (#1521)', () => {
|
||||
// allow-test-rule: emitted workflow runtime-resolution shell block is the runtime contract surface (#1521)
|
||||
for (const rt of NON_CLAUDE) {
|
||||
for (const wf of WORKFLOWS) {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', wf),
|
||||
'utf8',
|
||||
);
|
||||
const out = conversion._applyRuntimeRewrites(src, rt, `$HOME/.${rt}/`, true, undefined);
|
||||
|
||||
// No un-stamped claude runtime line may survive
|
||||
assert.ok(
|
||||
!out.includes(CLAUDE_RUNTIME_LINE),
|
||||
`${rt}/${wf}: residual un-stamped claude runtime read — _stampNonClaudeRuntimeDefaults not applied`,
|
||||
);
|
||||
|
||||
// No un-stamped use_worktrees=true line may survive
|
||||
assert.ok(
|
||||
!out.includes(TRUE_WT_LINE),
|
||||
`${rt}/${wf}: residual un-stamped use_worktrees=true read — _stampNonClaudeRuntimeDefaults not applied`,
|
||||
);
|
||||
|
||||
// If the source had a runtime read, the output must have --default <rt>
|
||||
if (src.includes(CLAUDE_RUNTIME_LINE)) {
|
||||
assert.ok(
|
||||
out.includes(`config-get runtime --default ${rt} --raw 2>/dev/null || echo "${rt}"`),
|
||||
`${rt}/${wf}: runtime line not stamped to --default ${rt}`,
|
||||
);
|
||||
}
|
||||
|
||||
// If the source had a use_worktrees read, the output must have --default false
|
||||
if (src.includes(TRUE_WT_LINE)) {
|
||||
assert.ok(
|
||||
out.includes(FALSE_WT_LINE),
|
||||
`${rt}/${wf}: use_worktrees line not defaulted to false`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claude unchanged — no stamping for the native runtime
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('claude runtime leaves runtime default and use_worktrees=true unchanged (#1521)', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'),
|
||||
'utf8',
|
||||
);
|
||||
const out = conversion._applyRuntimeRewrites(src, 'claude', '$HOME/.claude/', true, undefined);
|
||||
|
||||
// Claude emit must preserve the original --default claude line
|
||||
if (src.includes(CLAUDE_RUNTIME_LINE)) {
|
||||
assert.ok(
|
||||
out.includes(CLAUDE_RUNTIME_LINE),
|
||||
`claude/execute-phase.md: expected original claude runtime line to survive; got mutated`,
|
||||
);
|
||||
}
|
||||
|
||||
// Claude emit must NOT gain --default false for use_worktrees
|
||||
assert.ok(
|
||||
!out.includes(FALSE_WT_LINE),
|
||||
`claude/execute-phase.md: use_worktrees line must NOT be stamped false for claude runtime`,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fc property — identity: each runtime stamps itself, claude stays unchanged
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('property: _stampNonClaudeRuntimeDefaults stamps each non-claude runtime and leaves claude unchanged (#1521)', () => {
|
||||
const line =
|
||||
'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n';
|
||||
fc.assert(
|
||||
fc.property(fc.constantFrom(...NON_CLAUDE, 'claude'), (rt) => {
|
||||
const out = conversion._applyRuntimeRewrites(line, rt, `$HOME/.${rt}/`, true, undefined);
|
||||
if (rt === 'claude') {
|
||||
return out.includes('--default claude') && !/--default (?!claude)/.test(out);
|
||||
}
|
||||
return out.includes(`--default ${rt}`) && !out.includes('--default claude');
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fc property — idempotence: stamping twice equals once
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('property: _stampNonClaudeRuntimeDefaults is idempotent (#1521)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...NON_CLAUDE),
|
||||
fc.constantFrom('runtime', 'use_worktrees'),
|
||||
(rt, which) => {
|
||||
const line =
|
||||
which === 'runtime'
|
||||
? 'RUNTIME=$(gsd_run query config-get runtime --default claude --raw 2>/dev/null || echo "claude")\n'
|
||||
: 'USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true")\n';
|
||||
const once = conversion._applyRuntimeRewrites(line, rt, `$HOME/.${rt}/`, true, undefined);
|
||||
const twice = conversion._applyRuntimeRewrites(once, rt, `$HOME/.${rt}/`, true, undefined);
|
||||
return once === twice;
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Guard generalization: execute-phase.md uses != "claude" not = "codex"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Guard generalization: execute-phase.md, quick.md, and diagnose-issues.md
|
||||
// all use != "claude" (not = "codex") for the worktree guard (#1521)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('execute-phase.md, quick.md, and diagnose-issues.md guards are generalized to != "claude" (not Codex-specific) (#1521)', () => {
|
||||
// allow-test-rule: emitted workflow runtime-resolution shell block is the runtime contract surface (#1521)
|
||||
const GUARD_WORKFLOWS = ['execute-phase.md', 'quick.md', 'diagnose-issues.md'];
|
||||
for (const wf of GUARD_WORKFLOWS) {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', wf),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(
|
||||
src.includes('[ "$RUNTIME" != "claude" ] && [ "$USE_WORKTREES" != "false" ]'),
|
||||
`${wf}: expected generalized guard [ "$RUNTIME" != "claude" ] && [ "$USE_WORKTREES" != "false" ]`,
|
||||
);
|
||||
assert.ok(
|
||||
!src.includes('[ "$RUNTIME" = "codex" ] && [ "$USE_WORKTREES" != "false" ]'),
|
||||
`${wf}: found Codex-specific guard — should have been generalized to != "claude"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestration gating: manager.md + autonomous.md now gate on codex for
|
||||
// background dispatch, not on "not claude". (#1521 Stage 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('manager.md and autonomous.md gate run_in_background on codex specifically (#1521)', () => {
|
||||
// allow-test-rule: orchestration dispatch gating in manager/autonomous .md is the runtime contract surface (#1521)
|
||||
const manager = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', 'manager.md'),
|
||||
'utf8',
|
||||
);
|
||||
const autonomous = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', 'autonomous.md'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Both files must gate run_in_background on codex (not on a generic "not claude" condition)
|
||||
assert.ok(
|
||||
/`RUNTIME` is `codex`[\s\S]{0,500}?run_in_background=true/.test(manager),
|
||||
'manager.md: expected run_in_background dispatch gated on RUNTIME=codex specifically',
|
||||
);
|
||||
assert.ok(
|
||||
/`RUNTIME` is `codex`[\s\S]{0,700}?run_in_background=true/.test(autonomous),
|
||||
'autonomous.md: expected run_in_background dispatch gated on RUNTIME=codex specifically',
|
||||
);
|
||||
|
||||
// Inline is the default/else branch (not just claude)
|
||||
assert.ok(
|
||||
/Otherwise[\s\S]{0,200}?Claude Code or any other non-Codex runtime/.test(manager),
|
||||
'manager.md: expected "Otherwise (Claude Code or any other non-Codex runtime)" inline branch',
|
||||
);
|
||||
assert.ok(
|
||||
/Otherwise[\s\S]{0,200}?Claude Code or any other non-Codex runtime/.test(autonomous),
|
||||
'autonomous.md: expected "Otherwise (Claude Code or any other non-Codex runtime)" inline branch',
|
||||
);
|
||||
});
|
||||
|
||||
test('manager.md and autonomous.md no longer contain old "not claude" background-dispatch gating (#1521)', () => {
|
||||
// allow-test-rule: orchestration dispatch gating in manager/autonomous .md is the runtime contract surface (#1521)
|
||||
const manager = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', 'manager.md'),
|
||||
'utf8',
|
||||
);
|
||||
const autonomous = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', 'autonomous.md'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// The old phrasing that unconditionally sent every non-claude runtime to background must be gone
|
||||
assert.ok(
|
||||
!manager.includes('If `RUNTIME` is not `claude` (e.g. Codex)'),
|
||||
'manager.md: old "If `RUNTIME` is not `claude` (e.g. Codex)" gating must be replaced',
|
||||
);
|
||||
assert.ok(
|
||||
!autonomous.includes('On other runtimes:'),
|
||||
'autonomous.md: old "On other runtimes:" branch label must be replaced',
|
||||
);
|
||||
});
|
||||
95
tests/fix-1521-real-install-stamping.test.cjs
Normal file
95
tests/fix-1521-real-install-stamping.test.cjs
Normal file
@@ -0,0 +1,95 @@
|
||||
'use strict';
|
||||
/**
|
||||
* E2E regression tests for #1521: real install path (copyWithPathReplacement)
|
||||
* MUST stamp non-Claude runtime defaults into emitted gsd-core/workflows/*.md.
|
||||
*
|
||||
* The earlier unit tests in fix-1521-non-claude-runtime-default-resolution.test.cjs
|
||||
* only verify the engine (_applyRuntimeRewrites). This test verifies the wiring:
|
||||
* that a REAL `node bin/install.js --codex/--cursor --global` actually emits
|
||||
* execute-phase.md with --default codex / --default cursor (not --default claude).
|
||||
*
|
||||
* Root cause: copyWithPathReplacement is the emit path for gsd-core/workflows/*.md;
|
||||
* it did its own inline path rewrites but never called _stampNonClaudeRuntimeDefaults,
|
||||
* so the stamping was dead-on-arrival in real installs.
|
||||
*
|
||||
* This test must be RED before the fix is applied (Step 1) and GREEN after (Step 2).
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const INSTALL = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
|
||||
/**
|
||||
* Run a real install into a temp config dir and return the emitted
|
||||
* execute-phase.md content.
|
||||
* @param {string} runtime e.g. 'codex', 'cursor', 'claude'
|
||||
* @returns {string}
|
||||
*/
|
||||
function installAndRead(runtime) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-inst-${runtime}-`));
|
||||
const res = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL, `--${runtime}`, '--global', '--config-dir', dir],
|
||||
{ encoding: 'utf8', timeout: 120000 },
|
||||
);
|
||||
assert.strictEqual(res.status, 0, `install --${runtime} failed: ${res.stderr || res.stdout}`);
|
||||
const wf = path.join(dir, 'gsd-core', 'workflows', 'execute-phase.md');
|
||||
assert.ok(fs.existsSync(wf), `emitted workflow missing for ${runtime}: ${wf}`);
|
||||
const content = fs.readFileSync(wf, 'utf8');
|
||||
cleanup(dir);
|
||||
return content;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RED tests: these MUST FAIL before the copyWithPathReplacement wiring is added
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('real install: codex-emitted execute-phase.md resolves runtime=codex and defaults worktrees off (#1521)', () => {
|
||||
const c = installAndRead('codex');
|
||||
assert.ok(
|
||||
c.includes('config-get runtime --default codex --raw'),
|
||||
'codex runtime default not stamped in real install',
|
||||
);
|
||||
assert.ok(
|
||||
c.includes('config-get workflow.use_worktrees --default false --raw'),
|
||||
'codex use_worktrees not defaulted false in real install',
|
||||
);
|
||||
assert.ok(
|
||||
!c.includes('config-get runtime --default claude --raw'),
|
||||
'residual claude default in codex install',
|
||||
);
|
||||
});
|
||||
|
||||
test('real install: cursor-emitted execute-phase.md resolves runtime=cursor (#1521)', () => {
|
||||
const c = installAndRead('cursor');
|
||||
assert.ok(
|
||||
c.includes('config-get runtime --default cursor --raw'),
|
||||
'cursor runtime default not stamped in real install',
|
||||
);
|
||||
assert.ok(
|
||||
!c.includes('config-get runtime --default claude --raw'),
|
||||
'residual claude default in cursor install',
|
||||
);
|
||||
});
|
||||
|
||||
test('real install: claude-emitted execute-phase.md keeps claude default + worktrees on (#1521)', () => {
|
||||
const c = installAndRead('claude');
|
||||
assert.ok(
|
||||
c.includes('config-get runtime --default claude --raw'),
|
||||
'claude default changed in claude install',
|
||||
);
|
||||
assert.ok(
|
||||
c.includes('config-get workflow.use_worktrees --raw 2>/dev/null || echo "true"'),
|
||||
'claude worktrees default changed (should still be true)',
|
||||
);
|
||||
assert.ok(
|
||||
!c.includes('config-get workflow.use_worktrees --default false --raw'),
|
||||
'claude install must NOT have use_worktrees=false stamped',
|
||||
);
|
||||
});
|
||||
@@ -11,6 +11,7 @@ const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
@@ -32,7 +33,10 @@ function walk(dir, acc) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIP_DIRS.has(entry.name)) walk(path.join(dir, entry.name), acc);
|
||||
} else if (SCAN_EXT.has(path.extname(entry.name))) {
|
||||
// entry.isFile() excludes symlinks (and other non-regular dirents) so a broken symlink like
|
||||
// a gitignored CLAUDE.md worktree symlink is skipped deterministically on every platform —
|
||||
// it can't be read and isn't shipped repo text (#1545).
|
||||
} else if (entry.isFile() && SCAN_EXT.has(path.extname(entry.name))) {
|
||||
acc.push(path.join(dir, entry.name));
|
||||
}
|
||||
}
|
||||
@@ -56,3 +60,41 @@ test('no phantom pre-migration issue references remain in repo text (#1073)', ()
|
||||
`successor (#717/#720) or rewrite as prose (see #1073):\n` + offenders.join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('walk() skips broken symlinks and does not throw ENOENT (#1545)', (t) => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'nophantom-symlink-'));
|
||||
let symlinkCreated = false;
|
||||
try {
|
||||
fs.writeFileSync(path.join(fixture, 'real.md'), '# real, no phantom refs\n');
|
||||
try {
|
||||
fs.symlinkSync(
|
||||
path.join(fixture, 'does-not-exist-target'),
|
||||
path.join(fixture, 'broken.md'),
|
||||
);
|
||||
// Verify the symlink actually exists (lstat succeeds even for dangling symlinks)
|
||||
fs.lstatSync(path.join(fixture, 'broken.md'));
|
||||
symlinkCreated = true;
|
||||
} catch (e) {
|
||||
// Windows without symlink privilege — genuine skip
|
||||
}
|
||||
|
||||
if (!symlinkCreated) {
|
||||
t.skip('platform cannot create symlinks unprivileged');
|
||||
return;
|
||||
}
|
||||
|
||||
const found = walk(fixture, []).map((f) => path.basename(f));
|
||||
|
||||
assert.ok(found.includes('real.md'), 'walk() must include real.md');
|
||||
assert.ok(!found.includes('broken.md'), 'walk() must NOT include broken.md (broken symlink)');
|
||||
|
||||
// Mirror the production read loop — must not throw ENOENT
|
||||
assert.doesNotThrow(
|
||||
() => found.length && walk(fixture, []).forEach((fp) => fs.readFileSync(fp, 'utf8')),
|
||||
'readFileSync on every walk() result must not throw (no broken symlinks returned)',
|
||||
);
|
||||
} finally {
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup in standalone guard test; no helpers import available (would introduce a test-dep cycle)
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,14 +20,19 @@ const os = require('os');
|
||||
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
|
||||
// Simulate the pathPrefix computation from install.js (global install)
|
||||
// Thin adapter over the REAL _computePathPrefix (ADR-1508 Phase 2: deleted hand-copy).
|
||||
// Old signature: computePathPrefix(homedir, targetDir) assumed isGlobal=true, isOpencode=false.
|
||||
// This adapter preserves that contract so existing call-sites stay unchanged.
|
||||
process.env['GSD_TEST_MODE'] = '1';
|
||||
const { _computePathPrefix } = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
|
||||
function computePathPrefix(homedir, targetDir) {
|
||||
const resolvedTarget = path.resolve(targetDir).replace(/\\/g, '/');
|
||||
const homeDir = homedir.replace(/\\/g, '/');
|
||||
if (resolvedTarget.startsWith(homeDir)) {
|
||||
return '$HOME' + resolvedTarget.slice(homeDir.length) + '/';
|
||||
}
|
||||
return resolvedTarget + '/';
|
||||
return _computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: false,
|
||||
isWindowsHost: process.platform === 'win32',
|
||||
resolvedTarget: path.resolve(targetDir).replace(/\\/g, '/'),
|
||||
homeDir: homedir.replace(/\\/g, '/'),
|
||||
});
|
||||
}
|
||||
|
||||
// Detect whether `content` leaks a resolved absolute homedir path (e.g.
|
||||
@@ -65,29 +70,28 @@ describe('pathPrefix computation', () => {
|
||||
});
|
||||
|
||||
test('Windows-style paths produce $HOME/ not C:/', () => {
|
||||
// On Windows, path.resolve returns the input unchanged when it's already absolute.
|
||||
// Simulate the string operation directly (can't use path.resolve for Windows paths on macOS/Linux).
|
||||
const winHomedir = 'C:\\Users\\matte';
|
||||
const winTargetDir = 'C:\\Users\\matte\\.claude';
|
||||
const resolvedTarget = winTargetDir.replace(/\\/g, '/');
|
||||
const homeDir = winHomedir.replace(/\\/g, '/');
|
||||
const prefix = resolvedTarget.startsWith(homeDir)
|
||||
? '$HOME' + resolvedTarget.slice(homeDir.length) + '/'
|
||||
: resolvedTarget + '/';
|
||||
// Call the REAL _computePathPrefix with Windows-style paths.
|
||||
// isWindowsHost=true is passed; today the function ignores it (no-op) and
|
||||
// the $HOME shorthand is determined by the startsWith(homeDir) check alone.
|
||||
const prefix = _computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: false,
|
||||
isWindowsHost: true,
|
||||
resolvedTarget: 'C:/Users/matte/.claude',
|
||||
homeDir: 'C:/Users/matte',
|
||||
});
|
||||
assert.strictEqual(prefix, '$HOME/.claude/');
|
||||
assert.ok(!prefix.includes('C:'), `Should not contain drive letter, got: ${prefix}`);
|
||||
});
|
||||
|
||||
test('target outside home uses absolute path', () => {
|
||||
const homedir = '/home/user';
|
||||
const targetDir = '/opt/gsd/.claude';
|
||||
// path.resolve won't change an already-absolute path on the same OS,
|
||||
// so simulate the string operation directly
|
||||
const resolvedTarget = targetDir.replace(/\\/g, '/');
|
||||
const homeDir = homedir.replace(/\\/g, '/');
|
||||
const prefix = resolvedTarget.startsWith(homeDir)
|
||||
? '$HOME' + resolvedTarget.slice(homeDir.length) + '/'
|
||||
: resolvedTarget + '/';
|
||||
const prefix = _computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: false,
|
||||
isWindowsHost: false,
|
||||
resolvedTarget: '/opt/gsd/.claude',
|
||||
homeDir: '/home/user',
|
||||
});
|
||||
assert.strictEqual(prefix, '/opt/gsd/.claude/');
|
||||
assert.ok(!prefix.includes('$HOME'), `Should not contain $HOME for non-home paths`);
|
||||
});
|
||||
|
||||
@@ -193,8 +193,15 @@ describe('ADR-857 Phase 6 capstone conformance (#1139)', () => {
|
||||
// extract to capabilities. Frozen pre-phase-6 sizes (LF bytes); the files must
|
||||
// drop strictly below these. This also defeats double-run gaming — declaring a
|
||||
// hook while leaving the inline block keeps the file from shrinking -> red.
|
||||
//
|
||||
// #1298: the execute-phase.md ceiling was raised from 93166 to accommodate
|
||||
// wiring the mandatory `worktree record-agent` writer verb into the per-agent
|
||||
// wave-manifest append. That verb is privileged host machinery (ADR-857
|
||||
// Decision #1) — NOT the optional-feature inline logic this budget ratchets
|
||||
// toward capabilities — so its footprint legitimately raises the host-loop
|
||||
// ceiling rather than signalling an un-extracted optional feature.
|
||||
const { lfByteCount } = require('../scripts/workflow-size.cjs');
|
||||
const PRE_PHASE6 = { 'plan-phase.md': 94519, 'execute-phase.md': 93166 };
|
||||
const PRE_PHASE6 = { 'plan-phase.md': 94519, 'execute-phase.md': 93600 };
|
||||
const notShrunk = [];
|
||||
for (const [file, frozen] of Object.entries(PRE_PHASE6)) {
|
||||
const now = lfByteCount(path.join(ROOT, 'gsd-core', 'workflows', file));
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
"audit-fix.md": 10988,
|
||||
"audit-milestone.md": 17637,
|
||||
"audit-uat.md": 7425,
|
||||
"autonomous.md": 42263,
|
||||
"autonomous.md": 42778,
|
||||
"check-todos.md": 9431,
|
||||
"cleanup.md": 9897,
|
||||
"code-review-fix.md": 23890,
|
||||
"code-review.md": 31602,
|
||||
"complete-milestone.md": 29987,
|
||||
"debug.md": 13505,
|
||||
"diagnose-issues.md": 12425,
|
||||
"diagnose-issues.md": 12820,
|
||||
"discovery-phase.md": 8651,
|
||||
"discuss-phase-assumptions.md": 26984,
|
||||
"discuss-phase-power.md": 11273,
|
||||
@@ -24,7 +24,7 @@
|
||||
"docs-update.md": 55662,
|
||||
"edit-phase.md": 12883,
|
||||
"eval-review.md": 9923,
|
||||
"execute-phase.md": 92914,
|
||||
"execute-phase.md": 93426,
|
||||
"execute-plan.md": 31365,
|
||||
"explore.md": 10497,
|
||||
"extract-learnings.md": 12849,
|
||||
@@ -39,7 +39,7 @@
|
||||
"insert-phase.md": 8943,
|
||||
"list-phase-assumptions.md": 4305,
|
||||
"list-workspaces.md": 5655,
|
||||
"manager.md": 25937,
|
||||
"manager.md": 26265,
|
||||
"map-codebase.md": 20789,
|
||||
"milestone-summary.md": 11774,
|
||||
"mvp-phase.md": 13582,
|
||||
@@ -57,7 +57,7 @@
|
||||
"pr-branch.md": 9561,
|
||||
"profile-user.md": 20650,
|
||||
"progress.md": 29387,
|
||||
"quick.md": 48435,
|
||||
"quick.md": 48830,
|
||||
"reapply-patches.md": 20393,
|
||||
"remove-phase.md": 8469,
|
||||
"remove-workspace.md": 7507,
|
||||
|
||||
@@ -677,7 +677,9 @@ describe('bug #3384: worktree cleanup workflow contracts', () => {
|
||||
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf8');
|
||||
assert.match(content, /WAVE_WORKTREE_MANIFEST/);
|
||||
assert.match(content, /worktree\.cleanup-wave/);
|
||||
assert.match(content, /atomically append `\{agent_id, worktree_path, branch, expected_base\}`/);
|
||||
// #1298: the per-agent manifest write now goes through the validated
|
||||
// `worktree record-agent` writer verb (was a prose "atomically append").
|
||||
assert.match(content, /record the `\{agent_id, worktree_path, branch, expected_base\}` entry with `gsd_run query worktree\.record-agent/);
|
||||
assert.match(content, /try\{if\(!p\)throw new Error\("WAVE_WORKTREE_MANIFEST is unset"\)/);
|
||||
assert.match(content, /WT_PATHS_FILE=.*gsd-worktree-paths-/);
|
||||
assert.doesNotMatch(content, /done < <\(node -e 'const fs=require\("fs"\);const p=process\.env\.WAVE_WORKTREE_MANIFEST/);
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const fc = require('fast-check');
|
||||
const { createTempGitProject, createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const WORKTREE_SAFETY_PATH = path.join(
|
||||
@@ -37,6 +38,8 @@ const {
|
||||
snapshotWorktreeInventory,
|
||||
planWorktreeWaveCleanup,
|
||||
executeWorktreeWaveCleanupPlan,
|
||||
planWorktreeRecordAgent,
|
||||
cmdWorktreeRecordAgent,
|
||||
} = require(WORKTREE_SAFETY_PATH);
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
@@ -562,6 +565,382 @@ describe('planWorktreeWaveCleanup', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── planWorktreeRecordAgent (#1298 writer verb) ──────────────────────────────
|
||||
// These tests pin the verb's reason for existing: a per-agent entry that
|
||||
// record-agent ACCEPTS must survive the cleanup-wave reader, and one it REJECTS
|
||||
// is exactly what the reader would have dropped silently. If write- and
|
||||
// read-side validation ever diverge, the round-trip tests below fail.
|
||||
|
||||
describe('planWorktreeRecordAgent', () => {
|
||||
const VALID = {
|
||||
agentId: 'a1',
|
||||
worktreePath: '/repo/.claude/worktrees/agent-a1',
|
||||
branch: 'worktree-agent-a1',
|
||||
base: 'abc123',
|
||||
};
|
||||
|
||||
test('appends a validated entry that the cleanup-wave reader accepts (write/read parity)', () => {
|
||||
const plan = planWorktreeRecordAgent('{"orchestrator_root":"/repo/main","worktrees":[]}', VALID);
|
||||
assert.equal(plan.ok, true);
|
||||
assert.deepEqual(plan.entry, {
|
||||
agent_id: 'a1',
|
||||
worktree_path: '/repo/.claude/worktrees/agent-a1',
|
||||
branch: 'worktree-agent-a1',
|
||||
expected_base: 'abc123',
|
||||
});
|
||||
// The serialized manifest must round-trip through the reader the cleanup
|
||||
// path uses — proving write and read validate identically.
|
||||
const written = JSON.parse(plan.manifest);
|
||||
assert.equal(written.orchestrator_root, '/repo/main'); // preserved, no schema change
|
||||
const readBack = planWorktreeWaveCleanup('/repo/main', written);
|
||||
assert.equal(readBack.ok, true);
|
||||
assert.equal(readBack.entries.length, 1);
|
||||
assert.equal(readBack.entries[0].agent_id, 'a1');
|
||||
});
|
||||
|
||||
test('preserves existing entries and other top-level keys when appending', () => {
|
||||
const existing = JSON.stringify({
|
||||
orchestrator_root: '/repo/main',
|
||||
worktrees: [{
|
||||
agent_id: 'a0',
|
||||
worktree_path: '/repo/.claude/worktrees/agent-a0',
|
||||
branch: 'worktree-agent-a0',
|
||||
expected_base: 'aaa000',
|
||||
}],
|
||||
});
|
||||
const plan = planWorktreeRecordAgent(existing, VALID);
|
||||
assert.equal(plan.ok, true);
|
||||
const written = JSON.parse(plan.manifest);
|
||||
assert.equal(written.orchestrator_root, '/repo/main');
|
||||
assert.equal(written.worktrees.length, 2);
|
||||
assert.deepEqual(written.worktrees.map((w) => w.agent_id), ['a0', 'a1']);
|
||||
});
|
||||
|
||||
test('accepts a bare top-level array manifest', () => {
|
||||
const plan = planWorktreeRecordAgent('[]', VALID);
|
||||
assert.equal(plan.ok, true);
|
||||
const written = JSON.parse(plan.manifest);
|
||||
assert.ok(Array.isArray(written));
|
||||
assert.equal(written.length, 1);
|
||||
assert.equal(written[0].branch, 'worktree-agent-a1');
|
||||
});
|
||||
|
||||
// Write-strict agent_id: the reader treats agent_id as nullable, but the
|
||||
// writer requires it — an entry whose author cannot be identified defeats the
|
||||
// verb's purpose. This is the deliberate write-strict-vs-read-lenient decision.
|
||||
test('fails loudly when --agent-id is empty (write-strict, unlike the lenient reader)', () => {
|
||||
const plan = planWorktreeRecordAgent('{"worktrees":[]}', { ...VALID, agentId: '' });
|
||||
assert.equal(plan.ok, false);
|
||||
assert.equal(plan.reason, 'missing_field');
|
||||
assert.match(plan.hint, /--agent-id/);
|
||||
assert.equal(plan.manifest, null);
|
||||
});
|
||||
|
||||
test('reports every missing field, not just the first', () => {
|
||||
const plan = planWorktreeRecordAgent('{"worktrees":[]}', {
|
||||
agentId: '', worktreePath: '', branch: '', base: '',
|
||||
});
|
||||
assert.equal(plan.reason, 'missing_field');
|
||||
for (const flag of ['--agent-id', '--path', '--branch', '--base']) {
|
||||
assert.match(plan.hint, new RegExp(flag.replace(/[-]/g, '\\$&')));
|
||||
}
|
||||
});
|
||||
|
||||
// Branch-regex consistency caveat: a branch outside the disposable namespace
|
||||
// is what the reader drops silently — record-agent must reject it at write time.
|
||||
test('rejects a branch outside the worktree-agent-* namespace (the entry the reader would drop)', () => {
|
||||
const plan = planWorktreeRecordAgent('{"worktrees":[]}', { ...VALID, branch: 'feature/user-work' });
|
||||
assert.equal(plan.ok, false);
|
||||
assert.equal(plan.reason, 'invalid_entry');
|
||||
assert.match(plan.hint, /worktree-agent-/);
|
||||
assert.equal(plan.manifest, null);
|
||||
// Confirm the rejected entry is genuinely one the reader drops.
|
||||
const readBack = planWorktreeWaveCleanup('/repo/main', {
|
||||
worktrees: [{ agent_id: 'a1', worktree_path: VALID.worktreePath, branch: 'feature/user-work', expected_base: 'abc123' }],
|
||||
});
|
||||
assert.equal(readBack.ok, false);
|
||||
assert.equal(readBack.reason, 'empty_manifest');
|
||||
});
|
||||
|
||||
test('fails loudly on malformed manifest JSON instead of clobbering it', () => {
|
||||
const plan = planWorktreeRecordAgent('{not valid json', VALID);
|
||||
assert.equal(plan.ok, false);
|
||||
assert.equal(plan.reason, 'invalid_manifest_json');
|
||||
assert.equal(plan.manifest, null);
|
||||
});
|
||||
|
||||
test('rejects a manifest whose worktrees field is not an array', () => {
|
||||
const plan = planWorktreeRecordAgent('{"worktrees":{}}', VALID);
|
||||
assert.equal(plan.ok, false);
|
||||
assert.equal(plan.reason, 'manifest_shape_invalid');
|
||||
assert.equal(plan.manifest, null);
|
||||
});
|
||||
|
||||
// The reader dedups on (worktree_path, branch); a re-record would be silently
|
||||
// dropped at cleanup — exactly the failure mode the verb exists to eliminate —
|
||||
// so the writer must reject it loudly rather than swallow it.
|
||||
test('rejects a duplicate (worktree_path, branch) loudly instead of writing a droppable entry', () => {
|
||||
const existing = JSON.stringify({
|
||||
worktrees: [{
|
||||
agent_id: 'a1',
|
||||
worktree_path: '/repo/.claude/worktrees/agent-a1',
|
||||
branch: 'worktree-agent-a1',
|
||||
expected_base: 'abc123',
|
||||
}],
|
||||
});
|
||||
// Same path+branch, different agent_id/base — still a duplicate by the reader's key.
|
||||
const plan = planWorktreeRecordAgent(existing, { ...VALID, agentId: 'a1-retry', base: 'deadbee' });
|
||||
assert.equal(plan.ok, false);
|
||||
assert.equal(plan.reason, 'duplicate_entry');
|
||||
assert.match(plan.hint, /worktree-agent-a1/);
|
||||
assert.equal(plan.manifest, null);
|
||||
});
|
||||
|
||||
test('detects a duplicate stored under the legacy `path` field too', () => {
|
||||
const existing = JSON.stringify({
|
||||
worktrees: [{ path: '/repo/.claude/worktrees/agent-a1', branch: 'worktree-agent-a1', expected_base: 'abc123' }],
|
||||
});
|
||||
const plan = planWorktreeRecordAgent(existing, VALID);
|
||||
assert.equal(plan.reason, 'duplicate_entry');
|
||||
});
|
||||
|
||||
// Reader-alignment: the cleanup reader dedups only over entries that normalize
|
||||
// successfully, so a malformed same-key entry it would DROP must not block a
|
||||
// valid recording — otherwise the writer is stricter than the reader and
|
||||
// blocks legitimate recovery.
|
||||
test('a malformed same-key existing entry does not block recording a valid one', () => {
|
||||
const existing = JSON.stringify({
|
||||
// Same path+branch as VALID but no expected_base — the reader drops this.
|
||||
worktrees: [{ worktree_path: '/repo/.claude/worktrees/agent-a1', branch: 'worktree-agent-a1' }],
|
||||
});
|
||||
const plan = planWorktreeRecordAgent(existing, VALID);
|
||||
assert.equal(plan.ok, true);
|
||||
const readBack = planWorktreeWaveCleanup('/repo/main', JSON.parse(plan.manifest));
|
||||
assert.equal(readBack.ok, true);
|
||||
assert.equal(readBack.entries.length, 1); // reader keeps only the valid one
|
||||
assert.equal(readBack.entries[0].expected_base, 'abc123');
|
||||
});
|
||||
|
||||
test('rejects whitespace-only --path/--base (values are trimmed)', () => {
|
||||
const wsPath = planWorktreeRecordAgent('{"worktrees":[]}', { ...VALID, worktreePath: ' ' });
|
||||
assert.equal(wsPath.reason, 'missing_field');
|
||||
assert.match(wsPath.hint, /--path/);
|
||||
const wsBase = planWorktreeRecordAgent('{"worktrees":[]}', { ...VALID, base: ' \t ' });
|
||||
assert.equal(wsBase.reason, 'missing_field');
|
||||
assert.match(wsBase.hint, /--base/);
|
||||
});
|
||||
|
||||
test('trims incidental surrounding whitespace on accepted values', () => {
|
||||
const plan = planWorktreeRecordAgent('{"worktrees":[]}', {
|
||||
agentId: ' a1 ', worktreePath: ' /repo/wt-a1 ', branch: ' worktree-agent-a1 ', base: ' abc123 ',
|
||||
});
|
||||
assert.equal(plan.ok, true);
|
||||
assert.deepEqual(plan.entry, {
|
||||
agent_id: 'a1', worktree_path: '/repo/wt-a1', branch: 'worktree-agent-a1', expected_base: 'abc123',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── planWorktreeRecordAgent — property-based write/read parity (#1298) ────────
|
||||
// The verb's reason for existing is the write→read parity invariant, so it must
|
||||
// carry a fast-check property test (RULESET.TESTS.property-based-testing): an
|
||||
// entry the writer ACCEPTS must survive the cleanup reader unchanged, and an
|
||||
// entry with an invalid branch must be REJECTED symmetrically.
|
||||
|
||||
describe('planWorktreeRecordAgent — fast-check parity invariant (#1298)', () => {
|
||||
const seg = fc.stringMatching(/^[A-Za-z0-9._/-]+$/); // include '/' — the namespace allows it
|
||||
const agentBranch = seg.map((s) => `worktree-agent-${s}`);
|
||||
const nonEmpty = fc.stringMatching(/^\S[\S ]*$/); // no leading whitespace, not blank
|
||||
|
||||
test('any writer-accepted entry round-trips through the cleanup reader unchanged', () => {
|
||||
fc.assert(fc.property(
|
||||
fc.record({ agentId: nonEmpty, worktreePath: nonEmpty, branch: agentBranch, base: nonEmpty }),
|
||||
(fields) => {
|
||||
const plan = planWorktreeRecordAgent('{"worktrees":[]}', fields);
|
||||
if (!plan.ok) return; // rejection is fine; this property is about accepted entries
|
||||
const readBack = planWorktreeWaveCleanup('/repo/main', JSON.parse(plan.manifest));
|
||||
assert.equal(readBack.ok, true);
|
||||
assert.equal(readBack.entries.length, 1);
|
||||
const e = readBack.entries[0];
|
||||
assert.equal(e.worktree_path, fields.worktreePath.trim());
|
||||
assert.equal(e.branch, fields.branch.trim());
|
||||
assert.equal(e.expected_base, fields.base.trim());
|
||||
assert.equal(e.agent_id, fields.agentId.trim());
|
||||
},
|
||||
));
|
||||
});
|
||||
|
||||
test('an entry with a branch outside the worktree-agent-* namespace is always rejected', () => {
|
||||
fc.assert(fc.property(
|
||||
fc.record({
|
||||
agentId: nonEmpty,
|
||||
worktreePath: nonEmpty,
|
||||
// Any branch that does NOT match the disposable namespace.
|
||||
branch: fc.string({ minLength: 1 }).filter((b) => !/^worktree-agent-[A-Za-z0-9._/-]+$/.test(b.trim())),
|
||||
base: nonEmpty,
|
||||
}),
|
||||
(fields) => {
|
||||
const plan = planWorktreeRecordAgent('{"worktrees":[]}', fields);
|
||||
assert.equal(plan.ok, false);
|
||||
assert.equal(plan.manifest, null);
|
||||
},
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── cmdWorktreeRecordAgent (#1298 CLI wrapper) ───────────────────────────────
|
||||
|
||||
describe('cmdWorktreeRecordAgent', () => {
|
||||
// process.exitCode is global; each failure-path test resets it so a failing
|
||||
// exit code does not leak into the test runner's own exit status.
|
||||
function withExitCode(fn) {
|
||||
const saved = process.exitCode;
|
||||
try { return fn(); } finally { process.exitCode = saved; }
|
||||
}
|
||||
|
||||
const okArgs = [
|
||||
'--manifest', 'manifest.json',
|
||||
'--agent-id', 'a1',
|
||||
'--path', '/repo/.claude/worktrees/agent-a1',
|
||||
'--branch', 'worktree-agent-a1',
|
||||
'--base', 'abc123',
|
||||
];
|
||||
|
||||
test('writes the manifest and reports ok on the happy path', () => {
|
||||
let writtenPath = null;
|
||||
let writtenContent = null;
|
||||
const out = [];
|
||||
const result = cmdWorktreeRecordAgent('/repo/main', okArgs, {
|
||||
readFile: () => '{"orchestrator_root":"/repo/main","worktrees":[]}',
|
||||
writeFile: (p, c) => { writtenPath = p; writtenContent = c; },
|
||||
write: (s) => out.push(s),
|
||||
writeErr: () => {},
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(writtenPath, path.resolve('/repo/main', 'manifest.json'));
|
||||
const written = JSON.parse(writtenContent);
|
||||
assert.equal(written.worktrees.length, 1);
|
||||
assert.equal(written.worktrees[0].agent_id, 'a1');
|
||||
assert.match(out.join(''), /"ok": true/);
|
||||
});
|
||||
|
||||
test('exits 2 with usage when --manifest is missing', () => {
|
||||
withExitCode(() => {
|
||||
const errs = [];
|
||||
const result = cmdWorktreeRecordAgent('/repo/main', ['--agent-id', 'a1'], {
|
||||
writeErr: (s) => errs.push(s),
|
||||
write: () => {},
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'usage');
|
||||
assert.equal(process.exitCode, 2);
|
||||
assert.match(errs.join(''), /Usage: worktree record-agent/);
|
||||
});
|
||||
});
|
||||
|
||||
test('exits 1 loudly when the manifest cannot be read', () => {
|
||||
withExitCode(() => {
|
||||
const errs = [];
|
||||
const result = cmdWorktreeRecordAgent('/repo/main', okArgs, {
|
||||
readFile: () => { throw new Error('ENOENT'); },
|
||||
writeErr: (s) => errs.push(s),
|
||||
write: () => {},
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'manifest_read_failed');
|
||||
assert.equal(process.exitCode, 1);
|
||||
assert.match(errs.join(''), /manifest_read_failed/);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not write the manifest when the entry is invalid', () => {
|
||||
withExitCode(() => {
|
||||
let wrote = false;
|
||||
const errs = [];
|
||||
const result = cmdWorktreeRecordAgent('/repo/main',
|
||||
['--manifest', 'm.json', '--agent-id', 'a1', '--path', '/p', '--branch', 'feature/x', '--base', 'abc123'], {
|
||||
readFile: () => '{"worktrees":[]}',
|
||||
writeFile: () => { wrote = true; },
|
||||
writeErr: (s) => errs.push(s),
|
||||
write: () => {},
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'invalid_entry');
|
||||
assert.equal(wrote, false); // must NOT append an under-populated entry
|
||||
assert.equal(process.exitCode, 1);
|
||||
assert.match(errs.join(''), /worktree-agent-/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── record-agent: real CLI dispatch + workflow wiring (#1298 integration) ────
|
||||
// The unit tests above inject IO; these pin the live `gsd-tools.cjs query
|
||||
// worktree.record-agent` dispatch and the execute-phase.md call site, so a
|
||||
// future typo in the dotted command or the workflow wiring fails loudly.
|
||||
|
||||
describe('worktree record-agent — real CLI dispatch (#1298)', () => {
|
||||
const fs = require('node:fs');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
test('the dotted `query worktree.record-agent` path writes an entry the cleanup reader accepts', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
const manifest = path.join(dir, 'wave-manifest.json');
|
||||
fs.writeFileSync(manifest, `${JSON.stringify({ orchestrator_root: dir, worktrees: [] })}\n`);
|
||||
const out = execFileSync(process.execPath, [
|
||||
GSD_TOOLS, 'query', 'worktree.record-agent',
|
||||
'--manifest', manifest,
|
||||
'--agent-id', 'a1',
|
||||
'--path', path.join(dir, 'wt-a1'),
|
||||
'--branch', 'worktree-agent-a1',
|
||||
'--base', 'abc123',
|
||||
], { encoding: 'utf8' });
|
||||
assert.match(out, /"ok": true/);
|
||||
const written = JSON.parse(fs.readFileSync(manifest, 'utf8'));
|
||||
assert.equal(written.worktrees.length, 1);
|
||||
assert.equal(written.worktrees[0].agent_id, 'a1');
|
||||
// What the live CLI wrote must read back through the cleanup reader.
|
||||
const readBack = planWorktreeWaveCleanup(dir, written);
|
||||
assert.equal(readBack.ok, true);
|
||||
assert.equal(readBack.entries[0].branch, 'worktree-agent-a1');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('a missing field fails loudly via the real CLI (non-zero exit, manifest untouched)', () => {
|
||||
const dir = createTempDir();
|
||||
try {
|
||||
const manifest = path.join(dir, 'wave-manifest.json');
|
||||
fs.writeFileSync(manifest, `${JSON.stringify({ worktrees: [] })}\n`);
|
||||
let threw = false;
|
||||
try {
|
||||
execFileSync(process.execPath, [
|
||||
GSD_TOOLS, 'query', 'worktree.record-agent',
|
||||
'--manifest', manifest,
|
||||
'--path', path.join(dir, 'wt'), '--branch', 'worktree-agent-x', '--base', 'abc123',
|
||||
], { encoding: 'utf8', stdio: 'pipe' });
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
assert.equal(err.status, 1);
|
||||
assert.match(String(err.stderr), /record-agent: missing_field/);
|
||||
}
|
||||
assert.ok(threw, 'CLI must exit non-zero when --agent-id is missing');
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(manifest, 'utf8')).worktrees, []);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('the execute-phase.md per-agent append calls the record-agent verb', () => {
|
||||
const wf = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'), 'utf8',
|
||||
);
|
||||
assert.match(wf, /worktree\.record-agent/, 'execute-phase.md must wire the record-agent verb');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── executeWorktreeWaveCleanupPlan ───────────────────────────────────────────
|
||||
|
||||
describe('executeWorktreeWaveCleanupPlan', () => {
|
||||
|
||||
Reference in New Issue
Block a user