From 9a3ef3cda7c0949cf1bc45d224cb4ed363b03275 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 20 May 2026 20:25:41 -0700 Subject: [PATCH] feat: pgGraph-inspired CI scaffolding wave (v0.37.4.0) (#1228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema-migration matrix + fuzz harness + RSS budget gate + read-latency under sync + sync lock regression + tests/heavy convention + nightly CI workflow + BFS frontier cap on traverseGraph. CI infra (T1-T7): - tests/heavy/ directory convention + scripts/run-heavy.sh + bun run test:heavy - tests/heavy/pg_upgrade_matrix.sh: walk pre-v0.13 + pre-v0.18 brain shapes forward to head via bootstrap → SCHEMA_SQL → migrations → verifySchema - test/fuzz/{pure,mixed,filesystem}-validators.test.ts: 1000-run fast-check property tests across 8 trust-boundary validators - scripts/check-fuzz-purity.sh: bun-bundle + grep guard, wired into verify - tests/heavy/measure_rss.sh: in-memory PGLite workload + peak RSS measurement via /proc/self/status (Linux) or process.memoryUsage().rss fallback (macOS, refuses to write baseline) - tests/heavy/read_latency_under_sync.sh: phase A baseline + phase B under parallel writer load, reports p50/p95/p99 + delta_pct - tests/heavy/sync_lock_regression.sh: N concurrent gbrain sync against one DB, asserts 1 winner + N-1 lock-busy + zero leaked gbrain_cycle_locks rows - .github/workflows/heavy-tests.yml: cron '17 8 * * *' + heavy-tests label trigger + Postgres service + artifact upload on failure Engine (T8): - BrainEngine.traverseGraph opts gain frontierCap?: number + onTruncation?: (info: TruncationInfo) => void callback. Return shape preserved (Promise) for MCP wire stability. - Postgres CTE: parenthesized LIMIT N ORDER BY (slug, id) inside recursive term. - PGLite: same SQL with positional params. - Per-call callback closure — not engine-instance state — so concurrent traversals on the same engine don't cross-talk. 5 contracts pinned in test/regressions/v0_36_frontier_cap.test.ts. Three plan-review passes ran before any code: CEO scope review (Approach C), Eng dual-voice review (Claude subagent + Codex), and Codex 2nd-pass against the revised plan. The 2nd pass caught issues the first two missed (Bun ESM vs require.cache; engine-instance metadata stomping under concurrency; fixture-size inconsistency). All addressed. Co-authored-by: Claude Opus 4.7 --- .github/workflows/heavy-tests.yml | 89 ++++++ CHANGELOG.md | 94 +++++++ CLAUDE.md | 4 +- TODOS.md | 7 + VERSION | 2 +- bun.lock | 5 + llms-full.txt | 4 +- package.json | 7 +- scripts/check-fuzz-purity.sh | 164 +++++++++++ scripts/run-heavy.sh | 74 +++++ src/core/engine.ts | 28 +- src/core/pglite-engine.ts | 42 ++- src/core/postgres-engine.ts | 41 ++- test/fuzz/filesystem-validators.test.ts | 126 +++++++++ test/fuzz/mixed-validators.test.ts | 102 +++++++ test/fuzz/pure-validators.test.ts | 93 ++++++ test/fuzz/regressions/README.md | 27 ++ test/regressions/v0_36_frontier_cap.test.ts | 134 +++++++++ tests/heavy/README.md | 73 +++++ tests/heavy/_build_legacy_fixtures.sh | 76 +++++ tests/heavy/_measure_rss_workload.ts | 220 +++++++++++++++ tests/heavy/_read_latency_workload.ts | 266 ++++++++++++++++++ .../heavy/fixtures/down-mutate-pre-v0.13.sql | 25 ++ .../heavy/fixtures/down-mutate-pre-v0.18.sql | 34 +++ tests/heavy/measure_rss.sh | 131 +++++++++ tests/heavy/pg_upgrade_matrix.sh | 116 ++++++++ tests/heavy/read_latency_under_sync.sh | 77 +++++ tests/heavy/rss-baseline.json | 12 + tests/heavy/sync_lock_regression.sh | 163 +++++++++++ 29 files changed, 2214 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/heavy-tests.yml create mode 100755 scripts/check-fuzz-purity.sh create mode 100755 scripts/run-heavy.sh create mode 100644 test/fuzz/filesystem-validators.test.ts create mode 100644 test/fuzz/mixed-validators.test.ts create mode 100644 test/fuzz/pure-validators.test.ts create mode 100644 test/fuzz/regressions/README.md create mode 100644 test/regressions/v0_36_frontier_cap.test.ts create mode 100644 tests/heavy/README.md create mode 100755 tests/heavy/_build_legacy_fixtures.sh create mode 100644 tests/heavy/_measure_rss_workload.ts create mode 100644 tests/heavy/_read_latency_workload.ts create mode 100644 tests/heavy/fixtures/down-mutate-pre-v0.13.sql create mode 100644 tests/heavy/fixtures/down-mutate-pre-v0.18.sql create mode 100755 tests/heavy/measure_rss.sh create mode 100755 tests/heavy/pg_upgrade_matrix.sh create mode 100755 tests/heavy/read_latency_under_sync.sh create mode 100644 tests/heavy/rss-baseline.json create mode 100755 tests/heavy/sync_lock_regression.sh diff --git a/.github/workflows/heavy-tests.yml b/.github/workflows/heavy-tests.yml new file mode 100644 index 000000000..a0bb78c1e --- /dev/null +++ b/.github/workflows/heavy-tests.yml @@ -0,0 +1,89 @@ +name: Heavy Tests + +# Heavy ops-shape tests under tests/heavy/. Cost minutes per run; NOT part +# of default PR CI. Two triggers: +# - Nightly schedule (catches regressions within 24h of merge to master). +# - On-demand opt-in via PR label `heavy-tests` (slow loop kept off by default). +# - Manual workflow_dispatch for triage. +# +# See CLAUDE.md "tests/heavy/*.sh" entry and tests/heavy/README.md. + +on: + schedule: + - cron: '17 8 * * *' # 08:17 UTC daily — staggered to avoid noisy slots + pull_request: + # `synchronize` + `reopened` fire on subsequent pushes / reopens — without + # them, a PR labeled `heavy-tests` would NEVER re-run heavy on later + # commits. The job-level `if:` below filters to PRs that still carry the + # label so we don't fan out on unrelated label changes. + types: [labeled, synchronize, reopened] + workflow_dispatch: + +permissions: + contents: read + +# When a PR gets the heavy-tests label, cancel any in-flight heavy-tests run on +# the same ref so we only ever measure the latest commit. +concurrency: + group: heavy-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + heavy: + name: Heavy tests + # On pull_request: only run when the PR currently carries the `heavy-tests` + # label. Works for all three trigger types (labeled, synchronize, reopened) + # because `contains(labels.*.name, ...)` reads the live label set, not the + # event payload's `label.name` (which is only populated for `labeled`). + if: | + github.event_name != 'pull_request' || + contains(github.event.pull_request.labels.*.name, 'heavy-tests') + runs-on: ubuntu-latest + timeout-minutes: 30 + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: gbrain_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: latest + - run: bun install + + - name: Run heavy tests + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test + run: bun run test:heavy + + # The heavy runner writes per-script logs to ~/.gbrain/audit/ on every + # run. Upload those + the rss workload JSON on failure for triage + # without re-running locally. + # + # actions/upload-artifact runs as a node action — `~` is NOT expanded by + # the shell here. Stage logs into the workspace first, then upload from + # the stable workspace-relative path. + - name: Stage heavy-test logs into workspace + if: always() + run: | + mkdir -p heavy-artifacts + cp -r "$HOME/.gbrain/audit"/heavy-* heavy-artifacts/ 2>/dev/null || true + cp tests/heavy/rss-baseline.json heavy-artifacts/ 2>/dev/null || true + - name: Upload heavy-test artifacts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: heavy-tests-${{ github.run_id }}-${{ github.run_attempt }} + path: heavy-artifacts/ + retention-days: 14 + if-no-files-found: ignore diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b3a8fcd8..00b3dc4d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,100 @@ All notable changes to GBrain will be documented in this file. +## [0.37.4.0] - 2026-05-20 + +**A nightly safety net for the bug class that bit gbrain 10 times in 2 years.** +**Plus a graph cap so a hub person with 500 connections can't make `traverseGraph` blow up.** + +The 10+ forward-reference bugs documented in CLAUDE.md (#239 / #243 / #266 / #357 / #366 / #374 / #375 / #378 / #395 / #396) all shipped the same way: a new release added a column to the schema blob, an old user's brain didn't have that column, and `gbrain upgrade` wedged on `column "..." does not exist`. We always caught these in production. This release ports a CI pattern from pgGraph (a sibling pgrx extension) that catches the next member of that bug class before users hit it — by walking a simulated-legacy brain forward to head on every nightly CI run, against real Postgres. + +The same wave adds an opt-in cap on `traverseGraph` for hub-fanout protection, a property-based fuzz harness for the trust-boundary validators, and a memory budget gate that probes peak RSS during a synthetic workload. None of it changes default behavior; everything that touches production code is back-compat. + +### What landed + +| Piece | What it catches | How it runs | +|---|---|---| +| **Schema-migration matrix** | Walk-forward wedges from any historical brain shape (pre-v0.13 + pre-v0.18 seeded; extensible to any earlier shape) | `bash tests/heavy/pg_upgrade_matrix.sh` — Postgres-only, ~4s for both shapes | +| **Fuzz harness for trust-boundary validators** | Edge-case crashes in `validatePageSlug`, `validateFilename`, `escapeLikePattern`, `parseFactsFence`, plus property tests for `splitBody`, `slugifyPath`, `sanitizeQueryForPrompt`, `validateUploadPath` | `bun test test/fuzz/` (runs in default `bun test`, ~3s for 1000 inputs × 8 properties) | +| **RSS budget gate** | Peak RSS regressions over a 200-page synthetic workload, baseline-vs-now delta | `bash tests/heavy/measure_rss.sh` — Linux-only baseline refresh, informational on macOS | +| **Read-latency-under-sync** | Search p99 degradation while writes hammer the engine | `bash tests/heavy/read_latency_under_sync.sh` | +| **Sync lock regression** | One winner + N-1 lock-busy + zero leaked `gbrain_cycle_locks` rows under concurrent `gbrain sync` (real semantics — losers fail fast, they don't queue) | `bash tests/heavy/sync_lock_regression.sh` — Postgres-only | +| **`tests/heavy/` convention** | Home for ops-shape scripts that don't fit `*.slow.test.ts` (per-file unit-shape) | `bun run test:heavy` runs the directory sequentially | +| **Nightly + opt-in CI workflow** | Runs the whole heavy suite at 08:17 UTC daily AND on any PR tagged `heavy-tests`, with Postgres service + artifact upload on failure | `.github/workflows/heavy-tests.yml` | +| **BFS frontier cap on `traverseGraph`** | Hub-person fan-out blowing up at depth ≥ 2 (back-compat opt-in via new `frontierCap` knob) | `engine.traverseGraph(slug, depth, { frontierCap: 500 })` | + +### How to turn it on + +The heavy suite is opt-in. To run locally: + +```bash +# Spin up Postgres for the Postgres-only tests: +docker run -d --name gbrain-test-pg \ + -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=gbrain_test -p 5434:5432 pgvector/pgvector:pg16 +export DATABASE_URL=postgresql://postgres:postgres@localhost:5434/gbrain_test +bun run test:heavy +``` + +For the frontier cap, opt in per call: + +```ts +const nodes = await engine.traverseGraph('alice', 3, { frontierCap: 500 }); +// nodes.length is bounded — that's the protection. A truncation-signal +// callback was designed but stripped pre-merge; see "what's safe to know" +// below for the deferral. +``` + +### What's safe to know about + +- **Default behavior unchanged.** No call to `traverseGraph` sees different results unless they pass `frontierCap`. The `traverse_graph` MCP wire shape (Array of nodes, not a struct) is preserved — external clients keep working. +- **`onTruncation` callback stripped pre-merge.** The original plan called for a callback that fired when the cap dropped nodes. /review caught two bugs in the v1 algorithm: false positives when a graph organically has exactly `cap` unique nodes at some depth, and false negatives in diamond graphs because the recursive `LIMIT N` ran before the outer `SELECT DISTINCT`. Rather than ship a signal callers couldn't trust, we cut it. The cap itself (the actually-useful frontier protection) ships clean; the signal returns in a follow-up wave once a dedupe-then-cap SQL rewrite + Postgres parity E2E land. Tracked in TODOS.md → "T8 truncation signal". +- **`tests/heavy/` isn't in `bun test`.** The runner stays fast (8 shards, ~6 minutes). The heavy stuff runs nightly or on label. +- **RSS baseline is empty on first commit.** The first Linux CI nightly populates it via `tests/heavy/measure_rss.sh --refresh-baseline`. Until then every measurement is informational. The macOS fallback path is `process.memoryUsage().rss` (VmRSS, mmap-inflated) — the gate refuses to write a baseline from a macOS run by design. +- **Fuzz purity is bundle-verified.** `scripts/check-fuzz-purity.sh` runs in `verify`. It bundles each pure-target file via `bun build --target=bun` and greps for `node:fs` / `node:child_process` / engine imports. Source-grep can be fooled by transitive imports; the bundle can't. + +### What we caught and fixed before merging + +Three review passes ran on the plan before any code: a CEO scope review (Approach C, full sweep, 9 tasks), an Eng dual-voice review (Claude subagent + Codex), and a Codex 2nd-pass verifier against the revised plan. The 2nd pass caught issues the first two missed: + +- The fuzz purity guard's `require.cache` snapshot would have been theatrical under Bun's ESM loader. Swapped to bun-bundle-then-grep, which catches transitive impurity. Five proposed pure targets turned out to transitively import `fs` (validator-shaped functions living in modules that pull in helpers that import fs); they moved to `mixed-validators.test.ts` with property tests but no purity guarantee. Only `escapeLikePattern` and `parseFactsFence` are bundle-pure. +- The first-pass T8 design stored truncation metadata on the engine instance. Concurrent traversals would have stomped each other's metadata. Switched to a per-call `onTruncation` callback — each call's closure is independent. +- The first-pass T3 design proposed committing a 50K-page PGLite fixture to the repo. Repo-size risk + contradicted T1's no-blobs principle. Switched to in-process synthesis (200 pages by default; configurable up). +- Three reviewers caught the original `LIMIT N PARTITION BY depth` SQL as not-actually-valid syntax. Real shape is parenthesized `LIMIT N ORDER BY (slug, id)` inside the recursive term, with `DISTINCT ON` post-dedupe. + +### Itemized changes + +#### Engine (production code) + +- `src/core/engine.ts` — new export `TraverseGraphOpts`. `traverseGraph(slug, depth, opts?)` opts widen to include `frontierCap?: number`. Return type unchanged (`Promise`). +- `src/core/postgres-engine.ts` — recursive CTE in `traverseGraph` adds parenthesized `LIMIT N ORDER BY p2.slug ASC, p2.id ASC` inside the recursive term when `frontierCap` is set. +- `src/core/pglite-engine.ts` — same shape, same SQL, positional params. + +#### Heavy tests (new directory) + +- `tests/heavy/pg_upgrade_matrix.sh` + `tests/heavy/_build_legacy_fixtures.sh` + `tests/heavy/fixtures/down-mutate-pre-v0.13.sql` + `tests/heavy/fixtures/down-mutate-pre-v0.18.sql` — schema-migration walk-forward matrix. +- `tests/heavy/measure_rss.sh` + `tests/heavy/_measure_rss_workload.ts` + `tests/heavy/rss-baseline.json` — RSS budget gate, informational-only until Linux baseline lands. +- `tests/heavy/read_latency_under_sync.sh` + `tests/heavy/_read_latency_workload.ts` — search p50/p95/p99 baseline vs under-load. +- `tests/heavy/sync_lock_regression.sh` — concurrent `gbrain sync` lock contention. +- `tests/heavy/README.md` + `scripts/run-heavy.sh` + `bun run test:heavy` script — convention + runner. Underscore-prefix files (`_foo.sh`) are helpers skipped by the runner. + +#### Fuzz harness (new) + +- `test/fuzz/pure-validators.test.ts` — purity-guarded: `escapeLikePattern`, `parseFactsFence`. +- `test/fuzz/mixed-validators.test.ts` — same property tests, no purity guarantee: `validatePageSlug`, `validateFilename`, `splitBody`, `slugifyPath`, `sanitizeQueryForPrompt`. +- `test/fuzz/filesystem-validators.test.ts` — fs-backed property tests with temp dirs: `validateUploadPath` (symlink-escape, traversal probe, arbitrary input). +- `test/fuzz/regressions/README.md` — pin-failed-fuzz-inputs convention. +- `scripts/check-fuzz-purity.sh` — bun-bundle + grep for banned imports. Wired into `bun run verify`. + +#### CI workflow (new) + +- `.github/workflows/heavy-tests.yml` — `cron: '17 8 * * *'` + `pull_request: types: [labeled]` with `heavy-tests` filter + `workflow_dispatch`. Postgres service + pinned action SHAs + artifact upload on failure (uploads `~/.gbrain/audit/heavy-*` + `tests/heavy/rss-baseline.json`). + +#### Tests + docs + +- `test/regressions/v0_36_frontier_cap.test.ts` — 4 pinned contracts: cap-unset back-compat, cap-hit bounds result to `<= cap+1` (the actually-useful protection invariant), MCP wire-shape preservation (still Array), concurrency independence (two concurrent calls on same engine with different caps — larger cap sees >= as many nodes). +- `CLAUDE.md` — file taxonomy gains `tests/heavy/*.sh` + `test/fuzz/*.test.ts` entries; `traverseGraph` entry notes the new opt + the stripped truncation callback. +- `llms-full.txt` regenerated. ## [0.37.3.0] - 2026-05-19 **Your agent now catches skills that would call the web before checking the brain. The same class of miss that flagged Garry's own Palantir tweet as a risk because none of the three eval models knew he built it.** diff --git a/CLAUDE.md b/CLAUDE.md index 7e7e0ba26..d811418b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ strict behavior when unset. ## Key files - `src/core/operations.ts` — Contract-first operation definitions (the foundation). Also exports upload validators: `validateUploadPath`, `validatePageSlug`, `validateFilename`, plus `matchesSlugAllowList(slug, prefixes)` (v0.23 glob matcher: `/*` matches recursive children; bare `` matches exact only). `OperationContext.remote` flags untrusted callers; `OperationContext.allowedSlugPrefixes` (v0.23) is the trusted-workspace allow-list set by the dream cycle. `put_page` enforces: when `viaSubagent` and `allowedSlugPrefixes` is set, slug must match the allow-list; else the legacy `wiki/agents//...` namespace check applies. Auto-link enabled for trusted-workspace writes (skipped only when `remote=true && !trustedWorkspace`). As of v0.26.0, every `Operation` also carries `scope?: 'read' | 'write' | 'admin'` + `localOnly?: boolean`. All ops are annotated; `sync_brain`, `file_upload`, `file_list`, and `file_url` are `admin + localOnly` (rejected over HTTP). `OperationContext.auth?: AuthInfo` is threaded through HTTP dispatch for scope enforcement in `serve-http.ts` before the op runs. **v0.26.9 (D12 + F7b):** `OperationContext.remote` is now a REQUIRED field in the TypeScript type — the compiler is the first defense against transports that forget to set it. Four trust-boundary call sites (`put_page` allowlist, file_upload trust-narrowing, submit_job protected-name guard, auto-link skip) flipped from falsy-default (`!ctx.remote`) to fail-closed semantics (`ctx.remote === false` for "trusted-only" sites and `ctx.remote !== false` for "untrust unless explicit-false"). Anything that isn't strictly `false` is now treated as remote. Closed an HTTP MCP shell-job RCE: a `read+write`-scoped OAuth token could submit `shell` jobs because the HTTP request handler's literal context skipped `remote: true` and `submit_job`'s protected-name guard saw a falsy undefined. Stdio MCP set the field correctly via dispatch.ts; HTTP inlined a parallel context-builder for several releases and lost it. **v0.34.1.0 (#861 + #876):** new helper `sourceScopeOpts(ctx)` encodes the precedence ladder for source-scoped reads — federated array (`ctx.auth.allowedSources`) wins over scalar (`ctx.sourceId` / `ctx.auth.sourceId`) over nothing. Every read-side op handler routes through it so future ops can't silently drift from the canonical v0.31.8 thread. Closes the source-isolation leak on the read path: a `read+write`-scoped OAuth client bound to `--source dept-x` no longer sees rows from neighboring sources via `search` / `query` / `list_pages` / `get_page` / `find_experts` / `query`'s image path. -- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput` / `TimelineBatchInput` for the v0.12.1 bulk-insert API (`addLinksBatch` / `addTimelineEntriesBatch`). As of v0.13.1, `BrainEngine` has a `readonly kind: 'postgres' | 'pglite'` discriminator so migrations (`src/core/migrate.ts`) and other consumers can branch on engine without `instanceof` + dynamic imports. **v0.29:** four new methods — `batchLoadEmotionalInputs(slugs?)` (CTE-shaped read with per-table aggregates so a page × N tags × M takes never produces N×M rows), `setEmotionalWeightBatch(rows)` (`UPDATE FROM unnest($1::text[], $2::text[], $3::real[])` composite-keyed on `(slug, source_id)` for multi-source safety), `getRecentSalience(opts)`, `findAnomalies(opts)`. `PageFilters` extended with `sort?: 'updated_desc' | 'updated_asc' | 'created_desc' | 'slug'` + `PAGE_SORT_SQL` whitelist consumed by both engines (was hardcoded `ORDER BY updated_at DESC`). **v0.32.8 (PR #860):** new `listAllPageRefs(): Promise>` ordered by `(source_id, slug)`. Cheap cross-source enumeration for hot loops on large brains — replaces the `getAllSlugs()→getPage(slug)` N+1 pattern in extract-takes, extract, integrity, which silently defaulted to `source_id='default'` for non-default-source pages. Implementation parity across postgres-engine.ts + pglite-engine.ts. Pinned by `test/e2e/multi-source-bug-class.test.ts`. **v0.34.1.0 (#861):** `SearchOpts` + `PageFilters` add `sourceIds?: string[]` for the federated read axis; both engines apply `WHERE source_id = ANY($N::text[])` when the array is set and preserve the scalar `sourceId` fast path when unset. `traverseGraph(slug, depth, opts?)` and `traversePaths(slug, opts?)` accept `opts.sourceId` / `opts.sourceIds` so graph walks respect the caller's scope. **v0.35.6.0:** two new methods supporting the phantom-redirect cycle pass — `refreshPageBody(slug, sourceId, compiled_truth, timeline, content_hash)` narrow-UPDATEs three columns + updated_at, skipping soft-deleted rows (codex #7: content_hash refresh is required so `gbrain sync` sees the canonical as unchanged after fence merge); `migrateFactsToCanonical(phantomSlug, canonicalSlug, sourceId)` UPDATEs `entity_slug` + `source_markdown_slug` on every active fact row keyed on the phantom, preserving embedding/validUntil/kind/status/source_session/confidence — codex #3 fix for the writeFactsToFence lossy-migration trap. Both methods have engine parity tests at `test/phantom-redirect-engine-parity.test.ts`. +- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput` / `TimelineBatchInput` for the v0.12.1 bulk-insert API (`addLinksBatch` / `addTimelineEntriesBatch`). As of v0.13.1, `BrainEngine` has a `readonly kind: 'postgres' | 'pglite'` discriminator so migrations (`src/core/migrate.ts`) and other consumers can branch on engine without `instanceof` + dynamic imports. **v0.29:** four new methods — `batchLoadEmotionalInputs(slugs?)` (CTE-shaped read with per-table aggregates so a page × N tags × M takes never produces N×M rows), `setEmotionalWeightBatch(rows)` (`UPDATE FROM unnest($1::text[], $2::text[], $3::real[])` composite-keyed on `(slug, source_id)` for multi-source safety), `getRecentSalience(opts)`, `findAnomalies(opts)`. `PageFilters` extended with `sort?: 'updated_desc' | 'updated_asc' | 'created_desc' | 'slug'` + `PAGE_SORT_SQL` whitelist consumed by both engines (was hardcoded `ORDER BY updated_at DESC`). **v0.32.8 (PR #860):** new `listAllPageRefs(): Promise>` ordered by `(source_id, slug)`. Cheap cross-source enumeration for hot loops on large brains — replaces the `getAllSlugs()→getPage(slug)` N+1 pattern in extract-takes, extract, integrity, which silently defaulted to `source_id='default'` for non-default-source pages. Implementation parity across postgres-engine.ts + pglite-engine.ts. Pinned by `test/e2e/multi-source-bug-class.test.ts`. **v0.34.1.0 (#861):** `SearchOpts` + `PageFilters` add `sourceIds?: string[]` for the federated read axis; both engines apply `WHERE source_id = ANY($N::text[])` when the array is set and preserve the scalar `sourceId` fast path when unset. `traverseGraph(slug, depth, opts?)` and `traversePaths(slug, opts?)` accept `opts.sourceId` / `opts.sourceIds` so graph walks respect the caller's scope. **T8 wave (pgGraph-inspired CI infra, v0.37.4.0):** `traverseGraph` opts gains `frontierCap?: number` (per-iteration cap on the recursive CTE — approximately per-BFS-layer). Return type stays `Promise` for MCP wire stability. New export `TraverseGraphOpts`. Postgres path uses parenthesized `LIMIT N ORDER BY (slug, id)` inside the recursive term; PGLite mirrors with positional params + the same shape SQL. Pinned by `test/regressions/v0_36_frontier_cap.test.ts` (4 contracts: cap-unset back-compat, cap-hit bounds result to `<= cap+1`, MCP wire-shape preservation, concurrency independence). **`onTruncation` callback designed but stripped pre-merge in /review** — adversarial pass caught false-positive (organic count == cap) + false-negative (LIMIT-before-DISTINCT in diamond graphs) cases in the v1 algorithm. Restoring the signal requires a dedupe-then-cap SQL rewrite + Postgres parity E2E — see TODOS.md → "T8 truncation signal". **v0.35.6.0:** two new methods supporting the phantom-redirect cycle pass — `refreshPageBody(slug, sourceId, compiled_truth, timeline, content_hash)` narrow-UPDATEs three columns + updated_at, skipping soft-deleted rows (codex #7: content_hash refresh is required so `gbrain sync` sees the canonical as unchanged after fence merge); `migrateFactsToCanonical(phantomSlug, canonicalSlug, sourceId)` UPDATEs `entity_slug` + `source_markdown_slug` on every active fact row keyed on the phantom, preserving embedding/validUntil/kind/status/source_session/confidence — codex #3 fix for the writeFactsToFence lossy-migration trap. Both methods have engine parity tests at `test/phantom-redirect-engine-parity.test.ts`. - `src/core/engine-factory.ts` — Engine factory with dynamic imports (`'pglite'` | `'postgres'`) - `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all 40 BrainEngine methods. `addLinksBatch` / `addTimelineEntriesBatch` use multi-row `unnest()` with manual `$N` placeholders. As of v0.13.1, `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error naming the macOS 26.3 WASM bug (#223) and pointing at `gbrain doctor`; the lock is released on failure so the next process can retry cleanly. v0.22.0: `searchKeyword` and `searchKeywordChunks` multiply `ts_rank` by the source-factor CASE expression at the chunk-grain level; `searchVector` becomes a two-stage CTE — inner CTE keeps `ORDER BY cc.embedding <=> vec` so HNSW stays usable, outer SELECT re-ranks by `raw_score * source_factor`. Inner LIMIT scales with offset to preserve pagination contract. As of v0.22.6.1, `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL — probes for the specific forward-referenced state the embedded schema blob needs (`pages.source_id`, `links.link_source`, `links.origin_page_id`, `content_chunks.symbol_name`, `content_chunks.language`, `sources` FK target table) and adds only what's missing. Closes the upgrade-wedge bug class that bit users 10+ times across 6 schema versions over 2 years (#239/#243/#266/#357/#366/#374/#375/#378/#395/#396). No-op on fresh installs and modern brains. **v0.35.5.0:** probe set extended in parity with postgres-engine.ts — `files.source_id`, `files.page_id`, `oauth_clients.source_id`, `oauth_clients.federated_read`, `sources.archived`, `sources.archived_at`, `sources.archive_expires_at`. Bootstrap also threads the DDL connection from `initSchema` so probes run inside the advisory-lock scope. Closes #1018, #974, #820. - `src/core/pglite-schema.ts` — PGLite-specific DDL (pgvector, pg_trgm, triggers) @@ -679,6 +679,8 @@ If a shard wedges (per-shard `GBRAIN_TEST_SHARD_TIMEOUT` cap, default 600s), the - `*.slow.test.ts` → run via `bun run test:slow` only (intentional cold-path tests; would dominate the fast loop's wallclock). - `*.serial.test.ts` → run via `bun run test:serial` after the parallel pass completes; uses `--max-concurrency=1`. Quarantine for tests that share file-wide state and race when run alongside other files in the same `bun test` process. Currently: `test/brain-registry.serial.test.ts`, `test/reconcile-links.serial.test.ts`, `test/core/cycle.serial.test.ts`, `test/embed.serial.test.ts` (the latter two added in v0.26.7 — they use `mock.module(...)` which leaks across files in the shard process). **Do not put the parallelism back on a serial file unless you've fixed the contention root cause** (it just re-introduces the flake). - `test/e2e/*.test.ts` → real-Postgres E2E. Skipped when `DATABASE_URL` is unset. +- `tests/heavy/*.sh` → ops-shape shell scripts. Cost minutes per run; NOT in default `bun test`. Run via `bun run test:heavy` or scheduled nightly via `.github/workflows/heavy-tests.yml`. Examples: pg_upgrade matrix (boot legacy brain → walk to head), RSS budget gate (measure peak worker RSS vs committed baseline), read-latency-under-sync (p50/p95/p99 under concurrent writer load), sync lock regression (N concurrent syncs assert 1 winner + N-1 lock-busy + zero leaked `gbrain_cycle_locks` rows). See `tests/heavy/README.md` for when to add a script here vs `*.slow.test.ts`. Files prefixed with `_` (e.g. `tests/heavy/_build_legacy_fixtures.sh`) are helpers/libs invoked by sibling tests — the runner skips them. +- `test/fuzz/*.test.ts` → property-based fuzz harness. Pure-validator targets in `pure-validators.test.ts` are guarded by `scripts/check-fuzz-purity.sh` (in `bun run verify`), which `bun build --target=bun` bundles each target and greps the resulting bundle for banned transitive imports (`node:fs`, `node:child_process`, engine modules). Anything that fails the guard moves to `mixed-validators.test.ts` (still property-tested, but no purity guarantee) or `filesystem-validators.test.ts` (fs-backed, uses temp dirs). Fuzz tests run in the default `bun test` loop because they're fast (~3s for ~12 properties × 1000 runs each). The intra-file parallelism project (turn `bun test` into `bun test --concurrent` after sweeping shared-state contention sites) is sliced across v0.26.7 (foundation), v0.26.8 (env-mutation sweep), and v0.26.9 (PGLite sweep + codemod + measurement). v0.26.4 ships file-level parallelism only. diff --git a/TODOS.md b/TODOS.md index ffd9de933..4c72dfe5b 100644 --- a/TODOS.md +++ b/TODOS.md @@ -1,6 +1,13 @@ # TODOS +## v0.37.4.0 pgGraph CI scaffolding follow-ups (v0.37.x+) + +- [ ] **T8 truncation signal — defer until dedupe-then-cap SQL + Postgres parity E2E.** v0.37.4.0 ships `frontierCap` as the actually-useful protection but strips the `onTruncation` callback after /review adversarial pass (Claude + Codex both flagged). Two bugs in the v1 algorithm: (a) FALSE POSITIVE — `count == cap` at a depth fires the callback even when the graph organically has exactly cap unique nodes at that depth with no truncation; (b) FALSE NEGATIVE — recursive `LIMIT N` runs BEFORE outer `SELECT DISTINCT`, so diamond graphs (one parent fans out to N+5 candidates with duplicates) can have the LIMIT eat its slots on dupes, then DISTINCT collapses to /*` matches recursive children; bare `` matches exact only). `OperationContext.remote` flags untrusted callers; `OperationContext.allowedSlugPrefixes` (v0.23) is the trusted-workspace allow-list set by the dream cycle. `put_page` enforces: when `viaSubagent` and `allowedSlugPrefixes` is set, slug must match the allow-list; else the legacy `wiki/agents//...` namespace check applies. Auto-link enabled for trusted-workspace writes (skipped only when `remote=true && !trustedWorkspace`). As of v0.26.0, every `Operation` also carries `scope?: 'read' | 'write' | 'admin'` + `localOnly?: boolean`. All ops are annotated; `sync_brain`, `file_upload`, `file_list`, and `file_url` are `admin + localOnly` (rejected over HTTP). `OperationContext.auth?: AuthInfo` is threaded through HTTP dispatch for scope enforcement in `serve-http.ts` before the op runs. **v0.26.9 (D12 + F7b):** `OperationContext.remote` is now a REQUIRED field in the TypeScript type — the compiler is the first defense against transports that forget to set it. Four trust-boundary call sites (`put_page` allowlist, file_upload trust-narrowing, submit_job protected-name guard, auto-link skip) flipped from falsy-default (`!ctx.remote`) to fail-closed semantics (`ctx.remote === false` for "trusted-only" sites and `ctx.remote !== false` for "untrust unless explicit-false"). Anything that isn't strictly `false` is now treated as remote. Closed an HTTP MCP shell-job RCE: a `read+write`-scoped OAuth token could submit `shell` jobs because the HTTP request handler's literal context skipped `remote: true` and `submit_job`'s protected-name guard saw a falsy undefined. Stdio MCP set the field correctly via dispatch.ts; HTTP inlined a parallel context-builder for several releases and lost it. **v0.34.1.0 (#861 + #876):** new helper `sourceScopeOpts(ctx)` encodes the precedence ladder for source-scoped reads — federated array (`ctx.auth.allowedSources`) wins over scalar (`ctx.sourceId` / `ctx.auth.sourceId`) over nothing. Every read-side op handler routes through it so future ops can't silently drift from the canonical v0.31.8 thread. Closes the source-isolation leak on the read path: a `read+write`-scoped OAuth client bound to `--source dept-x` no longer sees rows from neighboring sources via `search` / `query` / `list_pages` / `get_page` / `find_experts` / `query`'s image path. -- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput` / `TimelineBatchInput` for the v0.12.1 bulk-insert API (`addLinksBatch` / `addTimelineEntriesBatch`). As of v0.13.1, `BrainEngine` has a `readonly kind: 'postgres' | 'pglite'` discriminator so migrations (`src/core/migrate.ts`) and other consumers can branch on engine without `instanceof` + dynamic imports. **v0.29:** four new methods — `batchLoadEmotionalInputs(slugs?)` (CTE-shaped read with per-table aggregates so a page × N tags × M takes never produces N×M rows), `setEmotionalWeightBatch(rows)` (`UPDATE FROM unnest($1::text[], $2::text[], $3::real[])` composite-keyed on `(slug, source_id)` for multi-source safety), `getRecentSalience(opts)`, `findAnomalies(opts)`. `PageFilters` extended with `sort?: 'updated_desc' | 'updated_asc' | 'created_desc' | 'slug'` + `PAGE_SORT_SQL` whitelist consumed by both engines (was hardcoded `ORDER BY updated_at DESC`). **v0.32.8 (PR #860):** new `listAllPageRefs(): Promise>` ordered by `(source_id, slug)`. Cheap cross-source enumeration for hot loops on large brains — replaces the `getAllSlugs()→getPage(slug)` N+1 pattern in extract-takes, extract, integrity, which silently defaulted to `source_id='default'` for non-default-source pages. Implementation parity across postgres-engine.ts + pglite-engine.ts. Pinned by `test/e2e/multi-source-bug-class.test.ts`. **v0.34.1.0 (#861):** `SearchOpts` + `PageFilters` add `sourceIds?: string[]` for the federated read axis; both engines apply `WHERE source_id = ANY($N::text[])` when the array is set and preserve the scalar `sourceId` fast path when unset. `traverseGraph(slug, depth, opts?)` and `traversePaths(slug, opts?)` accept `opts.sourceId` / `opts.sourceIds` so graph walks respect the caller's scope. **v0.35.6.0:** two new methods supporting the phantom-redirect cycle pass — `refreshPageBody(slug, sourceId, compiled_truth, timeline, content_hash)` narrow-UPDATEs three columns + updated_at, skipping soft-deleted rows (codex #7: content_hash refresh is required so `gbrain sync` sees the canonical as unchanged after fence merge); `migrateFactsToCanonical(phantomSlug, canonicalSlug, sourceId)` UPDATEs `entity_slug` + `source_markdown_slug` on every active fact row keyed on the phantom, preserving embedding/validUntil/kind/status/source_session/confidence — codex #3 fix for the writeFactsToFence lossy-migration trap. Both methods have engine parity tests at `test/phantom-redirect-engine-parity.test.ts`. +- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput` / `TimelineBatchInput` for the v0.12.1 bulk-insert API (`addLinksBatch` / `addTimelineEntriesBatch`). As of v0.13.1, `BrainEngine` has a `readonly kind: 'postgres' | 'pglite'` discriminator so migrations (`src/core/migrate.ts`) and other consumers can branch on engine without `instanceof` + dynamic imports. **v0.29:** four new methods — `batchLoadEmotionalInputs(slugs?)` (CTE-shaped read with per-table aggregates so a page × N tags × M takes never produces N×M rows), `setEmotionalWeightBatch(rows)` (`UPDATE FROM unnest($1::text[], $2::text[], $3::real[])` composite-keyed on `(slug, source_id)` for multi-source safety), `getRecentSalience(opts)`, `findAnomalies(opts)`. `PageFilters` extended with `sort?: 'updated_desc' | 'updated_asc' | 'created_desc' | 'slug'` + `PAGE_SORT_SQL` whitelist consumed by both engines (was hardcoded `ORDER BY updated_at DESC`). **v0.32.8 (PR #860):** new `listAllPageRefs(): Promise>` ordered by `(source_id, slug)`. Cheap cross-source enumeration for hot loops on large brains — replaces the `getAllSlugs()→getPage(slug)` N+1 pattern in extract-takes, extract, integrity, which silently defaulted to `source_id='default'` for non-default-source pages. Implementation parity across postgres-engine.ts + pglite-engine.ts. Pinned by `test/e2e/multi-source-bug-class.test.ts`. **v0.34.1.0 (#861):** `SearchOpts` + `PageFilters` add `sourceIds?: string[]` for the federated read axis; both engines apply `WHERE source_id = ANY($N::text[])` when the array is set and preserve the scalar `sourceId` fast path when unset. `traverseGraph(slug, depth, opts?)` and `traversePaths(slug, opts?)` accept `opts.sourceId` / `opts.sourceIds` so graph walks respect the caller's scope. **T8 wave (pgGraph-inspired CI infra, v0.37.4.0):** `traverseGraph` opts gains `frontierCap?: number` (per-iteration cap on the recursive CTE — approximately per-BFS-layer). Return type stays `Promise` for MCP wire stability. New export `TraverseGraphOpts`. Postgres path uses parenthesized `LIMIT N ORDER BY (slug, id)` inside the recursive term; PGLite mirrors with positional params + the same shape SQL. Pinned by `test/regressions/v0_36_frontier_cap.test.ts` (4 contracts: cap-unset back-compat, cap-hit bounds result to `<= cap+1`, MCP wire-shape preservation, concurrency independence). **`onTruncation` callback designed but stripped pre-merge in /review** — adversarial pass caught false-positive (organic count == cap) + false-negative (LIMIT-before-DISTINCT in diamond graphs) cases in the v1 algorithm. Restoring the signal requires a dedupe-then-cap SQL rewrite + Postgres parity E2E — see TODOS.md → "T8 truncation signal". **v0.35.6.0:** two new methods supporting the phantom-redirect cycle pass — `refreshPageBody(slug, sourceId, compiled_truth, timeline, content_hash)` narrow-UPDATEs three columns + updated_at, skipping soft-deleted rows (codex #7: content_hash refresh is required so `gbrain sync` sees the canonical as unchanged after fence merge); `migrateFactsToCanonical(phantomSlug, canonicalSlug, sourceId)` UPDATEs `entity_slug` + `source_markdown_slug` on every active fact row keyed on the phantom, preserving embedding/validUntil/kind/status/source_session/confidence — codex #3 fix for the writeFactsToFence lossy-migration trap. Both methods have engine parity tests at `test/phantom-redirect-engine-parity.test.ts`. - `src/core/engine-factory.ts` — Engine factory with dynamic imports (`'pglite'` | `'postgres'`) - `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all 40 BrainEngine methods. `addLinksBatch` / `addTimelineEntriesBatch` use multi-row `unnest()` with manual `$N` placeholders. As of v0.13.1, `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error naming the macOS 26.3 WASM bug (#223) and pointing at `gbrain doctor`; the lock is released on failure so the next process can retry cleanly. v0.22.0: `searchKeyword` and `searchKeywordChunks` multiply `ts_rank` by the source-factor CASE expression at the chunk-grain level; `searchVector` becomes a two-stage CTE — inner CTE keeps `ORDER BY cc.embedding <=> vec` so HNSW stays usable, outer SELECT re-ranks by `raw_score * source_factor`. Inner LIMIT scales with offset to preserve pagination contract. As of v0.22.6.1, `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL — probes for the specific forward-referenced state the embedded schema blob needs (`pages.source_id`, `links.link_source`, `links.origin_page_id`, `content_chunks.symbol_name`, `content_chunks.language`, `sources` FK target table) and adds only what's missing. Closes the upgrade-wedge bug class that bit users 10+ times across 6 schema versions over 2 years (#239/#243/#266/#357/#366/#374/#375/#378/#395/#396). No-op on fresh installs and modern brains. **v0.35.5.0:** probe set extended in parity with postgres-engine.ts — `files.source_id`, `files.page_id`, `oauth_clients.source_id`, `oauth_clients.federated_read`, `sources.archived`, `sources.archived_at`, `sources.archive_expires_at`. Bootstrap also threads the DDL connection from `initSchema` so probes run inside the advisory-lock scope. Closes #1018, #974, #820. - `src/core/pglite-schema.ts` — PGLite-specific DDL (pgvector, pg_trgm, triggers) @@ -815,6 +815,8 @@ If a shard wedges (per-shard `GBRAIN_TEST_SHARD_TIMEOUT` cap, default 600s), the - `*.slow.test.ts` → run via `bun run test:slow` only (intentional cold-path tests; would dominate the fast loop's wallclock). - `*.serial.test.ts` → run via `bun run test:serial` after the parallel pass completes; uses `--max-concurrency=1`. Quarantine for tests that share file-wide state and race when run alongside other files in the same `bun test` process. Currently: `test/brain-registry.serial.test.ts`, `test/reconcile-links.serial.test.ts`, `test/core/cycle.serial.test.ts`, `test/embed.serial.test.ts` (the latter two added in v0.26.7 — they use `mock.module(...)` which leaks across files in the shard process). **Do not put the parallelism back on a serial file unless you've fixed the contention root cause** (it just re-introduces the flake). - `test/e2e/*.test.ts` → real-Postgres E2E. Skipped when `DATABASE_URL` is unset. +- `tests/heavy/*.sh` → ops-shape shell scripts. Cost minutes per run; NOT in default `bun test`. Run via `bun run test:heavy` or scheduled nightly via `.github/workflows/heavy-tests.yml`. Examples: pg_upgrade matrix (boot legacy brain → walk to head), RSS budget gate (measure peak worker RSS vs committed baseline), read-latency-under-sync (p50/p95/p99 under concurrent writer load), sync lock regression (N concurrent syncs assert 1 winner + N-1 lock-busy + zero leaked `gbrain_cycle_locks` rows). See `tests/heavy/README.md` for when to add a script here vs `*.slow.test.ts`. Files prefixed with `_` (e.g. `tests/heavy/_build_legacy_fixtures.sh`) are helpers/libs invoked by sibling tests — the runner skips them. +- `test/fuzz/*.test.ts` → property-based fuzz harness. Pure-validator targets in `pure-validators.test.ts` are guarded by `scripts/check-fuzz-purity.sh` (in `bun run verify`), which `bun build --target=bun` bundles each target and greps the resulting bundle for banned transitive imports (`node:fs`, `node:child_process`, engine modules). Anything that fails the guard moves to `mixed-validators.test.ts` (still property-tested, but no purity guarantee) or `filesystem-validators.test.ts` (fs-backed, uses temp dirs). Fuzz tests run in the default `bun test` loop because they're fast (~3s for ~12 properties × 1000 runs each). The intra-file parallelism project (turn `bun test` into `bun test --concurrent` after sweeping shared-state contention sites) is sliced across v0.26.7 (foundation), v0.26.8 (env-mutation sweep), and v0.26.9 (PGLite sweep + codemod + measurement). v0.26.4 ships file-level parallelism only. diff --git a/package.json b/package.json index 33723b62c..fd31dbe1c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gbrain", - "version": "0.37.3.0", + "version": "0.37.4.0", "description": "Postgres-native personal knowledge brain with hybrid RAG search", "type": "module", "main": "src/core/index.ts", @@ -38,7 +38,7 @@ "build:pglite-snapshot": "bun run scripts/build-pglite-snapshot.ts", "test": "bash scripts/run-unit-parallel.sh", "test:full": "bun run verify && bash scripts/run-unit-parallel.sh && bun run test:slow && ([ -n \"$DATABASE_URL\" ] && bash scripts/run-e2e.sh || echo '[test:full] skipped E2E (no DATABASE_URL); run docker-compose -f docker-compose.ci.yml up + bun run test:e2e to include' 1>&2)", - "verify": "bun run check:privacy && bun run check:proposal-pii && bun run check:test-names && bun run check:jsonb && bun run check:source-id-projection && bun run check:progress && bun run check:test-isolation && bun run check:wasm && bun run check:admin-build && bun run check:admin-scope-drift && bun run check:cli-exec && bun run check:system-of-record && bun run check:eval-glossary && bun run check:synthetic-corpus-privacy && bun run check:skill-brain-first && bun run typecheck", + "verify": "bun run check:privacy && bun run check:proposal-pii && bun run check:test-names && bun run check:jsonb && bun run check:source-id-projection && bun run check:progress && bun run check:test-isolation && bun run check:wasm && bun run check:admin-build && bun run check:admin-scope-drift && bun run check:cli-exec && bun run check:system-of-record && bun run check:eval-glossary && bun run check:synthetic-corpus-privacy && bun run check:skill-brain-first && bun run check:fuzz-purity && bun run typecheck", "check:synthetic-corpus-privacy": "scripts/check-synthetic-corpus-privacy.sh", "check:system-of-record": "scripts/check-system-of-record.sh", "check:admin-scope-drift": "scripts/check-admin-scope-drift.sh", @@ -49,6 +49,7 @@ "check:newlines": "scripts/check-trailing-newline.sh", "test:e2e": "bash scripts/run-e2e.sh", "test:slow": "bash scripts/run-slow-tests.sh", + "test:heavy": "bash scripts/run-heavy.sh", "test:profile": "bash scripts/profile-tests.sh", "test:serial": "bash scripts/run-serial-tests.sh", "ci:local": "bash scripts/ci-local.sh", @@ -66,6 +67,7 @@ "check:admin-build": "scripts/check-admin-build.sh", "check:admin-embedded": "scripts/check-admin-embedded.sh", "check:test-isolation": "scripts/check-test-isolation.sh", + "check:fuzz-purity": "scripts/check-fuzz-purity.sh", "postinstall": "command -v gbrain >/dev/null 2>&1 && gbrain apply-migrations --yes --non-interactive || echo '[gbrain] postinstall skipped. If installed via bun install -g github:...: run `gbrain doctor` and `gbrain apply-migrations --yes` manually. See https://github.com/garrytan/gbrain/issues/218' 1>&2", "prepublish:clawhub": "bun run build:all", "publish:clawhub": "clawhub package publish . --family bundle-plugin" @@ -113,6 +115,7 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "bun-types": "^1.3.13", + "fast-check": "^4.8.0", "typescript": "^5.6.0" }, "trustedDependencies": [ diff --git a/scripts/check-fuzz-purity.sh b/scripts/check-fuzz-purity.sh new file mode 100755 index 000000000..ae1ffc4e3 --- /dev/null +++ b/scripts/check-fuzz-purity.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# scripts/check-fuzz-purity.sh +# CI guard: verify that every fuzz target in `test/fuzz/pure-validators.test.ts` +# is genuinely PURE — no transitive imports of `node:fs`, `node:child_process`, +# the engine layer, or `node:net` / `node:http` / `node:https`. +# +# Mechanism: bundle each target file with `bun build --target=bun` (which +# resolves the full transitive import graph) then grep the bundle for forbidden +# imports. Bun surfaces transitively-imported node builtins in the bundle output +# even when the indirect-importing file is only reached through several layers, +# so the grep catches what require.cache / source-grep approaches miss. This is +# the "isolated Bun subprocess with import-trace probe" design from the T2 +# plan revision. +# +# Smoke-tested 2026-05-19: adding `import { lstatSync } from 'node:fs'` to a +# target file makes this script fail loudly with the offending file + matched +# pattern. +# +# Failure modes the guard catches: +# - Direct import of a banned builtin in a target file +# - Transitive import through a helper (the failure mode my v1 require.cache +# proposal couldn't catch in Bun's ESM loader) +# - Re-export from a barrel module +# +# What this DOESN'T catch: +# - Runtime-only `require('fs')` constructed via string concatenation +# (intentional escape hatch is rare and would surface in code review) +# - Native addon dynamic imports +# +# Usage: scripts/check-fuzz-purity.sh +# Exit: 0 = all targets pure, 1 = any target imports a banned module. + +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$ROOT" + +# Targets — keep in sync with `test/fuzz/pure-validators.test.ts` imports. +# Only files whose bundle is bundle-pure live here. The original T2 plan listed +# more files; the bundle disproved their purity (validator-shaped functions +# living in modules that transitively import fs). Those validators still get +# property-tested in `test/fuzz/mixed-validators.test.ts` — same fuzz coverage, +# no purity guarantee. Filesystem-touching validators live in +# `test/fuzz/filesystem-validators.test.ts`. +TARGET_FILES=( + "src/core/cjk.ts" # escapeLikePattern + "src/core/facts-fence.ts" # parseFactsFence +) + +# Banned imports. Bun's bundler emits a mix of forms in the output JS: +# - `from "fs"` / `from "node:fs"` (named + namespace imports preserve this) +# - `__require("fs")` / `require("fs")` (CJS-style or dynamic-import lowering) +# - `from "fs/promises"` / `from "node:fs/promises"` (subpaths — promises API) +# - `import("fs")` (raw dynamic import that may survive bundling) +# /review codex pass caught the subpath + dynamic-import gaps; this list closes +# them. Subpath patterns use trailing-slash matches so `fs/promises` and any +# future `fs/` all trip the guard. +BANNED_PATTERNS=( + 'from "node:fs"' + 'from "fs"' + 'from "node:fs/' + 'from "fs/' + 'from "node:child_process"' + 'from "child_process"' + 'from "node:net"' + 'from "node:http"' + 'from "node:https"' + 'from "node:dns"' + 'from "node:cluster"' + 'require("node:fs")' + 'require("fs")' + 'require("node:fs/' + 'require("fs/' + 'require("node:child_process")' + 'require("child_process")' + '__require("fs")' + '__require("node:fs")' + '__require("child_process")' + '__require("node:child_process")' + 'import("fs")' + 'import("node:fs")' + 'import("child_process")' + 'import("node:child_process")' +) + +# Engine-layer imports — these are the OTHER thing fuzz targets must not touch. +# A fuzz harness that pulls in `engine.ts` could trigger DB connections through +# transitive imports of engine-factory. +BANNED_PATH_PATTERNS=( + 'src/core/engine.ts' + 'src/core/postgres-engine.ts' + 'src/core/pglite-engine.ts' + 'src/core/db.ts' + 'src/core/engine-factory.ts' +) + +TMP_BUNDLE_DIR=$(mktemp -d -t gbrain-fuzz-purity-XXXXXX) +trap 'rm -rf "$TMP_BUNDLE_DIR"' EXIT + +violations=0 + +for target in "${TARGET_FILES[@]}"; do + if [ ! -f "$target" ]; then + echo "[check-fuzz-purity] WARN: target not found: $target" >&2 + continue + fi + + # Bundle the target into a fresh subdir. --outdir handles the case where a + # target's bundle includes side-asset files (WASM, etc) — --outfile fails on + # those with "cannot write multiple output files." A pure target should + # produce exactly one .js file, but we route through --outdir for safety. + SUB="$TMP_BUNDLE_DIR/$(basename "$target" .ts)" + mkdir -p "$SUB" + if ! bun build --target=bun "$target" --outdir="$SUB" >"$SUB/build.log" 2>&1; then + echo "[check-fuzz-purity] FAIL: $target failed to bundle." >&2 + sed 's/^/ /' "$SUB/build.log" >&2 + violations=$((violations + 1)) + continue + fi + + # A bundle that emits asset files (.wasm, etc) is itself a smell — pure + # targets shouldn't ship binary assets. + assets=$(find "$SUB" -maxdepth 1 -type f ! -name '*.js' ! -name 'build.log' | wc -l | tr -d ' ') + if [ "$assets" -gt 0 ]; then + echo "[check-fuzz-purity] FAIL: $target bundle emitted $assets side-asset file(s); pure targets must be JS-only." >&2 + find "$SUB" -maxdepth 1 -type f ! -name '*.js' ! -name 'build.log' | head -5 >&2 + violations=$((violations + 1)) + continue + fi + + BUNDLE_JS="$SUB/$(basename "$target" .ts).js" + if [ ! -f "$BUNDLE_JS" ]; then + echo "[check-fuzz-purity] FAIL: $target bundle produced no .js output." >&2 + violations=$((violations + 1)) + continue + fi + + # Check each banned import pattern against the bundled output. + for pattern in "${BANNED_PATTERNS[@]}"; do + if grep -F -q -- "$pattern" "$BUNDLE_JS"; then + echo "[check-fuzz-purity] FAIL: $target bundle contains banned import: $pattern" >&2 + grep -n -F -- "$pattern" "$BUNDLE_JS" | head -3 >&2 + violations=$((violations + 1)) + fi + done + + # Check engine-path patterns (these appear as `// ` comments in Bun's + # bundle output when a transitively-imported module is bundled in). + for path_pat in "${BANNED_PATH_PATTERNS[@]}"; do + if grep -F -q -- "$path_pat" "$BUNDLE_JS"; then + echo "[check-fuzz-purity] FAIL: $target transitively pulls in: $path_pat" >&2 + violations=$((violations + 1)) + fi + done +done + +if [ "$violations" -gt 0 ]; then + echo "" >&2 + echo "[check-fuzz-purity] $violations violation(s). Move impure validators to" >&2 + echo " test/fuzz/filesystem-validators.test.ts (no purity guard there)." >&2 + exit 1 +fi + +echo "[check-fuzz-purity] OK — ${#TARGET_FILES[@]} pure-fuzz target(s) verified clean." diff --git a/scripts/run-heavy.sh b/scripts/run-heavy.sh new file mode 100755 index 000000000..28c1b26a6 --- /dev/null +++ b/scripts/run-heavy.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# scripts/run-heavy.sh +# Runs every shell script under tests/heavy/ sequentially. +# Sister to scripts/run-slow-tests.sh and scripts/run-e2e.sh, but for +# ops-shape tests that aren't bun:test targets. +# +# Usage: +# bun run test:heavy # all scripts, sequential +# bash scripts/run-heavy.sh # same +# bash scripts/run-heavy.sh # only scripts whose basename matches glob +# +# Exit codes: +# 0 all scripts passed (or no scripts found — informational) +# N exit code of the first failing script + +set -euo pipefail +cd "$(dirname "$0")/.." + +PATTERN="${1:-}" + +heavy_files=() +while IFS= read -r f; do + # Skip README.md, non-shell files, and underscore-prefixed helpers + # (convention: `_foo.sh` is a library/helper invoked by sibling tests). + [[ "$f" == *.sh ]] || continue + base=$(basename "$f") + case "$base" in + _*) continue ;; + esac + if [ -n "$PATTERN" ]; then + case "$base" in + $PATTERN) ;; + *) continue ;; + esac + fi + heavy_files+=("$f") +done < <(find tests/heavy -maxdepth 1 -type f -name '*.sh' | sort) + +if [ "${#heavy_files[@]}" -eq 0 ]; then + if [ -n "$PATTERN" ]; then + echo "[run-heavy] no scripts under tests/heavy/ matched '$PATTERN'" >&2 + exit 1 + fi + echo "[run-heavy] no scripts under tests/heavy/; nothing to do." + exit 0 +fi + +echo "[run-heavy] running ${#heavy_files[@]} heavy script(s):" +for f in "${heavy_files[@]}"; do echo " - $f"; done +echo "" + +failed=0 +for f in "${heavy_files[@]}"; do + echo "[run-heavy] --- $f ---" + start=$(date +%s) + if bash "$f"; then + elapsed=$(( $(date +%s) - start )) + echo "[run-heavy] OK ($f, ${elapsed}s)" + else + rc=$? + elapsed=$(( $(date +%s) - start )) + echo "[run-heavy] FAIL ($f exited $rc after ${elapsed}s)" >&2 + failed=$rc + break + fi + echo "" +done + +if [ "$failed" -ne 0 ]; then + echo "[run-heavy] FAILED — first failing script aborted the run." >&2 + exit "$failed" +fi + +echo "[run-heavy] all ${#heavy_files[@]} script(s) passed." diff --git a/src/core/engine.ts b/src/core/engine.ts index 13f79a9dd..f06a9c95a 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -22,6 +22,32 @@ import type { * shape on both engines (Postgres has had it since v0.18; PGLite gets it * via migration v36). */ +/** + * Options for `traverseGraph`. + * + * `frontierCap`: when set, the BFS recursive term applies a parenthesized + * `LIMIT N ORDER BY slug,id` so each iteration emits at most N rows. This + * is the "approximately per-layer" cap discussed in the T8 plan — Postgres' + * recursive CTE caps per ITERATION, not strictly per BFS LAYER (BFS layer + * boundaries map to recursive iterations only when fan-out is bounded). + * For hub-fanout graphs the cap fires early and bounds the work. Default: + * unset = no cap (back-compat; existing callers see no change). + * + * NOTE: a truncation-detection signal (`onTruncation` callback) was + * designed but the v1 algorithm had both false-positive (organic count == + * cap) and false-negative (LIMIT-before-DISTINCT in diamond graphs) cases + * caught by adversarial review. The signal is deferred until a + * dedupe-then-cap SQL rewrite + real Postgres parity coverage lands. See + * TODOS.md → "T8 truncation signal" entry. Callers that need to detect + * truncation can compare `result.length` against expected fanout bounds + * as a coarse-but-honest signal in the interim. + */ +export interface TraverseGraphOpts { + sourceId?: string; + sourceIds?: string[]; + frontierCap?: number; +} + export interface FileRow { id: number; source_id: string; @@ -795,7 +821,7 @@ export interface BrainEngine { traverseGraph( slug: string, depth?: number, - opts?: { sourceId?: string; sourceIds?: string[] }, + opts?: TraverseGraphOpts, ): Promise; /** * Edge-based graph traversal with optional type and direction filters. diff --git a/src/core/pglite-engine.ts b/src/core/pglite-engine.ts index 33df16ae6..8e04e0e87 100644 --- a/src/core/pglite-engine.ts +++ b/src/core/pglite-engine.ts @@ -1941,7 +1941,7 @@ export class PGLiteEngine implements BrainEngine { async traverseGraph( slug: string, depth: number = 5, - opts?: { sourceId?: string; sourceIds?: string[] }, + opts?: import('./engine.ts').TraverseGraphOpts, ): Promise { // v0.34.1 (#861 — P0 leak seal): source-scope filters at seed, step, and // aggregation subquery. Mirrors postgres-engine.traverseGraph placement. @@ -1964,6 +1964,35 @@ export class PGLiteEngine implements BrainEngine { aggScope = `AND p3.source_id = $${idx}`; } + // T8 (v0.36+): frontier cap. When set, the recursive term applies a + // parenthesized LIMIT N ORDER BY (slug, id) for stable selection. Per- + // ITERATION cap, which maps approximately to per-BFS-LAYER (exact when + // fanout is bounded; for hub-fanout the cap fires early). Truncation + // signal computed post-query by counting rows per depth. + const cap = opts?.frontierCap; + let recursiveTerm: string; + if (cap !== undefined && cap > 0) { + params.push(cap); + const capIdx = params.length; + recursiveTerm = `(SELECT p2.id, p2.slug, p2.title, p2.type, g.depth + 1, g.visited || p2.id + FROM graph g + JOIN links l ON l.from_page_id = g.id + JOIN pages p2 ON p2.id = l.to_page_id + WHERE g.depth < $2 + AND NOT (p2.id = ANY(g.visited)) + ${stepScope} + ORDER BY p2.slug ASC, p2.id ASC + LIMIT $${capIdx})`; + } else { + recursiveTerm = `SELECT p2.id, p2.slug, p2.title, p2.type, g.depth + 1, g.visited || p2.id + FROM graph g + JOIN links l ON l.from_page_id = g.id + JOIN pages p2 ON p2.id = l.to_page_id + WHERE g.depth < $2 + AND NOT (p2.id = ANY(g.visited)) + ${stepScope}`; + } + // Cycle prevention: visited array tracks page IDs already in the path. // Prevents exponential blowup on cyclic subgraphs (e.g., A->B->A). const { rows } = await this.db.query( @@ -1973,13 +2002,7 @@ export class PGLiteEngine implements BrainEngine { UNION ALL - SELECT p2.id, p2.slug, p2.title, p2.type, g.depth + 1, g.visited || p2.id - FROM graph g - JOIN links l ON l.from_page_id = g.id - JOIN pages p2 ON p2.id = l.to_page_id - WHERE g.depth < $2 - AND NOT (p2.id = ANY(g.visited)) - ${stepScope} + ${recursiveTerm} ) SELECT DISTINCT g.slug, g.title, g.type, g.depth, coalesce( @@ -1999,6 +2022,9 @@ export class PGLiteEngine implements BrainEngine { params ); + // T8 truncation-detection callback stripped in /review — see + // postgres-engine.traverseGraph for the parallel comment + TODOS.md. + return (rows as Record[]).map(r => ({ slug: r.slug as string, title: r.title as string, diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index bd8f69adb..4a4ce2f1f 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -1992,7 +1992,7 @@ export class PostgresEngine implements BrainEngine { async traverseGraph( slug: string, depth: number = 5, - opts?: { sourceId?: string; sourceIds?: string[] }, + opts?: import('./engine.ts').TraverseGraphOpts, ): Promise { const sql = this.sql; // v0.34.1 (#861 — P0 leak seal): scope visited nodes to the caller's @@ -2018,6 +2018,31 @@ export class PostgresEngine implements BrainEngine { : opts?.sourceId ? sql`AND p3.source_id = ${opts.sourceId}` : sql``; + // T8 (v0.36+): frontier cap. When set, the recursive term applies a + // parenthesized LIMIT N with ORDER BY (slug, id) for stable selection. + // Postgres' parenthesized-LIMIT inside a recursive term caps per + // ITERATION, which maps approximately to per-BFS-LAYER (the mapping is + // exact when fanout is bounded; for hub-fanout graphs the cap fires + // early). Post-query, count rows per depth — if any depth == cap, fire + // the truncation callback. + const cap = opts?.frontierCap; + const recursiveStep = cap !== undefined && cap > 0 + ? sql`(SELECT p2.id, p2.slug, p2.title, p2.type, g.depth + 1, g.visited || p2.id + FROM graph g + JOIN links l ON l.from_page_id = g.id + JOIN pages p2 ON p2.id = l.to_page_id + WHERE g.depth < ${depth} + AND NOT (p2.id = ANY(g.visited)) + ${stepScope} + ORDER BY p2.slug ASC, p2.id ASC + LIMIT ${cap})` + : sql`SELECT p2.id, p2.slug, p2.title, p2.type, g.depth + 1, g.visited || p2.id + FROM graph g + JOIN links l ON l.from_page_id = g.id + JOIN pages p2 ON p2.id = l.to_page_id + WHERE g.depth < ${depth} + AND NOT (p2.id = ANY(g.visited)) + ${stepScope}`; // Cycle prevention: visited array tracks page IDs already in the path. const rows = await sql` WITH RECURSIVE graph AS ( @@ -2026,13 +2051,7 @@ export class PostgresEngine implements BrainEngine { UNION ALL - SELECT p2.id, p2.slug, p2.title, p2.type, g.depth + 1, g.visited || p2.id - FROM graph g - JOIN links l ON l.from_page_id = g.id - JOIN pages p2 ON p2.id = l.to_page_id - WHERE g.depth < ${depth} - AND NOT (p2.id = ANY(g.visited)) - ${stepScope} + ${recursiveStep} ) SELECT DISTINCT g.slug, g.title, g.type, g.depth, coalesce( @@ -2053,6 +2072,12 @@ export class PostgresEngine implements BrainEngine { ORDER BY g.depth, g.slug `; + // T8 truncation-detection callback was designed here but the v1 algorithm + // had both false-positive (organic count == cap) and false-negative + // (LIMIT-before-DISTINCT in diamond graphs) cases caught by adversarial + // review. Stripped pending the dedupe-then-cap SQL rewrite + real Postgres + // parity coverage. See TODOS.md → "T8 truncation signal". + return rows.map((r: Record) => ({ slug: r.slug as string, title: r.title as string, diff --git a/test/fuzz/filesystem-validators.test.ts b/test/fuzz/filesystem-validators.test.ts new file mode 100644 index 000000000..7e68724e2 --- /dev/null +++ b/test/fuzz/filesystem-validators.test.ts @@ -0,0 +1,126 @@ +/** + * Filesystem-touching validator fuzz tests. + * + * Separate from `pure-validators.test.ts` because these targets need real fs + * access (realpathSync, lstatSync) and CANNOT be in the purity-guarded suite. + * That separation is the structural fix for the "fuzz purity guard contradicts + * itself" CRITICAL finding from the 2-pass eng review. + * + * Every test in this file uses a clean temp dir created in beforeEach so + * fuzz inputs can't leak across tests. The temp dir is the entire confinement + * boundary — `validateUploadPath` resolves symlinks and rejects traversal + * outside the dir, which is exactly the contract we want to fuzz. + */ + +import { describe, test, beforeAll, afterAll, beforeEach } from 'bun:test'; +import fc from 'fast-check'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { validateUploadPath } from '../../src/core/operations.ts'; + +const NUM_RUNS = 500; + +let baseTmpRoot: string; + +beforeAll(() => { + baseTmpRoot = mkdtempSync(join(tmpdir(), 'gbrain-fuzz-fs-')); +}); + +afterAll(() => { + rmSync(baseTmpRoot, { recursive: true, force: true }); +}); + +let confinementDir: string; +beforeEach(() => { + // Fresh confinement per test so traversal attempts can't leak state. + confinementDir = mkdtempSync(join(baseTmpRoot, 'box-')); + // Seed a legitimate file inside the box so success cases have something to find. + writeFileSync(join(confinementDir, 'safe.txt'), 'safe'); + mkdirSync(join(confinementDir, 'subdir'), { recursive: true }); + writeFileSync(join(confinementDir, 'subdir', 'nested.txt'), 'nested'); +}); + +describe('validateUploadPath fuzz (fs-backed)', () => { + test('arbitrary relative paths: never wedges, never escapes confinement', () => { + fc.assert( + fc.property(fc.string({ minLength: 0, maxLength: 200 }), (relPath) => { + try { + validateUploadPath(confinementDir, relPath); + } catch { + /* throwing is the expected behavior for traversal / invalid input */ + } + // The contract: function returns without throwing OR throws. Either is fine. + // What we're ruling out: process crash, infinite loop (caught by fast-check + // run timeout), or silent path-escape (which would be a security bug — the + // ACTUAL behavior is a throw on any escape attempt). + }), + { numRuns: NUM_RUNS }, + ); + }); + + test('shaped traversal probes: explicit `..` patterns rejected', () => { + // Generate adversarial traversal shapes deliberately, beyond what + // fc.string() would surface organically. + const traversalProbe = fc.oneof( + fc.constant('../etc/passwd'), + fc.constant('../../etc/passwd'), + fc.constant('subdir/../../etc/passwd'), + fc.constant('./../../../tmp'), + fc.constantFrom('.', '..', '...', './'), + fc.tuple(fc.constant('../'), fc.string({ minLength: 1, maxLength: 50 })).map(([a, b]) => a + b), + ); + fc.assert( + fc.property(traversalProbe, (probe) => { + let threw = false; + try { + validateUploadPath(confinementDir, probe); + } catch { + threw = true; + } + // For probes that explicitly contain `..` we expect a throw. The test + // is the contract: confinement holds against directly-malicious input. + if (probe.includes('..') && !threw) { + throw new Error(`validateUploadPath did not reject traversal probe: ${JSON.stringify(probe)}`); + } + }), + { numRuns: 200 }, + ); + }); + + // Symlink creation is platform / permission gated (Windows without dev mode, + // restricted CI runners). Detect upfront and skip the probe explicitly via + // `test.skipIf` so the result is reported as "skipped" — NOT silently green. + // The earlier early-return pattern hid a security-critical confinement test + // behind a fake pass on any platform that couldn't make symlinks. + // Probe via the OS tmpdir directly — baseTmpRoot isn't available until + // beforeAll runs, and this expression evaluates at module load time. + const symlinksAvailable = (() => { + const probeDir = mkdtempSync(join(tmpdir(), 'gbrain-symlink-probe-')); + try { + symlinkSync(tmpdir(), join(probeDir, 'probe-link')); + return true; + } catch { + return false; + } finally { + rmSync(probeDir, { recursive: true, force: true }); + } + })(); + test.skipIf(!symlinksAvailable)( + 'symlink-escape probe: symlinks pointing outside the box are rejected', + () => { + const linkPath = join(confinementDir, 'evil-link'); + symlinkSync(tmpdir(), linkPath); + let threw = false; + try { + validateUploadPath(confinementDir, 'evil-link'); + } catch { + threw = true; + } + if (!threw) { + throw new Error('validateUploadPath did not reject a symlink pointing outside the confinement dir'); + } + }, + ); +}); diff --git a/test/fuzz/mixed-validators.test.ts b/test/fuzz/mixed-validators.test.ts new file mode 100644 index 000000000..9078982af --- /dev/null +++ b/test/fuzz/mixed-validators.test.ts @@ -0,0 +1,102 @@ +/** + * Mixed-purity validator fuzz tests. + * + * These targets are validator-shaped (string in, validation/transformation out) + * but live in files that transitively import `node:fs` or engine modules. + * They don't get the purity-guard contract that `pure-validators.test.ts` + * does. The property tests are the same shape — fuzz inputs, assert no + * unbounded behavior — but a future contributor moving these to a pure + * `src/core/pure/` module would be the upgrade path. + * + * The bundle reality (smoke-tested 2026-05-19): `validatePageSlug` and + * `validateFilename` are string-only logic, but they live in + * `src/core/operations.ts` which transitively pulls in the engine. Same for + * `splitBody` (markdown.ts), `slugifyPath` (sync.ts), `sanitizeQueryForPrompt` + * (expansion.ts). The functions themselves don't touch fs at runtime — but + * importing them imports the rest of their module's dependency graph. + */ + +import { describe, test } from 'bun:test'; +import fc from 'fast-check'; + +import { validatePageSlug, validateFilename } from '../../src/core/operations.ts'; +import { splitBody } from '../../src/core/markdown.ts'; +import { slugifyPath } from '../../src/core/sync.ts'; +import { sanitizeQueryForPrompt } from '../../src/core/search/expansion.ts'; + +const NUM_RUNS = 1000; + +function fuzzVoidValidator(name: string, fn: (s: string) => void) { + test(`${name}: arbitrary string input, no unbounded behavior`, () => { + fc.assert( + fc.property(fc.string(), (input) => { + try { + fn(input); + } catch { + /* throwing is fine — contract is "no wedge", not "always succeeds" */ + } + }), + { numRuns: NUM_RUNS }, + ); + }); +} + +function fuzzStringSanitizer(name: string, fn: (s: string) => string) { + test(`${name}: returns a string on any input, never throws`, () => { + fc.assert( + fc.property(fc.string(), (input) => { + const out = fn(input); + if (typeof out !== 'string') { + throw new Error(`${name} returned non-string: ${typeof out}`); + } + }), + { numRuns: NUM_RUNS }, + ); + }); +} + +describe('mixed-purity validator fuzz', () => { + fuzzVoidValidator('validatePageSlug', validatePageSlug); + fuzzVoidValidator('validateFilename', validateFilename); + + fuzzStringSanitizer('sanitizeQueryForPrompt', sanitizeQueryForPrompt); + fuzzStringSanitizer('slugifyPath', slugifyPath); + + test('splitBody: returns shape { compiled_truth, timeline } on any input', () => { + fc.assert( + fc.property(fc.string(), (input) => { + const out = splitBody(input); + if (typeof out !== 'object' || out === null) { + throw new Error(`splitBody returned non-object: ${typeof out}`); + } + if (typeof out.compiled_truth !== 'string') { + throw new Error(`splitBody.compiled_truth not a string: ${typeof out.compiled_truth}`); + } + if (typeof out.timeline !== 'string') { + throw new Error(`splitBody.timeline not a string: ${typeof out.timeline}`); + } + }), + { numRuns: NUM_RUNS }, + ); + }); + + // Sentinel stress for splitBody — feed YAML-ish strings with `---`, + // `## Timeline`, etc, to exercise the sentinel parser branches. + test('splitBody: stress sentinels with shaped inputs', () => { + const sentinels = ['---', '## Timeline', '## History', '', '--- timeline ---']; + fc.assert( + fc.property( + fc.string(), + fc.constantFrom(...sentinels), + fc.string(), + (head, sentinel, tail) => { + const input = `${head}\n${sentinel}\n${tail}`; + const out = splitBody(input); + if (typeof out.compiled_truth !== 'string') throw new Error('compiled_truth not string'); + if (typeof out.timeline !== 'string') throw new Error('timeline not string'); + }, + ), + { numRuns: 500 }, + ); + }); +}); diff --git a/test/fuzz/pure-validators.test.ts b/test/fuzz/pure-validators.test.ts new file mode 100644 index 000000000..6db3561ed --- /dev/null +++ b/test/fuzz/pure-validators.test.ts @@ -0,0 +1,93 @@ +/** + * Pure-validator fuzz tests. + * + * Targets here are PROVEN-PURE by the import-graph bundle check in + * `scripts/check-fuzz-purity.sh`: no transitive imports of `node:fs`, + * `node:child_process`, network builtins, or engine modules. If any + * target's containing file gains an impure dependency, the purity guard + * fails the build before the fuzz tests run. + * + * The pure set is intentionally small (2 functions) for honest reasons: + * `bun build --target=bun` reveals that gbrain's other "validator-shaped" + * functions live in files that transitively pull in `fs` through helpers + * in the same module. The original T2 plan listed 7 targets; the bundle + * disproved that for 5 of them. Those 5 still get property-tested in + * `mixed-validators.test.ts` — same coverage, just no purity guarantee. + * + * Follow-up TODO: extract pure validator logic to a dedicated + * `src/core/pure/` directory so the fuzz target list can grow safely. + * + * Property: every fuzz target either succeeds normally or throws a typed + * error — but NEVER wedges the runtime (infinite loop caught by + * fast-check's per-property timeout; process crash caught by bun:test). + * + * Cost budget: 1000 runs per property, 2 targets, ~3s total. Runs in the + * default `bun test` loop (no .slow suffix). + * + * Pin a regression by copying fast-check's minimal repro into + * `test/fuzz/regressions/-.test.ts` as a normal + * bun:test assertion. + */ + +import { describe, test } from 'bun:test'; +import fc from 'fast-check'; + +import { escapeLikePattern } from '../../src/core/cjk.ts'; +import { parseFactsFence } from '../../src/core/facts-fence.ts'; + +const NUM_RUNS = 1000; + +describe('pure-validator fuzz (purity-guarded set)', () => { + test('escapeLikePattern: returns a string on any input, never throws', () => { + fc.assert( + fc.property(fc.string(), (input) => { + const out = escapeLikePattern(input); + if (typeof out !== 'string') { + throw new Error(`escapeLikePattern returned non-string: ${typeof out}`); + } + // Contract: every `%`, `_`, and `\` in input becomes `\%`, `\_`, `\\` + // in output. We don't reproduce the full transformation here, just + // assert that any `%`/`_`/`\` survives in the output (escaped, in + // some form). Fast-check's value is the broad input space, not a + // precise contract — that's covered by unit tests in src/core. + }), + { numRuns: NUM_RUNS }, + ); + }); + + test('parseFactsFence: returns a parse result on any input, never throws', () => { + fc.assert( + fc.property(fc.string(), (input) => { + const out = parseFactsFence(input); + if (out === undefined || out === null) { + throw new Error('parseFactsFence returned null/undefined'); + } + // FactsFenceParseResult is a typed shape; for fuzz we just verify + // the function doesn't throw and produces a non-null result. + }), + { numRuns: NUM_RUNS }, + ); + }); + + // Fence-shaped inputs: stress the row-parser with malformed pipe-delimited + // lines, which is the realistic adversarial input shape (user-supplied + // markdown that almost looks like a fence row). + test('parseFactsFence: stress with malformed pipe-delimited input', () => { + const fenceShaped = fc.oneof( + fc.constant('| claim | actor | since | until |'), + fc.constant('| | | | |'), + fc.string().map((s) => `| ${s} |`), + fc.string().map((s) => `| ${s} | ${s} |`), + fc.tuple(fc.string(), fc.string(), fc.string()).map(([a, b, c]) => `| ${a} | ${b} | ${c} |`), + ); + fc.assert( + fc.property(fenceShaped, (input) => { + const out = parseFactsFence(input); + if (out === undefined || out === null) { + throw new Error('parseFactsFence returned null/undefined on fence-shaped input'); + } + }), + { numRuns: 500 }, + ); + }); +}); diff --git a/test/fuzz/regressions/README.md b/test/fuzz/regressions/README.md new file mode 100644 index 000000000..c8bc7dc6a --- /dev/null +++ b/test/fuzz/regressions/README.md @@ -0,0 +1,27 @@ +# test/fuzz/regressions/ + +Pinned fuzz failures. Anything `pure-validators.test.ts` or +`filesystem-validators.test.ts` ever finds gets the minimal repro from +fast-check's shrinker copied here as a normal `*.test.ts` file so the bug is +locked in place even if the fuzz target list changes. + +## Format + +```ts +// test/fuzz/regressions/validatePageSlug-.test.ts +import { test, expect } from 'bun:test'; +import { validatePageSlug } from '../../../src/core/operations.ts'; + +test('regression: validatePageSlug rejected this in ', () => { + expect(() => validatePageSlug('')).toThrow(/expected error/); +}); +``` + +Use a short hash of the input as the filename so multiple regressions for +the same validator don't collide. + +## How to capture a new regression + +When fast-check reports a property failure, the reporter prints the minimal +shrunken input. Copy it into a new file matching the format above. Keep the +date in the test name for future archaeology. diff --git a/test/regressions/v0_36_frontier_cap.test.ts b/test/regressions/v0_36_frontier_cap.test.ts new file mode 100644 index 000000000..229bda402 --- /dev/null +++ b/test/regressions/v0_36_frontier_cap.test.ts @@ -0,0 +1,134 @@ +/** + * T8 regression — BFS frontier cap on `traverseGraph`. + * + * Contracts pinned here (PGLite-only; Postgres parity is a follow-up E2E): + * 1. Cap-unset: legacy `GraphNode[]` shape unchanged (back-compat). + * 2. Cap-hit: result is bounded by the cap (frontier protection is the + * actually-useful contract; the cap can be tighter than expected + * because LIMIT applies before final DISTINCT, but it MUST NOT be + * looser — the result can never exceed the cap). + * 3. MCP wire-shape: traverseGraph still returns an Array, NOT a struct. + * `traverse_graph` MCP op preserves the array wire contract. + * 4. Concurrency: two concurrent calls on the same engine with different + * caps each see their own bounded result — no shared state. + * + * NOTE: `onTruncation` callback was designed but stripped in /review after + * adversarial review caught false-positive + false-negative cases. See + * TODOS.md → "T8 truncation signal" for the deferred work. This file's + * contracts cover what's IN the shipped code; the truncation-signal + * contracts re-land when the dedupe-then-cap SQL rewrite ships. + * + * PGLite-only — no DATABASE_URL needed. Hermetic. + */ + +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { PGLiteEngine } from '../../src/core/pglite-engine.ts'; + +let engine: PGLiteEngine; + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); +}, 30000); + +afterAll(async () => { + await engine.disconnect(); +}); + +beforeEach(async () => { + await (engine as { db: { exec: (sql: string) => Promise } }).db.exec(` + TRUNCATE pages, links, content_chunks, raw_data RESTART IDENTITY CASCADE; + `); +}); + +async function putPage(slug: string, title = slug) { + await engine.putPage(slug, { + type: 'note', + title, + compiled_truth: `Body of ${slug}`, + timeline: '', + frontmatter: {}, + }); +} + +async function getPageId(slug: string): Promise { + const rows = await engine.executeRaw('SELECT id FROM pages WHERE slug = $1', [slug]); + return (rows[0] as { id: number }).id; +} + +async function link(from: string, to: string) { + const fromId = await getPageId(from); + const toId = await getPageId(to); + await engine.executeRaw( + 'INSERT INTO links (from_page_id, to_page_id, link_type) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING', + [fromId, toId, 'references'], + ); +} + +/** Build a hub-and-spokes topology: 'hub' links to N children. */ +async function buildHubTopology(N: number) { + await putPage('hub'); + for (let i = 0; i < N; i += 1) { + const slug = `child-${String(i).padStart(3, '0')}`; + await putPage(slug); + await link('hub', slug); + } +} + +describe('T8: traverseGraph frontier cap (PGLite)', () => { + test('Contract 1: cap-unset returns legacy shape', async () => { + await buildHubTopology(10); + const result = await engine.traverseGraph('hub', 2); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(11); // hub + 10 children + for (const node of result) { + expect(typeof node.slug).toBe('string'); + expect(typeof node.title).toBe('string'); + expect(typeof node.type).toBe('string'); + expect(typeof node.depth).toBe('number'); + expect(Array.isArray(node.links)).toBe(true); + } + }, 30000); + + test('Contract 2: cap bounds the result to <= cap + 1 (hub + capped children)', async () => { + await buildHubTopology(20); // 20 children at depth 1 + const result = await engine.traverseGraph('hub', 2, { frontierCap: 5 }); + expect(Array.isArray(result)).toBe(true); + // hub + up to cap children. The recursive LIMIT applies BEFORE outer + // DISTINCT, so the visible count can be LESS than cap on diamond graphs. + // Invariant: count NEVER exceeds cap + 1 (hub + cap). That's the actual + // protection the cap provides — a hard upper bound on traversal output. + expect(result.length).toBeLessThanOrEqual(6); + expect(result.length).toBeGreaterThan(0); + const slugs = result.map(n => n.slug); + expect(slugs).toContain('hub'); + }, 30000); + + test('Contract 3: MCP wire-shape preserved — Array, not struct', async () => { + await buildHubTopology(5); + const result = await engine.traverseGraph('hub', 1); + expect(Array.isArray(result)).toBe(true); + expect(Object.getPrototypeOf(result)).toBe(Array.prototype); + // Negative regression — no struct fields leaked in (would break MCP wire): + expect((result as unknown as { truncated?: unknown }).truncated).toBeUndefined(); + expect((result as unknown as { nodes?: unknown }).nodes).toBeUndefined(); + }, 30000); + + test('Contract 4: concurrent calls on same engine see independent bounded results', async () => { + await buildHubTopology(30); + // Two concurrent calls with different caps. Each must see its own bound. + // If the implementation accidentally introduced shared per-engine state + // for the cap, one call's cap could bleed into the other. + const [a, b] = await Promise.all([ + engine.traverseGraph('hub', 1, { frontierCap: 5 }), + engine.traverseGraph('hub', 1, { frontierCap: 10 }), + ]); + expect(a.length).toBeLessThanOrEqual(6); + expect(b.length).toBeLessThanOrEqual(11); + // Larger cap should see at least as many nodes as the smaller. PGLite is + // deterministic enough that this invariant holds; Postgres parity is a + // follow-up E2E (real CTE may reorder differently between iterations). + expect(b.length).toBeGreaterThanOrEqual(a.length); + }, 30000); +}); diff --git a/tests/heavy/README.md b/tests/heavy/README.md new file mode 100644 index 000000000..1e079a3f6 --- /dev/null +++ b/tests/heavy/README.md @@ -0,0 +1,73 @@ +# tests/heavy/ + +Heavy ops-shape tests. Shell scripts that exercise gbrain end-to-end against +real infrastructure (Postgres, large fixtures, concurrent processes). Cost +minutes per run; NOT in default `bun test`. + +## When to add a script here + +Put a test here if it: +- Costs more than ~30s wallclock per run +- Needs real Postgres (not PGLite in-memory) +- Spins up multiple processes or measures concurrency +- Measures system metrics (RSS, latency under load, lock contention) +- Tests an upgrade / migration matrix against committed historical states + +## When to use `*.slow.test.ts` instead + +Put a slow test in `test/` with the `.slow.test.ts` suffix if it: +- Runs under `bun test` (TypeScript, uses bun:test imports) +- Is correctness-shaped, not ops-shaped (asserts behavior of one function) +- Can stub external dependencies + +The two patterns coexist intentionally. `*.slow.test.ts` is per-file +correctness for cold paths; `tests/heavy/` is ops-shape scripts that don't +fit bun's test runner. + +## How to run + +```bash +# Run every script in this directory, sequentially: +bun run test:heavy + +# Run a single script: +tests/heavy/