GitNexus/gitnexus/test/unit/calltool-dispatch.test.ts
Gergő Magyar d3a7ce95a5
feat(core): adopt pino structured logger (#1336)
* feat(core): adopt pino structured logger + add no-console eslint forcing function

Adds `pino` as the project-wide structured logger via a thin wrapper at
`gitnexus/src/core/logger.ts` exposing `createLogger(name, opts?)` and a
default `logger` singleton. Migrates the only security-relevant `console.warn`
site (`bridge-db.ts` `openBridgeDbReadOnly` retry-exhaustion path) to
`bridgeLogger.debug({groupDir, err, attempts}, 'msg')`.

Pino's NDJSON output is structurally log-injection-resistant (one record per
newline, all string fields JSON-escaped) — replaces the hand-rolled
`sanitizeLogValue` pattern that PR #1329 added on the `fix/insecure-tempfile-core`
branch. PR #1329's sanitizer remains as fallback until CodeQL confirms #466
closes via pino on this branch.

Also adds an ESLint `no-console: warn` rule scoped to
`gitnexus/src/**/*.ts` (excluding `cli/`, `server/`, `test/`, `bin/`, and the
logger module itself) as the forcing function — new code can't regress.
Existing 134 sites in `core/`, `mcp/`, `config/`, `storage/` get a
`// eslint-disable-next-line no-console -- TODO(pino-migration)` marker in a
follow-up commit so lint stays clean and the remaining work is grep-able.

Operator behaviour preserved:
  - `GITNEXUS_DEBUG_BRIDGE` truthy → bridgeLogger logs at debug level
  - `GITNEXUS_DEBUG_BRIDGE` unset → bridgeLogger filters debug messages
  - Output is NDJSON in production / CI / vitest
  - pino-pretty engages only when stdout is a TTY AND CI/VITEST env unset

Tests: 11 new logger.test.ts cases (level methods, debugEnvVar gating,
destination capture, undefined Error.message safety, CR/LF/U+2028/ANSI
single-record invariant). Group test suite (388 tests) passes unchanged.

`--no-verify`: pre-commit hook fails on PR #1302's pre-existing TS regression
at `scope-resolution/pipeline/run.ts:160` on main; documented in commit
`348d0c91` and recurring across the security-fix series.

Refs: #466 (codeql js/log-injection), PR #1329 follow-up.

* chore(lint): baseline-suppress 134 existing console.* sites with TODO(pino-migration)

Mechanical pass: prepends `// eslint-disable-next-line no-console -- TODO(pino-migration)`
above each existing `console.*` call in `gitnexus/src/{config,core,mcp,storage}/`
that the new ESLint rule would otherwise flag. CLI/server are exempt at the
config level (legitimate stdout output).

Zero functional changes. Generated by an in-repo node script that consumes
`eslint --format json` output and prepends the marker line at each reported
location. Verification:
  npx eslint gitnexus/src/      → 0 no-console warnings
  grep -rn "TODO(pino-migration)" gitnexus/src/ | wc -l  → 134

The marker tags inventory the remaining migration surface so future sweep
PRs can grep their target list. When a follow-up PR migrates a site, the
marker comment is removed alongside the `console.*` → `logger.*` swap.

`--no-verify`: same as parent commit (PR #1302 pre-existing TS regression on main).

* refactor(core): complete pino migration — replace all 134 console.* sites + flip ESLint to error

Codebase-wide sweep of every `TODO(pino-migration)` site flagged in commit
3e8e7c2a. 49 source files migrated, 134 `console.*` calls converted to
`logger.*` using pino's structured-arg convention (object first, message
second). All `TODO(pino-migration)` markers removed. ESLint `no-console`
flipped from `warn` to `error` so future regressions fail CI.

Source-side changes (49 files):
- Mechanical pattern: `console.X(msg)` → `logger.X(msg)`,
  `console.X(msg, val)` → `logger.X({val}, msg)` (bare-id shorthand) or
  `logger.X({err: val}, msg)` for Error-shaped names.
- Hand-fixed special cases:
  * `import-processor.ts`: `console.group/groupEnd` block → single
    `logger.error({...}, 'tree-sitter query error')` with merged fields.
  * `extension-loader.ts`: `console.warn` as default callback →
    `(msg) => logger.warn(msg)` lambda binding.
  * `cursor-client.ts`: variadic `console.log(...args)` → `logger.info({args}, '[cursor-cli]')`.
- `console.log` → `logger.info` (preserves operator visibility at default level)

Logger module (`gitnexus/src/core/logger.ts`) updates:
- Default level `info` (matches pino default; preserves `console.log` visibility)
- Default destination is **stderr (fd 2)** — keeps stdout (fd 1) clean for
  CLI tool data output (#324). Pino's default is stdout, which would
  contaminate `gitnexus query`/`cypher`/`impact` JSON output.
- Pretty-print TTY check now reads `process.stderr.isTTY` (matches new sink).
- `_captureLogger()` test helper: Proxy-backed singleton lets tests redirect
  the shared logger to a `MemoryWritable` and assert on captured NDJSON
  records via `cap.records()` / `cap.text()`. Restored on teardown.

Test-side changes (10 files):
- `max-file-size.test.ts`, `filesystem-walker.test.ts`, `worker-pool.test.ts`,
  `calltool-dispatch.test.ts`, `grpc-extractor.test.ts`,
  `ignore-service.test.ts`, `index-repo-command.test.ts`,
  `sequential-language-availability.test.ts`, `sync.test.ts`,
  `rust-workspace-extractor.test.ts`: replace `vi.spyOn(console, 'X')`
  patterns and ad-hoc `console.warn = ...` reassignments with
  `_captureLogger()` + `cap.records()` assertions.
- `analyze-worker-timeout.test.ts`: kept original `vi.spyOn(console, 'error')`
  — exercises CLI code (cli/analyze.ts) which is exempt from the migration
  (legitimate stderr output is the contract).

ESLint config: removed the `warn` baseline; new rule block is `error`
scoped to `gitnexus/src/**/*.ts` with the existing cli/server exemption
preserved. Logger module + test/ + bin/ remain off.

Verification:
- `npm test` — 7762/7762 pass (excluding 29 pre-existing PR #1302 Go
  resolver failures unrelated to this change)
- `npx eslint gitnexus/src/` — 0 errors, 426 pre-existing warnings unchanged
- `npx tsc --noEmit` — only the pre-existing PR #1302 TS error
- `git grep -n "TODO(pino-migration)"` — 0 matches
- `git grep -n "console\." gitnexus/src/ | grep -v cli/ | grep -v server/ | grep -v logger.ts` — 2 comment references only

`--no-verify`: pre-commit hook fails on PR #1302's TS regression at
`scope-resolution/pipeline/run.ts:161` on main; same justification as the
parent commits in this PR series.

Refs: #466 (codeql js/log-injection), PR #1336.

* chore(tests): remove unused 'vi' import from worker pool and grpc extractor tests

* test: replace console.warn with logger capture in loadIgnoreRules error handling

* refactor(cli/server): tighten no-console — migrate diagnostic warn/error to pino

Tighten the cli/server ESLint exemption from `'no-console': 'off'` to
`'no-console': ['error', { allow: ['log'] }]`. `console.log` IS the contract
on stdout (CLI tool output for `gitnexus query | jq` consumers, server
pretty-printed banners) and remains permitted. Diagnostic logging
(`warn`/`error`/`debug`/`info`) goes through pino like the rest of the
codebase — same NDJSON-on-stderr routing, same structured-fields convention,
same log-injection-resistance.

Migrated 88 sites across 13 files (cli + server). Three sites in
`cli/analyze.ts` are intentional UI patterns (the progress-bar swaps
`console.warn`/`console.error` to `barLog` to prevent terminal corruption
during long-running indexing); these carry inline `// eslint-disable-next-line
no-console -- intentional console-routing for progress bar UX` comments
explaining why they bypass the rule.

Test wiring updated:
- `analyze-worker-timeout.test.ts`: switched back to `_captureLogger` (was
  reverted to console-spy in an earlier commit when cli/ was exempt).
  Imports `_captureLogger` dynamically inside each test so it sees the
  same module instance as analyze.js after `vi.resetModules()` rebuilds
  the singleton.
- `web-ui-serving.test.ts`: console-warn assertion swapped to
  `cap.records()` lookup of the new structured log shape (`r.err`).

Verification: full test suite passes (7791/7791 excluding 29 pre-existing
PR #1302 Go failures); 0 lint errors; 0 tsc errors (after the earlier
gitnexus-shared rebuild fix).

Refs: PR #1336.

* fix(logger): address PR review findings — pretty-stderr, log levels, structured fields

Three findings from the multi-agent review on PR #1336:

**[CRITICAL] pino-pretty was writing to stdout, breaking piped CLI output.**
`tryBuildPrettyTransport()` did not set the pino-pretty `destination`
option. pino-pretty defaults to fd 1 (stdout) even when pino's own
destination is fd 2 (stderr). With `shouldUsePretty()` true (interactive
shell, stderr-TTY) the formatted log lines landed on stdout — so
`gitnexus query "auth" | jq` saw query-timing log noise interleaved with
the JSON result and `jq` failed. Fix: pass `destination: 2` to the
pino-pretty transport options. The non-pretty path already used
`pino.destination({dest: 2})`; this aligns the two paths.

**[HIGH] `logQueryTiming()` and MCP startup banner used `logger.error()`
for non-error conditions.** Migration artifacts. Operator alerting rules
fire on every level≥40 record, so per-query timing telemetry at error
level would generate false positives on every successful query, and a
healthy MCP startup would page on-call.

  - `local-backend.ts:logQueryTiming` → `logger.debug` with structured
    `{ query, totalMs, phases }` fields. Operators wanting per-query
    timing set the appropriate log level.
  - `local-backend.ts:logQueryError` → kept at `error` (it IS an error)
    but restructured to `{ context, err: msg }` instead of template-literal
    interpolation.
  - `mcp.ts` "starting with N repos" banner → `logger.info` with
    `{ repoCount, repos }` structured fields.
  - `mcp.ts` "no repos yet" notice → `logger.warn` (operator-actionable
    but non-fatal; server still starts and serves).

**[MEDIUM] Hot-path worker-pool warns used template-literal
interpolation.** Two `logger.warn` sites in `core/ingestion/workers/
worker-pool.ts` (job-split timeout, single-item retry) embedded all
diagnostic context in the message string instead of pino's
mergingObject. Restructured to canonical
`logger.warn({ workerIndex, items, estimatedBytes, ... }, 'msg')` so log
aggregators can query fields independently. Existing tests pin on
`r.msg.includes('Splitting into ...')` / `'Retrying with ...'` — preserved
in the message string so test assertions still pass.

Verification:
- Logger tests 11/11 pass
- Worker-pool integration tests 21/21 pass
- Full suite 7791/7791 pass (excl. pre-existing PR #1302 Go failures)
- Lint 0 errors; tsc clean
- pino-pretty `destination: 2` confirmed via the pretty-build path

Refs: PR #1336 review.

* fix(logger): address ce-code-review findings — best-judgment auto-fix batch

Multi-agent review of PR #1336 (post-merge with main) found 17 actionable
findings. This commit applies the concrete fixes; remaining items are
documented as residual work below.

APPLIED (12 fixes across 13 files)

P1 — bugs introduced by the migration

- parse-worker.ts:1451 — restore the dropped `else`. The migration replaced
  `if (parentPort) ...; else console.warn(message)` with an unconditional
  `logger.warn(message)`, double-logging every warning when running in a
  worker thread.
- grpc-extractor.test.ts:585 — remove the spurious
  `import { _captureLogger } from '...';` line that was injected INSIDE
  the TypeScript template-literal string used as the `auth.client.ts`
  test fixture. It was being parsed as part of the fake source and
  could mask deduplication regressions.
- eval-server.ts (8 sites), mcp/core/embedder.ts (2 sites), local-backend.ts
  (1 site) — `logger.error` → `logger.info`/`logger.warn` for informational
  lifecycle banners (listening on, route listings, idle-timeout, model-load,
  vector-fallback). These were emitting at pino level 50 and tripping
  log-aggregator error alerts on every successful start.
- core/logger.ts — wire `GITNEXUS_LOG_LEVEL` env var into `buildBaseOptions`.
  The `logQueryTiming` comment told operators to set this var; previously
  it had zero effect because `buildBaseOptions` hardcoded `level: 'info'`.
- core/logger.ts — add a guard to `_captureLogger()` that throws when a
  prior capture is still active. Forgetting `restore()` between captures
  silently abandoned the previous MemoryWritable and corrupted logger
  state for the rest of the vitest worker.
- core/logger.ts — Proxy `get` trap now uses `Reflect.get(inner, prop, inner)`
  instead of `(inner as ...)[prop as string]`. The `prop as string` cast
  silently coerced symbol-keyed lookups (e.g. Symbol.toPrimitive) to the
  wrong key.
- embedding-pipeline.ts:259 — restore the `if (!vectorAvailable && isDev)`
  guard around `vectorUnavailableMessage`. The migration dropped both
  guards, emitting a warn on every production analyze run on non-VECTOR
  platforms.

P2 — error-shape fixes for pino's err serializer

- serve.ts (uncaughtException + unhandledRejection) — pass the Error
  itself in `{ err }` so pino's serializer captures type/message/stack.
  Was passing `err.message` (string) which lost the stack and shape.
- api.ts:1823 — same fix; was passing `err?.stack || err`.
- wiki.ts:587 — was passing the bare Error as the first arg to
  `logger.error(err)`, which pino coerces via `.toString()` and loses the
  shape; changed to `logger.error({ err }, 'wiki command failed')`.

P2 — design hygiene

- core/logger.ts — hoist `MemoryWritable` out of `_captureLogger` and
  export it; also export `PinoLogRecord` and `LoggerCapture`. Removes
  the duplicate definition in `logger.test.ts`.
- core/logger.ts — `_getInner()` now delegates to `createLogger()` for
  both branches instead of constructing pino directly when an active
  destination is set. Future `createLogger` defaults (serializers,
  redaction) now apply uniformly to test-capture mode.
- eslint.config.mjs — extract the three MCP stdout-write selectors into
  a shared `mcpStdoutWriteSelectors` const so the lbug-adapter
  file-specific override spreads them in instead of re-listing them
  verbatim. Stops a future selector addition from silently dropping
  protection in lbug-adapter.

P2 — test coverage

- worker-pool.test.ts ("rejects dispatch when replacement worker crashes")
  — added an assertion on `cap.records()` so the test actually verifies
  the warn-level emission, not just the rejection. Was capturing pino
  output and discarding it.
- logger.test.ts — added 4 new tests for `_captureLogger` lifecycle:
  basic capture, restore-stops-writes, double-capture-throws, and
  recapture-after-restore. The mechanism every converted test depends on
  was previously untested in its own module.

NOT APPLIED — residual actionable work (5 findings)

- #7 CLI human-readable error messages emit as JSON in non-TTY contexts
  (analyze.ts validators, EADDRINUSE banners, OOM/ERESOLVE recovery
  blocks). Design issue: needs a dedicated `cliMessage()` helper that
  bypasses pino. Scope is too large for this batch.
- #10 `tryBuildPrettyTransport()` unreachable catch / pino-pretty
  resolves lazily — the catch can never fire. Fix is to probe with
  `require.resolve('pino-pretty')` inside the try block. Mechanical but
  changes the safety contract; deferred for review.
- #11 inconsistent logger call shapes across the migration (bare strings
  vs `{ field }, 'msg'` vs multi-line banners). Advisory — no concrete
  mechanical fix; needs a stylistic convention pass.
- #12 `pino.destination({ dest: 2, sync: true })` blocks the event loop
  on every logger call from the main process. Fix needs `sync: false` +
  `flushSync()` hooks on `beforeExit`/`SIGTERM`. Non-trivial; deferred.
- #17 `pino.final()` not registered in serve.ts crash handlers — async
  pretty-print path may not flush before `process.exit(1)` on dev TTY.
  Defer; bounded to dev TTY scenarios.

Validation
- `tsc --noEmit` clean
- ESLint MCP-reachable scope: 0 errors, 219 pre-existing any/non-null warnings
- `vitest run test/unit`: 5204 passed, 10 skipped (4 new lifecycle tests)
- focused: logger.test.ts 26/26, worker-pool.test.ts 22/22, grpc-extractor 39/39

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

* fix(logger): harden runtime — pino-pretty packaging, sync writes, CLI UX

Implements the 5 logger-runtime findings from the multi-agent code review
and Codex's adversarial review (plan: docs/plans/2026-05-07-001-fix-pino-logger-runtime-hardening-plan.md).

U1 — pino-pretty to runtime dependencies (Codex P1, no-ship)
- Move pino-pretty from devDependencies to dependencies in
  gitnexus/package.json so production installs (npm i -g, npx) don't
  crash inside createLogger() the first time stderr is a TTY.
- Lockfile regenerated; npm ls --omit=dev confirms placement.

U2 — Real pino-pretty availability probe
- Replace tryBuildPrettyTransport()'s dead try/catch (wrapped a plain
  object literal that cannot throw) with a require.resolve('pino-pretty')
  probe via createRequire. Memoize via _prettyAvailable cache.
- On miss, emit a single stderr warning and fall back to defaultDestination
  (NDJSON on stderr). Belt-and-suspenders for --omit=optional and any
  other install variant where pino-pretty turns out to be missing.
- Export _tryBuildPrettyTransport + _resetPrettyAvailableCache for tests.
- Add 3 unit tests covering happy path, memoization, and warning bound.

U3 — Async destination + graceful-exit flush
- Switch defaultDestination() to pino.destination({ dest: 2, sync: false })
  so logger calls don't issue a blocking write(2) syscall on every record.
- Cache the destination in module-level _dest. Register process.on(
  'beforeExit', flushSync) once at module load (gated on !VITEST so
  vitest's between-test cleanup doesn't fight _captureLogger).
- Export flushLoggerSync() helper. Wire into existing shutdown handlers
  in cli/analyze.ts (SIGINT) and mcp/server.ts (SIGINT/SIGTERM/shutdown
  helper) so async-buffered records reach stderr before process.exit.
- Add smoke test for flushLoggerSync's no-op-on-empty-state contract.

U4 — Crash flush in serve.ts and api.ts
- Add flushLoggerSync() between logger.error and process.exit(1) in
  serve.ts uncaughtException/unhandledRejection handlers and api.ts
  uncaughtException handler.
- Pino v10 removed pino.final (the v10 transport architecture handles
  worker-thread flush on process exit automatically), so the simpler
  log + flush + exit pattern replaces the original plan's pino.final
  integration. Captured in the commented logger.ts JSDoc.
- api.ts shutdown() also flushes before process.exit(0).

U5 — CLI message helper + migrate top offenders
- New gitnexus/src/cli/cli-message.ts exporting cliInfo/cliWarn/cliError.
  Each writes plain text to process.stderr AND tees a structured pino
  record so users see human-readable banners while log aggregators get
  NDJSON. Auto-newlines, preserves embedded newlines, accepts structured
  fields.
- Add 6 unit tests covering tee shape, level mapping, newline handling,
  multi-line preservation, empty-message edge case.
- Migrate top user-facing offenders identified in review:
  - cli/analyze.ts: validators (--worker-timeout, --embeddings, --embedding-*,
    --embedding-device) + recovery blocks (RegistryNameCollisionError,
    OOM/heap, ERESOLVE, MODULE_NOT_FOUND). Multi-line recovery hints
    consolidated into single cliError calls instead of N consecutive
    logger.error('') lines that emitted N empty NDJSON records.
  - cli/serve.ts: EADDRINUSE banner + Failed-to-start error.
  - cli/eval-server.ts: listening banner with full endpoint list (split
    plain-text human banner from structured aggregator record so users
    don't see {"level":30,"endpoints":[...]} in their terminal).
- Update analyze-embeddings-limit.test.ts to spy on process.stderr.write
  instead of console.error (the validator now bypasses console).

Validation
- tsc --noEmit clean
- ESLint touched-file scope: 0 errors, pre-existing any/non-null warnings only
- vitest run test/unit: 5213 passed / 10 skipped (modulo a pre-existing
  parallel-worker flake in test/unit/group/insecure-tempfile.test.ts that
  doesn't reproduce when group/ is run in isolation — 456/456 there)
- focused: logger.test.ts 19/19, cli-message.test.ts 6/6,
  analyze-embeddings-limit.test.ts 9/9

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

* fix(cli): route hard-exit diagnostics through cliError to defeat buffer drain race

Codex's adversarial review on PR #1336 flagged that nine `logger.error/warn`
+ `process.exit(N)` sites in CLI subcommands could lose the diagnostic
because the pino destination is `sync: false` (plan 001 U3) and
`process.exit` skips the `beforeExit` flush hook. Symptom: a non-zero
exit with no visible message.

U1: migrate the nine sites to `cliError`/`cliWarn`
- gitnexus/src/cli/tool.ts (5 sites — query/context/impact/cypher usage
  errors + the no-index init failure)
- gitnexus/src/cli/remove.ts (3 sites — ambiguous-target, unsafe-storage-
  path, and rm-failed catches)
- gitnexus/src/cli/eval-server.ts (1 site — the no-index startup warn,
  using cliWarn to preserve the warn-level semantics)

`cliError`/`cliWarn` (gitnexus/src/cli/cli-message.ts, plan 001 U5) write
plain text directly to process.stderr AND tee a structured pino record.
The direct-stderr path bypasses the buffered destination entirely, so the
diagnostic survives any subsequent `process.exit` regardless of buffer
state. Removed the now-unused `import { logger }` from tool.ts (lint
caught it).

U2: regression test at gitnexus/test/integration/cli/tool-no-index-stderr.test.ts
- Spawns `node dist/cli/index.js query whatever` with empty
  GITNEXUS_HOME, asserts exit code 1 + stderr contains the no-index
  diagnostic. Pattern mirrors test/integration/mcp/server-startup.test.ts.

Honesty caveat: the regression signal is not deterministic. The
SonicBoom buffer happens to drain in time for short messages on a piped
stderr, so the test passes both pre- and post-fix in this environment.
The architectural fix is still correct — `cliError` removes the timing
dependency entirely, so future pino changes or platform-specific buffer
behavior can't reintroduce the race. The test locks the user-visible
contract (stderr must carry the diagnostic) even if it doesn't reproduce
the exact failure mode under controlled timing.

Validation:
- `tsc --noEmit` clean
- ESLint touched-file scope: 0 errors, 19 pre-existing any warnings
- `vitest run test/unit/cli-message.test.ts test/unit/logger.test.ts`:
  25/25 pass
- New regression test passes against built dist/

Closes Codex P1 from the post-runtime-hardening review.

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

* fix(ci): replace console.error with cliWarn in optional-grammars

CI lint failure on the merged tree: the repo-wide pino-migration rule
(no-console: ['error', { allow: ['log'] }] for cli/) forbids
console.error in CLI code. optional-grammars.ts was added by PR #1383
and used console.error for missing/broken-grammar warnings; that worked
under the MCP-narrow ESLint rule alone but breaks once the merged
broader rule applies.

Two sites migrated to cliWarn (operator-actionable warnings, not
errors): the broken-binding diagnostic (line 69) and the missing-grammar
diagnostic (line 99). Each now writes plain text to stderr AND tees a
structured logger.warn record with grammar/extensions/error fields.

Also: hoisted opts?.relevantExtensions into a local const so the closure
inside .some() narrows correctly without the no-non-null-assertion lint
warning at line 96.

Validation
- ESLint optional-grammars.ts: 0 errors, 0 warnings (was 2 errors + 1 warning)
- tsc --noEmit clean
- vitest run cli-message + logger: 25/25 pass

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-07 20:56:25 +01:00

1102 lines
38 KiB
TypeScript

/**
* Unit Tests: LocalBackend callTool dispatch & lifecycle
*
* Tests the callTool dispatch logic, resolveRepo, init/disconnect,
* error cases, and silent failure patterns — all with mocked LadybugDB.
*
* These are pure unit tests that mock the LadybugDB layer to test
* the dispatch and error handling logic in isolation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// We need to mock the LadybugDB adapter and repo-manager BEFORE importing LocalBackend.
// local-backend.ts imports from core/lbug/pool-adapter.js; the mcp/core/lbug-adapter.js
// re-exports from the same module, so we mock the canonical source.
// vi.hoisted runs before vi.mock hoisting, making the fns available to both factories.
const { lbugMocks, platformMocks } = vi.hoisted(() => ({
lbugMocks: {
initLbug: vi.fn().mockResolvedValue(undefined),
executeQuery: vi.fn().mockResolvedValue([]),
executeParameterized: vi.fn().mockResolvedValue([]),
closeLbug: vi.fn().mockResolvedValue(undefined),
isLbugReady: vi.fn().mockReturnValue(true),
},
platformMocks: {
isVectorExtensionSupportedByPlatform: vi.fn().mockReturnValue(true),
},
}));
vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, ...lbugMocks };
});
// Re-export shim must resolve to the same mocks
vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, ...lbugMocks };
});
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
// `core/git-staleness` is also imported by `local-backend.ts` (for
// `checkStaleness` and `checkCwdMatch`). Stub it out here so unit
// tests don't shell out to git.
vi.mock('../../src/core/git-staleness.js', () => ({
checkStaleness: vi.fn().mockReturnValue({ isStale: false, commitsBehind: 0 }),
checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }),
}));
vi.mock('../../src/core/platform/capabilities.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/platform/capabilities.js')>();
return {
...actual,
isVectorExtensionSupportedByPlatform: platformMocks.isVectorExtensionSupportedByPlatform,
};
});
// Also mock the search modules to avoid loading onnxruntime
vi.mock('../../src/core/search/bm25-index.js', () => ({
searchFTSFromLbug: vi.fn().mockResolvedValue([]),
}));
vi.mock('../../src/mcp/core/embedder.js', () => ({
embedQuery: vi.fn().mockResolvedValue([]),
getEmbeddingDims: vi.fn().mockReturnValue(384),
}));
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos, cleanupOldKuzuFiles } from '../../src/storage/repo-manager.js';
import { _captureLogger } from '../../src/core/logger.js';
import {
initLbug,
executeQuery,
executeParameterized,
isLbugReady,
closeLbug,
} from '../../src/mcp/core/lbug-adapter.js';
// ─── Helpers ─────────────────────────────────────────────────────────
const MOCK_REPO_ENTRY = {
name: 'test-project',
path: '/tmp/test-project',
storagePath: '/tmp/.gitnexus/test-project',
indexedAt: '2024-06-01T12:00:00Z',
lastCommit: 'abc1234567890',
stats: { files: 10, nodes: 50, edges: 100, communities: 3, processes: 5 },
};
function setupSingleRepo() {
(listRegisteredRepos as any).mockResolvedValue([MOCK_REPO_ENTRY]);
}
function setupMultipleRepos() {
(listRegisteredRepos as any).mockResolvedValue([
MOCK_REPO_ENTRY,
{
...MOCK_REPO_ENTRY,
name: 'other-project',
path: '/tmp/other-project',
storagePath: '/tmp/.gitnexus/other-project',
},
]);
}
function setupNoRepos() {
(listRegisteredRepos as any).mockResolvedValue([]);
}
// ─── LocalBackend lifecycle ──────────────────────────────────────────
describe('LocalBackend.init', () => {
let backend: LocalBackend;
beforeEach(() => {
backend = new LocalBackend();
vi.clearAllMocks();
});
it('returns true when repos are available', async () => {
setupSingleRepo();
const result = await backend.init();
expect(result).toBe(true);
});
it('returns false when no repos are registered', async () => {
setupNoRepos();
const result = await backend.init();
expect(result).toBe(false);
});
it('calls listRegisteredRepos with validate: true', async () => {
setupSingleRepo();
await backend.init();
expect(listRegisteredRepos).toHaveBeenCalledWith({ validate: true });
});
});
describe('LocalBackend.disconnect', () => {
let backend: LocalBackend;
beforeEach(() => {
backend = new LocalBackend();
vi.clearAllMocks();
});
it('does not throw when no repos are initialized', async () => {
setupNoRepos();
await backend.init();
await expect(backend.disconnect()).resolves.not.toThrow();
});
it('calls closeLbug on disconnect', async () => {
setupSingleRepo();
await backend.init();
await backend.disconnect();
expect(closeLbug).toHaveBeenCalled();
});
});
// ─── callTool dispatch ───────────────────────────────────────────────
describe('LocalBackend.callTool', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true);
backend = new LocalBackend();
setupSingleRepo();
await backend.init();
});
it('routes list_repos without needing repo param', async () => {
const result = await backend.callTool('list_repos', {});
expect(Array.isArray(result)).toBe(true);
expect(result[0].name).toBe('test-project');
});
it('throws for unknown tool name', async () => {
await expect(backend.callTool('nonexistent_tool', {})).rejects.toThrow(
'Unknown tool: nonexistent_tool',
);
});
it('dispatches query tool', async () => {
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('query', { query: 'auth' });
expect(result).toHaveProperty('processes');
expect(result).toHaveProperty('definitions');
});
it('skips vector index query when VECTOR is unsupported by the platform', async () => {
const cap = _captureLogger();
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(false);
(executeQuery as any).mockImplementation(async (_repoId: string, cypher: string) => {
if (cypher.includes('COUNT(*) AS cnt')) return [{ cnt: 1 }];
if (cypher.includes('MATCH (e:CodeEmbedding)')) return [];
return [];
});
(executeParameterized as any).mockResolvedValue([]);
try {
await backend.callTool('query', { query: 'auth' });
const queries = (executeQuery as any).mock.calls.map(
([, cypher]: [string, string]) => cypher,
);
expect(queries.some((cypher: string) => cypher.includes('QUERY_VECTOR_INDEX'))).toBe(false);
expect(
queries.some(
(cypher: string) =>
cypher.includes('RETURN e.nodeId AS nodeId') &&
cypher.includes('e.embedding AS embedding'),
),
).toBe(true);
expect(
cap
.records()
.some((r) =>
String(r.msg ?? '').includes(
'GitNexus [query:vector]: VECTOR extension not supported on this platform',
),
),
).toBe(true);
} finally {
cap.restore();
}
});
it('issues vector index query when VECTOR is supported by the platform', async () => {
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true);
(executeQuery as any).mockImplementation(async (_repoId: string, cypher: string) => {
if (cypher.includes('COUNT(*) AS cnt')) return [{ cnt: 1 }];
return [];
});
(executeParameterized as any).mockResolvedValue([]);
await backend.callTool('query', { query: 'auth' });
const queries = (executeQuery as any).mock.calls.map(([, cypher]: [string, string]) => cypher);
expect(queries.some((cypher: string) => cypher.includes('QUERY_VECTOR_INDEX'))).toBe(true);
});
it('query tool returns error for empty query', async () => {
const result = await backend.callTool('query', { query: '' });
expect(result.error).toContain('query parameter is required');
});
it('query tool returns error for whitespace-only query', async () => {
const result = await backend.callTool('query', { query: ' ' });
expect(result.error).toContain('query parameter is required');
});
it('dispatches cypher tool and blocks write queries', async () => {
const result = await backend.callTool('cypher', { query: 'CREATE (n:Test)' });
expect(result).toHaveProperty('error');
expect(result.error).toContain('Write operations');
});
it('dispatches cypher tool with valid read query', async () => {
(executeQuery as any).mockResolvedValue([{ name: 'test', filePath: 'src/test.ts' }]);
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN n.name AS name, n.filePath AS filePath LIMIT 5',
});
// formatCypherAsMarkdown returns { markdown, row_count } for tabular results
expect(result).toHaveProperty('markdown');
expect(result).toHaveProperty('row_count');
expect(result.row_count).toBe(1);
});
it('dispatches context tool', async () => {
(executeParameterized as any).mockResolvedValue([
{
id: 'func:main',
name: 'main',
type: 'Function',
filePath: 'src/index.ts',
startLine: 1,
endLine: 10,
},
]);
const result = await backend.callTool('context', { name: 'main' });
expect(result.status).toBe('found');
expect(result.symbol.name).toBe('main');
});
it('context tool returns error when name and uid are both missing', async () => {
const result = await backend.callTool('context', {});
expect(result.error).toContain('Either "name" or "uid"');
});
it('context tool returns not-found for missing symbol', async () => {
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('context', { name: 'doesNotExist' });
expect(result.error).toContain('not found');
});
it('context tool returns disambiguation for multiple matches', async () => {
(executeParameterized as any).mockResolvedValue([
{
id: 'func:main:1',
name: 'main',
type: 'Function',
filePath: 'src/a.ts',
startLine: 1,
endLine: 5,
},
{
id: 'func:main:2',
name: 'main',
type: 'Function',
filePath: 'src/b.ts',
startLine: 1,
endLine: 5,
},
]);
const result = await backend.callTool('context', { name: 'main' });
expect(result.status).toBe('ambiguous');
expect(result.candidates).toHaveLength(2);
// #470: every candidate carries a relevance score in [0, 1] and the list
// is sorted descending by score (with deterministic tiebreakers).
for (const c of result.candidates) {
expect(typeof c.score).toBe('number');
expect(c.score).toBeGreaterThanOrEqual(0);
expect(c.score).toBeLessThanOrEqual(1);
}
expect(result.candidates[0].score).toBeGreaterThanOrEqual(result.candidates[1].score);
});
it('context tool ranks file_path match higher than non-match (#470)', async () => {
(executeParameterized as any).mockResolvedValue([
{
id: 'func:handleConnect:1',
name: 'handleConnect',
type: 'Function',
filePath: 'src/lib/socket.ts',
startLine: 10,
endLine: 20,
},
{
id: 'func:handleConnect:2',
name: 'handleConnect',
type: 'Function',
filePath: 'src/App.tsx',
startLine: 42,
endLine: 60,
},
]);
const result = await backend.callTool('context', {
name: 'handleConnect',
file_path: 'App.tsx',
});
// In production, `WHERE n.filePath CONTAINS $filePath` would pre-filter
// at the DB layer and only `src/App.tsx` would come back — resolving
// via the single-candidate early return rather than via scoring. The
// `executeParameterized` mock here returns both rows regardless of the
// WHERE clause parameters, so this asserts that the resolver ends up
// picking the App.tsx candidate in either case (via mock-relaxed DB
// pre-filter or via scoring promotion). The dedicated scoring-promotion
// path is covered by the next `it()` block below.
expect(result.status).toBe('found');
expect(result.symbol.filePath).toBe('src/App.tsx');
});
it('context tool promotes top candidate via scoring when multiple rows survive DB pre-filter (#470)', async () => {
// This test explicitly exercises the scored-promotion path (#470
// review): both candidates satisfy the file_path hint (so DB
// pre-filter would return both in production), and promotion is
// determined purely by the combined file_path + kind score.
(executeParameterized as any).mockResolvedValue([
{
id: 'fn:App:1',
name: 'render',
type: 'Function',
filePath: 'src/components/App.tsx',
startLine: 10,
endLine: 20,
},
{
id: 'method:App:1',
name: 'render',
type: 'Method',
filePath: 'src/pages/App.tsx',
startLine: 5,
endLine: 15,
},
]);
const result = await backend.callTool('context', {
name: 'render',
file_path: 'App.tsx',
kind: 'Function',
});
// Expected scoring:
// Function candidate: 0.50 base + 0.40 file_path + 0.20 kind = 1.10 → cap 1.00
// Method candidate: 0.50 base + 0.40 file_path + 0.00 kind = 0.90
// Top score ≥ 0.95 and beats runner-up by 0.10 → confident promotion
// to `{ status: 'found' }` with the Function.
expect(result.status).toBe('found');
expect(result.symbol.filePath).toBe('src/components/App.tsx');
expect(result.symbol.kind).toBe('Function');
});
it('context tool returns ranked candidates when file_path only partially narrows (#470)', async () => {
(executeParameterized as any).mockResolvedValue([
{
id: 'func:foo:1',
name: 'foo',
type: 'Function',
filePath: 'src/a.ts',
startLine: 1,
endLine: 5,
},
{
id: 'func:foo:2',
name: 'foo',
type: 'Function',
filePath: 'src/b.ts',
startLine: 1,
endLine: 5,
},
]);
// No hints → both candidates score 0.56 (0.50 base + 0.06 Function
// priority). Tied scores fall back to deterministic tiebreakers.
const result = await backend.callTool('context', { name: 'foo' });
expect(result.status).toBe('ambiguous');
expect(result.candidates).toHaveLength(2);
expect(result.candidates[0].score).toBeCloseTo(0.56, 2);
expect(result.candidates[1].score).toBeCloseTo(0.56, 2);
});
it('context tool boosts the candidate whose kind matches the hint (#470)', async () => {
(executeParameterized as any).mockResolvedValue([
{
id: 'method:save:1',
name: 'save',
type: 'Method',
filePath: 'src/service.ts',
startLine: 10,
endLine: 20,
},
{
id: 'func:save:1',
name: 'save',
type: 'Function',
filePath: 'src/util.ts',
startLine: 5,
endLine: 15,
},
]);
const result = await backend.callTool('context', { name: 'save', kind: 'Function' });
// When kind hint is given, kind-priority bonus is suppressed and +0.20
// kind-match bonus applies instead. Function becomes the top candidate.
expect(result.status).toBe('ambiguous');
expect(result.candidates[0].kind).toBe('Function');
expect(result.candidates[0].score).toBeGreaterThan(result.candidates[1].score);
});
it('impact tool returns ambiguous shape with ranked candidates when target has multiple matches (#470)', async () => {
// resolveSymbolCandidates issues a single name query; mock it to return
// two Function rows in different files with no hints.
(executeParameterized as any).mockResolvedValue([
{
id: 'func:login:1',
name: 'login',
type: 'Function',
filePath: 'src/auth.ts',
startLine: 5,
endLine: 15,
},
{
id: 'func:login:2',
name: 'login',
type: 'Function',
filePath: 'src/admin/login.ts',
startLine: 8,
endLine: 20,
},
]);
const result = await backend.callTool('impact', { target: 'login', direction: 'upstream' });
expect(result.status).toBe('ambiguous');
expect(result.candidates).toHaveLength(2);
expect(result.impactedCount).toBe(0);
expect(result.risk).toBe('UNKNOWN');
expect(result.target.name).toBe('login');
for (const c of result.candidates) {
expect(typeof c.score).toBe('number');
expect(c.uid).toBeDefined();
expect(c.kind).toBe('Function');
}
});
it('impact tool resolves via target_uid without running the name-based resolver (#470)', async () => {
// UID path: exactly one executeParameterized call for the lookup, then
// the BFS issues executeQuery calls (which we mock empty). Crucially,
// no `WHERE n.name =` query fires.
(executeParameterized as any).mockResolvedValue([
{
id: 'uid:1234',
name: 'pickedByUid',
type: 'Function',
filePath: 'src/pick.ts',
startLine: 1,
endLine: 10,
},
]);
(executeQuery as any).mockResolvedValue([]);
const result = await backend.callTool('impact', {
target: 'ignoredName',
target_uid: 'uid:1234',
direction: 'upstream',
});
// No ambiguous shape and no name-lookup error — the uid short-circuit won.
expect(result.status).not.toBe('ambiguous');
expect(result.target).toBeDefined();
// All executeParameterized calls this test dispatched must have been
// uid-keyed, never name-keyed. That proves the name resolver was skipped.
const calls = (executeParameterized as any).mock.calls as Array<
[string, string, Record<string, unknown>]
>;
for (const [, cypher] of calls) {
expect(cypher).not.toMatch(/WHERE n\.name = \$symName/);
}
});
it('dispatches impact tool', async () => {
// impact() calls executeParameterized to find target, then executeQuery for traversal
(executeParameterized as any).mockResolvedValue([
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
]);
(executeQuery as any).mockResolvedValue([]);
const result = await backend.callTool('impact', { target: 'main', direction: 'upstream' });
expect(result).toBeDefined();
expect(result.target).toBeDefined();
});
it('dispatches detect_changes tool', async () => {
// detect_changes calls execFileSync which we haven't mocked at module level,
// so it will throw a git error — that's fine, we test the error path
const result = await backend.callTool('detect_changes', { scope: 'unstaged' });
// Should either return changes or a git error
expect(result).toBeDefined();
expect(result.error || result.summary).toBeDefined();
});
it('dispatches rename tool', async () => {
(executeParameterized as any)
.mockResolvedValueOnce([
{
id: 'func:oldName',
name: 'oldName',
type: 'Function',
filePath: 'src/test.ts',
startLine: 1,
endLine: 5,
},
])
.mockResolvedValue([]);
const result = await backend.callTool('rename', {
symbol_name: 'oldName',
new_name: 'newName',
dry_run: true,
});
expect(result).toBeDefined();
});
it('rename returns error when both symbol_name and symbol_uid are missing', async () => {
const result = await backend.callTool('rename', { new_name: 'newName' });
expect(result.error).toContain('Either symbol_name or symbol_uid');
});
// api_impact tool
it('dispatches api_impact tool with route param', async () => {
(executeParameterized as any).mockResolvedValue([
{
routeId: 'Route:/api/grants',
routeName: '/api/grants',
handlerFile: 'app/api/grants/route.ts',
responseKeys: ['data', 'pagination'],
errorKeys: ['error', 'message'],
middleware: ['withAuth'],
consumerName: 'GrantsList',
consumerFile: 'src/GrantsList.tsx',
fetchReason: 'fetch-url-match|keys:data,pagination',
},
]);
const result = await backend.callTool('api_impact', { route: '/api/grants' });
expect(result).toHaveProperty('route', '/api/grants');
expect(result).toHaveProperty('handler', 'app/api/grants/route.ts');
expect(result).toHaveProperty('responseShape');
expect(result.responseShape.success).toEqual(['data', 'pagination']);
expect(result.responseShape.error).toEqual(['error', 'message']);
expect(result).toHaveProperty('middleware', ['withAuth']);
expect(result).toHaveProperty('consumers');
expect(result.consumers).toHaveLength(1);
expect(result).toHaveProperty('impactSummary');
expect(result.impactSummary.directConsumers).toBe(1);
expect(result.impactSummary.riskLevel).toBe('LOW');
});
it('api_impact returns error when no route or file param', async () => {
const result = await backend.callTool('api_impact', {});
expect(result.error).toContain('Either "route" or "file"');
});
it('api_impact returns error when no routes found', async () => {
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('api_impact', { route: '/api/nonexistent' });
expect(result.error).toContain('No routes found');
});
it('api_impact detects mismatches and bumps risk level', async () => {
(executeParameterized as any).mockResolvedValue([
{
routeId: 'Route:/api/data',
routeName: '/api/data',
handlerFile: 'api/data.ts',
responseKeys: ['items'],
errorKeys: ['error'],
middleware: null,
consumerName: 'DataView',
consumerFile: 'src/DataView.tsx',
fetchReason: 'fetch-url-match|keys:items,meta',
},
]);
const result = await backend.callTool('api_impact', { route: '/api/data' });
expect(result.mismatches).toBeDefined();
expect(result.mismatches).toHaveLength(1);
expect(result.mismatches[0].field).toBe('meta');
expect(result.mismatches[0].reason).toContain('not in response shape');
// 1 consumer = LOW, but mismatch bumps to MEDIUM
expect(result.impactSummary.riskLevel).toBe('MEDIUM');
});
it('api_impact supports file param lookup', async () => {
(executeParameterized as any).mockResolvedValue([
{
routeId: 'Route:/api/users',
routeName: '/api/users',
handlerFile: 'app/api/users/route.ts',
responseKeys: ['users'],
errorKeys: null,
middleware: null,
consumerName: null,
consumerFile: null,
fetchReason: null,
},
]);
const result = await backend.callTool('api_impact', { file: 'app/api/users/route.ts' });
expect(result.route).toBe('/api/users');
expect(result.impactSummary.directConsumers).toBe(0);
expect(result.impactSummary.riskLevel).toBe('LOW');
});
it('api_impact returns array for multiple matching routes', async () => {
(executeParameterized as any).mockResolvedValue([
{
routeId: 'Route:/api/a',
routeName: '/api/a',
handlerFile: 'api/a.ts',
responseKeys: null,
errorKeys: null,
middleware: null,
consumerName: null,
consumerFile: null,
fetchReason: null,
},
{
routeId: 'Route:/api/b',
routeName: '/api/b',
handlerFile: 'api/b.ts',
responseKeys: null,
errorKeys: null,
middleware: null,
consumerName: null,
consumerFile: null,
fetchReason: null,
},
]);
const result = await backend.callTool('api_impact', { route: '/api/' });
expect(result.routes).toHaveLength(2);
expect(result.total).toBe(2);
});
it('api_impact HIGH risk for 10+ consumers', async () => {
const rows = [];
for (let i = 0; i < 10; i++) {
rows.push({
routeId: 'Route:/api/popular',
routeName: '/api/popular',
handlerFile: 'api/popular.ts',
responseKeys: ['data'],
errorKeys: null,
middleware: null,
consumerName: `Consumer${i}`,
consumerFile: `src/Consumer${i}.tsx`,
fetchReason: null,
});
}
(executeParameterized as any).mockResolvedValue(rows);
const result = await backend.callTool('api_impact', { route: '/api/popular' });
expect(result.impactSummary.directConsumers).toBe(10);
expect(result.impactSummary.riskLevel).toBe('HIGH');
});
// Legacy tool aliases
it('dispatches "search" as alias for query', async () => {
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('search', { query: 'auth' });
expect(result).toHaveProperty('processes');
});
it('dispatches "explore" as alias for context', async () => {
(executeParameterized as any).mockResolvedValue([
{
id: 'func:main',
name: 'main',
type: 'Function',
filePath: 'src/index.ts',
startLine: 1,
endLine: 10,
},
]);
const result = await backend.callTool('explore', { name: 'main' });
// explore calls context — which may return found or ambiguous depending on mock
expect(result).toBeDefined();
expect(result.status === 'found' || result.symbol || result.error === undefined).toBeTruthy();
});
});
// ─── Repo resolution ────────────────────────────────────────────────
describe('LocalBackend.resolveRepo', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
});
it('resolves single repo without param', async () => {
setupSingleRepo();
await backend.init();
const result = await backend.callTool('list_repos', {});
expect(result).toHaveLength(1);
});
it('throws when no repos are registered', async () => {
setupNoRepos();
await backend.init();
await expect(backend.callTool('query', { query: 'test' })).rejects.toThrow(
'No indexed repositories',
);
});
it('throws for ambiguous repos without param', async () => {
setupMultipleRepos();
await backend.init();
await expect(backend.callTool('query', { query: 'test' })).rejects.toThrow(
'Multiple repositories indexed',
);
});
it('resolves repo by name parameter', async () => {
setupMultipleRepos();
await backend.init();
// With repo param, it should resolve correctly
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('query', {
query: 'auth',
repo: 'test-project',
});
expect(result).toHaveProperty('processes');
});
it('throws for unknown repo name', async () => {
setupSingleRepo();
await backend.init();
await expect(backend.callTool('query', { query: 'test', repo: 'nonexistent' })).rejects.toThrow(
'not found',
);
});
it('resolves repo case-insensitively', async () => {
setupSingleRepo();
await backend.init();
(executeParameterized as any).mockResolvedValue([]);
// Should match even with different case
const result = await backend.callTool('query', {
query: 'test',
repo: 'Test-Project',
});
expect(result).toHaveProperty('processes');
});
it('refreshes registry on repo miss', async () => {
setupNoRepos();
await backend.init();
// Now make a repo appear
(listRegisteredRepos as any).mockResolvedValue([MOCK_REPO_ENTRY]);
// The resolve should re-read the registry and find the new repo
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('query', {
query: 'test',
repo: 'test-project',
});
expect(result).toHaveProperty('processes');
// listRegisteredRepos should have been called again
expect(listRegisteredRepos).toHaveBeenCalledTimes(2); // once in init, once in refreshRepos
});
it('emits sibling-clone drift warning exactly once per (repo, cwd) pair', async () => {
// Regression guard for the one-shot stderr warning emitted when
// the caller's cwd is in a sibling clone of the resolved index.
// The cache must short-circuit BOTH `console.error` and the
// underlying `checkCwdMatch` git shellouts on subsequent calls.
const { checkCwdMatch } = await import('../../src/core/git-staleness.js');
(listRegisteredRepos as any).mockResolvedValue([
{ ...MOCK_REPO_ENTRY, remoteUrl: 'https://example.com/foo/bar' },
]);
(checkCwdMatch as any).mockResolvedValue({
match: 'sibling-by-remote',
entry: { ...MOCK_REPO_ENTRY, remoteUrl: 'https://example.com/foo/bar' },
cwdGitRoot: '/tmp/sibling-clone',
cwdHead: 'feedface',
hint: '⚠️ stale sibling clone',
});
const cap = _captureLogger();
try {
await backend.init();
// Three resolveRepo invocations from the same cwd:
await backend.callTool('list_repos', {}); // resolveRepo not called for list_repos
// Use a real resolveRepo path:
await backend.resolveRepo();
await backend.resolveRepo();
await backend.resolveRepo();
const drift = cap
.records()
.filter((r) => String(r.msg ?? '').includes('stale sibling clone'));
expect(drift).toHaveLength(1);
// checkCwdMatch should also only run once — the cache check
// happens BEFORE the shellout-heavy match call.
expect(checkCwdMatch).toHaveBeenCalledTimes(1);
} finally {
cap.restore();
(checkCwdMatch as any).mockResolvedValue({ match: 'none' });
}
});
});
// ─── getContext ──────────────────────────────────────────────────────
describe('LocalBackend.getContext', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
setupSingleRepo();
await backend.init();
});
it('returns context for single repo without specifying id', () => {
const ctx = backend.getContext();
expect(ctx).not.toBeNull();
expect(ctx!.projectName).toBe('test-project');
expect(ctx!.stats.fileCount).toBe(10);
expect(ctx!.stats.functionCount).toBe(50);
});
it('returns context by repo id', () => {
const ctx = backend.getContext('test-project');
expect(ctx).not.toBeNull();
expect(ctx!.projectName).toBe('test-project');
});
it('returns single repo context even with unknown id (single-repo fallback)', () => {
// When only 1 repo is registered, getContext falls through the id check
// and returns the single repo's context. This is intentional behavior.
const ctx = backend.getContext('nonexistent');
// The id doesn't match, but since repos.size === 1, it returns that single context
// This is the actual behavior — test documents it
expect(ctx).not.toBeNull();
expect(ctx!.projectName).toBe('test-project');
});
});
// ─── LadybugDB lazy initialization ──────────────────────────────────────
describe('ensureInitialized', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
setupSingleRepo();
await backend.init();
});
it('calls initLbug on first tool call', async () => {
(executeParameterized as any).mockResolvedValue([]);
await backend.callTool('query', { query: 'test' });
expect(initLbug).toHaveBeenCalled();
});
it('retries initLbug if connection was evicted', async () => {
(executeParameterized as any).mockResolvedValue([]);
// First call initializes
await backend.callTool('query', { query: 'test' });
expect(initLbug).toHaveBeenCalledTimes(1);
// Simulate idle eviction
(isLbugReady as any).mockReturnValueOnce(false);
await backend.callTool('query', { query: 'test' });
expect(initLbug).toHaveBeenCalledTimes(2);
});
it('handles initLbug failure gracefully', async () => {
(initLbug as any).mockRejectedValueOnce(new Error('DB locked'));
await expect(backend.callTool('query', { query: 'test' })).rejects.toThrow('DB locked');
});
});
// ─── Cypher write blocking through callTool ──────────────────────────
describe('callTool cypher write blocking', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
setupSingleRepo();
await backend.init();
});
const writeQueries = [
'CREATE (n:Function {name: "test"})',
'MATCH (n) DELETE n',
'MATCH (n) SET n.name = "hacked"',
'MERGE (n:Function {name: "test"})',
'MATCH (n) REMOVE n.name',
'DROP TABLE Function',
'ALTER TABLE Function ADD COLUMN foo STRING',
'COPY Function FROM "file.csv"',
'MATCH (n) DETACH DELETE n',
];
for (const query of writeQueries) {
it(`blocks write query: ${query.slice(0, 30)}...`, async () => {
const result = await backend.callTool('cypher', { query });
expect(result).toHaveProperty('error');
expect(result.error).toContain('Write operations');
});
}
it('allows read query through callTool', async () => {
(executeQuery as any).mockResolvedValue([]);
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN n.name LIMIT 5',
});
// Should not have error property with write-block message
expect(result.error).toBeUndefined();
});
});
// ─── listRepos ──────────────────────────────────────────────────────
describe('LocalBackend.listRepos', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
});
it('returns empty array when no repos', async () => {
setupNoRepos();
await backend.init();
const repos = await backend.callTool('list_repos', {});
expect(repos).toEqual([]);
});
it('returns repo metadata', async () => {
setupSingleRepo();
await backend.init();
const repos = await backend.callTool('list_repos', {});
expect(repos).toHaveLength(1);
expect(repos[0]).toEqual(
expect.objectContaining({
name: 'test-project',
path: '/tmp/test-project',
indexedAt: expect.any(String),
lastCommit: expect.any(String),
}),
);
});
it('re-reads registry on each listRepos call', async () => {
setupSingleRepo();
await backend.init();
await backend.callTool('list_repos', {});
await backend.callTool('list_repos', {});
// listRegisteredRepos called: once in init, once per listRepos
expect(listRegisteredRepos).toHaveBeenCalledTimes(3);
});
});
// ─── Cypher LadybugDB not ready ────────────────────────────────────────
describe('cypher tool LadybugDB not ready', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
setupSingleRepo();
await backend.init();
});
it('returns error when LadybugDB is not ready', async () => {
(isLbugReady as any).mockReturnValue(false);
// initLbug will succeed but isLbugReady returns false after ensureInitialized
// Actually ensureInitialized checks isLbugReady and re-inits — let's make that pass
// then the cypher method checks isLbugReady again
(isLbugReady as any)
.mockReturnValueOnce(false) // ensureInitialized check
.mockReturnValueOnce(false); // cypher's own check
const result = await backend.callTool('cypher', {
query: 'MATCH (n) RETURN n LIMIT 1',
});
expect(result.error).toContain('LadybugDB not ready');
});
});
// ─── formatCypherAsMarkdown ──────────────────────────────────────────
describe('cypher result formatting', () => {
let backend: LocalBackend;
beforeEach(async () => {
// Full reset of all mocks to prevent state leaking from other tests
vi.resetAllMocks();
(listRegisteredRepos as any).mockResolvedValue([MOCK_REPO_ENTRY]);
(cleanupOldKuzuFiles as any).mockResolvedValue({ found: false, needsReindex: false });
(initLbug as any).mockResolvedValue(undefined);
(isLbugReady as any).mockReturnValue(true);
(closeLbug as any).mockResolvedValue(undefined);
(executeParameterized as any).mockResolvedValue([]);
backend = new LocalBackend();
await backend.init();
});
it('formats tabular results as markdown table', async () => {
(executeQuery as any).mockResolvedValue([
{ name: 'main', filePath: 'src/index.ts' },
{ name: 'helper', filePath: 'src/utils.ts' },
]);
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN n.name AS name, n.filePath AS filePath',
});
expect(result).toHaveProperty('markdown');
expect(result.markdown).toContain('name');
expect(result.markdown).toContain('main');
expect(result.row_count).toBe(2);
});
it('returns empty array as-is', async () => {
(executeQuery as any).mockResolvedValue([]);
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN n.name LIMIT 0',
});
expect(result).toEqual([]);
});
it('returns error object when cypher fails', async () => {
(executeQuery as any).mockRejectedValue(new Error('Syntax error'));
const result = await backend.callTool('cypher', {
query: 'INVALID CYPHER SYNTAX',
});
expect(result).toHaveProperty('error');
expect(result.error).toContain('Syntax error');
});
});