Files
msd-core/tests/configuration-generator.test.cjs
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

356 lines
14 KiB
JavaScript

'use strict';
/**
* Parity test: configuration.generated.cjs (CJS) vs sdk/dist/configuration/index.js (ESM).
*
* For every fixture in the vitest pinning tests, asserts that both sides produce
* identical output. This ensures the generator faithfully replicates the TS source.
*
* Uses node:test + dynamic import() for the ESM side.
*/
const { describe, test, before } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
// ─── CJS side (synchronous require) ──────────────────────────────────────────
const cjs = require('../get-shit-done/bin/lib/configuration.generated.cjs');
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeTmpProject() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-parity-'));
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
return dir;
}
function writeConfig(dir, data) {
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(data, null, 2));
}
function readConfigRaw(dir) {
return fs.readFileSync(path.join(dir, '.planning', 'config.json'), 'utf-8');
}
function cleanup(dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
// ─── ESM side (loaded once via before()) ─────────────────────────────────────
let esm;
before(async () => {
esm = await import('../sdk/dist/configuration/index.js');
});
// ─── Parity helper ────────────────────────────────────────────────────────────
/**
* Deep-equal assertion that normalizes Sets to arrays for comparison.
*/
function assertDeepEqual(label, actual, expected) {
const normalize = (v) => JSON.parse(JSON.stringify(v, (_k, val) =>
val instanceof Set ? [...val].sort() : val
));
assert.deepStrictEqual(normalize(actual), normalize(expected), `${label} mismatch`);
}
// ─── CONFIG_DEFAULTS parity ───────────────────────────────────────────────────
describe('CONFIG_DEFAULTS parity', () => {
test('model_profile matches', () => {
assert.strictEqual(cjs.CONFIG_DEFAULTS.model_profile, esm.CONFIG_DEFAULTS.model_profile);
});
test('git section matches', () => {
assertDeepEqual('git', cjs.CONFIG_DEFAULTS.git, esm.CONFIG_DEFAULTS.git);
});
test('workflow section matches', () => {
assertDeepEqual('workflow', cjs.CONFIG_DEFAULTS.workflow, esm.CONFIG_DEFAULTS.workflow);
});
test('hooks section matches', () => {
assertDeepEqual('hooks', cjs.CONFIG_DEFAULTS.hooks, esm.CONFIG_DEFAULTS.hooks);
});
});
// ─── VALID_CONFIG_KEYS parity ─────────────────────────────────────────────────
describe('VALID_CONFIG_KEYS parity', () => {
test('same size', () => {
assert.strictEqual(cjs.VALID_CONFIG_KEYS.size, esm.VALID_CONFIG_KEYS.size);
});
test('same entries', () => {
for (const key of esm.VALID_CONFIG_KEYS) {
assert.ok(cjs.VALID_CONFIG_KEYS.has(key), `CJS missing key: ${key}`);
}
for (const key of cjs.VALID_CONFIG_KEYS) {
assert.ok(esm.VALID_CONFIG_KEYS.has(key), `ESM missing key: ${key}`);
}
});
});
// ─── DYNAMIC_KEY_PATTERNS parity ─────────────────────────────────────────────
describe('DYNAMIC_KEY_PATTERNS parity', () => {
test('same length', () => {
assert.strictEqual(cjs.DYNAMIC_KEY_PATTERNS.length, esm.DYNAMIC_KEY_PATTERNS.length);
});
test('same topLevel and source strings', () => {
for (let i = 0; i < esm.DYNAMIC_KEY_PATTERNS.length; i++) {
assert.strictEqual(cjs.DYNAMIC_KEY_PATTERNS[i].topLevel, esm.DYNAMIC_KEY_PATTERNS[i].topLevel, `topLevel[${i}]`);
assert.strictEqual(cjs.DYNAMIC_KEY_PATTERNS[i].source, esm.DYNAMIC_KEY_PATTERNS[i].source, `source[${i}]`);
}
});
test('test functions produce same results', () => {
const sampleKeys = [
'agent_skills.planner',
'agent_skills.executor',
'review.models.ollama',
'features.thinking_partner',
'claude_md_assembly.blocks.intro',
'model_profile_overrides.openai.opus',
'models.planning',
'dynamic_routing.enabled',
'model_overrides.my-agent',
'workflow.research',
'unknown_key',
];
for (const key of sampleKeys) {
for (let i = 0; i < esm.DYNAMIC_KEY_PATTERNS.length; i++) {
const esmResult = esm.DYNAMIC_KEY_PATTERNS[i].test(key);
const cjsResult = cjs.DYNAMIC_KEY_PATTERNS[i].test(key);
assert.strictEqual(cjsResult, esmResult, `pattern[${i}].test('${key}')`);
}
}
});
});
// ─── normalizeLegacyKeys parity ───────────────────────────────────────────────
describe('normalizeLegacyKeys parity', () => {
test('branching_strategy migration', () => {
const input = { branching_strategy: 'phase' };
const esmR = esm.normalizeLegacyKeys(input);
const cjsR = cjs.normalizeLegacyKeys(input);
assertDeepEqual('parsed', cjsR.parsed, esmR.parsed);
assertDeepEqual('normalizations', cjsR.normalizations, esmR.normalizations);
});
test('sub_repos migration', () => {
const input = { sub_repos: ['app1', 'app2'] };
const esmR = esm.normalizeLegacyKeys(input);
const cjsR = cjs.normalizeLegacyKeys(input);
assertDeepEqual('parsed', cjsR.parsed, esmR.parsed);
assertDeepEqual('normalizations', cjsR.normalizations, esmR.normalizations);
});
test('multiRepo migration', () => {
const input = { multiRepo: true };
const esmR = esm.normalizeLegacyKeys(input);
const cjsR = cjs.normalizeLegacyKeys(input);
assertDeepEqual('parsed', cjsR.parsed, esmR.parsed);
assert.strictEqual(cjsR.normalizations.length, esmR.normalizations.length);
assert.strictEqual(cjsR.normalizations[0].requiresFilesystem, esmR.normalizations[0].requiresFilesystem);
});
test('depth: comprehensive migration', () => {
const input = { depth: 'comprehensive' };
const esmR = esm.normalizeLegacyKeys(input);
const cjsR = cjs.normalizeLegacyKeys(input);
assertDeepEqual('parsed', cjsR.parsed, esmR.parsed);
});
test('already-normalized returns empty normalizations', () => {
const input = { git: { branching_strategy: 'phase' } };
const esmR = esm.normalizeLegacyKeys(input);
const cjsR = cjs.normalizeLegacyKeys(input);
assert.strictEqual(cjsR.normalizations.length, 0);
assert.strictEqual(esmR.normalizations.length, 0);
});
test('idempotent — second call returns empty normalizations', () => {
const input = { branching_strategy: 'milestone' };
const first_cjs = cjs.normalizeLegacyKeys(input);
const second_cjs = cjs.normalizeLegacyKeys(first_cjs.parsed);
const first_esm = esm.normalizeLegacyKeys(input);
const second_esm = esm.normalizeLegacyKeys(first_esm.parsed);
assert.strictEqual(second_cjs.normalizations.length, 0);
assert.strictEqual(second_esm.normalizations.length, 0);
assertDeepEqual('second_parsed', second_cjs.parsed, second_esm.parsed);
});
});
// ─── mergeDefaults parity ─────────────────────────────────────────────────────
describe('mergeDefaults parity', () => {
test('empty input returns CONFIG_DEFAULTS shape', () => {
const esmR = esm.mergeDefaults({});
const cjsR = cjs.mergeDefaults({});
assert.strictEqual(cjsR.model_profile, esmR.model_profile);
assertDeepEqual('git', cjsR.git, esmR.git);
assertDeepEqual('workflow', cjsR.workflow, esmR.workflow);
assertDeepEqual('hooks', cjsR.hooks, esmR.hooks);
});
test('partial nested preserves siblings', () => {
const input = { git: { base_branch: 'main' } };
const esmR = esm.mergeDefaults(input);
const cjsR = cjs.mergeDefaults(input);
assert.strictEqual(cjsR.git.base_branch, esmR.git.base_branch);
assert.strictEqual(cjsR.git.branching_strategy, esmR.git.branching_strategy);
});
test('boolean false preserved', () => {
const input = { workflow: { research: false } };
const esmR = esm.mergeDefaults(input);
const cjsR = cjs.mergeDefaults(input);
assert.strictEqual(cjsR.workflow.research, false);
assert.strictEqual(esmR.workflow.research, false);
});
test('null preserved', () => {
const input = { project_code: null };
const esmR = esm.mergeDefaults(input);
const cjsR = cjs.mergeDefaults(input);
assert.strictEqual(cjsR.project_code, null);
assert.strictEqual(esmR.project_code, null);
});
});
// ─── loadConfig parity ────────────────────────────────────────────────────────
describe('loadConfig parity', () => {
test('missing config.json returns defaults', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-parity-lc-'));
try {
const esmR = await esm.loadConfig(dir);
const cjsR = await cjs.loadConfig(dir);
assert.strictEqual(cjsR.model_profile, esmR.model_profile);
assertDeepEqual('git', cjsR.git, esmR.git);
} finally {
cleanup(dir);
}
});
test('empty {} config.json returns defaults', async () => {
const dir = makeTmpProject();
writeConfig(dir, {});
try {
const esmR = await esm.loadConfig(dir);
const cjsR = await cjs.loadConfig(dir);
assert.strictEqual(cjsR.model_profile, esmR.model_profile);
} finally {
cleanup(dir);
}
});
test('nested git.branching_strategy preserved', async () => {
const dir = makeTmpProject();
writeConfig(dir, { git: { branching_strategy: 'phase' } });
try {
const esmR = await esm.loadConfig(dir);
const cjsR = await cjs.loadConfig(dir);
assert.strictEqual(cjsR.git.branching_strategy, 'phase');
assert.strictEqual(esmR.git.branching_strategy, 'phase');
} finally {
cleanup(dir);
}
});
test('legacy top-level branching_strategy normalized, disk unchanged', async () => {
const dir = makeTmpProject();
writeConfig(dir, { branching_strategy: 'phase' });
const before = readConfigRaw(dir);
try {
const esmR = await esm.loadConfig(dir);
const cjsR = await cjs.loadConfig(dir);
assert.strictEqual(cjsR.git.branching_strategy, 'phase');
assert.strictEqual(esmR.git.branching_strategy, 'phase');
// Disk must be unchanged
assert.strictEqual(readConfigRaw(dir), before);
} finally {
cleanup(dir);
}
});
test('throws on malformed JSON', async () => {
const dir = makeTmpProject();
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), '{bad json');
try {
await assert.rejects(() => cjs.loadConfig(dir), /parse|invalid|json/i);
await assert.rejects(() => esm.loadConfig(dir), /parse|invalid|json/i);
} finally {
cleanup(dir);
}
});
});
// ─── migrateOnDisk parity ─────────────────────────────────────────────────────
describe('migrateOnDisk parity', () => {
test('no-op for already-normalized config', async () => {
const dir = makeTmpProject();
writeConfig(dir, { git: { branching_strategy: 'phase' } });
try {
const esmR = await esm.migrateOnDisk(dir);
// Reset file for CJS test
writeConfig(dir, { git: { branching_strategy: 'phase' } });
const cjsR = await cjs.migrateOnDisk(dir);
assert.strictEqual(cjsR.migrated, false);
assert.strictEqual(esmR.migrated, false);
assert.strictEqual(cjsR.wrote, null);
assert.strictEqual(esmR.wrote, null);
} finally {
cleanup(dir);
}
});
test('migrates legacy key and writes disk', async () => {
const dirEsm = makeTmpProject();
const dirCjs = makeTmpProject();
writeConfig(dirEsm, { branching_strategy: 'phase' });
writeConfig(dirCjs, { branching_strategy: 'phase' });
try {
const esmR = await esm.migrateOnDisk(dirEsm);
const cjsR = await cjs.migrateOnDisk(dirCjs);
assert.strictEqual(cjsR.migrated, true);
assert.strictEqual(esmR.migrated, true);
assert.ok(cjsR.wrote !== null);
assert.ok(esmR.wrote !== null);
// Both should have normalized the disk file
const cjsDisk = JSON.parse(readConfigRaw(dirCjs));
const esmDisk = JSON.parse(readConfigRaw(dirEsm));
assert.strictEqual(cjsDisk.branching_strategy, undefined);
assert.strictEqual(esmDisk.branching_strategy, undefined);
assert.strictEqual(cjsDisk.git?.branching_strategy, 'phase');
assert.strictEqual(esmDisk.git?.branching_strategy, 'phase');
} finally {
cleanup(dirEsm);
cleanup(dirCjs);
}
});
test('missing file returns migrated:false, wrote:null', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-parity-md-'));
try {
const esmR = await esm.migrateOnDisk(dir);
const cjsR = await cjs.migrateOnDisk(dir);
assert.strictEqual(cjsR.migrated, false);
assert.strictEqual(esmR.migrated, false);
assert.strictEqual(cjsR.wrote, null);
assert.strictEqual(esmR.wrote, null);
} finally {
cleanup(dir);
}
});
});