fix(#3972): one worktreesOptedOut ladder in planning-workspace, shared by resolver and guard (#3979)
* test(#3972): the guard fallback must share the opt-out ladder * fix(#3972): one worktreesOptedOut ladder in planning-workspace, shared by resolver and guard * test(#3972): without a workstream the root config is the effective config * fix(#3972): guard the whole ladder body — malformed env values degrade, never throw * chore(#3972): changeset fragment (pr number backfilled after PR creation) * chore(#3972): backfill changeset PR number (3979) --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -1552,3 +1552,81 @@ describe('gsd-agent-isolation-guard.js: #3582 cold tree — RuntimeBuildError su
|
||||
assert.ok(out.reason.length > 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3972: the guard's sentinel-absent fallback shares the opt-out ladder ───
|
||||
// The guard's own config read was flat-root, so a workstream-LOCAL
|
||||
// use_worktrees=false was invisible to it: with no sentinel present (fresh
|
||||
// checkout) the fallback denied the sequential dispatch the config
|
||||
// explicitly allowed. The ladder now lives in planning-workspace and both
|
||||
// the resolver and the guard consume it.
|
||||
describe('guard fallback — worktreesOptedOut ladder (#3972)', () => {
|
||||
function wsFixture(rootCfg, wsCfg) {
|
||||
const dir = createTempDir('gsd-3972-guard-');
|
||||
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(rootCfg));
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'workstreams', 'alpha', 'config.json'), JSON.stringify(wsCfg));
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('#3972: a workstream-local use_worktrees=false opts the fallback out', (t) => {
|
||||
const dir = wsFixture({ runtime: 'claude' }, { runtime: 'claude', workflow: { use_worktrees: false } });
|
||||
t.after(() => cleanup(dir));
|
||||
assert.equal(fs.existsSync(sentinelFile(dir)), false, 'fixture: sentinel absent — the fallback is under test');
|
||||
const r = runHook(agentPayload(), dir, { GSD_WORKSTREAM: 'alpha' });
|
||||
assert.equal(r.status, 0,
|
||||
`#3972: the workstream opted out — the sentinel-absent fallback must allow; got stdout=${r.stdout}`);
|
||||
});
|
||||
|
||||
test('#3972: a root-only opt-out under a workstream also allows (the #3963 shape, guard side)', (t) => {
|
||||
const dir = wsFixture({ runtime: 'claude', workflow: { use_worktrees: false } }, { runtime: 'claude' });
|
||||
t.after(() => cleanup(dir));
|
||||
const r = runHook(agentPayload(), dir, { GSD_WORKSTREAM: 'alpha' });
|
||||
assert.equal(r.status, 0, 'the inherited root opt-out must reach the fallback too');
|
||||
});
|
||||
|
||||
test('#3972: no opt-out anywhere still denies (unchanged)', (t) => {
|
||||
const dir = wsFixture({ runtime: 'claude' }, { runtime: 'claude' });
|
||||
t.after(() => cleanup(dir));
|
||||
const r = runHook(agentPayload(), dir, { GSD_WORKSTREAM: 'alpha' });
|
||||
assert.equal(r.status, 2, 'the guard must keep demanding the harness flag when nothing opted out');
|
||||
});
|
||||
});
|
||||
|
||||
describe('worktreesOptedOut — ladder unit semantics (#3972)', () => {
|
||||
const { worktreesOptedOut } = require('../gsd-core/bin/lib/planning-workspace.cjs');
|
||||
|
||||
test('scoped own-key wins; root inherited only under the ws gate; strict === false', (t) => {
|
||||
const dir = createTempDir('gsd-3972-unit-');
|
||||
t.after(() => cleanup(dir));
|
||||
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
|
||||
const root = path.join(dir, '.planning', 'config.json');
|
||||
const ws = path.join(dir, '.planning', 'workstreams', 'alpha', 'config.json');
|
||||
const prev = process.env['GSD_WORKSTREAM'];
|
||||
t.after(() => { if (prev === undefined) delete process.env['GSD_WORKSTREAM']; else process.env['GSD_WORKSTREAM'] = prev; });
|
||||
|
||||
fs.writeFileSync(ws, JSON.stringify({ workflow: { use_worktrees: false } }));
|
||||
process.env['GSD_WORKSTREAM'] = 'alpha';
|
||||
assert.equal(worktreesOptedOut(dir), true, 'scoped own false');
|
||||
|
||||
fs.writeFileSync(ws, JSON.stringify({ workflow: { use_worktrees: true } }));
|
||||
assert.equal(worktreesOptedOut(dir), false, 'scoped own true wins over any root');
|
||||
|
||||
fs.writeFileSync(ws, JSON.stringify({ runtime: 'claude' }));
|
||||
fs.writeFileSync(root, JSON.stringify({ workflow: { use_worktrees: false } }));
|
||||
assert.equal(worktreesOptedOut(dir), true, 'root false inherited under the ws gate');
|
||||
|
||||
delete process.env['GSD_WORKSTREAM'];
|
||||
// Without a workstream, planningDir IS the flat root — the root's own key
|
||||
// is the scoped read (the plain-project opt-out), so this answers true.
|
||||
// The no-cross-inheritance contract (a GSD_PROJECT-scoped dir ignoring the
|
||||
// flat root) is pinned by the resolver-level #3963 boundary test.
|
||||
assert.equal(worktreesOptedOut(dir), true, 'no ws: the root config IS the effective config');
|
||||
|
||||
fs.writeFileSync(ws, JSON.stringify({ workflow: { use_worktrees: 'false' } }));
|
||||
process.env['GSD_WORKSTREAM'] = 'alpha';
|
||||
assert.equal(worktreesOptedOut(dir), false, 'string "false" never coerces');
|
||||
|
||||
fs.writeFileSync(ws, '{ malformed');
|
||||
assert.equal(worktreesOptedOut(dir), true, 'unreadable scoped config falls to the root view under the gate');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user