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.
356 lines
14 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
});
|