Files
msd-core/tests/review-lane-runner.test.cjs
Tom Boucher 0e6fa2e2cf enhance(#3118): close the dead injectables and the shell projection follow-on — Wave 4 (#3124)
* test(#3118): failing-first coverage for the dead injectables and the shell projection

Adds the counter-tests Wave 4 closes against, before any fix:

- antigravityWatermark had zero test references. The four existing tests
  that look like watermark coverage hand the fallback a literal mark and
  never call the producer, so nothing pinned whether a real run's mark is
  correct. Covers all six branches plus the non-object cache classes.
- Pins the fail-open: a transcript read that throws reports lines:0,
  indistinguishable from a genuinely empty transcript, and the consumer
  then replays a previous run's review as this run's.
- Pins the export-line escaping across the repair, persist and win32 bash
  lanes, including the parity assertion that they must not diverge.
- sliceCurrentPositionSection: empty-vs-absent, fenced heading, second
  occurrence, H3, CRLF.
- Proves deps.progressProvider is inert by supplying a throwing stub to
  all ten transition intents.

Verification through the remote runner only.

Refs #3118

* fix(#3118): distinguish an unreadable transcript from an empty one

antigravityWatermark's final read can throw on a transcript that
indisputably exists. It returned lines:0, which is the same value a
genuinely empty transcript produces, so the caller could not tell the
two apart.

antigravityTranscriptFallback derives its skip from that count. A mark
of {convId:'c1', lines:0} for a conversation that pre-dates the run
makes it skip nothing and return the last PLANNER_RESPONSE in a
transcript written before this run started — a previous review
presented as this one's, which is exactly what the function's own
'never stale' docstring promises cannot happen.

The unreadable case now sets unreadable:true and the fallback declines
for a same-conv-id unreadable mark. An absent or empty transcript is
untouched: those genuinely have zero prior lines.

* fix(#3118): escape the export line for the file it lands in, not the echo

Three lanes emit export PATH="<dir>:$PATH". repair escaped it with
escapePosixDoubleQuoted; persist and the win32 Git Bash lane escaped it
with escapeSingleQuotedShellLiteral instead.

The single-quoting is correct for the echo, so nothing runs when the
user pastes the command. But the bytes appended to ~/.bashrc are the
export line itself, and inside double quotes in an rc file a $(...) or
a backtick in the directory name is command substitution that runs on
every new shell. Those characters are legal in a path on both POSIX and
Windows, so the path was reachable.

projectPathExportLine is now the single source of that line and escapes
for its final rc-file context; each lane still applies its own transport
escaping on top. fish keeps the single-quote escaper — its value really
does stay single-quoted.

The cmd.exe lane interpolated into a cmd double-quoted string with no
cmd-level escaping, so a quote closed the region and &cmd& ran. A quote
is reserved on Windows and cannot appear in a real path, so there is no
correct command to suggest: the win32 lanes now fail closed for one.

Metacharacter-free paths render byte-identically on every lane.

* fix(#3118): drop a stray carriage return and a deps field nobody reads

locateCurrentPosition subtracted a fixed one byte to exclude the newline
before the next heading, which assumes LF. On a CRLF document the slice
kept an unpaired trailing carriage return. It now walks back over the
newline and over a preceding carriage return if there is one.

StateTransitionDeps also required a progressProvider that 33 sites
supplied and no site ever called. A required field nothing reads widens
the module's interface without changing its implementation, which is the
shape epic #3051 cites as its reason for refusing blanket injection.
Removed along with the ProgressRecord alias that existed only as its
return type; state-document.cts's unrelated interface of the same name
is untouched.

* fix(#3118): stop an empty span duplicating bytes, and name the empty results

Three findings from the isolated review pass.

locateCurrentPosition could return end < start when the section was
empty and the next heading followed with no blank line between. Every
mutator splices with slice(0,start) + body + slice(end), so an inverted
span duplicated the region between them — a blank line silently
inserted into STATE.md on every transition, two bytes on CRLF. The span
is now clamped, and an empty section is a zero-length span, which is
what it always meant.

The win32 fail-closed path left the installer printing 'Add it with one
of:' with nothing under it. An empty shellActions folded two different
facts together, so projectPathActionProjection now carries a frozen
PATH_ACTION_REASON and the installer branches on it. Two empty results
with different causes staying distinguishable is the subject of the
epic this belongs to.

fish_add_path parses a leading dash as an option, so a directory named
-v printed 'No paths to add' instead of being added. Verified against
fish 4.8.1: the end-of-options separator fixes it.

Replaces the console-prose test the second fix first arrived with — a
regex over captured stdout is what CONTRIBUTING prohibits, and the
typed reason is the surface it asks for instead.

* fix(#3118): escape TOML control characters, and stop a test name overstating

Five findings from the two review axes.

escapeTomlDoubleQuotedString escaped only backslash and quote. TOML
basic strings also require U+0000-U+0008, U+000A-U+001F and U+007F to be
escaped, so a value carrying a raw newline or NUL wrote a config.toml no
parser accepts — rejecting the whole file, not just that value. Four of
its call sites write real config. Tab stays raw; the grammar exempts it.

The byte-identity test claimed every lane was unchanged for an ordinary
path, which is false: fish now takes the end-of-options separator on
every path, not only hostile ones. Renamed, and the one intended delta
now has its own named test instead of hiding inside a claim that read
as broader than it was.

Also: exact-equality assertions in place of substring checks that could
pass on a subtly wrong escape, newline and null-byte cases for all five
quoting primitives, and a temp dir registered with t.after so it is
removed when an assertion fails.

* docs(#3118): add the changeset fragments

* fix(#3118): degrade instead of throwing on a null conversation cache

A cache file whose whole content is the literal null — what a truncated
or zeroed write leaves behind — made both antigravityWatermark and
antigravityTranscriptFallback throw. JSON.parse('null') succeeds, so the
try/catch wrapping the parse never fired, and resolveConvId then called
hasOwnProperty on null.

Both functions advertise the opposite; the existing test next to them is
named 'a missing cache or transcript degrades to empty, never throws'.
Parsing successfully is not the same fact as the payload being usable,
and a guard that only wraps the parse cannot tell them apart.

resolveConvId is now total for any non-object input, so one guard covers
both callers. Caught by the null case in this wave's own cache matrix.

* test(#3118): correct a stale fish expectation and a parity comparison

The pre-existing 'POSIX persist mode escapes single quotes' test pinned
fish_add_path without the end-of-options separator this wave adds, so it
asserted behavior that is no longer correct. A repo-wide scan found one
such hardcoded expectation; every other site derives its expectation
from the projection.

The new parity test compared the token from a POSIX path against the
win32 lane, which posix-normalizes its input first — two different
inputs, so the tokens differed for a reason that had nothing to do with
the parity it claims to check. It now derives the win32 expectation from
the same input the lane receives.

* docs(#3118): reword a comment the injection scanner reads as an instruction

The scanner pattern act\s+as\s+(?:a|an|the)\s+ carries no word
boundary, so 'the same fact as the payload' matched on the tail of
'fact'. Reworded per the documented remedy for this collision.

The missing boundary is a scanner defect rather than a prose problem —
any contributor writing 'fact as the' trips it — but the pattern is
gate plumbing, which the sibling epic owns, so it is surfaced rather
than changed here.

* chore(#3118): backfill changeset pr number to 3124

* chore(#3118): backfill changeset pr number to 3124

* fix(#2784): make the negation scan single-pass and index it correctly

Three defects in the negation suppression added by #3127, all in one
block, none of which had a test.

The pair scan was verbs.some(nouns.some(...)) with a slice and a split
per pair, so it grew cubically with clause length: 1.1ms before that PR
and 8462ms after, on 800 verb+noun pairs in one clause. api-coverage's
property test generates documents large enough to reach the runner's
600s file cap, which is why it hangs as 'fail 0, cancelled 1' rather
than failing an assertion. Every (verb, noun) window is a subset of the
single widest one, so one scan of that window answers the same question
in a linear pass. Verified equivalent against the old predicate over
20,000 generated clauses.

Both checks also subtracted clause.start from offsets that collectTerm-
Matches already returns clause-local. The first clause on a line has
start 0 so it worked there and nowhere else: later clauses went
negative, and slice reads a negative index from the end, so suppression
silently examined unrelated text.

The comment claimed 'without any API integration' was suppressed. It is
not — the qualifier sits outside the two-word lookback and the noun
precedes the verb. Widening the window would trade a false positive
that costs one declaration line for a false negative that slips a real
integration past a blocking gate, so the behavior stands and the
comment now says so. Pinned by a test.

The qualifier sets were also rebuilt for every line of every document.
2026-08-06 23:57:05 -04:00

666 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Reviewer lane runner — execution, probes, handlers, egress (ADR-2782 Phase 5b, #2799).
*
* Every dependency is injected, so these are behavioural tests over the real control flow with no
* network, no spawn and no clock. Where a filesystem failure is forced it is done by making the
* injected `writeFile`/`readFile` throw — never by `chmod 0o000`, which root bypasses, silently
* turning the test into a vacuous pass in root Docker/CI.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { REVIEWER_LANES } = require('../gsd-core/bin/lib/review-lane-descriptor.cjs');
const { resolveLanePlan, LANE_UNAVAILABLE } = require('../gsd-core/bin/lib/review-lane-invocation.cjs');
const {
checkEgressHost,
probeLane,
runLane,
writeReviewOrStub,
handleOpencodeOutput,
stampBlindReview,
antigravityWatermark,
antigravityTranscriptFallback,
runOpenAiCompatible,
} = require('../gsd-core/bin/lib/review-lane-runner.cjs');
const RUN = '/run';
const ROOT = '/repo';
function plan(slug, config = {}) {
const lane = REVIEWER_LANES.find((l) => l.slug === slug);
const r = resolveLanePlan({ lane, configGet: (k) => config[k], runDir: RUN, repoRoot: ROOT });
assert.equal(r.ok, true, `${slug} failed to resolve`);
return r.plan;
}
/** An in-memory dependency set. Overrides replace individual seams per test. */
function deps(overrides = {}) {
const files = overrides.files || {};
const warnings = [];
const spawns = [];
const base = {
files,
warnings,
spawns,
spawn: (binary, argv, opts) => {
spawns.push({ binary, argv, opts });
return { status: 0, stdout: '', stderr: '' };
},
httpJson: async () => ({ ok: true, status: 200, body: '{}' }),
readFile: (p) => {
if (!(p in files)) throw new Error(`ENOENT ${p}`);
return files[p];
},
writeFile: (p, c) => { files[p] = c; },
exists: (p) => p in files,
hasBinary: () => true,
configGet: () => undefined,
homeDir: '/home/u',
warn: (m) => warnings.push(m),
};
return Object.assign(base, overrides);
}
describe('runner — egress host re-verification (ADR-2782 D5 rules 2-4)', () => {
test('no consent record ALLOWS — first-party lanes are never consent-gated', () => {
// Blocking on absence would break every existing local-model user on upgrade: ollama,
// lm_studio and llama_cpp ship inside the SHA-pinned distribution and have no consent record.
assert.equal(checkEgressHost(undefined, 'http://localhost:11434').allowed, true);
assert.equal(checkEgressHost(null, 'http://localhost:11434').allowed, true);
});
test('a record predating the field ALLOWS — absence must not force re-consent', () => {
// D4 rule 5: an absent field must not perturb consent, or every installed capability
// re-prompts on upgrade.
assert.equal(checkEgressHost('', 'http://localhost:8080').allowed, true);
});
test('a matching destination proceeds', () => {
const r = checkEgressHost('http://localhost:8080', 'http://localhost:8080');
assert.equal(r.allowed, true);
});
test('a changed destination BLOCKS and names both hosts', () => {
const r = checkEgressHost('http://localhost:8080', 'http://evil.example');
assert.equal(r.allowed, false);
assert.equal(r.consentedHost, 'http://localhost:8080');
assert.equal(r.currentHost, 'http://evil.example');
});
test('cosmetic host edits are not a change', () => {
for (const [a, b] of [
['http://localhost:8080', 'http://localhost:8080/'],
['http://a.com:80', 'http://a.com'],
['http://A.com', 'http://a.com'],
]) {
assert.equal(checkEgressHost(a, b).allowed, true, `${a} vs ${b}`);
}
});
test('a non-string consented value is treated as absent, never coerced', () => {
for (const v of [42, {}, [], true]) {
assert.equal(checkEgressHost(v, 'http://a.com').allowed, true);
}
});
test('a blocked lane never reaches the network and writes no review', async () => {
const p = plan('ollama');
const d = deps();
const r = await runLane(p, d, { consentedHost: 'http://elsewhere.example', repoRoot: ROOT });
assert.equal(r.ok, false);
assert.equal(r.reason, LANE_UNAVAILABLE.EGRESS_HOST_CHANGED);
assert.equal(d.files[p.reviewPath], undefined, 'a blocked lane must not write a review');
assert.ok(d.warnings.some((w) => w.includes('elsewhere.example')));
});
test('a spawn lane skips the host check entirely', async () => {
const p = plan('qwen');
const d = deps({ spawn: () => ({ status: 0, stdout: 'review', stderr: '' }) });
// A stale host on a spawn lane must be inert, not a block.
const r = await runLane(p, d, { consentedHost: 'http://stale.example', repoRoot: ROOT });
assert.equal(r.ok, true);
});
});
describe('runner — probe (ADR-2782 D7)', () => {
test('command-exists both ways', async () => {
const p = plan('gemini');
assert.equal((await probeLane(p, deps({ hasBinary: () => true }))).available, true);
const miss = await probeLane(p, deps({ hasBinary: () => false }));
assert.equal(miss.available, false);
assert.equal(miss.reason, LANE_UNAVAILABLE.MISSING_BINARY);
});
test('command-capability accepts the right tool and REJECTS the wrong one', async () => {
// This is the entire reason D7 ships wider than existence: `kimi` is claimed by both Kimi Code
// CLI and the legacy Python kimi-cli, and an existence-only probe registers the wrong tool.
const p = plan('kimi-code');
const real = await probeLane(p, deps({
spawn: () => ({ status: 0, stdout: 'usage: kimi --output-format json -p', stderr: '' }),
}));
assert.equal(real.available, true);
const legacy = await probeLane(p, deps({
spawn: () => ({ status: 0, stdout: 'usage: kimi --print --work-dir DIR', stderr: '' }),
}));
assert.equal(legacy.available, false);
assert.equal(legacy.reason, LANE_UNAVAILABLE.PROBE_FAILED);
});
test('a capability probe that times out reports unavailable, never hangs', async () => {
// The original probe (closed PR #2776) was an unbounded `kimi --help | grep` that ran on EVERY
// review regardless of flags — a live instance of the named Unbounded Subprocesses defect.
const p = plan('kimi-code');
const r = await probeLane(p, deps({
spawn: () => ({ status: null, stdout: '', stderr: '', errorCode: 'ETIMEDOUT' }),
}));
assert.equal(r.available, false);
assert.equal(r.reason, LANE_UNAVAILABLE.PROBE_TIMEOUT);
});
test('the capability probe passes the declared bound to the spawn', async () => {
const p = plan('kimi-code');
const d = deps({ spawn: (b, a, o) => { d.spawns.push({ b, a, o }); return { status: 0, stdout: '--output-format', stderr: '' }; } });
await probeLane(p, d);
const call = d.spawns[d.spawns.length - 1];
assert.equal(typeof call.o.timeoutMs, 'number');
assert.ok(call.o.timeoutMs > 0, 'every probe that starts a process MUST be bounded');
});
test('a missing required binary is named rather than left to fail obscurely', async () => {
const p = { ...plan('gemini'), requiresBinaries: ['jq'] };
const r = await probeLane(p, deps({ hasBinary: (n) => n !== 'jq' }));
assert.equal(r.available, false);
assert.equal(r.reason, LANE_UNAVAILABLE.MISSING_REQUIRED_BINARY);
});
test('no shipped lane still requires jq or curl', () => {
// Phase 5b moved parsing to JSON.parse and HTTP to fetch. Leaving a stale requiresBinaries
// entry would report lanes unavailable on stock Windows for a dependency they no longer use.
for (const lane of REVIEWER_LANES) {
for (const bin of lane.requiresBinaries) {
assert.ok(bin !== 'jq' && bin !== 'curl', `${lane.slug} still declares ${bin}`);
}
}
});
test('http-reachable reports unreachable rather than throwing', async () => {
const p = plan('ollama');
const r = await probeLane(p, deps({ httpJson: async () => ({ ok: false, status: 0, body: '', error: 'ECONNREFUSED' }) }));
assert.equal(r.available, false);
assert.equal(r.reason, LANE_UNAVAILABLE.HOST_UNREACHABLE);
});
});
describe('runner — empty-output policy (#2494 / #2605 / #2794)', () => {
test('a real review is written verbatim', () => {
const p = plan('gemini');
const d = deps();
const r = writeReviewOrStub(p, '## Findings\nreal', d);
assert.equal(r.stubbed, false);
assert.ok(d.files[p.reviewPath].startsWith('## Findings'));
});
test('empty output writes a stub carrying the captured stderr', () => {
const p = plan('gemini');
const d = deps({ files: { [`${RUN}/gsd-review-gemini.err`]: 'auth failed' } });
const r = writeReviewOrStub(p, '', d);
assert.equal(r.stubbed, true);
assert.ok(d.files[p.reviewPath].includes('failed or returned empty output'));
assert.ok(d.files[p.reviewPath].includes('auth failed'));
});
test('whitespace-only output is stubbed on every lane', () => {
// Before this, `[ ! -s file ]` counted bytes so " " rendered as a clean review on five lanes.
for (const slug of ['gemini', 'claude', 'codex', 'qwen', 'cursor']) {
const p = plan(slug);
const d = deps();
assert.equal(writeReviewOrStub(p, ' \n', d).stubbed, true, `${slug} accepted whitespace`);
}
});
test('the stub is distinguishable from a real review', () => {
// The ambiguity between "failed" and "ran cleanly with nothing to report" IS the defect.
const p = plan('gemini');
const d = deps();
writeReviewOrStub(p, '', d);
assert.ok(/failed or returned empty output/.test(d.files[p.reviewPath]));
});
test('an http lane appends the raw response body', () => {
// An OpenAI-compatible server reports errors with a 4xx/5xx and the JSON in the BODY, so
// stderr alone is empty and the body is the only evidence. The bash piped it into jq and lost it.
const p = plan('ollama');
const d = deps();
writeReviewOrStub(p, '', d, '{"error":{"message":"model not found"}}');
assert.ok(d.files[p.reviewPath].includes('Raw response body:'));
assert.ok(d.files[p.reviewPath].includes('model not found'));
});
test('a filesystem write failure degrades rather than crashing the run', () => {
// Injected by making the seam throw — never chmod 0o000, which root bypasses.
const p = plan('gemini');
const d = deps({ writeFile: () => { throw new Error('EROFS'); } });
assert.throws(() => writeReviewOrStub(p, 'x', d), /EROFS/);
});
});
describe('runner — opencode handler (#1936)', () => {
test('the review is rebuilt from assistant text parts', () => {
const stream = [
JSON.stringify({ type: 'text', part: { text: 'first' } }),
JSON.stringify({ type: 'text', part: { text: 'second' } }),
].join('\n');
assert.equal(handleOpencodeOutput(stream).review, 'first\nsecond');
});
test('a malformed line is skipped, not fatal to the whole review', () => {
// Losing an entire review to one bad line would be strictly worse than the bug this fixes.
const stream = [
JSON.stringify({ type: 'text', part: { text: 'kept' } }),
'NOT JSON AT ALL',
'{"truncated":',
JSON.stringify({ type: 'text', part: { text: 'also kept' } }),
].join('\n');
assert.equal(handleOpencodeOutput(stream).review, 'kept\nalso kept');
});
test('a zero-output turn surfaces the stop reason and token count', () => {
const stream = JSON.stringify({ type: 'step_finish', part: { reason: 'stop', tokens: { output: 0 } } });
const r = handleOpencodeOutput(stream);
assert.equal(r.review, '');
assert.ok(r.diagnostic.includes('stop'));
assert.ok(r.diagnostic.includes('0'));
});
test('the raw JSON envelope never becomes the review', async () => {
// The regression this locks: a plain stdout copy would write the JSON stream into REVIEWS.md.
const p = plan('opencode');
const stream = JSON.stringify({ type: 'text', part: { text: 'THE REVIEW' } });
const d = deps({ spawn: () => ({ status: 0, stdout: stream, stderr: '' }) });
await runLane(p, d, { repoRoot: ROOT });
assert.equal(d.files[p.reviewPath].trim(), 'THE REVIEW');
assert.ok(!d.files[p.reviewPath].includes('"type"'));
});
test('CRLF in the stream is handled', () => {
const stream = [
JSON.stringify({ type: 'text', part: { text: 'a' } }),
JSON.stringify({ type: 'text', part: { text: 'b' } }),
].join('\r\n');
assert.equal(handleOpencodeOutput(stream).review, 'a\nb');
});
});
describe('runner — antigravity handler (#2073 / #2176)', () => {
const CACHE = '/home/u/.gemini/antigravity-cli/cache/last_conversations.json';
const TX = (id) => `/home/u/.gemini/antigravity-cli/brain/${id}/.system_generated/logs/transcript.jsonl`;
const entry = (content) =>
JSON.stringify({ source: 'MODEL', status: 'DONE', type: 'PLANNER_RESPONSE', content });
test('the watermark prevents a PRIOR run’s response leaking in as this one', () => {
// Without it the fallback reads the last PLANNER_RESPONSE regardless of when it was written,
// silently presenting a stale review as the current one.
const files = {
[CACHE]: JSON.stringify({ [ROOT]: 'c1' }),
[TX('c1')]: [entry('STALE FROM LAST RUN')].join('\n'),
};
const d = deps({ files });
const got = antigravityTranscriptFallback(ROOT, { convId: 'c1', lines: 1 }, d);
assert.equal(got, '', 'nothing was appended after the watermark, so nothing may be returned');
});
test('a response appended after the watermark IS returned', () => {
const files = {
[CACHE]: JSON.stringify({ [ROOT]: 'c1' }),
[TX('c1')]: [entry('old'), entry('THIS RUN')].join('\n'),
};
const d = deps({ files });
assert.equal(antigravityTranscriptFallback(ROOT, { convId: 'c1', lines: 1 }, d), 'THIS RUN');
});
test('a new conversation id means every line is new (skip 0)', () => {
const files = {
[CACHE]: JSON.stringify({ [ROOT]: 'c2' }),
[TX('c2')]: [entry('FRESH SESSION')].join('\n'),
};
const d = deps({ files });
assert.equal(antigravityTranscriptFallback(ROOT, { convId: 'c1', lines: 9 }, d), 'FRESH SESSION');
});
test('workspace lookup is case-insensitive', () => {
const files = {
[CACHE]: JSON.stringify({ '/REPO': 'c1' }),
[TX('c1')]: [entry('found')].join('\n'),
};
assert.equal(antigravityTranscriptFallback('/repo', { convId: '', lines: 0 }, deps({ files })), 'found');
});
test('a missing cache or transcript degrades to empty, never throws', () => {
assert.equal(antigravityTranscriptFallback(ROOT, { convId: '', lines: 0 }, deps()), '');
const d = deps({ files: { [CACHE]: 'NOT JSON' } });
assert.equal(antigravityTranscriptFallback(ROOT, { convId: '', lines: 0 }, d), '');
});
// ── antigravityWatermark (#3118) ───────────────────────────────────────────
//
// Every test above hands the fallback a HAND-WRITTEN mark. None of them calls
// `antigravityWatermark`, so none says anything about whether the mark a real run produces is
// correct. The producer had zero test references before this block; the fail-open below lived
// entirely in that gap.
describe('antigravityWatermark — the mark a real run actually produces', () => {
test('returns an empty mark when the conversation cache is absent', () => {
assert.deepEqual(antigravityWatermark(ROOT, deps()), { convId: '', lines: 0 });
});
test('returns an empty mark when the cache is not valid JSON', () => {
const d = deps({ files: { [CACHE]: 'NOT JSON' } });
assert.deepEqual(antigravityWatermark(ROOT, d), { convId: '', lines: 0 });
});
test('returns an empty mark when the workspace has no conversation', () => {
const d = deps({ files: { [CACHE]: JSON.stringify({ '/somewhere/else': 'c9' }) } });
assert.deepEqual(antigravityWatermark(ROOT, d), { convId: '', lines: 0 });
});
for (const [label, body] of [
['a number', '0'],
['a string', '"just a string"'],
['an array', '[]'],
['null', 'null'],
['a boolean', 'true'],
]) {
test(`a conversation cache that is ${label} yields an empty mark`, () => {
// Valid JSON that is not an object still reaches hasOwnProperty / Object.entries.
const d = deps({ files: { [CACHE]: body } });
assert.deepEqual(antigravityWatermark(ROOT, d), { convId: '', lines: 0 });
});
}
test('ignores a non-string conversation id', () => {
const d = deps({ files: { [CACHE]: JSON.stringify({ [ROOT]: 42 }) } });
assert.equal(antigravityWatermark(ROOT, d).convId, '');
});
test('ignores an empty-string conversation id', () => {
const d = deps({ files: { [CACHE]: JSON.stringify({ [ROOT]: '' }) } });
assert.equal(antigravityWatermark(ROOT, d).convId, '');
});
test('resolves the workspace case-insensitively', () => {
const d = deps({ files: { [CACHE]: JSON.stringify({ '/REPO': 'c1' }), [TX('c1')]: entry('x') } });
assert.equal(antigravityWatermark('/repo', d).convId, 'c1');
});
test('keeps the conversation id when the transcript does not exist yet', () => {
// Distinct from the cases above: the conversation is KNOWN, it simply has no transcript.
const d = deps({ files: { [CACHE]: JSON.stringify({ [ROOT]: 'c1' }) } });
assert.deepEqual(antigravityWatermark(ROOT, d), { convId: 'c1', lines: 0 });
});
test('counts the non-blank transcript lines', () => {
const files = { [CACHE]: JSON.stringify({ [ROOT]: 'c1' }), [TX('c1')]: [entry('a'), entry('b')].join('\n') };
assert.equal(antigravityWatermark(ROOT, deps({ files })).lines, 2);
});
test('an empty transcript is zero lines, not an unreadable one', () => {
// Negative space for the fix: a genuinely empty transcript must NOT degrade.
const files = { [CACHE]: JSON.stringify({ [ROOT]: 'c1' }), [TX('c1')]: '' };
const mark = antigravityWatermark(ROOT, deps({ files }));
assert.equal(mark.lines, 0);
assert.notEqual(mark.unreadable, true, 'an empty transcript is readable, just empty');
});
test('whitespace-only transcript lines are not counted', () => {
const files = { [CACHE]: JSON.stringify({ [ROOT]: 'c1' }), [TX('c1')]: '\n \n\t\n' };
const mark = antigravityWatermark(ROOT, deps({ files }));
assert.equal(mark.lines, 0);
assert.notEqual(mark.unreadable, true);
});
test('counts CRLF transcript lines the same as LF', () => {
const lf = { [CACHE]: JSON.stringify({ [ROOT]: 'c1' }), [TX('c1')]: [entry('a'), entry('b')].join('\n') };
const crlf = { [CACHE]: JSON.stringify({ [ROOT]: 'c1' }), [TX('c1')]: [entry('a'), entry('b')].join('\r\n') };
assert.equal(
antigravityWatermark(ROOT, deps({ files: lf })).lines,
antigravityWatermark(ROOT, deps({ files: crlf })).lines,
);
});
test('does not report zero lines when the transcript could not be read', () => {
// THE FAIL-OPEN. The transcript EXISTS and its conversation pre-dates this run, so its
// content is definitionally stale — but the read threw, so the count is unknown. Returning
// `lines: 0` is indistinguishable from "genuinely empty" and asserts a fact the function
// could not verify.
const files = { [CACHE]: JSON.stringify({ [ROOT]: 'c1' }), [TX('c1')]: 'unused' };
const d = deps({ files });
const realRead = d.readFile;
d.readFile = (p) => {
if (p === TX('c1')) throw new Error('EACCES');
return realRead(p);
};
const mark = antigravityWatermark(ROOT, d);
assert.equal(mark.convId, 'c1', 'the conversation id was resolved and stays trustworthy');
assert.equal(mark.unreadable, true, 'an unreadable transcript must be distinguishable from an empty one');
});
test('the fallback declines when the watermark could not be established', () => {
// The CONSEQUENCE of the branch above. The watermark read fails; the fallback's own read
// then succeeds (transient EACCES, a concurrent writer, a partial flush). With `lines: 0`
// and a matching convId the fallback skips nothing and returns a PREVIOUS run's review as
// this run's — the precise failure the "never stale" docstring promises cannot happen.
const files = { [CACHE]: JSON.stringify({ [ROOT]: 'c1' }), [TX('c1')]: entry('STALE FROM LAST RUN') };
const marking = deps({ files });
const realRead = marking.readFile;
marking.readFile = (p) => {
if (p === TX('c1')) throw new Error('EACCES');
return realRead(p);
};
const mark = antigravityWatermark(ROOT, marking);
assert.equal(
antigravityTranscriptFallback(ROOT, mark, deps({ files })),
'',
'an unverified watermark must not license replaying the transcript',
);
});
test('the fallback still skips exactly the pre-run lines when the mark is sound', () => {
// Negative space for the fix: a sound mark must keep working end-to-end, producer included.
const files = { [CACHE]: JSON.stringify({ [ROOT]: 'c1' }), [TX('c1')]: entry('old') };
const mark = antigravityWatermark(ROOT, deps({ files }));
assert.equal(mark.lines, 1);
files[TX('c1')] = [entry('old'), entry('THIS RUN')].join('\n');
assert.equal(antigravityTranscriptFallback(ROOT, mark, deps({ files })), 'THIS RUN');
});
});
test('the blind-review marker is anchored to the head of the output', () => {
assert.ok(stampBlindReview('REVIEWED-WITHOUT-REPO-ACCESS\nbody').startsWith('> [reviewed-without-repo-access]'));
});
test('a review that merely QUOTES the marker further down is NOT stamped', () => {
// A grounded review of this very file would otherwise be mis-stamped and down-weighted.
const quoting = ['1', '2', '3', '4', '5', '6', 'we look for REVIEWED-WITHOUT-REPO-ACCESS here'].join('\n');
assert.ok(!stampBlindReview(quoting).startsWith('>'));
});
test('the scratch-dir tell requires a workspace DECLARATION, not a mention', () => {
const declared = 'my working directory is /home/u/.gemini/antigravity-cli/scratch so I could not read';
assert.ok(stampBlindReview(declared).startsWith('>'));
const mention = 'the path .gemini/antigravity-cli/scratch appears in the plan under review';
assert.ok(!stampBlindReview(mention).startsWith('>'));
});
test('a non-zero exit discards partial output so the fallback can take over', async () => {
// The spawn APPENDS to the transcript, as the real `agy` does. That ordering is the whole
// point of the watermark: only what this run wrote may be read back. A test that pre-seeds the
// response instead would be asserting that a STALE entry leaks through — the exact bug the
// watermark exists to prevent — so it must be written this way round.
const p = plan('antigravity');
const files = {
[CACHE]: JSON.stringify({ [ROOT]: 'c1' }),
[TX('c1')]: [entry('from a PREVIOUS run')].join('\n'),
};
const d = deps({
files,
spawn: () => {
files[TX('c1')] = [entry('from a PREVIOUS run'), entry('FROM TRANSCRIPT')].join('\n');
return { status: 124, stdout: 'partial garbage', stderr: '' };
},
});
await runLane(p, d, { repoRoot: ROOT });
assert.ok(d.files[p.reviewPath].includes('FROM TRANSCRIPT'));
assert.ok(!d.files[p.reviewPath].includes('partial garbage'), 'rc!=0 must discard stdout');
assert.ok(!d.files[p.reviewPath].includes('PREVIOUS'), 'the pre-run entry must stay invisible');
});
});
describe('runner — openai-compatible handler', () => {
test('the configured model is used and discovery is skipped', async () => {
const p = plan('ollama', { 'review.models.ollama': 'pinned' });
let posted = null;
const d = deps({
httpJson: async (url, o) => {
if (o.method === 'POST') { posted = JSON.parse(o.body); return { ok: true, status: 200, body: JSON.stringify({ choices: [{ message: { content: 'R' } }] }) }; }
return { ok: true, status: 200, body: JSON.stringify({ data: [{ id: 'discovered' }] }) };
},
});
const r = await runOpenAiCompatible(p, 'PROMPT', d);
assert.equal(posted.model, 'pinned');
assert.equal(r.review, 'R');
});
test('an unset model discovers the first from /v1/models', async () => {
const p = plan('ollama');
let posted = null;
const d = deps({
httpJson: async (url, o) => {
if (o.method === 'POST') { posted = JSON.parse(o.body); return { ok: true, status: 200, body: JSON.stringify({ choices: [{ message: { content: 'R' } }] }) }; }
return { ok: true, status: 200, body: JSON.stringify({ data: [{ id: 'discovered' }] }) };
},
});
await runOpenAiCompatible(p, 'P', d);
assert.equal(posted.model, 'discovered');
});
test('discovery failure falls back to the declared fallbackModel', async () => {
const p = plan('ollama');
let posted = null;
const d = deps({
httpJson: async (url, o) => {
if (o.method === 'POST') { posted = JSON.parse(o.body); return { ok: true, status: 200, body: '{}' }; }
return { ok: false, status: 0, body: '', error: 'refused' };
},
});
await runOpenAiCompatible(p, 'P', d);
assert.equal(posted.model, 'llama3');
});
test('a served-model mismatch warns without failing the review', async () => {
const p = plan('lm_studio', { 'review.models.lm_studio': 'asked' });
const d = deps({
httpJson: async () => ({ ok: true, status: 200, body: JSON.stringify({ model: 'served', choices: [{ message: { content: 'R' } }] }) }),
});
const r = await runOpenAiCompatible(p, 'P', d);
assert.equal(r.review, 'R');
assert.ok(d.warnings.some((w) => w.includes('served') && w.includes('asked')));
});
test('an HTTP error body is preserved for the stub', async () => {
const p = plan('ollama');
const d = deps({
httpJson: async (url, o) =>
o.method === 'POST'
? { ok: false, status: 404, body: '{"error":"no such model"}' }
: { ok: false, status: 0, body: '' },
});
const r = await runOpenAiCompatible(p, 'P', d);
assert.equal(r.review, '');
assert.ok(r.rawBody.includes('no such model'));
});
test('a non-JSON response body does not throw', async () => {
const p = plan('ollama');
const d = deps({ httpJson: async () => ({ ok: true, status: 200, body: '<html>502</html>' }) });
const r = await runOpenAiCompatible(p, 'P', d);
assert.equal(r.review, '');
assert.ok(r.rawBody.includes('502'));
});
});
describe('runner — orchestration', () => {
test('an unavailable lane requested EXPLICITLY is surfaced (D4 carve-out)', async () => {
const p = plan('gemini');
const d = deps({ hasBinary: () => false });
const r = await runLane(p, d, { repoRoot: ROOT, explicitlyRequested: true });
assert.equal(r.ok, false);
assert.ok(d.warnings.some((w) => w.includes('explicitly requested')));
});
test('an unavailable lane nobody asked for is quiet but still reported', async () => {
const p = plan('gemini');
const d = deps({ hasBinary: () => false });
const r = await runLane(p, d, { repoRoot: ROOT, explicitlyRequested: false });
assert.equal(r.ok, false);
assert.equal(r.reason, LANE_UNAVAILABLE.MISSING_BINARY);
assert.deepStrictEqual(d.warnings, []);
});
test('a file-arg lane reads its review from the file, not stdout', async () => {
// Codex writes via -o and its stdout carries Windows teardown noise after the final message
// (#1698); a stdout redirect would append that to a non-empty file and slip past the guard.
const p = plan('codex');
const d = deps({
files: { [`${RUN}/gsd-review-codex.md`]: 'FROM FILE' },
spawn: () => ({ status: 0, stdout: 'TEARDOWN NOISE', stderr: '' }),
});
await runLane(p, d, { repoRoot: ROOT });
assert.ok(d.files[p.reviewPath].includes('FROM FILE'));
assert.ok(!d.files[p.reviewPath].includes('TEARDOWN NOISE'));
});
test('stderr is always captured to the sidecar, never discarded', async () => {
const p = plan('gemini');
const d = deps({ spawn: () => ({ status: 0, stdout: 'R', stderr: 'a warning' }) });
await runLane(p, d, { repoRoot: ROOT });
assert.equal(d.files[p.errPath], 'a warning');
});
test('the prompt reaches stdin for a stdin lane', async () => {
const p = plan('gemini');
const d = deps({
files: { [`${RUN}/gsd-review-prompt.md`]: 'THE PLAN' },
spawn: (b, a, o) => { d.spawns.push({ b, a, o }); return { status: 0, stdout: 'R', stderr: '' }; },
});
await runLane(p, d, { repoRoot: ROOT });
assert.equal(d.spawns[0].o.input, 'THE PLAN');
});
test('a prompt-less lane is fed nothing', async () => {
const p = plan('coderabbit');
const d = deps({
files: { [`${RUN}/gsd-review-prompt.md`]: 'THE PLAN' },
spawn: (b, a, o) => { d.spawns.push({ b, a, o }); return { status: 0, stdout: 'R', stderr: '' }; },
});
await runLane(p, d, { repoRoot: ROOT });
assert.equal(d.spawns[0].o.input, undefined);
});
test('every spawn carries a positive timeout', async () => {
// DEFECT.UNBOUNDED-SUBPROCESS: a frozen sync spawn cannot be interrupted and hangs a whole CI
// chunk to its 10-minute kill with `# fail 0` and no `not ok`.
for (const lane of REVIEWER_LANES.filter((l) => l.transport === 'spawn')) {
const p = plan(lane.slug);
const d = deps({ spawn: (b, a, o) => { d.spawns.push({ b, a, o }); return { status: 0, stdout: 'R', stderr: '' }; } });
await runLane(p, d, { repoRoot: ROOT });
for (const s of d.spawns) {
assert.ok(s.o.timeoutMs > 0, `${lane.slug} spawned unbounded`);
}
}
});
});