GitNexus/gitnexus/test/unit/laravel-route-extraction.test.ts
Gergő Magyar bd59fa95ce
refactor(ingestion): delete legacy resolution context + tiered-lookup plumbing (RING4-2, #943) (#2033)
* test(ingestion): characterize Laravel route → controller CALLS edges (RING4-2 #943)

Pins the current processRoutesFromExtracted edge-emission behavior (which had
no direct coverage) before migrating it off the legacy ResolutionContext.resolve
tiered lookup. Locks edge target, reason, and confidence values.

* refactor(ingestion): resolve Laravel route controllers via type registry (RING4-2 #943)

Migrate processRoutesFromExtracted off the legacy ResolutionContext.resolve
tiered lookup onto model.types.lookupClassByName (global class resolution) +
model.symbols.lookupExactAll (same-file method lookup). Drops the TIER_CONFIDENCE
dependency for a fixed ROUTE_EDGE_CONFIDENCE constant matching the prior
global-tier confidence. Characterization tests (6) stay green — behavior preserved.

* refactor(ingestion): delete ResolutionContext.resolve tiered lookup (RING4-2 #943)

Removes the legacy tiered name resolution — resolve/resolveUncached,
TieredCandidates, ResolutionTier, TIER_CONFIDENCE, walkBindingChain, the
package-dir index, the per-file resolve cache, and tier-hit stats. The context
is now a thin holder for the live SemanticModel plus the (now-dead) per-file
import maps, which the follow-up prune removes.

Deletes the dedicated resolution-context.test.ts and symbol-resolver.test.ts
(both exercised the removed .resolve tiered lookup). Full unit suite green
(the 3 analyze worker-pool tests are pre-existing load flakes — pass isolated).

* refactor(ingestion): delete legacy import-map plumbing + wildcard synthesis (RING4-2 #943)

The per-file importMap / namedImportMap / packageMap / moduleAliasMap that fed
the retired tiered resolver are now dead — nothing reads them (IMPORTS edges
come from scope-resolution's imports-to-edges bridge, independent of these
maps). Removes:
  - wildcard-synthesis.ts (synthesized the dead namedImportMap/moduleAliasMap)
  - import-processor's resolution path (processImports/processImportsFromExtracted/
    wireImplicitImports/buildImportResolutionContext), keeping only the live
    preprocessImportPath path-cleanup helper
  - the parse-impl orchestration that drove them

The parse phase now threads its SemanticModel to scope-resolution directly
(parseOutput.model) instead of wrapping it in the resolution context. Deletes
the obsolete wildcard/import-processor unit tests; trims the dead processImports
cases from sequential-language-availability (processParsing coverage kept).

* refactor(ingestion): delete resolution context + named-binding plumbing (RING4-2 #943)

Completes the legacy-resolution retirement. With the tiered resolver gone, the
entire per-file import-extraction chain is dead — its only consumer was the
deleted ResolutionContext.resolve, and scope-resolution emits IMPORTS edges
from its own finalized ImportEdges:

  - delete model/resolution-context.ts (the legacy context); the parse phase
    now hands its SemanticModel to scope-resolution as parseOutput.model
  - delete the named-bindings/ extractors + the namedBindingExtractor provider
    hook (built the dead NamedImportMap) across all 8 providers + the worker
  - delete the orphaned implicitImportWirer hook + Swift implementation +
    providersWithImplicitWiring (scope-resolution owns implicit imports now)
  - drop the dead ExtractedImport type + worker/sequential import accumulation
    (result.imports / WorkerExtractedData.imports)
  - import-processor.ts and its preprocessImportPath helper are now unreferenced

Deletes the obsolete named-bindings + preprocessImportPath unit tests. tsc
clean; full unit suite green (3 analyze worker-pool tests are pre-existing load
flakes); 1229 import/cross-file/resolver integration tests pass incl. the
wildcard-import languages (Go/Ruby/C++/Swift) that previously used synthesis.

* docs(ingestion): scrub stale references to deleted resolution-context machinery (RING4-2 #943)

* docs(ingestion): reword route resolver comment to clear acceptance grep gate (#943)

* fix(review): apply autofix feedback (RING4-2 #943)

Code-review autofixes from the multi-agent pass:
- delete orphaned dead code the deletion missed: swift.ts groupSwiftFilesByTarget
  + SwiftPackageConfig import (live copy is target-grouping.ts), import-resolvers
  EMPTY_INDEX export (no consumers after the importCtx reset was removed)
- scrub stale comments referencing deleted symbols (processImports,
  preprocessImportPath, moduleAliasMap, NamedImportMap/PackageMap, wildcard-synthesis)
  and fix a broken comment fragment in parse-impl.ts
- document the intentional global-resolution convergence for route controllers
  (the import-scoped tier was deleted with the resolver): confidence flattens
  0.9→0.5 but resolved edges stay at the 0.5 process-trace/community gate; only
  the narrow imported-controller-with-unresolved-method guessed edge crosses it
- add an overloaded-method characterization case pinning lookupExactAll[0]

* style(ingestion): prettier-format parse-impl unwind + route characterization test (#943)

* refactor(ingestion): address tri-review findings (RING4-2 #943)

From the PR #2033 tri-review (Codex + CE lanes):
- delete the now-dead importSemantics provider field + ImportSemantics type
  (wildcard-synthesis.ts was its sole consumer; zero readers remain) across
  language-provider.ts + 7 providers + DEFAULTS
- correct the processRoutesFromExtracted JSDoc: the import-disambiguated
  controller skip is STRICTER than the legacy global-tier guard (the legacy
  import-scoped tier resolved aliased / same-short-name controllers and emitted
  the edge); document the aliased-import missed-edge case explicitly
- add an aliased-controller characterization test pinning the documented
  global-resolution convergence (no edge for an aliased/unresolvable controller name)
- scrub stale parse-impl.ts docstrings/comments that still listed the removed
  import-resolution / wildcard-synthesis / heritage passes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ingestion): capture routes-file use/FQN map for Laravel controller resolution (#943)

Adds ExtractedRoute.controllerQualifiedName: the Laravel route extractor now
builds the routes file's `use`-import alias map (local→normalized dot-joined
FQN, via splitNamespaceUseDeclaration) and captures inline qualified ::class
references, threading the disambiguating FQN through every route. Normalized via
the shared normalizeQualifiedName so it matches the type registry's key shape
(issue #1982). Foundation for qualified-first route→controller resolution (U2).

* fix(ingestion): resolve Laravel route controllers qualified-first (#943)

processRoutesFromExtracted now resolves the controller via
model.types.lookupClassByQualifiedName(route.controllerQualifiedName) when the
extractor disambiguated it (aliased use / same-short-name / inline FQN), falling
back to the short-name lookupClassByName (which still skips on ambiguity). This
restores the route→controller CALLS edges the PR #2033 tri-review (Codex F1 +
ce-adversarial) found dropped, without re-adding the deleted per-file import map.
Method resolution, guessed-id, and confidence are unchanged. JSDoc rewritten to
qualified-first precedence; the aliased characterization test flips from no-edge
to edge; adds duplicated-name-disambiguated + stale-FQN-fallback cases.

* test(ingestion): end-to-end Laravel route→controller qualified resolution + PSR-4 disambiguation (#943)

Adds an integration test that parses real namespaced PHP controllers + a routes
file through the worker pipeline and asserts the route CALLS edges target the
correct namespaced controller — the authoritative gate the unit tests can't be
(hand-built models). It surfaced that PHP's statement-form `namespace X;`
leaves the structure-phase qualifiedName as the SHORT name, so
lookupClassByQualifiedName misses; resolveControllerByQualifiedName now adds a
PSR-4 file-path disambiguation (FQN namespace tail ↔ file directory tail) to
pick the right same-short-name controller. Forces the worker path
(workerThresholdsForTest) since route extraction is worker-only.

* style(ingestion): prettier-format Laravel route resolution changes (#943)

* test(ingestion): regenerate php-captures golden for the new php-laravel-routes fixture (#943)

* test(ingestion): move route fixture out of the php-* scope-capture corpus (#943)

The laravel route-resolution fixture lived under lang-resolution/php-laravel-routes,
which the php scope-capture golden + benchmark both glob (lang-resolution/php-*),
drifting their fingerprints. The fixture is for route resolution, not php
scope-capture parity, so rename it to lang-resolution/laravel-route-resolution
to decouple it. Reverts the golden's php-laravel-routes entries; bench
scope-capture --check passes (php back to baseline).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:51:28 +01:00

283 lines
8.5 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import Parser from 'tree-sitter';
import PHP from 'tree-sitter-php';
import { extractLaravelRoutes } from '../../src/core/ingestion/route-extractors/laravel.js';
const parser = new Parser();
parser.setLanguage(PHP.php_only);
const extract = (source: string) =>
extractLaravelRoutes(parser.parse(source), 'routes/web.php').map((route) => ({
httpMethod: route.httpMethod,
routePath: route.routePath,
controllerName: route.controllerName,
methodName: route.methodName,
routeName: route.routeName,
middleware: route.middleware,
prefix: route.prefix,
}));
describe('Laravel route extraction', () => {
it('extracts representative HTTP verb route declarations', () => {
const routes = extract(`<?php
Route::get('/orders', [OrderController::class, 'index']);
Route::post('/orders', [OrderController::class, 'store']);
Route::put('/orders/{order}', [OrderController::class, 'update']);
Route::patch('/orders/{order}', [OrderController::class, 'patch']);
Route::delete('/orders/{order}', [OrderController::class, 'destroy']);
Route::options('/orders/options', [OrderController::class, 'options']);
Route::any('/orders/any', [OrderController::class, 'any']);
Route::match(['get', 'post'], '/orders/search', [OrderController::class, 'search']);
`);
expect(routes).toEqual(
expect.arrayContaining([
expect.objectContaining({ httpMethod: 'get', routePath: '/orders', methodName: 'index' }),
expect.objectContaining({ httpMethod: 'post', routePath: '/orders', methodName: 'store' }),
expect.objectContaining({
httpMethod: 'put',
routePath: '/orders/{order}',
methodName: 'update',
}),
expect.objectContaining({
httpMethod: 'patch',
routePath: '/orders/{order}',
methodName: 'patch',
}),
expect.objectContaining({
httpMethod: 'delete',
routePath: '/orders/{order}',
methodName: 'destroy',
}),
expect.objectContaining({
httpMethod: 'options',
routePath: '/orders/options',
methodName: 'options',
}),
expect.objectContaining({
httpMethod: 'any',
routePath: '/orders/any',
methodName: 'any',
}),
expect.objectContaining({ httpMethod: 'match', routePath: '/orders/search' }),
]),
);
});
it('expands resource and apiResource controller actions', () => {
const routes = extract(`<?php
Route::resource('/photos', PhotoController::class);
Route::apiResource('/api/photos', ApiPhotoController::class);
`);
const photos = routes.filter((route) => route.routePath === '/photos');
expect(photos.map((route) => route.methodName)).toEqual([
'index',
'create',
'store',
'show',
'edit',
'update',
'destroy',
]);
expect(new Set(photos.map((route) => route.controllerName))).toEqual(
new Set(['PhotoController']),
);
expect(photos.map((route) => route.routeName)).toEqual([
'photos.index',
'photos.create',
'photos.store',
'photos.show',
'photos.edit',
'photos.update',
'photos.destroy',
]);
const apiPhotos = routes.filter((route) => route.routePath === '/api/photos');
expect(apiPhotos.map((route) => route.methodName)).toEqual([
'index',
'store',
'show',
'update',
'destroy',
]);
expect(new Set(apiPhotos.map((route) => route.controllerName))).toEqual(
new Set(['ApiPhotoController']),
);
expect(apiPhotos.map((route) => route.routeName)).toEqual([
'api.photos.index',
'api.photos.store',
'api.photos.show',
'api.photos.update',
'api.photos.destroy',
]);
});
it('threads middleware, prefix, and controller chains into grouped routes', () => {
const routes = extract(`<?php
Route::get('/loose', 'index');
Route::group([
'prefix' => 'api',
'middleware' => ['auth'],
'controller' => ApiOrderController::class,
], function () {
Route::post('/orders', 'store');
});
Route::middleware(['auth', 'verified'])
->prefix('admin')
->controller(OrderController::class)
->group(function () {
Route::get('/orders', 'index');
});
`);
expect(routes).toContainEqual(
expect.objectContaining({
httpMethod: 'get',
routePath: '/orders',
controllerName: 'OrderController',
methodName: 'index',
middleware: ['auth', 'verified'],
prefix: 'admin',
}),
);
expect(routes).toContainEqual(
expect.objectContaining({
httpMethod: 'post',
routePath: '/orders',
controllerName: 'ApiOrderController',
methodName: 'store',
middleware: ['auth'],
prefix: 'api',
}),
);
expect(routes).toContainEqual(
expect.objectContaining({
httpMethod: 'get',
routePath: '/loose',
controllerName: null,
methodName: null,
}),
);
});
it('extracts named routes from fluent routes and named groups', () => {
const routes = extract(`<?php
Route::get('/login', [AuthController::class, 'login'])->name('login');
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth')->name('logout');
Route::name('admin.')
->prefix('admin')
->group(function () {
Route::post('/settings/cache', [SettingsController::class, 'refresh'])
->name('settings.refresh-cache');
});
Route::group([
'as' => 'log-viewer.',
'prefix' => 'logs',
], function () {
Route::post('/login', [LogViewerController::class, 'login'])
->name('login.submit');
});
`);
expect(routes).toEqual(
expect.arrayContaining([
expect.objectContaining({
httpMethod: 'get',
routePath: '/login',
routeName: 'login',
}),
expect.objectContaining({
httpMethod: 'post',
routePath: '/logout',
routeName: 'logout',
middleware: ['auth'],
}),
expect.objectContaining({
httpMethod: 'post',
routePath: '/settings/cache',
routeName: 'admin.settings.refresh-cache',
prefix: 'admin',
}),
expect.objectContaining({
httpMethod: 'post',
routePath: '/login',
routeName: 'log-viewer.login.submit',
prefix: 'logs',
}),
]),
);
});
});
describe('Laravel controller qualified-name resolution (RING4-2 follow-up)', () => {
const routeFor = (source: string, routePath: string) =>
extractLaravelRoutes(parser.parse(source), 'routes/web.php').find(
(r) => r.routePath === routePath,
);
it('resolves an aliased controller import to its normalized FQN', () => {
const route = routeFor(
`<?php
use App\\Http\\Controllers\\OrderController as Orders;
Route::get('/orders', [Orders::class, 'index']);
`,
'/orders',
);
expect(route?.controllerName).toBe('Orders');
expect(route?.controllerQualifiedName).toBe('App.Http.Controllers.OrderController');
});
it('resolves a plain (non-aliased) controller import to its normalized FQN', () => {
const route = routeFor(
`<?php
use App\\Http\\Controllers\\OrderController;
Route::get('/orders', [OrderController::class, 'index']);
`,
'/orders',
);
expect(route?.controllerName).toBe('OrderController');
expect(route?.controllerQualifiedName).toBe('App.Http.Controllers.OrderController');
});
it('captures an inline qualified ::class reference as the normalized FQN', () => {
const route = routeFor(
`<?php
Route::get('/orders', [\\App\\Admin\\OrderController::class, 'index']);
`,
'/orders',
);
expect(route?.controllerQualifiedName).toBe('App.Admin.OrderController');
});
it('leaves controllerQualifiedName null for a bare short name with no use import', () => {
const route = routeFor(
`<?php
Route::get('/orders', [OrderController::class, 'index']);
`,
'/orders',
);
expect(route?.controllerName).toBe('OrderController');
expect(route?.controllerQualifiedName ?? null).toBeNull();
});
it('threads the FQN through resource routes', () => {
const routes = extractLaravelRoutes(
parser.parse(`<?php
use App\\Http\\Controllers\\PhotoController as Photos;
Route::resource('/photos', Photos::class);
`),
'routes/web.php',
).filter((r) => r.routePath === '/photos');
expect(routes.length).toBeGreaterThan(0);
for (const r of routes) {
expect(r.controllerQualifiedName).toBe('App.Http.Controllers.PhotoController');
}
});
});