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.
218 lines
8.1 KiB
JavaScript
218 lines
8.1 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* GENERATED FILE — DO NOT EDIT.
|
|
*
|
|
* Source: sdk/src/configuration/index.ts
|
|
* Regenerate: cd sdk && npm run gen:configuration
|
|
*
|
|
* Configuration Module — single source of truth for config loading,
|
|
* legacy-key normalization, defaults merge, and explicit on-disk migration.
|
|
*/
|
|
|
|
const { readFileSync, writeFileSync, existsSync, readdirSync } = require('node:fs');
|
|
const { join } = require('node:path');
|
|
|
|
// ─── Manifest requires ───────────────────────────────────────────────────────
|
|
// Resolved relative to this file: get-shit-done/bin/lib/ → sdk/shared/
|
|
// This file lives at: get-shit-done/bin/lib/configuration.generated.cjs
|
|
// sdk/shared lives at: sdk/shared/ (3 dirs up from bin/lib, then into sdk/shared)
|
|
const CONFIG_DEFAULTS = require('../../../sdk/shared/config-defaults.manifest.json');
|
|
const SCHEMA_MANIFEST = require('../../../sdk/shared/config-schema.manifest.json');
|
|
const VALID_CONFIG_KEYS = new Set(SCHEMA_MANIFEST.validKeys);
|
|
const RUNTIME_STATE_KEYS = new Set(SCHEMA_MANIFEST.runtimeStateKeys);
|
|
const DYNAMIC_KEY_PATTERNS = SCHEMA_MANIFEST.dynamicKeyPatterns.map(p => ({ ...p, test: (key) => new RegExp(p.source).test(key) }));
|
|
|
|
// ─── Depth → Granularity mapping ─────────────────────────────────────────────
|
|
const DEPTH_TO_GRANULARITY = {
|
|
quick: 'coarse',
|
|
standard: 'standard',
|
|
comprehensive: 'fine',
|
|
};
|
|
|
|
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
function planningDir(cwd, workstream) {
|
|
if (!workstream)
|
|
return join(cwd, '.planning');
|
|
return join(cwd, '.planning', 'workstreams', workstream);
|
|
}
|
|
|
|
function detectSubRepos(cwd) {
|
|
const results = [];
|
|
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, overlay) {
|
|
const result = { ...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, ov);
|
|
}
|
|
else {
|
|
result[key] = deepMergeConfig({}, ov);
|
|
}
|
|
}
|
|
else {
|
|
result[key] = ov;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ─── Exported functions ───────────────────────────────────────────────────────
|
|
function normalizeLegacyKeys(parsed) {
|
|
const result = { ...parsed };
|
|
const normalizations = [];
|
|
// 1. branching_strategy → git.branching_strategy
|
|
if (Object.prototype.hasOwnProperty.call(result, 'branching_strategy')) {
|
|
const value = result.branching_strategy;
|
|
const git = result.git ?? {};
|
|
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 ?? {};
|
|
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;
|
|
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) {
|
|
// Start with a deep clone of defaults, then overlay parsed
|
|
const defaults = JSON.parse(JSON.stringify(CONFIG_DEFAULTS));
|
|
return deepMergeConfig(defaults, parsed);
|
|
}
|
|
|
|
async function loadConfig(cwd, options) {
|
|
const configPath = join(planningDir(cwd, options?.workstream), 'config.json');
|
|
let raw;
|
|
try {
|
|
raw = readFileSync(configPath, 'utf-8');
|
|
}
|
|
catch {
|
|
// File missing — return defaults
|
|
return mergeDefaults({});
|
|
}
|
|
const trimmed = raw.trim();
|
|
if (trimmed === '') {
|
|
return mergeDefaults({});
|
|
}
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(trimmed);
|
|
}
|
|
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);
|
|
}
|
|
|
|
async function migrateOnDisk(cwd, workstream) {
|
|
const configPath = join(planningDir(cwd, workstream), 'config.json');
|
|
let raw;
|
|
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;
|
|
try {
|
|
parsed = JSON.parse(trimmed);
|
|
}
|
|
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 ?? {};
|
|
result.planning = { ...planning, sub_repos: detected, commit_docs: false };
|
|
}
|
|
}
|
|
}
|
|
writeFileSync(configPath, JSON.stringify(result, null, 2));
|
|
return { migrated: true, normalizations, wrote: configPath };
|
|
}
|
|
|
|
module.exports = {
|
|
loadConfig,
|
|
normalizeLegacyKeys,
|
|
mergeDefaults,
|
|
migrateOnDisk,
|
|
CONFIG_DEFAULTS,
|
|
VALID_CONFIG_KEYS,
|
|
RUNTIME_STATE_KEYS,
|
|
DYNAMIC_KEY_PATTERNS,
|
|
};
|