fix(#4444): honor config-set --dry-run instead of silently ignoring it (#4504)

* test(#4444): failing-first regression coverage for config-set --dry-run

config-set --dry-run is currently parsed nowhere -- routeConfigSet
(gsd-core/bin/gsd-tools.cjs) never checks args for it, and cmdConfigSet
has no dry-run parameter, so the flag is silently swallowed and the
command always writes for real. Reproduces the issue's own repro
(sequential --dry-run calls where the second's previousValue proves
the first persisted), plus coverage for validation-still-runs,
secret-masking, and the sibling unset (config-set <key> null) branch,
which has the identical defect. This commit adds the regression
coverage only; the fix lands in the next commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4444): honor config-set --dry-run instead of silently ignoring it

routeConfigSet (gsd-core/bin/gsd-tools.cjs) never read args for
--dry-run, and cmdConfigSet had no dry-run parameter at all -- so the
flag was silently accepted (as any unrecognized trailing argument is)
and the command always wrote for real. A second "dry run" then showed
previousValue reflecting the first one, proving it had persisted.

Threads a dryRun option through cmdConfigSet, gating BOTH mutating
branches: the null/unset path (unsetConfigValue) and the real-set path
(setConfigValue) -- the unset branch had the identical defect,
undiscovered until auditing every mutation site while designing this
fix. Each gains a previewConfigValue/previewUnsetConfigValue
counterpart that reuses the real function's exact traversal/creation
logic (_setNestedValue/_unsetNestedValue) on a throwaway in-memory
config copy that is never written -- so the preview can never diverge
from what the real write would compute. All validation (unknown key,
enum/number/boolean checks, secret masking) runs identically whether
or not --dry-run is passed; only the final write is skipped, replaced
with a `{ dry_run: true, would_update / would_unset: true, ... }`
preview payload matching the precedent established by `milestone
complete --dry-run` (#2118) and `todo complete --dry-run` (#4096/#4325).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* refactor(#4444): extract loadConfigJson to stop a 5th copy-paste of the same load/parse block

Code review flagged that setConfigValue, unsetConfigValue,
setConfigValues, and the two new preview functions each repeated the
identical "load .planning/config.json, JSON.parse, catch ->
CONFIG_PARSE_FAILED" block -- exactly CLAUDE.md's own
"Generative Fix Divergence" known-defect pattern. Extracted a single
loadConfigJson(cwd) helper; behavior is unchanged (verified: build,
tsc, and the dry-run/real-write smoke test all pass byte-identical to
before).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4444): changeset for the config-set --dry-run fix

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4444): backfill changeset PR number

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4444): raise per-chunk CI test timeout to 800s for Windows headroom

install-minimal-hooks.test.cjs (weight=24.45, the heaviest file in the
suite) sits alone in its own chunk yet still occasionally brushed the
600000ms per-chunk ceiling on Windows -- observed on PR #4504's first
CI run for this change (passed clean on rerun, consistent with the
"legitimately too slow for the budget" cause the chunk-timeout
diagnostic already names, not a leaked handle).

Raised RUN_TESTS_CHUNK_TIMEOUT_MS's default from 600000ms to 800000ms:
~33% more margin, still comfortably below the 900000ms regen:derived
fixture timeout that fragment-single-edit-propagation.install.test.cjs
deliberately keeps ABOVE the chunk ceiling, and far under the 45-minute
job cap -- Windows shards currently finish in ~19-20 minutes total, so
there is ample headroom. Updated every dependent mirror/assertion in
lockstep (tests/helpers/emitted-runtime.cjs's duplicated
CHUNK_TIMEOUT_CEILING_MS constant, its lock test in
tests/emitted-attribution.test.cjs, the Windows-skip prose in
fragment-single-edit-propagation.install.test.cjs, and
docs/TESTING-SUITES.md's reference table) so nothing describes a stale
value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Revert "fix(#4444): raise per-chunk CI test timeout to 800s for Windows headroom"

This reverts commit 394aadaf6f5af6fd700bf0f444c9fbd686285a4f.

* test(#4444): consolidate redundant installer spawns in install-minimal-hooks.test.cjs

This file's real, unrelated pre-existing cost (dated 2026-09-06, PR #4428)
is what tipped a Windows CI shard over the per-chunk timeout backstop on
PR #4504 (issue #4444's own diff never touches this file or the
installer). Rather than raise the timeout, cut the file's actual spawn
count: several describe blocks independently re-installed the IDENTICAL
runtime/scope/flag configuration just to assert different things about
the same install output. Merged each such group onto a single shared
install, with every original assertion preserved:

- --help x3 -> x1
- the three per-runtime/scope --minimal E2E loops (global, local, and
  on-disk-matches-manifest) merged into one loop over
  SKILL_RUNTIMES x [global, local]: 44 spawns -> 22
- the --minimal manifest-mode/backcompat triple-install -> one shared,
  memoized install via sharedMinimalManifestInstall()
- .sh hooks existence checks (5 tests) -> 1, executable-bit check (its
  own Windows-conditional skip) left separate
- Codex #4087 hook-helper tests (3) -> 1
- Windsurf #4087 hook-helper tests (2) -> 1
- pi shared-hooks-bundle tests (3 per scope) -> 1 per scope

Net: ~65 real installer spawns in this file down to ~29, no assertion
dropped or weakened.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-09-07 21:08:06 -04:00
committed by GitHub
parent 93e141a006
commit 423f38e655
5 changed files with 374 additions and 221 deletions

View File

@@ -627,19 +627,36 @@ function _unsetNestedValue(
* Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in
* the happy path. But note that `error()` will still `exit(1)` out of the process.
*/
/**
* Loads `.planning/config.json` as a plain object, or `{}` if the file does
* not exist. A parse failure calls `error()` (process-exiting) rather than
* throwing, matching every caller's existing behavior.
*
* Single source for this load+parse step — `setConfigValue`,
* `unsetConfigValue`, `setConfigValues`, `previewConfigValue`, and
* `previewUnsetConfigValue` all delegate here instead of each repeating the
* same try/catch (CLAUDE.md's "Generative Fix Divergence" known-defect
* pattern: independently-guessed copies of the same logic can silently
* drift apart).
*/
function loadConfigJson(cwd: string): Record<string, unknown> {
const configPath = path.join(planningDir(cwd), 'config.json');
let config: Record<string, unknown> = {};
try {
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
}
} catch (err) {
error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED);
}
return config;
}
function unsetConfigValue(cwd: string, keyPath: string): UnsetConfigValueResult {
const configPath = path.join(planningDir(cwd), 'config.json');
return withPlanningLock(cwd, () => {
// Load existing config or start with empty object
let config: Record<string, unknown> = {};
try {
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
}
} catch (err) {
error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED);
}
const config = loadConfigJson(cwd);
const { previousValue, existed } = _unsetNestedValue(config, keyPath);
@@ -664,15 +681,7 @@ function setConfigValue(cwd: string, keyPath: string, parsedValue: unknown): Set
const configPath = path.join(planningDir(cwd), 'config.json');
return withPlanningLock(cwd, () => {
// Load existing config or start with empty object
let config: Record<string, unknown> = {};
try {
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
}
} catch (err) {
error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED);
}
const config = loadConfigJson(cwd);
const previousValue = _setNestedValue(config, keyPath, parsedValue);
@@ -686,6 +695,31 @@ function setConfigValue(cwd: string, keyPath: string, parsedValue: unknown): Set
}) as SetConfigValueResult;
}
/**
* #4444: read-only preview counterpart to `setConfigValue` — loads config
* exactly like the real setter and reuses `_setNestedValue` (the SAME
* traversal/creation logic, including its prototype-pollution guards) on a
* throwaway in-memory copy that is NEVER written back to disk. This is what
* makes the dry-run preview provably identical to what the real write would
* compute, rather than a second, hand-maintained traversal that could drift
* from the real one.
*/
function previewConfigValue(cwd: string, keyPath: string, parsedValue: unknown): { key: string; value: unknown; previousValue: unknown } {
const config = loadConfigJson(cwd);
const previousValue = _setNestedValue(config, keyPath, parsedValue);
return { key: keyPath, value: parsedValue, previousValue };
}
/**
* #4444: read-only preview counterpart to `unsetConfigValue` — same pattern
* as `previewConfigValue`, reusing `_unsetNestedValue` on a throwaway copy.
*/
function previewUnsetConfigValue(cwd: string, keyPath: string): { key: string; value: null; previousValue: unknown; existed: boolean } {
const config = loadConfigJson(cwd);
const { previousValue, existed } = _unsetNestedValue(config, keyPath);
return { key: keyPath, value: null, previousValue, existed };
}
/**
* Batched sibling of setConfigValue: apply multiple key-path writes in a
* single load → set-all → write cycle inside ONE withPlanningLock call.
@@ -707,15 +741,7 @@ function setConfigValues(
const configPath = path.join(planningDir(cwd), 'config.json');
return withPlanningLock(cwd, () => {
// Load existing config or start with empty object
let config: Record<string, unknown> = {};
try {
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
}
} catch (err) {
error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED);
}
const config = loadConfigJson(cwd);
const results: SetConfigValueResult[] = [];
for (const entry of entries) {
@@ -757,7 +783,12 @@ function assertEnumValue(parsedValue: unknown, rawVal: string, allowed: readonly
* Note that this exits the process (via `output()`) even in the happy path; use `setConfigValue()`
* directly if you need to avoid this.
*/
function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string | undefined, raw: boolean): void {
interface ConfigSetOptions {
dryRun?: boolean;
}
function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string | undefined, raw: boolean, options: ConfigSetOptions = {}): void {
const dryRun = options.dryRun === true;
if (!keyPath) {
error('Usage: config-set <key.path> <value>', ERROR_REASON.USAGE);
}
@@ -805,6 +836,18 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string |
// present, truthy-adjacent value that consumers must special-case — worst for
// secret keys where a leftover value can be passed as a real credential.
if (parsedValue === null) {
if (dryRun) {
const preview = previewUnsetConfigValue(cwd, kp);
if (isSecretKey(kp)) {
const maskedPrev = preview.previousValue === undefined
? undefined
: maskSecret(preview.previousValue as Parameters<typeof maskSecret>[0]);
output({ dry_run: true, would_unset: true, key: kp, value: null, previousValue: maskedPrev, masked: true }, raw, `${kp} unset (dry run)`);
return;
}
output({ dry_run: true, would_unset: true, key: kp, value: null, previousValue: preview.previousValue }, raw, `${kp} unset (dry run)`);
return;
}
const unsetResult = unsetConfigValue(cwd, kp);
if (isSecretKey(kp)) {
const maskedPrev = unsetResult.previousValue === undefined
@@ -1014,6 +1057,20 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string |
}
}
if (dryRun) {
const preview = previewConfigValue(cwd, kp, parsedValue);
if (isSecretKey(kp)) {
const masked = maskSecret(parsedValue as Parameters<typeof maskSecret>[0]);
const maskedPrev = preview.previousValue === undefined
? undefined
: maskSecret(preview.previousValue as Parameters<typeof maskSecret>[0]);
output({ dry_run: true, would_update: true, key: kp, value: masked, previousValue: maskedPrev, masked: true }, raw, `${kp}=${masked} (dry run)`);
return;
}
output({ dry_run: true, would_update: true, key: kp, value: parsedValue, previousValue: preview.previousValue }, raw, `${kp}=${String(parsedValue)} (dry run)`);
return;
}
const setConfigValueResult = setConfigValue(cwd, kp, parsedValue);
// Mask secrets in both JSON and text output. The plaintext is written