'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('../msd-core/bin/lib/config-loader.cjs'); const { loadConfig, loadConfigResolved, _resetRuntimeWarningCacheForTests, _deepMergeConfig } = configLoader; // ─── helpers ────────────────────────────────────────────────────────────────── function makeTempProject(prefix = 'msd-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, { __msd_unknown_sentinel__: true }); loadConfig(tmpDir); const warnings = stderrLines.filter(l => l.includes('__msd_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, { __msd_dedup_test__: true }); loadConfig(tmpDir); loadConfig(tmpDir); const warnings = stderrLines.filter(l => l.includes('__msd_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: { 'msd-executor': ['WebFetch'] } }); const config = loadConfig(tmpDir); assert.deepEqual(config.agent_tools, { 'msd-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, { __msd_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 ~/.msd/defaults.json readable', () => { const homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-home-test-')); const origMsdHome = process.env['MSD_HOME']; try { const msdDir = path.join(homeTmp, '.msd'); fs.mkdirSync(msdDir, { recursive: true }); fs.writeFileSync(path.join(msdDir, 'defaults.json'), JSON.stringify({ model_profile: 'home-defaults' }), 'utf-8'); process.env['MSD_HOME'] = homeTmp; const noPlanning = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-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 (origMsdHome === undefined) delete process.env['MSD_HOME']; else process.env['MSD_HOME'] = origMsdHome; cleanup(homeTmp); } }); test('source is "builtin-defaults" when no .planning and no global defaults', () => { const noPlanning = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-noplanning2-')); const homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-nohome-')); const origMsdHome = process.env['MSD_HOME']; try { // Point MSD_HOME to a directory with no .msd/defaults.json process.env['MSD_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 (origMsdHome === undefined) delete process.env['MSD_HOME']; else process.env['MSD_HOME'] = origMsdHome; 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.MSD_WORKSTREAM; try { process.env.MSD_WORKSTREAM = workstream; const ambient = loadConfigResolved(tmpDir); assert.equal(ambient.source, 'root'); assert.equal(ambient.degraded, false); } finally { if (original === undefined) delete process.env.MSD_WORKSTREAM; else process.env.MSD_WORKSTREAM = original; } } }); test('Fix 2a: MSD_WORKSTREAM set to nonexistent workstream (dir absent) → source:"root", degraded:true', () => { writeConfig(tmpDir, { model_profile: 'root-value' }); const origWs = process.env['MSD_WORKSTREAM']; try { process.env['MSD_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['MSD_WORKSTREAM']; else process.env['MSD_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('../msd-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 msd-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 MSD_HOME to an empty sandbox so a developer's real // ~/.msd/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, MSD_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('msd-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('msd-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('msd-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 __msd3523_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('__msd3523_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('msd-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('msd-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('msd-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, { MSD_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('msd-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('msd-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('msd-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('msd-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 // (readMsdEffectiveEffortConfig) 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 msdHome; let stderrLines; let originalStderrWrite; let originalMsdHome; beforeEach(() => { tmpDir = makeTempProject('msd-3532-shadow-'); msdHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3532-home-')); stderrLines = []; originalStderrWrite = process.stderr.write.bind(process.stderr); process.stderr.write = (chunk) => { stderrLines.push(String(chunk)); return true; }; originalMsdHome = process.env.MSD_HOME; process.env.MSD_HOME = msdHome; if (_resetRuntimeWarningCacheForTests) _resetRuntimeWarningCacheForTests(); }); afterEach(() => { process.stderr.write = originalStderrWrite; if (originalMsdHome === undefined) delete process.env.MSD_HOME; else process.env.MSD_HOME = originalMsdHome; if (tmpDir) cleanup(tmpDir); if (msdHome) cleanup(msdHome); tmpDir = msdHome = null; }); function writeGlobalDefaults(obj) { fs.mkdirSync(path.join(msdHome, '.msd'), { recursive: true }); fs.writeFileSync( path.join(msdHome, '.msd', '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: { 'msd-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(), 'msd-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(msdHome, '.msd'), { recursive: true }); fs.writeFileSync(path.join(msdHome, '.msd', '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({ __msd_3532_arbitrary__: true }); loadConfigResolved(tmpDir); assert.equal(stderrLines.filter(l => l.includes('__msd_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('msd-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('../msd-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 ~/.msd/defaults.json resolves', () => { const homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3894-home-')); const origMsdHome = process.env['MSD_HOME']; try { const msdDir = path.join(homeTmp, '.msd'); fs.mkdirSync(msdDir, { 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(msdDir, 'defaults.json'), JSON.stringify({ workflow: { post_planning_gaps: true, research_before_questions: true }, }), 'utf-8'); process.env['MSD_HOME'] = homeTmp; const noPlanning = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-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 (origMsdHome === undefined) delete process.env['MSD_HOME']; else process.env['MSD_HOME'] = origMsdHome; cleanup(homeTmp); } }); test('flat top-level research_before_questions in ~/.msd/defaults.json also resolves (Branch D alias parity)', () => { const homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3894b-home-')); const origMsdHome = process.env['MSD_HOME']; try { const msdDir = path.join(homeTmp, '.msd'); fs.mkdirSync(msdDir, { recursive: true }); fs.writeFileSync(path.join(msdDir, 'defaults.json'), JSON.stringify({ research_before_questions: true }), 'utf-8'); process.env['MSD_HOME'] = homeTmp; const noPlanning = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3894b-noplanning-')); try { const result = loadConfigResolved(noPlanning); assert.equal(result.config.research_before_questions, true); } finally { cleanup(noPlanning); } } finally { if (origMsdHome === undefined) delete process.env['MSD_HOME']; else process.env['MSD_HOME'] = origMsdHome; cleanup(homeTmp); } }); test('unset resolves to the documented default (false), never undefined', () => { const noPlanning = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3894c-')); const homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3894c-home-')); const origMsdHome = process.env['MSD_HOME']; try { process.env['MSD_HOME'] = homeTmp; // no .msd/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 (origMsdHome === undefined) delete process.env['MSD_HOME']; else process.env['MSD_HOME'] = origMsdHome; cleanup(homeTmp); cleanup(noPlanning); } }); }); // ── #4717 — an empty config.runtime is filled from MSD_RUNTIME / the marker ── describe('loadConfigResolved — runtime identity fill (#4717)', () => { const slash = require('../msd-core/bin/lib/runtime-slash.cjs'); const fsx = require('node:fs'); const pathx = require('node:path'); const { createTempDir: mkTmp4717, cleanup: cleanup4717 } = require('./helpers.cjs'); let tmpCodexHome; let originalCodexHome; let originalMsdRuntime; let originalMsdHome; let originalHome; let originalUserProfile; beforeEach(() => { originalCodexHome = process.env.CODEX_HOME; originalMsdRuntime = process.env.MSD_RUNTIME; originalMsdHome = process.env.MSD_HOME; originalHome = process.env.HOME; originalUserProfile = process.env.USERPROFILE; delete process.env.MSD_RUNTIME; tmpCodexHome = mkTmp4717('msd-4717-'); process.env.CODEX_HOME = tmpCodexHome; // Isolate the SHARED defaults file too: loadConfigResolved's global-defaults // branch reads ~/.msd/defaults.json from MSD_HOME || homedir, and a real // machine's stamped defaults would bleed into these rows (#4717 review). process.env.MSD_HOME = tmpCodexHome; process.env.HOME = tmpCodexHome; process.env.USERPROFILE = tmpCodexHome; slash._setInstallRuntimeMarkerForTests('codex'); }); afterEach(() => { slash._resetInstallRuntimeMarkerCacheForTests(); if (originalCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = originalCodexHome; if (originalMsdRuntime === undefined) delete process.env.MSD_RUNTIME; else process.env.MSD_RUNTIME = originalMsdRuntime; if (originalMsdHome === undefined) delete process.env.MSD_HOME; else process.env.MSD_HOME = originalMsdHome; if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; if (originalUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = originalUserProfile; cleanup4717(tmpCodexHome); }); test('an empty config.runtime is filled from the install marker (#4717)', (t) => { const projDir = mkTmp4717('msd-4717-proj-'); t.after(() => cleanup4717(projDir)); const resolved = loadConfigResolved(projDir, { persist: false }); assert.equal(resolved.config.runtime, 'codex', 'the marker-owned runtime fills an empty config.runtime (copy-on-write)'); }); test('MSD_RUNTIME outranks the marker in the identity fill (#4717)', (t) => { const projDir = mkTmp4717('msd-4717-proj-'); t.after(() => cleanup4717(projDir)); process.env.MSD_RUNTIME = 'opencode'; const resolved = loadConfigResolved(projDir, { persist: false }); assert.equal(resolved.config.runtime, 'opencode'); }); // Branch D (the shared-defaults path) fires only when the project dir has NO // .planning/ at all — these rows use bare dirs for exactly that (#4717). function bareProjDir(t, prefix) { const dir = mkTmp4717(prefix); t.after(() => cleanup4717(dir)); return dir; } test('#4717 stamped-defaults leg: a shared defaults runtime does not leak past THIS install\'s marker', (t) => { // The issue's second failure shape: the first non-Claude install stamped // `runtime` into the SHARED ~/.msd/defaults.json; a Claude install on the // same machine must not inherit that identity. Branch D forwards the // stamped value; the fill corrects it to the marker's own. fsx.mkdirSync(pathx.join(tmpCodexHome, '.msd'), { recursive: true }); fsx.writeFileSync( pathx.join(tmpCodexHome, '.msd', 'defaults.json'), JSON.stringify({ runtime: 'codex' }), ); slash._setInstallRuntimeMarkerForTests('claude'); const projDir = bareProjDir(t, 'msd-4717-proj-stamped-'); const resolved = loadConfigResolved(projDir); assert.equal(resolved.source, 'global-defaults', 'fixture: the shared-defaults branch must fire'); assert.equal( resolved.config.runtime, 'claude', 'the marker-owned identity must correct the machine-wide stamp', ); }); test('#4717 stamped-defaults leg: with no marker of its own, the stamped value still stands (status quo preserved)', (t) => { fsx.mkdirSync(pathx.join(tmpCodexHome, '.msd'), { recursive: true }); fsx.writeFileSync( pathx.join(tmpCodexHome, '.msd', 'defaults.json'), JSON.stringify({ runtime: 'codex' }), ); slash._setInstallRuntimeMarkerForTests(null); const projDir = bareProjDir(t, 'msd-4717-proj-stamped-nomarker-'); const resolved = loadConfigResolved(projDir); assert.equal(resolved.config.runtime, 'codex', 'no own identity — the stamped value is all we know'); }); test('#4717 fail-safe: a garbage marker does not fill the runtime (#4717 review)', (_t) => { slash._setInstallRuntimeMarkerForTests(' not-a-runtime '); const projDir = bareProjDir(_t, 'msd-4717-proj-garbage-'); const resolved = loadConfigResolved(projDir); assert.equal( resolved.config.runtime || null, null, 'an unrecognizable marker value must fail safe to no identity', ); }); test('an explicit config.runtime is preserved — the fill never overrides it (#4717)', (t) => { const projDir = mkTmp4717('msd-4717-proj-'); t.after(() => cleanup4717(projDir)); fsx.mkdirSync(pathx.join(projDir, '.planning'), { recursive: true }); fsx.writeFileSync( pathx.join(projDir, '.planning', 'config.json'), JSON.stringify({ runtime: 'claude' }), ); const resolved = loadConfigResolved(projDir, { persist: false }); assert.equal(resolved.config.runtime, 'claude', 'an explicit project runtime is never overwritten by the marker fill'); }); test('copy-on-write: the shared builtin-defaults object is never mutated (#4717)', (t) => { const projDir = mkTmp4717('msd-4717-proj-'); t.after(() => cleanup4717(projDir)); const resolved = loadConfigResolved(projDir, { persist: false }); assert.equal(resolved.config.runtime, 'codex'); const again = loadConfigResolved(projDir, { persist: false }); assert.equal(again.config.runtime, 'codex'); }); });