mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix: dream --source/--source-id plumbs sourceId to runCycle (supersedes #1559) Closes the silent-no-op class where `gbrain dream --source <id>` ran the cycle but never wrote `last_full_cycle_at`, leaving `gbrain doctor`'s cycle_freshness check stuck red forever. Changes to src/commands/dream.ts: - DreamArgs.source field; parseArgs recognizes --source <id> AND the --source-id alias (matches v0.37.7.0 #1167 naming across import/extract/graph-query) - Argv validation: missing value → exit 2; repeated different values → exit 2; --source X --source-id Y conflict → exit 2; same-value repetition → accepted - --help short-circuit ordering preserved with IRON-RULE comment + structural test guard - runDream engine-null guard: --source requires a connected brain - runDream resolveSourceId → archived-source guard via fetchSource from src/core/sources-load.ts (single-row SELECT that projects archived + handles pre-v0.26.5 schema via isUndefinedColumnError) - Typed-error try/catch via isResolverUserError predicate: only swallows known resolver-user errors; TypeError / postgres errors propagate uncaught with stack trace so genuine programmer bugs aren't hidden behind operator-error UX - Forwarded sourceId to runCycle; existing v0.38 writeback at cycle.ts:1947-1967 now actually fires - --help text documents both flag names Tests: - test/dream-cli-flags.test.ts: structural assertions for new flags, help text, IRON-RULE comment guard, resolver/predicate wiring - test/dream.test.ts: 13 PGLite integration cases covering happy path (the regression that closes PR #1559), back-compat, alias equivalence, all argv edge cases, engine-null, archived, --help short-circuit ordering, T3 typed-error propagation, and D5 end-to-end dream→checkCycleFreshness column-name drift guard Plan + 11 decisions: ~/.claude/plans/system-instruction-you-are-working-starry-papert.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: judgeSignificance uses canonical safeSplitIndex (closes #1559/#1561 emoji crash) Closes the 2026-05-24 production SYNTH_PHASE_FAIL: 🤖 (U+1F916, surrogate pair U+D83E U+DD16) at offset 3999 in a long telegram transcript made the raw 4000-char slice produce a lone high surrogate; Anthropic's JSON parser rejected the payload with "no low surrogate in string"; the synthesize phase failed. Changes to src/core/cycle/synthesize.ts: - judgeSignificance head+tail slice routed through safeSplitIndex from src/core/text-safe.ts (already imported) - Did NOT introduce safeSliceEnd from PRs #1559+#1561 — that helper re-introduces the case-3 bug src/core/text-safe.ts:18-21 documents - Did NOT touch findBoundary — master already routes through safeSplitIndex per the v0.42.0.0 wave Tests in test/cycle-synthesize.test.ts: - New describe('judgeSignificance — UTF-16 safety') block - test.each over head boundaries (offsets 3998-4001) AND tail boundaries (offsets 3999-4002) for an 8001-char content with the robot emoji placed at each - Primary assertion: explicit unpaired-surrogate scan over the captured prompt (NOT JSON.stringify per codex C-11 — V8/JSCore do not throw on lone surrogates, so that assertion was weak) - Sub-8000 short-content branch case: no slicing, emoji passes through unchanged Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: expand error_page_title + add cloudflare_challenge_title (supersedes #1561) Closes the bug class where scraper error pages with titles like "Forbidden", "Access Denied", "Service Unavailable", "Robot Check", and "Just a moment..." were slipping through the ingest gate because the matcher only caught bare numeric codes (403/404/500...) and "page not found". 232+ pages observed (202+ from straylight- brain) were inflating page counts and tripping content_sanity_audit_recent on every doctor run. Changes to src/core/content-sanity.ts BUILT_IN_JUNK_PATTERNS: - Expanded error_page_title regex to also catch forbidden, access denied, service unavailable, robot check, verify you are human (case-insensitive, anchored — so long-form essays about these topics still ingest fine) - New cloudflare_challenge_title pattern with DISTINCT name from error_page_title (PR #1561 collapsed both into one name and lost audit signal — the new name preserves diagnosability in ~/.gbrain/audit/content-sanity-YYYY-Www.jsonl and doctor's content_sanity_audit_recent aggregation) - Dropped PR #1561's bare-`error` matcher — too aggressive on legitimate concept/taxonomy pages titled exactly "Error" Tests: - test/content-sanity.test.ts: pattern-count locked at 7, new matches via test.each, over-match regression guard (legitimate prose titled "How to Handle Access Denied Errors" / "Error Boundary in React" etc. must pass), audit-name distinctness pinned - test/import-file-content-sanity.test.ts: end-to-end ContentSanityBlockError via importFromContent for each new pattern family (D6 — assessor wiring coverage, not just regex) Out of scope, filed in TODOS.md as TODO-V13-C: gbrain pages audit-junk-titles legacy-cleanup command. Dropped from this PR per codex outside-voice tension (T1) for ship-and-validate- matchers-first discipline. The 200+ pre-existing scraper pages already in the DB will get the destructive-cleanup operator surface after ~1 week of production observation against this matcher. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump v0.41.23.0 + CHANGELOG + follow-up TODOs VERSION + package.json bump to 0.41.23.0. CHANGELOG voice: ELI10 lead naming the bug ("`gbrain dream --source <id>` finally counts as a cycle"), then per-fix detail, then a "To take advantage of v0.41.23.0" operator-action block and itemized changes. TODOS.md v0.41.23.x follow-ups: - TODO-V13-A (P2): --max-pages plumbing (PR #1559's flag, deferred because CycleOpts has no maxPages field today) - TODO-V13-B (P3): --source vs --source-id flag-name unification across all CLI commands - TODO-V13-C (P2): gbrain pages audit-junk-titles legacy cleanup (deferred for ~1 week of matcher production observation) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump v0.41.25.0 → v0.41.26.0 (leave headroom for in-flight PR) Master shipped v0.41.23.0 + v0.41.24.0 mid-review; this branch originally bumped to v0.41.25.0 post-merge. User flagged v0.41.26.0 to leave a slot open for another in-flight PR. No code changes; VERSION + package.json + CHANGELOG header + "To take advantage" section updated in lockstep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
255 lines
9.2 KiB
TypeScript
255 lines
9.2 KiB
TypeScript
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
import { withEnv } from './helpers/with-env.ts';
|
|
import { mkdtempSync, rmSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { importFromContent } from '../src/core/import-file.ts';
|
|
import { ContentSanityBlockError } from '../src/core/content-sanity.ts';
|
|
import { isEmbedSkipped, EMBED_SKIP_KEY } from '../src/core/embed-skip.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
let auditDir: string;
|
|
let gbrainHomeDir: string;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
});
|
|
|
|
/** Wrap an importFromContent call with GBRAIN_HOME + GBRAIN_AUDIT_DIR
|
|
* pointed at fresh tempdirs so config and audit writes don't leak
|
|
* between tests or pollute the developer's real ~/.gbrain. */
|
|
async function withIsolatedHome<T>(fn: () => Promise<T>): Promise<T> {
|
|
gbrainHomeDir = mkdtempSync(join(tmpdir(), 'cs-gate-home-'));
|
|
auditDir = mkdtempSync(join(tmpdir(), 'cs-gate-audit-'));
|
|
try {
|
|
return await withEnv({
|
|
GBRAIN_HOME: gbrainHomeDir,
|
|
GBRAIN_AUDIT_DIR: auditDir,
|
|
}, fn);
|
|
} finally {
|
|
rmSync(gbrainHomeDir, { recursive: true, force: true });
|
|
rmSync(auditDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
const FRONTMATTER = `---
|
|
title: 'Test Page'
|
|
type: note
|
|
created: 2026-05-24
|
|
---
|
|
|
|
`;
|
|
|
|
describe('importFromContent — content-sanity hard-block (D6)', () => {
|
|
test('throws ContentSanityBlockError on Cloudflare junk title', async () => {
|
|
await withIsolatedHome(async () => {
|
|
const content = `---
|
|
title: 'Attention Required! | Cloudflare'
|
|
type: note
|
|
created: 2026-05-24
|
|
---
|
|
|
|
Body.`;
|
|
await expect(
|
|
importFromContent(engine, 'test/junk', content, { noEmbed: true })
|
|
).rejects.toThrow(ContentSanityBlockError);
|
|
});
|
|
});
|
|
|
|
test('throws with PAGE_JUNK_PATTERN-tagged message for classifyErrorCode', async () => {
|
|
await withIsolatedHome(async () => {
|
|
const content = FRONTMATTER + 'Cloudflare Ray ID: abc123';
|
|
let caught: Error | undefined;
|
|
try {
|
|
await importFromContent(engine, 'test/ray', content, { noEmbed: true });
|
|
} catch (e) {
|
|
caught = e as Error;
|
|
}
|
|
expect(caught).toBeDefined();
|
|
expect(caught!.message).toContain('PAGE_JUNK_PATTERN');
|
|
});
|
|
});
|
|
|
|
test('thrown page is NOT written to DB', async () => {
|
|
await withIsolatedHome(async () => {
|
|
// Title matches the anchored error_page_title pattern exactly
|
|
// (`^(403|404|500|...|page not found)\s*$`). "404 Not Found"
|
|
// doesn't anchor; the test needs the bare form.
|
|
const content = `---
|
|
title: '404'
|
|
type: note
|
|
created: 2026-05-24
|
|
---
|
|
|
|
`;
|
|
try {
|
|
await importFromContent(engine, 'test/404', content, { noEmbed: true });
|
|
} catch { /* expected */ }
|
|
const page = await engine.getPage('test/404');
|
|
expect(page).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── v0.41.13: end-to-end coverage for the expanded patterns ────────
|
|
// Exercises the assessor wiring (not just the regex) per D6.
|
|
|
|
test.each([
|
|
['Forbidden', 'error_page_title'],
|
|
['Access Denied', 'error_page_title'],
|
|
['Service Unavailable', 'error_page_title'],
|
|
['Robot Check', 'error_page_title'],
|
|
['Just a moment...', 'cloudflare_challenge_title'],
|
|
])('v0.41.13: title %j → ContentSanityBlockError (matches %s)', async (title, expectedPattern) => {
|
|
await withIsolatedHome(async () => {
|
|
const content = `---
|
|
title: '${title}'
|
|
type: note
|
|
created: 2026-05-24
|
|
---
|
|
|
|
scraper junk body`;
|
|
let caught: ContentSanityBlockError | undefined;
|
|
try {
|
|
await importFromContent(engine, 'test/v04113-' + title.toLowerCase().replace(/[^a-z]/g, '-'), content, { noEmbed: true });
|
|
} catch (e) {
|
|
if (e instanceof ContentSanityBlockError) caught = e;
|
|
else throw e;
|
|
}
|
|
expect(caught).toBeDefined();
|
|
expect(caught!.result.junk_pattern_matches).toContain(expectedPattern);
|
|
expect(caught!.message).toContain('PAGE_JUNK_PATTERN');
|
|
});
|
|
});
|
|
|
|
test('v0.41.13: over-match regression — "How to Handle Access Denied Errors" imports cleanly', async () => {
|
|
await withIsolatedHome(async () => {
|
|
const content = `---
|
|
title: 'How to Handle Access Denied Errors'
|
|
type: note
|
|
created: 2026-05-24
|
|
---
|
|
|
|
A legitimate essay about handling access-denied errors in your app.`;
|
|
// Should NOT throw.
|
|
const result = await importFromContent(engine, 'test/v04113-essay', content, { noEmbed: true });
|
|
expect(result.status).not.toBe('error');
|
|
const page = await engine.getPage('test/v04113-essay');
|
|
expect(page).not.toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('importFromContent — soft-block (D9 transition + embed_skip)', () => {
|
|
test('soft-block writes page with embed_skip frontmatter marker', async () => {
|
|
await withIsolatedHome(async () => {
|
|
// 600K of clean text → soft-block (oversize but no junk pattern).
|
|
const content = FRONTMATTER + 'a'.repeat(600_000);
|
|
const result = await importFromContent(engine, 'test/big', content, { noEmbed: true });
|
|
expect(result.status).not.toBe('error');
|
|
const page = await engine.getPage('test/big');
|
|
expect(page).not.toBeNull();
|
|
const fm = page!.frontmatter as Record<string, unknown>;
|
|
expect(isEmbedSkipped(fm)).toBe(true);
|
|
const marker = fm[EMBED_SKIP_KEY] as Record<string, unknown>;
|
|
expect(marker.reason).toBe('oversized');
|
|
expect(marker.bytes).toBeGreaterThan(500_000);
|
|
});
|
|
});
|
|
|
|
test('soft-block deletes existing chunks (D9 transition invariant)', async () => {
|
|
await withIsolatedHome(async () => {
|
|
// First write a normal page to seed some chunks.
|
|
const small = FRONTMATTER + 'Short content with multiple sentences. Plenty of words here. Enough to chunk.';
|
|
await importFromContent(engine, 'test/grow', small, { noEmbed: true });
|
|
const beforeChunks = await engine.getChunks('test/grow');
|
|
expect(beforeChunks.length).toBeGreaterThan(0);
|
|
|
|
// Now re-import with content that grew past the block threshold.
|
|
const big = FRONTMATTER + 'a'.repeat(600_000);
|
|
await importFromContent(engine, 'test/grow', big, { noEmbed: true });
|
|
const afterChunks = await engine.getChunks('test/grow');
|
|
// D9: transition to embed_skip should delete chunks.
|
|
expect(afterChunks.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
test('soft-block skips chunking entirely (no new chunks created)', async () => {
|
|
await withIsolatedHome(async () => {
|
|
const content = FRONTMATTER + 'a'.repeat(600_000);
|
|
await importFromContent(engine, 'test/big2', content, { noEmbed: true });
|
|
const chunks = await engine.getChunks('test/big2');
|
|
expect(chunks.length).toBe(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('importFromContent — kill-switch bypass', () => {
|
|
test('GBRAIN_NO_SANITY=1 lets junk through with bypass audit + stderr', async () => {
|
|
const gbrainHomeDirLocal = mkdtempSync(join(tmpdir(), 'cs-bypass-home-'));
|
|
const auditDirLocal = mkdtempSync(join(tmpdir(), 'cs-bypass-audit-'));
|
|
try {
|
|
await withEnv({
|
|
GBRAIN_HOME: gbrainHomeDirLocal,
|
|
GBRAIN_AUDIT_DIR: auditDirLocal,
|
|
GBRAIN_NO_SANITY: '1',
|
|
}, async () => {
|
|
const content = `---
|
|
title: 'Attention Required! | Cloudflare'
|
|
type: note
|
|
created: 2026-05-24
|
|
---
|
|
|
|
junk body`;
|
|
const result = await importFromContent(engine, 'test/bypass', content, { noEmbed: true });
|
|
expect(result.status).not.toBe('error');
|
|
const page = await engine.getPage('test/bypass');
|
|
expect(page).not.toBeNull();
|
|
// Page lands with frontmatter unchanged (no embed_skip set on bypass).
|
|
const fm = page!.frontmatter as Record<string, unknown>;
|
|
expect(isEmbedSkipped(fm)).toBe(false);
|
|
});
|
|
} finally {
|
|
rmSync(gbrainHomeDirLocal, { recursive: true, force: true });
|
|
rmSync(auditDirLocal, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('importFromContent — normal pages unaffected', () => {
|
|
test('clean page imports successfully', async () => {
|
|
await withIsolatedHome(async () => {
|
|
const content = FRONTMATTER + 'A thoughtful essay about software design.';
|
|
const result = await importFromContent(engine, 'test/clean', content, { noEmbed: true });
|
|
expect(result.status).toBe('imported');
|
|
const page = await engine.getPage('test/clean');
|
|
expect(page).not.toBeNull();
|
|
const fm = page!.frontmatter as Record<string, unknown>;
|
|
expect(isEmbedSkipped(fm)).toBe(false);
|
|
});
|
|
});
|
|
|
|
test('warn-tier page (50K-500K body) lands normally without embed_skip', async () => {
|
|
await withIsolatedHome(async () => {
|
|
const content = FRONTMATTER + 'a'.repeat(100_000);
|
|
const result = await importFromContent(engine, 'test/warn', content, { noEmbed: true });
|
|
expect(result.status).toBe('imported');
|
|
const page = await engine.getPage('test/warn');
|
|
expect(page).not.toBeNull();
|
|
const fm = page!.frontmatter as Record<string, unknown>;
|
|
expect(isEmbedSkipped(fm)).toBe(false);
|
|
});
|
|
});
|
|
});
|