GitNexus/gitnexus/test/unit/call-processor-routes.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

297 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Characterization tests for `processRoutesFromExtracted` — the Laravel
* framework-route → controller-method `CALLS`-edge emitter in
* call-processor.ts.
*
* RING4-2 (#943) migrates this emitter off the legacy `ResolutionContext.resolve`
* tiered lookup and onto the scope-resolution registry / symbol table. These
* tests pin the *current* edge-emission behavior (which had no direct coverage)
* so the migration is provably behavior-preserving:
*
* - resolvable controller + same-file method → CALLS edge to the method node
* - resolvable controller + unknown method → CALLS edge to a *guessed* Method id
* - unknown controller → no edge
* - ambiguous global controller (>1 match) → no edge
* - one edge emitted per route
*
* Confidence values captured here (controller resolves at the `global` tier for
* routes-file → controller references, so 0.5; guessed-method edges are × 0.8)
* are the contract the migrated implementation must match.
*/
import { describe, it, expect } from 'vitest';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { createSemanticModel } from '../../src/core/ingestion/model/index.js';
import { processRoutesFromExtracted } from '../../src/core/ingestion/call-processor.js';
import { generateId } from '../../src/lib/utils.js';
import type { ExtractedRoute } from '../../src/core/ingestion/route-extractors/laravel.js';
import type { KnowledgeGraph } from '../../src/core/graph/types.js';
const ROUTES_FILE = 'routes/web.php';
const CONTROLLER_FILE = 'app/Http/Controllers/OrderController.php';
function makeRoute(overrides: Partial<ExtractedRoute> = {}): ExtractedRoute {
return {
filePath: ROUTES_FILE,
httpMethod: 'get',
routePath: '/orders',
routeName: null,
controllerName: 'OrderController',
methodName: 'index',
middleware: [],
prefix: null,
lineNumber: 1,
...overrides,
};
}
/** A semantic model with a single OrderController class + the given methods
* registered in the controller's own file (so method resolution finds them
* via the same-file symbol-table lookup). */
function modelWithController(methods: string[]) {
const model = createSemanticModel();
model.symbols.add(CONTROLLER_FILE, 'OrderController', 'class:OrderController', 'Class');
for (const m of methods) {
model.symbols.add(CONTROLLER_FILE, m, `method:OrderController.${m}`, 'Method', {
ownerId: 'class:OrderController',
});
}
return model;
}
function routeCallsEdges(graph: KnowledgeGraph) {
return graph.relationships.filter((r) => r.type === 'CALLS' && r.reason === 'laravel-route');
}
describe('processRoutesFromExtracted — Laravel route → controller CALLS edges', () => {
it('resolvable controller + same-file method → one CALLS edge to the method node', async () => {
const graph = createKnowledgeGraph();
const model = modelWithController(['index']);
await processRoutesFromExtracted(graph, [makeRoute({ methodName: 'index' })], model);
const edges = routeCallsEdges(graph);
expect(edges).toHaveLength(1);
expect(edges[0].sourceId).toBe(generateId('File', ROUTES_FILE));
expect(edges[0].targetId).toBe('method:OrderController.index');
// controller resolved by global class name → ROUTE_EDGE_CONFIDENCE (0.5)
expect(edges[0].confidence).toBeCloseTo(0.5, 5);
});
it('resolvable controller + unknown method → CALLS edge to a guessed Method id at reduced confidence', async () => {
const graph = createKnowledgeGraph();
const model = modelWithController([]); // controller class only, no methods
await processRoutesFromExtracted(graph, [makeRoute({ methodName: 'ghost' })], model);
const edges = routeCallsEdges(graph);
expect(edges).toHaveLength(1);
expect(edges[0].sourceId).toBe(generateId('File', ROUTES_FILE));
expect(edges[0].targetId).toBe(generateId('Method', `${CONTROLLER_FILE}:ghost`));
// guessed-method edges are emitted at controller-confidence × 0.8
expect(edges[0].confidence).toBeCloseTo(0.5 * 0.8, 5);
});
it('unknown controller → no edge emitted', async () => {
const graph = createKnowledgeGraph();
const model = modelWithController(['index']);
await processRoutesFromExtracted(
graph,
[makeRoute({ controllerName: 'GhostController', methodName: 'index' })],
model,
);
expect(routeCallsEdges(graph)).toHaveLength(0);
});
it('ambiguous controller name (2+ global matches) → no edge emitted', async () => {
const graph = createKnowledgeGraph();
const model = createSemanticModel();
// Two distinct classes share the controller short-name in different files →
// lookupClassByName returns >1 candidate, which the emitter refuses.
model.symbols.add(
'app/A/OrderController.php',
'OrderController',
'class:A.OrderController',
'Class',
);
model.symbols.add(
'app/B/OrderController.php',
'OrderController',
'class:B.OrderController',
'Class',
);
await processRoutesFromExtracted(graph, [makeRoute({ methodName: 'index' })], model);
expect(routeCallsEdges(graph)).toHaveLength(0);
});
it('route missing controllerName or methodName → skipped', async () => {
const graph = createKnowledgeGraph();
const model = modelWithController(['index']);
await processRoutesFromExtracted(
graph,
[makeRoute({ controllerName: null }), makeRoute({ methodName: null })],
model,
);
expect(routeCallsEdges(graph)).toHaveLength(0);
});
it('multiple routes to the same controller → one edge per route, distinct targets', async () => {
const graph = createKnowledgeGraph();
const model = modelWithController(['index', 'store']);
await processRoutesFromExtracted(
graph,
[
makeRoute({ httpMethod: 'get', routePath: '/orders', methodName: 'index' }),
makeRoute({ httpMethod: 'post', routePath: '/orders', methodName: 'store' }),
],
model,
);
const edges = routeCallsEdges(graph);
expect(edges).toHaveLength(2);
expect(edges.map((e) => e.targetId).sort()).toEqual([
'method:OrderController.index',
'method:OrderController.store',
]);
});
it('overloaded controller method → edge targets the first-registered definition', async () => {
// Two same-name method definitions in the controller file (overloads).
// The emitter takes lookupExactAll(...)[0] — first-registered wins, parity
// with the legacy same-file tier which returned candidates[0]. Pins the
// selection policy so it can't silently drift.
const graph = createKnowledgeGraph();
const model = createSemanticModel();
model.symbols.add(CONTROLLER_FILE, 'OrderController', 'class:OrderController', 'Class');
model.symbols.add(CONTROLLER_FILE, 'index', 'method:OrderController.index#1', 'Method', {
ownerId: 'class:OrderController',
});
model.symbols.add(CONTROLLER_FILE, 'index', 'method:OrderController.index#2', 'Method', {
ownerId: 'class:OrderController',
});
await processRoutesFromExtracted(graph, [makeRoute({ methodName: 'index' })], model);
const edges = routeCallsEdges(graph);
expect(edges).toHaveLength(1);
expect(edges[0].targetId).toBe('method:OrderController.index#1');
});
it('aliased controller resolves via controllerQualifiedName → edge emitted', async () => {
// An aliased import `use App\\Http\\Controllers\\OrderController as Orders;`
// + `[Orders::class, 'index']` yields controllerName='Orders' but the extractor
// also threads controllerQualifiedName='App.Http.Controllers.OrderController'
// (the alias resolved to its FQN). The class is registered under that FQN, so
// lookupClassByQualifiedName resolves it → edge — restoring what the legacy
// import-scoped tier emitted (RING4-2 follow-up).
const graph = createKnowledgeGraph();
const model = createSemanticModel();
const FQN = 'App.Http.Controllers.OrderController';
model.symbols.add(CONTROLLER_FILE, 'OrderController', 'class:OrderController', 'Class', {
qualifiedName: FQN,
});
model.symbols.add(CONTROLLER_FILE, 'index', 'method:OrderController.index', 'Method', {
ownerId: 'class:OrderController',
});
await processRoutesFromExtracted(
graph,
[makeRoute({ controllerName: 'Orders', controllerQualifiedName: FQN, methodName: 'index' })],
model,
);
const edges = routeCallsEdges(graph);
expect(edges).toHaveLength(1);
expect(edges[0].targetId).toBe('method:OrderController.index');
expect(edges[0].confidence).toBeCloseTo(0.5, 5);
});
it('globally-duplicated short name disambiguated by controllerQualifiedName → edge to the specific controller', async () => {
// Two OrderControllers in different namespaces share the short name. The route
// carries the FQN of the one its `use` import selected, so the edge targets
// that specific class's method — not the other, and not a skip.
const graph = createKnowledgeGraph();
const model = createSemanticModel();
const ADMIN_FQN = 'App.Admin.OrderController';
const PUBLIC_FQN = 'App.Http.Controllers.OrderController';
model.symbols.add(
'app/Admin/OrderController.php',
'OrderController',
'class:Admin.OrderController',
'Class',
{
qualifiedName: ADMIN_FQN,
},
);
model.symbols.add(
'app/Admin/OrderController.php',
'index',
'method:Admin.OrderController.index',
'Method',
{
ownerId: 'class:Admin.OrderController',
},
);
model.symbols.add(
'app/Http/Controllers/OrderController.php',
'OrderController',
'class:Public.OrderController',
'Class',
{
qualifiedName: PUBLIC_FQN,
},
);
model.symbols.add(
'app/Http/Controllers/OrderController.php',
'index',
'method:Public.OrderController.index',
'Method',
{
ownerId: 'class:Public.OrderController',
},
);
await processRoutesFromExtracted(
graph,
[
makeRoute({
controllerName: 'OrderController',
controllerQualifiedName: ADMIN_FQN,
methodName: 'index',
}),
],
model,
);
const edges = routeCallsEdges(graph);
expect(edges).toHaveLength(1);
expect(edges[0].targetId).toBe('method:Admin.OrderController.index');
});
it('controllerQualifiedName set but no class matches → falls back to short-name resolution', async () => {
// A stale/unmatched FQN must not block the short-name fallback when that is unique.
const graph = createKnowledgeGraph();
const model = modelWithController(['index']); // 'OrderController' registered, no FQN
await processRoutesFromExtracted(
graph,
[
makeRoute({
controllerName: 'OrderController',
controllerQualifiedName: 'App.Nonexistent.OrderController',
methodName: 'index',
}),
],
model,
);
const edges = routeCallsEdges(graph);
expect(edges).toHaveLength(1);
expect(edges[0].targetId).toBe('method:OrderController.index');
});
});