/** * GSD Tools Tests - config.cjs * * CLI integration tests for config-ensure-section, config-set, and config-get * commands exercised through gsd-tools.cjs via execSync. * * Requirements: TEST-13 */ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { runGsdTools, createTempProject, cleanup, delay } = require('./helpers.cjs'); // ─── helpers ────────────────────────────────────────────────────────────────── function readConfig(tmpDir) { const configPath = path.join(tmpDir, '.planning', 'config.json'); return JSON.parse(fs.readFileSync(configPath, 'utf-8')); } function writeConfig(tmpDir, obj) { const configPath = path.join(tmpDir, '.planning', 'config.json'); fs.writeFileSync(configPath, JSON.stringify(obj, null, 2), 'utf-8'); } async function runConfigEnsureSectionWithRetry(tmpDir, attempts = 4) { let last; for (let i = 0; i < attempts; i += 1) { last = runGsdTools('config-ensure-section', tmpDir); if (last.success) return last; const detail = `${last.error || ''}\n${last.output || ''}`; const transient = /(EPERM|EBUSY|EACCES|ENOTEMPTY|resource busy|used by another process|permission denied)/i.test(detail); if (!transient || i === attempts - 1) return last; await delay(150 * (i + 1)); } return last; } /** * Seed `.planning/config.json` for a test and guarantee it lands on disk * before the test body runs. * * `config-ensure-section` is invoked through a spawned `gsd-tools.cjs` child. * On the scoped CI lane (`--test-concurrency=4`, config.test.cjs scheduled * alongside the heavy install/tarball suites) that child can be transiently * killed under resource pressure — surfacing as a non-zero exit with empty * stderr (an OS-level kill, not a gsd-tools application error; see the * `runGsdTools` catch). A bare `runGsdTools('config-ensure-section')` in * `beforeEach` swallows that failure, leaving config.json absent so the first * subtest's `readConfig()` throws a confusing ENOENT (#770 scoped-lane flake). * * This retries on ANY failure or missing file (not just the EPERM/EBUSY class * `runConfigEnsureSectionWithRetry` covers) and throws a clear diagnostic if it * still cannot create the file, so setup is deterministic under load. */ async function ensureConfigReady(tmpDir, attempts = 5) { const configPath = path.join(tmpDir, '.planning', 'config.json'); let last; for (let i = 0; i < attempts; i += 1) { last = runGsdTools('config-ensure-section', tmpDir); if (last.success && fs.existsSync(configPath)) return last; if (i < attempts - 1) await delay(150 * (i + 1)); } throw new Error( `config-ensure-section failed to create ${configPath} after ${attempts} attempts: ` + `${(last && last.error) || 'unknown error'}`, ); } // ─── config-ensure-section ─────────────────────────────────────────────────── describe('config-ensure-section command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('creates config.json with expected structure and types', () => { const result = runGsdTools('config-ensure-section', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.created, true); const config = readConfig(tmpDir); // Verify structure and types — exact values may vary if ~/.gsd/defaults.json exists assert.strictEqual(typeof config.model_profile, 'string'); assert.strictEqual(typeof config.commit_docs, 'boolean'); assert.strictEqual(typeof config.parallelization, 'boolean'); assert.ok(config.git && typeof config.git === 'object', 'git should be an object'); assert.strictEqual(typeof config.git.branching_strategy, 'string'); assert.ok(config.workflow && typeof config.workflow === 'object', 'workflow should be an object'); assert.strictEqual(typeof config.workflow.research, 'boolean'); assert.strictEqual(typeof config.workflow.plan_check, 'boolean'); assert.strictEqual(typeof config.workflow.verifier, 'boolean'); assert.strictEqual(typeof config.workflow.nyquist_validation, 'boolean'); // These hardcoded defaults are always present (may be overridden by user defaults) assert.ok('model_profile' in config, 'model_profile should exist'); assert.ok('brave_search' in config, 'brave_search should exist'); assert.ok('search_gitignored' in config, 'search_gitignored should exist'); }); test('is idempotent — returns already_exists on second call', async () => { const first = await runConfigEnsureSectionWithRetry(tmpDir); assert.ok(first.success, `First call failed: ${first.error}`); const firstOutput = JSON.parse(first.output); assert.strictEqual(firstOutput.created, true); const second = await runConfigEnsureSectionWithRetry(tmpDir); assert.ok(second.success, `Second call failed: ${second.error}`); const secondOutput = JSON.parse(second.output); assert.strictEqual(secondOutput.created, false); assert.strictEqual(secondOutput.reason, 'already_exists'); }); test('detects Brave Search from file-based key', () => { // runGsdTools sandboxes HOME=tmpDir, so brave_api_key is written there — // no real filesystem side effects, cleanup happens via afterEach. const gsdDir = path.join(tmpDir, '.gsd'); fs.mkdirSync(gsdDir, { recursive: true }); fs.writeFileSync(path.join(gsdDir, 'brave_api_key'), 'test-key', 'utf-8'); const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.brave_search, true); }); test('detects Tavily Search from env var', () => { const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, TAVILY_API_KEY: 'test-key' }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.tavily_search, true); }); test('tavily_search is false when env var absent and no key file', () => { const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, TAVILY_API_KEY: '' }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.tavily_search, false); }); test('detects Tavily Search from file-based key', () => { const gsdDir = path.join(tmpDir, '.gsd'); fs.mkdirSync(gsdDir, { recursive: true }); fs.writeFileSync(path.join(gsdDir, 'tavily_api_key'), 'test-key', 'utf-8'); const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, TAVILY_API_KEY: '' }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.tavily_search, true); }); test('detects Ref Search from env var', () => { const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, REF_API_KEY: 'test-key' }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.ref_search, true); }); test('ref_search is false when env var absent and no key file', () => { const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, REF_API_KEY: '' }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.ref_search, false); }); test('detects Ref Search from file-based key', () => { const gsdDir = path.join(tmpDir, '.gsd'); fs.mkdirSync(gsdDir, { recursive: true }); fs.writeFileSync(path.join(gsdDir, 'ref_api_key'), 'test-key', 'utf-8'); const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, REF_API_KEY: '' }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.ref_search, true); }); test('detects Perplexity from env var', () => { const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, PERPLEXITY_API_KEY: 'test-key' }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.perplexity, true); }); test('perplexity is false when env var absent and no key file', () => { const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, PERPLEXITY_API_KEY: '' }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.perplexity, false); }); test('detects Perplexity from file-based key', () => { const gsdDir = path.join(tmpDir, '.gsd'); fs.mkdirSync(gsdDir, { recursive: true }); fs.writeFileSync(path.join(gsdDir, 'perplexity_api_key'), 'test-key', 'utf-8'); const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, PERPLEXITY_API_KEY: '' }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.perplexity, true); }); test('detects Jina from env var', () => { const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, JINA_API_KEY: 'test-key' }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.jina, true); }); test('jina is false when env var absent and no key file', () => { const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, JINA_API_KEY: '' }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.jina, false); }); test('detects Jina from file-based key', () => { const gsdDir = path.join(tmpDir, '.gsd'); fs.mkdirSync(gsdDir, { recursive: true }); fs.writeFileSync(path.join(gsdDir, 'jina_api_key'), 'test-key', 'utf-8'); const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, JINA_API_KEY: '' }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.jina, true); }); test('merges user defaults from defaults.json', () => { // runGsdTools sandboxes HOME=tmpDir, so defaults.json is written there — // no real filesystem side effects, cleanup happens via afterEach. const gsdDir = path.join(tmpDir, '.gsd'); fs.mkdirSync(gsdDir, { recursive: true }); fs.writeFileSync(path.join(gsdDir, 'defaults.json'), JSON.stringify({ model_profile: 'quality', commit_docs: false, }), 'utf-8'); const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.model_profile, 'quality', 'model_profile should be overridden'); assert.strictEqual(config.commit_docs, false, 'commit_docs should be overridden'); assert.ok(config.git && typeof config.git === 'object', 'git should be an object'); assert.strictEqual(typeof config.git.branching_strategy, 'string', 'git.branching_strategy should be a string'); }); test('merges nested workflow keys from defaults.json preserving unset keys', () => { // runGsdTools sandboxes HOME=tmpDir, so defaults.json is written there — // no real filesystem side effects, cleanup happens via afterEach. const gsdDir = path.join(tmpDir, '.gsd'); fs.mkdirSync(gsdDir, { recursive: true }); fs.writeFileSync(path.join(gsdDir, 'defaults.json'), JSON.stringify({ workflow: { research: false }, }), 'utf-8'); const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.workflow.research, false, 'research should be overridden'); assert.strictEqual(typeof config.workflow.plan_check, 'boolean', 'plan_check should be a boolean'); assert.strictEqual(typeof config.workflow.verifier, 'boolean', 'verifier should be a boolean'); }); }); // ─── config-set ────────────────────────────────────────────────────────────── describe('config-set command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); // Create initial config runGsdTools('config-ensure-section', tmpDir); }); afterEach(() => { cleanup(tmpDir); }); test('sets a top-level string value', () => { const result = runGsdTools('config-set model_profile quality', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.updated, true); assert.strictEqual(output.key, 'model_profile'); assert.strictEqual(output.value, 'quality'); const config = readConfig(tmpDir); assert.strictEqual(config.model_profile, 'quality'); }); test('coerces true to boolean', () => { const result = runGsdTools('config-set commit_docs true', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.commit_docs, true); assert.strictEqual(typeof config.commit_docs, 'boolean'); }); test('coerces false to boolean', () => { const result = runGsdTools('config-set commit_docs false', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.commit_docs, false); assert.strictEqual(typeof config.commit_docs, 'boolean'); }); test('coerces numeric strings to numbers', () => { const result = runGsdTools('config-set granularity 42', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.granularity, 42); assert.strictEqual(typeof config.granularity, 'number'); }); test('preserves plain strings', () => { const result = runGsdTools('config-set model_profile hello', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.model_profile, 'hello'); assert.strictEqual(typeof config.model_profile, 'string'); }); test('sets nested values via dot-notation', () => { const result = runGsdTools('config-set workflow.research false', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.workflow.research, false); }); test('auto-creates nested objects for dot-notation', () => { // Start with empty config writeConfig(tmpDir, {}); const result = runGsdTools('config-set workflow.research false', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.workflow.research, false); assert.strictEqual(typeof config.workflow, 'object'); }); test('rejects unknown config keys', () => { const result = runGsdTools('config-set workflow.nyquist_validation_enabled false', tmpDir); assert.strictEqual(result.success, false); assert.ok( result.error.includes('Unknown config key'), `Expected "Unknown config key" in error: ${result.error}` ); }); test('sets workflow.text_mode for remote session support', () => { writeConfig(tmpDir, {}); const result = runGsdTools('config-set workflow.text_mode true', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.workflow.text_mode, true); }); test('sets workflow.use_worktrees to disable worktree isolation', () => { writeConfig(tmpDir, {}); const result = runGsdTools('config-set workflow.use_worktrees false', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.workflow.use_worktrees, false); }); test('sets git.base_branch for non-main default branches', () => { writeConfig(tmpDir, {}); const result = runGsdTools('config-set git.base_branch master', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.git.base_branch, 'master'); }); test('sets intel.enabled to opt into the intel subsystem', () => { writeConfig(tmpDir, {}); const result = runGsdTools('config-set intel.enabled true', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.intel.enabled, true); }); test('errors when no key path provided', () => { const result = runGsdTools('config-set', tmpDir); assert.strictEqual(result.success, false); }); test('rejects known invalid nyquist alias keys with a suggestion', () => { const result = runGsdTools('config-set workflow.nyquist_validation_enabled false', tmpDir); assert.strictEqual(result.success, false); assert.match(result.error, /Unknown config key: workflow\.nyquist_validation_enabled/); assert.match(result.error, /workflow\.nyquist_validation/); const config = readConfig(tmpDir); assert.strictEqual(config.workflow.nyquist_validation_enabled, undefined); assert.strictEqual(config.workflow.nyquist_validation, true); }); }); // ─── config-get ────────────────────────────────────────────────────────────── describe('config-get command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); // Create config with known values — sandbox HOME to avoid global defaults runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); }); afterEach(() => { cleanup(tmpDir); }); test('gets a top-level value', () => { const result = runGsdTools('config-get model_profile', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output, 'balanced'); }); test('gets a nested value via dot-notation', () => { const result = runGsdTools('config-get workflow.research', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output, true); }); test('errors for nonexistent key', () => { const result = runGsdTools('config-get nonexistent_key', tmpDir); assert.strictEqual(result.success, false); assert.ok( result.error.includes('Key not found'), `Expected "Key not found" in error: ${result.error}` ); }); test('errors for deeply nested nonexistent key', () => { const result = runGsdTools('config-get workflow.nonexistent', tmpDir); assert.strictEqual(result.success, false); assert.ok( result.error.includes('Key not found'), `Expected "Key not found" in error: ${result.error}` ); }); describe('when config.json does not exist', () => { let emptyTmpDir; beforeEach(() => { emptyTmpDir = createTempProject(); }); afterEach(() => { cleanup(emptyTmpDir); }); test('errors when config.json does not exist', () => { const result = runGsdTools('config-get model_profile', emptyTmpDir); assert.strictEqual(result.success, false); assert.ok( result.error.includes('No config.json'), `Expected "No config.json" in error: ${result.error}` ); }); }); test('gets git.base_branch after it is set', () => { runGsdTools('config-set git.base_branch master', tmpDir); const result = runGsdTools('config-get git.base_branch', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output, 'master'); }); test('errors for git.base_branch when not explicitly set', () => { // Default config from config-ensure-section does not include git.base_branch, // so config-get should return "Key not found" — this triggers auto-detect // fallback in the workflow (origin/HEAD detection). const result = runGsdTools('config-get git.base_branch', tmpDir); assert.strictEqual(result.success, false); assert.ok( result.error.includes('Key not found'), `Expected "Key not found" in error: ${result.error}` ); }); test('errors when no key path provided', () => { const result = runGsdTools('config-get', tmpDir); assert.strictEqual(result.success, false); }); }); // ─── config-new-project ─────────────────────────────────────────────────────── describe('config-new-project command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('creates full config with all expected keys', () => { const choices = JSON.stringify({ mode: 'interactive', granularity: 'standard', parallelization: true, commit_docs: true, model_profile: 'balanced', workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true }, }); const result = runGsdTools(['config-new-project', choices], tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); // User choices present assert.strictEqual(config.mode, 'interactive'); assert.strictEqual(config.granularity, 'standard'); assert.strictEqual(config.parallelization, true); assert.strictEqual(config.commit_docs, true); assert.strictEqual(config.model_profile, 'balanced'); // Defaults materialized — these were silently missing before assert.strictEqual(typeof config.search_gitignored, 'boolean'); assert.strictEqual(typeof config.brave_search, 'boolean'); // git section present with all three keys assert.ok(config.git && typeof config.git === 'object', 'git section should exist'); assert.strictEqual(config.git.branching_strategy, 'none'); assert.strictEqual(config.git.phase_branch_template, 'gsd/phase-{phase}-{slug}'); assert.strictEqual(config.git.milestone_branch_template, 'gsd/{milestone}-{slug}'); // workflow section present with all keys assert.ok(config.workflow && typeof config.workflow === 'object', 'workflow section should exist'); assert.strictEqual(config.workflow.research, true); assert.strictEqual(config.workflow.plan_check, true); assert.strictEqual(config.workflow.verifier, true); assert.strictEqual(config.workflow.nyquist_validation, true); assert.strictEqual(config.workflow.auto_advance, false); assert.strictEqual(config.workflow.node_repair, true); assert.strictEqual(config.workflow.node_repair_budget, 2); assert.strictEqual(config.workflow.ui_phase, true); assert.strictEqual(config.workflow.ui_safety_gate, true); // hooks section present assert.ok(config.hooks && typeof config.hooks === 'object', 'hooks section should exist'); assert.strictEqual(config.hooks.context_warnings, true); }); test('user choices override defaults', () => { const choices = JSON.stringify({ mode: 'yolo', granularity: 'coarse', parallelization: false, commit_docs: false, model_profile: 'quality', workflow: { research: false, plan_check: false, verifier: true, nyquist_validation: false }, }); const result = runGsdTools(['config-new-project', choices], tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.mode, 'yolo'); assert.strictEqual(config.granularity, 'coarse'); assert.strictEqual(config.parallelization, false); assert.strictEqual(config.commit_docs, false); assert.strictEqual(config.model_profile, 'quality'); assert.strictEqual(config.workflow.research, false); assert.strictEqual(config.workflow.plan_check, false); assert.strictEqual(config.workflow.verifier, true); assert.strictEqual(config.workflow.nyquist_validation, false); // Defaults still present for non-chosen keys assert.strictEqual(config.git.branching_strategy, 'none'); assert.strictEqual(typeof config.search_gitignored, 'boolean'); }); test('works with empty choices — all defaults materialized', () => { const result = runGsdTools(['config-new-project', '{}'], tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.model_profile, 'balanced'); assert.strictEqual(config.commit_docs, true); assert.strictEqual(config.parallelization, true); assert.strictEqual(config.search_gitignored, false); assert.ok(config.git && typeof config.git === 'object'); assert.strictEqual(config.git.branching_strategy, 'none'); assert.ok(config.workflow && typeof config.workflow === 'object'); assert.strictEqual(config.workflow.nyquist_validation, true); assert.strictEqual(config.workflow.auto_advance, false); assert.strictEqual(config.workflow.node_repair, true); assert.strictEqual(config.workflow.node_repair_budget, 2); assert.strictEqual(config.workflow.ui_phase, true); assert.strictEqual(config.workflow.ui_safety_gate, true); assert.ok(config.hooks && typeof config.hooks === 'object'); assert.strictEqual(config.hooks.context_warnings, true); }); test('is idempotent — returns already_exists if config exists', () => { const choices = JSON.stringify({ mode: 'yolo', granularity: 'fine' }); const first = runGsdTools(['config-new-project', choices], tmpDir); assert.ok(first.success, `First call failed: ${first.error}`); const firstOut = JSON.parse(first.output); assert.strictEqual(firstOut.created, true); const second = runGsdTools(['config-new-project', choices], tmpDir); assert.ok(second.success, `Second call failed: ${second.error}`); const secondOut = JSON.parse(second.output); assert.strictEqual(secondOut.created, false); assert.strictEqual(secondOut.reason, 'already_exists'); // Config unchanged const config = readConfig(tmpDir); assert.strictEqual(config.mode, 'yolo'); assert.strictEqual(config.granularity, 'fine'); }); test('auto_advance in workflow choices is preserved', () => { const choices = JSON.stringify({ mode: 'yolo', granularity: 'standard', workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true, auto_advance: true }, }); const result = runGsdTools(['config-new-project', choices], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.workflow.auto_advance, true); }); test('rejects invalid JSON choices', () => { const result = runGsdTools(['config-new-project', '{not-json}'], tmpDir); assert.strictEqual(result.success, false); assert.ok(result.error.includes('Invalid JSON'), `Expected "Invalid JSON" in: ${result.error}`); }); test('output has created:true and path on success', () => { const choices = JSON.stringify({ mode: 'interactive', granularity: 'standard' }); const result = runGsdTools(['config-new-project', choices], tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.created, true); assert.strictEqual(out.path, '.planning/config.json'); }); }); // ─── config-set (research_before_questions and discuss_mode) ────────────────── describe('config-set research_before_questions and discuss_mode', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); }); afterEach(() => { cleanup(tmpDir); }); test('workflow.research_before_questions is a valid config key', () => { const result = runGsdTools('config-set workflow.research_before_questions true', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.workflow.research_before_questions, true); }); test('workflow.discuss_mode is a valid config key', () => { const result = runGsdTools('config-set workflow.discuss_mode assumptions', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.workflow.discuss_mode, 'assumptions'); }); test('research_before_questions defaults to false in new configs', () => { const config = readConfig(tmpDir); assert.strictEqual(config.workflow.research_before_questions, false); }); test('discuss_mode defaults to discuss in new configs', () => { const config = readConfig(tmpDir); assert.strictEqual(config.workflow.discuss_mode, 'discuss'); }); test('hooks.research_questions is rejected with suggestion', () => { const result = runGsdTools('config-set hooks.research_questions true', tmpDir); assert.strictEqual(result.success, false); assert.ok( result.error.includes('Unknown config key'), `Expected "Unknown config key" in error: ${result.error}` ); assert.ok( result.error.includes('workflow.research_before_questions'), `Expected suggestion for workflow.research_before_questions in error: ${result.error}` ); }); }); // ─── config-set (additional coverage) ──────────────────────────────────────── describe('config-set unknown key (no suggestion)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); runGsdTools('config-ensure-section', tmpDir); }); afterEach(() => { cleanup(tmpDir); }); test('rejects a key that has no suggestion', () => { const result = runGsdTools('config-set totally.unknown.key value', tmpDir); assert.strictEqual(result.success, false); assert.ok( result.error.includes('Unknown config key'), `Expected "Unknown config key" in error: ${result.error}` ); }); }); // ─── config-get (additional coverage) ──────────────────────────────────────── describe('config-get edge cases', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('errors when traversing a dot-path through a non-object value', () => { // model_profile is a string — requesting model_profile.something traverses into a non-object writeConfig(tmpDir, { model_profile: 'balanced' }); const result = runGsdTools('config-get model_profile.something', tmpDir); assert.strictEqual(result.success, false); assert.ok( result.error.includes('Key not found'), `Expected "Key not found" in error: ${result.error}` ); }); test('errors when config.json contains malformed JSON', () => { const configPath = path.join(tmpDir, '.planning', 'config.json'); fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); fs.writeFileSync(configPath, '{not valid json', 'utf-8'); const result = runGsdTools('config-get model_profile', tmpDir); assert.strictEqual(result.success, false); assert.ok( result.error.includes('Failed to read config.json'), `Expected "Failed to read config.json" in error: ${result.error}` ); }); }); // ─── config-set-model-profile ───────────────────────────────────────────────── describe('config-set-model-profile command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); }); afterEach(() => { cleanup(tmpDir); }); test('sets a valid profile and updates config', () => { const result = runGsdTools('config-set-model-profile quality', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.updated, true); assert.strictEqual(out.profile, 'quality'); assert.ok(out.agentToModelMap && typeof out.agentToModelMap === 'object'); const config = readConfig(tmpDir); assert.strictEqual(config.model_profile, 'quality'); }); test('reports previous profile in output', () => { const result = runGsdTools('config-set-model-profile budget', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.previousProfile, 'balanced'); // default was balanced assert.strictEqual(out.profile, 'budget'); }); test('setting the same profile is a no-op on config but still succeeds', () => { // Set to quality first, then set to quality again runGsdTools('config-set-model-profile quality', tmpDir); const result = runGsdTools('config-set-model-profile quality', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const out = JSON.parse(result.output); assert.strictEqual(out.profile, 'quality'); assert.strictEqual(out.previousProfile, 'quality'); }); test('is case-insensitive', () => { const result = runGsdTools('config-set-model-profile BALANCED', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.model_profile, 'balanced'); }); test('rejects invalid profile', () => { const result = runGsdTools('config-set-model-profile turbo', tmpDir); assert.strictEqual(result.success, false); assert.ok( result.error.includes('Invalid profile'), `Expected "Invalid profile" in error: ${result.error}` ); }); test('errors when no profile provided', () => { const result = runGsdTools('config-set-model-profile', tmpDir); assert.strictEqual(result.success, false); }); describe('when config is missing', () => { let emptyDir; beforeEach(() => { emptyDir = createTempProject(); }); afterEach(() => { cleanup(emptyDir); }); test('creates config if missing before setting profile', () => { const result = runGsdTools('config-set-model-profile budget', emptyDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(emptyDir); assert.strictEqual(config.model_profile, 'budget'); }); }); }); // ─── config-set (workflow.skip_discuss) ─────────────────────────────────────── describe('config-set workflow.skip_discuss', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); runGsdTools('config-ensure-section', tmpDir); }); afterEach(() => { cleanup(tmpDir); }); test('workflow.skip_discuss is a valid config key', () => { const result = runGsdTools('config-set workflow.skip_discuss true', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.workflow.skip_discuss, true); }); test('skip_discuss defaults to false in new configs', () => { const config = readConfig(tmpDir); assert.strictEqual(config.workflow.skip_discuss, false); }); test('skip_discuss can be toggled back to false', () => { runGsdTools('config-set workflow.skip_discuss true', tmpDir); const result = runGsdTools('config-set workflow.skip_discuss false', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.workflow.skip_discuss, false); }); describe('skip_discuss in config-new-project', () => { let emptyDir; beforeEach(() => { emptyDir = createTempProject(); }); afterEach(() => { cleanup(emptyDir); }); test('skip_discuss is present in config-new-project output', () => { const result = runGsdTools(['config-new-project', '{}'], emptyDir, { HOME: emptyDir, USERPROFILE: emptyDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(emptyDir); assert.strictEqual(config.workflow.skip_discuss, false, 'skip_discuss should default to false'); }); test('skip_discuss can be set via config-new-project choices', () => { const choices = JSON.stringify({ workflow: { skip_discuss: true }, }); const result = runGsdTools(['config-new-project', choices], emptyDir, { HOME: emptyDir, USERPROFILE: emptyDir }); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(emptyDir); assert.strictEqual(config.workflow.skip_discuss, true); }); }); test('config-get workflow.skip_discuss returns the set value', () => { runGsdTools('config-set workflow.skip_discuss true', tmpDir); const result = runGsdTools('config-get workflow.skip_discuss', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output, true); }); }); // ─── config-set/config-get workflow.use_worktrees ──────────────────────────── describe('config-set/config-get workflow.use_worktrees', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); }); afterEach(() => { cleanup(tmpDir); }); test('config-get workflow.use_worktrees returns false after setting to false', () => { runGsdTools('config-set workflow.use_worktrees false', tmpDir); const result = runGsdTools('config-get workflow.use_worktrees', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output, false); }); test('config-get workflow.use_worktrees errors when not set (default config)', () => { // config-ensure-section does NOT include use_worktrees in hardcoded defaults, // so config-get should error with "Key not found". This is the expected behavior // that workflows rely on: the shell fallback `|| echo "true"` provides the default. const result = runGsdTools('config-get workflow.use_worktrees', tmpDir); assert.strictEqual(result.success, false); assert.ok( result.error.includes('Key not found'), `Expected "Key not found" in error: ${result.error}` ); }); test('config-get workflow.use_worktrees returns true after setting to true', () => { runGsdTools('config-set workflow.use_worktrees true', tmpDir); const result = runGsdTools('config-get workflow.use_worktrees', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output, true); }); test('use_worktrees can be toggled back and forth', () => { runGsdTools('config-set workflow.use_worktrees false', tmpDir); runGsdTools('config-set workflow.use_worktrees true', tmpDir); const result = runGsdTools('config-get workflow.use_worktrees', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output, true); }); }); // ─── config-set/config-get context ───────────────────────────────────────── describe('config-set/config-get context', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); }); afterEach(() => { cleanup(tmpDir); }); test('config set context dev succeeds', () => { const result = runGsdTools('config-set context dev', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.context, 'dev'); }); test('config set context research succeeds', () => { const result = runGsdTools('config-set context research', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.context, 'research'); }); test('config set context review succeeds', () => { const result = runGsdTools('config-set context review', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.context, 'review'); }); test('config get context returns the set value', () => { runGsdTools('config-set context dev', tmpDir); const result = runGsdTools('config-get context', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output, 'dev'); }); test('config set context rejects invalid values', () => { const result = runGsdTools('config-set context foobar', tmpDir); assert.strictEqual(result.success, false); assert.ok( result.error.includes('Invalid context value'), `Expected "Invalid context value" in error: ${result.error}` ); }); test('all three context profile files exist', () => { const contextsDir = path.join(__dirname, '..', 'gsd-core', 'contexts'); assert.ok(fs.existsSync(path.join(contextsDir, 'dev.md')), 'dev.md should exist'); assert.ok(fs.existsSync(path.join(contextsDir, 'research.md')), 'research.md should exist'); assert.ok(fs.existsSync(path.join(contextsDir, 'review.md')), 'review.md should exist'); }); }); // ─── config-path (#2282) ──────────────────────────────────────────────────── describe('config-path command (#2282)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); runGsdTools('config-ensure-section', tmpDir); }); afterEach(() => { cleanup(tmpDir); }); test('returns root config path when no workstream is active', () => { const result = runGsdTools('config-path', tmpDir); assert.ok(result.success, `config-path failed: ${result.error}`); // Normalize separators: Windows emits backslashes in the resolved path. assert.ok(result.output.trim().replace(/\\/g, '/').endsWith('.planning/config.json'), `expected root config path, got: ${result.output}`); assert.ok(!result.output.includes('workstreams'), 'should not include workstreams in path'); }); test('returns workstream config path when GSD_WORKSTREAM is set', () => { const result = runGsdTools('config-path', tmpDir, { GSD_WORKSTREAM: 'my-stream' }); assert.ok(result.success, `config-path failed: ${result.error}`); assert.ok(result.output.trim().replace(/\\/g, '/').includes('workstreams/my-stream/config.json'), `expected workstream config path, got: ${result.output}`); }); test('config-path and config-get agree on the active path', () => { // Write a value via config-set (uses planningDir internally) runGsdTools('config-set model_profile quality', tmpDir); // config-path should point to a file containing that value const pathResult = runGsdTools('config-path', tmpDir); const configPath = pathResult.output.trim(); const configContent = JSON.parse(require('fs').readFileSync(configPath, 'utf-8')); assert.strictEqual(configContent.model_profile, 'quality', 'config-path should point to the file config-set wrote'); }); }); // ─── config-set prototype-pollution guard (#663) ───────────────────────────── describe('config-set prototype-pollution guard (#663)', () => { let tmpDir; beforeEach(async () => { tmpDir = createTempProject(); // Initialise config so there is a config.json to write to. Retry + assert // so a transient config-ensure-section child failure under scoped-lane load // cannot leave config.json absent (#770). await ensureConfigReady(tmpDir); }); afterEach(() => { cleanup(tmpDir); }); test('rejects __proto__ key segment and does not pollute Object.prototype', () => { const result = runGsdTools('config-set __proto__.polluted true', tmpDir); assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`); // No prototype pollution. assert.strictEqual(({}).polluted, undefined, '__proto__ pollution: {}.polluted should be undefined'); assert.strictEqual(Object.prototype.polluted, undefined, '__proto__ pollution: Object.prototype.polluted should be undefined'); // Confirm .planning/config.json does not have a 'polluted' property at any level. const config = readConfig(tmpDir); assert.strictEqual(Object.prototype.hasOwnProperty.call(config, 'polluted'), false, 'config.json root must not gain a "polluted" key'); }); test('rejects constructor.prototype key and does not pollute Object.prototype', () => { const result = runGsdTools('config-set constructor.prototype.polluted2 true', tmpDir); assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`); assert.strictEqual(({}).polluted2, undefined, 'constructor chain pollution: {}.polluted2 should be undefined'); assert.strictEqual(Object.prototype.polluted2, undefined, 'constructor chain pollution: Object.prototype.polluted2 should be undefined'); }); test('rejects bare prototype key segment', () => { const result = runGsdTools('config-set prototype.x true', tmpDir); assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`); assert.strictEqual(Object.prototype.x, undefined, 'prototype.x should not leak onto Object.prototype'); }); test('positive control: legitimate nested key workflow.research succeeds', () => { const result = runGsdTools('config-set workflow.research true', tmpDir); assert.ok(result.success, `Legitimate key rejected unexpectedly: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.workflow.research, true, 'workflow.research should be written to config.json'); }); }); // ─── config-set prototype-pollution guard via dynamic-key prefixes (alert #26) ─ describe('config-set prototype-pollution guard via dynamic-key prefixes (alert #26)', () => { let tmpDir; beforeEach(async () => { tmpDir = createTempProject(); // Initialise config so there is a config.json to write to. Retry + assert // so a transient config-ensure-section child failure under scoped-lane load // cannot leave config.json absent (#770). await ensureConfigReady(tmpDir); }); afterEach(() => { cleanup(tmpDir); }); test('agent_skills.__proto__ is blocked by setConfigValue guard (not schema gate)', () => { const result = runGsdTools('config-set agent_skills.__proto__ somevalue', tmpDir); assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`); // Must be the pollution guard, not the schema gate. assert.ok( result.error.includes('prototype pollution guard'), `Expected "prototype pollution guard" in error, got: ${result.error}`, ); // No schema-gate message. assert.ok( !result.error.includes('Unknown config key'), `Should not hit schema gate, got: ${result.error}`, ); // No prototype pollution occurred. assert.strictEqual(({}).somevalue, undefined, 'agent_skills.__proto__: {}.somevalue should be undefined'); assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false, 'agent_skills.__proto__: Object.prototype should not gain "somevalue"'); }); test('agent_skills.constructor is blocked by setConfigValue guard (not schema gate)', () => { const result = runGsdTools('config-set agent_skills.constructor somevalue', tmpDir); assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`); assert.ok( result.error.includes('prototype pollution guard'), `Expected "prototype pollution guard" in error, got: ${result.error}`, ); assert.ok( !result.error.includes('Unknown config key'), `Should not hit schema gate, got: ${result.error}`, ); assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false, 'agent_skills.constructor: Object.prototype should not gain "somevalue"'); }); test('agent_skills.prototype is blocked by setConfigValue guard (not schema gate)', () => { const result = runGsdTools('config-set agent_skills.prototype somevalue', tmpDir); assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`); assert.ok( result.error.includes('prototype pollution guard'), `Expected "prototype pollution guard" in error, got: ${result.error}`, ); assert.ok( !result.error.includes('Unknown config key'), `Should not hit schema gate, got: ${result.error}`, ); assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false, 'agent_skills.prototype: Object.prototype should not gain "somevalue"'); }); test('features.__proto__ is blocked by setConfigValue guard (not schema gate)', () => { const result = runGsdTools('config-set features.__proto__ somevalue', tmpDir); assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`); assert.ok( result.error.includes('prototype pollution guard'), `Expected "prototype pollution guard" in error, got: ${result.error}`, ); assert.ok( !result.error.includes('Unknown config key'), `Should not hit schema gate, got: ${result.error}`, ); assert.strictEqual(({}).somevalue, undefined, 'features.__proto__: {}.somevalue should be undefined'); assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false, 'features.__proto__: Object.prototype should not gain "somevalue"'); }); test('review.models.constructor is blocked by setConfigValue guard (not schema gate)', () => { const result = runGsdTools('config-set review.models.constructor somevalue', tmpDir); assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`); assert.ok( result.error.includes('prototype pollution guard'), `Expected "prototype pollution guard" in error, got: ${result.error}`, ); assert.ok( !result.error.includes('Unknown config key'), `Should not hit schema gate, got: ${result.error}`, ); assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false, 'review.models.constructor: Object.prototype should not gain "somevalue"'); }); test('positive control: agent_skills.sonnet-coder with valid value succeeds', () => { const result = runGsdTools('config-set agent_skills.sonnet-coder true', tmpDir); assert.ok(result.success, `Legitimate agent_skills key rejected unexpectedly: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.agent_skills['sonnet-coder'], true, 'agent_skills.sonnet-coder should be written to config.json'); }); }); // ─── plan_review.source_grounding + _authority (#22) ───────────────────────── describe('plan_review.source_grounding and plan_review.source_grounding_authority (#22)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); }); afterEach(() => { cleanup(tmpDir); }); // (a) Default of plan_review.source_grounding is true test('plan_review.source_grounding defaults to true when not set in config.json', () => { const config = readConfig(tmpDir); assert.strictEqual( config.plan_review?.source_grounding, true, 'plan_review.source_grounding must default to true' ); }); // (b) Default of plan_review.source_grounding_authority is "grep" test('plan_review.source_grounding_authority defaults to "grep" when not set in config.json', () => { const config = readConfig(tmpDir); assert.strictEqual( config.plan_review?.source_grounding_authority, 'grep', 'plan_review.source_grounding_authority must default to "grep"' ); }); // (c) Both keys are recognized as valid config keys test('plan_review.source_grounding is a valid config key accepted by config-set', () => { const result = runGsdTools('config-set plan_review.source_grounding false', tmpDir); assert.ok(result.success, `config-set plan_review.source_grounding failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.plan_review.source_grounding, false); }); test('plan_review.source_grounding_authority is a valid config key accepted by config-set', () => { const result = runGsdTools('config-set plan_review.source_grounding_authority intel', tmpDir); assert.ok(result.success, `config-set plan_review.source_grounding_authority failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.plan_review.source_grounding_authority, 'intel'); }); // Enum positive: all valid authority values test('plan_review.source_grounding_authority accepts all valid enum values', () => { const validValues = ['grep', 'intel', 'treesitter', 'lsp', 'scip']; for (const v of validValues) { const result = runGsdTools(`config-set plan_review.source_grounding_authority ${v}`, tmpDir); assert.ok(result.success, `config-set plan_review.source_grounding_authority ${v} failed: ${result.error}`); const config = readConfig(tmpDir); assert.strictEqual(config.plan_review.source_grounding_authority, v); } }); // (d) NEGATIVE MATRIX — invalid enum values are rejected test('plan_review.source_grounding_authority rejects invalid value "bogus"', () => { const result = runGsdTools('config-set plan_review.source_grounding_authority bogus', tmpDir); assert.strictEqual(result.success, false, 'bogus should be rejected'); assert.ok( result.error.includes('Invalid plan_review.source_grounding_authority'), `Expected "Invalid plan_review.source_grounding_authority" in error: ${result.error}` ); }); test('plan_review.source_grounding_authority rejects flag-looking value "--grep"', () => { const result = runGsdTools(['config-set', 'plan_review.source_grounding_authority', '--grep'], tmpDir); assert.strictEqual(result.success, false, '--grep should be rejected'); assert.ok( result.error.includes('Invalid plan_review.source_grounding_authority'), `Expected "Invalid plan_review.source_grounding_authority" in error: ${result.error}` ); }); test('plan_review.source_grounding_authority rejects empty string', () => { const result = runGsdTools(['config-set', 'plan_review.source_grounding_authority', ''], tmpDir); assert.strictEqual(result.success, false, 'empty string should be rejected'); }); test('plan_review.source_grounding rejects non-boolean value "yes"', () => { const result = runGsdTools('config-set plan_review.source_grounding yes', tmpDir); assert.strictEqual(result.success, false, '"yes" should be rejected as non-boolean'); assert.ok( result.error.includes('Invalid plan_review.source_grounding'), `Expected "Invalid plan_review.source_grounding" in error: ${result.error}` ); }); test('plan_review.source_grounding rejects numeric value 1', () => { const result = runGsdTools('config-set plan_review.source_grounding 1', tmpDir); assert.strictEqual(result.success, false, 'numeric 1 should be rejected as non-boolean'); assert.ok( result.error.includes('Invalid plan_review.source_grounding'), `Expected "Invalid plan_review.source_grounding" in error: ${result.error}` ); }); });