/** * Configuration Module — legacy-key normalization, defaults merge, and explicit * on-disk migration. Pure normalization primitives consumed by config-loader.cjs * and config-schema.cjs. `loadConfig` was extracted to config-loader.cjs per * ADR-857 phase 2e (#885) and removed from this module per #893. * * ADR-457 build-at-publish: the hand-written bin/lib/configuration.cjs collapsed * to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour * from the prior hand-written .cjs; only types are added. */ import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; // In .cts (CommonJS output) files, `require` is available as a global. const _require: NodeRequire = require; // ─── Manifest requires ─────────────────────────────────────────────────────── function loadConfigurationManifest(fileName: string): Record { const candidates = [ // Installed runtime layout: gsd-core/bin/shared/*.manifest.json join(__dirname, '..', 'shared', fileName), ]; let lastErr: Error | null = null; for (const candidate of candidates) { try { return _require(candidate) as Record; } catch (err) { const e = err as NodeJS.ErrnoException; const isMissingCandidate = e && e.code === 'MODULE_NOT_FOUND' && String(e.message || '').includes(candidate); if (!isMissingCandidate) throw err; lastErr = e; } } throw new Error( `${fileName} not found. Tried:\n${candidates.map((p) => ` ${p}`).join('\n')}\nLast error: ${lastErr?.message}` ); } const CONFIG_DEFAULTS = loadConfigurationManifest('config-defaults.manifest.json'); const SCHEMA_MANIFEST = loadConfigurationManifest('config-schema.manifest.json') as { validKeys: string[]; runtimeStateKeys: string[]; dynamicKeyPatterns: Array<{ source: string; [k: string]: unknown }>; }; const VALID_CONFIG_KEYS = new Set(SCHEMA_MANIFEST.validKeys); const RUNTIME_STATE_KEYS = new Set(SCHEMA_MANIFEST.runtimeStateKeys); interface DynamicKeyPattern { source: string; test: (key: string) => boolean; [k: string]: unknown; } const DYNAMIC_KEY_PATTERNS: DynamicKeyPattern[] = SCHEMA_MANIFEST.dynamicKeyPatterns.map((p) => { const pattern = new RegExp(p.source); return { ...p, test: (key: string) => { pattern.lastIndex = 0; return pattern.test(key); }, }; }); // ─── Depth → Granularity mapping ───────────────────────────────────────────── const DEPTH_TO_GRANULARITY: Record = { 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(); } function deepMergeConfig(base: Record, overlay: Record): Record { const result: Record = { ...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, ov as Record); } else { result[key] = deepMergeConfig({}, ov as Record); } } else { result[key] = ov; } } return result; } // ─── Exported types ─────────────────────────────────────────────────────────── interface Normalization { from: string; to: string; value: unknown; requiresFilesystem?: boolean; } interface NormalizeLegacyKeysResult { parsed: Record; normalizations: Normalization[]; } interface MigrateOnDiskResult { migrated: boolean; normalizations: Normalization[]; wrote: string | null; } // ─── Exported functions ─────────────────────────────────────────────────────── function normalizeLegacyKeys(parsed: Record): NormalizeLegacyKeysResult { const result: Record = { ...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; 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; if (planning['sub_repos'] === undefined) { result['planning'] = { ...planning, sub_repos: value }; } else { // canonical nested wins — just drop the stale top-level 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 }; } function mergeDefaults(parsed: Record): Record { // Start with a deep clone of defaults, then overlay parsed const defaults = structuredClone(CONFIG_DEFAULTS); return deepMergeConfig(defaults, parsed); } function migrateOnDisk(cwd: string, workstream?: string): MigrateOnDiskResult { 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: unknown; try { parsed = JSON.parse(trimmed); } catch { // Malformed — can't migrate return { migrated: false, normalizations: [], wrote: null }; } const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed as Record); if (normalizations.length === 0) { return { migrated: false, normalizations: [], wrote: null }; } // Resolve multiRepo filesystem detection const result: Record = { ...normalized }; for (const norm of normalizations) { if (norm.requiresFilesystem) { const detected = detectSubRepos(cwd); if (detected.length > 0) { const planning = (result['planning'] ?? {}) as Record; result['planning'] = { ...planning, sub_repos: detected, commit_docs: false }; } } } try { writeFileSync(configPath, JSON.stringify(result, null, 2)); } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new Error(`Failed to write migrated config at ${configPath}: ${msg}`); } return { migrated: true, normalizations, wrote: configPath }; } export { normalizeLegacyKeys, mergeDefaults, migrateOnDisk, CONFIG_DEFAULTS, VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS, };