Some checks failed
Tests / PR mergeability (push) Successful in 18s
Tests / Base branch health (push) Successful in 9s
Tests / Detect test scope (push) Successful in 16s
Tests / lint-tests (push) Failing after 1m43s
Tests / plugin-validate (push) Successful in 58s
Tests / test (ubuntu-latest, 24, shard 1/3) (push) Failing after 19s
Tests / test (ubuntu-latest, 24, shard 2/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24, shard 3/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24) (push) Failing after 18s
Tests / test (inert CI) (push) Has been skipped
Tests / QA loop walk (smell ratchet) (push) Failing after 19s
Tests / Coverage gate (merged shards) (push) Has been skipped
Tests / Publish emitted-baseline artifact (push) Has been skipped
Duplicate auto-close sweep / sweep (push) Successful in 19s
CI timeout budget report / report (push) Failing after 14s
Close Draft PRs (sweep) / Sweep open draft PRs (push) Successful in 9s
Dismiss Unauthorized PR Approvals / dismiss-unauthorized-approval (push) Successful in 9s
Tests / conformance test (macos-latest, 24) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 1/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 2/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 3/3) (push) Has been cancelled
Tests / Required tests (push) Has been cancelled
607 lines
28 KiB
JavaScript
607 lines
28 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 fc = require('fast-check');
|
|
|
|
const { createTempProject, createTempDir, cleanup, runMsdTools } = require('./helpers.cjs');
|
|
const configSchema = require('../msd-core/bin/lib/config-schema.cjs');
|
|
const capValidator = require('../msd-core/bin/lib/capability-validator.cjs');
|
|
const registry = require('../msd-core/bin/lib/capability-registry.cjs');
|
|
const gen = require('../scripts/gen-capability-registry.cjs');
|
|
const configLoader = require('../msd-core/bin/lib/config-loader.cjs');
|
|
const { REVIEWER_LANES } = require('../msd-core/bin/lib/review-lane-descriptor.cjs');
|
|
|
|
/** Keys that moved to a lane capability, with the lane that must own each. */
|
|
const FEDERATED = {
|
|
'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 MODEL or HOST key (#3691 narrows #2797)', () => {
|
|
// Absent-safe (ADR-2782 D4): coderabbit takes neither a model
|
|
// argument nor a host. Under #2797 that meant "declares nothing" — the only
|
|
// way a lane owned a config key was via a model flag or a host. #3691 gave
|
|
// every CLI lane a `review.max_prompt_tokens_per_reviewer.<slug>` key, a
|
|
// third legitimate reason to own a key, so coderabbit now legitimately
|
|
// owns its own budget key. `cursor` gained a real model flag (#3653) and
|
|
// now legitimately owns `review.models.cursor` too, so it is excluded from
|
|
// this loop. The part of the #2797 invariant that still holds — a lane must
|
|
// never own a MODEL or HOST key it has no use for, or another lane's budget
|
|
// key — is what this asserts directly for the lane that still has
|
|
// neither.
|
|
for (const [key, entry] of Object.entries(registry.configSchema || {})) {
|
|
const owner = entry && entry.owner;
|
|
if (owner !== 'coderabbit') continue;
|
|
assert.ok(
|
|
!key.startsWith('review.models.') && !key.endsWith('_host'),
|
|
`${owner} must not own a model or host key, but owns "${key}"`,
|
|
);
|
|
assert.equal(
|
|
key, `review.max_prompt_tokens_per_reviewer.${owner}`,
|
|
`${owner} must own no key other than its own budget key, but owns "${key}"`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
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 = runMsdTools('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 = runMsdTools('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 = runMsdTools('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 = runMsdTools('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 = runMsdTools('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 = runMsdTools(
|
|
'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 = runMsdTools('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 = runMsdTools(
|
|
'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));
|
|
|
|
runMsdTools('config-set review.max_prompt_tokens_per_reviewer.ollama 6000', tmpDir);
|
|
const get = runMsdTools(
|
|
'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 = runMsdTools('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('msd-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('msd-2797-dirmanifest-');
|
|
t.after(() => cleanup(dir));
|
|
|
|
assert.throws(
|
|
() => gen.loadCentralConfigPatterns(dir),
|
|
'reading a directory as the manifest must fail closed',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// #3691 — no prompt cap reaches any CLI reviewer lane.
|
|
//
|
|
// Two independent defects, per .msd/bug/fix-3691-reviewer-prompt-budget/10-diagnosis.md:
|
|
// (1) every `transport: spawn` lane (claude, coderabbit, antigravity, cursor, gemini,
|
|
// codex, opencode) declares `promptBudgetKey: null`, so
|
|
// `budgetFor` (msd-core/bin/msd-tools.cjs) returns null for them unconditionally;
|
|
// (2) `review.max_prompt_tokens` is documented and in validKeys but
|
|
// `config-defaults.manifest.json` has no `review` section, so the resolved
|
|
// config surface never materializes the key at all — `budgetFor`'s global
|
|
// fallback is dead code.
|
|
//
|
|
// `budgetFor` is an unexported closure inside `routeReviewLane`
|
|
// (msd-core/bin/msd-tools.cjs:1373-1380, confirmed via `module.exports` at
|
|
// msd-tools.cjs:4410 — it is not there), so there is no in-process seam to call
|
|
// directly; every row below drives the real `review-lane plan` CLI end-to-end,
|
|
// same idiom as the federation suite above.
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
describe('reviewer prompt budget — #3691 (CLI lanes cannot receive a cap)', () => {
|
|
/** Write `.planning/config.json` for a temp project (overwrites any existing one). */
|
|
function writeReviewConfig(tmpDir, cfg) {
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify(cfg, null, 2));
|
|
}
|
|
|
|
/** Run `review-lane plan --selected <slugs>` and return the parsed plan array. */
|
|
function planLanes(tmpDir, slugs) {
|
|
const runDir = path.join(tmpDir, 'run');
|
|
const r = runMsdTools(
|
|
['review-lane', 'plan', '--selected', slugs.join(','), '--run-dir', runDir, '--repo-root', tmpDir],
|
|
tmpDir,
|
|
);
|
|
assert.equal(r.success, true, `review-lane plan failed: ${r.error || r.output}`);
|
|
return JSON.parse(r.output);
|
|
}
|
|
|
|
/** Run `review-lane plan` for exactly one slug and return its entry. */
|
|
function planLane(tmpDir, slug) {
|
|
const entry = planLanes(tmpDir, [slug]).find((e) => e.slug === slug);
|
|
assert.ok(entry, `no plan entry for slug "${slug}"`);
|
|
return entry;
|
|
}
|
|
|
|
test('row1 (regression): a CLI spawn lane with only the central global set still reports null today', (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
writeReviewConfig(tmpDir, { review: { max_prompt_tokens: 50000 } });
|
|
assert.equal(
|
|
planLane(tmpDir, 'codex').promptBudget, 50000,
|
|
'codex must inherit the central global once it declares a promptBudgetKey',
|
|
);
|
|
});
|
|
|
|
test('row2 (regression): the -1 per-lane sentinel on an already-budgeted lane must inherit the global', (t) => {
|
|
// The exact repro from 10-diagnosis.md.
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
writeReviewConfig(tmpDir, {
|
|
review: { max_prompt_tokens: 50000, max_prompt_tokens_per_reviewer: { ollama: -1 } },
|
|
});
|
|
assert.equal(
|
|
planLane(tmpDir, 'ollama').promptBudget, 50000,
|
|
'ollama already declares a promptBudgetKey, so this fails purely on defect 2 (the dead global)',
|
|
);
|
|
});
|
|
|
|
test('row3 (regression): the resolved config surface must carry review.max_prompt_tokens', (t) => {
|
|
// Asserts on the resolver's own surface, not only on promptBudget — the two
|
|
// defects are independent, and fixing only the lane keys would leave THIS
|
|
// row red even after row1/row2 go green.
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
writeReviewConfig(tmpDir, {
|
|
review: { max_prompt_tokens: 50000, max_prompt_tokens_per_reviewer: { ollama: -1 } },
|
|
});
|
|
const resolved = configLoader.loadConfigResolved(tmpDir);
|
|
const reviewKeys = Object.keys(resolved.config.review || {});
|
|
assert.ok(
|
|
Object.prototype.hasOwnProperty.call(resolved.config.review || {}, 'max_prompt_tokens'),
|
|
`resolved review surface is missing max_prompt_tokens, got keys: ${JSON.stringify(reviewKeys)}`,
|
|
);
|
|
assert.equal(resolved.config.review.max_prompt_tokens, 50000);
|
|
});
|
|
|
|
test('row4 (happy path): a per-lane value overrides the global', (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
writeReviewConfig(tmpDir, {
|
|
review: { max_prompt_tokens: 50000, max_prompt_tokens_per_reviewer: { codex: 12345 } },
|
|
});
|
|
assert.equal(planLane(tmpDir, 'codex').promptBudget, 12345);
|
|
});
|
|
|
|
test('row5 (boundary, limit-1): an explicit per-lane 0 means "do not trim", never the global', (t) => {
|
|
// The specific regression budgetFor's own comment warns about — 0 is a real
|
|
// value, not the unset sentinel, and must not be silently promoted to the
|
|
// global budget.
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
writeReviewConfig(tmpDir, {
|
|
review: { max_prompt_tokens: 50000, max_prompt_tokens_per_reviewer: { codex: 0 } },
|
|
});
|
|
assert.equal(planLane(tmpDir, 'codex').promptBudget, 0);
|
|
});
|
|
|
|
test('row6 (boundary, limit): the -1 sentinel inherits the global', (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
writeReviewConfig(tmpDir, {
|
|
review: { max_prompt_tokens: 50000, max_prompt_tokens_per_reviewer: { codex: -1 } },
|
|
});
|
|
assert.equal(planLane(tmpDir, 'codex').promptBudget, 50000);
|
|
});
|
|
|
|
test('row7 (boundary, limit+1): a real one-token budget is not read as a sentinel', (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
writeReviewConfig(tmpDir, {
|
|
review: { max_prompt_tokens: 50000, max_prompt_tokens_per_reviewer: { codex: 1 } },
|
|
});
|
|
assert.equal(planLane(tmpDir, 'codex').promptBudget, 1);
|
|
});
|
|
|
|
test('row8 (anti-tightening pin, green today and after): no config at all trims nothing, on every declared lane', (t) => {
|
|
// Guards against a "fix" that hard-codes a budget onto every lane: that
|
|
// would satisfy rows 1-7 while breaking every user who configured nothing.
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
writeReviewConfig(tmpDir, {});
|
|
const allSlugs = REVIEWER_LANES.map((l) => l.slug);
|
|
const plans = planLanes(tmpDir, allSlugs);
|
|
for (const slug of allSlugs) {
|
|
const entry = plans.find((e) => e.slug === slug);
|
|
assert.ok(entry, `no plan entry for slug "${slug}"`);
|
|
assert.equal(entry.promptBudget, null, `${slug}: the default resolved surface must not gain trimming`);
|
|
}
|
|
});
|
|
|
|
test('row9 (independence pin, green today and after): the three pre-existing http lanes are unaffected', (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
writeReviewConfig(tmpDir, { review: { max_prompt_tokens_per_reviewer: { ollama: 777 } } });
|
|
const plans = planLanes(tmpDir, ['ollama', 'lm_studio', 'llama_cpp']);
|
|
assert.equal(plans.find((e) => e.slug === 'ollama').promptBudget, 777, 'ollama must keep resolving its own configured value');
|
|
assert.equal(plans.find((e) => e.slug === 'lm_studio').promptBudget, null, 'lm_studio must not shift with no config of its own');
|
|
assert.equal(plans.find((e) => e.slug === 'llama_cpp').promptBudget, null, 'llama_cpp must not shift with no config of its own');
|
|
});
|
|
|
|
test('row10 (negative space, green today and after): config-set still rejects an unknown per-lane slug', (t) => {
|
|
// #2841 made an unknown `review.max_prompt_tokens_per_reviewer.<x>` slug an
|
|
// error; extending the family to nine more lanes must not loosen that.
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
const set = runMsdTools('config-set review.max_prompt_tokens_per_reviewer.not_a_lane 5', tmpDir);
|
|
assert.equal(set.success, false, 'an unknown lane slug must still be rejected, not silently accepted');
|
|
});
|
|
|
|
// ─── property: the budget-resolution contract ──────────────────────────
|
|
//
|
|
// `budgetFor`'s contract (msd-core/bin/msd-tools.cjs:1361-1380): the resolved
|
|
// budget is the per-lane value when it is a finite number other than -1;
|
|
// otherwise the global when finite; otherwise null.
|
|
//
|
|
// Driven through the REAL CLI (review-lane plan against a temp project), not
|
|
// a reimplementation of the formula — `budgetFor` is not exported (see the
|
|
// describe-block header), so this is the lowest reachable seam that still
|
|
// exercises production code rather than a copy of it.
|
|
//
|
|
// Generator note on non-finite numbers: JSON cannot encode a literal NaN or
|
|
// Infinity (`JSON.stringify(NaN) === 'null'`), so a real `.planning/config.json`
|
|
// can never carry a numeric NaN/Infinity in the first place — driving those
|
|
// exact values through this seam would not be testing anything reachable.
|
|
// The string forms below ('NaN', 'Infinity', 'not-a-number') ARE reachable
|
|
// (JSON strings survive the round trip) and exercise the identical
|
|
// `typeof v === 'number' && Number.isFinite(v)` guard: a string is rejected
|
|
// by `typeof` exactly as a real NaN would be rejected by `Number.isFinite`.
|
|
test('property: per-lane wins when finite and not -1, else the global when finite, else null', () => {
|
|
const perLaneArb = fc.oneof(
|
|
fc.integer({ min: -1000, max: 1000000 }),
|
|
fc.constantFrom(-1, 0),
|
|
fc.constantFrom('NaN', 'Infinity', 'not-a-number'),
|
|
fc.constant(undefined),
|
|
);
|
|
const globalArb = fc.oneof(
|
|
fc.integer({ min: 0, max: 1000000 }),
|
|
fc.constant(null),
|
|
fc.constantFrom('NaN', 'not-a-number'),
|
|
fc.constant(undefined),
|
|
);
|
|
|
|
fc.assert(
|
|
fc.property(perLaneArb, globalArb, (p, g) => {
|
|
const review = {};
|
|
if (g !== undefined) review.max_prompt_tokens = g;
|
|
if (p !== undefined) review.max_prompt_tokens_per_reviewer = { codex: p };
|
|
|
|
const tmpDir = createTempProject();
|
|
try {
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ review }, null, 2));
|
|
const runDir = path.join(tmpDir, 'run');
|
|
const r = runMsdTools(
|
|
['review-lane', 'plan', '--selected', 'codex', '--run-dir', runDir, '--repo-root', tmpDir],
|
|
tmpDir,
|
|
);
|
|
if (!r.success) return false;
|
|
const entry = JSON.parse(r.output).find((e) => e.slug === 'codex');
|
|
if (!entry) return false;
|
|
|
|
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
const expected = isNum(p) && p !== -1 ? p : (isNum(g) ? g : null);
|
|
return entry.promptBudget === expected;
|
|
} finally {
|
|
cleanup(tmpDir);
|
|
}
|
|
}),
|
|
// Bounded low: each run spawns a real msd-tools child process (plus its own
|
|
// nested `query resolve-execution` spawn), so this is deliberately far
|
|
// below the suite's usual 200-run property budget — see the file header
|
|
// note on why the CLI is nonetheless the right seam.
|
|
{ seed: 36910824, numRuns: 20 },
|
|
);
|
|
});
|
|
});
|