15af0f5536fc4d33f66fb1927dcf70ebfa451b51
4 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0408276791 |
chore(#2797): federate reviewer config keys off the central schema (#2841)
* chore(#2797): federate reviewer config keys off the central schema Phase 4 of epic #2782 (ADR-2782 D9, config half). Runs AFTER 5a per the ADR's swap amendment: a federated config slice lives inside a capabilities/<id>/capability.json, and three of the five key families had no capability directory until 5a created them. Four key families move to the lanes that use them; the central-schema removal and the federated addition land in this one commit because the exclusivity invariant fails the build on a key present in both. review.max_prompt_tokens, review.default_reviewers and review.reviewer_instances describe policy ACROSS lanes and stay central. Two things the issue did not name, both found while building it: 1. THE EXCLUSIVITY GATE WAS BLIND TO PATTERNS. It compared federated keys against manifest.validKeys only, and two of the four families (review.models.<slug>, review.max_prompt_tokens_per_reviewer.<slug>) were pattern-backed. That is not cosmetic: isCentralConfigKey consults those patterns and mergeFederatedConfig skips every key for which it returns true, so declaring a slice while the pattern survived would have shipped an INERT slice behind a green gate — the exact half-migrated shape the invariant exists to prevent. The gate now loads the patterns from the same manifest the runtime reads. 2. AN UNSET PER-LANE BUDGET NOW RESOLVES TO 0, NOT NOT-FOUND, because a federated key always resolves to its declared default. The three fallback guards in review.md checked only empty-or-"null", so a user who set the GLOBAL review.max_prompt_tokens would have silently lost trimming on the HTTP lanes. The guards now treat 0 as unset. D9 says review.models.<slug> is owned by "the lane whose slug it names". That is false for one lane: the shipped key is review.models.agy while the slug is antigravity. Ownership follows the lane; the key name is preserved, because renaming would break every config that sets it. Existing tests updated rather than left asserting the old world: config-get on a cleared federated key yields empty instead of not-found (what #2046 actually protects — never persisting the literal "null" — is unchanged and still asserted); the config-schema dynamic pattern representative moves to reviewer_instances; the prototype-pollution guard case moves to a surviving dynamic prefix so alert #26 keeps its coverage, with a new case asserting the old key is now rejected earlier; and Phase 2's harvest-widening inertness assertion becomes an ownership assertion, since Phase 4 is what consumes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2797): use a -1 sentinel so an explicit per-lane budget of 0 survives A federated config key always resolves to its declared default, so an unset per-lane prompt budget needed a value the workflow could treat as 'not configured'. The first cut used 0 — which is wrong: 0 is already a LEGITIMATE per-lane budget meaning 'do not trim this lane' (the early-return guard in prepare_trimmed_prompt_for_reviewer). Treating it as unset would have silently switched a user who deliberately disabled trimming for one lane onto the global budget. The sentinel is now -1, which is not a valid token budget, so all three states stay distinguishable: unset falls back to global, an explicit 0 disables trimming for that lane, and an explicit N is used. Locked by three CLI round-trip tests. Surfaced by the isolated security reviewer before it crashed mid-run; verified independently against the shipped trim guard rather than taken on trust. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2797): update central-registration assertions and stay under the review.md cap The remote runner caught both; my local sweep missed the files. 1. tests/plan-review-convergence.test.cjs asserted the three local-server host keys are in VALID_CONFIG_KEYS. They are federated to their lane capabilities now, and the exclusivity invariant forbids a key living in both places. What #2306-local actually protects is that config-set ACCEPTS them, so that is what is asserted — via isValidConfigKey, the predicate config-set itself uses, which spans central and federated. A second assertion pins federated ownership, so a silent reversion back to the central schema fails too. 2. review.md exceeded the LARGE tier hard cap (62583 > 61440). That cap is a red line, not a budget to raise. The three per-lane budget guard comments were near-identical; condensed to one terse line each. 61371 bytes, 69 to spare. Real extraction to workflows/review/modes/ is Phase 5b/6 work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2797): fail closed on a broken config-schema manifest; reconcile stale docs Isolated security review findings. MAJOR — loadCentralConfigPatterns failed OPEN. It swallowed a JSON parse error and returned [], while its sibling loadCentralConfigKeys, reading the SAME file, writes to stderr and throws ExitError(1) on that identical failure class. Fail-open here defeats the gate this function exists to feed: with zero patterns, validateCrossCapability's pattern-collision check silently passes and an inert federated slice ships green. It was masked in the one production call site only because loadCentralConfigKeys runs first against the same path — a coincidence of ordering, not a guarantee, and this function is exported and called standalone. The two now share a contract: ENOENT is the legitimate absent case, anything else throws loudly. A single unparseable PATTERN is still skipped, which degrades to "checked less" rather than blocking every build. The branch had zero coverage; it now has two tests (malformed JSON, EISDIR). MINOR — docs/CONFIGURATION.md still listed review.models.qwen and review.models.cursor as settable, ~770 lines below this PR's own new Ownership section. Those lanes take no model flag, so they declare no model key and config-set now rejects them. Rows removed; the missing review.models.agy row added; the per-reviewer budget row corrected to name only the lanes that own a budget key, and to document that a per-lane 0 disables trimming for that lane. Also fixes a shadowed "raw" binding introduced by the fail-closed change, which made the generator unrequirable — caught immediately by its own --check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#2797): backfill changeset pr number to 2841 * fix(#2452): make the base-ref mutation test hermetic against leaked GIT_* env tests/mutation-workflow-base-ref.test.cjs fails on PR branches while next stays green, and it is currently blocking at least three unrelated PRs (#2841, #2832, #2827) with: error: invalid object 100644 <sha> for 'base-N.txt' error: Error building trees The existing loop comment attributes this to `git add .` rehashing O(n^2) blobs "before the object write had landed" and works around it by staging one path per iteration. That is not the cause: sequential execFileSync calls cannot race each other's object writes, and the failure persisted after that change — it simply moved to a lower commit index. The cause is that the git() helper inherited the runner's environment. A leaked GIT_INDEX_FILE makes `git add` write into a DIFFERENT repository's index; GIT_OBJECT_DIRECTORY / GIT_ALTERNATE_OBJECT_DIRECTORIES send the blob to another object store; GIT_DIR / GIT_WORK_TREE redirect the whole operation. In every case `git commit` then cannot resolve a blob it just staged, which is precisely the error above. Verified by negative control: with GIT_DIR exported, this test fails on the unfixed helper (the git commands operate on the wrong repository entirely); with the helper stripping GIT_* it passes. The single-path staging is kept — it is genuinely less work — but it is no longer load bearing. Found while shipping #2797. Fixed in place rather than deferred: it is a defect surfaced during the work, and it is blocking other contributors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2452): build the base-advance commits empty, removing the lost-object class The base-ref guard has been failing in CI with: error: invalid object 100644 <sha> for 'base-N.txt' error: Error building trees It is currently red on at least three unrelated PRs (#2841, #2832, #2827) while next stays green. Two theories have now been tried and neither held. #1881 blamed `git add .` rehashing O(n^2) blobs and switched to staging one path per iteration; the failure moved from commit 32 to commit 25 and carried on. The preceding commit here made the git helper hermetic against leaked GIT_* environment — that IS a real vulnerability (with GIT_DIR exported the helper operates on the wrong repository entirely, proven by negative control) but it produces a different error than CI reports, so it is not demonstrably the cause either. Neither trigger reproduces off-CI, so this stops guessing at the trigger and removes the failure CLASS instead. The loop needs base-branch DEPTH and nothing else: no assertion reads these commits' contents, and base-side files cannot appear in `origin/base...HEAD` regardless. `--allow-empty` writes no blob and no tree, so there is no object for the index to reference and lose. It is also far less work than 60 write+hash+index cycles. The guard still proves its mechanism: the test asserts that a --depth=1 base fetch FAILS and a full fetch resolves, so a broken topology would surface immediately rather than passing vacuously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Test <test@example.com> |
||
|
|
80778e2674 |
fix(#1881): report an unreadable ROADMAP instead of reading it as absent (#2729)
* test(#1882): stage one file per commit in the base-ref ancestry fixture CI failed on ubuntu-24 inside this test's setup loop, before any code under test ran: at commit 32 of 60 the index referenced a blob whose object write had not landed -- "invalid object ... for 'base-31.txt' / Error building trees". The loop staged with `git add .`, which re-stages every file already in the tree. Across 60 iterations that rehashes O(n squared) blobs -- roughly 1,800 stagings and 60 full index rewrites to add 60 one-line files -- and that churn is what the object store failed under. Each commit only ever adds a single new file, so staging that one path is equivalent and removes the redundant work entirely. Verified the loop still builds the intended history: 61 commits, git fsck clean. The fixture already carries a note from an earlier fix in this epic recording that it passed on ubuntu-22 and windows-24 and failed on ubuntu-24 for the same commit. That was a different stage -- fetch versus diff -- but the same lane and the same brittleness, so this is the second time this fixture's cost has surfaced as a red build rather than as a test failure. Not caused by this PR's change, which touches two configuration lists and cannot reach a scratch git repository in tmpdir. Fixed here rather than deferred, because the run surfaced it. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#1881): prove an unreadable ROADMAP is indistinguishable from an absent one Failing-first. Encodes the issue's runtime repro: an unreadable ROADMAP.md makes getRoadmapPhaseInternal return the same null it returns for "phase not found", and getMilestoneInfo return the same {v1.0, milestone} it returns for a project with no roadmap at all -- so a permission or I/O fault reads as a brand-new project. Half these cases exist to hold the opposite line. getMilestoneInfo has no existsSync guard, so platformReadSync's null-for-ENOENT is converted to a synthetic Error carrying no errno, and that lands in the SAME catch as a real EACCES. Reporting unconditionally there would flag every project without a ROADMAP.md -- every brand-new project -- as corrupt. The absent case, the errno-less error, a non-string errno, unparseable content and a genuinely missing phase are all pinned silent. One case guards a decision rather than behaviour: an unreadable STATE.md alone must stay silent, because the inner catch that swallows it is deliberate and documented under the #2245 audit as an optional enhancement falling back to ROADMAP-only heuristics. Two more pin the invariant ADR-1411 names explicitly -- neither function may throw, because src/state.cts removed its own defensive try/catch on the strength of that guarantee. Assertions are on the frozen reason enum and the emission counter, never on diagnostic prose. Faults are injected by overriding the platformReadSync seam and restoring in t.after(), never chmod 0o000, which root bypasses. Adds the ROADMAP_UNREADABLE reason to the shared vocabulary as scaffolding; no call site emits it yet, which is what makes these tests red. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#1881): report an unreadable ROADMAP instead of reading it as absent getRoadmapPhaseInternal returned null for a read failure exactly as it does for "phase not found", and getMilestoneInfo returned {v1.0, milestone} exactly as it does for a project with no roadmap -- so a permission or I/O fault presented as a brand-new project and workflows synthesised a blank phase or skipped requirement extraction with no signal. Both return values are preserved exactly, per ADR-1411's amendment: continuity is correct, the silence was the defect. Each catch now reports through the shared unusable-input seam that shipped with #1882 rather than a second copy of the same mechanism. The discriminator is the errno, and it is load-bearing in the silent direction. getMilestoneInfo has no existsSync guard, so platformReadSync's null-for-ENOENT is converted into a synthetic Error with no code that lands in the same catch as a real EACCES. Reporting unconditionally there would flag every project without a ROADMAP.md -- every brand-new project -- as corrupt. A genuine read fault always carries an errno; absence never does. The parse is regex over text and cannot throw, so nothing else reaches these catches. Neither function gains a throw. ADR-1411 names this explicitly: src/state.cts removed its defensive try/catch around getMilestoneInfo under the #2245 audit because it never throws, and two tests pin that. The inner STATE.md catch stays untouched and silent -- its fallback to ROADMAP-only heuristics is a deliberate, documented optional-enhancement path, not a fault. Where the fix belongs was the design question. platformReadSync does not leak: it keeps absent and unusable as two channels, exactly as an abstraction should. Both callers re-collapsed that distinction, so the fix is caller-side and the projection seam -- with roughly ninety other dependents -- is untouched. Closes #1881 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#1881): admit the roadmap reason to the locked vocabulary The seam documents adding a reason as three coordinated changes -- the enum entry, the emitting call site, and the test that locks Object.keys(...).sort(). This PR made the first two and the lock caught the third, which is the whole point of pinning the key set rather than asserting each value exists. The roadmap suite no longer re-locks the full set. Two complete locks would mean two files to update every time a later phase adds a reason, and #1883 and #1884 are both going to. The canonical lock stays in the seam's own suite; the roadmap suite asserts only the value it introduces. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#1881): resolve the roadmap path inside the try, not outside it Naming the file in the diagnostic required the resolved path in the catch, and the obvious way to get it was to hoist `path.join(planningDir(cwd), 'ROADMAP.md')` above the try. planningDir throws a plain Error for an invalid GSD_WORKSTREAM or GSD_PROJECT segment -- one containing a slash, backslash or `..` -- so hoisting it let that throw escape uncaught. That broke the exact invariant ADR-1411 names as this file's hazard: src/state.cts removed its defensive try/catch around getMilestoneInfo under the #2245 audit because that function never throws. Of its callers only archivePhaseDirectories wraps it; cmdInitExecutePhase, cmdInitNewMilestone, cmdInitMilestoneOp, cmdInitManager, cmdInitProgress, cmdProgressRender and cmdStats all call it bare, so a workstream name with a slash in it crashed the CLI outright instead of degrading. The previous commit asserted "neither function gains a throw -- two tests pin that". That was false. Both tests inject faults through platformReadSync only and never through planningDir, so neither could have exercised the path that broke. The guarantee was claimed, not demonstrated. The path is now declared before the try and resolved inside it, so the catch can still name the file when there is one, and a path that never resolved reports nothing and returns the sentinel unchanged. The two test names are narrowed to what they actually prove -- that a failing READ does not throw -- and a new case injects the planningDir failure directly, which is what would have caught this. getRoadmapPhaseInternal carried the same hazard, resolving the path outside its try since before this branch. It is fixed the same way rather than left: ADR-227 is explicit that throwing breaks pipeline continuity, this read path already degrades to null for every other failure, and a PR whose purpose is hardening this invariant is the wrong place to leave the sibling crashing. Behaviour otherwise unchanged and re-verified: healthy lookups, EACCES reporting on both functions, absent-roadmap silence, and the errno discriminator all unaffected. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#1881): backfill changeset pr number to 2729 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3eb1cede26 |
fix(#1880): distinguish a corrupt config from an absent one (epic #1879 Phase 1) (#2688)
* 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> |
||
|
|
3435218089 |
fix(#2452): stop shallow-fetching the base ref in three-dot-diff CI gates (#2485)
* fix(#2452): stop shallow-fetching the base ref in three-dot-diff CI gates `mutation.yml` re-fetched the base branch with `--depth=1` after checking out with `fetch-depth: 0`. The shallow re-fetch truncates the base ref's ancestry, so `git diff --name-only origin/<base>...HEAD` in scripts/mutation-matrix.cjs can no longer compute a merge base and aborts with `fatal: origin/next...HEAD: no merge base` (exit 2). The `detect` job then fails and the `mutate` shards never run — so the 80% mutation-score threshold went UNVERIFIED rather than enforced. The failure is branch-position dependent, which is why it went unnoticed: a branch already level with the base incidentally passes (its merge base IS the single fetched commit), while a branch that is BEHIND fails. Observed on PRs #2436 and #2005. `changeset-required.yml` and `docs-required.yml` shallow-fetched the base ref too (`--depth=50`), shrinking the same window further. All three now fetch the BASE REF unshallowed. Their shallow *checkout* depth is left at 50: that is a separate, deliberate cost control with fail-closed semantics, owned by tests/policy-lint-shallow-checkout.test.cjs. Only the base-ref fetch changes. The two `${{ }}` interpolations in mutation.yml's run: blocks now pass the base name through `env:`, matching the sibling workflows. Regression coverage in tests/mutation-workflow-base-ref.test.cjs: - a per-workflow contract guard asserting the base fetch carries no --depth (RED on origin/next for all three files, GREEN here). The YAML step parser handles block scalars and skips commented-out steps, so a future refactor to a multi-line `run:` cannot silently degrade the guard. - a real-git mechanism proof with boundary coverage at the shallow edge: with the base advanced 60 commits past the branch point, --depth=1 and --depth=60 both fail with `no merge base`, --depth=61 (merge base exactly at the boundary) succeeds, and an unbounded fetch succeeds. Each variant uses an independent clone, because a plain fetch does not un-shallow a repo that already carries a .git/shallow boundary. Also fixes a startup race in tests/run-with-timeout.test.cjs surfaced by this branch's gsd-test run (C1, linux-node24). The heartbeat file only appeared ~100ms after the grandchild's runtime was up, but the window is 1s spanning two cold node starts, so on a loaded runner the timeout fired before any heartbeat existed and the precondition failed for reasons unrelated to reaping. The child now writes its heartbeat once synchronously at startup; the frozen-vs-ticking comparison that actually proves reaping is unchanged. Closes #2452 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(changeset): backfill PR number to 2485 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |