mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
orphan_ratio's denominator is swamped on auto_chronicle brains by the machine-generated chronicle events (life/events/<day>-<hash>, written per eligible event) — no inbound links by design. The shipped policy already excludes raw/atoms/skills/dreaming/daily and extracts/, but life/events/ was still counted; on a 1,657-page auto_chronicle brain it was ~72% of the orphan mass, enough to pin the ratio red. Add 'life/events/' to DENY_PREFIXES — a scoped prefix, NOT the whole `life/` first-segment, so human-authored life/diary/ (gbrain capture --type diary) stays IN the denominator. Same shipped hardcoded-class mechanism as the existing entries; not the #2215 user-config route (closed not_planned). Knowledge classes (concepts/people/notes/projects) also stay in, so genuine graph decay still trips. Regression in test/orphans-pure-fn.test.ts: life/events/ now excluded (fails before, passes after); life/diary/ and concepts//notes//projects/ pinned as still-counted. doctor's orphan_ratio uses the same shouldExclude path (getOrphansData; local + doctor-remote MCP), covered transitively.
300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
/**
|
|
* IRON RULE regression test (per D1 from /plan-eng-review for v0.42.0.0).
|
|
*
|
|
* Pins byte-identical output between:
|
|
* - `gbrain orphans --json` (CLI orchestrator `runOrphans`)
|
|
* - `findOrphans(engine, opts)` (canonical pure data fn)
|
|
* - `getOrphansData(engine, opts)` (v0.42.0.0 alias for findOrphans)
|
|
*
|
|
* If a future refactor lets the CLI filter results differently after
|
|
* `findOrphans` returns, this test catches the drift. Doctor's
|
|
* `orphan_ratio` check imports `getOrphansData`; this test guarantees
|
|
* the doctor count cannot disagree with `gbrain orphans --count`.
|
|
*
|
|
* Hermetic via PGLite. No DATABASE_URL needed.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import {
|
|
findOrphans,
|
|
getOrphansData,
|
|
shouldExclude,
|
|
runOrphans,
|
|
} from '../src/commands/orphans.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
let logBuffer: string[];
|
|
const originalLog = console.log;
|
|
|
|
function captureConsoleLog(): void {
|
|
logBuffer = [];
|
|
console.log = (msg?: unknown) => {
|
|
logBuffer.push(typeof msg === 'string' ? msg : String(msg));
|
|
};
|
|
}
|
|
|
|
function restoreConsoleLog(): void {
|
|
console.log = originalLog;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
restoreConsoleLog();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Clean slate per test — keeps the IRON RULE assertions deterministic
|
|
// across the file's test suite.
|
|
await engine.executeRaw('DELETE FROM links');
|
|
await engine.executeRaw('DELETE FROM pages');
|
|
});
|
|
|
|
async function seedFixture(): Promise<void> {
|
|
// 5 entity pages: 2 will have inbound links, 3 will be orphans.
|
|
// 2 pseudo-pages: should be excluded by default filter.
|
|
// 1 content page: links to person-1 + company-1.
|
|
await engine.putPage('people/person-1', {
|
|
type: 'person', title: 'Person 1', compiled_truth: 'p1', timeline: '', frontmatter: { domain: 'people' },
|
|
});
|
|
await engine.putPage('people/person-2', {
|
|
type: 'person', title: 'Person 2', compiled_truth: 'p2', timeline: '', frontmatter: { domain: 'people' },
|
|
});
|
|
await engine.putPage('people/person-3', {
|
|
type: 'person', title: 'Person 3', compiled_truth: 'p3', timeline: '', frontmatter: { domain: 'people' },
|
|
});
|
|
await engine.putPage('companies/company-1', {
|
|
type: 'company', title: 'Company 1', compiled_truth: 'c1', timeline: '', frontmatter: { domain: 'companies' },
|
|
});
|
|
await engine.putPage('companies/company-2', {
|
|
type: 'company', title: 'Company 2', compiled_truth: 'c2', timeline: '', frontmatter: { domain: 'companies' },
|
|
});
|
|
// Pseudo-pages — should be excluded from default orphan results.
|
|
await engine.putPage('_atlas', {
|
|
type: 'note', title: 'Atlas', compiled_truth: 'atlas', timeline: '', frontmatter: {},
|
|
});
|
|
await engine.putPage('templates/meeting', {
|
|
type: 'note', title: 'Meeting template', compiled_truth: 'tmpl', timeline: '', frontmatter: {},
|
|
});
|
|
// Content page that links to person-1 + company-1.
|
|
await engine.putPage('writing/post-1', {
|
|
type: 'note', title: 'Post 1', compiled_truth: 'content', timeline: '', frontmatter: {},
|
|
});
|
|
await engine.addLinksBatch([
|
|
{ from_slug: 'writing/post-1', to_slug: 'people/person-1', link_type: 'mentions', link_source: 'markdown', context: '' },
|
|
{ from_slug: 'writing/post-1', to_slug: 'companies/company-1', link_type: 'mentions', link_source: 'markdown', context: '' },
|
|
]);
|
|
}
|
|
|
|
describe('orphans pure data fn — IRON RULE byte-identical contract', () => {
|
|
test('getOrphansData is the same function reference as findOrphans', () => {
|
|
expect(getOrphansData).toBe(findOrphans);
|
|
});
|
|
|
|
test('findOrphans and getOrphansData produce deep-equal output', async () => {
|
|
await seedFixture();
|
|
const viaFindOrphans = await findOrphans(engine, { includePseudo: false });
|
|
const viaGetOrphansData = await getOrphansData(engine, { includePseudo: false });
|
|
expect(viaGetOrphansData).toEqual(viaFindOrphans);
|
|
});
|
|
|
|
test('includePseudo: false vs true changes excluded count', async () => {
|
|
await seedFixture();
|
|
const def = await findOrphans(engine, { includePseudo: false });
|
|
const all = await findOrphans(engine, { includePseudo: true });
|
|
expect(all.excluded).toBe(0);
|
|
expect(def.excluded).toBeGreaterThan(0);
|
|
expect(all.total_orphans).toBeGreaterThanOrEqual(def.total_orphans);
|
|
});
|
|
|
|
test('CLI --json output deep-equals findOrphans return value', async () => {
|
|
await seedFixture();
|
|
const direct = await findOrphans(engine, { includePseudo: false });
|
|
captureConsoleLog();
|
|
try {
|
|
await runOrphans(engine, ['--json']);
|
|
} finally {
|
|
restoreConsoleLog();
|
|
}
|
|
expect(logBuffer.length).toBe(1);
|
|
const cliOutput = JSON.parse(logBuffer[0]!);
|
|
// IRON RULE: CLI --json output must deep-equal the pure-fn output.
|
|
// If a future change adds CLI-side post-filtering, this fires.
|
|
expect(cliOutput).toEqual(direct);
|
|
});
|
|
|
|
test('CLI --count matches total_orphans from pure fn', async () => {
|
|
await seedFixture();
|
|
const direct = await findOrphans(engine, { includePseudo: false });
|
|
captureConsoleLog();
|
|
try {
|
|
await runOrphans(engine, ['--count']);
|
|
} finally {
|
|
restoreConsoleLog();
|
|
}
|
|
expect(logBuffer.length).toBe(1);
|
|
expect(logBuffer[0]).toBe(String(direct.total_orphans));
|
|
});
|
|
|
|
test('CLI --count with --include-pseudo matches pure-fn total_orphans (includePseudo: true)', async () => {
|
|
await seedFixture();
|
|
const direct = await findOrphans(engine, { includePseudo: true });
|
|
captureConsoleLog();
|
|
try {
|
|
await runOrphans(engine, ['--count', '--include-pseudo']);
|
|
} finally {
|
|
restoreConsoleLog();
|
|
}
|
|
expect(logBuffer[0]).toBe(String(direct.total_orphans));
|
|
});
|
|
});
|
|
|
|
describe('shouldExclude — orphan filter regression (preserve curation)', () => {
|
|
test('pseudo-pages are excluded', () => {
|
|
expect(shouldExclude('_atlas')).toBe(true);
|
|
expect(shouldExclude('_index')).toBe(true);
|
|
expect(shouldExclude('_orphans')).toBe(true);
|
|
});
|
|
|
|
test('auto-suffix patterns are excluded', () => {
|
|
expect(shouldExclude('people/_index')).toBe(true);
|
|
expect(shouldExclude('writing/log')).toBe(true);
|
|
});
|
|
|
|
test('raw segment is excluded', () => {
|
|
expect(shouldExclude('media/x/raw/post')).toBe(true);
|
|
expect(shouldExclude('raw/chats/claude-code/session')).toBe(true);
|
|
});
|
|
|
|
test('leading raw/ segment is excluded (same archive convention)', () => {
|
|
expect(shouldExclude('raw/whatsapp/2025-01/chat-log')).toBe(true);
|
|
expect(shouldExclude('raw/transcripts/meeting')).toBe(true);
|
|
// 'rawhide/...' must NOT match — prefix is 'raw/', not 'raw'.
|
|
expect(shouldExclude('rawhide/notes')).toBe(false);
|
|
});
|
|
|
|
test('daily-log pages are excluded (calendar/email integrations write these)', () => {
|
|
expect(shouldExclude('daily/calendar/2025/2025-01-01')).toBe(true);
|
|
expect(shouldExclude('daily/x/2025-06-13')).toBe(true);
|
|
});
|
|
|
|
test('outputs/ plural prefix is excluded like output/', () => {
|
|
expect(shouldExclude('outputs/render-batch-3')).toBe(true);
|
|
});
|
|
|
|
test('readme folder descriptors are excluded at any depth', () => {
|
|
expect(shouldExclude('readme')).toBe(true);
|
|
expect(shouldExclude('index')).toBe(true);
|
|
expect(shouldExclude('projects/readme')).toBe(true);
|
|
expect(shouldExclude('media/readme')).toBe(true);
|
|
// A page merely mentioning readme in its name is NOT excluded.
|
|
expect(shouldExclude('concepts/readme-driven-development')).toBe(false);
|
|
});
|
|
|
|
test('machine-generated extracts pages are excluded', () => {
|
|
expect(shouldExclude('extracts/2026-06-12/takes.proposed/host/propose-x/round-single')).toBe(true);
|
|
});
|
|
|
|
test('inbox intake-tray pages are excluded (same rationale as daily)', () => {
|
|
expect(shouldExclude('inbox/some-renewal-notice-2026-06-22')).toBe(true);
|
|
});
|
|
|
|
test('root schema and log pages are excluded', () => {
|
|
expect(shouldExclude('schema')).toBe(true);
|
|
expect(shouldExclude('log')).toBe(true);
|
|
expect(shouldExclude('concepts/schema-design')).toBe(false);
|
|
});
|
|
|
|
test('deny-prefixes are excluded', () => {
|
|
expect(shouldExclude('templates/meeting')).toBe(true);
|
|
expect(shouldExclude('dashboards/_index')).toBe(true);
|
|
expect(shouldExclude('scripts/build')).toBe(true);
|
|
expect(shouldExclude('output/foo')).toBe(true);
|
|
// #2264 — auto_chronicle event volume (life/events/…) is a machine leaf.
|
|
expect(shouldExclude('life/events/2026-08-01-abc123')).toBe(true);
|
|
});
|
|
|
|
test('first-segment exclusions fire', () => {
|
|
expect(shouldExclude('scratch/notes')).toBe(true);
|
|
expect(shouldExclude('thoughts/today')).toBe(true);
|
|
expect(shouldExclude('catalog/movies')).toBe(true);
|
|
expect(shouldExclude('entities/anonymous')).toBe(true);
|
|
expect(shouldExclude('atoms/fact-123')).toBe(true);
|
|
expect(shouldExclude('skills/gbrain-operations')).toBe(true);
|
|
expect(shouldExclude('dreaming/light/2026-07-20')).toBe(true);
|
|
expect(shouldExclude('daily/2026-07-20')).toBe(true);
|
|
expect(shouldExclude('agent-openclaw/daily/2026-07-20')).toBe(true);
|
|
});
|
|
|
|
test('workspace convention slugs are excluded', () => {
|
|
expect(shouldExclude('_brain-conventions')).toBe(true);
|
|
expect(shouldExclude('_templates/decision')).toBe(true);
|
|
expect(shouldExclude('extracts/2026-06-30/takes.proposed/round-single')).toBe(true);
|
|
expect(shouldExclude('2026-07-20')).toBe(true);
|
|
expect(shouldExclude('2026-07-20-qa-sweep')).toBe(true);
|
|
expect(shouldExclude('agents/arya/identity')).toBe(true);
|
|
expect(shouldExclude('agents/arya/memory/dreaming/deep/2026-07-20')).toBe(true);
|
|
});
|
|
|
|
test('regular slugs are NOT excluded', () => {
|
|
expect(shouldExclude('people/alice')).toBe(false);
|
|
expect(shouldExclude('companies/acme')).toBe(false);
|
|
expect(shouldExclude('writing/post-1')).toBe(false);
|
|
expect(shouldExclude('agents/arya/qa-reports/launch-review')).toBe(false);
|
|
// #2264 — knowledge classes must STAY in the denominator so real graph decay still trips.
|
|
expect(shouldExclude('concepts/information-architecture')).toBe(false);
|
|
expect(shouldExclude('notes/some-note')).toBe(false);
|
|
expect(shouldExclude('projects/proj-x')).toBe(false);
|
|
// #2264 — only life/events/ is excluded; human-authored life/diary/ stays counted.
|
|
expect(shouldExclude('life/diary/2026-08-01-xyz')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getHealth orphan_pages uses shared exclusion policy', () => {
|
|
test('excluded convention islands do not count against health', async () => {
|
|
await engine.putPage('_templates/decision', {
|
|
type: 'template', title: 'Decision', compiled_truth: 'template', timeline: '', frontmatter: {},
|
|
});
|
|
await engine.putPage('skills/arya/source-check', {
|
|
type: 'concept', title: 'Skill', compiled_truth: 'skill', timeline: '', frontmatter: {},
|
|
});
|
|
await engine.putPage('agents/arya/identity', {
|
|
type: 'note', title: 'Identity', compiled_truth: 'identity', timeline: '', frontmatter: {},
|
|
});
|
|
await engine.putPage('people/alice', {
|
|
type: 'person', title: 'Alice', compiled_truth: 'real island', timeline: '', frontmatter: {},
|
|
});
|
|
|
|
const health = await engine.getHealth();
|
|
|
|
expect(health.orphan_pages).toBe(1);
|
|
});
|
|
|
|
test('per-brain config overrides (orphans.exclude_*) also apply to health', async () => {
|
|
await engine.putPage('my-private-folder/secret-ref', {
|
|
type: 'note', title: 'Ref', compiled_truth: 'ref', timeline: '', frontmatter: {},
|
|
});
|
|
await engine.putPage('one-off-fixture-page', {
|
|
type: 'note', title: 'Fixture', compiled_truth: 'fixture', timeline: '', frontmatter: {},
|
|
});
|
|
await engine.putPage('people/alice', {
|
|
type: 'person', title: 'Alice', compiled_truth: 'real island', timeline: '', frontmatter: {},
|
|
});
|
|
|
|
expect((await engine.getHealth()).orphan_pages).toBe(3);
|
|
|
|
await engine.setConfig('orphans.exclude_prefixes', 'my-private-folder/');
|
|
await engine.setConfig('orphans.exclude_slugs', 'one-off-fixture-page');
|
|
expect((await engine.getHealth()).orphan_pages).toBe(1);
|
|
|
|
await engine.unsetConfig('orphans.exclude_prefixes');
|
|
await engine.unsetConfig('orphans.exclude_slugs');
|
|
});
|
|
});
|