* 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>
365 lines
16 KiB
JavaScript
365 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Reviewer config-key federation — ADR-2782 D9 (config half), Phase 4 (#2797).
|
|
*
|
|
* Four key families move from the central config-schema to federated `config`
|
|
* slices owned by their lane capabilities. Three keys deliberately stay central,
|
|
* because a key describing policy *across* lanes must not be federated *into*
|
|
* one.
|
|
*
|
|
* The trap this suite exists to catch: two of the four families were governed by
|
|
* central DYNAMIC PATTERNS rather than exact keys. `isCentralConfigKey` consults
|
|
* those patterns, and `mergeFederatedConfig` skips every key for which it returns
|
|
* true — so declaring a slice while the pattern survives yields an INERT slice
|
|
* and a green build. Assertions here are on provenance (`isCentralConfigKey` vs
|
|
* `isCapabilityConfigKey`), not merely on validity, because validity alone cannot
|
|
* tell a working migration from a no-op.
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const { createTempProject, createTempDir, cleanup, runGsdTools } = require('./helpers.cjs');
|
|
const configSchema = require('../gsd-core/bin/lib/config-schema.cjs');
|
|
const capValidator = require('../gsd-core/bin/lib/capability-validator.cjs');
|
|
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
|
const gen = require('../scripts/gen-capability-registry.cjs');
|
|
|
|
/** Keys that moved to a lane capability, with the lane that must own each. */
|
|
const FEDERATED = {
|
|
'review.models.gemini': 'gemini',
|
|
'review.models.claude': 'claude',
|
|
'review.models.codex': 'codex',
|
|
'review.models.opencode': 'opencode',
|
|
// The suffix is the lane's binary/flag alias, NOT its slug `antigravity` —
|
|
// preserved verbatim so existing .planning/config.json files keep working.
|
|
'review.models.agy': 'antigravity',
|
|
'review.models.ollama': 'ollama',
|
|
'review.models.lm_studio': 'lm-studio',
|
|
'review.models.llama_cpp': 'llama-cpp',
|
|
'review.ollama_host': 'ollama',
|
|
'review.lm_studio_host': 'lm-studio',
|
|
'review.llama_cpp_host': 'llama-cpp',
|
|
'review.max_prompt_tokens_per_reviewer.ollama': 'ollama',
|
|
'review.max_prompt_tokens_per_reviewer.lm_studio': 'lm-studio',
|
|
'review.max_prompt_tokens_per_reviewer.llama_cpp': 'llama-cpp',
|
|
};
|
|
|
|
/** Keys D9 names as staying central — policy across lanes, not lane properties. */
|
|
const CENTRAL_SURVIVORS = [
|
|
'review.max_prompt_tokens',
|
|
'review.default_reviewers',
|
|
'review.reviewer_instances.myinstance.cli',
|
|
];
|
|
|
|
describe('reviewer config federation — provenance actually moved (#2797)', () => {
|
|
test('every federated key is owned by a capability and no longer central', () => {
|
|
for (const [key, owner] of Object.entries(FEDERATED)) {
|
|
assert.equal(
|
|
configSchema.isCentralConfigKey(key), false,
|
|
`${key} must NOT be central — while it is, mergeFederatedConfig skips it and the slice is inert`,
|
|
);
|
|
assert.equal(
|
|
configSchema.isCapabilityConfigKey(key), true,
|
|
`${key} must be owned by a capability config slice`,
|
|
);
|
|
assert.equal(configSchema.isValidConfigKey(key), true, `${key} must remain valid`);
|
|
assert.equal(
|
|
registry.configSchema[key] && registry.configSchema[key].owner, owner,
|
|
`${key} must be owned by "${owner}"`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('the keys D9 keeps central are untouched', () => {
|
|
for (const key of CENTRAL_SURVIVORS) {
|
|
assert.equal(configSchema.isCentralConfigKey(key), true, `${key} must stay central`);
|
|
assert.equal(
|
|
configSchema.isCapabilityConfigKey(key), false,
|
|
`${key} describes policy across lanes and must not be federated into one`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('no capability claims a policy-across-lanes key', () => {
|
|
const owned = new Set(Object.keys(registry.configSchema || {}));
|
|
for (const key of ['review.max_prompt_tokens', 'review.default_reviewers', 'review.reviewer_instances']) {
|
|
assert.equal(owned.has(key), false, `${key} must not appear in any capability slice`);
|
|
}
|
|
});
|
|
|
|
test('the container key remains central so whole-object get/set still works', () => {
|
|
// Only the per-slug leaves federate. Narrowing the container is a separate
|
|
// decision D9 did not make; locking today's behavior so a future change is
|
|
// deliberate rather than accidental.
|
|
assert.equal(configSchema.isCentralConfigKey('review.max_prompt_tokens_per_reviewer'), true);
|
|
});
|
|
|
|
test('a lane with no model flag and no host owns no config keys', () => {
|
|
// Absent-safe (ADR-2782 D4): qwen, cursor and coderabbit take neither a model
|
|
// argument nor a host, so they declare nothing. That is not a coverage hole.
|
|
const owners = Object.values(registry.configSchema || {}).map((e) => e && e.owner);
|
|
for (const laneId of ['qwen', 'cursor', 'coderabbit']) {
|
|
assert.equal(owners.includes(laneId), false, `${laneId} must declare no config keys`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('reviewer config federation — the disclosed tightening (#2797)', () => {
|
|
test('a model key naming no declared lane is rejected', () => {
|
|
// Was accepted by the central pattern ^review\.models\.[a-zA-Z0-9_-]+$.
|
|
// The exclusivity invariant forbids keeping that pattern alongside the
|
|
// federated keys, so this tightening is unavoidable — and desirable: it
|
|
// catches typos and stale keys that previously validated silently.
|
|
assert.equal(configSchema.isValidConfigKey('review.models.__not_a_lane__'), false);
|
|
});
|
|
|
|
test('a per-lane budget key naming no declared lane is rejected', () => {
|
|
assert.equal(
|
|
configSchema.isValidConfigKey('review.max_prompt_tokens_per_reviewer.__nope__'), false,
|
|
);
|
|
});
|
|
|
|
test('the hyphenated capability id is not a config key', () => {
|
|
// The directory is `lm-studio`; the config key uses the slug `lm_studio`.
|
|
// Conflating them yields a key no user has ever set.
|
|
assert.equal(configSchema.isValidConfigKey('review.lm-studio_host'), false);
|
|
assert.equal(configSchema.isValidConfigKey('review.lm_studio_host'), true);
|
|
});
|
|
});
|
|
|
|
describe('reviewer config federation — end-to-end through the CLI (#2797)', () => {
|
|
test('a federated model key round-trips: set, persist, get', (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
const set = runGsdTools('config-set review.models.ollama llama3', tmpDir);
|
|
assert.ok(set.success, `config-set must accept the federated key: ${set.error || ''}`);
|
|
|
|
const cfg = JSON.parse(fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
assert.equal(cfg.review?.models?.ollama, 'llama3', 'value must be persisted');
|
|
|
|
const get = runGsdTools('query config-get review.models.ollama --raw', tmpDir);
|
|
assert.ok(get.success, 'config-get must resolve the federated key');
|
|
});
|
|
|
|
test('a federated host key round-trips', (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
const set = runGsdTools('config-set review.ollama_host http://127.0.0.1:9999', tmpDir);
|
|
assert.ok(set.success, `config-set must accept the federated host key: ${set.error || ''}`);
|
|
|
|
const cfg = JSON.parse(fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
assert.equal(cfg.review?.ollama_host, 'http://127.0.0.1:9999');
|
|
});
|
|
|
|
test('a central survivor still round-trips unchanged', (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
const set = runGsdTools('config-set review.max_prompt_tokens 8000', tmpDir);
|
|
assert.ok(set.success, `the global budget must stay settable: ${set.error || ''}`);
|
|
|
|
const cfg = JSON.parse(fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
assert.equal(cfg.review?.max_prompt_tokens, 8000);
|
|
});
|
|
|
|
test('an existing config carrying federated keys loads unchanged — no migration', (t) => {
|
|
// The acceptance criterion: key NAMES and existing files are unchanged; only
|
|
// validation provenance moved. A file written before this phase must still load.
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
const cfgPath = path.join(tmpDir, '.planning', 'config.json');
|
|
const pre = {
|
|
review: {
|
|
models: { ollama: 'llama3', agy: 'gemini-3-pro' },
|
|
ollama_host: 'http://localhost:11434',
|
|
max_prompt_tokens_per_reviewer: { ollama: 6000 },
|
|
max_prompt_tokens: 8000,
|
|
},
|
|
};
|
|
fs.writeFileSync(cfgPath, JSON.stringify(pre, null, 2));
|
|
|
|
const get = runGsdTools('query config-get review.models.ollama --raw', tmpDir);
|
|
assert.ok(get.success, 'a pre-existing federated value must still resolve');
|
|
|
|
const after = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
|
assert.deepEqual(after, pre, 'reading must not rewrite the file');
|
|
});
|
|
|
|
test('an unset per-lane budget resolves to the -1 sentinel, not 0', (t) => {
|
|
// 0 is a LEGITIMATE per-lane budget meaning "do not trim this lane" — the
|
|
// guard in prepare_trimmed_prompt_for_reviewer returns early on it. A
|
|
// federated key always resolves to its declared default, so if that default
|
|
// were 0, "unset" and "deliberately disabled" would be indistinguishable and
|
|
// the workflow's fallback-to-global branch could not tell them apart.
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
const get = runGsdTools(
|
|
'query config-get review.max_prompt_tokens_per_reviewer.ollama --raw', tmpDir,
|
|
);
|
|
assert.strictEqual((get.output || '').trim(), '-1',
|
|
'an unset per-lane budget must read back as the -1 sentinel');
|
|
});
|
|
|
|
test('an explicit per-lane budget of 0 survives federation', (t) => {
|
|
// The regression this guards: treating 0 as "unset" in the workflow fallback
|
|
// would silently switch a user who disabled trimming for one lane onto the
|
|
// global budget instead.
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
const set = runGsdTools('config-set review.max_prompt_tokens_per_reviewer.ollama 0', tmpDir);
|
|
assert.ok(set.success, `config-set must accept an explicit 0: ${set.error || ''}`);
|
|
|
|
const get = runGsdTools(
|
|
'query config-get review.max_prompt_tokens_per_reviewer.ollama --raw', tmpDir,
|
|
);
|
|
assert.strictEqual((get.output || '').trim(), '0',
|
|
'an explicit 0 must survive as 0, distinguishable from unset');
|
|
});
|
|
|
|
test('an explicit per-lane budget value round-trips', (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
runGsdTools('config-set review.max_prompt_tokens_per_reviewer.ollama 6000', tmpDir);
|
|
const get = runGsdTools(
|
|
'query config-get review.max_prompt_tokens_per_reviewer.ollama --raw', tmpDir,
|
|
);
|
|
assert.strictEqual((get.output || '').trim(), '6000');
|
|
});
|
|
|
|
test('a model key naming no declared lane is rejected by the CLI', (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
const set = runGsdTools('config-set review.models.__not_a_lane__ x', tmpDir);
|
|
assert.equal(set.success, false, 'an unknown lane key must be rejected, not silently accepted');
|
|
});
|
|
});
|
|
|
|
describe('exclusivity gate sees dynamic patterns (#2797)', () => {
|
|
const slice = { type: 'string', default: '', description: 'x' };
|
|
const capWith = (key) => new Map([['acme', { config: { [key]: slice } }]]);
|
|
|
|
test('the shipped capability set passes the extended gate', () => {
|
|
// The production-shape row: proves the cutover is COMPLETE, not merely
|
|
// declared. If any federated key still had a central pattern, this fails.
|
|
const errors = capValidator.validateCrossCapability(
|
|
new Map(Object.entries(registry.capabilities || {})),
|
|
gen.loadCentralConfigKeys(),
|
|
gen.loadCentralConfigPatterns(),
|
|
);
|
|
assert.deepEqual(errors, [], `shipped capabilities must pass: ${JSON.stringify(errors)}`);
|
|
});
|
|
|
|
test('a federated key colliding with a central PATTERN fails the gate', () => {
|
|
// The gap this phase closes. `centralKeys` is built from validKeys alone, so
|
|
// before the fix this returned [] and the inert slice shipped green.
|
|
const errors = capValidator.validateCrossCapability(
|
|
capWith('review.reviewer_instances.acme.cli'),
|
|
new Set(),
|
|
gen.loadCentralConfigPatterns(),
|
|
);
|
|
assert.equal(errors.length, 1, `expected one pattern-collision error, got ${JSON.stringify(errors)}`);
|
|
assert.match(errors[0], /matched by central config-schema pattern/);
|
|
assert.match(errors[0], /review\.reviewer_instances\.acme\.cli/);
|
|
});
|
|
|
|
test('a federated key colliding with an exact central key still fails', () => {
|
|
const errors = capValidator.validateCrossCapability(
|
|
capWith('review.max_prompt_tokens'),
|
|
new Set(['review.max_prompt_tokens']),
|
|
[],
|
|
);
|
|
assert.equal(errors.length, 1);
|
|
assert.match(errors[0], /exists in the central config-schema/);
|
|
});
|
|
|
|
test('a cleanly federated key passes', () => {
|
|
const errors = capValidator.validateCrossCapability(
|
|
capWith('review.models.ollama'),
|
|
gen.loadCentralConfigKeys(),
|
|
gen.loadCentralConfigPatterns(),
|
|
);
|
|
assert.deepEqual(errors, []);
|
|
});
|
|
|
|
test('two capabilities declaring one key still collide', () => {
|
|
const errors = capValidator.validateCrossCapability(
|
|
new Map([
|
|
['a', { config: { 'x.y': slice } }],
|
|
['b', { config: { 'x.y': slice } }],
|
|
]),
|
|
new Set(),
|
|
[],
|
|
);
|
|
assert.equal(errors.length, 1);
|
|
assert.match(errors[0], /owned by both/);
|
|
});
|
|
|
|
test('omitting the patterns argument preserves the pre-#2797 signature', () => {
|
|
// Back-compat: existing callers passing two arguments must not start seeing
|
|
// pattern errors they cannot act on.
|
|
const errors = capValidator.validateCrossCapability(
|
|
capWith('review.reviewer_instances.acme.cli'),
|
|
new Set(),
|
|
);
|
|
assert.deepEqual(errors, []);
|
|
});
|
|
|
|
test('loadCentralConfigPatterns reads the same manifest the runtime reads', () => {
|
|
const pats = gen.loadCentralConfigPatterns();
|
|
assert.ok(pats.length > 0, 'expected the central schema to declare patterns');
|
|
for (const p of pats) assert.ok(p instanceof RegExp);
|
|
// The two families this phase removed must be gone.
|
|
const sources = pats.map((p) => p.source);
|
|
assert.equal(sources.some((s) => s.includes('review\\.models')), false,
|
|
'the review.models pattern must be removed — it is federated now');
|
|
assert.equal(sources.some((s) => s.includes('max_prompt_tokens_per_reviewer')), false,
|
|
'the per-reviewer budget pattern must be removed — it is federated now');
|
|
});
|
|
|
|
test('an absent manifest returns no patterns — the legitimate case', () => {
|
|
assert.deepEqual(gen.loadCentralConfigPatterns('/nonexistent/path.json'), []);
|
|
});
|
|
|
|
test('a MALFORMED manifest throws rather than silently reporting no patterns', (t) => {
|
|
// Fail-open here would defeat the gate this function exists to feed: with
|
|
// zero patterns, the pattern-collision check silently passes and an inert
|
|
// federated slice ships green. `loadCentralConfigKeys` reads the same file
|
|
// and throws on the same failure class — the two must not disagree about
|
|
// what a broken manifest means.
|
|
const dir = createTempDir('gsd-2797-badmanifest-');
|
|
t.after(() => cleanup(dir));
|
|
|
|
const bad = path.join(dir, 'broken.json');
|
|
fs.writeFileSync(bad, '{ "validKeys": [ this is not json');
|
|
|
|
assert.throws(
|
|
() => gen.loadCentralConfigPatterns(bad),
|
|
(err) => err && /malformed|JSON/i.test(String(err.message)),
|
|
'a broken manifest must fail closed, not return []',
|
|
);
|
|
});
|
|
|
|
test('an unreadable manifest path throws rather than returning no patterns', (t) => {
|
|
// A directory where a file is expected yields EISDIR, not ENOENT — the
|
|
// "absent" carve-out must not swallow it.
|
|
const dir = createTempDir('gsd-2797-dirmanifest-');
|
|
t.after(() => cleanup(dir));
|
|
|
|
assert.throws(
|
|
() => gen.loadCentralConfigPatterns(dir),
|
|
'reading a directory as the manifest must fail closed',
|
|
);
|
|
});
|
|
});
|