* 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:
113
src/config.cts
113
src/config.cts
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user