Merge branch 'main' into feat/spring-array-form-routes

This commit is contained in:
Gergő Magyar 2026-06-23 17:51:35 +01:00 • committed by GitHub
commit 4cbd5ab4b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1829 additions and 20 deletions

View file

@ -13,7 +13,7 @@ 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**.
(exit non-zero on any failure). Expected verdict: **16/16 checks passed**.
## Cases covered (one scenario each)
@ -32,6 +32,18 @@ analyze → sync → trace/impact pipeline, and prints PASS/FAIL per assertion
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`.
5. **Cross-file named handler** (#2275) — a route whose handler (`listUsers`) is
imported from another file than its registration. Asserts the provider
resolves to the handler via the import-pinned module lookup, and the trace is
symbol-precise (no file-level fallback).
6. **Aliased cross-file import** (#2275) — `import { listUsers as handleUsers }`
with an unrelated decoy `handleUsers` elsewhere. Asserts the route resolves
through the import to the declared `listUsers` (not the alias or the decoy),
proving import-pinned resolution.
7. **Python aliased import** (#2275) — a Flask `add_url_rule('/api/users',
view_func=handle_users)` whose view is `from .handlers.users import list_users
as handle_users`. Asserts the handler resolves through Python's dotted
relative module to `list_users`, symbol-precise.
The **ambiguous-destination** (a file making several HTTP calls whose consumer
contracts have no resolved uid) and **degraded-member** (a member DB that throws

View file

@ -271,6 +271,177 @@ def fetch_items():
fs.rmSync(home, { recursive: true, force: true });
}
// ── Scenario: Python Flask add_url_rule with an ALIASED relative import —
// import-pinned resolution across Python's dotted module syntax. ───────────
line('\n## Scenario: Python aliased import (Flask add_url_rule) — import-pinned');
{
const { sync, backend, home } = await setup(
'pyalias',
{
'pyalias-backend': {
'app/handlers/users.py': `def list_users():
return []
`,
'app/routes.py': `from flask import Flask
from .handlers.users import list_users as handle_users
app = Flask(__name__)
app.add_url_rule('/api/users', view_func=handle_users)
`,
},
'pyalias-frontend': {
'client.py': `import requests
def fetch_users():
return requests.get('/api/users').json()
`,
},
},
'pyalias-group',
{ 'app/backend': 'pyalias-backend', 'app/frontend': 'pyalias-frontend' },
);
const provider = sync.contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/users',
);
check(
provider?.symbolName === 'list_users',
'Python Flask aliased view resolves through the relative import to list_users',
`sym=${provider?.symbolName} uid=${provider?.symbolUid ? 'set' : 'empty'}`,
);
const tr = await backend.callTool('trace', {
repo: '@pyalias-group',
from: 'fetch_users',
to: 'list_users',
});
check(
tr.status === 'ok' && crossingId(tr) === 'http::GET::/api/users' && !hasNote(tr, 'FILE'),
'Python aliased-import trace is symbol-precise (no file-level fallback)',
`status=${tr.status} crossing=${crossingId(tr)}`,
);
fs.rmSync(home, { recursive: true, force: true });
}
// ── Scenario: cross-file named handler (#2275) — repo-wide unique resolution ──
line('\n## Scenario: cross-file named handler — repo-wide unique resolution');
{
const { sync, backend, home } = await setup(
'xfile',
{
'xfile-backend': {
'src/handlers/users.ts': `export function listUsers(req: { body: unknown }, res: { json: (v: unknown) => void }) {
res.json([]);
}
`,
'src/routes.ts': `import { Router } from 'express';
import { listUsers } from './handlers/users';
const router = Router();
router.get('/api/users', listUsers);
export default router;
`,
'package.json': '{ "name": "xfile-backend", "version": "1.0.0" }',
},
'xfile-frontend': {
'src/api.ts': `export async function fetchUsers() {
const r = await fetch('/api/users');
return r.json();
}
`,
'package.json': '{ "name": "xfile-frontend", "version": "1.0.0" }',
},
},
'xfile-group',
{ 'app/backend': 'xfile-backend', 'app/frontend': 'xfile-frontend' },
);
const provider = sync.contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/users',
);
check(
Boolean(provider?.symbolUid) && provider?.symbolName === 'listUsers',
'cross-file provider resolves to the handler defined in another file (repo-wide unique)',
`sym=${provider?.symbolName} uid=${provider?.symbolUid ? 'set' : 'empty'}`,
);
const tr = await backend.callTool('trace', {
repo: '@xfile-group',
from: 'fetchUsers',
to: 'listUsers',
pdg: true,
});
check(
tr.status === 'ok' && crossingId(tr) === 'http::GET::/api/users' && !hasNote(tr, 'FILE'),
'cross-file trace is symbol-precise (no file-level fallback)',
`status=${tr.status} crossing=${crossingId(tr)}`,
);
fs.rmSync(home, { recursive: true, force: true });
}
// ── Scenario: ALIASED cross-file import — resolved through the import to the
// declared symbol, not the local alias (and not a same-named decoy). ───────
line('\n## Scenario: aliased cross-file import — import-pinned resolution');
{
const { sync, backend, home } = await setup(
'alias',
{
'alias-backend': {
'src/handlers/users.ts': `export function listUsers(req: { body: unknown }, res: { json: (v: unknown) => void }) {
res.json([]);
}
`,
// Decoy: a DIFFERENT, unrelated symbol named handleUsers. Name-only
// resolution of the local alias would wrongly pick this one.
'src/util.ts': `export function handleUsers() {
return 1;
}
`,
'src/routes.ts': `import { Router } from 'express';
import { listUsers as handleUsers } from './handlers/users';
const router = Router();
router.get('/api/users', handleUsers);
export default router;
`,
'package.json': '{ "name": "alias-backend", "version": "1.0.0" }',
},
'alias-frontend': {
'src/api.ts': `export async function fetchUsers() {
const r = await fetch('/api/users');
return r.json();
}
`,
'package.json': '{ "name": "alias-frontend", "version": "1.0.0" }',
},
},
'alias-group',
{ 'app/backend': 'alias-backend', 'app/frontend': 'alias-frontend' },
);
const provider = sync.contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/users',
);
check(
provider?.symbolName === 'listUsers',
'aliased handler resolves through the import to the declared symbol (not the alias/decoy)',
`sym=${provider?.symbolName} uid=${provider?.symbolUid ? 'set' : 'empty'}`,
);
const tr = await backend.callTool('trace', {
repo: '@alias-group',
from: 'fetchUsers',
to: 'listUsers',
pdg: true,
});
check(
tr.status === 'ok' && crossingId(tr) === 'http::GET::/api/users' && !hasNote(tr, 'FILE'),
'aliased-import trace is symbol-precise (no file-level fallback)',
`status=${tr.status} crossing=${crossingId(tr)}`,
);
fs.rmSync(home, { recursive: true, force: true });
}
// ── Summary ────────────────────────────────────────────────────────────────
const passed = results.filter((r) => r.pass).length;
line(`\n## Verdict: ${passed}/${results.length} checks passed`);

View file

@ -1036,12 +1036,13 @@ const FILE_BASENAME_RE =
/**
* 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
* name; the source-scan fallbacks leave a generic token (`'handler'`/`'fetch'`,
* or `'route'` for an unresolved named-controller / closure Laravel route) or a
* file basename. Those are treated as anonymous and shown as
* `<METHOD /path handler>` 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.
* the sentinel check is suppressed so a function genuinely named `handler` (or,
* hypothetically, `route`) is not mislabeled anonymous.
*/
function providerLabel(
providerName: string,
@ -1052,7 +1053,8 @@ function providerLabel(
const generic =
providerName === '' ||
FILE_BASENAME_RE.test(providerName) ||
(!resolved && (providerName === 'handler' || providerName === 'fetch'));
(!resolved &&
(providerName === 'handler' || providerName === 'fetch' || providerName === 'route'));
return generic
? { label: `<${contractId} handler>`, anon: true }
: { label: providerName, anon: false };

View file

@ -17,8 +17,11 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js';
// ─── Provider: framework routing ──────────────────────────────────────
// Matches `\w+\.GET(...)` etc. (gin, echo, chi all share this shape).
// Captures the HTTP method (field name), path literal, and handler
// identifier passed as the second argument.
// Captures the HTTP method (field name), path literal, and the handler —
// anchored to the LAST argument (`@handler .`) so a variadic middleware
// chain (`r.GET("/x", mw, handler)`, gin/echo/chi style) binds the real
// handler, not a middleware identifier (which would otherwise over-match
// and attach the route to the wrong symbol — see #2276 review).
const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({
name: 'go-framework-route',
language: Go,
@ -31,7 +34,8 @@ const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({
field: (field_identifier) @http_method (#match? @http_method "^(GET|POST|PUT|DELETE|PATCH)$"))
arguments: (argument_list
(interpreted_string_literal) @path
(identifier) @handler))
[(identifier) (func_literal)] @handler
.))
`,
},
],
@ -51,7 +55,8 @@ const HANDLE_FUNC_PATTERNS = compilePatterns({
field: (field_identifier) @fn (#eq? @fn "HandleFunc"))
arguments: (argument_list
(interpreted_string_literal) @path
(identifier) @handler))
[(identifier) (func_literal)] @handler
.))
`,
},
],
@ -138,12 +143,18 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
if (!methodNode || !pathNode) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
// An inline `func(){…}` handler has no name → emit `name: null` and a
// `line` so it resolves to its containing/closure symbol by line-span
// containment (like a consumer). A named identifier handler keeps its
// name and resolves by name; `line` is harmless there.
const isInlineHandler = handlerNode?.type === 'func_literal';
out.push({
role: 'provider',
framework: 'go-framework',
method: methodNode.text.toUpperCase(),
path,
name: handlerNode?.text ?? null,
name: isInlineHandler ? null : (handlerNode?.text ?? null),
line: (handlerNode ?? pathNode).startPosition.row + 1,
confidence: 0.8,
});
}
@ -155,12 +166,16 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = {
if (!pathNode) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
// Inline `func(){…}` handler → resolve by containment (see go-framework
// note above); a named handler resolves by name.
const isInlineHandler = handlerNode?.type === 'func_literal';
out.push({
role: 'provider',
framework: 'go-stdlib',
method: 'GET',
path,
name: handlerNode?.text ?? null,
name: isInlineHandler ? null : (handlerNode?.text ?? null),
line: (handlerNode ?? pathNode).startPosition.row + 1,
confidence: 0.8,
});
}

View file

@ -746,6 +746,13 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
method: route.httpMethod,
path: joinPath(prefix, route.rawPath),
name: route.methodName,
// Spring providers are named controller methods resolved BY NAME, so
// `line` is inert — a named provider never falls through to line-span
// containment. Gate it on a present name so a (grammar-impossible)
// nameless provider degrades to file-level rather than resolving by
// containment to the enclosing class. Wired for consumer-emit parity
// and a future inline DSL.
line: route.methodName ? route.methodNode.startPosition.row + 1 : undefined,
confidence: 0.8,
});
}

View file

@ -1019,6 +1019,13 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
method: httpMethod,
path: joinPath(prefix, rawPath),
name: nameNode?.text ?? null,
// Spring providers are named controller methods resolved BY NAME, so
// `line` is inert — a named provider never falls through to line-span
// containment. Gate it on a present name so a (grammar-impossible)
// nameless provider degrades to file-level rather than resolving by
// containment to the enclosing class. Wired for consumer-emit parity
// and a future inline DSL.
line: nameNode?.text ? methodNode.startPosition.row + 1 : undefined,
confidence: 0.8,
});
}

View file

@ -295,8 +295,53 @@ function findDecoratedMethod(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNod
return null;
}
/**
* Map each named import's LOCAL binding to its DECLARED export name and source
* module, by walking the file's `import { x as y } from 'm'` statements. Lets
* the express handler resolve through an alias (the local `y`) to the real
* symbol (`x` in `m`) instead of looking up the alias text. Only named imports
* are mapped — default and namespace imports are left to fall through as
* locally-scoped identifiers.
*/
function buildImportMap(tree: Parser.Tree): Map<string, { name: string; module: string }> {
const map = new Map<string, { name: string; module: string }>();
const walk = (node: Parser.SyntaxNode): void => {
if (node.type === 'import_statement') {
const sourceNode = node.childForFieldName('source');
const module = sourceNode ? unquoteLiteral(sourceNode.text) : null;
if (module !== null) {
const collect = (n: Parser.SyntaxNode): void => {
if (n.type === 'import_specifier') {
const nameNode = n.childForFieldName('name');
const aliasNode = n.childForFieldName('alias');
const local = aliasNode ?? nameNode;
if (nameNode && local && local.type === 'identifier') {
map.set(local.text, { name: nameNode.text, module });
}
}
for (let i = 0; i < n.namedChildCount; i++) {
const c = n.namedChild(i);
if (c) collect(c);
}
};
collect(node);
}
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) walk(c);
}
};
walk(tree.rootNode);
return map;
}
function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection[] {
const out: HttpDetection[] = [];
// Local-binding → { declared export name, module } for the file's named
// imports, so an express handler that is an imported (possibly aliased)
// symbol resolves to the real definition rather than its local alias text.
const importMap = buildImportMap(tree);
// NestJS: collect `@Controller('prefix')` class decorators, keyed by
// the `class_declaration` they decorate.
@ -364,14 +409,19 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
// → `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.
// it uses the registration line for containment instead. When the handler is
// an imported (possibly aliased) symbol, carry the resolved import so the
// extractor can pin it to the source module rather than the local alias text.
const handlerNode = match.captures.handler;
const localHandler = handlerNode?.type === 'identifier' ? handlerNode.text : null;
const imported = localHandler !== null ? importMap.get(localHandler) : undefined;
out.push({
role: 'provider',
framework: 'express',
method: methodNode.text.toUpperCase(),
path,
name: handlerNode?.type === 'identifier' ? handlerNode.text : null,
name: imported ? imported.name : localHandler,
handlerImport: imported,
line: (handlerNode ?? pathNode).startPosition.row + 1,
confidence: 0.8,
});

View file

@ -37,7 +37,9 @@ const LARAVEL_ROUTE_SPEC: PatternSpec<Record<string, never>> = {
(scoped_call_expression
scope: (name) @scope (#eq? @scope "Route")
name: (name) @method (#match? @method "^(get|post|put|delete|patch)$")
arguments: (arguments . (argument (string) @path)))
arguments: (arguments
. (argument (string) @path)
(argument [(anonymous_function) (arrow_function)] @closure)?))
`,
};
@ -150,12 +152,22 @@ export const PHP_HTTP_PLUGIN: HttpLanguagePlugin = {
if (!methodNode || !pathNode) continue;
const path = phpStringText(pathNode);
if (path === null) continue;
// A closure handler (`Route::get('/x', function(){…})` / `fn() => …`) has
// no name → emit `name: null` + the registration line so it resolves to
// its containing symbol (e.g. a service-provider `boot()` or controller
// method) by line-span containment. A named-controller route keeps the
// `'route'` label — resolving its array/string handler to a real method is
// a separate, graph-backed concern. NOTE: a closure at FILE scope
// (routes/web.php) has no enclosing function and PHP closures are not yet
// indexed as symbols, so it still degrades to file-level (see #2276).
const closureNode = match.captures.closure;
out.push({
role: 'provider',
framework: 'laravel',
method: methodNode.text.toUpperCase(),
path,
name: 'route',
name: closureNode ? null : 'route',
line: (closureNode ?? pathNode).startPosition.row + 1,
confidence: 0.8,
});
}

View file

@ -79,6 +79,33 @@ const FASTAPI_ROUTER_PATTERNS = compilePatterns({
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Provider: Flask `app.add_url_rule('/path', view_func=handler)` ───
// The imperative Flask route registration: unlike `@app.route` (whose handler
// is the decorated function, same-file), `view_func` is frequently an IMPORTED
// (and sometimes aliased) view, so the handler resolves through the file's
// imports. `add_url_rule` + a `view_func=` keyword is highly Flask-specific, so
// the false-positive risk is low. Method(s) come from a `methods=[...]` keyword
// (default GET), extracted in code from the captured call.
const FLASK_ADD_URL_RULE_PATTERNS = compilePatterns({
name: 'python-flask-add-url-rule',
language: Python,
patterns: [
{
meta: {},
query: `
(call
function: (attribute
attribute: (identifier) @fn (#eq? @fn "add_url_rule"))
arguments: (argument_list
. (string) @path
(keyword_argument
name: (identifier) @kw (#eq? @kw "view_func")
value: (identifier) @handler))) @call
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── include_router(<router_obj>, prefix='/x') across the repo ────────
// Two shapes are common:
// app.include_router(assistant.router, prefix='/ai')
@ -331,6 +358,73 @@ const WRAPPER_URI_VAR_PATTERNS = compilePatterns({
],
} satisfies LanguagePatterns<Record<string, never>>);
/**
* Map each `from <module> import <name> [as <alias>]` binding to its declared
* name + raw module specifier (the spec keeps the leading dots for relative
* imports — `.users`, `..pkg.users` — which the extractor resolves to a target
* file). Lets a Flask `view_func` handler resolve through an alias to the real
* symbol in its module rather than the local alias text. `import x` / `import x
* as y` (module imports, not symbol imports) are left out — a route handler is a
* symbol, addressed via `from … import …`.
*/
function buildPythonImportMap(tree: Parser.Tree): Map<string, { name: string; module: string }> {
const map = new Map<string, { name: string; module: string }>();
const walk = (node: Parser.SyntaxNode): void => {
if (node.type === 'import_from_statement') {
const moduleNode = node.childForFieldName('module_name');
const module = moduleNode?.text ?? null;
if (module !== null) {
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (!c || c.id === moduleNode?.id) continue;
if (c.type === 'dotted_name') {
map.set(c.text, { name: c.text, module });
} else if (c.type === 'aliased_import') {
const nameNode = c.childForFieldName('name');
const aliasNode = c.childForFieldName('alias');
if (nameNode && aliasNode) {
map.set(aliasNode.text, { name: nameNode.text, module });
}
}
}
}
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) walk(c);
}
};
walk(tree.rootNode);
return map;
}
/**
* HTTP verbs declared on a Flask `add_url_rule(..., methods=[...])` call, upper-
* cased. Defaults to `['GET']` when no `methods` keyword is present (Flask's own
* default). Reads the captured call node directly since the list value is awkward
* to capture in a tree-sitter query.
*/
function extractFlaskMethods(callNode: Parser.SyntaxNode): string[] {
const args = callNode.childForFieldName('arguments');
if (args) {
for (let i = 0; i < args.namedChildCount; i++) {
const kw = args.namedChild(i);
if (!kw || kw.type !== 'keyword_argument') continue;
if (kw.childForFieldName('name')?.text !== 'methods') continue;
const list = kw.childForFieldName('value');
if (!list) continue;
const methods: string[] = [];
for (let j = 0; j < list.namedChildCount; j++) {
const el = list.namedChild(j);
const v = el && el.type === 'string' ? unquoteLiteral(el.text) : null;
if (v) methods.push(v.toUpperCase());
}
if (methods.length > 0) return methods;
}
}
return ['GET'];
}
// Pre-scan: collect local string assignments (uri = "api/v1/endpoint/")
function buildLocalStringMap(tree: Parser.Tree): Map<string, string> {
const map = new Map<string, string>();
@ -943,6 +1037,10 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
const out: HttpDetection[] = [];
const httpxAsyncClients = collectHttpxAsyncClients(tree);
const ctx = repoContext as PythonRepoContext | undefined;
// Local-binding → { declared name, module } for the file's `from … import …`
// statements, so an imperatively-registered handler (Flask `view_func`) that
// is an imported (possibly aliased) symbol resolves to its real definition.
const importMap = buildPythonImportMap(tree);
// Providers: FastAPI @app.<verb>("/path") — already absolute path.
for (const match of runCompiledPatterns(FASTAPI_APP_PATTERNS, tree)) {
@ -959,6 +1057,12 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
method: httpMethod,
path,
name: null,
// The decorated handler has no captured name → resolve by line-span
// containment. Best-effort fallback: FastAPI routes are graph-backed
// (ingestion decorator routes) and the function span starts at `def`
// (decorators excluded), so this lands the single-decorator case and
// degrades to file-level for multi-decorator stacks.
line: pathNode.startPosition.row + 1,
confidence: 0.8,
});
}
@ -1003,6 +1107,34 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
method: httpMethod,
path: p,
name: null,
// Best-effort containment fallback — see the @app provider note above.
line: pathNode.startPosition.row + 1,
confidence: 0.8,
});
}
}
// Providers: Flask `app.add_url_rule('/path', view_func=handler, methods=[…])`.
// The handler is a `view_func` identifier, frequently an imported (possibly
// aliased) view, so resolve it through the file's imports to the declared
// symbol + its module for import-pinned resolution downstream.
for (const match of runCompiledPatterns(FLASK_ADD_URL_RULE_PATTERNS, tree)) {
const pathNode = match.captures.path;
const handlerNode = match.captures.handler;
const callNode = match.captures.call;
if (!pathNode || !handlerNode || !callNode) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
const imported = importMap.get(handlerNode.text);
for (const method of extractFlaskMethods(callNode)) {
out.push({
role: 'provider',
framework: 'flask',
method,
path,
name: imported ? imported.name : handlerNode.text,
handlerImport: imported,
line: (imported ? pathNode : handlerNode).startPosition.row + 1,
confidence: 0.8,
});
}

View file

@ -45,6 +45,17 @@ export interface HttpDetection {
* not set it falls back to file-level boundary resolution downstream.
*/
line?: number;
/**
* When the handler is an IMPORTED symbol, the import resolved to its declared
* (exported) `name` and the `module` specifier it came from. The extractor
* pins resolution to the import's target file, so an aliased import
* (`import { listUsers as handleUsers }`) or a name that collides with a local
* symbol resolves to the right handler instead of a same-named decoy. `name`
* here is the DECLARED export name (not the local alias); `module` is the raw
* specifier (e.g. `./handlers/users`). Set only for named imports; omitted for
* locally-defined or anonymous handlers.
*/
handlerImport?: { name: string; module: string };
/** Confidence in (0, 1]. Source-scan plugins typically use 0.7–0.8. */
confidence: number;
}

View file

@ -80,6 +80,67 @@ WHERE sym.filePath = $filePath AND sym.startLine IS NOT NULL AND sym.endLine IS
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`;
// Repo-wide lookup of a symbol by exact name (label-union, as in
// manifest-extractor.ts). Used to resolve a provider's named handler when it is
// defined in a file OTHER than its route registration — and only honored when
// the result is unique (see resolveSymbolByNameUnique).
//
// `n.filePath <> ''` excludes synthetic non-source `CodeElement` nodes that
// carry no real file — ORM model/table nodes (orm.ts emits `filePath: ''`) and
// similar — so a handler name colliding with an ORM model neither resolves to a
// degenerate edge-less node NOR inflates the uniqueness count and masks the real
// handler. `LIMIT 2` bounds materialization: distinguishing unique (1) from
// ambiguous (>=2) never needs more than two rows (the count guard stays exact).
const RESOLVE_BY_NAME_QUERY = `
MATCH (n:Function|Method|CodeElement)
WHERE n.name = $name AND n.filePath <> ''
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
LIMIT 2`;
// Resolve an IMPORTED handler by pinning it to the import's target module: the
// declared export `$name` whose file is the module the handler was imported from
// (`$fileDot` matches `mod.ext`, `$fileSlash` matches `mod/index.ext`). This is
// the precise rung — it survives aliases and local same-name collisions that a
// repo-wide name lookup cannot, and only resolves on a unique match within that
// module. `LIMIT 2` keeps the uniqueness count exact (see RESOLVE_BY_NAME_QUERY).
const RESOLVE_IN_MODULE_QUERY = `
MATCH (n:Function|Method|CodeElement)
WHERE n.name = $name AND (n.filePath STARTS WITH $fileDot OR n.filePath STARTS WITH $fileSlash)
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
LIMIT 2`;
// Source-file extensions an import specifier may resolve to (stripped before
// building the module file-prefix so `./h/users` and `./h/users.ts` agree).
const SOURCE_EXT_RE = /\.(?:m|c)?[jt]sx?$/;
/**
* Resolve an import specifier to a repo-relative FILE BASE (path without
* extension) so the target module can be matched by `filePath STARTS WITH`.
* Handles two relative-import dialects and returns null for bare/absolute
* imports (which fall back to a repo-wide name lookup):
* - path-style (JS/TS): `./handlers/users`, `../x` → joined against the
* importing file's directory.
* - dotted-relative (Python): `.users`, `..pkg.users` → leading dots are
* package levels (one dot = the file's own package), the rest dot→slash.
*/
function resolveModuleBase(fromFile: string, module: string): string | null {
const dir = path.posix.dirname(fromFile.replace(/\\/g, '/'));
if (module.includes('/')) {
// path-style relative import
if (!module.startsWith('.')) return null;
return path.posix.normalize(path.posix.join(dir, module)).replace(SOURCE_EXT_RE, '');
}
if (module.startsWith('.')) {
// Python dotted-relative import
const dots = module.length - module.replace(/^\.+/, '').length;
const rest = module.slice(dots).replace(/\./g, '/');
let base = dir;
for (let i = 1; i < dots; i++) base = path.posix.dirname(base);
return rest ? path.posix.normalize(path.posix.join(base, rest)) : base;
}
return null; // bare / absolute import — repo-wide fallback
}
interface ResolvedSymbol {
uid: string;
name: string;
@ -357,22 +418,114 @@ export class HttpRouteExtractor implements ContractExtractor {
fileSymbolCache.set(filePath, rows);
return rows;
};
// Repo-wide UNAMBIGUOUS resolution for a provider handler defined in a file
// other than its route registration (e.g. `router.get('/x', listUsers)` with
// `listUsers` imported from another module). Returns the symbol ONLY when
// exactly one Function/Method/CodeElement carries that name across the repo.
// The strict uniqueness guard is intentionally conservative: when a name is
// shared across files (homonyms like `handler`/`index`), we prefer a
// false-negative (no attribution → file-level fallback) over a false-positive
// (wrong symbol).
//
// An IMPORTED handler (the common cross-file case) is pinned to its source
// module first by resolveImportedSymbol, so an alias or a name colliding with
// a local symbol resolves correctly; this repo-wide-by-name rung is the
// fallback for non-relative/bare imports and for plugins that supply only a
// name. Cached by name for the lifetime of this extract().
const globalNameCache = new Map<string, ResolvedSymbol | null>();
const toResolvedSymbol = (rows: Record<string, unknown>[]): ResolvedSymbol | null => {
const norm = (x: unknown): string => String(x ?? '');
const uid = rows.length === 1 ? norm(rows[0]!.uid ?? rows[0]![0]) : '';
const filePath = uid ? norm(rows[0]!.filePath ?? rows[0]![2]) : '';
// Reject a unique match that carries no real file (a synthetic ORM /
// non-source node) so it can never anchor a cross-trace on an edge-less
// node — defence in depth alongside the queries' filePath predicates.
return uid && filePath ? { uid, name: norm(rows[0]!.name ?? rows[0]![1]), filePath } : null;
};
const resolveSymbolByNameUnique = async (name: string): Promise<ResolvedSymbol | null> => {
if (!dbExecutor) return null;
const cached = globalNameCache.get(name);
if (cached !== undefined) return cached;
let rows: Record<string, unknown>[] = [];
try {
rows = await dbExecutor(RESOLVE_BY_NAME_QUERY, { name });
} catch {
rows = [];
}
const result = toResolvedSymbol(rows);
globalNameCache.set(name, result);
return result;
};
// Resolve a handler imported from a RELATIVE module to the unique declared
// symbol of that name inside the import's target file. Returns null for
// non-relative (bare/aliased-path) imports — those fall back to the repo-wide
// name lookup. Cached by (target-file-prefix, declared name).
const importedSymbolCache = new Map<string, ResolvedSymbol | null>();
const resolveImportedSymbol = async (
fromFile: string,
imp: { name: string; module: string },
): Promise<ResolvedSymbol | null> => {
if (!dbExecutor) return null;
const base = resolveModuleBase(fromFile, imp.module);
if (base === null) return null; // bare/absolute import → repo-wide fallback
const cacheKey = JSON.stringify([base, imp.name]);
const cached = importedSymbolCache.get(cacheKey);
if (cached !== undefined) return cached;
let rows: Record<string, unknown>[] = [];
try {
rows = await dbExecutor(RESOLVE_IN_MODULE_QUERY, {
name: imp.name,
fileDot: `${base}.`,
fileSlash: `${base}/`,
});
} catch {
rows = [];
}
const result = toResolvedSymbol(rows);
importedSymbolCache.set(cacheKey, result);
return result;
};
const resolveDetectionSymbol = async (
filePath: string,
d: HttpDetection,
): Promise<ResolvedSymbol | null> => {
if (!dbExecutor) return null;
const syms = await loadFileSymbols(filePath);
if (syms.length === 0) return null;
// Name resolution does NOT need a detection line — a named provider
// handler (Spring/Go/etc. method name) resolves by name even when the
// plugin didn't set `line`. Try it FIRST; only the containment fallback
// requires a line.
// plugin didn't set `line`. Try the registration file FIRST; then, for a
// handler defined in another file, the unique repo-wide match. Only the
// containment fallback requires a line.
if (d.role === 'provider' && d.name) {
// IMPORTED handler: pin to the import's target module first. This is the
// precise rung — it survives aliases and names that collide with a local
// symbol. The handler is defined ELSEWHERE, so a file-scoped lookup of
// its (declared) name would be wrong; on a miss go straight to a unique
// repo-wide match on the declared name, never file-scoped.
if (d.handlerImport) {
const byImport = await resolveImportedSymbol(filePath, d.handlerImport);
if (byImport) return byImport;
const byGlobal = await resolveSymbolByNameUnique(d.handlerImport.name);
if (byGlobal) return byGlobal;
return null;
}
const byName = resolveSymbolByName(syms, d.name);
if (byName) return byName;
const byGlobal = await resolveSymbolByNameUnique(d.name);
if (byGlobal) return byGlobal;
// A NAMED handler we could not resolve by name (neither file-scoped nor
// the unique repo-wide match) must NOT fall through to line-span
// containment: `d.line` is the route REGISTRATION site, so containment
// would attach the route to the enclosing registrar (e.g. a
// `setupRoutes()` wrapper) rather than the handler. Leave it empty →
// file-level boundary fallback, upholding the invariant that a
// zero/ambiguous name match never yields a wrong-symbol attribution.
return null;
}
if (d.line == null) return null;
// Consumers (the function making the fetch) and inline-arrow providers
// (d.name === null) DO resolve by containment — there the enclosing symbol
// is the right one.
if (syms.length === 0 || d.line == null) return null;
return resolveContainingSymbol(syms, d.line);
};

View file

@ -0,0 +1,114 @@
/**
* End-to-end validation of the INLINE provider source-scan containment path
* (#2276).
*
* The unit suite (`test/unit/group/http-route-extractor.test.ts`) proves the
* resolver logic by MOCKING `CONTAINING_QUERY` with hand-picked spans. That
* leaves one assumption unverified: that the REAL ingestion pipeline records a
* Go enclosing function with a 0-based span that actually contains the emitted
* call-site line. This test closes that gap.
*
* It runs the real pipeline over a Go file whose `http.HandleFunc` handler is an
* inline `func(){…}` (the issue's headline Go example), persists the resulting
* graph into a real LadybugDB, and runs the production `HttpRouteExtractor`
* against the real executor. The provider must resolve to the containing
* `main()` symbol via line-span containment (`source_scan_resolved`) — not the
* file-level fallback. Go does not index anonymous func literals as symbols
* (only `function_declaration`/`method_declaration`), so the innermost
* containing symbol is `main` itself.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import { HttpRouteExtractor } from '../../src/core/group/extractors/http-route-extractor.js';
import type { CypherExecutor } from '../../src/core/group/contract-extractor.js';
import type { RepoHandle } from '../../src/core/group/types.js';
let tmpBase: string;
let repoDir: string;
let storagePath: string;
let dbPath: string;
beforeAll(async () => {
// Atomic, unique temp dir (fs.mkdtemp) — avoids the predictable
// os.tmpdir()+name pattern CodeQL flags as an insecure temporary file.
tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-http-inline-e2e-'));
repoDir = path.join(tmpBase, 'repo');
storagePath = path.join(tmpBase, '.gitnexus');
dbPath = path.join(storagePath, 'lbug');
await fs.mkdir(path.join(repoDir, 'cmd'), { recursive: true });
await fs.mkdir(dbPath, { recursive: true });
// net/http inline handler INSIDE main() — the #2276 Go example. Before this
// change the func literal was not even captured; now it emits name:null + the
// call-site line so it resolves to main() by containment.
await fs.writeFile(
path.join(repoDir, 'cmd', 'server.go'),
`package main
import "net/http"
func main() {
\thttp.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
\t\tw.Write([]byte("ok"))
\t})
\thttp.ListenAndServe(":8080", nil)
}
`,
);
const result = await runPipelineFromRepo(repoDir, () => {}, {});
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
await adapter.loadGraphToLbug(result.graph, tmpBase, storagePath);
}, 120_000);
afterAll(async () => {
try {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.closeLbug();
} catch {
/* may not have opened */
}
if (tmpBase) {
for (let attempt = 0; attempt < 5; attempt++) {
try {
await fs.rm(tmpBase, { recursive: true, force: true });
return;
} catch {
if (attempt < 4) await new Promise((r) => setTimeout(r, 200 * (attempt + 1)));
}
}
}
});
describe('inline Go provider handler resolves via real source-scan containment (#2276)', () => {
it('resolves an inline http.HandleFunc closure to the containing main() with a real symbolUid', async () => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
// Param-aware executor (CONTAINING_QUERY binds $filePath) — the same shape
// production passes to ContractExtractors.
const dbExecutor: CypherExecutor = (query, params = {}) =>
adapter.executePrepared(query, params);
const repo: RepoHandle = {
id: 'test-repo',
path: 'repo',
repoPath: repoDir,
storagePath,
};
const contracts = await new HttpRouteExtractor().extract(dbExecutor, repoDir, repo);
const provider = contracts.find(
(c) => c.role === 'provider' && c.contractId === 'http::GET::/api/health',
);
expect(provider).toBeDefined();
// The real pipeline indexed main() with its true 0-based span; the emitted
// call-site line lands inside it, so containment yields a real symbolUid
// rather than the empty file-level fallback.
expect(provider?.symbolUid).toBeTruthy();
expect(provider?.symbolName).toBe('main');
expect(provider?.meta.extractionStrategy).toBe('source_scan_resolved');
});
});

View file

@ -478,6 +478,73 @@ describe('runGroupTrace', () => {
},
);
itLbugReopen(
'destination trace anonymizes an unresolved Laravel `route` placeholder (#2276)',
async () => {
// A named-controller / closure Laravel provider that did not resolve keeps
// the synthetic `'route'` placeholder (never a real symbol name). It must
// be treated as anonymous — shown as `<route handler>` — exactly like the
// `'handler'`/`'fetch'` sentinels, not displayed as the literal `route`.
const consumer = makeContract({
repo: 'app/frontend',
role: 'consumer',
symbolUid: 'callUsers-uid',
symbolRef: { filePath: 'src/api.ts', name: 'callUsers' },
symbolName: 'callUsers',
contractId: 'http::GET::/api/users',
});
const provider = makeContract({
repo: 'app/backend',
role: 'provider',
symbolUid: '', // unresolved file-scope closure / named-controller route
symbolRef: { filePath: 'routes/web.php', name: 'route' },
symbolName: 'route',
contractId: 'http::GET::/api/users',
});
const link: CrossLink = {
from: { repo: 'app/frontend', symbolUid: 'callUsers-uid', symbolRef: consumer.symbolRef },
to: { repo: 'app/backend', symbolUid: '', symbolRef: provider.symbolRef },
type: 'http',
contractId: 'http::GET::/api/users',
matchType: 'exact',
confidence: 1,
};
await writeBridge(groupDir, {
contracts: [consumer, provider],
crossLinks: [link],
repoSnapshots: {},
missingRepos: [],
});
const port = makePort(
{ 'reg-fe:callUsers': okSym('callUsers-uid', 'callUsers', 'src/api.ts', 3) },
{
'reg-fe:callUsers-uid->callUsers-uid': okTrace(
[{ name: 'callUsers', filePath: 'src/api.ts', startLine: 3 }],
[],
),
},
);
const r = await runGroupTrace(
{ port, gitnexusDir: tmpDir },
{ name: 'g1', from: 'callUsers' },
);
expect(r).toMatchObject({
status: 'ok',
to: {
name: '<http::GET::/api/users handler>',
repo: 'app/backend',
filePath: 'routes/web.php',
},
hops: [
{ name: 'callUsers', repo: 'app/frontend' },
{ name: '<http::GET::/api/users handler>', repo: 'app/backend' },
],
notes: expect.arrayContaining([expect.stringContaining('anonymous')]),
});
},
);
itLbugReopen('destination trace not_found when no HTTP link leaves the repo', async () => {
await writeUnlinkedBridge(groupDir);
const port = makePort(

File diff suppressed because it is too large Load diff