Files
msd-core/tests/config.test.cjs
Tom Boucher 11afca2968 feat(#656): Research module — content-addressed cache + provider seam + registry-API legitimacy (#664)
* feat(#656): add Research Store module (content-addressed cache, TTL staleness)

Content-addressed research cache behind a clock seam: researchKey (sha256, deterministic), putResearch/getResearch ({hit,stale}, never throws), ttlForSource (curated HIGH 30d / MED 7d / web LOW 1d), two-tier resolveStorePath (curated -> ~/.gsd/research-cache, web/synthesis -> project .planning/research/.cache). 28 behavioral + property tests; boundary coverage at ttl-1/ttl/ttl+1.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#656): add Research Provider module (waterfall + confidence + plan)

Single source of truth for the Balanced provider waterfall (docs Context7->Ref->Jina, web Exa+Tavily, fallback Perplexity/Brave, Firecrawl scrape-only). classifyConfidence stamps HIGH|MEDIUM|LOW by provider (never throws). providerAvailability maps config flags to usable providers. planResearch checks the Research Store (injected seam) and returns cache-hits + a per-question fetch plan, falling through the waterfall to the always-available websearch terminal. 22 behavioral + property tests.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#656): add Package Legitimacy module (registry-API verdicts, slopcheck optional)

Replaces the pip-install-or-degrade slopcheck prose gate with code: classifyPackage (pure, never throws) computes OK|SUS|SLOP from tunable thresholds (minAgeDays 30, minWeeklyDownloads 1000, requireRepo). checkPackages queries injectable npm/PyPI/crates registry adapters (real https with 5s timeout, degraded-not-thrown on failure); slopcheck is one optional adapter that can only escalate severity, never degrade to [ASSUMED]. 34 behavioral + property tests; boundary coverage on age and downloads (limit-1/limit/limit+1).

Known follow-up: real npm adapter must add api.npmjs.org last-week downloads fetch (currently null -> unknown-downloads). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#656): detect Tavily/Ref/Perplexity/Jina provider keys; complete npm downloads adapter

config: add tavily_search/ref_search/perplexity/jina availability flags (env var or ~/.gsd/<x>_api_key), mirroring brave_search/exa_search/firecrawl, so the Research Provider waterfall can gate them. package-legitimacy: real npm adapter now fetches api.npmjs.org last-week downloads (bounded, degraded-not-thrown) so weeklyDownloads is populated. +12 config tests; 34 legitimacy tests unchanged.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#656): expose Research seam via gsd-tools query (research-plan, research-store, package-legitimacy)

Routes the L2-hybrid surface so agents reach it as CLI: 'query research-store get/put' (cache, HOME-sandboxable), 'query research-plan --input' (cache-hits + fetch plan from planResearch), 'query package-legitimacy check --ecosystem' (async registry verdicts). Commands skip .planning root resolution and appear in top-level usage. 5 behavioral runGsdTools tests; command-contract unchanged (335).

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(#656): document Research module (CONTEXT predicates, ADR-0656, architecture, changeset)

Adds GSD-RESEARCH.* + DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT predicates to CONTEXT.md, ADR-0656 recording the L2-hybrid seam decision, a docs/ARCHITECTURE.md Research Module subsection, and an Added changeset fragment (pr:0, backfill on PR). Notes the #657 deferrals (agent collapse + install.js MCP mapping).

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#656): sync inventory for research modules

Regenerate INVENTORY-MANIFEST.json and bump docs/INVENTORY.md CLI Modules count 82->85 with rows for research-store/research-provider/package-legitimacy (DEFECT.INVENTORY-DRIFT).

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#656): eslint-ignore generated research .cjs artifacts (ADR-457)

research-store/research-provider/package-legitimacy .cjs are tsc-generated from src/*.cts, so they belong in the ESLint ignore block (lint the .cts source, not the emitted .cjs). Fixes tests/551-eslint-bin-lib-coverage.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#656): backfill changeset pr number to #664

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#656): satisfy eslint lint-tests gate

Fix 20 eslint errors in the new research files: use helpers.cleanup() instead of raw fs.rmSync() in tests (local/no-raw-rmsync-in-tests, Windows-EBUSY retry budget); drop redundant '| string' union members and unnecessary type assertions; deterministic object normalization in researchKey (no-base-to-string). Logic unchanged; 6180 tests still green.

Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): harden package legitimacy per review (W1/W2/I3/I4)

W1: httpsGet now reads statusCode; npm/PyPI/crates map 404 -> exists:false -> SLOP (registry-existence is the #1 slopsquatting defense; previously only npm caught it). Transport made injectable (_setHttpGet) for hermetic 404 tests. W2: suspicious-postinstall is now terminal SLOP independent of the optional slopcheck adapter, and the regex drops the bare https?:// arm (over-fired on esbuild/sharp/node-gyp) for shell-exec/download-exec signatures only. I3: checkPackages now threads version to registry.lookup and adapters verify that specific version exists. I4: moreServerVerdict -> moreSevereVerdict. +11 regression tests (all RED-first); 45 total green.

Addresses review by @davesienkowski on #664. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): research-store tier coherence + freshness + version TTL (W4/I1/I2/I4)

I1: tier now derives from source (curated -> user ~/.gsd, else -> project .planning), not kind, so put-tier and get-tier can't diverge; kind is a key component only. W4: getResearch searches both tiers and returns the freshest (non-stale preferred), never letting a stale curated entry shadow a fresh web one; blank version caps TTL at 1 day (no 30d on version-blind keys). I2: atomic platformWriteSync instead of raw fs.writeFileSync on the shared global path. I4: dropped the dead ttlForSource arm. CLI get now searches both tiers. +5 RED-first regression tests; 38 green.

Addresses review by @davesienkowski on #664. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): expose classifyConfidence as a CLI route, killing dead code (W3)

Adds 'gsd-tools query classify-confidence --provider X [--verified]' so research agents get the confidence tier FROM CODE (provider waterfall + verification lever) instead of asserting it in prose. classifyConfidence previously had no runtime caller. HIGH means 'trusted provider'; --verified raises web results to MEDIUM (verification semantics documented in ADR-0656). +4 behavioral tests.

Addresses review by @davesienkowski on #664 (W3). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): close Codex adversarial-review findings (path-traversal, version-age, malformed-cache)

HIGH: research key must be 64-hex sha256 (isValidResearchKey) + resolved-path containment check in put/get + CLI validation -> blocks '../../x' arbitrary-file-write. HIGH: package legitimacy now derives publishedAt from the REQUESTED version (npm time[version], PyPI releases[version] upload_time, crates versions[].created_at) so a new malicious version of an old package can't inherit old age and evade 'too-new'. MEDIUM: getResearch validates entry shape (finite fetched_at + positive ttl + required fields) -> malformed cache entry is a miss, not fresh-forever. +regression tests (RED-first); 111 green.

Codex adversarial review (required pre-PR gate). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): close code-review correctness findings

(1) package-legitimacy CLI now rejects unknown --flags instead of silently consuming the following package as a flag value; only --ecosystem takes a value. (2) crates recent_downloads (90-day) normalized to a weekly figure before the minWeeklyDownloads threshold (was ~13x too lenient). (3) research-plan --input validates parsed JSON is an object with an Array questions before destructuring -> clean usage error instead of an uncaught TypeError on null/bad input. (4) research-store put rejects a flag value that is itself a --flag (no more storing '--source' as content). (5) planResearch skips questions whose text is not a non-empty string instead of emitting question:undefined. +13 RED-first regression tests; 143 green.

Code-review gate. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(#657): extract researcher documentation_lookup to shared @-reference

6 researcher agents carried a near-duplicate <documentation_lookup> block; consolidate into gsd-core/references/research-documentation-lookup.md (@-included). Unifies the ctx7 CLI fallback to the safer 'command -v ctx7' guard (drops silent 'npx --yes ctx7@latest' execution in 5 agents). Behavior-preserving dedup; inventory 63->64 references. Phase A of the agent collapse.

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(#657): extract researcher philosophy + verification-protocol to shared @-references

philosophy and the pitfalls+pre-submission-checklist common-core were near-duplicated in project/phase researchers; consolidate into gsd-core/references/research-{philosophy,verification-protocol}.md (@-included). phase-researcher keeps its 3 extra checklist items inline. Pre-submission domains checklist made agent-agnostic so project-researcher doesn't lose features/architecture coverage. Write-contract intentionally left inline (bug-214 tests assert it verbatim). Inventory 64->66 refs. Behavior-preserving. Phase A.

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#657): wire gsd-phase-researcher to the Research seam (Phase B / S1)

The phase researcher now CALLS the code seam instead of carrying inline mechanics: provider waterfall -> 'gsd-tools query research-plan' (+ research-store put to cache digests); confidence-tier prose -> 'gsd-tools query classify-confidence'; slopcheck pip-install protocol -> 'gsd-tools query package-legitimacy check'. This makes the Research module a real runtime consumer (validates the seam end-to-end, addresses reviewer S1) and removes the duplicated waterfall/confidence/slopcheck prose. RESEARCH.md output contract, commit step, structured returns, and Phase-A @-includes unchanged. package-legitimacy-gate.test.cjs rewritten prose-grep -> behavioral (asserts the seam invocation).

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#657): wire gsd-project-researcher to the seam + add tavily/ref/jina MCP tools (Phase C.1)

project-researcher now calls gsd-tools query research-plan / classify-confidence (+ research-store put) instead of the inline provider waterfall + confidence-tier prose (mirrors the phase-researcher rewire; no package-legitimacy — phase-only). Output contract (STACK/FEATURES/ARCHITECTURE/PITFALLS/SUMMARY.md + sections, no-commit, structured returns, Phase-A @-includes) unchanged. Adds mcp__tavily/ref/jina__* to the project/phase/ui researcher tools frontmatter (Balanced provider set) so install.js MCP mapping (C.2) has a consumer.

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(#657): cover tavily/ref/jina MCP install handling + frontmatter parity guard (Phase C.2)

Investigation: exa/firecrawl have no explicit per-runtime tool-mapping — every mcp__<server>__* except context7 rides the generic passthrough (Copilot lowercases; OpenCode/Cursor/Windsurf/Augment keep as-is; Gemini auto-discovers). tavily/ref/jina are handled identically, no install path broken. Added 12 copilot-install passthrough tests + a mcp-tool-inheritance parity guard (tavily co-declared with exa, jina with firecrawl, ref present across the 3 web researchers) so the MCP set can't drift. No io.github registry ids invented (none sourceable in-repo); documented as a follow-up. 488 tests green.

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#657): profiles as source of truth for researcher agents + drift-guard (Phase C.3)

scripts/research-profiles.cjs declares each of the 7 researcher agents' identity + contract (name, description, color, tools, required @-includes, required gsd-tools seam calls, output-contract markers). scripts/gen-research-agents.cjs --check validates every committed agent against its profile; --write regenerates ONLY the frontmatter from profiles (body untouched) and is a verified no-op against the current agents (zero diff = fidelity). tests/research-agent-profiles.test.cjs is the DEFECT.GENERATIVE-FIX drift guard. Design note: profiles govern the generatable/contract surface rather than destructively regenerating the disparate operational prose bodies (those were deduped via @-includes in Phase A). scripts/ is not inventoried (no inventory change).

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#657): complete agent provider-dispatch + parity guard; align legitimacy field; validate profiles

Adversarial-review findings: (HIGH) the seam-wired agents' Step-C dispatch only mapped 6 providers, so a planResearch result of jina/ref/perplexity/brave (reachable via the waterfall fallbacks) had no handling -> agent stall; completed both agents' dispatch to all 9 PROVIDER_WATERFALL ids + a catch-all, and added a parity test asserting agent dispatch stays in sync with research-provider PROVIDER_WATERFALL (DEFECT.GENERATIVE-FIX). (MEDIUM) phase-researcher package-legitimacy JSON example used 'package' but the module returns 'name' -> aligned. (LOW) gen-research-agents checkAgent now returns a clear failure for a malformed profile instead of throwing. +parity/validation tests (RED-first).

Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#656): make classifyConfidence verification-evidence-driven (W3)

Confidence conflated provider authority with claim verification — context7/ref
stamped HIGH purely by provider identity, and the only verification lever was a
self-set --verified flag. Split into two axes: provider authority (static) +
verification evidence (code-computed). HIGH now requires ground-truth
corroboration (legitimacyVerdict OK), independent of provider; authority alone
caps at MEDIUM; SLOP caps at LOW; the self-reported --verified is demoted to a
MEDIUM-only web lever. HIGH = corroborated-against-authoritative-source, not a
correctness guarantee. Adds --legitimacy-verdict to the classify-confidence CLI;
updates CONTEXT.md predicate + ADR-0656 (tier set unchanged, ADR-consistent).

Addresses davesienkowski's W3 review on #664.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#656): bind classify-confidence verdict to code, closing CLI self-grading

Adversarial review found the new --legitimacy-verdict flag was caller-supplied,
so an agent could self-assert OK->HIGH without any real legitimacy check —
reintroducing the exact self-grading hole W3 closes. Remove the free flag; the
CLI now computes the verdict via checkPackages only when --package/--ecosystem
is given (code-computed, not agent-asserted). Update the stale CLI test
(context7 alone -> MEDIUM) and extend the property test to vary legitimacyVerdict.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:58:48 -04:00

1269 lines
50 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 } = 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');
}
function sleep(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
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;
sleep(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', () => {
const first = runConfigEnsureSectionWithRetry(tmpDir);
assert.ok(first.success, `First call failed: ${first.error}`);
const firstOutput = JSON.parse(first.output);
assert.strictEqual(firstOutput.created, true);
const second = 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');
});
});
// ─── 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}`
);
});
});