* 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.
666 lines
29 KiB
JavaScript
666 lines
29 KiB
JavaScript
/**
|
||
* 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`);
|
||
}
|
||
}
|
||
});
|
||
});
|