* fix(#1521): resolve own runtime + worktrees-off for all non-Claude installs Generalizes the Codex-only #1515/#1519 fix to every non-Claude runtime, and wires it into the real install path (where it was previously dead-on-arrival). Root causes: 1. The runtime-default stamping lived only in `_applyRuntimeRewrites`, but the installer emits `gsd-core/workflows/*.md` via `copyWithPathReplacement`, which never calls it — so a real `--codex`/`--cursor`/etc. install emitted `--default claude` and worktrees-on. RUNTIME mis-resolved to claude and the workflow ran executors unisolated against the main checkout. (#1515/#1519 were also dead-on-arrival in real installs; this repairs them.) 2. Only `case 'codex'` was stamped; every other non-Claude runtime kept the Claude default. Fix: - New `_stampNonClaudeRuntimeDefaults(content, runtime)` (single shared helper) stamps `--default <runtime>` + `use_worktrees=false` for every `runtime != claude`; called from both `_applyRuntimeRewrites` and, crucially, `copyWithPathReplacement` in bin/install.js (the real workflow emit path). - Generalize the fail-closed worktree guard `= codex` -> `!= claude` in execute-phase/quick/diagnose-issues (worktree isolation is Claude-Code-only). - Flip manager/autonomous inline-vs-background gating to `codex -> background, everything-else -> inline` (research: only Codex can background-nest the pipeline's subagents; all others run inline, which they support). Worktree-capability determination is research-backed (official docs for all 14 non-Claude runtimes: none honor GSD's isolation="worktree" mechanism, only Codex background-nests). New end-to-end real-install test asserts the EMITTED workflow is stamped — the regression guard that would have caught the dead-on-arrival bug. Closes #1521 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013vX5eUtWa2wsZEyeMf5i3r * chore(#1521): backfill changeset PR number (#1537) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013vX5eUtWa2wsZEyeMf5i3r --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
229 lines
10 KiB
JavaScript
229 lines
10 KiB
JavaScript
'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',
|
||
);
|
||
});
|