From a74e71b049f7c9b106e3be61575e61d557c7403d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 8 Jun 2026 15:36:38 -0400 Subject: [PATCH] refactor(#885): extract loadConfig cluster into config-loader.cts (#886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-857 rollout phase 2e — the largest core.cts extraction. Move the configuration-loading subsystem (loadConfig + _getConfigDefault/ _getNestedConfigDefault/CONFIG_DEFAULTS/_deepMergeConfig, isGitIgnored + _gitIgnoredCache, _warnUnknownProfileOverrides + RUNTIME_OVERRIDE_TIERS + the dedup Sets, _resetRuntimeWarningCacheForTests) out of core.cts into a new leaf module src/config-loader.cts. core.cts re-exports the public surface (loadConfig, isGitIgnored, CONFIG_DEFAULTS, RUNTIME_OVERRIDE_TIERS, _resetRuntimeWarningCacheForTests); 12+ callers unchanged. Cycle-free: config-loader imports only leaves (configuration, config-schema, planning-workspace, shell-command-projection, core-utils, model-catalog). All core-internal helpers loadConfig touches moved with it to avoid a cycle. core keeps CANONICAL_CONFIG_DEFAULTS for its model-resolver functions, which now resolve loadConfig via the binding — this unblocks the final model-resolver extraction (2f). core.cts: 1275 -> 792 lines. Repointed tests/config-field-docs.test.cjs (a docs-parity source check) to read the CONFIG_DEFAULTS literal from its new home (config-loader.cjs). New-CLI-module checklist done (.gitignore, eslint, INVENTORY 95->96 + row, manifest, ARCHITECTURE, CONTEXT.md "Config Loader Module"). Adds tests/config-loader.test.cjs (27 tests: behavioral + shim-identity + adversarial config fixtures). Gates: lint, code-review, security-review (prototype-pollution guard confirmed intact), codex adversarial-review (0 findings; byte-identical move). Mac 4115 pass; clean-build docker: full-suite hit the local mirror's known incremental-tsc non-determinism on an unrelated re-exported symbol (findPhaseInternal, from already-merged 2d), but a clean targeted rebuild of the affected file passed 163/0 — CI's clean full matrix is the authoritative gate. Closes #885 Co-authored-by: Claude Opus 4.8 --- .gitignore | 1 + CONTEXT.md | 3 + docs/ARCHITECTURE.md | 1 + docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 5 +- eslint.config.mjs | 1 + src/config-loader.cts | 556 +++++++++++++++++++++++++++++++ src/core.cts | 527 ++--------------------------- tests/config-field-docs.test.cjs | 9 +- tests/config-loader.test.cjs | 355 ++++++++++++++++++++ 10 files changed, 948 insertions(+), 511 deletions(-) create mode 100644 src/config-loader.cts create mode 100644 tests/config-loader.test.cjs diff --git a/.gitignore b/.gitignore index 247e9bae1..de83f3122 100644 --- a/.gitignore +++ b/.gitignore @@ -130,6 +130,7 @@ build/ /gsd-core/bin/lib/core-utils.cjs /gsd-core/bin/lib/io.cjs /gsd-core/bin/lib/phase-id.cjs +/gsd-core/bin/lib/config-loader.cjs /gsd-core/bin/lib/phase-locator.cjs /gsd-core/bin/lib/roadmap-parser.cjs /gsd-core/bin/lib/drift.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 4c794299e..faada64d6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -121,6 +121,9 @@ Module owning ROADMAP.md parsing: shipped-milestone slicing, current-milestone e ### Core Utilities Module Module owning the shared low-level utility primitives extracted from Core: POSIX path normalization (`toPosixPath`), filesystem scanning (`detectSubRepos`, `readSubdirectories`, `getPhaseFileStats`, `pathExistsInternal`), and small pure helpers (`generateSlugInternal`, `extractOneLinerFromBody`, `filterPlanFiles`, `filterSummaryFiles`, `extractCanonicalPlanId`, `timeAgo`). Depends only on Node built-ins and already-leafed modules (`phase-id` for `comparePhaseNum`, `planning-workspace` for `findContextMdIn`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2c (#877) as the shared leaf that unblocks the phase-locator fs-search extraction (2d); `core.cjs` re-exports the public helpers for back-compat. Source of truth: `gsd-core/bin/lib/core-utils.cjs` (generated from `src/core-utils.cts`). +### Config Loader Module +Module owning project configuration loading: reads `.planning/config.json`, merges built-in defaults (`CONFIG_DEFAULTS`/`CANONICAL_CONFIG_DEFAULTS`), normalizes legacy keys, applies the active-workstream overlay, validates against the config schema, and warns on unknown keys/profile overrides (`loadConfig` plus its `_deepMergeConfig`/`isGitIgnored`/`_warnUnknownProfileOverrides` helpers). Depends only on leaf modules (`configuration`, `config-schema`, `planning-workspace`, `shell-command-projection`, `core-utils`, `model-catalog`) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2e (#885) as the prerequisite for the model-resolver extraction (the resolvers call `loadConfig`); `core.cjs` re-exports `loadConfig` for back-compat. Source of truth: `gsd-core/bin/lib/config-loader.cjs` (generated from `src/config-loader.cts`). + ### Package Identity Module [Planned] Single seam owning GSD's published-package coordinates so a repoint/rename is a one-line change instead of a tree-wide sweep. Source of truth is `package.json`; values are *derived*, not re-typed: `packageName` (`.name` → `@opengsd/get-shit-done-redux`), `binName` (`Object.keys(.bin)[0]` → `get-shit-done-redux`), `repoSlug` (parsed from `.repository.url` → `open-gsd/get-shit-done-redux`), plus derived `changelogRawUrl` and `manualInstallCommand({ scope, runtime })`. Generated `.cjs` per ADR-457 (generated-single-source); shipped under `gsd-core/bin/lib/`. Three consumer worlds: **Node** consumers `require()` it at runtime (worker, `check-latest-version.cjs`, `bin/install.js`); the **bash launcher** snippet receives the literal injected by `scripts/sync-runtime-launcher.cjs` at sync time; **prose/help** literals (`update.md`, installer help) carry a committed copy. A drift-guard lint (`scripts/lint-package-identity-drift.cjs`, sibling to `check:alias-drift`) fails CI on any raw package/repo literal outside `package.json`, the generated module, and the value-checked materialization sites — this is what keeps the seam real (`two adapters`, not one). Replaces the contradictory pair it consolidates: the runtime-broken `require('../package.json').name` in `hooks/gsd-check-update-worker.js` (#378, resolves to `undefined` post-install) and the hardcoded constant in `check-latest-version.cjs` (#2992). _Avoid_: "package name string", "the npm name" (when you mean the seam). See ADR-457 and Installer Module. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ac66e3833..dae299b7f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -342,6 +342,7 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core | Module | Responsibility | | ---------------------- | --------------------------------------------------------------------------------------------------- | +| `config-loader.cjs` | Project config loading — defaults merge, legacy-key migration, workstream overlay, unknown-key/profile-override validation (extracted from `core.cjs`, ADR-857) | | `core-utils.cjs` | Shared low-level utility primitives — POSIX path normalization, sub-repo/subdirectory scanning, phase file stats, slug/one-liner/plan-id helpers, time-ago (extracted from `core.cjs`, ADR-857) | | `core.cjs` | Shared utilities; compatibility re-exports for planning, I/O (`io.cjs`), and phase-id helpers | | `io.cjs` | CLI I/O primitives — output/error emission, JSON-error mode, large-payload temp-file spillover | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 12955f818..b823920a5 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -280,6 +280,7 @@ "command-arg-projection.cjs", "command-routing-hub.cjs", "commands.cjs", + "config-loader.cjs", "config-schema.cjs", "config-types.cjs", "config.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index cc1f9ac94..2e42ab2b0 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (95 shipped) +## CLI Modules (96 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -391,9 +391,10 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `command-arg-projection.cjs` | Typed flag and positional argument projection helpers shared across command-family routers | | `command-routing-hub.cjs` | Pure-result dispatch hub that centralizes mode decision (SDK vs CJS), error taxonomy, and no-throw contract for all command-family routers (#3788) | | `commands.cjs` | Misc CLI commands (slug, timestamp, todos, scaffolding, stats) | +| `config-loader.cjs` | Project config loading — defaults merge, legacy-key migration, workstream overlay, unknown-key/profile-override validation (extracted from `core.cjs`, ADR-857) | | `config-schema.cjs` | Single source of truth for `VALID_CONFIG_KEYS` and dynamic key patterns; imported by both the validator and the config-schema-docs parity test | -| `config.cjs` | `config.json` read/write, section initialization; imports validator from `config-schema.cjs` | | `config-types.cjs` | TypeScript type definitions for the `model_policy` config block — `ModelPolicyConfig`, `TierEntry`, `RuntimeTiers`; compiled from `src/config-types.cts` at publish time (ADR-457) | +| `config.cjs` | `config.json` read/write, section initialization; imports validator from `config-schema.cjs` | | `configuration.cjs` | Configuration Module — canonical config loading, legacy-key normalization, defaults merge, and explicit on-disk migration; source of truth for both SDK and CJS consumers | | `context-utilization.cjs` | Pure classifier for `gsd-health --context` — turns (tokensUsed, contextWindow) into a `{ percent, state }` triage result against the 60%/70% fracture-point thresholds (#2792) | | `core-utils.cjs` | Shared low-level utilities — POSIX path normalization, sub-repo/subdirectory scanning, phase file stats, slug/one-liner/plan-id helpers, time-ago (extracted from `core.cjs`, ADR-857) | diff --git a/eslint.config.mjs b/eslint.config.mjs index c2c2a3cb1..b4842242f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -92,6 +92,7 @@ export default tseslint.config( 'gsd-core/bin/lib/core-utils.cjs', 'gsd-core/bin/lib/io.cjs', 'gsd-core/bin/lib/phase-id.cjs', + 'gsd-core/bin/lib/config-loader.cjs', 'gsd-core/bin/lib/phase-locator.cjs', 'gsd-core/bin/lib/roadmap-parser.cjs', 'gsd-core/bin/lib/drift.cjs', diff --git a/src/config-loader.cts b/src/config-loader.cts new file mode 100644 index 000000000..67982afee --- /dev/null +++ b/src/config-loader.cts @@ -0,0 +1,556 @@ +/** + * Config Loader — Project configuration loading + * + * ADR-857 rollout phase 2e: extracted from core.cts (issue #885). + * Owns project configuration loading: reads `.planning/config.json`, + * merges built-in defaults (`CONFIG_DEFAULTS`/`CANONICAL_CONFIG_DEFAULTS`), + * normalizes legacy keys, applies the active-workstream overlay, validates + * against the config schema, and warns on unknown keys/profile overrides. + * Behaviour is preserved byte-for-behaviour from the prior location; only + * the module boundary moved. core.cjs re-exports `loadConfig` for back-compat. + * + * New imports should pull loadConfig from config-loader.cjs directly. + * + * Dependencies (leaf modules only — no core.cjs): + * - node:fs / node:os / node:path (stdlib) + * - ./configuration.cjs (normalizeLegacyKeys, CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS) + * - ./config-schema.cjs (VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS) + * - ./planning-workspace.cjs (planningDir, planningRoot) + * - ./shell-command-projection.cjs (execGit, platformWriteSync, platformReadSync) + * - ./core-utils.cjs (detectSubRepos) + * - ./model-catalog.cjs (KNOWN_RUNTIMES, KNOWN_PROVIDERS) + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execGit, platformWriteSync, platformReadSync } from './shell-command-projection.cjs'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planningWorkspace = require('./planning-workspace.cjs'); +const { planningDir, planningRoot } = planningWorkspace; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import coreUtilsModule = require('./core-utils.cjs'); +const { detectSubRepos } = coreUtilsModule; +// ─── Configuration Module (generated CJS mirror) ──────────────────────────── +import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS, normalizeLegacyKeys } from './configuration.cjs'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import configSchema = require('./config-schema.cjs'); +const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS } = configSchema; +import { KNOWN_RUNTIMES, KNOWN_PROVIDERS } from './model-catalog.cjs'; + +// ─── File & Config utilities ────────────────────────────────────────────────── + +/** + * Canonical config defaults — flat-key projection for CJS consumers. + * + * Cycle 4: Values are sourced from CANONICAL_CONFIG_DEFAULTS (the nested + * manifest loaded by configuration.generated.cjs). The flat shape is + * preserved here so legacy consumers (config.cjs, verify.cjs, tests that + * regex-parse this source) continue to work without changes. The key names + * and the `const CONFIG_DEFAULTS = {` pattern are intentionally kept. + * + * Mapping notes: + * - workflow.plan_check → plan_checker (CJS flat name; verify.cjs uses this) + * - git.* → flat git keys (branching_strategy, templates) + * - workflow.* → flat names (research, verifier, …) + * - planning.sub_repos → sub_repos + * - planning.commit_docs / search_gitignored → top-level flat keys + */ + +// CANONICAL_CONFIG_DEFAULTS is typed as Record from configuration.cjs; +// we use a typed accessor to avoid repeated casts. +function _getConfigDefault(key: string): unknown { + return (CANONICAL_CONFIG_DEFAULTS)[key]; +} +function _getNestedConfigDefault(section: string, field: string): unknown { + const sec = (CANONICAL_CONFIG_DEFAULTS)[section]; + if (sec && typeof sec === 'object' && !Array.isArray(sec)) { + return (sec as Record)[field]; + } + return undefined; +} + +const CONFIG_DEFAULTS = { + model_profile: _getConfigDefault('model_profile'), + commit_docs: _getConfigDefault('commit_docs'), + search_gitignored: _getConfigDefault('search_gitignored'), + branching_strategy: _getNestedConfigDefault('git', 'branching_strategy'), + phase_branch_template: _getNestedConfigDefault('git', 'phase_branch_template'), + milestone_branch_template: _getNestedConfigDefault('git', 'milestone_branch_template'), + quick_branch_template: _getNestedConfigDefault('git', 'quick_branch_template'), + research: _getNestedConfigDefault('workflow', 'research'), + plan_checker: _getNestedConfigDefault('workflow', 'plan_check'), // flat CJS name maps to workflow.plan_check + verifier: _getNestedConfigDefault('workflow', 'verifier'), + nyquist_validation: _getNestedConfigDefault('workflow', 'nyquist_validation'), + ai_integration_phase: _getNestedConfigDefault('workflow', 'ai_integration_phase'), + parallelization: _getConfigDefault('parallelization'), + brave_search: _getConfigDefault('brave_search'), + firecrawl: _getConfigDefault('firecrawl'), + exa_search: _getConfigDefault('exa_search'), + text_mode: _getNestedConfigDefault('workflow', 'text_mode'), + sub_repos: _getNestedConfigDefault('planning', 'sub_repos'), + resolve_model_ids: _getConfigDefault('resolve_model_ids'), + context_window: _getConfigDefault('context_window'), + phase_naming: _getConfigDefault('phase_naming'), + project_code: _getConfigDefault('project_code'), + subagent_timeout: _getNestedConfigDefault('workflow', 'subagent_timeout'), + security_enforcement: _getNestedConfigDefault('workflow', 'security_enforcement'), + security_asvs_level: _getNestedConfigDefault('workflow', 'security_asvs_level'), + security_block_on: _getNestedConfigDefault('workflow', 'security_block_on'), + post_planning_gaps: _getNestedConfigDefault('workflow', 'post_planning_gaps'), +}; + +/** + * 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. Non-object primitives use overlay value. + * + * Note: `undefined` in overlay is treated as "no value provided" and falls + * back to base (preserves inheritance). Explicit `null` overrides base. + */ +function _deepMergeConfig(base: Record, overlay: Record | null | undefined): Record | null | undefined { + if (overlay === null || overlay === undefined) return overlay; + if (typeof base !== 'object' || typeof overlay !== 'object') return overlay; + const result: Record = { ...base }; + for (const key of Object.keys(overlay)) { + if (overlay[key] !== null && typeof overlay[key] === 'object' && !Array.isArray(overlay[key])) { + result[key] = _deepMergeConfig((base[key] ?? {}) as Record, overlay[key] as Record); + } else { + result[key] = overlay[key]; + } + } + return result; +} + +// 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(); + +// Normalization result shape from configuration.cjs +interface NormalizationEntry { + requiresFilesystem?: boolean; + [key: string]: unknown; +} + +// Typed parsed config shape used internally +interface ParsedConfig { + [key: string]: unknown; + planning?: Record; +} + +// ─── Git utilities ──────────────────────────────────────────────────────────── + +const _gitIgnoredCache = new Map(); + +function isGitIgnored(cwd: string, targetPath: string): boolean { + const key = cwd + '::' + targetPath; + if (_gitIgnoredCache.has(key)) return _gitIgnoredCache.get(key)!; + // --no-index checks .gitignore rules regardless of whether the file is tracked. + const result = execGit(['check-ignore', '-q', '--no-index', '--', targetPath], { cwd }); + const ignored = result.exitCode === 0; + _gitIgnoredCache.set(key, ignored); + return ignored; +} + +// ─── Model alias resolution ─────────────────────────────────────────────────── + +const RUNTIME_OVERRIDE_TIERS = new Set(['opus', 'sonnet', 'haiku']); +const _warnedConfigKeys = new Set(); + +function _warnUnknownProfileOverrides(parsed: Record, configLabel: string): void { + if (!parsed || typeof parsed !== 'object') return; + + const runtime = parsed['runtime']; + if (runtime && typeof runtime === 'string' && !(KNOWN_RUNTIMES).has(runtime)) { + const key = `${configLabel}::runtime::${runtime}`; + if (!_warnedConfigKeys.has(key)) { + _warnedConfigKeys.add(key); + try { + process.stderr.write( + `gsd: warning — config key "runtime" has unknown value "${runtime}". ` + + `Known runtimes: ${[...(KNOWN_RUNTIMES)].sort().join(', ')}. ` + + `Resolution will fall back to safe defaults. (#2517)\n` + ); + } catch { /* stderr might be closed in some test harnesses */ } + } + } + + const overrides = parsed['model_profile_overrides']; + if (overrides && typeof overrides === 'object' && !Array.isArray(overrides)) { + for (const [overrideRuntime, tierMap] of Object.entries(overrides as Record)) { + if (!(KNOWN_RUNTIMES).has(overrideRuntime)) { + const key = `${configLabel}::override-runtime::${overrideRuntime}`; + if (!_warnedConfigKeys.has(key)) { + _warnedConfigKeys.add(key); + try { + process.stderr.write( + `gsd: warning — model_profile_overrides.${overrideRuntime}.* uses ` + + `unknown runtime "${overrideRuntime}". Known runtimes: ` + + `${[...(KNOWN_RUNTIMES)].sort().join(', ')}. (#2517)\n` + ); + } catch { /* ok */ } + } + } + if (!tierMap || typeof tierMap !== 'object') continue; + for (const tierName of Object.keys(tierMap)) { + if (!RUNTIME_OVERRIDE_TIERS.has(tierName)) { + const key = `${configLabel}::override-tier::${overrideRuntime}.${tierName}`; + if (!_warnedConfigKeys.has(key)) { + _warnedConfigKeys.add(key); + try { + process.stderr.write( + `gsd: warning — model_profile_overrides.${overrideRuntime}.${tierName} ` + + `uses unknown tier "${tierName}". Allowed tiers: opus, sonnet, haiku. (#2517)\n` + ); + } catch { /* ok */ } + } + } + } + } + } + + const policy = parsed['model_policy']; + if (policy && typeof policy === 'object' && !Array.isArray(policy)) { + const policyObj = policy as Record; + const provider = policyObj['provider']; + const _POLICY_SENTINEL_PROVIDERS = new Set(['generic', 'custom']); + if (provider && typeof provider === 'string' && + !(KNOWN_PROVIDERS).has(provider) && !_POLICY_SENTINEL_PROVIDERS.has(provider)) { + const pkey = `${configLabel}::model_policy::provider::${provider}`; + if (!_warnedConfigKeys.has(pkey)) { + _warnedConfigKeys.add(pkey); + try { + process.stderr.write( + `gsd: warning — model_policy.provider has unknown value "${provider}". ` + + `Known providers: ${[...(KNOWN_PROVIDERS)].sort().join(', ')}. ` + + `For manual model IDs use provider="custom". (#49)\n` + ); + } catch { /* ok */ } + } + } + + const rtOverrides = policyObj['runtime_tiers']; + if (rtOverrides && typeof rtOverrides === 'object' && !Array.isArray(rtOverrides)) { + for (const [pruntime, tierMap] of Object.entries(rtOverrides as Record)) { + if (!(KNOWN_RUNTIMES).has(pruntime)) { + const key = `${configLabel}::model_policy.runtime_tiers::${pruntime}`; + if (!_warnedConfigKeys.has(key)) { + _warnedConfigKeys.add(key); + try { + process.stderr.write( + `gsd: warning — model_policy.runtime_tiers.${pruntime}.* uses ` + + `unknown runtime "${pruntime}". Known runtimes: ` + + `${[...(KNOWN_RUNTIMES)].sort().join(', ')}. (#49)\n` + ); + } catch { /* ok */ } + } + } + if (!tierMap || typeof tierMap !== 'object') continue; + for (const tierName of Object.keys(tierMap)) { + if (!RUNTIME_OVERRIDE_TIERS.has(tierName)) { + const key = `${configLabel}::model_policy.runtime_tiers::${pruntime}.${tierName}`; + if (!_warnedConfigKeys.has(key)) { + _warnedConfigKeys.add(key); + try { + process.stderr.write( + `gsd: warning — model_policy.runtime_tiers.${pruntime}.${tierName} ` + + `uses unknown tier "${tierName}". Allowed: opus, sonnet, haiku. (#49)\n` + ); + } catch { /* ok */ } + } + } + } + } + } + } +} + +// Internal helper exposed for tests so per-process warning state can be reset +// between cases that intentionally exercise the warning path repeatedly. +function _resetRuntimeWarningCacheForTests(): void { + _warnedConfigKeys.clear(); +} + +function loadConfig(cwd: string, options: Record = {}): Record { + const activeWorkstream = Object.prototype.hasOwnProperty.call(options, 'workstream') + ? options['workstream'] + : (options['workstreamContext'] && Object.prototype.hasOwnProperty.call(options['workstreamContext'], 'ws')) + ? (options['workstreamContext'] as Record)['ws'] + : (process.env['GSD_WORKSTREAM'] || null); + // When GSD_WORKSTREAM is set, load root config first so workstream config + // can inherit from it. This prevents users from duplicating model_overrides, + // workflow.*, etc. across every workstream config (#2714). + const ws = typeof activeWorkstream === 'string' ? activeWorkstream : (activeWorkstream === null ? null : null); + // #315 — per-call lazy memo: all three detection sites inside this loadConfig + // call operate on the same cwd and the subrepo set cannot change mid-call, so + // a single scan is sufficient. The memo is scoped to THIS call (not module-level) + // so separate loadConfig invocations each get a fresh scan. + let cachedSubRepos: string[] | undefined; + const getDetectedSubRepos = (): string[] => { + if (cachedSubRepos === undefined) cachedSubRepos = detectSubRepos(cwd); + // Return a copy: original detectSubRepos returned a fresh array per call, + // so each site must keep an independent array (avoid cross-site aliasing). + return cachedSubRepos.slice(); + }; + let rootParsed: ParsedConfig | null = null; + if (ws) { + const rootConfigPath = path.join(planningRoot(cwd), 'config.json'); + try { + const raw = platformReadSync(rootConfigPath); + if (raw === null) throw new Error('missing'); + rootParsed = JSON.parse(raw) as ParsedConfig; + // Cycle 4: delegate all legacy-key normalization to the Configuration Module. + const { parsed: rootNormalized, normalizations: rootNorms } = normalizeLegacyKeys(rootParsed); + if (rootNorms.length > 0) { + // Resolve filesystem-dependent normalizations (multiRepo → planning.sub_repos) + for (const norm of rootNorms as unknown as NormalizationEntry[]) { + if (norm.requiresFilesystem && !(rootNormalized as ParsedConfig).planning?.['sub_repos']) { + const detected = getDetectedSubRepos(); + if (detected.length > 0) { + if (!(rootNormalized as ParsedConfig).planning) (rootNormalized as ParsedConfig).planning = {}; + (rootNormalized as ParsedConfig).planning!['sub_repos'] = detected; + (rootNormalized as ParsedConfig).planning!['commit_docs'] = false; + } + } + } + rootParsed = rootNormalized; + try { platformWriteSync(rootConfigPath, JSON.stringify(rootParsed, null, 2)); } catch { /* ignore */ } + } else { + rootParsed = rootNormalized; + } + } catch { + // Root config missing or unparseable — workstream config stands alone + } + } + + const configPath = path.join(planningDir(cwd, ws), 'config.json'); + const defaults = CONFIG_DEFAULTS; + + try { + const raw = platformReadSync(configPath); + if (raw === null) throw new Error('missing'); + // `fileData` is the parsed content of the config.json file on disk — used + // for migrations and writes so we never persist merged values back to disk. + const fileData: ParsedConfig = JSON.parse(raw) as ParsedConfig; + + // Cycle 4: Single normalizeLegacyKeys call replaces all four inline migration + // blocks (depth→granularity, multiRepo→planning.sub_repos, sub_repos→planning.sub_repos, + // branching_strategy→git.branching_strategy). The Module is pure (no I/O); disk + // writeback is handled below with the existing platformWriteSync pattern. + let configDirty = false; + { + const { parsed: normalized, normalizations } = normalizeLegacyKeys(fileData); + if (normalizations.length > 0) { + // Merge normalized values back into fileData (mutation-in-place for legacy code below) + Object.keys(fileData).forEach(k => delete (fileData as Record)[k]); + Object.assign(fileData, normalized); + configDirty = true; + // Resolve filesystem-dependent normalizations (multiRepo → planning.sub_repos). + for (const norm of normalizations as unknown as NormalizationEntry[]) { + if (norm.requiresFilesystem && !fileData.planning?.['sub_repos']) { + const detected = getDetectedSubRepos(); + if (detected.length > 0) { + if (!fileData.planning) fileData.planning = {}; + fileData.planning['sub_repos'] = detected; + fileData.planning['commit_docs'] = false; + } + } + } + } + } + + // Keep planning.sub_repos in sync with actual filesystem + const currentSubRepos = (fileData.planning?.['sub_repos'] as string[] | undefined) || []; + if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) { + const detected = getDetectedSubRepos(); + if (detected.length > 0) { + const sorted = [...currentSubRepos].sort(); + if (JSON.stringify(sorted) !== JSON.stringify(detected)) { + if (!fileData.planning) fileData.planning = {}; + fileData.planning['sub_repos'] = detected; + configDirty = true; + } + } + } + + // Persist sub_repos changes (migration or sync) — write only the on-disk + // file contents, never the merged result, to avoid polluting workstream configs. + if (configDirty) { + try { platformWriteSync(configPath, JSON.stringify(fileData, null, 2)); } catch { /* ignore */ } + } + + // Now apply root→workstream inheritance. `parsed` is the effective config + // used for value extraction below; fileData is kept for disk writes only. + const parsed: ParsedConfig = rootParsed + ? (_deepMergeConfig(rootParsed, fileData) as ParsedConfig ?? fileData) + : fileData; + + // Warn about unrecognized top-level keys so users don't silently lose config. + const KNOWN_TOP_LEVEL = new Set([ + // Extract top-level key names from dot-notation paths (e.g., 'workflow.research' → 'workflow') + ...[...VALID_CONFIG_KEYS].map((k: string) => k.split('.')[0]), + // Dynamic-pattern top-level containers (e.g. review, model_profile_overrides) + ...(DYNAMIC_KEY_PATTERNS as unknown as Array<{ topLevel: string }>).map(p => p.topLevel), + // Internal keys loadConfig reads but config-set doesn't expose + 'model_overrides', 'context_window', 'resolve_model_ids', 'claude_md_path', 'effort', 'fast_mode', + // Deprecated keys (still accepted for migration, not in config-set) + 'depth', 'multiRepo', 'branching_strategy', + ]); + const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_TOP_LEVEL.has(k)); + if (unknownKeys.length > 0) { + 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 + _warnUnknownProfileOverrides(parsed, '.planning/config.json'); + + const get = (key: string, nested?: { section: string; field: string }): unknown => { + if (parsed[key] !== undefined) return parsed[key]; + if (nested && parsed[nested.section] && typeof parsed[nested.section] === 'object' && parsed[nested.section] !== null) { + const sec = parsed[nested.section] as Record; + if (sec[nested.field] !== undefined) { + return sec[nested.field]; + } + } + return undefined; + }; + + const parallelization = (() => { + const val = get('parallelization'); + if (typeof val === 'boolean') return val; + if (typeof val === 'object' && val !== null && 'enabled' in (val)) return (val as Record)['enabled']; + return defaults.parallelization; + })(); + + return { + model_profile: get('model_profile') ?? defaults.model_profile, + commit_docs: (() => { + const explicit = get('commit_docs', { section: 'planning', field: 'commit_docs' }); + // If explicitly set in config, respect the user's choice + if (explicit !== undefined) return explicit; + // Auto-detection: when no explicit value and .planning/ is gitignored, + // default to false instead of true + if (isGitIgnored(cwd, '.planning/')) return false; + return defaults.commit_docs; + })(), + search_gitignored: get('search_gitignored', { section: 'planning', field: 'search_gitignored' }) ?? defaults.search_gitignored, + branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy, + phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template, + milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template, + quick_branch_template: get('quick_branch_template', { section: 'git', field: 'quick_branch_template' }) ?? defaults.quick_branch_template, + research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research, + plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker, + verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier, + nyquist_validation: get('nyquist_validation', { section: 'workflow', field: 'nyquist_validation' }) ?? defaults.nyquist_validation, + post_planning_gaps: get('post_planning_gaps', { section: 'workflow', field: 'post_planning_gaps' }) ?? defaults.post_planning_gaps, + parallelization, + brave_search: get('brave_search') ?? defaults.brave_search, + firecrawl: get('firecrawl') ?? defaults.firecrawl, + exa_search: get('exa_search') ?? defaults.exa_search, + tdd_mode: get('tdd_mode', { section: 'workflow', field: 'tdd_mode' }) ?? false, + mvp_mode: get('mvp_mode', { section: 'workflow', field: 'mvp_mode' }) ?? false, + text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode, + auto_advance: get('auto_advance', { section: 'workflow', field: 'auto_advance' }) ?? false, + _auto_chain_active: get('_auto_chain_active', { section: 'workflow', field: '_auto_chain_active' }) ?? false, + mode: get('mode') ?? 'interactive', + sub_repos: get('sub_repos', { section: 'planning', field: 'sub_repos' }) ?? defaults.sub_repos, + resolve_model_ids: get('resolve_model_ids') ?? defaults.resolve_model_ids, + context_window: get('context_window') ?? defaults.context_window, + phase_naming: get('phase_naming') ?? defaults.phase_naming, + project_code: get('project_code') ?? defaults.project_code, + subagent_timeout: get('subagent_timeout', { section: 'workflow', field: 'subagent_timeout' }) ?? defaults.subagent_timeout, + model_overrides: (parsed['model_overrides']) || null, + // #3023 — per-phase-type model map. + models: (parsed['models']) || null, + // #68 — top-level granularity + granularity: parsed['granularity'] !== undefined ? parsed['granularity'] : null, + // #68 — per-phase-type granularity map. + granularities: (parsed['granularities']) || null, + // #68 — planning sub-object + planning: (parsed['planning']) || null, + // #3024 — dynamic routing block. + dynamic_routing: (parsed['dynamic_routing']) || null, + // #2517 — runtime-aware profiles. + runtime: (parsed['runtime']) || null, + model_profile_overrides: (parsed['model_profile_overrides']) || null, + // #49 — provider-neutral model policy presets. + model_policy: (parsed['model_policy']) || null, + // #443 — effort/fast_mode + effort: (parsed['effort']) || null, + fast_mode: (parsed['fast_mode']) || null, + agent_skills: (parsed['agent_skills']) || {}, + agent_skills_security: (parsed['agent_skills_security']) || null, + manager: (parsed['manager']) || {}, + response_language: get('response_language') || null, + claude_md_path: get('claude_md_path') || null, + claude_md_assembly: (parsed['claude_md_assembly']) || null, + }; + } catch { + // Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683) + if (fs.existsSync(planningDir(cwd, ws))) { + if (rootParsed) { + // Workstream has no config.json: re-parse using root config as the sole source. + return loadConfig(cwd, { workstream: null }); + } + return defaults; + } + try { + const home = process.env['GSD_HOME'] || os.homedir(); + const globalDefaultsPath = path.join(home, '.gsd', 'defaults.json'); + const raw = platformReadSync(globalDefaultsPath); + if (raw === null) throw new Error('missing'); + const globalDefaults = JSON.parse(raw) as Record; + return { + ...defaults, + model_profile: (globalDefaults['model_profile']) ?? defaults.model_profile, + commit_docs: (globalDefaults['commit_docs']) ?? defaults.commit_docs, + research: (globalDefaults['research']) ?? defaults.research, + plan_checker: (globalDefaults['plan_checker']) ?? defaults.plan_checker, + verifier: (globalDefaults['verifier']) ?? defaults.verifier, + nyquist_validation: (globalDefaults['nyquist_validation']) ?? defaults.nyquist_validation, + post_planning_gaps: (globalDefaults['post_planning_gaps']) + ?? (globalDefaults['workflow'] as Record | undefined)?.['post_planning_gaps'] + ?? defaults.post_planning_gaps, + parallelization: (globalDefaults['parallelization']) ?? defaults.parallelization, + text_mode: (globalDefaults['text_mode']) ?? defaults.text_mode, + resolve_model_ids: (globalDefaults['resolve_model_ids']) ?? defaults.resolve_model_ids, + context_window: (globalDefaults['context_window']) ?? defaults.context_window, + subagent_timeout: (globalDefaults['subagent_timeout']) ?? defaults.subagent_timeout, + model_overrides: (globalDefaults['model_overrides']) || null, + models: (globalDefaults['models']) || null, + granularity: (globalDefaults['granularity']) !== undefined ? globalDefaults['granularity'] : null, + granularities: (globalDefaults['granularities']) || null, + planning: (globalDefaults['planning']) || null, + dynamic_routing: (globalDefaults['dynamic_routing']) || null, + effort: (globalDefaults['effort']) || null, + fast_mode: (globalDefaults['fast_mode']) || null, + agent_skills: (globalDefaults['agent_skills']) || {}, + response_language: (globalDefaults['response_language']) || null, + }; + } catch { + return defaults; + } + } +} + +export = { + loadConfig, + isGitIgnored, + CONFIG_DEFAULTS, + _getConfigDefault, + _getNestedConfigDefault, + _deepMergeConfig, + _warnedUnknownConfigKeys, + _warnUnknownProfileOverrides, + _resetRuntimeWarningCacheForTests, + _warnedConfigKeys, + _gitIgnoredCache, + RUNTIME_OVERRIDE_TIERS, +}; diff --git a/src/core.cts b/src/core.cts index 018cae0cb..afc725faf 100644 --- a/src/core.cts +++ b/src/core.cts @@ -7,9 +7,8 @@ */ import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; -import { execGit, platformWriteSync, platformReadSync } from './shell-command-projection.cjs'; +import { execGit } from './shell-command-projection.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import ioModule = require('./io.cjs'); const { output, error, ERROR_REASON, setJsonErrorMode, getJsonErrorMode, GSD_TEMP_DIR, reapStaleTempFiles } = ioModule; @@ -63,11 +62,20 @@ const { searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs } = phaseLocat import { findProjectRoot } from './project-root.cjs'; import { getGlobalConfigDir } from './runtime-homes.cjs'; -// ─── Configuration Module (generated CJS mirror) ──────────────────────────── -import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS, normalizeLegacyKeys } from './configuration.cjs'; +// ─── Configuration Module (for CANONICAL_CONFIG_DEFAULTS used by effort/fast_mode resolvers) ─ +import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS } from './configuration.cjs'; + +// ─── Config Loader Module (extracted from core, ADR-857 phase 2e / #885) ───── // eslint-disable-next-line @typescript-eslint/no-require-imports -import configSchema = require('./config-schema.cjs'); -const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS } = configSchema; +import configLoaderModule = require('./config-loader.cjs'); +const { + loadConfig, + isGitIgnored, + CONFIG_DEFAULTS, + _warnUnknownProfileOverrides, + _resetRuntimeWarningCacheForTests, + RUNTIME_OVERRIDE_TIERS, +} = configLoaderModule; // ─── Path helpers ──────────────────────────────────────────────────────────── // toPosixPath and detectSubRepos moved to core-utils.cjs (ADR-857 phase 2c / #877). @@ -76,388 +84,10 @@ const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS } = configSchema; // findProjectRoot is now re-exported from the generated CJS module above. -// ─── File & Config utilities ────────────────────────────────────────────────── - -/** - * Canonical config defaults — flat-key projection for CJS consumers. - * - * Cycle 4: Values are sourced from CANONICAL_CONFIG_DEFAULTS (the nested - * manifest loaded by configuration.generated.cjs). The flat shape is - * preserved here so legacy consumers (config.cjs, verify.cjs, tests that - * regex-parse this source) continue to work without changes. The key names - * and the `const CONFIG_DEFAULTS = {` pattern are intentionally kept. - * - * Mapping notes: - * - workflow.plan_check → plan_checker (CJS flat name; verify.cjs uses this) - * - git.* → flat git keys (branching_strategy, templates) - * - workflow.* → flat names (research, verifier, …) - * - planning.sub_repos → sub_repos - * - planning.commit_docs / search_gitignored → top-level flat keys - */ - -// CANONICAL_CONFIG_DEFAULTS is typed as Record from configuration.cjs; -// we use a typed accessor to avoid repeated casts. -function _getConfigDefault(key: string): unknown { - return (CANONICAL_CONFIG_DEFAULTS)[key]; -} -function _getNestedConfigDefault(section: string, field: string): unknown { - const sec = (CANONICAL_CONFIG_DEFAULTS)[section]; - if (sec && typeof sec === 'object' && !Array.isArray(sec)) { - return (sec as Record)[field]; - } - return undefined; -} - -const CONFIG_DEFAULTS = { - model_profile: _getConfigDefault('model_profile'), - commit_docs: _getConfigDefault('commit_docs'), - search_gitignored: _getConfigDefault('search_gitignored'), - branching_strategy: _getNestedConfigDefault('git', 'branching_strategy'), - phase_branch_template: _getNestedConfigDefault('git', 'phase_branch_template'), - milestone_branch_template: _getNestedConfigDefault('git', 'milestone_branch_template'), - quick_branch_template: _getNestedConfigDefault('git', 'quick_branch_template'), - research: _getNestedConfigDefault('workflow', 'research'), - plan_checker: _getNestedConfigDefault('workflow', 'plan_check'), // flat CJS name maps to workflow.plan_check - verifier: _getNestedConfigDefault('workflow', 'verifier'), - nyquist_validation: _getNestedConfigDefault('workflow', 'nyquist_validation'), - ai_integration_phase: _getNestedConfigDefault('workflow', 'ai_integration_phase'), - parallelization: _getConfigDefault('parallelization'), - brave_search: _getConfigDefault('brave_search'), - firecrawl: _getConfigDefault('firecrawl'), - exa_search: _getConfigDefault('exa_search'), - text_mode: _getNestedConfigDefault('workflow', 'text_mode'), - sub_repos: _getNestedConfigDefault('planning', 'sub_repos'), - resolve_model_ids: _getConfigDefault('resolve_model_ids'), - context_window: _getConfigDefault('context_window'), - phase_naming: _getConfigDefault('phase_naming'), - project_code: _getConfigDefault('project_code'), - subagent_timeout: _getNestedConfigDefault('workflow', 'subagent_timeout'), - security_enforcement: _getNestedConfigDefault('workflow', 'security_enforcement'), - security_asvs_level: _getNestedConfigDefault('workflow', 'security_asvs_level'), - security_block_on: _getNestedConfigDefault('workflow', 'security_block_on'), - post_planning_gaps: _getNestedConfigDefault('workflow', 'post_planning_gaps'), -}; - -/** - * 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. Non-object primitives use overlay value. - * - * Note: `undefined` in overlay is treated as "no value provided" and falls - * back to base (preserves inheritance). Explicit `null` overrides base. - */ -function _deepMergeConfig(base: Record, overlay: Record | null | undefined): Record | null | undefined { - if (overlay === null || overlay === undefined) return overlay; - if (typeof base !== 'object' || typeof overlay !== 'object') return overlay; - const result: Record = { ...base }; - for (const key of Object.keys(overlay)) { - if (overlay[key] !== null && typeof overlay[key] === 'object' && !Array.isArray(overlay[key])) { - result[key] = _deepMergeConfig((base[key] ?? {}) as Record, overlay[key] as Record); - } else { - result[key] = overlay[key]; - } - } - return result; -} - -// 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(); - -// Normalization result shape from configuration.cjs -interface NormalizationEntry { - requiresFilesystem?: boolean; - [key: string]: unknown; -} - -// Typed parsed config shape used internally -interface ParsedConfig { - [key: string]: unknown; - planning?: Record; -} - -function loadConfig(cwd: string, options: Record = {}): Record { - const activeWorkstream = Object.prototype.hasOwnProperty.call(options, 'workstream') - ? options['workstream'] - : (options['workstreamContext'] && Object.prototype.hasOwnProperty.call(options['workstreamContext'], 'ws')) - ? (options['workstreamContext'] as Record)['ws'] - : (process.env['GSD_WORKSTREAM'] || null); - // When GSD_WORKSTREAM is set, load root config first so workstream config - // can inherit from it. This prevents users from duplicating model_overrides, - // workflow.*, etc. across every workstream config (#2714). - const ws = typeof activeWorkstream === 'string' ? activeWorkstream : (activeWorkstream === null ? null : null); - // #315 — per-call lazy memo: all three detection sites inside this loadConfig - // call operate on the same cwd and the subrepo set cannot change mid-call, so - // a single scan is sufficient. The memo is scoped to THIS call (not module-level) - // so separate loadConfig invocations each get a fresh scan. - let cachedSubRepos: string[] | undefined; - const getDetectedSubRepos = (): string[] => { - if (cachedSubRepos === undefined) cachedSubRepos = detectSubRepos(cwd); - // Return a copy: original detectSubRepos returned a fresh array per call, - // so each site must keep an independent array (avoid cross-site aliasing). - return cachedSubRepos.slice(); - }; - let rootParsed: ParsedConfig | null = null; - if (ws) { - const rootConfigPath = path.join(planningRoot(cwd), 'config.json'); - try { - const raw = platformReadSync(rootConfigPath); - if (raw === null) throw new Error('missing'); - rootParsed = JSON.parse(raw) as ParsedConfig; - // Cycle 4: delegate all legacy-key normalization to the Configuration Module. - const { parsed: rootNormalized, normalizations: rootNorms } = normalizeLegacyKeys(rootParsed); - if (rootNorms.length > 0) { - // Resolve filesystem-dependent normalizations (multiRepo → planning.sub_repos) - for (const norm of rootNorms as unknown as NormalizationEntry[]) { - if (norm.requiresFilesystem && !(rootNormalized as ParsedConfig).planning?.['sub_repos']) { - const detected = getDetectedSubRepos(); - if (detected.length > 0) { - if (!(rootNormalized as ParsedConfig).planning) (rootNormalized as ParsedConfig).planning = {}; - (rootNormalized as ParsedConfig).planning!['sub_repos'] = detected; - (rootNormalized as ParsedConfig).planning!['commit_docs'] = false; - } - } - } - rootParsed = rootNormalized; - try { platformWriteSync(rootConfigPath, JSON.stringify(rootParsed, null, 2)); } catch { /* ignore */ } - } else { - rootParsed = rootNormalized; - } - } catch { - // Root config missing or unparseable — workstream config stands alone - } - } - - const configPath = path.join(planningDir(cwd, ws), 'config.json'); - const defaults = CONFIG_DEFAULTS; - - try { - const raw = platformReadSync(configPath); - if (raw === null) throw new Error('missing'); - // `fileData` is the parsed content of the config.json file on disk — used - // for migrations and writes so we never persist merged values back to disk. - const fileData: ParsedConfig = JSON.parse(raw) as ParsedConfig; - - // Cycle 4: Single normalizeLegacyKeys call replaces all four inline migration - // blocks (depth→granularity, multiRepo→planning.sub_repos, sub_repos→planning.sub_repos, - // branching_strategy→git.branching_strategy). The Module is pure (no I/O); disk - // writeback is handled below with the existing platformWriteSync pattern. - let configDirty = false; - { - const { parsed: normalized, normalizations } = normalizeLegacyKeys(fileData); - if (normalizations.length > 0) { - // Merge normalized values back into fileData (mutation-in-place for legacy code below) - Object.keys(fileData).forEach(k => delete (fileData as Record)[k]); - Object.assign(fileData, normalized); - configDirty = true; - // Resolve filesystem-dependent normalizations (multiRepo → planning.sub_repos). - for (const norm of normalizations as unknown as NormalizationEntry[]) { - if (norm.requiresFilesystem && !fileData.planning?.['sub_repos']) { - const detected = getDetectedSubRepos(); - if (detected.length > 0) { - if (!fileData.planning) fileData.planning = {}; - fileData.planning['sub_repos'] = detected; - fileData.planning['commit_docs'] = false; - } - } - } - } - } - - // Keep planning.sub_repos in sync with actual filesystem - const currentSubRepos = (fileData.planning?.['sub_repos'] as string[] | undefined) || []; - if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) { - const detected = getDetectedSubRepos(); - if (detected.length > 0) { - const sorted = [...currentSubRepos].sort(); - if (JSON.stringify(sorted) !== JSON.stringify(detected)) { - if (!fileData.planning) fileData.planning = {}; - fileData.planning['sub_repos'] = detected; - configDirty = true; - } - } - } - - // Persist sub_repos changes (migration or sync) — write only the on-disk - // file contents, never the merged result, to avoid polluting workstream configs. - if (configDirty) { - try { platformWriteSync(configPath, JSON.stringify(fileData, null, 2)); } catch { /* ignore */ } - } - - // Now apply root→workstream inheritance. `parsed` is the effective config - // used for value extraction below; fileData is kept for disk writes only. - const parsed: ParsedConfig = rootParsed - ? (_deepMergeConfig(rootParsed, fileData) as ParsedConfig ?? fileData) - : fileData; - - // Warn about unrecognized top-level keys so users don't silently lose config. - const KNOWN_TOP_LEVEL = new Set([ - // Extract top-level key names from dot-notation paths (e.g., 'workflow.research' → 'workflow') - ...[...VALID_CONFIG_KEYS].map((k: string) => k.split('.')[0]), - // Dynamic-pattern top-level containers (e.g. review, model_profile_overrides) - ...(DYNAMIC_KEY_PATTERNS as unknown as Array<{ topLevel: string }>).map(p => p.topLevel), - // Internal keys loadConfig reads but config-set doesn't expose - 'model_overrides', 'context_window', 'resolve_model_ids', 'claude_md_path', 'effort', 'fast_mode', - // Deprecated keys (still accepted for migration, not in config-set) - 'depth', 'multiRepo', 'branching_strategy', - ]); - const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_TOP_LEVEL.has(k)); - if (unknownKeys.length > 0) { - 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 - _warnUnknownProfileOverrides(parsed, '.planning/config.json'); - - const get = (key: string, nested?: { section: string; field: string }): unknown => { - if (parsed[key] !== undefined) return parsed[key]; - if (nested && parsed[nested.section] && typeof parsed[nested.section] === 'object' && parsed[nested.section] !== null) { - const sec = parsed[nested.section] as Record; - if (sec[nested.field] !== undefined) { - return sec[nested.field]; - } - } - return undefined; - }; - - const parallelization = (() => { - const val = get('parallelization'); - if (typeof val === 'boolean') return val; - if (typeof val === 'object' && val !== null && 'enabled' in (val)) return (val as Record)['enabled']; - return defaults.parallelization; - })(); - - return { - model_profile: get('model_profile') ?? defaults.model_profile, - commit_docs: (() => { - const explicit = get('commit_docs', { section: 'planning', field: 'commit_docs' }); - // If explicitly set in config, respect the user's choice - if (explicit !== undefined) return explicit; - // Auto-detection: when no explicit value and .planning/ is gitignored, - // default to false instead of true - if (isGitIgnored(cwd, '.planning/')) return false; - return defaults.commit_docs; - })(), - search_gitignored: get('search_gitignored', { section: 'planning', field: 'search_gitignored' }) ?? defaults.search_gitignored, - branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy, - phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template, - milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template, - quick_branch_template: get('quick_branch_template', { section: 'git', field: 'quick_branch_template' }) ?? defaults.quick_branch_template, - research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research, - plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker, - verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier, - nyquist_validation: get('nyquist_validation', { section: 'workflow', field: 'nyquist_validation' }) ?? defaults.nyquist_validation, - post_planning_gaps: get('post_planning_gaps', { section: 'workflow', field: 'post_planning_gaps' }) ?? defaults.post_planning_gaps, - parallelization, - brave_search: get('brave_search') ?? defaults.brave_search, - firecrawl: get('firecrawl') ?? defaults.firecrawl, - exa_search: get('exa_search') ?? defaults.exa_search, - tdd_mode: get('tdd_mode', { section: 'workflow', field: 'tdd_mode' }) ?? false, - mvp_mode: get('mvp_mode', { section: 'workflow', field: 'mvp_mode' }) ?? false, - text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode, - auto_advance: get('auto_advance', { section: 'workflow', field: 'auto_advance' }) ?? false, - _auto_chain_active: get('_auto_chain_active', { section: 'workflow', field: '_auto_chain_active' }) ?? false, - mode: get('mode') ?? 'interactive', - sub_repos: get('sub_repos', { section: 'planning', field: 'sub_repos' }) ?? defaults.sub_repos, - resolve_model_ids: get('resolve_model_ids') ?? defaults.resolve_model_ids, - context_window: get('context_window') ?? defaults.context_window, - phase_naming: get('phase_naming') ?? defaults.phase_naming, - project_code: get('project_code') ?? defaults.project_code, - subagent_timeout: get('subagent_timeout', { section: 'workflow', field: 'subagent_timeout' }) ?? defaults.subagent_timeout, - model_overrides: (parsed['model_overrides']) || null, - // #3023 — per-phase-type model map. - models: (parsed['models']) || null, - // #68 — top-level granularity - granularity: parsed['granularity'] !== undefined ? parsed['granularity'] : null, - // #68 — per-phase-type granularity map. - granularities: (parsed['granularities']) || null, - // #68 — planning sub-object - planning: (parsed['planning']) || null, - // #3024 — dynamic routing block. - dynamic_routing: (parsed['dynamic_routing']) || null, - // #2517 — runtime-aware profiles. - runtime: (parsed['runtime']) || null, - model_profile_overrides: (parsed['model_profile_overrides']) || null, - // #49 — provider-neutral model policy presets. - model_policy: (parsed['model_policy']) || null, - // #443 — effort/fast_mode - effort: (parsed['effort']) || null, - fast_mode: (parsed['fast_mode']) || null, - agent_skills: (parsed['agent_skills']) || {}, - agent_skills_security: (parsed['agent_skills_security']) || null, - manager: (parsed['manager']) || {}, - response_language: get('response_language') || null, - claude_md_path: get('claude_md_path') || null, - claude_md_assembly: (parsed['claude_md_assembly']) || null, - }; - } catch { - // Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683) - if (fs.existsSync(planningDir(cwd, ws))) { - if (rootParsed) { - // Workstream has no config.json: re-parse using root config as the sole source. - return loadConfig(cwd, { workstream: null }); - } - return defaults; - } - try { - const home = process.env['GSD_HOME'] || os.homedir(); - const globalDefaultsPath = path.join(home, '.gsd', 'defaults.json'); - const raw = platformReadSync(globalDefaultsPath); - if (raw === null) throw new Error('missing'); - const globalDefaults = JSON.parse(raw) as Record; - return { - ...defaults, - model_profile: (globalDefaults['model_profile']) ?? defaults.model_profile, - commit_docs: (globalDefaults['commit_docs']) ?? defaults.commit_docs, - research: (globalDefaults['research']) ?? defaults.research, - plan_checker: (globalDefaults['plan_checker']) ?? defaults.plan_checker, - verifier: (globalDefaults['verifier']) ?? defaults.verifier, - nyquist_validation: (globalDefaults['nyquist_validation']) ?? defaults.nyquist_validation, - post_planning_gaps: (globalDefaults['post_planning_gaps']) - ?? (globalDefaults['workflow'] as Record | undefined)?.['post_planning_gaps'] - ?? defaults.post_planning_gaps, - parallelization: (globalDefaults['parallelization']) ?? defaults.parallelization, - text_mode: (globalDefaults['text_mode']) ?? defaults.text_mode, - resolve_model_ids: (globalDefaults['resolve_model_ids']) ?? defaults.resolve_model_ids, - context_window: (globalDefaults['context_window']) ?? defaults.context_window, - subagent_timeout: (globalDefaults['subagent_timeout']) ?? defaults.subagent_timeout, - model_overrides: (globalDefaults['model_overrides']) || null, - models: (globalDefaults['models']) || null, - granularity: (globalDefaults['granularity']) !== undefined ? globalDefaults['granularity'] : null, - granularities: (globalDefaults['granularities']) || null, - planning: (globalDefaults['planning']) || null, - dynamic_routing: (globalDefaults['dynamic_routing']) || null, - effort: (globalDefaults['effort']) || null, - fast_mode: (globalDefaults['fast_mode']) || null, - agent_skills: (globalDefaults['agent_skills']) || {}, - response_language: (globalDefaults['response_language']) || null, - }; - } catch { - return defaults; - } - } -} - -// ─── Git utilities ──────────────────────────────────────────────────────────── - -const _gitIgnoredCache = new Map(); - -function isGitIgnored(cwd: string, targetPath: string): boolean { - const key = cwd + '::' + targetPath; - if (_gitIgnoredCache.has(key)) return _gitIgnoredCache.get(key)!; - // --no-index checks .gitignore rules regardless of whether the file is tracked. - const result = execGit(['check-ignore', '-q', '--no-index', '--', targetPath], { cwd }); - const ignored = result.exitCode === 0; - _gitIgnoredCache.set(key, ignored); - return ignored; -} +// loadConfig, isGitIgnored, CONFIG_DEFAULTS, and related helpers moved to +// config-loader.cjs (ADR-857 phase 2e / #885). The destructured bindings above +// (from configLoaderModule) make them available to core-internal callers; +// core.cjs re-exports loadConfig and isGitIgnored for back-compat. // ─── Common path helpers ────────────────────────────────────────────────────── @@ -612,123 +242,10 @@ function checkAgentsInstalled(runtime?: string): AgentsInstalledResult { } // ─── Model alias resolution ─────────────────────────────────────────────────── - -const RUNTIME_OVERRIDE_TIERS = new Set(['opus', 'sonnet', 'haiku']); -const _warnedConfigKeys = new Set(); - -function _warnUnknownProfileOverrides(parsed: Record, configLabel: string): void { - if (!parsed || typeof parsed !== 'object') return; - - const runtime = parsed['runtime']; - if (runtime && typeof runtime === 'string' && !(KNOWN_RUNTIMES).has(runtime)) { - const key = `${configLabel}::runtime::${runtime}`; - if (!_warnedConfigKeys.has(key)) { - _warnedConfigKeys.add(key); - try { - process.stderr.write( - `gsd: warning — config key "runtime" has unknown value "${runtime}". ` + - `Known runtimes: ${[...(KNOWN_RUNTIMES)].sort().join(', ')}. ` + - `Resolution will fall back to safe defaults. (#2517)\n` - ); - } catch { /* stderr might be closed in some test harnesses */ } - } - } - - const overrides = parsed['model_profile_overrides']; - if (overrides && typeof overrides === 'object' && !Array.isArray(overrides)) { - for (const [overrideRuntime, tierMap] of Object.entries(overrides as Record)) { - if (!(KNOWN_RUNTIMES).has(overrideRuntime)) { - const key = `${configLabel}::override-runtime::${overrideRuntime}`; - if (!_warnedConfigKeys.has(key)) { - _warnedConfigKeys.add(key); - try { - process.stderr.write( - `gsd: warning — model_profile_overrides.${overrideRuntime}.* uses ` + - `unknown runtime "${overrideRuntime}". Known runtimes: ` + - `${[...(KNOWN_RUNTIMES)].sort().join(', ')}. (#2517)\n` - ); - } catch { /* ok */ } - } - } - if (!tierMap || typeof tierMap !== 'object') continue; - for (const tierName of Object.keys(tierMap)) { - if (!RUNTIME_OVERRIDE_TIERS.has(tierName)) { - const key = `${configLabel}::override-tier::${overrideRuntime}.${tierName}`; - if (!_warnedConfigKeys.has(key)) { - _warnedConfigKeys.add(key); - try { - process.stderr.write( - `gsd: warning — model_profile_overrides.${overrideRuntime}.${tierName} ` + - `uses unknown tier "${tierName}". Allowed tiers: opus, sonnet, haiku. (#2517)\n` - ); - } catch { /* ok */ } - } - } - } - } - } - - const policy = parsed['model_policy']; - if (policy && typeof policy === 'object' && !Array.isArray(policy)) { - const policyObj = policy as Record; - const provider = policyObj['provider']; - const _POLICY_SENTINEL_PROVIDERS = new Set(['generic', 'custom']); - if (provider && typeof provider === 'string' && - !(KNOWN_PROVIDERS).has(provider) && !_POLICY_SENTINEL_PROVIDERS.has(provider)) { - const pkey = `${configLabel}::model_policy::provider::${provider}`; - if (!_warnedConfigKeys.has(pkey)) { - _warnedConfigKeys.add(pkey); - try { - process.stderr.write( - `gsd: warning — model_policy.provider has unknown value "${provider}". ` + - `Known providers: ${[...(KNOWN_PROVIDERS)].sort().join(', ')}. ` + - `For manual model IDs use provider="custom". (#49)\n` - ); - } catch { /* ok */ } - } - } - - const rtOverrides = policyObj['runtime_tiers']; - if (rtOverrides && typeof rtOverrides === 'object' && !Array.isArray(rtOverrides)) { - for (const [pruntime, tierMap] of Object.entries(rtOverrides as Record)) { - if (!(KNOWN_RUNTIMES).has(pruntime)) { - const key = `${configLabel}::model_policy.runtime_tiers::${pruntime}`; - if (!_warnedConfigKeys.has(key)) { - _warnedConfigKeys.add(key); - try { - process.stderr.write( - `gsd: warning — model_policy.runtime_tiers.${pruntime}.* uses ` + - `unknown runtime "${pruntime}". Known runtimes: ` + - `${[...(KNOWN_RUNTIMES)].sort().join(', ')}. (#49)\n` - ); - } catch { /* ok */ } - } - } - if (!tierMap || typeof tierMap !== 'object') continue; - for (const tierName of Object.keys(tierMap)) { - if (!RUNTIME_OVERRIDE_TIERS.has(tierName)) { - const key = `${configLabel}::model_policy.runtime_tiers::${pruntime}.${tierName}`; - if (!_warnedConfigKeys.has(key)) { - _warnedConfigKeys.add(key); - try { - process.stderr.write( - `gsd: warning — model_policy.runtime_tiers.${pruntime}.${tierName} ` + - `uses unknown tier "${tierName}". Allowed: opus, sonnet, haiku. (#49)\n` - ); - } catch { /* ok */ } - } - } - } - } - } - } -} - -// Internal helper exposed for tests so per-process warning state can be reset -// between cases that intentionally exercise the warning path repeatedly. -function _resetRuntimeWarningCacheForTests(): void { - _warnedConfigKeys.clear(); -} +// RUNTIME_OVERRIDE_TIERS, _warnedConfigKeys, _warnUnknownProfileOverrides, and +// _resetRuntimeWarningCacheForTests moved to config-loader.cjs (ADR-857 phase 2e / #885). +// The destructured bindings above (from configLoaderModule) make them available to +// core-internal callers; _resetRuntimeWarningCacheForTests is re-exported for back-compat. interface TierEntryResolved { model: string; diff --git a/tests/config-field-docs.test.cjs b/tests/config-field-docs.test.cjs index f9413af0d..5d330776d 100644 --- a/tests/config-field-docs.test.cjs +++ b/tests/config-field-docs.test.cjs @@ -1,7 +1,8 @@ // allow-test-rule: docs-parity -// Extracts CONFIG_DEFAULTS keys from core.cjs source to verify planning-config.md +// Extracts CONFIG_DEFAULTS keys from config-loader.cjs source to verify planning-config.md // stays in sync. The canonical list of defaults lives in source; there is no runtime // API to enumerate them. Source inspection is the only practical parity check here. +// CONFIG_DEFAULTS was extracted from core.cjs into config-loader.cjs by ADR-857 phase 2e. /** * Verify planning-config.md documents all config fields from source code. @@ -13,7 +14,7 @@ const fs = require('fs'); const path = require('path'); const REFERENCE_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'planning-config.md'); -const CORE_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'core.cjs'); +const CORE_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'config-loader.cjs'); describe('config-field-docs', () => { let content; @@ -59,12 +60,12 @@ describe('config-field-docs', () => { }); test('every CONFIG_DEFAULTS key appears in the doc', () => { - // Extract CONFIG_DEFAULTS keys from core.cjs source + // Extract CONFIG_DEFAULTS keys from config-loader.cjs source (moved from core.cjs by ADR-857 phase 2e) const coreSource = fs.readFileSync(CORE_PATH, 'utf-8'); const defaultsMatch = coreSource.match( /const CONFIG_DEFAULTS\s*=\s*\{([\s\S]*?)\n\};/ ); - assert.ok(defaultsMatch, 'Could not find CONFIG_DEFAULTS in core.cjs'); + assert.ok(defaultsMatch, 'Could not find CONFIG_DEFAULTS in config-loader.cjs'); const body = defaultsMatch[1]; // Match property keys (word characters before the colon) diff --git a/tests/config-loader.test.cjs b/tests/config-loader.test.cjs new file mode 100644 index 000000000..387f6fe5e --- /dev/null +++ b/tests/config-loader.test.cjs @@ -0,0 +1,355 @@ +'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'); + +// ─── module under test ──────────────────────────────────────────────────────── + +const configLoader = require('../gsd-core/bin/lib/config-loader.cjs'); +const coreModule = require('../gsd-core/bin/lib/core.cjs'); + +const { loadConfig, _resetRuntimeWarningCacheForTests } = configLoader; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function makeTempProject(prefix = 'gsd-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'); +} + +// ─── shim identity ──────────────────────────────────────────────────────────── + +describe('config-loader shim identity', () => { + test('core.loadConfig === configLoader.loadConfig (same function object)', () => { + assert.strictEqual( + coreModule.loadConfig, + configLoader.loadConfig, + 'core.cjs must re-export the same loadConfig function as config-loader.cjs' + ); + }); + + test('core.isGitIgnored === configLoader.isGitIgnored (same function object)', () => { + assert.strictEqual( + coreModule.isGitIgnored, + configLoader.isGitIgnored, + 'core.cjs must re-export the same isGitIgnored function as config-loader.cjs' + ); + }); +}); + +// ─── 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, { __gsd_unknown_sentinel__: true }); + loadConfig(tmpDir); + const warnings = stderrLines.filter(l => l.includes('__gsd_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, { __gsd_dedup_test__: true }); + loadConfig(tmpDir); + loadConfig(tmpDir); + const warnings = stderrLines.filter(l => l.includes('__gsd_dedup_test__')); + // Should appear at most once + assert.ok(warnings.length <= 1, `warning emitted more than once: ${warnings.length} times`); + }); +}); + +// ─── 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'); + }); +});