Files
gbrain/test/doctor.test.ts
Garry Tan d28be5d091 v0.40.4.0 feat(search): selective graph signals + per-stage attribution + audit-writer unification (#1300)
* v0.40.4.0 T1: shared audit-writer primitive

Extract createAuditWriter() helper. Five hand-rolled JSONL audit
modules (rerank-audit, shell-audit, supervisor-audit, audit-slug-
fallback, phantom-audit) duplicated the same ISO-week filename math,
best-effort write loop, and read-current-plus-previous-week loop.
T2 refactors all 5 onto this primitive.

Behavior preservation: filename format, JSONL line shape, mkdir
recursive, appendFileSync utf8, stderr-on-failure all byte-identical
to the existing modules so their tests pass unchanged.

resolveAuditDir() moves here from shell-audit.ts; shell-audit.ts
will re-export for back-compat (T2). Honors GBRAIN_AUDIT_DIR with
whitespace-trim, falls back to ~/.gbrain/audit/.

Test coverage: 22 cases covering ISO-week math + year-boundary edges
(2027-01-01 → 2026-W53), env override, mkdir-recursive, fail-open
stderr-warn shape, cross-week readback, corrupt-row skip, non-finite-
ts skip, round-trip with nested fields, computeFilename + resolveDir
accessors.

Plan ref: D5=B audit unification cathedral expansion.

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

* v0.40.4.0 T2: refactor 5 audit modules onto shared writer

Replace the duplicated ISO-week filename math + best-effort write loop
+ read-current-plus-previous-week loop in:
  - src/core/rerank-audit.ts (rerank-failures-*.jsonl)
  - src/core/audit-slug-fallback.ts (slug-fallback-*.jsonl)
  - src/core/minions/handlers/shell-audit.ts (shell-jobs-*.jsonl)
  - src/core/minions/handlers/supervisor-audit.ts (supervisor-*.jsonl)
  - src/core/facts/phantom-audit.ts (phantoms-*.jsonl)

All five now delegate file I/O to createAuditWriter from T1. Public
API preserved bit-for-bit:
  - logRerankFailure, readRecentRerankFailures, computeRerankAuditFilename
  - logSlugFallback, readRecentSlugFallbacks, computeSlugFallbackAuditFilename
  - logShellSubmission, computeAuditFilename, resolveAuditDir
  - writeSupervisorEvent, readSupervisorEvents, computeSupervisorAuditFilename
    plus isCrashExit, summarizeCrashes, CrashSummary (domain-specific
    helpers stay in supervisor-audit.ts; only file I/O moves)
  - logPhantomEvent, readRecentPhantomEvents, computePhantomAuditFilename

Domain-specific behavior preserved:
  - audit-slug-fallback emits per-call stderr (D7 dual logging) in the
    caller; the shared writer is failure-only stderr
  - rerank-audit truncates error_summary to 200 chars before write
  - phantom-audit spreads optional fields conditionally (skip undefined)
  - supervisor-audit keeps single-file readback (no cross-week walk)
    to preserve pre-v0.40.4 doctor assertions

resolveAuditDir lives in src/core/audit/audit-writer.ts; shell-audit.ts
re-exports it so existing imports keep working (every other audit
module + gbrain-home-isolation.test.ts + minions.test.ts +
minions-shell.test.ts pull resolveAuditDir from shell-audit.ts).

Operator-visible drift: rerank-audit stderr line drops the
'rerank-failure audit' qualifier — was '[gbrain] rerank-failure audit
write failed (...)' now '[gbrain] write failed (...); search continues'.
Stderr is human-debugging, not machine-parsed; the file written gives
the qualifier away in `tail -f audit/*`.

Test coverage: 128/128 audit-touching tests pass unchanged.

Plan ref: D5=B audit unification cathedral expansion.

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

* v0.40.4.0 T3: getAdjacencyBoosts engine method (PG+PGLite parity)

Add BrainEngine.getAdjacencyBoosts(pageIds) returning Map<page_id,
AdjacencyRow{hits, cross_source_hits}>. Returns ALL pages with
hits >= 1 (callers apply their own threshold).

Cross-source semantic (D15=A): cross_source_hits EXCLUDES the target
page's own source. A page in source A linked from 2 pages in source A
reports cross_source_hits = 0. Linked from 1 in source B + 1 in
source C reports 2.

Source-scope contract: pageIds MUST already be source-scoped by the
caller. Method does NOT filter by source_id. The in-set restriction
makes cross-source leakage impossible by construction. JSDoc spells
this out; same trust posture as cosineReScore's chunk_id handling.

COALESCE(p.source_id, 'default') on both target and from-page sides
for defense-in-depth even though pages.source_id is NOT NULL today.

JSDoc/SQL contract alignment (codex #2): HAVING >= 1 matches the
"returns ALL pages with hits >= 1" contract; threshold of 2 is the
caller's call in applyGraphSignals.

Known limitation (codex #15): cross_source_hits cannot distinguish
"genuinely linked from another team" from "mirrored imports from
another source." T-todo-4 captures the v0.41+ refinement.

SearchResult type extension (D4=A flat fields, D12=A attribution):
  - graph_adjacency_hits, graph_cross_source_hits,
    graph_session_demoted, graph_session_prefix
  - base_score, backlink_boost, salience_boost, recency_boost,
    exact_match_boost, graph_adjacency_boost, graph_cross_source_boost,
    session_demote_factor, reranker_delta
All optional; T4-T6 populate them.

Test coverage: 7/7 hermetic PGLite cases. Empty input, singleton,
same-source hub, cross-source attribution including the
"linked-only-from-other-source" case (widget in source b, linked
from alice+bob in source a → cross_source_hits=1), JSDoc HAVING>=1
contract. Postgres parity asserted by SQL-shape identity (will get a
mirror Postgres E2E in T10's eval gate work via DATABASE_URL when
set; PGLite hermetic case shipped now).

NULL source_id COALESCE branch noted as untestable in current PGLite
schema (pages.source_id is NOT NULL); kept as defense-in-depth.

Plan ref: T3 in v0.40.4.0 wave plan; D1=A, D3=A, D15=A.

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

* v0.40.4.0 T4+T11: applyGraphSignals 4th stage in runPostFusionStages

New file src/core/search/graph-signals.ts. Three signals:

  1. Adjacency-within-top-K (×1.05): hits >= 2 inbound from in-set.
  2. Cross-source adjacency (×1.10, stacks): cross_source_hits >= 2.
     Dormant on single-source brains.
  3. Session diversification (×0.95): if multiple top-K share a slug
     prefix, keep highest scoring, DEMOTE the rest. NOT amplify —
     codex caught the original framing was backwards (amplification
     of redundancy makes the cited "weak chunks compete for budget"
     problem worse, not better).

Conservative magnitudes (D14=B): 1.05/1.10/0.95. Score-distribution
probe (onScoreDistribution) collects min/p25/p50/p75/p95/max +
reorder_band_width to feed T-todo-2 magnitude calibration wave.

Slot: 4th stage inside runPostFusionStages (hybrid.ts:248), AFTER
backlink/salience/recency, pre-dedup. Inherits the v0.35.6.0
floor-ratio gate from computeFloorThreshold — this is the structural
protection that prevents a low-cosine hub from outranking a strong
non-hub (codex T2 / D1=A).

PostFusionOpts extends with graphSignalsEnabled, onGraphMeta,
onScoreDistribution. Caller (hybridSearch in subsequent T5 work)
resolves graph_signals from the mode bundle.

Source-scope contract preserved: getAdjacencyBoosts takes raw
page_ids, no source filter. Adjacency is in-set restricted so
cross-source leakage is impossible by construction (D3=A).

Fail-open: engine throw → JSONL audit row via shared createAuditWriter
(T1/T2 primitive, featureName='graph-signals-failures') + meta.errored
+ caller's results unchanged. Session diversification ALSO skips on
failure (predictable all-or-nothing posture).

Mutation note (codex #9): score mutated in place. base_score must be
stamped at runPostFusionStages entry BEFORE this stage so eval-capture
sees pre-boost score (T6 attribution wave).

Test coverage (24 cases, including T11 IRON RULE regression):
  - sessionPrefix multi/single/empty cases
  - computeScoreDistribution percentile math
  - Disabled + empty short-circuits
  - Adjacency hit, no-hit, cross-source stacking, cross-source alone
  - Session diversification 3-share + single-segment + singleton
  - Test seam injection (no engine call)
  - Fail-open: throw → audit row + meta.errored + unchanged
  - Empty Map → session still runs
  - Score-distribution always emits when enabled
  - Meta carries fire counts + duration_ms
  - Missing page_id silently skipped from dedup set
  - **T11 IRON RULE regression (3 cases):**
    * weak hub BELOW floor_threshold does NOT get boosted past
      above-floor non-hub (the bug class the floor gate exists for)
    * hub AT floor still gets boosted (gate is < not <=)
    * NaN score → NaN >= threshold is false → no boost

Plan ref: T4 + T11 in v0.40.4.0 wave plan; D1=A, D2=A, D11=B, D14=B,
D9=A, D5=B. Codex outside-voice #1 + #2 + #6 + #8 + #9 addressed.

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

* v0.40.4.0 T5: graph_signals mode-bundle knob + KNOBS_HASH bump 3→4

ModeBundle gains graph_signals: boolean. Per-mode defaults:
  - conservative: false (cost-sensitive tier)
  - balanced:     true (the wave's primary surface for default-on)
  - tokenmax:     true (power-user tier, capstone fit)

SearchKeyOverrides + SearchPerCallOpts gain optional graph_signals
field. resolveSearchMode picks via the standard per-call → config
override → mode bundle chain.

loadOverridesFromConfig parses 'search.graph_signals' from the config
table ('1' or 'true' → true). SEARCH_MODE_CONFIG_KEYS adds the key
so `gbrain search modes --reset` clears it alongside other knobs.

KNOBS_HASH_VERSION bump 3→4 (append-only per CDX2-F13). New `gs=`
parts entry appended AFTER cross-modal + column + prov entries. A
graph-on cache write cannot be served to a graph-off lookup —
mid-deploy hit-rate dip clears within cache.ttl_seconds (3600s).

src/commands/search.ts KNOB_DESCRIPTIONS gains graph_signals entry
so `gbrain search modes` dashboard renders the new knob.

Test coverage:
  - test/search-mode.test.ts (+ 8 new cases): per-mode defaults
    canonical, config override both directions, per-call override
    wins, knobsHash distinct for on/off, config key registered,
    attributeKnob reports per-call + mode sources correctly.
  - test/search/knobs-hash-reranker.test.ts: version assertion
    bumped 3→4 with v0.40.4 rationale comment.
  - test/cross-modal-phase1.test.ts: version assertion bumped
    3→4 with v0.40.4 rationale comment.
  - Canonical-bundle assertions updated to include graph_signals
    in expected shape (3 cases).

50/50 search-mode tests pass. 45/45 cross-modal pass. 17/17
knobs-hash-reranker pass. 10/10 balanced-reranker pass.

Plan ref: T5 in v0.40.4.0 wave plan.

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

* v0.40.4.0 T6: per-stage attribution stamping in every boost

Every boost stage that mutates SearchResult.score now stamps a field
recording WHAT it multiplied:

  - applyBacklinkBoost  → backlink_boost (skipped when count == 0)
  - applySalienceBoost  → salience_boost (skipped when score == 0)
  - applyRecencyBoost   → recency_boost (skipped on evergreen prefix)
  - applyExactMatchBoost → exact_match_boost (skipped on no-match
    OR when intent's exactMatchBoost == 1.0 no-op)
  - runPostFusionStages → base_score stamped ONCE at entry, BEFORE
    any boost mutates r.score. Idempotent: caller-pre-stamped value
    preserved. Empty-results short-circuit unchanged.
  - applyReranker → reranker_delta = original_index - new_index
    (positive = rank improved; raw rerank score stays in rerank_score)
  - applyGraphSignals → graph_adjacency_boost, graph_cross_source_boost,
    session_demote_factor (T4 already stamped these)

Why: feeds the T7 `gbrain search --explain` formatter so it can
attribute the final score to its components. Without these stamps,
"why did this rank where it did?" is grep-and-guess.

SearchResult.reranker_delta doc updated to clarify it's a RANK delta
(positive = improved), not a score delta. The raw relevance score
stays in `rerank_score` (untyped, for back-compat with telemetry that
already reads it).

Test coverage: 16 new cases in test/search/attribution-stamping.test.ts.
Pins: every boost stamps when it fires AND skips stamping when it
doesn't (no false attribution on no-op stages). base_score idempotency
preserved. reranker_delta computed correctly across rank-improved +
rank-degraded cases.

All 178/178 search tests pass (no regressions).

Plan ref: T6 cathedral expansion in v0.40.4.0 wave plan; D12=A.

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

* v0.40.4.0 T7: gbrain search --explain per-stage attribution

New file src/core/search/explain-formatter.ts renders SearchResult[]
as a multi-line breakdown of how the final score was formed:

  1. people/alice (score=12.4)
     base=10.2 (rrf+cosine)
     + backlink ×1.08
     + salience ×1.05
     + adjacency ×1.05 (hits=3)
     + cross_source ×1.10 (other_sources=2)
     ↑ reranker rank +2
     = final 12.4

Reads the boost_* / base_score / *_hits fields populated by T4 + T6.
Empty path: "no boosts applied" when no stage stamped anything.
Session demote rendered with `-` prefix (not `+`) so the demotion
direction is visually distinct from boosts.

CliOptions gains `explain: boolean`; parseGlobalFlags recognizes
`--explain` anywhere in argv. cli.ts formatResult for `search` +
`query` cases reads CliOptions.explain via the module-level
singleton and routes to formatResultsExplain when set. Lazy import
keeps the hot path narrow for the common non-explain case.

Number formatting: 4-decimal precision, trailing zeros stripped
('1.0000' → '1', '0.1234' → '0.1234'). NaN preserved as 'NaN'.

Test coverage:
  - test/search/explain-formatter.test.ts: 19 cases pin output
    format. Each boost type renders correctly, every-stage stacking
    composes, reranker_delta=0 doesn't render, empty list short-
    circuits, rank numbering 1-based, number formatting edge cases.
  - test/cli-options.test.ts: 3 new cases for --explain parsing
    (basic, absent default, any-argv-position).

Existing CliOptions literals in test/cli-options.test.ts +
test/thin-client-upgrade-prompt.test.ts updated for new required
explain field.

JSON envelope unchanged — the same attribution fields surface in
existing --json output via JSON.stringify; no separate JSON formatter
needed.

Plan ref: T7 cathedral expansion in v0.40.4.0 wave plan; D12=A + D6=A.

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

* v0.40.4.0 T8: doctor check graph_signals_coverage

New checkGraphSignalsCoverage in src/commands/doctor.ts. Wired into
both runDoctor (local engine) and doctorReportRemote (HTTP MCP /
JSON path) so local AND remote-server brains both surface the metric.

Logic:
  1. Resolve active graph_signals setting: config override
     'search.graph_signals' wins, else mode bundle default
     ('search.mode' → conservative=false, balanced/tokenmax=true).
  2. When disabled → silent ok ("disabled — coverage not checked").
     Avoids polluting doctor output on installs that don't use the
     feature.
  3. When enabled, compute global inbound-link density:
     COUNT(DISTINCT to_page_id) / COUNT(*) across non-deleted pages.
  4. <10% → warn ("signal will rarely fire") with paste-ready
     `gbrain extract all` fix hint.
  5. >=30% → ok ("fire on most queries") with metric.
  6. 10-29% → ok ("fire occasionally") with metric.

Known limitation (codex outside-voice #14): global density is an
imperfect proxy for "top-K subgraphs have enough edges to fire."
T-todo-5 captures the v0.41+ refinement that measures actual fire
rate from search-stats after 30 days of data.

Best-effort: SQL errors → warn with the underlying message. Never
breaks doctor.

Test coverage (7 new cases in test/doctor.test.ts):
  - conservative mode → silent ok regardless of coverage
  - balanced default + 0 links → warn at 0% with fix hint
  - balanced default + 40% inbound → ok "fire on most queries"
  - balanced default + 20% inbound → ok "fire occasionally"
  - explicit search.graph_signals=false overrides mode default
  - empty brain → ok with explanation
  - check is wired into runDoctor (source-grep regression guard)

All 55/55 doctor.test.ts cases pass.

Plan ref: T8 in v0.40.4.0 wave plan; D6=A.

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

* v0.40.4.0 T9: gbrain search stats graph_signals section

runStatsSubcommand in src/commands/search.ts gains a graph_signals
section in both --json and human output:

  Graph signals:
    enabled:    true (mode default)
    failures:   3 fail-open event(s)
      ECONNREFUSED         2
      timeout              1

Data sources:
  - config: 'search.graph_signals' override → enabled + source=config,
    otherwise mode-bundle default → enabled + source=mode_default.
  - JSONL audit: readRecentGraphSignalsFailures(days) returns events;
    failures_count is len, failures_by_reason buckets by first word of
    error_summary (e.g. 'ECONNREFUSED', 'timeout').

JSON envelope (schema_version 2 unchanged; graph_signals is a new
sibling property of stats, so consumers reading the existing fields
keep working):

  {
    "schema_version": 2,
    ...stats...,
    "graph_signals": {
      "enabled": bool,
      "source": "config" | "mode_default",
      "failures_count": int,
      "failures_by_reason": { reason: count }
    },
    "_meta": { metric_glossary: { ..., graph_signals_enabled: ..., graph_signals_failures_count: ... } }
  }

Fire-rate metrics (adjacency_fires, cross_source_fires,
session_demotions) and score-distribution stats are NOT in this
section yet — they require telemetry-table writes from the
applyGraphSignals onMeta callback. Wired in v0.41+ via T-todo-2
calibration wave (the wave that needs them). For v0.40.4: status +
error count is the actionable surface for "is graph_signals on, and
is it failing?"

Human output: prints the section after the existing stats block.
Edge case: when total_calls is 0 BUT graph_signals is enabled OR
has historical failures, still prints the section so operators
don't lose the signal on a brain with no telemetry yet.

Test coverage (6 cases in test/search/search-stats-graph-signals.test.ts):
  - search.graph_signals=true → enabled true, source=config
  - mode=conservative → enabled false, source=mode_default
  - no config → enabled true (balanced default), source=mode_default
  - JSONL failures bucketed by first word of error_summary
  - empty audit → failures_count 0, empty failures_by_reason
  - human output includes "Graph signals:" header

Plan ref: T9 in v0.40.4.0 wave plan; D6=A.

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

* v0.40.4.0 T10: eval gates (longmemeval-mini A/B + paired bootstrap)

New test/e2e/graph-signals-eval.test.ts runs each longmemeval-mini
question twice (graph_signals off, graph_signals on) and asserts:

  Gate 1 (QUALITY) — paired bootstrap, 10,000 resamples:
    - If signals-on is significantly WORSE than off
      (delta < 0 AND p < 0.05) → fail.
    - Otherwise pass. p>=0.05 either direction OR delta >= 0 → ok.

  Gate 2a (CHANGE-MAGNITUDE): mean Jaccard@5 over result-set overlap
    must be >= 0.5. If results overlap less than half, the change is
    too large and needs human review before default-on.

  Gate 2b (CHANGE-MAGNITUDE): top-1 stability rate >= 0.7. If 30%+
    of top picks change, hard look required.

  Gate 3 (HARD ABSOLUTE FLOOR): recall@5 drop <= 5pt. Catastrophic
    regression catch (codex outside-voice #18 — addresses the "top-5
    must not drop at all" brittleness on tiny fixtures).

Bootstrap implementation:
  - Per-question observation is binary (recall@5 hit/miss).
  - Paired pairing on question_id between on/off branches.
  - Centered distribution under null (subtract observed mean) per
    standard paired-bootstrap-shift approach for binary outcomes.
  - Two-tailed p-value: |resampled delta| >= |observed delta|.
  - Deterministic seeded RNG so test runs are stable across CI.

pairedBootstrapPValue exported as a pure function with separate
tests for edge cases (empty input, all-equal, strong positive, strong
negative, determinism). Reusable from future calibration waves.

Hermetic: in-memory PGLite via createBenchmarkBrain + resetTables
between questions. No API keys needed (--no-embed import path
exercises keyword-only retrieval). Skips gracefully via describe.skip
when the fixture is missing.

Plan ref: T10 in v0.40.4.0 wave plan; D7=C absolute floor + D13=A
paired bootstrap; codex #4 + #18 stability-vs-quality distinction.

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

* v0.40.4.0 T12: VERSION + package.json + CHANGELOG + TODOS

VERSION: 0.37.11.0 → 0.40.4.0
package.json: 0.37.11.0 → 0.40.4.0
CHANGELOG.md: top entry for v0.40.4.0 in ELI10-lead voice per
  CLAUDE.md release rules. Lead is plain-English ("Your search now
  notices when a page is a hub for your query"); precise file paths
  / SQL semantics / numbers live in the "Itemized changes" section
  below. Includes the cathedral-expansion notes (D5=B audit
  unification, D12=A per-stage attribution, D13=A eval gates) and
  the "To take advantage of v0.40.4.0" verify-and-fix block.

TODOS.md: 5 new items captured under "v0.40.4 graph signals —
deferred follow-ups (v0.41+)":
  - T-todo-1: profile graph-signal SQL latency, merge if hot (D8=C)
  - T-todo-2: magnitude calibration wave from probe data (D14=B / D17)
  - T-todo-3: DB-backed audit table for cross-deploy observability (codex #15)
  - T-todo-4: sync-topology-aware cross-source signal (codex #11)
  - T-todo-5: replace doctor's global density with fire-rate (codex #14)

Verified the 3-line audit: VERSION + package.json + CHANGELOG topmost
all match 0.40.4.0. `bun install` ran (lockfile unchanged — root
package version isn't stored in bun.lock). `bun run build:llms`
refreshed llms.txt + llms-full.txt for the next commit.

Plan ref: T12 in v0.40.4.0 wave plan.

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

* v0.40.4.0 TODO: document pre-existing shard-2 flake noticed during ship

3 isCacheSafe test failures in shard 2 reproduce on stashed clean
master. Confirmed pre-existing — not introduced by v0.40.4. Filed
under "Pre-existing flake on master (noticed during v0.40.4 ship)"
with reproduction commands + remediation options. Shipping v0.40.4
through it; future wave can fix.

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

* v0.40.4.0 privacy scrub: replace wintermute → media in example slugs

CLAUDE.md line 550 bans the private OpenClaw fork name in public
artifacts. Example session prefix in sessionPrefix() docs + 3 test
fixtures swept to 'media/chat/...' instead. Pre-existing
scripts/check-privacy.sh in `bun run verify` caught it.

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

* v0.40.4.0 fix: wire graph_signals from mode bundle to runPostFusionStages

CRITICAL: pre-landing review (codex outside-voice via /ship Step 9)
caught that hybrid.ts's `postFusionOpts` literal at line 566 was
building PostFusionOpts WITHOUT threading `resolvedMode.graph_signals`
to `graphSignalsEnabled`. The gate at hybrid.ts:358 read the field
from a literal that never set it.

Result before this fix: the entire v0.40.4 graph-signals wave was
dead code in production. Mode bundles set
`balanced.graph_signals = true` and `tokenmax.graph_signals = true`,
but no production call site ever reached applyGraphSignals. The
KNOBS_HASH bump 3→4 correctly varied the cache key by the flag, so
contamination was prevented — but the feature itself never fired.

All shipped infrastructure (engine SQL, fail-open audit, attribution
stamps, --explain formatter, doctor coverage check, search-stats
section) was reachable only through the unit-test seam
(`opts.adjacencyFn`). The CHANGELOG-advertised behavior never
landed in user-visible search.

Fix: thread `graphSignalsEnabled: resolvedMode.graph_signals` into
the postFusionOpts literal (1 line). Inline comment names codex's
catch so future refactors see the regression class.

Tests: new test/search/graph-signals-wire-integration.test.ts pins
the wire end-to-end. Three cases:
  1. balanced mode → hybridSearch on a seeded brain with adjacency
     hub produces a result with base_score stamped (proves
     runPostFusionStages actually ran).
  2. search.graph_signals=false config override → no graph_* fields
     stamped (proves the gate honors the override path).
  3. Source-grep regression guard pinning the
     `graphSignalsEnabled: resolvedMode.graph_signals` literal in
     hybrid.ts so a future refactor can't silently disconnect.

All 57 existing v0.40.4 wave tests still pass. Typecheck clean.

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

* v0.40.4.0 fix: pre-landing review AUTO-FIX findings (audit msg drift + deleted_at)

Two informational findings from /ship pre-landing review (Step 9):

1. Stderr message qualifier drift (rerank/slug-fallback/phantom audits)
   Pre-v0.40.4 messages included a per-feature qualifier:
     [gbrain] rerank-failure audit write failed (...)
     [gbrain] slug-fallback audit write failed (...)
     [gbrain] phantom audit write failed (...)
   The T2 refactor dropped the qualifier (plan promised "byte-identical"
   operator-visible behavior, but stderr lines did drift). Restored via
   new `errorMessagePrefix` option on `createAuditWriter` (optional, ''
   default). Three modules pass the per-feature qualifier; shell-audit
   and supervisor-audit unaffected (their pre-v0.40.4 messages didn't
   have a separate qualifier — label already carried the feature name).

2. Defense-in-depth `deleted_at IS NULL` on getAdjacencyBoosts
   SQL was previously protected by-construction (hybridSearch's
   visibility filter ensures input pageIds are live), but matches the
   v0.35.5.0 findOrphanPages pattern and closes the bug class if a
   future caller bypasses hybridSearch. Added to both Postgres and
   PGLite engines for parity. Three JOIN sites guarded (targets CTE,
   FROM-pages join). One inline comment per engine cites the codex
   review and the v0.35.5.0 precedent.

Plan ref: /ship pre-landing review v0.40.4.0 (codex finding C and F).

All 84 audit+graph-signals tests pass. Typecheck clean.

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

* v0.40.4.0 fix: adversarial review HIGH findings (codex H1+H2 + Claude F1)

Three HIGH-severity issues from /ship adversarial pass:

H1 (Codex): Eval gate was a no-op.
  Test passed `graph_signals: graphSignalsOn` via `as any` cast, but
  SearchOpts had no field and hybridSearch's perCall didn't thread it.
  Both off/on branches resolved to the mode-bundle default — gate
  measured identical behavior, could pass while detecting nothing.

  Fix: add `graph_signals?: boolean` to SearchOpts (types.ts:794).
  Thread `opts.graph_signals` into perCall in both hybridSearch
  (hybrid.ts:425) AND hybridSearchCached (hybrid.ts:1027) so the
  cache-key resolver also sees the override. Drop the `as any` from
  the eval test — types are real now.

H2 (Codex): Session diversification fired on entity directories.
  sessionPrefix() used "any shared parent directory" as the session
  signal. Result: a search for "people in SF" returned `people/alice`
  + `people/bob` + `people/charlie` and the latter two got demoted
  to 0.95×. Every common entity-search query silently penalized
  legitimate same-type results. Default-on for balanced/tokenmax
  means production behavior was wrong.

  Fix: narrow sessionPrefix() to fire ONLY when the slug contains a
  session-like marker (`chat`/`session`/`sessions` segment OR a
  `YYYY-MM-DD` date segment). Entity directories (`people/`,
  `companies/`, `docs/`) return null → diversification skips.
  Returns NULL (not the slug itself) so the loop skips clean.
  Examples in JSDoc:
    your-agent/chat/2026-05-20-foo → 'your-agent/chat/2026-05-20-foo'
    daily/2026-05-20/journal-entry-1 → 'daily/2026-05-20'
    transcripts/chat/funding-discussion → 'transcripts/chat/funding-discussion'
    people/alice → null  ← codex H2 regression
    docs/quickstart → null

F1 (Claude adversarial subagent): case-sensitivity drift across 3 sites.
  loadOverridesFromConfig in mode.ts is case-insensitive +
  whitespace-trimmed for 'search.graph_signals' values. But
  doctor's checkGraphSignalsCoverage (doctor.ts:899) AND
  search-stats's readGraphSignalsStats (search.ts:288) used
  case-sensitive compare. User sets `search.graph_signals TRUE`:
  production enables the feature, but doctor + search-stats both
  silently report disabled. Operators lose the only observability
  surface for the new feature on values like 'True'/'TRUE'.

  Fix: trim + lowercase parity at both sites. Mirror the parser's
  semantic. Also case-normalized `search.mode` reads at both sites
  for the same divergence class.

Tests:
  - sessionPrefix block rewritten with 7 cases covering chat marker
    + date anchor + entity dirs (now-NULL) + degenerate (no /).
  - Added regression test pinning codex H2: people/alice +
    people/bob + people/charlie do NOT get diversified.
  - graph-signals-eval.test.ts drops `as any` — typed field works.
  - Existing tests using `chat/a`/`chat/b` updated to session-shaped
    `media/2026-05-20/chunk-a` so the date anchor actually fires.

111/111 graph-signals + doctor + search-stats tests pass. Typecheck clean.

Plan ref: /ship adversarial review v0.40.4.0 (codex H1, H2; Claude F1).

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

* v0.40.4.0 TODOs: capture 11 LOW adversarial findings for v0.41+

Codex L1 (audit window underreport) + Claude F2/F3/F5-F8/F11/F12/F14/F16
from /ship adversarial review. None are load-bearing; all captured under
'v0.40.4 adversarial review LOW findings — captured for v0.41+'.

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

* docs: update project documentation for v0.40.4.0

- README: surface v0.40.4.0 graph signals + --explain in Hybrid search capability
- CLAUDE.md: annotate engine.ts getAdjacencyBoosts, new graph-signals.ts /
  explain-formatter.ts / audit/audit-writer.ts, plus hybrid.ts post-fusion
  4th stage, mode.ts graph_signals knob + KNOBS_HASH 3→4, cli-options.ts
  --explain flag, search stats + doctor coverage check
- llms-full.txt: regenerated from CLAUDE.md per the build:llms chaser rule

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(ci): pin bun-version to 1.3.13 across all workflows

setup-bun action with `bun-version: latest` calls the GitHub API
(https://api.github.com/repos/oven-sh/bun/git/refs/tags) to resolve
the tag. CI started failing today with HTTP 401 "Bad credentials"
even though the action receives a token (visible as `token: ***`
in the run log). Pinning the version eliminates the API call
entirely.

Affected workflows: test.yml, e2e.yml, release.yml, heavy-tests.yml
(5 invocations total). Pinned to 1.3.13 — matches package.json
engines (`bun >= 1.3.10`) and the version v0.40.4.0 was developed
against.

Bump cadence: when a new bun version is required, update this
pin in one PR. Trading "always-latest" for "always-deterministic"
is the right trade for a 5-shard CI matrix.

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

---------

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

789 lines
38 KiB
TypeScript

import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
describe('doctor command', () => {
test('doctor module exports runDoctor', async () => {
const { runDoctor } = await import('../src/commands/doctor.ts');
expect(typeof runDoctor).toBe('function');
});
test('LATEST_VERSION is importable from migrate', async () => {
const { LATEST_VERSION } = await import('../src/core/migrate.ts');
expect(typeof LATEST_VERSION).toBe('number');
});
test('CLI registers doctor command', async () => {
const result = Bun.spawnSync({
cmd: ['bun', 'run', 'src/cli.ts', '--help'],
cwd: import.meta.dir + '/..',
});
const stdout = new TextDecoder().decode(result.stdout);
expect(stdout).toContain('doctor');
expect(stdout).toContain('--fast');
});
test('frontmatter_integrity subcheck added in v0.22.4', async () => {
const fs = await import('fs');
const src = fs.readFileSync('src/commands/doctor.ts', 'utf8');
// Subcheck name and call into shared scanner are present.
expect(src).toContain("name: 'frontmatter_integrity'");
expect(src).toContain('scanBrainSources');
// Fix hint points at the right CLI command.
expect(src).toContain('gbrain frontmatter validate');
});
test('Check interface supports issues array', async () => {
// `Check` is a TypeScript interface — type-only, no runtime value.
// Importing it for type assertion is enough to validate the shape.
const check: import('../src/commands/doctor.ts').Check = {
name: 'resolver_health',
status: 'warn',
message: '2 issues',
issues: [{ type: 'unreachable', skill: 'test-skill', action: 'Add trigger row' }],
};
expect(check.issues).toHaveLength(1);
expect(check.issues![0].action).toContain('trigger');
});
test('runDoctor accepts null engine for filesystem-only mode', async () => {
const { runDoctor } = await import('../src/commands/doctor.ts');
// runDoctor should accept null engine — it runs filesystem checks only.
// Signature is (engine, args, dbSource?) — third param is optional and
// used by --fast to distinguish "no config" from "user skipped DB check".
// Function.length counts required params only (JS ignores ?-marked).
expect(runDoctor.length).toBeGreaterThanOrEqual(2);
expect(runDoctor.length).toBeLessThanOrEqual(3);
});
// Bug 7 — --fast should differentiate "no config anywhere" from "user
// chose --fast with GBRAIN_DATABASE_URL / config-file URL present".
test('getDbUrlSource reflects GBRAIN_DATABASE_URL env var', async () => {
const { getDbUrlSource } = await import('../src/core/config.ts');
const orig = process.env.GBRAIN_DATABASE_URL;
const origAlt = process.env.DATABASE_URL;
try {
process.env.GBRAIN_DATABASE_URL = 'postgresql://test@localhost/x';
expect(getDbUrlSource()).toBe('env:GBRAIN_DATABASE_URL');
delete process.env.GBRAIN_DATABASE_URL;
process.env.DATABASE_URL = 'postgresql://test@localhost/x';
expect(getDbUrlSource()).toBe('env:DATABASE_URL');
} finally {
if (orig === undefined) delete process.env.GBRAIN_DATABASE_URL;
else process.env.GBRAIN_DATABASE_URL = orig;
if (origAlt === undefined) delete process.env.DATABASE_URL;
else process.env.DATABASE_URL = origAlt;
}
});
test('doctor --fast emits source-specific message when URL present', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
// The source-aware message must reference the variable name so users
// know where their URL is coming from.
expect(source).toContain('Skipping DB checks (--fast mode, URL present from');
// The null-source fallback must still mention both config + env paths.
expect(source).toContain('GBRAIN_DATABASE_URL');
});
// v0.12.2 reliability wave — doctor detects JSONB double-encode + truncated
// bodies and points users at the standalone `gbrain repair-jsonb` command.
// Detection only; repair lives in src/commands/repair-jsonb.ts.
test('doctor source contains jsonb_integrity and markdown_body_completeness checks', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
expect(source).toContain('jsonb_integrity');
expect(source).toContain('markdown_body_completeness');
expect(source).toContain('gbrain repair-jsonb');
});
test('jsonb_integrity check covers the four JSONB sites fixed in v0.12.1', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
expect(source).toMatch(/table:\s*'pages'.*col:\s*'frontmatter'/);
expect(source).toMatch(/table:\s*'raw_data'.*col:\s*'data'/);
expect(source).toMatch(/table:\s*'ingest_log'.*col:\s*'pages_updated'/);
expect(source).toMatch(/table:\s*'files'.*col:\s*'metadata'/);
});
// v0.31.2 — facts_extraction_health check added in PR1 commit 12.
// Reads ingest_log rows with source_type='facts:absorb' (written by
// writeFactsAbsorbLog from src/core/facts/absorb-log.ts), groups by
// (source_id, reason) over the last 24h, warns when any (source, reason)
// pair exceeds the configurable threshold (facts.absorb_warn_threshold,
// default 10).
test('doctor source contains facts_extraction_health check that iterates sources', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
expect(source).toContain('facts_extraction_health');
// The check must group by source_id, not hardcode 'default'.
const block = source.slice(
source.indexOf('// 11a-bis-2. facts_extraction_health'),
source.indexOf('// 11a-2. effective_date_health'),
);
expect(block.length).toBeGreaterThan(0);
expect(block).toContain('GROUP BY source_id');
expect(block).toContain("source_type = 'facts:absorb'");
expect(block).toContain('facts.absorb_warn_threshold');
// 24h window
expect(block).toMatch(/INTERVAL\s+'24\s*hours?'/i);
// Pre-v47 fallback (column missing) reports skipped not warn
expect(block).toContain("Skipped (ingest_log.source_id unavailable");
// RLS deny gives a useful message
expect(block).toContain('RLS denies SELECT on ingest_log');
// Negative: must NOT hardcode 'default' as the only source
expect(block).not.toMatch(/source_id\s*=\s*'default'/);
});
// v0.18 RLS hardening — regression guards for PR #336 + schema backfill.
// These are structural assertions on the source string so a silent revert
// of the severity or the IN-filter removal fails loudly without a live DB.
test('RLS check scans ALL public tables (no hardcoded tablename IN list near the RLS block)', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
const rlsBlock = source.slice(
source.indexOf('// 5. RLS'),
source.indexOf('// 6. Schema version'),
);
expect(rlsBlock.length).toBeGreaterThan(0);
// Old pattern — must not come back. If it does, we're filtering the scan
// to a hardcoded set and every plugin/user table is invisible again.
expect(rlsBlock).not.toMatch(/tablename\s+IN\s*\(/);
// New semantics: the scan query has no WHERE-IN filter, just schemaname='public'.
expect(rlsBlock).toMatch(/FROM\s+pg_tables\b[\s\S]{0,200}schemaname\s*=\s*'public'/);
});
test('RLS check raises status=fail with quoted-identifier remediation SQL', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
const rlsBlock = source.slice(
source.indexOf('// 5. RLS'),
source.indexOf('// 6. Schema version'),
);
// Severity upgraded from 'warn' to 'fail' so `gbrain doctor` exits 1 on gaps.
expect(rlsBlock).toMatch(/status:\s*'fail'/);
// Remediation SQL uses quoted identifiers — safe for names with hyphens,
// reserved words, mixed case.
expect(rlsBlock).toContain('ALTER TABLE "public"."');
expect(rlsBlock).toContain('ENABLE ROW LEVEL SECURITY');
});
test('RLS check skips on PGLite (no PostgREST, not applicable)', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
const rlsBlock = source.slice(
source.indexOf('// 5. RLS'),
source.indexOf('// 6. Schema version'),
);
expect(rlsBlock).toMatch(/engine\.kind\s*===\s*'pglite'/);
expect(rlsBlock).toContain('PGLite');
});
test('RLS check reads pg_description and recognizes the GBRAIN:RLS_EXEMPT escape hatch', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
const rlsBlock = source.slice(
source.indexOf('// 5. RLS'),
source.indexOf('// 6. Schema version'),
);
expect(rlsBlock).toContain('obj_description');
expect(rlsBlock).toContain('GBRAIN:RLS_EXEMPT');
// The regex must require a non-empty reason= segment. "Blood" is in the
// requirement to write a real justification, not just the prefix.
expect(rlsBlock).toMatch(/reason=/);
});
// v0.26.7 — rls_event_trigger check (post-install drift detector for v35).
// Lives AFTER `// 6. Schema version` so the existing `// 5. RLS` slice
// tests stay intact (codex correction).
test('rls_event_trigger check exists, scoped after schema_version, healthy on (O,A) only', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
const idx7 = source.indexOf('// 7. RLS event trigger');
const idx8 = source.indexOf('// 8. Embedding health');
expect(idx7).toBeGreaterThan(0);
expect(idx8).toBeGreaterThan(idx7);
const block = source.slice(idx7, idx8);
expect(block).toContain("name: 'rls_event_trigger'");
// Healthy set is origin (`O`) or always (`A`). `R` is replica-only and
// would not fire in normal sessions; `D` is disabled. Both are warn states.
expect(block).toMatch(/evtenabled\s*!==\s*'O'[\s\S]*?evtenabled\s*!==\s*'A'/);
// PGLite skip path is required (no event triggers there).
expect(block).toMatch(/engine\.kind\s*===\s*'pglite'/);
// Recovery command names the migration version explicitly.
expect(block).toContain('--force-retry 35');
});
// v0.31.7 IRON-RULE regression test for #376 + #536.
// The graph_coverage WARN message used to suggest stale verbs (`gbrain
// link-extract` / `gbrain timeline-extract`) that were removed in v0.16
// when extraction was consolidated into `gbrain extract <links|timeline|all>`.
// PR #376 (FUSED-ID) flagged the stale hint; PR #536 (mayazbay) replaced it
// with the canonical `gbrain extract all`. Pin the user-facing copy so a
// future edit can't silently re-regress to a stale verb.
test('graph_coverage hint uses canonical `gbrain extract all`, not removed verbs', async () => {
const fs = await import('fs');
const src = fs.readFileSync('src/commands/doctor.ts', 'utf8');
// Canonical form (post-v0.16 single-verb consolidation).
expect(src).toContain('Run: gbrain extract all');
// Stale verb names removed in v0.16 must not return.
expect(src).not.toContain('gbrain link-extract');
expect(src).not.toContain('gbrain timeline-extract');
});
// v0.32 — takes_weight_grid pure-helper export.
// Codex review #7 demanded the check be extracted as a pure function so
// tests target it directly with stubbed engines instead of running the
// full runDoctor pipeline. This block validates the export shape and the
// 4 branches (no-takes / fail / warn / ok) behaviorally against PGLite.
test('takesWeightGridCheck is exported as a pure function', async () => {
const mod = await import('../src/commands/doctor.ts');
expect(typeof mod.takesWeightGridCheck).toBe('function');
});
test('takes_weight_grid: 0 takes → ok with "No takes yet"', async () => {
const { PGLiteEngine } = await import('../src/core/pglite-engine.ts');
const { takesWeightGridCheck } = await import('../src/commands/doctor.ts');
const engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
try {
const result = await takesWeightGridCheck(engine);
expect(result.name).toBe('takes_weight_grid');
expect(result.status).toBe('ok');
expect(result.message).toContain('No takes yet');
} finally {
await engine.disconnect();
}
});
test('takes_weight_grid: 100% on-grid → ok', async () => {
const { PGLiteEngine } = await import('../src/core/pglite-engine.ts');
const { takesWeightGridCheck } = await import('../src/commands/doctor.ts');
const engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
try {
// Seed a few on-grid takes via the engine's normalized path.
await engine.putPage('test/doc-on-grid', {
type: 'note', title: 't', compiled_truth: 'b', frontmatter: {},
});
const pageRows = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = 'test/doc-on-grid' LIMIT 1`,
);
await engine.addTakesBatch([
{ page_id: pageRows[0].id, row_num: 1, claim: 'a', kind: 'take', holder: 'world', weight: 0.75 },
{ page_id: pageRows[0].id, row_num: 2, claim: 'b', kind: 'take', holder: 'world', weight: 0.5 },
{ page_id: pageRows[0].id, row_num: 3, claim: 'c', kind: 'take', holder: 'world', weight: 1.0 },
]);
const result = await takesWeightGridCheck(engine);
expect(result.status).toBe('ok');
expect(result.message).toContain('on grid');
} finally {
await engine.disconnect();
}
});
test('takes_weight_grid: >10% off-grid → fail with fix hint', async () => {
const { PGLiteEngine } = await import('../src/core/pglite-engine.ts');
const { takesWeightGridCheck } = await import('../src/commands/doctor.ts');
const engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
try {
await engine.putPage('test/doc-fail', {
type: 'note', title: 't', compiled_truth: 'b', frontmatter: {},
});
const pageRows = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = 'test/doc-fail' LIMIT 1`,
);
// Bypass engine normalization: write off-grid weights directly.
// 8 of 10 off-grid → 80%, well past the 10% fail threshold.
for (let i = 1; i <= 8; i++) {
await engine.executeRaw(
`INSERT INTO takes (page_id, row_num, claim, kind, holder, weight, active)
VALUES ($1, $2, 'c', 'take', 'world', $3::real, true)`,
[pageRows[0].id, i, 0.74],
);
}
for (let i = 9; i <= 10; i++) {
await engine.executeRaw(
`INSERT INTO takes (page_id, row_num, claim, kind, holder, weight, active)
VALUES ($1, $2, 'c', 'take', 'world', 0.5::real, true)`,
[pageRows[0].id, i],
);
}
const result = await takesWeightGridCheck(engine);
expect(result.status).toBe('fail');
expect(result.message).toMatch(/8\/10/);
expect(result.message).toContain('apply-migrations');
} finally {
await engine.disconnect();
}
});
test('takes_weight_grid: 1-10% off-grid → warn', async () => {
const { PGLiteEngine } = await import('../src/core/pglite-engine.ts');
const { takesWeightGridCheck } = await import('../src/commands/doctor.ts');
const engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
try {
await engine.putPage('test/doc-warn', {
type: 'note', title: 't', compiled_truth: 'b', frontmatter: {},
});
const pageRows = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = 'test/doc-warn' LIMIT 1`,
);
// 5 off-grid out of 100 = 5% → warn band.
for (let i = 1; i <= 5; i++) {
await engine.executeRaw(
`INSERT INTO takes (page_id, row_num, claim, kind, holder, weight, active)
VALUES ($1, $2, 'c', 'take', 'world', 0.74::real, true)`,
[pageRows[0].id, i],
);
}
for (let i = 6; i <= 100; i++) {
await engine.executeRaw(
`INSERT INTO takes (page_id, row_num, claim, kind, holder, weight, active)
VALUES ($1, $2, 'c', 'take', 'world', 0.5::real, true)`,
[pageRows[0].id, i],
);
}
const result = await takesWeightGridCheck(engine);
expect(result.status).toBe('warn');
expect(result.message).toMatch(/5\/100/);
} finally {
await engine.disconnect();
}
});
test('takes_weight_grid: takes table missing → warn (graceful)', async () => {
const { takesWeightGridCheck } = await import('../src/commands/doctor.ts');
// Stub engine: executeRaw throws like a "relation does not exist" error.
const stubEngine = {
executeRaw: async () => {
throw new Error('relation "takes" does not exist');
},
} as any;
const result = await takesWeightGridCheck(stubEngine);
expect(result.status).toBe('warn');
expect(result.message).toContain('Could not check takes weight grid');
});
});
// ─────────────────────────────────────────────────────────────────────────
// v0.31.8 D19 — wedge migration force-retry hint.
//
// The pre-v0.31.8 minions_migration check emitted a generic
// `gbrain apply-migrations --yes` hint regardless of how partial the
// migration was. Operators wedged on v0.29.1 (3 consecutive partials)
// needed `--force-retry <v>` first because the apply-migrations runner's
// 3-consecutive-partials guard rejected plain --yes. The v0.31.8 fix
// extends the existing block in place: detect the wedge condition,
// emit the force-retry hint when matched, fall back to the plain --yes
// hint when the partial count is < 3.
// ─────────────────────────────────────────────────────────────────────────
describe('v0.31.8 — wedge migration force-retry hint (D19)', () => {
test('local doctor source contains wedge detection alongside the existing stuck path', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
// The existing forward-progress override stays intact. Both branches
// must be present and live next to each other; replacing the override
// with statusForVersion() would re-open stale wedge alerts (codex OV11).
expect(source).toContain('Forward-progress override');
expect(source).toContain('partialCount >= 3');
// Both branches must coexist. Wedged path builds the command list with
// --force-retry; partial path falls back to plain --yes. Order varies
// between the local + remote doctor blocks, so just assert presence.
expect(source).toContain('WEDGED MIGRATION(s)');
expect(source).toContain('MINIONS HALF-INSTALLED');
expect(source).toContain('--force-retry');
expect(source).toMatch(/MINIONS HALF-INSTALLED[\s\S]{0,400}--yes/);
});
test('wedge detection is local to doctor — no statusForVersion import (D19 anti-regression)', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
// D19 explicitly chose to extend the existing block in place rather than
// import statusForVersion, because statusForVersion is per-version only
// and doesn't encode the cross-version forward-progress override. If a
// future refactor re-introduces the import this regression guard
// catches it.
expect(source).not.toMatch(/import\s*\{\s*statusForVersion\s*\}/);
expect(source).not.toMatch(/from\s*['"]\.\/apply-migrations\.ts['"]/);
});
test('multiple wedged versions chain force-retry calls with &&', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
// The local doctor block uses `.join(' && ')` so multiple wedged
// versions render as a single copy-pasteable command line. Match BOTH
// engine.ts blocks (local doctor + remote doctor) — the regex finds
// either occurrence.
expect(source).toMatch(/wedged\.map\(v\s*=>\s*`gbrain apply-migrations --force-retry [^`]+`\)\.join\(' && '\)/);
});
test('remote doctor (doctorReportRemote) also emits the force-retry hint (D14)', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
// Check that the wedge detection is duplicated in the remote doctor
// path so thin-client operators see it. Find the doctorReportRemote
// function span and verify the wedge-hint code lives inside it.
const remoteStart = source.indexOf('export async function doctorReportRemote(');
expect(remoteStart).toBeGreaterThan(0);
const remoteEnd = source.indexOf('\nexport async function runDoctor(', remoteStart);
expect(remoteEnd).toBeGreaterThan(remoteStart);
const remoteBlock = source.slice(remoteStart, remoteEnd);
expect(remoteBlock).toContain('--force-retry');
expect(remoteBlock).toContain('partialCount >= 3');
expect(remoteBlock).toMatch(/WEDGED MIGRATION\(s\) on brain host/);
});
});
// ============================================================================
// v0.32.4 — sync_freshness check
// ============================================================================
// Pure staleness probe: reads sources.last_sync_at, no filesystem access.
// Drift detection was stripped in v0.32.4 — the doctorReportRemote path runs
// in the HTTP MCP server and walking DB-supplied local_path values from there
// crosses a trust boundary. Drift belongs in multi_source_drift's existing
// guard infrastructure (GBRAIN_DRIFT_LIMIT / GBRAIN_DRIFT_TIMEOUT_MS).
// ============================================================================
describe('v0.32.4 — sync_freshness check', () => {
// Stub engine: only checkSyncFreshness's executeRaw matters. Per-case rows
// shape is `{id, name, local_path, last_sync_at}`.
function makeStubEngine(rows: any[]): any {
return { executeRaw: async () => rows };
}
function agoMs(ms: number): Date {
return new Date(Date.now() - ms);
}
test('empty sources → ok with no-federated-sources message', async () => {
const { checkSyncFreshness } = await import('../src/commands/doctor.ts');
const result = await checkSyncFreshness(makeStubEngine([]));
expect(result.name).toBe('sync_freshness');
expect(result.status).toBe('ok');
expect(result.message).toBe('No federated sources to sync');
});
test('last_sync_at IS NULL → fail with "never been synced"', async () => {
const { checkSyncFreshness } = await import('../src/commands/doctor.ts');
const result = await checkSyncFreshness(makeStubEngine([
{ id: 'wiki', name: '', local_path: '/tmp/wiki', last_sync_at: null },
]));
expect(result.status).toBe('fail');
expect(result.message).toContain('never been synced');
expect(result.message).toContain(`'wiki'`); // source.id embedded
expect(result.message).toContain('gbrain sync --source <id>');
});
test('last_sync_at > 72h ago → fail with day-rounded "Nd ago"', async () => {
const { checkSyncFreshness } = await import('../src/commands/doctor.ts');
const result = await checkSyncFreshness(makeStubEngine([
{ id: 'wiki', name: '', local_path: '/tmp/wiki', last_sync_at: agoMs(4 * 24 * 60 * 60 * 1000) },
]));
expect(result.status).toBe('fail');
expect(result.message).toMatch(/4d ago/);
expect(result.message).toContain('brain search is stale');
});
test('exact 72h boundary → warn (>72h strict; 72h source NOT yet fail)', async () => {
const { checkSyncFreshness } = await import('../src/commands/doctor.ts');
// Exactly 72h. Strict `>` on fail threshold means 72h-stale is still in
// the warn window. The `nowMs` injection pins both clock reads to the
// same instant — without it, drift between `agoMs` and `Date.now()` in
// the check pushes ageMs above the threshold and flips the boundary.
const nowMs = Date.now();
const result = await checkSyncFreshness(makeStubEngine([
{ id: 'wiki', name: '', local_path: '/tmp/wiki', last_sync_at: new Date(nowMs - 72 * 60 * 60 * 1000) },
]), { nowMs });
expect(result.status).toBe('warn');
expect(result.message).toContain('72h ago');
});
test('24h < last_sync_at < 72h → warn with hour-rounded "Nh ago"', async () => {
const { checkSyncFreshness } = await import('../src/commands/doctor.ts');
const result = await checkSyncFreshness(makeStubEngine([
{ id: 'wiki', name: '', local_path: '/tmp/wiki', last_sync_at: agoMs(30 * 60 * 60 * 1000) },
]));
expect(result.status).toBe('warn');
expect(result.message).toMatch(/30h ago/);
});
test('exact 24h boundary → ok (>24h strict)', async () => {
const { checkSyncFreshness } = await import('../src/commands/doctor.ts');
// Exactly 24h. Strict `>` on warn threshold means 24h-stale is still ok.
// Same `nowMs` pinning as the 72h boundary test above — both clock reads
// must hit the same instant or μs-scale drift flips the boundary.
const nowMs = Date.now();
const result = await checkSyncFreshness(makeStubEngine([
{ id: 'wiki', name: '', local_path: '/tmp/wiki', last_sync_at: new Date(nowMs - 24 * 60 * 60 * 1000) },
]), { nowMs });
expect(result.status).toBe('ok');
expect(result.message).toContain('synced recently');
});
test('last_sync_at <= 24h → ok with "synced recently"', async () => {
const { checkSyncFreshness } = await import('../src/commands/doctor.ts');
const result = await checkSyncFreshness(makeStubEngine([
{ id: 'wiki', name: '', local_path: '/tmp/wiki', last_sync_at: agoMs(2 * 60 * 60 * 1000) },
{ id: 'gstack', name: '', local_path: '/tmp/gstack', last_sync_at: agoMs(60 * 1000) },
]));
expect(result.status).toBe('ok');
expect(result.message).toContain('2 federated source(s)');
});
test('future last_sync_at → warn (clock skew / corrupted timestamp)', async () => {
const { checkSyncFreshness } = await import('../src/commands/doctor.ts');
// 10 min in the future. Negative ageMs must NOT fall through as ok.
const result = await checkSyncFreshness(makeStubEngine([
{ id: 'wiki', name: '', local_path: '/tmp/wiki', last_sync_at: new Date(Date.now() + 10 * 60 * 1000) },
]));
expect(result.status).toBe('warn');
expect(result.message).toMatch(/future last_sync_at/);
expect(result.message).toMatch(/clock skew|corrupted timestamp/);
});
test('mixed sources (one fail + one warn) → fail with both issues listed', async () => {
const { checkSyncFreshness } = await import('../src/commands/doctor.ts');
const result = await checkSyncFreshness(makeStubEngine([
{ id: 'wiki', name: '', local_path: '/tmp/wiki', last_sync_at: agoMs(5 * 24 * 60 * 60 * 1000) },
{ id: 'gstack', name: '', local_path: '/tmp/gstack', last_sync_at: agoMs(30 * 60 * 60 * 1000) },
]));
expect(result.status).toBe('fail');
expect(result.message).toContain(`'wiki'`);
expect(result.message).toContain(`'gstack'`);
expect(result.message).toMatch(/5d ago/);
expect(result.message).toMatch(/30h ago/);
});
test('executeRaw throws → outer-catch returns warn (doctor keeps running)', async () => {
const { checkSyncFreshness } = await import('../src/commands/doctor.ts');
const engine: any = {
executeRaw: async () => { throw new Error('connection refused'); },
};
const result = await checkSyncFreshness(engine);
expect(result.status).toBe('warn');
expect(result.message).toContain('Could not check sync freshness');
expect(result.message).toContain('connection refused');
});
test('env-var override: GBRAIN_SYNC_FRESHNESS_FAIL_HOURS=6 → 7h-stale fails', async () => {
const { checkSyncFreshness } = await import('../src/commands/doctor.ts');
const prev = process.env.GBRAIN_SYNC_FRESHNESS_FAIL_HOURS;
process.env.GBRAIN_SYNC_FRESHNESS_FAIL_HOURS = '6';
try {
const result = await checkSyncFreshness(makeStubEngine([
{ id: 'wiki', name: '', local_path: '/tmp/wiki', last_sync_at: agoMs(7 * 60 * 60 * 1000) },
]));
expect(result.status).toBe('fail');
expect(result.message).toContain('brain search is stale');
} finally {
if (prev === undefined) delete process.env.GBRAIN_SYNC_FRESHNESS_FAIL_HOURS;
else process.env.GBRAIN_SYNC_FRESHNESS_FAIL_HOURS = prev;
}
});
test('source.id embedded in messages even when source.name is set', async () => {
const { checkSyncFreshness } = await import('../src/commands/doctor.ts');
const result = await checkSyncFreshness(makeStubEngine([
{ id: 'wiki-id', name: 'My Wiki', local_path: '/tmp/wiki', last_sync_at: null },
]));
expect(result.status).toBe('fail');
// User copy-pastes `gbrain sync --source wiki-id` (NOT "My Wiki"). Message
// must include the id so the CLI command actually works.
expect(result.message).toContain(`'wiki-id'`);
});
});
// Supervisor crash classifier wiring. Pre-fix, doctor.ts:1013 counted every
// `worker_exited` event as a crash regardless of `likely_cause`, inflating
// `crashes_24h` to 120+/day from RSS-watchdog drains and SIGTERM stops.
// These tests pin the read-side wiring so doctor and `gbrain jobs supervisor
// status` (jobs.ts:805) cannot drift: both go through `summarizeCrashes`.
describe('supervisor crash classifier wiring (v0.35.x)', () => {
test('doctor.ts uses summarizeCrashes — no ad-hoc worker_exited filter', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
// Wired to the shared helper.
expect(source).toContain('summarizeCrashes');
// The pre-fix ad-hoc filter pattern must NOT survive. The exact buggy
// expression was `events.filter(e => e.event === 'worker_exited').length`.
// Match the structural fingerprint, not whitespace.
expect(source).not.toMatch(
/events\.filter\([^)]*e\.event\s*===\s*'worker_exited'[^)]*\)\.length/,
);
});
test('doctor.ts warn threshold dropped from >3 to >=1', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
// The pre-fix `crashes24h > 3` threshold made sense only because the
// counter was over-counting clean exits. Under accurate counts, any real
// crash is signal — threshold lands at `>=1`.
expect(source).toMatch(/crashes24h\s*>=\s*1/);
// The old `> 3` predicate must not survive on the supervisor check.
expect(source).not.toMatch(/crashes24h\s*>\s*3/);
});
test('doctor.ts ok + warn messages include per-cause breakdown and clean_exits_24h', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
// Per-cause breakdown surfaces qualitative signal (oom vs runtime vs unknown
// vs legacy) so operators can triage without grep'ing JSONL.
expect(source).toContain('runtime=');
expect(source).toContain('oom=');
expect(source).toContain('unknown=');
expect(source).toContain('legacy=');
// Clean-exit count surfaces alongside crash count for transparency.
expect(source).toContain('clean_exits_24h=');
});
test('jobs.ts supervisor status uses summarizeCrashes — same wiring as doctor', async () => {
const source = await Bun.file(new URL('../src/commands/jobs.ts', import.meta.url)).text();
// Both surfaces MUST go through the shared helper. Without this, the two
// CLI commands report drifting crash counts (the bug class codex caught
// during the eng review outside-voice pass).
expect(source).toContain('summarizeCrashes');
expect(source).not.toMatch(
/events\.filter\([^)]*e\.event\s*===\s*'worker_exited'[^)]*\)\.length/,
);
// JSON output exposes the per-cause breakdown so dashboards/monitors can
// distinguish memory pressure from code bugs without re-classifying.
expect(source).toContain('crashes_by_cause');
expect(source).toContain('clean_exits_24h');
});
});
// v0.34.5 stub-guard observability tests (from v0.35.4.0). Doctor surfaces
// the 24h fire count for the resolver-stub-guard. WARN at >10 hits is the
// signal that prefix-expansion in resolveEntitySlug is missing a case.
describe('stub_guard_24h check (v0.34.5)', () => {
test('doctor source defines the stub_guard_24h check', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
expect(source).toContain("name: 'stub_guard_24h'");
});
test('WARN threshold is >10 hits/24h', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
// The WARN gate must fire above 10, not at or below — that's the threshold
// the v0.36 sunset criterion is calibrated against.
expect(source).toMatch(/events\.length\s*>\s*10/);
});
test('fix hint points operators at the audit log', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
expect(source).toContain('stub-guard-*.jsonl');
expect(source).toContain('prefix-expansion in resolveEntitySlug');
});
test('check reads via the dual-week-aware reader (NOT supervisor-audit pattern)', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
// The point of the divergence from supervisor-audit.ts is this reader
// reads both current and previous ISO-week files. If the check ever
// gets re-pointed at readSupervisorEvents-style single-week, this test
// fails — protecting the cross-week-boundary correctness.
expect(source).toContain('readRecentStubGuardEvents');
expect(source).not.toMatch(/from .*\/stub-guard-audit\.ts.*readSupervisorEvents/);
});
test('zero hits emits no check (keeps doctor output clean on healthy brains)', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
// The implementation falls through silently when events.length === 0.
// Codify this in source-grep form so a future refactor doesn't add an
// "ok: 0 hits" line that pollutes every doctor run.
expect(source).toMatch(/events\.length === 0|Zero hits is the goal/);
});
});
describe('v0.40.4 — graph_signals_coverage check', () => {
const { PGLiteEngine } = require('../src/core/pglite-engine.ts');
const { checkGraphSignalsCoverage } = require('../src/commands/doctor.ts');
let engine: any;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ engine: 'pglite' });
await engine.initSchema();
});
afterAll(async () => {
if (engine) await engine.disconnect();
});
beforeEach(async () => {
// Wipe pages + links + config between tests for isolation.
await engine.executeRaw(`DELETE FROM links`);
await engine.executeRaw(`DELETE FROM pages`);
await engine.executeRaw(`DELETE FROM config WHERE key IN ('search.graph_signals', 'search.mode')`);
});
test('graph_signals disabled (conservative mode) → silent ok regardless of coverage', async () => {
await engine.setConfig('search.mode', 'conservative');
// Seed pages without links (would normally warn) but conservative
// disables graph_signals so the check stays ok.
for (let i = 0; i < 5; i++) {
await engine.putPage(`page/${i}`, { type: 'note', title: `page-${i}`, compiled_truth: 'body' });
}
const check = await checkGraphSignalsCoverage(engine);
expect(check.status).toBe('ok');
expect(check.message).toContain('disabled');
});
test('graph_signals enabled (balanced default) + zero links → warn at <10%', async () => {
// No config set → balanced default (graph_signals=true).
for (let i = 0; i < 10; i++) {
await engine.putPage(`page/${i}`, { type: 'note', title: `page-${i}`, compiled_truth: 'body' });
}
const check = await checkGraphSignalsCoverage(engine);
expect(check.status).toBe('warn');
expect(check.message).toContain('0.0%');
expect(check.message).toContain('gbrain extract all');
});
test('graph_signals enabled + >=30% coverage → ok with metric', async () => {
for (let i = 0; i < 10; i++) {
await engine.putPage(`page/${i}`, { type: 'note', title: `page-${i}`, compiled_truth: 'body' });
}
// Add inbound links to 4/10 pages = 40%.
await engine.addLinksBatch([
{ from_slug: 'page/0', to_slug: 'page/1', link_type: 'mentions' },
{ from_slug: 'page/0', to_slug: 'page/2', link_type: 'mentions' },
{ from_slug: 'page/0', to_slug: 'page/3', link_type: 'mentions' },
{ from_slug: 'page/0', to_slug: 'page/4', link_type: 'mentions' },
]);
const check = await checkGraphSignalsCoverage(engine);
expect(check.status).toBe('ok');
expect(check.message).toContain('40.0%');
expect(check.message).toContain('fire on most queries');
});
test('graph_signals enabled + 10-29% coverage → ok with occasional-fire note', async () => {
for (let i = 0; i < 10; i++) {
await engine.putPage(`page/${i}`, { type: 'note', title: `page-${i}`, compiled_truth: 'body' });
}
// Add inbound to 2/10 = 20%.
await engine.addLinksBatch([
{ from_slug: 'page/0', to_slug: 'page/1', link_type: 'mentions' },
{ from_slug: 'page/0', to_slug: 'page/2', link_type: 'mentions' },
]);
const check = await checkGraphSignalsCoverage(engine);
expect(check.status).toBe('ok');
expect(check.message).toContain('20.0%');
expect(check.message).toContain('fire occasionally');
});
test('explicit search.graph_signals=false overrides mode default', async () => {
// Balanced normally enables; explicit override turns it off.
await engine.setConfig('search.graph_signals', 'false');
// No links → would normally warn, but override means we don't check.
for (let i = 0; i < 5; i++) {
await engine.putPage(`page/${i}`, { type: 'note', title: `page-${i}`, compiled_truth: 'body' });
}
const check = await checkGraphSignalsCoverage(engine);
expect(check.status).toBe('ok');
expect(check.message).toContain('disabled');
});
test('empty brain → ok with explanation', async () => {
const check = await checkGraphSignalsCoverage(engine);
expect(check.status).toBe('ok');
expect(check.message).toContain('Empty brain');
});
test('check is wired into runDoctor (source-grep)', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
// Local engine path.
expect(source).toMatch(/await checkGraphSignalsCoverage\(engine\)/);
// Remote/JSON path heartbeat.
expect(source).toContain("progress.heartbeat('graph_signals_coverage')");
});
});