mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
/**
|
|
* Tests for the v0.42.0.0 doctor `orphan_ratio` check (D5/D11).
|
|
*
|
|
* Local-surface tests run against the same runDoctor path as the CLI.
|
|
* Thin-client-surface tests exercise `runOrphanRatioCheck` from
|
|
* doctor-remote.ts with a stubbed callRemoteTool (no real MCP server
|
|
* needed for unit coverage; the cross-surface parity contract is the
|
|
* shared op + shared math, pinned via the source-grep regression at
|
|
* the bottom of this file).
|
|
*
|
|
* Hermetic PGLite for the local-surface path.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { runDoctor, type DoctorReport } from '../src/commands/doctor.ts';
|
|
import { setCliOptions } from '../src/core/cli-options.ts';
|
|
import { runOrphanRatioCheck } from '../src/core/doctor-remote.ts';
|
|
import { readFileSync } from 'fs';
|
|
|
|
let engine: PGLiteEngine;
|
|
let stdoutBuffer: string[];
|
|
const origLog = console.log;
|
|
const origErr = console.error;
|
|
const origExit = process.exit;
|
|
|
|
function captureCli(): void {
|
|
stdoutBuffer = [];
|
|
console.log = (msg?: unknown) => { stdoutBuffer.push(typeof msg === 'string' ? msg : String(msg)); };
|
|
console.error = () => {};
|
|
(process as { exit: unknown }).exit = (() => { throw new Error('__exit'); }) as unknown as typeof process.exit;
|
|
}
|
|
|
|
function restoreCli(): void {
|
|
console.log = origLog;
|
|
console.error = origErr;
|
|
(process as { exit: unknown }).exit = origExit;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
setCliOptions({ quiet: true, progressJson: false, progressInterval: 1000, explain: false, timeoutMs: null, brain: null });
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
restoreCli();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await engine.executeRaw('DELETE FROM links');
|
|
await engine.executeRaw('DELETE FROM pages');
|
|
});
|
|
|
|
async function runDoctorJson(): Promise<DoctorReport> {
|
|
captureCli();
|
|
try {
|
|
// DON'T pass --fast — orphan_ratio is in the DB-checks group that
|
|
// --fast skips. Tests need the full check set to verify the new
|
|
// check fires.
|
|
await runDoctor(engine, ['--json']);
|
|
} catch (e) {
|
|
if (!(e instanceof Error && e.message === '__exit')) throw e;
|
|
} finally {
|
|
restoreCli();
|
|
}
|
|
// Doctor --json writes the report as ONE big JSON string to stdout.
|
|
// Take the last log entry that parses as a DoctorReport-shape object.
|
|
for (let i = stdoutBuffer.length - 1; i >= 0; i--) {
|
|
try {
|
|
const parsed = JSON.parse(stdoutBuffer[i]!);
|
|
if (parsed && typeof parsed === 'object' && 'checks' in parsed) {
|
|
return parsed as DoctorReport;
|
|
}
|
|
} catch {
|
|
// skip non-JSON lines
|
|
}
|
|
}
|
|
throw new Error('No DoctorReport JSON found in stdout');
|
|
}
|
|
|
|
function findCheck(report: DoctorReport, name: string) {
|
|
return report.checks.find(c => c.name === name);
|
|
}
|
|
|
|
describe('runDoctor — orphan_ratio check (local surface, D5)', () => {
|
|
test('< 100 entity pages → vacuous status ok', async () => {
|
|
// Seed only a handful of entity pages — vacuous gate fires.
|
|
for (let i = 0; i < 5; i++) {
|
|
await engine.putPage(`people/p${i}`, {
|
|
type: 'person', title: `Person ${i}`, compiled_truth: 'b', timeline: '', frontmatter: {},
|
|
});
|
|
}
|
|
const report = await runDoctorJson();
|
|
const check = findCheck(report, 'orphan_ratio');
|
|
expect(check).toBeDefined();
|
|
expect(check!.status).toBe('ok');
|
|
expect(check!.message).toMatch(/vacuous/i);
|
|
});
|
|
|
|
test('100+ entity pages with low orphan ratio → status ok', async () => {
|
|
// Seed 100 entity pages with substantial inbound link coverage.
|
|
for (let i = 0; i < 100; i++) {
|
|
await engine.putPage(`people/person-${i}`, {
|
|
type: 'person', title: `Person ${i}`, compiled_truth: 'b', timeline: '', frontmatter: {},
|
|
});
|
|
}
|
|
// Link 80% of them to be non-orphans (1 inbound link each).
|
|
await engine.putPage('writing/index', {
|
|
type: 'note', title: 'Index', compiled_truth: 'index', timeline: '', frontmatter: {},
|
|
});
|
|
const links = [];
|
|
for (let i = 0; i < 80; i++) {
|
|
links.push({
|
|
from_slug: 'writing/index',
|
|
to_slug: `people/person-${i}`,
|
|
link_type: 'mentions', link_source: 'markdown', context: '',
|
|
});
|
|
}
|
|
await engine.addLinksBatch(links);
|
|
const report = await runDoctorJson();
|
|
const check = findCheck(report, 'orphan_ratio');
|
|
// 20 orphans / 100 linkable = 20% — under warn threshold (50%) → ok.
|
|
expect(check!.status).toBe('ok');
|
|
expect(check!.message).toMatch(/orphan ratio/i);
|
|
});
|
|
|
|
test('high orphan ratio (>0.5, <=0.8) → warn with fix-hint', async () => {
|
|
for (let i = 0; i < 100; i++) {
|
|
await engine.putPage(`companies/co-${i}`, {
|
|
type: 'company', title: `Co ${i}`, compiled_truth: 'b', timeline: '', frontmatter: {},
|
|
});
|
|
}
|
|
// Link only 30% of entities (70% orphan ratio).
|
|
await engine.putPage('writing/index', {
|
|
type: 'note', title: 'Index', compiled_truth: 'index', timeline: '', frontmatter: {},
|
|
});
|
|
const links = [];
|
|
for (let i = 0; i < 30; i++) {
|
|
links.push({
|
|
from_slug: 'writing/index',
|
|
to_slug: `companies/co-${i}`,
|
|
link_type: 'mentions', link_source: 'markdown', context: '',
|
|
});
|
|
}
|
|
await engine.addLinksBatch(links);
|
|
const report = await runDoctorJson();
|
|
const check = findCheck(report, 'orphan_ratio');
|
|
expect(check!.status).toBe('warn');
|
|
expect(check!.message).toContain('gbrain extract links --by-mention');
|
|
});
|
|
|
|
test('very high orphan ratio (>0.8) → fail with urgency fix-hint', async () => {
|
|
for (let i = 0; i < 100; i++) {
|
|
await engine.putPage(`orgs/org-${i}`, {
|
|
type: 'organization', title: `Org ${i}`, compiled_truth: 'b', timeline: '', frontmatter: {},
|
|
});
|
|
}
|
|
// Link only 10% — 90% orphan ratio.
|
|
await engine.putPage('writing/index', {
|
|
type: 'note', title: 'Index', compiled_truth: 'i', timeline: '', frontmatter: {},
|
|
});
|
|
const links = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
links.push({
|
|
from_slug: 'writing/index',
|
|
to_slug: `orgs/org-${i}`,
|
|
link_type: 'mentions', link_source: 'markdown', context: '',
|
|
});
|
|
}
|
|
await engine.addLinksBatch(links);
|
|
const report = await runDoctorJson();
|
|
const check = findCheck(report, 'orphan_ratio');
|
|
expect(check!.status).toBe('fail');
|
|
expect(check!.message).toContain('gbrain extract links --by-mention');
|
|
});
|
|
|
|
test('#2264 — auto_chronicle life/events/ volume is excluded, so it does not trip orphan_ratio', async () => {
|
|
// Healthy knowledge graph: 100 fully-linked entity pages (no real decay).
|
|
for (let i = 0; i < 100; i++) {
|
|
await engine.putPage(`people/person-${i}`, {
|
|
type: 'person', title: `Person ${i}`, compiled_truth: 'b', timeline: '', frontmatter: {},
|
|
});
|
|
}
|
|
await engine.putPage('writing/index', {
|
|
type: 'note', title: 'Index', compiled_truth: 'index', timeline: '', frontmatter: {},
|
|
});
|
|
const links = [];
|
|
for (let i = 0; i < 100; i++) {
|
|
links.push({
|
|
from_slug: 'writing/index',
|
|
to_slug: `people/person-${i}`,
|
|
link_type: 'mentions', link_source: 'markdown', context: '',
|
|
});
|
|
}
|
|
await engine.addLinksBatch(links);
|
|
// Machine chronicle volume: 500 life/events/ pages, no inbound links by
|
|
// design. Without the exclusion these swamp the denominator (~83% orphan
|
|
// → FAIL); excluded, orphan_ratio reflects the healthy knowledge graph.
|
|
for (let i = 0; i < 500; i++) {
|
|
await engine.putPage(`life/events/2026-08-${i}-evt`, {
|
|
type: 'event', title: `Event ${i}`, compiled_truth: 'e', timeline: '', frontmatter: {},
|
|
});
|
|
}
|
|
const report = await runDoctorJson();
|
|
const check = findCheck(report, 'orphan_ratio');
|
|
expect(check!.status).toBe('ok');
|
|
});
|
|
|
|
test('zero entity pages → vacuous status ok', async () => {
|
|
const report = await runDoctorJson();
|
|
const check = findCheck(report, 'orphan_ratio');
|
|
expect(check!.status).toBe('ok');
|
|
});
|
|
|
|
test('JSON envelope shape — orphan_ratio appears in checks[]', async () => {
|
|
const report = await runDoctorJson();
|
|
expect(report.schema_version).toBe(2);
|
|
expect(Array.isArray(report.checks)).toBe(true);
|
|
const names = report.checks.map(c => c.name);
|
|
expect(names).toContain('orphan_ratio');
|
|
});
|
|
});
|
|
|
|
describe('runOrphanRatioCheck — thin-client surface (D11)', () => {
|
|
// Stubbed callRemoteTool is hard to inject without `mock.module`,
|
|
// which violates the test-isolation rule. Instead exercise the
|
|
// network-failure branch (which catches the unconfigured-server case)
|
|
// and pin the shape of the returned RemoteCheck.
|
|
|
|
test('returns informational ok on network failure (unconfigured config)', async () => {
|
|
const result = await runOrphanRatioCheck({
|
|
// Missing remote_mcp → callRemoteTool will throw.
|
|
} as any);
|
|
expect(result.name).toBe('orphan_ratio');
|
|
expect(result.status).toBe('ok');
|
|
expect(result.message).toMatch(/could not query remote|informational/i);
|
|
});
|
|
});
|
|
|
|
describe('cross-surface parity contract', () => {
|
|
test('source greps: orphan_ratio check name appears in BOTH local doctor and remote doctor', () => {
|
|
const doctor = readFileSync('src/commands/doctor.ts', 'utf8');
|
|
const remote = readFileSync('src/core/doctor-remote.ts', 'utf8');
|
|
expect(doctor.includes("name: 'orphan_ratio'")).toBe(true);
|
|
expect(remote.includes("name: 'orphan_ratio'")).toBe(true);
|
|
});
|
|
|
|
test('source greps: both surfaces reference the same fix command', () => {
|
|
const doctor = readFileSync('src/commands/doctor.ts', 'utf8');
|
|
const remote = readFileSync('src/core/doctor-remote.ts', 'utf8');
|
|
expect(doctor).toContain('gbrain extract links --by-mention');
|
|
expect(remote).toContain('gbrain extract links --by-mention');
|
|
});
|
|
|
|
test('source greps: local hint is self-fix; thin-client hint points at operator', () => {
|
|
const doctor = readFileSync('src/commands/doctor.ts', 'utf8');
|
|
const remote = readFileSync('src/core/doctor-remote.ts', 'utf8');
|
|
// Local hint: just the command (user can run it).
|
|
expect(doctor).toContain('Run: gbrain extract links --by-mention');
|
|
// Thin-client hint: ask the operator.
|
|
expect(remote).toMatch(/Ask the brain operator/i);
|
|
});
|
|
});
|