Files
gbrain/test/conversation-parser-cli.test.ts
T
f702ec053b v0.41.16.0 feat: conversation parser cathedral + progressive-batch primitive (closes #1461) (#1510)
* v0.41.15.0 feat: conversation parser cathedral + progressive-batch primitive (closes #1461)

Replaces PR #1461's single-format Telegram regex with a 12-pattern
built-in registry covering iMessage/Slack, Telegram (×2), Discord
(×2), WhatsApp (×2 locales), Signal, Matrix/Element, IRC (×2), Teams.
Each pattern is hand-vetted from public format docs (signal-cli,
DiscordChatExporter, Telegram Desktop, WhatsApp export docs, Element
matrix-archive, irssi/weechat defaults); module-load validation runs
test_positive[] + test_negative[] for every pattern at startup so a
typo makes gbrain refuse to start.

PR #1461 contributor's BRACKET_TIME_RX + cleanSpeaker survive verbatim
as the `telegram-bracket` built-in pattern + DEFAULT_SPEAKER_CLEAN
export. All 33 of their test cases pass against the new orchestrator.

Three layers per page (orchestrator chooses):
  1. Built-in pattern registry (zero-cost, deterministic)
  2. User-declared simple_pattern via config (deferred to v0.42+)
  3. Opt-IN LLM polish + fallback (privacy-first; chat content goes
     to Anthropic only when user explicitly enables)

D18 priority scoring picks the highest-match-rate pattern across the
first 10 lines (not first-wins) so overlapping formats don't silently
mis-route. D5 multi_line per-pattern + D11 quick_reject prefix screen
+ D19 timezone_policy per-pattern complete the registry shape.

Companion: src/core/progressive-batch/ primitive (rule of three
satisfied across 12+ ad-hoc cost-prompt sites). Wintermute-inspired
ramp shape (trial 10 → 100 → 500 → full with verification at each
stage), productionized with verifier+policy injection (callers
describe HOW TO MEASURE SUCCESS, not WHEN TO WAIT FOR CTRL-C). D3
fail-closed budget gate: null tracker + null Policy.maxCostUsd →
abort_cost_cap reason='no_budget_safety_net'. D20 discriminated
Verifier union (output_count | idempotent_mutation | noop).
extract-conversation-facts is the one proven consumer in v0.41.15.0;
9-site retrofit deferred to v0.41.16.0+ per TODOS.md.

Codex outside-voice review absorbed 8 substantive findings:
  - Privacy posture (LLM polish/fallback flipped to opt-IN)
  - ReDoS theater (dropped arbitrary user regex; v0.42+ uses RE2)
  - LLM-inferred-regex persistence as silent-corruption machine
  - Pattern priority scoring across first 10 lines
  - Timezone policy on every PatternEntry
  - Verifier shape discriminated union
  - Behavior parity for sites that "jumped straight to full"
  - Real-corpus-redacted fixture gap (v0.42+ TODO)

CI gates:
  - bun run check:conversation-parser (13 fixtures, --no-llm, deterministic)
  - bun run check:fixture-privacy (banned-token grep)

Doctor surfaces 3 new checks: conversation_format_coverage,
progressive_batch_audit_health, conversation_parser_probe_health.

Tests: 198/198 across primitive + parser + LLM + nightly probe + eval
CLI + debug CLI + doctor checks + migration v97 round-trip + E2E
parser ↔ engine integration. Real bug caught + fixed during gap audit:
IdempotentMutationVerifier was comparing absolute mutated-count vs
per-stage expected (failed silently on stage 2+); now uses per-stage
delta semantics matching OutputCountVerifier.

Schema migration v97: conversation_parser_llm_cache table with
(content_sha256, model_id, call_shape) composite key. NO
inferred_patterns table (D17: silent-corruption machine).

Plan + 23 decisions + codex outside-voice absorption at
~/.claude/plans/system-instruction-you-are-working-cuddly-hollerith.md.

Co-Authored-By: garrytan-agents (PR #1461) <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(check-privacy): allowlist scripts/check-fixture-privacy.sh

The new sibling privacy guard literally names the banned tokens in its
BANNED_TOKENS array — same meta-exception that check-privacy.sh itself
gets. Without this allowlist entry, bun run verify rejects the file
post-merge because the banned name appears in the rule-definition script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: renumber v0.41.15.0 → v0.41.16.0 (queue drift)

Mechanical rename across all surfaces: VERSION, package.json,
CHANGELOG (header + body refs), CLAUDE.md, TODOS.md, src/core/
migrate.ts (migration v98 comment), all src/core/conversation-parser/*
and src/core/progressive-batch/* file headers, all test/ headers,
scripts/check-privacy.sh allowlist comment, llms-full.txt regenerated.

Audit clean: VERSION + package.json + CHANGELOG header all show
0.41.16.0. verify 24/24, touched tests 179/179.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: garrytan-agents (PR #1461) <noreply@github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:31:48 -07:00

198 lines
6.1 KiB
TypeScript

/**
* v0.41.16.0 — `gbrain conversation-parser` debug CLI tests.
*
* Pins:
* - list-builtins prints all 12 patterns + accepts --json
* - validate emits "deferred to v0.42+" notice (v1 has no compiler)
* - scan errors without engine
* - --help works
*
* Pure-function tests; no engine, no DB.
*/
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runConversationParser } from '../src/commands/conversation-parser.ts';
import { BUILTIN_PATTERNS } from '../src/core/conversation-parser/builtins.ts';
// Capture process.stdout.write + process.stderr.write + process.exit.
function captureStdio() {
const out: string[] = [];
const err: string[] = [];
let exitCode: number | null = null;
const origOut = process.stdout.write.bind(process.stdout);
const origErr = process.stderr.write.bind(process.stderr);
const origExit = process.exit.bind(process);
process.stdout.write = ((chunk: string | Uint8Array) => {
out.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString());
return true;
}) as typeof process.stdout.write;
process.stderr.write = ((chunk: string | Uint8Array) => {
err.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString());
return true;
}) as typeof process.stderr.write;
// process.exit throws to short-circuit; tests catch and inspect.
process.exit = ((code?: number) => {
exitCode = code ?? 0;
throw new Error(`PROCESS_EXIT:${exitCode}`);
}) as typeof process.exit;
return {
out,
err,
getExitCode: () => exitCode,
restore: () => {
process.stdout.write = origOut;
process.stderr.write = origErr;
process.exit = origExit;
},
};
}
describe('runConversationParser — help', () => {
test('--help prints usage', async () => {
const cap = captureStdio();
try {
await runConversationParser(null, ['--help']);
} finally {
cap.restore();
}
const text = cap.out.join('');
expect(text).toContain('Usage: gbrain conversation-parser');
expect(text).toContain('scan');
expect(text).toContain('list-builtins');
expect(text).toContain('validate');
});
test('no subcommand prints help', async () => {
const cap = captureStdio();
try {
await runConversationParser(null, []);
} finally {
cap.restore();
}
expect(cap.out.join('')).toContain('Usage: gbrain conversation-parser');
});
});
describe('runConversationParser — list-builtins', () => {
test('human output includes all 12 pattern ids', async () => {
const cap = captureStdio();
try {
await runConversationParser(null, ['list-builtins']);
} finally {
cap.restore();
}
const text = cap.out.join('');
for (const pattern of BUILTIN_PATTERNS) {
expect(text).toContain(pattern.id);
}
expect(text).toContain(`${BUILTIN_PATTERNS.length} built-in patterns`);
});
test('--json output is stable schema', async () => {
const cap = captureStdio();
try {
await runConversationParser(null, ['list-builtins', '--json']);
} finally {
cap.restore();
}
const json = JSON.parse(cap.out.join('').trim());
expect(json.schema_version).toBe(1);
expect(json.total).toBe(BUILTIN_PATTERNS.length);
expect(json.patterns).toHaveLength(BUILTIN_PATTERNS.length);
expect(json.patterns[0].id).toBe('imessage-slack');
expect(json.patterns[0].date_source).toBeDefined();
expect(json.patterns[0].time_format).toBeDefined();
expect(json.patterns[0].timezone_policy).toBeDefined();
expect(json.patterns[0].regex).toBeDefined();
});
});
describe('runConversationParser — validate', () => {
test('emits deferred notice (v0.42+ scope)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'cv-parser-cli-'));
const path = join(dir, 'pattern.json');
writeFileSync(path, '{}');
const cap = captureStdio();
try {
await runConversationParser(null, ['validate', path]);
} finally {
cap.restore();
}
expect(cap.out.join('')).toContain('deferred to v0.42+');
});
test('--json validate emits structured deferred envelope', async () => {
const dir = mkdtempSync(join(tmpdir(), 'cv-parser-cli-'));
const path = join(dir, 'pattern.json');
writeFileSync(path, '{}');
const cap = captureStdio();
try {
await runConversationParser(null, ['validate', path, '--json']);
} finally {
cap.restore();
}
const json = JSON.parse(cap.out.join('').trim());
expect(json.schema_version).toBe(1);
expect(json.status).toBe('deferred');
expect(json.todo_ref).toContain('v0.41.16.0');
});
test('exits 2 on missing file path', async () => {
const cap = captureStdio();
try {
await runConversationParser(null, ['validate']);
} catch (e) {
expect((e as Error).message).toBe('PROCESS_EXIT:2');
} finally {
cap.restore();
}
expect(cap.getExitCode()).toBe(2);
});
test('exits 2 on nonexistent file', async () => {
const cap = captureStdio();
try {
await runConversationParser(null, [
'validate',
'/nonexistent/path.json',
]);
} catch (e) {
expect((e as Error).message).toBe('PROCESS_EXIT:2');
} finally {
cap.restore();
}
expect(cap.getExitCode()).toBe(2);
});
});
describe('runConversationParser — scan', () => {
test('exits 2 when engine is null (scan requires brain)', async () => {
const cap = captureStdio();
try {
await runConversationParser(null, ['scan', 'some-slug']);
} catch (e) {
expect((e as Error).message).toBe('PROCESS_EXIT:2');
} finally {
cap.restore();
}
expect(cap.getExitCode()).toBe(2);
});
});
describe('runConversationParser — unknown subcommand', () => {
test('exits 2 with hint', async () => {
const cap = captureStdio();
try {
await runConversationParser(null, ['bogus-cmd']);
} catch (e) {
expect((e as Error).message).toBe('PROCESS_EXIT:2');
} finally {
cap.restore();
}
expect(cap.err.join('')).toContain('unknown subcommand');
});
});