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.
179 lines
5.9 KiB
JavaScript
179 lines
5.9 KiB
JavaScript
'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');
|
|
});
|
|
});
|