CodeQL alert #26 (js/prototype-pollution-utility) kept firing on setConfigValue because its dataflow does not trace the #663 Set-based, pre-loop keys.some(...) forbidden-key check as a sanitising barrier on the write site. - src/config.cts: replace the Set + pre-loop check with inline literal comparisons (key === '__proto__' || 'prototype' || 'constructor') on the exact key used to index `current`, immediately before each write (intermediate keys in the descent loop, plus the final key). Same forbidden set, same error message and ERROR_REASON.CONFIG_PARSE_FAILED — behaviour unchanged from #663, but the barrier is now CodeQL-recognised. - tests/config.test.cjs: add regression tests for schema-valid dynamic-prefix keys (agent_skills.__proto__, agent_skills.constructor, agent_skills.prototype, features.__proto__, review.models.constructor) that pass the isValidConfigKey schema gate and reach the guard. Each asserts the guard's own message fires (not the schema gate's "Unknown config key") and Object.prototype is not polluted. The prior #663 tests never reached the guard — their keys are rejected by the schema gate first — so the guard's real attack surface was untested. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1386 lines
55 KiB
JavaScript
1386 lines
55 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|
|
|
|
// ─── 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(() => {
|
|
tmpDir = createTempProject();
|
|
// Initialise config so there is a config.json to write to.
|
|
runGsdTools('config-ensure-section', 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(() => {
|
|
tmpDir = createTempProject();
|
|
// Initialise config so there is a config.json to write to.
|
|
runGsdTools('config-ensure-section', 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}`
|
|
);
|
|
});
|
|
});
|