Merge branch 'main' into docs/kilo-code-mcp

This commit is contained in:
Tanishq Khatri 2026-06-23 13:03:50 +05:30 • committed by GitHub
commit e91c9c43bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 7054 additions and 364 deletions

View file

@ -38,7 +38,7 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
| `detect_changes` | Map git diffs to affected symbols and processes |
| `rename` | Graph-assisted multi-file rename with `dry_run` preview |
| `api_impact` | Pre-change impact report for an API route handler |
| `trace` | Shortest directed path between two symbols (call + class-member edges) |
| `trace` | Shortest directed path between two symbols (call + class-member edges); group-aware (`repo: "@<group>"`) for cross-repo traces |
| `route_map` | API route → handler → consumer mappings |
| `tool_map` | MCP/RPC tool definitions and handlers |
| `shape_check` | Response shape vs consumer property access mismatches |
@ -47,7 +47,9 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
| `group_list` | List repo groups or details for one group |
| `group_sync` | Rebuild group Contract Registry (`contracts.json`) and bridge graph |
`query`, `context`, and `impact` are group-aware: pass `repo: "@<groupName>"` (or `"@<groupName>/<memberPath>"` to scope to one member) plus optional `service: "<monorepo/path>"`. Group-mode `query` merges per-repo results via Reciprocal Rank Fusion; group-mode `impact` runs the local walk in the chosen member and fans out across boundaries via the Contract Bridge (`gitnexus/src/core/group/cross-impact.ts`). The previously-planned `group_query`, `group_context`, `group_impact`, `group_contracts`, `group_status` MCP tools are intentionally not introduced — group-level state is exposed via resources instead:
`query`, `context`, and `impact` are group-aware: pass `repo: "@<groupName>"` (or `"@<groupName>/<memberPath>"` to scope to one member) plus optional `service: "<monorepo/path>"`. Group-mode `query` merges per-repo results via Reciprocal Rank Fusion; group-mode `impact` runs the local walk in the chosen member and fans out across boundaries via the Contract Bridge (`gitnexus/src/core/group/cross-impact.ts`). `trace` is also group-aware via `repo: "@<groupName>"` — but, unlike the others, it resolves `from`/`to` across **all** members (a `@<groupName>/<memberPath>` suffix is advisory for trace, not a scope); pass `from_uid`/`to_uid` to disambiguate a symbol name that occurs in more than one member.
Group-mode `trace` (`gitnexus/src/core/group/cross-trace.ts`) stitches a path that crosses repositories: it resolves `from`/`to` across all members, and when they live in different repos it joins the home-repo segment to the target-repo segment over a single `ContractLink` boundary (an HTTP consumer→provider link, joined on `Contract.symbolUid`), reported as a `CONTRACT_LINK` hop in `crossings[]`. The crossing is clamped to one boundary (`MAX_SUPPORTED_CROSS_DEPTH`, shared with cross-impact); deeper `crossDepth` is reported via `notes[]`. With `pdg: true` (experimental, opt-in), each boundary-adjacent segment is enriched with its intra-procedural REACHING_DEF data-flow when that repo was indexed with `--pdg` (reusing the same anchored `flows` query as `pdg_query`); data flow never crosses the repo boundary, and a missing PDG layer degrades to call-level hops with a note. Two stores meet only at the `symbolUid` grain — the per-repo PDG/call graph and the group bridge — so this is the documented join; full cross-program (SDG-like) data flow across the boundary remains deferred (see `docs/plans/2026-06-18-002-feat-unified-pdg-impact-evaluation-plan.md`). The previously-planned `group_query`, `group_context`, `group_impact`, `group_contracts`, `group_status` MCP tools are intentionally not introduced — group-level state is exposed via resources instead:
| Resource URI | Purpose |
|--------------|---------|
@ -216,6 +218,7 @@ On a `--pdg` run the parse worker builds a per-function control-flow graph from
- **M3/M4 — TAINTED / SANITIZES / TAINT_PATH** (#2083–#2084): intra- and inter-procedural taint (source→sink) — the `explain` tool's data.
- **M5 — CDG** (#2085): Ferrante control dependence over a Cooper–Harvey–Kennedy post-dominator tree (the EXIT-rooted reverse CFG); branch sense (`'T'`/`'F'`) rides `reason`. A CFG whose EXIT is unreachable from some block is skipped for CDG (post-dominance would be unsound) while its CFG/REACHING_DEF layers are kept.
- **M6 — read surface** (#2086): the `pdg_query` MCP tool answers "what gates X?" (CDG, `mode: controls`) and "where does Y flow?" (REACHING_DEF, `mode: flows`); `explain` is the taint consumer. Both are always anchored + `LIMIT`-bounded (LadybugDB has no rel-property index) and share one `resolveBlockAnchor` helper. These PDG edge types are deliberately kept out of the default `VALID_RELATION_TYPES` / web schema.
- **Cross-repo trace enrichment**: group-mode `trace` (`pdg: true`) reuses the same anchored REACHING_DEF `flows` query to annotate a boundary-adjacent segment with how a value reaches the cross-repo call — strictly intra-procedural (data flow never crosses the repo boundary). See the group-aware tools note above.
See `core/ingestion/cfg/` (emit + the pure CFG / post-dominator / control-dependence / reaching-defs / taint passes) and `mcp/local/local-backend.ts` (`_pdgQueryImpl`, `_explainImpl`, the shared `resolveBlockAnchor`).

View file

@ -105,6 +105,13 @@ export type ParsedImport =
readonly localName: string;
readonly importedName: string;
readonly targetRaw: string;
/**
* Set by providers when `targetRaw` already names the imported symbol
* rather than only its containing module. Consumers that compose
* `<local>.<member>` paths can then use `targetRaw.<member>` instead of
* duplicating `importedName`.
*/
readonly targetIncludesImportedName?: boolean;
}
/**
* Per-name import with rename.
@ -119,6 +126,8 @@ export type ParsedImport =
readonly importedName: string;
readonly alias: string;
readonly targetRaw: string;
/** See the same field on the `named` variant. */
readonly targetIncludesImportedName?: boolean;
}
/**
* Qualified module handle, with or without rename. `importedName` is the

View file

@ -0,0 +1,90 @@
# Cross-repo trace — end-to-end verification
Verifies the cross-repo `trace` MCP tool against the **real pipeline** (not
hand-persisted graphs): `runFullAnalysis(--pdg)` on two repos → real `syncGroup`
HTTP contract extraction + bridge build → `callTool('trace', { repo: '@group' })`.
Run from `gitnexus/` (needs a current build for the parse worker):
```bash
node scripts/build.js
node bench/cross-repo-trace/verify.mjs
```
`verify.mjs` is self-contained — it generates each fixture inline, runs the real
analyze → sync → trace/impact pipeline, and prints PASS/FAIL per assertion
(exit non-zero on any failure). Expected verdict: **9/9 checks passed**.
## Cases covered (one scenario each)
1. **Named handlers, same file** — a frontend with named `fetch` wrappers
(`fetchUsers`, `createUserReq`) and a backend with named express handlers
(`listUsers`, `createUser`) on `/api/users` GET/POST. Asserts: all four
contracts resolve a `symbolUid`; `trace` is **symbol-precise** (the GET pair
selects `http::GET`, the POST pair `http::POST`, no file-fallback note); the
destination trace lands at `listUsers`.
2. **Anonymous handler** — `router.get('/api/ping', (req,res) => …)`. Asserts the
provider contract has an empty `symbolUid`, and the **destination trace**
(omit `to`) reaches it, reported as `<http::GET::/api/ping handler>` with an
anonymous note.
3. **Cross-repo `impact` fan-out** — `impact @group` on `fetchUsers` crosses the
boundary (`cross_repo_hits >= 1`); the same `symbolUid` join was 0 before.
4. **Multi-language (Python)** — a Flask provider + `requests` consumer; asserts
the Python line wiring resolves the consumer and the cross-repo `trace`
stitches `fetch_items -> list_items`.
The **ambiguous-destination** (a file making several HTTP calls whose consumer
contracts have no resolved uid) and **degraded-member** (a member DB that throws
mid-resolution) paths need synthetic inputs the real analyzer cannot produce, so
they live in the unit suite (`test/unit/group/cross-trace.test.ts`).
## What it proves
- `analyze` + `syncGroup` build the correct `ContractLink`s (exact HTTP match).
- HTTP contracts carry a **real `symbolUid` whenever the endpoint resolves** —
the extractor binds each detection to the function it lives in (the function
CONTAINING the `fetch`; the named handler, or the inline handler by line-span
containment, for a route). A handler/consumer that resolves to no named symbol
(a fully anonymous handler, or a language plugin that does not yet set the
call-site line) keeps an empty uid and degrades to the file/destination
fallback. When resolved, contracts report
`extractionStrategy: 'source_scan_resolved'` / `'graph_assisted'` with a uid.
- `trace @group from=<calling fn> to=<handler fn>` **stitches the cross-repo
path** (`fetchUsers → listUsers`), reporting the `CONTRACT_LINK` hop and
(with `pdg:true`) the data-flow enrichment, **symbol-precise** (GET pair →
`http::GET` contract, POST → `http::POST`), with no file-fallback note.
- The same `symbolUid` fix makes `impact @group` fan out across the boundary
(it was 0 cross-repo hits before — both tools join crossings on `symbolUid`).
## Resolution precedence & residual limits
The extractor resolves `symbolUid` in this order, falling through on a miss:
1. **Named handler** — `router.get('/x', listUsers)` resolves `listUsers` by name.
2. **Containment** — the innermost `Function`/`Method` whose line span encloses
the call/registration line (consumers; inline-arrow providers).
3. **File-level boundary fallback** (in `cross-trace`) — only when 1–2 leave the
uid empty: if the user's `from`/`to` resolves into the contract's file, that
endpoint anchors the boundary. A `notes[]` entry flags it as file-level, not
symbol-precise.
The call-site line is set by all bundled language plugins (Node/TS, Python, Go,
PHP, Kotlin, Java), and containment matches symbols by `filePath` across
`Function`/`Method`/`CodeElement`, so it also resolves methods nested in classes
(Java/Kotlin), not just top-level functions.
### Anonymous handlers — the destination trace
A **fully anonymous handler** (`router.get('/x', (req,res) => res.json(...))`)
has no symbol node at all, so it cannot be named as a `to` target. This is
handled by the **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:
```
app/frontend:fetchUsers → app/backend:<http::GET::/api/users handler> [CONTRACT_LINK]
```
To go deeper into an anonymous handler, trace to a named function it calls (the
provider segment then resolves normally).

View file

@ -0,0 +1,281 @@
/**
* Cross-repo trace — comprehensive end-to-end verification.
*
* Drives the REAL pipeline (runFullAnalysis --pdg -> real syncGroup -> trace /
* impact via a LocalBackend) over inline fixtures, one scenario per implemented
* case, and reports PASS/FAIL per assertion. Run from gitnexus/ (needs a current
* build for the parse worker):
*
* node scripts/build.js
* node bench/cross-repo-trace/verify.mjs
*
* Cases covered: symbolUid containment resolution (named, same-file + nested),
* symbol-precise crossing selection, the destination trace (named + anonymous
* endpoint), cross-repo impact fan-out, and multi-language (Python) resolution.
* (Ambiguous-destination and degraded-member paths need synthetic inputs the
* real analyzer can't produce; those are covered in the unit suite.)
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const REPO = path.resolve('.');
const { runFullAnalysis } = await import(path.join(REPO, 'dist/core/run-analyze.js'));
const { getGroupDir } = await import(path.join(REPO, 'dist/core/group/storage.js'));
const { loadGroupConfig } = await import(path.join(REPO, 'dist/core/group/config-parser.js'));
const { syncGroup } = await import(path.join(REPO, 'dist/core/group/sync.js'));
const { LocalBackend } = await import(path.join(REPO, 'dist/mcp/local/local-backend.js'));
const cb = { onProgress: () => {}, onLog: () => {} };
const ANALYZE = { pdg: true, skipSkills: true, embeddings: false, force: true };
const line = (s = '') => console.log(s);
const results = [];
const check = (pass, label, detail = '') => {
results.push({ pass, label });
line(` [${pass ? 'PASS' : 'FAIL'}] ${label}${detail ? ` — ${detail}` : ''}`);
};
function writeFiles(dir, files) {
for (const [rel, content] of Object.entries(files)) {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content);
}
}
function groupYaml(name, repos) {
const lines = Object.entries(repos)
.map(([k, v]) => ` ${k}: ${v}`)
.join('\n');
return `version: 1
name: ${name}
description: ""
repos:
${lines}
links: []
packages: {}
detect:
http: true
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
`;
}
/** Analyze each repo, sync the group, return a ready LocalBackend + sync result. */
async function setup(tag, repos, groupName, groupRepos) {
const home = fs.mkdtempSync(path.join(os.tmpdir(), `gn-bench-${tag}-`));
process.env.GITNEXUS_HOME = home;
for (const [reg, files] of Object.entries(repos)) {
const dir = path.join(home, reg);
writeFiles(dir, files);
await runFullAnalysis(dir, ANALYZE, cb);
}
const gd = getGroupDir(home, groupName);
fs.mkdirSync(gd, { recursive: true });
fs.writeFileSync(path.join(gd, 'group.yaml'), groupYaml(groupName, groupRepos));
const sync = await syncGroup(await loadGroupConfig(gd), { groupDir: gd });
const backend = new LocalBackend();
await backend.init();
return { home, sync, backend };
}
const hasNote = (r, frag) => (r.notes ?? []).some((n) => n.includes(frag));
const crossingId = (r) => r.crossings?.[0]?.contractId;
// ── Scenario 1+3: named handlers (precise trace, destination, impact fan-out) ──
line('## Scenario: named handlers (same-file) — symbolUid precise');
{
const { sync, backend, home } = await setup(
'named',
{
'named-backend': {
'src/routes.ts': `import { Router } from 'express';
const router = Router();
export function listUsers(req: { body: unknown }, res: { json: (v: unknown) => void }) { res.json([]); }
export function createUser(req: { body: unknown }, res: { json: (v: unknown) => void }) { res.json({}); }
router.get('/api/users', listUsers);
router.post('/api/users', createUser);
export default router;
`,
'package.json': '{ "name": "named-backend", "version": "1.0.0" }',
},
'named-frontend': {
'src/api.ts': `export async function fetchUsers() {
const r = await fetch('/api/users');
return r.json();
}
export async function createUserReq(data: { name: string }) {
const r = await fetch('/api/users', { method: 'POST', body: JSON.stringify(data) });
return r.json();
}
`,
'package.json': '{ "name": "named-frontend", "version": "1.0.0" }',
},
},
'named-group',
{ 'app/backend': 'named-backend', 'app/frontend': 'named-frontend' },
);
const resolved = sync.contracts.filter((c) => c.symbolUid).length;
check(resolved >= 4, `all 4 contracts resolve a symbolUid (got ${resolved}/4)`);
const get = await backend.callTool('trace', {
repo: '@named-group',
from: 'fetchUsers',
to: 'listUsers',
pdg: true,
});
check(
get.status === 'ok' && crossingId(get) === 'http::GET::/api/users' && !hasNote(get, 'file'),
'GET trace is symbol-precise (fetchUsers -> listUsers over http::GET::/api/users, no file fallback)',
`status=${get.status} crossing=${crossingId(get)}`,
);
const post = await backend.callTool('trace', {
repo: '@named-group',
from: 'createUserReq',
to: 'createUser',
pdg: true,
});
check(
post.status === 'ok' && crossingId(post) === 'http::POST::/api/users',
'POST trace selects the POST crossing (no GET/POST confusion)',
`crossing=${crossingId(post)}`,
);
const dest = await backend.callTool('trace', { repo: '@named-group', from: 'fetchUsers' });
check(
dest.status === 'ok' &&
dest.to?.name === 'listUsers' &&
crossingId(dest) === 'http::GET::/api/users',
'destination trace (no `to`) lands at the named handler listUsers',
`to=${dest.to?.name}`,
);
const imp = await backend.callTool('impact', {
repo: '@named-group/app/frontend',
target: 'fetchUsers',
direction: 'downstream',
});
const hits = imp.summary?.cross_repo_hits ?? (Array.isArray(imp.cross) ? imp.cross.length : 0);
check(hits >= 1, `impact @group fans out across the boundary (cross_repo_hits=${hits})`);
fs.rmSync(home, { recursive: true, force: true });
}
// ── Scenario 2: anonymous handler — destination reports endpoint by route ──
line('\n## Scenario: anonymous handler — destination trace');
{
const { sync, backend, home } = await setup(
'anon',
{
'anon-backend': {
'src/routes.ts': `import { Router } from 'express';
const router = Router();
router.get('/api/ping', (req: unknown, res: { json: (v: unknown) => void }) => { res.json({ ok: true }); });
export default router;
`,
'package.json': '{ "name": "anon-backend", "version": "1.0.0" }',
},
'anon-frontend': {
'src/ping.ts': `export async function ping() {
const r = await fetch('/api/ping');
return r.json();
}
`,
'package.json': '{ "name": "anon-frontend", "version": "1.0.0" }',
},
},
'anon-group',
{ 'app/backend': 'anon-backend', 'app/frontend': 'anon-frontend' },
);
const provider = sync.contracts.find((c) => c.role === 'provider');
check(
provider !== undefined && !provider.symbolUid,
'anonymous provider has an empty symbolUid (no named symbol to resolve)',
`uid=${provider?.symbolUid || 'empty'}`,
);
const dest = await backend.callTool('trace', { repo: '@anon-group', from: 'ping' });
check(
dest.status === 'ok' &&
dest.to?.name === '<http::GET::/api/ping handler>' &&
hasNote(dest, 'anonymous'),
'destination trace reaches the anonymous handler, reported by route + anonymous note',
`to=${dest.to?.name}`,
);
fs.rmSync(home, { recursive: true, force: true });
}
// ── Scenario 4: multi-language (Python) — symbolUid resolution beyond TS ──
line('\n## Scenario: multi-language (Python) — line wiring + resolution');
{
const { sync, backend, home } = await setup(
'py',
{
'py-backend': {
'app.py': `from flask import Flask
app = Flask(__name__)
@app.route('/api/items')
def list_items():
return []
`,
},
'py-frontend': {
'client.py': `import requests
def fetch_items():
return requests.get('/api/items').json()
`,
},
},
'py-group',
{ 'app/backend': 'py-backend', 'app/frontend': 'py-frontend' },
);
line(
` (py contracts: ${sync.contracts
.map((c) => `${c.role}:${c.symbolName}:${c.symbolUid ? 'uid' : 'empty'}`)
.join(' ')} | crossLinks=${sync.crossLinks.length})`,
);
check(
sync.crossLinks.length >= 1,
`Python HTTP link built (crossLinks=${sync.crossLinks.length})`,
);
const tr = await backend.callTool('trace', {
repo: '@py-group',
from: 'fetch_items',
to: 'list_items',
});
check(
tr.status === 'ok' && crossingId(tr) === 'http::GET::/api/items',
'Python cross-repo trace stitches fetch_items -> list_items',
`status=${tr.status} crossing=${crossingId(tr) ?? tr.role}`,
);
// The Flask provider resolves no symbol here, so the provider boundary is
// anchored by the contract FILE (to=list_items lives in the provider file).
// This exercises the file-level fallback path end-to-end.
check(
hasNote(tr, 'FILE'),
'provider boundary uses the file-level fallback when the provider has no uid',
`notes=${(tr.notes ?? []).length}`,
);
fs.rmSync(home, { recursive: true, force: true });
}
// ── Summary ────────────────────────────────────────────────────────────────
const passed = results.filter((r) => r.pass).length;
line(`\n## Verdict: ${passed}/${results.length} checks passed`);
if (passed !== results.length) {
line(' FAILED:');
for (const r of results.filter((x) => !x.pass)) line(` - ${r.label}`);
}
process.exit(passed === results.length ? 0 : 1);

View file

@ -1,5 +1,5 @@
{
"fingerprint": "4cc418ea87b6d20a68b5c1139f35d81820b715c63de0ec812e73e2135f5b00b1",
"fingerprint": "b169463b7d02185d757b6d8601db6215ac6e7b2a20e52fb0f1276cc153836bd4",
"scaling_budget": 1.8,
"max_ms_large": 1000,
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."

View file

@ -1913,9 +1913,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
"version": "25.9.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz",
"integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"

View file

@ -62,6 +62,10 @@ const LBUG_NATIVE = [
'test/integration/lbug-orphan-sidecar-recovery.test.ts',
'test/integration/lbug-readonly-init.test.ts',
'test/integration/lbug-non-ascii-path.test.ts',
// Cross-repo trace e2e: builds two real lbug indexes + a real bridge and
// opens them through the pool adapter (native addon + bridge file locking).
// Windows is skipped in-file (describeReopen) due to the bridge reopen lock.
'test/integration/group/cross-trace-e2e.test.ts',
'test/integration/local-backend.test.ts',
'test/integration/local-backend-calltool.test.ts',
'test/integration/search-core.test.ts',

View file

@ -137,3 +137,28 @@ The bridge stores every extracted contract keyed by `symbolUid`.
Manifest-sourced contracts use the synthetic uid form so both sides
of the `(local impact) ↔ (bridge query)` join derive the same uid
without coordinating through any shared state.
## Cross-repo trace (`cross-trace.ts`)
A second consumer of the bridge. Where cross-impact fans a blast radius
*outward* from one symbol, cross-trace stitches a directed **path** between
two symbols that live in different repos:
```mermaid
flowchart TD
FT[from / to resolved<br/>across all members] --> SR{same repo?}
SR -- yes --> LT[single-repo trace<br/>no crossing]
SR -- no --> SEGA[trace: from → consumer symbol<br/>in home repo]
SEGA --> XB[Bridge pair query<br/>consumer.symbolUid → provider.symbolUid<br/>one ContractLink boundary]
XB --> SEGB[trace: provider symbol → to<br/>in target repo]
SEGB --> STITCH[stitched hops + CONTRACT_LINK edge<br/>+ optional REACHING_DEF data-flow]
```
It reuses the same `symbolUid` join as cross-impact, but issues its own
*pair* query (`listCrossingsBetween`) because a path needs BOTH endpoints of
a crossing — the uid-filtered neighbor join (`resolveBridgeNeighbors`, shared
with impact) returns only the far side. The crossing is clamped to one
boundary (`MAX_SUPPORTED_CROSS_DEPTH`). With `pdg: true` the boundary-adjacent
segments are enriched with intra-procedural REACHING_DEF data-flow (never
across the boundary). Full cross-program data flow across the boundary is a
deferred follow-up.

View file

@ -237,10 +237,18 @@ export async function closeBridgeDb(handle: BridgeHandle): Promise<void> {
// pending on disk, which makes a subsequent read-side open either race
// with the WAL replay or trip the database-id check on the sidecars.
// CHECKPOINT is a no-op when there's nothing pending, so it's cheap.
try {
await (handle._conn as lbug.Connection).query('CHECKPOINT');
} catch {
/* ignore — older LadybugDB or schemaless DB may not accept it */
//
// ONLY on a writable handle. A read-only connection has nothing to flush,
// and issuing CHECKPOINT on it leaves a WAL/shadow lock artifact that makes
// the very next read-only open of the same path fail in-process — which broke
// repeated `@group` impact/trace calls in a long-lived MCP server (the read
// path opens read-only, queries, and closes per call).
if (!handle._readOnly) {
try {
await (handle._conn as lbug.Connection).query('CHECKPOINT');
} catch {
/* ignore — older LadybugDB or schemaless DB may not accept it */
}
}
try {
await (handle._conn as lbug.Connection).close();
@ -252,6 +260,16 @@ export async function closeBridgeDb(handle: BridgeHandle): Promise<void> {
} catch {
/* ignore */
}
// NOTE: Windows in-process write→read reopen of the SAME bridge.lbug is still a
// known limitation (the writable close's OS file handle is not released before
// the read open races; the existing open-side LBUG_OPEN_RETRY only retries
// lock-pattern errors, not the post-rename sidecar database-id mismatch). The
// bridge's close-then-reopen tests stay Windows-skipped. A close-side
// waitForWindowsHandleRelease + finalizeLbugSidecarsAfterClose probe (mirroring
// safeClose) was tried and did NOT close that gap on Windows CI, so it was
// removed rather than carry latency/duplication for no Windows benefit. The
// read-only CHECKPOINT skip above is the load-bearing fix and works on
// Linux/macOS (the platforms where in-process reopen is supported).
}
/* ------------------------------------------------------------------ */
@ -713,7 +731,7 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHand
// (where we can retry) instead of on the first user query.
await handle.db.init();
await handle.conn.init();
return { _db: handle.db, _conn: handle.conn, groupDir } as BridgeHandle;
return { _db: handle.db, _conn: handle.conn, groupDir, _readOnly: true } as BridgeHandle;
} catch (err) {
lastErr = err;
if (handle) await closeLbugConnection(handle);

View file

@ -63,7 +63,7 @@ RETURN provider.repo AS neighborRepo,
provider.type AS contractType
`;
type BridgeNeighborRow = {
export type BridgeNeighborRow = {
neighborRepo: string;
neighborUid: string;
neighborFilePath?: string;
@ -352,7 +352,7 @@ export function mergeRisk(localRisk: string, cross: CrossRepoImpact[]): string {
return localRisk;
}
async function ensureBridgeReady(
export async function ensureBridgeReady(
groupDir: string,
): Promise<{ handle: BridgeHandle } | { error: string }> {
const meta = await readBridgeMeta(groupDir);
@ -394,6 +394,39 @@ function rowToNeighbor(r: Record<string, unknown>): BridgeNeighborRow | null {
};
}
/**
* Resolve cross-repo neighbors over `ContractLink` for a set of local symbol
* UIDs, in a single direction, sorted by descending confidence.
*
* This is the one shared consumer↔provider bridge join. `runGroupImpact`'s
* Phase-2 fan-out uses it directly; the cross-repo trace path (`cross-trace.ts`)
* reuses the same `queryBridge` + row-normalization primitives but issues a
* distinct *pair* query, because a trace must keep BOTH endpoints of a crossing
* (this neighbor join intentionally returns only the far side, which is lossy
* for stitching a path). Keeping this helper as the single uid-filtered join
* means impact never forks its own copy of the neighbor Cypher.
*
* Returns `[]` for an empty `uids` set without touching the DB.
*/
export async function resolveBridgeNeighbors(
handle: BridgeHandle,
opts: { localRepo: string; uids: string[]; direction: 'upstream' | 'downstream' },
): Promise<BridgeNeighborRow[]> {
if (opts.uids.length === 0) return [];
const cypher = opts.direction === 'upstream' ? CY_NEIGHBORS_UPSTREAM : CY_NEIGHBORS_DOWNSTREAM;
const rows = await queryBridge<Record<string, unknown>>(handle, cypher, {
localRepo: opts.localRepo,
uids: opts.uids,
});
const neighbors: BridgeNeighborRow[] = [];
for (const raw of rows) {
const n = rowToNeighbor(raw);
if (n) neighbors.push(n);
}
neighbors.sort((a, b) => b.confidence - a.confidence);
return neighbors;
}
export async function runGroupImpact(
deps: RunGroupImpactDeps,
params: Record<string, unknown>,
@ -537,19 +570,12 @@ export async function runGroupImpact(
const truncatedRepos: string[] = [];
try {
const cypher = direction === 'upstream' ? CY_NEIGHBORS_UPSTREAM : CY_NEIGHBORS_DOWNSTREAM;
const rows = await queryBridge<Record<string, unknown>>(handle, cypher, {
const neighbors = await resolveBridgeNeighbors(handle, {
localRepo: repoPath,
uids,
direction,
});
const neighbors: BridgeNeighborRow[] = [];
for (const raw of rows) {
const n = rowToNeighbor(raw);
if (n) neighbors.push(n);
}
neighbors.sort((a, b) => b.confidence - a.confidence);
const seen = new Set<string>();
for (const n of neighbors) {

File diff suppressed because it is too large Load diff

View file

@ -180,6 +180,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
method: httpMethod,
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -198,6 +199,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
method: method.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -215,6 +217,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
method: methodNode.text.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}

View file

@ -0,0 +1,269 @@
import Parser from 'tree-sitter';
import { unquoteLiteral } from '../tree-sitter-scanner.js';
// ─── Statically-resolvable consumer path + builder verb-walk helpers ──────
// RestTemplate calls increasingly pass a non-literal path argument that is
// still statically derivable — `URI.create("/x")` or a `UriComponentsBuilder`
// fluent chain. These helpers resolve those shapes to a literal path; a
// genuinely dynamic argument (a variable, a non-`URI`/`UriComponentsBuilder`
// call) resolves to null and the call site is skipped. This module also owns
// the OkHttp / Java-HttpClient builder verb-walks (`inferOkHttpMethod` /
// `inferHttpClientMethod`), which recover the request verb by walking UP the
// fluent chain from the matched `.url(...)` / `.uri(...)` call. Extracted from
// java.ts (#2268) so that plugin stays under ~1000 lines; the `methodInvocation*`
// primitives + `firstLiteralArgument` are module-internal (shared by the path
// resolvers and the verb-walks), while the path resolver and the two verb-walks
// are java.ts's only entry points here.
function methodInvocationName(node: Parser.SyntaxNode): string | null {
return node.type === 'method_invocation' ? (node.childForFieldName('name')?.text ?? null) : null;
}
function methodInvocationObject(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
return node.type === 'method_invocation' ? node.childForFieldName('object') : null;
}
function methodInvocationArguments(node: Parser.SyntaxNode): Parser.SyntaxNode[] {
const argsNode = node.type === 'method_invocation' ? node.childForFieldName('arguments') : null;
return argsNode?.namedChildren ?? [];
}
function firstLiteralArgument(node: Parser.SyntaxNode): string | null {
const first = methodInvocationArguments(node)[0];
return first?.type === 'string_literal' ? unquoteLiteral(first.text) : null;
}
/** Resolve a `URI.create("/path")` call to its literal path; null otherwise. */
function extractUriCreatePath(node: Parser.SyntaxNode): string | null {
if (node.type !== 'method_invocation') return null;
if (methodInvocationObject(node)?.text !== 'URI' || methodInvocationName(node) !== 'create')
return null;
return firstLiteralArgument(node);
}
// Join a builder base with a sub-path using exactly one separating slash. This
// is intentionally NOT the shared `joinPath`: `joinPath` force-prepends `/`,
// whereas `appendPath` must preserve an absolute/host base (`fromHttpUrl`
// "https://host/api") so the host survives until `normalizeConsumerPath` strips
// it downstream. Do not unify the two.
function appendPath(base: string, subPath: string): string {
if (!base) return subPath.startsWith('/') ? subPath : `/${subPath}`;
if (!subPath) return base;
return `${base.replace(/\/+$/, '')}/${subPath.replace(/^\/+/, '')}`;
}
/**
* Resolve a `UriComponentsBuilder` fluent chain to its literal path. Seed
* methods (`fromPath`/`fromUriString`/`fromHttpUrl`) return the literal arg
* VERBATIM — a `fromHttpUrl("https://host/api")` seed keeps its host, which the
* shared `normalizeConsumerPath` later reduces to the path (the same single
* normalization point every other consumer path goes through). `path` and
* `pathSegment` append literal segments; `build`/`toUriString`/`toUri`/`encode`
* and the `query*` family pass through (query attributes do not change the
* path). Any non-literal segment or unknown call → null.
*/
// A UriComponentsBuilder chain deeper than this is not realistic source; cap the
// recursion so a pathological / machine-generated chain returns null instead of
// overflowing the stack (mirrors the project's other AST-depth guards).
const MAX_BUILDER_DEPTH = 100;
function extractUriComponentsBuilderPath(node: Parser.SyntaxNode, depth = 0): string | null {
if (depth > MAX_BUILDER_DEPTH) return null;
if (node.type !== 'method_invocation') return null;
const name = methodInvocationName(node);
const objectNode = methodInvocationObject(node);
if (
(name === 'fromPath' || name === 'fromUriString' || name === 'fromHttpUrl') &&
objectNode?.text === 'UriComponentsBuilder'
) {
// Strip any `?query` baked into the seed literal so a later `.path()` appends
// to a clean base; otherwise the sub-path is glued after the query
// (`/base?x=1/sub`) and normalizeHttpPath truncates the whole tail at `?`.
// A host prefix (`https://h/api`) is preserved and stripped downstream by
// normalizeConsumerPath.
const seed = firstLiteralArgument(node);
return seed === null ? null : seed.split('?')[0];
}
if (!objectNode) return null;
if (name === 'path') {
const base = extractUriComponentsBuilderPath(objectNode, depth + 1);
const subPath = firstLiteralArgument(node);
if (base === null || subPath === null) return null;
// Spring `UriComponentsBuilder.path(p)` appends `p` VERBATIM (no slash
// inserted — unlike `pathSegment`, which slash-joins), then normalizes the
// full path to collapse duplicate slashes. So `fromPath("/api").path("users")`
// → `/apiusers`, while `.path("/users")` → `/api/users`, and a trailing-slash
// base collapses (`/api/` + `/users` → `/api/users`). The `(?<!:)` keeps a
// scheme `://` in a host seed intact; the downstream normalizer is the other
// slash-collapser for consumer paths (see KTD2 — do not remove it).
return (base + subPath).replace(/(?<!:)\/{2,}/g, '/');
}
if (name === 'pathSegment') {
const base = extractUriComponentsBuilderPath(objectNode, depth + 1);
if (base === null) return null;
const args = methodInvocationArguments(node);
const segments = args
.map((arg) => (arg.type === 'string_literal' ? unquoteLiteral(arg.text) : null))
.filter((segment): segment is string => segment !== null);
if (segments.length !== args.length) return null; // a non-literal segment defeats static resolution
return segments.reduce((acc, segment) => appendPath(acc, segment), base);
}
if (
name === 'build' ||
name === 'toUriString' ||
name === 'toUri' ||
name === 'encode' ||
name === 'query' ||
name === 'queryParam' ||
name === 'queryParams' ||
name === 'replaceQuery' ||
name === 'replaceQueryParam' ||
name === 'replaceQueryParams'
)
return extractUriComponentsBuilderPath(objectNode, depth + 1);
return null;
}
/**
* Resolve a statically-derivable path argument to a literal path: a bare
* string literal, a `URI.create("/x")` call, or a `UriComponentsBuilder`
* fluent chain. Genuinely dynamic arguments → null.
*/
export function extractStaticPathExpression(node: Parser.SyntaxNode): string | null {
if (node.type === 'string_literal') return unquoteLiteral(node.text);
return extractUriCreatePath(node) ?? extractUriComponentsBuilderPath(node);
}
// ─── Builder verb-walks ───────────────────────────────────────────────
// OkHttp / Java-HttpClient encode the request verb on a SIBLING call elsewhere in
// the fluent chain, not on the matched path call. Both queries capture the path
// call (`.url(...)` / `.uri(...)`); these helpers recover the verb by scanning
// the WHOLE chain — transparent to neutral calls (`.addHeader()`/`.header()`/
// `.timeout()`/`.version()`) wherever they sit relative to the path call, so the
// verb resolves whether it precedes or follows the path call.
/** Every `method_invocation` in the fluent chain `pathCall` belongs to, ordered
* innermost (next to the construction) → outermost (terminal). Lets the verb-walk
* find the verb wherever it sits, and lets the root gates inspect the chain base. */
function builderChainCalls(pathCall: Parser.SyntaxNode): Parser.SyntaxNode[] {
let innermost = pathCall;
let obj = methodInvocationObject(innermost);
while (obj?.type === 'method_invocation') {
innermost = obj;
obj = methodInvocationObject(innermost);
}
const calls: Parser.SyntaxNode[] = [innermost];
let cur = innermost;
let parent = cur.parent;
while (parent?.type === 'method_invocation' && methodInvocationObject(parent)?.id === cur.id) {
calls.push(parent);
cur = parent;
parent = parent.parent;
}
return calls;
}
/** Scan the chain for the verb a `name`-matching helper or a `.method("LITERAL")`
* call sets, resolving the LAST such call — each verb-setter overwrites the
* previous at runtime, so on the (non-idiomatic) chain that sets two verbs the
* one nearest the terminal wins. `null` means "verb is set but not statically
* resolvable" (a non-literal/empty `.method(verb)`) → the caller skips rather
* than guessing. `defaultVerb` is returned only when the chain has no verb call
* at all (a bare build). `verbHelpers` are matched on the method NAME (OkHttp
* helpers are lowercase, HttpClient helpers uppercase). */
function inferBuilderVerb(
pathCall: Parser.SyntaxNode,
verbHelpers: readonly string[],
defaultVerb: string,
): string | null {
let lastVerbCall: Parser.SyntaxNode | null = null;
for (const call of builderChainCalls(pathCall)) {
if (call.id === pathCall.id) continue; // the path call itself is never the verb
const name = methodInvocationName(call);
if (name === 'method' || (name !== null && verbHelpers.includes(name))) lastVerbCall = call;
}
if (lastVerbCall === null) return defaultVerb; // no verb call → builder default
const name = methodInvocationName(lastVerbCall);
// Explicit `.method(...)`: a non-empty string-literal verb resolves; a
// non-literal (variable-bound) OR empty-string verb is unresolvable → null
// (skip), NOT a guessed default or a malformed empty-method contract.
if (name === 'method') {
const verb = firstLiteralArgument(lastVerbCall);
return verb ? verb.toUpperCase() : null;
}
return name === null ? defaultVerb : name.toUpperCase(); // a verb-helper name
}
const OK_HTTP_VERB_HELPERS = ['get', 'head', 'post', 'put', 'delete', 'patch'] as const;
const HTTP_CLIENT_VERB_HELPERS = ['GET', 'POST', 'PUT', 'DELETE', 'HEAD'] as const;
/**
* Infer an OkHttp request verb by scanning the builder chain around the matched
* `.url(...)` call: a `.get()/.head()/.post()/.put()/.delete()/.patch()` helper
* (before or after `.url()`), or a `.method("VERB", …)` literal, wins. Returns
* `'GET'` only when the chain has NO verb call at all (a bare `.url(...).build()`
* — OkHttp's real default). Returns `null` for an explicit `.method(verb, …)`
* whose verb is a non-literal: the verb is set but not statically resolvable, so
* the caller skips the call rather than asserting a wrong GET (parity with the
* WebClient long-form variable-bound-verb behavior, which also skips).
*/
export function inferOkHttpMethod(urlCall: Parser.SyntaxNode): string | null {
return inferBuilderVerb(urlCall, OK_HTTP_VERB_HELPERS, 'GET');
}
/**
* Infer a Java-`HttpClient` request verb by walking UP the builder chain from the
* matched `.uri(URI.create("..."))` call: the first `.GET()/.POST()/.PUT()/`
* `.DELETE()/.HEAD()` verb-helper, or a `.method("VERB", body)` literal, wins.
* Returns `'GET'` only when the chain has no verb call (a bare `.build()` — the
* builder's real default). Returns `null` for an explicit `.method(verb, …)` with
* a non-literal verb (skip, not a guessed GET). The scan is transparent to
* neutral calls anywhere in the chain (`.header()`/`.timeout()`/`.version()`),
* before or after `.uri()`, so neither a header/timeout hop nor a verb call's
* position drops the contract.
*/
export function inferHttpClientMethod(uriCall: Parser.SyntaxNode): string | null {
return inferBuilderVerb(uriCall, HTTP_CLIENT_VERB_HELPERS, 'GET');
}
// ─── Builder-chain root gates (anti-overreach) ────────────────────────
// The path queries match a bare `.url(...)` / `.uri(URI.create(...))` call on ANY
// receiver so that a builder call BEFORE the path call (`new Request.Builder()`
// `.addHeader(...).url(...)`, `HttpRequest.newBuilder().version(v).uri(...)`) is
// still captured. These gates re-impose the framework anchor in JS — only a chain
// that bottoms out on the right construction emits, so a `.url(...)`/`.uri(...)`
// on an unrelated object does not.
/** True when `urlCall`'s receiver chain bottoms out on a `new Request.Builder()`
* object-creation (descending the `.object` chain past any intervening calls). */
export function okHttpUrlRootsAtBuilder(urlCall: Parser.SyntaxNode): boolean {
let obj = methodInvocationObject(urlCall);
while (obj?.type === 'method_invocation') obj = methodInvocationObject(obj);
return (
obj?.type === 'object_creation_expression' &&
obj.childForFieldName('type')?.text === 'Request.Builder'
);
}
/** True when `uriCall`'s receiver chain includes a `HttpRequest.newBuilder()`. */
export function httpClientUriRootsAtNewBuilder(uriCall: Parser.SyntaxNode): boolean {
let obj = methodInvocationObject(uriCall);
while (obj?.type === 'method_invocation') {
if (
methodInvocationName(obj) === 'newBuilder' &&
methodInvocationObject(obj)?.text === 'HttpRequest'
)
return true;
obj = methodInvocationObject(obj);
}
return false;
}
/** True when a `HttpRequest.newBuilder(URI.create(...))` chain ALSO calls `.uri(...)`
* later — a later `.uri()` overrides the constructor URI at runtime, so the
* constructor-arg path must NOT be emitted (the `.uri()` query emits the override). */
export function httpClientChainHasUriCall(newBuilderCall: Parser.SyntaxNode): boolean {
return builderChainCalls(newBuilderCall).some(
(call) => call.id !== newBuilderCall.id && methodInvocationName(call) === 'uri',
);
}

View file

@ -27,6 +27,14 @@ import {
REQUEST_LINE_CONFIDENCE,
EXCHANGE_CONFIDENCE,
} from './spring-consumer-shared.js';
import {
extractStaticPathExpression,
inferOkHttpMethod,
inferHttpClientMethod,
okHttpUrlRootsAtBuilder,
httpClientUriRootsAtNewBuilder,
httpClientChainHasUriCall,
} from './java-static-path.js';
import type {
HttpDetection,
HttpFileDetections,
@ -98,17 +106,15 @@ import type {
// sidesteps that hazard entirely; all name/key discrimination lives in the
// for-loop, where it reads as straight-line code.
//
// KNOWN LIMITATION — fully-qualified route annotations are not matched. `@ann`
// binds `name: (identifier)`, but a FQN annotation (`@org.springframework…
// GetMapping("/x")`) parses its name as a `scoped_identifier`, which this query
// does not match, so its route is not extracted. (The class is still recognized
// as a controller — `hasAnnotation` trailing-segment-matches the FQN — only the
// route-string extraction is missed.) In practice annotations are imported and
// written by simple name, so this is rare. It is a minor asymmetry with the
// Kotlin plugin, whose grammar models a FQN as separate `type_identifier`
// segments that its route queries DO match. Aligning Java would mean matching
// `scoped_identifier` too; that is deferred to avoid re-keying existing Java
// contracts via the predicate hazard above. Pinned by an anti-overreach test.
// FULLY-QUALIFIED route annotations ARE matched. `@ann` binds either an
// `identifier` (simple name) or a `scoped_identifier` (a FQN annotation such as
// `@org.springframework…GetMapping("/x")`); the for-loop normalizes the name to
// its trailing segment via `simpleName` before discriminating. This is a node-
// type widening only — the query stays predicate-free, so it does NOT reintroduce
// the bucket hazard above, and a simple name maps to itself so existing Java
// contracts are unchanged (only previously-unmatched FQN annotations gain
// routes). This brings Java to parity with the Kotlin plugin, whose grammar
// already matches FQN route annotations.
const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
name: 'java-route-annotation',
language: Java,
@ -120,17 +126,17 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
(class_declaration
(modifiers
(annotation
name: (identifier) @ann
name: [(identifier) (scoped_identifier)] @ann
arguments: (annotation_argument_list [(string_literal) @value (element_value_array_initializer (string_literal) @value)])))) @node
(interface_declaration
(modifiers
(annotation
name: (identifier) @ann
name: [(identifier) (scoped_identifier)] @ann
arguments: (annotation_argument_list [(string_literal) @value (element_value_array_initializer (string_literal) @value)])))) @node
(class_declaration
(modifiers
(annotation
name: (identifier) @ann
name: [(identifier) (scoped_identifier)] @ann
arguments: (annotation_argument_list
(element_value_pair
key: (identifier) @key
@ -138,7 +144,7 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
(interface_declaration
(modifiers
(annotation
name: (identifier) @ann
name: [(identifier) (scoped_identifier)] @ann
arguments: (annotation_argument_list
(element_value_pair
key: (identifier) @key
@ -146,13 +152,13 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
(method_declaration
(modifiers
(annotation
name: (identifier) @ann
name: [(identifier) (scoped_identifier)] @ann
arguments: (annotation_argument_list [(string_literal) @value (element_value_array_initializer (string_literal) @value)])))
name: (identifier) @member) @node
(method_declaration
(modifiers
(annotation
name: (identifier) @ann
name: [(identifier) (scoped_identifier)] @ann
arguments: (annotation_argument_list
(element_value_pair
key: (identifier) @key
@ -199,7 +205,7 @@ const REST_TEMPLATE_PATTERNS = compilePatterns({
(method_invocation
object: (identifier) @obj (#eq? @obj "restTemplate")
name: (identifier) @method
arguments: (argument_list . (string_literal) @path))
arguments: (argument_list . (_) @path))
`,
},
],
@ -216,7 +222,7 @@ const REST_TEMPLATE_EXCHANGE_PATTERNS = compilePatterns({
object: (identifier) @obj (#eq? @obj "restTemplate")
name: (identifier) @method (#eq? @method "exchange")
arguments: (argument_list
. (string_literal) @path
. (_) @path
(field_access
object: (identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod")
field: (identifier) @http_method)))
@ -275,10 +281,14 @@ const WEB_CLIENT_LONG_FORM_PATTERNS = compilePatterns({
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: OkHttp `new Request.Builder().url("path")` ─────────────
// Note: `Request.Builder` is a `scoped_type_identifier` whose text includes
// the dot, so `#eq?` against the literal string matches cleanly (no need
// to escape a regex dot).
// ─── Consumer: OkHttp `Request.Builder()…url("path")` ─────────────────
// Match a bare `.url("literal")` call on ANY receiver, capturing it as `@call`.
// `okHttpUrlRootsAtBuilder` (JS) then verifies the receiver chain bottoms out on
// `new Request.Builder()` — re-imposing the framework anchor while allowing
// builder calls BEFORE `.url()` (`new Request.Builder().addHeader(...).url("/x")`)
// that the old object-direct query dropped, and rejecting a `.url(...)` on an
// unrelated object. The verb is recovered by `inferOkHttpMethod` scanning the
// whole chain (java-static-path.ts).
const OK_HTTP_PATTERNS = compilePatterns({
name: 'java-okhttp',
language: Java,
@ -287,15 +297,22 @@ const OK_HTTP_PATTERNS = compilePatterns({
meta: {},
query: `
(method_invocation
object: (object_creation_expression
type: (scoped_type_identifier) @type (#eq? @type "Request.Builder"))
name: (identifier) @method (#eq? @method "url")
arguments: (argument_list . (string_literal) @path))
arguments: (argument_list . (string_literal) @path)) @call
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// Match a bare `.uri(URI.create("literal"))` call on ANY receiver, capturing it
// as `@call`. `httpClientUriRootsAtNewBuilder` (JS) verifies the chain includes
// `HttpRequest.newBuilder()` — allowing calls BEFORE `.uri()` (`.version(v)`
// `.uri(...)`) that the old object-direct query dropped, and rejecting a `.uri(...)`
// on an unrelated object (e.g. WebClient). The verb (a `.GET()/.POST()/.PUT()/`
// `.DELETE()/.HEAD()` helper, a `.method("VERB", body)` literal, or the bare-build
// default) is recovered by `inferHttpClientMethod` scanning the whole chain.
// Matching `.uri(...)` regardless of a trailing `.build()` mirrors the accepted
// OkHttp over-match posture.
const JAVA_HTTP_CLIENT_PATTERNS = compilePatterns({
name: 'java-http-client',
language: Java,
@ -304,18 +321,36 @@ const JAVA_HTTP_CLIENT_PATTERNS = compilePatterns({
meta: {},
query: `
(method_invocation
object: (method_invocation
object: (method_invocation
object: (identifier) @builderCls (#eq? @builderCls "HttpRequest")
name: (identifier) @newBuilder (#eq? @newBuilder "newBuilder")
arguments: (argument_list))
name: (identifier) @uri_method (#eq? @uri_method "uri")
arguments: (argument_list
(method_invocation
object: (identifier) @uriCls (#eq? @uriCls "URI")
name: (identifier) @create (#eq? @create "create")
arguments: (argument_list . (string_literal) @path))))
name: (identifier) @http_method (#match? @http_method "^(GET|POST|PUT|DELETE)$"))
name: (identifier) @uri_method (#eq? @uri_method "uri")
arguments: (argument_list
(method_invocation
object: (identifier) @uriCls (#eq? @uriCls "URI")
name: (identifier) @create (#eq? @create "create")
arguments: (argument_list . (string_literal) @path)))) @call
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// Constructor-arg form: `HttpRequest.newBuilder(URI.create("..."))` — the path is
// the `newBuilder(...)` argument, with no `.uri()` call. Captures the `newBuilder`
// call as `@call`; the emission skips it when a later `.uri(...)` overrides the
// constructor URI (`httpClientChainHasUriCall`), so the `.uri()` query owns that case.
const JAVA_HTTP_CLIENT_CTOR_PATTERNS = compilePatterns({
name: 'java-http-client-ctor',
language: Java,
patterns: [
{
meta: {},
query: `
(method_invocation
object: (identifier) @builderCls (#eq? @builderCls "HttpRequest")
name: (identifier) @newBuilder (#eq? @newBuilder "newBuilder")
arguments: (argument_list
(method_invocation
object: (identifier) @uriCls (#eq? @uriCls "URI")
name: (identifier) @create (#eq? @create "create")
arguments: (argument_list . (string_literal) @path)))) @call
`,
},
],
@ -361,6 +396,18 @@ function getNodeName(node: Parser.SyntaxNode): string | null {
return node.childForFieldName('name')?.text ?? null;
}
/**
* Trailing segment of a possibly fully-qualified annotation name
* (`org.springframework.web.bind.annotation.GetMapping` → `GetMapping`). The
* route query binds `@ann` to either an `identifier` (simple) or a
* `scoped_identifier` (FQN); normalizing here lets the one for-loop discriminate
* on the simple name in both cases. A simple name maps to itself, so this never
* changes how a non-FQN annotation is classified.
*/
function simpleName(text: string): string {
return text.split('.').pop() ?? text;
}
function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[]): boolean {
const modifiers = node.namedChildren.find((child) => child.type === 'modifiers');
if (!modifiers) return false;
@ -369,10 +416,9 @@ function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[
while (stack.length > 0) {
const cur = stack.pop()!;
const annotationName = cur.childForFieldName('name')?.text ?? '';
const simpleName = annotationName.split('.').pop() ?? annotationName;
if (
(cur.type === 'annotation' || cur.type === 'marker_annotation') &&
(allowed.has(annotationName) || allowed.has(simpleName))
(allowed.has(annotationName) || allowed.has(simpleName(annotationName)))
) {
return true;
}
@ -381,6 +427,11 @@ function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[
return false;
}
// The statically-resolvable consumer path helpers (URI.create /
// UriComponentsBuilder resolution) and the OkHttp / HttpClient builder verb-walks
// (`inferOkHttpMethod` / `inferHttpClientMethod`) live in ./java-static-path.ts
// (#2268), shared with the RestTemplate, OkHttp, and HttpClient consumer loops.
interface MethodRouteAnnotation {
methodNode: Parser.SyntaxNode;
methodName: string | null;
@ -444,7 +495,13 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
const node = captures.node;
const valueNode = captures.value;
if (!annNode || !node || !valueNode) continue;
const ann = annNode.text;
// Discrimination is on the trailing segment only (`simpleName`), so a
// non-Spring annotation whose last segment collides with a route annotation
// (e.g. `@com.evil.GetMapping("/x")`) is treated as a route. This is the
// same accepted trailing-segment trade-off `hasAnnotation` already makes and
// the intended parity with the Kotlin plugin — package-origin gating would
// break that parity and is deliberately not done.
const ann = simpleName(annNode.text);
const keyNode = captures.key; // undefined for the positional shape
if (node.type === 'method_declaration') {
@ -619,6 +676,26 @@ function scanSpringProject(files: readonly HttpScanInput[]): HttpFileDetections[
export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'java-http',
language: Java,
// routeCoverage intentionally LEFT at the default 'partial' (#2138 Part 2).
// The graph provider set is a strict *subset* of this scan()'s provider set —
// ingestion does NOT emit a Route node for (1) array-form `@GetMapping({...})`,
// (2) interface-inherited Spring routes, or (3) the 2nd verb of a same-URL
// GET+POST pair (Route nodes are URL-keyed). Declaring 'complete' here would
// let the parse-skip drop those group-only providers. Java flips to 'complete'
// only once ingestion provider extraction matches this scan (a follow-up:
// array-form query branch + interface-inheritance emission + per-verb Route
// identity). `hasConsumerSignals` below is kept ready for that flip.
// Consumer signals this plugin's scan() can detect: RestTemplate / WebClient /
// OkHttp / Java-HttpClient / Apache-HttpClient call sites, OpenFeign
// (`@FeignClient` + `@RequestLine`) interfaces, and Spring 6 HTTP Interface
// `@(Get|...)Exchange` / `@HttpExchange`. A provider-covered file containing
// any of these must still be parsed so its consumer contracts are not dropped
// (ingestion emits no FETCHES for Java). Conservative by design.
hasConsumerSignals(content) {
return /\brestTemplate\b|\bwebClient\b|Request\.Builder|HttpRequest|HttpMethod\.|new\s+Http(Get|Post|Put|Delete|Patch)\b|@RequestLine|@FeignClient|Exchange/.test(
content,
);
},
scan(tree) {
const out: HttpDetection[] = [];
@ -651,6 +728,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
method: route.httpMethod,
path: joinPath(prefix, route.rawPath),
name: route.methodName,
line: route.methodNode.startPosition.row + 1,
confidence: FEIGN_CONFIDENCE,
});
}
@ -694,6 +772,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
method: requestLine.parsed.method,
path: joinPath(prefix, requestLine.parsed.path),
name: requestLine.methodName,
line: requestLine.methodNode.startPosition.row + 1,
confidence: REQUEST_LINE_CONFIDENCE,
});
}
@ -715,6 +794,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
method: route.httpMethod,
path: joinPath(prefix, route.rawPath),
name: route.methodName,
line: route.methodNode.startPosition.row + 1,
confidence: EXCHANGE_CONFIDENCE,
});
}
@ -727,7 +807,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
if (!methodNode || !pathNode) continue;
const httpMethod = REST_TEMPLATE_TO_HTTP[methodNode.text];
if (!httpMethod) continue;
const path = unquoteLiteral(pathNode.text);
const path = extractStaticPathExpression(pathNode);
if (path === null) continue;
out.push({
role: 'consumer',
@ -735,6 +815,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
method: httpMethod,
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -743,7 +824,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
const httpMethodNode = match.captures.http_method;
const pathNode = match.captures.path;
if (!httpMethodNode || !pathNode) continue;
const path = unquoteLiteral(pathNode.text);
const path = extractStaticPathExpression(pathNode);
if (path === null) continue;
out.push({
role: 'consumer',
@ -751,6 +832,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
method: httpMethodNode.text.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -773,6 +855,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
method: httpMethod,
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -796,39 +879,81 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
method: verbText,
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
// ─── Consumers: OkHttp Request.Builder().url("path") ────────────
// Match any `.url("literal")`, gate to chains rooting at `new Request.Builder()`
// (so a call before `.url()` is captured but an unrelated `.url()` is not), then
// recover the verb (`.post()`/`.method("X")`) by scanning the builder chain.
for (const match of runCompiledPatterns(OK_HTTP_PATTERNS, tree)) {
const callNode = match.captures.call;
const pathNode = match.captures.path;
if (!pathNode) continue;
if (!callNode || !pathNode) continue;
if (!okHttpUrlRootsAtBuilder(callNode)) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
const method = inferOkHttpMethod(callNode);
// An explicit `.method(verb, …)` with a non-literal or empty verb is
// unresolvable (null) — emit nothing rather than a wrong GET or an empty
// `http::::/path` contract.
if (!method) continue;
out.push({
role: 'consumer',
framework: 'okhttp',
method: 'GET',
method,
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
// ─── Consumers: Java HttpClient request builder ─────────────────
// Java's builder exposes GET/POST/PUT/DELETE helpers. PATCH uses
// `.method("PATCH", body)`, which is intentionally deferred.
// Match any `.uri(URI.create("..."))`, gate to chains including `HttpRequest`
// `.newBuilder()` (so a call before `.uri()` is captured but an unrelated
// `.uri()` is not); `inferHttpClientMethod` scans the chain for the verb — a
// `.GET()/.POST()/.PUT()/.DELETE()/.HEAD()` helper, a `.method("VERB", body)`
// literal, or the bare-build default GET. A variable-bound `.method(verb, …)`
// is unresolvable → emit nothing. Mirrors the OkHttp loop above. The
// constructor-arg form (`newBuilder(URI.create(...))`, no `.uri()`) follows.
for (const match of runCompiledPatterns(JAVA_HTTP_CLIENT_PATTERNS, tree)) {
const httpMethodNode = match.captures.http_method;
const callNode = match.captures.call;
const pathNode = match.captures.path;
if (!httpMethodNode || !pathNode) continue;
if (!callNode || !pathNode) continue;
if (!httpClientUriRootsAtNewBuilder(callNode)) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
const method = inferHttpClientMethod(callNode);
if (!method) continue;
out.push({
role: 'consumer',
framework: 'java-http-client',
method: httpMethodNode.text.toUpperCase(),
method,
path,
name: null,
confidence: 0.65,
});
}
// Constructor-arg form: `HttpRequest.newBuilder(URI.create("..."))` with the
// path in the constructor and no overriding `.uri(...)` later in the chain
// (a later `.uri()` wins at runtime, so the loop above owns that case).
for (const match of runCompiledPatterns(JAVA_HTTP_CLIENT_CTOR_PATTERNS, tree)) {
const callNode = match.captures.call;
const pathNode = match.captures.path;
if (!callNode || !pathNode) continue;
if (httpClientChainHasUriCall(callNode)) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
const method = inferHttpClientMethod(callNode);
if (!method) continue;
out.push({
role: 'consumer',
framework: 'java-http-client',
method,
path,
name: null,
confidence: 0.65,

View file

@ -131,6 +131,118 @@ const arrayOfArg = (cap: string): string => `(call_expression
(simple_identifier) @arrayOf (#eq? @arrayOf "arrayOf")
(call_suffix (value_arguments (value_argument (string_literal) ${cap}))))`;
// ─── Kotlin OkHttp builder verb-walk (parity with java-static-path.ts) ──
// Mirrors `inferOkHttpMethod`, adapted to the Kotlin grammar: a call `X.name(args)`
// is a `call_expression` whose callee is a `navigation_expression` (receiver +
// `navigation_suffix` → the method name) and whose `call_suffix` holds the
// `value_arguments`. The chain is left-nested via `navigation_expression`, so we
// walk UP from the matched `.url(...)` call to the sibling verb call (`.post()` /
// `.method("X")`), exactly as the Java side does — so `.java` and `.kt` infer the
// same verb for the same OkHttp shape.
const OK_HTTP_VERB_HELPERS = ['get', 'head', 'post', 'put', 'delete', 'patch'];
/** The method name a Kotlin `call_expression` invokes (its `navigation_suffix`). */
function kotlinCallName(call: Parser.SyntaxNode): string | null {
const callee = call.namedChild(0);
if (callee?.type !== 'navigation_expression') return null;
for (let i = 0; i < callee.namedChildCount; i++) {
const child = callee.namedChild(i);
if (child?.type === 'navigation_suffix') return child.namedChild(0)?.text ?? null;
}
return null;
}
/** The first string-literal argument of a Kotlin `call_expression`, else null. */
function kotlinFirstStringArg(call: Parser.SyntaxNode): string | null {
for (let i = 0; i < call.namedChildCount; i++) {
const callSuffix = call.namedChild(i);
if (callSuffix?.type !== 'call_suffix') continue;
for (let j = 0; j < callSuffix.namedChildCount; j++) {
const args = callSuffix.namedChild(j);
if (args?.type !== 'value_arguments') continue;
const firstArg = args.namedChild(0);
if (firstArg?.type !== 'value_argument') return null;
// Positional literal `"X"`, or named-argument `name = "X"` (the label is a
// leading `simple_identifier` and the literal follows). A non-literal value
// (a variable) → null → unresolvable.
const positional = firstArg.namedChild(0);
if (positional?.type === 'string_literal') return unquoteLiteral(positional.text);
const labeled = positional?.type === 'simple_identifier' ? firstArg.namedChild(1) : null;
return labeled?.type === 'string_literal' ? unquoteLiteral(labeled.text) : null;
}
}
return null;
}
/** The receiver expression a Kotlin `call_expression` is invoked on. */
function kotlinReceiver(call: Parser.SyntaxNode): Parser.SyntaxNode | null {
const nav = call.namedChild(0);
return nav?.type === 'navigation_expression' ? nav.namedChild(0) : null;
}
/** The call that invokes a method ON `call` as its receiver — one hop up the chain. */
function kotlinChainParentCall(call: Parser.SyntaxNode): Parser.SyntaxNode | null {
const nav = call.parent;
if (nav?.type !== 'navigation_expression' || nav.namedChild(0)?.id !== call.id) return null;
return nav.parent?.type === 'call_expression' ? nav.parent : null;
}
/** Every `call_expression` in the fluent chain `pathCall` belongs to, innermost
* (next to the construction) → outermost. Lets the verb-walk find a verb call
* wherever it sits relative to `.url(...)` — parity with java-static-path.ts
* `builderChainCalls`. */
function kotlinBuilderChainCalls(pathCall: Parser.SyntaxNode): Parser.SyntaxNode[] {
let innermost = pathCall;
let recv = kotlinReceiver(innermost);
while (recv?.type === 'call_expression') {
innermost = recv;
recv = kotlinReceiver(innermost);
}
const calls: Parser.SyntaxNode[] = [innermost];
let cur = innermost;
for (let next = kotlinChainParentCall(cur); next; next = kotlinChainParentCall(cur)) {
calls.push(next);
cur = next;
}
return calls;
}
/** True when `urlCall`'s receiver chain bottoms out on `Request.Builder()` — the
* Kotlin anti-overreach gate (mirror of okHttpUrlRootsAtBuilder), so a `.url(...)`
* on an unrelated object is rejected while a builder call before `.url()` is kept. */
function kotlinUrlRootsAtRequestBuilder(urlCall: Parser.SyntaxNode): boolean {
let cur: Parser.SyntaxNode | null = kotlinReceiver(urlCall);
while (cur?.type === 'call_expression') {
const recv = kotlinReceiver(cur);
if (recv?.type === 'simple_identifier')
return recv.text === 'Request' && kotlinCallName(cur) === 'Builder';
cur = recv;
}
return false;
}
/** Infer the OkHttp verb by scanning the builder chain around the matched
* `.url(...)` call — parity with java-static-path.ts `inferOkHttpMethod`. The
* LAST verb call wins (runtime overwrite); `null` for an unresolvable
* `.method(verb)` (non-literal/empty) so the caller skips; `'GET'` when the
* chain has no verb call. */
function inferKotlinOkHttpMethod(urlCall: Parser.SyntaxNode): string | null {
let lastVerbCall: Parser.SyntaxNode | null = null;
for (const call of kotlinBuilderChainCalls(urlCall)) {
if (call.id === urlCall.id) continue;
const name = kotlinCallName(call);
if (name === 'method' || (name !== null && OK_HTTP_VERB_HELPERS.includes(name)))
lastVerbCall = call;
}
if (lastVerbCall === null) return 'GET'; // no verb call → OkHttp default
const name = kotlinCallName(lastVerbCall);
if (name === 'method') {
const verb = kotlinFirstStringArg(lastVerbCall);
return verb ? verb.toUpperCase() : null;
}
return name === null ? 'GET' : name.toUpperCase();
}
/**
* Build the plugin only if the Kotlin grammar is available. Compiling
* the queries against a null grammar would throw at module load time
@ -423,26 +535,25 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: OkHttp Request.Builder().url("/x") ─────────────────────
// Kotlin parses `Request.Builder()` as a `call_expression` whose
// callee is a `navigation_expression` (Request → .Builder), NOT as
// Java's `object_creation_expression`. The chain `.url("/x")` then
// wraps that in another `call_expression`. The query mirrors Java's
// `OK_HTTP_PATTERNS` (java.ts) but adapts the node types.
// Full parity with the Java OkHttp extraction (java.ts + java-static-path.ts),
// adapted to the Kotlin grammar (a call is a `call_expression` whose callee is a
// `navigation_expression`, not Java's `object_creation_expression`):
//
// Receiver `Request` is constrained by name (#eq? @cls); a project
// that imports OkHttp's `Request` under an alias (`import okhttp3.Request as OkRequest`)
// would not be picked up — this matches the Java plugin's heuristic.
// • Match a bare `.url("literal")` call on ANY receiver (capture it as `@call`);
// `kotlinUrlRootsAtRequestBuilder` (JS) then verifies the receiver chain
// bottoms out on `Request.Builder()` — re-imposing the framework anchor while
// allowing a builder call BEFORE `.url()` (`Request.Builder().addHeader(...)`
// `.url(...)`) and rejecting a `.url(...)` on an unrelated object.
// • `inferKotlinOkHttpMethod` scans the whole chain for the verb (`.post(body)`
// / `.get()` / `.method("X")`, before or after `.url()`) — the mirror of
// `inferOkHttpMethod`. So `Request.Builder().url("/x").post(body).build()`
// becomes `http::POST::/x` on both `.java` and `.kt` (pinned by the Java↔Kotlin
// parity harness). A variable-bound/empty `.method(verb)` is unresolvable →
// the call is skipped (not a guessed GET), matching the Java side.
//
// **Known limitation — verb defaults to GET.** OkHttp encodes the
// verb on a *sibling* call further down the builder chain (e.g.
// `.post(body)` / `.get()` / `.delete()`), not on `.url(...)` itself.
// This query intentionally does not walk the chain to recover the
// verb — it emits `method: 'GET'` for every match, mirroring
// `java.ts:OK_HTTP_PATTERNS`. So a `Request.Builder().url("/x").post(body).build()`
// call becomes `http::GET::/x`, not `http::POST::/x`. This is the
// same trade-off Java has accepted; pinned by an anti-overreach
// test in `http-route-extractor.test.ts` so a future verb-walk
// implementation has to update this comment in lockstep.
// Receiver `Request` is constrained by name (in the JS gate); a project that
// imports OkHttp's `Request` under an alias would not be picked up — matching the
// Java plugin's heuristic.
const OK_HTTP_PATTERNS = compilePatterns({
name: 'kotlin-okhttp',
language,
@ -452,14 +563,9 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
query: `
(call_expression
(navigation_expression
(call_expression
(navigation_expression
(simple_identifier) @cls (#eq? @cls "Request")
(navigation_suffix (simple_identifier) @builder (#eq? @builder "Builder")))
(call_suffix (value_arguments)))
(navigation_suffix (simple_identifier) @method (#eq? @method "url")))
(call_suffix
(value_arguments . (value_argument . (string_literal) @path))))
(value_arguments . (value_argument . (string_literal) @path)))) @call
`,
},
],
@ -890,6 +996,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
method: httpMethod,
path: joinPath(prefix, rawPath),
name: nameNode?.text ?? null,
line: methodNode.startPosition.row + 1,
confidence: FEIGN_CONFIDENCE,
});
}
@ -932,6 +1039,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
method: httpMethod,
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -951,6 +1059,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
method: httpMethod,
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -978,22 +1087,32 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
method: verbText,
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
// ─── Consumers: OkHttp Request.Builder().url("path") ────────────
// Gate to chains rooting at `Request.Builder()` (so a call before `.url()` is
// captured but an unrelated `.url()` is not), then recover the verb by scanning
// the chain (parity with Java); a variable-bound or empty `.method(verb)` is
// unresolvable → emit nothing rather than a GET.
for (const match of runCompiledPatterns(OK_HTTP_PATTERNS, tree)) {
const callNode = match.captures.call;
const pathNode = match.captures.path;
if (!pathNode) continue;
if (!callNode || !pathNode) continue;
if (!kotlinUrlRootsAtRequestBuilder(callNode)) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
const method = inferKotlinOkHttpMethod(callNode);
if (!method) continue;
out.push({
role: 'consumer',
framework: 'okhttp',
method: 'GET',
method,
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -1020,6 +1139,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
method: httpMethod,
path: joinPath(prefix, rawPath),
name: nameNode?.text ?? null,
line: methodNode.startPosition.row + 1,
confidence: EXCHANGE_CONFIDENCE,
});
}

View file

@ -65,7 +65,7 @@ const EXPRESS_SPEC: PatternSpec<Record<string, never>> = {
function: (member_expression
object: (identifier) @obj (#match? @obj "^(router|app)$")
property: (property_identifier) @http_method (#match? @http_method "^(get|post|put|delete|patch)$"))
arguments: (arguments . [(string) (template_string)] @path))
arguments: (arguments . [(string) (template_string)] @path . (_)? @handler))
`,
};
@ -348,6 +348,7 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
method: httpMethod,
path: joinPath(prefix, rawPath),
name,
line: methodNode.startPosition.row + 1,
confidence: 0.8,
});
}
@ -359,12 +360,19 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
if (!methodNode || !pathNode) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
// Capture the handler argument identifier (`router.get('/x', listUsers)`
// → `listUsers`) so a named handler resolves by name. For an inline/anonymous
// handler emit `name: null` (NOT the sentinel `'handler'`) so the resolver
// does NOT match an unrelated function that happens to be named `handler` —
// it uses the registration line for containment instead.
const handlerNode = match.captures.handler;
out.push({
role: 'provider',
framework: 'express',
method: methodNode.text.toUpperCase(),
path,
name: 'handler',
name: handlerNode?.type === 'identifier' ? handlerNode.text : null,
line: (handlerNode ?? pathNode).startPosition.row + 1,
confidence: 0.8,
});
}
@ -385,6 +393,7 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
method: method.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -403,6 +412,7 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
method: 'GET',
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -420,6 +430,7 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
method: methodNode.text.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -437,6 +448,7 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
method: methodNode.text.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -456,6 +468,7 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
method,
path,
name: null,
line: optionsNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -476,6 +489,7 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
method,
path,
name: null,
line: optionsNode.startPosition.row + 1,
confidence: 0.7,
});
}

View file

@ -130,6 +130,17 @@ function isHttpUrlLiteral(path: string): boolean {
export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'php-http',
language: PHP.php_only,
// Laravel `Route::<verb>(...)` definitions are emitted as Route nodes by
// ingestion, so the graph is authoritative for PHP providers (#2138 Part 2).
routeCoverage: 'complete',
// Consumer signals scan() can detect: Laravel `Http::<verb>`, Guzzle client
// `->get/post/.../request(...)`, and `file_get_contents` of an HTTP URL. A
// provider-covered file with any of these must still be parsed (ingestion
// emits no FETCHES for PHP). Conservative — the `->verb(` shape over-matches
// ordinary method calls, which only costs a parse, never data.
hasConsumerSignals(content) {
return /Http::|file_get_contents|->\s*(get|post|put|delete|patch|request)\s*\(/i.test(content);
},
scan(tree) {
const out: HttpDetection[] = [];
@ -161,6 +172,7 @@ export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
method: methodNode.text.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -177,6 +189,7 @@ export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
method: methodNode.text.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -192,6 +205,7 @@ export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
method: 'GET',
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}

View file

@ -920,6 +920,22 @@ function joinPrefix(prefix: string, route: string): string {
export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'python-http',
language: Python,
// routeCoverage intentionally LEFT at the default 'partial' (#2138 Part 2).
// It would be a no-op even if set to 'complete': FastAPI decorator routes set
// no handlerName (generic worker path) and Django sets methodName: null, so no
// Python file ever resolves a handlerSymbolId and none would be parse-skipped.
// Declaring 'complete' now is only a latent trap for the moment a follow-up
// gives FastAPI routes a handlerName. `hasConsumerSignals` is kept (and is a
// true superset of scan()'s consumer shapes) so the precondition already holds
// when Python is later flipped to 'complete'.
// Consumer signals scan() can detect: `requests.<verb>`/`requests.request`,
// `httpx` (sync/async client), the `uri=`/`url=` keyword/variable wrapper
// calls, plus aiohttp/urllib. Conservative — over-matching only costs a parse.
hasConsumerSignals(content) {
return /\brequests\s*\.|\bhttpx\b|\baiohttp\b|\burllib\b|\burlopen\b|\buri\s*=|\burl\s*=/.test(
content,
);
},
prepareRepo({ files, parser, readFile, parseSource }): RepoContext {
return buildPythonRepoContext(files, parser, readFile, parseSource);
},
@ -1005,6 +1021,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
method: methodNode.text.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -1022,6 +1039,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
method: methodNode.text.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -1040,6 +1058,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
method: methodRaw.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -1059,6 +1078,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
method: methodNode.text.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -1079,6 +1099,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
method: methodRaw.toUpperCase(),
path,
name: null,
line: pathNode.startPosition.row + 1,
confidence: 0.7,
});
}
@ -1111,6 +1132,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
method: httpMethod,
path,
name: null,
line: methodNode.startPosition.row + 1,
confidence: 0.65,
});
}
@ -1137,6 +1159,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
method: httpMethod,
path: normalized,
name: null,
line: methodNode.startPosition.row + 1,
confidence: 0.6,
});
}

View file

@ -36,6 +36,15 @@ export interface HttpDetection {
* Null when no good candidate is available.
*/
name: string | null;
/**
* 1-based source line of the call/registration site (the `fetch(...)` for
* consumers, the `router.get(...)` / decorator for providers). Lets the
* extractor resolve the contract to the *containing* symbol (the function
* the call lives in) via line-span containment, so HTTP contracts carry a
* real `symbolUid` instead of an empty one. Optional — a plugin that does
* not set it falls back to file-level boundary resolution downstream.
*/
line?: number;
/** Confidence in (0, 1]. Source-scan plugins typically use 0.7–0.8. */
confidence: number;
}
@ -78,6 +87,43 @@ export interface HttpLanguagePlugin {
name: string;
/** tree-sitter grammar object (passed to the shared parser). */
language: unknown;
/**
* Whether ingestion is known to emit a `Route` graph node for EVERY
* provider route in this language (Spring/FastAPI/Laravel annotations are
* extracted into Route nodes during parse). When `'complete'`, the
* orchestrator may skip the source-scan + tree-sitter parse for a file whose
* graph provider routes all resolved a handler symbol (#2138 Part 2) — the
* graph is authoritative, the scan would only re-discover the same routes.
*
* Defaults to `'partial'` (the safe assumption): the source scan always runs,
* so a language whose ingestion coverage is incomplete never loses routes.
* This is a deliberate, per-language trust assertion — set it only for
* languages whose route ingestion is provably complete.
*/
routeCoverage?: 'complete' | 'partial';
/**
* Cheap, parse-free pre-check used by the parse-skip optimization (#2138
* Part 2). Given a file's raw source text, return `false` ONLY when the file
* provably contains no outbound-HTTP (consumer) call that this plugin's
* `scan()` would detect; return `true` on any doubt.
*
* Why it exists: `routeCoverage: 'complete'` asserts *provider* Route-node
* completeness only. A provider-covered file may ALSO be a consumer (e.g. a
* Spring `@RestController` that calls `restTemplate`/`webClient`, a Laravel
* controller using Guzzle, a FastAPI handler calling `requests`/`httpx`).
* Ingestion's `FETCHES` edges are JS/TS-only, so the graph cannot back up
* those server-side consumers — they come solely from the source scan. The
* orchestrator may therefore skip a provider-covered file's parse only when
* this returns `false`; otherwise the file is still scanned so its consumer
* contracts are not dropped.
*
* MUST be implemented by any plugin whose `scan()` can emit `'consumer'`
* detections AND that declares `routeCoverage: 'complete'`; otherwise that
* language's provider-covered files are never parse-skipped (safe, no win).
* The check is intentionally conservative — over-matching only costs a parse
* that could have been skipped; it never drops data.
*/
hasConsumerSignals?(content: string): boolean;
/**
* Optional pre-pass: walk the relevant files in the repo and produce
* an opaque context that `scan` can use to resolve cross-file facts.

View file

@ -49,6 +49,7 @@ MATCH (handlerFile:File)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)
RETURN handlerFile.id AS fileId, handlerFile.filePath AS filePath,
route.name AS routePath, route.id AS routeId,
route.method AS routeMethod,
route.handlerSymbolId AS handlerSymbolId,
route.responseKeys AS responseKeys,
r.reason AS routeSource`;
const FETCHES_QUERY = `
@ -57,11 +58,91 @@ RETURN callerFile.id AS fileId, callerFile.filePath AS filePath,
route.name AS routePath, route.id AS routeId,
r.reason AS fetchReason`;
const CONTAINS_QUERY = `
MATCH (file:File {id: $fileId})<-[:CodeRelation {type: 'CONTAINS'}]-(sym)
WHERE sym.startLine IS NOT NULL
RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath, labels(sym) AS labels
ORDER BY sym.startLine`;
// Function/Method/CodeElement symbols (with line spans) in a file, addressed by
// repo-relative path so the source-scan paths — which have a path but no graph
// `fileId` — can resolve the symbol CONTAINING an HTTP call by line-span
// containment. Matched by `filePath` rather than a File-[DEFINES]->sym edge so
// it also reaches methods nested in classes (Java/Kotlin), where the File
// defines the class and the class defines the method.
const CONTAINING_QUERY = `
MATCH (sym:Function)
WHERE sym.filePath = $filePath AND sym.startLine IS NOT NULL AND sym.endLine IS NOT NULL
RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath,
sym.startLine AS startLine, sym.endLine AS endLine, labels(sym) AS labels
UNION ALL
MATCH (sym:Method)
WHERE sym.filePath = $filePath AND sym.startLine IS NOT NULL AND sym.endLine IS NOT NULL
RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath,
sym.startLine AS startLine, sym.endLine AS endLine, labels(sym) AS labels
UNION ALL
MATCH (sym:CodeElement)
WHERE sym.filePath = $filePath AND sym.startLine IS NOT NULL AND sym.endLine IS NOT NULL
RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath,
sym.startLine AS startLine, sym.endLine AS endLine, labels(sym) AS labels`;
interface ResolvedSymbol {
uid: string;
name: string;
filePath: string;
}
/**
* The innermost Function/Method whose `[startLine, endLine]` span contains
* `line` — i.e. the symbol the HTTP call lives inside. For a consumer this is
* the function making the `fetch`; for an inline-arrow provider it is the
* handler arrow itself. Returns null when nothing encloses the line (e.g. a
* route registered at module scope referencing a named handler defined
* elsewhere — that case resolves by name instead).
*/
function resolveContainingSymbol(
rows: Record<string, unknown>[],
line: number,
): ResolvedSymbol | null {
const norm = (x: unknown): string => String(x ?? '');
// Detection lines are 1-based; symbol spans are stored 0-based for the
// languages indexed today (parse-worker records `startPosition.row`). So the
// base-correct probe is `line - 1`. Pick the INNERMOST (smallest-span) symbol
// whose span contains the probe. Only if nothing contains `line - 1` do we
// retry with the raw `line` — a defensive fallback for any future language
// that stores 1-based spans. Probing `line - 1` first (rather than OR-ing both)
// avoids the +1 slack mis-picking a one-line sibling that sits on `line`.
const pick = (probe: number): ResolvedSymbol | null => {
let best: ResolvedSymbol | null = null;
let bestSpan = Number.POSITIVE_INFINITY;
for (const r of rows) {
const labels = JSON.stringify(r.labels ?? r[5] ?? '');
if (!['Function', 'Method', 'CodeElement'].some((l) => labels.includes(l))) continue;
const start = Number(r.startLine ?? r[3]);
const end = Number(r.endLine ?? r[4]);
if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
if (probe < start || probe > end) continue;
const span = end - start;
if (span < bestSpan) {
bestSpan = span;
best = {
uid: norm(r.uid ?? r[0]),
name: norm(r.name ?? r[1]),
filePath: norm(r.filePath ?? r[2]),
};
}
}
return best && best.uid ? best : null;
};
return pick(line - 1) ?? pick(line);
}
/** A Function/Method in the file matching `name` exactly (for named handlers). */
function resolveSymbolByName(rows: Record<string, unknown>[], name: string): ResolvedSymbol | null {
const norm = (x: unknown): string => String(x ?? '');
for (const r of rows) {
const labels = JSON.stringify(r.labels ?? r[5] ?? '');
if (!['Function', 'Method', 'CodeElement'].some((l) => labels.includes(l))) continue;
if (norm(r.name ?? r[1]) !== name) continue;
const uid = norm(r.uid ?? r[0]);
if (uid) return { uid, name, filePath: norm(r.filePath ?? r[2]) };
}
return null;
}
// ─── Path normalization (shared between provider / consumer paths) ──
@ -123,35 +204,6 @@ function methodFromRouteReason(reason: string): string | null {
return null;
}
function pickSymbolUid(
rows: Record<string, unknown>[],
preferredName: string | null,
): { uid: string; name: string; filePath: string } {
const norm = (x: unknown) => String(x ?? '');
const labeled = rows.filter((r) => {
const labels = r.labels ?? r[3];
const s = JSON.stringify(labels);
return s.includes('Method') || s.includes('Function');
});
const pool = labeled.length > 0 ? labeled : rows;
if (preferredName) {
const hit = pool.find((r) => norm(r.name ?? r[1]) === preferredName);
if (hit) {
return {
uid: norm(hit.uid ?? hit[0]),
name: norm(hit.name ?? hit[1]),
filePath: norm(hit.filePath ?? hit[2]),
};
}
}
const first = pool[0] || rows[0];
return {
uid: norm(first?.uid ?? first?.[0]),
name: norm(first?.name ?? first?.[1]),
filePath: norm(first?.filePath ?? first?.[2]),
};
}
// ─── Orchestrator ────────────────────────────────────────────────────
export class HttpRouteExtractor implements ContractExtractor {
@ -282,22 +334,105 @@ export class HttpRouteExtractor implements ContractExtractor {
};
const files = await getScannedFiles();
await collectProjectDetections(files);
// Resolve an HTTP detection to the symbol it lives in — the containing
// function for a consumer / inline-arrow provider, or a named handler for
// a provider — addressed by repo-relative file path so the source-scan
// paths (which have no graph `fileId`) can resolve too. Per-file symbol
// lists are cached. Returns null without a DB or when nothing resolves (a
// named provider resolves by name even with no `line`; containment needs
// one); the contract then keeps an empty symbolUid and downstream falls
// back to file-level boundary matching.
const fileSymbolCache = new Map<string, Record<string, unknown>[]>();
const loadFileSymbols = async (filePath: string): Promise<Record<string, unknown>[]> => {
if (!dbExecutor) return [];
const cached = fileSymbolCache.get(filePath);
if (cached) return cached;
let rows: Record<string, unknown>[] = [];
try {
rows = await dbExecutor(CONTAINING_QUERY, { filePath });
} catch {
rows = [];
}
fileSymbolCache.set(filePath, rows);
return rows;
};
const resolveDetectionSymbol = async (
filePath: string,
d: HttpDetection,
): Promise<ResolvedSymbol | null> => {
if (!dbExecutor) return null;
const syms = await loadFileSymbols(filePath);
if (syms.length === 0) return null;
// Name resolution does NOT need a detection line — a named provider
// handler (Spring/Go/etc. method name) resolves by name even when the
// plugin didn't set `line`. Try it FIRST; only the containment fallback
// requires a line.
if (d.role === 'provider' && d.name) {
const byName = resolveSymbolByName(syms, d.name);
if (byName) return byName;
}
if (d.line == null) return null;
return resolveContainingSymbol(syms, d.line);
};
// Run the graph provider pass FIRST. After #2138 Part 2 it reads handler
// symbols from the graph (no source parse for resolved routes), so it can
// report which files are fully graph-covered BEFORE we decide what to
// parse. Files fully covered by a `routeCoverage: 'complete'` language are
// candidates to skip the source scan + tree-sitter parse — but only their
// *providers* are graph-authoritative; the consumer-safety gate below
// removes any candidate that still needs scanning for outbound calls.
const coveredFiles = new Set<string>();
const graphProviders =
dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, getDetections) : [];
// Source scan always runs to capture routes in languages/files not covered
// by graph edges; the glob and per-file parse results are cached above.
dbExecutor != null
? await this.extractProvidersGraph(
dbExecutor,
getDetections,
resolveDetectionSymbol,
coveredFiles,
)
: [];
// Consumer-safety gate (#2138 Part 2): `extractProvidersGraph` marks a file
// covered on *provider* grounds (all HANDLES_ROUTE rows resolved + a
// `routeCoverage: 'complete'` language). But a provider-covered file may also
// be a *consumer* (a controller that calls RestTemplate/WebClient/Guzzle/
// requests/...), and ingestion emits no FETCHES edges for those server-side
// languages — the graph can't back them up. So a covered file is only truly
// safe to skip (parse) when its plugin can PROVE, from a cheap parse-free
// text scan, that it holds no such consumer call. Anything else (a positive
// signal, no `hasConsumerSignals` hook, or an unreadable file) stays in the
// scan set so its consumer contracts are preserved.
for (const f of [...coveredFiles]) {
const plugin = getPluginForFile(f);
const content = readSafe(repoPath, f);
const provenNoConsumer =
content != null && typeof plugin?.hasConsumerSignals === 'function'
? plugin.hasConsumerSignals(content) === false
: false;
if (!provenNoConsumer) coveredFiles.delete(f);
}
// Everything the graph did not fully cover still gets a full source scan
// (fail-open: partial-coverage languages, unresolved routes, and graph-less
// runs all land here).
const scanFiles = files.filter((f) => !coveredFiles.has(f));
await collectProjectDetections(scanFiles);
const providers = this.mergeGraphAndSourceContracts(
graphProviders,
await this.extractProvidersSourceScan(files, getDetections),
await this.extractProvidersSourceScan(scanFiles, getDetections, resolveDetectionSymbol),
);
const graphConsumers =
dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, getDetections) : [];
dbExecutor != null
? await this.extractConsumersGraph(dbExecutor, getDetections, resolveDetectionSymbol)
: [];
const consumers = this.mergeGraphAndSourceContracts(
graphConsumers,
await this.extractConsumersSourceScan(files, getDetections),
await this.extractConsumersSourceScan(scanFiles, getDetections, resolveDetectionSymbol),
);
return [...providers, ...consumers];
@ -323,8 +458,15 @@ export class HttpRouteExtractor implements ContractExtractor {
private async extractProvidersGraph(
db: CypherExecutor,
getDetections: (rel: string) => Promise<HttpDetection[]>,
resolveSymbol: (filePath: string, d: HttpDetection) => Promise<ResolvedSymbol | null>,
coveredFiles?: Set<string>,
): Promise<ExtractedContract[]> {
const out: ExtractedContract[] = [];
// Per-file coverage tracking (#2138 Part 2): a file is "fully graph-covered"
// when every one of its HANDLES_ROUTE rows resolved a handlerSymbolId AND its
// language plugin declares `routeCoverage: 'complete'`. Such files can skip
// the source scan + parse entirely — the graph is authoritative for them.
const fileAllResolved = new Map<string, boolean>();
let rows: Record<string, unknown>[];
try {
rows = await db(HANDLES_ROUTE_QUERY);
@ -354,67 +496,79 @@ export class HttpRouteExtractor implements ContractExtractor {
.toUpperCase();
let method = (graphMethod || null) ?? methodFromRouteReason(routeSource);
// Look up handler name (and backfill method if missing) from the
// plugin's scan of the handler file. This replaces the old
// regex-based `inferMethodFromFileScan` and `pickJavaHandlerName`
// helpers — tree-sitter gives both pieces of information
// structurally. Always run the lookup: even when method is set by
// `methodFromRouteReason`, we still need the handler name.
const detections = filePath ? await getDetections(filePath) : [];
const providerDetections = detections.filter((d) => d.role === 'provider');
let handlerName: string | null = null;
const normalizedRoute = normalizeHttpPath(routePath);
// Candidates share the same normalized path. When multiple
// detections at the same path exist (e.g. GET + POST /api/orders
// in one router), a blind `.find()` silently returned the first
// verb — attaching the wrong handler and, when method was not
// already pinned by the route reason, the wrong method too.
// Disambiguate by method when we know it; refuse to guess when
// we don't.
const candidates = providerDetections.filter(
(d) => normalizeHttpPath(d.path) === normalizedRoute,
);
let match: (typeof candidates)[number] | undefined;
const ambiguousCandidates = !method && candidates.length > 1;
if (method) {
match = candidates.find((d) => d.method === method);
} else if (candidates.length === 1) {
match = candidates[0];
const handlerSymbolId = String(row.handlerSymbolId ?? '').trim();
const fileId = row.fileId ?? row[0];
// Track per-file resolution for the parse-skip coverage set: a file stays
// "all resolved" only while every one of its rows carries a handlerSymbolId.
if (filePath) {
const prev = fileAllResolved.get(filePath);
fileAllResolved.set(filePath, (prev ?? true) && handlerSymbolId.length > 0);
}
// else: multiple candidates + unknown method → leave match
// undefined so handlerName stays null and skip symbol
// enrichment below, keeping the file-basename fallback instead
// of letting pickSymbolUid silently pick the first Function /
// Method in the file (which reintroduces the mis-attribution
// we were trying to avoid). Method stays at the conservative
// 'GET' default set below.
if (match) {
if (!method) method = match.method;
handlerName = match.name;
}
if (!method) method = 'GET';
const pathNorm = normalizeHttpPath(routePath);
const cid = contractIdFor(method, pathNorm);
const pathNormEarly = normalizeHttpPath(routePath);
let symbolUid = '';
let symbolName = path.basename(filePath) || 'handler';
let symPath = filePath;
const fileId = row.fileId ?? row[0];
if (fileId && !ambiguousCandidates) {
try {
const syms = await db(CONTAINS_QUERY, { fileId });
if (syms.length > 0) {
const picked = pickSymbolUid(syms, handlerName);
symbolUid = picked.uid;
symbolName = picked.name;
symPath = picked.filePath || filePath;
if (handlerSymbolId) {
// Fast path (Part 2, #2138): the handler symbol was resolved during
// ingestion and persisted on the Route node, so the uid is authoritative
// and we SKIP the source-scan/parse the legacy path needed. Recover the
// display name from the file's symbols via CONTAINING_QUERY (the correct
// File-[DEFINES]->symbol edge — NOT CONTAINS, which is File->Folder).
if (!method) method = 'GET';
symbolUid = handlerSymbolId;
if (filePath) {
try {
const syms = await db(CONTAINING_QUERY, { filePath });
const hit = syms.find((s) => String(s.uid ?? s[0]) === handlerSymbolId);
if (hit) {
symbolName = String(hit.name ?? hit[1]) || symbolName;
symPath = String(hit.filePath ?? hit[2]) || filePath;
}
} catch {
/* keep the authoritative uid + basename fallback */
}
} catch {
/* ignore */
}
} else {
// Legacy fallback (old index / unresolved handler): recover the handler
// from the plugin's scan and resolve it to a real symbol by name (the
// handler/method name) or, for an inline handler, by line-span containment
// — both over File-[DEFINES]->symbol via resolveSymbol. No CONTAINS /
// pickSymbolUid: CONTAINS is File->Folder and the old first-symbol guess
// could win the contractId merge with a wrong uid.
const detections = filePath ? await getDetections(filePath) : [];
const providerDetections = detections.filter((d) => d.role === 'provider');
// Candidates share the same normalized path. When multiple detections at
// the same path exist (GET + POST /api/orders in one router), a blind
// `.find()` silently returned the first verb — attaching the wrong
// handler/method. Disambiguate by method when known; refuse to guess.
const candidates = providerDetections.filter(
(d) => normalizeHttpPath(d.path) === pathNormEarly,
);
let match: (typeof candidates)[number] | undefined;
const ambiguousCandidates = !method && candidates.length > 1;
if (method) {
match = candidates.find((d) => d.method === method);
} else if (candidates.length === 1) {
match = candidates[0];
}
// else: multiple candidates + unknown method → leave match undefined and
// skip symbol enrichment, keeping the file-basename fallback rather than
// guessing the wrong handler.
if (match && !method) method = match.method;
if (!method) method = 'GET';
const resolved =
match && !ambiguousCandidates ? await resolveSymbol(filePath, match) : null;
if (resolved) {
symbolUid = resolved.uid;
symbolName = resolved.name;
symPath = resolved.filePath || filePath;
}
}
const pathNorm = pathNormEarly;
const cid = contractIdFor(method, pathNorm);
out.push({
contractId: cid,
type: 'http',
@ -432,6 +586,18 @@ export class HttpRouteExtractor implements ContractExtractor {
},
});
}
// Populate the parse-skip coverage set: files whose every provider route
// resolved a handler symbol AND whose language declares complete ingestion
// route coverage. Fail-open — any unresolved row or a 'partial' language
// leaves the file out, so it still gets a full source scan.
if (coveredFiles) {
for (const [fp, allResolved] of fileAllResolved) {
if (allResolved && getPluginForFile(fp)?.routeCoverage === 'complete') {
coveredFiles.add(fp);
}
}
}
return out;
}
@ -440,6 +606,7 @@ export class HttpRouteExtractor implements ContractExtractor {
private async extractProvidersSourceScan(
files: string[],
getDetections: (rel: string) => Promise<HttpDetection[]>,
resolveSymbol: (filePath: string, d: HttpDetection) => Promise<ResolvedSymbol | null>,
): Promise<ExtractedContract[]> {
const out: ExtractedContract[] = [];
for (const rel of files) {
@ -447,19 +614,26 @@ export class HttpRouteExtractor implements ContractExtractor {
for (const d of detections) {
if (d.role !== 'provider') continue;
const pathNorm = normalizeHttpPath(d.path);
// Resolve the handler to a real symbol (named handler, or the inline
// arrow that encloses the registration line) so the contract carries a
// real symbolUid; fall back to the file + detection name otherwise.
const resolved = await resolveSymbol(rel, d);
out.push({
contractId: contractIdFor(d.method, pathNorm),
type: 'http',
role: 'provider',
symbolUid: '',
symbolRef: { filePath: rel, name: d.name ?? 'handler' },
symbolName: d.name ?? 'handler',
symbolUid: resolved?.uid ?? '',
symbolRef: {
filePath: resolved?.filePath || rel,
name: resolved?.name ?? d.name ?? 'handler',
},
symbolName: resolved?.name ?? d.name ?? 'handler',
confidence: d.confidence,
meta: {
method: d.method,
path: pathNorm,
pathSegments: pathNorm.split('/').filter(Boolean),
extractionStrategy: 'source_scan',
extractionStrategy: resolved ? 'source_scan_resolved' : 'source_scan',
framework: d.framework,
},
});
@ -473,6 +647,7 @@ export class HttpRouteExtractor implements ContractExtractor {
private async extractConsumersGraph(
db: CypherExecutor,
getDetections: (rel: string) => Promise<HttpDetection[]>,
resolveSymbol: (filePath: string, d: HttpDetection) => Promise<ResolvedSymbol | null>,
): Promise<ExtractedContract[]> {
const out: ExtractedContract[] = [];
let rows: Record<string, unknown>[];
@ -512,19 +687,19 @@ export class HttpRouteExtractor implements ContractExtractor {
let symbolUid = '';
let symbolName = 'fetch';
let symPath = filePath;
const fileId = row.fileId ?? row[0];
if (fileId) {
try {
const syms = await db(CONTAINS_QUERY, { fileId });
if (syms.length > 0) {
const picked = pickSymbolUid(syms, null);
symbolUid = picked.uid;
symbolName = picked.name;
symPath = picked.filePath || filePath;
}
} catch {
/* ignore */
}
// Resolve the function CONTAINING the fetch by line-span. Do NOT fall back
// to the old `pickSymbolUid(syms, null)` first-symbol-in-file guess: an
// arbitrary wrong uid is worse than an empty one because it would win the
// contractId merge over a correctly-resolved source-scan contract (and the
// empty case degrades to the file-level boundary fallback downstream).
const resolved =
consumerCandidates.length === 1
? await resolveSymbol(filePath, consumerCandidates[0])
: null;
if (resolved) {
symbolUid = resolved.uid;
symbolName = resolved.name;
symPath = resolved.filePath || filePath;
}
out.push({
contractId: cid,
@ -550,6 +725,7 @@ export class HttpRouteExtractor implements ContractExtractor {
private async extractConsumersSourceScan(
files: string[],
getDetections: (rel: string) => Promise<HttpDetection[]>,
resolveSymbol: (filePath: string, d: HttpDetection) => Promise<ResolvedSymbol | null>,
): Promise<ExtractedContract[]> {
const out: ExtractedContract[] = [];
for (const rel of files) {
@ -557,18 +733,22 @@ export class HttpRouteExtractor implements ContractExtractor {
for (const d of detections) {
if (d.role !== 'consumer') continue;
const pathNorm = normalizeConsumerPath(d.path);
// Resolve the function CONTAINING the fetch/axios call so the consumer
// contract carries a real symbolUid (was always '' — the gap that left
// cross-repo trace/impact unable to traverse HTTP links).
const resolved = await resolveSymbol(rel, d);
out.push({
contractId: contractIdFor(d.method, pathNorm),
type: 'http',
role: 'consumer',
symbolUid: '',
symbolRef: { filePath: rel, name: 'fetch' },
symbolName: 'fetch',
symbolUid: resolved?.uid ?? '',
symbolRef: { filePath: resolved?.filePath || rel, name: resolved?.name ?? 'fetch' },
symbolName: resolved?.name ?? 'fetch',
confidence: d.confidence,
meta: {
method: d.method,
path: pathNorm,
extractionStrategy: 'source_scan',
extractionStrategy: resolved ? 'source_scan_resolved' : 'source_scan',
framework: d.framework,
},
});

View file

@ -90,6 +90,79 @@ export interface GroupToolPort {
include_content?: boolean;
},
): Promise<unknown>;
// ── Cross-repo trace support (optional on the port) ────────────────
// These are optional so existing GroupToolPort test mocks (which predate
// the trace path and only stub impact/query/context/impactByUid) keep
// type-checking. The real LocalBackend port supplies all three; runGroupTrace
// guards on their presence and degrades to a clear error/note when absent.
//
// Single-repo directed-path trace over CALLS + HAS_METHOD. Returns the same
// shape as the `trace` MCP tool (`{ status, from, to, hopCount, hops, edges }`).
trace?(
repo: GroupRepoHandle,
params: {
from?: string;
to?: string;
from_uid?: string;
to_uid?: string;
from_file?: string;
to_file?: string;
maxDepth?: number;
includeTests?: boolean;
},
): Promise<unknown>;
// Resolve a symbol within one repo to its node id (== bridge symbolUid) and
// location, or report ambiguity / absence. Wraps the same resolver the
// context()/trace() tools use.
resolveSymbol?(
repo: GroupRepoHandle,
query: { name?: string; uid?: string; file_path?: string },
): Promise<GroupSymbolResolution>;
// Intra-procedural REACHING_DEF data-flow from an anchor symbol, used to
// enrich a boundary-adjacent trace segment. `available:false` signals the
// repo has no PDG `flows` layer (degraded, not an error).
pdgFlows?(
repo: GroupRepoHandle,
anchor: { name?: string; uid?: string; file_path?: string },
opts: { limit?: number },
): Promise<GroupPdgFlowResult>;
}
export type GroupSymbolResolution =
| {
kind: 'ok';
symbol: {
id: string;
name: string;
type: string;
filePath: string;
startLine: number;
endLine: number;
};
}
| {
kind: 'ambiguous';
candidates: Array<{
id: string;
name: string;
type: string;
filePath: string;
startLine: number;
}>;
}
| { kind: 'not_found' };
export interface GroupPdgFlowHop {
line: number;
text: string;
variable?: string;
}
export interface GroupPdgFlowResult {
available: boolean;
variable?: string;
hops: GroupPdgFlowHop[];
truncated?: boolean;
}
function isStoredContract(raw: unknown): raw is StoredContract {
@ -313,6 +386,11 @@ export class GroupService {
return runGroupImpact({ port: this.port, gitnexusDir: getDefaultGitnexusDir() }, params);
}
async groupTrace(params: Record<string, unknown>): Promise<unknown> {
const { runGroupTrace } = await import('./cross-trace.js');
return runGroupTrace({ port: this.port, gitnexusDir: getDefaultGitnexusDir() }, params);
}
async groupContext(params: Record<string, unknown>): Promise<GroupContextResult> {
const name = String(params.name ?? '').trim();
const target = typeof params.target === 'string' ? params.target.trim() : '';

View file

@ -192,6 +192,13 @@ export interface BridgeHandle {
readonly _db: unknown;
readonly _conn: unknown;
readonly groupDir: string;
/**
* True when the handle was opened read-only. `closeBridgeDb` must NOT issue a
* CHECKPOINT on a read-only connection — doing so leaves a WAL/shadow lock
* artifact that makes the next read-only open of the same file fail in-process
* (repeated `@group` impact/trace calls in a long-lived server).
*/
readonly _readOnly?: boolean;
}
export interface BridgeMeta {

View file

@ -21,7 +21,9 @@ import { generateId } from '../../lib/utils.js';
import type { SymbolDefinition } from 'gitnexus-shared';
import { yieldToEventLoop } from './utils/event-loop.js';
import type { ExtractedRoute, ExtractedFetchCall } from './workers/parse-worker.js';
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js';
import { normalizeExtractedRoutePath } from './route-extractors/route-path.js';
import { extractReturnTypeName } from './type-extractors/shared.js';
const MAX_EXPORTS_PER_FILE = 500;
@ -243,6 +245,83 @@ export const processRoutesFromExtracted = async (
onProgress?.(extractedRoutes.length, extractedRoutes.length);
};
/**
* Resolve each route's handler to a real symbol UID, keyed by the normalized
* route URL (the same key the routes phase uses for the `Route` node). This is
* the Part 2 (#2138) groundwork that lets `HttpRouteExtractor.extractProvidersGraph`
* read the handler symbol from the graph instead of re-parsing source via
* `getDetections()`.
*
* Two route shapes, one resolution target — `(filePath, name) → nodeId`:
* - Laravel framework routes (`ExtractedRoute`) carry `controllerName` +
* `methodName`; resolve the controller (qualified-first) then the method in
* the controller's own file (mirrors `processRoutesFromExtracted`).
* - Decorator routes (`ExtractedDecoratorRoute`, e.g. Spring/FastAPI) carry
* `handlerName` (the decorated method, captured at extraction); resolve it
* directly in the route's own file.
*
* First-writer-wins per URL, matching the routes phase's dedup (it keeps the
* first route registered for a URL and counts the rest as duplicates). The first
* route to claim a URL reserves it **even when its handler is unresolvable**, so
* a later same-URL route can never stamp its handler onto the first route's Route
* node (the routes phase made that first route the node-winner). Routes whose
* handler cannot be *uniquely* resolved (no name, zero matches, or an ambiguous
* same-name match) carry no `handlerSymbolId`; the extractor then falls back to
* source scan for that route (fail-open, no regression, never a wrong handler).
*/
export function resolveRouteHandlerSymbols(
model: SemanticModel,
extractedRoutes: readonly ExtractedRoute[],
decoratorRoutes: readonly ExtractedDecoratorRoute[],
): Map<string, string> {
const out = new Map<string, string>();
// URLs already claimed by an earlier route (resolved or not). Mirrors the
// routes phase `addRoute` first-writer-wins so the handler we stamp always
// belongs to the route that actually won the Route node.
const claimed = new Set<string>();
// Resolve a single same-file symbol by name, refusing to guess on ambiguity:
// exactly one match → its nodeId; zero or many → undefined (fail-open).
const uniqueSymbolId = (filePath: string, name: string): string | undefined => {
const defs = model.symbols.lookupExactAll(filePath, name);
return defs.length === 1 ? defs[0]?.nodeId : undefined;
};
const claim = (routePath: string | null, prefix: string | null, symbolId: string | undefined) => {
if (!routePath) return;
const url = normalizeExtractedRoutePath(routePath, prefix);
if (claimed.has(url)) return; // first-writer-wins: later same-URL routes can't override
claimed.add(url);
if (symbolId) out.set(url, symbolId);
};
// Laravel framework routes — controller class + method name.
for (const route of extractedRoutes) {
let methodId: string | undefined;
if (route.controllerName && route.methodName) {
let controllerDef: SymbolDefinition | undefined;
if (route.controllerQualifiedName) {
controllerDef = resolveControllerByQualifiedName(model, route.controllerQualifiedName);
}
if (!controllerDef) {
const controllerDefs = model.types.lookupClassByName(route.controllerName);
if (controllerDefs.length === 1) controllerDef = controllerDefs[0];
}
if (controllerDef) methodId = uniqueSymbolId(controllerDef.filePath, route.methodName);
}
claim(route.routePath, route.prefix ?? null, methodId);
}
// Decorator routes (Spring / FastAPI / generic) — the decorated handler in
// the route's own file.
for (const dr of decoratorRoutes) {
const handlerId = dr.handlerName ? uniqueSymbolId(dr.filePath, dr.handlerName) : undefined;
claim(dr.routePath, dr.prefix ?? null, handlerId);
}
return out;
}
/** Common method names on response/data objects that are NOT property accesses */
// Properties/methods to ignore when extracting consumer accessed keys from `data.X` patterns.
// Avoids false positives from Fetch API, Array, Object, Promise, and DOM access on variables

View file

@ -30,6 +30,7 @@ export function interpretJavaImport(captures: CaptureMatch): ParsedImport | null
localName: nameCap?.text ?? simpleName,
importedName: simpleName,
targetRaw: sourceCap.text,
targetIncludesImportedName: true,
};
}
case 'wildcard': {

View file

@ -40,6 +40,7 @@ import { DEFAULT_PDG_MAX_FUNCTION_LINES } from '../cfg/collect.js';
import type { WorkerExtractedData } from '../parsing-processor.js';
import {
processRoutesFromExtracted,
resolveRouteHandlerSymbols,
buildExportedTypeMapFromGraph,
type ExportedTypeMap,
} from '../call-processor.js';
@ -370,6 +371,10 @@ export async function runChunkedParseAndResolve(
allToolDefs: ExtractedToolDef[];
allORMQueries: ExtractedORMQuery[];
bindingAccumulator: BindingAccumulator;
/** Route URL → resolved handler symbol UID (Part 2, #2138). Lets the routes
* phase stamp `handlerSymbolId` on Route nodes so contract extraction can
* read the handler from the graph instead of re-parsing source. */
routeHandlerSymbols: ReadonlyMap<string, string>;
/** SemanticModel populated during parse — scope-resolution reads its
* TypeRegistry / MethodRegistry / SymbolTable indexes. */
model: MutableSemanticModel;
@ -1282,6 +1287,13 @@ export async function runChunkedParseAndResolve(
'parse-impl-return',
`exportedTypeMap=${exportedTypeMap.size} parsedFiles=${allParsedFiles.length} nodes=${graph.nodeCount}`,
);
// Part 2 (#2138): resolve each route's handler to a real symbol UID now that
// the model is fully populated and decorator-route prefixes are finalized.
const routeHandlerSymbols = resolveRouteHandlerSymbols(
model,
allExtractedRoutes,
allDecoratorRoutes,
);
return {
exportedTypeMap,
allFetchCalls,
@ -1291,6 +1303,7 @@ export async function runChunkedParseAndResolve(
allToolDefs,
allORMQueries,
bindingAccumulator,
routeHandlerSymbols,
model,
// Whether a worker pool was actually constructed for this run. False means
// no pool was needed: a warm all-cache-hit run replays cached worker output

View file

@ -51,6 +51,9 @@ export interface ParseOutput {
readonly allDecoratorRoutes: readonly ExtractedDecoratorRoute[];
readonly allToolDefs: readonly ExtractedToolDef[];
readonly allORMQueries: readonly ExtractedORMQuery[];
/** Route URL → resolved handler symbol UID (Part 2, #2138). Consumed by the
* routes phase to stamp `handlerSymbolId` on Route nodes. */
readonly routeHandlerSymbols: ReadonlyMap<string, string>;
bindingAccumulator: BindingAccumulator;
/** SemanticModel populated during parse — scope-resolution reads its
* TypeRegistry / MethodRegistry / SymbolTable indexes. */

View file

@ -29,6 +29,7 @@ import {
compiledMatcherMatchesRoute,
} from '../route-extractors/middleware.js';
import { processNextjsFetchRoutes } from '../call-processor.js';
import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
import { generateId } from '../../../lib/utils.js';
import { readFileContents } from '../filesystem-walker.js';
import { isDev } from '../utils/env.js';
@ -133,17 +134,13 @@ export function extractTemplateStaticFetchCalls(
return calls;
}
export function normalizeExtractedRoutePath(routePath: string, prefix: string | null): string {
const pathPart = routePath.trim().replace(/^\/+/, '').replace(/\/+$/g, '');
const prefixPart = prefix?.trim().replace(/^\/+/, '').replace(/\/+$/g, '');
const joined = prefixPart ? `/${prefixPart}${pathPart ? `/${pathPart}` : ''}` : `/${pathPart}`;
return joined.replace(/\/+/g, '/') || '/';
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Re-exported for existing consumers/tests that import it from the routes phase.
export { normalizeExtractedRoutePath };
/**
* Canonicalize a route's HTTP verb for persistence on the Route node.
* Returns an upper-cased standard method, or `undefined` when the value
@ -189,6 +186,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
allFetchWrapperDefs,
allExtractedRoutes,
allDecoratorRoutes,
routeHandlerSymbols,
} = getPhaseOutput<ParseOutput>(deps, 'parse');
// Local copy — routes phase must not mutate upstream ParseOutput
@ -287,6 +285,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
const middleware = mwResult?.chain;
const routeNodeId = generateId('Route', routeURL);
const handlerSymbolId = routeHandlerSymbols.get(routeURL);
ctx.graph.addNode({
id: routeNodeId,
label: 'Route',
@ -294,6 +293,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
name: routeURL,
filePath: handlerPath,
...(routeMethod ? { method: routeMethod } : {}),
...(handlerSymbolId ? { handlerSymbolId } : {}),
...(responseKeys ? { responseKeys } : {}),
...(errorKeys ? { errorKeys } : {}),
...(middleware && middleware.length > 0 ? { middleware } : {}),

View file

@ -0,0 +1,21 @@
/**
* Shared route-path normalization.
*
* Extracted from the routes phase so both the routes phase (which creates the
* `Route` graph node, keyed by the normalized URL) and the parse phase (which
* resolves each route's handler symbol and needs the SAME key to associate the
* resolved id back to the route) can compute an identical route URL without a
* phase-to-phase import cycle. Pure string logic, no dependencies.
*/
/**
* Join a route's path with its (optional) prefix into a normalized,
* leading-slash URL used as the Route node identity. Collapses duplicate
* slashes and strips trailing ones; an empty result degrades to `/`.
*/
export function normalizeExtractedRoutePath(routePath: string, prefix: string | null): string {
const pathPart = routePath.trim().replace(/^\/+/, '').replace(/\/+$/g, '');
const prefixPart = prefix?.trim().replace(/^\/+/, '').replace(/\/+$/g, '');
const joined = prefixPart ? `/${prefixPart}${pathPart ? `/${pathPart}` : ''}` : `/${pathPart}`;
return joined.replace(/\/+/g, '/') || '/';
}

View file

@ -139,6 +139,9 @@ export function extractSpringRoutes(
if (routePath === null) continue;
const enclosingClass = findEnclosingClass(node);
const classPrefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : '';
// `node` is the annotated `method_declaration`; its name field is the
// handler method name (resolved to a symbol UID later by the routes phase).
const handlerName = node.childForFieldName('name')?.text;
routes.push({
filePath,
@ -147,6 +150,7 @@ export function extractSpringRoutes(
decoratorName: ann,
lineNumber: annNode.startPosition.row + lineOffset,
...(classPrefix ? { prefix: classPrefix } : {}),
...(handlerName ? { handlerName } : {}),
});
}

View file

@ -232,7 +232,6 @@ export function emitFileTaint(
const b = bindings[idx];
return b === undefined ? `#${idx}` : bindingKey(b);
};
// SANITIZES — one edge per kill, REGARDLESS of findings (kills can and do
// exist with zero findings: a fully-sanitized flow IS the kill evidence).
for (const kill of flows.kills) {
@ -263,13 +262,27 @@ export function emitFileTaint(
// `exec(req.body, req.query)`'s two findings; `property` is free-text
// (string-literal subscripts) and rides LAST so it cannot collide into
// another component.
const id = generateId(
'TAINTED',
`${fnAnchor}:${finding.sinkKind}:` +
`${pointKey(source.point)}.${source.siteIndex}:${bKey(source.objectBindingIdx)}:` +
`${pointKey(sink.point)}.${sink.siteIndex}.${sink.argIndex}:${bKey(sink.bindingIdx)}:` +
`${sink.entryName}:${source.property}`,
);
const id =
source.type === 'member-read'
? generateId(
'TAINTED',
`${fnAnchor}:${finding.sinkKind}:` +
`${pointKey(source.point)}.${source.siteIndex}:${bKey(source.objectBindingIdx)}:` +
`${pointKey(sink.point)}.${sink.siteIndex}.${sink.argIndex}:${bKey(
sink.bindingIdx,
)}:` +
`${sink.entryName}:${source.property}`,
)
: generateId(
'TAINTED',
`${fnAnchor}:${finding.sinkKind}:` +
`${pointKey(source.point)}.${source.siteIndex}:call-result:` +
`${bKey(source.resultBindingIdx)}:${source.calleeName}:` +
`${pointKey(sink.point)}.${sink.siteIndex}.${sink.argIndex}:${bKey(
sink.bindingIdx,
)}:` +
`${sink.entryName}`,
);
if (seenEdgeIds.has(id)) continue;
seenEdgeIds.add(id);
// `kind` rides the reason's `;<kind>` header — the only persisted

View file

@ -0,0 +1,24 @@
/**
* Built-in Java taint model (#2261 first slice).
*
* This deliberately starts small. Servlet request input is modeled only when a
* conventional request receiver's call result is assigned to a binding. Sinks
* are limited to static-import-proven JDK filesystem operations that current
* harvested call-site/import data can identify without broad same-name matching.
* No sanitizers are registered in this slice.
*/
import type { SourceSinkSanitizerSpec } from './source-sink-config.js';
export const JAVA_TAINT_MODEL: SourceSinkSanitizerSpec = {
sources: [
{
type: 'call-result',
kind: 'remote-input',
receivers: ['request', 'req'],
methods: ['getParameter', 'getHeader'],
},
],
sinks: [{ name: 'readString', kind: 'path-traversal', args: [0], module: 'java.nio.file.Files' }],
sanitizers: [],
};

View file

@ -76,8 +76,10 @@
import type { ParsedImport } from 'gitnexus-shared';
import type { FunctionCfg, SiteRecord } from '../cfg/types.js';
import type {
TaintCallResultSourceEntry,
SourceSinkSanitizerSpec,
TaintMemberSourceEntry,
TaintSourceEntry,
TaintSanitizerEntry,
TaintSinkEntry,
} from './source-sink-config.js';
@ -92,6 +94,12 @@ export interface TaintImportBinding {
* CJS interop makes the default export ≈ the module object).
*/
readonly member?: string;
/**
* True when the provider says `module` already includes `member`; used for
* class-like imports where a receiver call should resolve as
* `<module>.<method>`, not `<module>.<member>.<method>`.
*/
readonly targetIncludesMember?: boolean;
}
/** Local name → import provenance for one file. Build once per file (U4). */
@ -99,11 +107,24 @@ export type TaintImportIndex = ReadonlyMap<string, TaintImportBinding>;
/** A member-read site matched as a taint source. */
export interface MatchedSourceRead {
readonly type: 'member-read';
/** Index into the owning statement's `sites` array. */
readonly siteIndex: number;
readonly entry: TaintMemberSourceEntry;
}
/** A call-result source matched on a call site with direct result definitions. */
export interface MatchedSourceCall {
readonly type: 'call-result';
/** Index into the owning statement's `sites` array. */
readonly siteIndex: number;
readonly entry: TaintCallResultSourceEntry;
/** Bindings directly defined by this call result. Never empty. */
readonly resultDefs: readonly number[];
}
export type MatchedSource = MatchedSourceRead | MatchedSourceCall;
/** A call/new site matched as a sink. */
export interface MatchedSinkCall {
/** Index into the owning statement's `sites` array. */
@ -141,7 +162,7 @@ export interface StatementMatches {
readonly blockIndex: number;
readonly statementIndex: number;
readonly line: number;
readonly sources: readonly MatchedSourceRead[];
readonly sources: readonly MatchedSource[];
readonly sinks: readonly MatchedSinkCall[];
readonly sanitizers: readonly MatchedSanitizerCall[];
}
@ -157,6 +178,9 @@ export interface FunctionSiteMatches {
const stripNodeScheme = (specifier: string): string =>
specifier.startsWith('node:') ? specifier.slice('node:'.length) : specifier;
const isCallResultSource = (entry: TaintSourceEntry): entry is TaintCallResultSourceEntry =>
entry.type === 'call-result';
/**
* Build the local-name → module/member index from a file's `parsedImports`.
* Only `named`/`alias`/`namespace` kinds bind matcher-visible local names;
@ -169,7 +193,13 @@ export function buildTaintImportIndex(imports: readonly ParsedImport[]): TaintIm
const module = stripNodeScheme(imp.targetRaw);
index.set(
imp.localName,
imp.importedName === 'default' ? { module } : { module, member: imp.importedName },
imp.importedName === 'default'
? { module }
: {
module,
member: imp.importedName,
...(imp.targetIncludesImportedName === true ? { targetIncludesMember: true } : {}),
},
);
} else if (imp.kind === 'namespace') {
index.set(imp.localName, { module: stripNodeScheme(imp.targetRaw) });
@ -239,6 +269,10 @@ export function matchFunctionSites(
const rest = path.slice(1);
const canonical: string[] = [];
let globalRoot = false;
const canonicalBase = (imp: TaintImportBinding): string[] =>
imp.member === undefined || imp.targetIncludesMember === true
? [imp.module]
: [imp.module, imp.member];
if (site.receiver !== undefined) {
// Member chain with an identifier root — origin known by binding index.
@ -246,8 +280,7 @@ export function matchFunctionSites(
if (rb.synthetic === true) {
const imp = imports.get(rb.name);
if (imp !== undefined) {
const base = imp.member === undefined ? [imp.module] : [imp.module, imp.member];
canonical.push([...base, ...rest].join('.'));
canonical.push([...canonicalBase(imp), ...rest].join('.'));
}
} else {
const module = requireByBinding.get(site.receiver);
@ -268,7 +301,11 @@ export function matchFunctionSites(
const imp = imports.get(root);
if (imp !== undefined) {
canonical.push(
imp.member === undefined ? `${imp.module}.default` : `${imp.module}.${imp.member}`,
imp.member === undefined
? `${imp.module}.default`
: imp.targetIncludesMember === true
? imp.module
: `${imp.module}.${imp.member}`,
);
} else {
globalRoot = true;
@ -330,7 +367,7 @@ export function matchFunctionSites(
block.statements?.forEach((stmt, statementIndex) => {
const sites = stmt.sites;
if (sites === undefined || sites.length === 0) return;
const sources: MatchedSourceRead[] = [];
const sources: MatchedSource[] = [];
const sinks: MatchedSinkCall[] = [];
const sanitizers: MatchedSanitizerCall[] = [];
@ -340,8 +377,9 @@ export function matchFunctionSites(
const objectName = bindings[site.object].name;
const property = site.property;
for (const entry of spec.sources) {
if (isCallResultSource(entry)) continue;
if (entry.objects.includes(objectName) && entry.properties.includes(property)) {
sources.push({ siteIndex, entry });
sources.push({ type: 'member-read', siteIndex, entry });
}
}
return;
@ -349,6 +387,26 @@ export function matchFunctionSites(
// call / new
const resolved = resolveCallee(site);
if (resolved === undefined) return;
if (site.kind === 'call') {
const resultDefs = site.resultDefs;
if (resultDefs !== undefined && resultDefs.length > 0) {
for (const entry of spec.sources) {
if (!isCallResultSource(entry)) continue;
if (
resolved.path.length === 2 &&
entry.receivers.includes(resolved.path[0]) &&
entry.methods.includes(resolved.path[1])
) {
sources.push({
type: 'call-result',
siteIndex,
entry,
resultDefs,
});
}
}
}
}
for (const entry of spec.sinks) {
if (!sinkMechanismHit(entry, site, resolved)) continue;
const argPositions: number[] = [];

View file

@ -157,19 +157,33 @@ export interface TaintHop {
}
/**
* The KTD6 rule-(b) source identity material: the matched member-read
* occurrence itself — statement point + site index + object/property. For
* worklist findings this is the ROOT source the taint chain was seeded from.
* The source identity material for a finding: either the matched member-read
* occurrence itself (statement point + site index + object/property) or an
* assigned call-result source. For worklist findings this is the ROOT source
* the taint chain was seeded from.
*/
export interface TaintSourceOccurrence {
interface BaseSourceOccurrence {
readonly point: ProgramPoint;
/** Index into the source statement's `sites` array. */
readonly siteIndex: number;
readonly objectBindingIdx: number;
readonly property: string;
readonly type: 'member-read' | 'call-result';
readonly kind: SourceKind;
}
interface MemberReadSourceOccurrence extends BaseSourceOccurrence {
readonly type: 'member-read';
readonly objectBindingIdx: number;
readonly property: string;
}
interface CallResultSourceOccurrence extends BaseSourceOccurrence {
readonly type: 'call-result';
readonly resultBindingIdx: number;
readonly calleeName: string;
}
export type TaintSourceOccurrence = MemberReadSourceOccurrence | CallResultSourceOccurrence;
/** The sink side of a finding's identity: point + site + argument + binding. */
export interface TaintSinkOccurrence {
readonly point: ProgramPoint;
@ -514,18 +528,33 @@ export function computeTaintFlows(
sinkKind: SinkKind,
source: TaintSourceOccurrence,
sink: Pick<TaintSinkOccurrence, 'point' | 'siteIndex' | 'argIndex' | 'bindingIdx'>,
): string =>
[
): string => {
if (source.type === 'member-read') {
return [
sinkKind,
pointKey(source.point),
source.siteIndex,
source.objectBindingIdx,
source.property,
pointKey(sink.point),
sink.siteIndex,
sink.argIndex,
sink.bindingIdx,
].join('|');
}
return [
sinkKind,
pointKey(source.point),
source.siteIndex,
source.objectBindingIdx,
source.property,
source.type,
source.resultBindingIdx,
source.calleeName,
pointKey(sink.point),
sink.siteIndex,
sink.argIndex,
sink.bindingIdx,
].join('|');
};
const recordFinding = (
sinkKind: SinkKind,
@ -704,11 +733,29 @@ export function computeTaintFlows(
const ctx = contextAt(sm.blockIndex, sm.statementIndex);
if (!ctx) continue;
for (const src of sm.sources) {
if (src.type === 'call-result') {
const srcSite = ctx.sites[src.siteIndex];
if (srcSite?.callee === undefined) continue;
const calleeName = srcSite.callee;
for (const d of src.resultDefs) {
const sourceOcc: CallResultSourceOccurrence = {
point: ctx.point,
siteIndex: src.siteIndex,
type: 'call-result',
resultBindingIdx: d,
calleeName,
kind: src.entry.kind,
};
deriveTaint(d, ctx.point, EMPTY_KINDS, undefined, sourceOcc, false);
}
continue;
}
const srcSite = ctx.sites[src.siteIndex];
if (srcSite?.object === undefined || srcSite.property === undefined) continue;
const sourceOcc: TaintSourceOccurrence = {
const sourceOcc: MemberReadSourceOccurrence = {
point: ctx.point,
siteIndex: src.siteIndex,
type: 'member-read',
objectBindingIdx: srcSite.object,
property: srcSite.property,
kind: src.entry.kind,

View file

@ -97,23 +97,41 @@ export interface TaintSanitizerEntry {
* the property is one of `properties` (`body`, `query`, …). Matching is
* name-based on the harvested `member-read` site (Semgrep-convention, not
* type-aware — the accepted M3 FP/FN trade recorded in the plan's risk
* table). One entry fans out over the objects × properties product.
* table). One entry fans out over the objects × properties product. `type`
* remains optional so existing/custom model objects that predate the
* discriminant continue to load as member-read sources.
*/
export interface TaintMemberSourceEntry {
readonly type?: 'member-read';
readonly kind: SourceKind;
readonly objects: readonly string[];
readonly properties: readonly string[];
}
/**
* A call-result taint source: the result of `<receiver>.<method>(...)` becomes
* tainted, but only when the call site records direct `resultDefs`. This keeps
* source seeding tied to proven data-flow destinations instead of treating an
* arbitrary nested call expression as a value occurrence.
*/
export interface TaintCallResultSourceEntry {
readonly type: 'call-result';
readonly kind: SourceKind;
readonly receivers: readonly string[];
readonly methods: readonly string[];
}
export type TaintSourceEntry = TaintMemberSourceEntry | TaintCallResultSourceEntry;
/**
* The taint configuration for a single language: which member reads introduce
* taint (sources), which callables are dangerous to reach with tainted input
* (sinks), and which callables clear it (sanitizers). M3 sources are
* member-read entries only; call-result sources are a forward extension
* (add a union variant), not a missing case.
* member-read entries for JS/TS/Python and call-result entries for languages
* whose request APIs return tainted values from calls.
*/
export interface SourceSinkSanitizerSpec {
readonly sources: readonly TaintMemberSourceEntry[];
readonly sources: readonly TaintSourceEntry[];
readonly sinks: readonly TaintSinkEntry[];
readonly sanitizers: readonly TaintSanitizerEntry[];
}

View file

@ -297,18 +297,27 @@ export function harvestFunctionSummary(
stmtIndex: sm.statementIndex,
line: facts.line,
};
const memberSources = sm.sources.filter((src) => src.type === 'member-read');
if (returnUseStmtKeys.has(stmtKey)) {
for (const src of sm.sources) sourceReturn.add(src.entry.kind);
for (const src of memberSources) sourceReturn.add(src.entry.kind);
}
for (const d of [...facts.defs, ...(facts.mayDefs ?? [])]) {
enqueue({ bindingIdx: d, point, seedId: -1, exclusions: new Set() });
if (memberSources.length > 0) {
for (const d of [...facts.defs, ...(facts.mayDefs ?? [])]) {
enqueue({ bindingIdx: d, point, seedId: -1, exclusions: new Set() });
}
}
for (const src of sm.sources) {
if (src.type !== 'call-result') continue;
for (const d of src.resultDefs) {
enqueue({ bindingIdx: d, point, seedId: -1, exclusions: new Set() });
}
}
// DIRECT source-in-call-arg (`runIt(req.body)`): no intermediate binding is
// defined, so the floor seed above records nothing. Climb the source
// member-read's `parent` chain — each enclosing call/new site is a
// `sourceToCallArg` (the cross-function fixpoint seed). A sink ancestor is
// M3's intra-procedural concern and harmless to also record here.
for (const src of sm.sources) {
for (const src of memberSources) {
let cur: SiteRecord | undefined = facts.sites?.[src.siteIndex];
const guard = new Set<number>([src.siteIndex]);
while (cur?.parent) {

View file

@ -1,8 +1,8 @@
/**
* Built-in TS/JS taint model (#2083 M3 U2, plan KTD7).
*
* The canonical Express/Node source/sink/sanitizer set, registered for the
* `typescript` and `javascript` language ids via the EXPLICIT
* The canonical Express/Node source/sink/sanitizer set plus the Java and
* Python models, registered for their language ids via the EXPLICIT
* {@link registerBuiltinTaintModels} seam — deliberately not an import
* side-effect, so the U4 emit path controls WHEN registration happens (call
* it once before the pdg window runs; it is idempotent — the registry is
@ -18,6 +18,7 @@
import { createHash } from 'node:crypto';
import { SupportedLanguages } from 'gitnexus-shared';
import type { SourceSinkSanitizerSpec } from './source-sink-config.js';
import { JAVA_TAINT_MODEL } from './java-model.js';
import { PYTHON_TAINT_MODEL } from './python-model.js';
import { registerSourceSinkConfig } from './source-sink-registry.js';
@ -99,6 +100,7 @@ function canonicalJson(value: unknown): string {
}
export const BUILTIN_TAINT_MODELS = {
[SupportedLanguages.Java]: JAVA_TAINT_MODEL,
[SupportedLanguages.JavaScript]: TS_JS_TAINT_MODEL,
[SupportedLanguages.Python]: PYTHON_TAINT_MODEL,
[SupportedLanguages.TypeScript]: TS_JS_TAINT_MODEL,
@ -111,12 +113,13 @@ export const BUILTIN_TAINT_MODELS = {
export const taintModelVersion: string = computeModelDigest(BUILTIN_TAINT_MODELS);
/**
* Register the built-in models for TypeScript, JavaScript, and Python.
* Register the built-in models for Java, TypeScript, JavaScript, and Python.
* Explicit init seam for the U4 emit path (call before the pdg window
* consumes the registry); idempotent. Other language ids remain unregistered
* until they have a dedicated model.
*/
export function registerBuiltinTaintModels(): void {
registerSourceSinkConfig(SupportedLanguages.Java, JAVA_TAINT_MODEL);
registerSourceSinkConfig(SupportedLanguages.TypeScript, TS_JS_TAINT_MODEL);
registerSourceSinkConfig(SupportedLanguages.JavaScript, TS_JS_TAINT_MODEL);
registerSourceSinkConfig(SupportedLanguages.Python, PYTHON_TAINT_MODEL);

View file

@ -317,6 +317,16 @@ export interface ExtractedDecoratorRoute {
* absent ⇒ no prefix applies.
*/
prefix?: string | null;
/**
* Name of the handler the route decorator sits on (the decorated
* method/function — e.g. `create` for `@PostMapping("/orders") Order create()`).
* Captured at extraction where the decorated definition node is in hand, so
* the routes phase can resolve it to a real handler symbol UID via the
* SemanticModel (same `(filePath, name) → nodeId` lookup Laravel routes use).
* Absent when the extractor could not identify the decorated definition;
* resolution then falls back (the Route node simply carries no handlerSymbolId).
*/
handlerName?: string;
}
export interface ExtractedToolDef {

View file

@ -381,7 +381,7 @@ export const streamAllCSVsToDisk = async (
// Route nodes for API endpoint mapping
const routeWriter = new BufferedCSVWriter(
path.join(csvDir, 'route.csv'),
'id,name,filePath,responseKeys,errorKeys,middleware,method',
'id,name,filePath,responseKeys,errorKeys,middleware,method,handlerSymbolId',
);
// Tool nodes for MCP tool definitions
@ -561,6 +561,7 @@ export const streamAllCSVsToDisk = async (
escapeCSVField(errorKeysStr),
escapeCSVField(middlewareStr),
escapeCSVField(String(node.properties.method ?? '')),
escapeCSVField(String(node.properties.handlerSymbolId ?? '')),
].join(','),
);
break;

View file

@ -1337,7 +1337,7 @@ export const getCopyQuery = (table: NodeTableName, filePath: string): string =>
return `COPY ${t}(id, name, filePath, startLine, endLine, level, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Route') {
return `COPY ${t}(id, name, filePath, responseKeys, errorKeys, middleware, method) FROM "${filePath}" ${COPY_CSV_OPTS}`;
return `COPY ${t}(id, name, filePath, responseKeys, errorKeys, middleware, method, handlerSymbolId) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Tool') {
return `COPY ${t}(id, name, filePath, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;

View file

@ -195,6 +195,7 @@ CREATE NODE TABLE Route (
errorKeys STRING[],
middleware STRING[],
method STRING,
handlerSymbolId STRING,
PRIMARY KEY (id)
)`;

View file

@ -39,7 +39,13 @@ import {
type RegistryEntry,
type BranchSummary,
} from '../../storage/repo-manager.js';
import { GroupService, type GroupToolPort } from '../../core/group/service.js';
import {
GroupService,
type GroupToolPort,
type GroupSymbolResolution,
type GroupPdgFlowResult,
type GroupPdgFlowHop,
} from '../../core/group/service.js';
import { resolveAtGroupMemberRepoPath } from '../../core/group/resolve-at-member.js';
import { collectBestChunks } from '../../core/embeddings/types.js';
import {
@ -596,12 +602,164 @@ export class LocalBackend {
query: (r, p) => this.query(r as RepoHandle, p),
impactByUid: (id, uid, d, o) => this.impactByUid(id, uid, d, o),
context: (r, p) => this.context(r as RepoHandle, p),
trace: (r, p) => this.trace(r as RepoHandle, p),
resolveSymbol: (r, q) => this.resolveSymbolForGroup(r as RepoHandle, q),
pdgFlows: (r, anchor, opts) => this.pdgFlowsForGroup(r as RepoHandle, anchor, opts),
};
this.groupToolSvc = new GroupService(port);
}
return this.groupToolSvc;
}
/**
* Adapt the shared symbol resolver to the GroupToolPort contract. Used by the
* cross-repo trace path to locate which member repo an endpoint lives in and
* recover its node id (== bridge `Contract.symbolUid`).
*/
private async resolveSymbolForGroup(
repo: RepoHandle,
query: { name?: string; uid?: string; file_path?: string },
): Promise<GroupSymbolResolution> {
await this.ensureInitialized(repo);
const outcome = await this.resolveSymbolCandidates(
repo,
{ uid: query.uid, name: query.name },
{ file_path: query.file_path },
);
if (outcome.kind === 'ok') {
const s = outcome.symbol;
return {
kind: 'ok',
symbol: {
id: s.id,
name: s.name,
type: s.type,
filePath: s.filePath,
startLine: s.startLine,
endLine: s.endLine,
},
};
}
if (outcome.kind === 'ambiguous') {
return {
kind: 'ambiguous',
candidates: outcome.candidates.map((c) => ({
id: c.id,
name: c.name,
type: c.type,
filePath: c.filePath,
startLine: c.startLine,
})),
};
}
return { kind: 'not_found' };
}
/**
* Intra-procedural REACHING_DEF data-flow for a single anchor symbol, adapted
* to the GroupToolPort contract. Reuses the same anchor + `flows` query as the
* `pdg_query` tool. `available:false` (not an error) when the repo has no PDG
* `flows` layer, so the cross-repo trace degrades to call-level hops.
*/
private async pdgFlowsForGroup(
repo: RepoHandle,
anchor: { name?: string; uid?: string; file_path?: string },
opts: { limit?: number },
): Promise<GroupPdgFlowResult> {
try {
await this.ensureInitialized(repo);
return await this._pdgFlowsForGroupImpl(repo, anchor, opts);
} catch {
// Enrichment is auxiliary — never let a PDG query failure fail the trace.
return { available: false, hops: [] };
}
}
/**
* Intra-procedural REACHING_DEF data-flow within the anchor symbol's block
* span. Reuses the same anchored, bind-param-only `flows` query as
* `pdg_query` (no rel-property index ⇒ the BasicBlock id-prefix + line-span
* anchor IS the bound). The anchor is resolved by UID when available (the
* boundary symbol is known precisely), avoiding the name-ambiguity the
* by-name `resolveBlockAnchor` path can hit. Data flow never crosses the repo
* boundary — this only describes how values move toward the boundary call
* inside one function.
*/
private async _pdgFlowsForGroupImpl(
repo: RepoHandle,
anchor: { name?: string; uid?: string; file_path?: string },
opts: { limit?: number },
): Promise<GroupPdgFlowResult> {
const rawLimit = opts.limit ?? PDG_QUERY_DEFAULT_LIMIT;
const limit =
Number.isInteger(rawLimit) && rawLimit >= 1 && rawLimit <= PDG_QUERY_MAX_LIMIT
? rawLimit
: PDG_QUERY_DEFAULT_LIMIT;
// Meta probe: layer present iff the flows cap is stamped. `false` is a
// definitive absence (degrade to call-level); `undefined` is unreadable
// meta (fall through and infer presence from rows found).
const pdgStamped = await pdgStampForMode(repo.lbugPath, 'flows');
if (pdgStamped === false) return { available: false, hops: [] };
// Resolve the anchor symbol (UID is precise; fall back to name/file).
const resolved = await this.resolveSymbolCandidates(
repo,
{ uid: anchor.uid, name: anchor.name },
{ file_path: anchor.file_path },
);
if (resolved.kind !== 'ok') {
// Layer may exist but we couldn't anchor — report availability from the
// stamp so the caller's note reflects the layer, not the miss.
return { available: pdgStamped === true, hops: [] };
}
const sym = resolved.symbol;
// Same span-anchored clause as resolveBlockAnchor's symbol branch: the
// BasicBlock startLine is 1-based vs the 0-based symbol span, so shift both
// bounds +1. `idPrefix`/`symStart`/`symEnd` are bind params; the edge type
// is a hardcoded literal — no user string is ever interpolated.
const hasSpan =
typeof sym.startLine === 'number' &&
typeof sym.endLine === 'number' &&
sym.endLine >= sym.startLine;
const idPrefix = `BasicBlock:${sym.filePath}:`;
const anchorClause = hasSpan
? 'a.id STARTS WITH $idPrefix AND a.startLine >= $symStart AND a.startLine <= $symEnd'
: 'a.id STARTS WITH $idPrefix';
const queryParams: Record<string, unknown> = hasSpan
? { idPrefix, symStart: sym.startLine + 1, symEnd: sym.endLine + 1 }
: { idPrefix };
const rows = await executeParameterized(
repo.lbugPath,
`MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)
WHERE r.type = 'REACHING_DEF' AND ${anchorClause}
RETURN a.startLine AS defLine, b.startLine AS useLine, b.text AS useText, r.reason AS reason
ORDER BY useLine, defLine, reason
LIMIT ${limit + 1}`,
queryParams,
);
const truncated = rows.length > limit;
const capped = truncated ? rows.slice(0, limit) : rows;
const hops: GroupPdgFlowHop[] = capped.map((r: Record<string, unknown>) => ({
// Number()/String() coerce the LadybugDB object/tuple cell; a bare
// `as number` cast on a nullish cell would surface NaN downstream.
line: Number(r.useLine ?? r[1] ?? 0),
text: String(r.useText ?? r[2] ?? '').trim(),
variable: decodeReachingDefReason(String(r.reason ?? r[3] ?? '')).name || undefined,
}));
const available = pdgStamped === true || hops.length > 0;
return {
available,
...(hops[0]?.variable ? { variable: hops[0].variable } : {}),
hops,
...(truncated ? { truncated: true } : {}),
};
}
/** Close all pooled LadybugDB connections (CLI one-shot; optional for long-lived MCP). */
async dispose(): Promise<void> {
await closeLbug();
@ -1323,7 +1481,7 @@ export class LocalBackend {
// — third-party MCP clients may legitimately send "query", so the alias is not slated
// for removal even if Claude Code's argument handling later changes.
if (
(method === 'impact' || method === 'query' || method === 'context') &&
(method === 'impact' || method === 'query' || method === 'context' || method === 'trace') &&
typeof p.repo === 'string' &&
p.repo.startsWith('@')
) {
@ -4039,6 +4197,22 @@ export class LocalBackend {
};
}
// A single-repo trace needs a target. Omitting `to` is the destination-trace
// shorthand, but that only exists for a cross-repo @group trace — reject a
// to-less single-repo call with an actionable error rather than the opaque
// "Target symbol 'undefined' not found".
const hasTo =
(typeof params.to === 'string' && params.to.trim() !== '') ||
(typeof params.to_uid === 'string' && params.to_uid.trim() !== '');
if (!hasTo) {
return {
status: 'error',
error: 'trace requires `to` (or `to_uid`) for a single-repo trace.',
suggestion:
'Pass a target symbol, or use repo:"@<group>" and omit `to` to trace `from` to its HTTP destination.',
};
}
const fromOutcome = await this.resolveSymbolCandidates(
repo,
{ uid: params.from_uid, name: params.from },
@ -5782,6 +5956,26 @@ export class LocalBackend {
if (resolved.ok === false) return { error: resolved.error };
const svc = this.getGroupService();
if (method === 'trace') {
// Cross-repo trace resolves `from`/`to` across ALL members (it does not
// anchor on a single member like impact/query/context), so the member
// path in `@group/path` is advisory here — `resolved` above still
// validates that the group exists. groupTrace owns cross-member
// resolution and the single-boundary bridge crossing.
const traceArgs: Record<string, unknown> = { name: groupName };
if (params.from !== undefined) traceArgs.from = params.from;
if (params.to !== undefined) traceArgs.to = params.to;
if (params.from_uid !== undefined) traceArgs.from_uid = params.from_uid;
if (params.to_uid !== undefined) traceArgs.to_uid = params.to_uid;
if (params.from_file !== undefined) traceArgs.from_file = params.from_file;
if (params.to_file !== undefined) traceArgs.to_file = params.to_file;
if (params.maxDepth !== undefined) traceArgs.maxDepth = params.maxDepth;
if (params.crossDepth !== undefined) traceArgs.crossDepth = params.crossDepth;
if (params.includeTests !== undefined) traceArgs.includeTests = params.includeTests;
if (params.pdg !== undefined) traceArgs.pdg = params.pdg;
if (params.limit !== undefined) traceArgs.limit = params.limit;
return svc.groupTrace(traceArgs);
}
if (method === 'impact') {
// KTD5/KTD12 — validate `mode` at the group-forward boundary too (the
// JSON-schema enum is advisory). An invalid mode errors; `mode:'pdg'` is

View file

@ -791,7 +791,11 @@ WHEN TO USE: Debugging "how does A reach B?" — answers in one call what would
Traverses CALLS edges plus HAS_METHOD (class → member) edges, so a trace can descend from a class into its methods. Each hop's edge type is reported in edges[], so call hops and containment hops remain distinguishable.
Returns: ordered hops with file:line, and an aligned edges[] of edge type + confidence. When no path exists, reports the furthest reachable node so you know where the chain breaks (and truncated: true if a traversal cap was hit first).`,
Returns: ordered hops with file:line, and an aligned edges[] of edge type + confidence. When no path exists, reports the furthest reachable node so you know where the chain breaks (and truncated: true if a traversal cap was hit first).
CROSS-REPO (experimental): pass repo as "@groupName" to trace across repositories in a group. When from/to live in different member repos, the trace stitches the two repo-local segments across a single ContractLink boundary (e.g. an HTTP consumer→provider link), clamped to one crossing. The result adds crossings[] (the bridged contract with matchType/confidence), tags each hop with its member repo, and a notes[] channel for degraded states. The boundary hop is reported with edge type CONTRACT_LINK. Pass pdg:true to also attach the intra-procedural data-flow (REACHING_DEF) for boundary-adjacent segments when those repos were indexed with --pdg; absent a PDG layer it degrades to call-level hops with a note.
DESTINATION TRACE (cross-repo): for an "@groupName" trace, OMIT to/to_uid/to_file to trace 'from' to wherever its outgoing HTTP call lands. The result ends at the provider endpoint (reported by route + file even when the handler is an anonymous function with no nameable symbol). This is the way to follow a client call to a backend handler you cannot name.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
@ -799,7 +803,11 @@ Returns: ordered hops with file:line, and an aligned edges[] of edge type + conf
from: { type: 'string', description: 'Source symbol name' },
from_uid: { type: 'string', description: 'Source symbol UID (zero-ambiguity)' },
from_file: { type: 'string', description: 'Source file path hint for disambiguation' },
to: { type: 'string', description: 'Target symbol name' },
to: {
type: 'string',
description:
"Target symbol name. Omit (with to_uid/to_file) on an @group trace to trace 'from' to its HTTP destination.",
},
to_uid: { type: 'string', description: 'Target symbol UID (zero-ambiguity)' },
to_file: { type: 'string', description: 'Target file path hint for disambiguation' },
maxDepth: {
@ -814,9 +822,32 @@ Returns: ordered hops with file:line, and an aligned edges[] of edge type + conf
description: 'Include test-file symbols in traversal (default: false)',
default: false,
},
pdg: {
type: 'boolean',
description:
'Cross-repo only (experimental): attach intra-procedural REACHING_DEF data-flow for boundary-adjacent segments when the repo has a --pdg layer. Default false.',
default: false,
},
crossDepth: {
type: 'number',
description:
'Cross-repo only: number of ContractLink boundaries to cross. Only 1 is supported today (multi-hop deferred); a direct caller that passes a higher value gets it clamped to 1 with a notes[] entry.',
default: 1,
minimum: 1,
maximum: 1,
},
limit: {
type: 'number',
description:
'Cross-repo + pdg:true only: max REACHING_DEF data-flow hops attached per boundary-adjacent segment (default 50, max 200). When a segment dataFlow is truncated, re-issue with a higher limit.',
default: 50,
minimum: 1,
maximum: 200,
},
repo: {
type: 'string',
description: 'Repository name or path. Omit if only one repo is indexed.',
description:
'Repository name or path, or "@groupName" / "@groupName/memberPath" for a cross-repo trace over a group. Omit if only one repo is indexed.',
},
},
required: [],

View file

@ -55,7 +55,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// the main thread (the #1983 OOM). Because the two stores share this version,
// any future change to the `ParsedFile` serialization shape MUST bump
// SCHEMA_BUMP so both invalidate in lockstep.
const SCHEMA_BUMP = 6; // #2082 M2: cfgSideChannel gained bindings + per-block statement facts
const SCHEMA_BUMP = 7; // #2138 Part 2: ExtractedDecoratorRoute gained `handlerName` (route handler symbol resolution)
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -0,0 +1,374 @@
/**
* 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']);
});
});

View file

@ -0,0 +1,77 @@
/**
* Real-LadybugDB round trip for `Route.handlerSymbolId` (issue #2138, Part 2).
*
* The Part-1 analogue (`route-method-roundtrip.test.ts`) pins `Route.method`;
* this pins the second persisted Route column added in Part 2. It persists a
* `Route` node carrying `handlerSymbolId` through the real CSV generator + the
* production `COPY` path into a real LadybugDB, then runs the exact production
* `HANDLES_ROUTE_QUERY` and asserts the handler UID round-trips.
*
* Covers the three persistence points touched by Part 2's U2:
* - `ROUTE_SCHEMA` (schema.ts) — the `handlerSymbolId` column must exist
* - the Route CSV row (csv-generator.ts) — the value must be written
* - `getCopyQuery('Route')` (lbug-adapter.ts) — the COPY must load it
*/
import { it, expect } from 'vitest';
import path from 'path';
import fs from 'fs/promises';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import { buildTestGraph } from '../helpers/test-graph.js';
import { streamAllCSVsToDisk } from '../../src/core/lbug/csv-generator.js';
import { HANDLES_ROUTE_QUERY } from '../../src/core/group/extractors/http-route-extractor.js';
const HANDLER_UID = 'Method:OrderController.java:create';
withTestLbugDB('route-handler-symbol-roundtrip', (handle) => {
it('persists Route.handlerSymbolId through CSV→COPY and HANDLES_ROUTE_QUERY returns it', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
// 1. Route node carrying a resolved handlerSymbolId (what the routes phase
// now stamps when resolveRouteHandlerSymbols resolves the handler).
const graph = buildTestGraph([
{
id: 'Route:/api/orders',
label: 'Route',
name: '/api/orders',
filePath: 'OrderController.java',
extra: {
method: 'POST',
handlerSymbolId: HANDLER_UID,
responseKeys: [],
errorKeys: [],
middleware: [],
},
},
]);
// 2. Generate CSVs through the real generator.
const csvDir = path.join(handle.tmpHandle.dbPath, 'csv-handler-roundtrip');
const repoDir = path.join(handle.tmpHandle.dbPath, 'repo-handler-roundtrip');
await fs.mkdir(repoDir, { recursive: true });
await streamAllCSVsToDisk(graph, repoDir, csvDir);
// Sanity: route.csv header + row include the handlerSymbolId column/value.
const routeCsv = await fs.readFile(path.join(csvDir, 'route.csv'), 'utf-8');
expect(routeCsv.split('\n')[0]).toContain('handlerSymbolId');
expect(routeCsv).toContain(HANDLER_UID);
// 3. COPY the Route node via the production COPY query.
const routeCsvPath = path.join(csvDir, 'route.csv').replace(/\\/g, '/');
await adapter.executeQuery(adapter.getCopyQuery('Route', routeCsvPath));
// 4. Seed the handler File node + HANDLES_ROUTE edge.
await adapter.executeQuery(
`CREATE (:File {id: 'File:OrderController.java', name: 'OrderController.java', filePath: 'OrderController.java'})`,
);
await adapter.executeQuery(
`MATCH (f:File {id: 'File:OrderController.java'}), (r:Route {id: 'Route:/api/orders'})
CREATE (f)-[:CodeRelation {type: 'HANDLES_ROUTE', confidence: 1.0, reason: 'framework-route', step: 0}]->(r)`,
);
// 5. Run the EXACT production query and assert the handler UID round-trips.
const rows = (await adapter.executeQuery(HANDLES_ROUTE_QUERY)) as Record<string, unknown>[];
const row = rows.find((r) => String(r.routePath) === '/api/orders');
expect(row, 'HANDLES_ROUTE_QUERY returned no row for the seeded route').toBeTruthy();
expect(row!.handlerSymbolId).toBe(HANDLER_UID);
});
});

View file

@ -0,0 +1,251 @@
/**
* #2138 Part 2 · parse-skip proof + P1 regression guards.
*
* The win: for a file whose provider routes are fully covered by the graph in a
* `routeCoverage: 'complete'` language, `HttpRouteExtractor` skips the source
* scan AND the tree-sitter parse — the graph is authoritative. We spy the real
* `parseSourceSafe` to COUNT parses (deterministic, not wall-time).
*
* PHP/Laravel is the language used for the *win* scenarios: ingestion's Laravel
* route extraction is a superset of the group PHP scan, so PHP is `'complete'`.
*
* Java is deliberately `'partial'` (the graph provider set is a strict subset of
* the group Java scan — array-form, interface-inherited, and same-URL multi-verb
* routes have no graph Route node). The Java cases below are REGRESSION GUARDS:
* they prove those group-only routes survive because Java is never parse-skipped.
* If someone flips Java to `'complete'` without making ingestion provider-
* complete, these tests fail — exactly the #2138 P1 data-loss class.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// Count real parses by wrapping the actual parseSourceSafe.
const parseCalls: string[] = [];
vi.mock('../../src/core/tree-sitter/safe-parse.js', async (importActual) => {
const actual = await importActual<typeof import('../../src/core/tree-sitter/safe-parse.js')>();
return {
...actual,
parseSourceSafe: (parser: unknown, src: unknown) => {
parseCalls.push(typeof src === 'string' ? src : '<non-string>');
return (actual.parseSourceSafe as (p: unknown, s: unknown) => unknown)(parser, src);
},
};
});
import { HttpRouteExtractor } from '../../src/core/group/extractors/http-route-extractor.js';
const repo = { name: 'r', url: 'r' } as never;
beforeEach(() => {
parseCalls.length = 0;
});
function mkRepo(files: Record<string, string>): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'route-parse-skip-'));
for (const [name, content] of Object.entries(files)) {
fs.writeFileSync(path.join(dir, name), content);
}
return dir;
}
/** HANDLES_ROUTE rows from a compact spec; CONTAINS/FETCHES return empty. */
function makeDb(
rows: Array<{ file: string; routePath: string; method: string; resolved: boolean }>,
) {
return vi.fn(async (query: string) => {
if (query.includes('HANDLES_ROUTE')) {
return rows.map((r, i) => ({
fileId: `File:${r.file}`,
filePath: r.file,
routePath: r.routePath,
routeMethod: r.method,
handlerSymbolId: r.resolved ? `Method:${r.file}:h${i}` : '',
routeSource: 'framework-route',
}));
}
return []; // CONTAINS (basename fallback is fine) + FETCHES (no consumers)
});
}
const providerPaths = (out: Awaited<ReturnType<HttpRouteExtractor['extract']>>) =>
out.filter((c) => c.role === 'provider').map((c) => `${c.meta.method}::${c.meta.path}`);
// ── PHP / Laravel — the `'complete'` language where the skip engages ──────────
const ROUTES_A = `<?php
Route::get('/api/a/list', 'AController@list');
`;
const ROUTES_B = `<?php
Route::post('/api/b/make', 'BController@make');
`;
describe('HttpRouteExtractor — PHP parse-skip for graph-covered files (#2138)', () => {
it('baseline: with no graph, every PHP file is parsed', async () => {
const dir = mkRepo({ 'routes_a.php': ROUTES_A, 'routes_b.php': ROUTES_B });
try {
const out = await new HttpRouteExtractor().extract(null, dir, repo);
expect(providerPaths(out)).toEqual(
expect.arrayContaining(['GET::/api/a/list', 'POST::/api/b/make']),
);
expect(parseCalls.length).toBeGreaterThanOrEqual(2);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('fully covered: zero PHP files parsed (the win)', async () => {
const dir = mkRepo({ 'routes_a.php': ROUTES_A, 'routes_b.php': ROUTES_B });
try {
const out = await new HttpRouteExtractor().extract(
makeDb([
{ file: 'routes_a.php', routePath: '/api/a/list', method: 'GET', resolved: true },
{ file: 'routes_b.php', routePath: '/api/b/make', method: 'POST', resolved: true },
]),
dir,
repo,
);
const providers = out.filter((c) => c.role === 'provider');
expect(providers.map((c) => c.meta.path)).toEqual(
expect.arrayContaining(['/api/a/list', '/api/b/make']),
);
expect(providers.every((c) => c.meta.extractionStrategy === 'graph_assisted')).toBe(true);
expect(parseCalls.length).toBe(0);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('mixed: an unresolved route falls back to a scan; the resolved file stays skipped', async () => {
const dir = mkRepo({ 'routes_a.php': ROUTES_A, 'routes_b.php': ROUTES_B });
try {
await new HttpRouteExtractor().extract(
makeDb([
{ file: 'routes_a.php', routePath: '/api/a/list', method: 'GET', resolved: true },
{ file: 'routes_b.php', routePath: '/api/b/make', method: 'POST', resolved: false },
]),
dir,
repo,
);
expect(parseCalls.some((s) => s.includes('/api/b/make'))).toBe(true); // B scanned
expect(parseCalls.some((s) => s.includes('/api/a/list'))).toBe(false); // A skipped
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('provider-covered file that ALSO calls out is still parsed (consumer not dropped)', async () => {
// routes_c.php is a Laravel provider AND a Laravel Http:: consumer.
const ROUTES_C = `<?php
Route::get('/api/c/list', 'CController@list');
Http::get('/api/inventory');
`;
const dir = mkRepo({ 'routes_c.php': ROUTES_C });
try {
const out = await new HttpRouteExtractor().extract(
makeDb([{ file: 'routes_c.php', routePath: '/api/c/list', method: 'GET', resolved: true }]),
dir,
repo,
);
expect(out.some((c) => c.role === 'provider' && c.meta.path === '/api/c/list')).toBe(true);
// The Http:: consumer lives only in source — it MUST survive because the
// consumer signal kept the file in the scan set (so it was parsed).
expect(parseCalls.some((s) => s.includes('/api/inventory'))).toBe(true);
expect(out.some((c) => c.role === 'consumer' && c.meta.path === '/api/inventory')).toBe(true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
// ── Java — `'partial'`, so the P1 group-only shapes must never be dropped ─────
describe('HttpRouteExtractor — Java parse-skip P1 regression guards (#2138)', () => {
it('array-form @GetMapping({"/a","/b"}) survives a co-located resolved route', async () => {
const AC = `package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
public class AController {
@GetMapping("/covered") public Object covered() { return null; }
@GetMapping({"/a","/b"}) public Object multi() { return null; }
}
`;
const dir = mkRepo({ 'AController.java': AC });
try {
// Graph resolves only /covered (ingestion has no array-form Route node).
const out = await new HttpRouteExtractor().extract(
makeDb([
{ file: 'AController.java', routePath: '/covered', method: 'GET', resolved: true },
]),
dir,
repo,
);
const paths = providerPaths(out);
// The array-form routes are graph-only-absent but survive via source scan.
expect(paths).toEqual(expect.arrayContaining(['GET::/a', 'GET::/b']));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('same-URL multi-verb (GET+POST /orders) keeps both verbs', async () => {
const OC = `package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
public class OrderController {
@GetMapping("/orders") public Object list() { return null; }
@PostMapping("/orders") public Object make() { return null; }
}
`;
const dir = mkRepo({ 'OrderController.java': OC });
try {
// Ingestion's URL-keyed Route node collapses to one verb; resolve only GET.
const out = await new HttpRouteExtractor().extract(
makeDb([
{ file: 'OrderController.java', routePath: '/orders', method: 'GET', resolved: true },
]),
dir,
repo,
);
const paths = providerPaths(out);
expect(paths).toEqual(expect.arrayContaining(['GET::/orders', 'POST::/orders']));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('interface-inherited Spring route survives on the implementing controller', async () => {
const IFACE = `package com.example;
import org.springframework.web.bind.annotation.*;
@RequestMapping("/orders")
public interface OrderApi {
@GetMapping("/{id}") Object get(Long id);
}
`;
const CTRL = `package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
public class OrderController implements OrderApi {
@GetMapping("/direct") public Object direct() { return null; }
public Object get(Long id) { return null; }
}
`;
const dir = mkRepo({ 'OrderApi.java': IFACE, 'OrderController.java': CTRL });
try {
// Graph resolves only the controller-direct route; the inherited route is
// composed only by the group scanProject pass.
const out = await new HttpRouteExtractor().extract(
makeDb([
{ file: 'OrderController.java', routePath: '/direct', method: 'GET', resolved: true },
]),
dir,
repo,
);
const paths = providerPaths(out);
expect(paths).toEqual(expect.arrayContaining(['GET::/orders/{param}']));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

View file

@ -104,4 +104,27 @@ describe('Spring @RequestMapping route ingestion pipeline', () => {
const userRoutes = handlesRouteEdges.filter((e) => e.filePath.includes('UserController.java'));
expect(userRoutes.length).toBeGreaterThanOrEqual(1);
});
it('resolves the decorated handler method to a symbol UID on the Route node (Part 2 #2138)', () => {
// Find the Route node for the @GetMapping("/list") handler.
let routeNode: { properties: Record<string, unknown> } | undefined;
result.graph.forEachNode((n) => {
if (n.label === 'Route' && n.properties.name === '/api/users/list') {
routeNode = n;
}
});
expect(routeNode, 'Route node /api/users/list should exist').toBeTruthy();
// U0+U1: the decorated handler (listUsers) was captured and resolved to a
// real symbol UID, stamped on the Route node.
const handlerSymbolId = routeNode!.properties.handlerSymbolId;
expect(handlerSymbolId, 'Route node should carry handlerSymbolId').toBeTruthy();
// The id resolves to the listUsers handler symbol in UserController.java.
const handler = result.graph.getNode(String(handlerSymbolId));
expect(handler, 'handlerSymbolId should resolve to a graph node').toBeTruthy();
expect(handler!.properties.name).toBe('listUsers');
expect(String(handler!.properties.filePath)).toContain('UserController.java');
expect(['Method', 'Function']).toContain(handler!.label);
});
});

View file

@ -131,6 +131,7 @@ describe('Blade/template static route extraction', () => {
},
],
allDecoratorRoutes: [],
routeHandlerSymbols: new Map(),
} as unknown as ParseOutput;
const output = await routesPhase.execute(

View file

@ -600,6 +600,51 @@ describe('LocalBackend.callTool', () => {
groupQuerySpy.mockRestore();
});
// U3: `trace` with an @group repo routes to the cross-repo groupTrace path
// and forwards the trace params (incl. the experimental pdg/crossDepth flags).
it('group-mode trace routes to groupTrace and forwards trace params', async () => {
resolveAtMemberMock.mockResolvedValue({ ok: true, repoPath: '/tmp/test-project' });
const groupTraceSpy = vi
.spyOn(backend.getGroupService(), 'groupTrace')
.mockResolvedValue({ status: 'ok' });
await backend.callTool('trace', {
from: 'A',
to: 'B',
pdg: true,
crossDepth: 3,
repo: '@grp',
});
expect(groupTraceSpy).toHaveBeenCalledTimes(1);
const args = groupTraceSpy.mock.calls[0][0] as Record<string, unknown>;
expect(args).toMatchObject({ name: 'grp', from: 'A', to: 'B', pdg: true, crossDepth: 3 });
groupTraceSpy.mockRestore();
});
// U3: a non-@group trace must NOT route to groupTrace — single-repo behavior
// is untouched (here it resolves to not_found against the empty mocked graph).
it('single-repo trace does not route to groupTrace', async () => {
const groupTraceSpy = vi.spyOn(backend.getGroupService(), 'groupTrace');
vi.mocked(executeParameterized).mockResolvedValue([]);
const result = await backend.callTool('trace', { from: 'A', to: 'B' });
expect(groupTraceSpy).not.toHaveBeenCalled();
expect(result).toMatchObject({ status: 'not_found' });
groupTraceSpy.mockRestore();
});
// The destination trace (omit `to`) is a cross-repo @group feature; a single-repo
// trace without `to` must error clearly, not return an opaque "symbol not found".
it('single-repo trace without `to` returns an actionable error', async () => {
const result = await backend.callTool('trace', { from: 'A' });
expect(result).toMatchObject({
status: 'error',
error: expect.stringContaining('requires `to`'),
});
});
// #2175 review: the MCP envelope is not schema-validated, so a client can send a
// non-string value for a string param. Resolve it to a friendly required-param error
// rather than throwing TypeError on `.trim()` (query() and cypher() both).

View file

@ -22,21 +22,21 @@ import type { CrossLink } from '../../../src/core/group/types.js';
import { makeContract } from './fixtures.js';
/**
* LadybugDB 0.16.0 has a known Windows-only regression: `Database.close()`
* does not release the underlying file lock until the process exits, so any
* read-after-write within the same process fails with Win32 Error 33
* ("process cannot access the file because another process has locked a
* portion of the file"). This blocks the close-then-reopen pattern that
* `writeBridge → openBridgeDbReadOnly` relies on.
* In-process close-then-reopen of `bridge.lbug` (`writeBridge →
* openBridgeDbReadOnly`, and the read path's open→query→close→reopen) — exactly
* what a long-lived MCP server does on repeated `@group` impact/trace calls.
*
* Production code paths are unaffected: `gitnexus analyze`, `serve`, and
* `mcp` each open the database exactly once per process and close it at
* exit. The pattern only manifests in tests and in worker pool reuse.
* On Linux/macOS this is now a supported, exercised pattern thanks to the
* `closeBridgeDb` fix that skips CHECKPOINT on read-only handles (a CHECKPOINT
* on a read-only connection left a lock artifact that failed the next open).
*
* Upstream: see kuzudb/kuzu#3872 / #3883 / #4730 (file-lock UX gaps on
* Windows). Skipping these specific tests on Windows lets the segfault fix
* (the original motivation for the 0.16.0 upgrade) ship while we wait for
* an upstream fix or pivot to a single-process bridge writer.
* On WINDOWS the writable-close → read-open handoff still does not release the
* OS file handle before the read open races (the existing open-side
* `LBUG_OPEN_RETRY_*` only retries lock-pattern errors, not the post-rename
* sidecar database-id mismatch), so these tests stay Windows-skipped — the
* pre-existing limitation. A close-side `waitForWindowsHandleRelease` +
* `finalizeLbugSidecarsAfterClose` probe was tried and did not close the gap on
* Windows CI, so it was not kept.
*/
const itLbugReopen = process.platform === 'win32' ? it.skip : it;
@ -219,6 +219,38 @@ describe('writeBridge + read', () => {
await closeBridgeDb(handle!);
});
itLbugReopen('test_openBridgeDbReadOnly_can_reopen_in_same_process', async () => {
// Regression: closeBridgeDb used to issue CHECKPOINT on read-only handles
// too, which left a WAL/shadow lock artifact that made the next read-only
// open of the same file fail in-process — breaking repeated @group
// impact/trace calls in a long-lived MCP server. closeBridgeDb now skips
// the checkpoint for read-only handles, so open→query→close→open works.
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
const first = await openBridgeDbReadOnly(tmpDir);
expect(first).not.toBeNull();
const r1 = await queryBridge<{ n: number }>(first!, 'MATCH (c:Contract) RETURN count(c) AS n');
expect(r1[0].n).toBe(1);
await closeBridgeDb(first!);
// Second open in the SAME process must succeed (previously returned null).
const second = await openBridgeDbReadOnly(tmpDir);
expect(second).not.toBeNull();
const r2 = await queryBridge<{ n: number }>(second!, 'MATCH (c:Contract) RETURN count(c) AS n');
expect(r2[0].n).toBe(1);
await closeBridgeDb(second!);
// And a third, to confirm it is not a one-shot.
const third = await openBridgeDbReadOnly(tmpDir);
expect(third).not.toBeNull();
await closeBridgeDb(third!);
});
it('test_writeBridge_meta_json_persists_missingRepos', async () => {
await writeBridge(tmpDir, {
contracts: [],

View file

@ -0,0 +1,972 @@
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 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);
});
});

View file

@ -0,0 +1,76 @@
/**
* Unit tests for `HttpLanguagePlugin.hasConsumerSignals` (#2138 Part 2).
*
* The parse-skip consumer-safety gate skips a provider-covered file only when
* its plugin proves (parse-free) the file has no outbound-HTTP call its `scan()`
* would detect. The contract (`types.ts`) requires `hasConsumerSignals` to be a
* SUPERSET of every consumer shape `scan()` emits — otherwise a covered file
* with an undetected consumer call would be wrongly parse-skipped and its
* consumer contract dropped. These tests pin that superset relationship per
* language with the exact idioms each `scan()` matches.
*/
import { describe, it, expect } from 'vitest';
import { JAVA_HTTP_PLUGIN } from '../../../src/core/group/extractors/http-patterns/java.js';
import { PHP_HTTP_PLUGIN } from '../../../src/core/group/extractors/http-patterns/php.js';
import { PYTHON_HTTP_PLUGIN } from '../../../src/core/group/extractors/http-patterns/python.js';
const has = (plugin: { hasConsumerSignals?: (s: string) => boolean }, src: string): boolean => {
if (!plugin.hasConsumerSignals) throw new Error('plugin has no hasConsumerSignals');
return plugin.hasConsumerSignals(src);
};
describe('Java hasConsumerSignals — superset of scan() consumer idioms', () => {
it.each([
['RestTemplate', 'restTemplate.getForObject("/api/x", X.class);'],
['WebClient short-form', 'webClient.get().uri("/api/x").retrieve();'],
['WebClient exchange', 'webClient.method(HttpMethod.GET).uri("/x");'],
['OkHttp', 'new Request.Builder().url("/api/x").build();'],
['Java HttpClient', 'HttpRequest.newBuilder().uri(URI.create("/x")).GET();'],
['Apache HttpGet', 'new HttpGet("/api/x");'],
['OpenFeign @FeignClient', '@FeignClient(name="svc") interface C {}'],
['OpenFeign @RequestLine', '@RequestLine("GET /users/{id}")'],
['Spring HTTP Interface @GetExchange', '@GetExchange("/api/x") Object x();'],
])('detects %s', (_label, src) => {
expect(has(JAVA_HTTP_PLUGIN, src)).toBe(true);
});
it('returns false for a pure provider controller (no outbound calls)', () => {
const src = `@RestController @RequestMapping("/api/a")
class AController { @GetMapping("/list") Object list() { return null; } }`;
expect(has(JAVA_HTTP_PLUGIN, src)).toBe(false);
});
});
describe('PHP hasConsumerSignals — superset of scan() consumer idioms', () => {
it.each([
['Laravel Http facade', "Http::get('/api/x');"],
['Guzzle member call', "$client->post('/api/x', []);"],
['file_get_contents', "file_get_contents('https://x/api');"],
])('detects %s', (_label, src) => {
expect(has(PHP_HTTP_PLUGIN, src)).toBe(true);
});
it('returns false for a pure Laravel route file (provider only)', () => {
expect(has(PHP_HTTP_PLUGIN, "Route::get('/api/a/list', 'AController@list');")).toBe(false);
});
});
describe('Python hasConsumerSignals — superset of scan() consumer idioms', () => {
it.each([
['requests verb', 'requests.get("/api/x")'],
['requests.request', 'requests.request("GET", "/api/x")'],
['httpx', 'client = httpx.AsyncClient()'],
['aiohttp', 'async with aiohttp.ClientSession() as s: ...'],
['urllib', 'urllib.request.urlopen("/api/x")'],
['uri= keyword', 'do_call(uri="/api/x")'],
['url= keyword', 'do_call(url="/api/x")'],
])('detects %s', (_label, src) => {
expect(has(PYTHON_HTTP_PLUGIN, src)).toBe(true);
});
it('returns false for a pure FastAPI provider (decorator route only)', () => {
const src = `@router.get("/api/x")
async def handler(): return {}`;
expect(has(PYTHON_HTTP_PLUGIN, src)).toBe(false);
});
});

File diff suppressed because it is too large Load diff

View file

@ -97,7 +97,7 @@ describe('HttpRouteExtractor — Route.method from graph (Step A / #2138)', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['createOrder']);
if (query.includes('UNION ALL')) return containsFor(['createOrder']);
return [];
});
@ -130,7 +130,7 @@ describe('HttpRouteExtractor — Route.method from graph (Step A / #2138)', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['listOrders', 'replaceOrder']);
if (query.includes('UNION ALL')) return containsFor(['listOrders', 'replaceOrder']);
return [];
});
@ -160,7 +160,7 @@ describe('HttpRouteExtractor — Route.method from graph (Step A / #2138)', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['deleteOrder']);
if (query.includes('UNION ALL')) return containsFor(['deleteOrder']);
return [];
});
@ -188,7 +188,7 @@ describe('HttpRouteExtractor — Route.method from graph (Step A / #2138)', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['listOrders']);
if (query.includes('UNION ALL')) return containsFor(['listOrders']);
return [];
});
@ -200,6 +200,54 @@ describe('HttpRouteExtractor — Route.method from graph (Step A / #2138)', () =
expect(out[0].symbolName).toBe('listOrders');
});
it('fast path: Route.handlerSymbolId resolves the handler without any source scan', async () => {
// Deliberately leave FILE_DETECTIONS empty: if the extractor still resolves
// the handler, it MUST have used the persisted handlerSymbolId (the graph
// fast path), not a plugin scan of the source.
const HID = 'Method:OrderController.java:OrderController.createOrder#0';
const db = vi.fn(async (query: string) => {
if (query.includes('HANDLES_ROUTE')) {
return [
{
fileId: 'f1',
filePath: 'OrderController.java',
routePath: '/api/orders',
routeMethod: 'POST',
handlerSymbolId: HID,
routeSource: 'framework-route',
},
];
}
if (query.includes('UNION ALL')) {
return [
{
uid: HID,
name: 'createOrder',
filePath: 'OrderController.java',
startLine: 10,
endLine: 12,
labels: ['Method'],
0: HID,
1: 'createOrder',
2: 'OrderController.java',
},
];
}
return [];
});
const out = await new HttpRouteExtractor().extract(db, '/repo', {
name: 'r',
url: 'r',
} as never);
expect(out).toHaveLength(1);
expect(out[0].meta.method).toBe('POST');
// The persisted symbol id is authoritative; name/path come from the cheap
// CONTAINING_QUERY graph lookup by filePath (no source parse).
expect(out[0].symbolUid).toBe(HID);
expect(out[0].symbolName).toBe('createOrder');
});
it('backward-compat: no Route.method and undecodable reason stays at conservative GET', async () => {
FILE_DETECTIONS.set('routes.ts', [detection('provider', 'POST', '/api/orders', 'createOrder')]);
@ -214,7 +262,7 @@ describe('HttpRouteExtractor — Route.method from graph (Step A / #2138)', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['createOrder']);
if (query.includes('UNION ALL')) return containsFor(['createOrder']);
return [];
});

View file

@ -96,7 +96,7 @@ describe('HttpRouteExtractor — graph-assisted multi-verb disambiguation', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['listOrders']);
if (query.includes('UNION ALL')) return containsFor(['listOrders']);
return [];
});
@ -125,7 +125,7 @@ describe('HttpRouteExtractor — graph-assisted multi-verb disambiguation', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['listOrders', 'createOrder']);
if (query.includes('UNION ALL')) return containsFor(['listOrders', 'createOrder']);
return [];
});
@ -155,7 +155,7 @@ describe('HttpRouteExtractor — graph-assisted multi-verb disambiguation', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['listOrders', 'createOrder']);
if (query.includes('UNION ALL')) return containsFor(['listOrders', 'createOrder']);
return [];
});
@ -225,7 +225,7 @@ describe('HttpRouteExtractor — graph-assisted multi-verb disambiguation', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['listOrders', 'createOrder']);
if (query.includes('UNION ALL')) return containsFor(['listOrders', 'createOrder']);
return [];
});
@ -264,7 +264,7 @@ describe('HttpRouteExtractor — graph-assisted multi-verb disambiguation', () =
},
];
}
if (query.includes('CONTAINS'))
if (query.includes('UNION ALL'))
return containsFor(['listOrders', 'createOrder', 'replaceOrder']);
return [];
});
@ -321,7 +321,7 @@ describe('HttpRouteExtractor — graph-assisted multi-verb disambiguation', () =
},
];
}
if (query.includes('CONTAINS')) return containsFor(['listOrders', 'createOrder']);
if (query.includes('UNION ALL')) return containsFor(['listOrders', 'createOrder']);
return [];
});

View file

@ -0,0 +1,130 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { cleanupTempDir } from '../../helpers/test-db.js';
import { resolveBridgeNeighbors } from '../../../src/core/group/cross-impact.js';
import {
writeBridge,
openBridgeDbReadOnly,
closeBridgeDb,
} from '../../../src/core/group/bridge-db.js';
import type { CrossLink } from '../../../src/core/group/types.js';
import { makeContract } from './fixtures.js';
/**
* U1 — direct coverage for the shared bridge-neighbor join extracted from
* `runGroupImpact`. Mirrors the close-then-reopen Windows guard used by the
* other writeBridge tests (`itLbugReopen`).
*/
const itLbugReopen = process.platform === 'win32' ? it.skip : it;
/** Build a bridge with one consumer→provider ContractLink (both UIDs populated). */
async function writeLinkedBridge(groupDir: string): Promise<void> {
const consumer = makeContract({
repo: 'app/frontend',
role: 'consumer',
symbolUid: 'consumer-uid',
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
symbolName: 'fetchUsers',
contractId: 'http::GET::/api/users',
confidence: 0.5,
});
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',
confidence: 0.9,
});
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: [],
});
}
describe('resolveBridgeNeighbors', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-neighbors-'));
});
afterEach(async () => {
await cleanupTempDir(tmpDir);
});
it('returns [] for an empty uid set without touching the DB', async () => {
// A null handle would throw if the DB were queried; the empty-set guard
// must short-circuit before any query.
const handleSentinel = null as unknown as Parameters<typeof resolveBridgeNeighbors>[0];
const rows = await resolveBridgeNeighbors(handleSentinel, {
localRepo: 'app/backend',
uids: [],
direction: 'upstream',
});
expect(rows).toEqual([]);
});
itLbugReopen('downstream: consumer uid resolves to its provider neighbor', async () => {
await writeLinkedBridge(tmpDir);
const handle = await openBridgeDbReadOnly(tmpDir);
expect(handle).not.toBeNull();
const rows = await resolveBridgeNeighbors(handle!, {
localRepo: 'app/frontend',
uids: ['consumer-uid'],
direction: 'downstream',
});
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
neighborRepo: 'app/backend',
neighborUid: 'provider-uid',
matchType: 'exact',
confidence: 0.9,
contractId: 'http::GET::/api/users',
contractType: 'http',
});
await closeBridgeDb(handle!);
});
itLbugReopen('upstream: provider uid resolves to its consumer neighbor', async () => {
await writeLinkedBridge(tmpDir);
const handle = await openBridgeDbReadOnly(tmpDir);
const rows = await resolveBridgeNeighbors(handle!, {
localRepo: 'app/backend',
uids: ['provider-uid'],
direction: 'upstream',
});
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
neighborRepo: 'app/frontend',
neighborUid: 'consumer-uid',
contractId: 'http::GET::/api/users',
});
await closeBridgeDb(handle!);
});
itLbugReopen('unknown uid yields no neighbors', async () => {
await writeLinkedBridge(tmpDir);
const handle = await openBridgeDbReadOnly(tmpDir);
const rows = await resolveBridgeNeighbors(handle!, {
localRepo: 'app/frontend',
uids: ['no-such-uid'],
direction: 'downstream',
});
expect(rows).toEqual([]);
await closeBridgeDb(handle!);
});
});

View file

@ -0,0 +1,148 @@
/**
* Direct unit tests for `resolveRouteHandlerSymbols` (#2138 Part 2).
*
* Pins the P2 fixes from the review:
* - ambiguity → fail-open: a same-name lookup returning ≠1 yields NO
* handlerSymbolId (never an arbitrary `[0]` guess).
* - first-writer-wins reservation: the first route to claim a URL reserves it
* even when its handler is unresolvable, so a later same-URL route can't
* stamp its handler onto the (node-winning) first route's slot.
* - happy path: a uniquely-resolvable handler is stamped, keyed by the
* normalized URL.
*/
import { describe, it, expect } from 'vitest';
import { createSemanticModel } from '../../src/core/ingestion/model/index.js';
import { resolveRouteHandlerSymbols } from '../../src/core/ingestion/call-processor.js';
import type { ExtractedDecoratorRoute } from '../../src/core/ingestion/workers/parse-worker.js';
import type { ExtractedRoute } from '../../src/core/ingestion/route-extractors/laravel.js';
const FILE = 'src/OrderController.java';
function decoratorRoute(overrides: Partial<ExtractedDecoratorRoute> = {}): ExtractedDecoratorRoute {
return {
filePath: FILE,
routePath: '/orders',
httpMethod: 'GET',
decoratorName: 'GetMapping',
lineNumber: 1,
handlerName: 'list',
...overrides,
};
}
describe('resolveRouteHandlerSymbols — decorator routes', () => {
it('uniquely-resolvable handler is stamped, keyed by normalized URL', () => {
const model = createSemanticModel();
model.symbols.add(FILE, 'list', 'method:OrderController.list', 'Method');
const out = resolveRouteHandlerSymbols(model, [], [decoratorRoute()]);
expect(out.get('/orders')).toBe('method:OrderController.list');
});
it('ambiguous same-name handler (overloads) → fail-open, no stamp', () => {
const model = createSemanticModel();
// Two same-(file,name) defs → lookupExactAll returns 2 → refuse to guess.
model.symbols.add(FILE, 'list', 'method:OrderController.list#1', 'Method');
model.symbols.add(FILE, 'list', 'method:OrderController.list#2', 'Method');
const out = resolveRouteHandlerSymbols(model, [], [decoratorRoute()]);
expect(out.has('/orders')).toBe(false);
});
it('unknown handler name → fail-open, no stamp', () => {
const model = createSemanticModel(); // nothing registered
const out = resolveRouteHandlerSymbols(model, [], [decoratorRoute({ handlerName: 'ghost' })]);
expect(out.has('/orders')).toBe(false);
});
it('same-URL collision: an unresolvable first route reserves the slot so a later resolvable route cannot stamp it', () => {
const model = createSemanticModel();
// Only the SECOND route's handler exists in the model.
model.symbols.add(FILE, 'second', 'method:OrderController.second', 'Method');
const out = resolveRouteHandlerSymbols(
model,
[],
[
// First route at /orders is unresolvable (no such symbol) — but it is the
// route the routes phase makes the Route-node winner, so its slot must be
// reserved (empty), NOT filled by the later same-URL route.
decoratorRoute({ handlerName: 'first_missing' }),
decoratorRoute({ handlerName: 'second' }),
],
);
// Reservation holds: the URL carries no (wrong) handler. Pre-fix this would
// have stamped `method:OrderController.second` onto the first route's node.
expect(out.has('/orders')).toBe(false);
});
it('first-writer-wins among resolvable same-URL routes', () => {
const model = createSemanticModel();
model.symbols.add(FILE, 'winner', 'method:OrderController.winner', 'Method');
model.symbols.add(FILE, 'loser', 'method:OrderController.loser', 'Method');
const out = resolveRouteHandlerSymbols(
model,
[],
[decoratorRoute({ handlerName: 'winner' }), decoratorRoute({ handlerName: 'loser' })],
);
expect(out.get('/orders')).toBe('method:OrderController.winner');
});
});
describe('resolveRouteHandlerSymbols — Laravel framework routes', () => {
const CTRL = 'app/Http/Controllers/OrderController.php';
function laravelRoute(overrides: Partial<ExtractedRoute> = {}): ExtractedRoute {
return {
filePath: 'routes/web.php',
httpMethod: 'get',
routePath: '/orders',
routeName: null,
controllerName: 'OrderController',
methodName: 'index',
middleware: [],
prefix: null,
lineNumber: 1,
...overrides,
};
}
it('resolvable controller + unique method → stamped', () => {
const model = createSemanticModel();
model.symbols.add(CTRL, 'OrderController', 'class:OrderController', 'Class');
model.symbols.add(CTRL, 'index', 'method:OrderController.index', 'Method', {
ownerId: 'class:OrderController',
});
const out = resolveRouteHandlerSymbols(model, [laravelRoute()], []);
expect(out.get('/orders')).toBe('method:OrderController.index');
});
it('ambiguous controller short-name (>1) → fail-open, no stamp', () => {
const model = createSemanticModel();
model.symbols.add(
'app/A/OrderController.php',
'OrderController',
'class:A.OrderController',
'Class',
);
model.symbols.add(
'app/B/OrderController.php',
'OrderController',
'class:B.OrderController',
'Class',
);
const out = resolveRouteHandlerSymbols(model, [laravelRoute()], []);
expect(out.has('/orders')).toBe(false);
});
});

View file

@ -0,0 +1,341 @@
/**
* Java taint model (#2261) over real Java CFG and import capture output.
*/
import { createRequire } from 'node:module';
import type { ParsedImport } from 'gitnexus-shared';
import { assert, describe, expect, it } from 'vitest';
import { createJavaCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/java.js';
import { computeReachingDefs } from '../../../src/core/ingestion/cfg/reaching-defs.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import { emitJavaScopeCaptures } from '../../../src/core/ingestion/languages/java/captures.js';
import { interpretJavaImport } from '../../../src/core/ingestion/languages/java/interpret.js';
import { JAVA_TAINT_MODEL } from '../../../src/core/ingestion/taint/java-model.js';
import { hasTaintSafeSites } from '../../../src/core/ingestion/taint/site-safety.js';
import {
buildTaintImportIndex,
matchFunctionSites,
type FunctionSiteMatches,
type MatchedSinkCall,
type MatchedSource,
} from '../../../src/core/ingestion/taint/match.js';
import { computeTaintFlows } from '../../../src/core/ingestion/taint/propagate.js';
import { makeCfgHarness, bindingIdx, type CfgHarness } from '../../helpers/cfg-harness.js';
const javaGrammar = createRequire(import.meta.url)('tree-sitter-java') as Parameters<
typeof makeCfgHarness
>[0];
const java: CfgHarness = makeCfgHarness(javaGrammar, createJavaCfgVisitor(), 'fixture.java');
function importsFor(src: string): ParsedImport[] {
return emitJavaScopeCaptures(src, 'fixture.java')
.filter((m) => m['@import.statement'] !== undefined)
.map((m) => interpretJavaImport(m))
.filter((p): p is ParsedImport => p !== null);
}
function cfgOf(code: string, fnIndex = 0): FunctionCfg {
const cfg = java.cfgOf(code, fnIndex);
expect(hasTaintSafeSites(cfg)).toBe(true);
return cfg;
}
function matchesOf(code: string, fnIndex = 0): { cfg: FunctionCfg; matches: FunctionSiteMatches } {
const cfg = cfgOf(code, fnIndex);
return {
cfg,
matches: matchFunctionSites(cfg, JAVA_TAINT_MODEL, buildTaintImportIndex(importsFor(code))),
};
}
function analyze(code: string, fnIndex = 0) {
const { cfg, matches } = matchesOf(code, fnIndex);
const defUse = computeReachingDefs(cfg);
return { cfg, matches, flows: computeTaintFlows(cfg, defUse, matches) };
}
const allSources = (m: FunctionSiteMatches): MatchedSource[] =>
m.statements.flatMap((s) => [...s.sources]);
const allSinks = (m: FunctionSiteMatches): MatchedSinkCall[] =>
m.statements.flatMap((s) => [...s.sinks]);
function matchedSinkSite(cfg: FunctionCfg, matches: FunctionSiteMatches, sink: MatchedSinkCall) {
const sinkSite = matches.statements
.flatMap((stmt) => stmt.sinks.map((matched) => ({ stmt, matched })))
.find(({ matched }) => matched === sink);
assert(sinkSite !== undefined, 'expected matched sink site');
const site =
cfg.blocks[sinkSite.stmt.blockIndex].statements?.[sinkSite.stmt.statementIndex]?.sites?.[
sink.siteIndex
];
assert(site !== undefined, 'expected concrete sink site');
return site;
}
const wrap = (body: string, imports = ''): string => `${imports}
class C {
void f(javax.servlet.http.HttpServletRequest request, javax.servlet.http.HttpServletRequest req, Helper helper, String safe) {
${body}
}
}`;
describe('Java taint model (#2261)', () => {
it('matches assigned request call results as remote-input sources', () => {
const { cfg, matches } = matchesOf(wrap(`String p = request.getParameter("id");`));
const sources = allSources(matches);
expect(sources).toHaveLength(1);
expect(sources[0].type).toBe('call-result');
expect(sources[0].entry.kind).toBe('remote-input');
expect(sources[0].type === 'call-result' ? [...sources[0].resultDefs] : []).toEqual([
bindingIdx(cfg, 'p'),
]);
expect(matches.hasSource).toBe(true);
});
it('propagates an assigned request source into a static-import-proven file read sink', () => {
const { cfg, matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
readString(of(p));
`,
`
import static java.nio.file.Files.readString;
import static java.nio.file.Path.of;
`,
),
);
const p = bindingIdx(cfg, 'p');
const sink = allSinks(matches)[0];
expect(sink.entry.name).toBe('readString');
const site = matchedSinkSite(cfg, matches, sink);
expect(site?.callee).toBe('readString');
expect(site?.args?.[0]).toContainEqual([p, expect.any(Number)]);
expect(flows.status).toBe('computed');
expect(flows.findings).toHaveLength(1);
expect(flows.findings[0].sinkKind).toBe('path-traversal');
expect(flows.findings[0].source.type).toBe('call-result');
});
it('propagates regular-import-proven Files.readString into a path-traversal finding', () => {
const { cfg, matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
Files.readString(Path.of(p));
`,
`
import java.nio.file.Files;
import java.nio.file.Path;
`,
),
);
const p = bindingIdx(cfg, 'p');
const sinks = allSinks(matches);
expect(sinks).toHaveLength(1);
expect(sinks[0].entry.kind).toBe('path-traversal');
const site = matchedSinkSite(cfg, matches, sinks[0]);
expect(site.callee).toBe('Files.readString');
expect(site.args?.[0]).toContainEqual([p, expect.any(Number)]);
expect(flows.findings).toHaveLength(1);
expect(flows.findings[0].sinkKind).toBe('path-traversal');
});
it('supports getHeader call-result sources', () => {
const { cfg, matches, flows } = analyze(
wrap(
`
String p = request.getHeader("X-Path");
readString(of(p));
`,
`
import static java.nio.file.Files.readString;
import static java.nio.file.Path.of;
`,
),
);
const p = bindingIdx(cfg, 'p');
const sources = allSources(matches);
expect(sources).toHaveLength(1);
assert(sources[0].type === 'call-result', 'expected getHeader to be a call-result source');
expect(sources[0].entry.kind).toBe('remote-input');
expect([...sources[0].resultDefs]).toEqual([p]);
const sinks = allSinks(matches);
expect(sinks).toHaveLength(1);
expect(sinks[0].entry.kind).toBe('path-traversal');
expect(flows.findings).toHaveLength(1);
expect(flows.findings[0].source.type).toBe('call-result');
});
it('supports the short req receiver for call-result sources', () => {
const { cfg, matches, flows } = analyze(
wrap(
`
String p = req.getParameter("path");
readString(of(p));
`,
`
import static java.nio.file.Files.readString;
import static java.nio.file.Path.of;
`,
),
);
const p = bindingIdx(cfg, 'p');
const [source] = allSources(matches);
assert(source?.type === 'call-result', 'expected req.getParameter source');
expect([...source.resultDefs]).toEqual([p]);
expect(flows.findings).toHaveLength(1);
});
it('does not seed an unassigned request call result', () => {
const { matches, flows } = analyze(
wrap(
`readString(request.getParameter("path"));`,
'import static java.nio.file.Files.readString;',
),
);
expect(allSources(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not treat unrelated same-named receivers as servlet sources', () => {
const { matches, flows } = analyze(
wrap(
`
String p = helper.getParameter("path");
readString(p);
`,
'import static java.nio.file.Files.readString;',
),
);
expect(allSources(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not report a sink when the tainted value is not in the dangerous argument', () => {
const { cfg, matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
String unused = p;
readString(of(safe));
`,
`
import static java.nio.file.Files.readString;
import static java.nio.file.Path.of;
`,
),
);
const p = bindingIdx(cfg, 'p');
const sink = allSinks(matches)[0];
expect(sink.entry.name).toBe('readString');
const site = matchedSinkSite(cfg, matches, sink);
expect(site?.callee).toBe('readString');
expect(site?.args?.[0]).not.toContainEqual([p, expect.any(Number)]);
expect(site?.args?.[0]).not.toContain(p);
expect(flows.status).toBe('computed');
expect(flows.findings).toHaveLength(0);
});
it('does not match same-named readString calls without static import provenance', () => {
const { matches, flows } = analyze(
wrap(`
String p = request.getParameter("path");
readString(p);
helper.readString(p);
`),
);
expect(allSinks(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not report Paths.get or Path.of constructors as sinks', () => {
const { matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
get(p);
of(p);
`,
`
import static java.nio.file.Paths.get;
import static java.nio.file.Path.of;
`,
),
);
expect(allSinks(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not match unrelated static imports named readString', () => {
const { matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
readString(p);
`,
'import static com.example.Files.readString;',
),
);
expect(allSinks(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not match unrelated regular imports named Files', () => {
const { matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
Files.readString(p);
`,
'import com.example.Files;',
),
);
expect(allSinks(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not let a local Files receiver inherit import provenance', () => {
const { matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
Helper Files = helper;
Files.readString(p);
`,
'import java.nio.file.Files;',
),
);
expect(allSinks(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not report untainted input reaching the file read sink', () => {
const { matches, flows } = analyze(
wrap(
`readString(of(safe));`,
`
import static java.nio.file.Files.readString;
import static java.nio.file.Path.of;
`,
),
);
expect(allSinks(matches)).toHaveLength(1);
expect(flows.findings).toHaveLength(0);
});
it('does not report regular-import path constructors without a file sink', () => {
const { matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
Path.of(p);
`,
'import java.nio.file.Path;',
),
);
expect(allSinks(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
});

View file

@ -25,7 +25,7 @@ import {
type FunctionSiteMatches,
type MatchedSanitizerCall,
type MatchedSinkCall,
type MatchedSourceRead,
type MatchedSource,
} from '../../../src/core/ingestion/taint/match.js';
import {
getSourceSinkConfig,
@ -47,7 +47,7 @@ function matchesOf(
const allSinks = (m: FunctionSiteMatches): MatchedSinkCall[] =>
m.statements.flatMap((s) => [...s.sinks]);
const allSources = (m: FunctionSiteMatches): MatchedSourceRead[] =>
const allSources = (m: FunctionSiteMatches): MatchedSource[] =>
m.statements.flatMap((s) => [...s.sources]);
const allSanitizers = (m: FunctionSiteMatches): MatchedSanitizerCall[] =>
m.statements.flatMap((s) => [...s.sanitizers]);
@ -78,6 +78,21 @@ function f(c) { cp.exec(c); }`);
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['exec']);
});
it('named import from a same-tail module is not canonicalized as a namespace handle', () => {
const spec: SourceSinkSanitizerSpec = {
sources: [],
sinks: [{ name: 'readString', kind: 'path-traversal', args: [0], module: 'pkg.Files' }],
sanitizers: [],
};
const m = matchesOf(
`import { Files } from 'pkg.Files';
function f(p) { Files.readString(p); }`,
0,
spec,
);
expect(allSinks(m)).toHaveLength(0);
});
it('node: scheme prefix is normalized — `from "node:child_process"` matches too', () => {
const m = matchesOf(`import { execSync } from 'node:child_process';
function f(c) { execSync(c); }`);
@ -276,7 +291,13 @@ describe('registry + model identity', () => {
it('registerBuiltinTaintModels registers TS, JS, and Python (idempotent); others stay undefined', () => {
registerBuiltinTaintModels();
registerBuiltinTaintModels(); // idempotent — last-write-wins on the same ids
expect(registeredTaintLanguages().sort()).toEqual(['javascript', 'python', 'typescript']);
expect(registeredTaintLanguages().sort()).toEqual([
'java',
'javascript',
'python',
'typescript',
]);
expect(getSourceSinkConfig('java')).toBe(BUILTIN_TAINT_MODELS.java);
expect(getSourceSinkConfig('typescript')).toBe(TS_JS_TAINT_MODEL);
expect(getSourceSinkConfig('javascript')).toBe(TS_JS_TAINT_MODEL);
expect(getSourceSinkConfig('python')).toBe(BUILTIN_TAINT_MODELS.python);

View file

@ -9,13 +9,14 @@ import { createPythonCfgVisitor } from '../../../src/core/ingestion/cfg/visitors
import { emitPythonScopeCaptures } from '../../../src/core/ingestion/languages/python/captures.js';
import { interpretPythonImport } from '../../../src/core/ingestion/languages/python/interpret.js';
import { PYTHON_TAINT_MODEL } from '../../../src/core/ingestion/taint/python-model.js';
import type { SourceSinkSanitizerSpec } from '../../../src/core/ingestion/taint/source-sink-config.js';
import { hasTaintSafeSites } from '../../../src/core/ingestion/taint/site-safety.js';
import {
buildTaintImportIndex,
matchFunctionSites,
type FunctionSiteMatches,
type MatchedSinkCall,
type MatchedSourceRead,
type MatchedSource,
} from '../../../src/core/ingestion/taint/match.js';
import { makeCfgHarness } from '../../helpers/cfg-harness.js';
@ -28,15 +29,19 @@ function importsFor(src: string): ParsedImport[] {
.filter((p): p is ParsedImport => p !== null);
}
function matchesOf(code: string, fnIndex = 0): FunctionSiteMatches {
function matchesOf(
code: string,
fnIndex = 0,
spec: SourceSinkSanitizerSpec = PYTHON_TAINT_MODEL,
): FunctionSiteMatches {
const cfg = harness.cfgOf(code, fnIndex);
expect(hasTaintSafeSites(cfg)).toBe(true);
return matchFunctionSites(cfg, PYTHON_TAINT_MODEL, buildTaintImportIndex(importsFor(code)));
return matchFunctionSites(cfg, spec, buildTaintImportIndex(importsFor(code)));
}
const allSinks = (m: FunctionSiteMatches): MatchedSinkCall[] =>
m.statements.flatMap((s) => [...s.sinks]);
const allSources = (m: FunctionSiteMatches): MatchedSourceRead[] =>
const allSources = (m: FunctionSiteMatches): MatchedSource[] =>
m.statements.flatMap((s) => [...s.sources]);
describe('Python taint model (#2204)', () => {
@ -75,6 +80,25 @@ def f(request):
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['run']);
});
it('does not dedupe named imports from a same-tail module path', () => {
const spec: SourceSinkSanitizerSpec = {
sources: [],
sinks: [{ name: 'read_string', kind: 'path-traversal', args: [0], module: 'pkg.Files' }],
sanitizers: [],
};
const m = matchesOf(
`
from pkg.Files import Files
def f(p):
Files.read_string(p)
`,
0,
spec,
);
expect(allSinks(m)).toHaveLength(0);
});
it('does not guess positional sink slots for keyword arguments', () => {
const m = matchesOf(`
import subprocess as sp

View file

@ -28,6 +28,19 @@ const SPEC: SourceSinkSanitizerSpec = {
sanitizers: [{ name: 'escape', neutralizes: ['command-injection'], global: true }],
};
const CALL_RESULT_SOURCE_SPEC: SourceSinkSanitizerSpec = {
sources: [
{
type: 'call-result',
kind: 'remote-input',
receivers: ['request'],
methods: ['getParameter'],
},
],
sinks: [],
sanitizers: [],
};
function harvest(code: string, spec: SourceSinkSanitizerSpec = SPEC, fnIndex = 0) {
const cfg: FunctionCfg = cfgOf(code, fnIndex);
const defUse = computeReachingDefs(cfg);
@ -93,15 +106,15 @@ describe('harvestFunctionSummary — call-arg sanitizer exclusions (#2084 review
// records that command-injection was neutralised on the path.
const f = harvest(`function f(x: string) { const y = escape(x); helper(y); }`);
const edge = f.paramToCallArg.find((c) => c.calleeName === 'helper');
expect(edge).toBeDefined();
expect(edge!.neutralized).toEqual(['command-injection']);
if (edge === undefined) throw new Error('expected helper call-arg edge');
expect(edge.neutralized).toEqual(['command-injection']);
});
it('records no neutralized when the param reaches the call directly', () => {
const f = harvest(`function f(x: string) { helper(x); }`);
const edge = f.paramToCallArg.find((c) => c.calleeName === 'helper');
expect(edge).toBeDefined();
expect(edge!.neutralized).toBeUndefined();
if (edge === undefined) throw new Error('expected helper call-arg edge');
expect(edge.neutralized).toBeUndefined();
});
});
@ -115,6 +128,19 @@ describe('harvestFunctionSummary — source→callee-arg (fixpoint seed)', () =>
const f = harvest(`function f() { const u = req.body; runIt(u); }`);
expect(f.sourceToCallArg.some((s) => s.calleeName === 'runIt')).toBe(true);
});
it('records an assigned call-result source passed via a local into a callee argument', () => {
const f = harvest(
`function f(request: { getParameter(name: string): string }) {
const u = request.getParameter('path');
runIt(u);
}`,
CALL_RESULT_SOURCE_SPEC,
);
expect(f.sourceToCallArg).toEqual([
{ sourceKind: 'remote-input', callLine: 3, argIndex: 0, calleeName: 'runIt' },
]);
});
});
describe('harvestFunctionSummary — call-result seeds (#2084 review P1-1)', () => {
@ -164,6 +190,17 @@ describe('harvestFunctionSummary — source→return', () => {
expect(f.sourceToReturn).toEqual([{ sourceKind: 'remote-input' }]);
});
it('records an assigned call-result source returned via a local', () => {
const f = harvest(
`function f(request: { getParameter(name: string): string }) {
const u = request.getParameter('path');
return u;
}`,
CALL_RESULT_SOURCE_SPEC,
);
expect(f.sourceToReturn).toEqual([{ sourceKind: 'remote-input' }]);
});
it('is empty when no source is present', () => {
const f = harvest(`function f(x: string) { return x; }`);
expect(f.sourceToReturn).toEqual([]);
@ -185,9 +222,9 @@ describe('harvestFunctionSummary — documented limitations', () => {
// fix (formal-param index from the worker) is deferred.
const f = harvest(`function f([a, b]: string[], x: string) { exec(x); }`);
const xSink = f.paramToSink.find((s) => s.sinkKind === 'command-injection');
expect(xSink).toBeDefined();
if (xSink === undefined) throw new Error('expected command-injection param sink');
// Current (limited) behaviour: ordinal index 2, NOT the formal index 1.
expect(xSink!.param).toBe(2);
expect(xSink.param).toBe(2);
});
});

View file

@ -18,6 +18,7 @@
import { describe, it, expect } from 'vitest';
import { cfgsOf, importsFor } from '../../helpers/ts-cfg-harness.js';
import { emitFileCfgs } from '../../../src/core/ingestion/cfg/emit.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import type { SourceSinkSanitizerSpec } from '../../../src/core/ingestion/taint/source-sink-config.js';
import {
emitFileTaint,
@ -44,6 +45,19 @@ const MECH_ALL_ARGS: SourceSinkSanitizerSpec = {
sinks: [{ name: 'exec', kind: 'command-injection', global: true }],
};
const CALL_RESULT_SOURCE_SPEC: SourceSinkSanitizerSpec = {
sources: [
{
type: 'call-result',
kind: 'remote-input',
receivers: ['request'],
methods: ['getParameter'],
},
],
sinks: [{ name: 'exec', kind: 'command-injection', args: [0], global: true }],
sanitizers: [],
};
interface RunResult {
graph: KnowledgeGraph;
result: TaintEmitResult;
@ -100,6 +114,36 @@ function handler(req: { body: string }) {
expect(tainted[0].id.startsWith('TAINTED:fixture.ts:')).toBe(true);
});
it('preserves the legacy member-read TAINTED edge identity', () => {
const { tainted } = run(CODE);
expect(tainted[0].id).toBe(
'TAINTED:fixture.ts:2:0:command-injection:2:0.0:req:2:17:2:1.0.0:cmd:3:8:exec:body',
);
});
it('persists a call-result source TAINTED edge with deterministic identity', () => {
const { result, tainted } = run(
`
function handler(request: { getParameter(name: string): string }) {
const cmd = request.getParameter('cmd');
exec(cmd);
}`,
{ spec: CALL_RESULT_SOURCE_SPEC },
);
expect(result.functionsAnalyzed).toBe(1);
expect(result.findingsEmitted).toBe(1);
expect(tainted).toHaveLength(1);
expect(tainted[0].id).toBe(
'TAINTED:fixture.ts:2:0:command-injection:2:0.0:call-result:cmd:3:8:request.getParameter:2:1.0.0:cmd:3:8:exec',
);
const decoded = decodeTaintPath(tainted[0].reason);
expect(decoded.ok).toBe(true);
if (decoded.ok) {
expect(decoded.kind).toBe('command-injection');
expect(decoded.hops.map((h) => `${h.variable}@${h.line}`)).toEqual(['cmd@3', 'cmd@4']);
}
});
it('the persisted reason decodes via the shared codec with ordered hops + variables', () => {
const { tainted } = run(CODE);
const decoded = decodeTaintPath(tainted[0].reason);

View file

@ -157,6 +157,25 @@ describe('GITNEXUS_TOOLS', () => {
expect(renameTool.inputSchema.required).toContain('new_name');
});
it('trace tool advertises cross-repo @group support plus pdg/crossDepth flags (U3)', () => {
const traceTool = GITNEXUS_TOOLS.find((t) => t.name === 'trace')!;
const props = traceTool.inputSchema.properties as Record<
string,
{ type?: string; default?: unknown; minimum?: number; description?: string }
>;
// Experimental cross-repo flags are advertised and optional.
expect(props.pdg).toBeDefined();
expect(props.pdg.type).toBe('boolean');
expect(props.crossDepth).toBeDefined();
expect(props.crossDepth.type).toBe('number');
expect(traceTool.inputSchema.required).toEqual([]);
// The repo param and top-level description both name the @group entry point.
expect(props.repo.description).toMatch(/@groupName/);
expect(traceTool.description).toMatch(/CROSS-REPO/i);
expect(traceTool.description).toContain('ContractLink');
expect(traceTool.description).toContain('crossings');
});
it('detect_changes tool has no required parameters', () => {
const detectTool = GITNEXUS_TOOLS.find((t) => t.name === 'detect_changes')!;
expect(detectTool.inputSchema.required).toEqual([]);