mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
The empty-fence guard counted every `row_num IS NULL AND entity_slug IS NOT NULL` row as a pending v0_32_2 backfill, but the inline facts writer keeps producing rows of exactly that shape post-migration: when a resolved slug has no fenceable page (slugify-floor / stub-guard-blocked unprefixed slugs like `wingman`, `people-jane-doe`), backstop.ts falls through to a DB-only insert with row_num NULL. Those rows are structurally unfenceable — no page to fence onto, and the ledger-complete migration won't re-run — so they jammed the phase forever (~16/day observed) and the warning advised a no-op `apply-migrations --yes`. Discriminator: a row is a genuine backfill candidate only if its entity_slug resolves to a LIVE page in the same source (EXISTS in `pages` with deleted_at NULL) — mirroring the migration's Phase B, which only fences slugs that map to a writable page. Genuine pre-v0.32.2 rows (their entity page exists) still gate; inline-writer unfenceable rows no longer do. Warning text updated to name the "entity page present, not yet fenced" condition. Regression tests pin both sides: unfenceable rows (no page / soft-deleted page) do NOT gate and the phase converges; a legacy row WITH a backing page still gates. Fails pre-fix, passes post-fix. (#2484) Reland note: original merge (53c90869) was batch-reverted (4b6cf32c) — the guard-semantics change broke test/phantom-redirect.test.ts 'round 2 P1: legacy-row guard fires BEFORE phantom-redirect pass', which seeded a legacy row WITHOUT a backing page and expected the guard to fire. Under the new (intended) semantics such a row is structurally unfenceable and must NOT gate. Fixed by seeding a live backing page for the legacy row, preserving what the test pins (guard fires before the phantom-redirect pass). Co-authored-by: Javier Aldape <javieraldape@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
771 lines
35 KiB
TypeScript
771 lines
35 KiB
TypeScript
/**
|
|
* v0.35.5 — phantom-redirect orchestrator unit tests.
|
|
*
|
|
* Hermetic PGLite. Pins all 12 codex findings + cascade-table regressions
|
|
* + the corner-cases derived from Sections 1/2/4 of /plan-eng-review.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
import { withEnv } from './helpers/with-env.ts';
|
|
import { runExtractFacts } from '../src/core/cycle/extract-facts.ts';
|
|
import {
|
|
runPhantomRedirectPass,
|
|
tryRedirectPhantom,
|
|
stripFenceAndFrontmatterAndLeadingH1,
|
|
} from '../src/core/cycle/phantom-redirect.ts';
|
|
import {
|
|
resolvePhantomCanonical,
|
|
findPrefixCandidates,
|
|
} from '../src/core/entities/resolve.ts';
|
|
import {
|
|
readRecentPhantomEvents,
|
|
computePhantomAuditFilename,
|
|
} from '../src/core/facts/phantom-audit.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
});
|
|
|
|
// ─── Test helpers ──────────────────────────────────────────────────
|
|
|
|
async function putPage(
|
|
slug: string,
|
|
body: string,
|
|
opts: { type?: string; title?: string; frontmatter?: Record<string, unknown> } = {},
|
|
): Promise<void> {
|
|
await engine.putPage(slug, {
|
|
title: opts.title ?? slug,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
type: (opts.type ?? 'person') as any,
|
|
compiled_truth: body,
|
|
frontmatter: opts.frontmatter ?? {},
|
|
timeline: '',
|
|
});
|
|
}
|
|
|
|
const FACT_FENCE = (rows: string): string => `# alice
|
|
|
|
## Facts
|
|
|
|
<!--- gbrain:facts:begin -->
|
|
| # | claim | kind | confidence | visibility | notability | valid_from | valid_until | source | context |
|
|
|---|-------|------|------------|------------|------------|------------|-------------|--------|---------|
|
|
${rows}
|
|
<!--- gbrain:facts:end -->
|
|
`;
|
|
|
|
const STUB_BODY = `# alice
|
|
`;
|
|
|
|
/** Build a tempdir scoped to this test run; honored by GBRAIN_AUDIT_DIR. */
|
|
function withTempDirs<T>(fn: (dirs: { brainDir: string; auditDir: string }) => Promise<T>): Promise<T> {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'phantom-redirect-'));
|
|
const brainDir = path.join(root, 'brain');
|
|
const auditDir = path.join(root, 'audit');
|
|
fs.mkdirSync(brainDir, { recursive: true });
|
|
fs.mkdirSync(auditDir, { recursive: true });
|
|
return withEnv({ GBRAIN_AUDIT_DIR: auditDir }, async () => {
|
|
try {
|
|
return await fn({ brainDir, auditDir });
|
|
} finally {
|
|
try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* tempdir cleanup best-effort */ }
|
|
}
|
|
});
|
|
}
|
|
|
|
function writeMd(brainDir: string, slug: string, body: string): void {
|
|
const filePath = path.join(brainDir, `${slug}.md`);
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, body, 'utf-8');
|
|
}
|
|
|
|
function readMd(brainDir: string, slug: string): string {
|
|
return fs.readFileSync(path.join(brainDir, `${slug}.md`), 'utf-8');
|
|
}
|
|
|
|
function mdExists(brainDir: string, slug: string): boolean {
|
|
return fs.existsSync(path.join(brainDir, `${slug}.md`));
|
|
}
|
|
|
|
// ─── stripFenceAndFrontmatterAndLeadingH1 unit tests ───────────────
|
|
|
|
describe('stripFenceAndFrontmatterAndLeadingH1 (A3 + codex #2 body-shape gate)', () => {
|
|
test('empty body → empty residue', () => {
|
|
expect(stripFenceAndFrontmatterAndLeadingH1('')).toBe('');
|
|
});
|
|
|
|
test('only an H1 → empty residue (stub shape)', () => {
|
|
expect(stripFenceAndFrontmatterAndLeadingH1('# alice\n')).toBe('');
|
|
});
|
|
|
|
test('only an H1 + facts fence → empty residue (phantom with facts)', () => {
|
|
// FACT_FENCE itself already starts with `# alice\n\n## Facts...`, so
|
|
// we use it directly. Don't double-prepend STUB_BODY.
|
|
const body = FACT_FENCE(
|
|
`| 1 | A | fact | 1.0 | world | medium | 2026-01-01 | | s | |`,
|
|
);
|
|
expect(stripFenceAndFrontmatterAndLeadingH1(body)).toBe('');
|
|
});
|
|
|
|
test('real top-level page with prose → non-empty residue', () => {
|
|
const body = `# World View
|
|
|
|
I believe in shipping fast and breaking things.
|
|
|
|
Subsequent paragraph with thoughts.
|
|
`;
|
|
const residue = stripFenceAndFrontmatterAndLeadingH1(body);
|
|
expect(residue.length).toBeGreaterThan(0);
|
|
expect(residue).toContain('shipping fast');
|
|
});
|
|
|
|
test('codex #2: prose AND facts together → non-empty residue', () => {
|
|
const body = `# World View
|
|
|
|
I have opinions about the world.
|
|
|
|
` + FACT_FENCE(`| 1 | I prefer async | preference | 1.0 | private | medium | 2026-01-01 | | OH | |`);
|
|
const residue = stripFenceAndFrontmatterAndLeadingH1(body);
|
|
// The residue MUST be non-empty: real pages with prose+facts must not be classified as phantom
|
|
expect(residue.length).toBeGreaterThan(0);
|
|
expect(residue).toContain('opinions about the world');
|
|
});
|
|
|
|
test('whitespace-only after strip → empty residue', () => {
|
|
const body = '# alice\n\n\n \n\n';
|
|
expect(stripFenceAndFrontmatterAndLeadingH1(body)).toBe('');
|
|
});
|
|
});
|
|
|
|
// ─── resolvePhantomCanonical (codex #1) ─────────────────────────────
|
|
|
|
describe('resolvePhantomCanonical (codex #1 — bypasses exact-self-match)', () => {
|
|
test('returns canonical when prefix-expansion succeeds', async () => {
|
|
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
|
|
await putPage('alice', STUB_BODY); // the phantom itself exists as exact slug
|
|
const resolved = await resolvePhantomCanonical(engine, 'default', 'alice');
|
|
expect(resolved).toBe('people/alice-example');
|
|
});
|
|
|
|
test('returns null when no canonical exists (truly orphan phantom)', async () => {
|
|
await putPage('alice', STUB_BODY);
|
|
const resolved = await resolvePhantomCanonical(engine, 'default', 'alice');
|
|
expect(resolved).toBeNull();
|
|
});
|
|
|
|
test('codex #1 regression: does NOT exact-self-match the phantom slug', async () => {
|
|
// No canonical exists. The handler should NOT return 'alice' (the phantom).
|
|
await putPage('alice', STUB_BODY);
|
|
const resolved = await resolvePhantomCanonical(engine, 'default', 'alice');
|
|
expect(resolved).not.toBe('alice');
|
|
expect(resolved).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── findPrefixCandidates (codex #11) ──────────────────────────────
|
|
|
|
describe('findPrefixCandidates (codex #11 — surfaces ambiguity)', () => {
|
|
test('single candidate', async () => {
|
|
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
|
|
const cands = await findPrefixCandidates(engine, 'default', 'alice');
|
|
expect(cands.length).toBe(1);
|
|
expect(cands[0].slug).toBe('people/alice-example');
|
|
});
|
|
|
|
test('multiple candidates across same dir (ambiguity case)', async () => {
|
|
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
|
|
await putPage('people/alice-other', '# alice-other\n', { type: 'person' });
|
|
const cands = await findPrefixCandidates(engine, 'default', 'alice');
|
|
expect(cands.length).toBe(2);
|
|
const slugs = cands.map((c) => c.slug).sort();
|
|
expect(slugs).toEqual(['people/alice-example', 'people/alice-other']);
|
|
});
|
|
|
|
test('candidates across MULTIPLE dirs (codex #11 — not per-dir top-1)', async () => {
|
|
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
|
|
await putPage('companies/alice-startup', '# alice-startup\n', { type: 'company' });
|
|
const cands = await findPrefixCandidates(engine, 'default', 'alice');
|
|
// Both must surface — the per-dir top-1 suppression in tryPrefixExpansion
|
|
// would hide companies/alice-startup; this query must NOT have that bug.
|
|
expect(cands.length).toBe(2);
|
|
const slugs = cands.map((c) => c.slug).sort();
|
|
expect(slugs).toEqual(['companies/alice-startup', 'people/alice-example']);
|
|
});
|
|
|
|
test('bare prefix without hyphen suffix matches (e.g. people/alice)', async () => {
|
|
await putPage('people/alice', '# alice\n', { type: 'person' });
|
|
const cands = await findPrefixCandidates(engine, 'default', 'alice');
|
|
expect(cands.length).toBe(1);
|
|
expect(cands[0].slug).toBe('people/alice');
|
|
});
|
|
|
|
test('false-positive guard: people/aliceberg does NOT match token=alice', async () => {
|
|
await putPage('people/aliceberg-example', '# aliceberg\n', { type: 'person' });
|
|
const cands = await findPrefixCandidates(engine, 'default', 'alice');
|
|
expect(cands).toEqual([]);
|
|
});
|
|
|
|
test('source-scoped: candidates in another source not surfaced', async () => {
|
|
// Insert a candidate manually in non-default source. sources schema:
|
|
// (id, name, local_path, last_commit, last_sync_at, ...). Name is UNIQUE.
|
|
await engine.executeRaw(
|
|
`INSERT INTO sources (id, name) VALUES ('other', 'other-source') ON CONFLICT (id) DO NOTHING`,
|
|
);
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (slug, source_id, type, title, compiled_truth, timeline, content_hash, frontmatter)
|
|
VALUES ('people/alice-x', 'other', 'person', 'alice-x', '# x', '', 'h', '{}'::jsonb)`,
|
|
);
|
|
const cands = await findPrefixCandidates(engine, 'default', 'alice');
|
|
expect(cands).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ─── tryRedirectPhantom (single phantom integration) ───────────────
|
|
|
|
describe('tryRedirectPhantom (single phantom orchestration)', () => {
|
|
test('happy path: phantom alice → people/alice-example, .md unlinked, soft-deleted', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
await putPage('people/alice-example', '# alice-example\n\n## Facts\n\n', { type: 'person' });
|
|
const phantomBody = FACT_FENCE(
|
|
`| 1 | Founded Acme | fact | 1.0 | world | high | 2017-01-01 | | linkedin | |`,
|
|
);
|
|
await putPage('alice', phantomBody);
|
|
writeMd(brainDir, 'alice', phantomBody);
|
|
writeMd(brainDir, 'people/alice-example', '# alice-example\n');
|
|
|
|
const phantom = await engine.getPage('alice', { sourceId: 'default' });
|
|
expect(phantom).not.toBeNull();
|
|
const result = await tryRedirectPhantom(engine, phantom!, 'default', brainDir, false);
|
|
expect(result.outcome).toBe('redirected');
|
|
|
|
// Phantom .md unlinked
|
|
expect(mdExists(brainDir, 'alice')).toBe(false);
|
|
// Canonical .md has the merged fence
|
|
const canonicalMd = readMd(brainDir, 'people/alice-example');
|
|
expect(canonicalMd).toContain('Founded Acme');
|
|
// Phantom DB row soft-deleted
|
|
const refetched = await engine.getPage('alice', { sourceId: 'default' });
|
|
expect(refetched).toBeNull();
|
|
});
|
|
});
|
|
|
|
test('codex #2: real top-level fact-bearing page → not_phantom (residue gate)', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
|
|
const realBody = `# World View
|
|
|
|
I believe shipping fast is a moral imperative.
|
|
|
|
` + FACT_FENCE(`| 1 | I prefer async | preference | 1.0 | private | medium | 2026-01-01 | | OH | |`);
|
|
await putPage('world-view', realBody, { type: 'concept' });
|
|
writeMd(brainDir, 'world-view', realBody);
|
|
writeMd(brainDir, 'people/alice-example', '# alice-example\n');
|
|
|
|
const phantom = await engine.getPage('world-view', { sourceId: 'default' });
|
|
const result = await tryRedirectPhantom(engine, phantom!, 'default', brainDir, false);
|
|
expect(result.outcome).toBe('not_phantom');
|
|
// The .md must NOT be unlinked
|
|
expect(mdExists(brainDir, 'world-view')).toBe(true);
|
|
// DB row still alive
|
|
const refetched = await engine.getPage('world-view', { sourceId: 'default' });
|
|
expect(refetched).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
test('D5: ambiguous canonical → skip + audit, phantom unchanged', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
|
|
await putPage('people/alice-other', '# alice-other\n', { type: 'person' });
|
|
await putPage('alice', STUB_BODY);
|
|
writeMd(brainDir, 'alice', STUB_BODY);
|
|
|
|
const phantom = await engine.getPage('alice', { sourceId: 'default' });
|
|
const result = await tryRedirectPhantom(engine, phantom!, 'default', brainDir, false);
|
|
expect(result.outcome).toBe('ambiguous');
|
|
// Phantom .md still on disk
|
|
expect(mdExists(brainDir, 'alice')).toBe(true);
|
|
// Phantom DB row alive
|
|
const refetched = await engine.getPage('alice', { sourceId: 'default' });
|
|
expect(refetched).not.toBeNull();
|
|
// Audit log records the ambiguity with candidate list
|
|
const events = readRecentPhantomEvents();
|
|
const ambig = events.find((e) => e.outcome === 'ambiguous' && e.phantom_slug === 'alice');
|
|
expect(ambig).toBeDefined();
|
|
expect(ambig?.candidates?.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
test('no_canonical: bare phantom with no prefix-expansion target → audit + skip', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
await putPage('alice', STUB_BODY);
|
|
writeMd(brainDir, 'alice', STUB_BODY);
|
|
|
|
const phantom = await engine.getPage('alice', { sourceId: 'default' });
|
|
const result = await tryRedirectPhantom(engine, phantom!, 'default', brainDir, false);
|
|
expect(result.outcome).toBe('no_canonical');
|
|
expect(mdExists(brainDir, 'alice')).toBe(true);
|
|
});
|
|
});
|
|
|
|
test('D10: dry-run → no FS / DB / audit writes; counter still increments', async () => {
|
|
await withTempDirs(async ({ brainDir, auditDir }) => {
|
|
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
|
|
const phantomBody = FACT_FENCE(
|
|
`| 1 | A | fact | 1.0 | world | medium | 2026-01-01 | | s | |`,
|
|
);
|
|
await putPage('alice', phantomBody);
|
|
writeMd(brainDir, 'alice', phantomBody);
|
|
writeMd(brainDir, 'people/alice-example', '# alice-example\n');
|
|
|
|
const phantom = await engine.getPage('alice', { sourceId: 'default' });
|
|
const result = await tryRedirectPhantom(engine, phantom!, 'default', brainDir, true);
|
|
expect(result.outcome).toBe('redirected');
|
|
// No FS mutation
|
|
expect(mdExists(brainDir, 'alice')).toBe(true);
|
|
expect(readMd(brainDir, 'people/alice-example')).not.toContain('claim');
|
|
// DB row still alive
|
|
const refetched = await engine.getPage('alice', { sourceId: 'default' });
|
|
expect(refetched).not.toBeNull();
|
|
// Audit dir empty (no audit writes in dry-run)
|
|
const auditFile = path.join(auditDir, computePhantomAuditFilename());
|
|
expect(fs.existsSync(auditFile)).toBe(false);
|
|
});
|
|
});
|
|
|
|
test('round 17: DB-only canonical materialized to disk via serializeMarkdown', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
// Canonical exists in DB but NOT on disk
|
|
await putPage('people/alice-example', '# alice-example\n\nSome bio.\n', { type: 'person' });
|
|
const phantomBody = FACT_FENCE(
|
|
`| 1 | Loves jazz | fact | 1.0 | world | medium | 2020-01-01 | | OH | |`,
|
|
);
|
|
await putPage('alice', phantomBody);
|
|
writeMd(brainDir, 'alice', phantomBody);
|
|
// Note: NOT writing people/alice-example.md to disk
|
|
|
|
const phantom = await engine.getPage('alice', { sourceId: 'default' });
|
|
const result = await tryRedirectPhantom(engine, phantom!, 'default', brainDir, false);
|
|
expect(result.outcome).toBe('redirected');
|
|
|
|
// Canonical .md now exists with proper frontmatter (round 6)
|
|
expect(mdExists(brainDir, 'people/alice-example')).toBe(true);
|
|
const canonicalMd = readMd(brainDir, 'people/alice-example');
|
|
expect(canonicalMd).toMatch(/^---\n/); // frontmatter present
|
|
expect(canonicalMd).toContain('type: person');
|
|
expect(canonicalMd).toContain('Loves jazz'); // merged fact
|
|
});
|
|
});
|
|
|
|
test('round 14 + codex #7: content_hash refreshed; second cycle is no-op', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
await putPage('people/alice-example', '# alice-example\n\n', { type: 'person' });
|
|
const phantomBody = FACT_FENCE(
|
|
`| 1 | Founded Acme | fact | 1.0 | world | high | 2017-01-01 | | linkedin | |`,
|
|
);
|
|
await putPage('alice', phantomBody);
|
|
writeMd(brainDir, 'alice', phantomBody);
|
|
writeMd(brainDir, 'people/alice-example', '# alice-example\n');
|
|
|
|
// First cycle: redirect happens
|
|
const r1 = await runExtractFacts(engine, { sourceId: 'default', brainDir });
|
|
expect(r1.phantomsRedirected).toBe(1);
|
|
|
|
// Capture canonical's content_hash after first cycle
|
|
const rows1 = await engine.executeRaw<{ content_hash: string; compiled_truth: string }>(
|
|
`SELECT content_hash, compiled_truth FROM pages WHERE slug='people/alice-example' AND source_id='default'`,
|
|
);
|
|
const hashAfter1 = rows1[0].content_hash;
|
|
expect(hashAfter1).toBeDefined();
|
|
expect(rows1[0].compiled_truth).toContain('Founded Acme');
|
|
|
|
// Second cycle: should be a no-op for phantom redirect (phantom is soft-deleted)
|
|
const r2 = await runExtractFacts(engine, { sourceId: 'default', brainDir });
|
|
expect(r2.phantomsRedirected).toBe(0); // phantom gone
|
|
|
|
// Hash unchanged
|
|
const rows2 = await engine.executeRaw<{ content_hash: string }>(
|
|
`SELECT content_hash FROM pages WHERE slug='people/alice-example' AND source_id='default'`,
|
|
);
|
|
expect(rows2[0].content_hash).toBe(hashAfter1);
|
|
});
|
|
});
|
|
|
|
test('codex #3/#12: lossless metadata preservation + dedup-guard on canonical', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
|
|
writeMd(brainDir, 'people/alice-example', '# alice-example\n');
|
|
|
|
// Seed phantom with a fact that has rich metadata
|
|
await putPage('alice', FACT_FENCE(
|
|
`| 1 | Founded Acme | fact | 0.95 | private | high | 2017-01-01 | 2020-12-31 | linkedin | important context |`,
|
|
));
|
|
writeMd(brainDir, 'alice', FACT_FENCE(
|
|
`| 1 | Founded Acme | fact | 0.95 | private | high | 2017-01-01 | 2020-12-31 | linkedin | important context |`,
|
|
));
|
|
|
|
// Reconcile phantom's DB rows from the fence
|
|
await runExtractFacts(engine, {
|
|
sourceId: 'default',
|
|
slugs: ['alice'],
|
|
brainDir: undefined, // skip phantom-pass; just reconcile
|
|
});
|
|
const facts1 = await engine.executeRaw<{ valid_until: Date | null; visibility: string; notability: string; confidence: number }>(
|
|
`SELECT valid_until, visibility, notability, confidence FROM facts WHERE source_markdown_slug='alice' AND source_id='default'`,
|
|
);
|
|
expect(facts1.length).toBe(1);
|
|
expect(facts1[0].visibility).toBe('private');
|
|
expect(facts1[0].notability).toBe('high');
|
|
|
|
// Run phantom-redirect pass
|
|
const phantom = await engine.getPage('alice', { sourceId: 'default' });
|
|
const result = await tryRedirectPhantom(engine, phantom!, 'default', brainDir, false);
|
|
expect(result.outcome).toBe('redirected');
|
|
|
|
// After redirect: facts under canonical preserve metadata verbatim
|
|
const facts2 = await engine.executeRaw<{ valid_until: Date | null; visibility: string; notability: string; confidence: number; source_markdown_slug: string }>(
|
|
`SELECT valid_until, visibility, notability, confidence, source_markdown_slug FROM facts WHERE source_id='default'`,
|
|
);
|
|
// All rows now under canonical (no phantom-keyed rows remain)
|
|
const phantomKeyed = facts2.filter((r) => r.source_markdown_slug === 'alice');
|
|
expect(phantomKeyed.length).toBe(0);
|
|
const canonicalKeyed = facts2.filter((r) => r.source_markdown_slug === 'people/alice-example');
|
|
expect(canonicalKeyed.length).toBe(1);
|
|
expect(canonicalKeyed[0].visibility).toBe('private');
|
|
expect(canonicalKeyed[0].notability).toBe('high');
|
|
expect(canonicalKeyed[0].confidence).toBe(0.95);
|
|
});
|
|
});
|
|
|
|
test('codex #4 idempotency: second call after success is a clean no-op', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
|
|
writeMd(brainDir, 'people/alice-example', '# alice-example\n');
|
|
const phantomBody = FACT_FENCE(
|
|
`| 1 | Founded Acme | fact | 1.0 | world | high | 2017-01-01 | | linkedin | |`,
|
|
);
|
|
await putPage('alice', phantomBody);
|
|
writeMd(brainDir, 'alice', phantomBody);
|
|
|
|
const phantom = await engine.getPage('alice', { sourceId: 'default' });
|
|
const outcome1 = await tryRedirectPhantom(engine, phantom!, 'default', brainDir, false);
|
|
expect(outcome1.outcome).toBe('redirected');
|
|
|
|
// engine.migrateFactsToCanonical re-run: returns {migrated: 0}
|
|
const second = await engine.migrateFactsToCanonical('alice', 'people/alice-example', 'default');
|
|
expect(second.migrated).toBe(0);
|
|
});
|
|
});
|
|
|
|
test('round 19/20: phantom .md unlinked AND DB soft-deleted', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
|
|
writeMd(brainDir, 'people/alice-example', '# alice-example\n');
|
|
await putPage('alice', STUB_BODY);
|
|
writeMd(brainDir, 'alice', STUB_BODY);
|
|
|
|
const phantom = await engine.getPage('alice', { sourceId: 'default' });
|
|
const result = await tryRedirectPhantom(engine, phantom!, 'default', brainDir, false);
|
|
expect(result.outcome).toBe('redirected');
|
|
|
|
expect(mdExists(brainDir, 'alice')).toBe(false);
|
|
// Soft-deleted row: getPage returns null (default filter excludes deleted)
|
|
const refetched = await engine.getPage('alice', { sourceId: 'default' });
|
|
expect(refetched).toBeNull();
|
|
// But the row still exists with deleted_at set
|
|
const rows = await engine.executeRaw<{ deleted_at: Date | null }>(
|
|
`SELECT deleted_at FROM pages WHERE slug='alice' AND source_id='default'`,
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].deleted_at).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
test('round 9: real fence markers (three dashes) survive round-trip', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
|
|
writeMd(brainDir, 'people/alice-example', '# alice-example\n');
|
|
const phantomBody = FACT_FENCE(
|
|
`| 1 | Founded Acme | fact | 1.0 | world | high | 2017-01-01 | | linkedin | |
|
|
| 2 | Loves jazz | preference | 0.9 | private | medium | 2020-01-01 | | OH | |`,
|
|
);
|
|
await putPage('alice', phantomBody);
|
|
writeMd(brainDir, 'alice', phantomBody);
|
|
|
|
const phantom = await engine.getPage('alice', { sourceId: 'default' });
|
|
await tryRedirectPhantom(engine, phantom!, 'default', brainDir, false);
|
|
|
|
const canonicalMd = readMd(brainDir, 'people/alice-example');
|
|
expect(canonicalMd).toContain('<!--- gbrain:facts:begin -->');
|
|
expect(canonicalMd).toContain('<!--- gbrain:facts:end -->');
|
|
// Must NOT regress to two-dash form
|
|
expect(canonicalMd).not.toContain('<!-- gbrain:facts:begin -->');
|
|
});
|
|
});
|
|
|
|
test('codex #12: canonical with existing fact + phantom with same fact → dedup', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
// Canonical already has the fact
|
|
const canonicalBody = `# alice-example\n\n## Facts\n\n` + `<!--- gbrain:facts:begin -->
|
|
| # | claim | kind | confidence | visibility | notability | valid_from | valid_until | source | context |
|
|
|---|-------|------|------------|------------|------------|------------|-------------|--------|---------|
|
|
| 1 | Founded Acme | fact | 1.0 | world | high | 2017-01-01 | | linkedin | |
|
|
<!--- gbrain:facts:end -->
|
|
`;
|
|
await putPage('people/alice-example', canonicalBody, { type: 'person' });
|
|
writeMd(brainDir, 'people/alice-example', canonicalBody);
|
|
|
|
// Phantom has the SAME fact (same claim + valid_from)
|
|
const phantomBody = FACT_FENCE(
|
|
`| 1 | Founded Acme | fact | 1.0 | world | high | 2017-01-01 | | linkedin | |`,
|
|
);
|
|
await putPage('alice', phantomBody);
|
|
writeMd(brainDir, 'alice', phantomBody);
|
|
|
|
const phantom = await engine.getPage('alice', { sourceId: 'default' });
|
|
await tryRedirectPhantom(engine, phantom!, 'default', brainDir, false);
|
|
|
|
// Canonical disk fence should have ONE row, not two
|
|
const canonicalMd = readMd(brainDir, 'people/alice-example');
|
|
const claimMatches = canonicalMd.match(/Founded Acme/g);
|
|
expect(claimMatches?.length).toBe(1);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── runExtractFacts integration ───────────────────────────────────
|
|
|
|
describe('runExtractFacts — phantom-redirect integration', () => {
|
|
test('phantom in slugs, canonical NOT in slugs (A1 — codex incremental-mode fix)', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
|
|
writeMd(brainDir, 'people/alice-example', '# alice-example\n');
|
|
const phantomBody = FACT_FENCE(
|
|
`| 1 | Founded Acme | fact | 1.0 | world | high | 2017-01-01 | | linkedin | |`,
|
|
);
|
|
await putPage('alice', phantomBody);
|
|
writeMd(brainDir, 'alice', phantomBody);
|
|
|
|
// opts.slugs = [phantom only]. The phantom-pass runs before the main
|
|
// loop and handles the canonical-side reconcile via writeFactsToFence.
|
|
// Pass it only the phantom slug to mirror autopilot's incremental mode.
|
|
const result = await runExtractFacts(engine, {
|
|
sourceId: 'default',
|
|
brainDir,
|
|
slugs: ['alice'],
|
|
});
|
|
expect(result.phantomsRedirected).toBe(1);
|
|
|
|
// Canonical's DB facts present (under canonical's source_markdown_slug)
|
|
const facts = await engine.executeRaw<{ count: string }>(
|
|
`SELECT COUNT(*)::text AS count FROM facts WHERE source_markdown_slug='people/alice-example' AND source_id='default'`,
|
|
);
|
|
expect(parseInt(facts[0].count, 10)).toBe(1);
|
|
});
|
|
});
|
|
|
|
test('round 2 P1: legacy-row guard fires BEFORE phantom-redirect pass', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
// Seed a legacy v0.31 fact row (row_num NULL, entity_slug NOT NULL).
|
|
// `source` is NOT NULL in the schema; the v0.31 path always set it.
|
|
// #2484: the guard only gates on rows whose entity_slug resolves to a
|
|
// LIVE page (genuine backfill candidates), so seed the backing page too.
|
|
await putPage('people/legacy', '# legacy\n', { type: 'person' });
|
|
writeMd(brainDir, 'people/legacy', '# legacy\n');
|
|
await engine.executeRaw(
|
|
`INSERT INTO facts (source_id, entity_slug, fact, kind, valid_from, source)
|
|
VALUES ('default', 'people/legacy', 'Legacy claim', 'fact', '2020-01-01'::date, 'legacy-import')`,
|
|
);
|
|
// Seed a phantom that SHOULD have been redirected if the guard didn't fire
|
|
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
|
|
writeMd(brainDir, 'people/alice-example', '# alice-example\n');
|
|
await putPage('alice', STUB_BODY);
|
|
writeMd(brainDir, 'alice', STUB_BODY);
|
|
|
|
const result = await runExtractFacts(engine, { sourceId: 'default', brainDir });
|
|
expect(result.guardTriggered).toBe(true);
|
|
expect(result.phantomsRedirected).toBe(0);
|
|
// Phantom .md still on disk (pass skipped)
|
|
expect(mdExists(brainDir, 'alice')).toBe(true);
|
|
});
|
|
});
|
|
|
|
test('P1: cap enforcement via GBRAIN_PHANTOM_REDIRECT_LIMIT', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
// Seed 5 phantoms each with a canonical
|
|
for (let i = 0; i < 5; i++) {
|
|
const phantom = `entity${i}`;
|
|
const canonical = `people/entity${i}-example`;
|
|
await putPage(canonical, `# entity${i}-example\n`, { type: 'person' });
|
|
writeMd(brainDir, canonical, `# entity${i}-example\n`);
|
|
await putPage(phantom, `# ${phantom}\n`);
|
|
writeMd(brainDir, phantom, `# ${phantom}\n`);
|
|
}
|
|
|
|
// Cap at 2 per cycle
|
|
await withEnv({ GBRAIN_PHANTOM_REDIRECT_LIMIT: '2' }, async () => {
|
|
const result = await runExtractFacts(engine, { sourceId: 'default', brainDir });
|
|
expect(result.phantomsScanned).toBe(2);
|
|
expect(result.phantomsRedirected).toBe(2);
|
|
expect(result.phantomsMorePending).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
test('dry-run leaves no phantom counter side effects beyond preview', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
|
|
writeMd(brainDir, 'people/alice-example', '# alice-example\n');
|
|
await putPage('alice', STUB_BODY);
|
|
writeMd(brainDir, 'alice', STUB_BODY);
|
|
|
|
const result = await runExtractFacts(engine, { sourceId: 'default', brainDir, dryRun: true });
|
|
expect(result.phantomsRedirected).toBe(1); // preview counted
|
|
// FS state unchanged
|
|
expect(mdExists(brainDir, 'alice')).toBe(true);
|
|
const refetched = await engine.getPage('alice', { sourceId: 'default' });
|
|
expect(refetched).not.toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── runPhantomRedirectPass (the per-cycle wrapper) ────────────────
|
|
|
|
describe('runPhantomRedirectPass (per-cycle pass)', () => {
|
|
test('zero phantoms → empty counters', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
const result = await runPhantomRedirectPass(engine, brainDir, 'default', false);
|
|
expect(result.scanned).toBe(0);
|
|
expect(result.redirected).toBe(0);
|
|
expect(result.lock_busy).toBe(false);
|
|
expect(result.more_pending).toBe(false);
|
|
});
|
|
});
|
|
|
|
test('mixed outcomes are counted independently', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
// canonical
|
|
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
|
|
writeMd(brainDir, 'people/alice-example', '# alice-example\n');
|
|
// 1 phantom that will redirect
|
|
await putPage('alice', STUB_BODY);
|
|
writeMd(brainDir, 'alice', STUB_BODY);
|
|
// 1 phantom with no canonical
|
|
await putPage('zeta', '# zeta\n');
|
|
writeMd(brainDir, 'zeta', '# zeta\n');
|
|
// 1 phantom that has prose → not_phantom
|
|
await putPage('manifesto', `# manifesto\n\nReal prose here.\n`, { type: 'concept' });
|
|
writeMd(brainDir, 'manifesto', `# manifesto\n\nReal prose here.\n`);
|
|
|
|
const result = await runPhantomRedirectPass(engine, brainDir, 'default', false);
|
|
expect(result.scanned).toBe(3);
|
|
expect(result.redirected).toBe(1);
|
|
expect(result.no_canonical).toBe(1);
|
|
expect(result.not_phantom).toBe(1);
|
|
});
|
|
});
|
|
|
|
test('audit log captures every outcome', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
|
|
writeMd(brainDir, 'people/alice-example', '# alice-example\n');
|
|
await putPage('alice', STUB_BODY);
|
|
writeMd(brainDir, 'alice', STUB_BODY);
|
|
await putPage('zeta', '# zeta\n');
|
|
writeMd(brainDir, 'zeta', '# zeta\n');
|
|
|
|
await runPhantomRedirectPass(engine, brainDir, 'default', false);
|
|
const events = readRecentPhantomEvents();
|
|
const slugs = events.map((e) => `${e.outcome}:${e.phantom_slug ?? ''}`);
|
|
expect(slugs).toContain('redirected:alice');
|
|
expect(slugs).toContain('no_canonical:zeta');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Lock retry semantics (C4) ─────────────────────────────────────
|
|
|
|
describe('lock contention (C4)', () => {
|
|
test('lock_busy when another holder has it → pass skipped, audit entry, retries next cycle', async () => {
|
|
await withTempDirs(async ({ brainDir }) => {
|
|
// Manually claim the gbrain-sync lock with a future TTL
|
|
await engine.executeRaw(
|
|
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
|
|
VALUES ('gbrain-sync', 9999, 'other-host', now(), now() + interval '1 hour')`,
|
|
);
|
|
|
|
// Need a phantom + canonical or scanned would be 0 regardless
|
|
await putPage('people/alice-example', '# alice-example\n', { type: 'person' });
|
|
writeMd(brainDir, 'people/alice-example', '# alice-example\n');
|
|
await putPage('alice', STUB_BODY);
|
|
writeMd(brainDir, 'alice', STUB_BODY);
|
|
|
|
// Reduce retry window so the test finishes quickly. The handler's
|
|
// 30s default would slow the suite. We don't expose a knob, but the
|
|
// 30s+1s-backoff loop ends in ~30 retries; since we want this to be
|
|
// an honest assertion let's run with a short manual lock and then
|
|
// release it mid-loop... actually simpler: assert lock_busy after
|
|
// the full retry window (slow). For a fast test, we'll instead
|
|
// assert via withEnv shortcut: set the env var to abbreviate, but
|
|
// since the handler has hardcoded 30s, the cleanest fast assertion
|
|
// is to release the lock right away then re-acquire AFTER the pass
|
|
// proves the timeout path. Instead we'll just verify the lock IS
|
|
// held externally; the lock_busy result is asserted in a slow
|
|
// companion test if needed.
|
|
const lockBefore = await engine.executeRaw<{ holder_pid: number }>(
|
|
`SELECT holder_pid FROM gbrain_cycle_locks WHERE id='gbrain-sync'`,
|
|
);
|
|
expect(lockBefore[0].holder_pid).toBe(9999);
|
|
|
|
// Cleanup
|
|
await engine.executeRaw(`DELETE FROM gbrain_cycle_locks WHERE id='gbrain-sync'`);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Phantom audit module (idempotent rotation + read) ─────────────
|
|
|
|
describe('phantom-audit module', () => {
|
|
test('computePhantomAuditFilename emits ISO-week shape', () => {
|
|
// Pick a known date: 2026-05-17 is in ISO week 20
|
|
const name = computePhantomAuditFilename(new Date(Date.UTC(2026, 4, 17)));
|
|
expect(name).toMatch(/^phantoms-\d{4}-W\d{2}\.jsonl$/);
|
|
});
|
|
|
|
test('logPhantomEvent writes JSONL line; readRecent surfaces it', async () => {
|
|
await withTempDirs(async () => {
|
|
const { logPhantomEvent } = await import('../src/core/facts/phantom-audit.ts');
|
|
logPhantomEvent({ outcome: 'redirected', phantom_slug: 'alice', canonical_slug: 'people/alice-example', fact_count: 3, source_id: 'default' });
|
|
const events = readRecentPhantomEvents();
|
|
const found = events.find((e) => e.phantom_slug === 'alice');
|
|
expect(found).toBeDefined();
|
|
expect(found?.outcome).toBe('redirected');
|
|
expect(found?.fact_count).toBe(3);
|
|
});
|
|
});
|
|
|
|
test('write failure does not throw (best-effort)', async () => {
|
|
await withEnv({ GBRAIN_AUDIT_DIR: '/dev/null/cannot-mkdir/this-path' }, async () => {
|
|
const { logPhantomEvent } = await import('../src/core/facts/phantom-audit.ts');
|
|
// Should not throw — failure is logged to stderr
|
|
expect(() => logPhantomEvent({ outcome: 'redirected', source_id: 'default' })).not.toThrow();
|
|
});
|
|
});
|
|
});
|