From 945fed61055ffbbf65d2d420cd9b9508452eacb0 Mon Sep 17 00:00:00 2001 From: daragao3 Date: Wed, 29 Jul 2026 19:08:08 -0400 Subject: [PATCH] fix(engine): enforce static engine-live import boundaries (#3596) * docs: design engine dynamic-import reconciliation Co-Authored-By: Claude * fix(engine): reconcile dynamic import hardening Co-Authored-By: Claude * test(engine): guard dynamic import policy * docs: plan engine dynamic-import reconciliation Record the approved TDD sequence for selective engine-path hardening, repository guard wiring, documentation, and local verification. Preserve the no-version-bump and no-publication boundaries for the remaining work. Co-Authored-By: Claude * docs(engine): record static import invariant * fix(engine): parse block comments in import guard Co-Authored-By: Claude * fix(engine): parse dynamic imports with TypeScript Co-Authored-By: Claude * fix(engine): close import guard bypasses Co-Authored-By: Claude * fix(engine): close parser guard edge cases Co-Authored-By: Claude * fix(engine): aggregate parser diagnostics Co-Authored-By: Claude * fix(engine): bound dynamic import marker directive Require the line-level opt-out marker to be standalone inside real comment trivia so negated or incidental longer tokens cannot authorize an import. Preserve the existing general marked-line contract and pin it with focused regression coverage. Co-Authored-By: Claude * fix(engine): close Unicode marker boundary bypasses Treat Unicode identifier continuations as marker-token characters and inspect adjacent text by code point so supplementary-plane characters cannot turn longer comment tokens into approvals.\n\nCo-Authored-By: Claude --------- Co-authored-by: Claude --- CLAUDE.md | 13 + docs/architecture/KEY_FILES.md | 6 +- ...28-engine-dynamic-import-reconciliation.md | 690 ++++++++++++++++++ ...ne-dynamic-import-reconciliation-design.md | 142 ++++ llms-full.txt | 13 + package.json | 3 +- scripts/check-engine-dynamic-import.sh | 31 + scripts/check-engine-dynamic-import.ts | 80 ++ scripts/run-verify-parallel.sh | 1 + src/core/migrate.ts | 9 +- src/core/pglite-engine.ts | 32 +- src/core/postgres-engine.ts | 43 +- .../check-engine-dynamic-import.test.ts | 330 +++++++++ 13 files changed, 1369 insertions(+), 24 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-28-engine-dynamic-import-reconciliation.md create mode 100644 docs/superpowers/specs/2026-07-28-engine-dynamic-import-reconciliation-design.md create mode 100644 scripts/check-engine-dynamic-import.sh create mode 100644 scripts/check-engine-dynamic-import.ts create mode 100644 test/scripts/check-engine-dynamic-import.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index c23e645bc..e57f5214d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,19 @@ Per-file detail is in `docs/architecture/KEY_FILES.md`. text, the cast parses it). Guarded by `scripts/check-jsonb-pattern.sh` (template grep) + `scripts/check-jsonb-params.mjs` (positional AST scanner); the real backstop is the DATABASE_URL-gated e2e parity tests, since PGLite can't surface the bug. Full rule in `docs/ENGINES.md`. +- **Engine-live paths avoid runtime dynamic `import()` for helper dependencies.** In + `src/core/pglite-engine.ts`, `src/core/postgres-engine.ts`, and + `src/core/migrate.ts`, dependencies previously reached through runtime dynamic + imports use static top-level imports. The only current dynamic-`import()` exceptions + are the four `ai/gateway.ts` lookups in both engines' + `initSchema()` and `_upsertChunksOnce()` methods; each remains lazy inside a + local `try/catch` because the gateway has a large provider/config closure and, + more importantly, eager evaluation would occur before the catch and could + turn a recoverable default/config-row fallback into a module-load failure. + Every exception carries `engine-dynamic-import-ok` on the import line. + `scripts/check-engine-dynamic-import.sh` enforces the rule. For history, use + `git log -G'await[[:space:]]+import\\('`, not `git log -S`: a dynamic-to-static + rewrite can preserve the searched token while changing its context. - **Engine parity.** `src/core/postgres-engine.ts` and `src/core/pglite-engine.ts` move in lockstep — a new method/SQL shape lands in BOTH, pinned by `test/e2e/engine-parity.test.ts`. Forward-referenced columns/indexes go in the bootstrap probe set (guarded by diff --git a/docs/architecture/KEY_FILES.md b/docs/architecture/KEY_FILES.md index e9c1f4552..37d5de938 100644 --- a/docs/architecture/KEY_FILES.md +++ b/docs/architecture/KEY_FILES.md @@ -31,9 +31,9 @@ per-release `**vX.Y.Z:**` narration — CI enforces this - `src/commands/search.ts:gbrain search stats` extension — `graph_signals` section (enabled/source/failures_count/failures_by_reason). JSON envelope adds a `graph_signals` sibling property; `_meta.metric_glossary` adds `graph_signals.enabled` + `graph_signals.failures_by_reason`. Human output prints the section after the existing block. Reads `search.graph_signals` config first, falls back to the mode default. Pinned by `test/search/search-stats-graph-signals.test.ts`. - `src/commands/doctor.ts` extension — `graph_signals_coverage` check wired into both `runDoctor` (local) and `doctorReportRemote` (HTTP/JSON thin-client path). Reads `search.graph_signals` config first, falls back to mode default; silent `ok` when disabled. Computes inbound link coverage on the page set; warns at <10% with `gbrain extract all` fix hint; `ok` at ≥30% ("fire on most queries") and 10-29% ("fire occasionally"), each with the percentage embedded. Pinned by cases in `test/doctor.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 BrainEngine methods. `listLinkSources({sourceId?, sourceIds?})` returns distinct `link_source` provenances + counts (`ORDER BY count DESC, link_source ASC NULLS LAST`; scalar + federated scoped; parity with postgres-engine.ts) powering `gbrain link-sources`. `addLinksBatch`/`addTimelineEntriesBatch`/`addTakesBatch` pass the whole batch as one JSONB document via `jsonb_to_recordset(($1::jsonb)->'rows')` (bound through `executeRawJsonb` with a `{ rows }` wrapper; rows built by the shared `src/core/batch-rows.ts` helpers, NUL-stripped), and are `batchRetry`-wrapped. `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error (macOS 26.3 WASM bug #223, points at `gbrain doctor`); the lock is released on failure so the next process can retry cleanly. `searchKeyword`/`searchKeywordChunks` multiply `ts_rank` by the source-factor CASE at chunk grain; `searchVector` is 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. `searchTakes`/`searchTakesVector` take full `SearchOpts` and apply the standard source-scope predicates (federated `sourceIds[]` wins over scalar `sourceId`, via the joined page's `source_id`) alongside the holder allow-list — parity SQL in postgres-engine.ts; pinned by `test/e2e/think-source-isolation-pglite.test.ts`. `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL — probes for forward-referenced state the embedded blob needs (`pages.source_id`, `links.link_source`, `links.origin_page_id`, `content_chunks.symbol_name`, `content_chunks.language`, `sources` FK target, plus `files.source_id`, `files.page_id`, `oauth_clients.source_id`, `oauth_clients.federated_read`, `sources.archived`, `sources.archived_at`, `sources.archive_expires_at`, `timeline_entries.event_page_id` — column-only, migration v121 stays the source of truth for its FK + indexes) and adds only what's missing; threads the DDL connection from `initSchema` so probes run inside the advisory-lock scope; no-op on fresh installs and modern brains (closes the upgrade-wedge bug class #239/#243/#266/#357/#366/#374/#375/#378/#395/#396/#1018/#974/#820). `getBrainScore` returns 100/100 with full breakdown (35/25/15/15/10) when `pageCount === 0` (vacuous truth — empty brain has no coverage problem); Pinned by `test/brain-score-breakdown.test.ts` empty-brain assertion + `test/doctor-report-remote.serial.test.ts`. `disconnect()` uses snapshot+early-null (snapshot `_db`/`_lock`, null instance fields BEFORE any `await` so a concurrent `connect()` can't see a partial mid-close state) wrapped in try/finally guaranteeing lock-release even if `db.close()` throws; KEEPS close-then-release order (release-then-close was rejected: it would widen the window where a sibling process connects to a still-closing brain); Pinned by `test/pglite-engine-disconnect.serial.test.ts`. `PGlite.create()` runs inside `preservingProcessExitCode` (#2084): PGLite's Emscripten runtime writes its own status into `process.exitCode` (99 at create; in-memory brains run initdb whose status lands on a later tick; the exit status at close — and assigning `undefined` cannot clear a prior value), which would otherwise silently clobber an errored op's exit 1 back to 0. The wrapper keeps the global tidy for external readers; `db.close()` stays unwrapped (its 0-write is baseline behavior test runners depend on). The CLI's exit verdict is immune either way — it lives in the gbrain-owned channel in `cli-force-exit.ts` and never reads `process.exitCode` back. Exports `classifyPgliteInitError(message): 'bunfs' | 'macos-26-3' | 'unknown'` + `buildPgliteInitErrorMessage(verdict, original)` routing the catch-block hint by failure shape (`bunfs` matches literal `$$bunfs` OR `ENOENT[\s\S]*pglite\.data` co-occurrence, surfaces a paste-ready `bun upgrade` + Node fallback; `macos-26-3` keeps the #223 link; `unknown` falls through); Pinned by `test/pglite-init-classifier.test.ts`. Implements `deletePages(slugs, {sourceId})` + `resolveSlugsByPaths(paths, {sourceId})` via `slug = ANY($1::text[])` array-param binding, caller-chunking primitive throwing when input exceeds `DELETE_BATCH_SIZE`, `deletePages` returns `RETURNING slug` rows so callers filter `pagesAffected` to confirmed deletes. Implements the embedding-signature stale-detection quartet — `sumStaleChunkChars({sourceId?, signature?})`, `setPageEmbeddingSignature(slug, {sourceId?, signature})`, `invalidateStaleSignatureEmbeddings({signature, sourceId?})`, widened `countStaleChunks({sourceId?, signature?})` (the `signature` opt widens via `JOIN pages p ... WHERE cc.embedding IS NULL OR (p.embedding_signature IS NOT NULL AND p.embedding_signature <> $signature)`, NULL grandfathered); parity SQL with postgres-engine.ts. PGLite-specific DDL (pgvector, pg_trgm, triggers). +- `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all BrainEngine methods. `listLinkSources({sourceId?, sourceIds?})` returns distinct `link_source` provenances + counts (`ORDER BY count DESC, link_source ASC NULLS LAST`; scalar + federated scoped; parity with postgres-engine.ts) powering `gbrain link-sources`. `addLinksBatch`/`addTimelineEntriesBatch`/`addTakesBatch` pass the whole batch as one JSONB document via `jsonb_to_recordset(($1::jsonb)->'rows')` (bound through `executeRawJsonb` with a `{ rows }` wrapper; rows built by the shared `src/core/batch-rows.ts` helpers, NUL-stripped), and are `batchRetry`-wrapped. `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error (macOS 26.3 WASM bug #223, points at `gbrain doctor`); the lock is released on failure so the next process can retry cleanly. `searchKeyword`/`searchKeywordChunks` multiply `ts_rank` by the source-factor CASE at chunk grain; `searchVector` is 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. `searchTakes`/`searchTakesVector` take full `SearchOpts` and apply the standard source-scope predicates (federated `sourceIds[]` wins over scalar `sourceId`, via the joined page's `source_id`) alongside the holder allow-list — parity SQL in postgres-engine.ts; pinned by `test/e2e/think-source-isolation-pglite.test.ts`. `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL — probes for forward-referenced state the embedded blob needs (`pages.source_id`, `links.link_source`, `links.origin_page_id`, `content_chunks.symbol_name`, `content_chunks.language`, `sources` FK target, plus `files.source_id`, `files.page_id`, `oauth_clients.source_id`, `oauth_clients.federated_read`, `sources.archived`, `sources.archived_at`, `sources.archive_expires_at`, `timeline_entries.event_page_id` — column-only, migration v121 stays the source of truth for its FK + indexes) and adds only what's missing; threads the DDL connection from `initSchema` so probes run inside the advisory-lock scope; no-op on fresh installs and modern brains (closes the upgrade-wedge bug class #239/#243/#266/#357/#366/#374/#375/#378/#395/#396/#1018/#974/#820). `getBrainScore` returns 100/100 with full breakdown (35/25/15/15/10) when `pageCount === 0` (vacuous truth — empty brain has no coverage problem); Pinned by `test/brain-score-breakdown.test.ts` empty-brain assertion + `test/doctor-report-remote.serial.test.ts`. `disconnect()` uses snapshot+early-null (snapshot `_db`/`_lock`, null instance fields BEFORE any `await` so a concurrent `connect()` can't see a partial mid-close state) wrapped in try/finally guaranteeing lock-release even if `db.close()` throws; KEEPS close-then-release order (release-then-close was rejected: it would widen the window where a sibling process connects to a still-closing brain); Pinned by `test/pglite-engine-disconnect.serial.test.ts`. `PGlite.create()` runs inside `preservingProcessExitCode` (#2084): PGLite's Emscripten runtime writes its own status into `process.exitCode` (99 at create; in-memory brains run initdb whose status lands on a later tick; the exit status at close — and assigning `undefined` cannot clear a prior value), which would otherwise silently clobber an errored op's exit 1 back to 0. The wrapper keeps the global tidy for external readers; `db.close()` stays unwrapped (its 0-write is baseline behavior test runners depend on). The CLI's exit verdict is immune either way — it lives in the gbrain-owned channel in `cli-force-exit.ts` and never reads `process.exitCode` back. Exports `classifyPgliteInitError(message): 'bunfs' | 'macos-26-3' | 'unknown'` + `buildPgliteInitErrorMessage(verdict, original)` routing the catch-block hint by failure shape (`bunfs` matches literal `$$bunfs` OR `ENOENT[\s\S]*pglite\.data` co-occurrence, surfaces a paste-ready `bun upgrade` + Node fallback; `macos-26-3` keeps the #223 link; `unknown` falls through); Pinned by `test/pglite-init-classifier.test.ts`. Implements `deletePages(slugs, {sourceId})` + `resolveSlugsByPaths(paths, {sourceId})` via `slug = ANY($1::text[])` array-param binding, caller-chunking primitive throwing when input exceeds `DELETE_BATCH_SIZE`, `deletePages` returns `RETURNING slug` rows so callers filter `pagesAffected` to confirmed deletes. Implements the embedding-signature stale-detection quartet — `sumStaleChunkChars({sourceId?, signature?})`, `setPageEmbeddingSignature(slug, {sourceId?, signature})`, `invalidateStaleSignatureEmbeddings({signature, sourceId?})`, widened `countStaleChunks({sourceId?, signature?})` (the `signature` opt widens via `JOIN pages p ... WHERE cc.embedding IS NULL OR (p.embedding_signature IS NOT NULL AND p.embedding_signature <> $signature)`, NULL grandfathered); parity SQL with postgres-engine.ts. PGLite-specific DDL (pgvector, pg_trgm, triggers). Engine-path helper dependencies (`retry`, ontology, recency decay) avoid dynamic `import()`; the only lazy dynamic imports are `ai/gateway.ts` in `initSchema` and `_upsertChunksOnce`, line-marked because their local catches preserve compiled-default and stored-config fallbacks that eager module evaluation would bypass. - `src/core/pglite-lock.ts` — advisory data-dir lock so two processes never open the same embedded PGLite (single-connection WASM Postgres) at once. Atomic `mkdir` of `.gbrain-lock/` + a `lock` file carrying `{pid, acquired_at, refreshed_at, command, subcommand}`. A held lock HEARTBEATS its `refreshed_at` every 30s (`.unref()`ed timer; informational). A waiting acquirer reaps a holder ONLY when its PID is dead — a LIVE holder is NEVER stolen, regardless of how stale its heartbeat is (#2348). A live `gbrain serve` holder is identified from the parsed `subcommand` and reported immediately with separate CLI-retry and MCP-tool choices; other live holders keep the bounded wait. The heartbeat runs on the JS event loop, which is blocked during long synchronous WASM imports/CHECKPOINTs, so a working `dream`/`embed` holder can look stale while alive; the old steal-on-stale-heartbeat grace let a second OS process open the same data dir and corrupt the catalog + pgvector extension (58P01 / `internal_load_library` / `type "vector" does not exist`), recoverable only by wipe+restore. A wedged-but-alive or PID-reused holder is never stolen: serve-tagged holders report immediately, while other holders time out with a message naming the PID. Each holder carries an ownership token (`:`); the heartbeat and `releaseLock` verify the on-disk lock is STILL theirs before touching it. In-memory engines take no lock. Pinned by `test/pglite-lock.test.ts`. A corrupted store surfaces a `reinit-pglite` recovery hint via `classifyPgliteInitError`'s `corrupt` verdict in `pglite-engine.ts`. -- `src/core/postgres-engine.ts` — Postgres + pgvector implementation (Supabase / self-hosted). `addLinksBatch`/`addTimelineEntriesBatch`/`addTakesBatch` pass the batch as one JSONB document — `INSERT ... SELECT FROM jsonb_to_recordset(($1::jsonb)->'rows') AS v(...) JOIN pages ...` bound through `executeRawJsonb({ rows })` — which encodes arbitrary free text safely (the old `unnest(${arr}::text[])` array-literal path crashed Postgres with "malformed array literal" on calendar/Zoom context, gbrain#1861) and sidesteps the 65535-parameter cap; takes declares native recordset column types (`page_id int, weight real, active boolean, …`) so no per-element casts; all three are `batchRetry`-wrapped. `searchKeyword`/`searchVector` scope `statement_timeout` via `sql.begin` + `SET LOCAL` so the GUC dies with the transaction instead of leaking across the pooled postgres.js connection. `getEmbeddingsByChunkIds` uses `tryParseEmbedding` so one corrupt row skips+warns instead of killing the query. `searchKeyword`/`searchKeywordChunks`/`searchVector` apply source-aware ranking by inlining the source-factor CASE and `NOT (col LIKE …)` hard-exclude from `src/core/search/sql-ranking.ts`; `searchVector` is a two-stage CTE (HNSW-safe inner ORDER BY, source-boost re-rank in outer SELECT) carrying `p.source_id` inner→outer. `_savedConfig` retains the connect config; `reconnect()` tears down + recreates the pool from saved config (called by supervisor watchdog after 3 consecutive health-check failures, and by `batchRetry` on a retryable connection error). Concurrent callers share one in-flight `_reconnectPromise` (they await the single reconnect rather than racing a half-rebuilt pool); ownership re-samples through the atomic `db.connect()` token on the connect leg. `reconnect(ctx?)` accepts the triggering error and records a pool-recovery audit event (`reap_detected`/`reconnect_other`/`reconnect_succeeded`/`reconnect_failed`) for the `pool_reap_health` doctor check. `executeRaw` is a single-statement passthrough — no per-call retry (unsound for non-idempotent statements; recovery is supervisor-driven). `connect()` applies `resolveSessionTimeouts()` from `db.ts` as connection-time startup parameters (`statement_timeout`, `idle_in_transaction_session_timeout`) so orphan pgbouncer backends can't hold locks for hours. `countStaleChunks()`+`listStaleChunks()` server-side-filter on `embedding IS NULL` for `embed --stale` (eliminates ~76 MB/call client-side pull); `upsertChunks()` resets both `embedding` AND `embedded_at` to NULL when chunk_text changes without a new embedding. `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL on the same probe set as PGLite (extended for column-only forward-reference cases: `files.source_id`, `files.page_id`, `oauth_clients.source_id`, `oauth_clients.federated_read`, `sources.archived`/`archived_at`/`archive_expires_at`, `timeline_entries.event_page_id`); the entire probe path runs on the DDL connection threaded from `initSchema` (closing a concurrent-bootstrap race for Supabase pooler users); closes #1018/#974/#820. `disconnect()` is idempotent — `_connectionStyle` tracks whether the engine owns its pool (worker engines) or shares the module-level singleton; second call on an instance-pool engine is a no-op rather than clobbering the singleton; and a module-style engine only calls `db.disconnect()` when it owns the singleton (`_ownsModuleSingleton`, set from the `db.connect()` creation token), so a borrower probe engine's teardown leaves the cycle owner's connection intact. Pinned by `test/e2e/postgres-engine-disconnect-idempotency.test.ts` + `test/postgres-engine-singleton-ownership.test.ts`. `getBrainScore` empty-brain parity with PGLite — 100/100 with breakdown 35/25/15/15/10 when `pageCount === 0` (both engines must agree to keep `doctor-report-remote.serial.test.ts` deterministic). Implements `deletePages(slugs, {sourceId}): Promise` via `DELETE FROM pages WHERE slug = ANY($1::text[]) AND source_id = $2 RETURNING slug` (single round-trip; caller chunks); `resolveSlugsByPaths` does `SELECT slug, source_path FROM pages WHERE source_path = ANY($1::text[]) AND source_id = $2`; FK cascades through `content_chunks`/`links`/`tags`/`raw_data`/`timeline_entries`/`page_versions`, `files.page_id`+`links.origin_page_id` go SET NULL; throws when input exceeds `DELETE_BATCH_SIZE` (from `src/core/engine-constants.ts`); both short-circuit on empty input. Implements the embedding-signature stale-detection quartet (`sumStaleChunkChars`, `setPageEmbeddingSignature`, `invalidateStaleSignatureEmbeddings`, widened `countStaleChunks`, all accept optional `signature` extending "stale" to model/dims-swap drift via the `pages.embedding_signature` JOIN, NULL grandfathered; the `embedding IS NULL` server-side filter is preserved as the no-signature fast path); Pinned by `test/e2e/engine-parity.test.ts`. +- `src/core/postgres-engine.ts` — Postgres + pgvector implementation (Supabase / self-hosted). `addLinksBatch`/`addTimelineEntriesBatch`/`addTakesBatch` pass the batch as one JSONB document — `INSERT ... SELECT FROM jsonb_to_recordset(($1::jsonb)->'rows') AS v(...) JOIN pages ...` bound through `executeRawJsonb({ rows })` — which encodes arbitrary free text safely (the old `unnest(${arr}::text[])` array-literal path crashed Postgres with "malformed array literal" on calendar/Zoom context, gbrain#1861) and sidesteps the 65535-parameter cap; takes declares native recordset column types (`page_id int, weight real, active boolean, …`) so no per-element casts; all three are `batchRetry`-wrapped. `searchKeyword`/`searchVector` scope `statement_timeout` via `sql.begin` + `SET LOCAL` so the GUC dies with the transaction instead of leaking across the pooled postgres.js connection. `getEmbeddingsByChunkIds` uses `tryParseEmbedding` so one corrupt row skips+warns instead of killing the query. `searchKeyword`/`searchKeywordChunks`/`searchVector` apply source-aware ranking by inlining the source-factor CASE and `NOT (col LIKE …)` hard-exclude from `src/core/search/sql-ranking.ts`; `searchVector` is a two-stage CTE (HNSW-safe inner ORDER BY, source-boost re-rank in outer SELECT) carrying `p.source_id` inner→outer. `_savedConfig` retains the connect config; `reconnect()` tears down + recreates the pool from saved config (called by supervisor watchdog after 3 consecutive health-check failures, and by `batchRetry` on a retryable connection error). Concurrent callers share one in-flight `_reconnectPromise` (they await the single reconnect rather than racing a half-rebuilt pool); ownership re-samples through the atomic `db.connect()` token on the connect leg. `reconnect(ctx?)` accepts the triggering error and records a pool-recovery audit event (`reap_detected`/`reconnect_other`/`reconnect_succeeded`/`reconnect_failed`) for the `pool_reap_health` doctor check. `executeRaw` is a single-statement passthrough — no per-call retry (unsound for non-idempotent statements; recovery is supervisor-driven). `connect()` applies `resolveSessionTimeouts()` from `db.ts` as connection-time startup parameters (`statement_timeout`, `idle_in_transaction_session_timeout`) so orphan pgbouncer backends can't hold locks for hours. `countStaleChunks()`+`listStaleChunks()` server-side-filter on `embedding IS NULL` for `embed --stale` (eliminates ~76 MB/call client-side pull); `upsertChunks()` resets both `embedding` AND `embedded_at` to NULL when chunk_text changes without a new embedding. `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL on the same probe set as PGLite (extended for column-only forward-reference cases: `files.source_id`, `files.page_id`, `oauth_clients.source_id`, `oauth_clients.federated_read`, `sources.archived`/`archived_at`/`archive_expires_at`, `timeline_entries.event_page_id`); the entire probe path runs on the DDL connection threaded from `initSchema` (closing a concurrent-bootstrap race for Supabase pooler users); closes #1018/#974/#820. `disconnect()` is idempotent — `_connectionStyle` tracks whether the engine owns its pool (worker engines) or shares the module-level singleton; second call on an instance-pool engine is a no-op rather than clobbering the singleton; and a module-style engine only calls `db.disconnect()` when it owns the singleton (`_ownsModuleSingleton`, set from the `db.connect()` creation token), so a borrower probe engine's teardown leaves the cycle owner's connection intact. Pinned by `test/e2e/postgres-engine-disconnect-idempotency.test.ts` + `test/postgres-engine-singleton-ownership.test.ts`. `getBrainScore` empty-brain parity with PGLite — 100/100 with breakdown 35/25/15/15/10 when `pageCount === 0` (both engines must agree to keep `doctor-report-remote.serial.test.ts` deterministic). Implements `deletePages(slugs, {sourceId}): Promise` via `DELETE FROM pages WHERE slug = ANY($1::text[]) AND source_id = $2 RETURNING slug` (single round-trip; caller chunks); `resolveSlugsByPaths` does `SELECT slug, source_path FROM pages WHERE source_path = ANY($1::text[]) AND source_id = $2`; FK cascades through `content_chunks`/`links`/`tags`/`raw_data`/`timeline_entries`/`page_versions`, `files.page_id`+`links.origin_page_id` go SET NULL; throws when input exceeds `DELETE_BATCH_SIZE` (from `src/core/engine-constants.ts`); both short-circuit on empty input. Implements the embedding-signature stale-detection quartet (`sumStaleChunkChars`, `setPageEmbeddingSignature`, `invalidateStaleSignatureEmbeddings`, widened `countStaleChunks`, all accept optional `signature` extending "stale" to model/dims-swap drift via the `pages.embedding_signature` JOIN, NULL grandfathered; the `embedding IS NULL` server-side filter is preserved as the no-signature fast path); Pinned by `test/e2e/engine-parity.test.ts`. Retry classifiers, ontology/recency helpers, and disconnect/pool-recovery audit writers bind statically; only the two `ai/gateway.ts` fallback lookups stay lazy and line-marked, in parity with PGLite. - `src/core/cjk.ts` — Single source of truth for CJK detection. Exports `CJK_RANGES_REGEX`, `CJK_SLUG_CHARS` (character-class fragment for embedding inside other regexes), `CJK_SENTENCE_DELIMITERS` (`。!?`), `CJK_CLAUSE_DELIMITERS` (`;:,、`), `CJK_DENSITY_THRESHOLD = 0.30`, `hasCJK(s)`, `countCJKAwareWords(s)` (30% density threshold — English docs with one Japanese term stay whitespace-tokenized; Chinese-dominant docs get char-counted), and `escapeLikePattern(s)` (escapes `%`, `_`, `\\` for `ILIKE ... ESCAPE '\\'`). BMP-only ranges (Han / Hiragana / Katakana / Hangul Syllables). Consumers: `expansion.ts`, `sync.ts:slugifySegment`, `operations.ts:validatePageSlug + validateFilename`, `chunkers/recursive.ts:countWords + DELIMITERS`, `pglite-engine.ts:searchKeyword + searchKeywordChunks`. - `src/core/audit-slug-fallback.ts` — Weekly ISO-week-rotated audit JSONL at `~/.gbrain/audit/slug-fallback-YYYY-Www.jsonl`. `logSlugFallback(slug, sourcePath)` fires when `importFromFile` falls back to a frontmatter slug because `slugifyPath` returned empty (emoji / Thai / Arabic / non-CJK exotic-script filenames). `readRecentSlugFallbacks(days)` reads the last N days for `gbrain doctor`'s `slug_fallback_audit` check. Honors `GBRAIN_AUDIT_DIR` via the shared `resolveAuditDir()`. Separate surface from `sync-failures.jsonl` — that file carries bookmark-gating semantics that info events shouldn't trigger. - `src/core/embedding-pricing.ts` — `EMBEDDING_PRICING` map keyed `provider:model` for the post-upgrade reindex cost estimate. Sibling to `anthropic-pricing.ts`. Entries: OpenAI text-embedding-3-large ($0.13/1M), 3-small ($0.02/1M), ada-002 ($0.10/1M), Voyage 3-large ($0.18/1M), 3 ($0.06/1M). `lookupEmbeddingPrice(modelString)` returns a tagged union (`known` with price + `unknown` with provider name); `estimateCostFromChars(charCount, pricePerMTok)` uses 3.5 chars/token. Unknown providers degrade to "estimate unavailable" instead of fabricating numbers. @@ -300,7 +300,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this - `src/commands/transcripts.ts` — `gbrain transcripts recent [--days N] [--full] [--json]`: recent raw `.txt` transcripts from the dream-cycle corpus dirs. Imports `listRecentTranscripts` from `src/core/transcripts.ts` (the same library the gated `get_recent_transcripts` MCP op uses). Local-only by construction — the CLI always runs with `ctx.remote=false`. - `src/commands/integrity.ts` — `gbrain integrity check|auto|review|extract`: bare-tweet detection, dead-link detection, three-bucket repair (auto-repair / review-queue / skip). `scanIntegrity()` is the shared library function called from `gbrain doctor` (sampled at limit=500) and `cmdCheck` (full scan). Batch-load fast path on Postgres uses a single SQL query (fixes the PgBouncer round-trip timeout, ~60s → ~6s), gated by `engine.kind === 'postgres'` at the call site so PGLite never enters batch; fallback `catch` logs at `GBRAIN_DEBUG=1`. Batch projection is `SELECT ... ORDER BY source_id, slug` (NOT `SELECT DISTINCT ON (slug)`, which collapsed same-slug-different-source pages into one scan) so multi-source brains scan each `(source, slug)` row independently. Sequential and auto-repair loops use `listAllPageRefs()` to enumerate `(slug, source_id)` pairs and thread `sourceId` to `getPage`; batch + sequential paths report the same page count on multi-source brains. - `src/commands/doctor.ts` — `gbrain doctor [--json] [--fast] [--fix] [--dry-run] [--index-audit]`: health checks. Checks include `jsonb_integrity` + `markdown_body_completeness` (reliability), `schema_version` (fails loudly when `version=0`, routes to `gbrain apply-migrations --yes`), `queue_health` (Postgres-only: stalled-forever active jobs started_at > 1h, waiting-depth-per-name > threshold default 10 via `GBRAIN_QUEUE_WAITING_THRESHOLD`, and dead-lettered subagent jobs with `last_error` matching the `prompt_too_long` classifier in last 24h), `sync_failures` (`[CODE=N, ...]` breakdown for unacked-warn + acked-ok; severity comes from the shared `decideSyncFailureSeverity` in `src/core/sync-failure-ledger.ts` so the LOCAL and REMOTE/thin-client doctor surfaces can never drift — a stuck bookmark escalates to FAIL once an OPEN failure has blocked past the staleness window or ≥10 files block, while already `auto_skipped` rows stay a visible WARN), `rls_event_trigger` (healthy `evtenabled` set is `('O','A')` only; fix hint `gbrain apply-migrations --force-retry 35`), `graph_coverage` (short-circuits to ok when `SELECT COUNT(*) FROM pages WHERE type IN ('entity','person','company','organization')` returns 0; WARN hint is `gbrain extract all`), `embedding_column_registry` (probes each declared column via Postgres `format_type(atttypid, atttypmod)` to catch dim mismatch with a paste-ready `gbrain config set embedding_columns '{...}'` hint, probes HNSW index presence via `pg_indexes`, computes default-column population via `COUNT(*) FILTER (WHERE IS NOT NULL) / COUNT(*)` warning below 90% except empty brains where chunk_count=0 short-circuits to ok; PGLite parity via `executeRaw`), and `skill_brain_first` (walks SKILL.md via `autoDetectSkillsDirReadOnly`, calls `analyzeSkillBrainFirst()` from `src/core/skill-brain-first.ts` per file with structured `Check.issues[]`; warn states `missing_brain_first`/`brain_first_typo`, ok states `compliant_callout`/`compliant_phase`/`compliant_position`/`exempt_frontmatter`/`no_external`; snapshot+diff audit at `~/.gbrain/audit/skill-brain-first-YYYY-Www.jsonl`). `--fix` delegates inlined cross-cutting rules to `> **Convention:** see [path](path).` callouts via `src/core/dry-fix.ts` (and MISSING_RULE_PATTERNS for the brain-first callout); `--fix --dry-run` previews. `--index-audit` (Postgres-only, informational, no auto-drop) reports zero-scan indexes from `pg_stat_user_indexes`. Every DB check runs under a progress phase; `markdown_body_completeness` runs under a 1s heartbeat. `runDoctor` uses `autoDetectSkillsDirReadOnly` (from `src/core/repo-root.ts`; install-path fallback so `cd ~ && gbrain doctor` finds bundled skills); `--fix` carries a D6 install-path safety gate that refuses auto-repair when `detected.source === 'install_path'` (would rewrite the bundled tree). The Lane D supervisor check at `doctor.ts:1011-1043` consumes `summarizeCrashes(events)` from `src/core/minions/handlers/supervisor-audit.ts` (warn at `>=1` real crash; ok message has `clean_exits_24h=N`; warn message has `runtime=A oom=B unknown=C legacy=D` per-cause breakdown) so OOM/runtime/unknown crashes are distinguishable from clean code=0 worker drains; cross-surface parity with `gbrain jobs supervisor status` is pinned by source-grep wiring assertions requiring the breakdown substrings in BOTH `doctor.ts` and `jobs.ts`. `checkSyncFreshness` (exported, in `runDoctor` local + `doctorReportRemote` thin-client) is a staleness probe: warns at 24h, fails at 72h or never-synced; future-`last_sync_at` warns ("clock skew") instead of falling through ok; env overrides `GBRAIN_SYNC_FRESHNESS_WARN_HOURS`/`GBRAIN_SYNC_FRESHNESS_FAIL_HOURS` (invalid fall back with once-per-process stderr warn via `_resolveSyncFreshnessHours`); failure messages embed `source.id` so the printed `gbrain sync --source ` matches. A source holding a LIVE, non-expired per-source sync lock (`inspectLock(engine, syncLockId(source.id))` from `src/core/db-lock.ts`) is reported as actively syncing (the message names the holder pid + host) and counted in `synced_recently_count`, NOT flagged stale — the live lock is the only honest in-progress signal (checkpoint banking can't distinguish in-progress from wedged: a blocked sync banks its files but writes no anchor). A blocked/failed sync's process has exited (no lock row) and a wedged holder stops refreshing (TTL lapses), so either falls through to the stale path and is never masked; the dynamic `db-lock` import is swallowed to a no-op on a stub engine or pre-lock-table brain, so this can only ADD an in-progress verdict, never suppress a real stale one. The in-progress note is appended to whatever verdict the buckets produce and is empty when nothing is syncing, so steady-state messages stay byte-for-byte unchanged. It has a `localOnly`-gated git short-circuit (`runDoctor` passes `localOnly: true`; `doctorReportRemote` runs in the HTTP MCP server `src/commands/serve-http.ts` and keeps default `false` so that path never walks DB-supplied `local_path` via subprocess — trust boundary). The local predicate mirrors sync's "do work?" gate (HEAD == `last_commit` AND working tree clean via `requireCleanWorkingTree: 'ignore-untracked'` so a quiet repo with only untracked dirs is `unchanged` not SEVERE, AND `chunker_version === CURRENT`); the inline SELECT carries `last_commit + chunker_version + newest_content_at`. The REMOTE path computes lag via `lagFromContentMs(newest_content_at, lastSync, now)` from the stored column, NO git subprocess; LOCAL fall-through and the `< 0` clock-skew check stay on raw wall-clock. Three-bucket count math populates `Check.details = {unchanged_count, synced_recently_count, stale_count}` with the invariant `sum === sources.length`. `checkCycleFreshness` is DELIBERATELY NOT git-short-circuited or content-relativized (`last_commit == HEAD` can't answer "did the full cycle complete?"; a sync can succeed while later cycle phases fail; different axis `last_full_cycle_at`). Pinned by `test/doctor.test.ts` (incl. IRON-RULE regression banning stale verb names, the sync_freshness boundary matrix, the D4 regression guard verifying git probes are NEVER called when `localOnly` is unset/false, the three-bucket invariant, and the untracked-folders / remote-never-shells-out trust-boundary cases). -- `src/core/migrate.ts` — schema-migration runner. Owns the `MIGRATIONS` array (source of truth for schema DDL). `Migration` interface carries `sqlFor?: { postgres?, pglite? }` (engine-specific SQL overrides `sql`) and `transaction?: boolean` (false for `CREATE INDEX CONCURRENTLY`, which Postgres refuses in a transaction; ignored on PGLite). Key migrations: v14 (handler branches on `engine.kind` for CONCURRENTLY-on-Postgres with invalid-remnant pre-drop via `pg_index.indisvalid`, plain `CREATE INDEX` on PGLite); v15 (`minion_jobs.max_stalled` default 1→5 + backfill non-terminal rows); v24 `rls_backfill_missing_tables` (`sqlFor: { pglite: '' }` no-op — PGLite has no RLS engine, targets subagent tables absent from pglite-schema.ts); v30 `dream_verdicts (file_path TEXT, content_hash TEXT, worth_processing BOOL, reasons JSONB, judged_at TIMESTAMPTZ, PK(file_path, content_hash))` (RLS-enabled under BYPASSRLS; synthesize reads/writes to avoid re-judging); v35 auto-RLS event trigger `auto_rls_on_create_table` fires on `ddl_command_end` for `WHEN TAG IN ('CREATE TABLE','CREATE TABLE AS','SELECT INTO')` running `ALTER TABLE … ENABLE ROW LEVEL SECURITY` on new `public.*` tables (no FORCE) + one-time backfill on every existing `public.*` base table whose comment doesn't match `^GBRAIN:RLS_EXEMPT\s+reason=\S.{3,}` (per-table failure aborts the offending CREATE TABLE; no EXCEPTION wrap; PGLite no-op via `sqlFor.pglite: ''`; breaking change: intentionally-RLS-off public tables need the GBRAIN:RLS_EXEMPT comment before upgrade); v40 `pages_emotional_weight` (`pages.emotional_weight REAL NOT NULL DEFAULT 0.0`, column-only metadata-only); v46 `mcp_request_log_params_jsonb_normalize` (`UPDATE ... SET params = params::text::jsonb WHERE jsonb_typeof(params) = 'string'`, idempotent); v60-v65 six-migration chain wiring source-scoping into `oauth_clients` — v60 (`oauth_clients_source_id_fk`: `source_id TEXT` NULL→`'default'` backfill + FK to `sources(id) ON DELETE SET NULL`), v61 (`federated_read TEXT[] NOT NULL DEFAULT '{}'`), v62 (explicit-CASE backfill so `source_id IS NULL` → `'{}'`), v63 (fail-loud check every row's source_id is in its federated_read array), v64 (FK flipped to `ON DELETE RESTRICT`), v65 (GIN index for array-containment); v68 `eval_candidates_embedding_column` (`eval_candidates.embedding_column TEXT NULL` per-row provenance for `gbrain eval replay` to reproduce the same retrieval space; NULL-tolerant); v108 `pages_embedding_signature` (`pages.embedding_signature TEXT NULL` = `:` stamped via `setPageEmbeddingSignature`; GRANDFATHER — stale predicate is `embedding_signature IS NOT NULL AND embedding_signature <> $current` so NULL is NEVER stale and upgrade never re-embeds the whole corpus; no index; metadata-only); v109 `sources_newest_content_at` (`sources.newest_content_at TIMESTAMPTZ` durable newest-COMMIT HEAD committer time written by `writeSyncAnchor`, read by the REMOTE staleness path instead of shelling to git; mirror in pglite-schema.ts + schema.sql + bootstrap probe); v110 `page_aliases` (`(id, source_id, alias_norm, slug, ...)` with `UNIQUE (source_id, alias_norm, slug)` + lookup indexes on `(source_id, alias_norm)` and `(source_id, slug)`; `alias_norm` is `normalizeAlias()` output so WRITE/READ key on the same form; also in `src/core/pglite-schema.ts`); v111 `search_telemetry_rank1_columns` (`ADD COLUMN IF NOT EXISTS` on both engines: `sum_rank1_score`, `count_rank1`, three buckets `rank1_lt_solid`/`rank1_solid`/`rank1_high` on `search_telemetry` — aggregate not per-query rows so rank-1 median drift is bounded-growth; ALTERs right after v57 which created the table); v114 `links_link_source_check_kebab_regex` (#1941, opens `link_source` from the closed allowlist to a kebab-case format gate `^[a-z][a-z0-9]*(-[a-z0-9]+)*$` + `char_length<=64`; Postgres branch uses `NOT VALID` + `VALIDATE CONSTRAINT` with `transaction:false`, PGLite plain DROP+ADD; existing built-ins all satisfy the regex so VALIDATE never fails on existing data); v116 `code_edges_source_backfill_and_callee_index` (#2073, idempotent: backfills NULL `code_edges_symbol`/`code_edges_chunk` `source_id` from each edge's `from_chunk` page — NULL never matched a scoped `AND source_id = …` filter so scoped `code-callers`/`code-callees` returned 0 rows on multi-source brains — plus plain `CREATE INDEX` on `from_symbol_qualified` for both edge tables, which had no index and seq-scanned per BFS node). The dedup-index self-heal (`timeline_dedup_index`, see `timeline-dedup-repair.ts`) is NOT version-gated: `runMigrations` invokes `repairTimelineDedupIndex` on every pass (including the no-pending early-return path) because a merge-renumbered migration can leave the version counter past the index change while the index stays the old shape. +- `src/core/migrate.ts` — schema-migration runner. Owns the `MIGRATIONS` array (source of truth for schema DDL). `Migration` interface carries `sqlFor?: { postgres?, pglite? }` (engine-specific SQL overrides `sql`) and `transaction?: boolean` (false for `CREATE INDEX CONCURRENTLY`, which Postgres refuses in a transaction; ignored on PGLite). Key migrations: v14 (handler branches on `engine.kind` for CONCURRENTLY-on-Postgres with invalid-remnant pre-drop via `pg_index.indisvalid`, plain `CREATE INDEX` on PGLite); v15 (`minion_jobs.max_stalled` default 1→5 + backfill non-terminal rows); v24 `rls_backfill_missing_tables` (`sqlFor: { pglite: '' }` no-op — PGLite has no RLS engine, targets subagent tables absent from pglite-schema.ts); v30 `dream_verdicts (file_path TEXT, content_hash TEXT, worth_processing BOOL, reasons JSONB, judged_at TIMESTAMPTZ, PK(file_path, content_hash))` (RLS-enabled under BYPASSRLS; synthesize reads/writes to avoid re-judging); v35 auto-RLS event trigger `auto_rls_on_create_table` fires on `ddl_command_end` for `WHEN TAG IN ('CREATE TABLE','CREATE TABLE AS','SELECT INTO')` running `ALTER TABLE … ENABLE ROW LEVEL SECURITY` on new `public.*` tables (no FORCE) + one-time backfill on every existing `public.*` base table whose comment doesn't match `^GBRAIN:RLS_EXEMPT\s+reason=\S.{3,}` (per-table failure aborts the offending CREATE TABLE; no EXCEPTION wrap; PGLite no-op via `sqlFor.pglite: ''`; breaking change: intentionally-RLS-off public tables need the GBRAIN:RLS_EXEMPT comment before upgrade); v40 `pages_emotional_weight` (`pages.emotional_weight REAL NOT NULL DEFAULT 0.0`, column-only metadata-only); v46 `mcp_request_log_params_jsonb_normalize` (`UPDATE ... SET params = params::text::jsonb WHERE jsonb_typeof(params) = 'string'`, idempotent); v60-v65 six-migration chain wiring source-scoping into `oauth_clients` — v60 (`oauth_clients_source_id_fk`: `source_id TEXT` NULL→`'default'` backfill + FK to `sources(id) ON DELETE SET NULL`), v61 (`federated_read TEXT[] NOT NULL DEFAULT '{}'`), v62 (explicit-CASE backfill so `source_id IS NULL` → `'{}'`), v63 (fail-loud check every row's source_id is in its federated_read array), v64 (FK flipped to `ON DELETE RESTRICT`), v65 (GIN index for array-containment); v68 `eval_candidates_embedding_column` (`eval_candidates.embedding_column TEXT NULL` per-row provenance for `gbrain eval replay` to reproduce the same retrieval space; NULL-tolerant); v108 `pages_embedding_signature` (`pages.embedding_signature TEXT NULL` = `:` stamped via `setPageEmbeddingSignature`; GRANDFATHER — stale predicate is `embedding_signature IS NOT NULL AND embedding_signature <> $current` so NULL is NEVER stale and upgrade never re-embeds the whole corpus; no index; metadata-only); v109 `sources_newest_content_at` (`sources.newest_content_at TIMESTAMPTZ` durable newest-COMMIT HEAD committer time written by `writeSyncAnchor`, read by the REMOTE staleness path instead of shelling to git; mirror in pglite-schema.ts + schema.sql + bootstrap probe); v110 `page_aliases` (`(id, source_id, alias_norm, slug, ...)` with `UNIQUE (source_id, alias_norm, slug)` + lookup indexes on `(source_id, alias_norm)` and `(source_id, slug)`; `alias_norm` is `normalizeAlias()` output so WRITE/READ key on the same form; also in `src/core/pglite-schema.ts`); v111 `search_telemetry_rank1_columns` (`ADD COLUMN IF NOT EXISTS` on both engines: `sum_rank1_score`, `count_rank1`, three buckets `rank1_lt_solid`/`rank1_solid`/`rank1_high` on `search_telemetry` — aggregate not per-query rows so rank-1 median drift is bounded-growth; ALTERs right after v57 which created the table); v114 `links_link_source_check_kebab_regex` (#1941, opens `link_source` from the closed allowlist to a kebab-case format gate `^[a-z][a-z0-9]*(-[a-z0-9]+)*$` + `char_length<=64`; Postgres branch uses `NOT VALID` + `VALIDATE CONSTRAINT` with `transaction:false`, PGLite plain DROP+ADD; existing built-ins all satisfy the regex so VALIDATE never fails on existing data); v116 `code_edges_source_backfill_and_callee_index` (#2073, idempotent: backfills NULL `code_edges_symbol`/`code_edges_chunk` `source_id` from each edge's `from_chunk` page — NULL never matched a scoped `AND source_id = …` filter so scoped `code-callers`/`code-callees` returned 0 rows on multi-source brains — plus plain `CREATE INDEX` on `from_symbol_qualified` for both edge tables, which had no index and seq-scanned per BFS node). The dedup-index self-heal (`timeline_dedup_index`, see `timeline-dedup-repair.ts`) is NOT version-gated: `runMigrations` invokes `repairTimelineDedupIndex` on every pass (including the no-pending early-return path) because a merge-renumbered migration can leave the version counter past the index change while the index stays the old shape. `retry-matcher.ts` and `timeline-dedup-repair.ts` are static dependencies because `runMigrations()` executes from live engine initialization; the engine dynamic-import guard scans this file with both engine implementations. - `src/core/timeline-dedup-repair.ts` (#2038) — schema-drift self-heal for `idx_timeline_dedup`. The migration that widened the dedup index from `(page_id, date, summary)` to `(page_id, date, summary, source)` was renumbered during a master merge, so a brain that ran the old variant has its version counter stamped past the change while the index keeps the 3-column shape — and every `addTimelineEntry` batch then fails its 4-column `ON CONFLICT`, silently breaking timeline writes brain-wide. The version counter can't detect this, so the repair is keyed off the actual index SHAPE: `checkTimelineDedupIndex(engine)` returns `{tablePresent, indexPresent, columns, needsRepair}` (read-only; powers the `timeline_dedup_index` doctor check) and `repairTimelineDedupIndex(engine)` dedupes-then-rebuilds the index. `runMigrations` invokes the repair on every pass (including the no-pending early-return path); idempotent no-op when the index is already 4-column. `gbrain apply-migrations --force-schema` triggers it on demand. Pinned by `test/timeline-dedup-repair.test.ts`. - `src/core/progress.ts` — Shared bulk-action progress reporter. Writes to stderr. Modes: `auto` (TTY `\r`-rewriting; non-TTY plain lines), `human`, `json` (JSONL), `quiet`. Rate-gated by `minIntervalMs` and `minItems`. `startHeartbeat(reporter, note)` for single long queries. `child()` composes phase paths. Singleton SIGINT/SIGTERM coordinator emits `abort` events for every live phase. EPIPE defense on both sync throws and stream `'error'` events. Zero dependencies. `emitHumanLine` is prefix-aware — inside a `withSourcePrefix(id, ...)` scope from `src/core/console-prefix.ts` it prepends `[id] ` (and TTY-rewrite mode `\r\x1b[2K` carries the prefix inside the clear-to-EOL escape); `emitJson` is intentionally NOT prefixed so NDJSON consumers don't choke on a `[id] {...}` shape. - `src/core/console-prefix.ts` — `AsyncLocalStorage`-backed per-source line-prefix helper. Exports `withSourcePrefix(id, fn)` (runs `fn` with `id` as active prefix; nested wraps replace then restore), `getSourcePrefix()` (read-only accessor; test seam), and `slog(...)` / `serr(...)` (prefix-aware `console.log`/`console.error`). Embedded-newline-safe: a multi-line string under prefix `[foo]` emits `[foo] line1\n[foo] line2`. Outside a wrap, `slog`/`serr` fall through to bare `console.log`/`console.error` so single-source callers see identical output (back-compat invariant). Use `src.id` (slug-validated by `sources add`) NOT `src.name` (free-form) to defeat log-injection through newline/control-character names. Coverage: `src/commands/sync.ts` performSync + callees, `src/commands/embed.ts` runEmbedCore + helpers, `src/core/progress.ts` emitHumanLine. diff --git a/docs/superpowers/plans/2026-07-28-engine-dynamic-import-reconciliation.md b/docs/superpowers/plans/2026-07-28-engine-dynamic-import-reconciliation.md new file mode 100644 index 000000000..a94fcce35 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-engine-dynamic-import-reconciliation.md @@ -0,0 +1,690 @@ +# Engine Dynamic-Import Reconciliation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reconstruct the missing engine-path static-import hardening, preserve the four load-bearing lazy gateway fallbacks, and prevent unreviewed dynamic imports from returning. + +**Architecture:** Make the 13 safe engine/migration import statements static and leave only four line-marked `ai/gateway.ts` imports inside their existing soft-failure `try/catch` boundaries. Enforce that current state with a repository-anchored Bash wrapper delegating to a fail-closed TypeScript AST scanner, a hermetic Bun regression test, package/verify wiring, and current-state architecture documentation. + +**Tech Stack:** TypeScript compiler API, Bun test runner, Bash, Git, generated llms documentation bundles. + +## Global Constraints + +- Reconstruct directly on branch `claude/kind-meitner-330c90`, based on investigated `origin/master` commit `6136e139972a5449630b4f47f5ed7b4cbe5b811b` plus design commit `d7f52d8c`. +- Do not merge or cherry-pick `48ada48f`, `248bfe55`, `ef4cf7a8`, or either historical branch wholesale. +- Do not modify `VERSION`, `CHANGELOG.md`, `TODOS.md`, or release metadata; this is a no-version-bump reconciliation. +- Keep all four `await import('./ai/gateway.ts')` calls lazy: PGLite and Postgres `initSchema`, plus both `_upsertChunksOnce` methods. +- Every allowed lazy gateway line must carry `engine-dynamic-import-ok`; there is no file-level exemption. +- Preserve the stronger gateway rationale: the static closure is large, and eager module evaluation would occur outside the local `try/catch`, potentially converting a recoverable configuration/import failure into a module-load-time hard failure. +- Describe the hoists as engine-path hardening. Do not claim every dynamic import deterministically causes a Windows crash; system-wide commit exhaustion confounded prior measurements. +- Keep shared PGLite/Postgres behavior in parity. +- Invoke repository shell scripts through `bash` in `package.json`. +- Capture complete test/check output to workspace-local `.context/*.txt` files before inspecting it; never pipe a test command directly through `head` or `tail`. +- Use `git log -G`, not `git log -S`, for any additional dynamic-to-static import history work. +- Keep every implementation and verification commit local. Do not push, create a PR, comment upstream, or otherwise publish without explicit user approval after local completion. +- Before editing any affected function, run GBrain `code_blast` and `code_callers` for that symbol and inspect any disambiguation candidates. + +--- + +## File Map + +- Create `scripts/check-engine-dynamic-import.sh` — repository-anchored Bash wrapper for default and explicit input routing. +- Create `scripts/check-engine-dynamic-import.ts` — TypeScript AST policy scanner for runtime `import()` expressions, parse/read failures, and exact-line comment-trivia opt-outs. +- Create `test/scripts/check-engine-dynamic-import.test.ts` — 22 hermetic adversarial, CRLF, fail-closed, real-tree, and wiring tests. +- Modify `src/core/pglite-engine.ts` — hoist three safe import statements and mark two deliberate gateway imports. +- Modify `src/core/postgres-engine.ts` — hoist eight safe import statements and mark two deliberate gateway imports. +- Modify `src/core/migrate.ts` — hoist two safe migration helper import statements. +- Modify `package.json` — expose `check:engine-dynamic-import` and append it to `check:all` through `bash`. +- Modify `scripts/run-verify-parallel.sh` — add the package check to the authoritative verify dispatcher. +- Modify `CLAUDE.md` — add the cross-cutting current-state invariant. +- Modify `docs/architecture/KEY_FILES.md` — update current-state entries for the three engine-path files. +- Regenerate `llms.txt` and `llms-full.txt` — required derived bundles after CLAUDE/reference documentation changes. + +--- + +### Task 1: Establish and enforce the source invariant + +**Files:** +- Create: `scripts/check-engine-dynamic-import.sh` +- Create: `scripts/check-engine-dynamic-import.ts` +- Create: `test/scripts/check-engine-dynamic-import.test.ts` +- Modify: `src/core/pglite-engine.ts` +- Modify: `src/core/postgres-engine.ts` +- Modify: `src/core/migrate.ts` + +**Interfaces:** +- Consumes: shell positional arguments `FILE...`; without arguments, the guard scans the three repository files. +- Produces: `scripts/check-engine-dynamic-import.sh [FILE...]`, exit `0` when every runtime dynamic import is allowed and exit `1` after reporting every `file:line:text` violation plus every read/parse error on stderr. +- Produces: one line-level opt-out token, `engine-dynamic-import-ok`, accepted only in real comment trivia on the same physical line as the deliberately lazy import. +- Fails closed on missing/unreadable inputs, TypeScript parse diagnostics, and scanner/process failures; comments, strings, templates, regex literals, and type-position `import(...)` syntax are not runtime imports. + +- [ ] **Step 1: Record call-graph blast radius before touching functions** + +First call `sources_list` and select the source whose registered path is this gbrain checkout. Then run `code_blast` and `code_callers` for these qualified symbols with that exact `source_id`, following `did_you_mean`/`candidates` when a method name is ambiguous: + +```text +src/core/pglite-engine.ts::PGLiteEngine.initSchema +src/core/pglite-engine.ts::PGLiteEngine.batchRetry +src/core/pglite-engine.ts::PGLiteEngine._upsertChunksOnce +src/core/pglite-engine.ts::PGLiteEngine.mergeOntologyFact +src/core/pglite-engine.ts::PGLiteEngine.getRecentSalience +src/core/postgres-engine.ts::PostgresEngine.disconnect +src/core/postgres-engine.ts::PostgresEngine.initSchema +src/core/postgres-engine.ts::PostgresEngine.batchRetry +src/core/postgres-engine.ts::PostgresEngine._upsertChunksOnce +src/core/postgres-engine.ts::PostgresEngine.mergeOntologyFact +src/core/postgres-engine.ts::PostgresEngine.reconnect +src/core/postgres-engine.ts::PostgresEngine.getRecentSalience +src/core/migrate.ts::runMigrationSQLWithRetry +src/core/migrate.ts::runMigrations +``` + +Use `depth: 5`, `max_nodes: 200`, and `limit: 100`. Expected: no caller requires a signature or behavior change; the patch only changes module binding time and retains all local fallback/error handling. + +- [ ] **Step 2: Write the failing guard regression test** + +Create `test/scripts/check-engine-dynamic-import.test.ts` as a hermetic subprocess suite. The completed 22-test surface covers: + +- unmarked runtime `import()` rejection, including bare and trivia-separated forms; +- same-line markers in real line or multiline block-comment trivia; +- rejection of markers on prior lines or inside strings, templates, and module paths; +- comments and comment-like delimiters inside strings, templates, and regex literals; +- live code after same-line or multiline block comments close; +- CRLF input and complete multi-file violation aggregation; +- missing/readable mixed inputs and TypeScript parse diagnostics; +- default repository anchoring when invoked from a foreign Git repository; +- the reconciled three-file source scan plus package/parallel-verifier wiring. + +Use the TypeScript parser rather than a partial lexical reimplementation. On Windows, set the test default to 30 seconds because each case launches Git Bash and Bun, whose startup can exceed Bun's 5-second per-test default. + +- [ ] **Step 3: Run the test to prove the pre-implementation red state** + +```bash +bun test test/scripts/check-engine-dynamic-import.test.ts > .context/engine-dynamic-import-red.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit 0 +``` + +Expected: non-zero Bun result captured inside the log. At minimum, the `exists` assertion fails because `scripts/check-engine-dynamic-import.sh` does not exist. Read `.context/engine-dynamic-import-red.txt`; do not infer the result from a truncated pipeline. + +- [ ] **Step 4: Add the CRLF-safe, fail-closed guard** + +Create `scripts/check-engine-dynamic-import.sh` as a thin LF-terminated wrapper. Resolve its own directory first; when no explicit files are passed, anchor the repository with `git -C "$SCRIPT_DIR/.."` and scan the two engines plus `migrate.ts`. Delegate with `exec bun "$SCRIPT_DIR/check-engine-dynamic-import.ts" "${FILES[@]}"` so scanner failures propagate. + +Create `scripts/check-engine-dynamic-import.ts` using the TypeScript compiler API: + +- read every requested file and aggregate read failures; +- parse as TypeScript and aggregate parse diagnostics; +- walk the AST for `CallExpression`s whose expression is `ImportKeyword`; +- locate all marker occurrences in the full source and use `ts.getTokenAtPosition` to admit only occurrences outside AST tokens (real comment trivia), recording their physical source lines; +- require each runtime import's line to have an admitted marker or report its original `file:line:text`; +- print every read/parse error and every violation before exiting nonzero. + +This preserves CRLF line accounting, ignores comment/literal/type-only false positives, catches every legal runtime `import()` shape the TypeScript parser recognizes, rejects marker spoofing, and fails closed. + +- [ ] **Step 5: Run the guard test to prove the source-tree midpoint is still red** + +```bash +bun test test/scripts/check-engine-dynamic-import.test.ts > .context/engine-dynamic-import-midpoint.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit 0 +``` + +Expected: the synthetic violation, marker, comments, and CRLF cases pass. The default repository scan fails and reports all 17 current imports: 13 unmarked safe candidates plus the four not-yet-marked gateway calls. + +- [ ] **Step 6: Hoist the three safe PGLite import statements** + +Replace the existing `retry.ts` import and add the ontology/recency imports near the top of `src/core/pglite-engine.ts`: + +```ts +// Engine-path imports stay static unless a call site carries an explicit +// engine-dynamic-import-ok justification. The gateway is the only current +// exception because its local try/catch preserves a soft fallback. +import { + withRetry, + BULK_RETRY_OPTS, + resolveBulkRetryOpts, + computeNextDelay, + isRetryableConnError, + type BatchAuditSite, +} from './retry.ts'; +import { + valueHash, + normalizeDimension, + isNovelDimension, +} from './chronicle/ontology.ts'; +import { + resolveRecencyDecayMap, + DEFAULT_FALLBACK, +} from './search/recency-decay.ts'; +``` + +Delete only these three in-method destructuring imports, leaving their uses unchanged: + +```ts +const { isRetryableConnError } = await import('./retry.ts'); +const { valueHash, normalizeDimension, isNovelDimension } = await import('./chronicle/ontology.ts'); +const { resolveRecencyDecayMap, DEFAULT_FALLBACK } = await import('./search/recency-decay.ts'); +``` + +- [ ] **Step 7: Mark both PGLite gateway soft-failure boundaries** + +In `PGLiteEngine.initSchema`, preserve the `try/catch` and accessors, changing only the rationale and import line: + +```ts +try { + // Keep the gateway lazy: its static closure is large, and evaluation inside + // this try/catch preserves the unconfigured-gateway default fallback. + const gw = await import('./ai/gateway.ts'); // engine-dynamic-import-ok + // Both accessors THROW when the gateway is unconfigured (they never + // return falsy), so the catch below is the only fallback path (#3461). + dims = gw.getEmbeddingDimensions(); + model = gw.getEmbeddingModel(); +} catch { /* gateway not configured — use defaults */ } +``` + +In `PGLiteEngine._upsertChunksOnce`, preserve the config-row and compile-time fallback chain: + +```ts +try { + // Keep the gateway lazy so module-load failure remains inside this soft + // fallback boundary; eager evaluation would bypass the config-row fallback. + const gw = await import('./ai/gateway.ts'); // engine-dynamic-import-ok + resolvedModel = gw.getEmbeddingModel(); +} catch { +``` + +- [ ] **Step 8: Hoist the eight safe Postgres import statements** + +Replace the existing `retry.ts` import and add these imports near the top of `src/core/postgres-engine.ts`: + +```ts +// Engine-path imports stay static unless a call site carries an explicit +// engine-dynamic-import-ok justification. The gateway is the only current +// exception because its local try/catch preserves a soft fallback. +import { + withRetry, + BULK_RETRY_OPTS, + resolveBulkRetryOpts, + computeNextDelay, + isRetryableConnError, + type BatchAuditSite, +} from './retry.ts'; +import { isConnectionEndedError } from './retry-matcher.ts'; +import { + valueHash, + normalizeDimension, + isNovelDimension, +} from './chronicle/ontology.ts'; +import { + resolveRecencyDecayMap, + DEFAULT_FALLBACK, +} from './search/recency-decay.ts'; +import { logDbDisconnect } from './audit/db-disconnect-audit.ts'; +import { logPoolRecovery } from './audit/pool-recovery-audit.ts'; +``` + +Delete the eight safe dynamic-import statements while keeping their surrounding `try/catch` blocks and calls unchanged: + +```ts +const { logDbDisconnect } = await import('./audit/db-disconnect-audit.ts'); +const { isRetryableConnError } = await import('./retry.ts'); +const { valueHash, normalizeDimension, isNovelDimension } = await import('./chronicle/ontology.ts'); +const { isConnectionEndedError } = await import('./retry-matcher.ts'); +const { logPoolRecovery } = await import('./audit/pool-recovery-audit.ts'); +const { logPoolRecovery } = await import('./audit/pool-recovery-audit.ts'); +const { logPoolRecovery } = await import('./audit/pool-recovery-audit.ts'); +const { resolveRecencyDecayMap, DEFAULT_FALLBACK } = await import('./search/recency-decay.ts'); +``` + +Update the stale `batchRetry` comment from “Lazy-import to avoid a circular dep concern” to current truth: + +```ts +// retry.ts is already in this module's static graph through withRetry, so +// classifying the exhausted error does not need a second runtime import. +``` + +- [ ] **Step 9: Mark both Postgres gateway soft-failure boundaries** + +In `PostgresEngine.initSchema`, mirror the PGLite rationale and preserve behavior: + +```ts +try { + // Keep the gateway lazy: its static closure is large, and evaluation inside + // this try/catch preserves the unconfigured-gateway default fallback. + const gw = await import('./ai/gateway.ts'); // engine-dynamic-import-ok + // Both accessors THROW when the gateway is unconfigured (they never + // return falsy), so the catch below is the only fallback path (#3461). + dims = gw.getEmbeddingDimensions(); + model = gw.getEmbeddingModel(); +} catch { /* gateway not yet configured — use defaults */ } +``` + +In `PostgresEngine._upsertChunksOnce`, preserve the DB-config fallback: + +```ts +try { + // Keep the gateway lazy so module-load failure remains inside this soft + // fallback boundary; eager evaluation would bypass the config-row fallback. + const gw = await import('./ai/gateway.ts'); // engine-dynamic-import-ok + resolvedModel = gw.getEmbeddingModel(); +} catch { +``` + +- [ ] **Step 10: Hoist the two migration helper import statements** + +Add these static imports at the top of `src/core/migrate.ts`: + +```ts +// runMigrations executes while an initialized engine is live. Keep its helper +// modules in the static graph rather than importing them from async handlers. +import { + isStatementTimeoutError, + isRetryableConnError, +} from './retry-matcher.ts'; +import { repairTimelineDedupIndex } from './timeline-dedup-repair.ts'; +``` + +Delete only these two local destructuring imports: + +```ts +const { isStatementTimeoutError, isRetryableConnError } = await import('./retry-matcher.ts'); +const { repairTimelineDedupIndex } = await import('./timeline-dedup-repair.ts'); +``` + +- [ ] **Step 11: Run the complete guard test and direct guard** + +```bash +bun test test/scripts/check-engine-dynamic-import.test.ts > .context/engine-dynamic-import-green.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc" +``` + +Expected: exit `0`; the full guard regression suite passes. + +```bash +bash scripts/check-engine-dynamic-import.sh > .context/engine-dynamic-import-guard.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc" +``` + +Expected: exit `0`; output contains `check-engine-dynamic-import: ok (3 file(s) scanned)`. + +- [ ] **Step 12: Prove the guard leaves exactly four marked dynamic imports** + +```bash +git grep -n -F "import('./ai/gateway.ts'); // engine-dynamic-import-ok" -- src/core/pglite-engine.ts src/core/postgres-engine.ts src/core/migrate.ts > .context/engine-dynamic-import-sites.txt; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc" +``` + +Expected: exactly four lines, all importing `./ai/gateway.ts` and all carrying `engine-dynamic-import-ok`; no match in `src/core/migrate.ts`. + +- [ ] **Step 13: Run focused behavior tests** + +```bash +bun test test/chronicle-ontology.test.ts test/chronicle-ontology-ops.test.ts test/recency-decay.test.ts test/core/retry.test.ts test/retry-matcher.test.ts test/audit/pool-recovery-audit.test.ts test/migrate-retry.test.ts test/timeline-dedup-repair.test.ts > .context/engine-dynamic-import-focused.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc" +``` + +Expected: exit `0`. If Windows resource pressure aborts the process, record the exact exit code and rerun the failing file alone; do not relabel an infrastructure abort as a source pass. + +- [ ] **Step 14: Commit the source invariant locally** + +```bash +git add scripts/check-engine-dynamic-import.sh scripts/check-engine-dynamic-import.ts test/scripts/check-engine-dynamic-import.test.ts src/core/pglite-engine.ts src/core/postgres-engine.ts src/core/migrate.ts +``` + +```bash +git commit -m "fix(engine): reconcile dynamic import hardening" +``` + +Expected: one local commit; no version or release files staged. + +--- + +### Task 2: Wire the guard into repository checks + +**Files:** +- Modify: `test/scripts/check-engine-dynamic-import.test.ts` +- Modify: `package.json` +- Modify: `scripts/run-verify-parallel.sh` + +**Interfaces:** +- Consumes: `scripts/check-engine-dynamic-import.sh` from Task 1. +- Produces: package script `check:engine-dynamic-import` and verify dry-list entry of the same name. + +- [ ] **Step 1: Add failing wiring assertions** + +Add these imports/constants to `test/scripts/check-engine-dynamic-import.test.ts`: + +```ts +const PACKAGE_JSON = resolve(REPO_ROOT, 'package.json'); +``` + +Append this test block: + +```ts +describe('engine dynamic-import guard wiring', () => { + it('is invoked through bash by check:all', () => { + const pkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf8')) as { + scripts: Record; + }; + expect(pkg.scripts['check:engine-dynamic-import']).toBe( + 'bash scripts/check-engine-dynamic-import.sh', + ); + expect(pkg.scripts['check:all']).toContain( + 'bash scripts/check-engine-dynamic-import.sh', + ); + }); + + it('is listed by the authoritative verify dispatcher', () => { + const result = spawnSync(BASH, [VERIFY_DISPATCHER, '--dry-list'], { + cwd: REPO_ROOT, + encoding: 'utf8', + timeout: 30_000, + }); + expect(result.status).toBe(0); + expect(new Set((result.stdout ?? '').trim().split('\n'))).toContain( + 'check:engine-dynamic-import', + ); + }); +}); +``` + +- [ ] **Step 2: Run the test and verify both wiring assertions fail** + +```bash +bun test test/scripts/check-engine-dynamic-import.test.ts > .context/engine-dynamic-import-wiring-red.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit 0 +``` + +Expected: non-zero Bun result. The source guard tests remain green; package-script and verify-list assertions fail because the wiring is absent. + +- [ ] **Step 3: Add the package scripts** + +In `package.json`, add this script alongside the other `check:*` entries: + +```json +"check:engine-dynamic-import": "bash scripts/check-engine-dynamic-import.sh" +``` + +Append the guard to the existing `check:all` chain, preserving every existing check: + +```text +&& bash scripts/check-engine-dynamic-import.sh +``` + +Do not rewrite any existing shell entry without its `bash` prefix. + +- [ ] **Step 4: Add the authoritative verify entry** + +In `scripts/run-verify-parallel.sh`, add this stable `CHECKS` entry near the other source-shape guards: + +```bash + "check:engine-dynamic-import" +``` + +- [ ] **Step 5: Run the regression test and package check** + +```bash +bun test test/scripts/check-engine-dynamic-import.test.ts > .context/engine-dynamic-import-wiring-green.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc" +``` + +Expected: exit `0`; the full guard regression suite passes. + +```bash +bun run check:engine-dynamic-import > .context/engine-dynamic-import-package-check.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc" +``` + +Expected: exit `0` and three files scanned. + +- [ ] **Step 6: Commit the wiring locally** + +```bash +git add package.json scripts/run-verify-parallel.sh test/scripts/check-engine-dynamic-import.test.ts +``` + +```bash +git commit -m "test(engine): guard dynamic import policy" +``` + +Expected: one local commit with the guard wiring and its regression assertions. + +--- + +### Task 3: Document the current-state invariant + +**Files:** +- Modify: `CLAUDE.md` +- Modify: `docs/architecture/KEY_FILES.md` +- Regenerate: `llms.txt` +- Regenerate: `llms-full.txt` + +**Interfaces:** +- Consumes: the four-marked-import source state and the `check:engine-dynamic-import` package surface. +- Produces: current-state contributor guidance and fresh generated documentation bundles. + +- [ ] **Step 1: Add the cross-cutting invariant to `CLAUDE.md`** + +Add this bullet under “Cross-cutting invariants” near the other language/filesystem guards: + +```md +- **Engine-live paths use static imports by default.** In + `src/core/pglite-engine.ts`, `src/core/postgres-engine.ts`, and + `src/core/migrate.ts`, helper modules are top-level imports. The only current + exceptions are the four `ai/gateway.ts` lookups in both engines' + `initSchema()` and `_upsertChunksOnce()` methods; each remains lazy inside a + local `try/catch` because the gateway has a large provider/config closure and, + more importantly, eager evaluation would occur before the catch and could + turn a recoverable default/config-row fallback into a module-load failure. + Every exception carries `engine-dynamic-import-ok` on the import line. + `scripts/check-engine-dynamic-import.sh` enforces the rule. For history, use + `git log -G'await[[:space:]]+import\\('`, not `git log -S`: a dynamic-to-static + rewrite can preserve the searched token while changing its context. +``` + +Do not add release tags, Windows-crash certainty, or historical branch names. + +- [ ] **Step 2: Update the PGLite current-state entry in `KEY_FILES.md`** + +Append this current-state sentence to the existing `src/core/pglite-engine.ts` entry, preserving the entry as one bullet: + +```md +Engine-path helper dependencies (`retry`, ontology, recency decay) bind statically; the only lazy imports are `ai/gateway.ts` in `initSchema` and `_upsertChunksOnce`, line-marked because their local catches preserve compiled-default and stored-config fallbacks that eager module evaluation would bypass. +``` + +- [ ] **Step 3: Update the Postgres current-state entry in `KEY_FILES.md`** + +Append this sentence to the existing `src/core/postgres-engine.ts` entry: + +```md +Retry classifiers, ontology/recency helpers, and disconnect/pool-recovery audit writers bind statically; only the two `ai/gateway.ts` fallback lookups stay lazy and line-marked, in parity with PGLite. +``` + +- [ ] **Step 4: Update the migration current-state entry in `KEY_FILES.md`** + +Append this sentence to the canonical `src/core/migrate.ts` entry (the broad runner entry, not the older v95-specific index note): + +```md +`retry-matcher.ts` and `timeline-dedup-repair.ts` are static dependencies because `runMigrations()` executes from live engine initialization; the engine dynamic-import guard scans this file with both engine implementations. +``` + +Keep all three entries current-state only: no `v0.42.x`, branch, commit, “previously,” or “was/now” narration. + +- [ ] **Step 5: Regenerate the llms bundles** + +```bash +bun run build:llms > .context/engine-dynamic-import-build-llms.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc" +``` + +Expected: exit `0`; `llms.txt` and/or `llms-full.txt` update according to their configured linked/inlined status. Byte-identical output for a linked source is acceptable; the freshness test is authoritative. + +- [ ] **Step 6: Run documentation freshness checks** + +```bash +bun test test/build-llms.test.ts > .context/engine-dynamic-import-llms-test.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc" +``` + +Expected: exit `0`. + +```bash +bun run check:doc-history > .context/engine-dynamic-import-doc-history.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc" +``` + +Expected: exit `0`; no release-history marker is introduced into current-state reference docs. + +- [ ] **Step 7: Confirm prohibited release files remain untouched** + +```bash +git diff --name-only d7f52d8c..HEAD -- VERSION CHANGELOG.md TODOS.md +``` + +Expected: no output. + +- [ ] **Step 8: Commit documentation and generated bundles locally** + +```bash +git add CLAUDE.md docs/architecture/KEY_FILES.md llms.txt llms-full.txt +``` + +```bash +git commit -m "docs(engine): record static import invariant" +``` + +Expected: one local documentation commit. If one generated bundle is byte-identical, Git simply omits it. + +--- + +### Task 4: Verify and review the complete local reconciliation + +**Files:** +- Verify all files changed since `d7f52d8c`. +- Do not create or modify release/publication metadata. + +**Interfaces:** +- Consumes: Tasks 1–3. +- Produces: full local verification evidence and an implementation diff ready for user review, not publication. + +- [ ] **Step 1: Run the regression test and direct guard again** + +```bash +bun test test/scripts/check-engine-dynamic-import.test.ts > .context/engine-dynamic-import-final-test.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc" +``` + +Expected: exit `0`; the full guard regression suite passes. + +```bash +bash scripts/check-engine-dynamic-import.sh > .context/engine-dynamic-import-final-guard.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc" +``` + +Expected: exit `0`; three files scanned. + +- [ ] **Step 2: Run TypeScript checking** + +```bash +bun run typecheck > .context/engine-dynamic-import-typecheck.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc" +``` + +Expected: exit `0`. Report exact diagnostics if the branch or current Windows environment has a pre-existing failure. + +- [ ] **Step 3: Run the authoritative verify dispatcher** + +```bash +bun run verify > .context/engine-dynamic-import-verify.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc" +``` + +Expected: exit `0`, including `check:engine-dynamic-import`. On Windows, classify any per-check timeout from the complete log instead of treating the aggregate result as a source regression without evidence. + +- [ ] **Step 4: Re-run focused tests as an ownership check** + +```bash +bun test test/chronicle-ontology.test.ts test/chronicle-ontology-ops.test.ts test/recency-decay.test.ts test/core/retry.test.ts test/retry-matcher.test.ts test/audit/pool-recovery-audit.test.ts test/migrate-retry.test.ts test/timeline-dedup-repair.test.ts > .context/engine-dynamic-import-final-focused.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc" +``` + +Expected: exit `0`; record any infrastructure abort separately and rerun only the named file before classifying it. + +- [ ] **Step 5: Run the llms freshness test after all documentation settles** + +```bash +bun test test/build-llms.test.ts > .context/engine-dynamic-import-final-llms.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc" +``` + +Expected: exit `0`. + +- [ ] **Step 6: Run whitespace and scope checks** + +```bash +git diff --check d7f52d8c..HEAD +``` + +Expected: exit `0`, no output. + +```bash +git diff --name-only d7f52d8c..HEAD +``` + +Expected files only: + +```text +CLAUDE.md +docs/architecture/KEY_FILES.md +docs/superpowers/plans/2026-07-28-engine-dynamic-import-reconciliation.md +llms-full.txt +llms.txt +package.json +scripts/check-engine-dynamic-import.sh +scripts/check-engine-dynamic-import.ts +scripts/run-verify-parallel.sh +src/core/migrate.ts +src/core/pglite-engine.ts +src/core/postgres-engine.ts +test/scripts/check-engine-dynamic-import.test.ts +``` + +Either generated llms file may be absent if regeneration proves it byte-identical. `VERSION`, `CHANGELOG.md`, and `TODOS.md` must be absent. + +- [ ] **Step 7: Review the exact implementation diff** + +```bash +git diff --stat d7f52d8c..HEAD && git diff d7f52d8c..HEAD -- src/core/pglite-engine.ts src/core/postgres-engine.ts src/core/migrate.ts scripts/check-engine-dynamic-import.sh test/scripts/check-engine-dynamic-import.test.ts package.json scripts/run-verify-parallel.sh CLAUDE.md docs/architecture/KEY_FILES.md +``` + +Expected review findings: + +- Exactly 13 safe `await import(...)` statements are removed. +- Exactly four `ai/gateway.ts` imports remain, all marked on the same line. +- All four gateway imports remain inside their original local `try/catch` fallback boundaries. +- No accessor logic, fallback ordering, SQL, public signature, or engine parity behavior changes. +- The parser-backed guard reports all violations plus read/parse failures, preserves CRLF line accounting, ignores comments/literals/type-only syntax, detects every runtime `import()` call expression, and accepts opt-outs only from real comment trivia on the same physical line. +- The package script invokes the shell guard through Bash; `check:all` invokes that shell guard directly, and the parallel verify dispatcher invokes the package check. +- Documentation is current-state and makes no deterministic Windows-crash claim. + +**Observed Windows verification classification:** The authoritative aggregate completed with 25 of 33 checks passing. Individual reruns showed `check:test-names` and `typecheck` green; privacy/isolation exceeded Windows timing budgets; WASM failed in unrelated temporary-symlink setup; eval-glossary was CRLF/LF drift; resolver/brain-first findings predated and did not intersect this branch. The focused aggregate produced 103 pass / 5 fail: three setup-hook timeouts reproduced at the untouched base, and the known `migrate-retry` polling failure reproduced there. Its additional race-status assertion did not reproduce at base, so it remains an unresolved timing-sensitive limitation in untouched code—not evidence of an in-scope defect and not claimed as conclusively pre-existing. + +- [ ] **Step 8: Commit the approved plan document locally** + +The plan is an approved, tracked execution artifact and must not be left as an uncommitted file after implementation: + +```bash +git add docs/superpowers/plans/2026-07-28-engine-dynamic-import-reconciliation.md +``` + +```bash +git commit -m "docs: plan engine dynamic-import reconciliation" +``` + +Expected: one local plan commit; no release metadata staged. + +- [ ] **Step 9: Inspect final status without publishing** + +```bash +git status --short --branch +``` + +Expected: branch `claude/kind-meitner-330c90` with a clean working tree. No push, PR, upstream comment, or other external side effect. + +- [ ] **Step 10: Capture the completed milestone to memory** + +Before writing, search MemPalace wing `gbrain` for this exact reconciliation to avoid duplication. Add a verbatim drawer recording exact base/head commits, the 13 hoists, four gateway opt-outs and rationale, guard/test/docs files, every verification command with exit code, and any environment-owned failures. Add a GBrain project timeline entry only if there is an existing relevant gbrain project page; do not create duplicate release metadata. + +- [ ] **Step 11: Report the local result and ask separately before publication** + +Report: + +- exact local commits; +- changed files; +- test/check exit codes; +- any blocked or pre-existing failures; +- confirmation that release files were untouched; +- confirmation that nothing was pushed or published. + +Do not run any publication command. Wait for explicit user approval before any push, PR, or upstream interaction. diff --git a/docs/superpowers/specs/2026-07-28-engine-dynamic-import-reconciliation-design.md b/docs/superpowers/specs/2026-07-28-engine-dynamic-import-reconciliation-design.md new file mode 100644 index 000000000..c1a0b7e0d --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-engine-dynamic-import-reconciliation-design.md @@ -0,0 +1,142 @@ +# Engine dynamic-import reconciliation design + +**Date:** 2026-07-28 + +## Goal + +Reconcile the overlapping engine dynamic-import changes from: + +- `claude/hungry-edison-8bb1cd` at release commits `48ada48f` and `248bfe55` +- `claude/elegant-gates-e5275e` at `ef4cf7a8` + +onto a fresh branch from current `origin/master`, without merging or cherry-picking either lineage wholesale and without adding a release/version bump. + +## Established state + +At investigation time: + +- `origin/master` was `6136e139972a5449630b4f47f5ed7b4cbe5b811b`, version `0.42.67.0`. +- Upstream PR #3511 was still open, so trunk did not contain its two `chronicle/ontology.ts` hoists. +- Neither source branch was an ancestor of trunk. +- Trunk contained 17 dynamic imports in the three engine-path files: + - 13 safe-hoist candidates: two ontology imports, nine engine helper/audit imports, and two migration imports. + - Four `ai/gateway.ts` imports, all inside `try/catch` fallback paths. +- `git log -G` showed the separate ontology, helper, migration, and gateway histories. `git log -S` is not suitable for this dynamic-to-static replacement because the relevant token can remain present while its context changes. +- The guard from `ef4cf7a8` passed against that commit but failed against trunk. It also knew about only two gateway opt-outs because two `_upsertChunksOnce` gateway lookups landed later in trunk. + +## Selected approach + +Reconstruct the intended current state directly on fresh `origin/master`. + +Do not merge or cherry-pick either old lineage. Selectively reproduce the desired source changes, adapt the guard to the current four gateway call sites, and write current-state documentation. This avoids importing stale release metadata, stale TODO claims, and unrelated lineage changes. + +## Source changes + +### Safe static imports + +Hoist all 13 safe candidates: + +- `src/core/pglite-engine.ts` + - `valueHash`, `normalizeDimension`, `isNovelDimension` from `chronicle/ontology.ts` + - `isRetryableConnError` through the existing `retry.ts` import + - `resolveRecencyDecayMap`, `DEFAULT_FALLBACK` from `search/recency-decay.ts` +- `src/core/postgres-engine.ts` + - the same ontology, retry, and recency helpers + - `isConnectionEndedError` from `retry-matcher.ts` + - `logDbDisconnect` from `audit/db-disconnect-audit.ts` + - `logPoolRecovery` from `audit/pool-recovery-audit.ts` +- `src/core/migrate.ts` + - `isStatementTimeoutError`, `isRetryableConnError` from `retry-matcher.ts` + - `repairTimelineDedupIndex` from `timeline-dedup-repair.ts` + +The implementation must keep the two engines in parity where the behavior is shared. Comments should describe current invariants, not repeat an unproven causal claim that these hoists fix the Windows test-runner crash. + +### Deliberately lazy gateway imports + +Keep all four `await import('./ai/gateway.ts')` call sites lazy: + +- PGLite `initSchema` +- PGLite `_upsertChunksOnce` +- Postgres `initSchema` +- Postgres `_upsertChunksOnce` + +Each line receives the explicit `engine-dynamic-import-ok` marker and a concise nearby rationale. + +The rationale has two parts: + +1. The gateway's static closure includes the AI SDK, provider packages, and validation/config machinery, so eager loading would tax engine startup paths that do not otherwise need it. +2. More importantly, each lookup is inside a `try/catch` that preserves a soft fallback (compiled defaults or the brain's stored embedding-model config). Hoisting the module would evaluate it before that catch can run and could convert a recoverable configuration/import failure into a module-load-time hard failure. + +The guard must not allow unmarked gateway imports or a broad file-level exemption. + +## Guard and wiring + +Add `scripts/check-engine-dynamic-import.sh`, adapted from `ef4cf7a8`, with these properties: + +- Default scan set: + - `src/core/pglite-engine.ts` + - `src/core/postgres-engine.ts` + - `src/core/migrate.ts` +- Normalize trailing CR before matching so CRLF checkouts cannot bypass the check. +- Ignore comment-only lines. +- Ignore only lines carrying `engine-dynamic-import-ok`. +- Report every unmarked `await import(` with file and line. +- Explain that contributors should prefer a static import and must justify a real opt-out. +- Avoid asserting that every dynamic import deterministically crashes Windows; the measured evidence supports treating the pattern as an engine-path hardening invariant, while box-level commit exhaustion remained a confound in prior runs. + +Wire it into: + +- `package.json` as `check:engine-dynamic-import` +- `package.json` `check:all` +- `scripts/run-verify-parallel.sh` + +Follow trunk's current rule that package scripts invoke repository shell scripts through `bash`. + +## Regression coverage + +Add an automated test for the guard. It must cover: + +- A real dynamic import produces exit 1 and is reported. +- A line carrying `engine-dynamic-import-ok` is allowed. +- Line comments and block-comment lines do not produce findings. +- The same violation is caught with CRLF input. +- The default repository scan passes after the source reconciliation. + +Use a temporary fixture rather than mutating tracked source files. Keep assertions path-portable. + +The pre-fix red demonstration is the exact guard from `ef4cf7a8` run against current trunk: it exits 1 and reports the existing unmarked imports. The post-fix guard and test must pass. + +## Documentation policy + +Preserve current behavior, not either old release narrative: + +- Do not modify `VERSION` or add a release `CHANGELOG.md` entry. +- Do not copy old version headings or completed release TODO blocks. +- Do not retain the old TODO claiming that extracting gateway accessors is necessarily the fix; the lazy imports are deliberately protected by their local soft-failure boundaries. +- Add the cross-cutting no-unmarked-dynamic-import invariant to `CLAUDE.md`. +- Update the current-state entries for `src/core/pglite-engine.ts`, `src/core/postgres-engine.ts`, and `src/core/migrate.ts` in `docs/architecture/KEY_FILES.md` where needed. +- Regenerate `llms.txt` and `llms-full.txt` after the documentation edits. +- Add a TODO only if implementation uncovers a real unresolved action. + +Public documentation must use generic language and must not overstate the historical Windows crash causality. + +## Verification + +Capture full output to files before inspecting summaries. Run, at minimum: + +1. The guard regression test. +2. `bash scripts/check-engine-dynamic-import.sh`. +3. Focused tests that exercise the touched engine, migration, retry, audit, and recency modules. +4. `bun run typecheck`. +5. `bun run verify`. +6. `bun run build:llms` followed by `bun test test/build-llms.test.ts`. +7. `git diff --check` and a final clean-status/diff review. + +If platform contention or existing Windows suite defects block a broad test, report the exact command, exit code, and ownership classification rather than declaring success from a partial run. + +## Git and publication boundary + +- Work on `claude/kind-meitner-330c90`, reset locally to the exact investigated `origin/master` base. +- Preserve the previous worktree tip under `claude/kind-meitner-330c90-pre-reconcile`. +- Keep implementation and verification commits local. +- Do not push, create a PR, comment upstream, or otherwise publish without explicit user approval after the local result is complete. diff --git a/llms-full.txt b/llms-full.txt index 0184bd797..d694ed6d5 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -216,6 +216,19 @@ Per-file detail is in `docs/architecture/KEY_FILES.md`. text, the cast parses it). Guarded by `scripts/check-jsonb-pattern.sh` (template grep) + `scripts/check-jsonb-params.mjs` (positional AST scanner); the real backstop is the DATABASE_URL-gated e2e parity tests, since PGLite can't surface the bug. Full rule in `docs/ENGINES.md`. +- **Engine-live paths avoid runtime dynamic `import()` for helper dependencies.** In + `src/core/pglite-engine.ts`, `src/core/postgres-engine.ts`, and + `src/core/migrate.ts`, dependencies previously reached through runtime dynamic + imports use static top-level imports. The only current dynamic-`import()` exceptions + are the four `ai/gateway.ts` lookups in both engines' + `initSchema()` and `_upsertChunksOnce()` methods; each remains lazy inside a + local `try/catch` because the gateway has a large provider/config closure and, + more importantly, eager evaluation would occur before the catch and could + turn a recoverable default/config-row fallback into a module-load failure. + Every exception carries `engine-dynamic-import-ok` on the import line. + `scripts/check-engine-dynamic-import.sh` enforces the rule. For history, use + `git log -G'await[[:space:]]+import\\('`, not `git log -S`: a dynamic-to-static + rewrite can preserve the searched token while changing its context. - **Engine parity.** `src/core/postgres-engine.ts` and `src/core/pglite-engine.ts` move in lockstep — a new method/SQL shape lands in BOTH, pinned by `test/e2e/engine-parity.test.ts`. Forward-referenced columns/indexes go in the bootstrap probe set (guarded by diff --git a/package.json b/package.json index d991b2902..0fc26109f 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,8 @@ "check:system-of-record": "bash scripts/check-system-of-record.sh", "check:admin-scope-drift": "bash scripts/check-admin-scope-drift.sh", "check:cli-exec": "bash scripts/check-cli-executable.sh", - "check:all": "bash scripts/check-privacy.sh && bash scripts/check-proposal-pii.sh && bash scripts/check-test-real-names.sh && bash scripts/check-jsonb-pattern.sh && bash scripts/check-source-id-projection.sh && bash scripts/check-source-config-leak.sh && bash scripts/check-progress-to-stdout.sh && bash scripts/check-no-tracked-symlinks.sh && bash scripts/check-no-legacy-getconnection.sh && bash scripts/check-test-isolation.sh && bash scripts/check-trailing-newline.sh && bash scripts/check-wasm-embedded.sh && bash scripts/check-exports-count.sh && bash scripts/check-admin-build.sh && bash scripts/check-admin-scope-drift.sh && bash scripts/check-cli-executable.sh && bash scripts/check-skill-brain-first.sh && bash scripts/check-operations-filter-bypass.sh && bash scripts/check-gateway-routed-no-direct-anthropic.sh && bash scripts/check-worker-pool-atomicity.sh && bash scripts/check-key-files-current-state.sh && bash scripts/check-no-double-retry.sh && bash scripts/check-batch-audit-site.sh", + "check:engine-dynamic-import": "bash scripts/check-engine-dynamic-import.sh", + "check:all": "bash scripts/check-privacy.sh && bash scripts/check-proposal-pii.sh && bash scripts/check-test-real-names.sh && bash scripts/check-jsonb-pattern.sh && bash scripts/check-source-id-projection.sh && bash scripts/check-source-config-leak.sh && bash scripts/check-progress-to-stdout.sh && bash scripts/check-no-tracked-symlinks.sh && bash scripts/check-no-legacy-getconnection.sh && bash scripts/check-test-isolation.sh && bash scripts/check-trailing-newline.sh && bash scripts/check-wasm-embedded.sh && bash scripts/check-exports-count.sh && bash scripts/check-admin-build.sh && bash scripts/check-admin-scope-drift.sh && bash scripts/check-cli-executable.sh && bash scripts/check-skill-brain-first.sh && bash scripts/check-operations-filter-bypass.sh && bash scripts/check-gateway-routed-no-direct-anthropic.sh && bash scripts/check-worker-pool-atomicity.sh && bash scripts/check-key-files-current-state.sh && bash scripts/check-no-double-retry.sh && bash scripts/check-batch-audit-site.sh && bash scripts/check-engine-dynamic-import.sh", "check:gateway-routed": "bash scripts/check-gateway-routed-no-direct-anthropic.sh", "check:worker-pool-atomicity": "bash scripts/check-worker-pool-atomicity.sh", "check:doc-history": "bash scripts/check-key-files-current-state.sh", diff --git a/scripts/check-engine-dynamic-import.sh b/scripts/check-engine-dynamic-import.sh new file mode 100644 index 000000000..7883c9954 --- /dev/null +++ b/scripts/check-engine-dynamic-import.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Engine-live paths use static imports by default. A line-level +# `engine-dynamic-import-ok` marker is required for a justified lazy import. +# +# Historical Windows runs associated imports on these paths with abrupt Bun +# test-process exits, but system-wide commit exhaustion remained a confound. +# This guard therefore enforces a reviewed engine-path hardening invariant; it +# does not claim every dynamic import deterministically crashes Windows. +# +# Usage: +# bash scripts/check-engine-dynamic-import.sh +# bash scripts/check-engine-dynamic-import.sh FILE [FILE...] + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" || exit 1 + +if [ "$#" -gt 0 ]; then + FILES=("$@") +else + ROOT="$(git -C "$SCRIPT_DIR/.." rev-parse --show-toplevel 2>/dev/null || true)" + [ -n "$ROOT" ] || ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + cd "$ROOT" || exit 1 + FILES=( + src/core/pglite-engine.ts + src/core/postgres-engine.ts + src/core/migrate.ts + ) +fi + +exec bun "$SCRIPT_DIR/check-engine-dynamic-import.ts" "${FILES[@]}" diff --git a/scripts/check-engine-dynamic-import.ts b/scripts/check-engine-dynamic-import.ts new file mode 100644 index 000000000..ea0da7137 --- /dev/null +++ b/scripts/check-engine-dynamic-import.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env bun + +import { readFile } from 'node:fs/promises'; +import ts from 'typescript'; + +const MARKER = 'engine-dynamic-import-ok'; +const MARKER_TOKEN_CHAR = /[\p{ID_Continue}$-]/u; +const files = process.argv.slice(2); +const violations: string[] = []; +const readErrors: string[] = []; + +for (const file of files) { + let sourceText: string; + try { + sourceText = await readFile(file, 'utf8'); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + readErrors.push(`ERROR: cannot read input file ${file}: ${detail}`); + continue; + } + + const sourceFile = ts.createSourceFile( + file, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const lines = sourceText.split(/\r?\n/); + const markerLines = new Set(); + + if (sourceFile.parseDiagnostics.length > 0) { + const diagnostics = sourceFile.parseDiagnostics + .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')) + .join('; '); + readErrors.push(`ERROR: cannot parse input file ${file}: ${diagnostics}`); + } + + for (let markerPos = sourceText.indexOf(MARKER); markerPos >= 0; markerPos = sourceText.indexOf(MARKER, markerPos + MARKER.length)) { + const before = Array.from(sourceText.slice(0, markerPos)).at(-1); + const after = Array.from(sourceText.slice(markerPos + MARKER.length))[0]; + const standaloneMarker = (!before || !MARKER_TOKEN_CHAR.test(before)) + && (!after || !MARKER_TOKEN_CHAR.test(after)); + const token = ts.getTokenAtPosition(sourceFile, markerPos); + const insideToken = token.getStart(sourceFile) <= markerPos && markerPos < token.end; + if (standaloneMarker && !insideToken) { + markerLines.add(sourceFile.getLineAndCharacterOfPosition(markerPos).line); + } + } + + function visit(node: ts.Node): void { + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + const { line } = sourceFile.getLineAndCharacterOfPosition(node.expression.getStart(sourceFile)); + const sourceLine = lines[line] ?? ''; + if (!markerLines.has(line)) { + violations.push(` ${file}:${line + 1}:${sourceLine}`); + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); +} + +for (const error of readErrors) console.error(error); + +if (violations.length > 0) { + console.error('ERROR: unreviewed dynamic import on an engine-live path:'); + console.error(); + console.error(violations.join('\n')); + console.error(); + console.error('Prefer a static top-level import. If lazy loading is load-bearing,'); + console.error("append 'engine-dynamic-import-ok' to that exact line and document"); + console.error('the startup or soft-failure boundary that requires it.'); + process.exit(1); +} + +if (readErrors.length > 0) process.exit(1); + +console.log(`check-engine-dynamic-import: ok (${files.length} file(s) scanned)`); diff --git a/scripts/run-verify-parallel.sh b/scripts/run-verify-parallel.sh index fa1689fd4..67f197c90 100755 --- a/scripts/run-verify-parallel.sh +++ b/scripts/run-verify-parallel.sh @@ -64,6 +64,7 @@ CHECKS=( "check:source-scope-onboard" "check:no-double-retry" "check:batch-audit-site" + "check:engine-dynamic-import" "check:worker-lock-renewal-shape" "typecheck" ) diff --git a/src/core/migrate.ts b/src/core/migrate.ts index 085368246..ff666d3d5 100644 --- a/src/core/migrate.ts +++ b/src/core/migrate.ts @@ -2,6 +2,13 @@ import type { BrainEngine } from './engine.ts'; import { slugifyPath } from './sync.ts'; import { getFtsLanguage } from './fts-language.ts'; import { hnswMaxDimsForType } from './vector-index.ts'; +// runMigrations executes while an initialized engine is live. Keep its helper +// modules in the static graph rather than importing them from async handlers. +import { + isStatementTimeoutError, + isRetryableConnError, +} from './retry-matcher.ts'; +import { repairTimelineDedupIndex } from './timeline-dedup-repair.ts'; /** * Schema migrations — run automatically on initSchema(). @@ -5801,7 +5808,6 @@ async function runMigrationSQLWithRetry( m: Migration, sql: string, ): Promise { - const { isStatementTimeoutError, isRetryableConnError } = await import('./retry-matcher.ts'); // GBRAIN_MIGRATE_BACKOFF_MS lets tests skip the 5s/15s/45s backoff. In // production the env var is unset and the default cadence applies. const fastBackoff = process.env.GBRAIN_MIGRATE_BACKOFF_MS; @@ -6071,7 +6077,6 @@ export async function runMigrations(engine: BrainEngine): Promise<{ applied: num // reach the loop below). Best-effort + idempotent: a no-op on a healthy // index; `doctor` surfaces it independently if this ever fails. try { - const { repairTimelineDedupIndex } = await import('./timeline-dedup-repair.ts'); const r = await repairTimelineDedupIndex(engine); if (r.repaired) { console.error( diff --git a/src/core/pglite-engine.ts b/src/core/pglite-engine.ts index 82a5471a9..2042a4641 100644 --- a/src/core/pglite-engine.ts +++ b/src/core/pglite-engine.ts @@ -17,7 +17,26 @@ import type { SourceRow, } from './engine.ts'; import { MAX_SEARCH_LIMIT, clampSearchLimit } from './engine.ts'; -import { withRetry, BULK_RETRY_OPTS, resolveBulkRetryOpts, computeNextDelay, type BatchAuditSite } from './retry.ts'; +// Engine-path imports stay static unless a call site carries an explicit +// engine-dynamic-import-ok justification. The gateway is the only current +// exception because its local try/catch preserves a soft fallback. +import { + withRetry, + BULK_RETRY_OPTS, + resolveBulkRetryOpts, + computeNextDelay, + isRetryableConnError, + type BatchAuditSite, +} from './retry.ts'; +import { + valueHash, + normalizeDimension, + isNovelDimension, +} from './chronicle/ontology.ts'; +import { + resolveRecencyDecayMap, + DEFAULT_FALLBACK, +} from './search/recency-decay.ts'; import { logBatchRetry as auditLogBatchRetry, logBatchExhausted as auditLogBatchExhausted } from './audit/batch-retry-audit.ts'; import { runMigrations } from './migrate.ts'; import { PGLITE_SCHEMA_SQL, getPGLiteSchema } from './pglite-schema.ts'; @@ -419,7 +438,9 @@ export class PGLiteEngine implements BrainEngine { let dims: number = DEFAULT_EMBEDDING_DIMENSIONS; let model: string = DEFAULT_EMBEDDING_MODEL; try { - const gw = await import('./ai/gateway.ts'); + // Keep the gateway lazy: its static closure is large, and evaluation inside + // this try/catch preserves the unconfigured-gateway default fallback. + const gw = await import('./ai/gateway.ts'); // engine-dynamic-import-ok // Both accessors THROW when the gateway is unconfigured (they never // return falsy), so the catch below is the only fallback path (#3461). dims = gw.getEmbeddingDimensions(); @@ -2264,7 +2285,6 @@ export class PGLiteEngine implements BrainEngine { }); } catch (err) { if (err instanceof Error && err.name === 'RetryAbortError') throw err; - const { isRetryableConnError } = await import('./retry.ts'); if (isRetryableConnError(err)) { auditLogBatchExhausted(auditSite, batchSize, opts.maxRetries + 1, err); } @@ -2330,7 +2350,9 @@ export class PGLiteEngine implements BrainEngine { // rationale — pglite mirrors it for parity. let resolvedModel: string | null = null; try { - const gw = await import('./ai/gateway.ts'); + // Keep the gateway lazy so module-load failure remains inside this soft + // fallback boundary; eager evaluation would bypass the config-row fallback. + const gw = await import('./ai/gateway.ts'); // engine-dynamic-import-ok resolvedModel = gw.getEmbeddingModel(); } catch { try { @@ -3842,7 +3864,6 @@ export class PGLiteEngine implements BrainEngine { async mergeOntologyFact(obs: OntologyObservationInput): Promise { const sourceId = obs.sourceId ?? 'default'; - const { valueHash, normalizeDimension, isNovelDimension } = await import('./chronicle/ontology.ts'); const dimension = normalizeDimension(obs.dimension); const vh = valueHash(obs.value); const conf = obs.confidence ?? 0.7; @@ -6005,7 +6026,6 @@ export class PGLiteEngine implements BrainEngine { const recencyBias = opts.recency_bias ?? 'flat'; let recencySql: string; if (recencyBias === 'on') { - const { resolveRecencyDecayMap, DEFAULT_FALLBACK } = await import('./search/recency-decay.ts'); recencySql = buildRecencyComponentSql({ slugColumn: 'p.slug', dateExpr: 'COALESCE(p.effective_date, p.updated_at)', diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index 9ced64cce..1d5a16d09 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -13,7 +13,29 @@ import type { NewFact, FactListOpts, FactsHealth, SourceRow, } from './engine.ts'; -import { withRetry, BULK_RETRY_OPTS, resolveBulkRetryOpts, computeNextDelay, type BatchAuditSite } from './retry.ts'; +// Engine-path imports stay static unless a call site carries an explicit +// engine-dynamic-import-ok justification. The gateway is the only current +// exception because its local try/catch preserves a soft fallback. +import { + withRetry, + BULK_RETRY_OPTS, + resolveBulkRetryOpts, + computeNextDelay, + isRetryableConnError, + type BatchAuditSite, +} from './retry.ts'; +import { isConnectionEndedError } from './retry-matcher.ts'; +import { + valueHash, + normalizeDimension, + isNovelDimension, +} from './chronicle/ontology.ts'; +import { + resolveRecencyDecayMap, + DEFAULT_FALLBACK, +} from './search/recency-decay.ts'; +import { logDbDisconnect } from './audit/db-disconnect-audit.ts'; +import { logPoolRecovery } from './audit/pool-recovery-audit.ts'; import { logBatchRetry as auditLogBatchRetry, logBatchExhausted as auditLogBatchExhausted } from './audit/batch-retry-audit.ts'; import type { DomainBankSampleOpts, CorpusSampleOpts, DomainBankRow, @@ -331,7 +353,6 @@ export class PostgresEngine implements BrainEngine { // even a no-op disconnect (engine that was never connected) is // recorded — that case may itself be a caller-side bug worth seeing. try { - const { logDbDisconnect } = await import('./audit/db-disconnect-audit.ts'); logDbDisconnect('postgres', this._connectionStyle ?? 'unknown'); } catch { /* best-effort; never block disconnect on audit failure */ } // v0.30.1: tear down the direct pool first if the manager owns one. @@ -381,7 +402,9 @@ export class PostgresEngine implements BrainEngine { let dims: number = DEFAULT_EMBEDDING_DIMENSIONS; let model: string = DEFAULT_EMBEDDING_MODEL; try { - const gw = await import('./ai/gateway.ts'); + // Keep the gateway lazy: its static closure is large, and evaluation inside + // this try/catch preserves the unconfigured-gateway default fallback. + const gw = await import('./ai/gateway.ts'); // engine-dynamic-import-ok // Both accessors THROW when the gateway is unconfigured (they never // return falsy), so the catch below is the only fallback path (#3461). dims = gw.getEmbeddingDimensions(); @@ -2381,8 +2404,8 @@ export class PostgresEngine implements BrainEngine { if (err instanceof Error && err.name === 'RetryAbortError') throw err; // Best-effort exhausted-retry log. If the error wasn't retryable in // the first place, isRetryableConnError(err) is false and we skip. - // Lazy-import to avoid a circular dep concern. - const { isRetryableConnError } = await import('./retry.ts'); + // retry.ts is already in this module's static graph through withRetry, so + // classifying the exhausted error does not need a second runtime import. if (isRetryableConnError(err)) { auditLogBatchExhausted(auditSite, batchSize, opts.maxRetries + 1, err); } @@ -2451,7 +2474,9 @@ export class PostgresEngine implements BrainEngine { // is the LAST resort (fresh brain whose config row doesn't exist yet). let resolvedModel: string | null = null; try { - const gw = await import('./ai/gateway.ts'); + // Keep the gateway lazy so module-load failure remains inside this soft + // fallback boundary; eager evaluation would bypass the config-row fallback. + const gw = await import('./ai/gateway.ts'); // engine-dynamic-import-ok resolvedModel = gw.getEmbeddingModel(); } catch { try { @@ -3983,7 +4008,6 @@ export class PostgresEngine implements BrainEngine { async mergeOntologyFact(obs: OntologyObservationInput): Promise { const sql = this.sql; const sourceId = obs.sourceId ?? 'default'; - const { valueHash, normalizeDimension, isNovelDimension } = await import('./chronicle/ontology.ts'); const dimension = normalizeDimension(obs.dimension); const vh = valueHash(obs.value); const conf = obs.confidence ?? 0.7; @@ -5823,12 +5847,10 @@ export class PostgresEngine implements BrainEngine { let isReap = false; if (ctx?.error !== undefined) { try { - const { isConnectionEndedError } = await import('./retry-matcher.ts'); isReap = isConnectionEndedError(ctx.error); } catch { /* classification is best-effort */ } } try { - const { logPoolRecovery } = await import('./audit/pool-recovery-audit.ts'); logPoolRecovery(isReap ? 'reap_detected' : 'reconnect_other', ctx?.error); } catch { /* audit is best-effort */ } @@ -5852,7 +5874,6 @@ export class PostgresEngine implements BrainEngine { // New pool is live — discard the old one best-effort. if (oldSql) { try { await oldSql.end({ timeout: 5 }); } catch { /* swallow */ } } try { - const { logPoolRecovery } = await import('./audit/pool-recovery-audit.ts'); logPoolRecovery('reconnect_succeeded'); } catch { /* best-effort */ } } catch (err) { @@ -5864,7 +5885,6 @@ export class PostgresEngine implements BrainEngine { this._sql = oldSql; this.connectionManager = oldManager; try { - const { logPoolRecovery } = await import('./audit/pool-recovery-audit.ts'); logPoolRecovery('reconnect_failed', err); } catch { /* best-effort */ } throw err; // let batchRetry's backoff handle the retry @@ -6301,7 +6321,6 @@ export class PostgresEngine implements BrainEngine { const recencyBias = opts.recency_bias ?? 'flat'; let recencySql: string; if (recencyBias === 'on') { - const { resolveRecencyDecayMap, DEFAULT_FALLBACK } = await import('./search/recency-decay.ts'); recencySql = buildRecencyComponentSql({ slugColumn: 'p.slug', dateExpr: 'COALESCE(p.effective_date, p.updated_at)', diff --git a/test/scripts/check-engine-dynamic-import.test.ts b/test/scripts/check-engine-dynamic-import.test.ts new file mode 100644 index 000000000..aa35d4699 --- /dev/null +++ b/test/scripts/check-engine-dynamic-import.test.ts @@ -0,0 +1,330 @@ +import { afterEach, describe, expect, it, setDefaultTimeout } from 'bun:test'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const REPO_ROOT = resolve(import.meta.dir, '..', '..'); +const PACKAGE_JSON = resolve(REPO_ROOT, 'package.json'); +const GUARD = resolve(REPO_ROOT, 'scripts', 'check-engine-dynamic-import.sh'); +const VERIFY_DISPATCHER = resolve(REPO_ROOT, 'scripts', 'run-verify-parallel.sh'); +const BASH = process.platform === 'win32' + ? resolve(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe') + : 'bash'; +const tempDirs: string[] = []; + +setDefaultTimeout(30_000); + +function fixture(name: string, content: string): string { + const dir = mkdtempSync(join(tmpdir(), 'gbrain-engine-import-')); + tempDirs.push(dir); + const path = join(dir, name); + writeFileSync(path, content, 'utf8'); + return path; +} + +function runGuard(files: string[] = [], cwd = REPO_ROOT) { + const result = spawnSync(BASH, [GUARD, ...files], { + cwd, + encoding: 'utf8', + timeout: 30_000, + }); + return { + code: result.status ?? -1, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('check-engine-dynamic-import.sh', () => { + it('exists', () => { + expect(existsSync(GUARD)).toBe(true); + }); + + it('rejects and reports an unmarked dynamic import', () => { + const path = fixture('violator.ts', "async function load() {\n return await import('./helper.ts');\n}\n"); + const result = runGuard([path]); + expect(result.code).toBe(1); + expect(result.stderr).toContain(`${basename(path)}:2:`); + expect(result.stderr).toContain("await import('./helper.ts')"); + }); + + it('allows a same-line marker for multiple non-gateway imports and ignores comment-only matches', () => { + const path = fixture( + 'allowed.ts', + [ + "// await import('./comment.ts')", + '/*', + " * await import('./block-body.ts')", + ' */', + "const first = import('./first.ts'); const second = import('./second.ts'); // engine-dynamic-import-ok", + '', + ].join('\n'), + ); + const result = runGuard([path]); + expect(result.code).toBe(0); + expect(result.stdout).toContain('check-engine-dynamic-import: ok (1 file(s) scanned)'); + }); + + it('rejects live code after a closed leading block comment', () => { + const path = fixture( + 'leading-block-comment.ts', + "/* load only when needed */ const helper = await import('./helper.ts');\n", + ); + const result = runGuard([path]); + expect(result.code).toBe(1); + expect(result.stderr).toContain(`${basename(path)}:1:`); + expect(result.stderr).toContain("await import('./helper.ts')"); + }); + + it('ignores dynamic-import text wholly inside a multiline block comment', () => { + const path = fixture( + 'multiline-block-comment.ts', + [ + '/*', + " * await import('./comment-only.ts')", + ' */', + 'const value = 1;', + '', + ].join('\n'), + ); + const result = runGuard([path]); + expect(result.code).toBe(0); + expect(result.stdout).toContain('check-engine-dynamic-import: ok (1 file(s) scanned)'); + }); + + it('reports every violation across multiple files', () => { + const first = fixture( + 'first-violator.ts', + "const first = await import('./first.ts');\nconst second = await import('./second.ts');\n", + ); + const second = fixture( + 'second-violator.ts', + "/* explanation */ const third = await import('./third.ts');\n", + ); + const result = runGuard([first, second]); + expect(result.code).toBe(1); + expect(result.stderr).toContain(`${basename(first)}:1:`); + expect(result.stderr).toContain(`${basename(first)}:2:`); + expect(result.stderr).toContain(`${basename(second)}:1:`); + }, 30_000); + + it('does not mistake comment delimiters inside literals for comments', () => { + const path = fixture( + 'literal-delimiters.ts', + [ + 'const url = "https://example.test";', + 'const block = "/* not a comment";', + 'const template = `https://example.test`;', + 'const pattern = /\\/\\//;', + "const helper = await import('./helper.ts');", + '', + ].join('\n'), + ); + const result = runGuard([path]); + expect(result.code).toBe(1); + expect(result.stderr).toContain(`${basename(path)}:5:`); + }, 30_000); + + it('detects bare and trivia-separated dynamic imports', () => { + const path = fixture( + 'dynamic-import-syntax.ts', + [ + "const first = import('./first.ts');", + "const second = await import /* explanation */ ('./second.ts');", + '', + ].join('\n'), + ); + const result = runGuard([path]); + expect(result.code).toBe(1); + expect(result.stderr).toContain(`${basename(path)}:1:`); + expect(result.stderr).toContain(`${basename(path)}:2:`); + }, 30_000); + + it('detects live code after a multiline block comment closes', () => { + const path = fixture( + 'after-multiline-comment.ts', + "/*\n * explanation\n */ const helper = await import('./helper.ts');\n", + ); + const result = runGuard([path]); + expect(result.code).toBe(1); + expect(result.stderr).toContain(`${basename(path)}:3:`); + }); + + it('requires the allow marker on the import line', () => { + const path = fixture( + 'marker-line.ts', + "// engine-dynamic-import-ok\nconst helper = await import('./helper.ts');\n", + ); + const result = runGuard([path]); + expect(result.code).toBe(1); + expect(result.stderr).toContain(`${basename(path)}:2:`); + }); + + it('does not accept marker text outside comment trivia or within longer comment tokens', () => { + const path = fixture( + 'marker-text.ts', + [ + "const first = import('./engine-dynamic-import-ok.ts');", + "const marker = 'engine-dynamic-import-ok'; const second = import('./second.ts');", + 'const template = `prefix', + '// engine-dynamic-import-ok ${import("./third.ts")}`;', + "const fourth = import('./fourth.ts'); // no-engine-dynamic-import-ok: not approved", + '', + ].join('\n'), + ); + const result = runGuard([path]); + expect(result.code).toBe(1); + expect(result.stderr).toContain(`${basename(path)}:1:`); + expect(result.stderr).toContain(`${basename(path)}:2:`); + expect(result.stderr).toContain(`${basename(path)}:4:`); + expect(result.stderr).toContain(`${basename(path)}:5:`); + }); + + it('does not accept markers adjacent to Unicode identifier characters', () => { + const path = fixture( + 'unicode-marker-text.ts', + [ + "const first = import('./first.ts'); // noéengine-dynamic-import-ok: not approved", + "const second = import('./second.ts'); // engine-dynamic-import-oké: not approved", + "const third = import('./third.ts'); // éengine-dynamic-import-oké: not approved", + "const fourth = import('./fourth.ts'); // nóengine-dynamic-import-ok: not approved", + "const fifth = import('./fifth.ts'); // 𐐀engine-dynamic-import-ok: not approved", + "const sixth = import('./sixth.ts'); // engine-dynamic-import-ok𐐀: not approved", + '', + ].join('\n'), + ); + const result = runGuard([path]); + expect(result.code).toBe(1); + expect(result.stderr).toContain(`${basename(path)}:1:`); + expect(result.stderr).toContain(`${basename(path)}:2:`); + expect(result.stderr).toContain(`${basename(path)}:3:`); + expect(result.stderr).toContain(`${basename(path)}:4:`); + expect(result.stderr).toContain(`${basename(path)}:5:`); + expect(result.stderr).toContain(`${basename(path)}:6:`); + }); + + it('allows a marker in real multiline comment trivia on the import line', () => { + const path = fixture( + 'multiline-marker.ts', + [ + '/* rationale', + ' * engine-dynamic-import-ok */ const helper = import("./helper.ts");', + '', + ].join('\n'), + ); + const result = runGuard([path]); + expect(result.code).toBe(0); + }); + + it('fails on TypeScript parse diagnostics', () => { + const path = fixture('malformed.ts', 'const broken = ;\n'); + const result = runGuard([path]); + expect(result.code).toBe(1); + expect(result.stderr).toContain('cannot parse input file'); + expect(result.stderr).toContain(basename(path)); + }); + + it('reports recovered-AST violations alongside parse diagnostics', () => { + const path = fixture( + 'malformed-violator.ts', + "const helper = import('./helper.ts');\nconst broken = ;\n", + ); + const result = runGuard([path]); + expect(result.code).toBe(1); + expect(result.stderr).toContain('cannot parse input file'); + expect(result.stderr).toContain(`${basename(path)}:1:`); + }); + + it('ignores type-position imports', () => { + const path = fixture( + 'type-import.ts', + "type Helper = import('./helper.ts').Helper;\n", + ); + const result = runGuard([path]); + expect(result.code).toBe(0); + }); + + it('reports readable-file violations alongside missing inputs', () => { + const path = fixture('mixed-violator.ts', "const helper = import('./helper.ts');\n"); + const missing = join(tmpdir(), `gbrain-engine-import-missing-${process.pid}.ts`); + const result = runGuard([path, missing]); + expect(result.code).toBe(1); + expect(result.stderr).toContain(`${basename(path)}:1:`); + expect(result.stderr).toContain('cannot read input file'); + expect(result.stderr).toContain(basename(missing)); + }); + + it('fails when an explicit input file is missing', () => { + const missing = join(tmpdir(), `gbrain-engine-import-missing-${process.pid}.ts`); + const result = runGuard([missing]); + expect(result.code).toBe(1); + expect(result.stderr).toContain('cannot read input file'); + expect(result.stderr).toContain(basename(missing)); + }); + + it('resolves default inputs from the guard repository', () => { + const foreign = mkdtempSync(join(tmpdir(), 'gbrain-engine-import-foreign-')); + tempDirs.push(foreign); + const foreignCore = join(foreign, 'src', 'core'); + mkdirSync(foreignCore, { recursive: true }); + expect(spawnSync('git', ['init', '-q'], { cwd: foreign }).status).toBe(0); + writeFileSync( + join(foreignCore, 'pglite-engine.ts'), + "const foreign = import('./foreign.ts');\n", + 'utf8', + ); + for (const name of ['postgres-engine.ts', 'migrate.ts']) { + writeFileSync(join(foreignCore, name), '', 'utf8'); + } + + const result = runGuard([], foreign); + expect(result.code).toBe(0); + expect(result.stdout).toContain('check-engine-dynamic-import: ok (3 file(s) scanned)'); + }, 30_000); + + it('still catches a violation in CRLF input', () => { + const path = fixture('crlf.ts', "async function load() {\r\n return await import('./helper.ts');\r\n}\r\n"); + const result = runGuard([path]); + expect(result.code).toBe(1); + expect(result.stderr).toContain(`${basename(path)}:2:`); + }); + + it('passes on the reconciled repository sources', () => { + const result = runGuard(); + expect(result.code).toBe(0); + expect(result.stdout).toContain('check-engine-dynamic-import: ok (3 file(s) scanned)'); + }, 30_000); +}); + +describe('engine dynamic-import guard wiring', () => { + it('is invoked through bash by check:all', () => { + const pkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf8')) as { + scripts: Record; + }; + expect(pkg.scripts['check:engine-dynamic-import']).toBe( + 'bash scripts/check-engine-dynamic-import.sh', + ); + expect(pkg.scripts['check:all']).toContain( + 'bash scripts/check-engine-dynamic-import.sh', + ); + }); + + it('is listed by the authoritative verify dispatcher', () => { + const result = spawnSync(BASH, [VERIFY_DISPATCHER, '--dry-list'], { + cwd: REPO_ROOT, + encoding: 'utf8', + timeout: 30_000, + }); + expect(result.status).toBe(0); + expect(new Set((result.stdout ?? '').trim().split('\n'))).toContain( + 'check:engine-dynamic-import', + ); + }); +});