GitNexus/gitnexus/test/unit/resolve-route-handler-symbols.test.ts
Gergő Magyar 5a1e5c8803
feat(kotlin): ingest Spring HTTP routes as decoratorRoutes (#3133)
* feat(kotlin): ingest Spring HTTP routes as decoratorRoutes (#3130)

Kotlin RestControllers now emit analyze Route nodes, handler attribution, and folded constant paths on the ingestion decoratorRoutes channel, matching Java Spring without changing Java or Python extractors.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(review): reject abstract/sealed Kotlin RestControllers and keep Java brace parsing

Code review required fail-closed admission for abstract and sealed classes, and Kotlin-owned method=[...] translation so Java extractors do not start emitting routes from bracket arrays.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(kotlin): note ingestion-side Spring decoratorRoutes wiring

The module comment still described Kotlin as group-layer-only. Ingestion now uses the same constant-fold hooks as Java.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(kotlin): keep empty Spring path arrays and trailing commas (#3133)

Empty [] / arrayOf() class or method paths are no prefix, not a skip, matching the group-layer contract. Trailing commas in annotation argument lists no longer fail parseSpringAnnotationArguments.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(kotlin): flatten empty Spring path classification

Match class-level empty [] / arrayOf() handling to the method-level branch so the fail-closed path is not nested.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 23:06:56 +01:00

583 lines
18 KiB
TypeScript

/**
* 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: ordinary route declarations reserve an
* identity even when unresolved; unproven data-table entries do not because
* the routes phase suppresses them entirely.
* - happy path: a uniquely-resolvable handler is stamped, keyed by the route's
* `(method, url)` identity (`routeNodeKey`).
* - multi-verb identity (#2289): `GET /x` and `POST /x` are distinct keys, so
* each verb's handler is resolved independently.
*/
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 { routeNodeKey } from '../../src/core/ingestion/route-extractors/route-path.js';
import type { ExtractedDecoratorRoute } from '../../src/core/ingestion/workers/parse-worker.js';
import type { ExtractedRoute } from '../../src/core/ingestion/route-extractors/laravel.js';
import { DATA_ROUTE_TABLE_SOURCE } from '../../src/core/ingestion/route-extractors/data-route-table.js';
const FILE = 'src/OrderController.java';
function decoratorRoute(overrides: Partial<ExtractedDecoratorRoute> = {}): ExtractedDecoratorRoute {
return {
filePath: FILE,
routePath: '/orders',
httpMethod: 'GET',
decoratorName: 'GetMapping',
lineNumber: 1,
handlerName: 'list',
...overrides,
};
}
describe('resolveRouteHandlerSymbols — decorator routes', () => {
const GET_ORDERS = routeNodeKey('GET', '/orders');
it('uniquely-resolvable handler is stamped, keyed by (method, url) identity', () => {
const model = createSemanticModel();
model.symbols.add(FILE, 'list', 'method:OrderController.list', 'Method');
const out = resolveRouteHandlerSymbols(model, [], [decoratorRoute()]);
expect(out.get(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(GET_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(GET_ORDERS)).toBe(false);
});
it('treats an empty routePath as a valid root route when resolving its handler', () => {
const model = createSemanticModel();
model.symbols.add(FILE, 'root', 'method:OrderController.root', 'Method');
const out = resolveRouteHandlerSymbols(
model,
[],
[decoratorRoute({ routePath: '', handlerName: 'root' })],
);
expect(out.get(routeNodeKey('GET', '/'))).toBe('method:OrderController.root');
});
it('same-identity 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 GET /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-identity route.
decoratorRoute({ handlerName: 'first_missing' }),
decoratorRoute({ handlerName: 'second' }),
],
);
// Reservation holds: the identity carries no (wrong) handler. Pre-fix this
// would have stamped `method:OrderController.second` onto the first node.
expect(out.has(GET_ORDERS)).toBe(false);
});
it('first-writer-wins among resolvable same-identity 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(GET_ORDERS)).toBe('method:OrderController.winner');
});
it('multi-verb same URL (#2289): GET /orders and POST /orders resolve to distinct keys', () => {
const model = createSemanticModel();
model.symbols.add(FILE, 'list', 'method:OrderController.list', 'Method');
model.symbols.add(FILE, 'create', 'method:OrderController.create', 'Method');
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({ httpMethod: 'GET', handlerName: 'list' }),
decoratorRoute({ httpMethod: 'POST', decoratorName: 'PostMapping', handlerName: 'create' }),
],
);
// Two independent identities — neither evicts the other (the pre-#2289
// URL-only key would have dropped POST /orders as a duplicate of GET /orders).
expect(out.get(routeNodeKey('GET', '/orders'))).toBe('method:OrderController.list');
expect(out.get(routeNodeKey('POST', '/orders'))).toBe('method:OrderController.create');
});
it('data-table bare handlers resolve in their lexical file', () => {
const model = createSemanticModel();
model.symbols.add('src/routes.js', 'list', 'function:list', 'Function');
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'list',
}),
],
);
expect(out.get(GET_ORDERS)).toBe('function:list');
});
it('data-table named imports resolve only exported callables', () => {
const model = createSemanticModel();
const exported = model.symbols.add(
'src/handlers.js',
'listUsers',
'function:listUsers',
'Function',
);
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'handleUsers',
}),
],
{
files: [
{
filePath: 'src/routes.js',
localDefs: [],
parsedImports: [
{
kind: 'named',
localName: 'handleUsers',
importedName: 'listUsers',
targetRaw: './handlers.js',
},
],
},
],
resolveImportTarget: () => 'src/handlers.js',
isExportedSymbol: (nodeId) => nodeId === exported.nodeId,
},
);
expect(out.get(GET_ORDERS)).toBe(exported.nodeId);
});
it('data-table named imports reject private callables', () => {
const model = createSemanticModel();
model.symbols.add('src/handlers.js', 'listUsers', 'function:listUsers', 'Function');
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'handleUsers',
}),
],
{
files: [
{
filePath: 'src/routes.js',
localDefs: [],
parsedImports: [
{
kind: 'named',
localName: 'handleUsers',
importedName: 'listUsers',
targetRaw: './handlers.js',
},
],
},
],
resolveImportTarget: () => 'src/handlers.js',
isExportedSymbol: () => false,
},
);
expect(out.has(GET_ORDERS)).toBe(false);
});
it('data-table named-import members reject private owners', () => {
const model = createSemanticModel();
const owner = model.symbols.add('src/handlers.js', 'auth', 'object:auth', 'Variable');
model.methods.register(owner.nodeId, 'getCurrentUser', {
filePath: 'src/handlers.js',
name: 'getCurrentUser',
nodeId: 'method:auth.getCurrentUser',
type: 'Method',
ownerId: owner.nodeId,
});
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'authService.getCurrentUser',
}),
],
{
files: [
{
filePath: 'src/routes.js',
localDefs: [],
parsedImports: [
{
kind: 'named',
localName: 'authService',
importedName: 'auth',
targetRaw: './handlers.js',
},
],
},
],
resolveImportTarget: () => 'src/handlers.js',
isExportedSymbol: () => false,
},
);
expect(out.has(GET_ORDERS)).toBe(false);
});
it('data-table members resolve through their proven same-file owner', () => {
const model = createSemanticModel();
model.symbols.add('src/routes.js', 'auth', 'object:auth', 'Variable');
model.methods.register('object:auth', 'getCurrentUser', {
filePath: 'src/routes.js',
name: 'getCurrentUser',
nodeId: 'method:auth.getCurrentUser',
type: 'Method',
ownerId: 'object:auth',
});
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'auth.getCurrentUser',
}),
],
);
expect(out.get(GET_ORDERS)).toBe('method:auth.getCurrentUser');
});
it('data-table members refuse an unrelated terminal-name decoy', () => {
const model = createSemanticModel();
model.symbols.add('src/routes.js', 'getCurrentUser', 'function:decoy', 'Function');
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'externalAuth.getCurrentUser',
}),
],
);
expect(out.has(GET_ORDERS)).toBe(false);
});
it('data-table members suppress unsupported multi-level receiver chains', () => {
const model = createSemanticModel();
model.symbols.add('src/routes.js', 'services', 'object:services', 'Variable');
model.methods.register('object:services', 'getCurrentUser', {
filePath: 'src/routes.js',
name: 'getCurrentUser',
nodeId: 'method:decoy',
type: 'Method',
ownerId: 'object:services',
});
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'services.auth.getCurrentUser',
}),
],
);
expect(out.has(GET_ORDERS)).toBe(false);
});
it('an unresolved data-table entry does not reserve a valid framework route identity', () => {
const model = createSemanticModel();
model.symbols.add(FILE, 'list', 'method:OrderController.list', 'Method');
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'missing.handler',
}),
decoratorRoute({ handlerName: 'list' }),
],
);
expect(out.get(GET_ORDERS)).toBe('method:OrderController.list');
});
it('an unresolved data-table duplicate suppresses the route identity', () => {
const model = createSemanticModel();
model.symbols.add('src/routes.js', 'second', 'function:second', 'Function');
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'missing',
}),
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'second',
}),
],
);
expect(out.has(GET_ORDERS)).toBe(false);
});
it('different resolvable handlers for one data-table identity are suppressed', () => {
const model = createSemanticModel();
model.symbols.add('src/routes.js', 'first', 'function:first', 'Function');
model.symbols.add('src/routes.js', 'second', 'function:second', 'Function');
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'first',
}),
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'second',
}),
],
);
expect(out.has(GET_ORDERS)).toBe(false);
});
it('fails closed for default imports even when the target file has an exported callable', () => {
const model = createSemanticModel();
const exported = model.symbols.add(
'src/handlers.js',
'listUsers',
'function:listUsers',
'Function',
);
const privateHelper = model.symbols.add(
'src/handlers.js',
'helper',
'function:helper',
'Function',
);
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'handleUsers',
}),
],
{
files: [
{
filePath: 'src/routes.js',
localDefs: [],
parsedImports: [
{
kind: 'alias',
localName: 'handleUsers',
importedName: 'default',
alias: 'handleUsers',
targetRaw: './handlers.js',
},
],
},
{
filePath: 'src/handlers.js',
localDefs: [exported, privateHelper],
parsedImports: [],
},
],
resolveImportTarget: () => 'src/handlers.js',
isExportedSymbol: (nodeId) => nodeId === exported.nodeId,
},
);
expect(out.has(GET_ORDERS)).toBe(false);
});
it('does not infer a barrel helper as a default re-export target', () => {
const model = createSemanticModel();
const helper = model.symbols.add('src/barrel.js', 'helper', 'function:helper', 'Function');
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'handleUsers',
}),
],
{
files: [
{
filePath: 'src/routes.js',
localDefs: [],
parsedImports: [
{
kind: 'alias',
localName: 'handleUsers',
importedName: 'default',
alias: 'handleUsers',
targetRaw: './barrel.js',
},
],
},
{
filePath: 'src/barrel.js',
localDefs: [{ ...helper, nodeId: 'def:helper', qualifiedName: 'helper' }],
parsedImports: [
{
kind: 'named',
localName: 'default',
importedName: 'default',
targetRaw: './actual.js',
},
],
},
],
resolveImportTarget: (parsedImport) =>
parsedImport.targetRaw === './barrel.js' ? 'src/barrel.js' : 'src/actual.js',
isExportedSymbol: (nodeId) => nodeId === helper.nodeId,
},
);
expect(out.has(GET_ORDERS)).toBe(false);
});
it('ordinary decorator routes do not gain the repo-wide fallback', () => {
const model = createSemanticModel();
model.symbols.add('src/other.js', 'list', 'function:other.list', 'Function');
const out = resolveRouteHandlerSymbols(model, [], [decoratorRoute()]);
expect(out.has(GET_ORDERS)).toBe(false);
});
});
describe('resolveRouteHandlerSymbols — Laravel framework routes', () => {
const CTRL = 'app/Http/Controllers/OrderController.php';
function laravelRoute(overrides: Partial<ExtractedRoute> = {}): ExtractedRoute {
return {
filePath: 'routes/web.php',
httpMethod: 'get',
routePath: '/orders',
routeName: null,
controllerName: 'OrderController',
methodName: 'index',
middleware: [],
prefix: null,
lineNumber: 1,
...overrides,
};
}
it('resolvable controller + unique method → stamped', () => {
const model = createSemanticModel();
model.symbols.add(CTRL, 'OrderController', 'class:OrderController', 'Class');
model.symbols.add(CTRL, 'index', 'method:OrderController.index', 'Method', {
ownerId: 'class:OrderController',
});
const out = resolveRouteHandlerSymbols(model, [laravelRoute()], []);
expect(out.get(routeNodeKey('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(routeNodeKey('GET', '/orders'))).toBe(false);
});
});