fix(#3729): hoist the .planning and CI gates above the graphify hook's node parse (#3935)

* test(#3729): non-GSD repo and CI must bail before the graphify hook node parse

* fix(#3729): hoist the .planning and CI gates above the graphify hook's node parse

* chore(#3729): changeset fragment (pr number backfilled after PR creation)

* chore(#3729): backfill changeset PR number (3935)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-27 12:15:21 -04:00
committed by GitHub
parent 90984c4316
commit 7f3119a29f
3 changed files with 94 additions and 18 deletions

View File

@@ -428,6 +428,73 @@ describe('auto-update', () => {
);
assert.strictEqual(r.status, 0, 'must not break commits when graphify missing');
});
// #3729 — a sentinel `node` shim records every spawn. In a non-GSD repo the
// hook must bail at Gate 0 ([ -f .planning/config.json ]) without spawning
// node at all: pre-fix, the Gate 1 payload parse spawned node first, which
// on Windows allocates a console window per Bash tool call.
function makeSentinelNodeBin(markerPath) {
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3729-node-'));
const shim = path.join(binDir, 'node');
fs.writeFileSync(
shim,
[
'#!/usr/bin/env bash',
`printf 'x' >> ${JSON.stringify(markerPath)}`,
'exit 1',
].join('\n') + '\n',
{ mode: 0o755 },
);
return { binDir, markerPath };
}
test('non-GSD repo bails at Gate 0 with zero node spawns (#3729)', (t) => {
const tmpDir = createTempGitRepo({ config: undefined });
const sentinel = makeSentinelNodeBin(path.join(tmpDir, '.node-spawned'));
t.after(() => {
cleanupHookRepo(tmpDir);
cleanup(sentinel.binDir);
});
const r = runHook(
tmpDir,
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
{ pathPrepend: sentinel.binDir },
);
assert.strictEqual(r.status, 0, 'hook must exit 0 in a non-GSD repo');
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')),
'no status file should be created in a non-GSD repo',
);
assert.ok(
!fs.existsSync(sentinel.markerPath),
'hook must not spawn node before the Gate 0 .planning/config.json bail (#3729)',
);
});
test('CI set bails before the node parse in a GSD project (#3729)', (t) => {
const tmpDir = createTempGitRepo({
config: { graphify: { enabled: true, auto_update: true } },
});
const sentinel = makeSentinelNodeBin(path.join(tmpDir, '.node-spawned'));
t.after(() => {
cleanupHookRepo(tmpDir);
cleanup(sentinel.binDir);
});
const r = runHook(
tmpDir,
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
{ env: { CI: 'true' }, pathPrepend: sentinel.binDir },
);
assert.strictEqual(r.status, 0);
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')),
'CI must suppress dispatch',
);
assert.ok(
!fs.existsSync(sentinel.markerPath),
'CI gate must run before the Gate 1 node parse (#3729)',
);
});
});
describe('hook — dispatch path (all gates pass)',