GitNexus/gitnexus/test/integration/group/cross-trace-e2e.test.ts
Gergő Magyar 1a03c8527a
feat(group): cross-repo call trace using PDG (#2269)
* refactor(group): extract shared resolveBridgeNeighbors from cross-impact

Lift the uid-filtered consumer<->provider ContractLink join (direction +
queryBridge + row normalization + confidence sort) out of runGroupImpact's
inline Phase-2 block into an exported resolveBridgeNeighbors helper. Behavior
is unchanged for impact; the helper becomes the single shared bridge join so
the upcoming cross-repo trace path never forks its own copy of the neighbor
Cypher. Empty uid sets short-circuit without a DB round-trip.

Adds direct coverage (real bridge via writeBridge/openBridgeDbReadOnly) for
both directions plus the empty-set and unknown-uid edges.

* feat(group): cross-repo trace stitching (groupTrace + runGroupTrace)

Add GroupService.groupTrace and the pure runGroupTrace engine that stitches
per-repo CALLS/HAS_METHOD trace segments across one ContractLink boundary in
the group bridge:

  from --(local trace)--> consumer --(ContractLink)--> provider --(local trace)--> to

- Resolves from/to across all members (symbol node id == bridge symbolUid);
  same-repo endpoints delegate to a single local trace with no crossing.
- Single boundary crossing (MAX_SUPPORTED_CROSS_DEPTH); deeper crossDepth is
  clamped with a note, mirroring cross-impact.
- Discriminated GroupTraceResult union (ok|not_found|ambiguous|error) with
  per-hop repo tags, a typed crossings[] entry, and centralized degraded-state
  note constants (TRACE_NOTES). No .
- Trace-specific pair query (keeps BOTH crossing endpoints) lives in this
  module; the uid-filtered neighbor join (resolveBridgeNeighbors) is reused
  where it fits. ensureBridgeReady exported for reuse.
- New GroupToolPort methods (trace/resolveSymbol/pdgFlows) are optional so
  existing port mocks keep type-checking; runGroupTrace guards on presence.

PDG enrichment is wired as an opt-in hook (enrichSegment) — the port method is
stubbed until U4. Covered by unit tests over a real bridge + mocked port.

* feat(group): route trace tool to groupTrace on @group syntax

Wire the cross-repo trace through the existing @group dispatch:
- callTool routes trace with an @-prefixed repo to callToolAtGroupRepo, which
  forwards from/to/uid/file/maxDepth/includeTests plus the experimental
  pdg/crossDepth flags to GroupService.groupTrace. Member path in @group/path
  is advisory for trace (resolution is whole-group).
- Port gains trace/resolveSymbol/pdgFlows adapters. resolveSymbolForGroup wraps
  the shared resolveSymbolCandidates so groupTrace can locate the member repo
  and recover each endpoint node id (== bridge symbolUid). pdgFlowsForGroup is
  a degraded stub here (call-level only); U4 implements the REACHING_DEF walk.
- trace tool schema documents the @group entry point, pdg, and crossDepth.

Single-repo trace is untouched. Covered by dispatch-routing tests (@group ->
groupTrace, non-group stays local) and tool-schema assertions.

* feat(group): opt-in PDG data-flow enrichment for cross-repo trace

Implement _pdgFlowsForGroupImpl: the real REACHING_DEF anchor walk that backs
the port pdgFlows adapter (replacing the U3 call-level stub). When pdg:true and
the segment repo has a flows PDG layer, the boundary-adjacent segments carry
their intra-procedural def->use hops:

- Anchors by the boundary symbol UID (precise; avoids the by-name ambiguity the
  resolveBlockAnchor path can hit), then reuses the same span-anchored,
  bind-param-only flows query as pdg_query (BasicBlock id-prefix + [start+1,
  end+1] line window; no rel-property index, so the anchor IS the bound).
- Stays intra-procedural: data flow never crosses the repo boundary.
- pdgStampForMode probe: false -> available:false (degrade with note); the
  trace stays ok. Any query failure is swallowed (enrichment is auxiliary).

Covered by runGroupTrace enrichment tests: dataFlow attached on opt-in,
degraded note when no layer, and no pdgFlows call when pdg is omitted.

* test(group): evaluation-first cross-repo trace e2e (two real indexes)

End-to-end gate for the cross-repo trace: stands up two real LadybugDB indexes
(consumer 'frontend' + provider 'backend'), a real ContractLink bridge, and a
real LocalBackend with both repos registered, then drives the public
callTool('trace', { repo: '@grp', pdg: true }) and asserts:
  - the stitched checkout -> callUsers -(CONTRACT_LINK)-> handleUsers -> getUsers
    path, each hop tagged with its member repo
  - real REACHING_DEF data-flow enrichment of the consumer segment (userId)
  - a degraded 'No PDG layer in app/backend' note (provider has no PDG layer)
  - single-repo trace against one member is unchanged (no crossings)

Hand-persists the minimal real graph (deterministic; a full two-repo analyze is
heavier than this gate needs) and exercises real Cypher across
resolveSymbolCandidates, _traceImpl, the bridge pair query, and
_pdgFlowsForGroupImpl. Windows-skipped (describeReopen) and registered in the
cross-platform native-lbug set.

Scoped to a single @group call: opening bridge.lbug read-only a SECOND time in
one process currently fails (shared bridge open/close lifecycle, also affects
impact @group) — the pdg-omitted/clamp variants are unit-covered.

* docs(group): document cross-repo trace + PDG enrichment

ARCHITECTURE.md: trace is now group-aware; describe the @group cross-repo
stitch over a single ContractLink boundary (CONTRACT_LINK hop, crossings[],
crossDepth clamp), the opt-in experimental PDG REACHING_DEF enrichment of
boundary-adjacent segments, the symbolUid-grain join between the two stores,
and the deferred full cross-program (SDG-like) data flow. PIPELINE.md: add the
cross-trace consumer of the bridge with its pair-query rationale.

Does not touch gitnexus/CHANGELOG.md (release-owned).

* fix(review): apply autofix feedback

Apply safe_auto findings from ce-code-review (run 20260622-094243):
- local-backend.ts: drop (r: any) in _pdgFlowsForGroupImpl row map; coerce
  hop line via Number() so a nullish LadybugDB cell can't surface NaN.
- tools.ts: advertise the forwarded  param in the trace schema and add
  crossDepth maximum:10 (schema now matches what groupTrace reads).
- cross-trace.ts: parallelize per-member resolveSymbol/resolveRepo with
  order-preserving Promise.all (matches groupContext/groupQuery); add a note
  when pdg:true is passed to a same-repo trace (PDG only enriches at a
  cross-repo boundary).
- tests: remove  / tighten  (no-any rule).

Residual gated_auto/manual findings (unbounded crossing query + loop,
whole-file PDG widening on absent span, error-vs-no_path masking, top-level
try/catch parity, helper dedupe, branch-coverage gaps) are recorded in the run
artifact for the PR body.

* fix(group): skip CHECKPOINT on read-only bridge close so it can reopen

Root cause of the in-process bridge.lbug reopen failure (which broke repeated
@group impact/trace calls in a long-lived MCP server): closeBridgeDb issued
CHECKPOINT on EVERY handle, including read-only ones. A CHECKPOINT on a
read-only connection has nothing to flush but leaves a WAL/shadow lock artifact
that makes the next read-only open of the same path fail (openBridgeDbReadOnly
returns null -> 'Could not open bridge.lbug read-only'). Reproduced: open ->
query -> closeBridgeDb -> open again returned null only when the close ran
CHECKPOINT; a non-checkpoint close reopened fine, and the raw native
open/close cycle was never the problem.

Fix: tag read-only handles (BridgeHandle._readOnly, set by openBridgeDbReadOnly)
and skip CHECKPOINT for them in closeBridgeDb. Writable handles are unchanged
(they still flush before close). This is the shared bridge-db close path, so
impact @group benefits identically.

- Regression test in bridge-db.test.ts: open/query/close/open/query/open in one
  process now succeeds.
- Re-enabled the second @group call in cross-trace-e2e.test.ts (was scoped to a
  single call for this very limitation).

* fix(group): bring bridge-db close to parity with the core adapter safeClose

The bridge open/close cycle was less robust than the main graph DB's: closeBridgeDb
closed the connection/database but skipped the post-close steps the core adapter's
safeClose performs, so a rapid in-process reopen could race the OS handle release
(Windows) or an orphaned WAL sidecar. That gap is why the close-then-reopen tests
had to skip Windows.

closeBridgeDb now mirrors safeClose after closing the handle:
- waitForWindowsHandleRelease(dbPath): probe the file (+ .wal) until the residual
  Windows lock clears, so the next open does not race (warns if the budget is
  exhausted, matching the core adapter).
- finalizeLbugSidecarsAfterClose(dbPath): quarantine an orphaned WAL (shadow
  missing) so the next open replays a consistent file.

Both helpers are the same ones safeClose uses (Windows-proven via the core adapter
CI), and the bridge read open already retries transient locks. Combined with the
read-only CHECKPOINT skip, the bridge reopen is now robust on every platform, so
the close-then-reopen tests run on all platforms (Windows CI exercises them via the
cross-platform subset). No write-path behavior change; Linux/macOS unaffected.

* fix(group): bound cross-repo crossing fan-out (LIMIT + segment memoization)

Address the top review residual: the bridge crossing query was unbounded and the
crossing-selection loop could run an O(2*N) sequential trace-BFS over every
ContractLink between a repo pair.

- CY_CROSSINGS_BETWEEN now ORDERs BY confidence DESC and LIMITs to
  MAX_CROSSINGS_TO_TRY + 1; listCrossingsBetween slices to the cap and reports
  truncation. Exceeding the cap surfaces a note (no silent truncation), keeping
  the highest-confidence crossings. Aligns with the repo's anchored+LIMIT-bounded
  query discipline (LadybugDB has no rel-property index).
- The home-repo segment (from -> consumer) depends only on the consumer uid and
  the target-repo segment (provider -> to) only on the provider uid, so each is
  memoized by that uid. Many crossings sharing a consumer/provider (one client
  call linked to several providers) now cost one trace per distinct endpoint
  instead of one per crossing. A consumer whose segment already failed is skipped
  for every later crossing that shares it.

Test: two links sharing a consumer (first provider unreachable, second reachable)
assert the from->consumer segment is traced exactly once and the second crossing
wins.

* fix(group): restore Windows skip for bridge reopen tests; drop ineffective close-side probe

The previous commit flipped the bridge close-then-reopen tests to run on Windows,
betting that a close-side waitForWindowsHandleRelease + finalizeLbugSidecarsAfterClose
probe (mirroring the core adapter safeClose) would make the in-process reopen work
there. Windows CI proved otherwise: 4 writeBridge->openBridgeDbReadOnly tests fail
('expected null not to be null' — the read open returns null). The writable-close ->
read-open handoff plus writeBridge's atomic sidecar rename does not release the OS
file handle before the read open races, and the existing open-side LBUG_OPEN_RETRY
only retries lock-pattern errors, not the post-rename sidecar database-id mismatch.
macOS passes; the core adapter's own reopen also passes — this is bridge+Windows
specific.

- Revert itLbugReopen to the Windows skip (the pre-existing, correct state).
- Remove the close-side probe + finalize from closeBridgeDb: it did NOT close the
  Windows gap, and reviewers flagged it for hot-path latency (finalize ran on every
  close, all platforms) and safeClose duplication.
- KEEP the load-bearing fix — skipping CHECKPOINT on read-only handles — which fixed
  the reproduced Linux/macOS in-process reopen artifact (the real bug).

Net: Linux/macOS repeated @group impact/trace works in-process; Windows in-process
bridge reopen remains a documented limitation (unchanged from before this PR).

* fix(group): surface degraded members + cap truncation; honest crossDepth schema

Address the cross-engine-corroborated tri-review findings (Codex + Claude):
- resolveAcrossMembers / runGroupTrace now track member repos that could NOT be
  queried (resolveRepo or resolveSymbol threw) and, when the result is not_found,
  attach a degraded-member note. A transient/corrupt member DB is no longer
  silently reported as a clean 'symbol absent' not_found. (Codex B1+B3 + ce-reliability.)
- The cross-repo not_found now carries a programmatic truncated:true flag (and a
  clearer suggestion) when the MAX_CROSSINGS_TO_TRY cap was hit, so a consumer can
  distinguish 'no path' from 'cap may have hidden a connecting ContractLink'.
  (Codex B3 + ce-adversarial + ce-api-contract.)
- trace tool schema: crossDepth maximum 10 -> 1 to match the implementation's
  single-hop clamp (the schema previously advertised an unsupported 2-10 range).
  (ce-api-contract, conf 100.)

Test: a member whose resolveSymbol throws yields not_found WITH a degraded note
naming the unreachable repo (if-free responder map).

* docs(group): clarify trace @group/memberPath is advisory (resolves all members)

Tri-review (Codex ce, conf 100) caught a doc/impl inconsistency: ARCHITECTURE.md
lumped trace with query/context/impact as honoring @group/memberPath member
scoping, but cross-repo trace resolves from/to across ALL members (the member
path is advisory). Clarify the behavior and point to from_uid/to_uid for
disambiguating same-named symbols across members.

* feat(group): file-level boundary fallback so cross-repo trace works on HTTP contracts

Benchmark (bench/cross-repo-trace/) running the REAL pipeline (runFullAnalysis
--pdg -> real syncGroup -> trace @group) found that cross-repo trace returned
not_found for real HTTP links even though sync built the correct ContractLinks:
HTTP (and other source-scan) contracts hardcode symbolUid:'' (http-route-extractor),
and both cross-trace AND cross-impact join crossings by Contract.symbolUid, which
never matches an empty uid. (Pre-existing — impact @group has the same gap.)

Fix: when a crossing's symbolUid is empty, fall back to the contract's FILE — if
the user's from/to resolves into the contract file, that endpoint anchors the
boundary. CY_CROSSINGS_BETWEEN now returns consumer/provider filePath; a crossing
is kept if it can be anchored by uid OR file on each side; a fileBoundaryFallback
note flags that the boundary is file-level, not symbol-precise. This makes the
common 'trace from=<calling fn> to=<handler fn>' case work end-to-end (verified:
fetchUsers -> listUsers stitches with a CONTRACT_LINK hop + PDG enrichment, 2/2).

Limits (documented in the bench README + the note): anonymous handlers have no
named target; when several contracts share files the file fallback may attach the
wrong contractId to a correct path. The proper upstream fix is to populate
symbolUid in the HTTP extraction (benefits impact too) — the bench is its gate.

Adds a unit test pinning the empty-symbolUid file-fallback stitch.

* fix(group): resolve HTTP contract symbolUid by containment (fixes cross-repo trace + impact)

Addresses the root cause behind the cross-repo trace file-fallback: HTTP
contracts hardcoded symbolUid:'' (http-route-extractor), so both cross-trace and
cross-impact — which join crossings on Contract.symbolUid — could not traverse
HTTP links. (Also found: the pre-existing graph-assisted resolution queried the
wrong edge, CONTAINS instead of DEFINES, so it never resolved a uid either.)

Now the extractor resolves each detection to a real symbol:
- HttpDetection carries the call-site line (node.ts sets it on every express/
  fetch/axios/jquery/nest detection; express also captures the handler arg).
- resolveDetectionSymbol resolves the named handler first, else the innermost
  Function/Method whose line span encloses the call (consumer = the function
  containing the fetch; provider = the named/inline handler), over the correct
  File-[DEFINES]->symbol edge. Base-tolerant (0- vs 1-based startLine).
- Wired into both source-scan and graph-assisted provider/consumer paths.

Verified end-to-end (bench/cross-repo-trace): all 4 contracts now carry real
uids, trace is symbol-precise (GET pair -> http::GET, POST -> http::POST, no
file-fallback note), and impact @group fans out (cross_repo_hits 0 -> 1). The
cross-trace file-level fallback remains as the secondary path for truly
anonymous handlers. Adds 2 containment unit tests; 738 group/integration pass.

Languages other than JS/TS still resolve providers by handler name; their
consumers fall through to the file fallback until their plugins set the line.

* fix(group): extend HTTP symbolUid containment to all languages + nested methods

Completes the symbolUid resolution across every bundled HTTP plugin: Python, Go,
PHP, Kotlin and Java now set the call-site line on their consumer (and Feign/
named) detections, so their HTTP contracts resolve to the containing function
the same way Node/TS already did.

Also generalizes the containment query: it now matches Function/Method/CodeElement
by filePath (UNION ALL) instead of File-[DEFINES]->symbol. The DEFINES edge only
reaches a file's TOP-LEVEL symbols, so methods nested in classes (Java/Kotlin —
File defines the class, the class defines the method) were invisible; matching by
filePath reaches them. Verified against a real index (LadybugDB supports the
UNION); JS/TS still fully symbol-precise (bench 2/2), 709 group tests pass.

Residual is now only the inherent case — a fully anonymous handler with no named
callee — which keeps the cross-trace file-level fallback.

* feat(group): destination trace — follow a consumer to an anonymous handler

Handles the one inherent residual: an anonymous route handler
(`router.get('/x', (req,res) => …)`) has no symbol node at all (the file holds
only a Const + PDG BasicBlocks), so it can never be named as a trace `to`.

Adds a DESTINATION TRACE: omit to/to_uid/to_file on an @group trace and
`trace from=<consumer>` follows the consumer's outgoing HTTP call across the
bridge and reports where it lands — by route + file:line, with a notes[] entry
flagging the handler as anonymous. Implemented as a new branch in runGroupTrace
(p.destination) backed by CY_CROSSINGS_FROM (all ContractLinks leaving the
consumer repo) + stitchToDestination; the provider endpoint is labelled
'<METHOD /path handler>' when its symbolName is a generic token/file basename.

The MCP routing already omitted an absent `to`, so only the schema docs changed.
parseTraceParams now treats a missing `to` as a destination trace instead of an
error. Verified end-to-end: anonymous fixture reports
'app/frontend:fetchUsers -> app/backend:<http::GET::/api/users handler>'; named
fixture lands at the real function. Adds 2 unit tests; 915 group tests pass.

* fix(group): tri-review fixes for cross-repo trace + symbolUid resolution

Two-engine tri-review (Claude swarm+ce + Codex GPT-5.5 swarm+ce+adversarial)
surfaced these; cross-engine-corroborated unless noted.

Correctness (P1, all four lanes): destination trace reported the WRONG endpoint
— an empty-uid consumer made trace(from->from) trivially succeed, so the highest-
confidence same-file crossing won regardless of which call `from` makes.
stitchToDestination now collects ALL connecting crossings, prefers symbol-precise
hits, and returns `ambiguous` (with candidates) when it cannot disambiguate.

Correctness (P1, Codex): resolveDetectionSymbol early-returned null when
d.line==null, blocking NAME resolution for named providers that set no line
(Spring/Go/etc.). Name resolution now runs first; only containment needs a line.

Correctness (P2): resolveContainingSymbol OR-ed `line` and `line-1`, which could
mis-pick a one-line sibling. It now probes the base-correct `line-1` first and
falls back to `line` only if nothing matches.

Correctness (Codex): anonymous Express handlers emitted name:'handler' and could
attach to an unrelated fn literally named `handler`. node.ts now emits name:null
for non-identifier handlers (containment-only).

Robustness: drop the first-symbol-in-file pickSymbolUid guess from the graph
consumer/provider paths (a wrong uid would win the contractId merge); remove the
dead CONTAINS_QUERY fallback (CONTAINS is File->Folder, never a symbol) + the now
-unused pickSymbolUid/handlerName; seed destination notes with degraded-member
notes so a successful trace still surfaces them; providerLabel takes providerUid
so a resolved fn named `handler` is not mislabeled anonymous, and only true file
basenames (known extensions) — not any dotted name — count as anonymous.

API contract: a single-repo trace with no `to` now returns an actionable error
(destination trace is @group-only) instead of "symbol 'undefined' not found".

Maintainability/tests: narrow asLocalTrace per-field (drop as-unknown-as); fix the
PR's lone as-any (vi.mocked); if-free e2e teardown; qualify the bench README.

Adds ambiguous-destination, anonymous-handler-no-false-name, and single-repo-no-to
tests; redirects graph mocks CONTAINS->UNION ALL. 918 group/integration pass.

* fix(group): carry degraded-member notes through SUCCESSFUL group traces

A reviewer (koriyoshi2041, PR #2269) correctly flagged that degraded-member
resolution was surfaced only on not_found, not on a successful ok result. Group
trace resolves names across ALL members, so an ok is 'unique among the members
we could query' — if a member that threw during resolveSymbol also holds from/to,
the real answer could be ambiguous. The destination path already seeded the note
(prior commit); this extends it to the same-repo and cross-repo success paths by
seeding the dispatch notes with degradedNotes([...fromRes.degraded, ...toRes.degraded]).

Adds a regression test: reg-be throws while a same-repo trace succeeds in reg-fe;
the ok result now carries the 'could not be queried' degraded note (app/backend).

* test(bench): cover all implemented cross-repo trace cases in one runner

Replace the single named-handler script with a self-contained verify.mjs that
generates each fixture inline and exercises every implemented end-to-end case
against the real analyze -> sync -> trace/impact pipeline, asserting PASS/FAIL
(exit non-zero on failure). 10 checks across 4 scenarios:
- named handlers: 4/4 symbolUid resolved; symbol-precise GET vs POST crossing
  selection; destination trace lands at the named handler.
- anonymous handler: empty symbolUid; destination trace reports it by route with
  the anonymous note.
- impact @group fan-out (cross_repo_hits >= 1).
- multi-language (Python Flask + requests): link built, cross-repo trace stitches,
  and the file-level boundary fallback is exercised when the provider has no uid.

Ambiguous-destination and degraded-member paths need synthetic inputs the real
analyzer cannot produce, so they stay in the unit suite (documented in the README
+ script header). Removes verify-named.mjs + fixtures-named/ (folded inline).

* test(group): pin destination degraded-success + precise-tier ambiguity

Adds the two regression guards koriyoshi2041 requested on PR #2269 after the
degraded-on-success fix:
- destination trace success with a degraded member: reg-fe resolves from and
  follows the link to an anonymous handler while reg-be throws; the ok result
  carries the anonymous endpoint AND the 'could not be queried' degraded note, so
  the no-to path stays aligned with explicit to traces.
- multiple PRECISE destination hits: one from reaches two consumers with resolved
  uids linked to different routes; the result is ambiguous (role: to) with both
  route candidates. Distinct from the existing file-level ambiguous test, this
  pins the stronger precise tier against a future change silently picking the
  highest-confidence destination.

Both already pass against current behavior; 716 group tests pass.
2026-06-23 07:54:13 +01:00

374 lines
12 KiB
TypeScript

/**
* U6 — Cross-repo trace, evaluation-first end-to-end.
*
* Stands up TWO real LadybugDB indexes (a "frontend" consumer repo and a
* "backend" provider repo), a real group bridge linking a consumer symbol to a
* provider symbol, and a real LocalBackend with both repos registered. Then it
* drives the public `callTool('trace', { repo: '@group', pdg: true })` and
* asserts the stitched cross-repo path AND the real REACHING_DEF data-flow
* enrichment — exercising every new query path against a real engine:
* resolveSymbolCandidates, _traceImpl, the bridge `listCrossingsBetween` pair
* query, and `_pdgFlowsForGroupImpl`.
*
* The two indexes are built sequentially with the writable core adapter (one
* open writer at a time) and read back through the MCP pool adapter the backend
* opens lazily. A real two-repo *analyze* pipeline is heavier than this gate
* needs; hand-persisting the minimal real graph keeps it deterministic while
* still hitting real LadybugDB Cypher.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { LocalBackend } from '../../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../../src/storage/repo-manager.js';
import { writeBridge } from '../../../src/core/group/bridge-db.js';
import type { CrossLink } from '../../../src/core/group/types.js';
import { makeContract } from '../../unit/group/fixtures.js';
vi.mock('../../../src/storage/repo-manager.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/storage/repo-manager.js')>();
return {
...actual,
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
// No meta.json for the seeded DBs — pdgStampForMode degrades to the
// row-existence probe (the seeded-DB reality, like pdg-query.test.ts).
loadMeta: vi.fn().mockResolvedValue(null),
};
});
// LadybugDB close-then-reopen is Windows-flaky (file lock held until process
// exit); the bridge write+read and the sequential two-DB build both hit it.
const describeReopen = process.platform === 'win32' ? describe.skip : describe;
/** Restore an env var to a prior value, or unset it if there was none. */
function restoreEnvVar(key: string, prev: string | undefined): void {
if (prev === undefined) delete process.env[key];
else process.env[key] = prev;
}
interface NodeSpec {
label: 'Function' | 'BasicBlock';
props: Record<string, unknown>;
}
interface RelSpec {
type: 'CALLS' | 'REACHING_DEF';
srcLabel: 'Function' | 'BasicBlock';
dstLabel: 'Function' | 'BasicBlock';
src: string;
dst: string;
reason?: string;
}
/** Build a real lbug DB at `lbugPath`, seeding nodes + rels via the writer. */
async function buildRepoDB(lbugPath: string, nodes: NodeSpec[], rels: RelSpec[]): Promise<void> {
const core = await import('../../../src/core/lbug/lbug-adapter.js');
await core.initLbug(lbugPath); // creates the full schema
try {
for (const n of nodes) {
const assignments = Object.keys(n.props)
.map((k) => `${k}: $${k}`)
.join(', ');
await core.executePrepared(`CREATE (x:${n.label} {${assignments}})`, n.props);
}
for (const r of rels) {
await core.executePrepared(
`MATCH (a:${r.srcLabel} {id: $src}), (b:${r.dstLabel} {id: $dst})
CREATE (a)-[:CodeRelation {type: '${r.type}', confidence: 1.0, reason: $reason, step: 0}]->(b)`,
{ src: r.src, dst: r.dst, reason: r.reason ?? '' },
);
}
await core.flushWAL();
} finally {
await core.closeLbug();
}
}
describeReopen('cross-repo trace e2e (two real indexes + bridge)', () => {
let tmpHome: string;
let storageFE: string;
let storageBE: string;
let backend: LocalBackend;
let prevHome: string | undefined;
beforeAll(async () => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cross-trace-e2e-'));
storageFE = path.join(tmpHome, 'fe-storage');
storageBE = path.join(tmpHome, 'be-storage');
fs.mkdirSync(storageFE, { recursive: true });
fs.mkdirSync(storageBE, { recursive: true });
// ── Frontend (consumer) index: checkout -> callUsers, with a REACHING_DEF
// data-flow inside callUsers (def line 3 -> use line 4 of `userId`). ──
await buildRepoDB(
path.join(storageFE, 'lbug'),
[
{
label: 'Function',
props: {
id: 'fn:checkout',
name: 'checkout',
filePath: 'src/checkout.ts',
startLine: 10,
endLine: 14,
},
},
{
label: 'Function',
props: {
id: 'fn:callUsers',
name: 'callUsers',
filePath: 'src/api.ts',
startLine: 2,
endLine: 6,
},
},
{
label: 'BasicBlock',
props: {
id: 'BasicBlock:src/api.ts:2:0:0',
filePath: 'src/api.ts',
startLine: 3,
endLine: 3,
text: 'const userId = req.params.id',
callees: '',
calleeIds: '',
},
},
{
label: 'BasicBlock',
props: {
id: 'BasicBlock:src/api.ts:2:0:1',
filePath: 'src/api.ts',
startLine: 4,
endLine: 4,
text: 'fetchUsers(userId)',
callees: '',
calleeIds: '',
},
},
],
[
{
type: 'CALLS',
srcLabel: 'Function',
dstLabel: 'Function',
src: 'fn:checkout',
dst: 'fn:callUsers',
},
{
type: 'REACHING_DEF',
srcLabel: 'BasicBlock',
dstLabel: 'BasicBlock',
src: 'BasicBlock:src/api.ts:2:0:0',
dst: 'BasicBlock:src/api.ts:2:0:1',
reason: 'userId',
},
],
);
// ── Backend (provider) index: handleUsers -> getUsers. No PDG layer. ──
await buildRepoDB(
path.join(storageBE, 'lbug'),
[
{
label: 'Function',
props: {
id: 'fn:handleUsers',
name: 'handleUsers',
filePath: 'src/routes.ts',
startLine: 5,
endLine: 9,
},
},
{
label: 'Function',
props: {
id: 'fn:getUsers',
name: 'getUsers',
filePath: 'src/users.ts',
startLine: 1,
endLine: 4,
},
},
],
[
{
type: 'CALLS',
srcLabel: 'Function',
dstLabel: 'Function',
src: 'fn:handleUsers',
dst: 'fn:getUsers',
},
],
);
// ── Group config + bridge (consumer callUsers -> provider handleUsers). ──
const groupDir = path.join(tmpHome, 'groups', 'grp');
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(
path.join(groupDir, 'group.yaml'),
`version: 1
name: grp
description: ""
repos:
app/frontend: reg-fe
app/backend: reg-be
links: []
packages: {}
detect:
http: true
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
`,
);
const consumer = makeContract({
repo: 'app/frontend',
role: 'consumer',
symbolUid: 'fn:callUsers',
symbolRef: { filePath: 'src/api.ts', name: 'callUsers' },
symbolName: 'callUsers',
contractId: 'http::GET::/api/users',
});
const provider = makeContract({
repo: 'app/backend',
role: 'provider',
symbolUid: 'fn:handleUsers',
symbolRef: { filePath: 'src/routes.ts', name: 'handleUsers' },
symbolName: 'handleUsers',
contractId: 'http::GET::/api/users',
});
const link: CrossLink = {
from: { repo: 'app/frontend', symbolUid: 'fn:callUsers', symbolRef: consumer.symbolRef },
to: { repo: 'app/backend', symbolUid: 'fn:handleUsers', symbolRef: provider.symbolRef },
type: 'http',
contractId: 'http::GET::/api/users',
matchType: 'exact',
confidence: 0.9,
};
await writeBridge(groupDir, {
contracts: [consumer, provider],
crossLinks: [link],
repoSnapshots: {},
missingRepos: [],
});
// ── Register both repos + a real backend (lazy pool open). ──
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'reg-fe',
path: path.join(tmpHome, 'fe-repo'),
storagePath: storageFE,
indexedAt: new Date(0).toISOString(),
lastCommit: 'fe',
stats: { files: 1, nodes: 2, communities: 0, processes: 0 },
},
{
name: 'reg-be',
path: path.join(tmpHome, 'be-repo'),
storagePath: storageBE,
indexedAt: new Date(0).toISOString(),
lastCommit: 'be',
stats: { files: 1, nodes: 2, communities: 0, processes: 0 },
},
]);
prevHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome;
backend = new LocalBackend();
await backend.init();
}, 120_000);
afterAll(async () => {
await backend?.dispose();
restoreEnvVar('GITNEXUS_HOME', prevHome);
fs.rmSync(tmpHome, { recursive: true, force: true });
}, 120_000);
it('stitches checkout -> getUsers across the bridge with real PDG enrichment', async () => {
const result = await backend.callTool('trace', {
repo: '@grp',
from: 'checkout',
to: 'getUsers',
pdg: true,
});
expect(result).toMatchObject({
status: 'ok',
crossings: [
{
fromRepo: 'app/frontend',
toRepo: 'app/backend',
contractId: 'http::GET::/api/users',
matchType: 'exact',
},
],
});
// The stitched path spans both repos in order, tagged by member repo.
const hops = (result.hops as Array<{ name: string; repo: string }>).map((h) => ({
name: h.name,
repo: h.repo,
}));
expect(hops).toEqual([
{ name: 'checkout', repo: 'app/frontend' },
{ name: 'callUsers', repo: 'app/frontend' },
{ name: 'handleUsers', repo: 'app/backend' },
{ name: 'getUsers', repo: 'app/backend' },
]);
// The boundary hop carries the CONTRACT_LINK edge.
const edgeTypes = (result.edges as Array<{ relType: string }>).map((e) => e.relType);
expect(edgeTypes).toContain('CONTRACT_LINK');
// Real REACHING_DEF enrichment of the consumer segment (intra-procedural).
expect(result.dataFlow).toEqual([
expect.objectContaining({
repo: 'app/frontend',
variable: 'userId',
hops: expect.arrayContaining([expect.objectContaining({ line: 4, variable: 'userId' })]),
}),
]);
// The provider repo has no PDG layer → a degraded note, but the trace is ok.
expect(result.notes).toEqual(
expect.arrayContaining([expect.stringContaining('No PDG layer in app/backend')]),
);
});
// A SECOND @group call in the same process — exercises the bridge read-only
// reopen that previously failed (closeBridgeDb used to CHECKPOINT read-only
// handles, leaving a lock artifact). Now fixed, so repeated @group traces work.
it('omitting pdg yields the same stitched path with no data-flow enrichment', async () => {
const result = await backend.callTool('trace', {
repo: '@grp',
from: 'checkout',
to: 'getUsers',
});
expect(result.status).toBe('ok');
expect(result.dataFlow).toBeUndefined();
expect((result.crossings as unknown[]).length).toBe(1);
expect((result.hops as Array<{ name: string }>).map((h) => h.name)).toEqual([
'checkout',
'callUsers',
'handleUsers',
'getUsers',
]);
});
it('single-repo trace against one member is unchanged (no group routing)', async () => {
const result = await backend.callTool('trace', {
repo: 'reg-fe',
from: 'checkout',
to: 'callUsers',
});
expect(result.status).toBe('ok');
// Plain single-repo result shape — no crossings field.
expect(result.crossings).toBeUndefined();
expect(result.hops.map((h: { name: string }) => h.name)).toEqual(['checkout', 'callUsers']);
});
});