* test(#1880): prove corrupt config is indistinguishable from absent Failing-first. Encodes the issue's runtime repro: a trailing comma in .planning/config.json currently yields source:builtin-defaults with degraded:false - byte-identical to the file not existing - and the user's entire configuration is silently discarded. Asserts on the typed surface (CONFIG_REASON, _warnedUnusableConfig) rather than diagnostic prose, per the ADR-1411 amendment's test-methodology clause and CONTRIBUTING.md's raw-text-matching rule. IO failure is injected by monkeypatching fs.readFileSync and restoring in t.after(), never chmod 0o000 (root bypasses mode bits). Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#1880): distinguish a corrupt config from an absent one loadConfigResolved wrapped the read, the JSON.parse and the entire config build in one try with one catch, so ENOENT, EACCES and SyntaxError all fell through to the same defaults and the branches returned degraded:false - actively asserting health over discarded configuration. A single trailing comma in .planning/config.json silently replaced the user's whole config, reporting source:builtin-defaults degraded:false, byte-identical to having no config file at all. ConfigResolution now carries a machine-readable reason. Genuine absence keeps degraded:false / not_configured; a file that exists but cannot be used sets degraded:true with config_unparseable or config_unreadable. The same split applies to the root config and to ~/.gsd/defaults.json. Control flow is deliberately unchanged. preflight_check reports cyclomatic 141 / cognitive 196 and 93 dependents on this function, with the guidance that small edits beat one big one, so faults are CAPTURED at the existing read sites and stamped onto the returns rather than the try/catch being restructured. Also carries the ADR-1411 amendment's wiring clause: loadConfig returns .config alone to ~51 call sites and would never see the new field, so an unusable file emits a deduplicated stderr diagnostic keyed on resolved path plus errno. Without it the reason would be an unreachable field and the user whose config was discarded would still get no signal - the actual defect. Registers the config-loader seam in lint-resolution-provenance, which until now guarded only agent-skills. Caller audit: ConfigResolution.degraded has exactly one consumer outside this module, cmdAgentSkills (src/init.cts:2259), which destructures {config, source, degraded} - adding a field does not break it. Its --json IR now reports degraded:true for a corrupt config, which is the intended fix and the one observable behavior change. Closes #1880 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#1880): degrade when any config on the path is unusable, not just the last Two defects found by isolated adversarial review of the first cut. BLOCKER: the success-path return did not consult configFault. A corrupt ROOT config whose workstream override happened to parse returned degraded:false / reason:resolved - the root's settings silently dropped, which is the exact failure this issue closes, reappearing for any project using workstreams. The stderr diagnostic fired, so the out-of-band half worked while the in-band half reported a clean resolve; a --json consumer saw health. MAJOR: reason was derived from Object.keys(parsed) - the root+workstream MERGE - so an empty workstream file inheriting a non-empty root reported resolved despite carrying no settings. Emptiness is now judged on the file actually read, snapshotted before normalizeLegacyKeys mutates it. Also: corrects the ConfigResolution JSDoc, which still described the pre-#1880 degraded contract; adds a fast-check property asserting a PRESENT file is never reported not_configured whatever its bytes (CONTRIBUTING.md parser rule); and asserts the literal enum values so the provenance lint's configured_empty/not_configured markers check real assertions rather than incidental prose in test titles. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#1880): reject valid JSON that is not a config object at the read seam The fast-check property added in the previous commit failed on both node lanes: a config.json containing 0, "str", [], null or true is valid JSON, so it parsed "ok", then threw downstream in normalizeLegacyKeys, and the outer catch reported not_configured - a PRESENT file reported as absent, which is precisely the collapse this issue exists to close. The property asserts a present file is never not_configured, and it caught it. _readConfigFile now validates shape, not just parseability (ADR-227: check the semantic shape at a trust boundary, not merely the type). A non-object JSON document is an unusable config, reported config_unparseable. Adds named regression cases for each non-object form alongside the property, so the class is documented and not only randomly sampled. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#1880): backfill changeset pr number (pr:0 -> 2688) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#2452): record a fetch-time shallow failure instead of crashing This guard failed CI on ubuntu-24 while passing on ubuntu-22 and windows-24 for the same commit, and passed on other PRs. Not a flake and not caused by the change under test - a real fragility in the test. runnerDiff ran the base fetch OUTSIDE its try and only guarded the diff, so it assumed the failure mode is always 'fetch succeeds, diff reports no merge base'. At a shallow boundary that lands short of the merge base, git can instead fail during the FETCH ('unable to parse commit' - the boundary commit's parent is not available). Which stage git fails at is version and transport dependent, so on some runners the error escaped runnerDiff and crashed the test rather than being recorded as the ok:false the assertions expect. Both stages mean the same thing for what this guard protects: a shallow base ref cannot resolve the three-dot diff. Also drops two assert.match calls against git's stderr prose. 'no merge base' and 'unable to parse commit' are the same condition reported at different stages, and CONTRIBUTING prohibits raw text matching on subprocess output. The typed outcome (ok === false) is the contract; the tests now assert that plus the presence of a cause. Found while investigating the red lane on #2688; fixed here per the no-defer rule rather than filed. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/proud-otters-howl.md
Normal file
5
.changeset/proud-otters-howl.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Fixed
|
||||||
|
pr: 2688
|
||||||
|
---
|
||||||
|
**A corrupt `.planning/config.json` no longer silently discards your entire configuration** — a single trailing comma used to fall back to built-in defaults with no signal, indistinguishable from having no config file at all, so a project could run for weeks on defaults while its model profile, workflow toggles and branching strategy sat unread on disk. GSD now tells you the file could not be used and that its settings were not applied, and reports the cause (`config_unparseable` / `config_unreadable`) distinctly from genuine absence. The same applies to an unreadable file and to the global `~/.gsd/defaults.json`. (#1880)
|
||||||
@@ -140,6 +140,28 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## When your config file cannot be read
|
||||||
|
|
||||||
|
GSD distinguishes a config file that is **absent** from one that is **present but unusable**. The
|
||||||
|
two used to be indistinguishable: a single trailing comma in `.planning/config.json` silently
|
||||||
|
replaced your entire configuration with built-in defaults, and nothing said so (#1880).
|
||||||
|
|
||||||
|
| Situation | What GSD does | Diagnostic |
|
||||||
|
|---|---|---|
|
||||||
|
| No `.planning/config.json` | Uses built-in defaults. This is normal. | none |
|
||||||
|
| File present, valid, has settings | Uses your settings. | none |
|
||||||
|
| File present, valid, but empty (`{}`) | Uses built-in defaults. | none |
|
||||||
|
| **File present but not valid JSON** | Uses built-in defaults — **your settings are not applied** | `warning: <path> is not valid JSON — its settings were NOT applied` |
|
||||||
|
| **File present but unreadable** (e.g. permissions) | Uses built-in defaults — **your settings are not applied** | `warning: <path> could not be read (EACCES) — its settings were NOT applied` |
|
||||||
|
|
||||||
|
The warning is printed once per file per run, so a repeated command will not spam it.
|
||||||
|
|
||||||
|
The same applies to the global `~/.gsd/defaults.json`. If the project config is also unusable, the
|
||||||
|
project one is reported, since that is the file you are most likely able to fix.
|
||||||
|
|
||||||
|
**If you see this warning:** your config was not applied. Validate the file, for example with
|
||||||
|
`node -e "JSON.parse(require('fs').readFileSync('.planning/config.json','utf8'))"`, then re-run.
|
||||||
|
|
||||||
## Core Settings
|
## Core Settings
|
||||||
|
|
||||||
| Setting | Type | Options | Default | Description |
|
| Setting | Type | Options | Default | Description |
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ const REGISTRY = [
|
|||||||
sourceFile: 'src/init.cts',
|
sourceFile: 'src/init.cts',
|
||||||
testFile: 'tests/agent-skills.test.cjs',
|
testFile: 'tests/agent-skills.test.cjs',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Registered by #1880 (ADR-1411 amendment "corrupt is not absent"). Until
|
||||||
|
// this entry existed the config-loader seam carried the provenance contract
|
||||||
|
// with nothing guarding it, so a regression that collapsed configured_empty
|
||||||
|
// back into not_configured would have shipped silently.
|
||||||
|
verb: 'config-loader',
|
||||||
|
sourceFile: 'src/config-loader.cts',
|
||||||
|
testFile: 'tests/config-loader.test.cjs',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Markers that MUST appear in every registered verb's test file.
|
// Markers that MUST appear in every registered verb's test file.
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ function _warnUnknownProfileOverrides(parsed: Record<string, unknown>, configLab
|
|||||||
function _resetRuntimeWarningCacheForTests(): void {
|
function _resetRuntimeWarningCacheForTests(): void {
|
||||||
_warnedConfigKeys.clear();
|
_warnedConfigKeys.clear();
|
||||||
_warnedUnknownConfigKeys.clear();
|
_warnedUnknownConfigKeys.clear();
|
||||||
|
_warnedUnusableConfig.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── FIX 2: Federated overlay helpers ────────────────────────────────────────
|
// ─── FIX 2: Federated overlay helpers ────────────────────────────────────────
|
||||||
@@ -425,13 +426,119 @@ type ConfigSource = 'workstream' | 'root' | 'builtin-defaults' | 'global-default
|
|||||||
/**
|
/**
|
||||||
* Result of loadConfigResolved — wraps the config object with provenance metadata.
|
* Result of loadConfigResolved — wraps the config object with provenance metadata.
|
||||||
* - source: which layer supplied the config
|
* - source: which layer supplied the config
|
||||||
* - degraded: true when a workstream was requested but its config.json was absent
|
* - degraded: true when the resolution did not deliver the configuration it
|
||||||
* (fell back to root config); false otherwise
|
* should have — either a workstream was requested but its
|
||||||
|
* config.json was absent (fell back to root), or a file on the
|
||||||
|
* resolution path exists but is unusable (#1880). `reason` says which.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Machine-readable outcome of a config resolution (#1880, ADR-1411 amendment
|
||||||
|
* "corrupt is not absent"). `Resolution<T>`'s four documented values all
|
||||||
|
* describe a resolution *miss*; the two `config_un*` values below are the
|
||||||
|
* unusable-input class that amendment introduced, and they are what makes a
|
||||||
|
* corrupt file distinguishable from an absent one.
|
||||||
|
*
|
||||||
|
* Frozen enum rather than bare strings so tests assert on the typed surface
|
||||||
|
* instead of diagnostic prose (CONTRIBUTING.md — Prohibited: Raw Text Matching
|
||||||
|
* on Test Outputs).
|
||||||
|
*/
|
||||||
|
const CONFIG_REASON = Object.freeze({
|
||||||
|
/** A config file was found, parsed, and supplied at least one setting. */
|
||||||
|
RESOLVED: 'resolved',
|
||||||
|
/** No config file exists at the resolved path. Genuine absence — NOT degraded. */
|
||||||
|
NOT_CONFIGURED: 'not_configured',
|
||||||
|
/** A config file exists and parsed, but carried no settings (`{}`). */
|
||||||
|
CONFIGURED_EMPTY: 'configured_empty',
|
||||||
|
/** A workstream was requested but had no config; fell back to root. */
|
||||||
|
WORKSTREAM_FALLBACK: 'workstream_fallback',
|
||||||
|
/** The file exists but is not valid JSON — settings were NOT applied. */
|
||||||
|
CONFIG_UNPARSEABLE: 'config_unparseable',
|
||||||
|
/** The file exists but could not be read (EACCES/EIO/…) — NOT applied. */
|
||||||
|
CONFIG_UNREADABLE: 'config_unreadable',
|
||||||
|
} as const);
|
||||||
|
|
||||||
|
type ConfigReason = (typeof CONFIG_REASON)[keyof typeof CONFIG_REASON];
|
||||||
|
|
||||||
|
/** A config file that exists but cannot be used. Absence is NOT a fault. */
|
||||||
|
interface ConfigFault {
|
||||||
|
reason: typeof CONFIG_REASON.CONFIG_UNPARSEABLE | typeof CONFIG_REASON.CONFIG_UNREADABLE;
|
||||||
|
/** Resolved path of the offending file — half of the diagnostic dedup key. */
|
||||||
|
path: string;
|
||||||
|
/** errno for an unreadable file; '' for a parse failure. The other half. */
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ConfigResolution {
|
interface ConfigResolution {
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
source: ConfigSource;
|
source: ConfigSource;
|
||||||
degraded: boolean;
|
degraded: boolean;
|
||||||
|
/**
|
||||||
|
* Why this resolution produced what it did. `degraded` alone cannot separate
|
||||||
|
* "no config here" from "your config is corrupt and was discarded" — both
|
||||||
|
* previously returned identical objects (#1880).
|
||||||
|
*/
|
||||||
|
reason: ConfigReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read + JSON-parse a config file, keeping *absent* distinguishable from
|
||||||
|
* *unusable*. `platformReadSync` returns null on ENOENT and re-throws every
|
||||||
|
* other errno, which is the seam that makes this separable at all.
|
||||||
|
*/
|
||||||
|
function _readConfigFile(filePath: string):
|
||||||
|
| { kind: 'ok'; data: Record<string, unknown> }
|
||||||
|
| { kind: 'absent' }
|
||||||
|
| { kind: 'fault'; fault: ConfigFault } {
|
||||||
|
let raw: string | null;
|
||||||
|
try {
|
||||||
|
raw = platformReadSync(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as NodeJS.ErrnoException).code ?? 'EUNKNOWN';
|
||||||
|
return { kind: 'fault', fault: { reason: CONFIG_REASON.CONFIG_UNREADABLE, path: filePath, code } };
|
||||||
|
}
|
||||||
|
if (raw === null) return { kind: 'absent' };
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return { kind: 'fault', fault: { reason: CONFIG_REASON.CONFIG_UNPARSEABLE, path: filePath, code: '' } };
|
||||||
|
}
|
||||||
|
// Shape, not just parseability (ADR-227). `0`, `"x"`, `[]` and `null` are all
|
||||||
|
// valid JSON but are not a config object. Accepting them let a PRESENT file
|
||||||
|
// parse "ok", then throw downstream, and be reported not_configured by the
|
||||||
|
// outer catch — a corrupt file indistinguishable from an absent one, which is
|
||||||
|
// the exact defect this change closes. Caught by the fast-check property.
|
||||||
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
return { kind: 'fault', fault: { reason: CONFIG_REASON.CONFIG_UNPARSEABLE, path: filePath, code: '' } };
|
||||||
|
}
|
||||||
|
return { kind: 'ok', data: parsed as Record<string, unknown> };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedup set for the unusable-config diagnostic. Keyed on resolved path + errno
|
||||||
|
* per the ADR-1411 amendment — never on message text, which would couple the
|
||||||
|
* guard to wording, and never on the errno alone, which would suppress a
|
||||||
|
* genuine second failure in a different file.
|
||||||
|
*/
|
||||||
|
const _warnedUnusableConfig = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wiring clause (ADR-1411 amendment). `reason` lives on `ConfigResolution`,
|
||||||
|
* but `loadConfig` — the wrapper roughly fifty call sites use — returns
|
||||||
|
* `.config` alone and would never surface it. Without this diagnostic the field
|
||||||
|
* is unreachable to almost every consumer, and the user whose config was
|
||||||
|
* silently discarded still gets no signal. That was the whole defect in #1880.
|
||||||
|
*/
|
||||||
|
function _warnUnusableConfig(fault: ConfigFault): void {
|
||||||
|
const key = `${fault.path} | ||||||