Merge pull request #3527 from gsd-build/fix/3523-bug-cjs-loadconfig-warns-top-level-branc

fix(3523): stop false 'branching_strategy will be ignored' warning in CJS loadConfig
This commit is contained in:
Tom Boucher
2026-05-14 19:58:08 -04:00
committed by GitHub
3 changed files with 433 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3527
---
**Top-level `branching_strategy` in `.planning/config.json` no longer triggers a false "unknown config key" warning** — `loadConfig` in `core.cjs` actively read the key via its nested fallback (`git.branching_strategy`) but the `KNOWN_TOP_LEVEL` allowlist was built from dot-notation paths via `.split('.')[0]`, turning `'git.branching_strategy'` into `'git'` instead of `'branching_strategy'`. The fix adds a self-healing on-disk migration (mirroring the existing `multiRepo → planning.sub_repos` precedent): on first `loadConfig`, the top-level key is grafted into `git.branching_strategy` and the stale top-level entry is removed. A module-level deduplication Set also prevents the same unknown-key warning from appearing twice when `loadConfig` is invoked multiple times in a single CLI call. A contract test asserts CJS and SDK agree on legacy-shape fixtures. (#3523)

View File

@@ -348,6 +348,14 @@ function loadConfig(cwd, options = {}) {
const raw = platformReadSync(rootConfigPath);
if (raw === null) throw new Error('missing');
rootParsed = JSON.parse(raw);
if (Object.prototype.hasOwnProperty.call(rootParsed, 'branching_strategy')) {
if (!rootParsed.git) rootParsed.git = {};
if (rootParsed.git.branching_strategy === undefined) {
rootParsed.git.branching_strategy = rootParsed.branching_strategy;
}
delete rootParsed.branching_strategy;
try { platformWriteSync(rootConfigPath, JSON.stringify(rootParsed, null, 2)); } catch {}
}
} catch {
// Root config missing or unparseable — workstream config stands alone
}
@@ -399,6 +407,21 @@ function loadConfig(cwd, options = {}) {
configDirty = true;
}
// #3523 — Migrate legacy top-level branching_strategy → git.branching_strategy.
// Canonical location is git.branching_strategy (per config-schema.cjs); writing
// at the top level trips the unknown-key warning even though loadConfig:485 actively
// reads it via the nested fallback. This migration mirrors the multiRepo → sub_repos
// precedent: graft then delete so the warning never fires again on this project.
// The nested value wins if already set (matches SDK mergeDefaults precedence, PR #3116).
if (Object.prototype.hasOwnProperty.call(fileData, 'branching_strategy')) {
if (!fileData.git) fileData.git = {};
if (fileData.git.branching_strategy === undefined) {
fileData.git.branching_strategy = fileData.branching_strategy;
}
delete fileData.branching_strategy;
configDirty = true;
}
// Keep planning.sub_repos in sync with actual filesystem
const currentSubRepos = fileData.planning?.sub_repos || [];
if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) {
@@ -439,13 +462,23 @@ function loadConfig(cwd, options = {}) {
// Internal keys loadConfig reads but config-set doesn't expose
'model_overrides', 'context_window', 'resolve_model_ids', 'claude_md_path',
// Deprecated keys (still accepted for migration, not in config-set)
'depth', 'multiRepo',
// 'branching_strategy' is kept here as a safety net: it is migrated to
// git.branching_strategy above (#3523), but on the first read of a root
// config that feeds into a workstream merge, `parsed` may still surface it.
'depth', 'multiRepo', 'branching_strategy',
]);
const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_TOP_LEVEL.has(k));
if (unknownKeys.length > 0) {
process.stderr.write(
`gsd-tools: warning: unknown config key(s) in .planning/config.json: ${unknownKeys.join(', ')} — these will be ignored\n`
);
// Deduplicate: a single `init phase-op N` invocation calls loadConfig twice
// (once for the sub-command setup, once for git-config resolution). Guard with
// a module-level Set so the same message never fires more than once per process.
const warnKey = unknownKeys.join(',');
if (!_warnedUnknownConfigKeys.has(warnKey)) {
_warnedUnknownConfigKeys.add(warnKey);
process.stderr.write(
`gsd-tools: warning: unknown config key(s) in .planning/config.json: ${unknownKeys.join(', ')} — these will be ignored\n`
);
}
}
// #2517 — Validate runtime/tier values for keys that loadConfig handles but
@@ -585,6 +618,11 @@ function loadConfig(cwd, options = {}) {
// ─── Git utilities ────────────────────────────────────────────────────────────
// Module-level deduplication for unknown-key warnings (#3523).
// A single `init phase-op N` call invokes loadConfig more than once; this Set
// prevents the same warning from being echoed on each invocation.
const _warnedUnknownConfigKeys = new Set();
const _gitIgnoredCache = new Map();
function isGitIgnored(cwd, targetPath) {

View File

@@ -0,0 +1,386 @@
'use strict';
// allow-test-rule: validates runtime CLI stdout/stderr warning behavior, not source grep
/**
* 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, before } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
GEMINI_SESSION_ID: '',
CURSOR_SESSION_ID: '',
WINDSURF_SESSION_ID: '',
TERM_SESSION_ID: '',
WT_SESSION: '',
TMUX_PANE: '',
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
};
/**
* Run gsd-tools and return { stdout, stderr, status }.
* Always captures stderr even when exit code is 0.
*/
function runWithStderr(args, cwd, env = {}) {
const result = spawnSync(process.execPath, [TOOLS_PATH, ...args], {
cwd,
encoding: 'utf-8',
env: { ...process.env, ...TEST_ENV_BASE, ...env },
});
return {
stdout: result.stdout || '',
stderr: result.stderr || '',
status: result.status,
};
}
// ─── 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'], 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.ok(
result.stdout.includes('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);
// 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}`
);
});
});