mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
Merge branch 'main' into feat/spring-array-form-routes
This commit is contained in:
commit
4cbd5ab4b4
14 changed files with 1829 additions and 20 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue