Merge pull request #3540 from gsd-build/feat/3536-configuration-module

feat(3536): Configuration Module via shared manifests + generator (Phase 2 of #3524)
This commit is contained in:
Tom Boucher
2026-05-15 09:33:22 -04:00
committed by GitHub
29 changed files with 2228 additions and 522 deletions

View File

@@ -154,10 +154,13 @@ describe('SDK wiring for #2492 gates', () => {
test('config-schema.ts VALID_CONFIG_KEYS allows workflow.context_coverage_gate', () => {
// #2653 — allowlist moved out of config-mutation.ts into shared config-schema.ts.
const c = fs.readFileSync(CONFIG_SCHEMA_TS, 'utf-8');
// After Cycle 5 (#3536), config-schema.ts is a thin adapter; verify via the
// manifest (the single source of truth for both CJS and SDK).
const manifestPath = path.join(__dirname, '..', 'sdk', 'shared', 'config-schema.manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
assert.ok(
c.includes("'workflow.context_coverage_gate'"),
'workflow.context_coverage_gate must be in VALID_CONFIG_KEYS',
manifest.validKeys.includes('workflow.context_coverage_gate'),
'workflow.context_coverage_gate must be in manifest validKeys (SDK config-schema.ts sources from manifest)',
);
});

View File

@@ -25,12 +25,16 @@ function runGsd(args, cwd) {
describe('bug #3212 execute-phase stall detection and safe resume', () => {
test('config schemas register executor stall detector keys', () => {
const cjs = require('../get-shit-done/bin/lib/config-schema.cjs');
const sdk = read('sdk/src/query/config-schema.ts');
// After Cycle 5 (#3536), both CJS and SDK source from the manifest.
// Use the CJS runtime Set for CJS; use the manifest directly for SDK-side
// verification (since config-schema.ts no longer has inline literals).
const { VALID_CONFIG_KEYS: cjsKeys } = require('../get-shit-done/bin/lib/config-schema.cjs');
const manifest = JSON.parse(read('sdk/shared/config-schema.manifest.json'));
const manifestKeys = new Set(manifest.validKeys);
for (const key of ['executor.stall_detect_interval_minutes', 'executor.stall_threshold_minutes']) {
assert.ok(cjs.VALID_CONFIG_KEYS.has(key), `CJS VALID_CONFIG_KEYS must include ${key}`);
assert.ok(sdk.includes(`'${key}'`), `SDK VALID_CONFIG_KEYS must include ${key}`);
assert.ok(cjsKeys.has(key), `CJS VALID_CONFIG_KEYS must include ${key}`);
assert.ok(manifestKeys.has(key), `Manifest validKeys must include ${key} (SDK sources from manifest)`);
}
});

View File

@@ -1,20 +1,29 @@
'use strict';
// allow-test-rule: structural source assertion — verifies that sdk/src/query/config-schema.ts
// is a re-export shell (no inline literals) rather than a redeclared Set. Runtime/IR comparison
// cannot distinguish a re-export from a redeclared `new Set([...])` with identical contents
// because both would deep-equal the manifest set; only source-shape inspection catches drift
// back to inline literals. See test "SDK config-schema.ts re-exports from configuration module".
/**
* CJS↔SDK config-schema parity (#2653).
* Manifest-as-source-of-truth guard (Phase 2, Cycle 5, #3536).
*
* The SDK has its own config-set handler at sdk/src/query/config-mutation.ts,
* which validates keys against sdk/src/query/config-schema.ts. That allowlist
* MUST match the CJS allowlist at get-shit-done/bin/lib/config-schema.cjs or
* SDK users are told "Unknown config key" for documented keys (regression
* that #2653 fixes).
* Prior to Cycle 5, the CJS and SDK schema files each had independent inline
* literals. This test existed to prevent drift between them. After Cycle 5,
* BOTH sides derive their data from sdk/shared/config-schema.manifest.json,
* so there is nothing to drift — but the guard still serves a purpose:
*
* This test parses the TS file as text (to avoid requiring a TS toolchain
* in the node:test runner) and asserts:
* 1. Every key in CJS VALID_CONFIG_KEYS appears in the SDK literal set.
* 2. Every dynamic pattern source in CJS has an identical counterpart
* in the SDK file.
* 3. The reverse direction — SDK has no keys/patterns the CJS side lacks.
* 1. Confirm that VALID_CONFIG_KEYS loaded at runtime from config-schema.cjs
* exactly matches the manifest's validKeys array.
* 2. Confirm that VALID_CONFIG_KEYS exported from sdk/src/query/config-schema.ts
* (via sdk/dist/) equals the same manifest.
* 3. Confirm that DYNAMIC_KEY_PATTERNS from config-schema.cjs have .source
* fields matching the manifest's dynamicKeyPatterns.
* 4. Confirm RUNTIME_STATE_KEYS from config-schema.cjs matches the manifest.
*
* This ensures neither side has accidentally disconnected from the manifest
* (e.g. reverted to inline literals or switched to a different data source).
*/
const { test } = require('node:test');
@@ -23,96 +32,115 @@ const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
const MANIFEST_PATH = path.join(ROOT, 'sdk', 'shared', 'config-schema.manifest.json');
const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
const manifestValidKeys = new Set(manifest.validKeys);
const manifestRuntimeKeys = new Set(manifest.runtimeStateKeys);
const manifestPatternSources = manifest.dynamicKeyPatterns.map((p) => p.source);
const {
VALID_CONFIG_KEYS: CJS_KEYS,
RUNTIME_STATE_KEYS: CJS_RUNTIME_KEYS,
DYNAMIC_KEY_PATTERNS: CJS_PATTERNS,
} =
require('../get-shit-done/bin/lib/config-schema.cjs');
} = require('../get-shit-done/bin/lib/config-schema.cjs');
const SDK_SCHEMA_PATH = path.join(ROOT, 'sdk', 'src', 'query', 'config-schema.ts');
const SDK_SRC = fs.readFileSync(SDK_SCHEMA_PATH, 'utf8');
// ─── CJS side: verify manifest-sourced values ─────────────────────────────
function extractSdkSet(src, setName) {
const start = src.indexOf(setName);
assert.ok(start > -1, `SDK config-schema.ts must export ${setName}`);
const setOpen = src.indexOf('new Set([', start);
const setClose = src.indexOf('])', setOpen);
assert.ok(setOpen > -1 && setClose > -1, `${setName} must be a new Set([...]) literal`);
const body = src.slice(setOpen + 'new Set(['.length, setClose);
const keys = new Set();
for (const match of body.matchAll(/'([^']+)'/g)) keys.add(match[1]);
return keys;
}
function extractSdkPatternSources(src) {
const sources = [];
for (const match of src.matchAll(/source:\s*'([^']+)'/g)) {
// TS source file stores escape sequences; convert \\ -> \ so the
// extracted value matches RegExp.source from the CJS side.
sources.push(match[1].replace(/\\\\/g, '\\'));
}
return sources;
}
test('#2653 — SDK VALID_CONFIG_KEYS matches CJS VALID_CONFIG_KEYS', () => {
const sdkKeys = extractSdkSet(SDK_SRC, 'VALID_CONFIG_KEYS');
const missingInSdk = [...CJS_KEYS].filter((k) => !sdkKeys.has(k));
const extraInSdk = [...sdkKeys].filter((k) => !CJS_KEYS.has(k));
test('CJS VALID_CONFIG_KEYS matches manifest validKeys exactly', () => {
const missingInCjs = [...manifestValidKeys].filter((k) => !CJS_KEYS.has(k));
const extraInCjs = [...CJS_KEYS].filter((k) => !manifestValidKeys.has(k));
assert.deepStrictEqual(
missingInSdk,
missingInCjs,
[],
'CJS keys missing from sdk/src/query/config-schema.ts:\n' +
missingInSdk.map((k) => ' ' + k).join('\n'),
'Manifest keys missing from CJS VALID_CONFIG_KEYS:\n' +
missingInCjs.map((k) => ' ' + k).join('\n'),
);
assert.deepStrictEqual(
extraInSdk,
extraInCjs,
[],
'SDK keys missing from get-shit-done/bin/lib/config-schema.cjs:\n' +
extraInSdk.map((k) => ' ' + k).join('\n'),
'CJS VALID_CONFIG_KEYS has keys not in manifest:\n' +
extraInCjs.map((k) => ' ' + k).join('\n'),
);
});
test('#3162 — SDK RUNTIME_STATE_KEYS matches CJS RUNTIME_STATE_KEYS', () => {
const sdkRuntimeKeys = extractSdkSet(SDK_SRC, 'RUNTIME_STATE_KEYS');
const missingInSdk = [...CJS_RUNTIME_KEYS].filter((k) => !sdkRuntimeKeys.has(k));
const extraInSdk = [...sdkRuntimeKeys].filter((k) => !CJS_RUNTIME_KEYS.has(k));
assert.deepStrictEqual(
missingInSdk,
[],
'CJS runtime-state keys missing from sdk/src/query/config-schema.ts:\n' +
missingInSdk.map((k) => ' ' + k).join('\n'),
);
assert.deepStrictEqual(
extraInSdk,
[],
'SDK runtime-state keys missing from get-shit-done/bin/lib/config-schema.cjs:\n' +
extraInSdk.map((k) => ' ' + k).join('\n'),
);
test('CJS RUNTIME_STATE_KEYS matches manifest runtimeStateKeys exactly', () => {
const missingInCjs = [...manifestRuntimeKeys].filter((k) => !CJS_RUNTIME_KEYS.has(k));
const extraInCjs = [...CJS_RUNTIME_KEYS].filter((k) => !manifestRuntimeKeys.has(k));
assert.deepStrictEqual(missingInCjs, [], 'Manifest runtime keys missing from CJS RUNTIME_STATE_KEYS');
assert.deepStrictEqual(extraInCjs, [], 'CJS RUNTIME_STATE_KEYS has keys not in manifest');
});
test('#2653 — SDK DYNAMIC_KEY_PATTERNS sources match CJS regex .source', () => {
const sdkSources = new Set(extractSdkPatternSources(SDK_SRC));
const cjsSources = CJS_PATTERNS.map((p) => {
// Reconstruct each CJS pattern's .source by probing with a known string
// that identifies the regex. CJS stores a `test` arrow only, so derive
// `.source` by running against sentinel inputs — instead, inspect function
// text as a fallback cross-check.
const fnSrc = p.test.toString();
const regexMatch = fnSrc.match(/\/(\^[^/]+\$)\//);
assert.ok(regexMatch, 'CJS dynamic pattern test function must embed a literal regex: ' + fnSrc);
return regexMatch[1];
});
for (const src of cjsSources) {
assert.ok(
sdkSources.has(src),
`CJS dynamic pattern ${src} not mirrored in SDK config-schema.ts (sources: ${[...sdkSources].join(', ')})`,
);
}
for (const src of sdkSources) {
assert.ok(
cjsSources.includes(src),
`SDK dynamic pattern ${src} not mirrored in CJS config-schema.cjs`,
test('CJS DYNAMIC_KEY_PATTERNS .source fields match manifest dynamicKeyPatterns', () => {
assert.strictEqual(
CJS_PATTERNS.length,
manifestPatternSources.length,
`CJS has ${CJS_PATTERNS.length} patterns but manifest has ${manifestPatternSources.length}`,
);
for (let i = 0; i < manifestPatternSources.length; i++) {
const expected = manifestPatternSources[i];
const actual = CJS_PATTERNS[i].source;
assert.strictEqual(
actual,
expected,
`CJS pattern[${i}].source mismatch: expected "${expected}", got "${actual}"`,
);
}
});
// ─── SDK side: verify config-schema.ts re-exports from configuration module ─
test('SDK config-schema.ts re-exports from configuration module (not inline literals)', () => {
const SDK_SCHEMA_PATH = path.join(ROOT, 'sdk', 'src', 'query', 'config-schema.ts');
const src = fs.readFileSync(SDK_SCHEMA_PATH, 'utf8');
// After Cycle 5, the file must NOT contain inline key literals.
// It should import/re-export from '../configuration/index.js'.
assert.ok(
src.includes("from '../configuration/index.js'"),
'sdk/src/query/config-schema.ts must re-export from ../configuration/index.js (not inline literals)',
);
// Must NOT contain a standalone new Set([...]) block with key literals.
// A minimal check: the file should not define VALID_CONFIG_KEYS as a Set literal.
assert.ok(
!src.includes("new Set([\n 'mode'") && !src.includes("new Set(['mode'"),
'sdk/src/query/config-schema.ts must not contain an inline VALID_CONFIG_KEYS Set literal',
);
});
// ─── Cross-check: CJS equals SDK via manifest ─────────────────────────────
test('#2653 — CJS and SDK both source from the same manifest (set equality via manifest)', () => {
// Since both sides derive from sdk/shared/config-schema.manifest.json,
// the CJS runtime set must equal the manifest set (verified above).
// This test is the explicit statement of the invariant for audit purposes.
const cjsKeysSorted = [...CJS_KEYS].sort();
const manifestKeysSorted = [...manifestValidKeys].sort();
assert.deepStrictEqual(
cjsKeysSorted,
manifestKeysSorted,
'CJS VALID_CONFIG_KEYS must equal manifest validKeys — both sides source from the manifest',
);
});
test('#2653 — CJS DYNAMIC_KEY_PATTERNS test functions work correctly', () => {
// Verify that each pattern's test() function (reconstructed from manifest source)
// correctly accepts sample keys and rejects non-matching ones.
const samples = [
['agent_skills.gsd-planner', 0],
['review.models.claude', 1],
['features.some_feature', 2],
['claude_md_assembly.blocks.intro', 3],
['model_profile_overrides.codex.opus', 4],
['models.planning', 5],
['dynamic_routing.enabled', 6],
['model_overrides.my-agent', 7],
];
for (const [key, idx] of samples) {
assert.ok(
CJS_PATTERNS[idx].test(key),
`CJS pattern[${idx}] must accept "${key}"`,
);
}
});

View File

@@ -0,0 +1,355 @@
'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);
}
});
});

View File

@@ -0,0 +1,178 @@
'use strict';
/**
* Tests for `gsd-tools migrate-config` subcommand (#3536).
*
* Covers the three acceptance-criteria cases:
* 1. No-op when config is already canonical (migrated: false)
* 2. Migrates when top-level branching_strategy is present (migrated: true)
* 3. Idempotent: running twice produces no-op the second time
*
* Also covers the --raw human-readable output path.
*/
const { describe, test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
GEMINI_SESSION_ID: '',
CURSOR_SESSION_ID: '',
WINDSURF_SESSION_ID: '',
TERM_SESSION_ID: '',
WT_SESSION: '',
TMUX_PANE: '',
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
};
function runMigrateConfig(cwd, extraArgs = [], env = {}) {
const result = spawnSync(process.execPath, [TOOLS_PATH, 'migrate-config', ...extraArgs], {
cwd,
encoding: 'utf-8',
env: { ...process.env, ...TEST_ENV_BASE, ...env },
});
return {
stdout: result.stdout || '',
stderr: result.stderr || '',
status: result.status,
};
}
// ─── Test 1: No-op when config is already canonical ──────────────────────────
describe('migrate-config — no-op on already-canonical config', () => {
let tmpDir;
afterEach(() => {
if (tmpDir) cleanup(tmpDir);
tmpDir = null;
});
test('returns migrated: false when no legacy keys present', () => {
tmpDir = createTempProject('gsd-migrate-noop-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({
git: { branching_strategy: 'phase', base_branch: 'main' },
workflow: { research: true },
}, null, 2),
'utf-8'
);
const result = runMigrateConfig(tmpDir);
assert.equal(
result.status,
0,
`migrate-config must exit 0 on no-op — status ${result.status}, stderr: ${result.stderr}`
);
assert.equal(result.stderr.trim(), '', `No stderr expected — got: ${result.stderr}`);
const parsed = JSON.parse(result.stdout);
assert.equal(parsed.migrated, false, 'migrated must be false for canonical config');
assert.deepEqual(parsed.normalizations, [], 'normalizations must be empty for canonical config');
assert.equal(parsed.wrote, null, 'wrote must be null for no-op');
});
});
// ─── Test 2: Migrates when top-level branching_strategy is present ────────────
describe('migrate-config — migrates legacy branching_strategy', () => {
let tmpDir;
afterEach(() => {
if (tmpDir) cleanup(tmpDir);
tmpDir = null;
});
test('returns migrated: true and normalizations for top-level branching_strategy', () => {
tmpDir = createTempProject('gsd-migrate-bs-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({
branching_strategy: 'milestone',
git: { base_branch: 'main' },
}, null, 2),
'utf-8'
);
const result = runMigrateConfig(tmpDir);
assert.equal(
result.status,
0,
`migrate-config must exit 0 — status ${result.status}, stderr: ${result.stderr}`
);
assert.equal(result.stderr.trim(), '', `No stderr expected — got: ${result.stderr}`);
const parsed = JSON.parse(result.stdout);
assert.equal(parsed.migrated, true, 'migrated must be true when legacy key present');
assert.ok(
parsed.normalizations.some(n => n.from === 'branching_strategy' && n.to === 'git.branching_strategy'),
`normalizations must include branching_strategy→git.branching_strategy entry. Got: ${JSON.stringify(parsed.normalizations)}`
);
assert.ok(typeof parsed.wrote === 'string', 'wrote must be a file path string');
// Verify on-disk result
const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.equal(
onDisk.git?.branching_strategy,
'milestone',
'On-disk config must have git.branching_strategy = "milestone" after migration'
);
assert.equal(
onDisk.branching_strategy,
undefined,
'On-disk config must not have top-level branching_strategy after migration'
);
});
});
// ─── Test 3: Idempotent ───────────────────────────────────────────────────────
describe('migrate-config — idempotent (running twice produces no-op)', () => {
let tmpDir;
afterEach(() => {
if (tmpDir) cleanup(tmpDir);
tmpDir = null;
});
test('second run is a no-op after first run migrated the config', () => {
tmpDir = createTempProject('gsd-migrate-idem-');
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(
configPath,
JSON.stringify({
branching_strategy: 'phase',
git: { base_branch: 'main' },
}, null, 2),
'utf-8'
);
// First run — must migrate
const first = runMigrateConfig(tmpDir);
assert.equal(first.status, 0, `First run must exit 0 — status ${first.status}`);
const firstParsed = JSON.parse(first.stdout);
assert.equal(firstParsed.migrated, true, 'First run must migrate');
// Second run — must be no-op
const second = runMigrateConfig(tmpDir);
assert.equal(second.status, 0, `Second run must exit 0 — status ${second.status}`);
const secondParsed = JSON.parse(second.stdout);
assert.equal(secondParsed.migrated, false, 'Second run must be a no-op (idempotent)');
assert.deepEqual(secondParsed.normalizations, [], 'Second run normalizations must be empty');
});
});

View File

@@ -274,22 +274,20 @@ describe('feat-3210: M2 - node_modules/.bin resolution order', () => {
describe('feat-3210: workflow and config contracts', () => {
test('config schema allows code_quality.fallow.* keys in CJS and SDK', () => {
const cjsSchema = fs.readFileSync(
path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'config-schema.cjs'),
'utf8',
);
const sdkSchema = fs.readFileSync(
path.join(ROOT, 'sdk', 'src', 'query', 'config-schema.ts'),
'utf8',
);
// After Cycle 5 (#3536), both CJS and SDK source from the manifest.
// Use the CJS runtime Set and the manifest directly (no inline text parsing).
const { VALID_CONFIG_KEYS } = require('../get-shit-done/bin/lib/config-schema.cjs');
const manifestPath = path.join(ROOT, 'sdk', 'shared', 'config-schema.manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const manifestKeys = new Set(manifest.validKeys);
for (const key of [
'code_quality.fallow.enabled',
'code_quality.fallow.scope',
'code_quality.fallow.profile',
'code_quality.fallow.mcp',
]) {
assert.ok(cjsSchema.includes(`'${key}'`), `missing CJS config key: ${key}`);
assert.ok(sdkSchema.includes(`'${key}'`), `missing SDK config key: ${key}`);
assert.ok(VALID_CONFIG_KEYS.has(key), `missing CJS config key: ${key}`);
assert.ok(manifestKeys.has(key), `missing manifest key: ${key} (SDK sources from manifest)`);
}
});

View File

@@ -478,11 +478,13 @@ describe('plan-review-convergence workflow: success criteria (#2306-v2)', () =>
// ─── Config schema registration ───────────────────────────────────────────
describe('plan-review-convergence config schema registration (#2306-v2)', () => {
const schema = fs.readFileSync(SCHEMA_PATH, 'utf8');
// After Cycle 5 (#3536), config-schema.cjs is a thin adapter sourcing from
// the manifest. Use the runtime Set instead of text-parsing the source file.
const { VALID_CONFIG_KEYS } = require('../get-shit-done/bin/lib/config-schema.cjs');
test('workflow.plan_review_convergence is registered in config-schema.cjs', () => {
assert.ok(
schema.includes("'workflow.plan_review_convergence'"),
VALID_CONFIG_KEYS.has('workflow.plan_review_convergence'),
"workflow.plan_review_convergence must be registered in VALID_CONFIG_KEYS in config-schema.cjs so gsd config-set accepts it (#2306-v2)"
);
});
@@ -538,25 +540,27 @@ describe('plan-review-convergence local model reviewer flags (#2306-local)', ()
});
describe('plan-review-convergence local model config schema registration (#2306-local)', () => {
const schema = fs.readFileSync(SCHEMA_PATH, 'utf8');
// After Cycle 5 (#3536), config-schema.cjs is a thin adapter sourcing from
// the manifest. Use the runtime Set instead of text-parsing the source file.
const { VALID_CONFIG_KEYS } = require('../get-shit-done/bin/lib/config-schema.cjs');
test('review.ollama_host is registered in config-schema.cjs', () => {
assert.ok(
schema.includes("'review.ollama_host'"),
VALID_CONFIG_KEYS.has('review.ollama_host'),
"review.ollama_host must be in VALID_CONFIG_KEYS so gsd config-set accepts it"
);
});
test('review.lm_studio_host is registered in config-schema.cjs', () => {
assert.ok(
schema.includes("'review.lm_studio_host'"),
VALID_CONFIG_KEYS.has('review.lm_studio_host'),
"review.lm_studio_host must be in VALID_CONFIG_KEYS so gsd config-set accepts it"
);
});
test('review.llama_cpp_host is registered in config-schema.cjs', () => {
assert.ok(
schema.includes("'review.llama_cpp_host'"),
VALID_CONFIG_KEYS.has('review.llama_cpp_host'),
"review.llama_cpp_host must be in VALID_CONFIG_KEYS so gsd config-set accepts it"
);
});