fix(#2702): workstream config-get inherits absent keys from root config (#2833)

* test(#2702): failing-first regression for workstream config-get root inheritance

* fix(#2702): workstream config-get inherits absent keys from root config

* fix(#2702): root inheritance wins over --default; gate on GSD_WORKSTREAM not path (review)

* test(#2702): make GSD_PROJECT test exit-safe (--default sentinel, no error path)

* docs(changeset): #2702 workstream config-get root inheritance

* docs(changeset): backfill #2702 PR number to 2833
This commit is contained in:
Tom Boucher
2026-07-29 14:19:57 -04:00
committed by GitHub
parent 6229f0e55c
commit 8fc244b754
3 changed files with 244 additions and 4 deletions

View File

@@ -1376,3 +1376,182 @@ test('runCli treats jsonErrors=false as an explicit human-formatter path', (t) =
});
});
}
// ─── #2702: workstream-scoped config-get inherits from root config ──────────
//
// When GSD_WORKSTREAM is set, planningDir(cwd) points at .planning/workstreams/<ws>/.
// cmdConfigGet used to read ONLY that file, so a key configured at the project root
// (.planning/config.json) but absent from the workstream's own config was reported
// "Key not found" — silently inverting boolean workflow guards. The fix adds a
// root-config inheritance rung: workstream overrides root, but root fills gaps.
describe('#2702: workstream config-get inherits from root config', () => {
let tmpDir;
let rootPlanningDir;
let wsPlanningDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-config-ws-2702-'));
rootPlanningDir = path.join(tmpDir, '.planning');
wsPlanningDir = path.join(rootPlanningDir, 'workstreams', 'alpha');
fs.mkdirSync(wsPlanningDir, { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
// Run cmdConfigGet with GSD_WORKSTREAM scoped to 'alpha' (so planningDir → ws dir).
function runScoped(...args) {
const { keyPath, raw, defaultValue } = parseConfigGetArgs(args);
const saved = process.env.GSD_WORKSTREAM;
process.env.GSD_WORKSTREAM = 'alpha';
let out;
try {
out = captureFdWrite(1, () => {
config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue);
});
} finally {
if (saved === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = saved;
}
return out.trim();
}
function runScopedExpectError(...args) {
const { keyPath, raw, defaultValue } = parseConfigGetArgs(args);
const saved = process.env.GSD_WORKSTREAM;
process.env.GSD_WORKSTREAM = 'alpha';
const origExit = process.exit;
const origWriteSync = fs.writeSync;
io.setJsonErrorMode(true);
let exitCode;
let stderr = '';
fs.writeSync = (fd, ...rest) => {
if (fd !== 2) return origWriteSync.call(fs, fd, ...rest);
stderr += String(rest[0]);
return Buffer.byteLength(String(rest[0]));
};
process.exit = (code) => { exitCode = code; throw new _ExitSignal(code); };
try {
config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue);
} catch (e) {
if (!(e instanceof _ExitSignal)) throw e;
} finally {
process.exit = origExit;
fs.writeSync = origWriteSync;
io.setJsonErrorMode(false);
if (saved === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = saved;
}
const parts = stderr.split('\n').filter(Boolean);
let payload = {};
try { payload = JSON.parse(parts[parts.length - 1]); } catch { /* human mode */ }
return { status: exitCode, reason: payload.reason };
}
function writeRoot(obj) {
fs.writeFileSync(path.join(rootPlanningDir, 'config.json'), JSON.stringify(obj));
}
function writeWs(obj) {
fs.writeFileSync(path.join(wsPlanningDir, 'config.json'), JSON.stringify(obj));
}
test('inherits a fail-closed key (workflow.use_worktrees) from root when workstream omits it', () => {
writeRoot({ workflow: { use_worktrees: true } });
// workstream config exists but does NOT set the key
writeWs({ workflow: {} });
assert.equal(runScoped('config-get', 'workflow.use_worktrees', '--raw'), 'true');
});
test('inherits a fail-open key (workflow.plan_review_convergence) from root', () => {
writeRoot({ workflow: { plan_review_convergence: true } });
writeWs({ workflow: {} });
assert.equal(runScoped('config-get', 'workflow.plan_review_convergence', '--raw'), 'true');
});
test('inherits a core key (workflow.verifier) from root', () => {
writeRoot({ workflow: { verifier: true } });
writeWs({ workflow: {} });
assert.equal(runScoped('config-get', 'workflow.verifier', '--raw'), 'true');
});
test('workstream config overrides root value for a key it sets', () => {
writeRoot({ workflow: { use_worktrees: true } });
writeWs({ workflow: { use_worktrees: false } });
assert.equal(runScoped('config-get', 'workflow.use_worktrees', '--raw'), 'false');
});
test('still errors on a key absent from workstream, root, and schema', () => {
writeWs({ workflow: {} });
writeRoot({ workflow: {} });
const { status, reason } = runScopedExpectError('config-get', 'workflow.totally_absent', '--raw');
assert.notEqual(status, 0, 'a key absent everywhere must error');
assert.equal(reason, io.ERROR_REASON.CONFIG_KEY_NOT_FOUND);
});
test('--default is ignored when the key is inherited from root', () => {
writeRoot({ workflow: { use_worktrees: true } });
writeWs({ workflow: {} });
assert.equal(
runScoped('config-get', 'workflow.use_worktrees', '--default', 'false', '--raw'),
'true',
);
});
test('inherits a nested fail-closed key (workflow.context_coverage_gate) from root', () => {
writeRoot({ workflow: { context_coverage_gate: false } });
writeWs({ workflow: {} });
assert.equal(runScoped('config-get', 'workflow.context_coverage_gate', '--raw'), 'false');
});
test('scoped and unscoped reads return the same string form when workstream does not override', () => {
writeRoot({ workflow: { use_worktrees: true } });
writeWs({ workflow: {} });
const scoped = runScoped('config-get', 'workflow.use_worktrees', '--raw');
// Unscoped read: no GSD_WORKSTREAM — reads root directly.
const unscoped = captureFdWrite(1, () => {
config.cmdConfigGet(tmpDir, 'workflow.use_worktrees', true, undefined);
}).trim();
assert.equal(scoped, unscoped, 'scoped (inherited) and unscoped reads must agree');
assert.equal(scoped, 'true');
});
test('unscoped config-get still reads root value (no regression when not scoped)', () => {
writeRoot({ workflow: { use_worktrees: true } });
const out = captureFdWrite(1, () => {
config.cmdConfigGet(tmpDir, 'workflow.use_worktrees', true, undefined);
}).trim();
assert.equal(out, 'true');
});
test('inherits from root even when the workstream has no config.json at all', () => {
writeRoot({ workflow: { use_worktrees: true } });
// No writeWs — workstreams/alpha/config.json does not exist.
assert.equal(runScoped('config-get', 'workflow.use_worktrees', '--raw'), 'true');
});
test('GSD_PROJECT alone (no workstream) does not trigger root inheritance — matches loadConfigResolved', () => {
// GSD_PROJECT scopes planningDir to .planning/<project>/ but loadConfigResolved
// gates root-reading on `if (ws)`, so a project-only read must NOT inherit root.
// The fix gates on GSD_WORKSTREAM presence (not path inequality) to match.
// A --default is supplied so a non-inheriting read returns a clean sentinel
// ('not-inherited') rather than hitting the error/exit path.
const projectPlanningDir = path.join(rootPlanningDir, 'myproj');
fs.mkdirSync(projectPlanningDir, { recursive: true });
fs.writeFileSync(path.join(projectPlanningDir, 'config.json'), JSON.stringify({ workflow: {} }));
writeRoot({ workflow: { use_worktrees: true } });
const saved = process.env.GSD_PROJECT;
process.env.GSD_PROJECT = 'myproj';
try {
const out = captureFdWrite(1, () => {
config.cmdConfigGet(tmpDir, 'workflow.use_worktrees', true, 'not-inherited');
}).trim();
// No workstream → no root inheritance → returns the --default sentinel, NOT root 'true'.
assert.equal(out, 'not-inherited', 'GSD_PROJECT-only must not inherit root (matches loadConfigResolved)');
} finally {
if (saved === undefined) delete process.env.GSD_PROJECT;
else process.env.GSD_PROJECT = saved;
}
});
});