test(ci): R5 isolation guard — configureGateway/__setEmbedTransportForTests require resetGateway teardown (#3066)

check-test-isolation.sh gains rule R5 (comment-stripped grep so prose
mentions of resetGateway() don't satisfy it, exact call syntax on the
trigger so test-name prose doesn't fire it). Fixes the 11 current
violators with resetGateway() in afterAll; 5 fixture cases pin the rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-07-23 11:10:33 -07:00
co-authored by Claude Fable 5
parent c11ec1166f
commit e5dc0ac399
13 changed files with 141 additions and 13 deletions
+22
View File
@@ -18,6 +18,12 @@
# R4: any file that creates `new PGLiteEngine(` must call `.disconnect(`
# inside an `afterAll(` block. Without disconnect, engines leak across
# file boundaries within a shard process.
# R5: any file that calls `configureGateway(` or
# `__setEmbedTransportForTests(` must also call `resetGateway()` and
# have an `afterAll(`/`afterEach(` hook. The gateway is module-global;
# a configured remote provider + fake key left past the file boundary
# makes the next embed-triggering file in the shard fire a live HTTP
# call (issue #3066; master shard 6 broke twice this way).
#
# Scope:
# - Recursively scans `test/**/*.test.ts`.
@@ -132,6 +138,22 @@ while IFS= read -r f; do
emit_violation "$f" "R4" "creates PGLiteEngine but missing afterAll(() => engine.disconnect()); engine leaks across files in the shard process" ""
fi
fi
# R5: gateway configuration requires resetGateway + an afterAll/afterEach
# hook. Same loose two-grep shape as R4 — `resetGateway(` present plus at
# least one afterAll(/afterEach( — but on comment-stripped lines: a prose
# mention like "a test that calls resetGateway()" must not satisfy the
# rule (that exact false pass hid the cycle-consolidate leaker).
# No [[:space:]]* before the paren on the trigger side: prose in a test
# name ("works WITHOUT configureGateway (reads registry...)") must not
# trigger the rule; real call sites are always `configureGateway(`.
r5_code=$(grep -vE '^[[:space:]]*(//|\*)' "$f" 2>/dev/null || true)
if printf '%s\n' "$r5_code" | grep -qE 'configureGateway\(|__setEmbedTransportForTests\('; then
if ! printf '%s\n' "$r5_code" | grep -qE 'resetGateway\(' \
|| ! printf '%s\n' "$r5_code" | grep -qE 'afterAll[[:space:]]*\(|afterEach[[:space:]]*\('; then
emit_violation "$f" "R5" "configures the AI gateway but never calls resetGateway() in afterAll/afterEach; gateway state (provider, fake keys, transports) leaks across files in the shard process" ""
fi
fi
done <<EOF
$FILE_LIST
EOF
+7 -1
View File
@@ -29,7 +29,7 @@
* the bug made you believe was sufficient.
*/
import { describe, test, expect, beforeEach } from 'bun:test';
import { describe, test, expect, beforeEach, afterAll } from 'bun:test';
import {
chat,
configureGateway,
@@ -37,6 +37,12 @@ import {
__setGenerateTextTransportForTests,
} from '../../src/core/ai/gateway.ts';
// R5 shard hygiene: leave no configured gateway past the file boundary.
afterAll(() => {
resetGateway();
__setGenerateTextTransportForTests(null);
});
describe('gbrain#2490 — Anthropic cache breakpoint placement', () => {
beforeEach(() => {
resetGateway();
+7 -1
View File
@@ -16,7 +16,7 @@
* `generateText` import via Bun's module-replace pattern.
*/
import { describe, test, expect, beforeEach, mock } from 'bun:test';
import { describe, test, expect, beforeEach, mock, afterAll } from 'bun:test';
import {
configureGateway,
resetGateway,
@@ -30,6 +30,12 @@ import { parseModelId, resolveRecipe, assertTouchpoint } from '../../src/core/ai
import { AIConfigError } from '../../src/core/ai/errors.ts';
import { listRecipes, getRecipe } from '../../src/core/ai/recipes/index.ts';
// R5 shard hygiene: leave no configured gateway past the file boundary.
afterAll(() => {
resetGateway();
__setGenerateTextTransportForTests(null);
});
describe('chat touchpoint — recipe registry', () => {
test('all six chat-capable providers ship a chat touchpoint with supports_subagent_loop', () => {
const expected = ['anthropic', 'openai', 'google', 'deepseek', 'groq', 'together'];
@@ -16,7 +16,7 @@
* nothing), and config `provider_chat_options` overrides the derived key
*/
import { describe, test, expect, beforeEach } from 'bun:test';
import { describe, test, expect, beforeEach, afterAll } from 'bun:test';
import {
chat,
configureGateway,
@@ -25,6 +25,12 @@ import {
__setGenerateTextTransportForTests,
} from '../../src/core/ai/gateway.ts';
// R5 shard hygiene: leave no configured gateway past the file boundary.
afterAll(() => {
resetGateway();
__setGenerateTextTransportForTests(null);
});
describe('openAIPromptCacheKey — derivation', () => {
test('same system + same tools → identical stable key (sticky routing)', () => {
const a = openAIPromptCacheKey({ system: 'SYS', toolNames: ['search', 'put_page'] });
+2 -1
View File
@@ -10,7 +10,7 @@
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { configureGateway } from '../src/core/ai/gateway.ts';
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
import { runPhaseConsolidate } from '../src/core/cycle/phases/consolidate.ts';
let engine: PGLiteEngine;
@@ -37,6 +37,7 @@ beforeAll(async () => {
afterAll(async () => {
await engine.disconnect();
resetGateway();
});
beforeEach(async () => {
+2 -1
View File
@@ -8,7 +8,7 @@
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { configureGateway } from '../src/core/ai/gateway.ts';
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
import { checkFederationHealth } from '../src/commands/doctor.ts';
let engine: PGLiteEngine;
@@ -33,6 +33,7 @@ beforeAll(async () => {
afterAll(async () => {
await engine.disconnect();
resetGateway();
});
beforeEach(async () => {
+2 -1
View File
@@ -15,7 +15,7 @@
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { configureGateway } from '../src/core/ai/gateway.ts';
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
import {
detectRegressions,
computeDriftScore,
@@ -45,6 +45,7 @@ beforeAll(async () => {
afterAll(async () => {
await engine.disconnect();
resetGateway();
});
beforeEach(async () => {
+2 -2
View File
@@ -14,7 +14,7 @@ import { readFileSync } from 'fs';
import { join } from 'path';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { hybridSearch } from '../src/core/search/hybrid.ts';
import { __setEmbedTransportForTests } from '../src/core/ai/gateway.ts';
import { __setEmbedTransportForTests, resetGateway } from '../src/core/ai/gateway.ts';
import { parseQuestionsJsonl, runRetrievalQuality, evaluateGate, type SearchFn } from '../src/eval/retrieval-quality/harness.ts';
import type { ChunkInput } from '../src/core/types.ts';
@@ -60,7 +60,7 @@ beforeAll(async () => {
});
afterAll(async () => {
__setEmbedTransportForTests(null);
resetGateway();
await engine.disconnect();
});
+6 -1
View File
@@ -14,7 +14,7 @@
* - parseExtractorOutput unit tests for the raw JSON parser
*/
import { describe, test, expect } from 'bun:test';
import { describe, test, expect, afterAll } from 'bun:test';
import {
runPhaseProposeTakes,
parseExtractorOutput,
@@ -28,6 +28,11 @@ import {
type ProposedTake,
} from '../src/core/cycle/propose-takes.ts';
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
// R5 shard hygiene: leave no configured gateway past the file boundary.
afterAll(() => {
resetGateway();
});
import { BudgetMeter } from '../src/core/cycle/budget-meter.ts';
import type { OperationContext } from '../src/core/operations.ts';
import type { BrainEngine } from '../src/core/engine.ts';
+78
View File
@@ -199,6 +199,84 @@ describe('check-test-isolation.sh', () => {
});
});
describe('R5 — gateway configuration requires resetGateway teardown (#3066)', () => {
it('flags configureGateway without resetGateway in a teardown hook', () => {
const r = runLintIn([
{
path: 'gateway-leak.test.ts',
contents:
`import { beforeAll, test, expect } from 'bun:test';\n` +
`import { configureGateway } from '../src/core/ai/gateway.ts';\n` +
`beforeAll(() => { configureGateway({ env: {} }); });\n` +
`test('x', () => expect(1).toBe(1));\n`,
},
]);
expect(r.status).toBe(1);
expect(r.stdout).toContain('R5');
expect(r.stdout).toContain('gateway-leak.test.ts');
});
it('flags __setEmbedTransportForTests without resetGateway', () => {
const r = runLintIn([
{
path: 'transport-leak.test.ts',
contents:
`import { afterAll, test, expect } from 'bun:test';\n` +
`import { __setEmbedTransportForTests } from '../src/core/ai/gateway.ts';\n` +
`__setEmbedTransportForTests(async () => ({ embeddings: [] }));\n` +
`afterAll(() => { /* disconnect only */ });\n` +
`test('x', () => expect(1).toBe(1));\n`,
},
]);
expect(r.status).toBe(1);
expect(r.stdout).toContain('R5');
});
it('passes when resetGateway is called and an afterAll hook exists', () => {
const r = runLintIn([
{
path: 'gateway-clean.test.ts',
contents:
`import { beforeAll, afterAll, test, expect } from 'bun:test';\n` +
`import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';\n` +
`beforeAll(() => { configureGateway({ env: {} }); });\n` +
`afterAll(() => { resetGateway(); });\n` +
`test('x', () => expect(1).toBe(1));\n`,
},
]);
expect(r.status).toBe(0);
});
it('a comment mentioning resetGateway() does not satisfy the rule', () => {
const r = runLintIn([
{
path: 'gateway-comment-leak.test.ts',
contents:
`import { beforeAll, afterAll, test, expect } from 'bun:test';\n` +
`import { configureGateway } from '../src/core/ai/gateway.ts';\n` +
`// a co-sharded test that calls resetGateway() would clear this\n` +
`beforeAll(() => { configureGateway({ env: {} }); });\n` +
`afterAll(() => { /* disconnect only */ });\n` +
`test('x', () => expect(1).toBe(1));\n`,
},
]);
expect(r.status).toBe(1);
expect(r.stdout).toContain('R5');
});
it('a test name mentioning "configureGateway (" does not trigger the rule', () => {
const r = runLintIn([
{
path: 'gateway-prose.test.ts',
contents:
`import { test, expect } from 'bun:test';\n` +
`test('works WITHOUT configureGateway (reads registry)', () => expect(1).toBe(1));\n`,
},
]);
expect(r.status).toBe(0);
});
});
describe('scope', () => {
it('skips *.serial.test.ts files entirely', () => {
const r = runLintIn([
+2 -2
View File
@@ -6,7 +6,7 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { __setEmbedTransportForTests } from '../../src/core/ai/gateway.ts';
import { __setEmbedTransportForTests, resetGateway } from '../../src/core/ai/gateway.ts';
import { runSearchDiagnose } from '../../src/commands/search-diagnose.ts';
import type { ChunkInput } from '../../src/core/types.ts';
@@ -35,7 +35,7 @@ beforeAll(async () => {
await engine.setPageAliases('projects/mingtang', 'default', ['hall of light']);
});
afterAll(async () => { __setEmbedTransportForTests(null); await engine.disconnect(); });
afterAll(async () => { resetGateway(); await engine.disconnect(); });
describe('search diagnose', () => {
test('alias query: trace shows alias match + hybrid rank 1', async () => {
+2 -1
View File
@@ -23,7 +23,7 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { configureGateway } from '../../src/core/ai/gateway.ts';
import { configureGateway, resetGateway } from '../../src/core/ai/gateway.ts';
import type { ChunkInput } from '../../src/core/types.ts';
let engine: PGLiteEngine;
@@ -95,6 +95,7 @@ beforeAll(async () => {
afterAll(async () => {
await engine.disconnect();
resetGateway();
});
describe('searchVector per-page max-pool (T1)', () => {
+2 -1
View File
@@ -27,7 +27,7 @@ import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { resetPgliteState } from '../helpers/reset-pglite.ts';
import { hybridSearch } from '../../src/core/search/hybrid.ts';
import { buildOrFallbackWebsearchQuery } from '../../src/core/search/sql-ranking.ts';
import { configureGateway } from '../../src/core/ai/gateway.ts';
import { configureGateway, resetGateway } from '../../src/core/ai/gateway.ts';
let engine: PGLiteEngine;
@@ -48,6 +48,7 @@ beforeAll(async () => {
afterAll(async () => {
await engine.disconnect();
resetGateway();
// Restore the preload-equivalent gateway for sibling files in this shard.
configureGateway({
embedding_model: 'openai:text-embedding-3-large',