From 77741fe13a384e915f1a74805b2edb13dfc95b82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Mon, 22 Jun 2026 09:45:11 +0100 Subject: [PATCH 1/5] feat(group): expand Java and Kotlin HTTP consumer extraction (re #1888) (#2268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(group): extract RestTemplate URI.create(...) static paths (Java) Widen the RestTemplate query @path captures to (_) and resolve URI.create("/x") arguments via a new extractStaticPathExpression helper. Variable-bound paths stay unresolved (no consumer). * feat(group): resolve RestTemplate UriComponentsBuilder chains (Java) Add appendPath + recursive extractUriComponentsBuilderPath (fromPath/ fromUriString/fromHttpUrl seeds, path/pathSegment append, build/query passthrough). Host-bearing seeds are normalized downstream. Non-literal segments stay unresolved. * feat(group): infer OkHttp request verb from builder chain (Java) Walk up from the matched .url(...) call to the sibling verb helper (.post()/.method("X")), defaulting to GET. Variable-bound verbs stay GET. Re-document the Kotlin OkHttp GET-default pin as an accepted Java/Kotlin asymmetry (Kotlin verb-walk is a tracked follow-up). * feat(group): Java HttpClient HEAD, .method("X"), and default-GET Add HEAD to the verb-helper regex and two dedicated pattern families: .method("VERB", body) (covers PATCH) and a bare .build() defaulting to GET. The three families terminate at distinct calls, so each chain matches exactly one (no double-emit); variable-bound verbs stay unresolved. * feat(group): extract fully-qualified Java route annotations Widen JAVA_ROUTE_ANNOTATION_PATTERNS @ann to match scoped_identifier (predicate-free node-type change), normalizing to the trailing segment via simpleName in the scan loop. Snapshot-verified: only previously unmatched FQN annotations gain routes; existing contracts unchanged. Brings Java to FQN parity with the Kotlin plugin (closes #2254 limitation). * refactor(group): reuse simpleName helper in hasAnnotation Drop the inline split('.').pop() (which shadowed the new module-level simpleName) and call the helper. Note appendPath's deliberate divergence from the shared joinPath so it is not accidentally unified. * docs(test): fix reversed Java/Kotlin OkHttp asymmetry comment The comment stated .kt->POST / .java->GET; it is the opposite — Java infers the verb (inferOkHttpMethod) so .java emits POST, while Kotlin still defaults so .kt emits GET. Aligns the prose with the assertion below it. * docs(group): correct stale kotlin.ts OkHttp parity comment The comment claimed Kotlin's GET-default 'mirrors java.ts:OK_HTTP_PATTERNS' and is 'the same trade-off Java has accepted'. The Java plugin now infers the verb (inferOkHttpMethod), so this is now a documented Java/Kotlin asymmetry; the Kotlin verb-walk is the tracked follow-up. * test(group): pin OkHttp default-GET branch on a distinct path The bare `.build()` (no verb call) now uses /api/bare-build and is asserted individually, so the default-GET branch of inferOkHttpMethod is no longer masked by the explicit .get() case collapsing into the same {param} slot. * fix(group): skip OkHttp emission for variable-bound .method(verb) inferOkHttpMethod now returns string|null: an explicit .method(verb, …) with a non-literal verb returns null and the loop skips it, instead of asserting a wrong GET contract. A bare .url().build() with no verb call still defaults to GET (OkHttp's real default). Matches WebClient long-form, which also skips variable-bound verbs. * fix(group): strip query from UriComponentsBuilder seed literal A query baked into the seed (fromUriString("/base?x=1")) was returned verbatim, so a later .path("/sub") glued onto it (/base?x=1/sub) and normalizeHttpPath truncated the tail at ? to /base. Strip ?query at the seed so .path() appends to a clean base → /base/sub. Host prefixes are preserved and stripped downstream by normalizeConsumerPath. * fix(group): add recursion depth guard to extractUriComponentsBuilderPath The recursive builder-chain walk was unbounded; a pathological or machine-generated chain could overflow the stack. Cap recursion at MAX_BUILDER_DEPTH (100) and return null past it — consistent with the project's other AST-depth guards. * docs(group): document accepted FQN simple-name collision trade-off The route discriminator matches on the trailing annotation segment, so a non-Spring annotation sharing a route name (@com.evil.GetMapping) is treated as a route — the same trade-off hasAnnotation makes and the intended Kotlin parity. Note why package-origin gating is deliberately not added. * refactor(group): extract static-path helpers to java-static-path.ts Move the URI.create / UriComponentsBuilder resolution helpers (methodInvocation*, firstLiteralArgument, appendPath, extractUri*, extractStaticPathExpression) out of java.ts (back under ~1000 lines). java.ts imports the four it consumes; inferOkHttpMethod stays. Pure move, behavior-preserving — full group suite unchanged. * fix(group): walk builder chain for Java HttpClient verb (#2268) Replace the three rigid JAVA_HTTP_CLIENT_* pattern families with one .uri()-anchored query plus inferHttpClientMethod, which walks up the fluent chain for the verb (mirroring inferOkHttpMethod). The walk is transparent to intervening .header()/.timeout()/.version() calls, so a header/timeout hop before the terminal no longer silently drops the consumer contract. Relocate both verb-walks onto a shared inferBuilderVerb in java-static-path.ts and de-export the now-internal methodInvocation* primitives; java.ts drops 1015 -> 910 lines. * fix(group): append UriComponentsBuilder .path() verbatim (#2268) Spring's UriComponentsBuilder.path(p) appends p as-is without inserting a slash (then collapses duplicate slashes), unlike .pathSegment() which slash-joins. The resolver used the always-one-slash appendPath for both, so fromPath("/api").path("users") resolved to /api/users instead of Spring's /apiusers. Switch the .path() branch to verbatim append plus a colon-aware duplicate-slash collapse (preserving a host seed's ://); .pathSegment() keeps appendPath. * fix(group): skip empty-string verb literal in builder verb-walk (#2268) `.method("", body)` produced a malformed `http::::/path` consumer: unquoteLiteral('""') returns "" (not null), so the `=== null` guard let an empty method through. Treat a falsy literal verb as unresolvable (return null from the shared inferBuilderVerb) and switch the OkHttp and HttpClient emission guards to falsiness, so an empty verb skips like a variable-bound one. * test(group): harden Java HTTP consumer coverage (#2268) Add coverage beyond the tri-review findings: a count guard on the UriComponentsBuilder query-seed test (so a double-emit can't slip past the two find assertions), an exchange()+UriComponentsBuilder end-to-end case (the widened (_) @path exchange capture was only covered with URI.create), and an HttpClient .method("REPORT") custom-verb pass-through pin. * docs(group): document pre-path builder rigidity + fix stale refs (#2268) Document the OkHttp pre-.url() limitation (a builder call before .url() is missed) at OK_HTTP_PATTERNS, cross-referencing the Java-HttpClient pre-.uri() dual the verb-walk rewrite leaves in place — so neither comment overclaims that the chain is walked before the path call. Update the now-stale 'inferOkHttpMethod in java.ts' references in kotlin.ts and the test to point at java-static-path.ts after the relocation. * feat(group): match Java HTTP consumer chains with a pre-path builder call (#2268) The OkHttp .url() and HttpClient .uri() queries required the path call to sit directly on the construction, so a builder call BEFORE it — new Request.Builder().addHeader(...).url(...) or HttpRequest.newBuilder().version(v).uri(...) — silently dropped the consumer contract. Match the path call on any receiver and re-impose the framework anchor in JS (okHttpUrlRootsAtBuilder / httpClientUriRootsAtNewBuilder: the chain must root at new Request.Builder() / HttpRequest.newBuilder()), so a preceding call is captured while an unrelated .url()/.uri() is rejected. The verb-walk now scans the whole chain, so a verb set before the path call also resolves. Also extract the HttpRequest.newBuilder(URI.create(...)) constructor-arg form (skipped when a later .uri() overrides it). Resolves the deferred follow-ups from the round-2 tri-review. * feat(group): Kotlin OkHttp verb-walk parity with Java (#2268) The Kotlin OkHttp consumer always emitted GET while the Java side walks the builder chain to recover the verb — a documented Java/Kotlin asymmetry. Mirror the verb-walk into kotlin.ts, adapted to the tree-sitter-kotlin call_expression/navigation_expression grammar: match .url("literal") on any receiver, gate to chains rooting at Request.Builder() (kotlinUrlRootsAtRequestBuilder), and scan the whole chain for the verb (inferKotlinOkHttpMethod — last-wins, null-skip for a variable/empty .method(verb), resolves a named-argument .method(method="X")). This brings .kt to full parity with .java — verb inference, a builder call before .url(), and verb-before-url — pinned by two new Java<->Kotlin parity-harness rows. Flips the former GET-default asymmetry test. --- .../http-patterns/java-static-path.ts | 269 ++++++ .../group/extractors/http-patterns/java.ts | 197 +++-- .../group/extractors/http-patterns/kotlin.ts | 166 +++- .../unit/group/http-route-extractor.test.ts | 834 +++++++++++++++++- 4 files changed, 1346 insertions(+), 120 deletions(-) create mode 100644 gitnexus/src/core/group/extractors/http-patterns/java-static-path.ts diff --git a/gitnexus/src/core/group/extractors/http-patterns/java-static-path.ts b/gitnexus/src/core/group/extractors/http-patterns/java-static-path.ts new file mode 100644 index 000000000..cf51e5d3f --- /dev/null +++ b/gitnexus/src/core/group/extractors/http-patterns/java-static-path.ts @@ -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 `(? (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', + ); +} diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index 5afebbace..7d70864cd 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -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>); -// ─── 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>); +// 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>); + +// 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') { @@ -727,7 +784,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', @@ -743,7 +800,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', @@ -801,15 +858,25 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { } // ─── 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, confidence: 0.7, @@ -817,18 +884,48 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { } // ─── 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, diff --git a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts index 4286ba758..3b51dc722 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts @@ -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>); // ─── 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 `, }, ], @@ -983,15 +1089,23 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { } // ─── 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, confidence: 0.7, diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 2d688c7e8..f1af4ff62 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -1158,12 +1158,13 @@ public class StatusController implements StatusApi { ).toHaveLength(0); }); - it('does not extract fully-qualified Java route annotations (documented limitation #2254)', async () => { - // JAVA_ROUTE_ANNOTATION_PATTERNS binds `name: (identifier)`; a FQN route - // annotation parses its name as `scoped_identifier` and is not matched, so - // its route is not extracted (only the route string — the controller itself - // is still recognised). This pins that documented limitation / asymmetry - // with Kotlin. If FQN matching is ever added, flip this assertion. + it('extracts fully-qualified Java route annotations (#2254 FQN follow-through)', async () => { + // JAVA_ROUTE_ANNOTATION_PATTERNS now binds `name: [(identifier) + // (scoped_identifier)]`; a deep FQN route annotation + // (`@org.springframework…GetMapping`) is matched and the for-loop + // normalizes the name to its trailing segment (`simpleName`). The + // controller was already recognised (hasAnnotation trailing-segment match); + // now the route string is extracted too — parity with the Kotlin plugin. const dir = path.join(tmpDir, 'java-fqn-route-annotation'); fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); fs.writeFileSync( @@ -1181,12 +1182,57 @@ class FqnController { const contracts = await extractor.extract(null, dir, makeRepo(dir)); const providers = contracts.filter((c) => c.role === 'provider'); - // The FQN route annotation is not extracted (documented limitation). - expect(providers.find((c) => c.contractId === 'http::GET::/api/users')).toBeUndefined(); - // And it must not over-match into a bogus contract either. expect( - providers.filter((c) => c.symbolRef.filePath.endsWith('FqnController.java')), - ).toHaveLength(0); + providers.find( + (c) => + c.contractId === 'http::GET::/api/users' && + c.meta.framework === 'spring' && + c.confidence === 0.8, + ), + ).toBeDefined(); + }); + + it('extracts FQN OpenFeign consumers + two-segment FQN, with anti-overreach', async () => { + const dir = path.join(tmpDir, 'java-fqn-feign'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'QualifiedClient.java'), + ` +@org.springframework.cloud.openfeign.FeignClient(name = "order-service", path = "/api") +interface QualifiedClient { + @org.springframework.web.bind.annotation.GetMapping("/orders/{id}") + OrderDto getOrder(String id); +} + +@foo.RestController +@foo.RequestMapping("/v2") +class ShortFqnController { + @foo.GetMapping("/items") + Object items() { return null; } +} + +@com.example.NotARoute("/should-not-extract") +class Unrelated { + @com.example.AlsoNotARoute("/nope") + Object noop() { return null; } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const fromFile = contracts.filter((c) => + c.symbolRef.filePath.endsWith('QualifiedClient.java'), + ); + + // Exactly two contracts: the FQN @FeignClient(path)+@GetMapping consumer and + // the two-segment-FQN provider. The `Unrelated` class's non-route FQN + // annotations contribute nothing (anti-overreach — simpleName misses them). + expect(new Set(fromFile.map((c) => `${c.role} ${c.contractId} ${c.meta.framework}`))).toEqual( + new Set([ + 'consumer http::GET::/api/orders/{param} openfeign', + 'provider http::GET::/v2/items spring', + ]), + ); }); it('extracts Express router.get patterns', async () => { @@ -1771,6 +1817,366 @@ class ApiClient { ).toBeDefined(); }); + it('extracts Java RestTemplate URI.create(...) static paths', async () => { + const dir = path.join(tmpDir, 'java-rest-template-uri-create'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'UriClient.java'), + ` +import java.net.URI; +import org.springframework.http.HttpMethod; +import org.springframework.web.client.RestTemplate; + +class UriClient { + void run(RestTemplate restTemplate) { + String dynamicPath = "/api/dynamic-users/99"; + restTemplate.getForEntity(URI.create("/api/uri-users/42"), String.class); + restTemplate.exchange(URI.create("/api/uri-users/42/details"), HttpMethod.POST, null, String.class); + restTemplate.getForObject(dynamicPath, String.class); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/api/uri-users/{param}' && + c.meta.framework === 'spring-rest-template' && + c.confidence === 0.7, + ), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::POST::/api/uri-users/{param}/details'), + ).toBeDefined(); + // Anti-overreach: a variable-bound path is not statically resolvable, so + // the widened `(_) @path` capture must NOT emit a consumer for it. + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/dynamic-users/{param}'), + ).toBeUndefined(); + }); + + it('extracts Java RestTemplate UriComponentsBuilder fluent-chain paths', async () => { + const dir = path.join(tmpDir, 'java-rest-template-builder'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'BuilderClient.java'), + ` +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +class BuilderClient { + void run(RestTemplate restTemplate, String idVar) { + restTemplate.getForObject( + UriComponentsBuilder.fromPath("/api").path("/builder-users").pathSegment("42").build().toUriString(), + String.class); + restTemplate.getForObject( + UriComponentsBuilder.fromUriString("/base").path("/sub").queryParam("page", "1").build().toUriString(), + String.class); + restTemplate.getForObject( + UriComponentsBuilder.fromHttpUrl("https://example.com/api").path("/external-users").query("page=1").build().toUriString(), + String.class); + restTemplate.getForObject( + UriComponentsBuilder.fromPath("/api").pathSegment(idVar).build().toUriString(), + String.class); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // fromPath + path + numeric pathSegment → joined, numeric → {param}. + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/builder-users/{param}'), + ).toBeDefined(); + // fromUriString seed + path; the queryParam attribute does not alter the path. + expect(consumers.find((c) => c.contractId === 'http::GET::/base/sub')).toBeDefined(); + // fromHttpUrl host seed: helper keeps the host; normalizeConsumerPath strips it. + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/external-users'), + ).toBeDefined(); + // Anti-overreach: the non-literal `pathSegment(idVar)` call defeats static + // resolution, so exactly the three resolvable chains emit — not a fourth. + expect( + consumers.filter((c) => c.symbolRef.filePath.endsWith('BuilderClient.java')), + ).toHaveLength(3); + }); + + it('strips a query string baked into a UriComponentsBuilder seed (#2268)', async () => { + const dir = path.join(tmpDir, 'java-rest-template-builder-query-seed'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'QuerySeedClient.java'), + ` +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +class QuerySeedClient { + void run(RestTemplate restTemplate) { + restTemplate.getForObject( + UriComponentsBuilder.fromUriString("/base?x=1").path("/sub").build().toUriString(), + String.class); + restTemplate.getForObject( + UriComponentsBuilder.fromHttpUrl("https://example.com/api?x=1").path("/y").build().toUriString(), + String.class); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // Query in the seed must NOT swallow the later .path() segment: + // fromUriString("/base?x=1").path("/sub") → /base/sub (was /base before the fix). + expect(consumers.find((c) => c.contractId === 'http::GET::/base/sub')).toBeDefined(); + // Host + query seed: query stripped at the seed, host stripped downstream. + expect(consumers.find((c) => c.contractId === 'http::GET::/api/y')).toBeDefined(); + // Count guard: exactly the two resolvable chains. A regression that + // double-emitted (e.g. both /base and /base/sub) would slip past the two + // `find` assertions above without this. + expect( + consumers.filter((c) => c.symbolRef.filePath.endsWith('QuerySeedClient.java')), + ).toHaveLength(2); + }); + + it('resolves a UriComponentsBuilder argument passed to restTemplate.exchange (#2268)', async () => { + // The exchange() path capture was widened to `(_) @path` alongside the + // plain RestTemplate loop, but was only covered with URI.create. Pin that a + // UriComponentsBuilder chain through exchange() also resolves end-to-end. + const dir = path.join(tmpDir, 'java-rest-template-exchange-builder'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'ExchangeBuilderClient.java'), + ` +import org.springframework.http.HttpMethod; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +class ExchangeBuilderClient { + void run(RestTemplate restTemplate) { + restTemplate.exchange( + UriComponentsBuilder.fromPath("/api").path("/exchange-users").build().toUriString(), + HttpMethod.PUT, null, String.class); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + expect( + consumers.find((c) => c.contractId === 'http::PUT::/api/exchange-users'), + ).toBeDefined(); + }); + + it('appends UriComponentsBuilder .path() verbatim per Spring semantics (#2268)', async () => { + // Spring `.path(p)` appends `p` as-is (no slash inserted) then collapses + // duplicate slashes — unlike `.pathSegment`, which slash-joins. So a + // no-leading-slash arg is NOT given a phantom slash, a leading-slash arg + // joins cleanly, a trailing-slash base collapses, and a host seed keeps its + // `://` until the downstream normalizer strips the host. + const dir = path.join(tmpDir, 'java-rest-template-builder-path'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'PathClient.java'), + ` +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +class PathClient { + void run(RestTemplate restTemplate) { + restTemplate.getForObject(UriComponentsBuilder.fromPath("/api").path("noslash").build().toUriString(), String.class); + restTemplate.getForObject(UriComponentsBuilder.fromPath("/svc").path("/withslash").build().toUriString(), String.class); + restTemplate.getForObject(UriComponentsBuilder.fromPath("/trail/").path("/seg").build().toUriString(), String.class); + restTemplate.getForObject(UriComponentsBuilder.fromPath("/first-").path("value/").path("/end").build().toUriString(), String.class); + restTemplate.getForObject(UriComponentsBuilder.fromHttpUrl("https://example.com/api").path("/ext").build().toUriString(), String.class); + restTemplate.getForObject(UriComponentsBuilder.fromPath("/empty").path("").build().toUriString(), String.class); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const fromFile = contracts.filter( + (c) => c.role === 'consumer' && c.symbolRef.filePath.endsWith('PathClient.java'), + ); + + // Exactly these six — no phantom slash on the no-leading-slash arg, no + // double slash from the trailing-slash base, no `://` corruption. + expect(new Set(fromFile.map((c) => c.contractId))).toEqual( + new Set([ + 'http::GET::/apinoslash', // fromPath("/api").path("noslash") — verbatim, no slash + 'http::GET::/svc/withslash', // leading-slash arg joins cleanly + 'http::GET::/trail/seg', // trailing-slash base collapses + 'http::GET::/first-value/end', // value/ + /end → one slash + 'http::GET::/api/ext', // host seed: `://` kept, host stripped downstream + 'http::GET::/empty', // empty .path("") is a no-op + ]), + ); + }); + + it('does not overflow on a pathological UriComponentsBuilder chain (#2268)', async () => { + const dir = path.join(tmpDir, 'java-rest-template-builder-deep'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + // A chain far deeper than the recursion cap — exercises the depth guard. + const deepChain = `UriComponentsBuilder.fromPath("/r")${'.path("/x")'.repeat(200)}.build().toUriString()`; + fs.writeFileSync( + path.join(dir, 'src', 'DeepClient.java'), + ` +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +class DeepClient { + void run(RestTemplate restTemplate) { + restTemplate.getForObject(${deepChain}, String.class); + } +} +`, + ); + + // Must not throw (the depth guard caps recursion). A chain past the cap + // resolves to null, so no consumer is emitted for it (without the guard it + // would either overflow or resolve to a bogus deep path). + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + expect( + contracts.filter( + (c) => c.role === 'consumer' && c.symbolRef.filePath.endsWith('DeepClient.java'), + ), + ).toHaveLength(0); + }); + + it('infers Java OkHttp verbs from sibling Request.Builder calls', async () => { + const dir = path.join(tmpDir, 'java-okhttp-verbs'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'OkHttpVerbs.java'), + ` +import okhttp3.Request; +import okhttp3.RequestBody; + +class OkHttpVerbs { + void run(RequestBody body, String verb) { + new Request.Builder().url("/api/orders/0").get().build(); + new Request.Builder().url("/api/orders/head").head().build(); + new Request.Builder().url("/api/orders").post(body).build(); + new Request.Builder().url("/api/orders/1").put(body).build(); + new Request.Builder().url("/api/orders/2").delete().build(); + new Request.Builder().url("/api/orders/3").method("PATCH", body).build(); + new Request.Builder().url("/api/bare-build").build(); + new Request.Builder().url("/api/dyn-verb").method(verb, body).build(); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const okhttp = contracts.filter( + (c) => + c.role === 'consumer' && + c.meta.framework === 'okhttp' && + c.symbolRef.filePath.endsWith('OkHttpVerbs.java'), + ); + + // The bare `.build()` with no verb call gets its OWN path (`/api/bare-build`) + // so the default-GET branch of inferOkHttpMethod is pinned independently. + // An explicit `.method(verb, …)` with a *variable* verb (`/api/dyn-verb`) is + // unresolvable and emits NOTHING — not a guessed GET (parity with WebClient + // long-form). Explicit verbs resolve; numeric segments normalize to {param}. + expect(okhttp.find((c) => c.contractId === 'http::GET::/api/bare-build')).toBeDefined(); + // Variable-bound `.method(verb)` produces no contract at all (any verb). + expect(okhttp.find((c) => c.contractId.includes('/api/dyn-verb'))).toBeUndefined(); + expect(new Set(okhttp.map((c) => c.contractId))).toEqual( + new Set([ + 'http::GET::/api/bare-build', + 'http::GET::/api/orders/{param}', + 'http::POST::/api/orders', + 'http::PUT::/api/orders/{param}', + 'http::DELETE::/api/orders/{param}', + 'http::PATCH::/api/orders/{param}', + 'http::HEAD::/api/orders/head', + ]), + ); + expect(okhttp.every((c) => c.confidence === 0.7)).toBe(true); + }); + + it('does not emit a contract for an empty-string verb literal (#2268)', async () => { + // `.method("", body)` is an explicit-but-unresolvable verb — `unquoteLiteral` + // returns "" (not null), so the falsiness guard must skip it rather than + // emit a malformed `http::::/path` contract or a guessed GET. Covers both + // the OkHttp and Java-HttpClient verb-walks (shared `inferBuilderVerb`). + const dir = path.join(tmpDir, 'java-empty-verb'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'EmptyVerb.java'), + ` +import java.net.URI; +import java.net.http.HttpRequest; +import okhttp3.Request; +import okhttp3.RequestBody; + +class EmptyVerb { + void run(RequestBody body) throws Exception { + new Request.Builder().url("/api/okhttp-empty").method("", body).build(); + HttpRequest hc = HttpRequest.newBuilder().uri(URI.create("/api/hc-empty")).method("", body).build(); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const fromFile = contracts.filter( + (c) => c.role === 'consumer' && c.symbolRef.filePath.endsWith('EmptyVerb.java'), + ); + + // No contract at all — neither a malformed empty-method id nor a guessed GET. + expect(fromFile.map((c) => c.contractId)).toEqual([]); + }); + + it('extracts OkHttp chains with a builder call before .url(), with anti-overreach (#2268)', async () => { + // A builder call BEFORE .url() (`new Request.Builder().addHeader(...).url(...)`) + // no longer drops the contract — the chain is matched as long as it roots at + // `new Request.Builder()`. The verb-walk scans the whole chain, so a verb set + // before .url() also resolves. A `.url(...)` on an unrelated object does NOT + // emit (the root gate rejects it). + const dir = path.join(tmpDir, 'java-okhttp-pre-url'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'OkHttpPreUrl.java'), + ` +import okhttp3.Request; +import okhttp3.RequestBody; + +class OkHttpPreUrl { + void run(RequestBody body, SomeClient other) { + new Request.Builder().addHeader("A", "b").url("/api/pre-url").build(); + new Request.Builder().post(body).url("/api/verb-first").build(); + other.url("/api/not-okhttp").build(); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const okhttp = contracts.filter( + (c) => + c.role === 'consumer' && + c.meta.framework === 'okhttp' && + c.symbolRef.filePath.endsWith('OkHttpPreUrl.java'), + ); + + // The header-before-url chain (default GET) and the verb-before-url chain + // (POST) both emit; `other.url(...)` does not (not a Request.Builder chain). + expect(new Set(okhttp.map((c) => c.contractId))).toEqual( + new Set(['http::GET::/api/pre-url', 'http::POST::/api/verb-first']), + ); + }); + it('extracts Java WebClient long-form method(HttpMethod.X).uri(...) — #2254 parity', async () => { // Parity with the Kotlin plugin: a single structural query matches the // verb (HttpMethod.X field access) and path. Previously deferred on the @@ -2553,6 +2959,208 @@ class HttpClients { ).toBeDefined(); }); + it('extracts Java HttpClient HEAD, .method(), and default-GET forms', async () => { + const dir = path.join(tmpDir, 'java-http-client-verbs'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'HttpClientVerbs.java'), + ` +import java.net.URI; +import java.net.http.HttpRequest; + +class HttpClientVerbs { + void run(String verb) throws Exception { + HttpRequest head = HttpRequest.newBuilder().uri(URI.create("/api/users/head")).HEAD().build(); + HttpRequest patch = HttpRequest.newBuilder().uri(URI.create("/api/users/2")).method("PATCH", HttpRequest.BodyPublishers.ofString("{}")).build(); + HttpRequest def = HttpRequest.newBuilder().uri(URI.create("/api/default-users/3")).build(); + HttpRequest dyn = HttpRequest.newBuilder().uri(URI.create("/api/dyn/4")).method(verb, HttpRequest.BodyPublishers.noBody()).build(); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const hc = contracts.filter( + (c) => + c.role === 'consumer' && + c.meta.framework === 'java-http-client' && + c.symbolRef.filePath.endsWith('HttpClientVerbs.java'), + ); + + // HEAD via verb helper, PATCH via `.method("X")`, default GET via bare + // `.build()`. The variable-bound `.method(verb, …)` is NOT resolved + // (string literal only) → no contract. Exact set-equality also pins that + // no chain double-emits (e.g. HEAD would never also yield a GET). + expect(new Set(hc.map((c) => c.contractId))).toEqual( + new Set([ + 'http::HEAD::/api/users/head', + 'http::PATCH::/api/users/{param}', + 'http::GET::/api/default-users/{param}', + ]), + ); + expect(hc.every((c) => c.confidence === 0.65)).toBe(true); + }); + + it('extracts Java HttpClient verbs across intervening builder calls (#2268)', async () => { + // The verb-walk is transparent to neutral calls AFTER `.uri(...)`, so a + // `.header()`/`.timeout()` hop before the terminal no longer drops the + // contract. Each verb-producing branch gets a DISTINCT non-numeric path so + // the set-equality assertion cannot mask a branch. A call BEFORE `.uri()`, + // a constructor-arg `newBuilder(uri)`, and a non-literal `.uri()` arg are + // documented misses (must NOT extract). + const dir = path.join(tmpDir, 'java-http-client-intervening'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'HttpClientIntervening.java'), + ` +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpClient; +import java.time.Duration; + +class HttpClientIntervening { + void run(URI uriVar, Duration dur, HttpClient.Version ver, HttpRequest.BodyPublisher body) throws Exception { + // Intervening calls AFTER .uri() — header/timeout transparent to the verb-walk. + HttpRequest a = HttpRequest.newBuilder().uri(URI.create("/api/hdr")).header("Accept", "application/json").build(); + HttpRequest b = HttpRequest.newBuilder().uri(URI.create("/api/tmo")).timeout(dur).method("PUT", body).build(); + HttpRequest c = HttpRequest.newBuilder().uri(URI.create("/api/hdr-verb")).header("X", "y").DELETE().build(); + // Verb helper BEFORE an intervening call (walk does not stop at the first non-verb). + HttpRequest d = HttpRequest.newBuilder().uri(URI.create("/api/verb-then-hdr")).POST(body).header("X", "y").build(); + // Unbuilt .uri() — no .build(); over-match emits the default GET (mirrors OkHttp). + HttpRequest.Builder e = HttpRequest.newBuilder().uri(URI.create("/api/unbuilt")); + // Call BEFORE .uri() and the constructor-arg form are now captured too (the + // chain roots at HttpRequest.newBuilder); only a non-literal .uri() is a miss. + HttpRequest f = HttpRequest.newBuilder().version(ver).uri(URI.create("/api/pre-uri")).build(); // call before .uri() + HttpRequest g = HttpRequest.newBuilder(URI.create("/api/ctor")).build(); // constructor-arg, no .uri() + HttpRequest h = HttpRequest.newBuilder().uri(uriVar).build(); // non-literal .uri() arg → miss + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const hc = contracts.filter( + (c) => + c.role === 'consumer' && + c.meta.framework === 'java-http-client' && + c.symbolRef.filePath.endsWith('HttpClientIntervening.java'), + ); + + // The seven resolvable chains (incl. the pre-`.uri()` and constructor-arg + // forms); only the non-literal `.uri(uriVar)` contributes nothing. + expect(new Set(hc.map((c) => c.contractId))).toEqual( + new Set([ + 'http::GET::/api/hdr', + 'http::PUT::/api/tmo', + 'http::DELETE::/api/hdr-verb', + 'http::POST::/api/verb-then-hdr', + 'http::GET::/api/unbuilt', + 'http::GET::/api/pre-uri', + 'http::GET::/api/ctor', + ]), + ); + expect(hc.every((c) => c.confidence === 0.65)).toBe(true); + }); + + it('passes through a non-standard HttpClient .method("VERB") verb (#2268)', async () => { + // A custom verb literal passes through (uppercased), matching the OkHttp + // .method() precedent — the verb-walk does not restrict to known verbs. + const dir = path.join(tmpDir, 'java-http-client-custom-verb'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'CustomVerb.java'), + ` +import java.net.URI; +import java.net.http.HttpRequest; + +class CustomVerb { + void run(HttpRequest.BodyPublisher body) throws Exception { + HttpRequest r = HttpRequest.newBuilder().uri(URI.create("/api/report")).method("REPORT", body).build(); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const hc = contracts.filter( + (c) => + c.role === 'consumer' && + c.meta.framework === 'java-http-client' && + c.symbolRef.filePath.endsWith('CustomVerb.java'), + ); + expect(new Set(hc.map((c) => c.contractId))).toEqual(new Set(['http::REPORT::/api/report'])); + }); + + it('handles HttpClient constructor-URI override and rejects non-newBuilder .uri (#2268)', async () => { + // A constructor URI overridden by a later `.uri(...)` emits ONLY the override + // (not both), and a `.uri(URI.create(...))` on a chain that does not root at + // HttpRequest.newBuilder (e.g. WebClient) is NOT a java-http-client consumer. + const dir = path.join(tmpDir, 'java-http-client-ctor-override'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'CtorOverride.java'), + ` +import java.net.URI; +import java.net.http.HttpRequest; +import org.springframework.web.reactive.function.client.WebClient; + +class CtorOverride { + void run(WebClient webClient) { + HttpRequest a = HttpRequest.newBuilder(URI.create("/api/ctor-overridden")).uri(URI.create("/api/override-wins")).build(); + webClient.get().uri(URI.create("/api/webclient-not-hc")).retrieve(); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const hc = contracts.filter( + (c) => + c.role === 'consumer' && + c.meta.framework === 'java-http-client' && + c.symbolRef.filePath.endsWith('CtorOverride.java'), + ); + + // Only the override URI, exactly once; the overridden constructor URI and the + // WebClient `.uri()` are not java-http-client contracts. + expect(new Set(hc.map((c) => c.contractId))).toEqual( + new Set(['http::GET::/api/override-wins']), + ); + }); + + it('resolves the last verb when a chain sets two (runtime last-wins) (#2268)', async () => { + // Each verb-setter overwrites the previous at runtime, so a chain that sets + // two verbs resolves to the one nearest the terminal — not the first found. + const dir = path.join(tmpDir, 'java-http-two-verb'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'TwoVerb.java'), + ` +import java.net.URI; +import java.net.http.HttpRequest; +import okhttp3.Request; +import okhttp3.RequestBody; + +class TwoVerb { + void run(RequestBody body, HttpRequest.BodyPublisher pub) throws Exception { + HttpRequest hc = HttpRequest.newBuilder().GET().uri(URI.create("/api/hc-two")).POST(pub).build(); + new Request.Builder().get().url("/api/ok-two").post(body).build(); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const fromFile = contracts.filter( + (c) => c.role === 'consumer' && c.symbolRef.filePath.endsWith('TwoVerb.java'), + ); + + // Both resolve to the LAST verb (POST), not the first (GET). + expect(new Set(fromFile.map((c) => `${c.meta.framework} ${c.contractId}`))).toEqual( + new Set(['java-http-client http::POST::/api/hc-two', 'okhttp http::POST::/api/ok-two']), + ); + }); + // ─── Kotlin consumers (RestTemplate / WebClient short+long / OkHttp) ── // Same shape as the Java consumer test above, but parsed by the // tree-sitter-kotlin grammar via `KOTLIN_HTTP_PLUGIN`. Four @@ -2678,26 +3286,14 @@ class OkClient(private val client: OkHttpClient) { }); itKotlinConsumer( - 'OkHttp Request.Builder().url("/x").post(body) — verb defaults to GET (Java parity)', + 'Kotlin OkHttp .url("/x").post(body) infers POST — verb-walk parity with Java (#2268)', async () => { - // Anti-overreach / known-limitation pin: OkHttp encodes the - // HTTP verb on a sibling call (`.post(body)` / `.delete()` / - // ...), not on `.url(...)`. The query at `kotlin.ts:OK_HTTP_PATTERNS` - // intentionally does not walk the chain to recover the verb — - // it emits `method: 'GET'` for every match, mirroring the Java - // plugin's `OK_HTTP_PATTERNS` (java.ts). - // - // This test pins the accepted behavior so a future verb-walk - // implementation must update kotlin.ts's known-limitation - // comment in lockstep. Concretely: - // - `Request.Builder().url("/api/users").post(body).build()` - // → ONE consumer: `http::GET::/api/users` (heuristic-default) - // → NO `http::POST::/api/users` consumer - // - // Test signal: - // - if this becomes correct (POST detected) without updating - // the kotlin.ts comment + java.ts behavior together, this - // test goes red and the reviewer must reconcile both sides. + // OkHttp encodes the HTTP verb on a sibling call (`.post(body)` / `.delete()` + // / `.method("X")`), not on `.url(...)`. The Kotlin plugin now WALKS the + // builder chain (`inferKotlinOkHttpMethod`) to recover it — the mirror of + // the Java side's `inferOkHttpMethod`. So `Request.Builder().url("/api/users")` + // `.post(body).build()` emits `http::POST::/api/users` (not GET) on `.kt`, + // identical to `.java` (pinned by the Java↔Kotlin parity harness below). const dir = path.join(tmpDir, 'kotlin-okhttp-post-chain'); fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); fs.writeFileSync( @@ -2717,24 +3313,102 @@ class OkPostClient(private val client: OkHttpClient, private val body: RequestBo ); const contracts = await extractor.extract(null, dir, makeRepo(dir)); - const consumers = contracts.filter((c) => c.role === 'consumer'); - - const fromThisFile = consumers.filter((c) => - c.symbolRef.filePath.endsWith('OkPostClient.kt'), + const fromThisFile = contracts.filter( + (c) => c.role === 'consumer' && c.symbolRef.filePath.endsWith('OkPostClient.kt'), ); - // Heuristic-default GET: exactly one consumer is emitted for - // the .url("/x") capture, with method=GET regardless of the - // sibling .post(body) call. + // The sibling `.post(body)` is recovered: exactly one POST consumer, no GET. expect(fromThisFile).toHaveLength(1); - expect(fromThisFile[0].contractId).toBe('http::GET::/api/users'); - expect(fromThisFile[0].meta.method).toBe('GET'); + expect(fromThisFile[0].contractId).toBe('http::POST::/api/users'); + expect(fromThisFile[0].meta.method).toBe('POST'); + expect(fromThisFile.find((c) => c.contractId === 'http::GET::/api/users')).toBeUndefined(); + }, + ); - // Anti-overreach: no second contract with POST should appear. - // If a future verb-walk lands and this assertion needs to flip - // (i.e. POST is now detected), bump kotlin.ts's known-limitation - // comment and java.ts in the same PR. - expect(fromThisFile.find((c) => c.contractId === 'http::POST::/api/users')).toBeUndefined(); + itKotlinConsumer( + 'Kotlin OkHttp verb-walk: helpers, .method("X"), default GET, variable skip (#2268)', + async () => { + // Parity with the Java OkHttp verb cases: a verb helper resolves, a literal + // `.method("X")` resolves, a bare `.build()` defaults to GET, and a + // variable-bound `.method(verb)` is unresolvable → emits nothing. + const dir = path.join(tmpDir, 'kotlin-okhttp-verbs'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'OkVerbs.kt'), + `package com.example +import okhttp3.Request +import okhttp3.RequestBody + +class OkVerbs(private val body: RequestBody, private val verb: String) { + fun run() { + Request.Builder().url("/api/k-get").build() + Request.Builder().url("/api/k-delete").delete().build() + Request.Builder().url("/api/k-patch").method("PATCH", body).build() + Request.Builder().url("/api/k-named").method(method = "REPORT", body = body).build() + Request.Builder().url("/api/k-dyn").method(verb, body).build() + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const okhttp = contracts.filter( + (c) => + c.role === 'consumer' && + c.meta.framework === 'okhttp' && + c.symbolRef.filePath.endsWith('OkVerbs.kt'), + ); + + // The variable-bound `.method(verb)` (`/api/k-dyn`) emits nothing; the + // named-argument `.method(method = "REPORT")` resolves its literal verb. + expect(new Set(okhttp.map((c) => c.contractId))).toEqual( + new Set([ + 'http::GET::/api/k-get', + 'http::DELETE::/api/k-delete', + 'http::PATCH::/api/k-patch', + 'http::REPORT::/api/k-named', + ]), + ); + }, + ); + + itKotlinConsumer( + 'Kotlin OkHttp: builder call before .url(), with anti-overreach (#2268)', + async () => { + // Parity with the Java OkHttp pre-`.url()` support: a builder call BEFORE + // `.url()` is captured (the chain roots at Request.Builder), the verb-walk + // scans the whole chain so a verb before `.url()` resolves, and a `.url(...)` + // on an unrelated object does NOT emit. + const dir = path.join(tmpDir, 'kotlin-okhttp-pre-url'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'OkPreUrl.kt'), + `package com.example +import okhttp3.Request +import okhttp3.RequestBody + +class OkPreUrl(private val body: RequestBody, private val other: SomeClient) { + fun run() { + Request.Builder().addHeader("A", "b").url("/api/k-pre-url").build() + Request.Builder().post(body).url("/api/k-verb-first").build() + other.url("/api/k-not-okhttp").build() + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const okhttp = contracts.filter( + (c) => + c.role === 'consumer' && + c.meta.framework === 'okhttp' && + c.symbolRef.filePath.endsWith('OkPreUrl.kt'), + ); + + // header-before-url → GET, verb-before-url → POST; `other.url(...)` emits nothing. + expect(new Set(okhttp.map((c) => c.contractId))).toEqual( + new Set(['http::GET::/api/k-pre-url', 'http::POST::/api/k-verb-first']), + ); }, ); @@ -4547,6 +5221,78 @@ class GatewayController : OrdersApi, UsersApi { ); const rows: ParityRow[] = [ + { + name: 'OkHttp builder verb inference', + files: [ + { + name: 'OkClient', + java: ` +import okhttp3.Request; +import okhttp3.RequestBody; + +class OkClient { + void run(RequestBody body) { + new Request.Builder().url("/api/things").post(body).build(); + } +} +`, + kotlin: `package com.example +import okhttp3.Request +import okhttp3.RequestBody + +class OkClient(private val body: RequestBody) { + fun run() { + Request.Builder().url("/api/things").post(body).build() + } +} +`, + }, + ], + expected: [ + { + role: 'consumer', + contractId: 'http::POST::/api/things', + framework: 'okhttp', + confidence: 0.7, + }, + ], + }, + { + name: 'OkHttp verb + builder call before .url()', + files: [ + { + name: 'OkPre', + java: ` +import okhttp3.Request; +import okhttp3.RequestBody; + +class OkPre { + void run(RequestBody body) { + new Request.Builder().post(body).url("/api/pre").build(); + } +} +`, + kotlin: `package com.example +import okhttp3.Request +import okhttp3.RequestBody + +class OkPre(private val body: RequestBody) { + fun run() { + Request.Builder().post(body).url("/api/pre").build() + } +} +`, + }, + ], + expected: [ + { + role: 'consumer', + contractId: 'http::POST::/api/pre', + framework: 'okhttp', + confidence: 0.7, + }, + ], + }, { name: '@RequestLine with @RequestMapping prefix fallback', files: [ From d7da752cfb90550acbb115576b97287944757458 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 04:17:59 +0100 Subject: [PATCH 2/5] chore(deps)(deps-dev): bump @types/node in /gitnexus (#2273) --- gitnexus/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 97bbbc1a1..416d298dc 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -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" From 1c8ad84796e6d3a8c529ded7ca1945eeaa5b0075 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Tue, 23 Jun 2026 06:59:46 +0100 Subject: [PATCH 3/5] feat(taint): add conservative Java source/sink model (#2267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(taint): add conservative Java source model * fix(taint): preserve Java import provenance * chore: retry CI after network timeout --------- Co-authored-by: Gergő Magyar --- gitnexus-shared/src/scope-resolution/types.ts | 9 + .../ingestion/languages/java/interpret.ts | 1 + gitnexus/src/core/ingestion/taint/emit.ts | 29 +- .../src/core/ingestion/taint/java-model.ts | 24 ++ gitnexus/src/core/ingestion/taint/match.ts | 72 +++- .../src/core/ingestion/taint/propagate.ts | 69 +++- .../ingestion/taint/source-sink-config.ts | 26 +- .../core/ingestion/taint/summary-harvest.ts | 17 +- .../core/ingestion/taint/typescript-model.ts | 9 +- .../test/unit/taint/java-model-match.test.ts | 341 ++++++++++++++++++ gitnexus/test/unit/taint/model-match.test.ts | 27 +- .../unit/taint/python-model-match.test.ts | 32 +- .../test/unit/taint/summary-harvest.test.ts | 49 ++- gitnexus/test/unit/taint/taint-emit.test.ts | 44 +++ 14 files changed, 699 insertions(+), 50 deletions(-) create mode 100644 gitnexus/src/core/ingestion/taint/java-model.ts create mode 100644 gitnexus/test/unit/taint/java-model-match.test.ts diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index c73543d05..bf837639e 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -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 + * `.` paths can then use `targetRaw.` 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 diff --git a/gitnexus/src/core/ingestion/languages/java/interpret.ts b/gitnexus/src/core/ingestion/languages/java/interpret.ts index 09c911aa9..38d6128d4 100644 --- a/gitnexus/src/core/ingestion/languages/java/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/java/interpret.ts @@ -30,6 +30,7 @@ export function interpretJavaImport(captures: CaptureMatch): ParsedImport | null localName: nameCap?.text ?? simpleName, importedName: simpleName, targetRaw: sourceCap.text, + targetIncludesImportedName: true, }; } case 'wildcard': { diff --git a/gitnexus/src/core/ingestion/taint/emit.ts b/gitnexus/src/core/ingestion/taint/emit.ts index a1981f35f..2e1e4498d 100644 --- a/gitnexus/src/core/ingestion/taint/emit.ts +++ b/gitnexus/src/core/ingestion/taint/emit.ts @@ -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 `;` header — the only persisted diff --git a/gitnexus/src/core/ingestion/taint/java-model.ts b/gitnexus/src/core/ingestion/taint/java-model.ts new file mode 100644 index 000000000..8c5111c78 --- /dev/null +++ b/gitnexus/src/core/ingestion/taint/java-model.ts @@ -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: [], +}; diff --git a/gitnexus/src/core/ingestion/taint/match.ts b/gitnexus/src/core/ingestion/taint/match.ts index 50c513182..8f4a69ff3 100644 --- a/gitnexus/src/core/ingestion/taint/match.ts +++ b/gitnexus/src/core/ingestion/taint/match.ts @@ -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 + * `.`, not `..`. + */ + readonly targetIncludesMember?: boolean; } /** Local name → import provenance for one file. Build once per file (U4). */ @@ -99,11 +107,24 @@ export type TaintImportIndex = ReadonlyMap; /** 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[] = []; diff --git a/gitnexus/src/core/ingestion/taint/propagate.ts b/gitnexus/src/core/ingestion/taint/propagate.ts index e82e7ff1a..36f093480 100644 --- a/gitnexus/src/core/ingestion/taint/propagate.ts +++ b/gitnexus/src/core/ingestion/taint/propagate.ts @@ -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, - ): 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, diff --git a/gitnexus/src/core/ingestion/taint/source-sink-config.ts b/gitnexus/src/core/ingestion/taint/source-sink-config.ts index b1f9d3c86..d7ca28b25 100644 --- a/gitnexus/src/core/ingestion/taint/source-sink-config.ts +++ b/gitnexus/src/core/ingestion/taint/source-sink-config.ts @@ -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 `.(...)` 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[]; } diff --git a/gitnexus/src/core/ingestion/taint/summary-harvest.ts b/gitnexus/src/core/ingestion/taint/summary-harvest.ts index 4924a0e18..51e2da5f5 100644 --- a/gitnexus/src/core/ingestion/taint/summary-harvest.ts +++ b/gitnexus/src/core/ingestion/taint/summary-harvest.ts @@ -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([src.siteIndex]); while (cur?.parent) { diff --git a/gitnexus/src/core/ingestion/taint/typescript-model.ts b/gitnexus/src/core/ingestion/taint/typescript-model.ts index ebe7aa9c9..bfa41c49c 100644 --- a/gitnexus/src/core/ingestion/taint/typescript-model.ts +++ b/gitnexus/src/core/ingestion/taint/typescript-model.ts @@ -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); diff --git a/gitnexus/test/unit/taint/java-model-match.test.ts b/gitnexus/test/unit/taint/java-model-match.test.ts new file mode 100644 index 000000000..7a0c35cb0 --- /dev/null +++ b/gitnexus/test/unit/taint/java-model-match.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/unit/taint/model-match.test.ts b/gitnexus/test/unit/taint/model-match.test.ts index 32c7cf49a..3c275eda7 100644 --- a/gitnexus/test/unit/taint/model-match.test.ts +++ b/gitnexus/test/unit/taint/model-match.test.ts @@ -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); diff --git a/gitnexus/test/unit/taint/python-model-match.test.ts b/gitnexus/test/unit/taint/python-model-match.test.ts index 478d6d16d..ad770a9fc 100644 --- a/gitnexus/test/unit/taint/python-model-match.test.ts +++ b/gitnexus/test/unit/taint/python-model-match.test.ts @@ -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 diff --git a/gitnexus/test/unit/taint/summary-harvest.test.ts b/gitnexus/test/unit/taint/summary-harvest.test.ts index 0828d4fe6..f9b327786 100644 --- a/gitnexus/test/unit/taint/summary-harvest.test.ts +++ b/gitnexus/test/unit/taint/summary-harvest.test.ts @@ -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); }); }); diff --git a/gitnexus/test/unit/taint/taint-emit.test.ts b/gitnexus/test/unit/taint/taint-emit.test.ts index 7b73d0e69..c6d8f734a 100644 --- a/gitnexus/test/unit/taint/taint-emit.test.ts +++ b/gitnexus/test/unit/taint/taint-emit.test.ts @@ -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); From b16ec344f706e28ce736e45a613bc27e31a76d73 Mon Sep 17 00:00:00 2001 From: henry201605 <31428013+henry201605@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:22:43 +0800 Subject: [PATCH 4/5] perf(group/http): skip source parse for graph-covered route files (#2138 Part 2) (#2265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Gergő Magyar --- .../bench/emit-persistence/baselines.json | 2 +- .../group/extractors/http-patterns/java.ts | 20 ++ .../group/extractors/http-patterns/php.ts | 11 + .../group/extractors/http-patterns/python.ts | 16 ++ .../group/extractors/http-patterns/types.ts | 37 +++ .../group/extractors/http-route-extractor.ts | 193 ++++++++++---- gitnexus/src/core/ingestion/call-processor.ts | 79 ++++++ .../ingestion/pipeline-phases/parse-impl.ts | 13 + .../core/ingestion/pipeline-phases/parse.ts | 3 + .../core/ingestion/pipeline-phases/routes.ts | 14 +- .../ingestion/route-extractors/route-path.ts | 21 ++ .../core/ingestion/route-extractors/spring.ts | 4 + .../core/ingestion/workers/parse-worker.ts | 10 + gitnexus/src/core/lbug/csv-generator.ts | 3 +- gitnexus/src/core/lbug/lbug-adapter.ts | 2 +- gitnexus/src/core/lbug/schema.ts | 1 + gitnexus/src/storage/parse-cache.ts | 2 +- .../route-handler-symbol-roundtrip.test.ts | 77 ++++++ .../test/integration/route-parse-skip.test.ts | 251 ++++++++++++++++++ .../integration/spring-route-pipeline.test.ts | 23 ++ .../test/unit/blade-template-routes.test.ts | 1 + .../unit/group/http-consumer-signals.test.ts | 76 ++++++ .../group/http-route-graph-method.test.ts | 47 ++++ .../resolve-route-handler-symbols.test.ts | 148 +++++++++++ 24 files changed, 985 insertions(+), 69 deletions(-) create mode 100644 gitnexus/src/core/ingestion/route-extractors/route-path.ts create mode 100644 gitnexus/test/integration/route-handler-symbol-roundtrip.test.ts create mode 100644 gitnexus/test/integration/route-parse-skip.test.ts create mode 100644 gitnexus/test/unit/group/http-consumer-signals.test.ts create mode 100644 gitnexus/test/unit/resolve-route-handler-symbols.test.ts diff --git a/gitnexus/bench/emit-persistence/baselines.json b/gitnexus/bench/emit-persistence/baselines.json index 55307c2fc..ce67aafdf 100644 --- a/gitnexus/bench/emit-persistence/baselines.json +++ b/gitnexus/bench/emit-persistence/baselines.json @@ -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`." diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index 7d70864cd..c62d9f937 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -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[] = []; diff --git a/gitnexus/src/core/group/extractors/http-patterns/php.ts b/gitnexus/src/core/group/extractors/http-patterns/php.ts index c1c40a09c..a2042cfd4 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/php.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/php.ts @@ -130,6 +130,17 @@ function isHttpUrlLiteral(path: string): boolean { export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = { name: 'php-http', language: PHP.php_only, + // Laravel `Route::(...)` 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::`, 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[] = []; diff --git a/gitnexus/src/core/group/extractors/http-patterns/python.ts b/gitnexus/src/core/group/extractors/http-patterns/python.ts index 6408f0eca..cadaacc32 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/python.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/python.ts @@ -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.`/`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); }, diff --git a/gitnexus/src/core/group/extractors/http-patterns/types.ts b/gitnexus/src/core/group/extractors/http-patterns/types.ts index fb4ab09cb..22f6597ba 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/types.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/types.ts @@ -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. diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index 450cf3841..722161e20 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -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(); 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, + coveredFiles?: Set, ): Promise { 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(); let rows: Record[]; 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; } diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index b5b258dcd..20aacffd5 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -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 { + const out = new Map(); + // 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(); + + // 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 diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index c652e255f..0c8f5cdac 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -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; /** 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 diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts index 1875d9911..08da0068a 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts @@ -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; bindingAccumulator: BindingAccumulator; /** SemanticModel populated during parse — scope-resolution reads its * TypeRegistry / MethodRegistry / SymbolTable indexes. */ diff --git a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts index 4c45e6232..aea78ecd3 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts @@ -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 = { allFetchWrapperDefs, allExtractedRoutes, allDecoratorRoutes, + routeHandlerSymbols, } = getPhaseOutput(deps, 'parse'); // Local copy — routes phase must not mutate upstream ParseOutput @@ -287,6 +285,7 @@ export const routesPhase: PipelinePhase = { 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 = { name: routeURL, filePath: handlerPath, ...(routeMethod ? { method: routeMethod } : {}), + ...(handlerSymbolId ? { handlerSymbolId } : {}), ...(responseKeys ? { responseKeys } : {}), ...(errorKeys ? { errorKeys } : {}), ...(middleware && middleware.length > 0 ? { middleware } : {}), diff --git a/gitnexus/src/core/ingestion/route-extractors/route-path.ts b/gitnexus/src/core/ingestion/route-extractors/route-path.ts new file mode 100644 index 000000000..907574163 --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/route-path.ts @@ -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, '/') || '/'; +} diff --git a/gitnexus/src/core/ingestion/route-extractors/spring.ts b/gitnexus/src/core/ingestion/route-extractors/spring.ts index 4476e55cc..a92d09afe 100644 --- a/gitnexus/src/core/ingestion/route-extractors/spring.ts +++ b/gitnexus/src/core/ingestion/route-extractors/spring.ts @@ -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 } : {}), }); } diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 436833a5a..ee5c59a9f 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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 { diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index a03a81ada..04bf0fd5d 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -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; diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 903493f8f..698c98fdd 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -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}`; diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index 7fd19f5bf..d04ca42b4 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -195,6 +195,7 @@ CREATE NODE TABLE Route ( errorKeys STRING[], middleware STRING[], method STRING, + handlerSymbolId STRING, PRIMARY KEY (id) )`; diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index c460a480d..cf1263831 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -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 diff --git a/gitnexus/test/integration/route-handler-symbol-roundtrip.test.ts b/gitnexus/test/integration/route-handler-symbol-roundtrip.test.ts new file mode 100644 index 000000000..e633841c9 --- /dev/null +++ b/gitnexus/test/integration/route-handler-symbol-roundtrip.test.ts @@ -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[]; + 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); + }); +}); diff --git a/gitnexus/test/integration/route-parse-skip.test.ts b/gitnexus/test/integration/route-parse-skip.test.ts new file mode 100644 index 000000000..18845585b --- /dev/null +++ b/gitnexus/test/integration/route-parse-skip.test.ts @@ -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(); + return { + ...actual, + parseSourceSafe: (parser: unknown, src: unknown) => { + parseCalls.push(typeof src === 'string' ? src : ''); + 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 { + 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>) => + 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 = ` { + 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 = ` 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 }); + } + }); +}); diff --git a/gitnexus/test/integration/spring-route-pipeline.test.ts b/gitnexus/test/integration/spring-route-pipeline.test.ts index 27d721c52..018ead80f 100644 --- a/gitnexus/test/integration/spring-route-pipeline.test.ts +++ b/gitnexus/test/integration/spring-route-pipeline.test.ts @@ -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 } | 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); + }); }); diff --git a/gitnexus/test/unit/blade-template-routes.test.ts b/gitnexus/test/unit/blade-template-routes.test.ts index 305b1e01d..231248184 100644 --- a/gitnexus/test/unit/blade-template-routes.test.ts +++ b/gitnexus/test/unit/blade-template-routes.test.ts @@ -131,6 +131,7 @@ describe('Blade/template static route extraction', () => { }, ], allDecoratorRoutes: [], + routeHandlerSymbols: new Map(), } as unknown as ParseOutput; const output = await routesPhase.execute( diff --git a/gitnexus/test/unit/group/http-consumer-signals.test.ts b/gitnexus/test/unit/group/http-consumer-signals.test.ts new file mode 100644 index 000000000..087524cfe --- /dev/null +++ b/gitnexus/test/unit/group/http-consumer-signals.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/unit/group/http-route-graph-method.test.ts b/gitnexus/test/unit/group/http-route-graph-method.test.ts index 0162a3678..d392bce9c 100644 --- a/gitnexus/test/unit/group/http-route-graph-method.test.ts +++ b/gitnexus/test/unit/group/http-route-graph-method.test.ts @@ -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')]); diff --git a/gitnexus/test/unit/resolve-route-handler-symbols.test.ts b/gitnexus/test/unit/resolve-route-handler-symbols.test.ts new file mode 100644 index 000000000..4a214991b --- /dev/null +++ b/gitnexus/test/unit/resolve-route-handler-symbols.test.ts @@ -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 { + 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 { + 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); + }); +}); From 1a03c8527a5546e7a8613e3e1993b57eca0d5459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Tue, 23 Jun 2026 07:54:13 +0100 Subject: [PATCH 5/5] feat(group): cross-repo call trace using PDG (#2269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(group): extract shared resolveBridgeNeighbors from cross-impact Lift the uid-filtered consumer<->provider ContractLink join (direction + queryBridge + row normalization + confidence sort) out of runGroupImpact's inline Phase-2 block into an exported resolveBridgeNeighbors helper. Behavior is unchanged for impact; the helper becomes the single shared bridge join so the upcoming cross-repo trace path never forks its own copy of the neighbor Cypher. Empty uid sets short-circuit without a DB round-trip. Adds direct coverage (real bridge via writeBridge/openBridgeDbReadOnly) for both directions plus the empty-set and unknown-uid edges. * feat(group): cross-repo trace stitching (groupTrace + runGroupTrace) Add GroupService.groupTrace and the pure runGroupTrace engine that stitches per-repo CALLS/HAS_METHOD trace segments across one ContractLink boundary in the group bridge: from --(local trace)--> consumer --(ContractLink)--> provider --(local trace)--> to - Resolves from/to across all members (symbol node id == bridge symbolUid); same-repo endpoints delegate to a single local trace with no crossing. - Single boundary crossing (MAX_SUPPORTED_CROSS_DEPTH); deeper crossDepth is clamped with a note, mirroring cross-impact. - Discriminated GroupTraceResult union (ok|not_found|ambiguous|error) with per-hop repo tags, a typed crossings[] entry, and centralized degraded-state note constants (TRACE_NOTES). No . - Trace-specific pair query (keeps BOTH crossing endpoints) lives in this module; the uid-filtered neighbor join (resolveBridgeNeighbors) is reused where it fits. ensureBridgeReady exported for reuse. - New GroupToolPort methods (trace/resolveSymbol/pdgFlows) are optional so existing port mocks keep type-checking; runGroupTrace guards on presence. PDG enrichment is wired as an opt-in hook (enrichSegment) — the port method is stubbed until U4. Covered by unit tests over a real bridge + mocked port. * feat(group): route trace tool to groupTrace on @group syntax Wire the cross-repo trace through the existing @group dispatch: - callTool routes trace with an @-prefixed repo to callToolAtGroupRepo, which forwards from/to/uid/file/maxDepth/includeTests plus the experimental pdg/crossDepth flags to GroupService.groupTrace. Member path in @group/path is advisory for trace (resolution is whole-group). - Port gains trace/resolveSymbol/pdgFlows adapters. resolveSymbolForGroup wraps the shared resolveSymbolCandidates so groupTrace can locate the member repo and recover each endpoint node id (== bridge symbolUid). pdgFlowsForGroup is a degraded stub here (call-level only); U4 implements the REACHING_DEF walk. - trace tool schema documents the @group entry point, pdg, and crossDepth. Single-repo trace is untouched. Covered by dispatch-routing tests (@group -> groupTrace, non-group stays local) and tool-schema assertions. * feat(group): opt-in PDG data-flow enrichment for cross-repo trace Implement _pdgFlowsForGroupImpl: the real REACHING_DEF anchor walk that backs the port pdgFlows adapter (replacing the U3 call-level stub). When pdg:true and the segment repo has a flows PDG layer, the boundary-adjacent segments carry their intra-procedural def->use hops: - Anchors by the boundary symbol UID (precise; avoids the by-name ambiguity the resolveBlockAnchor path can hit), then reuses the same span-anchored, bind-param-only flows query as pdg_query (BasicBlock id-prefix + [start+1, end+1] line window; no rel-property index, so the anchor IS the bound). - Stays intra-procedural: data flow never crosses the repo boundary. - pdgStampForMode probe: false -> available:false (degrade with note); the trace stays ok. Any query failure is swallowed (enrichment is auxiliary). Covered by runGroupTrace enrichment tests: dataFlow attached on opt-in, degraded note when no layer, and no pdgFlows call when pdg is omitted. * test(group): evaluation-first cross-repo trace e2e (two real indexes) End-to-end gate for the cross-repo trace: stands up two real LadybugDB indexes (consumer 'frontend' + provider 'backend'), a real ContractLink bridge, and a real LocalBackend with both repos registered, then drives the public callTool('trace', { repo: '@grp', pdg: true }) and asserts: - the stitched checkout -> callUsers -(CONTRACT_LINK)-> handleUsers -> getUsers path, each hop tagged with its member repo - real REACHING_DEF data-flow enrichment of the consumer segment (userId) - a degraded 'No PDG layer in app/backend' note (provider has no PDG layer) - single-repo trace against one member is unchanged (no crossings) Hand-persists the minimal real graph (deterministic; a full two-repo analyze is heavier than this gate needs) and exercises real Cypher across resolveSymbolCandidates, _traceImpl, the bridge pair query, and _pdgFlowsForGroupImpl. Windows-skipped (describeReopen) and registered in the cross-platform native-lbug set. Scoped to a single @group call: opening bridge.lbug read-only a SECOND time in one process currently fails (shared bridge open/close lifecycle, also affects impact @group) — the pdg-omitted/clamp variants are unit-covered. * docs(group): document cross-repo trace + PDG enrichment ARCHITECTURE.md: trace is now group-aware; describe the @group cross-repo stitch over a single ContractLink boundary (CONTRACT_LINK hop, crossings[], crossDepth clamp), the opt-in experimental PDG REACHING_DEF enrichment of boundary-adjacent segments, the symbolUid-grain join between the two stores, and the deferred full cross-program (SDG-like) data flow. PIPELINE.md: add the cross-trace consumer of the bridge with its pair-query rationale. Does not touch gitnexus/CHANGELOG.md (release-owned). * fix(review): apply autofix feedback Apply safe_auto findings from ce-code-review (run 20260622-094243): - local-backend.ts: drop (r: any) in _pdgFlowsForGroupImpl row map; coerce hop line via Number() so a nullish LadybugDB cell can't surface NaN. - tools.ts: advertise the forwarded param in the trace schema and add crossDepth maximum:10 (schema now matches what groupTrace reads). - cross-trace.ts: parallelize per-member resolveSymbol/resolveRepo with order-preserving Promise.all (matches groupContext/groupQuery); add a note when pdg:true is passed to a same-repo trace (PDG only enriches at a cross-repo boundary). - tests: remove / tighten (no-any rule). Residual gated_auto/manual findings (unbounded crossing query + loop, whole-file PDG widening on absent span, error-vs-no_path masking, top-level try/catch parity, helper dedupe, branch-coverage gaps) are recorded in the run artifact for the PR body. * fix(group): skip CHECKPOINT on read-only bridge close so it can reopen Root cause of the in-process bridge.lbug reopen failure (which broke repeated @group impact/trace calls in a long-lived MCP server): closeBridgeDb issued CHECKPOINT on EVERY handle, including read-only ones. A CHECKPOINT on a read-only connection has nothing to flush but leaves a WAL/shadow lock artifact that makes the next read-only open of the same path fail (openBridgeDbReadOnly returns null -> 'Could not open bridge.lbug read-only'). Reproduced: open -> query -> closeBridgeDb -> open again returned null only when the close ran CHECKPOINT; a non-checkpoint close reopened fine, and the raw native open/close cycle was never the problem. Fix: tag read-only handles (BridgeHandle._readOnly, set by openBridgeDbReadOnly) and skip CHECKPOINT for them in closeBridgeDb. Writable handles are unchanged (they still flush before close). This is the shared bridge-db close path, so impact @group benefits identically. - Regression test in bridge-db.test.ts: open/query/close/open/query/open in one process now succeeds. - Re-enabled the second @group call in cross-trace-e2e.test.ts (was scoped to a single call for this very limitation). * fix(group): bring bridge-db close to parity with the core adapter safeClose The bridge open/close cycle was less robust than the main graph DB's: closeBridgeDb closed the connection/database but skipped the post-close steps the core adapter's safeClose performs, so a rapid in-process reopen could race the OS handle release (Windows) or an orphaned WAL sidecar. That gap is why the close-then-reopen tests had to skip Windows. closeBridgeDb now mirrors safeClose after closing the handle: - waitForWindowsHandleRelease(dbPath): probe the file (+ .wal) until the residual Windows lock clears, so the next open does not race (warns if the budget is exhausted, matching the core adapter). - finalizeLbugSidecarsAfterClose(dbPath): quarantine an orphaned WAL (shadow missing) so the next open replays a consistent file. Both helpers are the same ones safeClose uses (Windows-proven via the core adapter CI), and the bridge read open already retries transient locks. Combined with the read-only CHECKPOINT skip, the bridge reopen is now robust on every platform, so the close-then-reopen tests run on all platforms (Windows CI exercises them via the cross-platform subset). No write-path behavior change; Linux/macOS unaffected. * fix(group): bound cross-repo crossing fan-out (LIMIT + segment memoization) Address the top review residual: the bridge crossing query was unbounded and the crossing-selection loop could run an O(2*N) sequential trace-BFS over every ContractLink between a repo pair. - CY_CROSSINGS_BETWEEN now ORDERs BY confidence DESC and LIMITs to MAX_CROSSINGS_TO_TRY + 1; listCrossingsBetween slices to the cap and reports truncation. Exceeding the cap surfaces a note (no silent truncation), keeping the highest-confidence crossings. Aligns with the repo's anchored+LIMIT-bounded query discipline (LadybugDB has no rel-property index). - The home-repo segment (from -> consumer) depends only on the consumer uid and the target-repo segment (provider -> to) only on the provider uid, so each is memoized by that uid. Many crossings sharing a consumer/provider (one client call linked to several providers) now cost one trace per distinct endpoint instead of one per crossing. A consumer whose segment already failed is skipped for every later crossing that shares it. Test: two links sharing a consumer (first provider unreachable, second reachable) assert the from->consumer segment is traced exactly once and the second crossing wins. * fix(group): restore Windows skip for bridge reopen tests; drop ineffective close-side probe The previous commit flipped the bridge close-then-reopen tests to run on Windows, betting that a close-side waitForWindowsHandleRelease + finalizeLbugSidecarsAfterClose probe (mirroring the core adapter safeClose) would make the in-process reopen work there. Windows CI proved otherwise: 4 writeBridge->openBridgeDbReadOnly tests fail ('expected null not to be null' — the read open returns null). The writable-close -> read-open handoff plus writeBridge's atomic sidecar rename does not release the OS file handle before the read open races, and the existing open-side LBUG_OPEN_RETRY only retries lock-pattern errors, not the post-rename sidecar database-id mismatch. macOS passes; the core adapter's own reopen also passes — this is bridge+Windows specific. - Revert itLbugReopen to the Windows skip (the pre-existing, correct state). - Remove the close-side probe + finalize from closeBridgeDb: it did NOT close the Windows gap, and reviewers flagged it for hot-path latency (finalize ran on every close, all platforms) and safeClose duplication. - KEEP the load-bearing fix — skipping CHECKPOINT on read-only handles — which fixed the reproduced Linux/macOS in-process reopen artifact (the real bug). Net: Linux/macOS repeated @group impact/trace works in-process; Windows in-process bridge reopen remains a documented limitation (unchanged from before this PR). * fix(group): surface degraded members + cap truncation; honest crossDepth schema Address the cross-engine-corroborated tri-review findings (Codex + Claude): - resolveAcrossMembers / runGroupTrace now track member repos that could NOT be queried (resolveRepo or resolveSymbol threw) and, when the result is not_found, attach a degraded-member note. A transient/corrupt member DB is no longer silently reported as a clean 'symbol absent' not_found. (Codex B1+B3 + ce-reliability.) - The cross-repo not_found now carries a programmatic truncated:true flag (and a clearer suggestion) when the MAX_CROSSINGS_TO_TRY cap was hit, so a consumer can distinguish 'no path' from 'cap may have hidden a connecting ContractLink'. (Codex B3 + ce-adversarial + ce-api-contract.) - trace tool schema: crossDepth maximum 10 -> 1 to match the implementation's single-hop clamp (the schema previously advertised an unsupported 2-10 range). (ce-api-contract, conf 100.) Test: a member whose resolveSymbol throws yields not_found WITH a degraded note naming the unreachable repo (if-free responder map). * docs(group): clarify trace @group/memberPath is advisory (resolves all members) Tri-review (Codex ce, conf 100) caught a doc/impl inconsistency: ARCHITECTURE.md lumped trace with query/context/impact as honoring @group/memberPath member scoping, but cross-repo trace resolves from/to across ALL members (the member path is advisory). Clarify the behavior and point to from_uid/to_uid for disambiguating same-named symbols across members. * feat(group): file-level boundary fallback so cross-repo trace works on HTTP contracts Benchmark (bench/cross-repo-trace/) running the REAL pipeline (runFullAnalysis --pdg -> real syncGroup -> trace @group) found that cross-repo trace returned not_found for real HTTP links even though sync built the correct ContractLinks: HTTP (and other source-scan) contracts hardcode symbolUid:'' (http-route-extractor), and both cross-trace AND cross-impact join crossings by Contract.symbolUid, which never matches an empty uid. (Pre-existing — impact @group has the same gap.) Fix: when a crossing's symbolUid is empty, fall back to the contract's FILE — if the user's from/to resolves into the contract file, that endpoint anchors the boundary. CY_CROSSINGS_BETWEEN now returns consumer/provider filePath; a crossing is kept if it can be anchored by uid OR file on each side; a fileBoundaryFallback note flags that the boundary is file-level, not symbol-precise. This makes the common 'trace from= to=' case work end-to-end (verified: fetchUsers -> listUsers stitches with a CONTRACT_LINK hop + PDG enrichment, 2/2). Limits (documented in the bench README + the note): anonymous handlers have no named target; when several contracts share files the file fallback may attach the wrong contractId to a correct path. The proper upstream fix is to populate symbolUid in the HTTP extraction (benefits impact too) — the bench is its gate. Adds a unit test pinning the empty-symbolUid file-fallback stitch. * fix(group): resolve HTTP contract symbolUid by containment (fixes cross-repo trace + impact) Addresses the root cause behind the cross-repo trace file-fallback: HTTP contracts hardcoded symbolUid:'' (http-route-extractor), so both cross-trace and cross-impact — which join crossings on Contract.symbolUid — could not traverse HTTP links. (Also found: the pre-existing graph-assisted resolution queried the wrong edge, CONTAINS instead of DEFINES, so it never resolved a uid either.) Now the extractor resolves each detection to a real symbol: - HttpDetection carries the call-site line (node.ts sets it on every express/ fetch/axios/jquery/nest detection; express also captures the handler arg). - resolveDetectionSymbol resolves the named handler first, else the innermost Function/Method whose line span encloses the call (consumer = the function containing the fetch; provider = the named/inline handler), over the correct File-[DEFINES]->symbol edge. Base-tolerant (0- vs 1-based startLine). - Wired into both source-scan and graph-assisted provider/consumer paths. Verified end-to-end (bench/cross-repo-trace): all 4 contracts now carry real uids, trace is symbol-precise (GET pair -> http::GET, POST -> http::POST, no file-fallback note), and impact @group fans out (cross_repo_hits 0 -> 1). The cross-trace file-level fallback remains as the secondary path for truly anonymous handlers. Adds 2 containment unit tests; 738 group/integration pass. Languages other than JS/TS still resolve providers by handler name; their consumers fall through to the file fallback until their plugins set the line. * fix(group): extend HTTP symbolUid containment to all languages + nested methods Completes the symbolUid resolution across every bundled HTTP plugin: Python, Go, PHP, Kotlin and Java now set the call-site line on their consumer (and Feign/ named) detections, so their HTTP contracts resolve to the containing function the same way Node/TS already did. Also generalizes the containment query: it now matches Function/Method/CodeElement by filePath (UNION ALL) instead of File-[DEFINES]->symbol. The DEFINES edge only reaches a file's TOP-LEVEL symbols, so methods nested in classes (Java/Kotlin — File defines the class, the class defines the method) were invisible; matching by filePath reaches them. Verified against a real index (LadybugDB supports the UNION); JS/TS still fully symbol-precise (bench 2/2), 709 group tests pass. Residual is now only the inherent case — a fully anonymous handler with no named callee — which keeps the cross-trace file-level fallback. * feat(group): destination trace — follow a consumer to an anonymous handler Handles the one inherent residual: an anonymous route handler (`router.get('/x', (req,res) => …)`) has no symbol node at all (the file holds only a Const + PDG BasicBlocks), so it can never be named as a trace `to`. Adds a DESTINATION TRACE: omit to/to_uid/to_file on an @group trace and `trace from=` follows the consumer's outgoing HTTP call across the bridge and reports where it lands — by route + file:line, with a notes[] entry flagging the handler as anonymous. Implemented as a new branch in runGroupTrace (p.destination) backed by CY_CROSSINGS_FROM (all ContractLinks leaving the consumer repo) + stitchToDestination; the provider endpoint is labelled '' when its symbolName is a generic token/file basename. The MCP routing already omitted an absent `to`, so only the schema docs changed. parseTraceParams now treats a missing `to` as a destination trace instead of an error. Verified end-to-end: anonymous fixture reports 'app/frontend:fetchUsers -> app/backend:'; named fixture lands at the real function. Adds 2 unit tests; 915 group tests pass. * fix(group): tri-review fixes for cross-repo trace + symbolUid resolution Two-engine tri-review (Claude swarm+ce + Codex GPT-5.5 swarm+ce+adversarial) surfaced these; cross-engine-corroborated unless noted. Correctness (P1, all four lanes): destination trace reported the WRONG endpoint — an empty-uid consumer made trace(from->from) trivially succeed, so the highest- confidence same-file crossing won regardless of which call `from` makes. stitchToDestination now collects ALL connecting crossings, prefers symbol-precise hits, and returns `ambiguous` (with candidates) when it cannot disambiguate. Correctness (P1, Codex): resolveDetectionSymbol early-returned null when d.line==null, blocking NAME resolution for named providers that set no line (Spring/Go/etc.). Name resolution now runs first; only containment needs a line. Correctness (P2): resolveContainingSymbol OR-ed `line` and `line-1`, which could mis-pick a one-line sibling. It now probes the base-correct `line-1` first and falls back to `line` only if nothing matches. Correctness (Codex): anonymous Express handlers emitted name:'handler' and could attach to an unrelated fn literally named `handler`. node.ts now emits name:null for non-identifier handlers (containment-only). Robustness: drop the first-symbol-in-file pickSymbolUid guess from the graph consumer/provider paths (a wrong uid would win the contractId merge); remove the dead CONTAINS_QUERY fallback (CONTAINS is File->Folder, never a symbol) + the now -unused pickSymbolUid/handlerName; seed destination notes with degraded-member notes so a successful trace still surfaces them; providerLabel takes providerUid so a resolved fn named `handler` is not mislabeled anonymous, and only true file basenames (known extensions) — not any dotted name — count as anonymous. API contract: a single-repo trace with no `to` now returns an actionable error (destination trace is @group-only) instead of "symbol 'undefined' not found". Maintainability/tests: narrow asLocalTrace per-field (drop as-unknown-as); fix the PR's lone as-any (vi.mocked); if-free e2e teardown; qualify the bench README. Adds ambiguous-destination, anonymous-handler-no-false-name, and single-repo-no-to tests; redirects graph mocks CONTAINS->UNION ALL. 918 group/integration pass. * fix(group): carry degraded-member notes through SUCCESSFUL group traces A reviewer (koriyoshi2041, PR #2269) correctly flagged that degraded-member resolution was surfaced only on not_found, not on a successful ok result. Group trace resolves names across ALL members, so an ok is 'unique among the members we could query' — if a member that threw during resolveSymbol also holds from/to, the real answer could be ambiguous. The destination path already seeded the note (prior commit); this extends it to the same-repo and cross-repo success paths by seeding the dispatch notes with degradedNotes([...fromRes.degraded, ...toRes.degraded]). Adds a regression test: reg-be throws while a same-repo trace succeeds in reg-fe; the ok result now carries the 'could not be queried' degraded note (app/backend). * test(bench): cover all implemented cross-repo trace cases in one runner Replace the single named-handler script with a self-contained verify.mjs that generates each fixture inline and exercises every implemented end-to-end case against the real analyze -> sync -> trace/impact pipeline, asserting PASS/FAIL (exit non-zero on failure). 10 checks across 4 scenarios: - named handlers: 4/4 symbolUid resolved; symbol-precise GET vs POST crossing selection; destination trace lands at the named handler. - anonymous handler: empty symbolUid; destination trace reports it by route with the anonymous note. - impact @group fan-out (cross_repo_hits >= 1). - multi-language (Python Flask + requests): link built, cross-repo trace stitches, and the file-level boundary fallback is exercised when the provider has no uid. Ambiguous-destination and degraded-member paths need synthetic inputs the real analyzer cannot produce, so they stay in the unit suite (documented in the README + script header). Removes verify-named.mjs + fixtures-named/ (folded inline). * test(group): pin destination degraded-success + precise-tier ambiguity Adds the two regression guards koriyoshi2041 requested on PR #2269 after the degraded-on-success fix: - destination trace success with a degraded member: reg-fe resolves from and follows the link to an anonymous handler while reg-be throws; the ok result carries the anonymous endpoint AND the 'could not be queried' degraded note, so the no-to path stays aligned with explicit to traces. - multiple PRECISE destination hits: one from reaches two consumers with resolved uids linked to different routes; the result is ambiguous (role: to) with both route candidates. Distinct from the existing file-level ambiguous test, this pins the stronger precise tier against a future change silently picking the highest-confidence destination. Both already pass against current behavior; 716 group tests pass. --- ARCHITECTURE.md | 7 +- gitnexus/bench/cross-repo-trace/README.md | 90 ++ gitnexus/bench/cross-repo-trace/verify.mjs | 281 ++++ gitnexus/scripts/cross-platform-tests.ts | 4 + gitnexus/src/core/group/PIPELINE.md | 25 + gitnexus/src/core/group/bridge-db.ts | 28 +- gitnexus/src/core/group/cross-impact.ts | 48 +- gitnexus/src/core/group/cross-trace.ts | 1268 +++++++++++++++++ .../core/group/extractors/http-patterns/go.ts | 3 + .../group/extractors/http-patterns/java.ts | 8 + .../group/extractors/http-patterns/kotlin.ts | 6 + .../group/extractors/http-patterns/node.ts | 18 +- .../group/extractors/http-patterns/php.ts | 3 + .../group/extractors/http-patterns/python.ts | 7 + .../group/extractors/http-patterns/types.ts | 9 + .../group/extractors/http-route-extractor.ts | 285 ++-- gitnexus/src/core/group/service.ts | 78 + gitnexus/src/core/group/types.ts | 7 + gitnexus/src/mcp/local/local-backend.ts | 198 ++- gitnexus/src/mcp/tools.ts | 37 +- .../integration/group/cross-trace-e2e.test.ts | 374 +++++ gitnexus/test/unit/calltool-dispatch.test.ts | 45 + gitnexus/test/unit/group/bridge-db.test.ts | 58 +- gitnexus/test/unit/group/cross-trace.test.ts | 972 +++++++++++++ .../unit/group/http-route-extractor.test.ts | 182 ++- .../group/http-route-graph-method.test.ts | 17 +- .../unit/group/http-route-multi-verb.test.ts | 12 +- .../group/resolve-bridge-neighbors.test.ts | 130 ++ gitnexus/test/unit/tools.test.ts | 19 + 29 files changed, 4059 insertions(+), 160 deletions(-) create mode 100644 gitnexus/bench/cross-repo-trace/README.md create mode 100644 gitnexus/bench/cross-repo-trace/verify.mjs create mode 100644 gitnexus/src/core/group/cross-trace.ts create mode 100644 gitnexus/test/integration/group/cross-trace-e2e.test.ts create mode 100644 gitnexus/test/unit/group/cross-trace.test.ts create mode 100644 gitnexus/test/unit/group/resolve-bridge-neighbors.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e936e2dd9..3b7d06cba 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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: "@"`) 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: "@"` (or `"@/"` to scope to one member) plus optional `service: ""`. 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: "@"` (or `"@/"` to scope to one member) plus optional `service: ""`. 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: "@"` — but, unlike the others, it resolves `from`/`to` across **all** members (a `@/` 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`). diff --git a/gitnexus/bench/cross-repo-trace/README.md b/gitnexus/bench/cross-repo-trace/README.md new file mode 100644 index 000000000..93781fe9a --- /dev/null +++ b/gitnexus/bench/cross-repo-trace/README.md @@ -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 `` 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= to=` **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=` 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: [CONTRACT_LINK] +``` + +To go deeper into an anonymous handler, trace to a named function it calls (the +provider segment then resolves normally). diff --git a/gitnexus/bench/cross-repo-trace/verify.mjs b/gitnexus/bench/cross-repo-trace/verify.mjs new file mode 100644 index 000000000..fa63ff47a --- /dev/null +++ b/gitnexus/bench/cross-repo-trace/verify.mjs @@ -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 === '' && + 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); diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 38f769fd3..129a788a3 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -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', diff --git a/gitnexus/src/core/group/PIPELINE.md b/gitnexus/src/core/group/PIPELINE.md index 7730b48e7..8bdf86c48 100644 --- a/gitnexus/src/core/group/PIPELINE.md +++ b/gitnexus/src/core/group/PIPELINE.md @@ -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
across all members] --> SR{same repo?} + SR -- yes --> LT[single-repo trace
no crossing] + SR -- no --> SEGA[trace: from → consumer symbol
in home repo] + SEGA --> XB[Bridge pair query
consumer.symbolUid → provider.symbolUid
one ContractLink boundary] + XB --> SEGB[trace: provider symbol → to
in target repo] + SEGB --> STITCH[stitched hops + CONTRACT_LINK edge
+ 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. diff --git a/gitnexus/src/core/group/bridge-db.ts b/gitnexus/src/core/group/bridge-db.ts index 7f44253bf..8cb7782a8 100644 --- a/gitnexus/src/core/group/bridge-db.ts +++ b/gitnexus/src/core/group/bridge-db.ts @@ -237,10 +237,18 @@ export async function closeBridgeDb(handle: BridgeHandle): Promise { // 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 { } 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 { const meta = await readBridgeMeta(groupDir); @@ -394,6 +394,39 @@ function rowToNeighbor(r: Record): 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 { + if (opts.uids.length === 0) return []; + const cypher = opts.direction === 'upstream' ? CY_NEIGHBORS_UPSTREAM : CY_NEIGHBORS_DOWNSTREAM; + const rows = await queryBridge>(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, @@ -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>(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(); for (const n of neighbors) { diff --git a/gitnexus/src/core/group/cross-trace.ts b/gitnexus/src/core/group/cross-trace.ts new file mode 100644 index 000000000..93df99a1a --- /dev/null +++ b/gitnexus/src/core/group/cross-trace.ts @@ -0,0 +1,1268 @@ +/** + * Cross-repo call trace. + * + * Stitches per-repo directed-path segments (CALLS + HAS_METHOD, via the + * existing single-repo `trace`) across a single `ContractLink` boundary in the + * group bridge, and — when a `--pdg` layer is present and the caller opts in — + * enriches the boundary-adjacent segments with their intra-procedural + * REACHING_DEF data-flow. + * + * Design notes: + * - The crossing is a single bridge hop joined on `symbolUid` (the symbol node + * id is the same value the bridge stores as `Contract.symbolUid`). This + * mirrors `cross-impact.ts` and is clamped to one boundary + * (`MAX_SUPPORTED_CROSS_DEPTH`); multi-hop is deferred. + * - All trace-specific bridge Cypher lives in THIS module (mirroring the + * "all bridge Cypher for this feature lives here" convention in + * cross-impact.ts). The trace needs BOTH endpoints of a crossing to stitch a + * path, so it issues its own pair query rather than the lossy uid-filtered + * neighbor join exported by cross-impact (`resolveBridgeNeighbors`), which + * intentionally returns only the far side. + * - PDG is enrichment only: data flow never crosses the repo boundary. Full + * cross-program (SDG-like) data flow is deferred — see + * docs/plans/2026-06-18-002-feat-unified-pdg-impact-evaluation-plan.md. + */ + +import { GroupNotFoundError, loadGroupConfig } from './config-parser.js'; +import { getGroupDir } from './storage.js'; +import { ensureBridgeReady, MAX_SUPPORTED_CROSS_DEPTH } from './cross-impact.js'; +import { closeBridgeDb, queryBridge } from './bridge-db.js'; +import type { + GroupPdgFlowHop, + GroupRepoHandle, + GroupSymbolResolution, + GroupToolPort, +} from './service.js'; +import type { BridgeHandle, GroupConfig } from './types.js'; + +// ── Result types (discriminated on `status`) ───────────────────────────── + +export interface TraceHop { + name: string; + filePath: string; + startLine: number; + /** The member repo path (group.yaml key) this hop belongs to. */ + repo: string; +} + +export interface TraceEdge { + relType: string; + confidence: number; +} + +export interface SegmentDataFlow { + /** Member repo path of the enriched segment. */ + repo: string; + /** The boundary-adjacent symbol the flow was anchored on. */ + anchor: string; + variable?: string; + hops: GroupPdgFlowHop[]; + truncated?: boolean; +} + +export interface BridgeCrossing { + fromRepo: string; + toRepo: string; + contractId: string; + contractType: string; + matchType: string; + confidence: number; +} + +export interface GroupTraceEndpoint { + name: string; + filePath: string; + startLine: number; + repo: string; +} + +export interface GroupTraceOkResult { + status: 'ok'; + group: string; + from: GroupTraceEndpoint; + to: GroupTraceEndpoint; + /** 0 for a same-repo trace, 1 for a single boundary crossing. */ + crossings: BridgeCrossing[]; + hopCount: number; + hops: TraceHop[]; + edges: TraceEdge[]; + /** Present only when PDG enrichment ran for at least one segment. */ + dataFlow?: SegmentDataFlow[]; + truncated?: boolean; + notes: string[]; +} + +export interface GroupTraceCandidate { + repo: string; + id: string; + name: string; + filePath: string; + startLine: number; +} + +export interface GroupTraceNotFoundResult { + status: 'not_found'; + group: string; + role?: 'from' | 'to'; + query?: string; + /** + * True when the answer is NOT authoritative: the crossing cap + * (`MAX_CROSSINGS_TO_TRY`) was hit, so a connecting ContractLink ranked beyond + * the cap may have been skipped. A consumer should treat this as "unknown", + * not "no path exists". + */ + truncated?: boolean; + notes: string[]; + suggestion?: string; +} + +export interface GroupTraceAmbiguousResult { + status: 'ambiguous'; + group: string; + role: 'from' | 'to'; + candidates: GroupTraceCandidate[]; + notes: string[]; +} + +export interface GroupTraceErrorResult { + status: 'error'; + group: string; + error: string; + notes: string[]; +} + +export type GroupTraceResult = + | GroupTraceOkResult + | GroupTraceNotFoundResult + | GroupTraceAmbiguousResult + | GroupTraceErrorResult; + +/** + * Centralized degraded-state messages so wording stays consistent and + * testable. Kept as named constants/builders rather than inline strings. + */ +export const TRACE_NOTES = { + noBridgeLink: + 'No ContractLink connects the two endpoints across repos — the call chain ' + + 'likely crosses a boundary the group bridge has not linked. Run group_sync, ' + + 'or check that both sides of the contract were extracted.', + crossDepthClamped: + `Multi-hop cross-boundary trace is not implemented yet; using a single ` + + `boundary crossing (crossDepth ${MAX_SUPPORTED_CROSS_DEPTH}).`, + noLocalPath: (repo: string): string => + `No directed CALLS/HAS_METHOD path within ${repo} for the resolved endpoints.`, + noPdgLayer: (repo: string): string => `No PDG layer in ${repo}; call-level hops only.`, + pdgRequested: + 'PDG enrichment was requested (experimental). Data-flow hops are intra-procedural ' + + 'and never cross the repo boundary.', + pdgSameRepoNoop: + 'pdg:true has no effect for a same-repo trace — PDG data-flow enrichment only runs ' + + 'at a cross-repo ContractLink boundary.', + crossingsCapped: (cap: number): string => + `More than ${cap} ContractLinks connect these repos; only the ${cap} highest-confidence ` + + `crossings were tried. Narrow with from_uid/to_uid if the expected path was missed.`, + degradedMembers: (repos: string[]): string => + `${repos.length} member repo(s) could not be queried (${repos.join(', ')}) — a not_found ` + + `result may be incomplete; re-run once the repo(s) are indexed and available.`, + fileBoundaryFallback: + 'A cross-repo boundary was anchored by contract FILE, not symbol — the HTTP (or other ' + + 'source-scan) contract carried no resolved symbolUid, so the endpoint was matched because ' + + 'it lives in the contract file. The boundary is file-level, not symbol-precise.', + anonymousHandler: (route: string, location: string): string => + `The handler for ${route} is anonymous (no named symbol); reported by location ${location}. ` + + `Pass a named function the handler calls as 'to' to trace deeper into the provider.`, + destinationNoLink: + 'No outgoing HTTP ContractLink leaves this repo for any provider — the symbol may make no ' + + 'cross-repo HTTP call, or group_sync has not linked it. Pass a `to` for a symbol-to-symbol trace.', + destinationNoReach: + 'An HTTP ContractLink leaves this repo, but `from` does not reach its consumer call site. ' + + 'Trace from the function that actually issues the request.', + destinationMultiple: + '`from` reaches more than one HTTP endpoint — the destination is ambiguous. The candidates ' + + 'are listed; pass `to`/`to_uid` to pick one, or trace from the exact calling function.', + destinationAmbiguousFile: + '`from`’s file makes more than one HTTP call and those consumer contracts carry no resolved ' + + 'symbolUid, so the specific destination cannot be determined (file-level, not symbol-precise). ' + + 'The candidates are listed; trace from the exact calling function or pass `to_uid`.', +} as const; + +/** Repo-relative path equality, tolerant of a leading "./" / "/" or a repo prefix. */ +function sameFile(a: string, b: string): boolean { + if (!a || !b) return false; + const norm = (s: string): string => s.replace(/^\.?\//, ''); + const na = norm(a); + const nb = norm(b); + return na === nb || na.endsWith('/' + nb) || nb.endsWith('/' + na); +} + +export interface RunGroupTraceDeps { + port: GroupToolPort; + gitnexusDir: string; +} + +// ── Local single-repo trace result narrowing ───────────────────────────── + +interface LocalTraceShape { + status: string; + from?: { name: string; filePath: string; startLine: number }; + to?: { name: string; filePath: string; startLine: number }; + hopCount?: number; + hops?: Array<{ name: string; filePath: string; startLine: number }>; + edges?: Array<{ relType: string; confidence: number }>; +} + +function asLocalTrace(raw: unknown): LocalTraceShape | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + if (typeof o.status !== 'string') return null; + // Project the known fields (all optional but `status`) instead of a blanket + // `as unknown as` cast. The per-field `unknown -> T` assertions trust the + // single-repo `trace` port contract for the array element shapes. + return { + status: o.status, + from: o.from as LocalTraceShape['from'], + to: o.to as LocalTraceShape['to'], + hopCount: typeof o.hopCount === 'number' ? o.hopCount : undefined, + hops: Array.isArray(o.hops) ? (o.hops as LocalTraceShape['hops']) : undefined, + edges: Array.isArray(o.edges) ? (o.edges as LocalTraceShape['edges']) : undefined, + }; +} + +// ── Bridge pair query (trace-specific; keeps BOTH endpoints) ────────────── + +/** + * Cap on ContractLinks attempted between one repo pair. Each crossing can cost + * up to two trace BFS queries, so an unbounded `bridge.lbug` (a repo pair that + * shares many contracts) would otherwise drive an unbounded fan-out. Crossings + * are confidence-sorted, so the cap keeps the strongest candidates; exceeding it + * is surfaced via a note (no silent truncation). Generous because the per-unique + * endpoint memoization in `stitchCrossRepo` already collapses most of the cost. + */ +const MAX_CROSSINGS_TO_TRY = 50; + +// ORDER BY + LIMIT in the query so the cap keeps the highest-confidence +// crossings (LadybugDB has no rel-property index; this is the bound). One extra +// row (`+ 1`) lets the caller detect — and surface — truncation. +const CY_CROSSINGS_BETWEEN = ` +MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract) +WHERE consumer.repo = $fromRepo + AND provider.repo = $toRepo + AND consumer.role = 'consumer' + AND provider.role = 'provider' +RETURN consumer.symbolUid AS consumerUid, + provider.symbolUid AS providerUid, + consumer.filePath AS consumerFile, + provider.filePath AS providerFile, + l.matchType AS matchType, + l.confidence AS confidence, + l.contractId AS contractId, + consumer.type AS contractType +ORDER BY l.confidence DESC +LIMIT ${MAX_CROSSINGS_TO_TRY + 1} +`; + +// Destination trace: every ContractLink leaving a consumer repo, to ANY provider +// repo. Returns the provider's repo + symbol name so the endpoint can be reported +// even when it has no resolved symbolUid (an anonymous handler). +const CY_CROSSINGS_FROM = ` +MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract) +WHERE consumer.repo = $fromRepo + AND consumer.role = 'consumer' + AND provider.role = 'provider' +RETURN consumer.symbolUid AS consumerUid, + provider.symbolUid AS providerUid, + consumer.filePath AS consumerFile, + provider.filePath AS providerFile, + provider.repo AS providerRepo, + provider.symbolName AS providerName, + l.matchType AS matchType, + l.confidence AS confidence, + l.contractId AS contractId, + consumer.type AS contractType +ORDER BY l.confidence DESC +LIMIT ${MAX_CROSSINGS_TO_TRY + 1} +`; + +interface CrossingRow { + consumerUid: string; + providerUid: string; + consumerFile: string; + providerFile: string; + matchType: string; + confidence: number; + contractId: string; + contractType: string; + /** Populated only by the destination query (CY_CROSSINGS_FROM). */ + providerRepo?: string; + providerName?: string; +} + +function rowToCrossing(r: Record): CrossingRow | null { + const consumerFile = String(r.consumerFile ?? r[2] ?? ''); + const providerFile = String(r.providerFile ?? r[3] ?? ''); + // A crossing is usable if EITHER endpoint can be anchored — by a resolved + // symbolUid (gRPC/manifest/named) OR, when the uid is empty (HTTP and other + // source-scan contracts hardcode symbolUid:''), by the contract's file so the + // file-level boundary fallback in stitchCrossRepo can still connect it. Drop + // only crossings that have neither a uid nor a file on a side. + const consumerUid = String(r.consumerUid ?? r[0] ?? ''); + const providerUid = String(r.providerUid ?? r[1] ?? ''); + if (!consumerUid && !consumerFile) return null; + if (!providerUid && !providerFile) return null; + return { + consumerUid, + providerUid, + consumerFile, + providerFile, + matchType: String(r.matchType ?? r[4] ?? 'exact'), + confidence: Number(r.confidence ?? r[5] ?? 0), + contractId: String(r.contractId ?? r[6] ?? ''), + contractType: String(r.contractType ?? r[7] ?? 'custom'), + }; +} + +async function listCrossingsBetween( + handle: BridgeHandle, + fromRepo: string, + toRepo: string, +): Promise<{ crossings: CrossingRow[]; truncated: boolean }> { + const rows = await queryBridge>(handle, CY_CROSSINGS_BETWEEN, { + fromRepo, + toRepo, + }); + const all: CrossingRow[] = []; + for (const raw of rows) { + const c = rowToCrossing(raw); + if (c) all.push(c); + } + // The query already orders by confidence DESC; re-sort defensively so the cap + // keeps the strongest candidates even if a tuple-mode driver reorders rows. + all.sort((a, b) => b.confidence - a.confidence); + const truncated = all.length > MAX_CROSSINGS_TO_TRY; + return { + crossings: truncated ? all.slice(0, MAX_CROSSINGS_TO_TRY) : all, + truncated, + }; +} + +function destRowToCrossing(r: Record): CrossingRow | null { + const consumerUid = String(r.consumerUid ?? r[0] ?? ''); + const consumerFile = String(r.consumerFile ?? r[2] ?? ''); + // Only the consumer side must be anchorable here — the provider endpoint is + // reported (by name/file), not traced into, so an empty provider uid is fine. + if (!consumerUid && !consumerFile) return null; + return { + consumerUid, + providerUid: String(r.providerUid ?? r[1] ?? ''), + consumerFile, + providerFile: String(r.providerFile ?? r[3] ?? ''), + providerRepo: String(r.providerRepo ?? r[4] ?? ''), + providerName: String(r.providerName ?? r[5] ?? ''), + matchType: String(r.matchType ?? r[6] ?? 'exact'), + confidence: Number(r.confidence ?? r[7] ?? 0), + contractId: String(r.contractId ?? r[8] ?? ''), + contractType: String(r.contractType ?? r[9] ?? 'custom'), + }; +} + +async function listCrossingsFrom( + handle: BridgeHandle, + fromRepo: string, +): Promise<{ crossings: CrossingRow[]; truncated: boolean }> { + const rows = await queryBridge>(handle, CY_CROSSINGS_FROM, { fromRepo }); + const all: CrossingRow[] = []; + for (const raw of rows) { + const c = destRowToCrossing(raw); + if (c) all.push(c); + } + all.sort((a, b) => b.confidence - a.confidence); + const truncated = all.length > MAX_CROSSINGS_TO_TRY; + return { + crossings: truncated ? all.slice(0, MAX_CROSSINGS_TO_TRY) : all, + truncated, + }; +} + +// ── Cross-member symbol resolution ─────────────────────────────────────── + +interface MemberHandle { + repoPath: string; + registryName: string; + handle: GroupRepoHandle; +} + +interface ResolvedEndpoint { + member: MemberHandle; + symbol: { id: string; name: string; filePath: string; startLine: number }; +} + +type ResolveAcrossOutcome = + | { kind: 'ok'; endpoint: ResolvedEndpoint } + | { kind: 'ambiguous'; candidates: GroupTraceCandidate[] } + | { kind: 'not_found' }; + +/** Resolution result plus the member repos that could not be queried at all. */ +interface ResolveAcrossResult { + outcome: ResolveAcrossOutcome; + /** repoPaths whose resolveSymbol threw (corrupt/unopenable DB) — distinct from "symbol absent". */ + degraded: string[]; +} + +/** + * Resolve a symbol query across every member repo. Exactly one `ok` match and + * no per-member ambiguity → resolved. Zero matches → not_found. Anything else + * (multiple members match, or any member is itself ambiguous) → ambiguous, with + * every candidate tagged by its repo so the caller can disambiguate. + */ +async function resolveAcrossMembers( + port: GroupToolPort, + members: MemberHandle[], + query: { name?: string; uid?: string; file_path?: string }, +): Promise { + const okMatches: ResolvedEndpoint[] = []; + const ambiguous: GroupTraceCandidate[] = []; + const degraded: string[] = []; + + // Resolve the symbol in every member concurrently (each is an independent DB + // read). Promise.all preserves member order, so the aggregated okMatches / + // ambiguous sets are identical to a sequential walk — matching how + // groupContext / groupQuery iterate members. + const resolveSymbol = port.resolveSymbol; + const perMember = await Promise.all( + members.map( + async ( + member, + ): Promise< + | { member: MemberHandle; outcome: GroupSymbolResolution } + | { member: MemberHandle; failed: true } + | null + > => { + if (!resolveSymbol) return null; + try { + return { member, outcome: await resolveSymbol(member.handle, query) }; + } catch { + // A throw means the member's DB could not be queried — NOT that the + // symbol is absent. Record it so a not_found can be flagged as + // possibly-incomplete rather than asserting the symbol does not exist. + return { member, failed: true }; + } + }, + ), + ); + + for (const entry of perMember) { + if (!entry) continue; + if ('failed' in entry) { + degraded.push(entry.member.repoPath); + continue; + } + const { member, outcome } = entry; + if (outcome.kind === 'ok') { + okMatches.push({ + member, + symbol: { + id: outcome.symbol.id, + name: outcome.symbol.name, + filePath: outcome.symbol.filePath, + startLine: outcome.symbol.startLine, + }, + }); + } else if (outcome.kind === 'ambiguous') { + for (const c of outcome.candidates) { + ambiguous.push({ + repo: member.repoPath, + id: c.id, + name: c.name, + filePath: c.filePath, + startLine: c.startLine, + }); + } + } + } + + if (okMatches.length === 1 && ambiguous.length === 0) { + return { outcome: { kind: 'ok', endpoint: okMatches[0]! }, degraded }; + } + if (okMatches.length === 0 && ambiguous.length === 0) { + return { outcome: { kind: 'not_found' }, degraded }; + } + // Multiple repos matched, or a member was internally ambiguous: surface all. + const candidates = [ + ...okMatches.map((m) => ({ + repo: m.member.repoPath, + id: m.symbol.id, + name: m.symbol.name, + filePath: m.symbol.filePath, + startLine: m.symbol.startLine, + })), + ...ambiguous, + ]; + return { outcome: { kind: 'ambiguous', candidates }, degraded }; +} + +// ── Stitching ──────────────────────────────────────────────────────────── + +function tagHops(hops: LocalTraceShape['hops'], repo: string): TraceHop[] { + return (hops ?? []).map((h) => ({ + name: h.name, + filePath: h.filePath, + startLine: h.startLine, + repo, + })); +} + +function endpointFrom(res: ResolvedEndpoint): GroupTraceEndpoint { + return { + name: res.symbol.name, + filePath: res.symbol.filePath, + startLine: res.symbol.startLine, + repo: res.member.repoPath, + }; +} + +// ── PDG enrichment (U4 hook) ───────────────────────────────────────────── + +/** + * Attach the intra-procedural REACHING_DEF data-flow for a boundary-adjacent + * segment, when the caller opted in (`pdg:true`) and the repo has a PDG `flows` + * layer. Returns `undefined` (no enrichment) plus a note when degraded. + */ +async function enrichSegment( + port: GroupToolPort, + member: MemberHandle, + anchorUid: string, + anchorName: string, + limit: number, + notes: string[], +): Promise { + const pdgFlows = port.pdgFlows; + if (!pdgFlows) return undefined; + let flow; + try { + flow = await pdgFlows(member.handle, { uid: anchorUid }, { limit }); + } catch { + return undefined; + } + if (!flow.available) { + notes.push(TRACE_NOTES.noPdgLayer(member.repoPath)); + return undefined; + } + if (flow.hops.length === 0) return undefined; + return { + repo: member.repoPath, + anchor: anchorName, + variable: flow.variable, + hops: flow.hops, + truncated: flow.truncated, + }; +} + +// ── Param parsing ──────────────────────────────────────────────────────── + +interface ParsedTraceParams { + name: string; + from?: string; + to?: string; + from_uid?: string; + to_uid?: string; + from_file?: string; + to_file?: string; + maxDepth?: number; + includeTests: boolean; + pdg: boolean; + pdgLimit: number; + /** True when the caller asked for a deeper crossDepth than is supported. */ + crossDepthClamped: boolean; + /** + * True when no `to`/`to_uid`/`to_file` was given: a *destination* trace that + * follows `from`'s outgoing HTTP call across the bridge and reports where it + * lands. This is how an anonymous handler (no nameable symbol) is reached. + */ + destination: boolean; +} + +const DEFAULT_PDG_FLOW_LIMIT = 50; +const MAX_PDG_FLOW_LIMIT = 200; + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v.trim() !== '' ? v : undefined; +} + +function parseTraceParams( + params: Record, +): { ok: true; parsed: ParsedTraceParams } | { ok: false; error: string } { + const name = String(params.name ?? '').trim(); + if (!name) return { ok: false, error: 'name is required' }; + const from = str(params.from); + const to = str(params.to); + const from_uid = str(params.from_uid); + const to_uid = str(params.to_uid); + const to_file = str(params.to_file); + if (!from && !from_uid) return { ok: false, error: 'from (or from_uid) is required' }; + // No `to` at all → destination trace (trace `from` to its HTTP endpoint). + const destination = !to && !to_uid && !to_file; + const maxDepth = + typeof params.maxDepth === 'number' && params.maxDepth > 0 ? params.maxDepth : undefined; + const rawLimit = + typeof params.limit === 'number' && params.limit > 0 ? params.limit : DEFAULT_PDG_FLOW_LIMIT; + const crossDepthClamped = + typeof params.crossDepth === 'number' && + Number.isFinite(params.crossDepth) && + Math.floor(params.crossDepth) > MAX_SUPPORTED_CROSS_DEPTH; + return { + ok: true, + parsed: { + name, + from, + to, + from_uid, + to_uid, + from_file: str(params.from_file), + to_file, + maxDepth, + includeTests: Boolean(params.includeTests), + pdg: params.pdg === true, + pdgLimit: Math.min(rawLimit, MAX_PDG_FLOW_LIMIT), + crossDepthClamped, + destination, + }, + }; +} + +// ── Main entry ─────────────────────────────────────────────────────────── + +export async function runGroupTrace( + deps: RunGroupTraceDeps, + params: Record, +): Promise { + const parsedResult = parseTraceParams(params); + if (parsedResult.ok === false) { + return { + status: 'error', + group: String(params.name ?? ''), + error: parsedResult.error, + notes: [], + }; + } + const p = parsedResult.parsed; + + if (!deps.port.trace || !deps.port.resolveSymbol) { + return { + status: 'error', + group: p.name, + error: 'Cross-repo trace is not supported by this backend (trace/resolveSymbol unavailable).', + notes: [], + }; + } + + const groupDir = getGroupDir(deps.gitnexusDir, p.name); + let config: GroupConfig; + try { + config = await loadGroupConfig(groupDir); + } catch (e) { + if (e instanceof GroupNotFoundError) { + return { + status: 'error', + group: p.name, + error: `Group "${p.name}" not found. Run group_list to see configured groups.`, + notes: [], + }; + } + return { + status: 'error', + group: p.name, + error: e instanceof Error ? e.message : String(e), + notes: [], + }; + } + + // Resolve member handles up front, concurrently (each opens an independent + // repo pool; order is preserved by Promise.all). A member that can't be + // resolved is skipped — its absence only matters if an endpoint lived there, + // which surfaces downstream as not_found. + const resolvedMembers = await Promise.all( + Object.entries(config.repos).map( + async ([repoPath, registryName]): Promise< + MemberHandle | { repoPath: string; failed: true } + > => { + try { + const handle = await deps.port.resolveRepo(registryName); + return { repoPath, registryName, handle }; + } catch { + return { repoPath, failed: true }; + } + }, + ), + ); + const members: MemberHandle[] = []; + const unreachableMembers: string[] = []; + for (const m of resolvedMembers) { + if ('failed' in m) unreachableMembers.push(m.repoPath); + else members.push(m); + } + if (members.length === 0) { + return { + status: 'error', + group: p.name, + error: 'No resolvable repos in this group.', + notes: [], + }; + } + + // A not_found is only authoritative if every relevant member was actually + // queryable. Members whose repo failed to resolve, or whose symbol query + // threw, are tracked so a not_found can be flagged as possibly-incomplete + // rather than asserting the symbol does not exist anywhere. + const degradedNotes = (extra: string[]): string[] => { + const all = [...new Set([...unreachableMembers, ...extra])]; + return all.length > 0 ? [TRACE_NOTES.degradedMembers(all)] : []; + }; + + const fromRes = await resolveAcrossMembers(deps.port, members, { + name: p.from, + uid: p.from_uid, + file_path: p.from_file, + }); + if (fromRes.outcome.kind === 'not_found') { + return { + status: 'not_found', + group: p.name, + role: 'from', + query: p.from_uid ?? p.from, + notes: degradedNotes(fromRes.degraded), + suggestion: + 'Check the symbol name, pass from_uid for zero-ambiguity, or from_file to narrow.', + }; + } + if (fromRes.outcome.kind === 'ambiguous') { + return { + status: 'ambiguous', + group: p.name, + role: 'from', + candidates: fromRes.outcome.candidates, + notes: [], + }; + } + const fromEp = fromRes.outcome.endpoint; + + // Destination trace: no `to` was given → follow `from`'s outgoing HTTP call + // across the bridge and report where it lands. This is the only way to reach + // a handler that has no nameable symbol (an inline anonymous route handler). + if (p.destination) { + return stitchToDestination(deps, p, fromEp, groupDir, degradedNotes(fromRes.degraded)); + } + + const toRes = await resolveAcrossMembers(deps.port, members, { + name: p.to, + uid: p.to_uid, + file_path: p.to_file, + }); + if (toRes.outcome.kind === 'not_found') { + return { + status: 'not_found', + group: p.name, + role: 'to', + query: p.to_uid ?? p.to, + notes: degradedNotes([...fromRes.degraded, ...toRes.degraded]), + suggestion: 'Check the symbol name, pass to_uid for zero-ambiguity, or to_file to narrow.', + }; + } + if (toRes.outcome.kind === 'ambiguous') { + return { + status: 'ambiguous', + group: p.name, + role: 'to', + candidates: toRes.outcome.candidates, + notes: [], + }; + } + + const toEp = toRes.outcome.endpoint; + // Seed with degraded-member notes so a SUCCESSFUL trace is still flagged as + // possibly-incomplete: group resolution is "unique among the members we could + // query", so if a member that threw during resolveSymbol also holds `from`/`to` + // the real answer could be ambiguous. Carries through same-repo and cross-repo + // success, not just the not_found branches. + const notes: string[] = degradedNotes([...fromRes.degraded, ...toRes.degraded]); + + // Same repo → single-repo trace, no crossing. + if (fromEp.member.repoPath === toEp.member.repoPath) { + return stitchSameRepo(deps.port, p, fromEp, toEp, notes); + } + + // Different repos → cross one ContractLink boundary. + return stitchCrossRepo(deps, p, fromEp, toEp, groupDir, notes); +} + +async function stitchSameRepo( + port: GroupToolPort, + p: ParsedTraceParams, + fromEp: ResolvedEndpoint, + toEp: ResolvedEndpoint, + notes: string[], +): Promise { + // PDG enrichment only runs at a cross-repo boundary; tell the agent why a + // same-repo trace returns no dataFlow even though pdg:true was passed. + if (p.pdg) notes.push(TRACE_NOTES.pdgSameRepoNoop); + const segRaw = await port.trace!(fromEp.member.handle, { + from_uid: fromEp.symbol.id, + to_uid: toEp.symbol.id, + maxDepth: p.maxDepth, + includeTests: p.includeTests, + }); + const seg = asLocalTrace(segRaw); + if (!seg || seg.status !== 'ok') { + notes.push(TRACE_NOTES.noLocalPath(fromEp.member.repoPath)); + return { + status: 'not_found', + group: p.name, + notes, + suggestion: + 'Both endpoints resolve in the same repo but no directed path connects them. ' + + 'Try a higher maxDepth or inspect connections with context().', + }; + } + const hops = tagHops(seg.hops, fromEp.member.repoPath); + const edges: TraceEdge[] = (seg.edges ?? []).map((e) => ({ + relType: e.relType, + confidence: e.confidence, + })); + return { + status: 'ok', + group: p.name, + from: endpointFrom(fromEp), + to: endpointFrom(toEp), + crossings: [], + hopCount: edges.length, + hops, + edges, + notes, + }; +} + +async function stitchCrossRepo( + deps: RunGroupTraceDeps, + p: ParsedTraceParams, + fromEp: ResolvedEndpoint, + toEp: ResolvedEndpoint, + groupDir: string, + notes: string[], +): Promise { + const bridgePrep = await ensureBridgeReady(groupDir); + if ('error' in bridgePrep) { + return { status: 'error', group: p.name, error: bridgePrep.error, notes }; + } + const handle = bridgePrep.handle; + + if (p.crossDepthClamped) notes.push(TRACE_NOTES.crossDepthClamped); + if (p.pdg) notes.push(TRACE_NOTES.pdgRequested); + + try { + const { crossings, truncated: crossingsTruncated } = await listCrossingsBetween( + handle, + fromEp.member.repoPath, + toEp.member.repoPath, + ); + if (crossings.length === 0) { + notes.push(TRACE_NOTES.noBridgeLink); + return { + status: 'not_found', + group: p.name, + notes, + suggestion: + 'The endpoints live in different repos with no ContractLink between them. ' + + 'Run group_sync, or trace within a single repo.', + }; + } + if (crossingsTruncated) notes.push(TRACE_NOTES.crossingsCapped(MAX_CROSSINGS_TO_TRY)); + + // Per-endpoint trace memoization: many crossings can share one consumer + // (a client call linked to several providers) or one provider, and the + // home-repo segment depends ONLY on the consumer uid (from is fixed) while + // the target-repo segment depends ONLY on the provider uid (to is fixed). + // Cache each unique-endpoint trace so the fan-out is bounded by the number + // of distinct endpoints, not the number of crossings. `undefined` = not yet + // attempted; `null` = attempted and did not connect. + const segACache = new Map(); + const segBCache = new Map(); + + const traceFromTo = async ( + handleToUse: GroupRepoHandle, + fromUid: string, + toUid: string, + ): Promise => { + const raw = await deps.port.trace!(handleToUse, { + from_uid: fromUid, + to_uid: toUid, + maxDepth: p.maxDepth, + includeTests: p.includeTests, + }); + const seg = asLocalTrace(raw); + return seg && seg.status === 'ok' ? seg : null; + }; + + // Single boundary crossing (MAX_SUPPORTED_CROSS_DEPTH). Try crossings in + // confidence order; the first one whose two segments both connect wins. + let usedFileFallback = false; + for (const crossing of crossings) { + // Anchor each boundary to a usable symbol id. Prefer the resolved + // symbolUid; when it is empty (HTTP and other source-scan contracts + // hardcode symbolUid:''), fall back to the trace endpoint itself when it + // lives in the contract's file — the common "trace from the calling + // function to the handler function" case. A side we can anchor on neither + // a uid nor the file is skipped. + let consumerUid = crossing.consumerUid; + let consumerViaFile = false; + if (!consumerUid && sameFile(fromEp.symbol.filePath, crossing.consumerFile)) { + consumerUid = fromEp.symbol.id; + consumerViaFile = true; + } + if (!consumerUid) continue; + + let providerUid = crossing.providerUid; + let providerViaFile = false; + if (!providerUid && sameFile(toEp.symbol.filePath, crossing.providerFile)) { + providerUid = toEp.symbol.id; + providerViaFile = true; + } + if (!providerUid) continue; + + let segA = segACache.get(consumerUid); + if (segA === undefined) { + segA = await traceFromTo(fromEp.member.handle, fromEp.symbol.id, consumerUid); + segACache.set(consumerUid, segA); + } + if (!segA) continue; + + let segB = segBCache.get(providerUid); + if (segB === undefined) { + segB = await traceFromTo(toEp.member.handle, providerUid, toEp.symbol.id); + segBCache.set(providerUid, segB); + } + if (!segB) continue; + + usedFileFallback = consumerViaFile || providerViaFile; + + // Found a connecting crossing. Build the stitched path. + const hopsA = tagHops(segA.hops, fromEp.member.repoPath); + const hopsB = tagHops(segB.hops, toEp.member.repoPath); + const edgesA: TraceEdge[] = (segA.edges ?? []).map((e) => ({ + relType: e.relType, + confidence: e.confidence, + })); + const edgesB: TraceEdge[] = (segB.edges ?? []).map((e) => ({ + relType: e.relType, + confidence: e.confidence, + })); + const boundaryEdge: TraceEdge = { relType: 'CONTRACT_LINK', confidence: crossing.confidence }; + + const bridgeCrossing: BridgeCrossing = { + fromRepo: fromEp.member.repoPath, + toRepo: toEp.member.repoPath, + contractId: crossing.contractId, + contractType: crossing.contractType, + matchType: crossing.matchType, + confidence: crossing.confidence, + }; + + if (usedFileFallback) notes.push(TRACE_NOTES.fileBoundaryFallback); + + // PDG enrichment (opt-in): anchor on the RESOLVED boundary uids (which, + // under the file fallback, are the trace endpoints themselves). + const dataFlow: SegmentDataFlow[] = []; + if (p.pdg) { + const dfA = await enrichSegment( + deps.port, + fromEp.member, + consumerUid, + consumerNameFromHops(hopsA, consumerUid), + p.pdgLimit, + notes, + ); + if (dfA) dataFlow.push(dfA); + const dfB = await enrichSegment( + deps.port, + toEp.member, + providerUid, + providerNameFromHops(hopsB), + p.pdgLimit, + notes, + ); + if (dfB) dataFlow.push(dfB); + } + + const edges = [...edgesA, boundaryEdge, ...edgesB]; + const result: GroupTraceOkResult = { + status: 'ok', + group: p.name, + from: endpointFrom(fromEp), + to: endpointFrom(toEp), + crossings: [bridgeCrossing], + hopCount: edges.length, + hops: [...hopsA, ...hopsB], + edges, + notes, + ...(dataFlow.length > 0 ? { dataFlow } : {}), + }; + return result; + } + + // Crossings exist but none connected both segments. If the crossing cap was + // hit, this is NOT authoritative — a connecting link may rank beyond the cap. + notes.push(TRACE_NOTES.noBridgeLink); + return { + status: 'not_found', + group: p.name, + ...(crossingsTruncated ? { truncated: true } : {}), + notes, + suggestion: crossingsTruncated + ? `No connecting crossing among the ${MAX_CROSSINGS_TO_TRY} highest-confidence ` + + 'ContractLinks tried, but more exist (truncated:true) — narrow with from_uid/to_uid, ' + + 'or a higher maxDepth, before concluding no path exists.' + : 'A ContractLink exists between the repos, but no local path reaches the consumer ' + + 'call site or leaves the provider handler. Try a higher maxDepth.', + }; + } finally { + await closeBridgeDb(handle); + } +} + +// A source-scan fallback leaves a file basename as the symbol name when it could +// not resolve a real handler (e.g. `routes.ts`). Match only KNOWN source-file +// extensions — NOT any dotted name — so a legitimate method-style handler name +// (`users.list`, `UserController.index`) is not misclassified as anonymous. +const FILE_BASENAME_RE = + /\.(ts|tsx|js|jsx|mjs|cjs|py|go|php|java|kt|kts|rb|cs|rs|swift|dart|scala|c|cc|cpp|cxx|h|hpp|m|mm)$/i; + +/** + * A provider endpoint's display label. A resolved handler has a real function + * name; the source-scan fallbacks leave a generic token (`'handler'`/`'fetch'`) + * or a file basename. Those are treated as anonymous and shown as + * `` so the endpoint is still identifiable by route. When + * the bridge row carries a resolved `providerUid`, the name IS a real symbol — + * the `'handler'`/`'fetch'` sentinel check is suppressed so a function genuinely + * named `handler` is not mislabeled anonymous. + */ +function providerLabel( + providerName: string, + contractId: string, + providerUid: string, +): { label: string; anon: boolean } { + const resolved = providerUid !== ''; + const generic = + providerName === '' || + FILE_BASENAME_RE.test(providerName) || + (!resolved && (providerName === 'handler' || providerName === 'fetch')); + return generic + ? { label: `<${contractId} handler>`, anon: true } + : { label: providerName, anon: false }; +} + +/** + * Destination trace: no `to` was given. Follow `from`'s outgoing HTTP call across + * the bridge and report the provider endpoint it lands on — by route + file when + * the handler is anonymous (the only way to reach a handler with no symbol). + */ +async function stitchToDestination( + deps: RunGroupTraceDeps, + p: ParsedTraceParams, + fromEp: ResolvedEndpoint, + groupDir: string, + degradedNoteList: string[], +): Promise { + const bridgePrep = await ensureBridgeReady(groupDir); + if ('error' in bridgePrep) { + return { status: 'error', group: p.name, error: bridgePrep.error, notes: [] }; + } + const handle = bridgePrep.handle; + // Seed with degraded-member notes so EVERY outcome (success included) carries + // them — a successful destination trace can still be incomplete if a member + // repo could not be queried. + const notes: string[] = [...degradedNoteList]; + if (p.crossDepthClamped) notes.push(TRACE_NOTES.crossDepthClamped); + + try { + const { crossings, truncated } = await listCrossingsFrom(handle, fromEp.member.repoPath); + if (crossings.length === 0) { + notes.push(TRACE_NOTES.destinationNoLink); + return { + status: 'not_found', + group: p.name, + role: 'to', + query: p.from_uid ?? p.from, + notes, + suggestion: 'Pass a `to` symbol for a symbol-to-symbol trace, or run group_sync.', + }; + } + if (truncated) notes.push(TRACE_NOTES.crossingsCapped(MAX_CROSSINGS_TO_TRY)); + + const segACache = new Map(); + const traceTo = async (uid: string): Promise => { + const cached = segACache.get(uid); + if (cached !== undefined) return cached; + const raw = await deps.port.trace!(fromEp.member.handle, { + from_uid: fromEp.symbol.id, + to_uid: uid, + maxDepth: p.maxDepth, + includeTests: p.includeTests, + }); + const seg = asLocalTrace(raw); + const r = seg && seg.status === 'ok' ? seg : null; + segACache.set(uid, r); + return r; + }; + + // Collect EVERY connecting crossing (not just the first) so an ambiguous + // destination is reported as ambiguous, not silently resolved to the + // highest-confidence sibling. Two tiers: + // PRECISE — the consumer resolved to a real symbolUid that `from` reaches. + // Strong: a successful `trace(from -> consumerUid)` proves it. + // FILE — the consumer uid is empty but `from` lives in the consumer's + // file. WEAK: `trace(from -> from)` is trivially ok, so this only + // proves the FILE makes the call, not that THIS `from` does. + type Hit = { crossing: CrossingRow; segA: LocalTraceShape }; + const precise: Hit[] = []; + const fileLevel: Hit[] = []; + for (const crossing of crossings) { + if (crossing.consumerUid) { + const segA = await traceTo(crossing.consumerUid); + if (segA) precise.push({ crossing, segA }); + } else if (sameFile(fromEp.symbol.filePath, crossing.consumerFile)) { + const segA = await traceTo(fromEp.symbol.id); + if (segA) fileLevel.push({ crossing, segA }); + } + } + + const endpointKey = (c: CrossingRow): string => `${c.providerRepo ?? ''} ${c.contractId}`; + const distinct = (hits: Hit[]): Hit[] => { + const seen = new Set(); + const out: Hit[] = []; + for (const h of hits) { + const k = endpointKey(h.crossing); + if (!seen.has(k)) { + seen.add(k); + out.push(h); + } + } + return out; + }; + const candidatesFrom = (hits: Hit[]): GroupTraceCandidate[] => + distinct(hits).map((h) => ({ + repo: h.crossing.providerRepo ?? '', + id: h.crossing.contractId, + name: providerLabel( + h.crossing.providerName ?? '', + h.crossing.contractId, + h.crossing.providerUid ?? '', + ).label, + filePath: h.crossing.providerFile, + startLine: 0, + })); + + const buildOk = (hit: Hit, fileLevelAnchor: boolean): GroupTraceOkResult => { + const { crossing, segA } = hit; + const providerRepo = crossing.providerRepo ?? ''; + const { label, anon } = providerLabel( + crossing.providerName ?? '', + crossing.contractId, + crossing.providerUid ?? '', + ); + const hopsA = tagHops(segA.hops, fromEp.member.repoPath); + const providerHop: TraceHop = { + name: label, + filePath: crossing.providerFile, + startLine: 0, + repo: providerRepo, + }; + const edgesA: TraceEdge[] = (segA.edges ?? []).map((e) => ({ + relType: e.relType, + confidence: e.confidence, + })); + const boundaryEdge: TraceEdge = { relType: 'CONTRACT_LINK', confidence: crossing.confidence }; + const resultNotes = [...notes]; + if (fileLevelAnchor) resultNotes.push(TRACE_NOTES.fileBoundaryFallback); + if (anon) { + resultNotes.push( + TRACE_NOTES.anonymousHandler( + crossing.contractId, + `${providerRepo}:${crossing.providerFile}`, + ), + ); + } + return { + status: 'ok', + group: p.name, + from: endpointFrom(fromEp), + to: { name: label, filePath: crossing.providerFile, startLine: 0, repo: providerRepo }, + crossings: [ + { + fromRepo: fromEp.member.repoPath, + toRepo: providerRepo, + contractId: crossing.contractId, + contractType: crossing.contractType, + matchType: crossing.matchType, + confidence: crossing.confidence, + }, + ], + hopCount: edgesA.length + 1, + hops: [...hopsA, providerHop], + edges: [...edgesA, boundaryEdge], + ...(truncated ? { truncated: true } : {}), + notes: resultNotes, + }; + }; + + // Precise hits win. Crossings are pre-sorted by confidence, so the first + // distinct precise hit is the strongest. More than one DISTINCT precise + // endpoint means `from` genuinely reaches several — report it as ambiguous. + const distinctPrecise = distinct(precise); + if (distinctPrecise.length === 1) return buildOk(distinctPrecise[0]!, false); + if (distinctPrecise.length > 1) { + return { + status: 'ambiguous', + group: p.name, + role: 'to', + candidates: candidatesFrom(precise), + notes: [...notes, TRACE_NOTES.destinationMultiple], + }; + } + // No precise hit — fall back to the file-level anchor, but ONLY when it is + // unambiguous (a single endpoint). Multiple file-level endpoints cannot be + // disambiguated without a resolved consumer uid, so report them as candidates + // rather than guessing the highest-confidence one. + const distinctFile = distinct(fileLevel); + if (distinctFile.length === 1) return buildOk(distinctFile[0]!, true); + if (distinctFile.length > 1) { + return { + status: 'ambiguous', + group: p.name, + role: 'to', + candidates: candidatesFrom(fileLevel), + notes: [...notes, TRACE_NOTES.destinationAmbiguousFile], + }; + } + + notes.push(TRACE_NOTES.destinationNoReach); + return { + status: 'not_found', + group: p.name, + role: 'to', + query: p.from_uid ?? p.from, + ...(truncated ? { truncated: true } : {}), + notes, + suggestion: 'Trace from the function that issues the HTTP request, or pass a `to` symbol.', + }; + } finally { + await closeBridgeDb(handle); + } +} + +/** The consumer call site is the last hop of segment A; fall back to the uid. */ +function consumerNameFromHops(hopsA: TraceHop[], consumerUid: string): string { + return hopsA.length > 0 ? hopsA[hopsA.length - 1]!.name : consumerUid; +} + +/** The provider handler is the first hop of segment B. */ +function providerNameFromHops(hopsB: TraceHop[]): string { + return hopsB.length > 0 ? hopsB[0]!.name : 'provider'; +} diff --git a/gitnexus/src/core/group/extractors/http-patterns/go.ts b/gitnexus/src/core/group/extractors/http-patterns/go.ts index afbfaad56..45507a948 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/go.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/go.ts @@ -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, }); } diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index c62d9f937..491d14ec4 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -728,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, }); } @@ -771,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, }); } @@ -792,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, }); } @@ -812,6 +815,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { method: httpMethod, path, name: null, + line: pathNode.startPosition.row + 1, confidence: 0.7, }); } @@ -828,6 +832,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { method: httpMethodNode.text.toUpperCase(), path, name: null, + line: pathNode.startPosition.row + 1, confidence: 0.7, }); } @@ -850,6 +855,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { method: httpMethod, path, name: null, + line: pathNode.startPosition.row + 1, confidence: 0.7, }); } @@ -873,6 +879,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { method: verbText, path, name: null, + line: pathNode.startPosition.row + 1, confidence: 0.7, }); } @@ -899,6 +906,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { method, path, name: null, + line: pathNode.startPosition.row + 1, confidence: 0.7, }); } diff --git a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts index 3b51dc722..9960dc6ed 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts @@ -996,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, }); } @@ -1038,6 +1039,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { method: httpMethod, path, name: null, + line: pathNode.startPosition.row + 1, confidence: 0.7, }); } @@ -1057,6 +1059,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { method: httpMethod, path, name: null, + line: pathNode.startPosition.row + 1, confidence: 0.7, }); } @@ -1084,6 +1087,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { method: verbText, path, name: null, + line: pathNode.startPosition.row + 1, confidence: 0.7, }); } @@ -1108,6 +1112,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { method, path, name: null, + line: pathNode.startPosition.row + 1, confidence: 0.7, }); } @@ -1134,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, }); } diff --git a/gitnexus/src/core/group/extractors/http-patterns/node.ts b/gitnexus/src/core/group/extractors/http-patterns/node.ts index fbf988665..b316cbf6e 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/node.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/node.ts @@ -65,7 +65,7 @@ const EXPRESS_SPEC: PatternSpec> = { 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, }); } diff --git a/gitnexus/src/core/group/extractors/http-patterns/php.ts b/gitnexus/src/core/group/extractors/http-patterns/php.ts index a2042cfd4..a033d9941 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/php.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/php.ts @@ -172,6 +172,7 @@ export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = { method: methodNode.text.toUpperCase(), path, name: null, + line: pathNode.startPosition.row + 1, confidence: 0.7, }); } @@ -188,6 +189,7 @@ export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = { method: methodNode.text.toUpperCase(), path, name: null, + line: pathNode.startPosition.row + 1, confidence: 0.7, }); } @@ -203,6 +205,7 @@ export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = { method: 'GET', path, name: null, + line: pathNode.startPosition.row + 1, confidence: 0.7, }); } diff --git a/gitnexus/src/core/group/extractors/http-patterns/python.ts b/gitnexus/src/core/group/extractors/http-patterns/python.ts index cadaacc32..d4e61b8f9 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/python.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/python.ts @@ -1021,6 +1021,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { method: methodNode.text.toUpperCase(), path, name: null, + line: pathNode.startPosition.row + 1, confidence: 0.7, }); } @@ -1038,6 +1039,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { method: methodNode.text.toUpperCase(), path, name: null, + line: pathNode.startPosition.row + 1, confidence: 0.7, }); } @@ -1056,6 +1058,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { method: methodRaw.toUpperCase(), path, name: null, + line: pathNode.startPosition.row + 1, confidence: 0.7, }); } @@ -1075,6 +1078,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { method: methodNode.text.toUpperCase(), path, name: null, + line: pathNode.startPosition.row + 1, confidence: 0.7, }); } @@ -1095,6 +1099,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { method: methodRaw.toUpperCase(), path, name: null, + line: pathNode.startPosition.row + 1, confidence: 0.7, }); } @@ -1127,6 +1132,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { method: httpMethod, path, name: null, + line: methodNode.startPosition.row + 1, confidence: 0.65, }); } @@ -1153,6 +1159,7 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { method: httpMethod, path: normalized, name: null, + line: methodNode.startPosition.row + 1, confidence: 0.6, }); } diff --git a/gitnexus/src/core/group/extractors/http-patterns/types.ts b/gitnexus/src/core/group/extractors/http-patterns/types.ts index 22f6597ba..28bedb1bb 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/types.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/types.ts @@ -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; } diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index 722161e20..62ff1de14 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -58,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[], + 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[], 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) ── @@ -124,35 +204,6 @@ function methodFromRouteReason(reason: string): string | null { return null; } -function pickSymbolUid( - rows: Record[], - 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 { @@ -284,6 +335,47 @@ export class HttpRouteExtractor implements ContractExtractor { const files = await getScannedFiles(); + // 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[]>(); + const loadFileSymbols = async (filePath: string): Promise[]> => { + if (!dbExecutor) return []; + const cached = fileSymbolCache.get(filePath); + if (cached) return cached; + let rows: Record[] = []; + try { + rows = await dbExecutor(CONTAINING_QUERY, { filePath }); + } catch { + rows = []; + } + fileSymbolCache.set(filePath, rows); + return rows; + }; + const resolveDetectionSymbol = async ( + filePath: string, + d: HttpDetection, + ): Promise => { + 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 @@ -294,7 +386,12 @@ export class HttpRouteExtractor implements ContractExtractor { const coveredFiles = new Set(); const graphProviders = dbExecutor != null - ? await this.extractProvidersGraph(dbExecutor, getDetections, coveredFiles) + ? await this.extractProvidersGraph( + dbExecutor, + getDetections, + resolveDetectionSymbol, + coveredFiles, + ) : []; // Consumer-safety gate (#2138 Part 2): `extractProvidersGraph` marks a file @@ -326,14 +423,16 @@ export class HttpRouteExtractor implements ContractExtractor { const providers = this.mergeGraphAndSourceContracts( graphProviders, - await this.extractProvidersSourceScan(scanFiles, 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(scanFiles, getDetections), + await this.extractConsumersSourceScan(scanFiles, getDetections, resolveDetectionSymbol), ); return [...providers, ...consumers]; @@ -359,6 +458,7 @@ export class HttpRouteExtractor implements ContractExtractor { private async extractProvidersGraph( db: CypherExecutor, getDetections: (rel: string) => Promise, + resolveSymbol: (filePath: string, d: HttpDetection) => Promise, coveredFiles?: Set, ): Promise { const out: ExtractedContract[] = []; @@ -409,19 +509,17 @@ export class HttpRouteExtractor implements ContractExtractor { let symbolUid = ''; let symbolName = path.basename(filePath) || 'handler'; let symPath = 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. + // 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 (fileId) { + if (filePath) { try { - const syms = await db(CONTAINS_QUERY, { fileId }); + 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; @@ -433,11 +531,13 @@ export class HttpRouteExtractor implements ContractExtractor { } } 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. + // 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'); - 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 @@ -452,28 +552,17 @@ export class HttpRouteExtractor implements ContractExtractor { } 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; - } + // 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'; - - 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 */ - } + const resolved = + match && !ambiguousCandidates ? await resolveSymbol(filePath, match) : null; + if (resolved) { + symbolUid = resolved.uid; + symbolName = resolved.name; + symPath = resolved.filePath || filePath; } } @@ -517,6 +606,7 @@ export class HttpRouteExtractor implements ContractExtractor { private async extractProvidersSourceScan( files: string[], getDetections: (rel: string) => Promise, + resolveSymbol: (filePath: string, d: HttpDetection) => Promise, ): Promise { const out: ExtractedContract[] = []; for (const rel of files) { @@ -524,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, }, }); @@ -550,6 +647,7 @@ export class HttpRouteExtractor implements ContractExtractor { private async extractConsumersGraph( db: CypherExecutor, getDetections: (rel: string) => Promise, + resolveSymbol: (filePath: string, d: HttpDetection) => Promise, ): Promise { const out: ExtractedContract[] = []; let rows: Record[]; @@ -589,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, @@ -627,6 +725,7 @@ export class HttpRouteExtractor implements ContractExtractor { private async extractConsumersSourceScan( files: string[], getDetections: (rel: string) => Promise, + resolveSymbol: (filePath: string, d: HttpDetection) => Promise, ): Promise { const out: ExtractedContract[] = []; for (const rel of files) { @@ -634,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, }, }); diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index a46118bd5..5edfdf3a0 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -90,6 +90,79 @@ export interface GroupToolPort { include_content?: boolean; }, ): Promise; + // ── 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; + // 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; + // 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; +} + +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): Promise { + const { runGroupTrace } = await import('./cross-trace.js'); + return runGroupTrace({ port: this.port, gitnexusDir: getDefaultGitnexusDir() }, params); + } + async groupContext(params: Record): Promise { const name = String(params.name ?? '').trim(); const target = typeof params.target === 'string' ? params.target.trim() : ''; diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index 8e43ff78f..42f6df368 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -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 { diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 42edd119e..10733727d 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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 { + 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 { + 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 { + 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 = 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) => ({ + // 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 { 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:"@" 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 = { 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 diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index a585c867f..121845566 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -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: [], diff --git a/gitnexus/test/integration/group/cross-trace-e2e.test.ts b/gitnexus/test/integration/group/cross-trace-e2e.test.ts new file mode 100644 index 000000000..11a89e364 --- /dev/null +++ b/gitnexus/test/integration/group/cross-trace-e2e.test.ts @@ -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(); + 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; +} +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 { + 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']); + }); +}); diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index a3c501573..a9e47bdef 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -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; + 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). diff --git a/gitnexus/test/unit/group/bridge-db.test.ts b/gitnexus/test/unit/group/bridge-db.test.ts index ead1533c9..ac3fdc3be 100644 --- a/gitnexus/test/unit/group/bridge-db.test.ts +++ b/gitnexus/test/unit/group/bridge-db.test.ts @@ -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: [], diff --git a/gitnexus/test/unit/group/cross-trace.test.ts b/gitnexus/test/unit/group/cross-trace.test.ts new file mode 100644 index 000000000..28a60d210 --- /dev/null +++ b/gitnexus/test/unit/group/cross-trace.test.ts @@ -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 { + 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 { + 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, + traceTable: Record, + pdgTable?: Record, +): GroupToolPort { + const handles: Record = { + '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 => + pdgTable[`${repo.name}:${anchor.uid}`] ?? { available: false, hops: [] }, + } + : {}), + }; +} + +/** The cross-repo stitch fixtures (checkout -> getUsers over one ContractLink). */ +function crossSymbolTable(): Record { + 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 { + 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 Promise> = { + '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 = { + checkout: okSym('checkout-uid', 'checkout', 'src/a.ts', 1), + callUsers: okSym('callUsers-uid', 'callUsers', 'src/a.ts', 5), + }; + const responders: Record Promise> = { + '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: '', + repo: 'app/backend', + filePath: 'src/routes.ts', + }, + hops: [ + { name: 'callUsers', repo: 'app/frontend' }, + { name: '', 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 = { + callUsers: okSym('callUsers-uid', 'callUsers', 'src/api.ts', 3), + }; + const responders: Record Promise> = { + '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: '' }, + 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 = { + '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 = { + '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 = { + '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); + }); +}); diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index f1af4ff62..c55198f68 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -43,6 +43,136 @@ describe('HttpRouteExtractor', () => { const toPosixPath = (filePath: string): string => filePath.replace(/\\/g, '/'); + describe('symbolUid resolution via containment', () => { + it('resolves a source-scan consumer to the function CONTAINING the fetch', async () => { + const dir = path.join(tmpDir, 'consumer-containment'); + fs.mkdirSync(path.join(dir, 'src/api'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/api/users.ts'), + `export async function fetchUsers() { + const r = await fetch('/api/users'); + return r.json(); +} +`, + ); + // The DEFINES query (CONTAINING_QUERY) returns the function span; every + // other query (HANDLES_ROUTE / FETCHES / CONTAINS) is empty, so only the + // source-scan + line-span containment path resolves the symbol. + const mockDbExecutor = async ( + query: string, + params?: Record, + ): Promise[]> => { + if (query.includes('UNION ALL') && String(params?.filePath ?? '').includes('users.ts')) { + return [ + { + uid: 'fn-fetchUsers', + name: 'fetchUsers', + filePath: 'src/api/users.ts', + startLine: 1, + endLine: 4, + labels: ['Function'], + }, + ]; + } + return []; + }; + + const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir)); + const consumer = contracts.find((c) => c.role === 'consumer'); + expect(consumer).toMatchObject({ + symbolUid: 'fn-fetchUsers', + symbolName: 'fetchUsers', + meta: { extractionStrategy: 'source_scan_resolved' }, + }); + }); + + it('does NOT resolve an anonymous express handler to a sibling fn named "handler"', async () => { + const dir = path.join(tmpDir, 'anon-handler-no-false-name'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + // An unrelated function literally named `handler`, plus an ANONYMOUS route + // handler. The anonymous handler must not be mis-attached to `handler`. + fs.writeFileSync( + path.join(dir, 'src/routes.ts'), + `import { Router } from 'express'; +const router = Router(); +function handler() { return 1; } +router.get('/api/x', (req, res) => { res.json([]); }); +export default router; +`, + ); + const mockDbExecutor = async ( + query: string, + params?: Record, + ): Promise[]> => { + if (query.includes('UNION ALL') && String(params?.filePath ?? '').includes('routes.ts')) { + // `function handler` is on source line 3 → 0-based span [2,2]. The + // anonymous route handler is on line 4, so it is NOT inside this span. + return [ + { + uid: 'uid-unrelated-handler', + name: 'handler', + filePath: 'src/routes.ts', + startLine: 2, + endLine: 2, + labels: ['Function'], + }, + ]; + } + return []; + }; + + const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir)); + const provider = contracts.find( + (c) => c.role === 'provider' && c.contractId === 'http::GET::/api/x', + ); + expect(provider).toBeDefined(); + // The anonymous arrow (line ~4) is NOT inside `handler`'s span [3,3], so it + // must not borrow that uid — name resolution is skipped for anonymous. + expect(provider?.symbolUid).not.toBe('uid-unrelated-handler'); + }); + + it('resolves an express provider to its named handler symbol', async () => { + const dir = path.join(tmpDir, 'provider-named-handler'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/routes.ts'), + `import { Router } from 'express'; +const router = Router(); +export function listUsers(req, res) { res.json([]); } +router.get('/api/users', listUsers); +export default router; +`, + ); + const mockDbExecutor = async ( + query: string, + params?: Record, + ): Promise[]> => { + if (query.includes('UNION ALL') && String(params?.filePath ?? '').includes('routes.ts')) { + return [ + { + uid: 'fn-listUsers', + name: 'listUsers', + filePath: 'src/routes.ts', + startLine: 3, + endLine: 3, + labels: ['Function'], + }, + ]; + } + return []; + }; + + const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir)); + const provider = contracts.find( + (c) => c.role === 'provider' && c.contractId === 'http::GET::/api/users', + ); + expect(provider).toMatchObject({ + symbolUid: 'fn-listUsers', + symbolName: 'listUsers', + }); + }); + }); + describe('provider extraction — graph-first (Strategy A)', () => { it('extracts routes from Route/HANDLES_ROUTE graph + source scan for method', async () => { const dir = path.join(tmpDir, 'graph-first'); @@ -75,7 +205,7 @@ public class UserController { }, ]; } - if (query.includes('CONTAINS')) { + if (query.includes('UNION ALL')) { return [ { uid: 'uid-ctrl-list', @@ -145,7 +275,7 @@ func main() { ]; } if (query.includes('FETCHES')) return []; - if (query.includes('CONTAINS')) { + if (query.includes('UNION ALL')) { return [ { uid: 'uid-ctrl-list', @@ -5805,10 +5935,19 @@ async def standalone(): }); describe('consumer extraction — graph-first (Strategy A)', () => { - it('extracts consumers from FETCHES graph edges', async () => { + it('extracts consumers from FETCHES graph edges, resolved to the containing fn', async () => { const dir = path.join(tmpDir, 'graph-consumers'); fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); - fs.writeFileSync(path.join(dir, 'src/api.ts'), 'export const api = {};'); + // A real fetch so the plugin produces a consumer detection with a line; + // the graph path then resolves it to the CONTAINING function by line-span. + fs.writeFileSync( + path.join(dir, 'src/api.ts'), + `export async function fetchUsers() { + const r = await fetch('/api/users'); + return r.json(); +} +`, + ); const mockDbExecutor = async (query: string) => { if (query.includes('HANDLES_ROUTE')) return []; @@ -5823,12 +5962,14 @@ async def standalone(): }, ]; } - if (query.includes('CONTAINS')) { + if (query.includes('UNION ALL') && String(query).includes('filePath')) { return [ { uid: 'uid-fn-fetch', name: 'fetchUsers', filePath: 'src/api.ts', + startLine: 1, + endLine: 4, labels: ['Function'], }, ]; @@ -5842,12 +5983,20 @@ async def standalone(): expect(consumers.length).toBeGreaterThanOrEqual(1); expect(consumers[0].confidence).toBe(0.9); expect(consumers[0].symbolName).toBe('fetchUsers'); + expect(consumers[0].symbolUid).toBe('uid-fn-fetch'); }); it('supplements graph consumers with source-scan consumers from other files', async () => { const dir = path.join(tmpDir, 'graph-source-consumer-union'); fs.mkdirSync(path.join(dir, 'src/api'), { recursive: true }); - fs.writeFileSync(path.join(dir, 'src/api/graph.ts'), 'export const api = {};'); + fs.writeFileSync( + path.join(dir, 'src/api/graph.ts'), + `export async function fetchUsers() { + const r = await fetch('/api/users'); + return r.json(); +} +`, + ); fs.writeFileSync( path.join(dir, 'src/api/health.ts'), ` @@ -5858,7 +6007,7 @@ export async function fetchHealth() { `, ); - const mockDbExecutor = async (query: string) => { + const mockDbExecutor = async (query: string, params?: Record) => { if (query.includes('HANDLES_ROUTE')) return []; if (query.includes('FETCHES')) { return [ @@ -5871,15 +6020,14 @@ export async function fetchHealth() { }, ]; } - if (query.includes('CONTAINS')) { - return [ - { - uid: 'uid-fn-fetch', - name: 'fetchUsers', - filePath: 'src/api/graph.ts', - labels: ['Function'], - }, - ]; + if (query.includes('UNION ALL')) { + const fp = String(params?.filePath ?? ''); + const row = fp.includes('graph.ts') + ? { uid: 'uid-fn-fetch', name: 'fetchUsers', filePath: 'src/api/graph.ts' } + : fp.includes('health.ts') + ? { uid: 'uid-fn-health', name: 'fetchHealth', filePath: 'src/api/health.ts' } + : null; + return row ? [{ ...row, startLine: 1, endLine: 4, labels: ['Function'] }] : []; } return []; }; @@ -5894,7 +6042,7 @@ export async function fetchHealth() { const sourceConsumer = consumers.find((c) => c.contractId === 'http::GET::/api/health'); expect(sourceConsumer).toBeDefined(); - expect(sourceConsumer?.meta.extractionStrategy).toBe('source_scan'); + expect(sourceConsumer?.meta.extractionStrategy).toBe('source_scan_resolved'); }); }); diff --git a/gitnexus/test/unit/group/http-route-graph-method.test.ts b/gitnexus/test/unit/group/http-route-graph-method.test.ts index d392bce9c..0391c28ce 100644 --- a/gitnexus/test/unit/group/http-route-graph-method.test.ts +++ b/gitnexus/test/unit/group/http-route-graph-method.test.ts @@ -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 []; }); @@ -218,17 +218,18 @@ describe('HttpRouteExtractor — Route.method from graph (Step A / #2138)', () = }, ]; } - if (query.includes('CONTAINS')) { + 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', - 3: ['Method'], }, ]; } @@ -242,7 +243,7 @@ describe('HttpRouteExtractor — Route.method from graph (Step A / #2138)', () = 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). + // CONTAINING_QUERY graph lookup by filePath (no source parse). expect(out[0].symbolUid).toBe(HID); expect(out[0].symbolName).toBe('createOrder'); }); @@ -261,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 []; }); diff --git a/gitnexus/test/unit/group/http-route-multi-verb.test.ts b/gitnexus/test/unit/group/http-route-multi-verb.test.ts index b2884fc60..ed8dfb97d 100644 --- a/gitnexus/test/unit/group/http-route-multi-verb.test.ts +++ b/gitnexus/test/unit/group/http-route-multi-verb.test.ts @@ -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 []; }); diff --git a/gitnexus/test/unit/group/resolve-bridge-neighbors.test.ts b/gitnexus/test/unit/group/resolve-bridge-neighbors.test.ts new file mode 100644 index 000000000..73ffa5b7e --- /dev/null +++ b/gitnexus/test/unit/group/resolve-bridge-neighbors.test.ts @@ -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 { + 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[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!); + }); +}); diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 1d8906131..a2cb484c7 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -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([]);