mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
Merge branch 'main' into docs/kilo-code-mcp
This commit is contained in:
commit
e91c9c43bc
63 changed files with 7054 additions and 364 deletions
|
|
@ -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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
90
gitnexus/bench/cross-repo-trace/README.md
Normal file
90
gitnexus/bench/cross-repo-trace/README.md
Normal 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).
|
||||
281
gitnexus/bench/cross-repo-trace/verify.mjs
Normal file
281
gitnexus/bench/cross-repo-trace/verify.mjs
Normal 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);
|
||||
|
|
@ -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`."
|
||||
|
|
|
|||
6
gitnexus/package-lock.json
generated
6
gitnexus/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
1268
gitnexus/src/core/group/cross-trace.ts
Normal file
1268
gitnexus/src/core/group/cross-trace.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() : '';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export function interpretJavaImport(captures: CaptureMatch): ParsedImport | null
|
|||
localName: nameCap?.text ?? simpleName,
|
||||
importedName: simpleName,
|
||||
targetRaw: sourceCap.text,
|
||||
targetIncludesImportedName: true,
|
||||
};
|
||||
}
|
||||
case 'wildcard': {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
21
gitnexus/src/core/ingestion/route-extractors/route-path.ts
Normal file
21
gitnexus/src/core/ingestion/route-extractors/route-path.ts
Normal 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, '/') || '/';
|
||||
}
|
||||
|
|
@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
24
gitnexus/src/core/ingestion/taint/java-model.ts
Normal file
24
gitnexus/src/core/ingestion/taint/java-model.ts
Normal 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: [],
|
||||
};
|
||||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ CREATE NODE TABLE Route (
|
|||
errorKeys STRING[],
|
||||
middleware STRING[],
|
||||
method STRING,
|
||||
handlerSymbolId STRING,
|
||||
PRIMARY KEY (id)
|
||||
)`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
374
gitnexus/test/integration/group/cross-trace-e2e.test.ts
Normal file
374
gitnexus/test/integration/group/cross-trace-e2e.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
251
gitnexus/test/integration/route-parse-skip.test.ts
Normal file
251
gitnexus/test/integration/route-parse-skip.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ describe('Blade/template static route extraction', () => {
|
|||
},
|
||||
],
|
||||
allDecoratorRoutes: [],
|
||||
routeHandlerSymbols: new Map(),
|
||||
} as unknown as ParseOutput;
|
||||
|
||||
const output = await routesPhase.execute(
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
972
gitnexus/test/unit/group/cross-trace.test.ts
Normal file
972
gitnexus/test/unit/group/cross-trace.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
76
gitnexus/test/unit/group/http-consumer-signals.test.ts
Normal file
76
gitnexus/test/unit/group/http-consumer-signals.test.ts
Normal 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
|
|
@ -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 [];
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
});
|
||||
|
||||
|
|
|
|||
130
gitnexus/test/unit/group/resolve-bridge-neighbors.test.ts
Normal file
130
gitnexus/test/unit/group/resolve-bridge-neighbors.test.ts
Normal 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!);
|
||||
});
|
||||
});
|
||||
148
gitnexus/test/unit/resolve-route-handler-symbols.test.ts
Normal file
148
gitnexus/test/unit/resolve-route-handler-symbols.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
341
gitnexus/test/unit/taint/java-model-match.test.ts
Normal file
341
gitnexus/test/unit/taint/java-model-match.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue