GitNexus/gitnexus/test/unit/group/cross-trace.test.ts
Gergő Magyar 698f5efc82
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
feat(group): resolve inline HTTP provider handlers via call-site line (#2276) (#2282)
* feat(group): resolve Go inline provider handlers via line containment (#2276)

Widen the Go HandleFunc + framework-route handler capture to match
func literals and emit name:null + call-site line for them, so an
inline handler resolves to its containing/closure symbol instead of
file-level. Named identifier handlers keep resolving by name.

* feat(group): resolve Laravel closure provider handlers via line containment (#2276)

Capture the Laravel route handler argument; a closure (anonymous
function or arrow fn) now emits name:null + the registration line so it
resolves to its containing symbol (service-provider boot, controller
method) by containment. Named-controller routes keep the 'route' label.
File-scope closures stay file-level (PHP closures not yet indexed).

* feat(group): wire call-site line on FastAPI provider emits (#2276)

Set line on the FastAPI @app/@router provider detections (already
name:null) so the source-scan fallback resolves the decorated handler
by line-span containment. Best-effort: FastAPI routes are graph-backed
and the function span starts at def, so this lands the single-decorator
case. Flask add_url_rule already carried line.

* feat(group): wire call-site line on Kotlin/Java Spring provider emits (#2276)

Add line to the Kotlin and Java Spring @*Mapping provider detections
for parity with the consumer emits and a future inline DSL. Inert for
current resolution: a named Spring controller method resolves by name
and never falls through to line-span containment.

* fix(review): apply autofix feedback

Pin two documented limitations with tests: a file-scope Laravel closure
and a multi-decorator FastAPI handler both degrade to file-level rather
than mis-attributing (#2276 ce-code-review autofix).

* test(group): lock named gin framework-route resolves by name not registrar (#2276)

Reviewer verified named Go handlers still resolve by name across the
widened queries; the HandleFunc path was already pinned, this adds the
framework-route (gin/echo) path with a DB + enclosing registrar whose
span covers the registration line, proving the emitted line never
diverts a named provider to its registrar via containment.

* test(group): end-to-end inline Go provider resolution against real LadybugDB (#2276)

Closes the validation gap that all prior coverage mocked CONTAINING_QUERY:
runs the real pipeline over a Go file with an inline http.HandleFunc
func-literal handler, persists into a real LadybugDB, and runs the
production HttpRouteExtractor against the real executor — proving the
emitted call-site line lands inside main()'s real 0-based span and yields
source_scan_resolved, not the file-level fallback.

* fix(test): use fs.mkdtemp to satisfy CodeQL insecure-temporary-file gate (#2276)

The new integration test created its temp base via a predictable
os.tmpdir()+name join, which CodeQL flags as js/insecure-temporary-file
(1 high). Switch to fs.mkdtemp for an atomic, randomly-named base dir.

* fix(group): anchor Go provider @handler to the trailing argument (#2276)

The widened framework-route and HandleFunc handler captures
(`[(identifier) (func_literal)] @handler`) were unanchored, so a variadic
middleware route `r.GET("/x", mw, func(){})` produced two provider
detections — one for the middleware identifier and one for the closure.
The contractId-only merge then kept the middleware detection and
mis-attributed the route to it (and the pre-existing `mw, namedHandler`
shape had the same defect), silently neutralizing the inline-handler
containment resolution from #2276.

Add a trailing tree-sitter anchor (`@handler .`) so the handler binds the
LAST argument of the call, leaving middleware args before it unconstrained.
Verified against tree-sitter-go: the multi-arg shapes now yield exactly one
detection (the real handler) while every 2-arg case is unchanged. Adds two
regression tests pinning that a middleware + inline closure resolves to its
containing function and a middleware + named handler resolves by name.

* test(group): cover FastAPI @router inline-handler containment (#2276)

The @router/APIRouter provider emit gained a call-site `line` in #2276 but
only the @app path was tested; the existing @router tests call
`extract(null, …)` so the resolver/containment path never ran for @router.
Add two tests mirroring the @app cases: a single-decorator @router handler
resolves to its function via source_scan_resolved (which fails if `line` is
dropped), and a multi-decorator one degrades to file-level.

* fix(group): treat synthetic 'route' label as anonymous in cross-trace (#2276)

After #2276 an unresolved file-scope Laravel closure emits name:null, so its
persisted symbolName falls back to 'handler' — which providerLabel already
anonymizes to '<contractId handler>'. But an unresolved named-controller
route still carries the synthetic 'route' placeholder, which the sentinel
did NOT cover, so group_trace/group_cross_impact rendered it as the literal
'route' while equivalent closures showed '<... handler>'.

'route' is only ever the synthetic Laravel placeholder (php.ts), never a
resolved handler name, so add it to the unresolved-generic sentinel set
alongside 'handler'/'fetch'. The resolved branch is untouched, so a real
symbol genuinely named 'route' still displays its name. Adds a cross-trace
test pinning the anonymized label.

* fix(group): gate Spring provider line on a present method name (#2276)

The Java/Kotlin Spring @*Mapping provider emits set `line` unconditionally
while the method name is typed string|null. The 'a named provider never
reaches containment' guarantee held only because the grammar always captures
a method name — the type did not enforce it. A (grammar-impossible) null name
would emit name:null + line and resolve by containment to the enclosing class
body instead of staying file-level.

Emit `line` only when the method name is truthy, so a nameless provider
degrades to file-level (the safe no-mis-attribution outcome). Behavior is
unchanged for every real Spring route (name is always present), but the
inertness is now enforced rather than incidental.
2026-06-23 17:51:11 +01:00

1039 lines
37 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { cleanupTempDir } from '../../helpers/test-db.js';
import { runGroupTrace } from '../../../src/core/group/cross-trace.js';
import { writeBridge } from '../../../src/core/group/bridge-db.js';
import type {
GroupToolPort,
GroupRepoHandle,
GroupSymbolResolution,
GroupPdgFlowResult,
} from '../../../src/core/group/service.js';
import type { CrossLink } from '../../../src/core/group/types.js';
import { makeContract } from './fixtures.js';
/**
* U2 — cross-repo trace stitching. The bridge (crossing pair query) is a real
* bridge.lbug; the per-repo trace + symbol resolution are driven by a typed
* mock port with data-driven (if-free) dispatch.
*/
const itLbugReopen = process.platform === 'win32' ? it.skip : it;
function writeGroupYaml(groupDir: string): void {
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(
path.join(groupDir, 'group.yaml'),
`version: 1
name: g1
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
`,
);
}
/** Real bridge with one frontend(consumer) → backend(provider) ContractLink. */
async function writeLinkedBridge(groupDir: string): Promise<void> {
const consumer = makeContract({
repo: 'app/frontend',
role: 'consumer',
symbolUid: 'consumer-uid',
symbolRef: { filePath: 'src/api.ts', name: 'callUsers' },
symbolName: 'callUsers',
contractId: 'http::GET::/api/users',
});
const provider = makeContract({
repo: 'app/backend',
role: 'provider',
symbolUid: 'provider-uid',
symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' },
symbolName: 'getUsers',
contractId: 'http::GET::/api/users',
});
const link: CrossLink = {
from: { repo: 'app/frontend', symbolUid: 'consumer-uid', symbolRef: consumer.symbolRef },
to: { repo: 'app/backend', symbolUid: 'provider-uid', 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: [],
});
}
/** Bridge with contracts but NO frontend→backend link. */
async function writeUnlinkedBridge(groupDir: string): Promise<void> {
await writeBridge(groupDir, {
contracts: [
makeContract({ repo: 'app/frontend', role: 'consumer', symbolUid: 'c2' }),
makeContract({ repo: 'app/backend', role: 'provider', symbolUid: 'p2' }),
],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
}
function okSym(
id: string,
name: string,
filePath: string,
startLine: number,
): GroupSymbolResolution {
return {
kind: 'ok',
symbol: { id, name, type: 'Function', filePath, startLine, endLine: startLine + 3 },
};
}
function okTrace(
hops: Array<{ name: string; filePath: string; startLine: number }>,
edges: Array<{ relType: string; confidence: number }>,
): unknown {
return {
status: 'ok',
from: hops[0],
to: hops[hops.length - 1],
hopCount: edges.length,
hops,
edges,
};
}
/** Build a mock port from a symbol table and a trace table (both if-free). */
function makePort(
symbolTable: Record<string, GroupSymbolResolution>,
traceTable: Record<string, unknown>,
pdgTable?: Record<string, GroupPdgFlowResult>,
): GroupToolPort {
const handles: Record<string, GroupRepoHandle> = {
'reg-fe': { id: 'fe', name: 'reg-fe', repoPath: '/fe', storagePath: '/fe/.gitnexus' },
'reg-be': { id: 'be', name: 'reg-be', repoPath: '/be', storagePath: '/be/.gitnexus' },
};
return {
resolveRepo: async (p) => handles[String(p)] ?? handles['reg-fe']!,
impact: async () => ({}),
query: async () => ({}),
impactByUid: async () => null,
context: async () => ({}),
resolveSymbol: async (repo, q) =>
symbolTable[`${repo.name}:${q.name ?? q.uid ?? ''}`] ?? { kind: 'not_found' },
trace: async (repo, params) =>
traceTable[`${repo.name}:${params.from_uid}->${params.to_uid}`] ?? { status: 'no_path' },
...(pdgTable
? {
pdgFlows: async (
repo: GroupRepoHandle,
anchor: { uid?: string },
): Promise<GroupPdgFlowResult> =>
pdgTable[`${repo.name}:${anchor.uid}`] ?? { available: false, hops: [] },
}
: {}),
};
}
/** The cross-repo stitch fixtures (checkout -> getUsers over one ContractLink). */
function crossSymbolTable(): Record<string, GroupSymbolResolution> {
return {
'reg-fe:checkout': okSym('checkout-uid', 'checkout', 'src/checkout.ts', 10),
'reg-be:getUsers': okSym('getUsers-uid', 'getUsers', 'src/routes.ts', 5),
};
}
function crossTraceTable(): Record<string, unknown> {
return {
'reg-fe:checkout-uid->consumer-uid': okTrace(
[
{ name: 'checkout', filePath: 'src/checkout.ts', startLine: 10 },
{ name: 'callUsers', filePath: 'src/api.ts', startLine: 3 },
],
[{ relType: 'CALLS', confidence: 1 }],
),
'reg-be:provider-uid->getUsers-uid': okTrace(
[{ name: 'getUsers', filePath: 'src/routes.ts', startLine: 5 }],
[],
),
};
}
describe('runGroupTrace', () => {
let tmpDir: string;
let groupDir: string;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'cross-trace-'));
groupDir = path.join(tmpDir, 'groups', 'g1');
writeGroupYaml(groupDir);
});
afterEach(async () => {
await cleanupTempDir(tmpDir);
});
it('errors when from is missing', async () => {
const port = makePort({}, {});
const r = await runGroupTrace({ port, gitnexusDir: tmpDir }, { name: 'g1' });
expect(r).toMatchObject({ status: 'error', error: expect.stringContaining('from') });
});
it('not_found when the from symbol resolves in no member', async () => {
const port = makePort({ 'reg-be:Target': okSym('t', 'Target', 'src/x.ts', 1) }, {});
const r = await runGroupTrace(
{ port, gitnexusDir: tmpDir },
{ name: 'g1', from: 'Ghost', to: 'Target' },
);
expect(r).toMatchObject({ status: 'not_found', role: 'from' });
});
it('ambiguous when a symbol resolves in multiple members', async () => {
const port = makePort(
{
'reg-fe:shared': okSym('s-fe', 'shared', 'fe/a.ts', 1),
'reg-be:shared': okSym('s-be', 'shared', 'be/a.ts', 1),
'reg-be:Target': okSym('t', 'Target', 'be/x.ts', 1),
},
{},
);
const r = await runGroupTrace(
{ port, gitnexusDir: tmpDir },
{ name: 'g1', from: 'shared', to: 'Target' },
);
expect(r).toMatchObject({
status: 'ambiguous',
role: 'from',
candidates: expect.arrayContaining([
expect.objectContaining({ repo: 'app/frontend' }),
expect.objectContaining({ repo: 'app/backend' }),
]),
});
});
it('flags not_found as possibly-incomplete when a member DB cannot be queried', async () => {
// reg-be's resolveSymbol throws (corrupt/locked DB). A throw must NOT be
// reported as a clean not_found ("symbol absent"); the result carries a
// degraded-member note so the caller knows the answer may be incomplete.
const responders: Record<string, () => Promise<GroupSymbolResolution>> = {
'reg-be': () => Promise.reject(new Error('DB locked')),
'reg-fe': () => Promise.resolve({ kind: 'not_found' }),
};
const base = makePort({}, {});
const port: GroupToolPort = {
...base,
resolveSymbol: async (repo) =>
(responders[repo.name] ?? (() => Promise.resolve({ kind: 'not_found' })))(),
};
const r = await runGroupTrace(
{ port, gitnexusDir: tmpDir },
{ name: 'g1', from: 'Ghost', to: 'Target' },
);
expect(r).toMatchObject({
status: 'not_found',
role: 'from',
notes: expect.arrayContaining([expect.stringContaining('could not be queried')]),
});
// The degraded member is named.
expect((r as { notes: string[] }).notes.join(' ')).toContain('app/backend');
});
it('flags a SUCCESSFUL trace as possibly-incomplete when a member DB cannot be queried', async () => {
// reg-be throws (locked DB) while reg-fe resolves both endpoints and the
// same-repo trace succeeds. The `ok` result must STILL carry the degraded
// note — group resolution is only unique among the members we could query,
// so if the unreachable member also held `from`/`to` the answer is suspect.
const feSyms: Record<string, GroupSymbolResolution> = {
checkout: okSym('checkout-uid', 'checkout', 'src/a.ts', 1),
callUsers: okSym('callUsers-uid', 'callUsers', 'src/a.ts', 5),
};
const responders: Record<string, (q: { name?: string }) => Promise<GroupSymbolResolution>> = {
'reg-be': () => Promise.reject(new Error('DB locked')),
'reg-fe': (q) => Promise.resolve(feSyms[q.name ?? ''] ?? { kind: 'not_found' }),
};
const base = makePort(
{},
{
'reg-fe:checkout-uid->callUsers-uid': okTrace(
[
{ name: 'checkout', filePath: 'src/a.ts', startLine: 1 },
{ name: 'callUsers', filePath: 'src/a.ts', startLine: 5 },
],
[{ relType: 'CALLS', confidence: 1 }],
),
},
);
const port: GroupToolPort = {
...base,
resolveSymbol: async (repo, q) =>
(responders[repo.name] ?? (() => Promise.resolve({ kind: 'not_found' })))(q),
};
const r = await runGroupTrace(
{ port, gitnexusDir: tmpDir },
{ name: 'g1', from: 'checkout', to: 'callUsers' },
);
expect(r).toMatchObject({
status: 'ok',
notes: expect.arrayContaining([expect.stringContaining('could not be queried')]),
});
expect((r as { notes: string[] }).notes.join(' ')).toContain('app/backend');
});
itLbugReopen('stitches a cross-repo path over one ContractLink', async () => {
await writeLinkedBridge(groupDir);
const port = makePort(
{
'reg-fe:checkout': okSym('checkout-uid', 'checkout', 'src/checkout.ts', 10),
'reg-be:getUsers': okSym('getUsers-uid', 'getUsers', 'src/routes.ts', 5),
},
{
'reg-fe:checkout-uid->consumer-uid': okTrace(
[
{ name: 'checkout', filePath: 'src/checkout.ts', startLine: 10 },
{ name: 'callUsers', filePath: 'src/api.ts', startLine: 3 },
],
[{ relType: 'CALLS', confidence: 1 }],
),
'reg-be:provider-uid->getUsers-uid': okTrace(
[{ name: 'getUsers', filePath: 'src/routes.ts', startLine: 5 }],
[],
),
},
);
const r = await runGroupTrace(
{ port, gitnexusDir: tmpDir },
{ name: 'g1', from: 'checkout', to: 'getUsers' },
);
expect(r).toMatchObject({
status: 'ok',
crossings: [
{
fromRepo: 'app/frontend',
toRepo: 'app/backend',
contractId: 'http::GET::/api/users',
matchType: 'exact',
},
],
hopCount: 2,
hops: [
{ name: 'checkout', repo: 'app/frontend' },
{ name: 'callUsers', repo: 'app/frontend' },
{ name: 'getUsers', repo: 'app/backend' },
],
edges: [{ relType: 'CALLS' }, { relType: 'CONTRACT_LINK', confidence: 0.9 }],
});
});
itLbugReopen(
'stitches an HTTP-style crossing with EMPTY symbolUid via the contract-file fallback',
async () => {
// HTTP contracts hardcode symbolUid:'' and only record the file. When the
// user's from/to resolve into the contract files, the file-level fallback
// anchors the boundary so the trace still stitches.
const consumer = makeContract({
repo: 'app/frontend',
role: 'consumer',
symbolUid: '', // <-- empty, like a real HTTP source-scan contract
symbolRef: { filePath: 'src/api.ts', name: 'fetch' },
symbolName: 'fetch',
contractId: 'http::GET::/api/users',
});
const provider = makeContract({
repo: 'app/backend',
role: 'provider',
symbolUid: '',
symbolRef: { filePath: 'src/routes.ts', name: 'handler' },
symbolName: 'handler',
contractId: 'http::GET::/api/users',
});
const link: CrossLink = {
from: { repo: 'app/frontend', symbolUid: '', symbolRef: consumer.symbolRef },
to: { repo: 'app/backend', symbolUid: '', symbolRef: provider.symbolRef },
type: 'http',
contractId: 'http::GET::/api/users',
matchType: 'exact',
confidence: 1,
};
await writeBridge(groupDir, {
contracts: [consumer, provider],
crossLinks: [link],
repoSnapshots: {},
missingRepos: [],
});
const port = makePort(
{
// from/to resolve to symbols that LIVE IN the contract files.
'reg-fe:callUsers': okSym('callUsers-uid', 'callUsers', 'src/api.ts', 3),
'reg-be:getUsers': okSym('getUsers-uid', 'getUsers', 'src/routes.ts', 5),
},
{
// Trivial same-symbol segments (from IS the consumer, to IS the provider).
'reg-fe:callUsers-uid->callUsers-uid': okTrace(
[{ name: 'callUsers', filePath: 'src/api.ts', startLine: 3 }],
[],
),
'reg-be:getUsers-uid->getUsers-uid': okTrace(
[{ name: 'getUsers', filePath: 'src/routes.ts', startLine: 5 }],
[],
),
},
);
const r = await runGroupTrace(
{ port, gitnexusDir: tmpDir },
{ name: 'g1', from: 'callUsers', to: 'getUsers' },
);
expect(r).toMatchObject({
status: 'ok',
crossings: [{ contractId: 'http::GET::/api/users' }],
hops: [
{ name: 'callUsers', repo: 'app/frontend' },
{ name: 'getUsers', repo: 'app/backend' },
],
notes: expect.arrayContaining([expect.stringContaining('anchored by contract FILE')]),
});
},
);
itLbugReopen(
'destination trace (no `to`) reports an ANONYMOUS handler endpoint by route + file',
async () => {
// The inherent case: the provider handler is an anonymous arrow with no
// symbol (symbolUid:''). Omitting `to` follows the consumer's HTTP call to
// the endpoint, reported by route + file even though it has no name.
const consumer = makeContract({
repo: 'app/frontend',
role: 'consumer',
symbolUid: 'callUsers-uid',
symbolRef: { filePath: 'src/api.ts', name: 'callUsers' },
symbolName: 'callUsers',
contractId: 'http::GET::/api/users',
});
const provider = makeContract({
repo: 'app/backend',
role: 'provider',
symbolUid: '', // anonymous handler — no symbol in the graph
symbolRef: { filePath: 'src/routes.ts', name: 'handler' },
symbolName: 'handler',
contractId: 'http::GET::/api/users',
});
const link: CrossLink = {
from: { repo: 'app/frontend', symbolUid: 'callUsers-uid', symbolRef: consumer.symbolRef },
to: { repo: 'app/backend', symbolUid: '', symbolRef: provider.symbolRef },
type: 'http',
contractId: 'http::GET::/api/users',
matchType: 'exact',
confidence: 1,
};
await writeBridge(groupDir, {
contracts: [consumer, provider],
crossLinks: [link],
repoSnapshots: {},
missingRepos: [],
});
const port = makePort(
{ 'reg-fe:callUsers': okSym('callUsers-uid', 'callUsers', 'src/api.ts', 3) },
{
'reg-fe:callUsers-uid->callUsers-uid': okTrace(
[{ name: 'callUsers', filePath: 'src/api.ts', startLine: 3 }],
[],
),
},
);
// NO `to` — destination trace.
const r = await runGroupTrace(
{ port, gitnexusDir: tmpDir },
{ name: 'g1', from: 'callUsers' },
);
expect(r).toMatchObject({
status: 'ok',
crossings: [{ contractId: 'http::GET::/api/users', toRepo: 'app/backend' }],
to: {
name: '<http::GET::/api/users handler>',
repo: 'app/backend',
filePath: 'src/routes.ts',
},
hops: [
{ name: 'callUsers', repo: 'app/frontend' },
{ name: '<http::GET::/api/users handler>', repo: 'app/backend' },
],
edges: [{ relType: 'CONTRACT_LINK' }],
notes: expect.arrayContaining([expect.stringContaining('anonymous')]),
});
},
);
itLbugReopen(
'destination trace anonymizes an unresolved Laravel `route` placeholder (#2276)',
async () => {
// A named-controller / closure Laravel provider that did not resolve keeps
// the synthetic `'route'` placeholder (never a real symbol name). It must
// be treated as anonymous — shown as `<route handler>` — exactly like the
// `'handler'`/`'fetch'` sentinels, not displayed as the literal `route`.
const consumer = makeContract({
repo: 'app/frontend',
role: 'consumer',
symbolUid: 'callUsers-uid',
symbolRef: { filePath: 'src/api.ts', name: 'callUsers' },
symbolName: 'callUsers',
contractId: 'http::GET::/api/users',
});
const provider = makeContract({
repo: 'app/backend',
role: 'provider',
symbolUid: '', // unresolved file-scope closure / named-controller route
symbolRef: { filePath: 'routes/web.php', name: 'route' },
symbolName: 'route',
contractId: 'http::GET::/api/users',
});
const link: CrossLink = {
from: { repo: 'app/frontend', symbolUid: 'callUsers-uid', symbolRef: consumer.symbolRef },
to: { repo: 'app/backend', symbolUid: '', symbolRef: provider.symbolRef },
type: 'http',
contractId: 'http::GET::/api/users',
matchType: 'exact',
confidence: 1,
};
await writeBridge(groupDir, {
contracts: [consumer, provider],
crossLinks: [link],
repoSnapshots: {},
missingRepos: [],
});
const port = makePort(
{ 'reg-fe:callUsers': okSym('callUsers-uid', 'callUsers', 'src/api.ts', 3) },
{
'reg-fe:callUsers-uid->callUsers-uid': okTrace(
[{ name: 'callUsers', filePath: 'src/api.ts', startLine: 3 }],
[],
),
},
);
const r = await runGroupTrace(
{ port, gitnexusDir: tmpDir },
{ name: 'g1', from: 'callUsers' },
);
expect(r).toMatchObject({
status: 'ok',
to: {
name: '<http::GET::/api/users handler>',
repo: 'app/backend',
filePath: 'routes/web.php',
},
hops: [
{ name: 'callUsers', repo: 'app/frontend' },
{ name: '<http::GET::/api/users handler>', repo: 'app/backend' },
],
notes: expect.arrayContaining([expect.stringContaining('anonymous')]),
});
},
);
itLbugReopen('destination trace not_found when no HTTP link leaves the repo', async () => {
await writeUnlinkedBridge(groupDir);
const port = makePort(
{ 'reg-fe:checkout': okSym('checkout-uid', 'checkout', 'src/checkout.ts', 10) },
{},
);
const r = await runGroupTrace({ port, gitnexusDir: tmpDir }, { name: 'g1', from: 'checkout' });
expect(r).toMatchObject({
status: 'not_found',
role: 'to',
notes: expect.arrayContaining([expect.stringContaining('No outgoing HTTP ContractLink')]),
});
});
itLbugReopen(
'destination trace is AMBIGUOUS when a file makes multiple HTTP calls with empty uids',
async () => {
// Two HTTP consumer contracts in the SAME file, both with empty symbolUid
// (the file-fallback case). `from` lives in that file, so `trace(from->from)`
// trivially succeeds for BOTH — the destination must be reported ambiguous,
// not silently resolved to the highest-confidence sibling.
// Distinct consumer NAMES so the bridge links resolve uniquely, but the
// SAME file (src/api.ts) and empty uid so both hit the file-fallback.
const mk = (cid: string, consName: string, provFile: string) => ({
consumer: makeContract({
repo: 'app/frontend',
role: 'consumer',
symbolUid: '',
symbolRef: { filePath: 'src/api.ts', name: consName },
symbolName: consName,
contractId: cid,
}),
provider: makeContract({
repo: 'app/backend',
role: 'provider',
symbolUid: '',
symbolRef: { filePath: provFile, name: 'handler' },
symbolName: 'handler',
contractId: cid,
}),
});
const users = mk('http::GET::/api/users', 'fetchUsers', 'src/users.ts');
const orders = mk('http::GET::/api/orders', 'fetchOrders', 'src/orders.ts');
const link = (c: typeof users): CrossLink => ({
from: { repo: 'app/frontend', symbolUid: '', symbolRef: c.consumer.symbolRef },
to: { repo: 'app/backend', symbolUid: '', symbolRef: c.provider.symbolRef },
type: 'http',
contractId: c.consumer.contractId,
matchType: 'exact',
confidence: 1,
});
await writeBridge(groupDir, {
contracts: [users.consumer, users.provider, orders.consumer, orders.provider],
crossLinks: [link(users), link(orders)],
repoSnapshots: {},
missingRepos: [],
});
const port = makePort(
{ 'reg-fe:caller': okSym('caller-uid', 'caller', 'src/api.ts', 3) },
{
'reg-fe:caller-uid->caller-uid': okTrace(
[{ name: 'caller', filePath: 'src/api.ts', startLine: 3 }],
[],
),
},
);
const r = await runGroupTrace({ port, gitnexusDir: tmpDir }, { name: 'g1', from: 'caller' });
expect(r).toMatchObject({
status: 'ambiguous',
role: 'to',
candidates: expect.arrayContaining([
expect.objectContaining({ id: 'http::GET::/api/users' }),
expect.objectContaining({ id: 'http::GET::/api/orders' }),
]),
notes: expect.arrayContaining([expect.stringContaining('more than one HTTP call')]),
});
},
);
itLbugReopen('destination trace success still flags a degraded member', async () => {
// reg-fe resolves `from` and the destination follows an HTTP link to an
// anonymous backend handler; reg-be throws during resolveSymbol. The ok
// result must still carry the degraded note, keeping the no-`to` path aligned
// with explicit `to` traces (authoritative only among queryable members).
const consumer = makeContract({
repo: 'app/frontend',
role: 'consumer',
symbolUid: 'callUsers-uid',
symbolRef: { filePath: 'src/api.ts', name: 'callUsers' },
symbolName: 'callUsers',
contractId: 'http::GET::/api/users',
});
const provider = makeContract({
repo: 'app/backend',
role: 'provider',
symbolUid: '',
symbolRef: { filePath: 'src/routes.ts', name: 'handler' },
symbolName: 'handler',
contractId: 'http::GET::/api/users',
});
const link: CrossLink = {
from: { repo: 'app/frontend', symbolUid: 'callUsers-uid', symbolRef: consumer.symbolRef },
to: { repo: 'app/backend', symbolUid: '', symbolRef: provider.symbolRef },
type: 'http',
contractId: 'http::GET::/api/users',
matchType: 'exact',
confidence: 1,
};
await writeBridge(groupDir, {
contracts: [consumer, provider],
crossLinks: [link],
repoSnapshots: {},
missingRepos: [],
});
const feSyms: Record<string, GroupSymbolResolution> = {
callUsers: okSym('callUsers-uid', 'callUsers', 'src/api.ts', 3),
};
const responders: Record<string, (q: { name?: string }) => Promise<GroupSymbolResolution>> = {
'reg-be': () => Promise.reject(new Error('DB locked')),
'reg-fe': (q) => Promise.resolve(feSyms[q.name ?? ''] ?? { kind: 'not_found' }),
};
const base = makePort(
{},
{
'reg-fe:callUsers-uid->callUsers-uid': okTrace(
[{ name: 'callUsers', filePath: 'src/api.ts', startLine: 3 }],
[],
),
},
);
const port: GroupToolPort = {
...base,
resolveSymbol: async (repo, q) =>
(responders[repo.name] ?? (() => Promise.resolve({ kind: 'not_found' })))(q),
};
const r = await runGroupTrace({ port, gitnexusDir: tmpDir }, { name: 'g1', from: 'callUsers' });
expect(r).toMatchObject({
status: 'ok',
to: { name: '<http::GET::/api/users handler>' },
notes: expect.arrayContaining([
expect.stringContaining('anonymous'),
expect.stringContaining('could not be queried'),
]),
});
});
itLbugReopen(
'destination trace is AMBIGUOUS when `from` reaches multiple PRECISE endpoints',
async () => {
// `dispatch` reaches two consumer functions, each with a resolved uid and
// linked to a different provider route. This pins the PRECISE ambiguity tier
// (distinct from the file-level case) so a future change cannot silently pick
// the highest-confidence destination.
const mk = (
cid: string,
consName: string,
consUid: string,
consFile: string,
prov: string,
) => ({
consumer: makeContract({
repo: 'app/frontend',
role: 'consumer',
symbolUid: consUid,
symbolRef: { filePath: consFile, name: consName },
symbolName: consName,
contractId: cid,
}),
provider: makeContract({
repo: 'app/backend',
role: 'provider',
symbolUid: `uid-${prov}`,
symbolRef: { filePath: 'src/routes.ts', name: prov },
symbolName: prov,
contractId: cid,
}),
});
const a = mk('http::GET::/api/a', 'fetchA', 'fetchA-uid', 'src/a.ts', 'handlerA');
const b = mk('http::GET::/api/b', 'fetchB', 'fetchB-uid', 'src/b.ts', 'handlerB');
const link = (c: typeof a): CrossLink => ({
from: {
repo: 'app/frontend',
symbolUid: c.consumer.symbolUid,
symbolRef: c.consumer.symbolRef,
},
to: {
repo: 'app/backend',
symbolUid: c.provider.symbolUid,
symbolRef: c.provider.symbolRef,
},
type: 'http',
contractId: c.consumer.contractId,
matchType: 'exact',
confidence: 1,
});
await writeBridge(groupDir, {
contracts: [a.consumer, a.provider, b.consumer, b.provider],
crossLinks: [link(a), link(b)],
repoSnapshots: {},
missingRepos: [],
});
const port = makePort(
{ 'reg-fe:dispatch': okSym('dispatch-uid', 'dispatch', 'src/main.ts', 1) },
{
'reg-fe:dispatch-uid->fetchA-uid': okTrace(
[
{ name: 'dispatch', filePath: 'src/main.ts', startLine: 1 },
{ name: 'fetchA', filePath: 'src/a.ts', startLine: 1 },
],
[{ relType: 'CALLS', confidence: 1 }],
),
'reg-fe:dispatch-uid->fetchB-uid': okTrace(
[
{ name: 'dispatch', filePath: 'src/main.ts', startLine: 1 },
{ name: 'fetchB', filePath: 'src/b.ts', startLine: 1 },
],
[{ relType: 'CALLS', confidence: 1 }],
),
},
);
const r = await runGroupTrace(
{ port, gitnexusDir: tmpDir },
{ name: 'g1', from: 'dispatch' },
);
expect(r).toMatchObject({
status: 'ambiguous',
role: 'to',
candidates: expect.arrayContaining([
expect.objectContaining({ id: 'http::GET::/api/a' }),
expect.objectContaining({ id: 'http::GET::/api/b' }),
]),
notes: expect.arrayContaining([expect.stringContaining('more than one HTTP endpoint')]),
});
},
);
itLbugReopen('same-repo endpoints trace locally with no crossing', async () => {
await writeLinkedBridge(groupDir);
const port = makePort(
{
'reg-be:handlerA': okSym('a-uid', 'handlerA', 'src/a.ts', 1),
'reg-be:handlerB': okSym('b-uid', 'handlerB', 'src/b.ts', 1),
},
{
'reg-be:a-uid->b-uid': okTrace(
[
{ name: 'handlerA', filePath: 'src/a.ts', startLine: 1 },
{ name: 'handlerB', filePath: 'src/b.ts', startLine: 1 },
],
[{ relType: 'CALLS', confidence: 1 }],
),
},
);
const r = await runGroupTrace(
{ port, gitnexusDir: tmpDir },
{ name: 'g1', from: 'handlerA', to: 'handlerB' },
);
expect(r).toMatchObject({
status: 'ok',
crossings: [],
hopCount: 1,
hops: [
{ name: 'handlerA', repo: 'app/backend' },
{ name: 'handlerB', repo: 'app/backend' },
],
});
});
itLbugReopen('not_found with a bridge note when no ContractLink connects the repos', async () => {
await writeUnlinkedBridge(groupDir);
const port = makePort(
{
'reg-fe:checkout': okSym('checkout-uid', 'checkout', 'src/checkout.ts', 10),
'reg-be:getUsers': okSym('getUsers-uid', 'getUsers', 'src/routes.ts', 5),
},
{},
);
const r = await runGroupTrace(
{ port, gitnexusDir: tmpDir },
{ name: 'g1', from: 'checkout', to: 'getUsers' },
);
expect(r).toMatchObject({
status: 'not_found',
notes: expect.arrayContaining([expect.stringContaining('ContractLink')]),
});
});
itLbugReopen('clamps crossDepth>1 and surfaces a note', async () => {
await writeLinkedBridge(groupDir);
const port = makePort(
{
'reg-fe:checkout': okSym('checkout-uid', 'checkout', 'src/checkout.ts', 10),
'reg-be:getUsers': okSym('getUsers-uid', 'getUsers', 'src/routes.ts', 5),
},
{
'reg-fe:checkout-uid->consumer-uid': okTrace(
[{ name: 'checkout', filePath: 'src/checkout.ts', startLine: 10 }],
[],
),
'reg-be:provider-uid->getUsers-uid': okTrace(
[{ name: 'getUsers', filePath: 'src/routes.ts', startLine: 5 }],
[],
),
},
);
const r = await runGroupTrace(
{ port, gitnexusDir: tmpDir },
{ name: 'g1', from: 'checkout', to: 'getUsers', crossDepth: 4 },
);
expect(r).toMatchObject({
status: 'ok',
notes: expect.arrayContaining([expect.stringContaining('Multi-hop')]),
});
});
itLbugReopen(
'memoizes the home-repo segment across crossings that share one consumer',
async () => {
// Two ContractLinks from the SAME consumer (c1) to two providers (p1, p2).
// p1's provider→to segment fails; p2's succeeds. The from→consumer segment
// (segA) depends only on the consumer uid, so it must be traced ONCE even
// though two crossings are attempted — the O(2·N) → O(distinct endpoints) fix.
const consumer = makeContract({
repo: 'app/frontend',
role: 'consumer',
symbolUid: 'c1',
symbolRef: { filePath: 'src/api.ts', name: 'callBoth' },
symbolName: 'callBoth',
contractId: 'http::GET::/api/a',
});
const provider1 = makeContract({
repo: 'app/backend',
role: 'provider',
symbolUid: 'p1',
symbolRef: { filePath: 'src/r1.ts', name: 'h1' },
symbolName: 'h1',
contractId: 'http::GET::/api/a',
});
const provider2 = makeContract({
repo: 'app/backend',
role: 'provider',
symbolUid: 'p2',
symbolRef: { filePath: 'src/r2.ts', name: 'h2' },
symbolName: 'h2',
contractId: 'http::GET::/api/b',
});
const link1: CrossLink = {
from: { repo: 'app/frontend', symbolUid: 'c1', symbolRef: consumer.symbolRef },
to: { repo: 'app/backend', symbolUid: 'p1', symbolRef: provider1.symbolRef },
type: 'http',
contractId: 'http::GET::/api/a',
matchType: 'exact',
confidence: 0.9,
};
const link2: CrossLink = {
from: { repo: 'app/frontend', symbolUid: 'c1', symbolRef: consumer.symbolRef },
to: { repo: 'app/backend', symbolUid: 'p2', symbolRef: provider2.symbolRef },
type: 'http',
contractId: 'http::GET::/api/b',
matchType: 'exact',
confidence: 0.8,
};
await writeBridge(groupDir, {
contracts: [consumer, provider1, provider2],
crossLinks: [link1, link2],
repoSnapshots: {},
missingRepos: [],
});
const handles: Record<string, GroupRepoHandle> = {
'reg-fe': { id: 'fe', name: 'reg-fe', repoPath: '/fe', storagePath: '/fe/.gitnexus' },
'reg-be': { id: 'be', name: 'reg-be', repoPath: '/be', storagePath: '/be/.gitnexus' },
};
const symbolTable: Record<string, GroupSymbolResolution> = {
'reg-fe:start': okSym('start-uid', 'start', 'src/start.ts', 1),
'reg-be:target': okSym('target-uid', 'target', 'src/target.ts', 1),
};
const traceTable: Record<string, unknown> = {
'reg-fe:start-uid->c1': okTrace(
[
{ name: 'start', filePath: 'src/start.ts', startLine: 1 },
{ name: 'callBoth', filePath: 'src/api.ts', startLine: 1 },
],
[{ relType: 'CALLS', confidence: 1 }],
),
'reg-be:p1->target-uid': { status: 'no_path' },
'reg-be:p2->target-uid': okTrace(
[{ name: 'target', filePath: 'src/target.ts', startLine: 1 }],
[],
),
};
const traceCalls: string[] = [];
const port: GroupToolPort = {
resolveRepo: async (rp) => handles[String(rp)] ?? handles['reg-fe']!,
impact: async () => ({}),
query: async () => ({}),
impactByUid: async () => null,
context: async () => ({}),
resolveSymbol: async (repo, q) =>
symbolTable[`${repo.name}:${q.name ?? q.uid ?? ''}`] ?? { kind: 'not_found' },
trace: async (repo, params) => {
const key = `${repo.name}:${params.from_uid}->${params.to_uid}`;
traceCalls.push(key);
return traceTable[key] ?? { status: 'no_path' };
},
};
const r = await runGroupTrace(
{ port, gitnexusDir: tmpDir },
{ name: 'g1', from: 'start', to: 'target' },
);
// p2's crossing wins (p1's provider segment had no path).
expect(r).toMatchObject({
status: 'ok',
crossings: [{ contractId: 'http::GET::/api/b' }],
});
// segA (start → c1) was traced exactly once despite two crossings sharing c1.
expect(traceCalls.filter((k) => k === 'reg-fe:start-uid->c1')).toHaveLength(1);
},
);
// ── U4: opt-in PDG data-flow enrichment ──────────────────────────────────
itLbugReopen('pdg:true attaches data-flow for the boundary-adjacent segment', async () => {
await writeLinkedBridge(groupDir);
const port = makePort(crossSymbolTable(), crossTraceTable(), {
'reg-fe:consumer-uid': {
available: true,
variable: 'userId',
hops: [
{ line: 11, text: 'const userId = req.params.id', variable: 'userId' },
{ line: 12, text: 'callUsers(userId)', variable: 'userId' },
],
},
});
const r = await runGroupTrace(
{ port, gitnexusDir: tmpDir },
{ name: 'g1', from: 'checkout', to: 'getUsers', pdg: true },
);
expect(r).toMatchObject({
status: 'ok',
dataFlow: [
{
repo: 'app/frontend',
variable: 'userId',
hops: [{ line: 11, variable: 'userId' }, { line: 12 }],
},
],
notes: expect.arrayContaining([expect.stringContaining('experimental')]),
});
});
itLbugReopen('pdg:true with no PDG layer degrades with a note and no dataFlow', async () => {
await writeLinkedBridge(groupDir);
const port = makePort(crossSymbolTable(), crossTraceTable(), {
'reg-fe:consumer-uid': { available: false, hops: [] },
'reg-be:provider-uid': { available: false, hops: [] },
});
const r = await runGroupTrace(
{ port, gitnexusDir: tmpDir },
{ name: 'g1', from: 'checkout', to: 'getUsers', pdg: true },
);
expect(r).toMatchObject({
status: 'ok',
notes: expect.arrayContaining([expect.stringContaining('No PDG layer')]),
});
expect((r as { dataFlow?: unknown }).dataFlow).toBeUndefined();
});
itLbugReopen('pdg omitted never requests enrichment', async () => {
await writeLinkedBridge(groupDir);
let pdgCalls = 0;
const base = makePort(crossSymbolTable(), crossTraceTable());
const port: typeof base = {
...base,
pdgFlows: async () => {
pdgCalls++;
return { available: true, hops: [{ line: 1, text: 'x' }] };
},
};
const r = await runGroupTrace(
{ port, gitnexusDir: tmpDir },
{ name: 'g1', from: 'checkout', to: 'getUsers' },
);
expect(r).toMatchObject({ status: 'ok' });
expect((r as { dataFlow?: unknown }).dataFlow).toBeUndefined();
expect(pdgCalls).toBe(0);
});
});