Files
msd-core/tests/config-loader.test.cjs
Michel Moreira 26e650f312 fix(#4462): normalize whitespace workstream environment values (#4509)
* test(#4462): expose whitespace workstream scope

* fix(#4462): normalize the workstream environment scope

* docs(#4462): add changeset for #4509

* fix(#4462): normalize sibling planning scope readers

* test(#4462): cover workstream normalization properties

* fix(#4462): share normalized workstream resolution

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-15 00:40:15 -04:00

1707 lines
73 KiB
JavaScript

'use strict';
/**
* Tests for config-loader.cjs (ADR-857 phase 2e / #885).
*
* Covers:
* - loadConfig defaults when no config.json file exists
* - loadConfig merges file values over defaults
* - legacy-key normalization (branching_strategy → git.branching_strategy)
* - workstream overlay (root → workstream inheritance)
* - workstream-null fallback when workstream config is absent
* - unknown-key warning dedup (_warnedUnknownConfigKeys deduplications)
* - malformed JSON handling (falls back to defaults)
* - shim identity: core.loadConfig === configLoader.loadConfig
* - ADVERSARIAL fixtures: empty JSON, unknown keys, dynamic-prefix keys
* like agent_skills.__proto__, scalars-where-objects-expected,
* missing config file
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { cleanup } = require('./helpers.cjs');
const fc = require('fast-check');
// ─── module under test ────────────────────────────────────────────────────────
const configLoader = require('../gsd-core/bin/lib/config-loader.cjs');
const { loadConfig, loadConfigResolved, _resetRuntimeWarningCacheForTests, _deepMergeConfig } = configLoader;
// ─── helpers ──────────────────────────────────────────────────────────────────
function makeTempProject(prefix = 'gsd-cfg-loader-test-') {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
return tmpDir;
}
function writeConfig(tmpDir, obj) {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify(obj, null, 2), 'utf-8');
}
function writeWorkstreamConfig(tmpDir, wsName, obj) {
const wsDir = path.join(tmpDir, '.planning', 'workstreams', wsName);
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
fs.writeFileSync(path.join(wsDir, 'config.json'), JSON.stringify(obj, null, 2), 'utf-8');
}
// ─── defaults when no config.json ────────────────────────────────────────────
describe('loadConfig — defaults when no config.json', () => {
let tmpDir;
beforeEach(() => { tmpDir = makeTempProject(); });
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
test('returns an object with expected default keys when config.json is absent', () => {
const config = loadConfig(tmpDir);
// Structural checks — should have canonical keys from CONFIG_DEFAULTS
assert.ok('model_profile' in config, 'must have model_profile');
assert.ok('commit_docs' in config, 'must have commit_docs');
assert.ok('research' in config, 'must have research');
assert.ok('branching_strategy' in config, 'must have branching_strategy');
assert.ok('plan_checker' in config, 'must have plan_checker');
assert.ok('verifier' in config, 'must have verifier');
assert.ok('parallelization' in config, 'must have parallelization');
assert.ok('sub_repos' in config, 'must have sub_repos');
assert.ok('resolve_model_ids' in config, 'must have resolve_model_ids');
});
test('model_profile default is "balanced"', () => {
const config = loadConfig(tmpDir);
assert.equal(config.model_profile, 'balanced');
});
test('config.json present with empty object: agent_skills default is an empty object', () => {
// agent_skills only appears in the return when a config.json is successfully parsed
writeConfig(tmpDir, {});
const config = loadConfig(tmpDir);
assert.deepEqual(config.agent_skills, {});
});
test('config.json present with empty object: model_overrides default is null', () => {
// model_overrides only appears in the return when a config.json is successfully parsed
writeConfig(tmpDir, {});
const config = loadConfig(tmpDir);
assert.equal(config.model_overrides, null);
});
});
// ─── file values merge over defaults ─────────────────────────────────────────
describe('loadConfig — file values override defaults', () => {
let tmpDir;
beforeEach(() => { tmpDir = makeTempProject(); });
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
test('model_profile from config.json overrides the default', () => {
writeConfig(tmpDir, { model_profile: 'quality' });
const config = loadConfig(tmpDir);
assert.equal(config.model_profile, 'quality');
});
test('workflow.research from nested config is returned', () => {
writeConfig(tmpDir, { workflow: { research: 'deep' } });
const config = loadConfig(tmpDir);
assert.equal(config.research, 'deep');
});
test('top-level research is returned', () => {
writeConfig(tmpDir, { research: 'minimal' });
const config = loadConfig(tmpDir);
assert.equal(config.research, 'minimal');
});
test('mode from config.json is returned', () => {
writeConfig(tmpDir, { mode: 'autonomous' });
const config = loadConfig(tmpDir);
assert.equal(config.mode, 'autonomous');
});
test('model_overrides from config.json is returned', () => {
writeConfig(tmpDir, { model_overrides: { planner: 'claude-opus-4-5' } });
const config = loadConfig(tmpDir);
assert.deepEqual(config.model_overrides, { planner: 'claude-opus-4-5' });
});
});
// ─── legacy-key normalization ─────────────────────────────────────────────────
describe('loadConfig — legacy-key normalization', () => {
let tmpDir;
beforeEach(() => { tmpDir = makeTempProject(); });
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
test('top-level branching_strategy is migrated to git.branching_strategy', () => {
writeConfig(tmpDir, { branching_strategy: 'milestone' });
const config = loadConfig(tmpDir);
assert.equal(config.branching_strategy, 'milestone');
});
test('on-disk file has branching_strategy moved under git.* after migration', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ branching_strategy: 'phase' }, null, 2), 'utf-8');
loadConfig(tmpDir);
const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.equal(onDisk.git?.branching_strategy, 'phase');
assert.equal(onDisk.branching_strategy, undefined);
});
});
// ─── workstream overlay ───────────────────────────────────────────────────────
describe('loadConfig — workstream overlay', () => {
let tmpDir;
beforeEach(() => { tmpDir = makeTempProject(); });
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
test('workstream config overrides root config', () => {
writeConfig(tmpDir, { model_profile: 'balanced' });
writeWorkstreamConfig(tmpDir, 'ws-a', { model_profile: 'quality' });
const config = loadConfig(tmpDir, { workstream: 'ws-a' });
assert.equal(config.model_profile, 'quality');
});
test('root-only keys are inherited by workstream config', () => {
writeConfig(tmpDir, { model_profile: 'balanced', research: 'deep' });
writeWorkstreamConfig(tmpDir, 'ws-b', { mode: 'autonomous' });
const config = loadConfig(tmpDir, { workstream: 'ws-b' });
// Root's research should still be visible (inherited)
assert.equal(config.research, 'deep');
// Workstream's mode should override
assert.equal(config.mode, 'autonomous');
});
test('workstream-null fallback: root config used when workstream has no config.json', () => {
writeConfig(tmpDir, { model_profile: 'budget' });
// Create workstream directory but no config.json
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'ws-no-config');
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
// loadConfig with missing workstream config.json should fall back to root
const config = loadConfig(tmpDir, { workstream: 'ws-no-config' });
assert.equal(config.model_profile, 'budget');
});
});
// ─── unknown-key warning dedup ────────────────────────────────────────────────
describe('loadConfig — unknown-key warning dedup', () => {
let tmpDir;
let originalStderrWrite;
let stderrLines;
beforeEach(() => {
tmpDir = makeTempProject();
stderrLines = [];
originalStderrWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk) => {
stderrLines.push(String(chunk));
return true;
};
// Reset the module-level dedup set so each test starts clean
if (_resetRuntimeWarningCacheForTests) _resetRuntimeWarningCacheForTests();
});
afterEach(() => {
process.stderr.write = originalStderrWrite;
if (tmpDir) cleanup(tmpDir);
tmpDir = null;
});
test('unknown key produces a warning mentioning the key name', () => {
writeConfig(tmpDir, { __gsd_unknown_sentinel__: true });
loadConfig(tmpDir);
const warnings = stderrLines.filter(l => l.includes('__gsd_unknown_sentinel__'));
assert.ok(warnings.length >= 1, 'should warn about unknown key');
});
test('calling loadConfig twice does not double-emit the same unknown-key warning', () => {
writeConfig(tmpDir, { __gsd_dedup_test__: true });
loadConfig(tmpDir);
loadConfig(tmpDir);
const warnings = stderrLines.filter(l => l.includes('__gsd_dedup_test__'));
// Should appear at most once
assert.ok(warnings.length <= 1, `warning emitted more than once: ${warnings.length} times`);
});
test('agent_tools is accepted without an unknown-key warning (#4032)', () => {
writeConfig(tmpDir, { agent_tools: { 'gsd-executor': ['WebFetch'] } });
const config = loadConfig(tmpDir);
assert.deepEqual(config.agent_tools, { 'gsd-executor': ['WebFetch'] });
assert.equal(
stderrLines.some((line) => line.includes('agent_tools')),
false,
'a documented agent_tools config must not be reported as unknown',
);
});
// #2674: the two cases above only pass because each picks a key name no other
// case reuses — so neither can observe whether the documented reset actually
// runs. _resetRuntimeWarningCacheForTests is documented as resetting
// "per-process warning state", and this suite's beforeEach calls it expecting
// exactly that, but it cleared only _warnedConfigKeys and left
// _warnedUnknownConfigKeys populated. Any later case that reused a key would
// have its warning silently suppressed by the previous case's leaked state.
// Asserts on the exported Set rather than stderr prose (CONTRIBUTING.md —
// Prohibited: Raw Text Matching on Test Outputs).
test('_resetRuntimeWarningCacheForTests clears the unknown-key dedup set', () => {
writeConfig(tmpDir, { __gsd_reset_probe__: true });
loadConfig(tmpDir);
assert.ok(
configLoader._warnedUnknownConfigKeys.size > 0,
'precondition: loading an unknown key must populate the unknown-key dedup set',
);
configLoader._resetRuntimeWarningCacheForTests();
assert.equal(
configLoader._warnedUnknownConfigKeys.size,
0,
'the documented per-process warning-state reset must clear the unknown-key dedup set too',
);
});
});
// ─── malformed JSON handling ──────────────────────────────────────────────────
describe('loadConfig — malformed JSON', () => {
let tmpDir;
beforeEach(() => { tmpDir = makeTempProject(); });
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
test('malformed config.json returns defaults without throwing', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, '{ invalid json !!', 'utf-8');
let config;
assert.doesNotThrow(() => { config = loadConfig(tmpDir); });
assert.ok(typeof config === 'object' && config !== null, 'should return an object');
assert.ok('model_profile' in config, 'should have model_profile key');
});
test('empty config.json (empty braces) does not throw and returns defaults', () => {
writeConfig(tmpDir, {});
let config;
assert.doesNotThrow(() => { config = loadConfig(tmpDir); });
assert.equal(config.model_profile, 'balanced');
});
});
// ─── ADVERSARIAL fixtures ─────────────────────────────────────────────────────
describe('loadConfig — adversarial fixtures', () => {
let tmpDir;
beforeEach(() => { tmpDir = makeTempProject(); });
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
test('agent_skills.__proto__ key in config does not pollute Object prototype', () => {
// Write config with a prototype-pollution candidate key
const configPath = path.join(tmpDir, '.planning', 'config.json');
// JSON.stringify won't serialize __proto__ as an own property;
// write the raw string to simulate an adversarial file.
fs.writeFileSync(
configPath,
'{"agent_skills": {"__proto__": {"polluted": true}}}',
'utf-8'
);
const before = ({}).polluted;
let config;
assert.doesNotThrow(() => { config = loadConfig(tmpDir); });
const after = ({}).polluted;
assert.equal(before, after, 'Object prototype must not be polluted');
// agent_skills should be the parsed value or an empty object — not throw
assert.ok(typeof config.agent_skills === 'object', 'agent_skills should be an object');
});
test('scalars-where-objects-expected: workflow is a string', () => {
writeConfig(tmpDir, { workflow: 'invalid' });
let config;
assert.doesNotThrow(() => { config = loadConfig(tmpDir); });
assert.ok(typeof config === 'object', 'should return an object');
});
test('completely empty JSON file (just whitespace) falls back to defaults', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, ' ', 'utf-8');
let config;
assert.doesNotThrow(() => { config = loadConfig(tmpDir); });
assert.ok('model_profile' in config);
});
test('null JSON value (top-level null) falls back to defaults', () => {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, 'null', 'utf-8');
let config;
assert.doesNotThrow(() => { config = loadConfig(tmpDir); });
assert.ok('model_profile' in config);
});
test('deeply nested unknown keys do not throw', () => {
writeConfig(tmpDir, {
workflow: {
research: 'minimal',
__unknown_nested__: { a: 1, b: { c: 2 } },
},
});
let config;
assert.doesNotThrow(() => { config = loadConfig(tmpDir); });
assert.equal(config.research, 'minimal');
});
test('dynamic-prefix key agent_skills.* with unusual value type does not throw', () => {
writeConfig(tmpDir, { agent_skills: { 'my-skill': null } });
let config;
assert.doesNotThrow(() => { config = loadConfig(tmpDir); });
assert.ok(typeof config.agent_skills === 'object');
});
test('config with only unknown keys returns defaults for known keys', () => {
writeConfig(tmpDir, { completly_unknown_a: 1, completly_unknown_b: 2 });
const config = loadConfig(tmpDir);
assert.equal(config.model_profile, 'balanced');
});
});
// ─── loadConfigResolved — provenance ──────────────────────────────────────────
describe('loadConfigResolved — provenance', () => {
let tmpDir;
beforeEach(() => { tmpDir = makeTempProject(); });
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
test('source is "root" when config.json exists and no workstream requested', () => {
writeConfig(tmpDir, { model_profile: 'quality' });
const result = loadConfigResolved(tmpDir);
assert.equal(result.source, 'root');
assert.equal(result.degraded, false);
assert.ok(typeof result.config === 'object', 'config must be an object');
assert.equal(result.config.model_profile, 'quality');
});
test('source is "workstream", degraded:false when workstream config.json present', () => {
writeConfig(tmpDir, { model_profile: 'balanced' });
writeWorkstreamConfig(tmpDir, 'ws-a', { model_profile: 'quality' });
const result = loadConfigResolved(tmpDir, { workstream: 'ws-a' });
assert.equal(result.source, 'workstream');
assert.equal(result.degraded, false);
assert.equal(result.config.model_profile, 'quality');
});
test('source is "root", degraded:true when workstream requested but ws config.json absent', () => {
writeConfig(tmpDir, { model_profile: 'budget' });
// Create ws directory without config.json
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'ws-no-config');
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
const result = loadConfigResolved(tmpDir, { workstream: 'ws-no-config' });
assert.equal(result.source, 'root');
assert.equal(result.degraded, true);
assert.equal(result.config.model_profile, 'budget');
});
test('source is "builtin-defaults" when .planning exists but config.json is absent', () => {
// tmpDir already has .planning/ but no config.json
const result = loadConfigResolved(tmpDir);
assert.equal(result.source, 'builtin-defaults');
assert.equal(result.degraded, false);
assert.ok('model_profile' in result.config);
});
test('source is "global-defaults" when no .planning exists but ~/.gsd/defaults.json readable', () => {
const homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-home-test-'));
const origGsdHome = process.env['GSD_HOME'];
try {
const gsdDir = path.join(homeTmp, '.gsd');
fs.mkdirSync(gsdDir, { recursive: true });
fs.writeFileSync(path.join(gsdDir, 'defaults.json'), JSON.stringify({ model_profile: 'home-defaults' }), 'utf-8');
process.env['GSD_HOME'] = homeTmp;
const noPlanning = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-noplanning-'));
try {
const result = loadConfigResolved(noPlanning);
assert.equal(result.source, 'global-defaults');
assert.equal(result.degraded, false);
assert.equal(result.config.model_profile, 'home-defaults');
} finally {
cleanup(noPlanning);
}
} finally {
if (origGsdHome === undefined) delete process.env['GSD_HOME'];
else process.env['GSD_HOME'] = origGsdHome;
cleanup(homeTmp);
}
});
test('source is "builtin-defaults" when no .planning and no global defaults', () => {
const noPlanning = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-noplanning2-'));
const homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-nohome-'));
const origGsdHome = process.env['GSD_HOME'];
try {
// Point GSD_HOME to a directory with no .gsd/defaults.json
process.env['GSD_HOME'] = homeTmp;
const result = loadConfigResolved(noPlanning);
assert.equal(result.source, 'builtin-defaults');
assert.equal(result.degraded, false);
assert.ok('model_profile' in result.config);
} finally {
if (origGsdHome === undefined) delete process.env['GSD_HOME'];
else process.env['GSD_HOME'] = origGsdHome;
cleanup(noPlanning);
cleanup(homeTmp);
}
});
test('back-compat: loadConfig(tmp) deepEquals loadConfigResolved(tmp).config', () => {
writeConfig(tmpDir, { model_profile: 'quality', research: 'minimal' });
const fromLoadConfig = loadConfig(tmpDir);
const { config: fromResolved } = loadConfigResolved(tmpDir);
assert.deepEqual(fromLoadConfig, fromResolved);
});
test('back-compat: loadConfigResolved(descendant) does NOT walk up — returns defaults, not ancestor config', () => {
// Fix 1: loadConfigResolved must NOT call findProjectRoot internally.
// Calling from a descendant that has no .planning/ of its own must return
// defaults (builtin-defaults source), NOT the ancestor's config value.
writeConfig(tmpDir, { model_profile: 'ancestor-config-should-not-appear' });
const deepDir = path.join(tmpDir, 'src', 'deep');
fs.mkdirSync(deepDir, { recursive: true });
const result = loadConfigResolved(deepDir);
// No .planning/ in deepDir → must fall back to defaults, NOT walk up to tmpDir.
assert.notEqual(result.config.model_profile, 'ancestor-config-should-not-appear',
'loadConfigResolved must NOT walk up to find ancestor config');
// The source must be a defaults source (builtin-defaults or global-defaults),
// NOT "root" (which would imply a config.json was found).
assert.ok(
result.source === 'builtin-defaults' || result.source === 'global-defaults',
`Expected a defaults source, got: ${result.source}`,
);
});
test('Fix 4: loadConfigResolved(tmp, { workstream: "" }) → source:"root"', () => {
writeConfig(tmpDir, { model_profile: 'quality' });
// empty-string ws resolves the root path → source must be "root"
const result = loadConfigResolved(tmpDir, { workstream: '' });
assert.equal(result.source, 'root', 'empty-string workstream should yield source:"root"');
assert.equal(result.degraded, false);
});
test('whitespace-only explicit and environment workstreams resolve and label the root (#4462)', () => {
writeConfig(tmpDir, { model_profile: 'quality' });
for (const workstream of [' ', '\t', '\n', ' \t\n ']) {
const explicit = loadConfigResolved(tmpDir, { workstream });
assert.equal(explicit.source, 'root');
assert.equal(explicit.degraded, false);
const original = process.env.GSD_WORKSTREAM;
try {
process.env.GSD_WORKSTREAM = workstream;
const ambient = loadConfigResolved(tmpDir);
assert.equal(ambient.source, 'root');
assert.equal(ambient.degraded, false);
} finally {
if (original === undefined) delete process.env.GSD_WORKSTREAM;
else process.env.GSD_WORKSTREAM = original;
}
}
});
test('Fix 2a: GSD_WORKSTREAM set to nonexistent workstream (dir absent) → source:"root", degraded:true', () => {
writeConfig(tmpDir, { model_profile: 'root-value' });
const origWs = process.env['GSD_WORKSTREAM'];
try {
process.env['GSD_WORKSTREAM'] = 'nonexistent-ws';
// Do NOT create the workstream directory
const result = loadConfigResolved(tmpDir);
assert.equal(result.source, 'root', 'nonexistent workstream should fall back to source:"root"');
assert.equal(result.degraded, true, 'should be degraded when workstream dir is absent');
assert.equal(result.config.model_profile, 'root-value', 'config should equal root config');
} finally {
if (origWs === undefined) delete process.env['GSD_WORKSTREAM'];
else process.env['GSD_WORKSTREAM'] = origWs;
}
});
test('Fix 2b: options.workstream missing dir → source:"root", degraded:true', () => {
writeConfig(tmpDir, { model_profile: 'root-val' });
// workstream dir NOT created
const result = loadConfigResolved(tmpDir, { workstream: 'missing-ws' });
assert.equal(result.source, 'root');
assert.equal(result.degraded, true);
assert.equal(result.config.model_profile, 'root-val');
});
test('Fix 2c: workstream dir exists but no config.json → source:"root", degraded:true (existing case still works)', () => {
writeConfig(tmpDir, { model_profile: 'root-val-c' });
// Create ws dir but no config.json
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'ws-no-cfg');
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
const result = loadConfigResolved(tmpDir, { workstream: 'ws-no-cfg' });
assert.equal(result.source, 'root');
assert.equal(result.degraded, true);
assert.equal(result.config.model_profile, 'root-val-c');
});
});
// ─── _deepMergeConfig prototype-pollution guard (audit M4) ───────────────────
// The root↔workstream merge once iterated Object.keys(overlay) with no
// __proto__/constructor/prototype guard — while four sibling paths in the same
// file guard them. A config.json with {"__proto__": {...}} could pollute the
// merged object's prototype chain and spoof unset config flags.
describe('_deepMergeConfig — prototype-pollution guard (M4)', () => {
test('ignores a __proto__ overlay key (no proto pollution, no flag spoofing)', () => {
// JSON.parse (not an object literal) creates an OWN enumerable "__proto__"
// key — exactly what a malicious config.json on disk yields.
const malicious = JSON.parse('{"__proto__": {"injectedFlag": true}}');
const merged = _deepMergeConfig({ model_profile: 'base' }, malicious);
assert.equal({}.injectedFlag, undefined, 'global Object.prototype must not be polluted');
assert.equal(merged.injectedFlag, undefined, 'merged object must not expose the injected flag');
assert.equal(Object.getPrototypeOf(merged) === Object.prototype, true, 'merged prototype unchanged');
assert.equal(merged.model_profile, 'base', 'legitimate keys still merge');
});
test('ignores constructor/prototype overlay keys too', () => {
const malicious = JSON.parse('{"constructor": {"x": 1}, "prototype": {"y": 2}}');
const merged = _deepMergeConfig({ a: 1 }, malicious);
assert.equal(merged.a, 1);
// constructor must remain the native Object constructor, not the injected object
assert.equal(typeof merged.constructor, 'function');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2638-sub-repos-canonical-location.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2638-sub-repos-canonical-location (consolidation epic #1969 B3 #1972)", () => {
/**
* Regression test for bug #2638.
*
* loadConfig previously migrated/synced sub_repos to the TOP-LEVEL
* `parsed.sub_repos`, but the KNOWN_TOP_LEVEL allowlist only recognizes
* `planning.sub_repos` (per #2561 — canonical location). That asymmetry
* made loadConfig write a key it then warns is unknown on the next read.
*
* Fix: writers target `parsed.planning.sub_repos` and strip any stale
* top-level copy during the same migration pass.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { createTempProject, cleanup } = require('./helpers.cjs');
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
const { loadConfig } = require('../gsd-core/bin/lib/config-loader.cjs');
function makeSubRepo(parent, name) {
const dir = path.join(parent, name);
fs.mkdirSync(dir, { recursive: true });
gitOrThrow(['init'], { cwd: dir });
}
function readConfig(tmpDir) {
return JSON.parse(
fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8')
);
}
function writeConfig(tmpDir, obj) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify(obj, null, 2)
);
}
describe('bug #2638 — sub_repos canonical location', () => {
let tmpDir;
let originalCwd;
let stderrCapture;
let origStderrWrite;
beforeEach(() => {
tmpDir = createTempProject();
originalCwd = process.cwd();
stderrCapture = '';
origStderrWrite = process.stderr.write;
process.stderr.write = (chunk) => { stderrCapture += chunk; return true; };
});
afterEach(() => {
process.stderr.write = origStderrWrite;
process.chdir(originalCwd);
cleanup(tmpDir);
});
test('does not warn when planning.sub_repos is set (no top-level sub_repos)', () => {
makeSubRepo(tmpDir, 'backend');
makeSubRepo(tmpDir, 'frontend');
writeConfig(tmpDir, {
planning: { sub_repos: ['backend', 'frontend'] },
});
loadConfig(tmpDir);
assert.ok(
!stderrCapture.includes('unknown config key'),
`should not warn for planning.sub_repos, got: ${stderrCapture}`
);
assert.ok(
!stderrCapture.includes('sub_repos'),
`should not mention sub_repos at all, got: ${stderrCapture}`
);
});
test('migrates legacy multiRepo:true into planning.sub_repos (not top-level)', () => {
makeSubRepo(tmpDir, 'backend');
makeSubRepo(tmpDir, 'frontend');
writeConfig(tmpDir, { multiRepo: true });
loadConfig(tmpDir);
const after = readConfig(tmpDir);
assert.deepStrictEqual(
after.planning?.sub_repos,
['backend', 'frontend'],
'migration should write to planning.sub_repos'
);
assert.strictEqual(
Object.prototype.hasOwnProperty.call(after, 'sub_repos'),
false,
'migration must not leave a top-level sub_repos key'
);
assert.strictEqual(after.multiRepo, undefined, 'legacy multiRepo should be removed');
assert.ok(
!stderrCapture.includes('unknown config key'),
`post-migration read should not warn, got: ${stderrCapture}`
);
});
test('filesystem sync writes detected list to planning.sub_repos only', () => {
makeSubRepo(tmpDir, 'api');
makeSubRepo(tmpDir, 'web');
writeConfig(tmpDir, { planning: { sub_repos: ['api'] } });
loadConfig(tmpDir);
const after = readConfig(tmpDir);
assert.deepStrictEqual(after.planning?.sub_repos, ['api', 'web']);
assert.strictEqual(
Object.prototype.hasOwnProperty.call(after, 'sub_repos'),
false,
'sync must not create a top-level sub_repos key'
);
assert.ok(
!stderrCapture.includes('unknown config key'),
`sync should not produce unknown-key warning, got: ${stderrCapture}`
);
});
test('stale top-level sub_repos is stripped on load', () => {
makeSubRepo(tmpDir, 'backend');
writeConfig(tmpDir, {
sub_repos: ['backend'],
planning: { sub_repos: ['backend'] },
});
loadConfig(tmpDir);
const after = readConfig(tmpDir);
assert.strictEqual(
Object.prototype.hasOwnProperty.call(after, 'sub_repos'),
false,
'stale top-level sub_repos should be removed to self-heal legacy installs'
);
assert.deepStrictEqual(after.planning?.sub_repos, ['backend']);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3523-cjs-loadconfig-branching-strategy-warning (consolidation epic #1969 B6 #1975)", () => {
'use strict';
/**
* Regression tests for #3523 — CJS loadConfig must not emit a false
* "unknown config key(s)" warning for `branching_strategy` when that key
* is written at the top level of .planning/config.json.
*
* Root cause: KNOWN_TOP_LEVEL in core.cjs was built from VALID_CONFIG_KEYS
* via k.split('.')[0], which turns 'git.branching_strategy' → 'git', not
* 'branching_strategy'. So a config with the legacy top-level shape tripped
* the unknown-key warning even though core.cjs:485 actively reads the value.
*
* Fix (option 3 — self-healing): mirror the multiRepo → planning.sub_repos
* precedent: graft branching_strategy into fileData.git.branching_strategy
* and delete the top-level key, then persist. The KNOWN_TOP_LEVEL list also
* gains 'branching_strategy' as a deprecated-still-accepted key so the warning
* never fires even on the first read before the write-back occurs.
*
* Double-emission is also reduced: the warning site is guarded by a
* module-level Set so repeated loadConfig calls during one CLI invocation
* don't echo the same line twice.
*
* CJS↔SDK contract: the SDK mergeDefaults() already handles the legacy
* top-level key (PR #3116). This file adds a fixture-level parity check
* that proves both paths produce the same branching_strategy value.
*
* Test strategy: we use `resolve-model` as the minimal CJS entry point that
* calls loadConfig internally, then assert on stderr emptiness (typed-IR
* "no warning" pattern from #2687).
*/
const { describe, test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, TOOLS_PATH, TEST_ENV_BASE, installSpawnHome } = require('./helpers.cjs');
const { runNode } = require('./helpers/process-seam.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
/**
* Run gsd-tools and return { stdout, stderr, status }.
* Always captures stderr even when exit code is 0.
*/
function runWithStderr(args, cwd, env = {}) {
const result = runNode([TOOLS_PATH, ...args], {
cwd,
// #3532: pin GSD_HOME to an empty sandbox so a developer's real
// ~/.gsd/defaults.json cannot leak shadow-key warnings into children that
// these suites assert are stderr-clean (TEST_ENV_BASE only BLANKS the
// var; an empty string falls through to the real homedir).
env: { ...process.env, ...TEST_ENV_BASE, GSD_HOME: installSpawnHome(), ...env },
timeoutMs: PROBE_TIMEOUT_MS,
});
return {
stdout: result.stdout || '',
stderr: result.stderr || '',
status: result.exitCode,
};
}
// ─── Test 1: no warning for legacy top-level branching_strategy ──────────────
describe('bug-3523 — no warning for legacy top-level branching_strategy', () => {
let tmpDir;
afterEach(() => {
if (tmpDir) cleanup(tmpDir);
tmpDir = null;
});
test('loadConfig emits no stderr when config.json has top-level branching_strategy', () => {
tmpDir = createTempProject('gsd-3523-warn-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({
branching_strategy: 'phase',
git: { base_branch: 'main' },
}, null, 2),
'utf-8'
);
// resolve-model calls loadConfig internally, triggering KNOWN_TOP_LEVEL check.
const result = runWithStderr(['resolve-model', 'planner'], tmpDir);
assert.equal(
result.stderr.trim(),
'',
`loadConfig must not warn about top-level branching_strategy (#3523) — got: ${result.stderr}`
);
});
test('branching_strategy value is still surfaced after loadConfig on legacy shape', () => {
tmpDir = createTempProject('gsd-3523-value-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({
branching_strategy: 'milestone',
git: { base_branch: 'main' },
}, null, 2),
'utf-8'
);
// Trigger loadConfig (which runs the migration and writes git.branching_strategy
// back to disk), then read it with config-get to verify the value is preserved.
const triggerResult = runWithStderr(['resolve-model', 'planner'], tmpDir);
assert.equal(
triggerResult.stderr.trim(),
'',
`No warning should fire on legacy shape (#3523) — got: ${triggerResult.stderr}`
);
// After migration write-back, config-get should find git.branching_strategy.
const result = runWithStderr(['config-get', 'git.branching_strategy', '--raw'], tmpDir);
assert.equal(
result.status,
0,
`config-get command must succeed — exit status ${result.status}, stderr: ${result.stderr}`
);
assert.equal(
result.stderr.trim(),
'',
`No error should fire when reading migrated branching_strategy (#3523) — got: ${result.stderr}`
);
assert.equal(
result.stdout.trim(),
'milestone',
`Expected git.branching_strategy to be 'milestone' but got: ${result.stdout}`
);
});
});
// ─── Test 2: no duplicated warning (double-emission) ─────────────────────────
describe('bug-3523 — double-emission reduced to single-emission', () => {
let tmpDir;
afterEach(() => {
if (tmpDir) cleanup(tmpDir);
tmpDir = null;
});
test('unknown-key warning appears at most once per process invocation', () => {
// Use a key that IS genuinely unknown (not branching_strategy, which is now
// fixed) to verify the deduplication guard works for other keys too.
// We verify that the count of warning lines for a single unknown key is
// exactly once — not zero and not two — even if loadConfig is invoked twice internally.
tmpDir = createTempProject('gsd-3523-dedup-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({
// intentionally_unknown_key_for_dedup_test: a key that can never be valid
__gsd3523_dedup_sentinel__: true,
}, null, 2),
'utf-8'
);
const result = runWithStderr(['resolve-model', 'planner'], tmpDir);
// allow-test-rule: pending-migration-to-typed-ir [#3090]
// Counts occurrences of a sentinel substring in the CLI's human-readable
// stderr warning text — no structured "warning count"/warning-list API is
// exposed yet; adding one is a production change out of scope here.
// Tracked under #3090.
// Count how many times the sentinel key appears in warnings
const warningLines = result.stderr
.split('\n')
.filter(l => l.includes('__gsd3523_dedup_sentinel__'));
assert.equal(
warningLines.length,
1,
`Unknown-key warning must appear exactly once per process invocation — ` +
`appeared ${warningLines.length} times. stderr:\n${result.stderr}`
);
});
});
// ─── Test 3: on-disk migration (option 3 write-back) ─────────────────────────
describe('bug-3523 — option 3 on-disk migration of branching_strategy', () => {
let tmpDir;
afterEach(() => {
if (tmpDir) cleanup(tmpDir);
tmpDir = null;
});
test('after loadConfig, on-disk config.json has branching_strategy under git.*', () => {
tmpDir = createTempProject('gsd-3523-writeback-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({
branching_strategy: 'phase',
git: { base_branch: 'main' },
}, null, 2),
'utf-8'
);
// Trigger loadConfig by running a command.
runWithStderr(['resolve-model', 'planner'], tmpDir);
// On-disk file should now have git.branching_strategy and no top-level branching_strategy.
const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.equal(
onDisk.git?.branching_strategy,
'phase',
'Expected on-disk config.json to have git.branching_strategy = "phase" after migration'
);
assert.equal(
onDisk.branching_strategy,
undefined,
'Expected on-disk config.json to have no top-level branching_strategy after migration'
);
});
test('migration does not clobber existing git.branching_strategy', () => {
// If git.branching_strategy is already set, the top-level value should
// not overwrite it (nested wins, matching SDK mergeDefaults precedence).
tmpDir = createTempProject('gsd-3523-no-clobber-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({
branching_strategy: 'phase', // legacy top-level
git: {
base_branch: 'main',
branching_strategy: 'milestone', // canonical nested — must win
},
}, null, 2),
'utf-8'
);
// Trigger loadConfig.
runWithStderr(['resolve-model', 'planner'], tmpDir);
const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.equal(
onDisk.git?.branching_strategy,
'milestone',
'canonical git.branching_strategy must not be overwritten by legacy top-level key'
);
// top-level key should be removed since it was redundant
assert.equal(
onDisk.branching_strategy,
undefined,
'top-level branching_strategy should be removed even when git.branching_strategy already set'
);
});
test('workstream load also self-heals legacy root branching_strategy', () => {
tmpDir = createTempProject('gsd-3523-workstream-root-');
const rootConfigPath = path.join(tmpDir, '.planning', 'config.json');
const workstreamDir = path.join(tmpDir, '.planning', 'workstreams', 'alpha');
fs.mkdirSync(workstreamDir, { recursive: true });
fs.writeFileSync(
rootConfigPath,
JSON.stringify({
branching_strategy: 'phase',
git: { base_branch: 'main' },
}, null, 2),
'utf-8'
);
fs.writeFileSync(
path.join(workstreamDir, 'config.json'),
JSON.stringify({ workflow: { tdd: true } }, null, 2),
'utf-8'
);
const triggerResult = runWithStderr(['resolve-model', 'planner'], tmpDir, {
GSD_WORKSTREAM: 'alpha',
});
assert.equal(
triggerResult.status,
0,
`workstream load command must succeed — exit status ${triggerResult.status}, stderr: ${triggerResult.stderr}`
);
assert.equal(
triggerResult.stderr.trim(),
'',
`No warning should fire while migrating root config for a workstream — got: ${triggerResult.stderr}`
);
const onDisk = JSON.parse(fs.readFileSync(rootConfigPath, 'utf-8'));
assert.equal(
onDisk.git?.branching_strategy,
'phase',
'Expected root config.json to persist git.branching_strategy after workstream load'
);
assert.equal(
onDisk.branching_strategy,
undefined,
'Expected root config.json to remove top-level branching_strategy after workstream load'
);
const rootResult = runWithStderr(['config-get', 'git.branching_strategy'], tmpDir);
assert.equal(
rootResult.status,
0,
`root config-get command must succeed after workstream migration — exit status ${rootResult.status}, stderr: ${rootResult.stderr}`
);
assert.ok(
rootResult.stdout.includes('phase'),
`Expected migrated root git.branching_strategy to be 'phase' but got: ${rootResult.stdout}`
);
});
});
// ─── Test 4: CJS↔SDK contract parity ────────────────────────────────────────
describe('bug-3523 — CJS↔SDK contract: both agree on legacy branching_strategy fixture', () => {
/**
* This is a light-touch contract test: we invoke the CJS path via CLI and
* compare the branching_strategy value it returns against what the SDK's
* mergeDefaults would compute for the same fixture.
*
* We can't import SDK TypeScript here, so we assert on the CJS output and
* use a snapshot of expected SDK behavior derived from the mergeDefaults
* source (sdk/src/config.ts:192-218):
* mergeDefaults({ branching_strategy: 'phase', git: { base_branch: 'main' } })
* → git.branching_strategy = 'phase'
*/
let tmpDir;
afterEach(() => {
if (tmpDir) cleanup(tmpDir);
tmpDir = null;
});
test('CJS loadConfig surfaces branching_strategy matching SDK mergeDefaults behavior', () => {
tmpDir = createTempProject('gsd-3523-parity-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
// The fixture that the SDK's mergeDefaults handles correctly (PR #3116).
fs.writeFileSync(
configPath,
JSON.stringify({
branching_strategy: 'phase',
git: { base_branch: 'main' },
}, null, 2),
'utf-8'
);
// SDK mergeDefaults produces: git.branching_strategy = 'phase'
// CJS loadConfig must produce the same. Trigger loadConfig first (migration
// writes git.branching_strategy to disk), then verify with config-get.
const triggerResult = runWithStderr(['resolve-model', 'planner'], tmpDir);
assert.equal(
triggerResult.stderr.trim(),
'',
`No warning must fire on a standard legacy fixture — got: ${triggerResult.stderr}`
);
// After the migration write-back, config-get must find git.branching_strategy = 'phase',
// matching what the SDK's mergeDefaults would compute.
const result = runWithStderr(['config-get', 'git.branching_strategy'], tmpDir);
assert.equal(
result.status,
0,
`config-get command must succeed — exit status ${result.status}, stderr: ${result.stderr}`
);
assert.equal(
result.stderr.trim(),
'',
`No error when reading post-migration git.branching_strategy — got: ${result.stderr}`
);
assert.ok(
result.stdout.includes('phase'),
`CJS must agree with SDK: git.branching_strategy = 'phase' for legacy fixture. ` +
`Got: ${result.stdout}`
);
});
});
});
}
// ─── #1880: corrupt is not absent (ADR-1411 amendment) ────────────────────────
describe("loadConfigResolved — corrupt config is distinguishable from absent", () => {
let tmpDir;
let stderrLines;
let originalStderrWrite;
beforeEach(() => {
tmpDir = makeTempProject();
stderrLines = [];
originalStderrWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk) => { stderrLines.push(String(chunk)); return true; };
configLoader._resetRuntimeWarningCacheForTests();
});
afterEach(() => {
process.stderr.write = originalStderrWrite;
if (tmpDir) cleanup(tmpDir);
tmpDir = null;
});
const configPath = (d) => path.join(d, ".planning", "config.json");
const R = configLoader.CONFIG_REASON;
// The repro from the issue: absent and malformed were byte-identical.
test("absent config resolves not_configured and is NOT degraded", () => {
const res = configLoader.loadConfigResolved(tmpDir);
assert.equal(res.degraded, false, "a missing config is legitimate absence");
assert.equal(res.reason, R.NOT_CONFIGURED);
assert.equal(res.reason, "not_configured", "enum value is the wire contract");
});
test("malformed config is degraded with reason config_unparseable", () => {
fs.writeFileSync(configPath(tmpDir), '{"model_profile":"budget",}', "utf-8");
const res = configLoader.loadConfigResolved(tmpDir);
assert.equal(res.reason, R.CONFIG_UNPARSEABLE,
"a trailing comma must not read as \"no config here\"");
assert.equal(res.degraded, true, "corruption is a degraded resolution");
});
test("absent and malformed no longer produce the same resolution", () => {
const absent = configLoader.loadConfigResolved(tmpDir);
configLoader._resetRuntimeWarningCacheForTests();
fs.writeFileSync(configPath(tmpDir), '{"model_profile":"budget",}', "utf-8");
const corrupt = configLoader.loadConfigResolved(tmpDir);
assert.notEqual(absent.reason, corrupt.reason,
"the whole defect: these two were indistinguishable");
assert.notEqual(absent.degraded, corrupt.degraded);
});
test("unreadable config is degraded with reason config_unreadable", (t) => {
fs.writeFileSync(configPath(tmpDir), '{"model_profile":"budget"}', "utf-8");
// Deterministic IO fault via fs monkeypatch, restored in t.after() — never
// chmod 0o000, which root bypasses (CLAUDE.md cross-platform IO rule).
const realRead = fs.readFileSync;
t.after(() => { fs.readFileSync = realRead; });
fs.readFileSync = (f, ...rest) => {
if (String(f).endsWith("config.json")) {
const e = new Error("EACCES: permission denied"); e.code = "EACCES"; throw e;
}
return realRead(f, ...rest);
};
const res = configLoader.loadConfigResolved(tmpDir);
assert.equal(res.reason, R.CONFIG_UNREADABLE);
assert.equal(res.degraded, true);
});
// The wiring clause: loadConfig returns .config alone to ~51 call sites, so
// without a diagnostic the reason field is unreachable to nearly every consumer.
test("the plain loadConfig path still surfaces the cause on stderr", () => {
fs.writeFileSync(configPath(tmpDir), '{"model_profile":"budget",}', "utf-8");
configLoader.loadConfig(tmpDir);
assert.equal(configLoader._warnedUnusableConfig.size, 1,
"a loadConfig caller must still get a signal it can act on");
});
test("the diagnostic is deduplicated across repeat loads", () => {
fs.writeFileSync(configPath(tmpDir), '{"model_profile":"budget",}', "utf-8");
configLoader.loadConfig(tmpDir);
configLoader.loadConfig(tmpDir);
configLoader.loadConfig(tmpDir);
assert.equal(configLoader._warnedUnusableConfig.size, 1, "keyed on path+errno, warned once");
});
// Regression: found by isolated review of the first cut of this fix. The
// success path returned early WITHOUT consulting configFault, so a corrupt
// ROOT config whose workstream override happened to parse reported
// degraded:false / resolved — the same silent-discard defect this issue
// exists to close, reappearing for any project that uses workstreams.
test("a corrupt ROOT config still degrades when the workstream config parses", () => {
const wsDir = path.join(tmpDir, ".planning", "workstreams", "ws-a");
fs.mkdirSync(wsDir, { recursive: true });
fs.writeFileSync(configPath(tmpDir), '{"model_profile":"budget",}', "utf-8");
fs.writeFileSync(path.join(wsDir, "config.json"), '{"mode":"autonomous"}', "utf-8");
const res = configLoader.loadConfigResolved(tmpDir, { workstream: "ws-a" });
assert.equal(res.reason, R.CONFIG_UNPARSEABLE,
"the root config was discarded — that must not report as a clean resolve");
assert.equal(res.degraded, true);
});
// Regression: reason was computed from the root+workstream MERGE, so an empty
// workstream file inheriting a non-empty root reported "resolved" despite
// carrying no settings of its own.
test("an empty workstream file inheriting a non-empty root is configured_empty", () => {
const wsDir = path.join(tmpDir, ".planning", "workstreams", "ws-b");
fs.mkdirSync(wsDir, { recursive: true });
fs.writeFileSync(configPath(tmpDir), '{"model_profile":"quality"}', "utf-8");
fs.writeFileSync(path.join(wsDir, "config.json"), "{}", "utf-8");
const res = configLoader.loadConfigResolved(tmpDir, { workstream: "ws-b" });
assert.equal(res.reason, R.CONFIGURED_EMPTY,
"emptiness is a property of the file read, not of the merged result");
assert.equal(res.degraded, false, "an empty file is not corruption");
});
// Found by the property test below: valid JSON that is not an OBJECT parsed
// "ok", then threw downstream, and the outer catch reported not_configured —
// a present file indistinguishable from an absent one, the exact defect this
// issue closes. Shape is now validated at the read seam (ADR-227).
for (const body of ["0", '"a string"', "[]", "null", "true"]) {
test(`valid JSON that is not an object is unusable, not absent: ${body}`, () => {
fs.writeFileSync(configPath(tmpDir), body, "utf-8");
const res = configLoader.loadConfigResolved(tmpDir);
assert.equal(res.reason, R.CONFIG_UNPARSEABLE,
"a present-but-unusable file must never report as not_configured");
assert.equal(res.degraded, true);
});
}
// Property test (CONTRIBUTING.md: parsers require >=1 fast-check property).
// The classification is total and mutually exclusive: any byte string is
// exactly one of resolved/configured_empty (parses) or config_unparseable.
test("classification is total and never reports a corrupt file as absent", () => {
fc.assert(
fc.property(fc.string(), (body) => {
configLoader._resetRuntimeWarningCacheForTests();
fs.writeFileSync(configPath(tmpDir), body, "utf-8");
const res = configLoader.loadConfigResolved(tmpDir);
let parses = true;
try { const v = JSON.parse(body); parses = v !== null && typeof v === "object" && !Array.isArray(v); }
catch { parses = false; }
// The invariant that matters: a file that is PRESENT is never reported
// as not_configured, whatever its bytes.
assert.notEqual(res.reason, R.NOT_CONFIGURED);
if (!parses) assert.equal(res.reason, R.CONFIG_UNPARSEABLE);
return true;
}),
{ numRuns: 200, seed: 1880 },
);
});
// Contract markers required by scripts/lint-resolution-provenance.cjs:
// configured_empty and not_configured must stay distinguishable.
test("an empty config object is configured_empty, not not_configured", () => {
fs.writeFileSync(configPath(tmpDir), "{}", "utf-8");
const res = configLoader.loadConfigResolved(tmpDir);
assert.equal(res.reason, R.CONFIGURED_EMPTY,
"configured_empty and not_configured must be distinguishable (ADR-1411 rule 3)");
assert.equal(res.reason, "configured_empty", "enum value is the wire contract");
assert.equal(res.degraded, false, "an empty file is not corruption");
});
});
// ─── #2997: phase_id_convention survives config resolution ─────────────────
describe('#2997: phase_id_convention is not silently dropped on a clean read', () => {
const { createTempDir } = require('./helpers.cjs');
const cfgPath = (dir) => path.join(dir, '.planning', 'config.json');
test('setting phase_id_convention in config.json survives into the resolved config', () => {
const tmpDir = createTempDir('gsd-2997-');
try {
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(cfgPath(tmpDir), JSON.stringify({ phase_id_convention: 'milestone-prefixed' }), 'utf-8');
const res = loadConfigResolved(tmpDir);
assert.equal(res.degraded, false, 'read must report as non-degraded');
assert.equal(res.config.phase_id_convention, 'milestone-prefixed',
`phase_id_convention must survive resolution; got: ${JSON.stringify(res.config.phase_id_convention)}`);
} finally { cleanup(tmpDir); }
});
test('phase_id_convention set to null round-trips correctly', () => {
const tmpDir = createTempDir('gsd-2997-null-');
try {
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(cfgPath(tmpDir), JSON.stringify({ phase_id_convention: null }), 'utf-8');
const res = loadConfigResolved(tmpDir);
assert.equal(res.config.phase_id_convention, null,
'null phase_id_convention must round-trip as null');
} finally { cleanup(tmpDir); }
});
test('phase_id_convention absent → null in resolved config (no false default)', () => {
const tmpDir = createTempDir('gsd-2997-absent-');
try {
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(cfgPath(tmpDir), JSON.stringify({ commit_docs: true }), 'utf-8');
const res = loadConfigResolved(tmpDir);
assert.equal(res.config.phase_id_convention, null,
'absent phase_id_convention must resolve to null, not undefined');
} finally { cleanup(tmpDir); }
});
});
// ─── #3532 (10b): shadowed global-defaults diagnostic ─────────────────────────
// The keys Branch D's _globalBaseCfg demonstrably honors when NO project config
// exists. Under a project .planning/config.json (Branch A — every real project)
// the global file is never opened, so each of these set globally is silently
// inert. `effort` is deliberately absent: the install-time effort sync
// (readGsdEffectiveEffortConfig) DOES merge the global file, so warning on it
// would be false for the channel users control via effort sync.
const GLOBAL_KEYS_SHADOWED_UNDER_PROJECT = [
'model_profile', 'commit_docs', 'research', 'plan_checker', 'verifier',
'nyquist_validation', 'post_planning_gaps', 'research_before_questions', 'parallelization', 'text_mode',
'resolve_model_ids', 'context_window', 'subagent_timeout', 'model_overrides',
'models', 'granularity', 'granularities', 'planning', 'dynamic_routing',
'fast_mode', 'agent_skills', 'response_language', 'runtime',
'model_profile_overrides', 'model_policy',
];
describe('#3532 shadowed global-defaults warning', () => {
let tmpDir;
let gsdHome;
let stderrLines;
let originalStderrWrite;
let originalGsdHome;
beforeEach(() => {
tmpDir = makeTempProject('gsd-3532-shadow-');
gsdHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3532-home-'));
stderrLines = [];
originalStderrWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk) => { stderrLines.push(String(chunk)); return true; };
originalGsdHome = process.env.GSD_HOME;
process.env.GSD_HOME = gsdHome;
if (_resetRuntimeWarningCacheForTests) _resetRuntimeWarningCacheForTests();
});
afterEach(() => {
process.stderr.write = originalStderrWrite;
if (originalGsdHome === undefined) delete process.env.GSD_HOME;
else process.env.GSD_HOME = originalGsdHome;
if (tmpDir) cleanup(tmpDir);
if (gsdHome) cleanup(gsdHome);
tmpDir = gsdHome = null;
});
function writeGlobalDefaults(obj) {
fs.mkdirSync(path.join(gsdHome, '.gsd'), { recursive: true });
fs.writeFileSync(
path.join(gsdHome, '.gsd', 'defaults.json'),
JSON.stringify(obj, null, 2),
);
}
test('project config + global model keys -> one warning naming both keys', () => {
writeConfig(tmpDir, { model_profile: 'balanced' });
writeGlobalDefaults({ model_overrides: { 'gsd-executor': 'haiku' }, model_profile: 'quality' });
loadConfigResolved(tmpDir);
const warnings = stderrLines.filter(l => l.includes('model_overrides') && l.includes('model_profile'));
assert.equal(warnings.length, 1, `expected exactly one shadowed-keys warning, got: ${stderrLines.join('')}`);
});
test('second loadConfig call does not repeat the warning', () => {
writeConfig(tmpDir, { model_profile: 'balanced' });
writeGlobalDefaults({ model_profile: 'quality' });
loadConfigResolved(tmpDir);
loadConfigResolved(tmpDir);
const warnings = stderrLines.filter(l => l.includes('model_profile') && l.includes('defaults.json'));
assert.ok(warnings.length <= 1, `warning emitted more than once: ${warnings.length}`);
});
test('global effort keys do not warn (honored by the install-time effort sync)', () => {
writeConfig(tmpDir, { model_profile: 'balanced' });
writeGlobalDefaults({ effort: { default: 'low' } });
loadConfigResolved(tmpDir);
assert.equal(stderrLines.filter(l => l.includes('defaults.json')).length, 0,
`effort must not trigger the shadow warning: ${stderrLines.join('')}`);
});
test('absent global defaults never warn', () => {
writeConfig(tmpDir, { model_profile: 'balanced' });
loadConfigResolved(tmpDir);
assert.equal(stderrLines.length, 0, `unexpected warnings: ${stderrLines.join('')}`);
});
test('bare dir without .planning honors global defaults without warning (Branch D)', () => {
const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3532-bare-'));
try {
writeGlobalDefaults({ model_profile: 'quality' });
const resolution = loadConfigResolved(bare);
assert.equal(resolution.source, 'global-defaults');
assert.equal(resolution.config['model_profile'], 'quality');
assert.equal(stderrLines.length, 0, `Branch D must not warn: ${stderrLines.join('')}`);
} finally {
cleanup(bare);
}
});
test('unparseable global defaults skip the shadow warning', () => {
writeConfig(tmpDir, { model_profile: 'balanced' });
fs.mkdirSync(path.join(gsdHome, '.gsd'), { recursive: true });
fs.writeFileSync(path.join(gsdHome, '.gsd', 'defaults.json'), '{not json');
loadConfigResolved(tmpDir);
assert.equal(stderrLines.filter(l => l.includes('shadowed')).length, 0);
});
test('present-but-empty project config still shadows', () => {
writeConfig(tmpDir, {});
writeGlobalDefaults({ model_profile: 'quality' });
loadConfigResolved(tmpDir);
assert.ok(stderrLines.some(l => l.includes('model_profile')),
`empty project config must still warn: ${stderrLines.join('')}`);
});
test('non-resolution global keys do not warn from this check', () => {
writeConfig(tmpDir, { model_profile: 'balanced' });
writeGlobalDefaults({ __gsd_3532_arbitrary__: true });
loadConfigResolved(tmpDir);
assert.equal(stderrLines.filter(l => l.includes('__gsd_3532_arbitrary__') && l.includes('shadowed')).length, 0);
});
// Typed-IR parity canary (CONTRIBUTING: assert the exported dedup Set, not
// stderr prose — #2674 precedent). Every key Branch D honors must register
// as shadowed when set globally under a project config.
for (const key of GLOBAL_KEYS_SHADOWED_UNDER_PROJECT) {
test(`canary: global "${key}" alone warns under a project config`, () => {
writeConfig(tmpDir, { model_profile: 'balanced' });
writeGlobalDefaults({ [key]: true });
loadConfigResolved(tmpDir);
const registered = [...configLoader._warnedShadowedGlobalKeys].some(set => set.split(',').includes(key));
assert.ok(registered, `global "${key}" must register as shadowed`);
});
}
// The nested alias Branch D honors (workflow.post_planning_gaps fallback in
// _globalBaseCfg) is equally shadowed and reports under its dotted name.
test('canary: nested workflow.post_planning_gaps warns under a project config', () => {
writeConfig(tmpDir, { model_profile: 'balanced' });
writeGlobalDefaults({ workflow: { post_planning_gaps: 'extended' } });
loadConfigResolved(tmpDir);
const registered = [...configLoader._warnedShadowedGlobalKeys].some(set => set.split(',').includes('workflow.post_planning_gaps'));
assert.ok(registered, 'nested workflow.post_planning_gaps must register as shadowed');
});
// List parity, both directions: the implementation's exported list (minus
// effort) must equal this file's expected list — a key _globalBaseCfg grows
// without updating GLOBAL_DEFAULTS_RESOLUTION_KEYS goes silently unwarned,
// and a key the export grows without _globalBaseCfg reading makes the
// warning lie.
test('GLOBAL_DEFAULTS_RESOLUTION_KEYS parity with the expected shadow set', () => {
const exported = configLoader.GLOBAL_DEFAULTS_RESOLUTION_KEYS.filter(k => k !== 'effort').sort();
const expected = GLOBAL_KEYS_SHADOWED_UNDER_PROJECT.slice().sort();
assert.deepEqual(exported, expected,
`resolution-key list drifted: exported=${JSON.stringify(exported)} expected=${JSON.stringify(expected)}`);
});
test('_resetRuntimeWarningCacheForTests clears the shadowed-key dedup set', () => {
writeConfig(tmpDir, { model_profile: 'balanced' });
writeGlobalDefaults({ model_profile: 'quality' });
loadConfigResolved(tmpDir);
assert.ok(
configLoader._warnedShadowedGlobalKeys && configLoader._warnedShadowedGlobalKeys.size > 0,
'precondition: a shadowed key must populate the dedup set',
);
_resetRuntimeWarningCacheForTests();
assert.equal(configLoader._warnedShadowedGlobalKeys.size, 0);
});
});
// ─── Regressions — #3760: a non-object section must not be expanded or written ──
//
// The loader is the surface that actually WRITES: a non-empty `normalizations`
// array marks the config dirty and `platformWriteSync` persists it. Before the
// fix a config holding `{"git":"main","branching_strategy":"none"}` came back
// from `normalizeLegacyKeys` as `{"git":{"0":"m","1":"a","2":"i","3":"n",...}}`
// and that object was written to the user's file — the original `"main"` was
// unrecoverable afterwards.
//
// The loader's own multiRepo branches carried a second, quieter form of the
// same defect: `if (!fileData.planning) fileData.planning = {}` treats a
// non-empty STRING as an already-present section, so the next line
// (`fileData.planning['sub_repos'] = detected`) threw a strict-mode TypeError.
// That throw was swallowed by the enclosing catch, which discarded the entire
// configuration and fell back to defaults — the ADR-1411 silent-fallback
// failure, reached from an input the user can trivially write by hand.
describe('regressions — #3760 loader never expands or persists a non-object section', () => {
let tmpDir;
beforeEach(() => {
tmpDir = makeTempProject('gsd-3760-loader-');
_resetRuntimeWarningCacheForTests();
});
afterEach(() => {
if (tmpDir) cleanup(tmpDir);
tmpDir = null;
});
function readRawConfig() {
return fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8');
}
test('loadConfig leaves a string git section untouched on disk', () => {
writeConfig(tmpDir, { git: 'main', branching_strategy: 'none' });
const before = readRawConfig();
let config;
assert.doesNotThrow(() => { config = loadConfig(tmpDir); });
assert.equal(readRawConfig(), before, 'the config file must not be rewritten with an expanded section');
// The loader projects `git.branching_strategy` to a flat top-level key, so the
// legacy value the migration declined to move is still what resolution sees.
assert.equal(config.branching_strategy, 'none', 'the legacy value must still resolve');
});
test('loadConfig leaves a string planning section untouched on disk', () => {
writeConfig(tmpDir, { planning: 'docs', sub_repos: ['a'] });
const before = readRawConfig();
assert.doesNotThrow(() => { loadConfig(tmpDir); });
assert.equal(readRawConfig(), before);
assert.equal(JSON.parse(readRawConfig()).planning, 'docs');
});
test('loadConfig does not discard the config when multiRepo meets a string planning section', () => {
// Pre-fix the loader threw a TypeError assigning sub_repos onto the string,
// and the enclosing catch discarded the user's entire config. It also
// consumed `multiRepo` and wrote the file back with no diagnostic at all.
fs.mkdirSync(path.join(tmpDir, 'sub', '.git'), { recursive: true });
writeConfig(tmpDir, { planning: 'docs', multiRepo: true, model_profile: 'balanced' });
const before = readRawConfig();
let config;
assert.doesNotThrow(() => { config = loadConfig(tmpDir); });
assert.equal(
config.model_profile, 'balanced',
'an unrelated user setting must survive — a swallowed TypeError would have reverted it to the default',
);
assert.equal(
readRawConfig(), before,
'nothing migrated, so the file must be byte-identical — planning intact AND multiRepo still present',
);
});
test('multiRepo still migrates normally when the planning section is usable', () => {
// Negative space: refusing on a bad section must not break the good path.
fs.mkdirSync(path.join(tmpDir, 'sub', '.git'), { recursive: true });
writeConfig(tmpDir, { multiRepo: true });
assert.doesNotThrow(() => { loadConfig(tmpDir); });
assert.deepEqual(JSON.parse(readRawConfig()).planning.sub_repos, ['sub']);
assert.equal(JSON.parse(readRawConfig()).multiRepo, undefined, 'the marker is consumed once honored');
});
test('loadConfigResolved reports a usable resolution and writes no expanded section', () => {
writeConfig(tmpDir, { git: 'main', branching_strategy: 'none' });
const before = readRawConfig();
let resolution;
assert.doesNotThrow(() => { resolution = loadConfigResolved(tmpDir); });
assert.equal(readRawConfig(), before);
assert.equal(resolution.source, 'root', 'a present, parseable config still resolves from the project');
assert.equal(resolution.degraded, false);
});
test('a refused section emits exactly one deduplicated diagnostic, and a repeat emits none', () => {
// The loader is where the out-of-band diagnostic has to live: loadConfig
// returns `.config` alone, so an in-band `skipped` record would be unreachable
// to nearly every caller (ADR-1411: "a reason no caller reads is an
// unreachable field"). Asserted on the typed emission counter, never by
// scraping stderr prose.
const {
_resetUnusableInputWarningsForTests,
_unusableInputEmissionCountForTests,
} = require('../gsd-core/bin/lib/unusable-input.cjs');
function emissionsDuring(fn) {
const before = _unusableInputEmissionCountForTests();
const original = process.stderr.write;
process.stderr.write = () => true;
try { fn(); } finally { process.stderr.write = original; }
return _unusableInputEmissionCountForTests() - before;
}
_resetUnusableInputWarningsForTests();
writeConfig(tmpDir, { git: 'main', branching_strategy: 'none' });
const first = emissionsDuring(() => { loadConfig(tmpDir); });
assert.equal(first, 1, 'the operator must be told once');
const second = emissionsDuring(() => { loadConfig(tmpDir); });
assert.equal(second, 0, 'the ADR-1411 dedup guard must suppress the repeat');
});
test('an already-canonical config is still migrated normally', () => {
// Negative space: the guard must not suppress a legitimate hoist.
writeConfig(tmpDir, { git: { remote: 'origin' }, branching_strategy: 'none' });
const config = loadConfig(tmpDir);
// Assert on the FILE for the section shape (the loader flattens `git.*` into
// top-level keys, so the resolved object has no `git` to inspect) and on the
// resolved value for the projection.
const onDisk = JSON.parse(readRawConfig());
assert.equal(onDisk.git.branching_strategy, 'none', 'a well-formed section must still receive the hoisted key');
assert.equal(onDisk.git.remote, 'origin', 'existing section keys must be preserved');
assert.equal(onDisk.branching_strategy, undefined, 'the stale top-level key is consumed');
assert.equal(config.branching_strategy, 'none', 'the hoisted value still resolves');
});
});
// ─── #3894: workflow.research_before_questions resolves from global defaults ──
describe('#3894 research_before_questions global-defaults forwarding', () => {
test('nested workflow.research_before_questions in ~/.gsd/defaults.json resolves', () => {
const homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3894-home-'));
const origGsdHome = process.env['GSD_HOME'];
try {
const gsdDir = path.join(homeTmp, '.gsd');
fs.mkdirSync(gsdDir, { recursive: true });
// The reporter's exact shape: same nesting as the forwarded
// workflow.post_planning_gaps, one resolves and one did not.
fs.writeFileSync(path.join(gsdDir, 'defaults.json'), JSON.stringify({
workflow: { post_planning_gaps: true, research_before_questions: true },
}), 'utf-8');
process.env['GSD_HOME'] = homeTmp;
const noPlanning = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3894-noplanning-'));
try {
const result = loadConfigResolved(noPlanning);
assert.equal(result.config.post_planning_gaps, true, 'control: the already-forwarded key resolves');
assert.equal(
result.config.research_before_questions, true,
'#3894: same file, same nesting — the quick-path key must resolve too, not just post_planning_gaps'
);
} finally {
cleanup(noPlanning);
}
} finally {
if (origGsdHome === undefined) delete process.env['GSD_HOME'];
else process.env['GSD_HOME'] = origGsdHome;
cleanup(homeTmp);
}
});
test('flat top-level research_before_questions in ~/.gsd/defaults.json also resolves (Branch D alias parity)', () => {
const homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3894b-home-'));
const origGsdHome = process.env['GSD_HOME'];
try {
const gsdDir = path.join(homeTmp, '.gsd');
fs.mkdirSync(gsdDir, { recursive: true });
fs.writeFileSync(path.join(gsdDir, 'defaults.json'), JSON.stringify({ research_before_questions: true }), 'utf-8');
process.env['GSD_HOME'] = homeTmp;
const noPlanning = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3894b-noplanning-'));
try {
const result = loadConfigResolved(noPlanning);
assert.equal(result.config.research_before_questions, true);
} finally {
cleanup(noPlanning);
}
} finally {
if (origGsdHome === undefined) delete process.env['GSD_HOME'];
else process.env['GSD_HOME'] = origGsdHome;
cleanup(homeTmp);
}
});
test('unset resolves to the documented default (false), never undefined', () => {
const noPlanning = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3894c-'));
const homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3894c-home-'));
const origGsdHome = process.env['GSD_HOME'];
try {
process.env['GSD_HOME'] = homeTmp; // no .gsd/defaults.json — builtin defaults
const result = loadConfigResolved(noPlanning);
assert.equal(result.config.research_before_questions, false, 'CANONICAL_CONFIG_DEFAULTS.workflow.research_before_questions is false');
} finally {
if (origGsdHome === undefined) delete process.env['GSD_HOME'];
else process.env['GSD_HOME'] = origGsdHome;
cleanup(homeTmp);
cleanup(noPlanning);
}
});
});