Files
msd-core/sdk/src/config.test.ts
Tom Boucher 2de2d185fa feat(3536): Configuration Module via shared manifests + generator (Phase 2 of #3524)
Phase 2 of the CJS↔SDK hard-seam migration (parent #3524).
Eliminates the structural drift surface that produced bug class

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

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

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

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

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

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

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

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

Closes #3536.
2026-05-15 00:02:56 -04:00

278 lines
10 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { loadConfig, CONFIG_DEFAULTS } from './config.js';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
describe('loadConfig', () => {
let tmpDir: string;
let fakeHome: string;
let prevHome: string | undefined;
let prevGsdHome: string | undefined;
beforeEach(async () => {
tmpDir = join(tmpdir(), `gsd-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(join(tmpDir, '.planning'), { recursive: true });
// Isolate ~/.gsd/defaults.json by pointing HOME at an empty tmp dir.
fakeHome = join(tmpdir(), `gsd-home-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(fakeHome, { recursive: true });
prevHome = process.env.HOME;
process.env.HOME = fakeHome;
// Also isolate GSD_HOME (loadUserDefaults prefers it over HOME).
prevGsdHome = process.env.GSD_HOME;
delete process.env.GSD_HOME;
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
await rm(fakeHome, { recursive: true, force: true });
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
if (prevGsdHome === undefined) delete process.env.GSD_HOME;
else process.env.GSD_HOME = prevGsdHome;
});
async function writeUserDefaults(defaults: unknown) {
await mkdir(join(fakeHome, '.gsd'), { recursive: true });
await writeFile(join(fakeHome, '.gsd', 'defaults.json'), JSON.stringify(defaults));
}
it('returns all defaults when config file is missing', async () => {
// No config.json created
await rm(join(tmpDir, '.planning', 'config.json'), { force: true });
const config = await loadConfig(tmpDir);
expect(config).toEqual(CONFIG_DEFAULTS);
});
it('returns all defaults when config file is empty', async () => {
await writeFile(join(tmpDir, '.planning', 'config.json'), '');
const config = await loadConfig(tmpDir);
expect(config).toEqual(CONFIG_DEFAULTS);
});
it('loads valid config and merges with defaults', async () => {
const userConfig = {
model_profile: 'fast',
workflow: { research: false },
};
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify(userConfig),
);
const config = await loadConfig(tmpDir);
expect(config.model_profile).toBe('fast');
expect(config.workflow.research).toBe(false);
// Other workflow defaults preserved
expect(config.workflow.plan_check).toBe(true);
expect(config.workflow.verifier).toBe(true);
// Top-level defaults preserved
expect(config.commit_docs).toBe(true);
expect(config.parallelization).toBe(true);
});
it('partial config merges correctly for nested objects', async () => {
const userConfig = {
git: { branching_strategy: 'milestone' },
hooks: { context_warnings: false },
};
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify(userConfig),
);
const config = await loadConfig(tmpDir);
expect(config.git.branching_strategy).toBe('milestone');
// Other git defaults preserved
expect(config.git.phase_branch_template).toBe('gsd/phase-{phase}-{slug}');
expect(config.hooks.context_warnings).toBe(false);
});
it('preserves unknown top-level keys', async () => {
const userConfig = { custom_key: 'custom_value' };
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify(userConfig),
);
const config = await loadConfig(tmpDir);
expect(config.custom_key).toBe('custom_value');
});
it('merges agent_skills', async () => {
const userConfig = {
agent_skills: { planner: 'custom-skill' },
};
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify(userConfig),
);
const config = await loadConfig(tmpDir);
expect(config.agent_skills).toEqual({ planner: 'custom-skill' });
});
// ─── Negative tests ─────────────────────────────────────────────────────
it('throws on malformed JSON', async () => {
await writeFile(
join(tmpDir, '.planning', 'config.json'),
'{bad json',
);
await expect(loadConfig(tmpDir)).rejects.toThrow(/Failed to parse config/);
});
it('throws when config is not an object (array)', async () => {
await writeFile(
join(tmpDir, '.planning', 'config.json'),
'[1, 2, 3]',
);
await expect(loadConfig(tmpDir)).rejects.toThrow(/must be a JSON object/);
});
it('throws when config is not an object (string)', async () => {
await writeFile(
join(tmpDir, '.planning', 'config.json'),
'"just a string"',
);
await expect(loadConfig(tmpDir)).rejects.toThrow(/must be a JSON object/);
});
it('ignores unknown keys without error', async () => {
const userConfig = {
totally_unknown: true,
another_unknown: { nested: 'value' },
};
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify(userConfig),
);
const config = await loadConfig(tmpDir);
// Should load fine, with unknowns passed through
expect(config.model_profile).toBe('balanced');
expect((config as Record<string, unknown>).totally_unknown).toBe(true);
});
it('handles wrong value types gracefully (user sets string instead of bool)', async () => {
const userConfig = {
commit_docs: 'yes', // should be boolean but we don't validate types
parallelization: 0,
};
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify(userConfig),
);
const config = await loadConfig(tmpDir);
// We pass through the user's values as-is — runtime code handles type mismatches
expect(config.commit_docs).toBe('yes');
expect(config.parallelization).toBe(0);
});
// ─── User-level defaults (~/.gsd/defaults.json) ─────────────────────────
// Regression: issue #2652 — SDK loadConfig ignored user-level defaults
// for pre-project Codex installs, so init.quick still emitted Claude
// model aliases from MODEL_PROFILES via resolveModel even when the user
// had `resolve_model_ids: "omit"` in ~/.gsd/defaults.json.
//
// Mirrors current CJS parity expectations for SDK loadConfig + resolveModel:
// in pre-project context, loadConfig ignores ~/.gsd/defaults.json so
// resolveModel/MODEL_PROFILES do not emit aliases when resolve_model_ids
// is "omit". Once a project is initialized, config.json is authoritative,
// because buildNewProjectConfig bakes user defaults into project config
// at /gsd-new-project time.
it('pre-project: ignores user defaults and uses built-in defaults', async () => {
await writeUserDefaults({ resolve_model_ids: 'omit' });
const config = await loadConfig(tmpDir);
// BEHAVIOR CHANGE (Cycle 3, #3536): CONFIG_DEFAULTS now sourced from
// sdk/shared/config-defaults.manifest.json which includes resolve_model_ids: false.
// The key is NOT undefined — it has the manifest default (false), not the user
// default ('omit'), confirming that user-level ~/.gsd/defaults.json is still ignored.
expect((config as Record<string, unknown>).resolve_model_ids).toBe(false);
expect(config.model_profile).toBe('balanced');
expect(config.workflow.plan_check).toBe(true);
});
it('pre-project: keeps built-in nested defaults even when user defaults exist', async () => {
await writeUserDefaults({
git: { branching_strategy: 'milestone' },
agent_skills: { planner: 'user-skill' },
});
const config = await loadConfig(tmpDir);
expect(config.git.branching_strategy).toBe('none');
expect(config.git.phase_branch_template).toBe('gsd/phase-{phase}-{slug}');
expect(config.agent_skills).toEqual({});
});
it('project config is authoritative over user defaults (CJS parity)', async () => {
// User defaults set resolve_model_ids: "omit", but project config omits it.
// Per CJS core.cjs loadConfig (#1683): once .planning/config.json exists,
// ~/.gsd/defaults.json is ignored — buildNewProjectConfig already baked
// the user defaults in at project creation time.
await writeUserDefaults({
resolve_model_ids: 'omit',
model_profile: 'fast',
});
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'quality' }),
);
const config = await loadConfig(tmpDir);
expect(config.model_profile).toBe('quality');
// User-defaults not layered when project config present.
// BEHAVIOR CHANGE (Cycle 3, #3536): resolve_model_ids is now false (manifest default),
// not undefined — confirming user defaults are still ignored (value is NOT 'omit').
expect((config as Record<string, unknown>).resolve_model_ids).toBe(false);
});
it('ignores malformed ~/.gsd/defaults.json', async () => {
await mkdir(join(fakeHome, '.gsd'), { recursive: true });
await writeFile(join(fakeHome, '.gsd', 'defaults.json'), '{not json');
const config = await loadConfig(tmpDir);
// Falls back to built-in defaults
expect(config).toEqual(CONFIG_DEFAULTS);
});
it('maps legacy top-level branching_strategy into git.branching_strategy', async () => {
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ branching_strategy: 'phase' }),
);
const config = await loadConfig(tmpDir);
expect(config.git.branching_strategy).toBe('phase');
});
it('git.branching_strategy overrides legacy top-level branching_strategy when both are present', async () => {
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ branching_strategy: 'phase', git: { branching_strategy: 'milestone' } }),
);
const config = await loadConfig(tmpDir);
expect(config.git.branching_strategy).toBe('milestone');
});
it('does not mutate CONFIG_DEFAULTS between calls', async () => {
const before = structuredClone(CONFIG_DEFAULTS);
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'fast', workflow: { research: false } }),
);
await loadConfig(tmpDir);
expect(CONFIG_DEFAULTS).toEqual(before);
});
});