* chore(#2799): resolve reviewer lanes into executable invocation plans Phase 5b of ADR-2782. Adds the resolver and runner that let invoke_reviewers iterate declared lanes instead of hand-authored per-CLI bash. Five additive descriptor amendments, each forced by a lane that ships today: - LaneHandler gains 'opencode' — the lane rebuilds its review from assistant text parts of a --format json stream; a plain stdout copy re-breaks #1936. - modelConfigKey — antigravity's key is review.models.agy, not .antigravity, so resolving by slug silently dropped a configured model. - defaultHost/fallbackModel — Phase 4 federated every *_host with a default of empty string; the real fallback only existed in the bash. - args becomes an argv template with a closed four-placeholder vocabulary. Positional splicing produced 'codex --model M -o F exec --ephemeral', which is not a valid invocation: codex injects in the middle, twice. - kimi-code lane, with the bounded command-capability probe (needle --output-format) that tells Kimi Code from the legacy python kimi-cli. Parity gate re-pointed: the workflow-text families it scanned are the text this phase deletes, so they are replaced by descriptor-to-registry parity plus an anti-parity check that no bespoke leg returns. jq, curl and external timeout/gtimeout all drop out of the review path. Refs #2782 * chore(#2799): add review-lane query surface and widen the manifest vocabulary Adds the gsd-tools 'review-lane' route (plan/invoke/sections) the workflow loops over, projects all twelve lanes into their capability manifests, and widens capability-validator for the amendments. opencode admitted to VALID_LANE_HANDLERS under the second arm of the enum's own admission rule: one lane, justified by a documented upstream defect data cannot express (#1936 — the agent can end its turn with zero output tokens and --format default then drops the assistant text entirely). Two bugs caught by an end-to-end stub run and fixed here: - loadConfigResolved returns a provenance wrapper, not the config; using it directly resolved every key to undefined, which reads as 'nothing configured' and silently dropped every model override. - hasBinary used shell:true with an args array (Node 26 DEP0190). Replaced with a PATH scan that spawns nothing at all. Refs #2782 * chore(#2799): iterate declared lanes in invoke_reviewers and write_reviews Replaces the eleven hand-authored per-CLI bash legs with a loop over resolved lanes, and renders REVIEWS.md sections from each lane's declared reviewsSection instead of thirteen hardcoded headings. review.md drops from 1104 lines to 507 (61KB to 28.7KB). Parity gate re-pointed, as agreed: the leg-marker and section-heading families scanned exactly the text this phase deletes, so they are replaced by descriptor-to-registry parity in both directions, plus an anti-parity check that fires if a bespoke leg is ever re-added. Enum, emitting sites and the Object.keys lock moved together. The budget-trim helper is hoisted out of the Ollama leg: it was always lane-agnostic, and any lane may now declare a promptBudgetKey. Refs #2782 * feat(#2799): bind the consented egress host and re-verify it at invocation Completes ADR-2782 D5. Rule 1 was recorded in the ADR as delivered by Phase 3 but was not implemented: ConsentRecord had no host field and nothing in the tree bound one, so this phase's rule-4 comparison had no baseline. ConsentRecord gains an OPTIONAL reviewerHost. Optional is the whole design: isValidConsentRecord does not require it, so every record already on disk stays valid and no re-consent storm fires (D4 rule 5). It is deliberately excluded from disclosureSignature — the loader has no config resolver, so folding a config-derived value in would make loader and lifecycle compute different signatures for the same manifest and re-prompt forever. Install resolves hostConfigKey (falling back to the lane's declared defaultHost, which is what the invocation path uses) and records it. Invocation re-resolves and blocks on mismatch rather than silently redirecting. Absence allows: no record, or a record predating the field, means nothing to compare — denying there would break every existing local-model user on upgrade. Refs #2782 * test(#2799): cover the resolver, runner and handlers; retarget the parity suites Adds the golden invocation-plan table (one row per shipped lane, derived from the bash legs rather than the descriptor types) plus runner coverage for the probe, empty-output policy, the three handlers and the egress check. Retargets the existing suites onto the new contract: descriptor-to-registry parity, the anti-parity check, the opencode handler, and the twelfth lane. Two corrections found by running them: - modelConfigKey was required; that breaks D4 rule 2, since a reviewer manifest authored before this phase would fail validation on upgrade. It is optional, read as null when absent. - the antigravity non-zero-exit test pre-seeded the transcript, which asserted that a STALE entry leaks through — the exact bug the watermark prevents. The spawn now appends, as the real tool does. Refs #2782 * fix(#2799): restore agy --add-dir and the self-report prompt in the handler Retargeting the three legacy reviewer suites off the deleted bash surfaced two real regressions in the port, both #2176: - --add-dir was dropped. Without it agy's permission context never receives the cwd repo, so the agent anchors on its own scratch dir and reviews the plan text in isolation — the exact failure the Review Instructions forbid. It is capability-probed, because an older agy rejects the unknown flag outright and a lane that fails to start is worse than one running on the prompt anchor. - the prompt lost the clause mandating a REVIEWED-WITHOUT-REPO-ACCESS self-report, which is what makes a blind review distinguishable from a grounded one. antigravity now builds its own prompt variant. Also ports the #2073 mode-2 cli.log diagnostic, which was dropped: a pinned model that 404s exits 0 with empty stdout AND an empty transcript, so agy's own log is the only evidence that anything failed. The three suites now assert against the plan and the handler instead of matching fence text, so they no longer need allow-test-rule exemptions. Refs #2782 * docs(#2799): document the declared lanes, the new flag, and dropped prerequisites COMMANDS.md gains --kimi-code and replaces the jq-prerequisite paragraph, which is now false: no lane requires jq, curl or an external timeout. Adds the changed-egress-destination behavior, since a blocked lane is something a user can hit. CONFIGURATION.md records that the model config key is declared per lane rather than derived from the flag — antigravity's is review.models.agy — and adds review.models.kimi-code. reviewer-instances.md now routes an instance through its lane's single invocation seam instead of a copied per-adapter bash block, which is what lets a cross-cutting fix reach instances for free. That required implementing the --model/--agent/--as flags it documents; --model re-resolves through the lane's argv template rather than splicing, so the flag lands where the lane declares it rather than ahead of a subcommand. CONTEXT.md glossary gains both new modules. Refs #2782 * chore(#2799): drop the stale emitted-drift acknowledgment The only entry was #2797's, acknowledging COMMENT-ONLY GROWTH in review.md. That file now shrinks by ~32KB and every emitted hash that moved is attributable to this diff, so the ack no longer explains anything. Removing the last entry means removing the file: its presence is the alarm, and an empty one signals nothing. Verified by deleting it and re-running the attribution and provenance gates plus lint:ci — all green without it. Refs #2782 * docs(#2799): record the Phase 5b vocabulary widenings in ADR-2782 Five additive amendments, each forced by a lane that ships today, plus two corrections the phase had to make rather than work around: - D5 rule 1 was recorded as delivered by Phase 3 and was not implemented, so this phase's rule-4 comparison had no baseline. Recorded because an ADR asserting a rule was delivered is exactly what stops a later phase checking. - The DEFECT.GENERATIVE-FIX gate is re-pointed: its workflow-text families scanned the text this phase deletes. Also records that D7's 'skip the probe where no bounding mechanism exists' carve-out is obsolete — in practice it meant the Antigravity lane ran unbounded on every stock macOS host, which ships neither timeout nor gtimeout. Refs #2782 * fix(#2799): close four defects found by adversarial review Two confirmed bugs, both reproduced before fixing: - resolveLanePlan was not total. An openai-http lane with a missing or non-object invoke dereferenced inv.hostConfigKey and threw, contradicting the module's own documented contract; the spawn branch guarded correctly and the http branch did not. The CLI seam resolves every selected lane in one map, so one malformed overlay manifest would have aborted the whole review rather than dropping its own lane. Guarded, plus a per-lane try/catch at the seam so a throw can never take down siblings. - A reviewer-instance model was silently dropped for any lane declaring modelConfigKey null (cursor, qwen, coderabbit). reviewer_instances validates that cli is a known slug but never that the slug accepts a model, so a user could configure one, get a clean run, and never learn a different model reviewed their plan. Now warns explicitly. Two hardening fixes: - The slug is concatenated into artifact paths, so LANE_SLUG_RE is enforced in the resolver rather than inherited from a validator that does not run on this path — the module documents itself as the overlay-manifest trust boundary, so it should not depend on someone else having checked. - normalizeHost mangled a scheme-less value: new URL('localhost:11434') parses with an empty hostname, so it became 'localhost://11434' and was compared and requested as if real. An empty hostname now means not-a-URL. Also documents the one gap that cannot be closed here: the antigravity watermark is keyed by workspace, so two concurrent reviews of the same repo share a transcript. agy exposes no per-invocation id to filter on, so the handler now states which half of its never-stale guarantee actually holds. Refs #2782 * test(#2799): retarget the remaining eight review.md-asserting suites The remote runner found 37 failures the local sweep missed (it hit the shell's two-minute cap before reaching these). All eight extract per-CLI bash from review.md that this phase deletes; each protects a real invariant, so each is retargeted onto the plan, the runner or the handler rather than removed. Three real defects surfaced by doing so: - effort args never reached ANY lane. model-resolver.cjs exports no resolveExecution, so effortFor silently returned [] every time. Restored by calling the same bounded resolve-execution query the bash legs used — and NOT with --raw, which prints the resolved effort rather than the picked field, so claude got 'low' instead of '--effort low'. - the timeout guidance lost 'a silent empty output is a timeout kill, not a crash' — the operator note that exists because of the Codex 0xc0000142 misdiagnosis. Restored. - the opencode handler dropped EMPTY assistant text parts. The shipped jq was , and only substitutes for false/null — an empty string is truthy in jq and contributed a blank line. Found by a property test shrinking to ['', '']. The opencode property suite no longer spawns jq at all, which deletes the #2099 hang mechanism it was architected around rather than mitigating it. Refs #2782 * fix(#2799): register the two new generated modules, and untrack them The remote runner caught build output committed to git. Both new modules compile from src/*.cts into gsd-core/bin/lib/*.cjs, and every sibling generated that way is gitignored and eslint-ignored (ADR-457) - including Phase 1's own review-lane-descriptor.cjs. Mine were neither, so repo-invariants' "each bin/lib/*.cjs is linted xor ignored according to migration state" failed. Registered both in .gitignore and eslint.config.mjs alongside the Phase 1 module, and dropped them from the index. Nothing about the shipped behaviour changes; the artifacts are rebuilt by build:lib. This is the new-.cts-module registration ripple, and it is the one part of it I had not completed - the CONTEXT.md glossary and the inventory manifest were already done. Refs #2782 * chore(#2799): backfill changeset pr number to 2861 * chore(#2799): backfill changeset pr number to 2861 --------- Co-authored-by: Test <test@example.com>
418 lines
20 KiB
JavaScript
418 lines
20 KiB
JavaScript
'use strict';
|
|
|
|
// allow-test-rule: source-text-is-the-product
|
|
// Workflow markdown is runtime contract; these assertions verify deployed behavior text.
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
describe('review workflow default reviewer selection contract (#3079)', () => {
|
|
const workflow = fs.readFileSync(
|
|
path.join(process.cwd(), 'gsd-core', 'workflows', 'review.md'),
|
|
'utf8'
|
|
);
|
|
|
|
test('documents review.default_reviewers no-flag behavior', () => {
|
|
assert.ok(
|
|
workflow.includes('review.default_reviewers'),
|
|
'review workflow must reference review.default_reviewers for no-flag selection'
|
|
);
|
|
});
|
|
|
|
test('documents precedence order with explicit flags and --all overrides', () => {
|
|
assert.ok(
|
|
workflow.includes('Individual reviewer flags') &&
|
|
workflow.includes('--all') &&
|
|
workflow.includes('review.default_reviewers'),
|
|
'review workflow must document precedence: flags > --all > review.default_reviewers'
|
|
);
|
|
});
|
|
|
|
test('documents unknown/undetected configured slug handling', () => {
|
|
assert.ok(
|
|
workflow.includes('Unknown slugs warn') &&
|
|
workflow.includes('Known-but-undetected slugs'),
|
|
'review workflow must document unknown and undetected slug handling'
|
|
);
|
|
});
|
|
|
|
test('documents failure behavior when all configured reviewers unavailable', () => {
|
|
assert.ok(
|
|
workflow.includes('all configured reviewers are unavailable') &&
|
|
workflow.includes('fail'),
|
|
'review workflow must document failure path when configured reviewers are unavailable'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('review workflow source-grounding requirement in build_prompt (#1318)', () => {
|
|
const workflow = fs.readFileSync(
|
|
path.join(process.cwd(), 'gsd-core', 'workflows', 'review.md'),
|
|
'utf8'
|
|
);
|
|
|
|
// Extract ONLY the build_prompt Review Instructions region — the slice of the
|
|
// assembled prompt that is actually piped to the prompt-fed reviewers. The
|
|
// grounding instruction is worthless unless it lives HERE (#1318): asserting
|
|
// against the whole file would still pass if the text drifted into a note,
|
|
// the consensus step, or a comment that never reaches a reviewer's stdin.
|
|
//
|
|
// The region is the fenced prompt's `## Review Instructions` section, from
|
|
// that heading up to the next `## ` heading inside the same fenced block.
|
|
function buildPromptReviewInstructions(src) {
|
|
// Locate the build_prompt step, then its first fenced ```markdown block.
|
|
// NOTE: '<step name="build_prompt">' is a literal anchor — update it if the
|
|
// step is ever renamed or gains/reorders attributes.
|
|
const stepIdx = src.indexOf('<step name="build_prompt">');
|
|
assert.ok(stepIdx !== -1, 'build_prompt step must exist');
|
|
|
|
// Fence-run-aware extraction (CommonMark): a naive `indexOf('\n```')` would
|
|
// terminate at the FIRST triple-backtick line, truncating the prompt if its
|
|
// body embeds a fenced code example. Mirror the close rule used by
|
|
// src/markdown-sectionizer.cts stripFencedCode: the closing fence is a line
|
|
// of the SAME char and >= the opener's run length, with no trailing content,
|
|
// so a shorter nested fence inside the block is treated as content (#1318).
|
|
// Backtick-fenced only by design — the build_prompt block is ```markdown.
|
|
const lines = src.slice(stepIdx).split('\n');
|
|
const openRe = /^ {0,3}(`{3,})markdown\s*$/;
|
|
let openLen = 0;
|
|
let bodyStart = -1;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const m = openRe.exec(lines[i].replace(/\r$/, ''));
|
|
if (m) { openLen = m[1].length; bodyStart = i + 1; break; }
|
|
}
|
|
assert.ok(bodyStart !== -1, 'build_prompt must contain a ```markdown prompt block');
|
|
const closeRe = new RegExp(`^ {0,3}\`{${openLen},}\\s*$`);
|
|
let bodyEnd = -1;
|
|
for (let i = bodyStart; i < lines.length; i++) {
|
|
if (closeRe.test(lines[i].replace(/\r$/, ''))) { bodyEnd = i; break; }
|
|
}
|
|
assert.ok(bodyEnd !== -1, 'build_prompt markdown fence must be closed');
|
|
const fenced = lines.slice(bodyStart, bodyEnd).join('\n');
|
|
|
|
const hdr = fenced.indexOf('## Review Instructions');
|
|
assert.ok(hdr !== -1, 'fenced prompt must contain a ## Review Instructions section');
|
|
// Next top-level `## ` heading after the Review Instructions heading.
|
|
const after = fenced.indexOf('\n## ', hdr + 1);
|
|
return after === -1 ? fenced.slice(hdr) : fenced.slice(hdr, after);
|
|
}
|
|
|
|
const reviewInstructions = buildPromptReviewInstructions(workflow);
|
|
|
|
test('instructs reviewers to verify plan claims against source and cite file:line', () => {
|
|
// The cross-AI prompt assembled from plan text must push agentic reviewers
|
|
// to open the referenced source and ground findings in evidence, instead of
|
|
// paraphrasing plan text (the false-LOW failure mode in #1318). Assert the
|
|
// instruction lives INSIDE the prompt region, not merely somewhere in file.
|
|
assert.ok(
|
|
reviewInstructions.includes('Verify against source') &&
|
|
reviewInstructions.includes('check each claim against the actual code') &&
|
|
reviewInstructions.includes('`path/to/file:line`'),
|
|
'build_prompt Review Instructions region must require source verification + file:line evidence'
|
|
);
|
|
});
|
|
|
|
test('includes a graceful-degradation clause for reviewers without file access', () => {
|
|
// Prompt-only reviewers (ollama / lm_studio / llama.cpp) must flag that they
|
|
// could not verify rather than asserting an unverified finding — and this
|
|
// clause must sit WITHIN the prompt region so reviewers actually receive it.
|
|
assert.ok(
|
|
reviewInstructions.includes('If you cannot read the repo (no file access)') &&
|
|
reviewInstructions.includes('downgrade that finding to an open question'),
|
|
'build_prompt Review Instructions region must degrade gracefully for prompt-only reviewers'
|
|
);
|
|
});
|
|
|
|
test('#1318: prompt extraction is fence-run-aware — a nested code fence does not truncate it', () => {
|
|
// Regression guard for the fenceClose hardening. The feature feeds source/plan
|
|
// content (which routinely contains code fences) into the prompt; a naive
|
|
// first-`\n```` close scan would stop at a nested fence and drop everything
|
|
// after it — including the `## Review Instructions` section — yielding a
|
|
// spurious failure or false pass. A 4-backtick outer fence must extract in
|
|
// full past a nested 3-backtick block.
|
|
const synthetic = [
|
|
'<step name="build_prompt">',
|
|
'````markdown',
|
|
'# Prompt',
|
|
'Example for reviewers:',
|
|
'```bash',
|
|
'echo hi',
|
|
'```',
|
|
'## Review Instructions',
|
|
'- Verify against source and cite `path/to/file:line`.',
|
|
'````',
|
|
'</step>',
|
|
].join('\n');
|
|
const extracted = buildPromptReviewInstructions(synthetic);
|
|
assert.match(extracted, /## Review Instructions/);
|
|
assert.match(extracted, /cite `path\/to\/file:line`/);
|
|
});
|
|
});
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-687-agy-timeout.test.cjs — consolidation epic #1969 (B4 #1973)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-687-agy-timeout (consolidation epic #1969 B4 #1973)", () => {
|
|
// allow-test-rule: source-text-is-the-product (see #687)
|
|
// review.md is a workflow file whose deployed text IS the runtime contract; the
|
|
// agy -p invocation cannot be run in CI, so we assert on its content (issue #687).
|
|
'use strict';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const reviewPath = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'review.md');
|
|
const read = () => fs.readFileSync(reviewPath, 'utf-8');
|
|
|
|
describe('bug #687 → #2073: agy print mode is bounded, and its fallback chain fires', () => {
|
|
// Phase 5b (#2799) moved the agy invocation out of review.md's bash into the declared lane plus
|
|
// the named `antigravity` handler, so these assertions follow it.
|
|
//
|
|
// ONE INVARIANT DELIBERATELY CHANGED. The old contract was "--print-timeout PAIRED with an
|
|
// external timeout/gtimeout killer, falling back to bare agy on macOS". That fallback WAS the
|
|
// bug: stock macOS ships neither killer, so the lane ran unbounded there — and --print-timeout
|
|
// cannot fire before agy creates a session (#2073 mode 3), which is the whole reason a second
|
|
// bound existed. spawnSync's native timeout is always available, so the outer bound is now
|
|
// unconditional on every platform. Strictly stronger; only the mechanism changed.
|
|
const { REVIEWER_LANES } = require('../gsd-core/bin/lib/review-lane-descriptor.cjs');
|
|
const { resolveLanePlan } = require('../gsd-core/bin/lib/review-lane-invocation.cjs');
|
|
const { runLane } = require('../gsd-core/bin/lib/review-lane-runner.cjs');
|
|
const lane = REVIEWER_LANES.find((l) => l.slug === 'antigravity');
|
|
const planFor = () => {
|
|
const r = resolveLanePlan({ lane, configGet: (k) => ({ 'review.models.agy': 'agy-m' })[k], runDir: '/run', repoRoot: '/repo' });
|
|
assert.equal(r.ok, true);
|
|
return r.plan;
|
|
};
|
|
|
|
test('keeps its native --print-timeout AND carries an unconditional outer bound', () => {
|
|
const argv = planFor().argv;
|
|
const i = argv.indexOf('--print-timeout');
|
|
assert.notEqual(i, -1, 'the tool-native inner bound must survive');
|
|
assert.match(argv[i + 1], /^\d+s$/);
|
|
assert.ok(lane.timeoutFloorMs > 0, 'and an outer wall-clock bound must be declared');
|
|
});
|
|
|
|
test('the outer bound is larger than the inner one, so it only backstops', () => {
|
|
const argv = planFor().argv;
|
|
const innerSec = parseInt(argv[argv.indexOf('--print-timeout') + 1], 10);
|
|
assert.ok(lane.timeoutFloorMs / 1000 > innerSec,
|
|
'the outer cap must not pre-empt a healthy run bounded by --print-timeout');
|
|
});
|
|
|
|
test('uses a file-reference prompt, not inline "$(cat …)" (arg-list overflow, #2073)', () => {
|
|
assert.equal(lane.invoke.promptChannel, 'argv-file-ref');
|
|
const last = planFor().argv.slice(-1)[0];
|
|
assert.ok(last.includes('/run/gsd-review-prompt.md'));
|
|
assert.ok(last.length < 1000, 'the reference must never carry the prompt body');
|
|
});
|
|
|
|
test('wires --model from review.models.agy (#2073 mode 2)', () => {
|
|
assert.equal(lane.modelConfigKey, 'review.models.agy');
|
|
const argv = planFor().argv;
|
|
assert.equal(argv[argv.indexOf('--model') + 1], 'agy-m');
|
|
});
|
|
|
|
test('discards partial output on non-zero exit so the fallback fires (#687)', async () => {
|
|
const p = planFor();
|
|
const files = {};
|
|
const d = {
|
|
files,
|
|
spawn: () => ({ status: 124, stdout: 'PARTIAL GARBAGE', stderr: '' }),
|
|
httpJson: async () => ({ ok: true, status: 200, body: '{}' }),
|
|
readFile: (x) => { if (!(x in files)) throw new Error('ENOENT'); return files[x]; },
|
|
writeFile: (x, c) => { files[x] = c; },
|
|
exists: (x) => x in files,
|
|
hasBinary: () => true,
|
|
configGet: () => undefined,
|
|
homeDir: '/home/u',
|
|
warn: () => {},
|
|
};
|
|
await runLane(p, d, { repoRoot: '/repo' });
|
|
assert.ok(!files[p.reviewPath].includes('PARTIAL GARBAGE'),
|
|
'a non-zero exit must discard stdout so the transcript fallback and diagnostic can take over');
|
|
assert.ok(files[p.reviewPath].includes('failed or returned empty output'));
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/enh-773-codex-exec-automation-flags.test.cjs — consolidation epic #1969 (B4 #1973)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:enh-773-codex-exec-automation-flags (consolidation epic #1969 B4 #1973)", () => {
|
|
'use strict';
|
|
|
|
// allow-test-rule: source-text-is-the-product (see #773)
|
|
// Workflow markdown is runtime contract; these assertions verify that
|
|
// automated codex exec invocations carry the correct automation flags.
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
describe('enh-773: automated codex exec invocations include --ephemeral (hook-trust bypass dropped by #2479)', () => {
|
|
// Declared in the lane now rather than grepped out of review.md's bash lines.
|
|
const { REVIEWER_LANES } = require('../gsd-core/bin/lib/review-lane-descriptor.cjs');
|
|
const codex = REVIEWER_LANES.find((l) => l.slug === 'codex');
|
|
const args = codex.invoke.args.join(' ');
|
|
|
|
test('the codex lane uses the exec subcommand', () => {
|
|
assert.ok(args.includes('exec'));
|
|
assert.equal(codex.invoke.args[0], 'exec', 'exec is a SUBCOMMAND and must come first');
|
|
});
|
|
|
|
test('codex exec is --ephemeral', () => {
|
|
assert.ok(args.includes('--ephemeral'));
|
|
});
|
|
|
|
test('no hook-trust bypass flag is emitted (#2479)', () => {
|
|
assert.ok(!/dangerously|bypass/i.test(args),
|
|
'host-harness safety classifiers deny commands carrying it; a genuine untrusted-hook prompt '
|
|
+ 'surfaces through the .err capture and the empty-output stub instead');
|
|
});
|
|
|
|
test('#1115: codex review failures are surfaced, not silently swallowed', () => {
|
|
assert.equal(codex.emptyOutput, 'stub-with-stderr',
|
|
'a failed codex run must produce a diagnosable stub carrying its stderr');
|
|
});
|
|
});
|
|
|
|
describe('#1698 regression: codex review is captured via --output-last-message, not stdout', () => {
|
|
// WHY: on some platforms (Windows) `codex exec` writes process-teardown output
|
|
// to stdout *after* the final agent message. A `> FILE` stdout redirect appends
|
|
// that noise to a non-empty file, so it slips past the `[ ! -s … ]` empty-output
|
|
// guard and downstream consumers (severity extraction, the
|
|
// plan-review-convergence "concerns resolved?" gate) parse a polluted review.
|
|
// `-o/--output-last-message <FILE>` writes only the final message — robust on
|
|
// every platform — so each codex invocation must capture via -o and discard stdout.
|
|
const workflow = fs.readFileSync(
|
|
path.join(process.cwd(), 'gsd-core', 'workflows', 'review.md'),
|
|
'utf8'
|
|
);
|
|
const codexExecLines = workflow
|
|
.split(/\r?\n/)
|
|
.filter((line) => line.includes('codex exec') && !line.includes('codex exec --help'));
|
|
|
|
test('every codex exec invocation captures the review via -o <FILE>', () => {
|
|
for (const line of codexExecLines) {
|
|
assert.ok(
|
|
/\s-o\s+\{run_dir\}\/gsd-review-codex\.md\b/.test(line),
|
|
`codex exec invocation must capture the review via -o {run_dir}/gsd-review-codex.md:\n ${line.trim()}`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('no codex exec invocation redirects stdout into the review file', () => {
|
|
for (const line of codexExecLines) {
|
|
assert.ok(
|
|
!/>\s*\{run_dir\}\/gsd-review-codex\.md\b/.test(line),
|
|
`codex exec must not redirect stdout into the review file (teardown noise pollutes it); use -o + >/dev/null:\n ${line.trim()}`
|
|
);
|
|
assert.ok(
|
|
/>\s*\/dev\/null\b/.test(line),
|
|
`codex exec must discard stdout to /dev/null so teardown output is not captured:\n ${line.trim()}`
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// #1936: OpenCode reviewer must not silently yield an empty review
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe('#1936: OpenCode reviewer empty-output hardening', () => {
|
|
'use strict';
|
|
|
|
// allow-test-rule: source-text-is-the-product (see #1936)
|
|
// review.md is a workflow file whose embedded bash IS the runtime contract; the
|
|
// `opencode run` invocation on a large agentic prompt cannot be run in CI, so we
|
|
// assert on its content.
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const reviewPath = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'review.md');
|
|
const read = () => fs.readFileSync(reviewPath, 'utf-8');
|
|
|
|
// Isolate the base OpenCode reviewer block (heading -> next reviewer heading) so
|
|
// assertions about its stderr handling don't accidentally match sibling reviewers
|
|
// (gemini/claude/coderabbit/qwen legitimately use /dev/null).
|
|
function openCodeBlock() {
|
|
const c = read();
|
|
const start = c.indexOf('**OpenCode (via GitHub Copilot):**');
|
|
assert.notStrictEqual(start, -1, 'review.md must contain the base OpenCode reviewer block');
|
|
const rest = c.slice(start + 1);
|
|
const nextHeading = rest.search(/\n\*\*[A-Z][^\n]*:\*\*/);
|
|
return nextHeading === -1 ? c.slice(start) : c.slice(start, start + 1 + nextHeading);
|
|
}
|
|
|
|
describe('bug #1936: OpenCode reviewer must not silently yield an empty review', () => {
|
|
// The agent can end its turn with ZERO output tokens, and `--format default` then drops the
|
|
// assistant text entirely. Phase 5b (#2799) made the reconstruction a named first-party handler
|
|
// rather than a jq pipeline in bash, so these assertions are behavioural over that handler.
|
|
const { REVIEWER_LANES } = require('../gsd-core/bin/lib/review-lane-descriptor.cjs');
|
|
const { resolveLanePlan } = require('../gsd-core/bin/lib/review-lane-invocation.cjs');
|
|
const { handleOpencodeOutput } = require('../gsd-core/bin/lib/review-lane-runner.cjs');
|
|
const NL = String.fromCharCode(10);
|
|
const lane = REVIEWER_LANES.find((l) => l.slug === 'opencode');
|
|
|
|
test('captures opencode stderr to a sidecar, never /dev/null', () => {
|
|
const r = resolveLanePlan({ lane, configGet: () => undefined, runDir: '/run', repoRoot: '/repo' });
|
|
assert.equal(r.ok, true);
|
|
assert.ok(r.plan.errPath.endsWith('.err'));
|
|
assert.notEqual(r.plan.errPath, '/dev/null');
|
|
});
|
|
|
|
test('requests structured JSON output and reconstructs from assistant text parts', () => {
|
|
assert.ok(lane.invoke.args.join(' ').includes('--format json'),
|
|
'--format json is the PRIMARY invocation, not a fallback');
|
|
assert.equal(lane.handler, 'opencode', 'reconstruction cannot be expressed as data');
|
|
const stream = [
|
|
JSON.stringify({ type: 'text', part: { text: 'part one' } }),
|
|
JSON.stringify({ type: 'text', part: { text: 'part two' } }),
|
|
].join(NL);
|
|
assert.equal(handleOpencodeOutput(stream).review, ['part one', 'part two'].join(NL));
|
|
});
|
|
|
|
test('gates the empty-review stub on extracted CONTENT, not output-file size', () => {
|
|
// A JSON stream with no assistant text is many BYTES but zero review.
|
|
const noText = JSON.stringify({ type: 'step_finish', part: { reason: 'stop', tokens: { output: 0 } } });
|
|
assert.ok(noText.length > 0, 'the raw stream is non-empty by byte count');
|
|
assert.equal(handleOpencodeOutput(noText).review, '', 'yet the extracted review is empty');
|
|
});
|
|
|
|
test('empty-output stub is diagnosable: stop reason and output tokens', () => {
|
|
const stream = JSON.stringify({ type: 'step_finish', part: { reason: 'stop', tokens: { output: 0 } } });
|
|
const out = handleOpencodeOutput(stream);
|
|
assert.ok(out.diagnostic.includes('stop'), 'the stop reason must be surfaced');
|
|
assert.ok(out.diagnostic.includes('0'), 'the output-token count must be surfaced');
|
|
});
|
|
|
|
test('does not regress the Codex reviewer (still captures stderr to .err)', () => {
|
|
const codex = REVIEWER_LANES.find((l) => l.slug === 'codex');
|
|
const r = resolveLanePlan({ lane: codex, configGet: () => undefined, runDir: '/run', repoRoot: '/repo' });
|
|
assert.ok(r.plan.errPath.endsWith('.err'));
|
|
assert.equal(r.plan.outputTarget.kind, 'file', 'codex still captures via --output-last-message (#1698)');
|
|
});
|
|
});
|
|
|
|
});
|
|
}
|