* test(01-01): add failing protected-branch warning coverage - pin configured, absent, and malformed branch-list behavior - require opposite CLI and execute warning outcomes * feat(01-01): warn on configured protected branches - resolve the base branch union configured protected branch names - expose exact boolean CLI comparison output for workflow callers - keep execute-phase warning advisory and within its byte budget * test(01-01): add failing protected branch config coverage - cover valid list persistence and null unset - reject hostile shapes while preserving the prior value * feat(01-01): validate protected branch configuration - register git.protected_branches as a canonical config key - require a non-empty array of non-blank branch names * test(01-02): add failing ship protected-branch controls - Execute both workflow warning blocks with exact predicate arguments - Require true and false results to produce opposite warning outcomes - Preserve the none-strategy feature-branch offer contract * feat(01-02): warn at ship on protected branches - Reuse the typed protected-branch predicate in ship preflight - Keep raw base resolution for PR targeting and advisory branch creation - Prove execute and ship warning blocks with opposite-result controls * test(01-02): add failing protected-branch docs parity - Require the canonical schema key in both English config references - Pin the non-empty string-array type and absent default - Require synchronized multi-branch examples and advisory semantics * feat(01-02): publish protected branch configuration contract - Document the optional non-empty string-array field in both references - Explain resolved-base union and absent-field compatibility - Keep execute and ship warnings advisory under branching_strategy none * fix(01): CR-01 honor active workstream branch policy * fix(01): WR-01 assert protected config path selection * docs: add changeset fragment for #3648 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017CteVPJt4BkPmroMPGajYx * fix(#3648): resolve base_branch precedence inversion and round-1 findings Blocker 1/2: production config resolution was flat-first, so a project that migrated to git.base_branch but still carried a stale flat base_branch got the old value back. Add base_branch to normalizeLegacyKeys (mirrors the existing branching_strategy/sub_repos pattern: canonical nested wins) and route readEffectiveGitConfig's test seam through the same normalization so it can't silently diverge from production again. Adds a regression test with both keys set that fails without the fix. Blocker 3/4/5: restore the handle_branching case-selector prose and "none" contract sentence that #3389's tests anchor on, and revert the unrelated prose/comment compaction in the same step — both were drive-by edits outside #3552's scope. Also addresses review majors/minors: delete readConfigBaseBranch and readConfigProtectedBranches (dead in production, only self-tested); --is-protected now fails closed (reports protected) instead of silently answering false when the base branch can't be verified; trim configured protected-branch names; fix HOME-without-USERPROFILE vacuous isolation on Windows; correct the drift-ack's byte accounting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S44stkuQbhD3jTCtKzte5N * test(#3648): add failing legacy-key hoist safety coverage Round-2 review found normalizeLegacyKeys block 5 records a normalization carrying the DISCARDED flat value on the canonical-wins branch. Probing that turned up a second, unreported defect in the same helper shape: blocks 1, 2 and 5 all spread result['git'] / result['planning'] with no object guard, so a config whose section key holds a string is spread into index keys — {"git":"main","base_branch":"release"} -> {"git":{"0":"m","1":"a","2":"i","3":"n","base_branch":"release"}} The resolved value is accidentally still correct, so nothing fails and no diagnostic fires. But normalizations.length > 0 sets configDirty, and config-loader then serializes that shape back into the user's config.json — a read that silently corrupts config. The deleted #3057 W3 suite covered {"git":"main","base_branch":"release"} explicitly; this is the input it would have caught. Covers both defects across blocks 1 and 5, with object/array/null negative controls that must stay green in both phases, and a fast-check property over arbitrary `git` values. * test(#3648): pin fail-closed handling of malformed protected_branches Replaces the test that pinned the fail-OPEN behaviour. The old assertion — ['develop', 42] yields isProtected === false for 'develop' — locked in the exact failure #3552 exists to close: config-set validation is bypassable by a direct edit of .planning/config.json, so a user who believes 'develop' is protected got a silent false and no warning. It was also inconsistent with the fail-CLOSED direction twelve lines away, where an unverified base reports protected and writes a diagnostic. A protection predicate must not have two opposite failure directions depending on which input is bad (#3648 review Blocker 3). New coverage: a bad element drops only itself, a non-array contributes no names, an empty list is well-formed rather than malformed, and --is-protected surfaces the rejection. Both negative controls — a clean list reports nothing rejected and writes no diagnostic — must stay green in either phase, so the reject channel cannot fire unconditionally. * fix(#3648): drop only invalid protected_branches and report them Partition git.protected_branches instead of discarding the whole list on one bad element, and carry the rejections out through ProtectedBranchStatus so --is-protected can name them on stderr. Valid names keep protecting; the user finds out the rest were ignored. A non-array value still contributes no names — a bare string is not a list of branch names — but is now reported rather than swallowed. An empty array stays silent: declaring no extra protected branches is a valid choice, not a misconfiguration. writeDiagnostic is hoisted out of the unverified-base branch since both arms now use it. * test(#3648): prove the predicate diagnostic survives both call sites The workflow bash stub now emits a stderr diagnostic the way the real command does, which is what makes a swallowed `2>/dev/null` visible to a test — previously the stub was silent on stderr, so discarding it changed no observable behaviour and the call sites could drop the explanation undetected. Adds the Minor 2 binding check as well: ship must expose the predicate result as IS_PROTECTED rather than only echoing a warning, asserted by running the extracted bash and reading the bound value, not by grepping the workflow source. Both tests carry opposite-outcome controls — an empty diagnostic must leave the text absent, and a false predicate must bind false. * fix(#3648): surface the predicate diagnostic and bind ship's result Drop `2>/dev/null` from the --is-protected call at both call sites. The fail-closed explanation and the new rejected-entry warning both go to stderr, so discarding it left the user with a bare "protected branch" warning on a branch that is not protected and no way to tell a real match from a degraded-git guess. `git branch --show-current` keeps its own redirect — that one is genuine noise. ship.md binds IS_PROTECTED and its prose now branches on the variable, so the following steps have evaluable state instead of having to infer it from warning text in tool output. execute-phase.md byte accounting refreshed: 92326 -> 92645, net growth 319 bytes (was 331 before the redirect came out). Baseline re-verified against the current rebase base by blob id; the ceiling check passes with 755 bytes of margin. * test(#3648): restore negative space for the readFile config seam The #3057 W3 suite was deleted with readConfigBaseBranch, but every arm it pinned survives verbatim in readEffectiveGitConfig's readFile branch — the JSON.parse catch, the non-object guard, the git-section object guard, .trim() and blank-string rejection — and the four surviving readFile injections were positive-path only. protected_branches was never driven through this seam at all. Restores nine cases against the seam, including protected_branches partitioning, plus a control proving loadConfig still wins when both seams are supplied. Records honestly what the suite pins. Mutating the built lib shows .trim() is KILLED, while the non-object guard and the blank-string rejection SURVIVE — both are unreachable through this entry point for the same reasons the deleted suite documented against its own equivalents: a JSON-parsed non-object carries no relevant own-property either way, and a blank value is rejected a second time downstream by the resolver's truthiness check. They stay as defence-in-depth and are labelled known-unkillable rather than left looking like coverage this suite does not provide. * test(#3648): distinguish detached HEAD from a missing branch argument `args[1] ?? ''` collapsed two different situations into one: a detached HEAD, where `git branch --show-current` legitimately prints nothing, and the flag being called with no argument at all. Both answered false, so the right outcome arrived by an unintentional path and a caller bug was indistinguishable from normal operation. Asserts the detached case stays silent and the missing-argument case reports, with a control that the two diagnostics differ. * fix(#3648): report a missing --is-protected branch argument Answer false either way, but say so when the flag arrives with no argument. A detached HEAD passes an explicit empty string and stays silent, since that is a normal state rather than a misconfiguration. * docs(#3648): state exact-name matching and per-entry rejection isProtected is exact string equality, so a git-flow project must enumerate every release/* and hotfix/* by name. #3552 only asked for an integration-branch field, so the implementation satisfies the letter of the issue while leaving its git-flow motivation partly unserved — say so where users will meet it rather than leaving them to discover it. Also documents the Blocker 3 behaviour change: an invalid entry is ignored with a warning naming it and the remaining names still apply. Both statements land in docs/CONFIGURATION.md and gsd-core/references/planning-config.md, and the config-field-docs parity test asserts each in both so the two cannot drift. * refactor(#3648): extract isValidProtectedBranches for cross-surface pinning The `git.protected_branches` check inside `cmdConfigSet` and the resolver's per-entry filter in `git-base-branch.cts` are deliberately different shapes — all-or-nothing on write, per-entry on read, so a hand-edited config.json cannot fail the guard open. Nothing structural keeps their two definitions of "usable branch name" in step. Lifting the write-side check into a named, exported predicate lets a property test ask both surfaces about the same value and assert they agree, which is the fast-check gap the round-2 review flagged. No behaviour change: the predicate is the same expression, called from the same place. * fix(#3648): stop --is-protected rewriting the config it is asking about `gsd_run query git.base-branch --is-protected` runs on every execute-phase and every ship. It resolved config through `loadConfig`, whose normalize-then-write path rewrites `.planning/config.json` whenever any legacy key normalizes — so a boolean question was silently editing the user's checked-in config. This PR had widened the trigger by adding a fifth normalization block (top-level `base_branch` -> `git.base_branch`), making it fire for exactly the projects the feature targets. `loadConfigResolved` gains `options.persist` (opt-OUT, default true): resolution is unchanged, only the two write-back side effects are suppressed. The predicate passes `persist: false`; the ~30 other callers are untouched, so a legacy config is still migrated by ordinary use. Asserted on BYTES rather than parsed shape, because the rewrite reorders keys and reflows whitespace even when the values are equivalent. Three tests, each with its own control: the end-to-end CLI leaves the file byte-identical while still answering `true` from the legacy key (proving the config WAS read); an ordinary persisting load of the same fixture DOES change the bytes (proving the fixture is live rather than inert); and `persist:false` vs default over one directory returns deep-equal config while differing on the write. Reverting the one-line `persist: false` fails the first of those and only that one. Also from the review: - `readEffectiveGitConfig`'s comment claimed the readFile branch routed "through the same precedence authority production uses". It does not, and cannot — it reproduces two of production's steps over a single file. The comment now names what the seam covers and what it does NOT (root/workstream deep merge, builtin and global defaults, federated merge), and the seam now applies production's flat-then-nested lookup so it stops disagreeing about a surviving flat key. - The missing-argument diagnostic promised "answering false", which the fail-closed guard on the same call can contradict by printing `true`. It now states what it did with the argument and leaves the answer to stdout. * test(#3648): re-pin block 5 on #3760's refusal contract #3767 landed on next while this PR was in review and fixed the non-object config-section defect properly: a present-but-non-object section now BLOCKS its own migration — value preserved, no Normalization pushed, refusal reported via `skipped[]` — rather than being rebuilt from a plain-object view. That supersedes this branch's round-2 `hoistLegacyKey`, which prevented the character-key spread but still dropped the section value silently, and which the round-3 review correctly called out as destruction in place of corruption. The rebase drops that commit and routes block 5 through the upstream helper. This file's tests asserted the superseded design, so they are rewritten to pin block 5 — `base_branch` -> `git.base_branch`, which did not exist when #3760's suite was written — against the contract that now governs it: ordinary hoist into an absent/null/object section, canonical-nested-wins, and refusal for each of string/number/boolean/array sections with the exact `skipped` entry. Two controls keep it from passing vacuously: the refusal must be scoped to block 5 (an unrelated block still normalizes in the same call), and a property over arbitrary `git` values asserts hoist and refusal are exhaustive AND mutually exclusive per key, that a refusal leaves both the section and the legacy key untouched, and that a hoist manufactures no index key the input did not carry. * docs(#3648): correct the Git Query and Config Loader module contracts CONTEXT.md's Git Query Module still described base-branch tier 1 as a direct `.planning/config.json` read. Since this PR it is the EFFECTIVE configuration resolved by the Config Loader — a materially different authority, carrying the root/workstream deep merge, flat-then-nested lookup and builtin/federated defaults. The `--is-protected` predicate, `git.protected_branches`, and the two invariants that distinguish the predicate from the plain query (fails closed on an unverified base; must not write) were undocumented entirely. The Config Loader entry now states that loading is not side-effect-free by default and documents `options.persist`. docs/INVENTORY.md's `git-base-branch.cjs` row carried the same stale ladder and no mention of the predicate. `node scripts/gen-inventory-manifest.cjs --write` was run and produced no diff: the manifest indexes roster NAMES, not row prose, so a description edit cannot move it. Also closes the global-defaults minor: `git.protected_branches` is inert in `~/.gsd/defaults.json`, but so is every other `git.*` key — no branch-policy key appears in `_globalBaseCfg` or `GLOBAL_DEFAULTS_RESOLUTION_KEYS`. That is section-wide and predates this PR, so the fix is to state the scope where users meet it rather than to quietly extend the resolution set for two new keys. * fix(#3648): close four defects found by the round-4 external review Two external reviewers (codex, antigravity/Gemini 3.1 Pro) were run adversarially against this branch. Four findings reproduced against source; each is fixed with a failing-first test and a control, and each fix was verified by reverting it and watching exactly the intended test fail. 1. `persist:false` was DROPPED by the workstream fallback (codex). Blocker 1 was only half closed. `loadConfigResolved` re-enters itself with a bare `{ workstream: null }` when a workstream has no config.json of its own, and that literal discarded every other option — so the recursive pass ran at the DEFAULT persistence and rewrote the ROOT config. Reproduced: with GSD_WORKSTREAM=alpha and a legacy flat `base_branch`, `--is-protected` rewrote `.planning/config.json` despite `persist:false`. Both recursions now forward `options` and override only `workstream`; the explicit override still wins the hasOwnProperty check, so spreading cannot let `workstreamContext` reintroduce a workstream. 2. Both workflow call sites failed OPEN, and aborted under `set -e` (both reviewers, independently). `IS_PROTECTED=$(gsd_run ...)` yields an empty string when the query fails, so `[ "$X" = true ]` was simply false: no warning, no trace — a silent hole in the guard whose only job is to warn. The bare assignment also aborted the step under `set -e`. Both sites now degrade VISIBLY: `|| IS_PROTECTED=""`, then an explicit empty-string arm that says the check did not run. Deliberately not fail-closed — claiming "protected" on no evidence would warn on every branch whenever gsd-tools is unavailable. 3. `isValidProtectedBranches` and the resolver disagreed on a sparse array (antigravity). `.every()` skips holes; the resolver's `for...of` yields `undefined` for them, so `["main", , "develop"]` was accepted by config-set and rejected by the resolver. The cross-surface property passed only because `fc.array` cannot generate a hole. The predicate now indexes, and the generator punches holes so that axis is actually falsifiable. JSON cannot express a hole, so this is unreachable in production — but two definitions of one predicate must not contradict each other. 4. A top-level `protected_branches` silently outranked `git.protected_branches` (antigravity). Routing the key through `get(key, {section, field})` gave it flat-then-nested precedence, which is back-compat for keys `normalizeLegacyKeys` migrates. `protected_branches` is new in #3552 and has no legacy form, so that invented an undocumented alias. It now resolves nested-only through a new `getNested`, in production and in the test seam. `base_branch` keeps flat-then-nested — it HAS a legacy spelling that #3760's refusal path can leave behind — and a control pins that distinction. Also narrows a CONTEXT.md claim this round introduced. The predicate fails closed only when a git query TIMED OUT or could not be spawned (#3057 B4's `verified`); a git command that runs and exits non-zero counts as a clean negative, so a cwd that is not a repository answers `false`, not `true`. Verified pre-existing on next @738f42f4, so the documentation was over-claiming rather than the code regressing — but an over-broad contract is exactly what the module docs must not carry. Both workflow byte figures re-derived after the call-site change: execute-phase.md 92356 -> 92865 (+509), ship.md 36784 -> 37227 (+443). * test(#3648): pin git config read parity * docs(#3648): document git query contracts * fix(#3648): expose protected branch default * test(#3648): snapshot planning tree for read-only query * test(#3648): pin planning snapshot stray-write detection * fix(#3648): resolve merge conflict from #3078's ack-fragment sweep next swept the fully-spent 2818/3003 ack fragments this branch had appended to (#3078,a84f7563). Rebased onto upstream/next and took the deletions on both, then moved the #3552 append into a new fragment of its own. Rebasing onto the current base also left execute-phase.md only 34 bytes under the frozen ADR-857 Phase 6 margin ceiling (93400 bytes) — intervening next PRs consumed the rest while this PR was in review. Extracted the "none" arm's protected-branch-warning bash block into gsd-core/workflows/execute-phase/steps/protected-branch.md (content unchanged, matching the existing steps/ extraction pattern used elsewhere in this file) so the inline growth is a one-line pointer instead of the full block. 93366 -> 93385 bytes (+19), 15 bytes inside the ceiling. * fix(#3648): drop stale ack entry for the new step file The extracted execute-phase/steps/protected-branch.md needed no acknowledgment of its own — the differential-attribution check flagged the entry as stale once the build ran, so removed it and kept the two growth entries (execute-phase.md, ship.md) that actually needed one. * fix(#3648): follow the step-file reference in the bash-extraction test helper extractProtectedBranchWarningBash() read the "none" arm's bash block directly out of execute-phase.md. That block now lives in execute-phase/steps/protected-branch.md (byte-ceiling extraction); the helper follows the step-file reference and extracts from there when no inline block is found, so the three execute-phase tests that execute this bash for real keep exercising the actual behavior. * fix(#3648): regenerate INVENTORY-MANIFEST.json and satisfy the CRLF-fragile lint rule - gen-inventory-manifest.cjs --write to pick up the new execute-phase/steps/protected-branch.md entry (already covered by docs/INVENTORY.md's generic workflow_steps wildcard row, so no INVENTORY.md edit is needed). - Reworked the step-file-reference lookup in extractProtectedBranchWarningBash() to avoid a bare-\n regex split on file content (local/no-crlf-fragile-split), using the same line-array scan the function already uses elsewhere. * fix(#3648): regenerate golden install-tree fixtures for the new step file npm run gen:install-tree, adding gsd-core/workflows/execute-phase/ steps/protected-branch.md to all 19 runtime install-tree fixtures. CI's tests/golden-install-tree.test.cjs caught this on push — I'd verified the differential-attribution and INVENTORY-MANIFEST checks but missed this separate golden-fixture check for the new file. * fix(#3648): add the canonical gsd_run preamble to the new step file CI's runtime-launcher-parity suite requires exactly one canonical resolver preamble in every workflow .md that calls gsd_run. The inline "none"-arm block never needed one (execute-phase.md already carried a preamble elsewhere in the same file), but the extracted execute-phase/steps/protected-branch.md is now its own file with no preamble of its own. Ran node scripts/sync-runtime-launcher.cjs to insert it (execute-phase.md itself is untouched — still 93385 bytes, inside the ADR-857 ceiling). That preamble defines its own gsd_run(), which shadows the mock tests/git-base-branch.test.cjs injects for the three #3648 tests that execute this bash for real — without stripping it, those tests reached the real gsd-tools.cjs on the machine running them instead of the test's fixture. Preamble correctness is already covered by tests/runtime-launcher-parity.test.cjs, so extractProtectedBranchWarningBash() now strips the preamble line before handing the block to the harness; it only needs to exercise the #3552 warning logic. * fix(#3552): address PR 3648 review feedback on protected branch warnings - Fix execute-phase handle_branching branching_strategy=none instruction to "Read and execute execute-phase/steps/protected-branch.md" - Use io.error(..., ERROR_REASON.USAGE) for cmdGitBaseBranch usage errors - Align git.protected_branches schema default to (none) without fallback [] - Relocate CONTEXT.md forward-referencing sentence into module body - Sanitize control and ANSI characters in renderRejected diagnostics - Clean up out-of-scope whitespace hunks in gsd-tools.cjs Emitted-Drift-Ack-Growth: execute-phase.md — #3552: execute-phase handle_branching adds a pointer to execute-phase/steps/protected-branch.md for branching_strategy=none so the protected-branch check executes while keeping execute-phase.md within the ADR-857 Phase 6 margin ceiling (93400 bytes). 93392 bytes, 8 bytes inside the ceiling. Emitted-Drift-Ack-Growth: ship.md — #3552: ship preflight step 3 now asks the same typed git.base-branch --is-protected predicate as execute-phase, binding IS_PROTECTED and warning without refusing execution or blocking the branching_strategy=none feature-branch offer; it degrades visibly (rather than silently reading an empty result as "not protected") when the query itself fails to run. 36841 bytes, well inside the XL cap (98304, tests/workflow-size-budget.test.cjs). --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
1555 lines
72 KiB
JavaScript
1555 lines
72 KiB
JavaScript
/**
|
||
* Tests for config-get --default flag (#1893)
|
||
*
|
||
* When --default <value> is passed, config-get should return the default
|
||
* value (exit 0) instead of erroring (exit 1) when the key is absent.
|
||
* When the key IS present, --default should be ignored and the real value
|
||
* returned.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const { cleanup } = require('./helpers.cjs');
|
||
|
||
// In-process invocation, not execFileSync: cmdConfigGet is a pure CJS
|
||
// function reachable without spawning `node` as a child. The prior
|
||
// execFileSync(..., { timeout: 5000 }) raced a real subprocess's startup
|
||
// (full node boot + gsd-tools.cjs's large eager require graph — capability
|
||
// registry, phase/roadmap/agent/check/task routers, verify.cjs,
|
||
// cli-skew-check, findProjectRoot, etc.) against a fixed 5s wall clock, with
|
||
// no retry. Under Docker host contention that wall clock loses
|
||
// nondeterministically (ETIMEDOUT) — a test-harness race, not a product
|
||
// defect. bin/lib/config.cjs requires none of that dispatcher machinery, so
|
||
// calling cmdConfigGet directly removes the subprocess-spawn cost and the
|
||
// wall-clock race entirely: no timeout of any size can flake this.
|
||
const config = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'config.cjs'));
|
||
// io.cjs owns error()/output() and the JSON-error-mode toggle. cmdConfigGet's `error`
|
||
// is bound to io.error at load, so we drive io directly to (a) get structured stderr
|
||
// we can assert a typed `reason` on, and (b) restore the mode after each error probe.
|
||
const io = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'io.cjs'));
|
||
// ADR-3889: error() throws ExitError instead of calling process.exit()
|
||
// directly. The `runExpectError`/`runInProcessAt`/`runScopedExpectError`
|
||
// harnesses below now catch ExitError directly rather than mocking
|
||
// process.exit with a throwable sentinel — mocking process.exit no longer
|
||
// observes anything, since error() never calls it.
|
||
const { ExitError } = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'cli-exit.cjs'));
|
||
|
||
/**
|
||
* cmdConfigGet's error() path (gsd-core/bin/lib/io.cjs) now throws ExitError
|
||
* directly (ADR-3889) instead of calling process.exit(1). The harnesses below
|
||
* catch that ExitError directly — no process.exit mock / throwable sentinel
|
||
* is needed anymore.
|
||
*
|
||
* cmdConfigGet's "no config.json" branch sits inside a try/catch that used to
|
||
* reclassify any throw NOT starting with "No config.json" as a parse failure.
|
||
* Because an ExitError carries no message (`.message` defaults to
|
||
* "process exit 1"), that message-sniffing check could never match it — a
|
||
* real production bug this PR also fixes at src/config.cts (an unconditional
|
||
* `instanceof ExitError` re-throw now guards it). The `writeCount === 1`
|
||
* assertion in these harnesses is what would have caught it: a
|
||
* fall-through-and-reclassify shows up as a second stderr write.
|
||
*/
|
||
|
||
/**
|
||
* bin/lib/io.cjs's output()/error() write directly to the raw fd (1 or 2)
|
||
* via fs.writeSync — they bypass console.log entirely, so
|
||
* tests/helpers.cjs's captureConsole() cannot observe them (see
|
||
* tests/io.test.cjs: "output() writes directly to fd 1"). Monkeypatch
|
||
* fs.writeSync itself — save the original, override, restore in a finally,
|
||
* the project's standard IO capture/fault-injection seam — to capture what
|
||
* would have hit the fd.
|
||
*/
|
||
function captureFdWrite(fd, fn) {
|
||
const orig = fs.writeSync;
|
||
let captured = Buffer.alloc(0);
|
||
fs.writeSync = (writeFd, ...rest) => {
|
||
if (writeFd !== fd) return orig.call(fs, writeFd, ...rest);
|
||
const [data, offset = 0, length] = rest;
|
||
const chunk = Buffer.isBuffer(data)
|
||
? data.subarray(offset, offset + (length ?? data.length - offset))
|
||
: Buffer.from(String(data), 'utf8');
|
||
captured = Buffer.concat([captured, chunk]);
|
||
return chunk.length;
|
||
};
|
||
try {
|
||
fn();
|
||
} finally {
|
||
fs.writeSync = orig;
|
||
}
|
||
return captured.toString('utf-8');
|
||
}
|
||
|
||
/**
|
||
* Parse a CLI-style config-get argv (mirrors gsd-core/bin/gsd-tools.cjs's
|
||
* 'config-get' case: key is args[1], optional --default <value>, optional
|
||
* --raw) into cmdConfigGet's positional params. Keeps the test bodies below
|
||
* expressed in the same CLI-args vocabulary they always were.
|
||
*/
|
||
function parseConfigGetArgs(args) {
|
||
const rest = args.slice(1); // drop the leading 'config-get'
|
||
let raw = false;
|
||
let defaultValue;
|
||
const positional = [];
|
||
for (let i = 0; i < rest.length; i++) {
|
||
if (rest[i] === '--raw') { raw = true; continue; }
|
||
if (rest[i] === '--default') { defaultValue = rest[i + 1] ?? ''; i++; continue; }
|
||
positional.push(rest[i]);
|
||
}
|
||
return { keyPath: positional[0], raw, defaultValue };
|
||
}
|
||
|
||
describe('config-get --default flag (#1893)', () => {
|
||
let tmpDir;
|
||
let planningDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-config-default-'));
|
||
planningDir = path.join(tmpDir, '.planning');
|
||
fs.mkdirSync(planningDir, { recursive: true });
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
function run(...args) {
|
||
const { keyPath, raw, defaultValue } = parseConfigGetArgs(args);
|
||
const out = captureFdWrite(1, () => {
|
||
config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue);
|
||
});
|
||
return out.trim();
|
||
}
|
||
|
||
function runRaw(...args) {
|
||
return run(...args, '--raw');
|
||
}
|
||
|
||
function runExpectError(...args) {
|
||
const { keyPath, raw, defaultValue } = parseConfigGetArgs(args);
|
||
const origWriteSync = fs.writeSync;
|
||
io.setJsonErrorMode(true); // structured stderr line lets the payload carry the reason
|
||
let writeCount = 0;
|
||
let stderr = '';
|
||
fs.writeSync = (fd, ...rest) => {
|
||
if (fd !== 2) return origWriteSync.call(fs, fd, ...rest);
|
||
writeCount++;
|
||
const [data, offset = 0, length] = rest;
|
||
const chunk = Buffer.isBuffer(data)
|
||
? data.subarray(offset, offset + (length ?? data.length - offset)).toString('utf8')
|
||
: String(data);
|
||
stderr += chunk;
|
||
return Buffer.byteLength(chunk);
|
||
};
|
||
const lastError = () => {
|
||
const parts = stderr.split('\n').filter(Boolean);
|
||
try { return JSON.parse(parts[parts.length - 1]); } catch { return {}; }
|
||
};
|
||
// ADR-3889: error() now throws ExitError directly (rather than calling
|
||
// process.exit()), so the real termination contract is caught here
|
||
// instead of via a process.exit mock.
|
||
let exitCode;
|
||
try {
|
||
config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue);
|
||
assert.fail('expected cmdConfigGet to throw ExitError');
|
||
} catch (e) {
|
||
if (!(e instanceof ExitError)) throw e;
|
||
exitCode = e.code;
|
||
} finally {
|
||
fs.writeSync = origWriteSync;
|
||
io.setJsonErrorMode(false);
|
||
}
|
||
assert.ok(exitCode !== 0 && exitCode !== undefined, 'Expected non-zero exit code');
|
||
// Faithfulness guard: error() must fire exactly once. A count of 2 means
|
||
// the guard's ExitError was caught by a message-sniffing catch and
|
||
// reclassified into a 2nd error() call (the exact Part-2 defect class —
|
||
// an ExitError has no message, so `.message.startsWith(...)` conditions
|
||
// never match it and it falls through to a wrong, generic branch).
|
||
assert.equal(writeCount, 1, 'error() must fire exactly once');
|
||
const payload = lastError();
|
||
return { status: exitCode, reason: payload.reason, message: payload.message, stderr };
|
||
}
|
||
|
||
test('absent key without --default errors', () => {
|
||
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}');
|
||
const { reason } = runExpectError('config-get', 'nonexistent.key', '--raw');
|
||
assert.equal(reason, io.ERROR_REASON.CONFIG_KEY_NOT_FOUND, 'absent key must report CONFIG_KEY_NOT_FOUND');
|
||
});
|
||
|
||
test('absent key with --default returns default value', () => {
|
||
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}');
|
||
const result = runRaw('config-get', 'nonexistent.key', '--default', 'fallback');
|
||
assert.equal(result, 'fallback');
|
||
});
|
||
|
||
test('absent key with --default "" returns empty string', () => {
|
||
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}');
|
||
const result = runRaw('config-get', 'nonexistent.key', '--default', '');
|
||
assert.equal(result, '');
|
||
});
|
||
|
||
test('present key with --default returns real value (ignores default)', () => {
|
||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({
|
||
workflow: { discuss_mode: 'adaptive' }
|
||
}));
|
||
const result = runRaw('config-get', 'workflow.discuss_mode', '--default', 'ignored');
|
||
assert.equal(result, 'adaptive');
|
||
});
|
||
|
||
test('nested absent key with --default returns default', () => {
|
||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({
|
||
workflow: {}
|
||
}));
|
||
const result = runRaw('config-get', 'workflow.deep.missing.key', '--default', 'safe');
|
||
assert.equal(result, 'safe');
|
||
});
|
||
|
||
test('missing config.json with --default returns default', () => {
|
||
// No config.json written
|
||
const result = runRaw('config-get', 'any.key', '--default', 'no-config');
|
||
assert.equal(result, 'no-config');
|
||
});
|
||
|
||
test('missing config.json without --default errors', () => {
|
||
// No config.json written
|
||
const { reason } = runExpectError('config-get', 'any.key', '--raw');
|
||
assert.equal(reason, io.ERROR_REASON.CONFIG_NO_FILE, 'missing config.json must report CONFIG_NO_FILE');
|
||
});
|
||
|
||
test('--default works with JSON output (no --raw)', () => {
|
||
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}');
|
||
const result = run('config-get', 'missing.key', '--default', 'json-test');
|
||
const parsed = JSON.parse(result);
|
||
assert.equal(parsed, 'json-test');
|
||
});
|
||
});
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// #2256 — config-get was blind to capability-registry configSchema defaults.
|
||
//
|
||
// cmdConfigGet's three absent-key branches (no-config-file, mid-traversal
|
||
// non-object, final-undefined) only consulted the 4-key SCHEMA_DEFAULTS map
|
||
// before erroring "Key not found". The capability registry declares ~42
|
||
// configSchema defaults (e.g. workflow.security_enforcement -> true) that
|
||
// resolveConfigKey's Level 4 (capability-activation.cts) already honors at
|
||
// runtime — so `query config-get` could disagree with the runtime about the
|
||
// effective value of an absent key. Fix: cmdConfigGet now also consults
|
||
// getCapabilityConfigSchema(cwd) via a resolveSchemaDefault() helper before
|
||
// erroring.
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const configSchemaMod = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'config-schema.cjs'));
|
||
// Repo idiom for property tests (matches config-schema.property.test.cjs):
|
||
// require the shared seeded/bounded wrapper, not bare 'fast-check', so this
|
||
// property run is deterministic across CI (seed 42) rather than fuzzing with
|
||
// a fresh random seed on every invocation.
|
||
const fc = require('./helpers/fast-check-setup.cjs');
|
||
|
||
describe('config-get registry configSchema defaults (#2256)', () => {
|
||
let tmpDir;
|
||
let planningDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-config-2256-'));
|
||
planningDir = path.join(tmpDir, '.planning');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
function run(...args) {
|
||
const { keyPath, raw, defaultValue } = parseConfigGetArgs(args);
|
||
const out = captureFdWrite(1, () => {
|
||
config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue);
|
||
});
|
||
return out.trim();
|
||
}
|
||
|
||
function runRaw(...args) {
|
||
return run(...args, '--raw');
|
||
}
|
||
|
||
function runExpectError(...args) {
|
||
const { keyPath, raw, defaultValue } = parseConfigGetArgs(args);
|
||
const origWriteSync = fs.writeSync;
|
||
io.setJsonErrorMode(true);
|
||
let writeCount = 0;
|
||
let stderr = '';
|
||
fs.writeSync = (fd, ...rest) => {
|
||
if (fd !== 2) return origWriteSync.call(fs, fd, ...rest);
|
||
writeCount++;
|
||
const [data, offset = 0, length] = rest;
|
||
const chunk = Buffer.isBuffer(data)
|
||
? data.subarray(offset, offset + (length ?? data.length - offset)).toString('utf8')
|
||
: String(data);
|
||
stderr += chunk;
|
||
return Buffer.byteLength(chunk);
|
||
};
|
||
const lastError = () => {
|
||
const parts = stderr.split('\n').filter(Boolean);
|
||
try { return JSON.parse(parts[parts.length - 1]); } catch { return {}; }
|
||
};
|
||
// ADR-3889: error() throws ExitError directly; catch it here rather
|
||
// than mocking process.exit.
|
||
let exitCode;
|
||
try {
|
||
config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue);
|
||
assert.fail('expected cmdConfigGet to throw ExitError');
|
||
} catch (e) {
|
||
if (!(e instanceof ExitError)) throw e;
|
||
exitCode = e.code;
|
||
} finally {
|
||
fs.writeSync = origWriteSync;
|
||
io.setJsonErrorMode(false);
|
||
}
|
||
assert.ok(exitCode !== 0 && exitCode !== undefined, 'Expected non-zero exit code');
|
||
assert.equal(writeCount, 1, 'error() must fire exactly once');
|
||
const payload = lastError();
|
||
return { status: exitCode, reason: payload.reason, message: payload.message, stderr };
|
||
}
|
||
|
||
// Pull the real registry defaults instead of hardcoding a guess, so this
|
||
// test tracks the registry rather than pinning a stale snapshot of it.
|
||
const capSchema = configSchemaMod.getCapabilityConfigSchema();
|
||
const securityEnforcementDefault = capSchema['workflow.security_enforcement']?.default;
|
||
const securityBlockOnDefault = capSchema['workflow.security_block_on']?.default;
|
||
const securityAsvsLevelDefault = capSchema['workflow.security_asvs_level']?.default;
|
||
|
||
test('primary: no config.json — registry-defaulted boolean key resolves via --raw (not "Key not found")', () => {
|
||
// No .planning dir at all — the no-config-file branch (branch 1).
|
||
assert.equal(fs.existsSync(planningDir), false, 'pre-check: no .planning dir');
|
||
assert.equal(securityEnforcementDefault, true, 'pre-check: registry default for workflow.security_enforcement is true');
|
||
const result = runRaw('config-get', 'workflow.security_enforcement');
|
||
assert.equal(result, 'true', 'must return the registry default, not error');
|
||
});
|
||
|
||
test('no config.json — registry-defaulted enum key resolves to its registry default', () => {
|
||
assert.equal(fs.existsSync(planningDir), false, 'pre-check: no .planning dir');
|
||
assert.equal(typeof securityBlockOnDefault, 'string');
|
||
const result = runRaw('config-get', 'workflow.security_block_on');
|
||
assert.equal(result, securityBlockOnDefault);
|
||
});
|
||
|
||
test('no config.json — registry-defaulted number key resolves to its registry default', () => {
|
||
assert.equal(fs.existsSync(planningDir), false, 'pre-check: no .planning dir');
|
||
assert.equal(typeof securityAsvsLevelDefault, 'number');
|
||
const result = runRaw('config-get', 'workflow.security_asvs_level');
|
||
assert.equal(result, String(securityAsvsLevelDefault));
|
||
});
|
||
|
||
test('config.json exists but key is absent after full traversal — registry default still resolves (final-undefined branch)', () => {
|
||
// keys = ['workflow', 'security_enforcement']. First segment traverses
|
||
// into a real object ({ auto_advance: false }); the second segment is
|
||
// simply absent from it, so the loop completes and `current` comes out
|
||
// undefined — this is the FINAL-undefined branch (branch 3), not
|
||
// mid-traversal (branch 2 fires only when an INTERMEDIATE segment is a
|
||
// non-object scalar; see the dedicated mid-traversal test below).
|
||
fs.mkdirSync(planningDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'config.json'),
|
||
JSON.stringify({ workflow: { auto_advance: false } }),
|
||
);
|
||
const result = runRaw('config-get', 'workflow.security_enforcement');
|
||
assert.equal(result, 'true');
|
||
});
|
||
|
||
test('config.json has a non-object intermediate segment — registry default still resolves (true mid-traversal branch)', () => {
|
||
// keys = ['workflow', 'security_enforcement']. `workflow` itself is a
|
||
// boolean scalar, not an object, so the SECOND loop iteration's guard
|
||
// (`typeof current !== 'object'`) fires before any further descent —
|
||
// this is the genuine mid-traversal branch (branch 2).
|
||
fs.mkdirSync(planningDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'config.json'),
|
||
JSON.stringify({ workflow: true }),
|
||
);
|
||
const result = runRaw('config-get', 'workflow.security_enforcement');
|
||
assert.equal(result, 'true');
|
||
});
|
||
|
||
test('legacy SCHEMA_DEFAULTS key still resolves unchanged (context_window -> 200000)', () => {
|
||
fs.mkdirSync(planningDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'config.json'),
|
||
JSON.stringify({ workflow: { auto_advance: false } }),
|
||
);
|
||
const result = runRaw('config-get', 'context_window');
|
||
assert.equal(result, '200000');
|
||
});
|
||
|
||
test('--default flag still wins over the registry default for an absent registry key', () => {
|
||
const result = runRaw('config-get', 'workflow.security_enforcement', '--default', 'flag-wins');
|
||
assert.equal(result, 'flag-wins');
|
||
});
|
||
|
||
test('a genuinely unknown, non-registry, non-legacy key still errors "Key not found" (rc1) when config.json exists', () => {
|
||
// config.json must exist here: with NO config.json, cmdConfigGet's
|
||
// no-config-file branch fires first and reports CONFIG_NO_FILE before
|
||
// ever reaching the traversal path's CONFIG_KEY_NOT_FOUND check (see
|
||
// the dedicated no-config-file test below for that branch). Writing a
|
||
// config.json here routes the unknown key through the real traversal
|
||
// path so this test actually exercises "unknown key found not
|
||
// permissive", not "no config file yet".
|
||
fs.mkdirSync(planningDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'config.json'),
|
||
JSON.stringify({ workflow: { auto_advance: true } }),
|
||
);
|
||
const { status, reason } = runExpectError('config-get', 'nonsense.totally_made_up_key', '--raw');
|
||
assert.equal(status, 1);
|
||
assert.equal(reason, io.ERROR_REASON.CONFIG_KEY_NOT_FOUND, 'unknown key must not become permissive');
|
||
});
|
||
|
||
test('a genuinely unknown, non-registry, non-legacy key with NO config.json errors "No config.json found" (rc1)', () => {
|
||
// Pins the actual (distinct) behavior of the no-config-file branch:
|
||
// an unrecognized key with no config file at all legitimately reports
|
||
// CONFIG_NO_FILE — it never reaches the CONFIG_KEY_NOT_FOUND check,
|
||
// because that check lives in the traversal path which only runs once
|
||
// a config object exists (or a default/schema-default short-circuits
|
||
// first). A registry-defaulted key in this same no-file scenario
|
||
// instead resolves its default (see the "primary" test above) — the
|
||
// two behaviors are complementary and both worth locking in.
|
||
assert.equal(fs.existsSync(planningDir), false, 'pre-check: no .planning dir');
|
||
const { status, reason } = runExpectError('config-get', 'nonsense.totally_made_up_key', '--raw');
|
||
assert.equal(status, 1);
|
||
assert.equal(reason, io.ERROR_REASON.CONFIG_NO_FILE, 'no config file at all must report CONFIG_NO_FILE');
|
||
});
|
||
|
||
// ── Prototype-pollution guard: bracket-access traversal on a plain
|
||
// object walks the JS prototype chain, so an unqualified `current[key]`
|
||
// could resolve '__proto__' / 'constructor' / 'hasOwnProperty' to their
|
||
// inherited Object.prototype values instead of correctly reporting them
|
||
// absent. cmdConfigGet's traversal loop gates each descent on
|
||
// Object.prototype.hasOwnProperty.call(current, key) precisely to close
|
||
// this off; these tests pin that it stays closed.
|
||
for (const protoKey of ['__proto__', 'constructor']) {
|
||
test(`config-get ${protoKey} with no config.json errors safely (does not resolve Object.prototype/Function)`, () => {
|
||
assert.equal(fs.existsSync(planningDir), false, 'pre-check: no .planning dir');
|
||
const { status, reason } = runExpectError('config-get', protoKey, '--raw');
|
||
assert.equal(status, 1);
|
||
// No config.json at all -> the no-config-file branch fires first
|
||
// (same as any other absent, non-registry key); the important
|
||
// invariant is that it errors rc1 and never leaks a prototype
|
||
// object/function representation at rc0.
|
||
assert.equal(reason, io.ERROR_REASON.CONFIG_NO_FILE);
|
||
});
|
||
|
||
test(`config-get ${protoKey} with config.json present errors "Key not found" (does not walk the prototype chain)`, () => {
|
||
fs.mkdirSync(planningDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'config.json'),
|
||
JSON.stringify({ workflow: { auto_advance: true } }),
|
||
);
|
||
const { status, reason } = runExpectError('config-get', protoKey, '--raw');
|
||
assert.equal(status, 1);
|
||
assert.equal(reason, io.ERROR_REASON.CONFIG_KEY_NOT_FOUND,
|
||
`${protoKey} must not resolve via the prototype chain`);
|
||
});
|
||
}
|
||
|
||
test('config-get hasOwnProperty (a nested Object.prototype method name) errors "Key not found", not the inherited function', () => {
|
||
fs.mkdirSync(planningDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'config.json'),
|
||
JSON.stringify({ workflow: { auto_advance: true } }),
|
||
);
|
||
const { status, reason } = runExpectError('config-get', 'hasOwnProperty', '--raw');
|
||
assert.equal(status, 1);
|
||
assert.equal(reason, io.ERROR_REASON.CONFIG_KEY_NOT_FOUND);
|
||
});
|
||
|
||
// ── Secret-masking on the resolved-default path (finding #4). No
|
||
// first-party registry key is both secret-named and schema-defaulted,
|
||
// and getCapabilityConfigSchema(cwd) is not fixture-injectable from a
|
||
// black-box test (it composes from real installed-capability discovery
|
||
// under `cwd`, not a seam this test can substitute). The reachable,
|
||
// faithful-to-production seam is `--default` on a secret-named key path:
|
||
// cmdConfigGet's `hasDefault` branches sit in the exact same absent-key
|
||
// position as the resolveSchemaDefault() branches and must apply the
|
||
// identical isSecretKey()/maskSecret() masking — this exercises that the
|
||
// masking invariant is real and observable at the CLI-args level, not
|
||
// merely aspirational in the resolveSchemaDefault plumbing.
|
||
test('secret-named key resolved via --default is masked, not echoed in plaintext', () => {
|
||
// 'brave_search' is a real entry in SECRET_CONFIG_KEYS (src/secrets.cts) —
|
||
// the same isSecretKey() gate emitResolvedDefault() applies.
|
||
const result = runRaw('config-get', 'brave_search', '--default', 'sk-plaintext-should-not-leak');
|
||
assert.notEqual(result, 'sk-plaintext-should-not-leak', 'a secret-named key must never echo its raw value');
|
||
assert.match(result, /\*/, 'masked secret output should contain masking characters');
|
||
});
|
||
|
||
// ── Property test: dotted-key traversal safety contract ────────────────
|
||
//
|
||
// Runs cmdConfigGet fully in-process against an ISOLATED temp dir created
|
||
// fresh for every fc run (unique mkdtemp per run body, cleaned up in a
|
||
// finally — no shared/leaked state across runs).
|
||
function runInProcessAt(dir, keyPath) {
|
||
const origWriteSync = fs.writeSync;
|
||
io.setJsonErrorMode(true);
|
||
let stdout = '';
|
||
let stderr = '';
|
||
let exitCode = 0;
|
||
let exited = false;
|
||
fs.writeSync = (fd, ...rest) => {
|
||
const [data, offset = 0, length] = rest;
|
||
const chunk = Buffer.isBuffer(data)
|
||
? data.subarray(offset, offset + (length ?? data.length - offset)).toString('utf8')
|
||
: String(data);
|
||
if (fd === 1) stdout += chunk;
|
||
else if (fd === 2) stderr += chunk;
|
||
return Buffer.byteLength(chunk);
|
||
};
|
||
// ADR-3889: error() throws ExitError directly; catch it here rather
|
||
// than mocking process.exit.
|
||
try {
|
||
config.cmdConfigGet(dir, keyPath, true, undefined);
|
||
} catch (e) {
|
||
if (!(e instanceof ExitError)) throw e;
|
||
exited = true;
|
||
exitCode = e.code;
|
||
} finally {
|
||
fs.writeSync = origWriteSync;
|
||
io.setJsonErrorMode(false);
|
||
}
|
||
let reason = null;
|
||
if (exited) {
|
||
const parts = stderr.split('\n').filter(Boolean);
|
||
try { reason = JSON.parse(parts[parts.length - 1]).reason; } catch { /* no structured payload */ }
|
||
}
|
||
return { exited, exitCode, stdout: stdout.trim(), reason };
|
||
}
|
||
|
||
test('property: dotted-key traversal never resolves a value sourced from the JS prototype chain', () => {
|
||
const PROTO_MEMBER_NAMES = ['__proto__', 'constructor', 'prototype', 'hasOwnProperty', 'toString', 'valueOf', 'isPrototypeOf'];
|
||
const randomSegmentArb = fc.stringMatching(/^[a-z][a-z0-9_]{0,8}$/);
|
||
const segmentArb = fc.oneof(fc.constantFrom(...PROTO_MEMBER_NAMES), randomSegmentArb);
|
||
// Every generated path is FORCED to include at least one prototype-member
|
||
// segment (interleaved with 0-4 random segments). That guarantees the
|
||
// full dotted path can never equal a real SCHEMA_DEFAULTS or
|
||
// capability-registry key: none of those keys have a segment literally
|
||
// named '__proto__' / 'constructor' / etc., so equality would require
|
||
// every segment to match, which a proto-member segment rules out. That
|
||
// means any rc0 resolution below can ONLY be explained by a genuine
|
||
// own-property value present in the written config.json — never by the
|
||
// legitimate schema-default fallback, and never by the prototype chain.
|
||
const keyPathArb = fc.tuple(
|
||
fc.array(segmentArb, { maxLength: 2 }),
|
||
fc.constantFrom(...PROTO_MEMBER_NAMES),
|
||
fc.array(segmentArb, { maxLength: 2 }),
|
||
).map(([before, proto, after]) => [...before, proto, ...after].join('.'));
|
||
|
||
// Own-property-gated reference traversal — mirrors src/config.cts's
|
||
// fixed cmdConfigGet traversal loop exactly (Object.prototype.hasOwnProperty.call
|
||
// gate at every descent), so "expected" reflects only genuinely-present
|
||
// config data, never anything reachable only via the prototype chain.
|
||
function safeOwnTraverse(obj, dottedPath) {
|
||
let current = obj;
|
||
for (const seg of dottedPath.split('.')) {
|
||
if (current === undefined || current === null || typeof current !== 'object') return { found: false };
|
||
if (!Object.prototype.hasOwnProperty.call(current, seg)) return { found: false };
|
||
current = current[seg];
|
||
}
|
||
if (current === undefined) return { found: false };
|
||
return { found: true, value: current };
|
||
}
|
||
|
||
// Leaf value planted at the end of a genuine own-property chain (see
|
||
// "plant" below). JSON-safe scalars only — this exercises the "found"
|
||
// branch with values of several distinct typeof()s, including the
|
||
// `null` edge case (a real, resolvable value, distinct from "absent").
|
||
const leafValueArb = fc.oneof(fc.boolean(), fc.integer(), fc.string({ maxLength: 20 }), fc.constant(null));
|
||
|
||
fc.assert(
|
||
fc.property(
|
||
keyPathArb,
|
||
fc.boolean(),
|
||
leafValueArb,
|
||
fc.object({ maxDepth: 3 }),
|
||
(keyPath, plant, leafValue, backgroundObj) => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-proto-prop-'));
|
||
try {
|
||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||
|
||
let configObj;
|
||
if (plant) {
|
||
// Deliberately construct a config object where `keyPath` IS a
|
||
// genuine own-property chain terminating at `leafValue` — via
|
||
// COMPUTED property syntax `{ [seg]: nested }`, which (unlike
|
||
// `obj.__proto__ = v` / `obj['__proto__'] = v`) is NOT
|
||
// Annex-B-special-cased and always defines a real own data
|
||
// property, even when `seg === '__proto__'`. This is the
|
||
// same mechanism JSON.parse uses for a literal "__proto__"
|
||
// key in committed JSON, so it models a real project config.
|
||
const segments = keyPath.split('.');
|
||
let nested = leafValue;
|
||
for (let i = segments.length - 1; i >= 0; i--) {
|
||
nested = { [segments[i]]: nested };
|
||
}
|
||
configObj = nested;
|
||
} else {
|
||
// Independent random object — keyPath is (overwhelmingly)
|
||
// absent from it, exercising the safe-error side.
|
||
configObj = backgroundObj;
|
||
}
|
||
|
||
const serialized = JSON.stringify(configObj ?? {});
|
||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), serialized);
|
||
// Reference expectation is computed from the SAME round-tripped
|
||
// JSON cmdConfigGet itself reads back (JSON.stringify then
|
||
// JSON.parse), so it reflects exactly what fs.readFileSync +
|
||
// JSON.parse produced.
|
||
const roundTripped = JSON.parse(serialized);
|
||
const expected = safeOwnTraverse(roundTripped, keyPath);
|
||
|
||
const result = runInProcessAt(dir, keyPath);
|
||
|
||
if (result.exited) {
|
||
assert.equal(result.exitCode, 1, `keyPath=${JSON.stringify(keyPath)} exited non-1`);
|
||
assert.ok(
|
||
result.reason === io.ERROR_REASON.CONFIG_KEY_NOT_FOUND
|
||
|| result.reason === io.ERROR_REASON.CONFIG_NO_FILE,
|
||
`keyPath=${JSON.stringify(keyPath)} errored with unexpected reason=${result.reason}`,
|
||
);
|
||
// A planted path must NEVER fail to resolve — if it did, that
|
||
// would itself be a defect (own data lost/misread), distinct
|
||
// from the prototype-leak contract but still worth pinning.
|
||
assert.equal(plant, false, `planted own-property path ${JSON.stringify(keyPath)} unexpectedly errored`);
|
||
} else {
|
||
// rc0 — the guaranteed proto-member segment rules out both the
|
||
// SCHEMA_DEFAULTS and capability-registry fallback paths, so
|
||
// the ONLY legitimate explanation for a success here is a
|
||
// genuine own-property value actually present in config.json.
|
||
assert.ok(
|
||
expected.found,
|
||
`rc0 for keyPath=${JSON.stringify(keyPath)} but no own-reachable value exists in the ` +
|
||
`written config — possible prototype-chain leak (stdout=${JSON.stringify(result.stdout)})`,
|
||
);
|
||
assert.equal(result.stdout, String(expected.value));
|
||
}
|
||
} finally {
|
||
cleanup(dir);
|
||
}
|
||
},
|
||
),
|
||
// Bounded below the shared 200-run default (config-schema.property.test.cjs's
|
||
// global fc.configureGlobal) because each run does real filesystem I/O
|
||
// (mkdtemp + write + rm) rather than pure in-memory computation.
|
||
{ numRuns: 60 },
|
||
);
|
||
});
|
||
|
||
test('absent git.protected_branches has no schema default ((none) per docs)', () => {
|
||
fs.mkdirSync(planningDir, { recursive: true });
|
||
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}');
|
||
const { reason } = runExpectError('config-get', 'git.protected_branches');
|
||
assert.equal(reason, io.ERROR_REASON.CONFIG_KEY_NOT_FOUND);
|
||
const fallback = runRaw('config-get', 'git.protected_branches', '--default', '["main"]');
|
||
assert.equal(fallback, '["main"]');
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-2798-context-window-config-key.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-2798-context-window-config-key (consolidation epic #1969 B3 #1972)", () => {
|
||
/**
|
||
* Regression test for bug #2798
|
||
*
|
||
* `gsd-sdk query config-set context_window <n>` was rejected with
|
||
* "Unknown config key: context_window" because context_window was missing
|
||
* from VALID_CONFIG_KEYS in sdk/src/query/config-schema.ts.
|
||
*
|
||
* The fix added 'context_window' to the allowlist.
|
||
* This test prevents future drift where the key gets accidentally removed.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||
const { runNode } = require('./helpers/process-seam.cjs');
|
||
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||
|
||
const REPO_ROOT = path.join(__dirname, '..');
|
||
const SDK_CLI = path.join(REPO_ROOT, 'sdk', 'dist', 'cli.js');
|
||
|
||
function runConfigSet(key, value, projectDir) {
|
||
const argv = ['query', 'config-set', key, String(value), '--project-dir', projectDir];
|
||
const result = runNode([SDK_CLI, ...argv], {
|
||
env: { ...process.env, GSD_SESSION_KEY: '' },
|
||
timeoutMs: PROBE_TIMEOUT_MS,
|
||
});
|
||
let json = null;
|
||
try { json = JSON.parse(result.stdout.trim()); } catch { /* ok */ }
|
||
return { exitCode: result.exitCode, json };
|
||
}
|
||
|
||
describe('bug-2798: context_window is a valid config key', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempProject('gsd-test-2798-');
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'config.json'),
|
||
JSON.stringify({ mode: 'balanced' })
|
||
);
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('config-set context_window succeeds (not rejected as unknown key)', (t) => {
|
||
if (!fs.existsSync(SDK_CLI)) {
|
||
t.skip('sdk/dist/cli.js not built — run `cd sdk && npm run build` to enable this integration test');
|
||
return;
|
||
}
|
||
const result = runConfigSet('context_window', 1000000, tmpDir);
|
||
|
||
assert.strictEqual(result.exitCode, 0, 'should exit 0 (key is valid)');
|
||
assert.ok(result.json !== null, 'should emit JSON');
|
||
assert.strictEqual(result.json?.updated, true, 'updated should be true');
|
||
assert.strictEqual(result.json?.key, 'context_window');
|
||
});
|
||
|
||
test('context_window value is written to config.json', (t) => {
|
||
if (!fs.existsSync(SDK_CLI)) {
|
||
t.skip('sdk/dist/cli.js not built — run `cd sdk && npm run build` to enable this integration test');
|
||
return;
|
||
}
|
||
runConfigSet('context_window', 500000, tmpDir);
|
||
|
||
const config = JSON.parse(
|
||
fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8')
|
||
);
|
||
assert.strictEqual(config.context_window, 500000, 'context_window should be persisted');
|
||
});
|
||
|
||
test('config-schema CJS and SDK allowlists both include context_window', (t) => {
|
||
if (!fs.existsSync(path.join(REPO_ROOT, 'sdk', 'dist', 'query', 'config-schema.js'))) {
|
||
t.skip('sdk/dist/query/config-schema.js not built — run `cd sdk && npm run build` to enable this integration test');
|
||
return;
|
||
}
|
||
const cjsSchema = require(path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'config-schema.cjs'));
|
||
const sdkSchema = require(path.join(REPO_ROOT, 'sdk', 'dist', 'query', 'config-schema.js'));
|
||
|
||
assert.ok(
|
||
cjsSchema.VALID_CONFIG_KEYS.has('context_window'),
|
||
'CJS VALID_CONFIG_KEYS must include context_window'
|
||
);
|
||
assert.ok(
|
||
sdkSchema.VALID_CONFIG_KEYS.has('context_window'),
|
||
'SDK VALID_CONFIG_KEYS must include context_window'
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-2943-config-get-context-window-default.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-2943-config-get-context-window-default (consolidation epic #1969 B3 #1972)", () => {
|
||
/**
|
||
* Regression test for bug #2943
|
||
*
|
||
* `gsd-tools.cjs config-get context_window` (and the SDK equivalent) threw
|
||
* "Key not found: context_window" when the key was absent from config.json,
|
||
* even though context_window has a documented schema default of 200000.
|
||
*
|
||
* Fix: `cmdConfigGet` in bin/lib/config.cjs now consults a SCHEMA_DEFAULTS map
|
||
* before emitting "Key not found", so schema-defaulted keys always return the
|
||
* default value (exit 0) when not explicitly set in the project config.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
// Migrated to typed-IR (#2974): the previous shape grepped stderr/stdout for
|
||
// "Key not found"; now the test passes `--json-errors` to gsd-tools and
|
||
// asserts on the structured `reason` code (a frozen-enum value from
|
||
// `core.cjs::ERROR_REASON`). Exit code is also a typed signal — together
|
||
// they fully discriminate the failure class.
|
||
|
||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const os = require('node:os');
|
||
const { execFileSync } = require('node:child_process');
|
||
|
||
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||
const { ERROR_REASON } = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'io.cjs'));
|
||
const { cleanup } = require('./helpers.cjs');
|
||
|
||
describe('bug-2943: config-get returns schema default for context_window', () => {
|
||
let tmpDir;
|
||
let planningDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-2943-'));
|
||
planningDir = path.join(tmpDir, '.planning');
|
||
fs.mkdirSync(planningDir, { recursive: true });
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
/**
|
||
* Run config-get with optional extra args. Returns { exitCode, stdout, stderr }.
|
||
* Uses --raw so we get the plain scalar value, not JSON-wrapped.
|
||
*/
|
||
function runConfigGet(keyPath, extraArgs = []) {
|
||
const args = [GSD_TOOLS, 'config-get', keyPath, '--raw', '--cwd', tmpDir, ...extraArgs];
|
||
let stdout = '';
|
||
let stderr = '';
|
||
let exitCode = 0;
|
||
try {
|
||
// Windows/Node 22 under --test-concurrency=4 can starve subprocess slots when
|
||
// sharing a wave with bug-2760-codex-install (8–15s install subtests). 15s covers
|
||
// observed worst case (13.5s) with headroom.
|
||
stdout = execFileSync(process.execPath, args, {
|
||
encoding: 'utf-8',
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
timeout: 15000,
|
||
});
|
||
} catch (err) {
|
||
exitCode = err.status ?? 1;
|
||
stdout = err.stdout?.toString() ?? '';
|
||
stderr = err.stderr?.toString() ?? '';
|
||
}
|
||
return { exitCode, stdout: stdout.trim(), stderr: stderr.trim() };
|
||
}
|
||
|
||
test('returns "200000" (exit 0) when context_window absent from config.json', () => {
|
||
// Fixture A: config with unrelated keys, no context_window
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'config.json'),
|
||
JSON.stringify({ workflow: { auto_advance: false } })
|
||
);
|
||
|
||
const result = runConfigGet('context_window');
|
||
|
||
assert.strictEqual(result.exitCode, 0, 'should exit 0 (schema default applied)');
|
||
assert.strictEqual(result.stdout, '200000', 'should return schema default of 200000');
|
||
});
|
||
|
||
test('returns configured value when context_window is explicitly set', () => {
|
||
// Fixture B: config has context_window: 1000000
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'config.json'),
|
||
JSON.stringify({ context_window: 1000000 })
|
||
);
|
||
|
||
const result = runConfigGet('context_window');
|
||
|
||
assert.strictEqual(result.exitCode, 0, 'should exit 0 for found key');
|
||
assert.strictEqual(result.stdout, '1000000', 'should return configured value not schema default');
|
||
});
|
||
|
||
test('--default flag overrides schema default', () => {
|
||
// config has context_window but we pass --default with a different value —
|
||
// when key IS present, real value wins over any default
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'config.json'),
|
||
JSON.stringify({ workflow: { auto_advance: false } })
|
||
);
|
||
|
||
const result = runConfigGet('context_window', ['--default', '123456']);
|
||
|
||
assert.strictEqual(result.exitCode, 0, 'should exit 0 when --default provided');
|
||
assert.strictEqual(result.stdout, '123456', 'should return the --default value, not schema default');
|
||
});
|
||
|
||
test('errors with reason=CONFIG_KEY_NOT_FOUND (exit 1) for an unknown absent key — no regression', () => {
|
||
// An unrecognised key with no schema default still errors as before.
|
||
// Migrated #2974: assert on the structured reason code from --json-errors,
|
||
// not on substring presence in stderr/stdout text.
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'config.json'),
|
||
JSON.stringify({ workflow: { auto_advance: false } })
|
||
);
|
||
|
||
const result = runConfigGet('totally_unknown_key_xyz', ['--json-errors']);
|
||
|
||
assert.strictEqual(result.exitCode, 1, 'should exit 1 for unknown absent key');
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(result.stderr);
|
||
} catch (err) {
|
||
assert.fail(`expected JSON-shaped stderr from --json-errors; got: ${JSON.stringify(result.stderr)}`);
|
||
}
|
||
assert.strictEqual(parsed.ok, false);
|
||
assert.strictEqual(parsed.reason, ERROR_REASON.CONFIG_KEY_NOT_FOUND,
|
||
`expected reason=${ERROR_REASON.CONFIG_KEY_NOT_FOUND}, got=${parsed.reason}`);
|
||
});
|
||
|
||
test('--default flag still works for arbitrary absent keys', () => {
|
||
fs.writeFileSync(
|
||
path.join(planningDir, 'config.json'),
|
||
JSON.stringify({})
|
||
);
|
||
|
||
const result = runConfigGet('some.missing.key', ['--default', '200000']);
|
||
|
||
assert.strictEqual(result.exitCode, 0, 'should exit 0 when --default supplied');
|
||
assert.strictEqual(result.stdout, '200000', 'should return the explicit --default value');
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/feat-3593-cli-negative-config.test.cjs — consolidation epic #1969 (B6 #1975)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:feat-3593-cli-negative-config (consolidation epic #1969 B6 #1975)", () => {
|
||
/**
|
||
* CLI negative matrix for the `config` command family (#3593).
|
||
*
|
||
* Exercises the 12 adversarial input categories enumerated in
|
||
* CONTRIBUTING.md §"QA Matrix Requirements / CLI and command routing"
|
||
* against `config-get` and `config-set`. The harness in
|
||
* `tests/helpers/cli-negative.cjs` shapes spawnSync output into a typed
|
||
* IR so every assertion runs on `result.reason`, `result.status`, and
|
||
* `result.hasStackTrace` — never on stderr/stdout prose.
|
||
*
|
||
* Each test gets its own temp project (no shared state) so concurrent
|
||
* runs can't observe each other's filesystem mutations. Hostile values
|
||
* (shell metacharacters, null bytes, unicode, very long strings) reach
|
||
* the CLI as single argv elements via spawnSync — never composed into
|
||
* a shell string — so the test framework itself can't be the source of
|
||
* a false positive on shell-injection assertions.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const { test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const { runCli } = require('./helpers/cli-negative.cjs');
|
||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||
|
||
/**
|
||
* Universal invariants every adversarial case must satisfy when the
|
||
* CLI is invoked with --json-errors. Bundling these in a helper keeps
|
||
* each test focused on the case-specific reason assertion.
|
||
*/
|
||
function assertSafeFailure(result, msg = '') {
|
||
assert.notEqual(result.status, 0, `${msg} :: expected non-zero exit`);
|
||
assert.equal(result.signal, null, `${msg} :: must exit cleanly, not via signal`);
|
||
assert.equal(result.hasStackTrace, false, `${msg} :: stderr must not leak a V8 stack frame`);
|
||
assert.equal(result.ok, false, `${msg} :: JSON payload ok must be false`);
|
||
assert.equal(typeof result.reason, 'string', `${msg} :: reason must be a string`);
|
||
assert.notEqual(result.reason, '', `${msg} :: reason must not be empty`);
|
||
// The harness's JSON-shape detection runs on the trimmed stderr; if we
|
||
// got here with reason set, the payload was a valid object — that already
|
||
// implies no rogue prose was mixed in. Re-asserting the trimmed form would
|
||
// be redundant.
|
||
}
|
||
|
||
/**
|
||
* Snapshot the file inventory of a directory so a later assertion can
|
||
* prove the failing CLI invocation did NOT create or modify any file.
|
||
*/
|
||
function snapshotInventory(dir) {
|
||
const entries = [];
|
||
function walk(rel) {
|
||
const abs = path.join(dir, rel);
|
||
let stat;
|
||
try { stat = fs.lstatSync(abs); } catch { return; }
|
||
if (stat.isDirectory()) {
|
||
for (const name of fs.readdirSync(abs).sort()) walk(path.join(rel, name));
|
||
} else {
|
||
entries.push(`${rel}\t${stat.size}\t${stat.mtimeMs}`);
|
||
}
|
||
}
|
||
walk('.');
|
||
return entries.join('\n');
|
||
}
|
||
|
||
// ─── 1. Missing required arg ────────────────────────────────────────────────
|
||
|
||
test('config-get with no key fails with a typed reason and no stack trace', (t) => {
|
||
const projectDir = createTempProject('cli-neg-config-1-');
|
||
t.after(() => cleanup(projectDir));
|
||
const before = snapshotInventory(projectDir);
|
||
const result = runCli(['config-get'], { cwd: projectDir });
|
||
assertSafeFailure(result, 'config-get missing key');
|
||
assert.equal(snapshotInventory(projectDir), before, 'failing read must not mutate FS');
|
||
});
|
||
|
||
test('config-set with no key fails with a typed reason', (t) => {
|
||
const projectDir = createTempProject('cli-neg-config-2-');
|
||
t.after(() => cleanup(projectDir));
|
||
const result = runCli(['config-set'], { cwd: projectDir });
|
||
assertSafeFailure(result, 'config-set missing key');
|
||
});
|
||
|
||
test('config-set with key but no value fails with a typed reason', (t) => {
|
||
const projectDir = createTempProject('cli-neg-config-3-');
|
||
t.after(() => cleanup(projectDir));
|
||
const result = runCli(['config-set', 'model_profile'], { cwd: projectDir });
|
||
assertSafeFailure(result, 'config-set missing value');
|
||
});
|
||
|
||
// ─── 2/3. Empty / whitespace arg ────────────────────────────────────────────
|
||
|
||
test('config-get with empty-string key fails safely', (t) => {
|
||
const projectDir = createTempProject('cli-neg-config-4-');
|
||
t.after(() => cleanup(projectDir));
|
||
const before = snapshotInventory(projectDir);
|
||
const result = runCli(['config-get', ''], { cwd: projectDir });
|
||
assertSafeFailure(result, 'config-get empty key');
|
||
assert.equal(snapshotInventory(projectDir), before, 'failing read must not mutate FS');
|
||
});
|
||
|
||
test('config-get with whitespace-only key fails safely', (t) => {
|
||
const projectDir = createTempProject('cli-neg-config-5-');
|
||
t.after(() => cleanup(projectDir));
|
||
const result = runCli(['config-get', ' \t '], { cwd: projectDir });
|
||
assertSafeFailure(result, 'config-get whitespace key');
|
||
});
|
||
|
||
test('config-set with empty key string fails safely', (t) => {
|
||
const projectDir = createTempProject('cli-neg-config-6-');
|
||
t.after(() => cleanup(projectDir));
|
||
const result = runCli(['config-set', '', 'value'], { cwd: projectDir });
|
||
assertSafeFailure(result, 'config-set empty key');
|
||
});
|
||
|
||
// ─── 4. Duplicate flags ─────────────────────────────────────────────────────
|
||
|
||
test('--cwd specified twice does not silently use the wrong one', (t) => {
|
||
// Make two real but distinct dirs so the test can't accidentally pass
|
||
// because one of the paths is invalid.
|
||
const a = createTempProject('cli-neg-config-7a-');
|
||
const b = createTempProject('cli-neg-config-7b-');
|
||
t.after(() => { cleanup(a); cleanup(b); });
|
||
// No --json-errors here on purpose: --cwd is parsed before json mode is
|
||
// applied, so we exercise both code paths by running once each.
|
||
const result = runCli(['--cwd', a, '--cwd', b, 'config-get', 'model_profile'], { cwd: process.cwd() });
|
||
// Either: (a) the CLI rejects duplicate --cwd with a typed reason; OR
|
||
// (b) it commits to one of the values deterministically. The safety
|
||
// bar is "no stack trace, no half-state mutation in EITHER dir".
|
||
assert.equal(result.hasStackTrace, false, 'duplicate --cwd must not crash with a stack trace');
|
||
// Neither tmp dir should have a written config since model_profile is
|
||
// a read, not a write, and it didn't exist beforehand. Prove the read
|
||
// didn't accidentally trigger a write side effect.
|
||
assert.equal(fs.existsSync(path.join(a, '.planning', 'config.json')), false);
|
||
assert.equal(fs.existsSync(path.join(b, '.planning', 'config.json')), false);
|
||
});
|
||
|
||
// ─── 5. Conflicting flags ───────────────────────────────────────────────────
|
||
|
||
test('--json-errors with --no-such-flag does not crash with a stack trace', (t) => {
|
||
const projectDir = createTempProject('cli-neg-config-8-');
|
||
t.after(() => cleanup(projectDir));
|
||
const result = runCli(['--no-such-flag', 'config-get', 'model_profile'], { cwd: projectDir });
|
||
assert.equal(result.hasStackTrace, false, 'unknown global flag must not crash with a stack trace');
|
||
assert.notEqual(result.status, 0, 'unknown global flag must fail');
|
||
});
|
||
|
||
// ─── 6. Malformed assignment / unknown subcommand ──────────────────────────
|
||
|
||
test('config-FAKE subcommand fails with a typed reason', (t) => {
|
||
const projectDir = createTempProject('cli-neg-config-9-');
|
||
t.after(() => cleanup(projectDir));
|
||
const result = runCli(['config-FAKE'], { cwd: projectDir });
|
||
assertSafeFailure(result, 'unknown config-* command');
|
||
});
|
||
|
||
// ─── 7. Unknown subcommands at each command depth ───────────────────────────
|
||
|
||
test('config family — bare top-level "config" without a subcommand fails safely', (t) => {
|
||
const projectDir = createTempProject('cli-neg-config-10-');
|
||
t.after(() => cleanup(projectDir));
|
||
const result = runCli(['config'], { cwd: projectDir });
|
||
// Either "missing subcommand" usage or genuine no-op behavior — what we
|
||
// pin is "no stack trace, no FS mutation".
|
||
assert.equal(result.hasStackTrace, false);
|
||
});
|
||
|
||
// ─── 8. Values that look like flags ─────────────────────────────────────────
|
||
|
||
test('config-set value that starts with -- is treated as a value, not a flag', (t) => {
|
||
const projectDir = createTempProject('cli-neg-config-11-');
|
||
t.after(() => cleanup(projectDir));
|
||
// First create a config.json so the set has a target file.
|
||
runCli(['config-ensure-section'], { cwd: projectDir });
|
||
const result = runCli(['config-set', 'project_code', '--weird'], { cwd: projectDir });
|
||
// Acceptable outcomes:
|
||
// (a) CLI accepts --weird as the value (and persists it),
|
||
// (b) CLI rejects it as a usage error.
|
||
// Either way: no stack trace, no half-written corrupt config.
|
||
assert.equal(result.hasStackTrace, false, 'value-looking-like-a-flag must not crash');
|
||
const configPath = path.join(projectDir, '.planning', 'config.json');
|
||
if (fs.existsSync(configPath)) {
|
||
// If a config exists, it must still be valid JSON — no half-write corruption.
|
||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||
assert.doesNotThrow(() => JSON.parse(raw), 'config.json must remain parseable after a failed set');
|
||
}
|
||
});
|
||
|
||
// ─── 9. Invalid JSON / corrupt config file ──────────────────────────────────
|
||
|
||
test('config-get against a corrupt config.json fails with a parse-failed reason', (t) => {
|
||
const projectDir = createTempProject('cli-neg-config-12-');
|
||
t.after(() => cleanup(projectDir));
|
||
const configPath = path.join(projectDir, '.planning', 'config.json');
|
||
fs.writeFileSync(configPath, '{ this is not json'); // deliberate corruption
|
||
const originalCorrupt = fs.readFileSync(configPath, 'utf-8');
|
||
const result = runCli(['config-get', 'model_profile'], { cwd: projectDir });
|
||
assertSafeFailure(result, 'corrupt config.json');
|
||
// The corrupt file must remain untouched — the CLI must not "helpfully"
|
||
// overwrite an unparseable config in the failure path.
|
||
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalCorrupt, 'corrupt file must be preserved as-is');
|
||
// Specific reason: CONFIG_PARSE_FAILED (or equivalent) — pin this so a
|
||
// regression where parse failure leaks as "unknown" is caught.
|
||
assert.match(
|
||
result.reason,
|
||
/^(config_parse_failed|config_no_file|config_invalid_key|usage)$/,
|
||
`parse-failure reason must be from the typed ERROR_REASON enum (got: ${result.reason})`,
|
||
);
|
||
});
|
||
|
||
// ─── 10. Very long arg ──────────────────────────────────────────────────────
|
||
|
||
test('config-get with a very long key (50KB) fails safely without hanging', (t) => {
|
||
const projectDir = createTempProject('cli-neg-config-13-');
|
||
t.after(() => cleanup(projectDir));
|
||
const longKey = 'x'.repeat(50000);
|
||
const result = runCli(['config-get', longKey], { cwd: projectDir, timeoutMs: 8000 });
|
||
assert.equal(result.signal, null, 'long input must not trigger the harness timeout');
|
||
assert.equal(result.hasStackTrace, false, 'long input must not crash');
|
||
assert.notEqual(result.status, 0, 'unknown 50KB key must fail');
|
||
});
|
||
|
||
// ─── 11. Unicode / non-ASCII ────────────────────────────────────────────────
|
||
|
||
test('config-get with a Unicode key fails safely', (t) => {
|
||
const projectDir = createTempProject('cli-neg-config-14-');
|
||
t.after(() => cleanup(projectDir));
|
||
const result = runCli(['config-get', 'workflow.🔥_mode'], { cwd: projectDir });
|
||
assertSafeFailure(result, 'unicode key');
|
||
});
|
||
|
||
test('config-set with an emoji value persists or rejects without corrupting JSON', (t) => {
|
||
const projectDir = createTempProject('cli-neg-config-15-');
|
||
t.after(() => cleanup(projectDir));
|
||
runCli(['config-ensure-section'], { cwd: projectDir });
|
||
const result = runCli(['config-set', 'project_code', '🔥👾'], { cwd: projectDir });
|
||
assert.equal(result.hasStackTrace, false);
|
||
// If it accepted, the JSON must round-trip cleanly.
|
||
const configPath = path.join(projectDir, '.planning', 'config.json');
|
||
if (result.status === 0 && fs.existsSync(configPath)) {
|
||
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||
assert.equal(typeof parsed, 'object', 'config.json must be a valid object');
|
||
if (parsed.project_code != null) {
|
||
assert.equal(typeof parsed.project_code, 'string', 'project_code must remain a string');
|
||
}
|
||
}
|
||
});
|
||
|
||
// ─── 12. Shell metacharacters (the security-critical case) ──────────────────
|
||
|
||
const SHELL_PAYLOADS = [
|
||
// Each one would, if shell-interpreted, create a sentinel file
|
||
// adjacent to the project tree. Argv-based invocation must treat them
|
||
// as opaque text.
|
||
'$(touch ${PROJECT}/INJ-dollar-paren)',
|
||
'`touch ${PROJECT}/INJ-backtick`',
|
||
'; touch ${PROJECT}/INJ-semicolon;',
|
||
'&& touch ${PROJECT}/INJ-and',
|
||
'|| touch ${PROJECT}/INJ-or',
|
||
'| tee ${PROJECT}/INJ-pipe',
|
||
'> ${PROJECT}/INJ-redirect',
|
||
// Quote-balanced payloads — these have historically broken naive
|
||
// shell-string composition even when the rest of the code uses argv.
|
||
'"; touch ${PROJECT}/INJ-quote;"',
|
||
'\'; touch ${PROJECT}/INJ-quote;\'',
|
||
];
|
||
|
||
for (const payload of SHELL_PAYLOADS) {
|
||
test(`config-get with shell-metachar key (${payload.slice(0, 25)}…) does NOT execute the payload`, (t) => {
|
||
const projectDir = createTempProject('cli-neg-config-shell-');
|
||
t.after(() => cleanup(projectDir));
|
||
const resolvedPayload = payload.replace(/\$\{PROJECT\}/g, projectDir);
|
||
const result = runCli(['config-get', resolvedPayload], { cwd: projectDir });
|
||
// No shell interpretation: none of the INJ-* sentinel files must
|
||
// exist after the run. Walk the project dir and assert.
|
||
const entries = fs.readdirSync(projectDir);
|
||
const sentinels = entries.filter((n) => n.startsWith('INJ-'));
|
||
assert.deepEqual(sentinels, [], `shell payload must NOT create sentinel files (found: ${sentinels.join(', ')})`);
|
||
// The CLI may exit 0 (legitimate — the metacharacter-laden key
|
||
// simply doesn't exist in config) or non-zero (typed reason). Both
|
||
// are acceptable as long as no payload was executed.
|
||
assert.equal(result.hasStackTrace, false);
|
||
});
|
||
}
|
||
|
||
// ─── Cross-cutting: --cwd points at a non-existent path ────────────────────
|
||
|
||
test('--cwd pointing at a non-existent path fails with a typed usage reason', (_t) => {
|
||
const nonExistent = path.join(require('os').tmpdir(), 'cli-neg-no-such-dir-' + Date.now() + '-' + Math.random());
|
||
assert.equal(fs.existsSync(nonExistent), false, 'pre-check: path must not exist');
|
||
const result = runCli(['--cwd', nonExistent, 'config-get', 'model_profile'], { cwd: process.cwd() });
|
||
assert.notEqual(result.status, 0);
|
||
assert.equal(result.hasStackTrace, false);
|
||
// gsd-tools validates --cwd up-front and emits ERROR_REASON.USAGE.
|
||
assert.equal(result.reason, 'usage', `expected reason=usage for invalid --cwd, got: ${result.reason}`);
|
||
});
|
||
|
||
test('--cwd with an empty value fails with a typed usage reason', () => {
|
||
const result = runCli(['--cwd', '', 'config-get', 'model_profile'], { cwd: process.cwd() });
|
||
assert.notEqual(result.status, 0);
|
||
assert.equal(result.hasStackTrace, false);
|
||
assert.equal(result.reason, 'usage');
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/feat-3593-cli-negative-harness.test.cjs — consolidation epic #1969 (B6 #1975)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:feat-3593-cli-negative-harness (consolidation epic #1969 B6 #1975)", () => {
|
||
/**
|
||
* Meta-test for the CLI negative-matrix harness (#3593).
|
||
*
|
||
* The harness in `tests/helpers/cli-negative.cjs` shapes spawnSync
|
||
* results into a typed IR that adversarial-input tests consume. This
|
||
* file pins the IR contract by exercising the harness against
|
||
* deliberate scenarios — not as a placeholder for the real matrix tests
|
||
* (those live in sibling feat-3593-* files) but to surface harness
|
||
* regressions before they cascade through every matrix test.
|
||
*
|
||
* Tests deliberately avoid prose-matching: they assert on numeric exit
|
||
* codes, boolean flags, and reason codes pulled from the parsed JSON
|
||
* payload.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const { test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const { runCli, parseSpawnResult } = require('./helpers/cli-negative.cjs');
|
||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||
|
||
test('runCli rejects non-array argv with TypeError', () => {
|
||
assert.throws(
|
||
() => runCli('config-get', { cwd: '/tmp' }),
|
||
(err) => err instanceof TypeError && /argv/.test(err.message),
|
||
);
|
||
});
|
||
|
||
test('runCli rejects missing cwd with TypeError', () => {
|
||
assert.throws(
|
||
() => runCli(['config-get'], {}),
|
||
(err) => err instanceof TypeError && /cwd/.test(err.message),
|
||
);
|
||
});
|
||
|
||
test('runCli surfaces typed reason from a known failure path', (t) => {
|
||
const projectDir = createTempProject('cli-neg-harness-');
|
||
t.after(() => cleanup(projectDir));
|
||
// Unknown command — gsd-tools emits ERROR_REASON.SDK_UNKNOWN_COMMAND or
|
||
// USAGE depending on dispatch depth. Either is a real reason string;
|
||
// the contract we pin here is just "the IR carries a reason from the
|
||
// ERROR_REASON enum, never null".
|
||
const result = runCli(['this-command-does-not-exist'], { cwd: projectDir });
|
||
assert.notEqual(result.status, 0, 'unknown command must exit non-zero');
|
||
assert.equal(result.ok, false, 'JSON payload must report ok=false');
|
||
assert.equal(typeof result.reason, 'string', 'reason must be a string from ERROR_REASON');
|
||
assert.notEqual(result.reason, null);
|
||
assert.notEqual(result.reason, '');
|
||
assert.equal(result.hasStackTrace, false, 'a typed failure must NOT print a V8 stack trace');
|
||
});
|
||
|
||
test('parseSpawnResult detects stack-trace leakage in stderr', () => {
|
||
const fakeSpawn = {
|
||
status: 1,
|
||
signal: null,
|
||
stdout: '',
|
||
stderr: 'Error: boom\n at Object.<anonymous> (/some/file.js:10:5)\n at Module._compile\n',
|
||
error: null,
|
||
};
|
||
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: false });
|
||
assert.equal(ir.hasStackTrace, true, 'stack frames in stderr must be flagged');
|
||
assert.equal(ir.reason, null, 'non-JSON stderr leaves reason null');
|
||
});
|
||
|
||
test('parseSpawnResult does NOT match the literal word "at" in prose', () => {
|
||
// Guard against a regex regression that would catch sentences like
|
||
// "command failed at startup" as stack frames.
|
||
const fakeSpawn = {
|
||
status: 1,
|
||
signal: null,
|
||
stdout: '',
|
||
stderr: 'Error: command failed at startup\nbecause no project was found.\n',
|
||
error: null,
|
||
};
|
||
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: false });
|
||
assert.equal(ir.hasStackTrace, false, 'prose containing the word "at" is not a stack frame');
|
||
});
|
||
|
||
test('parseSpawnResult extracts ok/reason/message from a json-errors payload', () => {
|
||
const payload = { ok: false, reason: 'config_invalid_key', message: 'no such key: foo' };
|
||
const fakeSpawn = {
|
||
status: 1,
|
||
signal: null,
|
||
stdout: '',
|
||
stderr: JSON.stringify(payload) + '\n',
|
||
error: null,
|
||
};
|
||
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: true });
|
||
assert.equal(ir.ok, false);
|
||
assert.equal(ir.reason, 'config_invalid_key');
|
||
assert.equal(ir.message, 'no such key: foo');
|
||
assert.equal(ir.hasStackTrace, false);
|
||
});
|
||
|
||
test('parseSpawnResult ignores malformed JSON in stderr without throwing', () => {
|
||
const fakeSpawn = {
|
||
status: 1,
|
||
signal: null,
|
||
stdout: '',
|
||
stderr: '{ ok: false, reason }', // missing quotes — invalid JSON
|
||
error: null,
|
||
};
|
||
const ir = parseSpawnResult(fakeSpawn, { jsonErrorsRequested: true });
|
||
assert.equal(ir.ok, null, 'malformed JSON must NOT promote partial data into ok');
|
||
assert.equal(ir.reason, null);
|
||
assert.equal(ir.message, null);
|
||
});
|
||
|
||
test('parseSpawnResult ignores JSON arrays and primitives, only accepts objects', () => {
|
||
const cases = [
|
||
'["ok", false]', // array
|
||
'"just a string"', // primitive
|
||
'null', // null literal
|
||
'42', // number
|
||
];
|
||
for (const stderr of cases) {
|
||
const ir = parseSpawnResult(
|
||
{ status: 1, signal: null, stdout: '', stderr, error: null },
|
||
{ jsonErrorsRequested: true },
|
||
);
|
||
assert.equal(ir.ok, null, `non-object JSON (${stderr}) must not set ok`);
|
||
assert.equal(ir.reason, null);
|
||
}
|
||
});
|
||
|
||
test('runCli treats jsonErrors=false as an explicit human-formatter path', (t) => {
|
||
const projectDir = createTempProject('cli-neg-harness-text-');
|
||
t.after(() => cleanup(projectDir));
|
||
const result = runCli(['this-command-does-not-exist'], { cwd: projectDir, jsonErrors: false });
|
||
assert.notEqual(result.status, 0);
|
||
assert.equal(result.jsonErrorsRequested, false);
|
||
// Reason fields stay null in human-mode because stderr is prose, not JSON.
|
||
assert.equal(result.ok, null);
|
||
assert.equal(result.reason, null);
|
||
// But the prose still must not include a V8 stack trace.
|
||
assert.equal(result.hasStackTrace, false);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ─── #2702: workstream-scoped config-get inherits from root config ──────────
|
||
//
|
||
// When GSD_WORKSTREAM is set, planningDir(cwd) points at .planning/workstreams/<ws>/.
|
||
// cmdConfigGet used to read ONLY that file, so a key configured at the project root
|
||
// (.planning/config.json) but absent from the workstream's own config was reported
|
||
// "Key not found" — silently inverting boolean workflow guards. The fix adds a
|
||
// root-config inheritance rung: workstream overrides root, but root fills gaps.
|
||
|
||
describe('#2702: workstream config-get inherits from root config', () => {
|
||
let tmpDir;
|
||
let rootPlanningDir;
|
||
let wsPlanningDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-config-ws-2702-'));
|
||
rootPlanningDir = path.join(tmpDir, '.planning');
|
||
wsPlanningDir = path.join(rootPlanningDir, 'workstreams', 'alpha');
|
||
fs.mkdirSync(wsPlanningDir, { recursive: true });
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
// Run cmdConfigGet with GSD_WORKSTREAM scoped to 'alpha' (so planningDir → ws dir).
|
||
function runScoped(...args) {
|
||
const { keyPath, raw, defaultValue } = parseConfigGetArgs(args);
|
||
const saved = process.env.GSD_WORKSTREAM;
|
||
process.env.GSD_WORKSTREAM = 'alpha';
|
||
let out;
|
||
try {
|
||
out = captureFdWrite(1, () => {
|
||
config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue);
|
||
});
|
||
} finally {
|
||
if (saved === undefined) delete process.env.GSD_WORKSTREAM;
|
||
else process.env.GSD_WORKSTREAM = saved;
|
||
}
|
||
return out.trim();
|
||
}
|
||
|
||
function runScopedExpectError(...args) {
|
||
const { keyPath, raw, defaultValue } = parseConfigGetArgs(args);
|
||
const saved = process.env.GSD_WORKSTREAM;
|
||
process.env.GSD_WORKSTREAM = 'alpha';
|
||
const origWriteSync = fs.writeSync;
|
||
io.setJsonErrorMode(true);
|
||
let stderr = '';
|
||
fs.writeSync = (fd, ...rest) => {
|
||
if (fd !== 2) return origWriteSync.call(fs, fd, ...rest);
|
||
stderr += String(rest[0]);
|
||
return Buffer.byteLength(String(rest[0]));
|
||
};
|
||
// ADR-3889: error() throws ExitError directly; catch it here rather
|
||
// than mocking process.exit.
|
||
let exitCode;
|
||
try {
|
||
config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue);
|
||
assert.fail('expected cmdConfigGet to throw ExitError');
|
||
} catch (e) {
|
||
if (!(e instanceof ExitError)) throw e;
|
||
exitCode = e.code;
|
||
} finally {
|
||
fs.writeSync = origWriteSync;
|
||
io.setJsonErrorMode(false);
|
||
if (saved === undefined) delete process.env.GSD_WORKSTREAM;
|
||
else process.env.GSD_WORKSTREAM = saved;
|
||
}
|
||
const parts = stderr.split('\n').filter(Boolean);
|
||
let payload = {};
|
||
try { payload = JSON.parse(parts[parts.length - 1]); } catch { /* human mode */ }
|
||
return { status: exitCode, reason: payload.reason };
|
||
}
|
||
|
||
function writeRoot(obj) {
|
||
fs.writeFileSync(path.join(rootPlanningDir, 'config.json'), JSON.stringify(obj));
|
||
}
|
||
function writeWs(obj) {
|
||
fs.writeFileSync(path.join(wsPlanningDir, 'config.json'), JSON.stringify(obj));
|
||
}
|
||
|
||
test('inherits a fail-closed key (workflow.use_worktrees) from root when workstream omits it', () => {
|
||
writeRoot({ workflow: { use_worktrees: true } });
|
||
// workstream config exists but does NOT set the key
|
||
writeWs({ workflow: {} });
|
||
assert.equal(runScoped('config-get', 'workflow.use_worktrees', '--raw'), 'true');
|
||
});
|
||
|
||
test('inherits a fail-open key (workflow.plan_review_convergence) from root', () => {
|
||
writeRoot({ workflow: { plan_review_convergence: true } });
|
||
writeWs({ workflow: {} });
|
||
assert.equal(runScoped('config-get', 'workflow.plan_review_convergence', '--raw'), 'true');
|
||
});
|
||
|
||
test('inherits a core key (workflow.verifier) from root', () => {
|
||
writeRoot({ workflow: { verifier: true } });
|
||
writeWs({ workflow: {} });
|
||
assert.equal(runScoped('config-get', 'workflow.verifier', '--raw'), 'true');
|
||
});
|
||
|
||
test('workstream config overrides root value for a key it sets', () => {
|
||
writeRoot({ workflow: { use_worktrees: true } });
|
||
writeWs({ workflow: { use_worktrees: false } });
|
||
assert.equal(runScoped('config-get', 'workflow.use_worktrees', '--raw'), 'false');
|
||
});
|
||
|
||
test('still errors on a key absent from workstream, root, and schema', () => {
|
||
writeWs({ workflow: {} });
|
||
writeRoot({ workflow: {} });
|
||
const { status, reason } = runScopedExpectError('config-get', 'workflow.totally_absent', '--raw');
|
||
assert.notEqual(status, 0, 'a key absent everywhere must error');
|
||
assert.equal(reason, io.ERROR_REASON.CONFIG_KEY_NOT_FOUND);
|
||
});
|
||
|
||
test('--default is ignored when the key is inherited from root', () => {
|
||
writeRoot({ workflow: { use_worktrees: true } });
|
||
writeWs({ workflow: {} });
|
||
assert.equal(
|
||
runScoped('config-get', 'workflow.use_worktrees', '--default', 'false', '--raw'),
|
||
'true',
|
||
);
|
||
});
|
||
|
||
test('inherits a nested fail-closed key (workflow.context_coverage_gate) from root', () => {
|
||
writeRoot({ workflow: { context_coverage_gate: false } });
|
||
writeWs({ workflow: {} });
|
||
assert.equal(runScoped('config-get', 'workflow.context_coverage_gate', '--raw'), 'false');
|
||
});
|
||
|
||
test('scoped and unscoped reads return the same string form when workstream does not override', () => {
|
||
writeRoot({ workflow: { use_worktrees: true } });
|
||
writeWs({ workflow: {} });
|
||
const scoped = runScoped('config-get', 'workflow.use_worktrees', '--raw');
|
||
// Unscoped read: no GSD_WORKSTREAM — reads root directly.
|
||
const unscoped = captureFdWrite(1, () => {
|
||
config.cmdConfigGet(tmpDir, 'workflow.use_worktrees', true, undefined);
|
||
}).trim();
|
||
assert.equal(scoped, unscoped, 'scoped (inherited) and unscoped reads must agree');
|
||
assert.equal(scoped, 'true');
|
||
});
|
||
|
||
test('unscoped config-get still reads root value (no regression when not scoped)', () => {
|
||
writeRoot({ workflow: { use_worktrees: true } });
|
||
const out = captureFdWrite(1, () => {
|
||
config.cmdConfigGet(tmpDir, 'workflow.use_worktrees', true, undefined);
|
||
}).trim();
|
||
assert.equal(out, 'true');
|
||
});
|
||
|
||
test('inherits from root even when the workstream has no config.json at all', () => {
|
||
writeRoot({ workflow: { use_worktrees: true } });
|
||
// No writeWs — workstreams/alpha/config.json does not exist.
|
||
assert.equal(runScoped('config-get', 'workflow.use_worktrees', '--raw'), 'true');
|
||
});
|
||
|
||
test('GSD_PROJECT alone (no workstream) does not trigger root inheritance — matches loadConfigResolved', () => {
|
||
// GSD_PROJECT scopes planningDir to .planning/<project>/ but loadConfigResolved
|
||
// gates root-reading on `if (ws)`, so a project-only read must NOT inherit root.
|
||
// The fix gates on GSD_WORKSTREAM presence (not path inequality) to match.
|
||
// A --default is supplied so a non-inheriting read returns a clean sentinel
|
||
// ('not-inherited') rather than hitting the error/exit path.
|
||
const projectPlanningDir = path.join(rootPlanningDir, 'myproj');
|
||
fs.mkdirSync(projectPlanningDir, { recursive: true });
|
||
fs.writeFileSync(path.join(projectPlanningDir, 'config.json'), JSON.stringify({ workflow: {} }));
|
||
writeRoot({ workflow: { use_worktrees: true } });
|
||
const saved = process.env.GSD_PROJECT;
|
||
process.env.GSD_PROJECT = 'myproj';
|
||
try {
|
||
const out = captureFdWrite(1, () => {
|
||
config.cmdConfigGet(tmpDir, 'workflow.use_worktrees', true, 'not-inherited');
|
||
}).trim();
|
||
// No workstream → no root inheritance → returns the --default sentinel, NOT root 'true'.
|
||
assert.equal(out, 'not-inherited', 'GSD_PROJECT-only must not inherit root (matches loadConfigResolved)');
|
||
} finally {
|
||
if (saved === undefined) delete process.env.GSD_PROJECT;
|
||
else process.env.GSD_PROJECT = saved;
|
||
}
|
||
});
|
||
});
|