Files
gbrain/src/core/brain-writer.ts
T
fa2c7a6990 v0.40.10.0 feat: content sanity defense — junk-pattern throw + oversize-skip-embed (#1351)
* feat: add content-sanity assessor + embed-skip helper + audit JSONL primitives

Four new core modules (pure, no engine I/O):

- src/core/content-sanity.ts — assessor with 6 hand-vetted junk patterns
  (Cloudflare attention-required, just-a-moment, ray-id; access-denied;
  captcha-required; bare error-page titles). Bytes measured against
  compiled_truth + timeline (parseMarkdown body split, not file bytes).
  ContentSanityBlockError tagged with PAGE_JUNK_PATTERN code so
  classifyErrorCode hits via regex without a new ImportResult field.

- src/core/content-sanity-literals.ts — operator literal-substring loader
  for ~/.gbrain/junk-substrings.txt. Comment directives for name +
  applies_to. ENOENT returns empty list (fail-soft); no regex parsing so
  no ReDoS surface.

- src/core/embed-skip.ts — single source of truth for the embed-skip
  predicate. JS isEmbedSkipped() + filterOutEmbedSkipped() for in-memory
  callers; EMBED_SKIP_FILTER_FRAGMENT raw SQL string for engine-layer
  filters. buildEmbedSkipMarker() emits the canonical frontmatter shape.
  Both Postgres and PGLite use the same JSONB '?' existence operator.

- src/core/audit/content-sanity-audit.ts — ISO-week JSONL at
  ~/.gbrain/audit/content-sanity-YYYY-Www.jsonl. Built on v0.40.4.0
  audit-writer primitive. One stream for hard-block + soft-block + warn
  events with event_type discriminator. summarizeContentSanityEvents
  rolls up by type + source + pattern hits for doctor consumption.

99 unit tests across 4 new test files (207 assertions) covering
boundaries, every built-in pattern, bytes-parity assertion, operator
literals (regex meta-chars stay literal), audit JSONL round-trip + reader.

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

* feat(embed): apply embed-skip filter at all 5 stale-chunk sites

Embed sweep must skip pages with frontmatter.embed_skip set so soft-blocked
pages don't get re-embedded. Five wiring sites all use the shared helper:

  1. src/commands/embed.ts — --stale CLI path (delegates to embedAllStale)
  2. src/commands/embed.ts — --all CLI path (JS-side filterOutEmbedSkipped
     on the listPages result; Codex r2 #11 caught this previously-missed
     surface that re-embedded soft-blocked pages on every model swap)
  3. src/core/embed-stale.ts:90 — Minion helper (inherits via engine)
  4. src/core/postgres-engine.ts — listStaleChunks + countStaleChunks
     gain 'NOT (COALESCE(p.frontmatter, ''{}''::jsonb) ? ''embed_skip'')'
     filter at the SQL layer. Always JOINs pages now (pre-fix bare path
     skipped the JOIN; D4 + D8 require it for the filter).
  5. src/core/pglite-engine.ts — mirror of postgres-engine; PGLite is
     Postgres 17.5 in WASM so the same JSONB '?' operator works.

Cross-site invariant pinned by test/embed-skip.test.ts (20 cases on the
JS predicate + SQL fragment semantics). When v0.41+ promotes embed_skip
to a schema column, all 5 sites get updated in one helper file.

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

* feat(ingest): wire content-sanity gate into importFromContent narrow waist

Hard-block via thrown ContentSanityBlockError; soft-block via frontmatter
marker + chunk deletion on transition (D9 invariant). Single throw point
means every wrapper site (CLI, MCP put_page, sync) inherits correct
exit/error semantics through existing exception flow — no per-wrapper
status-vocabulary changes (Codex r2 #2).

import-file.ts:
- Gate runs AFTER parseMarkdown so assessor sees compiled_truth + timeline
  + title + frontmatter (Codex r2 #5+#7).
- Kill-switch (GBRAIN_NO_SANITY=1) checked via direct process.env AS WELL
  AS effective config — loadConfig() returns null on bare installs (no
  ~/.gbrain/config.json, no DATABASE_URL) so the config-only path missed
  the kill-switch. Caught by test/import-file-content-sanity.test.ts.
- Hard-block: throws ContentSanityBlockError. Existing import.ts catch
  increments errors; sync.ts:929 catch records failure with classified code.
- Soft-block: sets parsed.frontmatter.embed_skip via buildEmbedSkipMarker
  before hash compute (so hash differs from prior version → real write).
  Chunking block guards on isEmbedSkipped → chunks stays empty → existing
  tx.deleteChunks fires (D9 transition invariant).
- Audit JSONL records every assessment (hard / soft / warn + bypass-mode).

sync.ts:
- classifyErrorCode gains /PAGE_JUNK_PATTERN/ → 'PAGE_JUNK_PATTERN' regex.
  No PAGE_OVERSIZED code because oversize is now a soft state — page lands.

config.ts:
- New content_sanity.* field on GBrainConfig (4 keys: bytes_warn,
  bytes_block, junk_patterns_enabled, disabled).
- loadConfig() reads GBRAIN_PAGE_WARN_BYTES, GBRAIN_PAGE_BLOCK_BYTES,
  GBRAIN_NO_JUNK_PATTERNS, GBRAIN_NO_SANITY env vars sparse-merged.
- loadConfigWithEngine merges DB-plane content_sanity.* keys per-key
  sparse-merge so 'gbrain config set content_sanity.bytes_block N' takes
  effect uniformly (Codex r2 #6 D1 acceptance).
- KNOWN_CONFIG_KEYS + KNOWN_CONFIG_KEY_PREFIXES include the new keys.

cli.ts:
- runImport now honors result.errors > 0 for non-zero exit. Pre-fix the
  CLI awaited runImport but discarded the result, so hard-blocked imports
  exited 0 silently (Codex r2 #3).

9 PGLite-backed unit tests pin: hard-block throws, error message contains
PAGE_JUNK_PATTERN, blocked page does NOT land in DB, soft-block writes
page with embed_skip set, soft-block deletes pre-existing chunks (D9
transition), kill-switch bypass works.

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

* feat: lint rules + doctor checks + 'gbrain sources audit' CLI

Three operator surfaces backed by the shared content-sanity assessor:

lint.ts (2 new rules):
- huge-page: bytes (compiled_truth + timeline post-parse) exceeds warn or
  block threshold. Message names the actual byte count.
- scraper-junk: built-in junk pattern OR operator literal matched.
- Lint runs parseMarkdown to extract body for bytes-parity with doctor
  (D2 — both surfaces measure body-only, not file-with-frontmatter).
- runLintCore resolves effective config once per run: file/env (sync via
  loadConfig) + DB-lift when ~/.gbrain/ is reachable (D1). CI without
  ~/.gbrain/ falls through immediately. Engine probe wrapped in try/catch
  so lint never blocks on engine state.
- Operator literals loaded once per lint run; passed through to every
  page's lintContent call.

doctor.ts (3 new checks + 1 flag):
- oversized_pages: indexed-free table scan via
  octet_length(compiled_truth) + octet_length(COALESCE(timeline, ''))
  (Codex r2 #13: octet_length is bytes, length is chars). Status warn
  on 1+ rows; oversize is now a soft state so no 'fail'.
- scraper_junk_pages: capped 1000 most-recent default + --content-audit
  opt-in for full scan (D10 mirrors --index-audit precedent from v0.14.3).
  Applies assessor per-page on title + 2KB body slice + frontmatter.
- content_sanity_audit_recent: reads ~/.gbrain/audit/content-sanity-*.jsonl
  for last 7 days, aggregates by event_type + source. Warn at 10+ events,
  fail at 100+. Doctor message names the multi-host limitation explicitly
  (Codex r1 #14): 'audit reflects events on this host only; multi-host
  operators should share GBRAIN_AUDIT_DIR'.

sources.ts (new audit subcommand):
- gbrain sources audit <id> [--json] [--include-warns]
- Reads sources.local_path, walks disk (via pruneDir for node_modules /
  .git / dotfiles), runs assessContentSanity per .md file.
- Reports size distribution (p50, p99, max) + would-hard-block count +
  would-soft-block count + junk-pattern hit map.
- Read-only: NO DB writes, NO file mutations. Operator runs this BEFORE
  a sync to catch junk early, or AFTER landing v0.40.9.0 to audit
  historical inventory.

13 unit tests on lint rules; D1 config-lift behavior pinned by lift
in runLintCore + manual override via opts.contentSanity for tests.

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

* chore: bump version and changelog (v0.40.9.0)

v0.40.9.0 — content sanity defense: junk-pattern throw + oversize-skip-embed.

Plus TODOS.md entries for the 9 deferred v0.41+ follow-ups:
- chunk-level embed-quarantine (Codex r1 #3 — page-level granularity wrong)
- source-repo remediation CLI (gbrain sources prune-junk)
- threshold validation post-deploy on real corpora
- brain-score no_junk_pages_score component
- pages soft-delete --where CLI (paired with prune-junk)
- post-v0.45 operator-regex extensibility (needs real ReDoS story)
- post-v0.45 HTML-density rule (needs fenced-code handling)
- bytes-parity E2E across lint + doctor
- 5-path narrow-waist E2E pin tests + doctor integration tests

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

* docs: update CLAUDE.md for v0.40.9.0 content-sanity wave

Add v0.40.9.0 Key Files entries for the content-sanity defense modules:
content-sanity.ts (assessor), content-sanity-literals.ts (operator loader),
embed-skip.ts (5-site shared predicate), audit/content-sanity-audit.ts
(JSONL writer). Extend doctor.ts, lint.ts, embed.ts, import-file.ts, and
sources.ts entries with the v0.40.9.0 surfaces (3 new doctor checks,
2 new lint rules, embed-skip filter at 5 sites, importFromContent gate,
sources audit subcommand).

Regenerate llms-full.txt per the CLAUDE.md edit rule.

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

* chore: rebump v0.40.9.0 → v0.40.10.0 (queue collision with #1350)

PR #1350 also claimed v0.40.9.0. Advancing this PR to v0.40.10.0 so CI's
version-gate doesn't reject on overlap. No functional change — same shipped
content, just a different version slot.

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

* fix(brain-writer): +1ms overshoot on COUNT-race timer to defeat CI boundary flake

PR #1351 ship CI hit a single test failure (one in 2552):
  (fail) scanBrainSources partial-scan state > hanging COUNT does not
  exceed deadline — Promise.race timeout fires [579.01ms]

Run: https://github.com/garrytan/gbrain/actions/runs/77611667786

Cause: heavily-loaded CI runners (8 parallel shards × 4 concurrent test
files = ~32 concurrent bun processes) occasionally let the setTimeout
race callback resolve a microsecond BEFORE the wall-clock boundary,
leaving Date.now() one tick below deadline. The post-await deadline
check at brain-writer.ts:512 uses Date.now() >= deadline; on that tick
the check evaluated false and scanOneSource ran src-a anyway. Test then
asserted firstSource.status === 'skipped' and got 'scanned'.

Fix: add 1ms overshoot to the race-timer schedule:
  setTimeout(..., remainingMs + 1)

Guarantees the timer fires past the deadline by at least one millisecond
regardless of runner timer drift. Cost: 1ms additional wall-clock
latency on hung COUNT queries — operationally negligible.

Verified: stress-tested 5/5 passing locally. The bug class is identical
to the one the existing test comment block (lines 180-187) documents
(`>=` not `>` at line 512); this +1ms is the belt to that suspenders.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:49:56 -07:00

729 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* brain-writer — frontmatter validation/audit/auto-fix orchestrator.
*
* Thin layer on top of `parseMarkdown(..., {validate:true})` (the canonical
* source of frontmatter validation rules) and `isSyncable()` (the canonical
* brain-page filter). Three consumers call into this module: the
* `gbrain frontmatter` CLI, the `frontmatter_integrity` doctor subcheck, and
* the v0.22.4 migration audit phase. Single source of truth — no parallel
* validation stack.
*
* Path-guard contract: writeBrainPage refuses to write outside the source
* path. Pre-write backups are the safety contract (works for both git and
* non-git brain repos; the existing src/core/dry-fix.ts:getWorkingTreeStatus
* rejects non-git repos as unsafe, which is the wrong shape for brain
* rewrites). Backups live under ~/.gbrain/backups/frontmatter/... instead of
* beside source files so bulk repair never litters the user's workspace.
*/
import { createHash } from 'crypto';
import { existsSync, readFileSync, readdirSync, copyFileSync, writeFileSync, mkdirSync, lstatSync } from 'fs';
import { join, relative, resolve, dirname, basename, isAbsolute } from 'path';
import type { BrainEngine } from './engine.ts';
import type { ProgressReporter } from './progress.ts';
import { gbrainPath } from './config.ts';
import {
parseMarkdown,
type ParseValidationCode,
type ParseValidationError,
} from './markdown.ts';
import { isSyncable, pruneDir, slugifyPath } from './sync.ts';
export type { ParseValidationCode };
export interface AuditFix {
code: ParseValidationCode;
description: string;
}
export interface PerSourceReport {
source_id: string;
source_path: string;
total: number;
errors_by_code: Partial<Record<ParseValidationCode, number>>;
sample: { path: string; codes: ParseValidationCode[] }[];
ignoredMissingOpen: number;
/** Did this source finish the walk, get interrupted, or never start?
* 'scanned' = full walk completed.
* 'partial' = deadline/abort fired mid-walk; counts reflect prefix only.
* 'skipped' = source was never visited (outer loop broke before reaching it). */
status: 'scanned' | 'partial' | 'skipped';
/** Count of .md files actually parsed (numerator for "scanned ~N of M" doctor message).
* Distinct from `total` (which counts ERRORS) and `ignoredMissingOpen`. */
files_scanned: number;
/** DB-side denominator for the partial-state message. Coarse — DB pages and
* on-disk syncable files are overlapping but not identical sets. NULL when the
* COUNT query failed or wasn't issued. */
db_page_count?: number | null;
}
export interface AuditReport {
ok: boolean;
total: number;
errors_by_code: Partial<Record<ParseValidationCode, number>>;
per_source: PerSourceReport[];
scanned_at: string;
ignored_missing_open?: number;
/** True when any source got `status: 'partial'` or `'skipped'`. Doctor uses
* this to render the warn message and to ensure `ok` is false even when the
* scanned prefix happened to be clean (codex C2 fix). */
partial: boolean;
/** Source id where deadline/abort fired mid-walk, or null when no partial. */
aborted_at_source: string | null;
}
const SAMPLE_PER_SOURCE = 20;
// ---------------------------------------------------------------------------
// Frontmatter backups
// ---------------------------------------------------------------------------
export function makeFrontmatterBackupRunId(date = new Date()): string {
return date.toISOString().replace(/[:.]/g, '-');
}
export interface FrontmatterBackupOpts {
sourcePath?: string;
backupRoot?: string;
runId?: string;
}
function sourceKey(sourcePath: string): string {
return createHash('sha256').update(resolve(sourcePath)).digest('hex').slice(0, 12);
}
export function defaultFrontmatterBackupRoot(runId = makeFrontmatterBackupRunId()): string {
return gbrainPath('backups', 'frontmatter', runId);
}
export function createFrontmatterBackup(filePath: string, opts: FrontmatterBackupOpts = {}): string {
const resolvedFile = resolve(filePath);
const resolvedSource = resolve(opts.sourcePath ?? dirname(resolvedFile));
const rel = relative(resolvedSource, resolvedFile);
const safeRel = rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel : basename(resolvedFile);
const root = opts.backupRoot ?? defaultFrontmatterBackupRoot(opts.runId);
const backupPath = join(root, sourceKey(resolvedSource), safeRel + '.bak');
mkdirSync(dirname(backupPath), { recursive: true });
copyFileSync(resolvedFile, backupPath);
return backupPath;
}
// ---------------------------------------------------------------------------
// autoFixFrontmatter
// ---------------------------------------------------------------------------
/**
* Mechanical auto-repair for the fixable subset of validation codes:
* - NULL_BYTES — strip \x00 characters
* - NESTED_QUOTES — rewrite `"... "inner" ..."` to single-quoted outer
* - MISSING_CLOSE — insert `---` before the first heading found inside
* the YAML zone
* - SLUG_MISMATCH — remove `slug:` line (gbrain derives slug from path)
*
* Idempotent: running twice is a no-op on already-clean input. Any error class
* not in the list above is left untouched (e.g. EMPTY_FRONTMATTER, YAML_PARSE,
* MISSING_OPEN — those need human review).
*/
export function autoFixFrontmatter(
content: string,
opts?: { filePath?: string },
): { content: string; fixes: AuditFix[] } {
const fixes: AuditFix[] = [];
let working = content;
// 1. NULL_BYTES — strip them. Cheap, byte-level. Run first so subsequent
// line-based passes don't trip on stray nulls.
if (working.indexOf('\x00') >= 0) {
working = working.replace(/\x00/g, '');
fixes.push({ code: 'NULL_BYTES', description: 'Stripped null bytes' });
}
// 2. MISSING_CLOSE — if there's an opener but no closer before a heading,
// insert `---` immediately before the heading. Walk lines once.
{
const lines = working.split('\n');
let firstNonEmpty = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim().length > 0) { firstNonEmpty = i; break; }
}
if (firstNonEmpty >= 0 && lines[firstNonEmpty].trim() === '---') {
let closeIdx = -1;
let headingIdx = -1;
for (let i = firstNonEmpty + 1; i < lines.length; i++) {
const t = lines[i].trim();
if (t === '---') { closeIdx = i; break; }
if (/^#{1,6}\s/.test(t)) { headingIdx = i; break; }
}
if (closeIdx === -1 && headingIdx >= 0) {
const fixed = [
...lines.slice(0, headingIdx),
'---',
'',
...lines.slice(headingIdx),
];
working = fixed.join('\n');
fixes.push({
code: 'MISSING_CLOSE',
description: `Inserted closing --- before heading at line ${headingIdx + 1}`,
});
}
}
}
// Both step 3a and step 3 produce NESTED_QUOTES fix records on different
// patterns. When both fire on the same file, push ONE merged record rather
// than two — keeps the audit count honest about distinct files affected.
let nestedQuotesFixed = false;
// 3a. Canonical-style normalization for `tags:` / `aliases:` flow arrays.
// Post-v0.37.5.0 validator (PR #1229), `tags: ["yc", "w2025"]` is already
// valid YAML and no longer flagged. This pass rewrites it to the
// canonical single-quoted form (`tags: ['yc', 'w2025']`) so disk-side
// `frontmatter validate --fix` produces output consistent with the
// v0.37.9.0 serializer. Allow-list keys deliberately scoped to
// `tags` / `aliases` — extending to arbitrary keys would rewrite typed
// arrays (e.g. `scores: ["1", "2"]` would lose numeric intent).
{
const lines = working.split('\n');
let firstNonEmpty = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim().length > 0) { firstNonEmpty = i; break; }
}
if (firstNonEmpty >= 0 && lines[firstNonEmpty].trim() === '---') {
let closeIdx = lines.length;
for (let i = firstNonEmpty + 1; i < lines.length; i++) {
if (lines[i].trim() === '---') { closeIdx = i; break; }
}
let fixedAny = false;
for (let i = firstNonEmpty + 1; i < closeIdx; i++) {
// Allow-list: only `tags` and `aliases` (the keys this wave targets).
const arrMatch = lines[i].match(/^(\s*(?:tags|aliases)\s*:\s*)\[(.*)\]\s*$/);
if (!arrMatch || !arrMatch[2].includes('"')) continue;
const [, prefix, inner] = arrMatch;
// Quote-aware comma split — items may contain commas inside quotes.
const items: string[] = [];
let current = '';
let inQuote = false;
for (let j = 0; j < inner.length; j++) {
const ch = inner[j];
if (ch === '"' && (j === 0 || inner[j - 1] !== '\\')) {
inQuote = !inQuote;
} else if (ch === ',' && !inQuote) {
items.push(current.trim());
current = '';
} else {
current += ch;
}
}
if (current.trim()) items.push(current.trim());
// Re-quote: single quotes by default, double-quote fallback when the
// item contains an apostrophe (YAML's single-quoted form would need
// `''` escaping which the validator accepts but reads poorly).
const reQuoted = items.map(v => {
const clean = v.replace(/^"|"$/g, '').trim();
if (!clean) return "''";
return clean.includes("'") ? `"${clean}"` : `'${clean}'`;
});
lines[i] = `${prefix}[${reQuoted.join(', ')}]`;
fixedAny = true;
}
if (fixedAny) {
working = lines.join('\n');
fixes.push({
code: 'NESTED_QUOTES',
description: 'Normalized JSON-style double-quoted tag/alias arrays to single-quoted YAML',
});
nestedQuotesFixed = true;
}
}
}
// 3. NESTED_QUOTES — rewrite `key: "...inner..."` lines that have 3+ unescaped
// double-quotes by switching the outer wrapper to single quotes and
// leaving inner quotes alone.
{
const lines = working.split('\n');
let firstNonEmpty = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim().length > 0) { firstNonEmpty = i; break; }
}
if (firstNonEmpty >= 0 && lines[firstNonEmpty].trim() === '---') {
let closeIdx = lines.length;
for (let i = firstNonEmpty + 1; i < lines.length; i++) {
if (lines[i].trim() === '---') { closeIdx = i; break; }
}
let fixedAny = false;
for (let i = firstNonEmpty + 1; i < closeIdx; i++) {
const m = lines[i].match(/^(\s*[A-Za-z_][\w-]*\s*:\s*)"(.*)"\s*(.*)$/);
if (!m) continue;
const [, prefix, inner, trailing] = m;
let count = 0;
for (let j = 0; j < inner.length; j++) {
if (inner[j] === '"' && (j === 0 || inner[j - 1] !== '\\')) count++;
}
// Total " on the line includes the two outer quotes the regex
// captured, plus whatever's in inner. We need 3+ to trigger.
if (count >= 1) {
// Inner already has unescaped " — outer wrap is causing the YAML
// parse failure. Rewrite to 'single-quoted'. YAML escapes `'` inside
// a single-quoted string by doubling it.
const escapedInner = inner.replace(/'/g, "''");
lines[i] = `${prefix}'${escapedInner}'${trailing ? ' ' + trailing : ''}`.replace(/\s+$/, '');
fixedAny = true;
}
}
if (fixedAny) {
working = lines.join('\n');
if (!nestedQuotesFixed) {
fixes.push({
code: 'NESTED_QUOTES',
description: 'Rewrote nested double-quoted YAML values to single-quoted',
});
nestedQuotesFixed = true;
}
}
}
}
// 4. SLUG_MISMATCH — remove `slug:` line if filePath is provided and the
// declared slug doesn't match the path-derived one. Per PR #392 spec,
// gbrain derives slug from path; the field shouldn't be in frontmatter.
if (opts?.filePath) {
const expectedSlug = slugifyPath(opts.filePath);
// Use the (possibly partially-fixed) working content to detect whether
// the slug field is present and mismatched.
const re = /^slug:\s*(.+?)\s*$/m;
const m = working.match(re);
if (m && m[1].replace(/^["']|["']$/g, '') !== expectedSlug) {
working = working.replace(re, '').replace(/\n{3,}/g, '\n\n');
fixes.push({
code: 'SLUG_MISMATCH',
description: `Removed mismatched slug field (was "${m[1]}", expected "${expectedSlug}")`,
});
}
}
return { content: working, fixes };
}
// ---------------------------------------------------------------------------
// writeBrainPage — path-guarded write with centralized backup
// ---------------------------------------------------------------------------
export class BrainWriterError extends Error {
code: string;
hint?: string;
constructor(code: string, message: string, hint?: string) {
super(message);
this.name = 'BrainWriterError';
this.code = code;
this.hint = hint;
}
}
/**
* Path-guarded brain page writer. Always writes a backup under
* ~/.gbrain/backups/frontmatter/... before any in-place mutation (the contract
* that replaces git-tree-clean for non-git brain repos). Throws
* BrainWriterError if filePath is not under sourcePath.
*/
export function writeBrainPage(
filePath: string,
content: string,
opts: { sourcePath: string; autoFix?: boolean; backupRoot?: string; backupRunId?: string },
): { fixes: AuditFix[]; backupPath?: string } {
const resolvedSource = resolve(opts.sourcePath);
const resolvedTarget = resolve(filePath);
if (resolvedTarget !== resolvedSource && !resolvedTarget.startsWith(resolvedSource + '/')) {
throw new BrainWriterError(
'PATH_OUTSIDE_SOURCE',
`writeBrainPage: ${filePath} is not under ${opts.sourcePath}`,
'Pass --source <id> matching the source the file lives in.',
);
}
let toWrite = content;
let fixes: AuditFix[] = [];
if (opts.autoFix) {
const result = autoFixFrontmatter(content, { filePath });
toWrite = result.content;
fixes = result.fixes;
}
let backupPath: string | undefined;
if (existsSync(filePath)) {
backupPath = createFrontmatterBackup(filePath, {
sourcePath: opts.sourcePath,
backupRoot: opts.backupRoot,
runId: opts.backupRunId,
});
} else {
mkdirSync(dirname(filePath), { recursive: true });
}
writeFileSync(filePath, toWrite, 'utf8');
return { fixes, backupPath };
}
// ---------------------------------------------------------------------------
// scanBrainSources
// ---------------------------------------------------------------------------
interface SourceRow {
id: string;
local_path: string | null;
}
export interface ScanOpts {
/** Limit scan to one source. When omitted, all registered sources with a
* local_path are scanned. */
sourceId?: string;
/** Missing frontmatter is optional metadata coverage for broad document
* sources. Set true for curated page repos that require every file to carry
* YAML frontmatter. */
strictMissingOpen?: boolean;
onProgress?: ProgressReporter;
/** Outer-loop abort: checked at source boundaries in scanBrainSources.
* Does NOT interrupt the synchronous file-walk inside a single source —
* use `deadline` for that. */
signal?: AbortSignal;
/** Hard wall-clock bound for the synchronous walk. When set, scanOneSource
* checks `Date.now() > deadline` before parsing each file and returns
* partial state when exceeded.
*
* This is the load-bearing mid-walk interruption mechanism. AbortSignal.timeout
* cannot interrupt sync readdirSync/lstatSync/readFileSync (event loop blocked),
* so a deadline epoch-ms check is the only way to actually bound wall-clock
* inside the visit loop. Worst-case overshoot: one file's parse time. */
deadline?: number;
/** Async per-source DB hook: doctor uses this to fetch a coarse denominator
* (`SELECT COUNT(*) FROM pages WHERE source_id = $1 AND deleted_at IS NULL`)
* for the "scanned ~N of M" partial message. Returns null on query failure;
* scanBrainSources stamps the result on PerSourceReport.db_page_count.
* Optional — when omitted, db_page_count stays undefined. */
dbPageCountForSource?: (sourceId: string) => Promise<number | null>;
/** Test seam — fired by walkDir once per directory it descends into
* (post-pruneDir). Production callers don't pass this; the regression
* suite uses it to assert descent-time pruning directly. */
visitDir?: (dirPath: string) => void;
}
export async function scanBrainSources(
engine: BrainEngine,
opts: ScanOpts = {},
): Promise<AuditReport> {
const sources = await listSources(engine, opts.sourceId);
const totals: Partial<Record<ParseValidationCode, number>> = {};
const perSource: PerSourceReport[] = [];
let grandTotal = 0;
let ignoredMissingOpen = 0;
let abortedAtSource: string | null = null;
// Helper: mark sources from index i onward as 'skipped'. Used at every
// between-source abort point (top of loop AND after the COUNT await).
const markRemainingSkipped = (startIdx: number) => {
for (let j = startIdx; j < sources.length; j++) {
const skipped = sources[j];
if (!skipped.local_path) continue;
perSource.push({
source_id: skipped.id,
source_path: skipped.local_path,
total: 0,
errors_by_code: {},
sample: [],
ignoredMissingOpen: 0,
status: 'skipped',
files_scanned: 0,
});
}
};
for (let i = 0; i < sources.length; i++) {
const src = sources[i];
// Between-source abort check: AbortSignal works here (no sync I/O blocking
// the event loop at this boundary). For mid-walk interruption use deadline.
// `>=` not `>`: when Date.now() lands exactly on the deadline boundary,
// the budget IS exhausted — proceeding to scanOneSource would let the
// next source eat its own budget on top of what's already been spent.
// (Strict `>` was a real flake source on CI: GitHub Actions runners
// landing on the boundary exactly during a Promise.race timeout.)
if (opts.signal?.aborted || (opts.deadline && Date.now() >= opts.deadline)) {
// Codex adversarial review #3: when deadline fires BETWEEN sources,
// also stamp aborted_at_source with the source we were about to start.
// Pre-fix, the doctor message said "PARTIAL SCAN" with no source name.
if (abortedAtSource === null && src.local_path) {
abortedAtSource = src.id;
}
markRemainingSkipped(i);
break;
}
if (!src.local_path) continue;
if (!existsSync(src.local_path)) {
// Source registered but path is missing on disk; surface as a zero-row
// entry with a synthetic SCAN_PATH_MISSING note via warn-and-skip.
perSource.push({
source_id: src.id,
source_path: src.local_path,
total: 0,
errors_by_code: {},
sample: [],
ignoredMissingOpen: 0,
status: 'scanned',
files_scanned: 0,
});
continue;
}
// Best-effort denominator fetch — degrades gracefully on query failure.
// Codex adversarial #4: also race against the deadline. A wedged Postgres
// pool can make this await hang past the budget. Without the race, we'd
// wait indefinitely AND defeat the wall-clock guarantee.
let dbPageCount: number | null = null;
if (opts.dbPageCountForSource) {
try {
if (opts.deadline) {
const remainingMs = opts.deadline - Date.now();
if (remainingMs <= 0) {
dbPageCount = null;
} else {
// Race COUNT against the deadline so a hung query can't eat the budget.
//
// Boundary overshoot (+1ms): the post-await deadline check at line
// ~512 uses `Date.now() >= deadline`. setTimeout fires AT OR AFTER
// the requested delay, so in theory the check always passes. In
// practice on heavily-loaded CI runners (8 parallel shards × 4
// concurrent test files = ~32 concurrent bun processes) we saw
// intermittent failures where the timer callback resolved
// microseconds BEFORE the wall-clock boundary, leaving Date.now()
// a tick below deadline and the skip-check evaluating false. The
// src-a scan then ran on a populated dir before src-b's
// between-source check caught up — causing
// `firstSource.status === 'skipped'` to receive 'scanned'.
//
// Adding 1ms guarantees the timer fires past the deadline by at
// least one millisecond regardless of runner timer drift. Cost is
// 1ms additional wall-clock latency on hung COUNT queries, which
// is operationally negligible. Flake repro:
// https://github.com/garrytan/gbrain/actions/runs/77611667786
dbPageCount = await Promise.race([
opts.dbPageCountForSource(src.id),
new Promise<null>(resolve => setTimeout(() => resolve(null), remainingMs + 1)),
]);
}
} else {
dbPageCount = await opts.dbPageCountForSource(src.id);
}
} catch {
dbPageCount = null;
}
}
// Codex adversarial #2: re-check deadline AFTER the COUNT await. If the
// await ate the budget, we must NOT call scanOneSource — it would return
// status='partial' with files_scanned=0, which is misleading ("partial
// scan" when actually nothing was scanned). Mark this source + remainder
// as 'skipped' so the doctor message is honest.
// `>=` matches the between-source check above (line 445). The Promise.race
// setTimeout resolves null at exactly `remainingMs` from now, so post-await
// Date.now() often equals deadline within integer-ms precision — strict `>`
// missed those landings on CI and let the next scanOneSource run anyway.
if (opts.signal?.aborted || (opts.deadline && Date.now() >= opts.deadline)) {
if (abortedAtSource === null) {
abortedAtSource = src.id;
}
markRemainingSkipped(i);
break;
}
const report = scanOneSource(src.id, src.local_path, opts);
report.db_page_count = dbPageCount;
perSource.push(report);
grandTotal += report.total;
ignoredMissingOpen += report.ignoredMissingOpen;
for (const [code, n] of Object.entries(report.errors_by_code)) {
const k = code as ParseValidationCode;
totals[k] = (totals[k] ?? 0) + (n as number);
}
if (report.status === 'partial' && abortedAtSource === null) {
abortedAtSource = src.id;
}
}
const hasPartialOrSkipped = perSource.some(r => r.status === 'partial' || r.status === 'skipped');
return {
// Partial scans can never be 'ok' even when the scanned prefix is clean
// (codex outside-voice C2 — a clean prefix doesn't speak for the
// unscanned suffix).
ok: grandTotal === 0 && !hasPartialOrSkipped,
total: grandTotal,
errors_by_code: totals,
per_source: perSource,
scanned_at: new Date().toISOString(),
ignored_missing_open: ignoredMissingOpen || undefined,
partial: hasPartialOrSkipped,
aborted_at_source: abortedAtSource,
};
}
function scanOneSource(
sourceId: string,
sourcePath: string,
opts: ScanOpts,
): PerSourceReport {
const errorsByCode: Partial<Record<ParseValidationCode, number>> = {};
const sample: PerSourceReport['sample'] = [];
const rootResolved = resolve(sourcePath);
let scanned = 0;
let total = 0;
let ignoredMissingOpen = 0;
let interrupted = false;
walkDir(rootResolved, (absPath) => {
// Per-file deadline + abort gate. Deadline is the load-bearing
// wall-clock bound (sync I/O blocks the event loop so timer-based
// AbortSignal.timeout can't fire mid-walk — codex C1).
if (opts.deadline && Date.now() > opts.deadline) {
interrupted = true;
return false;
}
if (opts.signal?.aborted) {
interrupted = true;
return false;
}
// visitDir is consulted from walkDir directly (passed below). The
// per-file visit closure doesn't need it.
const relPath = relative(rootResolved, absPath);
if (!isSyncable(relPath, { strategy: 'markdown' })) return true;
scanned++;
let content: string;
try {
content = readFileSync(absPath, 'utf8');
} catch {
return true; // skip unreadable
}
const expectedSlug = slugifyPath(relPath);
const parsed = parseMarkdown(content, relPath, { validate: true, expectedSlug });
const errs = (parsed.errors ?? []).filter((e) => {
if (e.code !== 'MISSING_OPEN') return true;
if (opts.strictMissingOpen) return true;
ignoredMissingOpen++;
return false;
});
if (errs.length > 0) {
total += errs.length;
const codes: ParseValidationCode[] = [];
for (const e of errs) {
errorsByCode[e.code] = (errorsByCode[e.code] ?? 0) + 1;
codes.push(e.code);
}
if (sample.length < SAMPLE_PER_SOURCE) {
sample.push({ path: relPath, codes });
}
}
if (opts.onProgress && scanned % 50 === 0) {
opts.onProgress.tick(50);
}
return true;
}, opts.visitDir);
if (opts.onProgress) {
opts.onProgress.heartbeat(`scanned ${scanned} pages in ${sourceId}`);
}
return {
source_id: sourceId,
source_path: sourcePath,
total,
errors_by_code: errorsByCode,
sample,
ignoredMissingOpen,
status: interrupted ? 'partial' : 'scanned',
files_scanned: scanned,
};
}
/**
* Recursive directory walker with symlink-loop protection (via lstat) and
* descent-time pruning of vendor / hidden / generated subtrees.
*
* Walks `root`, calling `visit(absPath)` for each regular file. Returning
* `false` from `visit` stops the walk (used by scanOneSource for deadline
* + abort).
*
* `pruneDir(name, parentDir)` is the canonical pruning gate (single source
* of truth at src/core/sync.ts:258 — sync, extract, and transcript-discovery
* all share it). Skipped subtrees: `node_modules`, `.git`, `.obsidian`,
* `*.raw`, `ops`, all dot-prefix dirs, and git submodule dirs (`.git` as
* FILE not DIRECTORY, the gitfile pattern from v0.37.7.0 #1169).
*
* Pre-v0.38.2.0 this walker descended into every subtree and let
* `isSyncable` filter at the leaf — paying the IO cost of stat'ing hundreds
* of thousands of vendor entries that were never going to be parsed. That
* was the root cause of the `gbrain doctor` hang on 216K-page brains
* reported in PR #1287.
*
* Optional `visitDir(dir)` is fired once per directory the walker decides
* to DESCEND INTO (post-pruneDir, post-visited-set check). Production
* callers don't pass it; tests use it to assert descent-time pruning
* directly. Output-based tests pass under the original bug because
* `isSyncable` filters at the leaf — `visitDir` is the load-bearing
* observability hook for the regression suite.
*/
/** @internal — exported for the regression test suite (visitDir-based
* descent-time pruning assertions). Production callers should go through
* scanBrainSources / scanOneSource. */
export function walkDir(
root: string,
visit: (absPath: string) => boolean | void,
visitDir?: (dirPath: string) => void,
): void {
const stack: string[] = [root];
const visited = new Set<string>();
if (visitDir) visitDir(root);
while (stack.length > 0) {
const dir = stack.pop()!;
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
continue;
}
for (const name of entries) {
const full = join(dir, name);
let st: ReturnType<typeof lstatSync>;
try {
st = lstatSync(full);
} catch {
continue;
}
if (st.isSymbolicLink()) continue; // matches sync's no-symlink policy
if (st.isDirectory()) {
// Descent-time prune — the actual fix for the 216K-page hang.
if (!pruneDir(name, dir)) continue;
const real = resolve(full);
if (visited.has(real)) continue;
visited.add(real);
if (visitDir) visitDir(full);
stack.push(full);
} else if (st.isFile()) {
const result = visit(full);
if (result === false) return;
}
}
}
}
async function listSources(engine: BrainEngine, sourceId?: string): Promise<SourceRow[]> {
if (sourceId) {
const rows = await engine.executeRaw<SourceRow>(
`SELECT id, local_path FROM sources WHERE id = $1`,
[sourceId],
);
return rows;
}
return engine.executeRaw<SourceRow>(
`SELECT id, local_path FROM sources WHERE local_path IS NOT NULL ORDER BY id`,
);
}