* chore(#2794): single-source the reviewer invocation contract Phase 1 of epic #2782 (ADR-2782). Introduces one core descriptor table as the declared contract for all 11 cross-AI reviewer lanes, and the DEFECT.GENERATIVE-FIX parity assertion the roster has never had. The lane contract lived in three unrelated surfaces — the roster, ~640 lines of hand-authored per-CLI bash in invoke_reviewers, and the write_reviews section headings — so cross-cutting fixes landed per-leg (#2494 and #2605 were the same empty-output defect filed twice). - src/review-lane-descriptor.cts: frozen table declaring per lane the slug, flags, probe, invoke shape, timeout floor, empty-output policy, REVIEWS.md section, evidence class, required binaries, prompt-budget key and handler. Field names track ADR-2782 D1/D2/D6/D7 verbatim so Phase 2 harvests the shape with no translation layer. It declares; it does not execute — invoke_reviewers iterates in Phase 5b. - checkReviewerLaneParity: bidirectional parity across descriptor, roster, invoke_reviewers legs and write_reviews sections. Forward-only would miss the failure it exists to catch (#2718 added a leg, #2781 was the drift). ADR-1517 instance headings are exempt per D8. - Legs carry an explicit <!-- reviewer-lane: slug --> marker; five non-lane bold labels share the bold-then-fence shape a heuristic matcher would key on. - ADR-2782 D4: an explicitly-flagged reviewer that cannot run is now an error in both the core module and the workflow prose that mirrors it. A code-only change would be unobservable — the module has no production caller; the workflow narrates the policy. Discovery paths (--all, review.default_reviewers) stay lenient. - Fixes the qwen leg, the last one discarding stderr to /dev/null. Two ADR-2782 D2 vocabulary widenings were forced by surveying the shipped legs: promptChannel 'none' (CodeRabbit is fed no prompt) and outputChannel 'file-arg' (Codex writes via -o and discards stdout, #1698). Both are additive and closed; Phase 2 owns the validator. Closes #2690 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2794): make the parity checker total and pin the lane slug grammar Findings from the orthogonal review passes. Spec axis — the module claimed its vocabulary tracked ADR-2782 D1/D2 "verbatim" while diverging in three undisclosed ways, which is the translation layer Phase 2 was supposed to be spared: - `transport` moves from `invoke.transport` to the LANE level, a sibling of `probe`/`invoke`, exactly as D1's manifest example places it. The nested form read better as a TS discriminated union; the union is now discriminated at the lane level instead, which costs nothing. - The header and the CONTEXT.md glossary now enumerate all FOUR widenings (adding `outputArg` and `flags[]`), not two. Standards axis — CLAUDE.md requires a fast-check property test for a parser, and `checkReviewerLaneParity` parses markdown for markers and headings. Adding one found two real defects that the hand-written matrix missed: - NOT TOTAL: a malformed descriptor entry threw on `lane.flags` iteration, contradicting the module's own "never throws" claim. Every field is now narrowed from `unknown` at the trust boundary and reported as MALFORMED_LANE / INVALID_SLUG. This matters because Phase 2 feeds this function third-party overlay data, and a parity gate that crashes is indistinguishable from one never run. - SILENT GRAMMAR MISMATCH: LEG_MARKER_RE captures only [a-z0-9_-], so a slug outside that class was unmatchable — its marker could be present and correct and the scan would still report LEG_MARKER_MISSING forever. LANE_SLUG_RE now pins the grammar and a violating slug is reported INVALID_SLUG. A loud named violation beats a silent miss. Generators are document-shaped, not writer-seeded (CONTRIBUTING #2371): seeding from the module's own matchers could only produce documents those matchers already recognize. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#2794): register the new bin/lib module in the ESLint ignore list The remote runner caught this; lint:ci did not, because the invariant lives in the test suite rather than the lint chain: tests/repo-invariants.test.cjs "each bin/lib/*.cjs is linted xor ignored according to migration state" -> tsc-generated bin/lib modules not yet added to ESLint ignore list: review-lane-descriptor.cjs Adding a src/*.cts module ripples to six surfaces (.gitignore, the ESLint ignore list, docs/INVENTORY-MANIFEST.json, the CONTEXT.md glossary, the capability/inventory manifests, and any size baseline). The other five were covered; this was the miss. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#2794): amend ADR-2782 D1/D2/D8 with the vocabulary Phase 1 surfaced Building the Phase 1 descriptor table against all eleven shipped legs is the first time every lane's contract was written in one place, and it surfaced four cases the ADR's original survey did not cover. Amending the design lock rather than diverging from it, so Phase 2 (#2795) implements the manifest validator against the amended vocabulary instead of rediscovering the gaps. All four are additive widenings of closed enums; no decision reverses: - D2 promptChannel gains `none` — coderabbit is fed no prompt at all, it reviews the working-tree diff. - D2 outputChannel gains `file-arg` — the ADR called a file-writing lane a shape a real CLI *could* take; codex already is one, writing via -o/--output-last-message and discarding stdout (#1698). - D2 gains `outputArg`, required iff file-arg — knowing the review lands in a file is useless without the argument naming it. - D1 `flag` becomes `flags[]` and D8's uniqueness flattens across lanes — antigravity is selected by both --antigravity and --agy, which a single-valued field cannot express. This is the same evidence path that produced the openai-http transport: the vocabulary widens on a lane that exists, under review, never on speculation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#2794): backfill changeset pr number to 2820 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
167 lines
7.6 KiB
JavaScript
167 lines
7.6 KiB
JavaScript
// allow-test-rule: source-text-is-the-product (see #2794)
|
|
// The qwen reviewer dispatch block in gsd-core/workflows/review.md IS the runtime
|
|
// contract — the workflow's text is what the reviewing agent executes. This suite
|
|
// extracts that shell block verbatim and runs it under a real bash against a stubbed
|
|
// `qwen`, so the shipped guard is what gets exercised rather than a reimplementation
|
|
// of it. Assertions are on the guard's documented output contract (the stub line the
|
|
// consensus step must be able to tell apart from a clean empty review, and the
|
|
// presence of the captured stderr), not incidental string matching.
|
|
|
|
/**
|
|
* Regression tests for #2794 — the qwen reviewer leg was the last one still
|
|
* sending stderr to /dev/null.
|
|
*
|
|
* Every other lane captures stderr to a `.err` sidecar and appends it to the
|
|
* empty-output stub (#2494 for claude/gemini, #2605 for the local servers and
|
|
* CodeRabbit). qwen alone ran `2>/dev/null` and wrote a bare
|
|
* "Qwen review failed or returned empty output." with no diagnostic, so a missing
|
|
* binary, an auth prompt, a rate-limit, and a genuinely empty review were all
|
|
* indistinguishable — the same cross-cutting defect landing per-leg that ADR-2782
|
|
* exists to end.
|
|
*
|
|
* These tests fail against pre-fix review.md: the stub carries no stderr, so the
|
|
* diagnostic assertion in E2 trips.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { describe, test, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { spawnSync } = require('node:child_process');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const REVIEW_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'review.md');
|
|
|
|
// Normalize CRLF: on a Windows autocrlf checkout every line carries a trailing
|
|
// \r, which would mangle the extracted block's redirect tokens and defeat the
|
|
// fence regex below.
|
|
const WORKFLOW = fs.readFileSync(REVIEW_PATH, 'utf-8').replace(/\r\n/g, '\n');
|
|
|
|
/**
|
|
* Extract the qwen dispatch block verbatim. If review.md changes the block's
|
|
* shape this throws and the test fails loudly — intended coupling, the same
|
|
* contract the #2494 and #2605 suites pin for their legs.
|
|
*/
|
|
function extractQwenBlock() {
|
|
// `\r?\n` throughout: WORKFLOW is CRLF-normalized above, but the anchors stay
|
|
// CRLF-tolerant so the regex cannot silently miss on a Windows checkout.
|
|
const m = WORKFLOW.match(
|
|
/<!--\s*reviewer-lane:\s*qwen\s*-->\r?\n[\s\S]*?```bash\r?\n([\s\S]*?)\r?\n```/,
|
|
);
|
|
assert.ok(m, 'review.md must define the qwen reviewer dispatch as a bash block (#2794)');
|
|
return m[1];
|
|
}
|
|
|
|
const QWEN_BLOCK = extractQwenBlock();
|
|
|
|
// The extracted block is POSIX shell. Git Bash on Windows ignores Node's chmod
|
|
// exec bit for PATH-executed extension-less scripts, so the stub would never run
|
|
// there; the guard logic is platform-independent and is asserted in full on every
|
|
// macOS/Linux leg.
|
|
const WIN32_SKIP =
|
|
'extracted block is POSIX shell; guard logic is platform-independent and asserted on macOS/Linux';
|
|
|
|
let sandbox;
|
|
|
|
before(() => { sandbox = createTempDir('gsd-2794-'); });
|
|
after(() => { cleanup(sandbox); });
|
|
|
|
/**
|
|
* Run the extracted block with `{run_dir}` pointed at a fresh run directory and
|
|
* `qwenBody` installed on PATH as `qwen`. Pass `qwenBody: null` to run with no
|
|
* qwen on PATH at all.
|
|
*/
|
|
function runQwenLeg(qwenBody) {
|
|
// Single exec site so the Windows guard lives in one place: Git Bash (msys2)
|
|
// ignores Node's chmod exec bit for PATH-executed extension-less scripts
|
|
// (DEFECT.WINDOWS-TEST-PORTABILITY). Every test below is skipped on win32 —
|
|
// this early return keeps the exec unreachable there rather than relying on
|
|
// the skip alone.
|
|
if (process.platform === 'win32') return null;
|
|
|
|
const caseDir = fs.mkdtempSync(path.join(sandbox, 'run-'));
|
|
const runDir = path.join(caseDir, 'run');
|
|
const binDir = path.join(caseDir, 'bin');
|
|
fs.mkdirSync(runDir);
|
|
fs.mkdirSync(binDir);
|
|
|
|
if (qwenBody !== null) {
|
|
const stub = path.join(binDir, 'qwen');
|
|
fs.writeFileSync(stub, qwenBody);
|
|
fs.chmodSync(stub, 0o755);
|
|
}
|
|
|
|
fs.writeFileSync(path.join(runDir, 'gsd-review-prompt.md'), '# review prompt\n');
|
|
|
|
const script = QWEN_BLOCK.split('{run_dir}').join(runDir);
|
|
const result = spawnSync('bash', ['-c', script], {
|
|
encoding: 'utf8',
|
|
timeout: 30000,
|
|
killSignal: 'SIGKILL',
|
|
// An empty PATH would also break `cat`; prepend the stub dir instead so the
|
|
// no-qwen case still resolves the shell builtins the block needs.
|
|
env: { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH}` },
|
|
});
|
|
|
|
const reviewPath = path.join(runDir, 'gsd-review-qwen.md');
|
|
return {
|
|
result,
|
|
reviewPath,
|
|
errPath: path.join(runDir, 'gsd-review-qwen.err'),
|
|
review: fs.existsSync(reviewPath) ? fs.readFileSync(reviewPath, 'utf-8') : null,
|
|
};
|
|
}
|
|
|
|
const STUB_STDERR = 'gsd-2794-stub: qwen: authentication required';
|
|
|
|
describe('qwen reviewer leg — empty-output guard (#2794)', () => {
|
|
test('writes the review on success', { skip: process.platform === 'win32' ? WIN32_SKIP : false }, () => {
|
|
const out = runQwenLeg('#!/bin/sh\necho "## Qwen findings"\nexit 0\n');
|
|
assert.ok(out.review !== null, 'review file must exist');
|
|
assert.match(out.review, /Qwen findings/);
|
|
// A successful lane must NOT be decorated with the failure stub.
|
|
assert.doesNotMatch(out.review, /failed or returned empty output/i);
|
|
});
|
|
|
|
test('a failed lane surfaces its stderr in the review stub', { skip: process.platform === 'win32' ? WIN32_SKIP : false }, () => {
|
|
// THE regression row. Pre-fix the block ran `2>/dev/null`, so this stderr
|
|
// was discarded and the stub carried no diagnostic at all.
|
|
const out = runQwenLeg(`#!/bin/sh\necho "${STUB_STDERR}" >&2\nexit 1\n`);
|
|
assert.ok(out.review !== null, 'review file must exist after a failed lane');
|
|
assert.notStrictEqual(out.review.trim(), '', 'review file must not be empty');
|
|
assert.match(
|
|
out.review,
|
|
/Qwen review failed or returned empty output/i,
|
|
'stub must name the lane as failed-or-empty so consensus can tell it apart',
|
|
);
|
|
assert.ok(
|
|
out.review.includes(STUB_STDERR),
|
|
`stub must carry the captured stderr; got: ${JSON.stringify(out.review)}`,
|
|
);
|
|
assert.ok(fs.existsSync(out.errPath), 'stderr sidecar must be written');
|
|
});
|
|
|
|
test('a silently empty lane still produces a diagnosable stub', { skip: process.platform === 'win32' ? WIN32_SKIP : false }, () => {
|
|
// Boundary: nothing on either stream. The stub line is the only signal, and
|
|
// it must still be present so write_reviews does not render the lane as a
|
|
// reviewer that ran cleanly with nothing to report.
|
|
const out = runQwenLeg('#!/bin/sh\nexit 0\n');
|
|
assert.ok(out.review !== null, 'review file must exist');
|
|
assert.notStrictEqual(out.review.trim(), '', 'review file must not be zero-byte');
|
|
assert.match(out.review, /Qwen review failed or returned empty output/i);
|
|
});
|
|
|
|
test('a missing qwen binary produces a diagnosable stub', { skip: process.platform === 'win32' ? WIN32_SKIP : false }, () => {
|
|
const out = runQwenLeg(null);
|
|
assert.ok(out.review !== null, 'review file must exist when the binary is absent');
|
|
assert.notStrictEqual(out.review.trim(), '', 'review file must not be zero-byte');
|
|
assert.match(out.review, /Qwen review failed or returned empty output/i);
|
|
// The shell's own "command not found" lands in the sidecar and is appended,
|
|
// which is the whole point of capturing instead of discarding.
|
|
assert.ok(fs.existsSync(out.errPath), 'stderr sidecar must be written');
|
|
});
|
|
});
|