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:
5
.changeset/patient-lemurs-sing.md
Normal file
5
.changeset/patient-lemurs-sing.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 3540
|
||||
---
|
||||
**Configuration Module unifies CJS and SDK config sources** — `CONFIG_DEFAULTS`, `VALID_CONFIG_KEYS`, `DYNAMIC_KEY_PATTERNS`, and the four legacy-key migrations (`branching_strategy`, `sub_repos`, `multiRepo`, `depth`) now derive from two canonical manifests (`sdk/shared/config-{defaults,schema}.manifest.json`) via a generator-emitted mirror, eliminating the parallel-definition drift that caused the #1535/#1542/#2047/#2638/#2653/#2687/#2798/#3055/#3523 bug class. `mergeDefaults` now recursively deep-merges instead of spreading per-section, preserving sibling keys under partial overlays. Adds a new opt-in `gsd-tools migrate-config` subcommand for explicit on-disk canonicalization of legacy-key shapes.
|
||||
@@ -8,3 +8,7 @@ fi
|
||||
if git diff --cached --name-only | grep -Eq "^sdk/src/query/state-document\.|^get-shit-done/bin/lib/state-document\.generated\.cjs$|^sdk/scripts/gen-state-document\.ts$|^sdk/scripts/check-state-document-fresh\.mjs$"; then
|
||||
npm run check:state-document-fresh
|
||||
fi
|
||||
|
||||
if git diff --cached --name-only | grep -Eq "^sdk/src/configuration/|^sdk/shared/config-(defaults|schema)\.manifest\.json$|^get-shit-done/bin/lib/configuration\.generated\.cjs$|^sdk/scripts/gen-configuration\.mjs$"; then
|
||||
npm run check:configuration-fresh
|
||||
fi
|
||||
|
||||
5
.github/workflows/test.yml
vendored
5
.github/workflows/test.yml
vendored
@@ -108,6 +108,11 @@ jobs:
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-state-document-fresh.mjs
|
||||
|
||||
- name: SDK generated configuration artifact drift check
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 24
|
||||
shell: bash
|
||||
run: node sdk/scripts/check-configuration-fresh.mjs
|
||||
|
||||
- name: Run tests with coverage
|
||||
shell: bash
|
||||
run: npm run test:coverage
|
||||
|
||||
@@ -52,6 +52,9 @@ Compatibility Adapter Module for `gsd-tools.cjs` command families. Uses generate
|
||||
### Query Pre-Project Config Policy Module
|
||||
Module policy that defines query-time behavior when `.planning/config.json` is absent: use built-in defaults for parity-sensitive query Interfaces, and emit parity-aligned empty model ids for pre-project model resolution surfaces.
|
||||
|
||||
### Configuration Module
|
||||
Shared CJS/SDK Module owning config load, legacy-key normalization, defaults merge, and explicit on-disk migration for `.planning/config.json`. Interface: `loadConfig(cwd) → MergedConfig` (pure read, never writes disk), `normalizeLegacyKeys(parsed) → { parsed, normalizations[] }` (idempotent, pure, returns the list of normalizations applied), `mergeDefaults(parsed) → MergedConfig` (deep-merge of parsed config over canonical defaults), `migrateOnDisk(cwd) → MigrationReport` (explicit, opt-in, called by the installer and by `gsd-tools migrate-config`). Invariants: never mutates disk inside `loadConfig`; legacy top-level keys (`branching_strategy`, `sub_repos`, `multiRepo`, `depth`) are normalized into their canonical nested locations in the returned value; defaults come from the shared `sdk/shared/config-defaults.manifest.json`; schema (`VALID_CONFIG_KEYS`, `RUNTIME_STATE_KEYS`, `DYNAMIC_KEY_PATTERNS`) comes from `sdk/shared/config-schema.manifest.json`. Source of truth: `sdk/src/configuration/index.ts`; CJS callers consume the generator-emitted `get-shit-done/bin/lib/configuration.generated.cjs` via the thin Adapters at `bin/lib/core.cjs:loadConfig` and `bin/lib/config-schema.cjs`. Eliminates the recurring #3523-class drift bug structurally.
|
||||
|
||||
### Planning Workspace Module
|
||||
Module owning `.planning` path resolution, active workstream pointer policy (`session-scoped > shared`), pointer self-heal behavior, and planning lock semantics for workstream-aware execution.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"generated": "2026-05-14",
|
||||
"generated": "2026-05-15",
|
||||
"families": {
|
||||
"agents": [
|
||||
"gsd-advisor-researcher",
|
||||
@@ -268,6 +268,7 @@
|
||||
"commands.cjs",
|
||||
"config-schema.cjs",
|
||||
"config.cjs",
|
||||
"configuration.generated.cjs",
|
||||
"context-utilization.cjs",
|
||||
"core.cjs",
|
||||
"decisions.cjs",
|
||||
|
||||
@@ -360,7 +360,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
|
||||
|
||||
---
|
||||
|
||||
## CLI Modules (60 shipped)
|
||||
## CLI Modules (61 shipped)
|
||||
|
||||
Full listing: `get-shit-done/bin/lib/*.cjs`.
|
||||
|
||||
@@ -376,6 +376,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
|
||||
| `commands.cjs` | Misc CLI commands (slug, timestamp, todos, scaffolding, stats) |
|
||||
| `config-schema.cjs` | Single source of truth for `VALID_CONFIG_KEYS` and dynamic key patterns; imported by both the validator and the config-schema-docs parity test |
|
||||
| `config.cjs` | `config.json` read/write, section initialization; imports validator from `config-schema.cjs` |
|
||||
| `configuration.generated.cjs` | Generated Configuration Module — canonical config loading, legacy-key normalization, defaults merge, and explicit on-disk migration; source of truth for both SDK and CJS consumers |
|
||||
| `context-utilization.cjs` | Pure classifier for `gsd-health --context` — turns (tokensUsed, contextWindow) into a `{ percent, state }` triage result against the 60%/70% fracture-point thresholds (#2792) |
|
||||
| `core.cjs` | Error handling, output formatting, shared utilities, runtime fallbacks; compatibility re-exports for planning-workspace helpers |
|
||||
| `decisions.cjs` | Shared parser for CONTEXT.md `<decisions>` blocks (D-NN entries); used by `gap-checker.cjs` and intended for #2492 plan/verify decision gates |
|
||||
|
||||
@@ -367,7 +367,7 @@ async function main() {
|
||||
// phase / roadmap / milestone / progress / etc.
|
||||
const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>] [--json-errors]\n' +
|
||||
'Commands: agent-skills, audit-open, audit-uat, check-commit, commit, commit-to-subrepo, ' +
|
||||
'config-ensure-section, config-get, config-new-project, config-path, config-set, ' +
|
||||
'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, ' +
|
||||
'current-timestamp, detect-custom-files, docs-init, extract-messages, find-phase, ' +
|
||||
'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
|
||||
'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
|
||||
@@ -668,6 +668,13 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
break;
|
||||
}
|
||||
|
||||
case 'migrate-config': {
|
||||
// Explicit on-disk migration of legacy config keys to canonical shape (#3536).
|
||||
// Wraps Configuration Module migrateOnDisk(); idempotent. async — must await.
|
||||
await config.cmdMigrateConfig(cwd, raw);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'agent-skills': {
|
||||
init.cmdAgentSkills(cwd, args[1], raw);
|
||||
break;
|
||||
|
||||
@@ -1,127 +1,23 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Single source of truth for valid config key paths.
|
||||
* Thin adapter — sources schema data from the manifest via the generated
|
||||
* Configuration Module. All inline literals have been removed; the manifest
|
||||
* at sdk/shared/config-schema.manifest.json is the single source of truth.
|
||||
*
|
||||
* Imported by:
|
||||
* - config.cjs (isValidConfigKey validator)
|
||||
* - tests/config-schema-docs-parity.test.cjs (CI drift guard)
|
||||
* - tests/config-schema-sdk-parity.test.cjs (CJS↔SDK parity guard)
|
||||
*
|
||||
* Adding a key here without documenting it in docs/CONFIGURATION.md will
|
||||
* fail the parity test. Adding a key to docs/CONFIGURATION.md without
|
||||
* adding it here will cause config-set to reject it at runtime.
|
||||
* See Phase 2 Cycle 5 (#3536) — schema manifest migration.
|
||||
*/
|
||||
|
||||
/** Exact-match config key paths accepted by config-set. */
|
||||
const VALID_CONFIG_KEYS = new Set([
|
||||
'mode', 'granularity', 'parallelization', 'commit_docs', 'model_profile',
|
||||
'search_gitignored', 'brave_search', 'firecrawl', 'exa_search',
|
||||
'workflow.research', 'workflow.plan_check', 'workflow.verifier',
|
||||
'workflow.nyquist_validation', 'workflow.ai_integration_phase', 'workflow.ui_phase', 'workflow.ui_safety_gate',
|
||||
'workflow.auto_advance', 'workflow.node_repair', 'workflow.node_repair_budget',
|
||||
'workflow.tdd_mode',
|
||||
'workflow.human_verify_mode',
|
||||
'workflow.text_mode',
|
||||
'workflow.research_before_questions',
|
||||
'workflow.discuss_mode',
|
||||
'workflow.skip_discuss',
|
||||
'workflow.auto_prune_state',
|
||||
'workflow.use_worktrees',
|
||||
'workflow.worktree_skip_hooks',
|
||||
'workflow.code_review',
|
||||
'workflow.code_review_depth',
|
||||
'workflow.code_review_command',
|
||||
'workflow.pattern_mapper',
|
||||
'workflow.plan_bounce',
|
||||
'workflow.plan_bounce_script',
|
||||
'workflow.plan_bounce_passes',
|
||||
'workflow.plan_chunked',
|
||||
'workflow.plan_review_convergence',
|
||||
'workflow.post_planning_gaps',
|
||||
'workflow.security_enforcement',
|
||||
'workflow.security_asvs_level',
|
||||
'workflow.security_block_on',
|
||||
'workflow.drift_threshold',
|
||||
'workflow.drift_action',
|
||||
'code_quality.fallow.enabled',
|
||||
'code_quality.fallow.scope',
|
||||
'code_quality.fallow.profile',
|
||||
'code_quality.fallow.mcp',
|
||||
'ship.pr_body_sections',
|
||||
'git.branching_strategy', 'git.base_branch', 'git.create_tag', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template',
|
||||
'planning.commit_docs', 'planning.search_gitignored', 'planning.sub_repos',
|
||||
'review.ollama_host', 'review.lm_studio_host', 'review.llama_cpp_host',
|
||||
'review.default_reviewers',
|
||||
'workflow.cross_ai_execution', 'workflow.cross_ai_command', 'workflow.cross_ai_timeout',
|
||||
'workflow.subagent_timeout',
|
||||
'executor.stall_detect_interval_minutes',
|
||||
'executor.stall_threshold_minutes',
|
||||
'workflow.inline_plan_threshold',
|
||||
'hooks.context_warnings',
|
||||
'hooks.workflow_guard',
|
||||
'workflow.context_coverage_gate',
|
||||
'statusline.show_last_command',
|
||||
'statusline.context_position',
|
||||
'workflow.ui_review',
|
||||
'workflow.max_discuss_passes',
|
||||
'features.thinking_partner',
|
||||
'context',
|
||||
'features.global_learnings',
|
||||
'learnings.max_inject',
|
||||
'project_code', 'phase_naming',
|
||||
'manager.flags.discuss', 'manager.flags.plan', 'manager.flags.execute',
|
||||
'response_language',
|
||||
'context_window',
|
||||
'intel.enabled',
|
||||
'graphify.enabled',
|
||||
'graphify.build_timeout',
|
||||
'claude_md_path',
|
||||
'claude_md_assembly.mode',
|
||||
// #2517 — runtime-aware model profiles
|
||||
'runtime',
|
||||
// #3162 — documented top-level key: controls model ID resolution for non-Claude runtimes
|
||||
'resolve_model_ids',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Internal runtime-state keys — accepted by config-set (workflows write them) but not
|
||||
* exposed as user-settable options. Excluded from VALID_CONFIG_KEYS so they stay out of
|
||||
* the public docs-parity check and the "Valid keys:" error message.
|
||||
* See: #3162 (workflow._auto_chain_active written by plan/execute/discuss workflows)
|
||||
*/
|
||||
const RUNTIME_STATE_KEYS = new Set([
|
||||
'workflow._auto_chain_active',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Dynamic-pattern validators — keys matching these regexes are also accepted.
|
||||
* Each entry has a `test` function and a human-readable `description`.
|
||||
*/
|
||||
const DYNAMIC_KEY_PATTERNS = [
|
||||
{ topLevel: 'agent_skills', test: (k) => /^agent_skills\.[a-zA-Z0-9_-]+$/.test(k), description: 'agent_skills.<agent-type>' },
|
||||
{ topLevel: 'review', test: (k) => /^review\.models\.[a-zA-Z0-9_-]+$/.test(k), description: 'review.models.<cli-name>' },
|
||||
{ topLevel: 'features', test: (k) => /^features\.[a-zA-Z0-9_]+$/.test(k), description: 'features.<feature_name>' },
|
||||
{ topLevel: 'claude_md_assembly', test: (k) => /^claude_md_assembly\.blocks\.[a-zA-Z0-9_]+$/.test(k), description: 'claude_md_assembly.blocks.<section>' },
|
||||
// #2517 — runtime-aware model profile overrides: model_profile_overrides.<runtime>.<tier>
|
||||
// <runtime> is a free string (so users can map non-built-in runtimes); <tier> is enum-restricted.
|
||||
{ topLevel: 'model_profile_overrides', test: (k) => /^model_profile_overrides\.[a-zA-Z0-9_-]+\.(opus|sonnet|haiku)$/.test(k),
|
||||
description: 'model_profile_overrides.<runtime>.<opus|sonnet|haiku>' },
|
||||
// #3023 — per-phase-type model map: models.<phase_type> = <tier>
|
||||
// Six named slots (planning/discuss/research/execution/verification/completion);
|
||||
// unknown phase-types are rejected. Per-agent model_overrides still take
|
||||
// precedence over phase-type at resolve time.
|
||||
{ topLevel: 'models', test: (k) => /^models\.(planning|discuss|research|execution|verification|completion)$/.test(k),
|
||||
description: 'models.<planning|discuss|research|execution|verification|completion>' },
|
||||
// #3024 — dynamic routing block. Three top-level scalar settings
|
||||
// plus a tier_models sub-block keyed by light/standard/heavy.
|
||||
{ topLevel: 'dynamic_routing',
|
||||
test: (k) => /^dynamic_routing\.(enabled|escalate_on_failure|max_escalations|tier_models\.(light|standard|heavy))$/.test(k),
|
||||
description: 'dynamic_routing.<enabled|escalate_on_failure|max_escalations|tier_models.<light|standard|heavy>>' },
|
||||
// #3227 — per-agent model overrides: model_overrides.<agent-id>
|
||||
// Full model IDs (e.g. "openai/o3") and tier aliases (opus/sonnet/haiku/inherit)
|
||||
// are both accepted. Value validation is handled by the resolver at read time.
|
||||
{ topLevel: 'model_overrides', test: (k) => /^model_overrides\.[a-zA-Z0-9_-]+$/.test(k), description: 'model_overrides.<agent-id>' },
|
||||
];
|
||||
const {
|
||||
VALID_CONFIG_KEYS,
|
||||
RUNTIME_STATE_KEYS,
|
||||
DYNAMIC_KEY_PATTERNS,
|
||||
} = require('./configuration.generated.cjs');
|
||||
|
||||
/**
|
||||
* Returns true if keyPath is a valid config key (exact, dynamic pattern, or runtime state).
|
||||
|
||||
@@ -648,6 +648,40 @@ function cmdConfigPath(cwd) {
|
||||
output(configPath, true, configPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit on-disk migration of legacy config keys to canonical nested shape.
|
||||
*
|
||||
* Wraps the Configuration Module's migrateOnDisk() for the CLI surface. This
|
||||
* is the Phase 2 acceptance-criteria deliverable for opt-in migration (#3536):
|
||||
* users can run `gsd-tools migrate-config` to apply all four legacy-key
|
||||
* migrations to their .planning/config.json without having to load any config
|
||||
* implicitly via another command.
|
||||
*
|
||||
* Output: JSON object with { migrated, normalizations, wrote } or a human-readable
|
||||
* summary when --raw is set. Exits 0 in all cases (including no-op).
|
||||
*/
|
||||
async function cmdMigrateConfig(cwd, raw) {
|
||||
const { migrateOnDisk } = require('./configuration.generated.cjs');
|
||||
const ws = process.env.GSD_WORKSTREAM || null;
|
||||
const report = await migrateOnDisk(cwd, ws || undefined);
|
||||
|
||||
if (raw) {
|
||||
if (!report.migrated) {
|
||||
const msg = 'No legacy keys found — config is already canonical.';
|
||||
output(msg, true, msg);
|
||||
} else {
|
||||
const lines = [
|
||||
`Migrated: ${report.wrote}`,
|
||||
...report.normalizations.map(n => ` ${n.from} → ${n.to}`),
|
||||
].join('\n');
|
||||
output(lines, true, lines);
|
||||
}
|
||||
} else {
|
||||
// output() JSON.stringify's its first arg when raw=false; pass the report object.
|
||||
output(report, false, report);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
VALID_CONFIG_KEYS,
|
||||
cmdConfigEnsureSection,
|
||||
@@ -656,4 +690,5 @@ module.exports = {
|
||||
cmdConfigSetModelProfile,
|
||||
cmdConfigNewProject,
|
||||
cmdConfigPath,
|
||||
cmdMigrateConfig,
|
||||
};
|
||||
|
||||
233
get-shit-done/bin/lib/configuration.generated.cjs
Normal file
233
get-shit-done/bin/lib/configuration.generated.cjs
Normal file
@@ -0,0 +1,233 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT.
|
||||
*
|
||||
* Source: sdk/src/configuration/index.ts
|
||||
* Regenerate: cd sdk && npm run gen:configuration
|
||||
*
|
||||
* Configuration Module — single source of truth for config loading,
|
||||
* legacy-key normalization, defaults merge, and explicit on-disk migration.
|
||||
*/
|
||||
|
||||
const { readFileSync, writeFileSync, existsSync, readdirSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
|
||||
// ─── Manifest requires ───────────────────────────────────────────────────────
|
||||
// Resolved relative to this file: get-shit-done/bin/lib/ → sdk/shared/
|
||||
// This file lives at: get-shit-done/bin/lib/configuration.generated.cjs
|
||||
// sdk/shared lives at: sdk/shared/ (3 dirs up from bin/lib, then into sdk/shared)
|
||||
const CONFIG_DEFAULTS = require('../../../sdk/shared/config-defaults.manifest.json');
|
||||
const SCHEMA_MANIFEST = require('../../../sdk/shared/config-schema.manifest.json');
|
||||
const VALID_CONFIG_KEYS = new Set(SCHEMA_MANIFEST.validKeys);
|
||||
const RUNTIME_STATE_KEYS = new Set(SCHEMA_MANIFEST.runtimeStateKeys);
|
||||
const DYNAMIC_KEY_PATTERNS = SCHEMA_MANIFEST.dynamicKeyPatterns.map((p) => {
|
||||
const pattern = new RegExp(p.source);
|
||||
return {
|
||||
...p,
|
||||
test: (key) => {
|
||||
pattern.lastIndex = 0;
|
||||
return pattern.test(key);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Depth → Granularity mapping ─────────────────────────────────────────────
|
||||
const DEPTH_TO_GRANULARITY = {
|
||||
quick: 'coarse',
|
||||
standard: 'standard',
|
||||
comprehensive: 'fine',
|
||||
};
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||
function planningDir(cwd, workstream) {
|
||||
if (!workstream)
|
||||
return join(cwd, '.planning');
|
||||
return join(cwd, '.planning', 'workstreams', workstream);
|
||||
}
|
||||
|
||||
function detectSubRepos(cwd) {
|
||||
const results = [];
|
||||
try {
|
||||
const entries = readdirSync(cwd, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory())
|
||||
continue;
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
||||
continue;
|
||||
const gitPath = join(cwd, entry.name, '.git');
|
||||
try {
|
||||
if (existsSync(gitPath)) {
|
||||
results.push(entry.name);
|
||||
}
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
return results.sort();
|
||||
}
|
||||
|
||||
function deepMergeConfig(base, overlay) {
|
||||
const result = { ...base };
|
||||
for (const key of Object.keys(overlay)) {
|
||||
const ov = overlay[key];
|
||||
if (ov !== null && ov !== undefined && typeof ov === 'object' && !Array.isArray(ov)) {
|
||||
const bv = base[key];
|
||||
if (bv !== null && bv !== undefined && typeof bv === 'object' && !Array.isArray(bv)) {
|
||||
result[key] = deepMergeConfig(bv, ov);
|
||||
}
|
||||
else {
|
||||
result[key] = deepMergeConfig({}, ov);
|
||||
}
|
||||
}
|
||||
else {
|
||||
result[key] = ov;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Exported functions ───────────────────────────────────────────────────────
|
||||
function normalizeLegacyKeys(parsed) {
|
||||
const result = { ...parsed };
|
||||
const normalizations = [];
|
||||
// 1. branching_strategy → git.branching_strategy
|
||||
if (Object.prototype.hasOwnProperty.call(result, 'branching_strategy')) {
|
||||
const value = result.branching_strategy;
|
||||
const git = result.git ?? {};
|
||||
if (git.branching_strategy === undefined) {
|
||||
result.git = { ...git, branching_strategy: value };
|
||||
}
|
||||
else {
|
||||
// canonical nested wins — just delete the stale top-level
|
||||
result.git = { ...git };
|
||||
}
|
||||
delete result.branching_strategy;
|
||||
normalizations.push({ from: 'branching_strategy', to: 'git.branching_strategy', value });
|
||||
}
|
||||
// 2. top-level sub_repos → planning.sub_repos
|
||||
if (Object.prototype.hasOwnProperty.call(result, 'sub_repos')) {
|
||||
const value = result.sub_repos;
|
||||
const planning = result.planning ?? {};
|
||||
if (planning.sub_repos === undefined) {
|
||||
result.planning = { ...planning, sub_repos: value };
|
||||
}
|
||||
else {
|
||||
// canonical nested wins — just drop the stale top-level
|
||||
result.planning = { ...planning };
|
||||
}
|
||||
delete result.sub_repos;
|
||||
normalizations.push({ from: 'sub_repos', to: 'planning.sub_repos', value });
|
||||
}
|
||||
// 3. multiRepo: true → marker (filesystem detection deferred to migrateOnDisk / caller)
|
||||
if (result.multiRepo === true) {
|
||||
delete result.multiRepo;
|
||||
normalizations.push({ from: 'multiRepo', to: 'planning.sub_repos', value: true, requiresFilesystem: true });
|
||||
}
|
||||
// 4. top-level depth → granularity
|
||||
if (Object.prototype.hasOwnProperty.call(result, 'depth') && !Object.prototype.hasOwnProperty.call(result, 'granularity')) {
|
||||
const rawDepth = result.depth;
|
||||
const mapped = DEPTH_TO_GRANULARITY[rawDepth] ?? rawDepth;
|
||||
result.granularity = mapped;
|
||||
delete result.depth;
|
||||
normalizations.push({ from: 'depth', to: 'granularity', value: mapped });
|
||||
}
|
||||
return { parsed: result, normalizations };
|
||||
}
|
||||
|
||||
function mergeDefaults(parsed) {
|
||||
// Start with a deep clone of defaults, then overlay parsed
|
||||
const defaults = JSON.parse(JSON.stringify(CONFIG_DEFAULTS));
|
||||
return deepMergeConfig(defaults, parsed);
|
||||
}
|
||||
|
||||
async function loadConfig(cwd, options) {
|
||||
const configPath = join(planningDir(cwd, options?.workstream), 'config.json');
|
||||
let raw;
|
||||
try {
|
||||
raw = readFileSync(configPath, 'utf-8');
|
||||
}
|
||||
catch {
|
||||
// File missing — return defaults
|
||||
return mergeDefaults({});
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') {
|
||||
return mergeDefaults({});
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to parse config at ${configPath}: ${msg}`);
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error(`Config at ${configPath} must be a JSON object`);
|
||||
}
|
||||
const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed);
|
||||
if (options?.onNormalizations && normalizations.length > 0) {
|
||||
options.onNormalizations(normalizations);
|
||||
}
|
||||
return mergeDefaults(normalized);
|
||||
}
|
||||
|
||||
async function migrateOnDisk(cwd, workstream) {
|
||||
const configPath = join(planningDir(cwd, workstream), 'config.json');
|
||||
let raw;
|
||||
try {
|
||||
raw = readFileSync(configPath, 'utf-8');
|
||||
}
|
||||
catch {
|
||||
// File missing — nothing to migrate
|
||||
return { migrated: false, normalizations: [], wrote: null };
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') {
|
||||
return { migrated: false, normalizations: [], wrote: null };
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
}
|
||||
catch {
|
||||
// Malformed — can't migrate
|
||||
return { migrated: false, normalizations: [], wrote: null };
|
||||
}
|
||||
const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed);
|
||||
if (normalizations.length === 0) {
|
||||
return { migrated: false, normalizations: [], wrote: null };
|
||||
}
|
||||
// Resolve multiRepo filesystem detection
|
||||
const result = { ...normalized };
|
||||
for (const norm of normalizations) {
|
||||
if (norm.requiresFilesystem) {
|
||||
const detected = detectSubRepos(cwd);
|
||||
if (detected.length > 0) {
|
||||
const planning = result.planning ?? {};
|
||||
result.planning = { ...planning, sub_repos: detected, commit_docs: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
writeFileSync(configPath, JSON.stringify(result, null, 2));
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to write migrated config at ${configPath}: ${msg}`);
|
||||
}
|
||||
return { migrated: true, normalizations, wrote: configPath };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadConfig,
|
||||
normalizeLegacyKeys,
|
||||
mergeDefaults,
|
||||
migrateOnDisk,
|
||||
CONFIG_DEFAULTS,
|
||||
VALID_CONFIG_KEYS,
|
||||
RUNTIME_STATE_KEYS,
|
||||
DYNAMIC_KEY_PATTERNS,
|
||||
};
|
||||
@@ -25,6 +25,17 @@ const {
|
||||
setActiveWorkstream,
|
||||
} = require('./planning-workspace.cjs');
|
||||
|
||||
// ─── Configuration Module (generated CJS mirror) ────────────────────────────
|
||||
// Cycle 4: import canonical defaults + normalization primitives from the
|
||||
// generated module; core.cjs no longer carries its own inline literal or its
|
||||
// own migration logic. The exported CONFIG_DEFAULTS remains a flat-key object
|
||||
// (shape unchanged) so legacy consumers (config.cjs, verify.cjs, tests) require
|
||||
// no changes. Values are sourced from the canonical nested manifest.
|
||||
const {
|
||||
CONFIG_DEFAULTS: CANONICAL_CONFIG_DEFAULTS,
|
||||
normalizeLegacyKeys,
|
||||
} = require('./configuration.generated.cjs');
|
||||
|
||||
// ─── Path helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Normalize a relative path to always use forward slashes (cross-platform). */
|
||||
@@ -279,36 +290,49 @@ function error(message, reason = ERROR_REASON.UNKNOWN) {
|
||||
// ─── File & Config utilities ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Canonical config defaults. Single source of truth — imported by config.cjs and verify.cjs.
|
||||
* Canonical config defaults — flat-key projection for CJS consumers.
|
||||
*
|
||||
* Cycle 4: Values are sourced from CANONICAL_CONFIG_DEFAULTS (the nested
|
||||
* manifest loaded by configuration.generated.cjs). The flat shape is
|
||||
* preserved here so legacy consumers (config.cjs, verify.cjs, tests that
|
||||
* regex-parse this source) continue to work without changes. The key names
|
||||
* and the `const CONFIG_DEFAULTS = {` pattern are intentionally kept.
|
||||
*
|
||||
* Mapping notes:
|
||||
* - workflow.plan_check → plan_checker (CJS flat name; verify.cjs uses this)
|
||||
* - git.* → flat git keys (branching_strategy, templates)
|
||||
* - workflow.* → flat names (research, verifier, …)
|
||||
* - planning.sub_repos → sub_repos
|
||||
* - planning.commit_docs / search_gitignored → top-level flat keys
|
||||
*/
|
||||
const CONFIG_DEFAULTS = {
|
||||
model_profile: 'balanced',
|
||||
commit_docs: true,
|
||||
search_gitignored: false,
|
||||
branching_strategy: 'none',
|
||||
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
||||
milestone_branch_template: 'gsd/{milestone}-{slug}',
|
||||
quick_branch_template: null,
|
||||
research: true,
|
||||
plan_checker: true,
|
||||
verifier: true,
|
||||
nyquist_validation: true,
|
||||
ai_integration_phase: true,
|
||||
parallelization: true,
|
||||
brave_search: false,
|
||||
firecrawl: false,
|
||||
exa_search: false,
|
||||
text_mode: false, // when true, use plain-text numbered lists instead of AskUserQuestion menus
|
||||
sub_repos: [],
|
||||
resolve_model_ids: false, // false: return alias as-is | true: map to full Claude model ID | "omit": return '' (runtime uses its default)
|
||||
context_window: 200000, // default 200k; set to 1000000 for Opus/Sonnet 4.6 1M models
|
||||
phase_naming: 'sequential', // 'sequential' (default, auto-increment) or 'custom' (arbitrary string IDs)
|
||||
project_code: null, // optional short prefix for phase dirs (e.g., 'CK' → 'CK-01-foundation')
|
||||
subagent_timeout: 300000, // 5 min default; increase for large codebases or slower models (ms)
|
||||
security_enforcement: true, // workflow.security_enforcement — threat-model-anchored security verification via /gsd:secure-phase
|
||||
security_asvs_level: 1, // workflow.security_asvs_level — OWASP ASVS verification level (1=opportunistic, 2=standard, 3=comprehensive)
|
||||
security_block_on: 'high', // workflow.security_block_on — minimum severity that blocks phase advancement ('high' | 'medium' | 'low')
|
||||
post_planning_gaps: true, // workflow.post_planning_gaps — unified post-planning gap report (#2493): scan REQUIREMENTS.md + CONTEXT.md decisions vs all PLAN.md files
|
||||
model_profile: CANONICAL_CONFIG_DEFAULTS.model_profile,
|
||||
commit_docs: CANONICAL_CONFIG_DEFAULTS.commit_docs,
|
||||
search_gitignored: CANONICAL_CONFIG_DEFAULTS.search_gitignored,
|
||||
branching_strategy: CANONICAL_CONFIG_DEFAULTS.git.branching_strategy,
|
||||
phase_branch_template: CANONICAL_CONFIG_DEFAULTS.git.phase_branch_template,
|
||||
milestone_branch_template: CANONICAL_CONFIG_DEFAULTS.git.milestone_branch_template,
|
||||
quick_branch_template: CANONICAL_CONFIG_DEFAULTS.git.quick_branch_template,
|
||||
research: CANONICAL_CONFIG_DEFAULTS.workflow.research,
|
||||
plan_checker: CANONICAL_CONFIG_DEFAULTS.workflow.plan_check, // flat CJS name maps to workflow.plan_check
|
||||
verifier: CANONICAL_CONFIG_DEFAULTS.workflow.verifier,
|
||||
nyquist_validation: CANONICAL_CONFIG_DEFAULTS.workflow.nyquist_validation,
|
||||
ai_integration_phase: CANONICAL_CONFIG_DEFAULTS.workflow.ai_integration_phase,
|
||||
parallelization: CANONICAL_CONFIG_DEFAULTS.parallelization,
|
||||
brave_search: CANONICAL_CONFIG_DEFAULTS.brave_search,
|
||||
firecrawl: CANONICAL_CONFIG_DEFAULTS.firecrawl,
|
||||
exa_search: CANONICAL_CONFIG_DEFAULTS.exa_search,
|
||||
text_mode: CANONICAL_CONFIG_DEFAULTS.workflow.text_mode,
|
||||
sub_repos: CANONICAL_CONFIG_DEFAULTS.planning.sub_repos,
|
||||
resolve_model_ids: CANONICAL_CONFIG_DEFAULTS.resolve_model_ids,
|
||||
context_window: CANONICAL_CONFIG_DEFAULTS.context_window,
|
||||
phase_naming: CANONICAL_CONFIG_DEFAULTS.phase_naming,
|
||||
project_code: CANONICAL_CONFIG_DEFAULTS.project_code,
|
||||
subagent_timeout: CANONICAL_CONFIG_DEFAULTS.workflow.subagent_timeout,
|
||||
security_enforcement: CANONICAL_CONFIG_DEFAULTS.workflow.security_enforcement,
|
||||
security_asvs_level: CANONICAL_CONFIG_DEFAULTS.workflow.security_asvs_level,
|
||||
security_block_on: CANONICAL_CONFIG_DEFAULTS.workflow.security_block_on,
|
||||
post_planning_gaps: CANONICAL_CONFIG_DEFAULTS.workflow.post_planning_gaps,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -348,13 +372,26 @@ function loadConfig(cwd, options = {}) {
|
||||
const raw = platformReadSync(rootConfigPath);
|
||||
if (raw === null) throw new Error('missing');
|
||||
rootParsed = JSON.parse(raw);
|
||||
if (Object.prototype.hasOwnProperty.call(rootParsed, 'branching_strategy')) {
|
||||
if (!rootParsed.git) rootParsed.git = {};
|
||||
if (rootParsed.git.branching_strategy === undefined) {
|
||||
rootParsed.git.branching_strategy = rootParsed.branching_strategy;
|
||||
// Cycle 4: delegate all legacy-key normalization to the Configuration Module.
|
||||
// normalizeLegacyKeys handles branching_strategy → git.branching_strategy,
|
||||
// sub_repos → planning.sub_repos, multiRepo, and depth → granularity.
|
||||
const { parsed: rootNormalized, normalizations: rootNorms } = normalizeLegacyKeys(rootParsed);
|
||||
if (rootNorms.length > 0) {
|
||||
// Resolve filesystem-dependent normalizations (multiRepo → planning.sub_repos)
|
||||
for (const norm of rootNorms) {
|
||||
if (norm.requiresFilesystem && !rootNormalized.planning?.sub_repos) {
|
||||
const detected = detectSubRepos(cwd);
|
||||
if (detected.length > 0) {
|
||||
if (!rootNormalized.planning) rootNormalized.planning = {};
|
||||
rootNormalized.planning.sub_repos = detected;
|
||||
rootNormalized.planning.commit_docs = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
delete rootParsed.branching_strategy;
|
||||
rootParsed = rootNormalized;
|
||||
try { platformWriteSync(rootConfigPath, JSON.stringify(rootParsed, null, 2)); } catch {}
|
||||
} else {
|
||||
rootParsed = rootNormalized;
|
||||
}
|
||||
} catch {
|
||||
// Root config missing or unparseable — workstream config stands alone
|
||||
@@ -371,57 +408,37 @@ function loadConfig(cwd, options = {}) {
|
||||
// for migrations and writes so we never persist merged values back to disk.
|
||||
const fileData = JSON.parse(raw);
|
||||
|
||||
// Migrate deprecated "depth" key to "granularity" with value mapping
|
||||
if ('depth' in fileData && !('granularity' in fileData)) {
|
||||
const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' };
|
||||
fileData.granularity = depthToGranularity[fileData.depth] || fileData.depth;
|
||||
delete fileData.depth;
|
||||
try { platformWriteSync(configPath, JSON.stringify(fileData, null, 2)); } catch { /* intentionally empty */ }
|
||||
}
|
||||
|
||||
// Auto-detect and sync sub_repos: scan for child directories with .git
|
||||
// Cycle 4: Single normalizeLegacyKeys call replaces all four inline migration
|
||||
// blocks (depth→granularity, multiRepo→planning.sub_repos, sub_repos→planning.sub_repos,
|
||||
// branching_strategy→git.branching_strategy). The Module is pure (no I/O); disk
|
||||
// writeback is handled below with the existing platformWriteSync pattern.
|
||||
// Note: migrateOnDisk from the Module is async; loadConfig is sync — so we
|
||||
// call normalizeLegacyKeys inline and do the writeback at the call site.
|
||||
// Per brief §4.3: "use normalizeLegacyKeys directly and do writeback inline."
|
||||
let configDirty = false;
|
||||
|
||||
// Migrate legacy "multiRepo: true" boolean → planning.sub_repos array.
|
||||
// Canonical location is planning.sub_repos (#2561); writing to top-level
|
||||
// would be flagged as unknown by the validator below (#2638).
|
||||
if (fileData.multiRepo === true && !fileData.sub_repos && !fileData.planning?.sub_repos) {
|
||||
const detected = detectSubRepos(cwd);
|
||||
if (detected.length > 0) {
|
||||
if (!fileData.planning) fileData.planning = {};
|
||||
fileData.planning.sub_repos = detected;
|
||||
fileData.planning.commit_docs = false;
|
||||
delete fileData.multiRepo;
|
||||
{
|
||||
const { parsed: normalized, normalizations } = normalizeLegacyKeys(fileData);
|
||||
if (normalizations.length > 0) {
|
||||
// Merge normalized values back into fileData (mutation-in-place for legacy code below)
|
||||
Object.keys(fileData).forEach(k => delete fileData[k]);
|
||||
Object.assign(fileData, normalized);
|
||||
configDirty = true;
|
||||
// Resolve filesystem-dependent normalizations (multiRepo → planning.sub_repos).
|
||||
// Guard: only populate sub_repos from filesystem if not already set by normalization
|
||||
// AND the original file didn't have sub_repos already (preserve existing intent).
|
||||
for (const norm of normalizations) {
|
||||
if (norm.requiresFilesystem && !fileData.planning?.sub_repos) {
|
||||
const detected = detectSubRepos(cwd);
|
||||
if (detected.length > 0) {
|
||||
if (!fileData.planning) fileData.planning = {};
|
||||
fileData.planning.sub_repos = detected;
|
||||
fileData.planning.commit_docs = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Self-heal legacy/buggy installs: strip any stale top-level sub_repos,
|
||||
// preserving its value as the planning.sub_repos seed if that slot is empty.
|
||||
if (Object.prototype.hasOwnProperty.call(fileData, 'sub_repos')) {
|
||||
if (!fileData.planning) fileData.planning = {};
|
||||
if (!fileData.planning.sub_repos) {
|
||||
fileData.planning.sub_repos = fileData.sub_repos;
|
||||
}
|
||||
delete fileData.sub_repos;
|
||||
configDirty = true;
|
||||
}
|
||||
|
||||
// #3523 — Migrate legacy top-level branching_strategy → git.branching_strategy.
|
||||
// Canonical location is git.branching_strategy (per config-schema.cjs); writing
|
||||
// at the top level trips the unknown-key warning even though loadConfig:485 actively
|
||||
// reads it via the nested fallback. This migration mirrors the multiRepo → sub_repos
|
||||
// precedent: graft then delete so the warning never fires again on this project.
|
||||
// The nested value wins if already set (matches SDK mergeDefaults precedence, PR #3116).
|
||||
if (Object.prototype.hasOwnProperty.call(fileData, 'branching_strategy')) {
|
||||
if (!fileData.git) fileData.git = {};
|
||||
if (fileData.git.branching_strategy === undefined) {
|
||||
fileData.git.branching_strategy = fileData.branching_strategy;
|
||||
}
|
||||
delete fileData.branching_strategy;
|
||||
configDirty = true;
|
||||
}
|
||||
|
||||
// Keep planning.sub_repos in sync with actual filesystem
|
||||
const currentSubRepos = fileData.planning?.sub_repos || [];
|
||||
if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) {
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"build:sdk": "cd sdk && npm ci && npm run build",
|
||||
"check:alias-drift": "cd sdk && npm run check:alias-drift",
|
||||
"check:state-document-fresh": "cd sdk && npm run check:state-document-fresh",
|
||||
"check:configuration-fresh": "cd sdk && npm run check:configuration-fresh",
|
||||
"prepublishOnly": "npm run build:hooks && npm run build:sdk",
|
||||
"pretest": "npm run build:sdk && npm run lint:skill-deps",
|
||||
"pretest:coverage": "npm run build:sdk",
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
"check:alias-drift": "npm run build && node scripts/check-command-aliases-fresh.mjs",
|
||||
"gen:state-document": "npm run build && npx tsx scripts/gen-state-document.ts",
|
||||
"check:state-document-fresh": "npm run build && node scripts/check-state-document-fresh.mjs",
|
||||
"gen:configuration": "npm run build && node scripts/gen-configuration.mjs",
|
||||
"check:configuration-fresh": "npm run build && node scripts/check-configuration-fresh.mjs",
|
||||
"prepublishOnly": "rm -rf dist && tsc && chmod +x dist/cli.js",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run --project unit",
|
||||
|
||||
40
sdk/scripts/check-configuration-fresh.mjs
Normal file
40
sdk/scripts/check-configuration-fresh.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freshness check for get-shit-done/bin/lib/configuration.generated.cjs.
|
||||
*
|
||||
* Re-runs the generator in-memory, compares to the committed file,
|
||||
* exits 0 if equal, 1 if not.
|
||||
*
|
||||
* Usage: node sdk/scripts/check-configuration-fresh.mjs
|
||||
* Or: cd sdk && npm run check:configuration-fresh
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, '..', '..');
|
||||
|
||||
const { buildConfigurationCjs } = await import('./gen-configuration.mjs');
|
||||
|
||||
const expected = buildConfigurationCjs();
|
||||
const committedPath = resolve(repoRoot, 'get-shit-done', 'bin', 'lib', 'configuration.generated.cjs');
|
||||
|
||||
let committed;
|
||||
try {
|
||||
committed = readFileSync(committedPath, 'utf-8');
|
||||
} catch (err) {
|
||||
console.error(`configuration.generated.cjs not found at ${committedPath}`);
|
||||
console.error('Run: cd sdk && npm run gen:configuration');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (committed === expected) {
|
||||
console.log('configuration.generated.cjs is fresh');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('configuration.generated.cjs is STALE. Regenerate with:');
|
||||
console.error(' cd sdk && npm run gen:configuration');
|
||||
process.exit(1);
|
||||
}
|
||||
162
sdk/scripts/gen-configuration.mjs
Normal file
162
sdk/scripts/gen-configuration.mjs
Normal file
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generator for get-shit-done/bin/lib/configuration.generated.cjs.
|
||||
*
|
||||
* Reads the compiled Configuration Module from sdk/dist/configuration/index.js
|
||||
* and emits a CJS file that:
|
||||
* 1. Requires the two JSON manifests from sdk/shared/
|
||||
* 2. Exports loadConfig, normalizeLegacyKeys, mergeDefaults, migrateOnDisk,
|
||||
* CONFIG_DEFAULTS, VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS
|
||||
*
|
||||
* Run via: cd sdk && npm run gen:configuration
|
||||
* Or from repo root: node sdk/scripts/gen-configuration.mjs
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, '..', '..');
|
||||
|
||||
// ─── Read the compiled dist file for function extraction ─────────────────────
|
||||
|
||||
const distPath = resolve(here, '..', 'dist', 'configuration', 'index.js');
|
||||
if (!existsSync(distPath)) {
|
||||
throw new Error(
|
||||
`Missing compiled configuration module at ${distPath}. Run "cd sdk && npm run build" first.`,
|
||||
);
|
||||
}
|
||||
const distSrc = readFileSync(distPath, 'utf-8');
|
||||
|
||||
/**
|
||||
* Extract a named function from the compiled dist source by scanning for
|
||||
* `function <name>(` (or `async function <name>(`) and capturing the balanced
|
||||
* braces body. Returns the full `[async] function name(...) { ... }` string,
|
||||
* preserving the async keyword when present.
|
||||
*/
|
||||
function extractFunction(src, name) {
|
||||
// Try async first, then plain function
|
||||
let start = src.indexOf(`async function ${name}(`);
|
||||
if (start === -1) start = src.indexOf(`function ${name}(`);
|
||||
if (start === -1) throw new Error(`Function "${name}" not found in dist source`);
|
||||
|
||||
// Find opening brace
|
||||
const braceStart = src.indexOf('{', start);
|
||||
if (braceStart === -1) throw new Error(`No opening brace for "${name}"`);
|
||||
|
||||
// Balance braces
|
||||
let depth = 0;
|
||||
let i = braceStart;
|
||||
while (i < src.length) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
return src.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
throw new Error(`Unbalanced braces for "${name}"`);
|
||||
}
|
||||
|
||||
const fnPlanningDir = extractFunction(distSrc, 'planningDir');
|
||||
const fnDetectSubRepos = extractFunction(distSrc, 'detectSubRepos');
|
||||
const fnDeepMergeConfig = extractFunction(distSrc, 'deepMergeConfig');
|
||||
const fnNormalizeLegacyKeys = extractFunction(distSrc, 'normalizeLegacyKeys');
|
||||
const fnMergeDefaults = extractFunction(distSrc, 'mergeDefaults');
|
||||
const fnLoadConfig = extractFunction(distSrc, 'loadConfig');
|
||||
const fnMigrateOnDisk = extractFunction(distSrc, 'migrateOnDisk');
|
||||
|
||||
// Capture DEPTH_TO_GRANULARITY constant
|
||||
const dtgMatch = distSrc.match(/const DEPTH_TO_GRANULARITY = \{[^}]+\};/);
|
||||
if (!dtgMatch) throw new Error('DEPTH_TO_GRANULARITY not found in dist source');
|
||||
const dtgConst = dtgMatch[0];
|
||||
|
||||
// ─── Build CJS output ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the CJS output string.
|
||||
* Exported so check-configuration-fresh.mjs can call it without re-running the generator.
|
||||
*/
|
||||
export function buildConfigurationCjs() {
|
||||
return [
|
||||
`'use strict';`,
|
||||
``,
|
||||
`/**`,
|
||||
` * GENERATED FILE — DO NOT EDIT.`,
|
||||
` *`,
|
||||
` * Source: sdk/src/configuration/index.ts`,
|
||||
` * Regenerate: cd sdk && npm run gen:configuration`,
|
||||
` *`,
|
||||
` * Configuration Module — single source of truth for config loading,`,
|
||||
` * legacy-key normalization, defaults merge, and explicit on-disk migration.`,
|
||||
` */`,
|
||||
``,
|
||||
`const { readFileSync, writeFileSync, existsSync, readdirSync } = require('node:fs');`,
|
||||
`const { join } = require('node:path');`,
|
||||
``,
|
||||
`// ─── Manifest requires ───────────────────────────────────────────────────────`,
|
||||
`// Resolved relative to this file: get-shit-done/bin/lib/ → sdk/shared/`,
|
||||
`// This file lives at: get-shit-done/bin/lib/configuration.generated.cjs`,
|
||||
`// sdk/shared lives at: sdk/shared/ (3 dirs up from bin/lib, then into sdk/shared)`,
|
||||
`const CONFIG_DEFAULTS = require('../../../sdk/shared/config-defaults.manifest.json');`,
|
||||
`const SCHEMA_MANIFEST = require('../../../sdk/shared/config-schema.manifest.json');`,
|
||||
`const VALID_CONFIG_KEYS = new Set(SCHEMA_MANIFEST.validKeys);`,
|
||||
`const RUNTIME_STATE_KEYS = new Set(SCHEMA_MANIFEST.runtimeStateKeys);`,
|
||||
`const DYNAMIC_KEY_PATTERNS = SCHEMA_MANIFEST.dynamicKeyPatterns.map((p) => {`,
|
||||
` const pattern = new RegExp(p.source);`,
|
||||
` return {`,
|
||||
` ...p,`,
|
||||
` test: (key) => {`,
|
||||
` pattern.lastIndex = 0;`,
|
||||
` return pattern.test(key);`,
|
||||
` },`,
|
||||
` };`,
|
||||
`});`,
|
||||
``,
|
||||
`// ─── Depth → Granularity mapping ─────────────────────────────────────────────`,
|
||||
dtgConst,
|
||||
``,
|
||||
`// ─── Internal helpers ─────────────────────────────────────────────────────────`,
|
||||
fnPlanningDir,
|
||||
``,
|
||||
fnDetectSubRepos,
|
||||
``,
|
||||
fnDeepMergeConfig,
|
||||
``,
|
||||
`// ─── Exported functions ───────────────────────────────────────────────────────`,
|
||||
fnNormalizeLegacyKeys,
|
||||
``,
|
||||
fnMergeDefaults,
|
||||
``,
|
||||
fnLoadConfig,
|
||||
``,
|
||||
fnMigrateOnDisk,
|
||||
``,
|
||||
`module.exports = {`,
|
||||
` loadConfig,`,
|
||||
` normalizeLegacyKeys,`,
|
||||
` mergeDefaults,`,
|
||||
` migrateOnDisk,`,
|
||||
` CONFIG_DEFAULTS,`,
|
||||
` VALID_CONFIG_KEYS,`,
|
||||
` RUNTIME_STATE_KEYS,`,
|
||||
` DYNAMIC_KEY_PATTERNS,`,
|
||||
`};`,
|
||||
``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Main: write output file (only when run directly) ────────────────────────
|
||||
|
||||
// Guard: don't write the file when imported by check-configuration-fresh.mjs.
|
||||
// `process.argv[1]` is the absolute path of the entry-point script.
|
||||
const _thisFile = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] === _thisFile) {
|
||||
const cjsOut = buildConfigurationCjs();
|
||||
const outPath = resolve(repoRoot, 'get-shit-done', 'bin', 'lib', 'configuration.generated.cjs');
|
||||
writeFileSync(outPath, cjsOut, 'utf-8');
|
||||
console.log(`Generated: ${outPath}`);
|
||||
}
|
||||
72
sdk/shared/config-defaults.manifest.json
Normal file
72
sdk/shared/config-defaults.manifest.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"_comment": "Canonical CONFIG_DEFAULTS for the Configuration Module. Nested shape is canonical. CJS flat projection (branching_strategy, sub_repos, etc.) is handled by consumers at the boundary. Security keys (security_enforcement, security_asvs_level, security_block_on) and post_planning_gaps live in workflow.* as their canonical location. resolve_model_ids, context_window, phase_naming are CJS-originated top-level keys included here. brave_search, firecrawl, exa_search default to false in the manifest; at runtime buildNewProjectConfig detects API keys. The plan_checker (CJS flat) → workflow.plan_check (canonical nested) divergence is resolved: canonical name is workflow.plan_check. _auto_chain_active is a runtime-state field included for completeness.",
|
||||
"model_profile": "balanced",
|
||||
"commit_docs": true,
|
||||
"parallelization": true,
|
||||
"search_gitignored": false,
|
||||
"brave_search": false,
|
||||
"firecrawl": false,
|
||||
"exa_search": false,
|
||||
"resolve_model_ids": false,
|
||||
"context_window": 200000,
|
||||
"phase_naming": "sequential",
|
||||
"project_code": null,
|
||||
"mode": "interactive",
|
||||
"claude_md_path": "./CLAUDE.md",
|
||||
"git": {
|
||||
"branching_strategy": "none",
|
||||
"create_tag": true,
|
||||
"base_branch": null,
|
||||
"phase_branch_template": "gsd/phase-{phase}-{slug}",
|
||||
"milestone_branch_template": "gsd/{milestone}-{slug}",
|
||||
"quick_branch_template": null
|
||||
},
|
||||
"workflow": {
|
||||
"research": true,
|
||||
"plan_check": true,
|
||||
"verifier": true,
|
||||
"nyquist_validation": true,
|
||||
"ai_integration_phase": true,
|
||||
"tdd_mode": false,
|
||||
"human_verify_mode": "end-of-phase",
|
||||
"auto_advance": false,
|
||||
"_auto_chain_active": false,
|
||||
"node_repair": true,
|
||||
"node_repair_budget": 2,
|
||||
"ui_phase": true,
|
||||
"ui_safety_gate": true,
|
||||
"text_mode": false,
|
||||
"research_before_questions": false,
|
||||
"discuss_mode": "discuss",
|
||||
"skip_discuss": false,
|
||||
"max_discuss_passes": 3,
|
||||
"subagent_timeout": 300000,
|
||||
"context_coverage_gate": true,
|
||||
"code_review": true,
|
||||
"code_review_depth": "standard",
|
||||
"code_review_command": null,
|
||||
"pattern_mapper": true,
|
||||
"plan_bounce": false,
|
||||
"plan_bounce_script": null,
|
||||
"plan_bounce_passes": 2,
|
||||
"auto_prune_state": false,
|
||||
"post_planning_gaps": true,
|
||||
"security_enforcement": true,
|
||||
"security_asvs_level": 1,
|
||||
"security_block_on": "high"
|
||||
},
|
||||
"planning": {
|
||||
"commit_docs": true,
|
||||
"search_gitignored": false,
|
||||
"sub_repos": [],
|
||||
"granularity": "standard"
|
||||
},
|
||||
"hooks": {
|
||||
"context_warnings": true,
|
||||
"workflow_guard": false
|
||||
},
|
||||
"ship": {
|
||||
"pr_body_sections": []
|
||||
},
|
||||
"agent_skills": {}
|
||||
}
|
||||
143
sdk/shared/config-schema.manifest.json
Normal file
143
sdk/shared/config-schema.manifest.json
Normal file
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"_comment": "Canonical schema manifest for valid config key paths. validKeys is the union of CJS config-schema.cjs and SDK query/config-schema.ts (they are enforced set-equal by tests/config-schema-sdk-parity.test.cjs). runtimeStateKeys mirrors RUNTIME_STATE_KEYS. dynamicKeyPatterns mirrors DYNAMIC_KEY_PATTERNS from SDK (which includes the canonical 'source' regex strings). The .test function is reconstructed at runtime from new RegExp(source).",
|
||||
"validKeys": [
|
||||
"mode",
|
||||
"granularity",
|
||||
"parallelization",
|
||||
"commit_docs",
|
||||
"model_profile",
|
||||
"search_gitignored",
|
||||
"brave_search",
|
||||
"firecrawl",
|
||||
"exa_search",
|
||||
"workflow.research",
|
||||
"workflow.plan_check",
|
||||
"workflow.verifier",
|
||||
"workflow.nyquist_validation",
|
||||
"workflow.ai_integration_phase",
|
||||
"workflow.ui_phase",
|
||||
"workflow.ui_safety_gate",
|
||||
"workflow.auto_advance",
|
||||
"workflow.node_repair",
|
||||
"workflow.node_repair_budget",
|
||||
"workflow.tdd_mode",
|
||||
"workflow.human_verify_mode",
|
||||
"workflow.text_mode",
|
||||
"workflow.research_before_questions",
|
||||
"workflow.discuss_mode",
|
||||
"workflow.skip_discuss",
|
||||
"workflow.auto_prune_state",
|
||||
"workflow.use_worktrees",
|
||||
"workflow.worktree_skip_hooks",
|
||||
"workflow.code_review",
|
||||
"workflow.code_review_depth",
|
||||
"workflow.code_review_command",
|
||||
"workflow.pattern_mapper",
|
||||
"workflow.plan_bounce",
|
||||
"workflow.plan_bounce_script",
|
||||
"workflow.plan_bounce_passes",
|
||||
"workflow.plan_chunked",
|
||||
"workflow.plan_review_convergence",
|
||||
"workflow.post_planning_gaps",
|
||||
"workflow.security_enforcement",
|
||||
"workflow.security_asvs_level",
|
||||
"workflow.security_block_on",
|
||||
"workflow.drift_threshold",
|
||||
"workflow.drift_action",
|
||||
"code_quality.fallow.enabled",
|
||||
"code_quality.fallow.scope",
|
||||
"code_quality.fallow.profile",
|
||||
"code_quality.fallow.mcp",
|
||||
"ship.pr_body_sections",
|
||||
"git.branching_strategy",
|
||||
"git.base_branch",
|
||||
"git.create_tag",
|
||||
"git.phase_branch_template",
|
||||
"git.milestone_branch_template",
|
||||
"git.quick_branch_template",
|
||||
"planning.commit_docs",
|
||||
"planning.search_gitignored",
|
||||
"planning.sub_repos",
|
||||
"review.ollama_host",
|
||||
"review.lm_studio_host",
|
||||
"review.llama_cpp_host",
|
||||
"review.default_reviewers",
|
||||
"workflow.cross_ai_execution",
|
||||
"workflow.cross_ai_command",
|
||||
"workflow.cross_ai_timeout",
|
||||
"workflow.subagent_timeout",
|
||||
"executor.stall_detect_interval_minutes",
|
||||
"executor.stall_threshold_minutes",
|
||||
"workflow.inline_plan_threshold",
|
||||
"hooks.context_warnings",
|
||||
"hooks.workflow_guard",
|
||||
"workflow.context_coverage_gate",
|
||||
"statusline.show_last_command",
|
||||
"statusline.context_position",
|
||||
"workflow.ui_review",
|
||||
"workflow.max_discuss_passes",
|
||||
"features.thinking_partner",
|
||||
"context",
|
||||
"features.global_learnings",
|
||||
"learnings.max_inject",
|
||||
"project_code",
|
||||
"phase_naming",
|
||||
"manager.flags.discuss",
|
||||
"manager.flags.plan",
|
||||
"manager.flags.execute",
|
||||
"response_language",
|
||||
"context_window",
|
||||
"intel.enabled",
|
||||
"graphify.enabled",
|
||||
"graphify.build_timeout",
|
||||
"claude_md_path",
|
||||
"claude_md_assembly.mode",
|
||||
"runtime",
|
||||
"resolve_model_ids"
|
||||
],
|
||||
"runtimeStateKeys": [
|
||||
"workflow._auto_chain_active"
|
||||
],
|
||||
"dynamicKeyPatterns": [
|
||||
{
|
||||
"topLevel": "agent_skills",
|
||||
"source": "^agent_skills\\.[a-zA-Z0-9_-]+$",
|
||||
"description": "agent_skills.<agent-type>"
|
||||
},
|
||||
{
|
||||
"topLevel": "review",
|
||||
"source": "^review\\.models\\.[a-zA-Z0-9_-]+$",
|
||||
"description": "review.models.<cli-name>"
|
||||
},
|
||||
{
|
||||
"topLevel": "features",
|
||||
"source": "^features\\.[a-zA-Z0-9_]+$",
|
||||
"description": "features.<feature_name>"
|
||||
},
|
||||
{
|
||||
"topLevel": "claude_md_assembly",
|
||||
"source": "^claude_md_assembly\\.blocks\\.[a-zA-Z0-9_]+$",
|
||||
"description": "claude_md_assembly.blocks.<section>"
|
||||
},
|
||||
{
|
||||
"topLevel": "model_profile_overrides",
|
||||
"source": "^model_profile_overrides\\.[a-zA-Z0-9_-]+\\.(opus|sonnet|haiku)$",
|
||||
"description": "model_profile_overrides.<runtime>.<opus|sonnet|haiku>"
|
||||
},
|
||||
{
|
||||
"topLevel": "models",
|
||||
"source": "^models\\.(planning|discuss|research|execution|verification|completion)$",
|
||||
"description": "models.<planning|discuss|research|execution|verification|completion>"
|
||||
},
|
||||
{
|
||||
"topLevel": "dynamic_routing",
|
||||
"source": "^dynamic_routing\\.(enabled|escalate_on_failure|max_escalations|tier_models\\.(light|standard|heavy))$",
|
||||
"description": "dynamic_routing.<enabled|escalate_on_failure|max_escalations|tier_models.<light|standard|heavy>>"
|
||||
},
|
||||
{
|
||||
"topLevel": "model_overrides",
|
||||
"source": "^model_overrides\\.[a-zA-Z0-9_-]+$",
|
||||
"description": "model_overrides.<agent-id>"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -191,7 +191,11 @@ describe('loadConfig', () => {
|
||||
it('pre-project: ignores user defaults and uses built-in defaults', async () => {
|
||||
await writeUserDefaults({ resolve_model_ids: 'omit' });
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect((config as Record<string, unknown>).resolve_model_ids).toBeUndefined();
|
||||
// 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);
|
||||
});
|
||||
@@ -224,8 +228,10 @@ describe('loadConfig', () => {
|
||||
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect(config.model_profile).toBe('quality');
|
||||
// User-defaults not layered when project config present
|
||||
expect((config as Record<string, unknown>).resolve_model_ids).toBeUndefined();
|
||||
// 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 () => {
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { relPlanningPath } from './workstream-utils.js';
|
||||
import {
|
||||
CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS,
|
||||
mergeDefaults as canonicalMergeDefaults,
|
||||
normalizeLegacyKeys,
|
||||
} from './configuration/index.js';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -86,48 +91,31 @@ export interface GSDConfig {
|
||||
|
||||
// ─── Defaults ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const CONFIG_DEFAULTS: GSDConfig = {
|
||||
model_profile: 'balanced',
|
||||
commit_docs: true,
|
||||
parallelization: true,
|
||||
search_gitignored: false,
|
||||
brave_search: false,
|
||||
firecrawl: false,
|
||||
exa_search: false,
|
||||
git: {
|
||||
branching_strategy: 'none',
|
||||
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
||||
milestone_branch_template: 'gsd/{milestone}-{slug}',
|
||||
quick_branch_template: null,
|
||||
},
|
||||
workflow: {
|
||||
research: true,
|
||||
plan_check: true,
|
||||
verifier: true,
|
||||
nyquist_validation: true,
|
||||
tdd_mode: false,
|
||||
human_verify_mode: 'end-of-phase',
|
||||
auto_advance: false,
|
||||
node_repair: true,
|
||||
node_repair_budget: 2,
|
||||
ui_phase: true,
|
||||
ui_safety_gate: true,
|
||||
text_mode: false,
|
||||
research_before_questions: false,
|
||||
discuss_mode: 'discuss',
|
||||
skip_discuss: false,
|
||||
max_discuss_passes: 3,
|
||||
subagent_timeout: 300000,
|
||||
context_coverage_gate: true,
|
||||
_auto_chain_active: false,
|
||||
},
|
||||
hooks: {
|
||||
context_warnings: true,
|
||||
},
|
||||
agent_skills: {},
|
||||
project_code: null,
|
||||
mode: 'interactive',
|
||||
};
|
||||
/**
|
||||
* Canonical CONFIG_DEFAULTS delegated to the Configuration Module (ADR-3524).
|
||||
* Cast to GSDConfig to preserve typed access for existing consumers.
|
||||
* The canonical manifest may include additional keys beyond GSDConfig's
|
||||
* declared fields (e.g. resolve_model_ids, context_window, planning.*,
|
||||
* ship.*, workflow.security_*, workflow.code_review_*); these are accessible
|
||||
* via the [key: string]: unknown index signature on GSDConfig.
|
||||
*
|
||||
* BEHAVIOR CHANGE (Cycle 3, #3536): CONFIG_DEFAULTS now includes all keys from
|
||||
* sdk/shared/config-defaults.manifest.json. Keys added vs old inline literal:
|
||||
* top-level: resolve_model_ids (false), context_window (200000),
|
||||
* phase_naming ('sequential'), claude_md_path ('./CLAUDE.md')
|
||||
* git: create_tag (true), base_branch (null)
|
||||
* workflow: ai_integration_phase (true), code_review (true),
|
||||
* code_review_depth ('standard'), code_review_command (null),
|
||||
* pattern_mapper (true), plan_bounce (false), plan_bounce_script (null),
|
||||
* plan_bounce_passes (2), auto_prune_state (false),
|
||||
* post_planning_gaps (true), security_enforcement (true),
|
||||
* security_asvs_level (1), security_block_on ('high'),
|
||||
* context_coverage_gate: true (unchanged from old literal)
|
||||
* planning: { commit_docs: true, search_gitignored: false, sub_repos: [], granularity: 'standard' }
|
||||
* hooks: workflow_guard (false)
|
||||
* ship: { pr_body_sections: [] }
|
||||
*/
|
||||
export const CONFIG_DEFAULTS: GSDConfig = CANONICAL_CONFIG_DEFAULTS as unknown as GSDConfig;
|
||||
|
||||
// ─── Loader ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -186,33 +174,29 @@ export async function loadConfig(projectDir: string, workstream?: string): Promi
|
||||
|
||||
// Project config exists — user-level defaults are ignored (CJS parity).
|
||||
// `buildNewProjectConfig` already baked them into config.json at /gsd-new-project.
|
||||
return mergeDefaults(parsed);
|
||||
// Normalize legacy top-level keys (branching_strategy → git.branching_strategy, etc.)
|
||||
// before merging with defaults, matching the Configuration Module's loadConfig pipeline.
|
||||
const { parsed: normalized } = normalizeLegacyKeys(parsed);
|
||||
return mergeDefaults(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge config with defaults using the Configuration Module's deep-merge.
|
||||
* Delegates to canonicalMergeDefaults (ADR-3524, Cycle 3, #3536).
|
||||
*
|
||||
* BEHAVIOR CHANGE (Cycle 3, #3536): The old implementation used spread-per-section
|
||||
* (shallow merge for git/workflow/hooks/agent_skills, spread for top-level).
|
||||
* The new implementation uses recursive deep-merge via canonicalMergeDefaults,
|
||||
* which means partial nested objects (e.g. { workflow: { research: false } })
|
||||
* are now deep-merged rather than replacing the entire section's defaults.
|
||||
* The practical difference: deep-merge preserves sibling default keys within
|
||||
* nested sections even when the overlay only specifies one key — which was
|
||||
* already the intended behavior of the old spread-per-section approach.
|
||||
* Legacy branching_strategy top-level → git.branching_strategy normalization
|
||||
* is now handled by normalizeLegacyKeys inside canonicalMergeDefaults's pipeline
|
||||
* (via loadConfig); for the raw mergeDefaults path, legacy key handling is
|
||||
* delegated to the canonical module.
|
||||
*/
|
||||
function mergeDefaults(parsed: Record<string, unknown>): GSDConfig {
|
||||
const legacyBranchingStrategy = typeof parsed.branching_strategy === 'string'
|
||||
? parsed.branching_strategy
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...structuredClone(CONFIG_DEFAULTS),
|
||||
...parsed,
|
||||
git: {
|
||||
...CONFIG_DEFAULTS.git,
|
||||
...(legacyBranchingStrategy ? { branching_strategy: legacyBranchingStrategy } : {}),
|
||||
...(parsed.git as Partial<GitConfig> ?? {}),
|
||||
},
|
||||
workflow: {
|
||||
...CONFIG_DEFAULTS.workflow,
|
||||
...(parsed.workflow as Partial<WorkflowConfig> ?? {}),
|
||||
},
|
||||
hooks: {
|
||||
...CONFIG_DEFAULTS.hooks,
|
||||
...(parsed.hooks as Partial<HooksConfig> ?? {}),
|
||||
},
|
||||
agent_skills: {
|
||||
...CONFIG_DEFAULTS.agent_skills,
|
||||
...(parsed.agent_skills as Record<string, unknown> ?? {}),
|
||||
},
|
||||
};
|
||||
return canonicalMergeDefaults(parsed) as unknown as GSDConfig;
|
||||
}
|
||||
|
||||
318
sdk/src/configuration/index.test.ts
Normal file
318
sdk/src/configuration/index.test.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Pinning tests for the Configuration Module (ADR-3524 §6).
|
||||
*
|
||||
* These tests pin the public interface contract. They are RED until
|
||||
* sdk/src/configuration/index.ts is created (Cycle 2).
|
||||
*
|
||||
* Test precedent: sdk/src/config.test.ts (vitest + fs fixtures).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import {
|
||||
loadConfig,
|
||||
normalizeLegacyKeys,
|
||||
mergeDefaults,
|
||||
migrateOnDisk,
|
||||
CONFIG_DEFAULTS,
|
||||
} from './index.js';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTmpProject(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gsd-cfg-test-'));
|
||||
mkdirSync(join(dir, '.planning'), { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeConfig(dir: string, data: unknown): void {
|
||||
writeFileSync(join(dir, '.planning', 'config.json'), JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
function readConfigRaw(dir: string): string {
|
||||
return readFileSync(join(dir, '.planning', 'config.json'), 'utf-8');
|
||||
}
|
||||
|
||||
function cleanupDir(dir: string): void {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── loadConfig ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('loadConfig', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupDir(tmpDir);
|
||||
});
|
||||
|
||||
it('returns CONFIG_DEFAULTS when config.json is missing', async () => {
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect(config).toEqual(CONFIG_DEFAULTS);
|
||||
});
|
||||
|
||||
it('returns CONFIG_DEFAULTS when config.json is empty {}', async () => {
|
||||
writeConfig(tmpDir, {});
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect(config).toEqual(CONFIG_DEFAULTS);
|
||||
});
|
||||
|
||||
it('returns nested git.branching_strategy when already nested', async () => {
|
||||
writeConfig(tmpDir, { git: { branching_strategy: 'phase' } });
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect(config.git.branching_strategy).toBe('phase');
|
||||
});
|
||||
|
||||
it('normalizes legacy top-level branching_strategy to git.branching_strategy', async () => {
|
||||
writeConfig(tmpDir, { branching_strategy: 'phase' });
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect(config.git.branching_strategy).toBe('phase');
|
||||
});
|
||||
|
||||
it('does NOT write disk when normalizing legacy branching_strategy', async () => {
|
||||
writeConfig(tmpDir, { branching_strategy: 'phase' });
|
||||
const before = readConfigRaw(tmpDir);
|
||||
await loadConfig(tmpDir);
|
||||
const after = readConfigRaw(tmpDir);
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it('normalizes legacy top-level sub_repos to planning.sub_repos', async () => {
|
||||
writeConfig(tmpDir, { sub_repos: ['app1', 'app2'] });
|
||||
const config = await loadConfig(tmpDir);
|
||||
expect((config.planning as Record<string, unknown>)?.sub_repos).toEqual(['app1', 'app2']);
|
||||
});
|
||||
|
||||
it('handles legacy depth: comprehensive → planning.granularity: fine', async () => {
|
||||
writeConfig(tmpDir, { depth: 'comprehensive' });
|
||||
const config = await loadConfig(tmpDir);
|
||||
// depth is a legacy key that maps to granularity
|
||||
const granularity = (config as Record<string, unknown>).granularity
|
||||
?? (config.planning as Record<string, unknown> | undefined)?.granularity;
|
||||
expect(granularity).toBe('fine');
|
||||
});
|
||||
|
||||
it('handles legacy depth: quick → coarse', async () => {
|
||||
writeConfig(tmpDir, { depth: 'quick' });
|
||||
const config = await loadConfig(tmpDir);
|
||||
const granularity = (config as Record<string, unknown>).granularity
|
||||
?? (config.planning as Record<string, unknown> | undefined)?.granularity;
|
||||
expect(granularity).toBe('coarse');
|
||||
});
|
||||
|
||||
it('handles legacy depth: standard → standard', async () => {
|
||||
writeConfig(tmpDir, { depth: 'standard' });
|
||||
const config = await loadConfig(tmpDir);
|
||||
const granularity = (config as Record<string, unknown>).granularity
|
||||
?? (config.planning as Record<string, unknown> | undefined)?.granularity;
|
||||
expect(granularity).toBe('standard');
|
||||
});
|
||||
|
||||
it('throws with informative error on malformed JSON', async () => {
|
||||
writeFileSync(join(tmpDir, '.planning', 'config.json'), '{bad json');
|
||||
await expect(loadConfig(tmpDir)).rejects.toThrow(/parse|invalid|json/i);
|
||||
});
|
||||
|
||||
it('does not throw when .planning/config.json is missing — returns defaults', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gsd-cfg-noplan-'));
|
||||
// intentionally no .planning dir
|
||||
try {
|
||||
const config = await loadConfig(dir);
|
||||
expect(config).toEqual(CONFIG_DEFAULTS);
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizeLegacyKeys ─────────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeLegacyKeys', () => {
|
||||
it('migrates top-level branching_strategy to git.branching_strategy', () => {
|
||||
const input = { branching_strategy: 'phase' };
|
||||
const { parsed, normalizations } = normalizeLegacyKeys(input);
|
||||
expect((parsed as Record<string, unknown>).branching_strategy).toBeUndefined();
|
||||
expect((parsed as Record<string, Record<string, unknown>>).git?.branching_strategy).toBe('phase');
|
||||
expect(normalizations).toHaveLength(1);
|
||||
expect(normalizations[0]).toMatchObject({ from: 'branching_strategy', to: 'git.branching_strategy', value: 'phase' });
|
||||
});
|
||||
|
||||
it('migrates top-level sub_repos to planning.sub_repos', () => {
|
||||
const input = { sub_repos: ['app1', 'app2'] };
|
||||
const { parsed, normalizations } = normalizeLegacyKeys(input);
|
||||
expect((parsed as Record<string, unknown>).sub_repos).toBeUndefined();
|
||||
expect((parsed as Record<string, Record<string, unknown>>).planning?.sub_repos).toEqual(['app1', 'app2']);
|
||||
expect(normalizations).toHaveLength(1);
|
||||
expect(normalizations[0]).toMatchObject({ from: 'sub_repos', to: 'planning.sub_repos', value: ['app1', 'app2'] });
|
||||
});
|
||||
|
||||
it('migrates multiRepo: true to planning.sub_repos marker', () => {
|
||||
const input = { multiRepo: true };
|
||||
const { parsed, normalizations } = normalizeLegacyKeys(input);
|
||||
expect((parsed as Record<string, unknown>).multiRepo).toBeUndefined();
|
||||
expect(normalizations).toHaveLength(1);
|
||||
expect(normalizations[0]).toMatchObject({ from: 'multiRepo', to: 'planning.sub_repos', requiresFilesystem: true });
|
||||
});
|
||||
|
||||
it('migrates top-level depth to granularity (comprehensive → fine)', () => {
|
||||
const input = { depth: 'comprehensive' };
|
||||
const { parsed, normalizations } = normalizeLegacyKeys(input);
|
||||
expect((parsed as Record<string, unknown>).depth).toBeUndefined();
|
||||
expect(normalizations).toHaveLength(1);
|
||||
expect(normalizations[0].from).toBe('depth');
|
||||
// The value in parsed is the mapped granularity value
|
||||
const gv = (parsed as Record<string, unknown>).granularity
|
||||
?? (parsed as Record<string, Record<string, unknown>>).planning?.granularity;
|
||||
expect(gv).toBe('fine');
|
||||
});
|
||||
|
||||
it('migrates top-level depth: quick → coarse', () => {
|
||||
const input = { depth: 'quick' };
|
||||
const { parsed, normalizations } = normalizeLegacyKeys(input);
|
||||
const gv = (parsed as Record<string, unknown>).granularity
|
||||
?? (parsed as Record<string, Record<string, unknown>>).planning?.granularity;
|
||||
expect(gv).toBe('coarse');
|
||||
expect(normalizations[0].from).toBe('depth');
|
||||
});
|
||||
|
||||
it('migrates all four legacy keys in one pass', () => {
|
||||
const input = {
|
||||
branching_strategy: 'phase',
|
||||
sub_repos: ['app1'],
|
||||
multiRepo: true,
|
||||
depth: 'comprehensive',
|
||||
};
|
||||
const { parsed, normalizations } = normalizeLegacyKeys(input);
|
||||
expect(normalizations).toHaveLength(4);
|
||||
// branching_strategy → git.branching_strategy
|
||||
expect((parsed as Record<string, Record<string, unknown>>).git?.branching_strategy).toBe('phase');
|
||||
});
|
||||
|
||||
it('returns empty normalizations for already-normalized input', () => {
|
||||
const input = { git: { branching_strategy: 'phase' }, planning: { sub_repos: ['app1'] } };
|
||||
const { parsed, normalizations } = normalizeLegacyKeys(input);
|
||||
expect(normalizations).toHaveLength(0);
|
||||
expect(parsed).toEqual(input);
|
||||
});
|
||||
|
||||
it('is idempotent — running twice produces same result with empty normalizations second time', () => {
|
||||
const input = { branching_strategy: 'phase' };
|
||||
const first = normalizeLegacyKeys(input);
|
||||
const second = normalizeLegacyKeys(first.parsed as Record<string, unknown>);
|
||||
expect(second.normalizations).toHaveLength(0);
|
||||
expect(second.parsed).toEqual(first.parsed);
|
||||
});
|
||||
|
||||
it('preserves canonical git.branching_strategy when both top-level and nested exist', () => {
|
||||
const input = { branching_strategy: 'milestone', git: { branching_strategy: 'phase' } };
|
||||
const { parsed } = normalizeLegacyKeys(input);
|
||||
// canonical nested wins
|
||||
expect((parsed as Record<string, Record<string, unknown>>).git?.branching_strategy).toBe('phase');
|
||||
});
|
||||
|
||||
it('preserves canonical planning.sub_repos when both top-level and nested exist', () => {
|
||||
const input = { sub_repos: ['legacy'], planning: { sub_repos: null } };
|
||||
const { parsed } = normalizeLegacyKeys(input);
|
||||
expect((parsed as Record<string, Record<string, unknown>>).sub_repos).toBeUndefined();
|
||||
// canonical nested wins even when explicit null is used to unset
|
||||
expect((parsed as Record<string, Record<string, unknown>>).planning?.sub_repos).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mergeDefaults ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('mergeDefaults', () => {
|
||||
it('returns full CONFIG_DEFAULTS for empty input', () => {
|
||||
const result = mergeDefaults({});
|
||||
expect(result).toEqual(CONFIG_DEFAULTS);
|
||||
});
|
||||
|
||||
it('merges partial nested input without losing sibling keys', () => {
|
||||
const partial = { git: { base_branch: 'main' } };
|
||||
const result = mergeDefaults(partial);
|
||||
// base_branch from input
|
||||
expect((result.git as Record<string, unknown>).base_branch).toBe('main');
|
||||
// sibling from defaults
|
||||
expect(result.git.branching_strategy).toBe(CONFIG_DEFAULTS.git.branching_strategy);
|
||||
expect(result.git.phase_branch_template).toBe(CONFIG_DEFAULTS.git.phase_branch_template);
|
||||
});
|
||||
|
||||
it('preserves boolean false values (not overridden by truthy defaults)', () => {
|
||||
// workflow.research defaults to true; setting false should survive merge
|
||||
const partial = { workflow: { research: false } };
|
||||
const result = mergeDefaults(partial);
|
||||
expect(result.workflow.research).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves explicit null values', () => {
|
||||
const partial = { project_code: null };
|
||||
const result = mergeDefaults(partial);
|
||||
expect(result.project_code).toBeNull();
|
||||
});
|
||||
|
||||
it('user top-level keys win over defaults', () => {
|
||||
const partial = { model_profile: 'quality' };
|
||||
const result = mergeDefaults(partial);
|
||||
expect(result.model_profile).toBe('quality');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── migrateOnDisk ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('migrateOnDisk', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupDir(tmpDir);
|
||||
});
|
||||
|
||||
it('returns migrated:false, wrote:null for already-normalized config', async () => {
|
||||
writeConfig(tmpDir, { git: { branching_strategy: 'phase' } });
|
||||
const report = await migrateOnDisk(tmpDir);
|
||||
expect(report.migrated).toBe(false);
|
||||
expect(report.wrote).toBeNull();
|
||||
expect(report.normalizations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns migrated:true, writes disk when legacy key present', async () => {
|
||||
writeConfig(tmpDir, { branching_strategy: 'phase' });
|
||||
const report = await migrateOnDisk(tmpDir);
|
||||
expect(report.migrated).toBe(true);
|
||||
expect(report.wrote).not.toBeNull();
|
||||
expect(report.normalizations.length).toBeGreaterThan(0);
|
||||
// Verify disk was updated
|
||||
const onDisk = JSON.parse(readConfigRaw(tmpDir));
|
||||
expect(onDisk.branching_strategy).toBeUndefined();
|
||||
expect(onDisk.git?.branching_strategy).toBe('phase');
|
||||
});
|
||||
|
||||
it('returns report shape: { migrated, normalizations, wrote }', async () => {
|
||||
writeConfig(tmpDir, { branching_strategy: 'milestone' });
|
||||
const report = await migrateOnDisk(tmpDir);
|
||||
expect(report).toHaveProperty('migrated');
|
||||
expect(report).toHaveProperty('normalizations');
|
||||
expect(report).toHaveProperty('wrote');
|
||||
});
|
||||
|
||||
it('is a no-op when .planning/config.json is missing', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gsd-cfg-nomig-'));
|
||||
try {
|
||||
const report = await migrateOnDisk(dir);
|
||||
expect(report.migrated).toBe(false);
|
||||
expect(report.wrote).toBeNull();
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
325
sdk/src/configuration/index.ts
Normal file
325
sdk/src/configuration/index.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Configuration Module — single source of truth for config loading,
|
||||
* legacy-key normalization, defaults merge, and explicit on-disk migration.
|
||||
*
|
||||
* Source of truth for both the SDK and (via generator) the CJS side.
|
||||
* Manifests are read from sdk/shared/*.manifest.json.
|
||||
*
|
||||
* Public API:
|
||||
* loadConfig(cwd, options?) → MergedConfig — pure read, never writes disk
|
||||
* normalizeLegacyKeys(parsed) → { parsed, normalizations[] } — pure transform
|
||||
* mergeDefaults(parsed) → MergedConfig — fills in defaults
|
||||
* migrateOnDisk(cwd) → MigrationReport — explicit, opt-in disk writeback
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// ─── Manifest imports ─────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULTS_PATH = new URL('../../shared/config-defaults.manifest.json', import.meta.url);
|
||||
export const CONFIG_DEFAULTS: Record<string, unknown> = JSON.parse(
|
||||
readFileSync(fileURLToPath(DEFAULTS_PATH), 'utf-8'),
|
||||
);
|
||||
|
||||
const SCHEMA_PATH = new URL('../../shared/config-schema.manifest.json', import.meta.url);
|
||||
const _schemaManifest: {
|
||||
validKeys: string[];
|
||||
runtimeStateKeys: string[];
|
||||
dynamicKeyPatterns: Array<{ topLevel: string; source: string; description: string }>;
|
||||
} = JSON.parse(readFileSync(fileURLToPath(SCHEMA_PATH), 'utf-8'));
|
||||
|
||||
export const VALID_CONFIG_KEYS: ReadonlySet<string> = new Set(_schemaManifest.validKeys);
|
||||
export const RUNTIME_STATE_KEYS: ReadonlySet<string> = new Set(_schemaManifest.runtimeStateKeys);
|
||||
|
||||
export interface DynamicKeyPattern {
|
||||
readonly topLevel: string;
|
||||
readonly source: string;
|
||||
readonly description: string;
|
||||
readonly test: (key: string) => boolean;
|
||||
}
|
||||
|
||||
export const DYNAMIC_KEY_PATTERNS: readonly DynamicKeyPattern[] = _schemaManifest.dynamicKeyPatterns.map(
|
||||
(p) => {
|
||||
const pattern = new RegExp(p.source);
|
||||
return {
|
||||
...p,
|
||||
test: (key: string) => {
|
||||
pattern.lastIndex = 0;
|
||||
return pattern.test(key);
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Broad merged config type — consumers narrow as needed. */
|
||||
export type MergedConfig = Record<string, unknown>;
|
||||
|
||||
export interface Normalization {
|
||||
from: string;
|
||||
to: string;
|
||||
value: unknown;
|
||||
requiresFilesystem?: true;
|
||||
}
|
||||
|
||||
export interface NormalizationResult {
|
||||
parsed: MergedConfig;
|
||||
normalizations: Normalization[];
|
||||
}
|
||||
|
||||
export interface MigrationReport {
|
||||
migrated: boolean;
|
||||
normalizations: Normalization[];
|
||||
wrote: string | null;
|
||||
}
|
||||
|
||||
export interface LoadConfigOptions {
|
||||
/** Optional workstream name — routes to .planning/workstreams/<name>/config.json */
|
||||
workstream?: string;
|
||||
/** Optional callback to observe normalizations applied during load */
|
||||
onNormalizations?: (normalizations: Normalization[]) => void;
|
||||
}
|
||||
|
||||
// ─── Depth → Granularity mapping ─────────────────────────────────────────────
|
||||
|
||||
const DEPTH_TO_GRANULARITY: Record<string, string> = {
|
||||
quick: 'coarse',
|
||||
standard: 'standard',
|
||||
comprehensive: 'fine',
|
||||
};
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function planningDir(cwd: string, workstream?: string): string {
|
||||
if (!workstream) return join(cwd, '.planning');
|
||||
return join(cwd, '.planning', 'workstreams', workstream);
|
||||
}
|
||||
|
||||
function detectSubRepos(cwd: string): string[] {
|
||||
const results: string[] = [];
|
||||
try {
|
||||
const entries = readdirSync(cwd, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||
const gitPath = join(cwd, entry.name, '.git');
|
||||
try {
|
||||
if (existsSync(gitPath)) {
|
||||
results.push(entry.name);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return results.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-merge two plain config objects. overlay wins on key conflict.
|
||||
* Explicit null in overlay overrides base (null means "unset this key").
|
||||
* Arrays are replaced, not merged. undefined in overlay falls back to base.
|
||||
*/
|
||||
function deepMergeConfig(base: Record<string, unknown>, overlay: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = { ...base };
|
||||
for (const key of Object.keys(overlay)) {
|
||||
const ov = overlay[key];
|
||||
if (ov !== null && ov !== undefined && typeof ov === 'object' && !Array.isArray(ov)) {
|
||||
const bv = base[key];
|
||||
if (bv !== null && bv !== undefined && typeof bv === 'object' && !Array.isArray(bv)) {
|
||||
result[key] = deepMergeConfig(bv as Record<string, unknown>, ov as Record<string, unknown>);
|
||||
} else {
|
||||
result[key] = deepMergeConfig({}, ov as Record<string, unknown>);
|
||||
}
|
||||
} else {
|
||||
result[key] = ov;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── normalizeLegacyKeys ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pure transform: migrate legacy top-level config keys to their canonical nested locations.
|
||||
* Returns the normalized parsed object + a list of normalizations applied.
|
||||
* Idempotent: calling twice returns the same result with empty normalizations second time.
|
||||
*
|
||||
* Normalizations applied (in order):
|
||||
* 1. top-level branching_strategy → git.branching_strategy (canonical wins if both present)
|
||||
* 2. top-level sub_repos → planning.sub_repos (canonical wins if both present)
|
||||
* 3. multiRepo: true → planning.sub_repos marker (requiresFilesystem: true)
|
||||
* 4. top-level depth → granularity (top-level) with mapping quick→coarse/standard→standard/comprehensive→fine
|
||||
*/
|
||||
export function normalizeLegacyKeys(parsed: Record<string, unknown>): NormalizationResult {
|
||||
const result: Record<string, unknown> = { ...parsed };
|
||||
const normalizations: Normalization[] = [];
|
||||
|
||||
// 1. branching_strategy → git.branching_strategy
|
||||
if (Object.prototype.hasOwnProperty.call(result, 'branching_strategy')) {
|
||||
const value = result.branching_strategy;
|
||||
const git = (result.git as Record<string, unknown> | undefined) ?? {};
|
||||
if (git.branching_strategy === undefined) {
|
||||
result.git = { ...git, branching_strategy: value };
|
||||
} else {
|
||||
// canonical nested wins — just delete the stale top-level
|
||||
result.git = { ...git };
|
||||
}
|
||||
delete result.branching_strategy;
|
||||
normalizations.push({ from: 'branching_strategy', to: 'git.branching_strategy', value });
|
||||
}
|
||||
|
||||
// 2. top-level sub_repos → planning.sub_repos
|
||||
if (Object.prototype.hasOwnProperty.call(result, 'sub_repos')) {
|
||||
const value = result.sub_repos;
|
||||
const planning = (result.planning as Record<string, unknown> | undefined) ?? {};
|
||||
if (planning.sub_repos === undefined) {
|
||||
result.planning = { ...planning, sub_repos: value };
|
||||
} else {
|
||||
// canonical nested wins — just drop the stale top-level
|
||||
result.planning = { ...planning };
|
||||
}
|
||||
delete result.sub_repos;
|
||||
normalizations.push({ from: 'sub_repos', to: 'planning.sub_repos', value });
|
||||
}
|
||||
|
||||
// 3. multiRepo: true → marker (filesystem detection deferred to migrateOnDisk / caller)
|
||||
if (result.multiRepo === true) {
|
||||
delete result.multiRepo;
|
||||
normalizations.push({ from: 'multiRepo', to: 'planning.sub_repos', value: true, requiresFilesystem: true });
|
||||
}
|
||||
|
||||
// 4. top-level depth → granularity
|
||||
if (Object.prototype.hasOwnProperty.call(result, 'depth') && !Object.prototype.hasOwnProperty.call(result, 'granularity')) {
|
||||
const rawDepth = result.depth as string;
|
||||
const mapped = DEPTH_TO_GRANULARITY[rawDepth] ?? rawDepth;
|
||||
result.granularity = mapped;
|
||||
delete result.depth;
|
||||
normalizations.push({ from: 'depth', to: 'granularity', value: mapped });
|
||||
}
|
||||
|
||||
return { parsed: result, normalizations };
|
||||
}
|
||||
|
||||
// ─── mergeDefaults ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fill in CONFIG_DEFAULTS where the parsed object lacks values.
|
||||
* Deep-merges per-section (git, workflow, hooks, agent_skills, planning, ship).
|
||||
* Boolean false and explicit null are preserved — not overridden by truthy defaults.
|
||||
*/
|
||||
export function mergeDefaults(parsed: Record<string, unknown>): MergedConfig {
|
||||
// Start with a deep clone of defaults, then overlay parsed
|
||||
const defaults = JSON.parse(JSON.stringify(CONFIG_DEFAULTS)) as Record<string, unknown>;
|
||||
return deepMergeConfig(defaults, parsed);
|
||||
}
|
||||
|
||||
// ─── loadConfig ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load project config from .planning/config.json (workstream-aware).
|
||||
* Pure read — never writes disk.
|
||||
*
|
||||
* Pipeline: parse JSON → normalizeLegacyKeys → mergeDefaults → return.
|
||||
*
|
||||
* Missing file → returns CONFIG_DEFAULTS verbatim.
|
||||
* Empty file → returns CONFIG_DEFAULTS verbatim.
|
||||
* Malformed JSON → throws with informative error.
|
||||
*/
|
||||
export async function loadConfig(cwd: string, options?: LoadConfigOptions): Promise<MergedConfig> {
|
||||
const configPath = join(planningDir(cwd, options?.workstream), 'config.json');
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(configPath, 'utf-8');
|
||||
} catch {
|
||||
// File missing — return defaults
|
||||
return mergeDefaults({});
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') {
|
||||
return mergeDefaults({});
|
||||
}
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to parse config at ${configPath}: ${msg}`);
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error(`Config at ${configPath} must be a JSON object`);
|
||||
}
|
||||
|
||||
const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed);
|
||||
if (options?.onNormalizations && normalizations.length > 0) {
|
||||
options.onNormalizations(normalizations);
|
||||
}
|
||||
|
||||
return mergeDefaults(normalized);
|
||||
}
|
||||
|
||||
// ─── migrateOnDisk ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Explicit, opt-in disk writeback.
|
||||
* Reads raw config, runs normalizeLegacyKeys, writes back only if normalizations are non-empty.
|
||||
*
|
||||
* For multiRepo: true entries, also runs filesystem detection to populate planning.sub_repos.
|
||||
*
|
||||
* Returns MigrationReport: { migrated, normalizations, wrote }.
|
||||
*/
|
||||
export async function migrateOnDisk(cwd: string, workstream?: string): Promise<MigrationReport> {
|
||||
const configPath = join(planningDir(cwd, workstream), 'config.json');
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(configPath, 'utf-8');
|
||||
} catch {
|
||||
// File missing — nothing to migrate
|
||||
return { migrated: false, normalizations: [], wrote: null };
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') {
|
||||
return { migrated: false, normalizations: [], wrote: null };
|
||||
}
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Malformed — can't migrate
|
||||
return { migrated: false, normalizations: [], wrote: null };
|
||||
}
|
||||
|
||||
const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed);
|
||||
|
||||
if (normalizations.length === 0) {
|
||||
return { migrated: false, normalizations: [], wrote: null };
|
||||
}
|
||||
|
||||
// Resolve multiRepo filesystem detection
|
||||
const result = { ...normalized };
|
||||
for (const norm of normalizations) {
|
||||
if (norm.requiresFilesystem) {
|
||||
const detected = detectSubRepos(cwd);
|
||||
if (detected.length > 0) {
|
||||
const planning = (result.planning as Record<string, unknown> | undefined) ?? {};
|
||||
result.planning = { ...planning, sub_repos: detected, commit_docs: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(configPath, JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to write migrated config at ${configPath}: ${msg}`);
|
||||
}
|
||||
return { migrated: true, normalizations, wrote: configPath };
|
||||
}
|
||||
@@ -1,155 +1,31 @@
|
||||
/**
|
||||
* SDK-side mirror of get-shit-done/bin/lib/config-schema.cjs.
|
||||
* Thin re-export adapter — sources schema data from the Configuration Module
|
||||
* (sdk/src/configuration/index.ts), which reads from the manifest at
|
||||
* sdk/shared/config-schema.manifest.json.
|
||||
*
|
||||
* Single source of truth for valid config key paths accepted by
|
||||
* `config-set`. MUST stay in sync with the CJS schema — enforced
|
||||
* by tests/config-schema-sdk-parity.test.cjs (CI drift guard).
|
||||
* All inline literals have been removed. The manifest is the single source
|
||||
* of truth for both the SDK and CJS sides (Phase 2 Cycle 5, #3536).
|
||||
*
|
||||
* If you add/remove a key here, make the identical change in
|
||||
* get-shit-done/bin/lib/config-schema.cjs (and vice versa). The
|
||||
* parity test asserts the two allowlists are set-equal and that
|
||||
* DYNAMIC_KEY_PATTERN_SOURCES produce identical regex source strings.
|
||||
*
|
||||
* See #2653 — CJS/SDK drift caused config-set to reject documented
|
||||
* keys. #2479 added CJS↔docs parity; #2653 adds CJS↔SDK parity.
|
||||
* Consumers of this module see an identical public API to the previous version:
|
||||
* VALID_CONFIG_KEYS — ReadonlySet<string>
|
||||
* RUNTIME_STATE_KEYS — ReadonlySet<string>
|
||||
* DYNAMIC_KEY_PATTERNS — readonly DynamicKeyPattern[]
|
||||
* DynamicKeyPattern — interface (re-exported type)
|
||||
* isValidConfigKeyPath — (keyPath: string) => boolean
|
||||
*/
|
||||
|
||||
/** Exact-match config key paths accepted by config-set. */
|
||||
export const VALID_CONFIG_KEYS: ReadonlySet<string> = new Set([
|
||||
'mode', 'granularity', 'parallelization', 'commit_docs', 'model_profile',
|
||||
'search_gitignored', 'brave_search', 'firecrawl', 'exa_search',
|
||||
'workflow.research', 'workflow.plan_check', 'workflow.verifier',
|
||||
'workflow.nyquist_validation', 'workflow.ai_integration_phase', 'workflow.ui_phase', 'workflow.ui_safety_gate',
|
||||
'workflow.auto_advance', 'workflow.node_repair', 'workflow.node_repair_budget',
|
||||
'workflow.tdd_mode',
|
||||
'workflow.human_verify_mode',
|
||||
'workflow.text_mode',
|
||||
'workflow.research_before_questions',
|
||||
'workflow.discuss_mode',
|
||||
'workflow.skip_discuss',
|
||||
'workflow.auto_prune_state',
|
||||
'workflow.use_worktrees',
|
||||
'workflow.worktree_skip_hooks',
|
||||
'workflow.code_review',
|
||||
'workflow.code_review_depth',
|
||||
'workflow.code_review_command',
|
||||
'workflow.pattern_mapper',
|
||||
'workflow.plan_bounce',
|
||||
'workflow.plan_bounce_script',
|
||||
'workflow.plan_bounce_passes',
|
||||
'workflow.plan_chunked',
|
||||
'workflow.plan_review_convergence',
|
||||
'workflow.post_planning_gaps',
|
||||
'workflow.security_enforcement',
|
||||
'workflow.security_asvs_level',
|
||||
'workflow.security_block_on',
|
||||
'workflow.drift_threshold',
|
||||
'workflow.drift_action',
|
||||
'code_quality.fallow.enabled',
|
||||
'code_quality.fallow.scope',
|
||||
'code_quality.fallow.profile',
|
||||
'code_quality.fallow.mcp',
|
||||
'ship.pr_body_sections',
|
||||
'git.branching_strategy', 'git.base_branch', 'git.create_tag', 'git.phase_branch_template', 'git.milestone_branch_template', 'git.quick_branch_template',
|
||||
'planning.commit_docs', 'planning.search_gitignored', 'planning.sub_repos',
|
||||
'review.default_reviewers',
|
||||
'review.ollama_host', 'review.lm_studio_host', 'review.llama_cpp_host',
|
||||
'workflow.cross_ai_execution', 'workflow.cross_ai_command', 'workflow.cross_ai_timeout',
|
||||
'workflow.subagent_timeout',
|
||||
'executor.stall_detect_interval_minutes',
|
||||
'executor.stall_threshold_minutes',
|
||||
'workflow.inline_plan_threshold',
|
||||
'hooks.context_warnings',
|
||||
'hooks.workflow_guard',
|
||||
'workflow.context_coverage_gate',
|
||||
'statusline.show_last_command',
|
||||
'statusline.context_position',
|
||||
'workflow.ui_review',
|
||||
'workflow.max_discuss_passes',
|
||||
'features.thinking_partner',
|
||||
'context',
|
||||
'features.global_learnings',
|
||||
'learnings.max_inject',
|
||||
'project_code', 'phase_naming',
|
||||
'manager.flags.discuss', 'manager.flags.plan', 'manager.flags.execute',
|
||||
'response_language',
|
||||
'context_window',
|
||||
'intel.enabled',
|
||||
'graphify.enabled',
|
||||
'graphify.build_timeout',
|
||||
'claude_md_path',
|
||||
'claude_md_assembly.mode',
|
||||
// #2517 — runtime-aware model profiles
|
||||
'runtime',
|
||||
// #3162 — documented top-level key: controls model ID resolution for non-Claude runtimes
|
||||
'resolve_model_ids',
|
||||
]);
|
||||
import {
|
||||
VALID_CONFIG_KEYS,
|
||||
RUNTIME_STATE_KEYS,
|
||||
DYNAMIC_KEY_PATTERNS,
|
||||
} from '../configuration/index.js';
|
||||
|
||||
/**
|
||||
* Internal runtime-state keys accepted by config-set workflows but not exposed
|
||||
* as user-facing config options.
|
||||
*/
|
||||
export const RUNTIME_STATE_KEYS: ReadonlySet<string> = new Set([
|
||||
'workflow._auto_chain_active',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Dynamic-pattern validators — keys matching these regexes are also accepted.
|
||||
* Each entry's `source` MUST equal the corresponding CJS regex `.source`
|
||||
* (the parity test enforces this).
|
||||
*/
|
||||
export interface DynamicKeyPattern {
|
||||
readonly test: (k: string) => boolean;
|
||||
readonly description: string;
|
||||
readonly source: string;
|
||||
}
|
||||
|
||||
export const DYNAMIC_KEY_PATTERNS: readonly DynamicKeyPattern[] = [
|
||||
{
|
||||
source: '^agent_skills\\.[a-zA-Z0-9_-]+$',
|
||||
description: 'agent_skills.<agent-type>',
|
||||
test: (k) => /^agent_skills\.[a-zA-Z0-9_-]+$/.test(k),
|
||||
},
|
||||
{
|
||||
source: '^review\\.models\\.[a-zA-Z0-9_-]+$',
|
||||
description: 'review.models.<cli-name>',
|
||||
test: (k) => /^review\.models\.[a-zA-Z0-9_-]+$/.test(k),
|
||||
},
|
||||
{
|
||||
source: '^features\\.[a-zA-Z0-9_]+$',
|
||||
description: 'features.<feature_name>',
|
||||
test: (k) => /^features\.[a-zA-Z0-9_]+$/.test(k),
|
||||
},
|
||||
{
|
||||
source: '^claude_md_assembly\\.blocks\\.[a-zA-Z0-9_]+$',
|
||||
description: 'claude_md_assembly.blocks.<section>',
|
||||
test: (k) => /^claude_md_assembly\.blocks\.[a-zA-Z0-9_]+$/.test(k),
|
||||
},
|
||||
// #2517 — runtime-aware model profile overrides: model_profile_overrides.<runtime>.<tier>
|
||||
{
|
||||
source: '^model_profile_overrides\\.[a-zA-Z0-9_-]+\\.(opus|sonnet|haiku)$',
|
||||
description: 'model_profile_overrides.<runtime>.<opus|sonnet|haiku>',
|
||||
test: (k) => /^model_profile_overrides\.[a-zA-Z0-9_-]+\.(opus|sonnet|haiku)$/.test(k),
|
||||
},
|
||||
// #3023 — per-phase-type model map: models.<phase_type> = <tier>
|
||||
{
|
||||
source: '^models\\.(planning|discuss|research|execution|verification|completion)$',
|
||||
description: 'models.<planning|discuss|research|execution|verification|completion>',
|
||||
test: (k) => /^models\.(planning|discuss|research|execution|verification|completion)$/.test(k),
|
||||
},
|
||||
// #3024 — dynamic routing with failure-tier escalation
|
||||
{
|
||||
source: '^dynamic_routing\\.(enabled|escalate_on_failure|max_escalations|tier_models\\.(light|standard|heavy))$',
|
||||
description: 'dynamic_routing.<enabled|escalate_on_failure|max_escalations|tier_models.<light|standard|heavy>>',
|
||||
test: (k) => /^dynamic_routing\.(enabled|escalate_on_failure|max_escalations|tier_models\.(light|standard|heavy))$/.test(k),
|
||||
},
|
||||
// #3227 — per-agent model overrides: model_overrides.<agent-id>
|
||||
{
|
||||
source: '^model_overrides\\.[a-zA-Z0-9_-]+$',
|
||||
description: 'model_overrides.<agent-id>',
|
||||
test: (k) => /^model_overrides\.[a-zA-Z0-9_-]+$/.test(k),
|
||||
},
|
||||
];
|
||||
export {
|
||||
VALID_CONFIG_KEYS,
|
||||
RUNTIME_STATE_KEYS,
|
||||
DYNAMIC_KEY_PATTERNS,
|
||||
type DynamicKeyPattern,
|
||||
} from '../configuration/index.js';
|
||||
|
||||
/** Returns true if keyPath is a valid config key (exact, runtime-state, or dynamic pattern). */
|
||||
export function isValidConfigKeyPath(keyPath: string): boolean {
|
||||
|
||||
@@ -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)',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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)`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
355
tests/configuration-generator.test.cjs
Normal file
355
tests/configuration-generator.test.cjs
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
178
tests/configuration-migrate-config.test.cjs
Normal file
178
tests/configuration-migrate-config.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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)`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user