mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
* feat(routes): resolve + persist handler symbol on Route nodes (#2138 part 2, WIP) Part 2 groundwork for #2138: give the graph-assisted HTTP provider path the handler symbol directly, so it no longer re-parses source to recover the handler name. (The remaining parse-skip in extract() + a call-count benchmark land in a follow-up commit.) - ExtractedDecoratorRoute gains `handlerName`; the Spring extractor captures the decorated method's name (the method_declaration node is in hand). - New `resolveRouteHandlerSymbols` (call-processor) resolves each route's handler to a real symbol UID, keyed by normalized route URL — Laravel framework routes (controller + method) and decorator routes (Spring/FastAPI) both reduce to `(filePath, name) -> nodeId`. Threaded through the parse phase onto `ParseOutput.routeHandlerSymbols`. - routes phase stamps `Route.handlerSymbolId`; persisted end-to-end (schema + Route CSV row + getCopyQuery COPY columns), mirroring Part 1's `method`. - HttpRouteExtractor: `HANDLES_ROUTE_QUERY` returns `handlerSymbolId`; `extractProvidersGraph` uses it as the authoritative symbol and SKIPS `getDetections()` for resolved rows (CONTAINS is a cheap graph lookup for the display name only — no tree-sitter parse). Fully backward compatible: an unresolved/old-index route with no `handlerSymbolId` keeps the source-scan fallback. - Extracted `normalizeExtractedRoutePath` to `route-extractors/route-path.ts` (shared by routes phase + resolver without an import cycle). - SCHEMA_BUMP 6->7 (ParseWorkerResult gained `handlerName`); regenerated the emit-persistence byte-identity baseline (route.csv header gained two columns). - Tests: Spring pipeline asserts the Route node carries a handlerSymbolId resolving to the handler method; extractor fast-path test proves the handler resolves with zero source detections. Refs #2138 * perf(group/http): skip source parse for graph-covered route files (#2138 Part 2) Builds on the persisted Route.handlerSymbolId (U0–U3a). When a file's HANDLES_ROUTE rows all resolve a handler symbol AND its language plugin declares routeCoverage: 'complete' (Java/Python/PHP), the graph is authoritative for that file's providers, so the source scan + tree-sitter parse can be skipped — the scan would only re-discover routes the graph already has. This is the measurable parse reduction #2167 could not show. Consumer safety: routeCoverage: 'complete' asserts *provider* Route-node completeness only. The scan() of those same languages also emits consumer detections (RestTemplate/WebClient/OkHttp/Feign, Guzzle/Http::, requests/httpx), and ingestion's FETCHES edges are JS/TS-only — so the graph cannot back up server-side consumers. A provider-covered controller that also calls out would otherwise lose its consumer contract. Guarded by a cheap, parse-free text gate. - types: HttpLanguagePlugin gains - routeCoverage?: 'complete' | 'partial' (default 'partial') - hasConsumerSignals?(content): false only when the raw source provably has no outbound-HTTP call this plugin detects (conservative). - java/python/php: mark routeCoverage 'complete' + implement hasConsumerSignals with a token regex over their consumer idioms. - http-route-extractor: run the graph provider pass first to build a coveredFiles set; then keep a file covered only when hasConsumerSignals(content) === false (read via readSafe, no parse). scanFiles = files not covered → drives collectProjectDetections + both source scans. Fail-open per file: any unresolved row, a 'partial' language, a positive consumer signal, a missing hook, or an unreadable file leaves the file in the scan set. The orchestrator names no languages — token knowledge stays in the plugins. Net: pure-provider controllers skip the parse (the win); controllers that also call out are still parsed (no consumer loss); partial-coverage languages and graph-less runs are unchanged. - test: route-parse-skip integration test spies the real parseSourceSafe to COUNT parses over a temp repo of Spring controllers with a mock DB — baseline (every file parsed), fully-covered (0 parses), mixed (unresolved file falls back, resolved stays skipped), and provider+consumer (a covered controller that also calls restTemplate is parsed; its consumer contract survives). * fix(group/http): cover Spring HTTP Interface @*Exchange in Java consumer-signal gate #2254 (merged) added Spring 6 HTTP Interface `@(Get|...)Exchange` / `@HttpExchange` as a new Java *consumer* idiom. The #2138 parse-skip consumer-safety gate must recognize it, or a provider-covered file carrying an `@GetExchange` could be parse-skipped and lose that consumer contract. Add `Exchange` to JAVA_HTTP_PLUGIN.hasConsumerSignals (conservative; also matches `restTemplate.exchange(`). * style(group/http): prettier formatting for #2138 Part 2 files * style(ingestion): prettier formatting for call-processor.ts (#2138 Part 2) * fix(group/http): P1 (Java over-claim) + P2 (handler mis-attribution) on top of #2268 (#2138 Part 2) Re-applied on the maintainer's #2268 (expanded Java/Kotlin consumer extraction) base. P1 — `routeCoverage: 'complete'` over-claimed for Java: the graph provider set is a strict subset of the group scan (array-form `@GetMapping({...})`, interface-inherited routes, same-URL multi-verb have no graph Route node), so parse-skip could drop those group-only providers. - java/python → default 'partial' (always source-scanned). Java flips to 'complete' only once ingestion provider extraction matches the group scan (a separate follow-up). Python was a no-op anyway (no handlerName resolved); 'complete' was a latent trap. PHP stays 'complete' (Laravel ingestion ⊇ the group scan, the one language the skip engages for). - python hasConsumerSignals widened to a true superset of scan() (uri=/url= wrapper, aiohttp, urllib). Java's gate already covers #2268's consumer set (same receivers; the @*Exchange token is present). P2 — resolveRouteHandlerSymbols: reserve the URL slot on first encounter even when unresolved (mirrors addRoute first-writer-wins, so a later same-URL route can't stamp the node-winner's slot); refuse to guess on an ambiguous same-name lookup (exactly one match → use it; zero/many → fail-open, never a wrong handler). The cross-source case (filesystem route winning a URL a framework route also normalizes to) is unchanged — the resolver never receives filesystem routes — and stays fail-open. Tests: - route-parse-skip rewritten: the parse-skip win is proven on PHP (fully covered → 0 parses; mixed fallback; consumer-covered file still parsed), plus three Java P1 regression guards (array-form / interface-inherited / multi-verb) asserting the group-only routes survive — verified they go red if Java is flipped back to 'complete'. - resolve-route-handler-symbols: direct unit tests (the fn had none) — unique resolve, ambiguous/unknown fail-open, same-URL reservation, first-writer-wins. - http-consumer-signals: each plugin's hasConsumerSignals is a superset of its scan() consumer idioms; pure providers return false. - route-handler-symbol-roundtrip: real-LadybugDB CSV→COPY→query for Route.handlerSymbolId. --------- Co-authored-by: henry <zhangwei2017@unipus.cn> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
parent
1c8ad84796
commit
b16ec344f7
24 changed files with 985 additions and 69 deletions
|
|
@ -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`."
|
||||
|
|
|
|||
|
|
@ -676,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[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -78,6 +78,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 = `
|
||||
|
|
@ -282,22 +283,57 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
};
|
||||
|
||||
const files = await getScannedFiles();
|
||||
await collectProjectDetections(files);
|
||||
|
||||
// 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, 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),
|
||||
);
|
||||
|
||||
const graphConsumers =
|
||||
dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, getDetections) : [];
|
||||
const consumers = this.mergeGraphAndSourceContracts(
|
||||
graphConsumers,
|
||||
await this.extractConsumersSourceScan(files, getDetections),
|
||||
await this.extractConsumersSourceScan(scanFiles, getDetections),
|
||||
);
|
||||
|
||||
return [...providers, ...consumers];
|
||||
|
|
@ -323,8 +359,14 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
private async extractProvidersGraph(
|
||||
db: CypherExecutor,
|
||||
getDetections: (rel: string) => Promise<HttpDetection[]>,
|
||||
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 +396,90 @@ 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 we read it straight
|
||||
// from the graph and SKIP the `getDetections()` source-scan/parse the
|
||||
// legacy path needed just to recover the handler name. CONTAINS is a
|
||||
// cheap graph query (no tree-sitter parse) used only to surface the
|
||||
// handler's display name/path; the uid is authoritative regardless.
|
||||
if (!method) method = 'GET';
|
||||
symbolUid = handlerSymbolId;
|
||||
if (fileId) {
|
||||
try {
|
||||
const syms = await db(CONTAINS_QUERY, { fileId });
|
||||
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 */
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy fallback (old index / unresolved handler): recover the handler
|
||||
// name from the plugin's scan of the handler file (this parses source).
|
||||
// Always run the lookup: even when method is set, we still need the name.
|
||||
const detections = filePath ? await getDetections(filePath) : [];
|
||||
const providerDetections = detections.filter((d) => d.role === 'provider');
|
||||
let handlerName: string | null = null;
|
||||
// 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 so
|
||||
// handlerName stays null and we skip symbol enrichment, keeping the
|
||||
// file-basename fallback rather than letting pickSymbolUid pick the
|
||||
// first Function/Method (which reintroduces mis-attribution).
|
||||
if (match) {
|
||||
if (!method) method = match.method;
|
||||
handlerName = match.name;
|
||||
}
|
||||
if (!method) method = 'GET';
|
||||
|
||||
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;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const pathNorm = pathNormEarly;
|
||||
const cid = contractIdFor(method, pathNorm);
|
||||
|
||||
out.push({
|
||||
contractId: cid,
|
||||
type: 'http',
|
||||
|
|
@ -432,6 +497,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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -200,6 +200,53 @@ 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('CONTAINS')) {
|
||||
return [
|
||||
{
|
||||
uid: HID,
|
||||
name: 'createOrder',
|
||||
filePath: 'OrderController.java',
|
||||
labels: ['Method'],
|
||||
0: HID,
|
||||
1: 'createOrder',
|
||||
2: 'OrderController.java',
|
||||
3: ['Method'],
|
||||
},
|
||||
];
|
||||
}
|
||||
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
|
||||
// CONTAINS graph query (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')]);
|
||||
|
||||
|
|
|
|||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue