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:
310
sdk/src/configuration/index.test.ts
Normal file
310
sdk/src/configuration/index.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
310
sdk/src/configuration/index.ts
Normal file
310
sdk/src/configuration/index.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user