Compare commits

...
Author SHA1 Message Date
Garry TanandClaude Fable 5 e5dc0ac399 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>
2026-07-23 11:10:33 -07:00
Garry TanandClaude Fable 5 c11ec1166f fix(facts): surface chat-gateway unavailability instead of silent success-shaped no-op (#3062)
- runFactsBackstop records skipped: 'chat_unavailable' (both modes) and
  declines to enqueue facts-absorb jobs guaranteed to no-op.
- New gateway unavailableReason()/warnUnavailableOnce() name the configured
  model, the missing auth_env keys, and the recipe setup_url; once-per-
  process stderr warn fires at the extractFactsFromTurn guard and at
  expand()'s silent single-query degrade (tokenmax's headline knob).
- doctor facts_health distinguishes '0 active facts, chat gateway
  unreachable' (warn) from a genuinely empty brain (ok).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:10:33 -07:00
393805ab2c fix(extract): link-aware Source — Summary delimiter in timeline bullets (#3059)
Takeover of #3060: the Format-1 timeline parser split 'Source — Summary' at
the first dash after the pipe, which lands inside markdown links (hyphenated
link targets, em-dash link labels), shattering one entry into two fragments
that re-insert on every sync. Delimiter scan is now bracket-depth-aware and
whitespace-anchored; delimiterless bullets are kept whole under the
'markdown' source sentinel instead of dropped. Test fixtures renamed to the
repo's generic placeholders.

Co-authored-by: wright-io <wright-io@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:10:20 -07:00
21 changed files with 382 additions and 27 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
+8 -2
View File
@@ -7090,11 +7090,16 @@ export async function buildChecks(
);
if (factsExists[0]?.exists) {
const health = await engine.getFactsHealth('default');
const status: 'ok' | 'warn' = health.total_active >= 0 ? 'ok' : 'warn';
const top = health.top_entities
.slice(0, 3)
.map(t => `${t.entity_slug}:${t.count}`)
.join(', ') || '—';
// #3062: "0 active facts" used to read [OK] even when the chat
// gateway had never been reachable — indistinguishable from a brain
// with genuinely nothing to extract. Distinguish the two.
const { unavailableReason } = await import('../core/ai/gateway.ts');
const chatDown = health.total_active === 0 ? unavailableReason('chat') : null;
const status: 'ok' | 'warn' = chatDown ? 'warn' : health.total_active >= 0 ? 'ok' : 'warn';
checks.push({
name: 'facts_health',
status,
@@ -7102,7 +7107,8 @@ export async function buildChecks(
`facts_health(default): ${health.total_active} active, ` +
`${health.total_today} today, ${health.total_week} this week, ` +
`${health.total_consolidated} consolidated, ` +
`top entities ${top}`,
`top entities ${top}` +
(chatDown ? ` — extraction has no chat gateway: ${chatDown}` : ''),
});
} else {
checks.push({
+40 -2
View File
@@ -469,15 +469,53 @@ export async function extractLinksFromFile(
// --- Timeline extraction ---
/**
* Index of the first dash (—, , -) that can serve as the Source — Summary
* delimiter: it must have whitespace on both sides and sit outside every
* markdown-link span. Hyphens inside link targets
* (`../people/alice-example.md`) and dashes inside link labels
* (`[Deals — Q1 Review](...)`) are content, not delimiters — splitting on
* them shatters one entry into two fragments whose halves re-insert on
* every sync (the (page_id, date, summary, source) uniqueness sees each
* fragment shape as a new row). Returns -1 when the line has no delimiter.
*/
function findDelimiterOutsideLinks(text: string): number {
let depth = 0;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (c === '[' || c === '(') depth++;
else if (c === ']' || c === ')') { if (depth > 0) depth--; }
else if (
depth === 0 &&
(c === '—' || c === '' || c === '-') &&
i > 0 && /\s/.test(text[i - 1]) &&
i + 1 < text.length && /\s/.test(text[i + 1])
) {
return i;
}
}
return -1;
}
/** Extract timeline entries from markdown content */
export function extractTimelineFromContent(content: string, slug: string): ExtractedTimelineEntry[] {
const entries: ExtractedTimelineEntry[] = [];
// Format 1: Bullet — - **YYYY-MM-DD** | Source — Summary
const bulletPattern = /^-\s+\*\*(\d{4}-\d{2}-\d{2})\*\*\s*\|\s*(.+?)\s*[—–-]\s*(.+)$/gm;
// The delimiter search is link-aware (see findDelimiterOutsideLinks); a
// bullet with no delimiter (e.g. an auto-generated backlink line
// `- **date** | Referenced in [X](y.md)`) is kept whole as the summary
// rather than dropped or fragmented.
const bulletPattern = /^-\s+\*\*(\d{4}-\d{2}-\d{2})\*\*\s*\|\s*(.+)$/gm;
let match;
while ((match = bulletPattern.exec(content)) !== null) {
entries.push({ slug, date: match[1], source: match[2].trim(), summary: match[3].trim() });
const rest = match[2].trim();
const at = findDelimiterOutsideLinks(rest);
if (at >= 0) {
entries.push({ slug, date: match[1], source: rest.slice(0, at).trim(), summary: rest.slice(at + 1).trim() });
} else {
entries.push({ slug, date: match[1], source: 'markdown', summary: rest });
}
}
// Format 2: Header — ### YYYY-MM-DD — Title
+48 -1
View File
@@ -621,6 +621,7 @@ export function resetGateway(): void {
_embedTransportInstalled = false;
_chatTransport = null;
_warnedRecipes.clear();
_warnedUnavailable.clear();
_extendedModels.clear();
}
@@ -887,6 +888,47 @@ export function isAvailable(touchpoint: TouchpointKind, modelOverride?: string):
}
}
/**
* Human-readable reason a chat-capable touchpoint is unavailable, or null
* when it IS available. Names the configured model, the missing
* `auth_env.required` keys, and the recipe's `setup_url` so silent-degrade
* guards (#3062: facts extraction, query expansion) can warn with an
* actionable message instead of no-oping invisibly.
*/
export function unavailableReason(touchpoint: 'chat' | 'expansion'): string | null {
if (isAvailable(touchpoint)) return null;
if (!_config) return `${touchpoint} gateway not configured (no AI gateway config loaded)`;
try {
const modelStr = touchpoint === 'expansion' ? getExpansionModel() : getChatModel();
const { recipe } = resolveRecipe(modelStr);
if (!recipe.touchpoints[touchpoint]) {
return `${touchpoint} model ${modelStr}: provider recipe "${recipe.id}" does not support the ${touchpoint} touchpoint`;
}
const missing = (recipe.auth_env?.required ?? []).filter(k => !_config!.env[k]);
if (missing.length > 0) {
const setup = recipe.auth_env?.setup_url ? ` (get a key: ${recipe.auth_env.setup_url})` : '';
return `${touchpoint} model ${modelStr} needs ${missing.join(', ')}${setup}`;
}
return `${touchpoint} model ${modelStr} is unavailable`;
} catch (e) {
return `${touchpoint} gateway unavailable: ${e instanceof Error ? e.message : String(e)}`;
}
}
/**
* Once-per-process memo for silent-degrade warnings (#3062). Cleared by
* resetGateway() so a reconfigure gets a fresh warning if still broken.
*/
const _warnedUnavailable = new Set<string>();
export function warnUnavailableOnce(touchpoint: 'chat' | 'expansion', context: string): void {
if (_warnedUnavailable.has(touchpoint)) return;
const reason = unavailableReason(touchpoint);
if (!reason) return;
_warnedUnavailable.add(touchpoint);
// eslint-disable-next-line no-console
console.warn(`[ai.gateway] WARN: ${context}${reason}`);
}
// ---- Embedding ----
/**
@@ -2306,7 +2348,12 @@ const ExpansionSchema = z.object({
*/
export async function expand(query: string): Promise<string[]> {
if (!query || !query.trim()) return [query];
if (!isAvailable('expansion')) return [query];
if (!isAvailable('expansion')) {
// #3062: tokenmax's headline knob silently degrading to single-query
// was invisible. Warn once per process; still degrade gracefully.
warnUnavailableOnce('expansion', 'query expansion is configured but inert; searches run single-query');
return [query];
}
// Guardrail seam: classify the query before the expansion model call.
await classifyGatewayGuardrail({
+14 -2
View File
@@ -78,7 +78,7 @@ export type FactsBackstopResult =
mode: 'queue';
enqueued: boolean;
queueDepth: number;
skipped?: 'extraction_disabled' | 'queue_overflow' | 'queue_shutdown' | `eligibility_failed:${string}`;
skipped?: 'extraction_disabled' | 'chat_unavailable' | 'queue_overflow' | 'queue_shutdown' | `eligibility_failed:${string}`;
}
| {
mode: 'inline';
@@ -86,7 +86,7 @@ export type FactsBackstopResult =
duplicate: number;
superseded: number;
fact_ids: number[];
skipped?: 'extraction_disabled' | `eligibility_failed:${string}`;
skipped?: 'extraction_disabled' | 'chat_unavailable' | `eligibility_failed:${string}`;
};
interface ParsedPageInput {
@@ -155,6 +155,18 @@ export async function runFactsBackstop(
: { mode: 'inline', inserted: 0, duplicate: 0, superseded: 0, fact_ids: [], skipped };
}
// #3062: no chat gateway → extraction is guaranteed to yield nothing. The
// result was previously byte-identical to a genuine empty extraction
// (inserted: 0, no `skipped`), and queue mode enqueued jobs doomed to
// no-op. Record WHY, warn once per process, and skip the queue entirely.
const { isAvailable, warnUnavailableOnce } = await import('../ai/gateway.ts');
if (!isAvailable('chat')) {
warnUnavailableOnce('chat', 'facts extraction skipped');
return mode === 'queue'
? { mode: 'queue', enqueued: false, queueDepth: 0, skipped: 'chat_unavailable' }
: { mode: 'inline', inserted: 0, duplicate: 0, superseded: 0, fact_ids: [], skipped: 'chat_unavailable' };
}
// --- Mode dispatch ---
if (mode === 'queue') {
// Local patch 2026-06-11: in a one-shot CLI process the in-process queue
+5 -2
View File
@@ -21,7 +21,7 @@
* gateway-down errors are absorbed into NULL-embedding rows.
*/
import { chat, embedOne, isAvailable } from '../ai/gateway.ts';
import { chat, embedOne, isAvailable, warnUnavailableOnce } from '../ai/gateway.ts';
import type { ChatResult } from '../ai/gateway.ts';
import { INJECTION_PATTERNS } from '../think/sanitize.ts';
import { resolveModel } from '../model-config.ts';
@@ -175,7 +175,10 @@ export async function extractFactsFromTurn(input: ExtractInput): Promise<Extract
if (!isAvailable('chat')) {
// No chat gateway → no extraction. Caller still inserts facts via direct
// `gbrain take add` paths.
// `gbrain take add` paths. #3062: warn once per process — silently
// returning [] here made an unauthenticated brain byte-identical to a
// genuine empty extraction across every health surface.
warnUnavailableOnce('chat', 'facts extraction skipped');
return [];
}
+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();
});
+32
View File
@@ -136,6 +136,38 @@ describe('extractTimelineFromContent', () => {
expect(entries).toHaveLength(1);
});
it('does not split on hyphens inside markdown link targets', () => {
const content = `- **2025-03-18** | Referenced in [Alice](../people/alice-example.md)`;
const entries = extractTimelineFromContent(content, 'companies/acme-example');
expect(entries).toHaveLength(1);
expect(entries[0].source).toBe('markdown');
expect(entries[0].summary).toBe('Referenced in [Alice](../people/alice-example.md)');
});
it('does not split on spaced dashes inside link labels', () => {
const content = `- **2025-03-18** | Referenced in [Deals — Q1 Review](../deals/q1-review.md)`;
const entries = extractTimelineFromContent(content, 'companies/acme-example');
expect(entries).toHaveLength(1);
expect(entries[0].source).toBe('markdown');
expect(entries[0].summary).toBe('Referenced in [Deals — Q1 Review](../deals/q1-review.md)');
});
it('splits on the first spaced dash outside links', () => {
const content = `- **2025-03-18** | [Board notes](../meetings/2025-03-18-board.md) — Approved the hire`;
const entries = extractTimelineFromContent(content, 'test');
expect(entries).toHaveLength(1);
expect(entries[0].source).toBe('[Board notes](../meetings/2025-03-18-board.md)');
expect(entries[0].summary).toBe('Approved the hire');
});
it('keeps delimiterless bullet lines whole instead of dropping them', () => {
const content = `- **2025-03-18** | Imported from legacy tracker`;
const entries = extractTimelineFromContent(content, 'test');
expect(entries).toHaveLength(1);
expect(entries[0].source).toBe('markdown');
expect(entries[0].summary).toBe('Imported from legacy tracker');
});
it('extracts inline citation format entries', () => {
const content = `Closed the seed round with fund-a leading. [Source: board meeting notes, 2025-04-02]`;
const entries = extractTimelineFromContent(content, 'deals/acme-seed');
+5 -5
View File
@@ -83,16 +83,16 @@ describe('put_page facts backstop', () => {
'note/substantive',
`---\ntype: note\ntitle: Substantive\n---\n${'this is some real content with meaningful claims. '.repeat(10)}`,
);
// Either queued (gateway configured) or skipped due to gateway absence
// is acceptable; we only insist the gating doesn't reject on the
// happy path.
// Either queued (gateway configured) or skipped: 'chat_unavailable'
// (#3062: the reset gateway has no chat credential, and the backstop
// now records that instead of enqueueing a job doomed to no-op) is
// acceptable; we only insist the gating doesn't reject on the happy path.
expect(result).toBeDefined();
const r = result!;
if ('queued' in r) {
expect(r.queued).toBe(true);
} else {
// 'backstop_error' or 'queue_shutdown' would be a real failure.
expect(r.skipped).toMatch(/^(queue_shutdown|backstop_error)?$/);
expect(r.skipped).toBe('chat_unavailable');
}
});
+89
View File
@@ -25,8 +25,11 @@ import {
resetGateway,
__setChatTransportForTests,
getChatModel,
unavailableReason,
warnUnavailableOnce,
} from '../src/core/ai/gateway.ts';
import { extractFactsFromTurn } from '../src/core/facts/extract.ts';
import { runFactsBackstop } from '../src/core/facts/backstop.ts';
beforeEach(() => {
resetGateway();
@@ -147,3 +150,89 @@ describe('facts extract — silent-no-op regression (v0.31.6 bug class)', () =>
expect(chatCalled).toBe(true); // ← THE bug-class assertion
});
});
// #3062 — an unauthenticated chat gateway must be DIAGNOSABLE, not a
// silent success-shaped no-op. Three surfaces pinned here:
// 1. unavailableReason() names the missing auth_env key + model.
// 2. warnUnavailableOnce() writes exactly one stderr warning per process.
// 3. runFactsBackstop() records skipped: 'chat_unavailable' (and queue
// mode declines to enqueue a job that is guaranteed to no-op).
describe('#3062 — chat-unavailable is diagnosable, not silent', () => {
test('unavailableReason names the missing auth env key and the model', () => {
configureGateway({
chat_model: 'anthropic:claude-sonnet-4-6',
env: {},
});
expect(isAvailable('chat')).toBe(false);
const reason = unavailableReason('chat');
expect(reason).toContain('ANTHROPIC_API_KEY');
expect(reason).toContain('anthropic:claude-sonnet-4-6');
});
test('unavailableReason is null when the touchpoint is available', () => {
configureGateway({
chat_model: 'anthropic:claude-sonnet-4-6',
env: { ANTHROPIC_API_KEY: 'sk-ant-test' },
});
expect(unavailableReason('chat')).toBeNull();
});
test('warnUnavailableOnce warns exactly once per process per touchpoint', () => {
configureGateway({
chat_model: 'anthropic:claude-sonnet-4-6',
env: {},
});
const seen: string[] = [];
const orig = console.warn;
// eslint-disable-next-line no-console
console.warn = (msg: unknown) => { seen.push(String(msg)); };
try {
warnUnavailableOnce('chat', 'facts extraction skipped');
warnUnavailableOnce('chat', 'facts extraction skipped');
} finally {
// eslint-disable-next-line no-console
console.warn = orig;
}
expect(seen).toHaveLength(1);
expect(seen[0]).toContain('ANTHROPIC_API_KEY');
});
test('runFactsBackstop records skipped: chat_unavailable instead of a success-shaped empty result', async () => {
configureGateway({
chat_model: 'anthropic:claude-sonnet-4-6',
env: {},
});
// The gate fires before any engine use beyond the kill-switch config
// read, so a getConfig stub suffices — no PGLite needed.
const stubEngine = { getConfig: async () => null } as unknown as import('../src/core/engine.ts').BrainEngine;
const page = {
slug: 'note/eligible',
type: 'note' as const,
compiled_truth: 'this is some real content with meaningful claims. '.repeat(10),
frontmatter: {},
};
const inline = await runFactsBackstop(page, {
engine: stubEngine,
sourceId: 'default',
sessionId: null,
source: 'mcp:put_page',
mode: 'inline',
});
expect(inline).toEqual({
mode: 'inline', inserted: 0, duplicate: 0, superseded: 0, fact_ids: [],
skipped: 'chat_unavailable',
});
const queued = await runFactsBackstop(page, {
engine: stubEngine,
sourceId: 'default',
sessionId: null,
source: 'sync:import',
mode: 'queue',
});
expect(queued).toEqual({
mode: 'queue', enqueued: false, queueDepth: 0,
skipped: 'chat_unavailable',
});
});
});
+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',