fix(routes): resolve Express receivers and router mount paths (#3603)

* fix(routes): resolve Express receiver provenance and mounted paths

Require statically proven framework receivers, compose Router mount paths,
and share the same provider resolution between ingestion and group routes.
Preserve worker and parse-cache parity with per-file facts.

* fix(review): apply review findings

Invalidate exported Routers through namespace property mutations and retain
uncertain incoming mounts so invalid parents cannot revive local paths.

* refactor(routes): reuse export checks and fixture writer

* fix(routes): evaluate fluent call receivers once

* fix(review): preserve provider paths and uncertain mounts

* fix(test): distinguish parameterized router mutation cases

* docs: remove Express route implementation plan
This commit is contained in:
Gergő Magyar 2026-10-16 20:25:40 +03:00 • committed by GitHub
parent 252a64573e
commit f8209df2e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 2587 additions and 316 deletions

View file

@ -166,7 +166,7 @@ export const myPhase: PipelinePhase<MyPhaseOutput> = {
### Where routes come from
`route-extractors/` holds four independent ways a route can be discovered, all
`route-extractors/` holds five independent ways a route can be discovered, all
converging on the routes phase's `(method, url)` registry:
| Source | Shape | Examples |
@ -175,8 +175,9 @@ converging on the routes phase's `(method, url)` registry:
| Single-file framework route | `isRouteFile` + worker extraction | Laravel `routes/*.php` |
| Cross-file framework route | `discoverRootRouteFiles` + `extractRoutes` | Django `urlpatterns` |
| AST-level route in a normal file | `extractDecoratorRoutes` | Spring, FastAPI, NestJS (`@Controller` + `@Get`/`@Post`/…; URLs are controller-relative — `setGlobalPrefix` and URI versioning live in the bootstrap file and are not applied), **JS/TS dispatch guards and static data route tables** |
| Cross-file AST route facts | `extractRouteFacts` + `resolveRouteFacts` | Express apps/routers and their mount paths; directly proven Hono registrations |
The last row is the one whose name undersells it. A route is DECLARED by a
The AST-level route hook is the one whose name undersells it. A route is DECLARED by a
decorator, but it can also be **inferred** from a raw `node:http` server's own
dispatch — `if (req.method === 'GET' && pathname === '/api/x')` is a route with
a path, a verb and a handler, and nothing else in the pipeline could see it.
@ -200,10 +201,28 @@ without a verb, and any regex it cannot translate exactly are all dropped rather
than guessed at. A missing route is a coverage limit; an invented one is a lie.
Express `app.get(RegExp, handler)` registrations are likewise omitted: a regex
matcher does not have one finite URL to use as a Route node key.
Express `app.use(path, middleware)` is middleware attachment, not an endpoint;
it does not create a Route node. Dispatch-guard inference also ignores stored
`route.routePath` metadata: comparing a recorded route name with a literal
does not prove that the request path is being dispatched.
Express `app.use(path, middleware)` does not itself create a Route node. When
both receivers are proven Express instances, mounting a router joins the literal
prefix to its registered paths before handler selection. Nested and repeated
mounts, literal path arrays, and middleware arrays share this composition.
Express receiver facts reuse the existing AST and `ParsedFile` lexical identities.
They follow explicit ESM/CommonJS imports and exports through the configured
`ScopeResolver`, including stable aliases and barrels. An object named `app` or
`router` is not evidence; type-only imports, shadowed bindings, conflicting writes,
and unproven factories do not establish a receiver. Directly proven Hono verb
registrations remain supported, without applying Express mounting semantics.
A standalone router retains its local paths. A known dynamic mount suppresses
that fallback, while independently proven static mounts remain available. Cycles
terminate, and exceeding the expansion budget omits the affected mount component
rather than publishing a partial set of paths. Per-file facts are cached, but
cross-file paths are recomputed each run. Group HTTP contracts use the same
resolver and composition, including in source-only mode.
Dispatch-guard inference also ignores stored `route.routePath` metadata:
comparing a recorded route name with a literal does not prove that the request
path is being dispatched.
Two rules there need more than one comparison to decide, and are worth knowing
about before changing either:

View file

@ -9,7 +9,24 @@ import {
type LanguagePatterns,
type PatternSpec,
} from '../tree-sitter-scanner.js';
import type { HttpDetection, HttpLanguagePlugin, RepoContext } from './types.js';
import type {
HttpDetection,
HttpLanguagePlugin,
HttpScanInput,
HttpFileDetections,
RepoContext,
} from './types.js';
import { getProviderForFile } from '../../../ingestion/languages/index.js';
import { extractParsedFile } from '../../../ingestion/scope-extractor-bridge.js';
import { extractFrameworkRouteFacts } from '../../../ingestion/route-extractors/framework-route-facts.js';
import { resolveFrameworkRoutePaths } from '../../../ingestion/route-extractors/framework-route-paths.js';
import type {
FrameworkRoute,
FrameworkRouteFacts,
} from '../../../ingestion/route-extractors/framework-route-types.js';
import { typescriptScopeResolver } from '../../../ingestion/languages/typescript/scope-resolver.js';
import { javascriptScopeResolver } from '../../../ingestion/languages/javascript/scope-resolver.js';
import { getLanguageFromFilename, SupportedLanguages, type ParsedFile } from 'gitnexus-shared';
import { MAX_FOLD_LENGTH } from '../../../ingestion/route-extractors/constant-resolver.js';
import {
DATA_ROUTE_TABLE_SOURCE,
@ -49,18 +66,6 @@ import {
// NestJS providers are not queried here at all — see the `extractNestRoutes`
// call in `scanBundle`.
// ─── Provider: Express — router.get/app.post/... ─────────────────────
const EXPRESS_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(call_expression
function: (member_expression
object: (identifier) @obj (#match? @obj "^(router|app)$")
property: (property_identifier) @http_method (#match? @http_method "^(get|post|put|delete|patch)$"))
arguments: (arguments . [(string) (template_string)] @path . (_)? @handler))
`,
};
// ─── Consumer: fetch(url) with NO options ─────────────────────────────
const FETCH_NO_OPTIONS_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
@ -96,8 +101,8 @@ const FETCH_WITH_OPTIONS_SPEC: PatternSpec<Record<string, never>> = {
// `scanBundle` admits a match only after PROVING the receiver is an axios
// instance and resolving the argument to a path.
//
// The proof gate is load-bearing, not belt-and-braces: EXPRESS_SPEC above
// matches `router.get('/x', handler)` / `app.post(...)` as PROVIDERS. A
// The proof gate is load-bearing: proven Express registrations such as
// `router.get('/x', handler)` / `app.post(...)` are PROVIDERS. A
// receiver admitted on spelling alone would re-emit every Express route in the
// repo as a consumer of itself, on both sides of every cross-repo pair.
const HTTP_CLIENT_SPEC: PatternSpec<Record<string, never>> = {
@ -185,7 +190,6 @@ const REQUEST_OBJECT_SPEC: PatternSpec<Record<string, never>> = {
const WRAPPED_REQUEST_RECEIVERS = new Set(['httpClient', '$http']);
interface NodePatternBundle {
express: CompiledPatterns<Record<string, never>>;
fetchNoOptions: CompiledPatterns<Record<string, never>>;
fetchWithOptions: CompiledPatterns<Record<string, never>>;
httpClient: CompiledPatterns<Record<string, never>>;
@ -203,7 +207,6 @@ function compileBundle(language: Parser.Language, name: string): NodePatternBund
patterns: [spec],
} satisfies LanguagePatterns<Record<string, never>>);
return {
express: mk(EXPRESS_SPEC, 'express'),
fetchNoOptions: mk(FETCH_NO_OPTIONS_SPEC, 'fetch-no-options'),
fetchWithOptions: mk(FETCH_WITH_OPTIONS_SPEC, 'fetch-with-options'),
httpClient: mk(HTTP_CLIENT_SPEC, 'http-client'),
@ -372,6 +375,9 @@ function buildImportMap(tree: Parser.Tree): Map<string, { name: string; module:
interface NodeRepoContext {
readonly facts: JsRepoFacts;
readonly resolutionConfig: unknown;
readonly files: ReadonlySet<string>;
readonly configurationAvailable: boolean;
}
/**
@ -408,11 +414,12 @@ function grammarForFile(rel: string): unknown | null {
return null;
}
function buildNodeRepoContext(args: {
async function buildNodeRepoContext(args: {
repoPath: string;
files: string[];
readFile: (rel: string) => string | null;
parseSource: (parser: Parser, src: string) => Parser.Tree | null;
}): NodeRepoContext {
}): Promise<NodeRepoContext> {
const cached = REPO_CONTEXT_BY_FILE_LIST.get(args.files);
if (cached) return cached;
@ -472,11 +479,100 @@ function buildNodeRepoContext(args: {
}
}
const ctx: NodeRepoContext = { facts: buildJsRepoFacts(byFile) };
// Both Node resolvers consume the same tsconfig/jsconfig/workspace inputs;
// their import-target hooks retain their own extension ordering below.
let resolutionConfig: unknown;
let configurationAvailable = true;
try {
resolutionConfig = await typescriptScopeResolver.loadResolutionConfig?.(args.repoPath);
} catch {
configurationAvailable = false;
}
const ctx: NodeRepoContext = {
facts: buildJsRepoFacts(byFile),
resolutionConfig,
files: new Set(args.files.map(normalizeRel)),
configurationAvailable,
};
REPO_CONTEXT_BY_FILE_LIST.set(args.files, ctx);
return ctx;
}
/** Adapt exact lexical handler evidence without guessing from a missing name. */
function frameworkDetection(route: FrameworkRoute, facts: FrameworkRouteFacts): HttpDetection {
const call = route.frameworkCall;
const handler = call?.arguments.at(-1);
let name = route.handlerName ?? null;
let handlerImport: HttpDetection['handlerImport'];
if (!name && handler?.kind === 'binding' && !facts.invalidBindings.includes(handler.id)) {
const bindings = facts.bindings.filter((binding) => binding.id === handler.id);
const value = bindings.length === 1 ? bindings[0].value : undefined;
if (value?.kind === 'import' && !['default', '*', 'require'].includes(value.name)) {
name = value.name;
handlerImport = { name: value.name, module: value.source };
}
}
return {
role: 'provider',
framework: route.decoratorName?.startsWith('hono.') ? 'hono' : 'express',
method: route.httpMethod ?? '*',
path: route.routePath,
name,
...(handlerImport ? { handlerImport } : { strictHandlerResolution: true }),
...(!name && handler?.kind !== 'handler' ? { unresolvedHandler: true } : {}),
line: route.lineNumber + 1,
confidence: 0.8,
};
}
/** One project family for JS, TS and TSX, using the already parsed group trees. */
function scanNodeProject(
files: readonly HttpScanInput[],
repoContext?: RepoContext,
): HttpFileDetections[] {
const context = repoContext as NodeRepoContext | undefined;
if (context?.configurationAvailable === false) return [];
const parsedFiles: ParsedFile[] = [];
const facts = new Map<string, FrameworkRouteFacts>();
for (const { filePath: rawPath, tree } of files) {
const filePath = normalizeRel(rawPath);
const provider = getProviderForFile(filePath);
if (!provider) continue;
try {
const parsed = extractParsedFile(provider, tree.rootNode.text, filePath, undefined, tree);
if (!parsed) continue;
parsedFiles.push(parsed);
facts.set(filePath, extractFrameworkRouteFacts(tree, parsed));
} catch {
// A malformed file contributes no framework facts; other files survive.
}
}
const filePaths = context?.files ?? new Set(files.map((file) => normalizeRel(file.filePath)));
const routes = resolveFrameworkRoutePaths([...facts.values()], (fromFile, specifier) => {
const resolver =
getLanguageFromFilename(fromFile) === SupportedLanguages.JavaScript
? javascriptScopeResolver
: typescriptScopeResolver;
const target = resolver.resolveImportTarget(
specifier,
fromFile,
filePaths,
context?.resolutionConfig,
{ parsedFiles },
);
return typeof target === 'string' ? [target] : (target ?? []);
});
const detections = new Map<string, HttpDetection[]>();
for (const route of routes) {
const fileFacts = facts.get(route.filePath);
if (!fileFacts) continue;
const found = detections.get(route.filePath) ?? [];
found.push(frameworkDetection(route, fileFacts));
detections.set(route.filePath, found);
}
return [...detections].map(([filePath, found]) => ({ filePath, detections: found }));
}
/** The repo facts to resolve against, or null when there was no pre-pass. */
function resolveFactsFor(
repoContext: RepoContext | undefined,
@ -615,33 +711,16 @@ function scanBundle(
});
}
// Express: router/app.<verb>(...)
for (const match of runCompiledPatterns(bundle.express, tree)) {
const methodNode = match.captures.http_method;
const pathNode = match.captures.path;
if (!methodNode || !pathNode) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
// Capture the handler argument identifier (`router.get('/x', listUsers)`
// → `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. 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: imported ? imported.name : localHandler,
handlerImport: imported,
line: (handlerNode ?? pathNode).startPosition.row + 1,
confidence: 0.8,
});
// Bare single-tree callers retain local framework routes. Repository scans
// receive only the composed project providers, never a raw-path fallback.
if (fileRel === undefined) {
const filePath =
bundle === JAVASCRIPT_BUNDLE
? 'routes.js'
: bundle === TSX_BUNDLE
? 'routes.tsx'
: 'routes.ts';
out.push(...(scanNodeProject([{ filePath, tree }])[0]?.detections ?? []));
}
// Consumer: fetch with options { method: 'X' }
@ -853,6 +932,7 @@ export const JAVASCRIPT_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'javascript-http',
language: JavaScript,
prepareRepo: buildNodeRepoContext,
scanProject: scanNodeProject,
scan: (tree, repoContext, fileRel) => scanBundle(JAVASCRIPT_BUNDLE, tree, repoContext, fileRel),
};
@ -860,6 +940,7 @@ export const TYPESCRIPT_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'typescript-http',
language: TypeScript.typescript,
prepareRepo: buildNodeRepoContext,
scanProject: scanNodeProject,
scan: (tree, repoContext, fileRel) => scanBundle(TYPESCRIPT_BUNDLE, tree, repoContext, fileRel),
};
@ -867,5 +948,6 @@ export const TSX_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'tsx-http',
language: TypeScript.tsx,
prepareRepo: buildNodeRepoContext,
scanProject: scanNodeProject,
scan: (tree, repoContext, fileRel) => scanBundle(TSX_BUNDLE, tree, repoContext, fileRel),
};

View file

@ -181,5 +181,5 @@ export interface HttpLanguagePlugin {
* multiple files, such as Java controllers inheriting Spring mappings
* from annotated interfaces.
*/
scanProject?(files: readonly HttpScanInput[]): HttpFileDetections[];
scanProject?(files: readonly HttpScanInput[], repoContext?: RepoContext): HttpFileDetections[];
}

View file

@ -403,7 +403,7 @@ export class HttpRouteExtractor implements ContractExtractor {
{ plugin: HttpLanguagePlugin; input: HttpScanInput; repoContext: unknown } | null
>();
const projectDetections = new Map<string, HttpDetection[]>();
let projectScanComplete = false;
const completedProjectScans = new Set<NonNullable<HttpLanguagePlugin['scanProject']>>();
// Per-plugin cross-file context (e.g. Python's FastAPI router →
// include_router(prefix=...) map). Built lazily on first
@ -418,7 +418,7 @@ export class HttpRouteExtractor implements ContractExtractor {
if (!plugin || typeof plugin.prepareRepo !== 'function') return undefined;
if (repoContextByPlugin.has(plugin.name)) return repoContextByPlugin.get(plugin.name);
try {
const ctx = plugin.prepareRepo({
const ctx = await plugin.prepareRepo({
repoPath,
files: await getScannedFiles(),
parser,
@ -466,14 +466,21 @@ export class HttpRouteExtractor implements ContractExtractor {
};
const getDetections = async (rel: string): Promise<HttpDetection[]> => {
const cached = cachedDetections.get(rel);
// Graph enrichment needs composed providers too. Resolve only this
// project's plugin family; unrelated complete-coverage languages can
// still skip parsing when their graph handlers already resolved.
const projectScan = getPluginForFile(rel)?.scanProject;
if (projectScan && !completedProjectScans.has(projectScan))
await collectProjectDetections(await getScannedFiles(), projectScan);
const key = normalizeRepoRelPath(rel);
const cached = cachedDetections.get(key);
if (cached) return cached;
const scanInput = await getScanInput(rel);
const ownDetections = scanInput
? scanInput.plugin.scan(scanInput.input.tree, scanInput.repoContext, rel)
: [];
const detections = [...ownDetections, ...(projectDetections.get(rel) ?? [])];
cachedDetections.set(rel, detections);
const detections = [...ownDetections, ...(projectDetections.get(key) ?? [])];
cachedDetections.set(key, detections);
return detections;
};
@ -486,27 +493,41 @@ export class HttpRouteExtractor implements ContractExtractor {
return scannedFiles;
};
const collectProjectDetections = async (files: string[]): Promise<void> => {
if (projectScanComplete) return;
projectScanComplete = true;
const byPlugin = new Map<HttpLanguagePlugin, HttpScanInput[]>();
const collectProjectDetections = async (
files: string[],
onlyScan?: NonNullable<HttpLanguagePlugin['scanProject']>,
): Promise<void> => {
const byScan = new Map<
NonNullable<HttpLanguagePlugin['scanProject']>,
{ inputs: HttpScanInput[]; repoContext: unknown }
>();
for (const rel of files) {
const scanProject = getPluginForFile(rel)?.scanProject;
if (
!scanProject ||
completedProjectScans.has(scanProject) ||
(onlyScan && onlyScan !== scanProject)
)
continue;
const scanInput = await getScanInput(rel);
if (!scanInput?.plugin.scanProject) continue;
const items = byPlugin.get(scanInput.plugin) ?? [];
items.push(scanInput.input);
byPlugin.set(scanInput.plugin, items);
if (!scanInput) continue;
const group = byScan.get(scanProject) ?? { inputs: [], repoContext: scanInput.repoContext };
group.inputs.push(scanInput.input);
byScan.set(scanProject, group);
}
for (const [plugin, inputs] of byPlugin) {
const results = plugin.scanProject?.(inputs) ?? [];
// Shared function identity pools grammar variants without teaching the
// orchestrator which languages belong to the same project family.
for (const [scanProject, { inputs, repoContext }] of byScan) {
completedProjectScans.add(scanProject);
const results = scanProject(inputs, repoContext);
for (const result of results) {
const existing = projectDetections.get(result.filePath) ?? [];
projectDetections.set(result.filePath, [...existing, ...result.detections]);
const key = normalizeRepoRelPath(result.filePath);
const existing = projectDetections.get(key) ?? [];
projectDetections.set(key, [...existing, ...result.detections]);
cachedDetections.delete(key);
}
}
cachedDetections.clear();
};
const files = await getScannedFiles();

View file

@ -20,6 +20,7 @@ import type {
ScopeKind,
ScopeTree,
ParsedImport,
ParsedFile,
ParsedTypeBinding,
SymbolDefinition,
Callsite,
@ -605,6 +606,23 @@ interface LanguageProviderConfig {
lineOffset: number,
) => ExtractedDecoratorRoute[];
/** JSON-safe provider-owned facts captured from the existing AST and lexical
* identities. Shared parsing transports these without interpreting them. */
readonly extractRouteFacts?: (
tree: Parser.Tree,
parsedFile: ParsedFile,
lineOffset: number,
) => unknown;
/** Resolve project-wide provider facts before route winner selection. The
* callback uses the language's configured ScopeResolver module resolution. */
readonly resolveRouteFacts?: (
files: readonly { filePath: string; facts: unknown }[],
context: {
resolveImportTargets: (fromFile: string, moduleSpecifier: string) => readonly string[];
},
) => ExtractedDecoratorRoute[];
/** URL-bearing call candidates from the existing AST, before wrapper names
* are known across files. Routes selects known wrappers after parsing. */
readonly extractFetchWrapperCalls?: (

View file

@ -129,6 +129,8 @@ import { extractDispatchGuardRoutes } from '../route-extractors/dispatch-guard.j
import { extractDataRouteTableRoutes } from '../route-extractors/data-route-table.js';
import { extractNestRoutes } from '../route-extractors/nest.js';
import { extractFetchWrapperCalls } from '../route-extractors/fetch-wrapper.js';
import { extractFrameworkRouteFacts } from '../route-extractors/framework-route-facts.js';
import { resolveProjectFrameworkRouteFacts } from '../route-extractors/framework-route-paths.js';
import { extractTrpcRoutes, shouldScanForTrpcRoutes } from '../route-extractors/trpc.js';
import { extractConvexEndpointProperties } from './typescript/convex-endpoint-metadata.js';
import { extractToolDefinitions } from './typescript/tool-definitions.js';
@ -495,6 +497,8 @@ export const typescriptProvider = defineLanguage({
// to a literal; nothing else in this pipeline can see that shape. TS and JS
// share the grammar, so they share the extractor.
extractDecoratorRoutes: extractJsTsRoutes,
extractRouteFacts: extractFrameworkRouteFacts,
resolveRouteFacts: resolveProjectFrameworkRouteFacts,
extractFetchWrapperCalls,
// Content-based (not AST): tRPC procedure routers are scanned from source text.
// Path-gate lives here (language provider), not in the shared parse worker.
@ -582,6 +586,8 @@ export const javascriptProvider = defineLanguage({
arityCompatibility: jsArityCompatibility,
// See the TypeScript provider above.
extractDecoratorRoutes: extractJsTsRoutes,
extractRouteFacts: extractFrameworkRouteFacts,
resolveRouteFacts: resolveProjectFrameworkRouteFacts,
extractFetchWrapperCalls,
// Content-based (not AST): tRPC procedure routers are scanned from source text.
extractTextRoutes: extractJsTsTextRoutes,

View file

@ -40,6 +40,7 @@ export interface WorkerExtractedData {
fetchWrapperDefs: FetchWrapperDef[];
fetchWrapperCalls: FetchWrapperCall[];
decoratorRoutes: ExtractedDecoratorRoute[];
routeFacts?: { filePath: string; facts: unknown; routePosition: number }[];
routerIncludes: ExtractedRouterInclude[];
routerImports: ExtractedRouterImport[];
routerConstructorPrefixes: ExtractedRouterConstructorPrefix[];
@ -122,6 +123,7 @@ export const mergeChunkResults = (
const allFetchWrapperDefs: FetchWrapperDef[] = [];
const allFetchWrapperCalls: FetchWrapperCall[] = [];
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
const allRouteFacts: { filePath: string; facts: unknown; routePosition: number }[] = [];
const allRouterIncludes: ExtractedRouterInclude[] = [];
const allRouterImports: ExtractedRouterImport[] = [];
const allRouterConstructorPrefixes: ExtractedRouterConstructorPrefix[] = [];
@ -172,6 +174,11 @@ export const mergeChunkResults = (
for (const item of result.fetchCalls) allFetchCalls.push(item);
for (const item of result.fetchWrapperDefs ?? []) allFetchWrapperDefs.push(item);
for (const item of result.fetchWrapperCalls ?? []) allFetchWrapperCalls.push(item);
for (const item of result.routeFacts ?? [])
allRouteFacts.push({
...item,
routePosition: item.routePosition + allDecoratorRoutes.length,
});
for (const item of result.decoratorRoutes) allDecoratorRoutes.push(item);
for (const item of result.routerIncludes ?? []) allRouterIncludes.push(item);
for (const item of result.routerImports ?? []) allRouterImports.push(item);
@ -197,6 +204,7 @@ export const mergeChunkResults = (
fetchWrapperDefs: allFetchWrapperDefs,
fetchWrapperCalls: allFetchWrapperCalls,
decoratorRoutes: allDecoratorRoutes,
routeFacts: allRouteFacts,
routerIncludes: allRouterIncludes,
routerImports: allRouterImports,
routerConstructorPrefixes: allRouterConstructorPrefixes,

View file

@ -15,6 +15,7 @@
* @module
*/
import type { LanguageProvider } from '../language-provider.js';
import type { FetchWrapperCall } from '../route-extractors/fetch-wrapper.js';
import {
BindingAccumulator,
@ -778,6 +779,7 @@ export async function runChunkedParseAndResolve(
const allFetchWrapperCalls: FetchWrapperCall[] = [];
const allExtractedRoutes: ExtractedRoute[] = [];
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
const allRouteFacts: { filePath: string; facts: unknown; routePosition: number }[] = [];
const allRouterIncludes: ExtractedRouterInclude[] = [];
const allRouterImports: ExtractedRouterImport[] = [];
const allRouterConstructorPrefixes: ExtractedRouterConstructorPrefix[] = [];
@ -1004,6 +1006,11 @@ export async function runChunkedParseAndResolve(
if (chunkWorkerData.routes?.length) {
for (const item of chunkWorkerData.routes) allExtractedRoutes.push(item);
}
for (const item of chunkWorkerData.routeFacts ?? [])
allRouteFacts.push({
...item,
routePosition: item.routePosition + allDecoratorRoutes.length,
});
if (chunkWorkerData.decoratorRoutes?.length) {
for (const item of chunkWorkerData.decoratorRoutes) allDecoratorRoutes.push(item);
}
@ -1563,6 +1570,85 @@ export async function runChunkedParseAndResolve(
for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports);
}
const routeFilePaths = new Set(allPaths);
const routeResolutionConfigs = new Map<SupportedLanguages, unknown>();
for (const filePath of new Set(allRouteFacts.map((file) => file.filePath))) {
const language = getLanguageFromFilename(filePath);
if (language === null || routeResolutionConfigs.has(language)) continue;
const resolver = SCOPE_RESOLVERS.get(language);
routeResolutionConfigs.set(
language,
capturedResolutionConfigs?.has(language)
? capturedResolutionConfigs.get(language)
: resolver?.loadResolutionConfig === undefined
? undefined
: await resolver.loadResolutionConfig(repoPath),
);
}
let routeResolutionFiles = allParsedFiles;
const resolveRouteModuleTargets = (
fromFile: string,
moduleSpecifier: string,
parsedImport?: ParsedImport,
): readonly string[] => {
const language = getLanguageFromFilename(fromFile);
if (language === null) return [];
const target = SCOPE_RESOLVERS.get(language)?.resolveImportTarget(
moduleSpecifier,
fromFile,
routeFilePaths,
routeResolutionConfigs.get(language),
{ parsedFiles: routeResolutionFiles, ...(parsedImport ? { parsedImport } : {}) },
);
if (typeof target === 'string') return [target];
return target ?? [];
};
const resolveRouteImportTargets = (
parsedImport: ParsedImport,
fromFile: string,
): readonly string[] =>
resolveRouteModuleTargets(fromFile, parsedImport.targetRaw ?? '', parsedImport);
// A resolver's identity defines a fact family, including providers sharing
// one language implementation. Shared ingestion never inspects the payload.
const routeFactGroups = new Map<
NonNullable<LanguageProvider['resolveRouteFacts']>,
typeof allRouteFacts
>();
for (const file of allRouteFacts) {
const resolve = getProviderForFile(file.filePath)?.resolveRouteFacts;
if (!resolve) continue;
const group = routeFactGroups.get(resolve) ?? [];
group.push(file);
routeFactGroups.set(resolve, group);
}
const resolvedRoutesByFile = new Map<string, ExtractedDecoratorRoute[]>();
for (const [resolve, files] of routeFactGroups) {
for (const route of resolve(files, { resolveImportTargets: resolveRouteModuleTargets })) {
const routes = resolvedRoutesByFile.get(route.filePath) ?? [];
routes.push(route);
resolvedRoutesByFile.set(route.filePath, routes);
}
}
// Preserve the capture slot across sub-batch, chunk and cache boundaries.
// Insert before path expansion can change the decorator array's positions.
const routesAtPosition = new Map<number, ExtractedDecoratorRoute[]>();
for (const file of allRouteFacts) {
const routes = resolvedRoutesByFile.get(file.filePath);
if (!routes) continue;
const slot = routesAtPosition.get(file.routePosition) ?? [];
for (const route of routes) slot.push(route);
routesAtPosition.set(file.routePosition, slot);
resolvedRoutesByFile.delete(file.filePath);
}
const withResolvedRoutes: ExtractedDecoratorRoute[] = [];
for (let index = 0; index <= allDecoratorRoutes.length; index++) {
for (const route of routesAtPosition.get(index) ?? []) withResolvedRoutes.push(route);
if (index < allDecoratorRoutes.length) withResolvedRoutes.push(allDecoratorRoutes[index]);
}
allDecoratorRoutes.length = 0;
for (const route of withResolvedRoutes) allDecoratorRoutes.push(route);
// FastAPI router-prefix resolution (cross-file).
//
// #2391: resolve non-literal FastAPI decorator route paths (imported/composed
@ -1839,7 +1925,6 @@ export async function runChunkedParseAndResolve(
'parse-impl-return',
`exportedTypeMap=${exportedTypeMap.size} parsedFiles=${allParsedFiles.length} nodes=${graph.nodeCount}`,
);
const routeFilePaths = new Set(allPaths);
// Route files whose handlers resolve through imports: data route tables, and
// routes of a language that resolves its own handlers (`resolveRouteHandler`).
// Both need the file's parsed imports and its language's resolution config.
@ -1852,7 +1937,6 @@ export async function runChunkedParseAndResolve(
)
.map((route) => route.filePath),
);
const routeResolutionConfigs = new Map<SupportedLanguages, unknown>();
for (const filePath of dataRouteFilePaths) {
const language = getLanguageFromFilename(filePath);
if (language === null || routeResolutionConfigs.has(language)) continue;
@ -1861,28 +1945,9 @@ export async function runChunkedParseAndResolve(
language,
capturedResolutionConfigs?.has(language)
? capturedResolutionConfigs.get(language)
: resolver?.loadResolutionConfig === undefined
? undefined
: await resolver.loadResolutionConfig(repoPath),
: await resolver?.loadResolutionConfig?.(repoPath),
);
}
let routeResolutionFiles = allParsedFiles;
const resolveRouteImportTargets = (
parsedImport: ParsedImport,
fromFile: string,
): readonly string[] => {
const language = getLanguageFromFilename(fromFile);
if (language === null) return [];
const target = SCOPE_RESOLVERS.get(language)?.resolveImportTarget(
parsedImport.targetRaw ?? '',
fromFile,
routeFilePaths,
routeResolutionConfigs.get(language),
{ parsedFiles: routeResolutionFiles, parsedImport },
);
if (typeof target === 'string') return [target];
return target ?? [];
};
const resolveRouteImportTarget = (
parsedImport: ParsedImport,
fromFile: string,

View file

@ -0,0 +1,396 @@
import type Parser from 'tree-sitter';
import { buildPositionIndex, buildScopeTree, lookupLexicalName } from 'gitnexus-shared';
import type { ParsedFile, ParsedImport } from 'gitnexus-shared';
import { plainString } from './data-route-table.js';
import type { FrameworkExpression, FrameworkRouteFacts } from './framework-route-types.js';
type Node = Parser.SyntaxNode;
const UNKNOWN: FrameworkExpression = { kind: 'unknown' };
const HANDLER: FrameworkExpression = { kind: 'handler' };
const METHODS = new Set(['get', 'post', 'put', 'delete', 'patch', 'all', 'route', 'use']);
const children = (node: Node | null | undefined): Node[] =>
node?.namedChildren.filter((child) => child.type !== 'comment') ?? [];
const args = (node: Node): Node[] => children(node.childForFieldName('arguments'));
function literalPaths(node: Node | undefined): string[] | null {
if (!node) return null;
const value = plainString(node);
if (value !== null) return value.startsWith('/') ? [value] : null;
if (node.type !== 'array') return null;
const paths = children(node).map((child) => plainString(child));
return paths.length > 0 && paths.every((path) => path !== null && path.startsWith('/'))
? [...new Set(paths as string[])]
: null;
}
/**
* Reuses the parser's AST and ParsedFile lexical ownership. The only additional
* data is framework value flow; declarations/scopes are never reconstructed.
*/
export function extractFrameworkRouteFacts(
tree: Parser.Tree,
parsedFile: ParsedFile,
lineOffset = 0,
): FrameworkRouteFacts {
const filePath = parsedFile.filePath;
const facts: FrameworkRouteFacts = {
filePath,
bindings: [],
exports: [],
starExports: [],
invalidBindings: [],
mutations: [],
calls: [],
};
const scopes = buildScopeTree(parsedFile.scopes);
const positions = buildPositionIndex(parsedFile.scopes);
const invalid = new Set<string>();
const importsAtPosition = new Map<string, ParsedImport[]>();
for (const imported of parsedFile.parsedImports) {
const key = `${imported.atRange.startLine}:${imported.atRange.startCol}`;
const imports = importsAtPosition.get(key) ?? [];
imports.push(imported);
importsAtPosition.set(key, imports);
}
const expressionCache = new Map<number, FrameworkExpression>();
const bindingKey = (scope: string, name: string): string => `${scope}\0${name}`;
const ownership = (node: Node, name = node.text, atUse = false) => {
const scope = positions.atPosition(
filePath,
node.startPosition.row + 1 + lineOffset,
node.startPosition.column,
);
return scope === undefined
? undefined
: lookupLexicalName(
scope,
name,
{ scopes },
{
purpose: 'value',
...(atUse
? {
position: {
startLine: node.startPosition.row + 1 + lineOffset,
startCol: node.startPosition.column,
},
}
: {}),
},
);
};
const bindingId = (node: Node, name = node.text, atUse = false): string | undefined => {
const owner = ownership(node, name, atUse);
// Pre-finalization imports and untyped parameters deliberately have no
// BindingRef. A future binding claim, however, is a real TDZ barrier.
if (
atUse &&
owner?.status === 'blocked' &&
owner.claims.some(
(claim) =>
claim.kind === 'blocked' ||
(claim.kind === 'binding' &&
claim.availableFrom &&
(claim.availableFrom.startLine > node.startPosition.row + 1 + lineOffset ||
(claim.availableFrom.startLine === node.startPosition.row + 1 + lineOffset &&
claim.availableFrom.startCol > node.startPosition.column))),
)
)
return undefined;
return owner?.scope ? bindingKey(owner.scope.id, name) : undefined;
};
const isGlobal = (node: Node, name: string) => ownership(node, name)?.status === 'absent';
const expression = (node: Node | null | undefined, depth = 0): FrameworkExpression => {
if (!node || depth > 64 || node.hasError) return UNKNOWN;
const cached = expressionCache.get(node.id);
if (cached) return cached;
let value: FrameworkExpression = UNKNOWN;
if (node.type === 'identifier' || node.type === 'shorthand_property_identifier') {
const id = bindingId(node, node.text, true);
if (id) value = { kind: 'binding', id };
} else if (node.type === 'array') {
value = {
kind: 'array',
elements: children(node).map((child) => expression(child, depth + 1)),
};
} else if (
[
'parenthesized_expression',
'as_expression',
'satisfies_expression',
'non_null_expression',
].includes(node.type)
) {
value = expression(children(node)[0], depth + 1);
} else if (
[
'arrow_function',
'function_expression',
'function_declaration',
'generator_function_declaration',
].includes(node.type)
) {
value = HANDLER;
} else if (node.type === 'member_expression') {
const name = node.childForFieldName('property');
if (name?.type === 'property_identifier')
value = {
kind: 'member',
object: expression(node.childForFieldName('object'), depth + 1),
name: name.text,
};
} else if (node.type === 'call_expression' || node.type === 'new_expression') {
const callee = node.childForFieldName(
node.type === 'new_expression' ? 'constructor' : 'function',
);
const arguments_ = args(node);
const source = arguments_[0] && plainString(arguments_[0]);
if (
callee?.type === 'identifier' &&
callee.text === 'require' &&
isGlobal(callee, 'require') &&
source &&
arguments_.length === 1
) {
value = { kind: 'import', source, name: 'require' };
} else {
value = {
kind: 'call',
id: `${filePath}:${node.startIndex}`,
callee: expression(callee, depth + 1),
construct: node.type === 'new_expression',
argumentCount: arguments_.length,
paths: literalPaths(arguments_[0]),
};
}
}
expressionCache.set(node.id, value);
return value;
};
const bind = (node: Node, value: FrameworkExpression): void => {
if (node.type === 'identifier' || node.type === 'shorthand_property_identifier_pattern') {
const id = bindingId(node);
if (id) facts.bindings.push({ id, value });
} else if (node.type === 'object_pattern') {
for (const part of children(node)) {
const key = part.type === 'pair_pattern' ? part.childForFieldName('key') : part;
const target = part.type === 'pair_pattern' ? part.childForFieldName('value') : part;
if (
key &&
target &&
['identifier', 'property_identifier', 'shorthand_property_identifier_pattern'].includes(
key.type,
)
) {
bind(target, { kind: 'member', object: value, name: key.text });
}
}
}
};
const exported = (name: string, value: FrameworkExpression) =>
facts.exports.push({ name, value });
let cjsDefaultAssignments = 0;
let cjsShortcutDetached = false;
let cjsUnknownWrite = false;
const cjsShortcutExports: { name: string; value: FrameworkExpression }[] = [];
const cjsExports: { name: string; value: FrameworkExpression }[] = [];
const nodes: Node[] = [tree.rootNode];
while (nodes.length) {
const node = nodes.pop()!;
if (node.type === 'ERROR') continue;
nodes.push(...children(node).reverse());
if (node.type === 'import_statement') {
// ESM decomposition already carries precise aliases/type-only flags. CJS
// imports are intentionally handled at their AST calls to guard require.
const position = `${node.startPosition.row + 1 + lineOffset}:${node.startPosition.column}`;
for (const imported of importsAtPosition.get(position) ?? []) {
if (!('localName' in imported) || !('importedName' in imported)) continue;
const id = bindingKey(
imported.declaredAtScope ?? parsedFile.moduleScope,
imported.localName,
);
facts.bindings.push({
id,
value:
'typeOnly' in imported && imported.typeOnly
? UNKNOWN
: {
kind: 'import',
source: imported.targetRaw,
name: imported.kind === 'namespace' ? '*' : imported.importedName,
},
});
}
} else if (node.type === 'variable_declarator') {
const name = node.childForFieldName('name');
if (name) bind(name, expression(node.childForFieldName('value')));
} else if (
node.type === 'function_declaration' ||
node.type === 'generator_function_declaration'
) {
const name = node.childForFieldName('name');
if (name) bind(name, HANDLER);
} else if (node.type === 'export_statement') {
const sourceNode = node.childForFieldName('source');
const source = sourceNode && plainString(sourceNode);
if (node.children.some((child) => child.type === 'type')) continue;
const clause = children(node).find((child) => child.type === 'export_clause');
if (clause) {
for (const specifier of children(clause)) {
if (specifier.children.some((child) => child.type === 'type')) continue;
const name = specifier.childForFieldName('name');
const alias = specifier.childForFieldName('alias');
if (name)
exported(
alias?.text ?? name.text,
source ? { kind: 'import', source, name: name.text } : expression(name),
);
}
} else if (node.children.some((child) => child.type === 'default')) {
exported(
'default',
expression(
node.childForFieldName('value') ??
node.childForFieldName('declaration') ??
children(node)[0],
),
);
} else if (source) {
const namespace = children(node).find((child) => child.type === 'namespace_export');
const name = namespace && children(namespace)[0];
if (name) exported(name.text, { kind: 'import', source, name: '*' });
else if (!node.hasError) facts.starExports.push(source);
} else {
const declaration = node.childForFieldName('declaration');
if (
declaration?.type === 'lexical_declaration' ||
declaration?.type === 'variable_declaration'
) {
for (const declarator of children(declaration)) {
const name = declarator.childForFieldName('name');
if (name?.type === 'identifier') {
const id = bindingId(name);
if (id) exported(name.text, { kind: 'binding', id });
}
}
} else {
const name = declaration?.childForFieldName('name');
if (name) exported(name.text, expression(name));
}
}
} else if (
node.type === 'assignment_expression' ||
node.type === 'augmented_assignment_expression' ||
node.type === 'update_expression' ||
node.type === 'for_in_statement' ||
(node.type === 'unary_expression' && node.children.some((child) => child.type === 'delete'))
) {
const left = node.childForFieldName('left') ?? node.childForFieldName('argument');
const right = node.childForFieldName('right');
const object = left?.childForFieldName('object');
const property = left?.childForFieldName('property');
if (left?.type === 'identifier' && left.text === 'exports' && isGlobal(left, 'exports'))
cjsShortcutDetached = true;
const moduleExports = (candidate: Node | null | undefined): boolean =>
candidate?.type === 'member_expression' &&
candidate.childForFieldName('object')?.text === 'module' &&
candidate.childForFieldName('property')?.text === 'exports' &&
isGlobal(candidate, 'module');
const writesExportProperty =
moduleExports(object) || (object?.text === 'exports' && isGlobal(object, 'exports'));
if (
(left?.type === 'subscript_expression' && writesExportProperty) ||
(node.type !== 'assignment_expression' && (moduleExports(left) || writesExportProperty))
)
cjsUnknownWrite = true;
if (node.type === 'assignment_expression' && moduleExports(left)) {
cjsDefaultAssignments++;
cjsExports.length = 0;
if (right?.type === 'object') {
cjsExports.push({ name: 'default', value: UNKNOWN });
for (const part of children(right)) {
const key = part.type === 'pair' ? part.childForFieldName('key') : part;
const value = part.type === 'pair' ? part.childForFieldName('value') : part;
if (
key &&
['property_identifier', 'identifier', 'shorthand_property_identifier'].includes(
key.type,
)
) {
cjsExports.push({ name: key.text, value: expression(value) });
} else {
// A spread, computed key, or other unsupported member can replace
// an earlier explicit export. Never retain a partial object shape.
cjsUnknownWrite = true;
}
}
} else cjsExports.push({ name: 'default', value: expression(right) });
} else if (
node.type === 'assignment_expression' &&
left?.type === 'member_expression' &&
property?.type === 'property_identifier' &&
writesExportProperty
) {
const record = { name: property.text, value: expression(right) };
if (object?.text === 'exports') {
if (!cjsShortcutDetached) cjsShortcutExports.push(record);
} else {
// A replacement module.exports can itself be a Router. Writing one of
// its properties mutates that allocation, not only the export table.
const defaultValue = cjsExports.find((entry) => entry.name === 'default')?.value;
if (defaultValue) facts.mutations.push(defaultValue);
cjsExports.push(record);
}
} else if (left) {
const invalidate = (target: Node) => {
if (
target.type === 'identifier' ||
target.type === 'shorthand_property_identifier_pattern'
) {
const id = bindingId(target);
if (id) invalid.add(id);
} else if (
target.type === 'member_expression' ||
target.type === 'subscript_expression'
) {
let receiver = target.childForFieldName('object');
while (receiver) {
// A namespace member can resolve to an exported allocation even
// when the namespace itself is only a module value.
facts.mutations.push(expression(receiver));
if (receiver.type !== 'member_expression' && receiver.type !== 'subscript_expression')
break;
receiver = receiver.childForFieldName('object');
}
} else for (const child of children(target)) invalidate(child);
};
invalidate(left);
}
} else if (node.type === 'call_expression') {
const callee = node.childForFieldName('function');
const method = callee?.childForFieldName('property')?.text;
if (callee?.type === 'member_expression' && method && METHODS.has(method)) {
const arguments_ = args(node);
const handler = arguments_.at(-1);
facts.calls.push({
receiver: expression(callee.childForFieldName('object')),
method,
arguments: arguments_.map((argument) => expression(argument)),
paths: literalPaths(arguments_[0]),
lineNumber: node.startPosition.row + lineOffset,
...(handler?.type === 'identifier' ? { handlerName: handler.text } : {}),
});
}
}
}
// exports is only the original module.exports alias. A replacement breaks
// that alias; do not attribute its writes to the replacement object.
if (!cjsUnknownWrite) {
if (cjsDefaultAssignments === 0) facts.exports.push(...cjsShortcutExports);
if (cjsDefaultAssignments <= 1) facts.exports.push(...cjsExports);
}
facts.invalidBindings = [...invalid];
return facts;
}

View file

@ -0,0 +1,128 @@
import type { LanguageProvider } from '../language-provider.js';
import { resolveFrameworkRouteFacts } from './framework-route-resolver.js';
import { normalizeExtractedRoutePath } from './route-path.js';
import type {
FrameworkImportResolver,
FrameworkRoute,
FrameworkRouteFacts,
ResolvedFrameworkRoutes,
} from './framework-route-types.js';
/** Each connected mount component either finishes or contributes no routes. */
export const FRAMEWORK_MOUNT_EXPANSION_LIMIT = 10_000;
export function composeFrameworkRoutePaths(
resolved: ResolvedFrameworkRoutes,
expansionLimit = FRAMEWORK_MOUNT_EXPANSION_LIMIT,
): FrameworkRoute[] {
const outgoing = new Map<string, typeof resolved.mounts>();
const neighbors = new Map<string, Set<string>>();
const incoming = new Set<string>();
for (const mount of resolved.mounts) {
incoming.add(mount.childId); // Dynamic mounts also suppress standalone fallback.
const edges = outgoing.get(mount.parentId) ?? [];
edges.push(mount);
outgoing.set(mount.parentId, edges);
for (const [from, to] of [
[mount.parentId, mount.childId],
[mount.childId, mount.parentId],
]) {
const adjacent = neighbors.get(from) ?? new Set<string>();
adjacent.add(to);
neighbors.set(from, adjacent);
}
}
const prefixes = new Map<string, Set<string>>();
const seen = new Set<string>();
for (const allocation of resolved.allocations) {
if (seen.has(allocation.id) || allocation.kind === 'hono') continue;
const component = new Set<string>();
const pending = [allocation.id];
while (pending.length) {
const id = pending.pop()!;
if (component.has(id)) continue;
component.add(id);
seen.add(id);
for (const neighbor of neighbors.get(id) ?? []) pending.push(neighbor);
}
const roots = [...component].filter((id) => !incoming.has(id)).sort();
const componentPrefixes = new Map<string, Set<string>>();
const stack = roots.map((id) => ({ id, prefix: '', visiting: new Set<string>() }));
let steps = 0;
let exhausted = false;
while (stack.length) {
if (++steps > expansionLimit) {
exhausted = true;
break;
}
const current = stack.pop()!;
if (current.visiting.has(current.id)) continue;
const paths = componentPrefixes.get(current.id) ?? new Set<string>();
paths.add(current.prefix);
componentPrefixes.set(current.id, paths);
const visiting = new Set(current.visiting).add(current.id);
for (const mount of outgoing.get(current.id) ?? []) {
if (visiting.has(mount.childId)) continue;
for (const prefix of mount.prefixes ?? []) {
// Count queued work too, so a large path array cannot allocate an
// unbounded frontier before the next expansion checks its budget.
if (steps + stack.length >= expansionLimit) {
exhausted = true;
break;
}
stack.push({
id: mount.childId,
prefix: normalizeExtractedRoutePath(prefix, current.prefix),
visiting,
});
}
if (exhausted) break;
}
if (exhausted) break;
}
if (!exhausted) for (const [id, paths] of componentPrefixes) prefixes.set(id, paths);
}
const routes = new Map<string, FrameworkRoute>();
for (const registration of resolved.registrations) {
const paths =
registration.framework === 'hono' ? [''] : (prefixes.get(registration.receiverId) ?? []);
for (const prefix of paths) {
const route = {
...registration.route,
routePath: normalizeExtractedRoutePath(registration.route.routePath, prefix),
};
const key = JSON.stringify([
route.filePath,
route.lineNumber,
route.httpMethod,
route.routePath,
route.handlerName,
]);
routes.set(key, route);
}
}
return [...routes.values()].sort(
(left, right) =>
left.filePath.localeCompare(right.filePath) ||
left.lineNumber - right.lineNumber ||
left.routePath.localeCompare(right.routePath) ||
(left.httpMethod ?? '').localeCompare(right.httpMethod ?? ''),
);
}
/** Shared ingestion/group entry point. Inputs are already parsed provider facts. */
export function resolveFrameworkRoutePaths(
files: readonly FrameworkRouteFacts[],
resolveImportTargets: FrameworkImportResolver,
): FrameworkRoute[] {
return composeFrameworkRoutePaths(resolveFrameworkRouteFacts(files, resolveImportTargets));
}
/** The same function identity intentionally groups JS/TS/TSX facts together. */
export const resolveProjectFrameworkRouteFacts: NonNullable<
LanguageProvider['resolveRouteFacts']
> = (files, context) =>
resolveFrameworkRoutePaths(
files.map(({ facts }) => facts as FrameworkRouteFacts),
context.resolveImportTargets,
);

View file

@ -0,0 +1,384 @@
import type {
FrameworkAllocation,
FrameworkCallFact,
FrameworkExpression,
FrameworkImportResolver,
FrameworkMount,
FrameworkRouteFacts,
ResolvedFrameworkRoutes,
} from './framework-route-types.js';
const VERBS = new Set(['get', 'post', 'put', 'delete', 'patch', 'all']);
const MAX_RESOLUTION_DEPTH = 128;
const MAX_RESOLUTION_STEPS = 10_000;
type Value =
| { kind: 'factory'; framework: 'express' | 'router' | 'hono' }
| { kind: 'module'; filePath: string }
| { kind: 'instance'; allocation: FrameworkAllocation }
| { kind: 'builder'; receiverId: string; paths: string[] }
| { kind: 'array'; elements: (Value | undefined)[] }
| { kind: 'handler' };
type ExportResult = { found: boolean; value?: Value };
const keyOf = (value: Value): string => {
switch (value.kind) {
case 'factory':
return `factory:${value.framework}`;
case 'module':
return `module:${value.filePath}`;
case 'instance':
return `instance:${value.allocation.id}`;
case 'builder':
return `builder:${value.receiverId}:${JSON.stringify(value.paths)}`;
case 'array':
return `array:${value.elements.map((element) => element && keyOf(element)).join(',')}`;
case 'handler':
return 'handler';
}
};
/** Resolve explicit static provenance without parsing, filesystem guesses, or execution. */
export function resolveFrameworkRouteFacts(
files: readonly FrameworkRouteFacts[],
resolveImportTargets: FrameworkImportResolver,
): ResolvedFrameworkRoutes {
const byFile = new Map(files.map((file) => [file.filePath, file]));
const bindings = new Map<string, { filePath: string; values: FrameworkExpression[] }>();
const invalidBindings = new Set(files.flatMap((file) => file.invalidBindings));
for (const file of files)
for (const binding of file.bindings) {
const entry = bindings.get(binding.id) ?? { filePath: file.filePath, values: [] };
entry.values.push(binding.value);
bindings.set(binding.id, entry);
}
const allocations = new Map<string, FrameworkAllocation>();
const cache = new Map<string, Value>();
let remainingSteps = MAX_RESOLUTION_STEPS;
const invalidValues = new Set<string>();
const moduleTargets = new Map<string, readonly string[]>();
const targetFor = (filePath: string, source: string): string | undefined => {
const key = `${filePath}\0${source}`;
if (!moduleTargets.has(key)) {
moduleTargets.set(key, [...new Set(resolveImportTargets(filePath, source))]);
}
const targets = moduleTargets.get(key)!;
return targets.length === 1 && byFile.has(targets[0]) ? targets[0] : undefined;
};
const exportValue = (
filePath: string,
name: string,
visiting: Set<string>,
depth: number,
): ExportResult => {
const key = `export:${filePath}\0${name}`;
// Unknown due to a bound must compete with other star candidates; treating
// it as absent could publish a winner from an incompletely explored barrel.
if (--remainingSteps <= 0 || depth > MAX_RESOLUTION_DEPTH) return { found: true };
if (visiting.has(key)) return { found: false };
const file = byFile.get(filePath);
if (!file) return { found: false };
const direct = file.exports.filter((entry) => entry.name === name);
const next = new Set(visiting).add(key);
if (direct.length)
return {
found: true,
...(direct.length === 1
? { value: evaluate(direct[0].value, filePath, next, depth + 1) }
: {}),
};
// A star export never republishes default. Explicit exports above win.
if (name === 'default') return { found: false };
const candidates: ExportResult[] = [];
for (const source of file.starExports) {
const target = targetFor(filePath, source);
if (!target) {
candidates.push({ found: true });
continue;
}
const candidate = exportValue(target, name, next, depth + 1);
if (candidate.found) candidates.push(candidate);
}
if (remainingSteps <= 0) return { found: true };
if (!candidates.length) return { found: false };
const first = candidates[0].value;
return {
found: true,
value:
first &&
candidates.every((candidate) => candidate.value && keyOf(candidate.value) === keyOf(first))
? first
: undefined,
};
};
const imported = (
filePath: string,
source: string,
name: string,
visiting: Set<string>,
depth: number,
): Value | undefined => {
// Configured repository targets are authoritative even when their module
// specifier matches a framework package name (e.g. a tsconfig path alias).
const target = targetFor(filePath, source);
if (!target) {
if (moduleTargets.get(`${filePath}\0${source}`)?.length) return undefined;
if (source === 'express') {
if (name === 'Router') return { kind: 'factory', framework: 'router' };
if (name === 'default' || name === '*' || name === 'require')
return { kind: 'factory', framework: 'express' };
return undefined;
}
if (source === 'hono') {
if (name === 'Hono') return { kind: 'factory', framework: 'hono' };
if (name === '*' || name === 'require') return { kind: 'module', filePath: 'package:hono' };
return undefined;
}
return undefined;
}
if (name === '*') return { kind: 'module', filePath: target };
if (name === 'require') {
return (
exportValue(target, 'default', visiting, depth + 1).value ?? {
kind: 'module',
filePath: target,
}
);
}
return exportValue(target, name, visiting, depth + 1).value;
};
const member = (
value: Value | undefined,
name: string,
visiting: Set<string>,
depth: number,
): Value | undefined => {
if (value?.kind === 'factory' && value.framework === 'express') {
if (name === 'Router') return { kind: 'factory', framework: 'router' };
if (name === 'default') return value;
}
if (value?.kind === 'module') {
if (value.filePath === 'package:hono')
return name === 'Hono' ? { kind: 'factory', framework: 'hono' } : undefined;
return exportValue(value.filePath, name, visiting, depth + 1).value;
}
return undefined;
};
const evaluate = (
expression: FrameworkExpression,
filePath: string,
visiting = new Set<string>(),
depth = 0,
): Value | undefined => {
if (depth === 0) remainingSteps = MAX_RESOLUTION_STEPS;
if (--remainingSteps <= 0 || depth > MAX_RESOLUTION_DEPTH) return undefined;
let value: Value | undefined;
switch (expression.kind) {
case 'unknown':
return undefined;
case 'handler':
return { kind: 'handler' };
case 'array':
return {
kind: 'array',
elements: expression.elements.map((element) =>
evaluate(element, filePath, visiting, depth + 1),
),
};
case 'binding': {
if (invalidBindings.has(expression.id) || visiting.has(expression.id)) return undefined;
const cached = cache.get(expression.id);
if (cached) return invalidValues.has(keyOf(cached)) ? undefined : cached;
const binding = bindings.get(expression.id);
if (!binding || binding.values.length !== 1) return undefined;
value = evaluate(
binding.values[0],
binding.filePath,
new Set(visiting).add(expression.id),
depth + 1,
);
if (value) cache.set(expression.id, value);
break;
}
case 'import':
value = imported(filePath, expression.source, expression.name, visiting, depth + 1);
break;
case 'member':
value = member(
evaluate(expression.object, filePath, visiting, depth + 1),
expression.name,
visiting,
depth + 1,
);
break;
case 'call': {
// A fluent call's receiver can itself be a call chain. Evaluate it once
// for both member lookup and fluent propagation to avoid exponential work.
const memberCallee = expression.callee.kind === 'member' ? expression.callee : undefined;
const receiver = memberCallee
? evaluate(memberCallee.object, filePath, visiting, depth + 2)
: undefined;
const callee = memberCallee
? member(receiver, memberCallee.name, visiting, depth + 2)
: evaluate(expression.callee, filePath, visiting, depth + 1);
if (remainingSteps <= 0 || (callee && invalidValues.has(keyOf(callee)))) return undefined;
if (callee?.kind === 'factory' && (callee.framework !== 'hono' || expression.construct)) {
const allocation: FrameworkAllocation = {
id: expression.id,
kind: callee.framework === 'express' ? 'app' : callee.framework,
};
value = { kind: 'instance', allocation };
if (!invalidValues.has(keyOf(value))) allocations.set(allocation.id, allocation);
} else if (memberCallee && !expression.construct) {
const method = memberCallee.name;
if (
receiver?.kind === 'instance' &&
receiver.allocation.kind !== 'hono' &&
method === 'route' &&
expression.paths
) {
value = {
kind: 'builder',
receiverId: receiver.allocation.id,
paths: expression.paths,
};
} else if (
receiver?.kind === 'builder' &&
VERBS.has(method) &&
expression.argumentCount >= 1
)
value = receiver;
else if (
receiver?.kind === 'instance' &&
VERBS.has(method) &&
expression.argumentCount >= 2 &&
expression.paths
)
value = receiver;
else if (
receiver?.kind === 'instance' &&
receiver.allocation.kind !== 'hono' &&
method === 'use' &&
expression.argumentCount >= 1
)
value = receiver;
}
}
}
return value && remainingSteps > 0 && !invalidValues.has(keyOf(value)) ? value : undefined;
};
const flatten = (value: Value | undefined): (Value | undefined)[] =>
value?.kind === 'array' ? value.elements.flatMap(flatten) : [value];
// Resolve all targets before poisoning any value: invalidating a namespace
// must not hide a later mutation of another Router exported by that module.
const mutatedValues = files.flatMap((file) =>
file.mutations.map((mutation) => evaluate(mutation, file.filePath)),
);
// Retain proven incoming mounts before invalidation. An uncertain parent
// cannot justify a path, but its child must not become a standalone Router.
const uncertainMounts = new Map<FrameworkCallFact, FrameworkMount[]>();
if (mutatedValues.some((value) => value !== undefined))
for (const file of files)
for (const call of file.calls) {
if (call.method !== 'use') continue;
const parent = evaluate(call.receiver, file.filePath);
if (parent?.kind !== 'instance' || parent.allocation.kind === 'hono') continue;
const mounts: FrameworkMount[] = [];
for (const argument of call.arguments)
for (const child of flatten(evaluate(argument, file.filePath)))
if (child?.kind === 'instance' && child.allocation.kind !== 'hono')
mounts.push({
parentId: parent.allocation.id,
childId: child.allocation.id,
prefixes: null,
});
if (mounts.length) uncertainMounts.set(call, mounts);
}
for (const value of mutatedValues)
if (value) {
invalidValues.add(keyOf(value));
if (value.kind === 'factory' && value.framework === 'express')
invalidValues.add('factory:router');
}
cache.clear();
allocations.clear();
const result: ResolvedFrameworkRoutes = { allocations: [], registrations: [], mounts: [] };
for (const file of files) {
for (const binding of file.bindings)
evaluate({ kind: 'binding', id: binding.id }, file.filePath);
for (const call of file.calls) {
const receiver = evaluate(call.receiver, file.filePath);
if (!receiver) {
result.mounts.push(...(uncertainMounts.get(call) ?? []));
continue;
}
if (
call.method === 'use' &&
receiver.kind === 'instance' &&
receiver.allocation.kind !== 'hono'
) {
const values = call.arguments.flatMap((argument) =>
flatten(evaluate(argument, file.filePath)),
);
// An invalidated argument can hide a known child even when its parent
// still resolves. Keep that incoming edge without asserting a path.
if (values.some((value) => value === undefined))
result.mounts.push(...(uncertainMounts.get(call) ?? []));
const first = values[0];
const prefixes =
call.paths ??
(first?.kind === 'handler' ||
(first?.kind === 'instance' && first.allocation.kind !== 'hono')
? ['']
: null);
for (const child of values)
if (child?.kind === 'instance' && child.allocation.kind !== 'hono') {
result.mounts.push({
parentId: receiver.allocation.id,
childId: child.allocation.id,
prefixes,
});
}
continue;
}
if (!VERBS.has(call.method)) continue;
const builder = receiver.kind === 'builder';
if (receiver.kind !== 'instance' && !builder) continue;
if (call.arguments.length < (builder ? 1 : 2)) continue;
const paths = builder ? receiver.paths : call.paths;
if (!paths) continue;
const framework =
receiver.kind === 'instance' && receiver.allocation.kind === 'hono' ? 'hono' : 'express';
// The shared symbol model resolves a bare name only within this file.
// Retain that evidence only for the exact lexical callable declaration;
// parameters, imported aliases and unknown values must not borrow a
// same-named function elsewhere in the file.
const handler = call.arguments.at(-1);
const handlerBinding = handler?.kind === 'binding' ? bindings.get(handler.id) : undefined;
const handlerName =
call.handlerName &&
handlerBinding?.filePath === file.filePath &&
handlerBinding.values.length === 1 &&
handlerBinding.values[0].kind === 'handler' &&
handler?.kind === 'binding' &&
evaluate(handler, file.filePath)?.kind === 'handler'
? call.handlerName
: undefined;
for (const routePath of paths)
result.registrations.push({
receiverId: builder ? receiver.receiverId : receiver.allocation.id,
framework,
route: {
frameworkCall: call,
filePath: file.filePath,
routePath,
httpMethod: call.method === 'all' ? '*' : call.method.toUpperCase(),
decoratorName: `${framework}.${call.method}`,
lineNumber: call.lineNumber,
...(handlerName ? { handlerName } : {}),
},
});
}
}
result.allocations = [...allocations.values()];
return result;
}

View file

@ -0,0 +1,74 @@
import type { ExtractedDecoratorRoute } from '../workers/parse-worker.js';
/** Provider-private facts. Only JSON values cross the worker/cache boundary. */
export type FrameworkExpression =
| { kind: 'unknown' }
| { kind: 'handler' }
| { kind: 'array'; elements: FrameworkExpression[] }
| { kind: 'binding'; id: string }
| { kind: 'import'; source: string; name: string }
| { kind: 'member'; object: FrameworkExpression; name: string }
| {
kind: 'call';
id: string;
callee: FrameworkExpression;
construct: boolean;
argumentCount: number;
paths: string[] | null;
};
export interface FrameworkCallFact {
receiver: FrameworkExpression;
method: string;
arguments: FrameworkExpression[];
paths: string[] | null;
lineNumber: number;
handlerName?: string;
}
export interface FrameworkRouteFacts {
filePath: string;
bindings: { id: string; value: FrameworkExpression }[];
exports: { name: string; value: FrameworkExpression }[];
starExports: string[];
invalidBindings: string[];
/** Property writes invalidate the value behind aliases, including imports. */
mutations: FrameworkExpression[];
calls: FrameworkCallFact[];
}
export interface FrameworkAllocation {
id: string;
kind: 'app' | 'router' | 'hono';
}
/** Source registration identity survives mount expansion for provider adapters. */
export interface FrameworkRoute extends ExtractedDecoratorRoute {
frameworkCall?: FrameworkCallFact;
}
export interface FrameworkRegistration {
receiverId: string;
framework: 'express' | 'hono';
route: FrameworkRoute;
}
export interface FrameworkMount {
parentId: string;
childId: string;
/** null records an incoming mount whose path cannot be established. */
prefixes: string[] | null;
}
/** Resolved provenance, ready for mount composition before emission. */
export interface ResolvedFrameworkRoutes {
allocations: FrameworkAllocation[];
registrations: FrameworkRegistration[];
mounts: FrameworkMount[];
}
/** Existing ScopeResolver configuration remains the authority for module targets. */
export type FrameworkImportResolver = (
fromFile: string,
moduleSpecifier: string,
) => readonly string[];

View file

@ -129,11 +129,7 @@ import {
type SyntaxNode,
} from '../utils/ast-helpers.js';
import { isPositionQualifiedLocalLabel } from '../utils/callable-labels.js';
import {
countCallArguments,
extractCallArgTypes,
type MixedChainStep,
} from '../utils/call-analysis.js';
import { extractCallArgTypes, type MixedChainStep } from '../utils/call-analysis.js';
import { buildTypeEnv } from '../type-env.js';
import type { ConstructorBinding } from '../type-env.js';
import { detectFrameworkFromAST } from '../framework-detection.js';
@ -508,6 +504,8 @@ export interface ParseWorkerResult {
/** URL-bearing candidates, kept separate from confirmed outward-call sinks. */
fetchWrapperCalls?: FetchWrapperCall[];
decoratorRoutes: ExtractedDecoratorRoute[];
/** JSON-safe per-file provider facts; relationships are resolved on every run. */
routeFacts?: { filePath: string; facts: unknown; routePosition: number }[];
routerIncludes: ExtractedRouterInclude[];
routerImports: ExtractedRouterImport[];
routerConstructorPrefixes?: ExtractedRouterConstructorPrefix[];
@ -1403,18 +1401,6 @@ const processBatch = (
return result;
};
// Express/Hono method names that register routes
const EXPRESS_ROUTE_METHODS = new Set([
'get',
'post',
'put',
'delete',
'patch',
'all',
'use',
'route',
]);
/**
* Walk a tree-sitter AST subtree looking for a call to the global `fetch()` function.
* Returns `true` if found within `maxDepth` levels of nesting — keeps the check
@ -1434,32 +1420,10 @@ const checkForFetchCall = (node: SyntaxNode, depth = 0, maxDepth = 5): boolean =
// HTTP client methods that are ONLY used by clients, not Express route registration.
// Methods like get/post/put/delete/patch overlap with Express — those are captured by
// the express_route handler as route definitions, not consumers. The fetch() global
// provider route facts as registrations, not consumers. The fetch() global
// function is captured separately by the route.fetch query.
const HTTP_CLIENT_ONLY_METHODS = new Set(['head', 'options', 'request', 'ajax']);
// Known HTTP client receivers u2014 skip these, they're API consumers not routes
const HTTP_CLIENT_RECEIVERS = new Set([
'axios',
'request',
'fetch',
'http',
'https',
'got',
'ky',
'superagent',
'needle',
'undici',
'apiclient',
'client',
'httpclient',
'api',
'$http',
'session',
'httpservice',
'conn',
]);
// Decorator names that indicate HTTP route handlers (NestJS, Flask, FastAPI, Spring)
const ROUTE_DECORATOR_NAMES = new Set([
'Get',
@ -1892,6 +1856,7 @@ const processFileGroup = (
}
// Per-file map: decorator end-line → decorator info, for associating with definitions
const routePosition = result.decoratorRoutes.length;
const fileDecorators = new Map<number, { name: string; arg?: string; isTool?: boolean }>();
// Track start indices of definition nodes already processed by higher-priority captures
@ -2031,8 +1996,7 @@ const processFileGroup = (
}
// HTTP client calls: axios.get('/path'), $.post('/path'), requests.get('/path')
// Skip methods also in EXPRESS_ROUTE_METHODS to avoid double-registering Express
// routes as both route definitions AND consumers (both queries match same AST node)
// Verbs shared with provider registrations are handled by dedicated consumer captures.
if (captureMap['http_client'] && captureMap['http_client.url']) {
const method = captureMap['http_client.method']?.text;
const url = captureMap['http_client.url'].text;
@ -2046,103 +2010,6 @@ const processFileGroup = (
continue;
}
// Express/Hono route registration: app.get('/path', handler)
if (
captureMap['express_route'] &&
captureMap['express_route.method'] &&
captureMap['express_route.path']
) {
const method = captureMap['express_route.method'].text;
const routePath = captureMap['express_route.path'].text;
if (EXPRESS_ROUTE_METHODS.has(method) && method !== 'use' && routePath.startsWith('/')) {
// Extract the receiver (the object the method is called on) to filter out
// HTTP client calls like axios.get('/api/users') that match the same pattern
// as Express route registrations.
const callNode = captureMap['express_route'];
// route(path) returns a builder; verb registrations need a handler.
// Count arguments without comments, which are also named AST children.
const minimumArguments = method === 'route' ? 1 : 2;
if ((countCallArguments(callNode) ?? 0) < minimumArguments) continue;
const funcNode = callNode.childForFieldName?.('function') ?? callNode.children?.[0];
// Walk through nested member_expressions and call_expressions to
// reach the innermost receiver identifier. Handles chains like:
// this.httpService.get('/path') -> member chain -> 'httpservice'
// getClient().get('/path') -> call_expression -> 'getclient'
// axios.get('/path') -> bare identifier -> 'axios'
let receiverNode = funcNode?.childForFieldName?.('object') ?? funcNode?.children?.[0];
while (
receiverNode?.type === 'member_expression' ||
receiverNode?.type === 'call_expression'
) {
if (receiverNode.type === 'member_expression') {
// Drill into the property (rightmost part) of the member expression
const propNode = receiverNode.childForFieldName?.('property');
if (propNode) {
receiverNode = propNode;
} else {
break;
}
} else {
// call_expression: unwrap to the function being called
const innerFunc =
receiverNode.childForFieldName?.('function') ?? receiverNode.children?.[0];
if (innerFunc && innerFunc !== receiverNode) {
receiverNode = innerFunc;
} else {
break;
}
}
}
const receiverText = receiverNode?.text?.toLowerCase() ?? '';
if (HTTP_CLIENT_RECEIVERS.has(receiverText)) {
// This is an HTTP client call, not a route definition u2014 skip it
continue;
}
const registrations: { method: string; node: SyntaxNode }[] = [];
if (method === 'route') {
// A builder alone registers nothing. Follow only direct chained
// verb calls, each of which must supply a non-comment handler arg.
let builderNode = callNode;
while (builderNode.parent?.type === 'member_expression') {
const member = builderNode.parent;
const registration = member.parent;
const verb = member.childForFieldName('property')?.text;
if (
member.childForFieldName('object')?.id !== builderNode.id ||
registration?.type !== 'call_expression' ||
registration.childForFieldName('function')?.id !== member.id ||
!verb ||
!EXPRESS_ROUTE_METHODS.has(verb) ||
verb === 'route' ||
verb === 'use'
) {
break;
}
if ((countCallArguments(registration) ?? 0) >= 1) {
registrations.push({ method: verb, node: registration });
}
builderNode = registration;
}
} else {
registrations.push({ method, node: callNode });
}
for (const registration of registrations) {
const httpMethod =
registration.method === 'all' ? '*' : registration.method.toUpperCase();
result.decoratorRoutes.push({
filePath: file.path,
routePath,
httpMethod,
decoratorName: `express.${registration.method}`,
lineNumber: registration.node.startPosition.row + lineOffset,
});
}
}
continue;
}
// Extract call sites
if (captureMap['call']) {
const callNode = captureMap['call'];
@ -3368,6 +3235,14 @@ const processFileGroup = (
for (const candidate of candidates) (result.fetchWrapperCalls ??= []).push(candidate);
}
if (parsedFile && provider.extractRouteFacts) {
(result.routeFacts ??= []).push({
filePath: file.path,
facts: provider.extractRouteFacts(tree, parsedFile, lineOffset),
routePosition,
});
}
// Language-specific decorator route extraction via provider hook.
// The provider's extractDecoratorRoutes walks the AST for framework-specific
// route patterns (e.g., Java Spring class-level prefix joining). Routes are

View file

@ -35,6 +35,11 @@ export const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult):
appendAll(target.fetchCalls, src.fetchCalls);
appendAll(target.fetchWrapperDefs, src.fetchWrapperDefs);
if (src.fetchWrapperCalls) appendAll((target.fetchWrapperCalls ??= []), src.fetchWrapperCalls);
for (const file of src.routeFacts ?? [])
(target.routeFacts ??= []).push({
...file,
routePosition: file.routePosition + target.decoratorRoutes.length,
});
appendAll(target.decoratorRoutes, src.decoratorRoutes);
if (src.routerIncludes) appendAll(target.routerIncludes, src.routerIncludes);
if (src.routerImports) appendAll(target.routerImports, src.routerImports);

View file

@ -872,7 +872,9 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
// v143 (#3561): combine v142 wrapper candidates with the route corrections.
// Older caches may retain phantom middleware and stored route-metadata edges.
// The PR also used v142 without wrapper candidates; invalidate both histories.
const SCHEMA_BUMP = 143;
// v144: provider provenance facts replace receiver-name guesses; mount paths
// are recomputed from facts after cache replay, before route winner selection.
const SCHEMA_BUMP = 144;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -1,3 +1,6 @@
import express from 'express';
const app = express();
function fakeInfo() {
return 'fake info';
}

View file

@ -1,3 +1,6 @@
import express from 'express';
const app = express();
function realInfo() { return 'info'; }
function realQuery() { return 'query'; }
function mcp() { return 'mcp'; }

View file

@ -21,6 +21,7 @@ import {
getNodesByLabelFull,
runPipelineFromRepo,
type PipelineResult,
writeFixtureRepo,
} from './helpers.js';
describe('Express/Hono route detection', () => {
@ -129,7 +130,7 @@ describe('Express route identity and source priority', () => {
);
expect(chainedRoutes.map((node) => node.properties.method).sort()).toEqual(methods);
const handled = getRelationships(result, 'HANDLES_ROUTE').filter(
(edge) => edge.target === route,
(edge) => edge.target === route && edge.sourceLabel === 'File',
);
expect(handled).toHaveLength(methods.length);
expect(handled.every((edge) => edge.sourceFilePath === 'src/server.ts')).toBe(true);
@ -214,6 +215,7 @@ describe('Express route identity and source priority', () => {
a.path.localeCompare(b.path) || String(a.method).localeCompare(String(b.method)),
),
handled: getRelationships(pipeline, 'HANDLES_ROUTE')
.filter((edge) => edge.sourceLabel === 'File')
.map((edge) => ({
method: pipeline.graph.getNode(edge.rel.targetId)?.properties.method,
path: edge.target,
@ -244,7 +246,140 @@ describe('Express route identity and source priority', () => {
}
}, 120_000);
it.each([137, 138, 139, 140])(
it('recomputes mixed-language mounts after mount-only, barrel-only and resolution-config edits', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-express-mount-cache-'));
const repoDir = path.join(tempDir, 'repo');
const storageDir = path.join(tempDir, 'storage');
const mountSource = (prefix: string) => `
import express from 'express';
import chosen from '@routes';
const app = express();
function before() {} function after() {}
app.use('${prefix}', [before, [chosen], after]);
app.use(['/v1', '/v2'], chosen);
`;
const config = (target: string) =>
JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@routes': [target] } } });
const project = (pipeline: PipelineResult) =>
getNodesByLabelFull(pipeline, 'Route')
.map((route) => ({
path: route.name,
file: route.properties.filePath,
handler: route.properties.handlerSymbolId,
}))
.sort((a, b) => a.path.localeCompare(b.path));
const expectedPaths = (prefix: string) =>
[
`${prefix}/users`,
`${prefix}/nested/details`,
'/v1/users',
'/v1/nested/details',
'/v2/users',
'/v2/nested/details',
'/other',
].sort();
const paths = (pipeline: PipelineResult) => getNodesByLabel(pipeline, 'Route').sort();
const persist = async (cache: ParseCache) => {
pruneCache(cache, cache.usedKeys);
const keys = await saveParseCache(storageDir, cache);
await pruneAndSaveDurableParsedFileStore(
getDurableParsedFileDir(storageDir),
PARSE_CACHE_VERSION,
new Set(keys),
);
};
try {
writeFixtureRepo(repoDir, {
'mount.ts': mountSource('/api'),
'barrel.ts': `export { default } from './via.js';`,
'via.js': `export { default } from './router';`,
'router.ts': `
import { Router } from 'express'; import { child } from './nested';
const router = Router();
function users() {} router.get('/users', users);
router.use('/nested', child); export default router;
`,
'nested.tsx': `
import express from 'express'; export const child = express.Router();
function details() {} child.get('/details', details);
`,
'other.ts': `
const express = require('express'); const other = express.Router();
function alternate() {} other.get('/other', alternate); module.exports = other;
`,
'tsconfig.json': config('barrel.ts'),
});
const inMemory = await runPipelineFromRepo(repoDir, () => {}, { workerPoolSize: 1 });
expect(paths(inMemory)).toEqual(expectedPaths('/api'));
const cold: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set(),
storagePath: storageDir,
onDiskKeys: new Set(),
};
const durable = await runPipelineFromRepo(repoDir, () => {}, {
parseCache: cold,
workerPoolSize: 2,
});
expect(durable.reparsedFileCount).toBe(6);
expect(project(durable)).toEqual(project(inMemory));
expect(project(durable).every((route) => route.handler !== undefined)).toBe(true);
await persist(cold);
expect(
(await loadDurableParsedFileIndex(getDurableParsedFileDir(storageDir), PARSE_CACHE_VERSION))
.size,
).toBeGreaterThan(0);
const warm = await loadParseCache(storageDir);
const replay = await runPipelineFromRepo(repoDir, () => {}, {
parseCache: warm,
workerPoolSize: 2,
});
expect(replay.reparsedFileCount).toBe(0);
expect(replay.parseCacheHitFileCount).toBe(6);
expect(project(replay)).toEqual(project(inMemory));
fs.writeFileSync(path.join(repoDir, 'mount.ts'), mountSource('/changed'));
const mountCache = await loadParseCache(storageDir);
const mountEdit = await runPipelineFromRepo(repoDir, () => {}, {
parseCache: mountCache,
workerPoolSize: 1,
});
expect(mountEdit.reparsedFileCount).toBe(1);
expect(mountEdit.parseCacheHitFileCount).toBe(5);
expect(paths(mountEdit)).toEqual(expectedPaths('/changed'));
await persist(mountCache);
fs.writeFileSync(path.join(repoDir, 'barrel.ts'), `export { default } from './other';`);
const barrelCache = await loadParseCache(storageDir);
const barrelEdit = await runPipelineFromRepo(repoDir, () => {}, {
parseCache: barrelCache,
workerPoolSize: 1,
});
expect(barrelEdit.reparsedFileCount).toBe(1);
expect(barrelEdit.parseCacheHitFileCount).toBe(5);
expect(paths(barrelEdit)).toEqual(
['/changed/other', '/v1/other', '/v2/other', '/users', '/nested/details'].sort(),
);
await persist(barrelCache);
// Only resolver configuration changes: every source fact comes from the
// warm cache, but the current alias target must win over cached guesses.
fs.writeFileSync(path.join(repoDir, 'tsconfig.json'), config('via.js'));
const configCache = await loadParseCache(storageDir);
const configEdit = await runPipelineFromRepo(repoDir, () => {}, {
parseCache: configCache,
workerPoolSize: 2,
});
expect(configEdit.reparsedFileCount).toBe(0);
expect(configEdit.parseCacheHitFileCount).toBe(6);
expect(project(configEdit)).toEqual(project(mountEdit));
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}, 120_000);
it.each([137, 138, 139, 140, 143])(
'reparses route captures from a same-package v%i cache on upgrade',
async (previousSchema) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-express-route-upgrade-'));

View file

@ -0,0 +1,182 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
getNodesByLabel,
getRelationships,
runPipelineFromRepo,
} from '../integration/resolvers/helpers.js';
describe('framework receiver provenance through ingestion', () => {
it('does not attribute a shadowing handler parameter to a global function', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-handler-shadow-'));
try {
fs.writeFileSync(
path.join(root, 'server.ts'),
`
import express from 'express';
function handler() {}
function install(handler) { const app = express(); app.get('/shadow', handler); }
`,
);
const result = await runPipelineFromRepo(root, () => {});
expect(getNodesByLabel(result, 'Route')).toContain('/shadow');
const edges = getRelationships(result, 'HANDLES_ROUTE').filter(
(edge) => edge.target === '/shadow',
);
expect(edges).toHaveLength(1);
expect(result.graph.getNode(edges[0].rel.sourceId)?.label).toBe('File');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}, 60000);
it('preserves framework registration priority over a same-file data route table', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-source-priority-'));
try {
fs.writeFileSync(
path.join(root, 'server.ts'),
`
import express from 'express';
const app = express();
function frameworkHandler() {} function tableHandler() {}
const routes = [{ path: '/same', method: 'GET', handler: tableHandler }];
app.get('/same', frameworkHandler);
`,
);
const result = await runPipelineFromRepo(root, () => {});
const handlers = getRelationships(result, 'HANDLES_ROUTE').filter(
(edge) => edge.target === '/same' && edge.sourceLabel === 'Function',
);
expect(handlers.map((edge) => edge.source)).toEqual(['frameworkHandler']);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}, 60000);
it('preserves cross-file declaration priority when a later registration is a data table', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-file-priority-'));
try {
for (const file of ['a.ts', 'b.ts'])
fs.writeFileSync(
path.join(root, file),
`
import express from 'express'; const app = express();
function handler() {} app.get('/same', handler);
`,
);
const first = await runPipelineFromRepo(root, () => {});
const winner = getRelationships(first, 'HANDLES_ROUTE').find(
(edge) => edge.target === '/same' && edge.sourceLabel === 'File',
)!.sourceFilePath;
const loser = winner === 'a.ts' ? 'b.ts' : 'a.ts';
fs.writeFileSync(
path.join(root, loser),
`
function handler() {}
const routes = [{ path: '/same', method: 'GET', handler }];
`,
);
const changed = await runPipelineFromRepo(root, () => {});
expect(
getRelationships(changed, 'HANDLES_ROUTE')
.filter((edge) => edge.target === '/same' && edge.sourceLabel === 'File')
.map((edge) => edge.sourceFilePath),
).toEqual([winner]);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}, 60000);
it('keeps proven Hono routes and static mounts alongside unresolved Express mounts', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-dynamic-mount-'));
try {
fs.writeFileSync(
path.join(root, 'server.ts'),
`
import express from 'express'; import { Hono } from 'hono';
const app = express(); const hidden = express.Router(); const mixed = express.Router();
function handler() {}
hidden.get('/hidden', handler); mixed.get('/visible', handler);
app.use(runtimePrefix, hidden); app.use(runtimePrefix, mixed); app.use('/static', mixed);
const edge = new Hono(); edge.get('/hono', handler);
`,
);
const result = await runPipelineFromRepo(root, () => {});
expect(getNodesByLabel(result, 'Route').sort()).toEqual(['/hono', '/static/visible']);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}, 60000);
it('honors configured local modules that shadow a framework package specifier', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-package-shadow-'));
try {
fs.writeFileSync(
path.join(root, 'tsconfig.json'),
JSON.stringify({
compilerOptions: { baseUrl: '.', paths: { express: ['./local.ts'] } },
}),
);
fs.writeFileSync(
path.join(root, 'local.ts'),
`export default function storage() { return new Map(); }`,
);
fs.writeFileSync(
path.join(root, 'server.ts'),
`
import express from 'express'; import { Hono } from 'hono';
const storage = express(); storage.get('/not-http', 'fallback');
const edge = new Hono(); edge.get('/hono', () => {});
`,
);
const result = await runPipelineFromRepo(root, () => {});
expect(getNodesByLabel(result, 'Route')).toEqual(['/hono']);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}, 60000);
it('uses the mount prefix for an Express Router endpoint', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-mount-'));
try {
fs.writeFileSync(
path.join(root, 'server.ts'),
`
import express from 'express';
const app = express();
const router = express.Router();
router.get('/users', handler);
app.use('/api', router);
function handler() {}
`,
);
const result = await runPipelineFromRepo(root, () => {});
expect(getNodesByLabel(result, 'Route').sort()).toEqual(['/api/users']);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}, 60000);
it('keeps the proven Express endpoint and rejects an unrelated storage method', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-provenance-'));
try {
fs.writeFileSync(
path.join(root, 'server.ts'),
`
import express from 'express';
const app = express();
app.get('/real', handler);
const storage = new Map();
storage.get('/not-http', 'fallback');
function handler() {}
`,
);
const result = await runPipelineFromRepo(root, () => {});
expect(getNodesByLabel(result, 'Route').sort()).toEqual(['/real']);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}, 60000);
});

View file

@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest';
import { composeFrameworkRoutePaths } from '../../src/core/ingestion/route-extractors/framework-route-paths.js';
import type {
FrameworkMount,
ResolvedFrameworkRoutes,
} from '../../src/core/ingestion/route-extractors/framework-route-types.js';
function project(
mounts: FrameworkMount[],
ids = ['app', 'a', 'b', 'c', 'standalone'],
): ResolvedFrameworkRoutes {
return {
allocations: ids.map((id) => ({ id, kind: id === 'app' ? 'app' : 'router' })),
mounts,
registrations: ids
.filter((id) => id !== 'app')
.map((id) => ({
receiverId: id,
framework: 'express',
route: {
filePath: `${id}.ts`,
routePath: '/item',
httpMethod: 'GET',
decoratorName: 'express.get',
lineNumber: 7,
handlerName: `${id}Handler`,
},
})),
};
}
const paths = (resolved: ResolvedFrameworkRoutes, budget?: number) =>
composeFrameworkRoutePaths(resolved, budget)
.map((route) => `${route.filePath} ${route.routePath}`)
.sort();
const mount = (parentId: string, childId: string, ...prefixes: string[]): FrameworkMount => ({
parentId,
childId,
prefixes,
});
describe('framework mounted route paths', () => {
it('composes root, nested, repeated and path-array mounts while preserving standalone routers', () => {
const resolved = project([
mount('app', 'a', '/v1', '/v2'),
mount('a', 'b', '/admin'),
mount('b', 'c', '/'),
]);
expect(paths(resolved)).toEqual([
'a.ts /v1/item',
'a.ts /v2/item',
'b.ts /v1/admin/item',
'b.ts /v2/admin/item',
'c.ts /v1/admin/item',
'c.ts /v2/admin/item',
'standalone.ts /item',
]);
expect(
composeFrameworkRoutePaths(resolved).find((route) => route.filePath === 'c.ts'),
).toMatchObject({
lineNumber: 7,
handlerName: 'cHandler',
httpMethod: 'GET',
decoratorName: 'express.get',
});
});
it('does not invent local paths from dynamic mounts; independently known static paths survive', () => {
const resolved = project([
{ parentId: 'app', childId: 'a', prefixes: null },
mount('a', 'b', '/child'),
mount('app', 'c', '/known'),
{ parentId: 'app', childId: 'c', prefixes: null },
]);
expect(paths(resolved)).toEqual(['c.ts /known/item', 'standalone.ts /item']);
});
it('keeps all acyclic paths through a diamond and cycle, deduplicating identical outcomes', () => {
const resolved = project(
[
mount('app', 'a', '/a'),
mount('app', 'b', '/b'),
mount('a', 'c', '/c'),
mount('b', 'c', '/c'),
mount('c', 'a', '/again'),
mount('app', 'a', '/a'),
],
['app', 'a', 'b', 'c'],
);
expect(paths(resolved)).toEqual([
'a.ts /a/item',
'a.ts /b/c/again/item',
'b.ts /b/item',
'c.ts /a/c/item',
'c.ts /b/c/item',
]);
});
it('declines rootless cycles while retaining an unrelated standalone router', () => {
expect(
paths(project([mount('a', 'b', '/b'), mount('b', 'a', '/a')], ['a', 'b', 'standalone'])),
).toEqual(['standalone.ts /item']);
});
it('fails an exhausted component atomically and independently of input ordering', () => {
const resolved = project(
[mount('app', 'a', '/a', '/b', '/c'), mount('a', 'b', '/nested')],
['app', 'a', 'b', 'standalone'],
);
expect(paths(resolved, 3)).toEqual(['standalone.ts /item']);
expect(
paths(
{
allocations: [...resolved.allocations].reverse(),
mounts: [...resolved.mounts].reverse(),
registrations: [...resolved.registrations].reverse(),
},
3,
),
).toEqual(paths(resolved, 3));
expect(paths(resolved, 100)).toHaveLength(7);
});
it('preserves proven Hono routes without Express mounting semantics', () => {
const resolved: ResolvedFrameworkRoutes = {
allocations: [{ id: 'hono', kind: 'hono' }],
mounts: [],
registrations: [
{
receiverId: 'hono',
framework: 'hono',
route: {
filePath: 'hono.ts',
routePath: '/hello',
httpMethod: 'GET',
lineNumber: 2,
decoratorName: 'hono.get',
},
},
],
};
expect(paths(resolved)).toEqual(['hono.ts /hello']);
});
});

View file

@ -0,0 +1,426 @@
import { describe, expect, it } from 'vitest';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import JavaScript from 'tree-sitter-javascript';
import path from 'node:path';
import {
typescriptProvider,
javascriptProvider,
} from '../../src/core/ingestion/languages/typescript.js';
import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js';
import { extractFrameworkRouteFacts } from '../../src/core/ingestion/route-extractors/framework-route-facts.js';
import { resolveFrameworkRouteFacts } from '../../src/core/ingestion/route-extractors/framework-route-resolver.js';
import { composeFrameworkRoutePaths } from '../../src/core/ingestion/route-extractors/framework-route-paths.js';
function facts(source: string, filePath = 'server.ts') {
const parser = new Parser();
parser.setLanguage(
filePath.endsWith('.js')
? JavaScript
: filePath.endsWith('.tsx')
? TypeScript.tsx
: TypeScript.typescript,
);
const tree = parser.parse(source);
const parsed = extractParsedFile(
filePath.endsWith('.js') ? javascriptProvider : typescriptProvider,
source,
filePath,
undefined,
tree,
);
expect(parsed).toBeDefined();
return extractFrameworkRouteFacts(tree, parsed!);
}
function resolve(sources: Record<string, string>) {
const files = Object.entries(sources).map(([file, source]) => facts(source, file));
const output = resolveFrameworkRouteFacts(files, (from, specifier) => {
const base = path.posix.join(path.posix.dirname(from), specifier);
return Object.keys(sources).filter(
(file) =>
file === base ||
file === `${base}.ts` ||
file === `${base}.js` ||
file === `${base}/index.ts`,
);
});
return output;
}
const routes = (source: string) =>
resolve({ 'server.ts': source }).registrations.map((registration) => registration.route);
const urls = (source: string) =>
routes(source).map((route) => `${route.httpMethod} ${route.routePath}`);
describe('framework receiver provenance', () => {
it('accepts imported Express allocations and stable aliases, not receiver names', () => {
expect(
urls(`
import web from 'express';
const service = web(); const stable = service;
stable.get('/real', handler);
const storage = new Map(); storage.get('/not-http', 'fallback');
app.post('/undeclared', handler); router.get('/undeclared-router', handler);
`),
).toEqual(['GET /real']);
});
it.each([
`import { Router as Build } from 'express'; const r = Build();`,
`import * as web from 'express'; const r = web.Router();`,
`const web = require('express'); const r = web.Router();`,
`const { Router: Build } = require('express'); const r = Build();`,
`const Build = require('express').Router; const r = new Build();`,
`const r = require('express')();`,
`const r = require('express').Router();`,
])('recognizes factory provenance: %s', (setup) => {
expect(urls(`${setup} r.get('/ok', handler);`)).toEqual(['GET /ok']);
});
it('preserves builders, chains, all, handler evidence, and argument comments', () => {
const result = routes(`import express from 'express';
const router = express.Router();
router.route('/bare');
router.get('/setting');
router.use('/middleware', middleware);
router.route('/chain').get(/* comment */ handler).post(other);
const builder = router.route('/assigned');
const alias = builder;
alias.all(handler);
builder.delete(/* no handler */);
router.get('/direct', middleware, handler).put('/continued', other);
function handler() {} function other() {}`);
expect(result.map((route) => `${route.httpMethod} ${route.routePath}`)).toEqual([
'POST /chain',
'GET /chain',
'* /assigned',
'PUT /continued',
'GET /direct',
]);
expect(result.find((route) => route.routePath === '/assigned')).toMatchObject({
filePath: 'server.ts',
lineNumber: 8,
handlerName: 'handler',
decoratorName: 'express.all',
});
});
it.each([13, 24])(
'resolves all %i fluent registrations without exhausting the work budget',
(count) => {
const paths = Array.from({ length: count }, (_, index) => `/route-${index}`);
const chain = paths.map((route) => `.get('${route}', () => {})`).join('');
const extracted = facts(`import express from 'express'; const app = express(); app${chain};`);
expect(extracted.calls).toHaveLength(count);
const resolved = resolveFrameworkRouteFacts([extracted], () => []);
expect(resolved.registrations.map(({ route }) => route.routePath).sort()).toEqual(
[...paths].sort(),
);
},
);
it('withholds handler evidence for mutated callable bindings', () => {
const result = routes(`import express from 'express'; const app = express();
let handler = () => {}; handler = replacement; app.get('/real', handler);`);
expect(result).toHaveLength(1);
expect(result[0].handlerName).toBeUndefined();
});
it('respects parameter, block, catch, destructuring, and require shadows', () => {
expect(
urls(`
import express from 'express'; const router = express.Router();
function f(router) { router.get('/parameter', handler); }
{ const router = storage; router.get('/block', handler); }
try {} catch(router) { router.get('/catch', handler); }
function nested({ router }) { router.get('/destructured', handler); }
function wrong(require) { const r = require('express').Router(); r.get('/require', handler); }
router.get('/real', handler);
`),
).toEqual(['GET /real']);
});
it.each([
`import type express from 'express'; const r = express();`,
`import { type Router } from 'express'; const r = Router();`,
`import type * as express from 'express'; const r = express.Router();`,
`import express from 'express'; let r = express.Router(); r = storage;`,
`import express from 'express'; let r = express.Router(); function mutate() { r = storage; }`,
`import express from 'express'; let r = express.Router(); ({ r } = storage);`,
`import express from 'express'; const r = express.Router(); const alias = r; alias.get = other;`,
`import express from 'express'; express.Router = custom; const r = express.Router();`,
`import express, { Router } from 'express'; Router.custom = other; const r = express.Router();`,
`import express from 'express'; const r = express.Router(); delete r.get;`,
`import express from 'express'; let r = express.Router(); for (r of replacements) {}`,
])('declines erased or conflicting provenance: %s', (setup) => {
expect(urls(`${setup} r.get('/unsafe', handler);`)).toEqual([]);
});
it('does not lend later allocations to calls in a temporal dead zone', () => {
expect(
urls(`
import express from 'express';
api.get('/before', h); const api = express();
const router = express.Router();
{ router.get('/block-before', h); const router = storage; }
function install() { later.get('/closure', h); }
const later = express.Router();
api.get('/after', h);
`),
).toEqual(['GET /closure', 'GET /after']);
});
it('keeps directly proven Hono registrations separate from Express builders and mounts', () => {
const result = resolve({
'server.ts': `
import { Hono as Web } from 'hono';
const api = new Web();
api.get('/hono', handler); api.route('/builder').get(handler);
const router = api.route('/assigned'); router.post(handler);
api.use('/mount', api);
`,
});
expect(result.registrations).toMatchObject([
{ framework: 'hono', route: { routePath: '/hono', decoratorName: 'hono.get' } },
]);
expect(result.registrations).toHaveLength(1);
expect(result.mounts).toEqual([]);
});
it('records literal, root, middleware and unknown mounts without emitting endpoints', () => {
const result = resolve({
'server.ts': `
import express from 'express';
const app = express(); const router = express.Router();
function auth() {}
app.use(['/v1', '/v2'], auth, router, auth);
app.use(router); app.use(auth, router); app.use(runtimePrefix, auth, router);
app.use('/array', [auth, [router]]); app.use([auth, router]);
`,
});
expect(result.registrations).toEqual([]);
expect(result.mounts.map((mount) => mount.prefixes)).toEqual([
['/v1', '/v2'],
[''],
[''],
null,
['/array'],
[''],
]);
expect(result.allocations.map((allocation) => allocation.kind).sort()).toEqual([
'app',
'router',
]);
});
it('follows explicit ESM named/default exports and transitive barrels across JS/TS/TSX', () => {
const result = resolve({
'router.js': `const { Router } = require('express'); const r = Router(); export { r as users }; export default r;`,
'barrel.ts': `export { users as endpoints, default } from './router';`,
'index.ts': `export * from './barrel'; export { default } from './barrel';`,
'server.tsx': `import byDefault, { endpoints } from './index'; endpoints.get('/named', handler); byDefault.post('/default', handler);`,
});
expect(result.registrations.map((registration) => registration.route.routePath)).toEqual([
'/named',
'/default',
]);
expect(new Set(result.registrations.map((registration) => registration.receiverId)).size).toBe(
1,
);
});
it.each([
[`module.exports = r;`, `const api = require('./router');`],
[`exports.api = r;`, `const {api} = require('./router');`],
[`module.exports.api = r;`, `const api = require('./router').api;`],
[`module.exports = { api: r };`, `const {api} = require('./router');`],
])('follows explicit CommonJS exports: %s', (publish, consume) => {
const result = resolve({
'router.js': `const express = require('express'); const r = express.Router(); ${publish}`,
'server.js': `${consume} api.get('/ok', handler);`,
});
expect(result.registrations.map((registration) => registration.route.routePath)).toEqual([
'/ok',
]);
});
it('does not infer default from a unique named export or accept conflicting stars', () => {
const result = resolve({
'one.ts': `import {Router} from 'express'; export const api = Router();`,
'two.ts': `export const api = storage;`,
'barrel.ts': `export * from './one'; export * from './two';`,
'server.ts': `import guessed from './one'; import {api} from './barrel'; guessed.get('/default-guess', handler); api.get('/ambiguous', handler);`,
});
expect(result.registrations).toEqual([]);
});
it('declines conflicting CJS writes, shadowed module exports, and detached exports aliases', () => {
const result = resolve({
'conflict.js': `const r = require('express').Router(); exports.api = r; exports.api = storage;`,
'shadow.js': `const r = require('express').Router(); function f(module) { module.exports = r; }`,
'detached.js': `const r = require('express').Router(); module.exports = storage; exports.api = r;`,
'replaced.js': `const r = require('express').Router(); module.exports.api = r; module.exports = storage;`,
'computed.js': `const r = require('express').Router(); module.exports = r; module.exports[key] = other;`,
'server.js': `const e = require('./computed'); e.get('/computed', h); const d = require('./replaced').api; d.get('/replaced', h); const a = require('./conflict').api; a.get('/conflict', h); const b = require('./shadow'); b.get('/shadow', h); const c = require('./detached').api; c.get('/detached', h);`,
});
expect(result.registrations).toEqual([]);
});
it.each([
`exports.router = router; delete exports.router;`,
`exports.router = router; exports.router += 1;`,
`exports.router = router; exports.router++;`,
`module.exports.router = router; delete module.exports.router;`,
`module.exports.router = router; module.exports.router--;`,
`module.exports = { router }; delete module.exports;`,
`module.exports = { router, ...overrides };`,
`module.exports = { router, [key]: replacement };`,
])('declines invalidated or dynamically overwritten CommonJS exports: %s', (publish) => {
const result = resolve({
'router.js': `const { Router } = require('express'); const router = Router(); ${publish}`,
'server.js': `const { router } = require('./router'); router.get('/phantom', () => {});`,
});
expect(result.registrations).toEqual([]);
});
it.each([
[`exports = {}; exports.router = router;`, `const { router } = require('./router');`],
[
`module.exports = router; module.exports.get = replacement;`,
`const router = require('./router');`,
],
])('declines detached or mutated CommonJS export values: %s', (publish, consume) => {
const result = resolve({
'router.js': `const { Router } = require('express'); const router = Router(); ${publish}`,
'server.js': `${consume} router.get('/phantom', () => {});`,
});
expect(result.registrations).toEqual([]);
});
it('declines unresolved stars that could conflict with a proven named export', () => {
const result = resolve({
'router.ts': `import {Router} from 'express'; export const api = Router();`,
'barrel.ts': `export * from './router'; export * from './unavailable';`,
'server.ts': `import {api} from './barrel'; api.get('/uncertain', h);`,
});
expect(result.registrations).toEqual([]);
});
it('terminates seedless export cycles but retains an explicit seeded export', () => {
const result = resolve({
'one.ts': `export * from './two'; import {Router} from 'express'; export const api = Router();`,
'two.ts': `export * from './one'; export {missing} from './three';`,
'three.ts': `export {missing} from './two';`,
'server.ts': `import {api, missing} from './two'; api.get('/seed', h); missing.get('/cycle', h);`,
});
expect(result.registrations.map((registration) => registration.route.routePath)).toEqual([
'/seed',
]);
});
it('invalidates imported values when another file writes through an alias', () => {
const result = resolve({
'router.ts': `import {Router} from 'express'; export const api = Router(); api.get('/unsafe', h);`,
'mutation.ts': `import {api as alias} from './router'; alias.get = replacement;`,
});
expect(result.registrations).toEqual([]);
});
it.each([
[
`import {Router} from 'express'; export const api = Router(), other = Router();`,
`import * as routes from './router';`,
`import {api, other} from './router';`,
],
[
`const {Router} = require('express'); exports.api = Router(); exports.other = Router();`,
`const routes = require('./router');`,
`const {api, other} = require('./router');`,
],
])('invalidates Routers mutated through a module object: %#', (publish, namespace, consume) => {
const result = resolve({
'router.js': publish,
'mutation.js': `${namespace} routes.api.get = () => {}; routes.other.post = () => {};`,
'server.js': `${consume} api.get('/phantom', () => {}); other.post('/also-phantom', () => {});`,
});
expect(composeFrameworkRoutePaths(result)).toEqual([]);
});
it.each([`app.locals.title = 'demo';`, `app.use = () => {};`, `delete app.use;`])(
'does not revive local paths when a mount parent is mutated: %s',
(mutation) => {
const result = resolve({
'server.ts': `import express from 'express';
const app = express(), router = express.Router();
${mutation}
app.use('/api', [router]);
router.get('/users', () => {});`,
});
expect(composeFrameworkRoutePaths(result)).toEqual([]);
},
);
it('preserves an independent valid mount when another parent is mutated', () => {
const result = resolve({
'server.ts': `import express from 'express';
const app = express(), safe = express(), router = express.Router();
app.locals.title = 'demo';
app.use('/uncertain', router); safe.use('/safe', router);
router.get('/users', () => {});`,
});
expect(composeFrameworkRoutePaths(result).map((route) => route.routePath)).toEqual([
'/safe/users',
]);
});
it.each([false, true])('retains uncertain array mounts alongside safe mount=%s', (safeMount) => {
const result = resolve({
'server.ts': `import express from 'express';
const app = express(), safe = express(), router = express.Router();
const list = [router]; list.push = other;
app.use('/api', list);
${safeMount ? "safe.use('/safe', router);" : ''}
router.get('/users', () => {});`,
});
expect(composeFrameworkRoutePaths(result).map((route) => route.routePath)).toEqual(
safeMount ? ['/safe/users'] : [],
);
});
it('does not suppress standalone routes for an unproven mount receiver', () => {
const result = resolve({
'server.ts': `import express from 'express';
const router = express.Router(); storage.use('/unknown', router);
router.get('/users', () => {});`,
});
expect(composeFrameworkRoutePaths(result).map((route) => route.routePath)).toEqual(['/users']);
});
it('bounds repeated star-barrel exploration while preserving unrelated routes', () => {
const sources: Record<string, string> = {
'base.ts': `export const other = 1;`,
'server.ts': `import {missing} from './a24'; import express from 'express'; missing.get('/unknown', h); const app = express(); app.get('/safe', h);`,
};
for (let level = 0; level < 25; level++) {
const exports =
level === 0
? `export * from './base';`
: `export * from './a${level - 1}'; export * from './b${level - 1}';`;
sources[`a${level}.ts`] = exports;
sources[`b${level}.ts`] = exports;
}
expect(
resolve(sources).registrations.map((registration) => registration.route.routePath),
).toEqual(['/safe']);
});
it('round-trips compact facts through JSON with identical resolved output', () => {
const file = facts(
`import express from 'express'; const app = express(); const r = express.Router(); r.get('/x', handler); app.use('/api', r);`,
);
const original = resolveFrameworkRouteFacts([file], () => []);
expect(resolveFrameworkRouteFacts(JSON.parse(JSON.stringify([file])), () => [])).toEqual(
original,
);
expect(JSON.stringify(file)).not.toContain('namedChildren');
});
});

View file

@ -0,0 +1,248 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import * as globModule from 'glob';
import * as fsUtils from '../../../src/core/group/extractors/fs-utils.js';
import os from 'node:os';
import path from 'node:path';
import { HttpRouteExtractor } from '../../../src/core/group/extractors/http-route-extractor.js';
import type { RepoHandle } from '../../../src/core/group/types.js';
import {
getRelationships,
runPipelineFromRepo,
writeFixtureRepo,
} from '../../integration/resolvers/helpers.js';
vi.mock('glob', { spy: true });
describe('Express group provider provenance', () => {
let root: string;
const extractor = new HttpRouteExtractor();
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-express-'));
});
afterEach(() => {
vi.resetAllMocks();
vi.restoreAllMocks();
fs.rmSync(root, { recursive: true, force: true });
});
const write = (files: Record<string, string>) => writeFixtureRepo(root, files);
const repo = (): RepoHandle => ({
id: 'test',
path: root,
repoPath: root,
storagePath: path.join(root, '.gitnexus'),
});
const providers = (contracts: Awaited<ReturnType<HttpRouteExtractor['extract']>>) =>
contracts
.filter((contract) => contract.role === 'provider')
.map((contract) => contract.contractId)
.sort();
it('composes imported Express routers instead of publishing their local paths', async () => {
write({
'server.ts': `import express from 'express'; import router from './router'; const app = express(); app.use('/api', router);`,
'router.ts': `import { Router } from 'express'; const router = Router(); router.get('/users', () => {}); export default router;`,
});
expect(providers(await extractor.extract(null, root, repo()))).toEqual([
'http::GET::/api/users',
]);
});
it('retains project providers when the filesystem supplies Windows separators', async () => {
write({
'src/server.ts': `import express from 'express'; import router from './router'; const app = express(); app.use('/api', router);`,
'src/router.ts': `import { Router } from 'express'; const router = Router(); router.get('/users', () => {}); export default router;`,
});
vi.spyOn(globModule, 'glob').mockResolvedValue(['src\\server.ts', 'src\\router.ts']);
const readSafe = fsUtils.readSafe;
vi.spyOn(fsUtils, 'readSafe').mockImplementation((repoPath, rel) =>
readSafe(repoPath, rel.replace(/\\/g, '/')),
);
expect(providers(await extractor.extract(null, root, repo()))).toEqual([
'http::GET::/api/users',
]);
});
it('does not admit arbitrary receivers named app or router', async () => {
write({
'server.ts': `const router = new Map(); router.get('/not-http', 'fallback'); const app = { post() {} }; app.post('/also-not-http', () => {});`,
});
expect(providers(await extractor.extract(null, root, repo()))).toEqual([]);
});
it('agrees with ingestion across mixed extensions, aliases and multiple mounts while preserving consumers and other providers', async () => {
write({
'tsconfig.json': JSON.stringify({
compilerOptions: { baseUrl: '.', paths: { '@routes/*': ['src/*'] } },
}),
'server.ts': `import express from 'express'; import { middle } from '@routes/middle'; import hidden from './src/hidden'; const app = express(); app.use(['/v1', '/v2'], auth, middle); app.use(runtimePrefix, middle); app.use(runtimePrefix, hidden);`,
'src/middle.js': `import { Router } from 'express'; import { nested } from './leaf'; export const middle = Router(); middle.use('/admin', nested);`,
'src/leaf.tsx': `import { Router } from 'express'; import axios from 'axios'; export const nested = Router(); nested.get('/users', () => {}); export async function outbound() { await fetch('/remote'); return axios.post('/outbound'); }`,
'src/hidden.js': `import { Router } from 'express'; const hidden = Router(); hidden.get('/hidden', () => {}); export default hidden;`,
'src/other.ts': `import { Hono } from 'hono'; import { Controller, Get } from '@nestjs/common'; const edge = new Hono(); edge.get('/hono', () => {}); @Controller('nest') class ControllerClass { @Get('list') list() {} } const routes = [{ path: '/table', method: 'POST', handler: tableHandler }]; function tableHandler() {} for (const route of routes) { if (route.path === request.path && route.method === request.method) route.handler(); }`,
});
const expected = [
'http::GET::/hono',
'http::GET::/nest/list',
'http::GET::/v1/admin/users',
'http::GET::/v2/admin/users',
'http::POST::/table',
];
const sourceOnly = await extractor.extract(null, root, repo());
expect(providers(sourceOnly)).toEqual(expected);
expect(
sourceOnly
.filter((contract) => contract.role === 'consumer')
.map((contract) => contract.contractId)
.sort(),
).toEqual(['http::GET::/remote', 'http::POST::/outbound']);
const ingested = await runPipelineFromRepo(root, () => {});
const rows = getRelationships(ingested, 'HANDLES_ROUTE')
.filter((edge) => edge.sourceLabel === 'File')
.map((edge) => {
const route = ingested.graph.getNode(edge.rel.targetId)!;
return {
fileId: edge.rel.sourceId,
filePath: edge.sourceFilePath,
routePath: edge.target,
routeMethod: route.properties.method,
routeSource: edge.rel.reason,
handlerSymbolId: route.properties.handlerSymbolId,
};
});
expect(rows.map((row) => `http::${row.routeMethod}::${row.routePath}`).sort()).toEqual(
expected,
);
const db = async (query: string) => (query.includes("type: 'HANDLES_ROUTE'") ? rows : []);
expect(providers(await extractor.extract(db, root, repo()))).toEqual(expected);
// A later run rebuilds project relationships even when router files are unchanged.
write({
'server.ts': `import express from 'express'; import { middle } from '@routes/middle'; import hidden from './src/hidden'; const app = express(); app.use('/v3', middle); app.use(runtimePrefix, hidden);`,
});
expect(providers(await extractor.extract(null, root, repo()))).toEqual([
'http::GET::/hono',
'http::GET::/nest/list',
'http::GET::/v3/admin/users',
'http::POST::/table',
]);
}, 60000);
it('enriches composed graph routes from exact same-line registrations and imported handler aliases', async () => {
write({
'server.ts': `import express from 'express'; import router from './router'; const app = express(); app.use('/api', router);`,
'router.ts': `import { Router } from 'express'; import { list as imported } from './handlers'; const router = Router(); function local() {} router.get('/local', local); router.get('/imported', imported); export default router;`,
'handlers.ts': `export function list() {}`,
});
const db = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes("type: 'HANDLES_ROUTE'"))
return ['/local', '/imported'].map((route) => ({
fileId: 'router-file',
filePath: 'router.ts',
routePath: `/api${route}`,
routeMethod: 'GET',
handlerSymbolId: '',
}));
if (query.includes('UNION ALL') && params?.filePath === 'router.ts')
return [
{
uid: 'local-uid',
name: 'local',
filePath: 'router.ts',
startLine: 0,
endLine: 0,
labels: ['Function'],
},
];
if (query.includes('STARTS WITH') && params?.name === 'list')
return [{ uid: 'list-uid', name: 'list', filePath: 'handlers.ts' }];
return [];
};
const contracts = await extractor.extract(db, root, repo());
expect(providers(contracts)).toEqual(['http::GET::/api/imported', 'http::GET::/api/local']);
expect(
contracts.find((contract) => contract.contractId === 'http::GET::/api/imported'),
).toMatchObject({
symbolUid: 'list-uid',
symbolRef: { filePath: 'handlers.ts' },
meta: { extractionStrategy: 'graph_assisted' },
});
expect(
contracts.find((contract) => contract.contractId === 'http::GET::/api/local')?.symbolUid,
).toBe('local-uid');
});
it('does not borrow global handlers or the enclosing registrar for unresolved handler designators', async () => {
write({
'router.ts': `import { Router } from 'express';
import { imported } from './handlers';
const router = Router();
function handler() {}
function install(handler, imported, object) {
router.get('/parameter', handler); router.get('/shadowed-import', imported); router.get('/member', object.handler);
router.get('/inline', () => {});
}
function unknown() { router.get('/unknown', missing); }
export default router;`,
});
const db = async (query: string): Promise<Record<string, unknown>[]> =>
query.includes('UNION ALL')
? [
{
uid: 'global-handler',
name: 'handler',
filePath: 'router.ts',
startLine: 3,
endLine: 3,
labels: ['Function'],
},
{
uid: 'registrar',
name: 'install',
filePath: 'router.ts',
startLine: 4,
endLine: 7,
labels: ['Function'],
},
{
uid: 'unknown-registrar',
name: 'unknown',
filePath: 'router.ts',
startLine: 8,
endLine: 8,
labels: ['Function'],
},
]
: [];
const contracts = await extractor.extract(db, root, repo());
expect(providers(contracts)).toEqual([
'http::GET::/inline',
'http::GET::/member',
'http::GET::/parameter',
'http::GET::/shadowed-import',
'http::GET::/unknown',
]);
for (const contract of contracts.filter(
(contract) => contract.contractId !== 'http::GET::/inline',
))
expect(contract.symbolUid).toBe('');
expect(
contracts.find((contract) => contract.contractId === 'http::GET::/inline')?.symbolUid,
).toBe('registrar');
});
it('fails closed when configured internal modules shadow framework package names', async () => {
write({
'tsconfig.json': JSON.stringify({
compilerOptions: { baseUrl: '.', paths: { express: ['./local.ts'], hono: ['./local.ts'] } },
}),
'local.ts': `export default function storage() { return new Map(); } export class Hono { get() {} }`,
'server.ts': `import express from 'express'; import { Hono } from 'hono'; const app = express(); const edge = new Hono(); app.get('/not-http', 'fallback'); edge.get('/also-not-http', () => {});`,
});
expect(providers(await extractor.extract(null, root, repo()))).toEqual([]);
});
});

View file

@ -2537,12 +2537,14 @@ export default router;
fs.writeFileSync(
path.join(dir, 'src/routes/health-a.ts'),
`
import { Router } from 'express'; const router = Router();
router.get('/api/health', healthA);
`,
);
fs.writeFileSync(
path.join(dir, 'src/routes/health-b.ts'),
`
import { Router } from 'express'; const router = Router();
router.get('/api/health', healthB);
`,
);
@ -7566,7 +7568,11 @@ export async function fetchHealth() {
it('handles graph queries that throw gracefully', async () => {
const dir = path.join(tmpDir, 'graph-error');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(path.join(dir, 'src/routes.ts'), `router.get('/api/health', handler);`);
fs.writeFileSync(
path.join(dir, 'src/routes.ts'),
`import { Router } from 'express'; const router = Router();
router.get('/api/health', handler);`,
);
const throwingExecutor = async () => {
throw new Error('DB unavailable');
@ -7586,6 +7592,7 @@ export async function fetchHealth() {
fs.writeFileSync(
path.join(dir, 'src/router.ts'),
`
import { Router } from 'express'; const router = Router();
router.get('/api/users/', handler);
`,
);
@ -7601,6 +7608,7 @@ router.get('/api/users/', handler);
fs.writeFileSync(
path.join(dir, 'src/router.ts'),
`
import { Router } from 'express'; const router = Router();
router.get('/api/users/:id', handler1);
router.get('/api/posts/{postId}', handler2);
`,

View file

@ -7,6 +7,7 @@ import {
JAVASCRIPT_HTTP_PLUGIN,
} from '../../../src/core/group/extractors/http-patterns/node.js';
import { resolveJsImport } from '../../../src/core/ingestion/route-extractors/js-const-resolver.js';
import { normalizeRepoRelPath } from '../../../src/core/group/extractors/http-route-extractor.js';
import type { HttpDetection } from '../../../src/core/group/extractors/http-patterns/types.js';
const tsParser = new Parser();
@ -21,27 +22,37 @@ jsParser.setLanguage(JavaScript);
* Drive the plugin the way the orchestrator does: a `prepareRepo` pre-pass over
* a virtual repo, then a per-file `scan` with the resulting context.
*/
function scanRepo(files: Record<string, string>, target: string): HttpDetection[] {
async function scanRepo(files: Record<string, string>, target: string): Promise<HttpDetection[]> {
const paths = Object.keys(files);
const repoContext = TYPESCRIPT_HTTP_PLUGIN.prepareRepo?.({
const repoContext = await TYPESCRIPT_HTTP_PLUGIN.prepareRepo?.({
repoPath: '/repo',
files: paths,
parser: tsParser,
readFile: (rel) => files[rel] ?? null,
parseSource: (parser, src) => parser.parse(src),
});
return TYPESCRIPT_HTTP_PLUGIN.scan(tsParser.parse(files[target]), repoContext, target);
const inputs = paths.map((filePath) => ({ filePath, tree: tsParser.parse(files[filePath]) }));
return [
...TYPESCRIPT_HTTP_PLUGIN.scan(
inputs.find((input) => input.filePath === target)!.tree,
repoContext,
target,
),
...(TYPESCRIPT_HTTP_PLUGIN.scanProject?.(inputs, repoContext).find(
(file) => normalizeRepoRelPath(file.filePath) === normalizeRepoRelPath(target),
)?.detections ?? []),
];
}
const consumers = (detections: HttpDetection[]) => detections.filter((d) => d.role === 'consumer');
/** `scanRepo`, but the pre-pass may fail on chosen files. */
function scanRepoWithParse(
async function scanRepoWithParse(
files: Record<string, string>,
target: string,
parseSource: (parser: Parser, src: string) => Parser.Tree | null,
): HttpDetection[] {
const repoContext = TYPESCRIPT_HTTP_PLUGIN.prepareRepo?.({
): Promise<HttpDetection[]> {
const repoContext = await TYPESCRIPT_HTTP_PLUGIN.prepareRepo?.({
repoPath: '/repo',
files: Object.keys(files),
parser: tsParser,
@ -69,8 +80,8 @@ const API_ROUTES = `
`;
describe('JS/TS HTTP consumer resolution', () => {
it('resolves a configured client and a table path imported from other files', () => {
const detections = scanRepo(
it('resolves a configured client and a table path imported from other files', async () => {
const detections = await scanRepo(
{
'src/lib/axios.config.ts': AXIOS_CONFIG,
'src/api-modules/shared/api-routes.ts': API_ROUTES,
@ -90,8 +101,8 @@ describe('JS/TS HTTP consumer resolution', () => {
);
});
it('resolves relative imports for the client and the route table', () => {
const detections = scanRepo(
it('resolves relative imports for the client and the route table', async () => {
const detections = await scanRepo(
{
'src/lib/axios.config.ts': AXIOS_CONFIG,
'src/api/routes.ts': API_ROUTES,
@ -109,8 +120,8 @@ describe('JS/TS HTTP consumer resolution', () => {
);
});
it('folds a template partially, keeping the resolved prefix', () => {
const detections = scanRepo(
it('folds a template partially, keeping the resolved prefix', async () => {
const detections = await scanRepo(
{
'src/lib/axios.config.ts': AXIOS_CONFIG,
'src/api/routes.ts': API_ROUTES,
@ -131,8 +142,8 @@ describe('JS/TS HTTP consumer resolution', () => {
);
});
it('resolves a `+` concatenation against an imported base constant', () => {
const detections = scanRepo(
it('resolves a `+` concatenation against an imported base constant', async () => {
const detections = await scanRepo(
{
'src/lib/axios.config.ts': AXIOS_CONFIG,
'src/api/base.ts': `export const BASE = "/api/v1";`,
@ -150,8 +161,8 @@ describe('JS/TS HTTP consumer resolution', () => {
);
});
it('follows a barrel re-export to the defining module', () => {
const detections = scanRepo(
it('follows a barrel re-export to the defining module', async () => {
const detections = await scanRepo(
{
'src/lib/axios.config.ts': AXIOS_CONFIG,
'src/api/routes.ts': API_ROUTES,
@ -172,8 +183,8 @@ describe('JS/TS HTTP consumer resolution', () => {
// ─── Shapes real applications actually ship ────────────────────────
it('proves a client built by a factory wrapper, not just a bare axios.create', () => {
const detections = scanRepo(
it('proves a client built by a factory wrapper, not just a bare axios.create', async () => {
const detections = await scanRepo(
{
// The shape Sourcerer-fe ships: the instance is an argument to a
// decorator that returns the configured client.
@ -200,8 +211,8 @@ describe('JS/TS HTTP consumer resolution', () => {
);
});
it('follows `export *` through a directory barrel', () => {
const detections = scanRepo(
it('follows `export *` through a directory barrel', async () => {
const detections = await scanRepo(
{
'src/lib/axios.config.ts': AXIOS_CONFIG,
'src/api-modules/shared/api-routes.ts': API_ROUTES,
@ -224,8 +235,8 @@ describe('JS/TS HTTP consumer resolution', () => {
);
});
it('recognizes an aliased axios import', () => {
const detections = scanRepo(
it('recognizes an aliased axios import', async () => {
const detections = await scanRepo(
{
'src/lib/client.ts': `
import ax from 'axios';
@ -246,8 +257,8 @@ describe('JS/TS HTTP consumer resolution', () => {
);
});
it('keeps literal segments of a template nested inside a substitution', () => {
const detections = scanRepo(
it('keeps literal segments of a template nested inside a substitution', async () => {
const detections = await scanRepo(
{
'src/lib/axios.config.ts': AXIOS_CONFIG,
'src/api/routes.ts': API_ROUTES,
@ -266,8 +277,8 @@ describe('JS/TS HTTP consumer resolution', () => {
);
});
it('does not let a client built inside a callback vouch for the outer name', () => {
const detections = scanRepo(
it('does not let a client built inside a callback vouch for the outer name', async () => {
const detections = await scanRepo(
{
'src/thing.ts': `
const thing = configure(() => axios.create({ baseURL: '/' }));
@ -282,12 +293,13 @@ describe('JS/TS HTTP consumer resolution', () => {
// ─── Precision guards ──────────────────────────────────────────────
it('does NOT emit an Express provider route as a consumer of itself', () => {
const detections = scanRepo(
it('does NOT emit an Express provider route as a consumer of itself', async () => {
const detections = await scanRepo(
{
'src/server.ts': `
import express from 'express';
const router = express.Router();
const app = express();
router.get('/users', listUsers);
app.post('/orders', createOrder);
`,
@ -302,8 +314,8 @@ describe('JS/TS HTTP consumer resolution', () => {
);
});
it('does NOT claim an unproven receiver that merely has a .get method', () => {
const detections = scanRepo(
it('does NOT claim an unproven receiver that merely has a .get method', async () => {
const detections = await scanRepo(
{
'src/cache.ts': `
const cache = new Map<string, string>();
@ -317,8 +329,8 @@ describe('JS/TS HTTP consumer resolution', () => {
expect(consumers(detections)).toEqual([]);
});
it('refuses to resolve an import whose specifier matches two files', () => {
const detections = scanRepo(
it('refuses to resolve an import whose specifier matches two files', async () => {
const detections = await scanRepo(
{
'src/lib/axios.config.ts': AXIOS_CONFIG,
'a/shared/routes.ts': API_ROUTES,
@ -366,7 +378,7 @@ describe('JS/TS HTTP consumer resolution', () => {
// ─── Review findings: precision, termination and keying ────────────
it('keys the fact map the same way on a platform that hands it backslashes', () => {
it('keys the fact map the same way on a platform that hands it backslashes', async () => {
// glob v13 is called without `posix: true` and its walker joins with the
// platform separator, so on Windows every path here arrives backslashed.
const files = {
@ -379,13 +391,13 @@ describe('JS/TS HTTP consumer resolution', () => {
`,
};
expect(consumers(scanRepo(files, 'src\\api\\links.api.ts'))).toContainEqual(
expect(consumers(await scanRepo(files, 'src\\api\\links.api.ts'))).toContainEqual(
expect.objectContaining({ method: 'GET', path: '/links' }),
);
});
it('does NOT treat a container that merely HOLDS an axios instance as a client', () => {
const detections = scanRepo(
it('does NOT treat a container that merely HOLDS an axios instance as a client', async () => {
const detections = await scanRepo(
{
'src/stores.ts': `
import axios from 'axios';
@ -405,8 +417,8 @@ describe('JS/TS HTTP consumer resolution', () => {
expect(consumers(detections)).toEqual([]);
});
it('still proves the factory shape the containment rule existed for', () => {
const detections = scanRepo(
it('still proves the factory shape the containment rule existed for', async () => {
const detections = await scanRepo(
{
'src/lib/client.ts': `
import axios from 'axios';
@ -427,8 +439,8 @@ describe('JS/TS HTTP consumer resolution', () => {
);
});
it('refuses a resolved constant that is not path-shaped', () => {
const detections = scanRepo(
it('refuses a resolved constant that is not path-shaped', async () => {
const detections = await scanRepo(
{
'src/lib/axios.config.ts': AXIOS_CONFIG,
'src/api/strings.ts': `
@ -450,8 +462,8 @@ describe('JS/TS HTTP consumer resolution', () => {
expect(consumers(detections)).toEqual([]);
});
it('keeps an all-numeric path that is written as a path', () => {
const detections = scanRepo(
it('keeps an all-numeric path that is written as a path', async () => {
const detections = await scanRepo(
{
'src/lib/axios.config.ts': AXIOS_CONFIG,
'src/api/legacy.api.ts': `
@ -469,8 +481,8 @@ describe('JS/TS HTTP consumer resolution', () => {
);
});
it('refuses a path whose leading term never resolved', () => {
const detections = scanRepo(
it('refuses a path whose leading term never resolved', async () => {
const detections = await scanRepo(
{
'src/lib/axios.config.ts': AXIOS_CONFIG,
'src/api/unanchored.api.ts': `
@ -488,9 +500,9 @@ describe('JS/TS HTTP consumer resolution', () => {
expect(consumers(detections)).toEqual([]);
});
it('caps the folded output instead of building a path of unbounded length', () => {
it('caps the folded output instead of building a path of unbounded length', async () => {
const pad = 'a'.repeat(4000);
const detections = scanRepo(
const detections = await scanRepo(
{
'src/lib/axios.config.ts': AXIOS_CONFIG,
'src/api/big.api.ts': `
@ -507,7 +519,7 @@ describe('JS/TS HTTP consumer resolution', () => {
expect(consumers(detections)).toEqual([]);
});
it('terminates on expressions deep enough to overflow the stack', () => {
it('terminates on expressions deep enough to overflow the stack', async () => {
// `scan` is contractually non-throwing: `sync.ts` turns a throw here into an
// unexplained "missing repo" that silently drops every contract of every
// kind for that repo. Both shapes recursed once per term before this.
@ -518,12 +530,16 @@ describe('JS/TS HTTP consumer resolution', () => {
let nested = '`/x`';
for (let i = 0; i < 4000; i++) nested = '`${' + nested + '}`';
expect(() => scanRepo({ 'src/a.ts': `axios.get(${chain});` }, 'src/a.ts')).not.toThrow();
expect(() => scanRepo({ 'src/b.ts': `axios.get(${nested});` }, 'src/b.ts')).not.toThrow();
await expect(
scanRepo({ 'src/a.ts': `axios.get(${chain});` }, 'src/a.ts'),
).resolves.toBeDefined();
await expect(
scanRepo({ 'src/b.ts': `axios.get(${nested});` }, 'src/b.ts'),
).resolves.toBeDefined();
});
it('survives a file whose parse throws, and still resolves the rest of the repo', () => {
const detections = scanRepoWithParse(
it('survives a file whose parse throws, and still resolves the rest of the repo', async () => {
const detections = await scanRepoWithParse(
{
'src/lib/axios.config.ts': AXIOS_CONFIG,
'src/api/routes.ts': API_ROUTES,
@ -546,8 +562,8 @@ describe('JS/TS HTTP consumer resolution', () => {
);
});
it('sees an axios import declared below the binding that uses it', () => {
const detections = scanRepo(
it('sees an axios import declared below the binding that uses it', async () => {
const detections = await scanRepo(
{
// ES module bindings are hoisted, so this is legal and binds the same `ax`.
'src/lib/late.ts': `
@ -570,8 +586,8 @@ describe('JS/TS HTTP consumer resolution', () => {
);
});
it('keeps a partially folded path whose unresolved term contains spaces', () => {
const detections = scanRepo(
it('keeps a partially folded path whose unresolved term contains spaces', async () => {
const detections = await scanRepo(
{
'src/lib/axios.config.ts': AXIOS_CONFIG,
'src/api/routes.ts': API_ROUTES,
@ -605,9 +621,9 @@ describe('JS/TS HTTP consumer resolution', () => {
expect(consumers(detections).map((d) => d.path)).toEqual(['${API_BASE}/users', '${a}${b}']);
});
it('caps the literal fallback of an oversized template too', () => {
it('caps the literal fallback of an oversized template too', async () => {
const pad = 'a'.repeat(9000);
const detections = scanRepo(
const detections = await scanRepo(
{
'src/lib/axios.config.ts': AXIOS_CONFIG,
'src/api/big.api.ts': `
@ -621,8 +637,8 @@ describe('JS/TS HTTP consumer resolution', () => {
expect(consumers(detections)).toEqual([]);
});
it('proves a client handed to a factory inside a nested options object', () => {
const detections = scanRepo(
it('proves a client handed to a factory inside a nested options object', async () => {
const detections = await scanRepo(
{
'src/lib/client.ts': `
import axios from 'axios';
@ -649,8 +665,8 @@ describe('JS/TS HTTP consumer resolution', () => {
);
});
it('does NOT trust the spelling `axios` when the file binds that name itself', () => {
const detections = scanRepo(
it('does NOT trust the spelling `axios` when the file binds that name itself', async () => {
const detections = await scanRepo(
{
'src/shadow.ts': `
const axios = fakeFactory;
@ -671,7 +687,7 @@ describe('JS/TS HTTP consumer resolution', () => {
expect(consumers(detections)).toEqual([]);
expect(
consumers(
scanRepo(
await scanRepo(
{
'src/mock.ts': `
const axios = { create: () => ({ get: (u: string) => u }) };
@ -685,7 +701,7 @@ describe('JS/TS HTTP consumer resolution', () => {
).toEqual([]);
});
it('resolves a CommonJS require of axios, aliased or not', () => {
it('resolves a CommonJS require of axios, aliased or not', async () => {
const cjs = (local: string) => `
const ${local} = require('axios');
const api = ${local}.create({ baseURL: '/' });
@ -694,15 +710,15 @@ describe('JS/TS HTTP consumer resolution', () => {
`;
for (const local of ['axios', 'ax']) {
const detections = scanRepo({ 'src/cjs.ts': cjs(local) }, 'src/cjs.ts');
const detections = await scanRepo({ 'src/cjs.ts': cjs(local) }, 'src/cjs.ts');
expect(consumers(detections).map((d) => d.path)).toEqual(
expect.arrayContaining(['/instance', '/module']),
);
}
});
it('resolves the axios module used directly under an import alias', () => {
const detections = scanRepo(
it('resolves the axios module used directly under an import alias', async () => {
const detections = await scanRepo(
{
'src/aliased.ts': `
import ax from 'axios';
@ -717,8 +733,8 @@ describe('JS/TS HTTP consumer resolution', () => {
);
});
it('refuses a name two `export *` barrels both provide', () => {
const detections = scanRepo(
it('refuses a name two `export *` barrels both provide', async () => {
const detections = await scanRepo(
{
'src/lib/axios.config.ts': AXIOS_CONFIG,
'src/api/a.ts': `export const API_ROUTE_PATH = { LINKS: "/links-a" } as const;`,
@ -739,8 +755,8 @@ describe('JS/TS HTTP consumer resolution', () => {
expect(consumers(detections)).toEqual([]);
});
it('does NOT bind a Node builtin specifier to a same-named repo file', () => {
const detections = scanRepo(
it('does NOT bind a Node builtin specifier to a same-named repo file', async () => {
const detections = await scanRepo(
{
'src/lib/http.ts': `
import axios from 'axios';
@ -757,10 +773,10 @@ describe('JS/TS HTTP consumer resolution', () => {
expect(consumers(detections)).toEqual([]);
});
it('measures the pre-pass ceiling in bytes, not UTF-16 code units', () => {
it('measures the pre-pass ceiling in bytes, not UTF-16 code units', async () => {
// Under 512 Ki code units, over 512 KiB of UTF-8 — the ceiling mirrors the
// analyzer's byte-size limit, so this file must be skipped.
const detections = scanRepo(
const detections = await scanRepo(
{
'src/lib/huge.ts': `
import axios from 'axios';
@ -971,9 +987,9 @@ api.request({ url: '/api/users', method: 'POST' });
expect(detections[0]?.path).toBe('https://host/api/x');
});
it('admits axios.create instances calling .request', () => {
it('admits axios.create instances calling .request', async () => {
const detections = consumers(
scanRepo(
await scanRepo(
{
'src/lib/client.ts': `
import axios from 'axios';
@ -992,9 +1008,9 @@ api.request({ url: '/api/users', method: 'POST' });
);
});
it('admits member-verb calls with a gateway-prefixed template', () => {
it('admits member-verb calls with a gateway-prefixed template', async () => {
const detections = consumers(
scanRepo(
await scanRepo(
{
'src/lib/client.ts': `
import axios from 'axios';

View file

@ -325,8 +325,9 @@ describe('PARSE_CACHE_VERSION', () => {
// Moved 140 -> 141 for detached Go ownership captures used by rename (#3559).
// Moved 141 -> 142 to combine Go ownership with fetch-wrapper candidates (#3567).
// Moved 142 -> 143 to combine fetch-wrapper candidates with #3561 route corrections.
it('pins SCHEMA_BUMP to 143 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402, #3446, #3450, #3499, #3502, #3487, #3505, #3504, #3497, #3486, #3559, #3567, #3561)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(143);
// Moved 143 -> 144 for provider provenance facts and recomputed mounted paths.
it('pins SCHEMA_BUMP to 144 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402, #3446, #3450, #3499, #3502, #3487, #3505, #3504, #3497, #3486, #3559, #3567, #3561)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(144);
expect(PARSE_CACHE_BUCKET_COUNT).toBe(128);
// The PREVIOUS version must fail the reuse gate, not merely differ from the
// current one — a hardcoded number outside the conflict hunk rebases cleanly

View file

@ -33,6 +33,29 @@ function emptyResult(): ParseWorkerResult {
}
describe('mergeResult', () => {
it('retains opaque route facts across worker sub-batches', () => {
const target = emptyResult();
const first = { filePath: 'mount.ts', routePosition: 0, facts: { privateMountData: ['/api'] } };
const second = {
filePath: 'router.js',
routePosition: 0,
facts: { privateRegistrationData: ['/users'] },
};
mergeResult(target, { ...emptyResult(), routeFacts: [first] });
const declaration = {
filePath: 'middle.ts',
routePath: '/existing',
httpMethod: 'GET',
decoratorName: 'other',
lineNumber: 1,
};
mergeResult(target, { ...emptyResult(), decoratorRoutes: [declaration] });
mergeResult(target, { ...emptyResult(), routeFacts: [second] });
expect(target.routeFacts).toEqual([first, { ...second, routePosition: 1 }]);
expect(target.decoratorRoutes).toEqual([declaration]);
expect(second.routePosition).toBe(0);
});
it('retains wrapper candidates across sub-batches without treating them as HTTP sinks', () => {
const target = emptyResult();
const first = { filePath: 'a.ts', functionName: 'request', fetchURL: '/a', lineNumber: 1 };