* 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>
173 lines
6.9 KiB
JavaScript
173 lines
6.9 KiB
JavaScript
/**
|
|
* Review Model Config Tests (#1849)
|
|
*
|
|
* Verifies the review.models.<cli> dynamic config key pattern:
|
|
* - isValidConfigKey accepts review.models.<cli-name>
|
|
* - validateKnownConfigKeyPath suggests review.models.<cli-name> for review.model
|
|
* - End-to-end round-trip via config-set / config-get for model IDs and the
|
|
* null "Clear" action (#2046 — config-set <key> null unsets the key)
|
|
*/
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
describe('review.models.<cli> config key', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
// Ensure config exists for set/get
|
|
runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('isValidConfigKey accepts review.models.gemini', () => {
|
|
// Exercised via config-set, which calls isValidConfigKey internally and
|
|
// errors out if the key is not valid.
|
|
const result = runGsdTools(
|
|
['config-set', 'review.models.gemini', 'gemini-3.1-pro-preview'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(result.success, `config-set should succeed for review.models.gemini: ${result.error}`);
|
|
});
|
|
|
|
test('isValidConfigKey accepts review.models.codex', () => {
|
|
const result = runGsdTools(
|
|
['config-set', 'review.models.codex', 'gpt-5-codex'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(result.success, `config-set should succeed for review.models.codex: ${result.error}`);
|
|
});
|
|
|
|
test('isValidConfigKey accepts review.models.claude (#2688)', () => {
|
|
const result = runGsdTools(
|
|
['config-set', 'review.models.claude', 'claude-opus-4-6'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(result.success, `config-set should succeed for review.models.claude: ${result.error}`);
|
|
});
|
|
|
|
test('round-trip: review.models.claude config-set then config-get (#2688)', () => {
|
|
const setResult = runGsdTools(
|
|
['config-set', 'review.models.claude', 'claude-opus-4-6'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(setResult.success, `config-set failed: ${setResult.error}`);
|
|
|
|
const getResult = runGsdTools(
|
|
['config-get', 'review.models.claude', '--raw'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(getResult.success, `config-get failed: ${getResult.error}`);
|
|
assert.strictEqual(
|
|
getResult.output,
|
|
'claude-opus-4-6',
|
|
'config-get should return the model ID set via config-set'
|
|
);
|
|
});
|
|
|
|
test('review.model is rejected and suggests review.models.<cli-name>', () => {
|
|
// The suggestion path goes through validateKnownConfigKeyPath, which is
|
|
// called before isValidConfigKey in cmdConfigSet.
|
|
const result = runGsdTools(
|
|
['config-set', 'review.model', 'gemini-3.1-pro-preview'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(!result.success, 'config-set should fail for review.model');
|
|
assert.ok(
|
|
result.error.includes('review.models.<cli-name>'),
|
|
`error should suggest review.models.<cli-name>, got: ${result.error}`
|
|
);
|
|
});
|
|
|
|
test('round-trip: config-set then config-get for a model ID', () => {
|
|
const setResult = runGsdTools(
|
|
['config-set', 'review.models.gemini', 'gemini-3.1-pro-preview'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(setResult.success, `config-set failed: ${setResult.error}`);
|
|
|
|
const getResult = runGsdTools(
|
|
['config-get', 'review.models.gemini', '--raw'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(getResult.success, `config-get failed: ${getResult.error}`);
|
|
assert.strictEqual(
|
|
getResult.output,
|
|
'gemini-3.1-pro-preview',
|
|
'config-get should return the value set via config-set'
|
|
);
|
|
});
|
|
|
|
test('round-trip: config-set null UNSETS the model key (#2046 — the "Clear" action)', () => {
|
|
// #2046: `config-set <key> null` now DELETES the key (the documented "Clear"
|
|
// action) instead of persisting the literal string "null". A previously-set
|
|
// model override is removed cleanly; config-get then reports key-not-found.
|
|
// The review workflow's guard (`[ -n "$VAR" ] && [ "$VAR" != "null" ]`,
|
|
// review.md:259) treats the resulting empty read as "no override → use the
|
|
// reviewer's default", exactly as it treated the old "null" sentinel.
|
|
const setResult = runGsdTools(
|
|
['config-set', 'review.models.gemini', 'gemini-3.1-pro-preview'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(setResult.success, `config-set failed: ${setResult.error}`);
|
|
|
|
const clearResult = runGsdTools(
|
|
['config-set', 'review.models.gemini', 'null'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.ok(clearResult.success, `config-set null failed: ${clearResult.error}`);
|
|
|
|
// The key is gone from disk — not persisted as the string "null".
|
|
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
|
const rawText = fs.readFileSync(configPath, 'utf-8');
|
|
const config = JSON.parse(rawText);
|
|
assert.ok(
|
|
!config.review || !config.review.models ||
|
|
!Object.prototype.hasOwnProperty.call(config.review.models, 'gemini'),
|
|
`review.models.gemini must be absent after clear, got: ${rawText}`
|
|
);
|
|
assert.doesNotMatch(rawText, /"gemini":\s*"null"/,
|
|
'must never persist review.models.gemini as the literal string "null"');
|
|
|
|
// config-get on the removed key yields EMPTY (the review workflow reads it as
|
|
// `... 2>/dev/null || echo ""` → empty → the `[ -n "$VAR" ]` guard falls back
|
|
// to the reviewer default).
|
|
//
|
|
// #2797: this key is now federated to the `gemini` lane capability, and a
|
|
// federated key always resolves to its declared default — so config-get exits
|
|
// 0 with empty output rather than exiting non-zero with "Key not found". The
|
|
// WORKFLOW outcome is unchanged: the guard above sees empty either way, which
|
|
// is exactly what this test's own rationale (the comment above) turns on.
|
|
//
|
|
// What #2046 actually protects is asserted below and is untouched: clearing
|
|
// must never yield the literal string "null", which would be handed to the
|
|
// CLI as a model name.
|
|
const getResult = runGsdTools(
|
|
['config-get', 'review.models.gemini', '--raw'],
|
|
tmpDir,
|
|
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
|
);
|
|
assert.strictEqual((getResult.output || '').trim(), '',
|
|
'config-get on a cleared key must yield empty output');
|
|
assert.notStrictEqual(getResult.output && getResult.output.trim(), 'null',
|
|
'config-get must not emit the literal string "null" for a cleared key');
|
|
});
|
|
});
|