feat(3536): Configuration Module via shared manifests + generator (Phase 2 of #3524)

Phase 2 of the CJS↔SDK hard-seam migration (parent #3524).
Eliminates the structural drift surface that produced bug class

After this phase, neither bin/lib/ nor sdk/src/ defines
CONFIG_DEFAULTS, VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, or the
four legacy-key normalizations inline. All come from one canonical
source: the Configuration Module (sdk/src/configuration/index.ts)
+ two JSON manifests (sdk/shared/config-{defaults,schema}.manifest.json).
The CJS mirror is generator-emitted (get-shit-done/bin/lib/configuration.generated.cjs)
with a CI freshness check (sdk/scripts/check-configuration-fresh.mjs).

- sdk/shared/config-defaults.manifest.json — canonical nested defaults,
  union of CJS + SDK keys (includes security_*, post_planning_gaps,
  agent_skills, mode, every git/workflow/hooks sub-section).
- sdk/shared/config-schema.manifest.json — VALID_CONFIG_KEYS array,
  RUNTIME_STATE_KEYS array, DYNAMIC_KEY_PATTERNS array with source
  strings (regex reconstructed at runtime).
- sdk/src/configuration/index.ts — source of truth. Exports
  loadConfig (pure read), normalizeLegacyKeys (pure, idempotent,
  returns Normalization[]), mergeDefaults (deep-merge), migrateOnDisk
  (explicit opt-in disk writeback), plus CONFIG_DEFAULTS,
  VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS.
- sdk/src/configuration/index.test.ts — 29 vitest pinning tests.
- sdk/scripts/gen-configuration.mjs — generator (Function.prototype.toString()
  inspection of compiled SDK dist, plus brace-balanced text scan for
  internal helpers, matching the Phase 1 pattern).
- sdk/scripts/check-configuration-fresh.mjs — CI freshness gate.
- tests/configuration-generator.test.cjs — 27 parity assertions
  (CJS-generated == SDK source).
- tests/configuration-migrate-config.test.cjs — 3 cases for the new
  gsd-tools migrate-config subcommand.

- bin/lib/core.cjs: CONFIG_DEFAULTS literal now sources values from
  CANONICAL_CONFIG_DEFAULTS (the manifest), with a thin flat
  projection at the load boundary to preserve the existing
  flat-shape return contract for the ~21 CJS test files and 100+
  consumers. All four legacy-key migration blocks (branching_strategy,
  sub_repos, multiRepo, depth — historically lines 351-358, 388-397,
  401-408, 416-423) collapse to a single normalizeLegacyKeys call
  in each code path. The inline platformWriteSync writeback stays
  for now to preserve sync loadConfig semantics; the new async
  migrateOnDisk is reachable via gsd-tools migrate-config.
- bin/lib/config-schema.cjs: 135 → 31 lines. Re-exports from the
  generated Module.
- bin/lib/config.cjs: adds cmdMigrateConfig handler (calls
  migrateOnDisk on the explicit user-driven path).
- bin/gsd-tools.cjs: wires migrate-config into command dispatch.

- sdk/src/config.ts: re-exports CONFIG_DEFAULTS and mergeDefaults
  from the Module. loadConfig now calls normalizeLegacyKeys before
  mergeDefaults (replaces the inline branching_strategy graft).
- sdk/src/query/config-schema.ts: 160 → 36 lines. Re-exports from
  the Module.

- tests/config-schema-sdk-parity.test.cjs: refactored from
  "CJS Set equals SDK Set" (trivially true post-migration) to
  "both sides source from the manifest" — structural plus runtime
  invariant.
- Four other tests that text-grepped source files for valid keys
  (plan-review-convergence, bug-3212, bug-2492, feat-3210) are
  updated to use runtime VALID_CONFIG_KEYS.has() or manifest JSON
  lookups.

- CONTEXT.md: new Configuration Module entry with full Interface
  contract.
- Root package.json: check:configuration-fresh proxy script.
- sdk/package.json: gen:configuration + check:configuration-fresh.
- .githooks/pre-commit: configuration drift block.
- .github/workflows/test.yml: configuration drift step after the
  alias drift check.

- 9201 CJS tests pass (baseline pre-cycle: 9195; +6 net new tests
  across migrate-config + parity refactor)
- 1872 SDK vitest tests pass
- 29 Configuration Module vitest fixtures
- 27 CJS/SDK parity fixtures
- Net diff: +388 / −519 = 131-line reduction across the seven cycles,
  despite adding the new Module, manifests, generator, freshness
  check, and two new test files.

1. SDK CONFIG_DEFAULTS now includes manifest-canonical keys
   (resolve_model_ids: false, context_window: 200000, phase_naming,
   claude_md_path, git.create_tag, workflow.security_*,
   workflow.code_review_*, planning.*, hooks.workflow_guard, ship.*).
   Consumers accessing via [key: string]: unknown index get
   the manifest default instead of undefined.
2. SDK mergeDefaults is now proper recursive deep-merge instead of
   spread-per-section. Overlay { workflow: { research: false } }
   now preserves sibling workflow keys; previously it replaced
   the entire workflow section with only research + the section's
   defaults. Semantically identical for the common case;
   strictly better for partial nested overrides.
3. New gsd-tools migrate-config CLI subcommand for the explicit,
   opt-in on-disk migration path.

Closes #3536.
This commit is contained in:
Tom Boucher
2026-05-14 23:50:15 -04:00
parent a7f0af2ce9
commit 2de2d185fa
28 changed files with 2166 additions and 522 deletions

View File

@@ -0,0 +1,310 @@
/**
* Pinning tests for the Configuration Module (ADR-3524 §6).
*
* These tests pin the public interface contract. They are RED until
* sdk/src/configuration/index.ts is created (Cycle 2).
*
* Test precedent: sdk/src/config.test.ts (vitest + fs fixtures).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
loadConfig,
normalizeLegacyKeys,
mergeDefaults,
migrateOnDisk,
CONFIG_DEFAULTS,
} from './index.js';
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeTmpProject(): string {
const dir = mkdtempSync(join(tmpdir(), 'gsd-cfg-test-'));
mkdirSync(join(dir, '.planning'), { recursive: true });
return dir;
}
function writeConfig(dir: string, data: unknown): void {
writeFileSync(join(dir, '.planning', 'config.json'), JSON.stringify(data, null, 2));
}
function readConfigRaw(dir: string): string {
return readFileSync(join(dir, '.planning', 'config.json'), 'utf-8');
}
function cleanupDir(dir: string): void {
rmSync(dir, { recursive: true, force: true });
}
// ─── loadConfig ──────────────────────────────────────────────────────────────
describe('loadConfig', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = makeTmpProject();
});
afterEach(() => {
cleanupDir(tmpDir);
});
it('returns CONFIG_DEFAULTS when config.json is missing', async () => {
const config = await loadConfig(tmpDir);
expect(config).toEqual(CONFIG_DEFAULTS);
});
it('returns CONFIG_DEFAULTS when config.json is empty {}', async () => {
writeConfig(tmpDir, {});
const config = await loadConfig(tmpDir);
expect(config).toEqual(CONFIG_DEFAULTS);
});
it('returns nested git.branching_strategy when already nested', async () => {
writeConfig(tmpDir, { git: { branching_strategy: 'phase' } });
const config = await loadConfig(tmpDir);
expect(config.git.branching_strategy).toBe('phase');
});
it('normalizes legacy top-level branching_strategy to git.branching_strategy', async () => {
writeConfig(tmpDir, { branching_strategy: 'phase' });
const config = await loadConfig(tmpDir);
expect(config.git.branching_strategy).toBe('phase');
});
it('does NOT write disk when normalizing legacy branching_strategy', async () => {
writeConfig(tmpDir, { branching_strategy: 'phase' });
const before = readConfigRaw(tmpDir);
await loadConfig(tmpDir);
const after = readConfigRaw(tmpDir);
expect(after).toBe(before);
});
it('normalizes legacy top-level sub_repos to planning.sub_repos', async () => {
writeConfig(tmpDir, { sub_repos: ['app1', 'app2'] });
const config = await loadConfig(tmpDir);
expect((config.planning as Record<string, unknown>)?.sub_repos).toEqual(['app1', 'app2']);
});
it('handles legacy depth: comprehensive → planning.granularity: fine', async () => {
writeConfig(tmpDir, { depth: 'comprehensive' });
const config = await loadConfig(tmpDir);
// depth is a legacy key that maps to granularity
const granularity = (config as Record<string, unknown>).granularity
?? (config.planning as Record<string, unknown> | undefined)?.granularity;
expect(granularity).toBe('fine');
});
it('handles legacy depth: quick → coarse', async () => {
writeConfig(tmpDir, { depth: 'quick' });
const config = await loadConfig(tmpDir);
const granularity = (config as Record<string, unknown>).granularity
?? (config.planning as Record<string, unknown> | undefined)?.granularity;
expect(granularity).toBe('coarse');
});
it('handles legacy depth: standard → standard', async () => {
writeConfig(tmpDir, { depth: 'standard' });
const config = await loadConfig(tmpDir);
const granularity = (config as Record<string, unknown>).granularity
?? (config.planning as Record<string, unknown> | undefined)?.granularity;
expect(granularity).toBe('standard');
});
it('throws with informative error on malformed JSON', async () => {
writeFileSync(join(tmpDir, '.planning', 'config.json'), '{bad json');
await expect(loadConfig(tmpDir)).rejects.toThrow(/parse|invalid|json/i);
});
it('does not throw when .planning/config.json is missing — returns defaults', async () => {
const dir = mkdtempSync(join(tmpdir(), 'gsd-cfg-noplan-'));
// intentionally no .planning dir
try {
const config = await loadConfig(dir);
expect(config).toEqual(CONFIG_DEFAULTS);
} finally {
cleanupDir(dir);
}
});
});
// ─── normalizeLegacyKeys ─────────────────────────────────────────────────────
describe('normalizeLegacyKeys', () => {
it('migrates top-level branching_strategy to git.branching_strategy', () => {
const input = { branching_strategy: 'phase' };
const { parsed, normalizations } = normalizeLegacyKeys(input);
expect((parsed as Record<string, unknown>).branching_strategy).toBeUndefined();
expect((parsed as Record<string, Record<string, unknown>>).git?.branching_strategy).toBe('phase');
expect(normalizations).toHaveLength(1);
expect(normalizations[0]).toMatchObject({ from: 'branching_strategy', to: 'git.branching_strategy', value: 'phase' });
});
it('migrates top-level sub_repos to planning.sub_repos', () => {
const input = { sub_repos: ['app1', 'app2'] };
const { parsed, normalizations } = normalizeLegacyKeys(input);
expect((parsed as Record<string, unknown>).sub_repos).toBeUndefined();
expect((parsed as Record<string, Record<string, unknown>>).planning?.sub_repos).toEqual(['app1', 'app2']);
expect(normalizations).toHaveLength(1);
expect(normalizations[0]).toMatchObject({ from: 'sub_repos', to: 'planning.sub_repos', value: ['app1', 'app2'] });
});
it('migrates multiRepo: true to planning.sub_repos marker', () => {
const input = { multiRepo: true };
const { parsed, normalizations } = normalizeLegacyKeys(input);
expect((parsed as Record<string, unknown>).multiRepo).toBeUndefined();
expect(normalizations).toHaveLength(1);
expect(normalizations[0]).toMatchObject({ from: 'multiRepo', to: 'planning.sub_repos', requiresFilesystem: true });
});
it('migrates top-level depth to granularity (comprehensive → fine)', () => {
const input = { depth: 'comprehensive' };
const { parsed, normalizations } = normalizeLegacyKeys(input);
expect((parsed as Record<string, unknown>).depth).toBeUndefined();
expect(normalizations).toHaveLength(1);
expect(normalizations[0].from).toBe('depth');
// The value in parsed is the mapped granularity value
const gv = (parsed as Record<string, unknown>).granularity
?? (parsed as Record<string, Record<string, unknown>>).planning?.granularity;
expect(gv).toBe('fine');
});
it('migrates top-level depth: quick → coarse', () => {
const input = { depth: 'quick' };
const { parsed, normalizations } = normalizeLegacyKeys(input);
const gv = (parsed as Record<string, unknown>).granularity
?? (parsed as Record<string, Record<string, unknown>>).planning?.granularity;
expect(gv).toBe('coarse');
expect(normalizations[0].from).toBe('depth');
});
it('migrates all four legacy keys in one pass', () => {
const input = {
branching_strategy: 'phase',
sub_repos: ['app1'],
multiRepo: true,
depth: 'comprehensive',
};
const { parsed, normalizations } = normalizeLegacyKeys(input);
expect(normalizations).toHaveLength(4);
// branching_strategy → git.branching_strategy
expect((parsed as Record<string, Record<string, unknown>>).git?.branching_strategy).toBe('phase');
});
it('returns empty normalizations for already-normalized input', () => {
const input = { git: { branching_strategy: 'phase' }, planning: { sub_repos: ['app1'] } };
const { parsed, normalizations } = normalizeLegacyKeys(input);
expect(normalizations).toHaveLength(0);
expect(parsed).toEqual(input);
});
it('is idempotent — running twice produces same result with empty normalizations second time', () => {
const input = { branching_strategy: 'phase' };
const first = normalizeLegacyKeys(input);
const second = normalizeLegacyKeys(first.parsed as Record<string, unknown>);
expect(second.normalizations).toHaveLength(0);
expect(second.parsed).toEqual(first.parsed);
});
it('preserves canonical git.branching_strategy when both top-level and nested exist', () => {
const input = { branching_strategy: 'milestone', git: { branching_strategy: 'phase' } };
const { parsed } = normalizeLegacyKeys(input);
// canonical nested wins
expect((parsed as Record<string, Record<string, unknown>>).git?.branching_strategy).toBe('phase');
});
});
// ─── mergeDefaults ───────────────────────────────────────────────────────────
describe('mergeDefaults', () => {
it('returns full CONFIG_DEFAULTS for empty input', () => {
const result = mergeDefaults({});
expect(result).toEqual(CONFIG_DEFAULTS);
});
it('merges partial nested input without losing sibling keys', () => {
const partial = { git: { base_branch: 'main' } };
const result = mergeDefaults(partial);
// base_branch from input
expect((result.git as Record<string, unknown>).base_branch).toBe('main');
// sibling from defaults
expect(result.git.branching_strategy).toBe(CONFIG_DEFAULTS.git.branching_strategy);
expect(result.git.phase_branch_template).toBe(CONFIG_DEFAULTS.git.phase_branch_template);
});
it('preserves boolean false values (not overridden by truthy defaults)', () => {
// workflow.research defaults to true; setting false should survive merge
const partial = { workflow: { research: false } };
const result = mergeDefaults(partial);
expect(result.workflow.research).toBe(false);
});
it('preserves explicit null values', () => {
const partial = { project_code: null };
const result = mergeDefaults(partial);
expect(result.project_code).toBeNull();
});
it('user top-level keys win over defaults', () => {
const partial = { model_profile: 'quality' };
const result = mergeDefaults(partial);
expect(result.model_profile).toBe('quality');
});
});
// ─── migrateOnDisk ───────────────────────────────────────────────────────────
describe('migrateOnDisk', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = makeTmpProject();
});
afterEach(() => {
cleanupDir(tmpDir);
});
it('returns migrated:false, wrote:null for already-normalized config', async () => {
writeConfig(tmpDir, { git: { branching_strategy: 'phase' } });
const report = await migrateOnDisk(tmpDir);
expect(report.migrated).toBe(false);
expect(report.wrote).toBeNull();
expect(report.normalizations).toHaveLength(0);
});
it('returns migrated:true, writes disk when legacy key present', async () => {
writeConfig(tmpDir, { branching_strategy: 'phase' });
const report = await migrateOnDisk(tmpDir);
expect(report.migrated).toBe(true);
expect(report.wrote).not.toBeNull();
expect(report.normalizations.length).toBeGreaterThan(0);
// Verify disk was updated
const onDisk = JSON.parse(readConfigRaw(tmpDir));
expect(onDisk.branching_strategy).toBeUndefined();
expect(onDisk.git?.branching_strategy).toBe('phase');
});
it('returns report shape: { migrated, normalizations, wrote }', async () => {
writeConfig(tmpDir, { branching_strategy: 'milestone' });
const report = await migrateOnDisk(tmpDir);
expect(report).toHaveProperty('migrated');
expect(report).toHaveProperty('normalizations');
expect(report).toHaveProperty('wrote');
});
it('is a no-op when .planning/config.json is missing', async () => {
const dir = mkdtempSync(join(tmpdir(), 'gsd-cfg-nomig-'));
try {
const report = await migrateOnDisk(dir);
expect(report.migrated).toBe(false);
expect(report.wrote).toBeNull();
} finally {
cleanupDir(dir);
}
});
});

View File

@@ -0,0 +1,310 @@
/**
* Configuration Module — single source of truth for config loading,
* legacy-key normalization, defaults merge, and explicit on-disk migration.
*
* Source of truth for both the SDK and (via generator) the CJS side.
* Manifests are read from sdk/shared/*.manifest.json.
*
* Public API:
* loadConfig(cwd, options?) → MergedConfig — pure read, never writes disk
* normalizeLegacyKeys(parsed) → { parsed, normalizations[] } — pure transform
* mergeDefaults(parsed) → MergedConfig — fills in defaults
* migrateOnDisk(cwd) → MigrationReport — explicit, opt-in disk writeback
*/
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
// ─── Manifest imports ─────────────────────────────────────────────────────────
const DEFAULTS_PATH = new URL('../../shared/config-defaults.manifest.json', import.meta.url);
export const CONFIG_DEFAULTS: Record<string, unknown> = JSON.parse(
readFileSync(fileURLToPath(DEFAULTS_PATH), 'utf-8'),
);
const SCHEMA_PATH = new URL('../../shared/config-schema.manifest.json', import.meta.url);
const _schemaManifest: {
validKeys: string[];
runtimeStateKeys: string[];
dynamicKeyPatterns: Array<{ topLevel: string; source: string; description: string }>;
} = JSON.parse(readFileSync(fileURLToPath(SCHEMA_PATH), 'utf-8'));
export const VALID_CONFIG_KEYS: ReadonlySet<string> = new Set(_schemaManifest.validKeys);
export const RUNTIME_STATE_KEYS: ReadonlySet<string> = new Set(_schemaManifest.runtimeStateKeys);
export interface DynamicKeyPattern {
readonly topLevel: string;
readonly source: string;
readonly description: string;
readonly test: (key: string) => boolean;
}
export const DYNAMIC_KEY_PATTERNS: readonly DynamicKeyPattern[] = _schemaManifest.dynamicKeyPatterns.map(
(p) => ({ ...p, test: (key: string) => new RegExp(p.source).test(key) }),
);
// ─── Types ───────────────────────────────────────────────────────────────────
/** Broad merged config type — consumers narrow as needed. */
export type MergedConfig = Record<string, unknown>;
export interface Normalization {
from: string;
to: string;
value: unknown;
requiresFilesystem?: true;
}
export interface NormalizationResult {
parsed: MergedConfig;
normalizations: Normalization[];
}
export interface MigrationReport {
migrated: boolean;
normalizations: Normalization[];
wrote: string | null;
}
export interface LoadConfigOptions {
/** Optional workstream name — routes to .planning/workstreams/<name>/config.json */
workstream?: string;
/** Optional callback to observe normalizations applied during load */
onNormalizations?: (normalizations: Normalization[]) => void;
}
// ─── Depth → Granularity mapping ─────────────────────────────────────────────
const DEPTH_TO_GRANULARITY: Record<string, string> = {
quick: 'coarse',
standard: 'standard',
comprehensive: 'fine',
};
// ─── Internal helpers ─────────────────────────────────────────────────────────
function planningDir(cwd: string, workstream?: string): string {
if (!workstream) return join(cwd, '.planning');
return join(cwd, '.planning', 'workstreams', workstream);
}
function detectSubRepos(cwd: string): string[] {
const results: string[] = [];
try {
const entries = readdirSync(cwd, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
const gitPath = join(cwd, entry.name, '.git');
try {
if (existsSync(gitPath)) {
results.push(entry.name);
}
} catch { /* ignore */ }
}
} catch { /* ignore */ }
return results.sort();
}
/**
* Deep-merge two plain config objects. overlay wins on key conflict.
* Explicit null in overlay overrides base (null means "unset this key").
* Arrays are replaced, not merged. undefined in overlay falls back to base.
*/
function deepMergeConfig(base: Record<string, unknown>, overlay: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = { ...base };
for (const key of Object.keys(overlay)) {
const ov = overlay[key];
if (ov !== null && ov !== undefined && typeof ov === 'object' && !Array.isArray(ov)) {
const bv = base[key];
if (bv !== null && bv !== undefined && typeof bv === 'object' && !Array.isArray(bv)) {
result[key] = deepMergeConfig(bv as Record<string, unknown>, ov as Record<string, unknown>);
} else {
result[key] = deepMergeConfig({}, ov as Record<string, unknown>);
}
} else {
result[key] = ov;
}
}
return result;
}
// ─── normalizeLegacyKeys ─────────────────────────────────────────────────────
/**
* Pure transform: migrate legacy top-level config keys to their canonical nested locations.
* Returns the normalized parsed object + a list of normalizations applied.
* Idempotent: calling twice returns the same result with empty normalizations second time.
*
* Normalizations applied (in order):
* 1. top-level branching_strategy → git.branching_strategy (canonical wins if both present)
* 2. top-level sub_repos → planning.sub_repos (canonical wins if both present)
* 3. multiRepo: true → planning.sub_repos marker (requiresFilesystem: true)
* 4. top-level depth → granularity (top-level) with mapping quick→coarse/standard→standard/comprehensive→fine
*/
export function normalizeLegacyKeys(parsed: Record<string, unknown>): NormalizationResult {
const result: Record<string, unknown> = { ...parsed };
const normalizations: Normalization[] = [];
// 1. branching_strategy → git.branching_strategy
if (Object.prototype.hasOwnProperty.call(result, 'branching_strategy')) {
const value = result.branching_strategy;
const git = (result.git as Record<string, unknown> | undefined) ?? {};
if (git.branching_strategy === undefined) {
result.git = { ...git, branching_strategy: value };
} else {
// canonical nested wins — just delete the stale top-level
result.git = { ...git };
}
delete result.branching_strategy;
normalizations.push({ from: 'branching_strategy', to: 'git.branching_strategy', value });
}
// 2. top-level sub_repos → planning.sub_repos
if (Object.prototype.hasOwnProperty.call(result, 'sub_repos')) {
const value = result.sub_repos;
const planning = (result.planning as Record<string, unknown> | undefined) ?? {};
if (!planning.sub_repos) {
result.planning = { ...planning, sub_repos: value };
} else {
result.planning = { ...planning };
}
delete result.sub_repos;
normalizations.push({ from: 'sub_repos', to: 'planning.sub_repos', value });
}
// 3. multiRepo: true → marker (filesystem detection deferred to migrateOnDisk / caller)
if (result.multiRepo === true) {
delete result.multiRepo;
normalizations.push({ from: 'multiRepo', to: 'planning.sub_repos', value: true, requiresFilesystem: true });
}
// 4. top-level depth → granularity
if (Object.prototype.hasOwnProperty.call(result, 'depth') && !Object.prototype.hasOwnProperty.call(result, 'granularity')) {
const rawDepth = result.depth as string;
const mapped = DEPTH_TO_GRANULARITY[rawDepth] ?? rawDepth;
result.granularity = mapped;
delete result.depth;
normalizations.push({ from: 'depth', to: 'granularity', value: mapped });
}
return { parsed: result, normalizations };
}
// ─── mergeDefaults ───────────────────────────────────────────────────────────
/**
* Fill in CONFIG_DEFAULTS where the parsed object lacks values.
* Deep-merges per-section (git, workflow, hooks, agent_skills, planning, ship).
* Boolean false and explicit null are preserved — not overridden by truthy defaults.
*/
export function mergeDefaults(parsed: Record<string, unknown>): MergedConfig {
// Start with a deep clone of defaults, then overlay parsed
const defaults = JSON.parse(JSON.stringify(CONFIG_DEFAULTS)) as Record<string, unknown>;
return deepMergeConfig(defaults, parsed);
}
// ─── loadConfig ──────────────────────────────────────────────────────────────
/**
* Load project config from .planning/config.json (workstream-aware).
* Pure read — never writes disk.
*
* Pipeline: parse JSON → normalizeLegacyKeys → mergeDefaults → return.
*
* Missing file → returns CONFIG_DEFAULTS verbatim.
* Empty file → returns CONFIG_DEFAULTS verbatim.
* Malformed JSON → throws with informative error.
*/
export async function loadConfig(cwd: string, options?: LoadConfigOptions): Promise<MergedConfig> {
const configPath = join(planningDir(cwd, options?.workstream), 'config.json');
let raw: string;
try {
raw = readFileSync(configPath, 'utf-8');
} catch {
// File missing — return defaults
return mergeDefaults({});
}
const trimmed = raw.trim();
if (trimmed === '') {
return mergeDefaults({});
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(trimmed) as Record<string, unknown>;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to parse config at ${configPath}: ${msg}`);
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`Config at ${configPath} must be a JSON object`);
}
const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed);
if (options?.onNormalizations && normalizations.length > 0) {
options.onNormalizations(normalizations);
}
return mergeDefaults(normalized);
}
// ─── migrateOnDisk ───────────────────────────────────────────────────────────
/**
* Explicit, opt-in disk writeback.
* Reads raw config, runs normalizeLegacyKeys, writes back only if normalizations are non-empty.
*
* For multiRepo: true entries, also runs filesystem detection to populate planning.sub_repos.
*
* Returns MigrationReport: { migrated, normalizations, wrote }.
*/
export async function migrateOnDisk(cwd: string, workstream?: string): Promise<MigrationReport> {
const configPath = join(planningDir(cwd, workstream), 'config.json');
let raw: string;
try {
raw = readFileSync(configPath, 'utf-8');
} catch {
// File missing — nothing to migrate
return { migrated: false, normalizations: [], wrote: null };
}
const trimmed = raw.trim();
if (trimmed === '') {
return { migrated: false, normalizations: [], wrote: null };
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
// Malformed — can't migrate
return { migrated: false, normalizations: [], wrote: null };
}
const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed);
if (normalizations.length === 0) {
return { migrated: false, normalizations: [], wrote: null };
}
// Resolve multiRepo filesystem detection
const result = { ...normalized };
for (const norm of normalizations) {
if (norm.requiresFilesystem) {
const detected = detectSubRepos(cwd);
if (detected.length > 0) {
const planning = (result.planning as Record<string, unknown> | undefined) ?? {};
result.planning = { ...planning, sub_repos: detected, commit_docs: false };
}
}
}
writeFileSync(configPath, JSON.stringify(result, null, 2));
return { migrated: true, normalizations, wrote: configPath };
}