feat(routes): support JS data route tables (#2972)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Classify release event (push) Waiting to run
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run

This commit is contained in:
azizur100389 2026-08-18 04:39:45 +01:00 committed by GitHub
parent fe3d7e56be
commit 7f0ab16ffe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 3332 additions and 75 deletions

View file

@ -724,6 +724,7 @@ jobs:
test/integration/cobol-pipeline-benchmark.test.ts
test/integration/csharp-pipeline-benchmark.test.ts
test/integration/cpp-adl-benchmark.test.ts
test/integration/data-route-table-benchmark.test.ts
test/integration/instance-ownership-pipeline-benchmark.test.ts
test/integration/spring-bean-resource-benchmark.test.ts
test/integration/rust-pipeline-benchmark.test.ts

View file

@ -98,7 +98,7 @@ scan → structure → [springConfig, markdown, cobol] → parse → [routes, to
| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx |
| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) |
| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries |
| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators, and JS/TS dispatch guards — see below) |
| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators, and JS/TS static route sources — see below) |
| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges |
| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) |
| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order |
@ -174,7 +174,7 @@ converging on the routes phase's `(method, url)` registry:
| Filesystem convention | path → URL, no parsing | Next.js `app/`, Expo, PHP |
| 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, **JS/TS dispatch guards** |
| AST-level route in a normal file | `extractDecoratorRoutes` | Spring, FastAPI, NestJS, **JS/TS dispatch guards and static data route tables** |
The last row 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
@ -185,6 +185,15 @@ handler resolution are shared with decorator routes, and
`ExtractedDecoratorRoute.source` carries the provenance difference through to
the `HANDLES_ROUTE` edge.
JS/TS data route tables share that transport when a route-named array contains
direct object literals with static `path`, `method`, and `handler` fields and a
same-scope `for...of` dispatcher positively compares the path and method before
directly invoking the handler. Dynamic values, computed keys, spreads,
inline/called handlers, unknown verbs, and ambiguous handler bindings are
suppressed. Bare import aliases and single-level member handlers are attributed
only through declared import and owner provenance; an unproven receiver never
falls back to a global name guess.
That extractor is deliberately **precision-weighted**: `route_map` presents its
output as fact, so a `startsWith` namespace test, a bare `pathname === '/'`
without a verb, and any regex it cannot translate exactly are all dropped rather

View file

@ -10,6 +10,10 @@ import {
type PatternSpec,
} from '../tree-sitter-scanner.js';
import type { HttpDetection, HttpLanguagePlugin } from './types.js';
import {
DATA_ROUTE_TABLE_SOURCE,
scanDataRouteTables,
} from '../../../ingestion/route-extractors/data-route-table.js';
/**
* Node.js / TypeScript HTTP plugin family. Handles:
@ -544,6 +548,26 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
});
}
for (const route of scanDataRouteTables(tree)) {
const imported =
route.handlerLocalName === undefined ? undefined : importMap.get(route.handlerLocalName);
out.push({
role: 'provider',
framework: DATA_ROUTE_TABLE_SOURCE,
method: route.method,
path: route.path,
// A source-only scan can prove a bare local/imported binding. Member
// ownership needs the semantic model, so leave it unattributed here;
// the graph-backed path consumes the exact handlerSymbolId later.
name: imported?.name ?? (route.handlerLocalName === undefined ? null : route.handlerName),
...(imported === undefined ? {} : { handlerImport: imported }),
strictHandlerResolution: true,
...(route.handlerLocalName === undefined ? { unresolvedHandler: true } : {}),
line: route.line,
confidence: 0.8,
});
}
return out;
}

View file

@ -56,6 +56,14 @@ export interface HttpDetection {
* locally-defined or anonymous handlers.
*/
handlerImport?: { name: string; module: string };
/** Resolve only from the registration file or exact import target; never guess repo-wide. */
strictHandlerResolution?: boolean;
/**
* The plugin saw a provider handler designator but could not prove its owner.
* Prevents the orchestrator from treating it as an anonymous inline handler
* and attributing it to the containing registrar function.
*/
unresolvedHandler?: boolean;
/** Confidence in (0, 1]. Source-scan plugins typically use 0.70.8. */
confidence: number;
}

View file

@ -8,6 +8,7 @@ import { readSafe } from './fs-utils.js';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import { toZeroBasedLine } from '../../ingestion/utils/line-base.js';
import { logger } from '../../logger.js';
import { DATA_ROUTE_TABLE_SOURCE } from '../../ingestion/route-extractors/data-route-table.js';
import {
getPluginForFile,
HTTP_SCAN_GLOB,
@ -115,7 +116,7 @@ LIMIT 2`;
// Resolve an IMPORTED handler by pinning it to the import's target module: the
// declared export `$name` whose file is the module the handler was imported from
// (`$fileDot` matches `mod.ext`, `$fileSlash` matches `mod/index.ext`). This is
// (`$filePaths` contains exact source-file and directory-index candidates). This is
// the precise rung — it survives aliases and local same-name collisions that a
// repo-wide name lookup cannot, and only resolves on a unique match within that
// module. `LIMIT 2` keeps the uniqueness count exact (see RESOLVE_BY_NAME_QUERY).
@ -129,13 +130,22 @@ MATCH (n) WHERE labels(n) IN ['Function','Method','CodeElement']
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
LIMIT 2`;
// determinism: probe — uniqueness discriminator, not a window. The consumer
// accepts exactly one row and rejects a 2-row result whole, so row identity
// cannot affect the resolution decision.
export const RESOLVE_IN_EXACT_MODULE_QUERY = `
MATCH (n) WHERE labels(n) IN ['Function','Method','CodeElement']
AND n.name = $name AND n.filePath IN $filePaths
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
LIMIT 2`;
// Source-file extensions an import specifier may resolve to (stripped before
// building the module file-prefix so `./h/users` and `./h/users.ts` agree).
const SOURCE_EXT_RE = /\.(?:m|c)?[jt]sx?$/;
/**
* Resolve an import specifier to a repo-relative FILE BASE (path without
* extension) so the target module can be matched by `filePath STARTS WITH`.
* extension) so exact target-file candidates can be constructed.
* Handles two relative-import dialects and returns null for bare/absolute
* imports (which fall back to a repo-wide name lookup):
* - path-style (JS/TS): `./handlers/users`, `../x` joined against the
@ -161,6 +171,27 @@ function resolveModuleBase(fromFile: string, module: string): string | null {
return null; // bare / absolute import — repo-wide fallback
}
const MODULE_SOURCE_EXTENSIONS = [
'.ts',
'.tsx',
'.mts',
'.cts',
'.js',
'.jsx',
'.mjs',
'.cjs',
'.py',
];
function moduleFileCandidates(base: string): string[] {
return [
...MODULE_SOURCE_EXTENSIONS.map((extension) => `${base}${extension}`),
...MODULE_SOURCE_EXTENSIONS.map((extension) =>
extension === '.py' ? `${base}/__init__.py` : `${base}/index${extension}`,
),
];
}
interface ResolvedSymbol {
uid: string;
name: string;
@ -229,6 +260,17 @@ function resolveSymbolByName(rows: Record<string, unknown>[], name: string): Res
return null;
}
function resolveFileSymbolByNameUnique(
rows: Record<string, unknown>[],
name: string,
): ResolvedSymbol | null {
const matches = rows
.map((row) => resolveSymbolByName([row], name))
.filter((match): match is ResolvedSymbol => match !== null);
const byUid = new Map(matches.map((match) => [match.uid, match]));
return byUid.size === 1 ? (byUid.values().next().value ?? null) : null;
}
// ─── Path normalization (shared between provider / consumer paths) ──
/**
@ -484,28 +526,29 @@ export class HttpRouteExtractor implements ContractExtractor {
globalNameCache.set(name, result);
return result;
};
// Resolve a handler imported from a RELATIVE module to the unique declared
// symbol of that name inside the import's target file. Returns null for
// non-relative (bare/aliased-path) imports — those fall back to the repo-wide
// name lookup. Cached by (target-file-prefix, declared name).
// Resolve a handler imported from a relative module to the unique declared
// symbol inside its target file. The caller decides whether a miss may use
// the historical unique repository-wide fallback.
const importedSymbolCache = new Map<string, ResolvedSymbol | null>();
const resolveImportedSymbol = async (
fromFile: string,
imp: { name: string; module: string },
strict = false,
): Promise<ResolvedSymbol | null> => {
if (!dbExecutor) return null;
const base = resolveModuleBase(fromFile, imp.module);
if (base === null) return null; // bare/absolute import → repo-wide fallback
const cacheKey = JSON.stringify([base, imp.name]);
if (base === null) return null;
const cacheKey = JSON.stringify([base, imp.name, strict]);
const cached = importedSymbolCache.get(cacheKey);
if (cached !== undefined) return cached;
let rows: Record<string, unknown>[] = [];
try {
rows = await dbExecutor(RESOLVE_IN_MODULE_QUERY, {
name: imp.name,
fileDot: `${base}.`,
fileSlash: `${base}/`,
});
rows = await dbExecutor(
strict ? RESOLVE_IN_EXACT_MODULE_QUERY : RESOLVE_IN_MODULE_QUERY,
strict
? { name: imp.name, filePaths: moduleFileCandidates(base) }
: { name: imp.name, fileDot: `${base}.`, fileSlash: `${base}/` },
);
} catch {
rows = [];
}
@ -518,6 +561,7 @@ export class HttpRouteExtractor implements ContractExtractor {
d: HttpDetection,
): Promise<ResolvedSymbol | null> => {
if (!dbExecutor) return null;
if (d.role === 'provider' && d.unresolvedHandler) return null;
const syms = await loadFileSymbols(filePath);
// Name resolution does NOT need a detection line — a named provider
// handler (Spring/Go/etc. method name) resolves by name even when the
@ -531,14 +575,20 @@ export class HttpRouteExtractor implements ContractExtractor {
// its (declared) name would be wrong; on a miss go straight to a unique
// repo-wide match on the declared name, never file-scoped.
if (d.handlerImport) {
const byImport = await resolveImportedSymbol(filePath, d.handlerImport);
const byImport = await resolveImportedSymbol(
filePath,
d.handlerImport,
d.strictHandlerResolution,
);
if (byImport) return byImport;
const byGlobal = await resolveSymbolByNameUnique(d.handlerImport.name);
if (byGlobal) return byGlobal;
return null;
if (d.strictHandlerResolution) return null;
return resolveSymbolByNameUnique(d.handlerImport.name);
}
const byName = resolveSymbolByName(syms, d.name);
const byName = d.strictHandlerResolution
? resolveFileSymbolByNameUnique(syms, d.name)
: resolveSymbolByName(syms, d.name);
if (byName) return byName;
if (d.strictHandlerResolution) return null;
const byGlobal = await resolveSymbolByNameUnique(d.name);
if (byGlobal) return byGlobal;
// A NAMED handler we could not resolve by name (neither file-scoped nor
@ -789,7 +839,12 @@ export class HttpRouteExtractor implements ContractExtractor {
getDetections: (rel: string) => Promise<HttpDetection[]>,
resolveSymbol: (filePath: string, d: HttpDetection) => Promise<ResolvedSymbol | null>,
): Promise<ExtractedContract[]> {
const out: ExtractedContract[] = [];
const candidates: Array<{
detection: HttpDetection;
filePath: string;
pathNorm: string;
resolved: ResolvedSymbol | null;
}> = [];
for (const rel of files) {
const detections = await getDetections(rel);
const filePath = normalizeRepoRelPath(rel);
@ -800,27 +855,57 @@ export class HttpRouteExtractor implements ContractExtractor {
// arrow that encloses the registration line) so the contract carries a
// real symbolUid; fall back to the file + detection name otherwise.
const resolved = await resolveSymbol(filePath, d);
out.push({
contractId: contractIdFor(d.method, pathNorm),
type: 'http',
role: 'provider',
symbolUid: resolved?.uid ?? '',
symbolRef: {
filePath: resolved?.filePath || filePath,
name: resolved?.name ?? d.name ?? 'handler',
},
symbolName: resolved?.name ?? d.name ?? 'handler',
confidence: d.confidence,
meta: {
method: d.method,
path: pathNorm,
pathSegments: pathNorm.split('/').filter(Boolean),
extractionStrategy: resolved ? 'source_scan_resolved' : 'source_scan',
framework: d.framework,
},
});
candidates.push({ detection: d, filePath, pathNorm, resolved });
}
}
const dataCandidatesByIdentity = new Map<string, typeof candidates>();
for (const candidate of candidates) {
if (candidate.detection.framework !== DATA_ROUTE_TABLE_SOURCE) continue;
const identity = contractIdFor(candidate.detection.method, candidate.pathNorm);
const grouped = dataCandidatesByIdentity.get(identity) ?? [];
grouped.push(candidate);
dataCandidatesByIdentity.set(identity, grouped);
}
const ambiguousDataIdentities = new Set<string>();
for (const [identity, grouped] of dataCandidatesByIdentity) {
if (grouped.length < 2) continue;
const resolvedIds = new Set(
grouped.flatMap((candidate) =>
candidate.resolved === null ? [] : [candidate.resolved.uid],
),
);
if (grouped.some((candidate) => candidate.resolved === null) || resolvedIds.size !== 1) {
ambiguousDataIdentities.add(identity);
}
}
const out: ExtractedContract[] = [];
for (const { detection: d, filePath, pathNorm, resolved } of candidates) {
const contractId = contractIdFor(d.method, pathNorm);
if (d.framework === DATA_ROUTE_TABLE_SOURCE && ambiguousDataIdentities.has(contractId)) {
continue;
}
out.push({
contractId,
type: 'http',
role: 'provider',
symbolUid: resolved?.uid ?? '',
symbolRef: {
filePath: resolved?.filePath || filePath,
name: resolved?.name ?? d.name ?? 'handler',
},
symbolName: resolved?.name ?? d.name ?? 'handler',
confidence: d.confidence,
meta: {
method: d.method,
path: pathNorm,
pathSegments: pathNorm.split('/').filter(Boolean),
extractionStrategy: resolved ? 'source_scan_resolved' : 'source_scan',
framework: d.framework,
},
});
}
return this.dedupeContracts(out);
}

View file

@ -18,7 +18,7 @@
import { KnowledgeGraph } from '../graph/types.js';
import type { SemanticModel, SymbolTableReader } from './model/index.js';
import { generateId } from '../../lib/utils.js';
import type { SymbolDefinition } from 'gitnexus-shared';
import type { ParsedImport, SymbolDefinition } from 'gitnexus-shared';
import { yieldToEventLoop } from './utils/event-loop.js';
import type { ExtractedRoute, ExtractedFetchCall } from './workers/parse-worker.js';
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
@ -29,6 +29,7 @@ import {
routeNodeKey,
} from './route-extractors/route-path.js';
import { extractReturnTypeName } from './type-extractors/shared.js';
import { DATA_ROUTE_TABLE_SOURCE } from './route-extractors/data-route-table.js';
const MAX_EXPORTS_PER_FILE = 500;
const MAX_TYPE_NAME_LENGTH = 256;
@ -37,6 +38,18 @@ const MAX_TYPE_NAME_LENGTH = 256;
* Consumed by the cross-file re-resolution / enrichment pass. */
export type ExportedTypeMap = Map<string, Map<string, string>>;
interface RouteResolutionFile {
readonly filePath: string;
readonly parsedImports: readonly ParsedImport[];
readonly localDefs: readonly SymbolDefinition[];
}
interface RouteHandlerResolutionContext {
readonly files: readonly RouteResolutionFile[];
readonly resolveImportTarget: (parsedImport: ParsedImport, fromFile: string) => string | null;
readonly isExportedSymbol: (nodeId: string) => boolean;
}
/** Record one exported graph node into the incremental ExportedTypeMap. */
export const accumulateExportedTypesFromParsedNode = (
result: ExportedTypeMap,
@ -281,6 +294,7 @@ export function resolveRouteHandlerSymbols(
model: SemanticModel,
extractedRoutes: readonly ExtractedRoute[],
decoratorRoutes: readonly ExtractedDecoratorRoute[],
routeContext?: RouteHandlerResolutionContext,
): Map<string, string> {
const out = new Map<string, string>();
// Route identities already claimed by an earlier route (resolved or not).
@ -295,6 +309,97 @@ export function resolveRouteHandlerSymbols(
return defs.length === 1 ? defs[0]?.nodeId : undefined;
};
const uniqueById = (defs: readonly SymbolDefinition[]): SymbolDefinition | undefined => {
const byId = new Map(defs.map((def) => [def.nodeId, def]));
return byId.size === 1 ? byId.values().next().value : undefined;
};
const routeCallables = (defs: readonly SymbolDefinition[]): readonly SymbolDefinition[] =>
defs.filter((def) => def.type === 'Function' || def.type === 'Method');
const exportedRouteCallables = (
defs: readonly SymbolDefinition[],
): readonly SymbolDefinition[] =>
routeContext === undefined
? []
: routeCallables(defs).filter((def) => routeContext.isExportedSymbol(def.nodeId));
const filesByPath = new Map(routeContext?.files.map((file) => [file.filePath, file]) ?? []);
const uniqueImport = (filePath: string, localName: string): ParsedImport | undefined => {
const matches = (filesByPath.get(filePath)?.parsedImports ?? []).filter(
(parsedImport) =>
'localName' in parsedImport &&
parsedImport.localName === localName &&
parsedImport.kind !== 'dynamic-unresolved',
);
return matches.length === 1 ? matches[0] : undefined;
};
const importedTarget = (
filePath: string,
localName: string,
): { parsedImport: ParsedImport; targetFile: string } | undefined => {
if (routeContext === undefined) return undefined;
const parsedImport = uniqueImport(filePath, localName);
if (parsedImport === undefined) return undefined;
const targetFile = routeContext.resolveImportTarget(parsedImport, filePath);
return targetFile === null ? undefined : { parsedImport, targetFile };
};
const resolveDataRouteHandler = (filePath: string, designator: string): string | undefined => {
const parts = designator.split('.');
if (parts.length === 1) {
const local = uniqueById(routeCallables(model.symbols.lookupExactAll(filePath, designator)));
if (local !== undefined) return local.nodeId;
const imported = importedTarget(filePath, designator);
if (
imported === undefined ||
imported.parsedImport.kind === 'namespace' ||
imported.parsedImport.kind === 'wildcard' ||
!('importedName' in imported.parsedImport)
) {
return undefined;
}
if (imported.parsedImport.importedName === 'default') {
// ParsedFile does not carry explicit default-export provenance. Fail
// closed rather than infer an unrelated named export from the module.
return undefined;
}
return uniqueById(
exportedRouteCallables(
model.symbols.lookupExactAll(imported.targetFile, imported.parsedImport.importedName),
),
)?.nodeId;
}
if (parts.length !== 2) return undefined;
const [receiver, member] = parts;
const localOwner = uniqueById(model.symbols.lookupExactAll(filePath, receiver));
if (localOwner !== undefined) {
return uniqueById(model.methods.lookupAllByOwner(localOwner.nodeId, member))?.nodeId;
}
const imported = importedTarget(filePath, receiver);
if (imported === undefined || imported.parsedImport.kind === 'wildcard') return undefined;
if (imported.parsedImport.kind === 'namespace') {
return uniqueById(
exportedRouteCallables(model.symbols.lookupExactAll(imported.targetFile, member)),
)?.nodeId;
}
if (!('importedName' in imported.parsedImport)) return undefined;
if (imported.parsedImport.importedName === 'default') return undefined;
const owner = uniqueById(
model.symbols
.lookupExactAll(imported.targetFile, imported.parsedImport.importedName)
.filter((def) => routeContext?.isExportedSymbol(def.nodeId) === true),
);
return owner === undefined
? undefined
: uniqueById(model.methods.lookupAllByOwner(owner.nodeId, member))?.nodeId;
};
const claim = (
routePath: string | null,
prefix: string | null,
@ -326,10 +431,49 @@ export function resolveRouteHandlerSymbols(
claim(route.routePath, route.prefix ?? null, route.httpMethod, methodId);
}
// Decorator routes (Spring / FastAPI / generic) — the decorated handler in
// the route's own file.
const dataHandlerByRoute = new Map<ExtractedDecoratorRoute, string>();
const dataHandlersByIdentity = new Map<
string,
{ handlers: Set<string>; hasUnresolved: boolean }
>();
for (const dr of decoratorRoutes) {
const handlerId = dr.handlerName ? uniqueSymbolId(dr.filePath, dr.handlerName) : undefined;
if (dr.source !== DATA_ROUTE_TABLE_SOURCE || !dr.handlerName || !dr.routePath) continue;
const handlerId = resolveDataRouteHandler(dr.filePath, dr.handlerName);
const url = normalizeExtractedRoutePath(dr.routePath, dr.prefix ?? null);
const key = routeNodeKey(normalizeRouteMethod(dr.httpMethod), url);
const state = dataHandlersByIdentity.get(key) ?? {
handlers: new Set<string>(),
hasUnresolved: false,
};
if (handlerId === undefined) {
state.hasUnresolved = true;
} else {
dataHandlerByRoute.set(dr, handlerId);
state.handlers.add(handlerId);
}
dataHandlersByIdentity.set(key, state);
}
// Decorator routes (Spring / FastAPI / generic) — the decorated handler in
// the route's own file. Data tables additionally suppress an identity when
// duplicate entries resolve to different handlers: recording either one
// would invent a single-winner dispatch that the loop does not prove.
for (const dr of decoratorRoutes) {
const handlerId =
dr.source === DATA_ROUTE_TABLE_SOURCE
? dataHandlerByRoute.get(dr)
: dr.handlerName
? uniqueSymbolId(dr.filePath, dr.handlerName)
: undefined;
// An unproven data-table entry never becomes a Route node, so it must not
// reserve the identity and suppress a later, valid framework declaration.
if (dr.source === DATA_ROUTE_TABLE_SOURCE && handlerId === undefined) continue;
if (dr.source === DATA_ROUTE_TABLE_SOURCE && dr.routePath) {
const url = normalizeExtractedRoutePath(dr.routePath, dr.prefix ?? null);
const key = routeNodeKey(normalizeRouteMethod(dr.httpMethod), url);
const state = dataHandlersByIdentity.get(key);
if (state === undefined || state.hasUnresolved || state.handlers.size !== 1) continue;
}
claim(dr.routePath, dr.prefix ?? null, dr.httpMethod, handlerId);
}

View file

@ -125,6 +125,12 @@ import {
jsArityCompatibility,
} from './javascript/index.js';
import { extractDispatchGuardRoutes } from '../route-extractors/dispatch-guard.js';
import { extractDataRouteTableRoutes } from '../route-extractors/data-route-table.js';
const extractJsTsRoutes = (...args: Parameters<typeof extractDispatchGuardRoutes>) => [
...extractDispatchGuardRoutes(...args),
...extractDataRouteTableRoutes(...args),
];
/**
* TypeScript/JavaScript: arrow_function and function_expression are
@ -458,7 +464,7 @@ export const typescriptProvider = defineLanguage({
// A raw `node:http` server declares its routes by comparing the request path
// to a literal; nothing else in this pipeline can see that shape. TS and JS
// share the grammar, so they share the extractor.
extractDecoratorRoutes: extractDispatchGuardRoutes,
extractDecoratorRoutes: extractJsTsRoutes,
});
export const javascriptProvider = defineLanguage({
@ -532,5 +538,5 @@ export const javascriptProvider = defineLanguage({
receiverBinding: jsReceiverBinding,
arityCompatibility: jsArityCompatibility,
// See the TypeScript provider above.
extractDecoratorRoutes: extractDispatchGuardRoutes,
extractDecoratorRoutes: extractJsTsRoutes,
});

View file

@ -31,6 +31,7 @@ import {
import {
clearParsedFileStore,
persistParsedFileChunk,
loadParsedFilesForPaths,
getDurableParsedFileDir,
loadDurableParsedFileIndex,
prepareDurableParsedFileChunk,
@ -49,6 +50,7 @@ import { createSemanticModel, type MutableSemanticModel } from '../model/index.j
import {
type PipelineProgress,
getLanguageFromFilename,
type ParsedImport,
SupportedLanguages,
} from 'gitnexus-shared';
import { readFileContents } from '../filesystem-walker.js';
@ -59,6 +61,8 @@ import {
} from '../../tree-sitter/parser-loader.js';
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import { getProvider, providers } from '../languages/index.js';
import { SCOPE_RESOLVERS } from '../scope-resolution/pipeline/registry.js';
import { DATA_ROUTE_TABLE_SOURCE } from '../route-extractors/data-route-table.js';
import type Parser from 'tree-sitter';
import {
createWorkerPool,
@ -1521,12 +1525,69 @@ export async function runChunkedParseAndResolve(
'parse-impl-return',
`exportedTypeMap=${exportedTypeMap.size} parsedFiles=${allParsedFiles.length} nodes=${graph.nodeCount}`,
);
const routeFilePaths = new Set(allPaths);
const dataRouteFilePaths = new Set(
allDecoratorRoutes
.filter((route) => route.source === DATA_ROUTE_TABLE_SOURCE)
.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;
const resolver = SCOPE_RESOLVERS.get(language);
routeResolutionConfigs.set(
language,
resolver?.loadResolutionConfig === undefined
? undefined
: await resolver.loadResolutionConfig(repoPath),
);
}
let routeResolutionFiles = allParsedFiles;
const resolveRouteImportTarget = (
parsedImport: ParsedImport,
fromFile: string,
): string | null => {
const language = getLanguageFromFilename(fromFile);
if (language === null) return null;
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?.length === 1 ? target[0] : null;
};
if (parsedFileStorePath !== undefined && dataRouteFilePaths.size > 0) {
const byPath = await loadParsedFilesForPaths(parsedFileStorePath, dataRouteFilePaths);
for (const parsed of allParsedFiles) {
if (dataRouteFilePaths.has(parsed.filePath)) byPath.set(parsed.filePath, parsed);
}
routeResolutionFiles = [...byPath.values()];
const directTargets = new Set<string>();
for (const parsed of routeResolutionFiles) {
for (const parsedImport of parsed.parsedImports) {
const target = resolveRouteImportTarget(parsedImport, parsed.filePath);
if (target !== null) directTargets.add(target);
}
}
const importedFiles = await loadParsedFilesForPaths(parsedFileStorePath, directTargets);
for (const parsed of importedFiles.values()) byPath.set(parsed.filePath, parsed);
routeResolutionFiles = [...byPath.values()];
}
// Part 2 (#2138): resolve each route's handler to a real symbol UID now that
// the model is fully populated and decorator-route prefixes are finalized.
const routeHandlerSymbols = resolveRouteHandlerSymbols(
model,
allExtractedRoutes,
allDecoratorRoutes,
{
files: routeResolutionFiles,
resolveImportTarget: resolveRouteImportTarget,
isExportedSymbol: (nodeId: string) => graph.getNode(nodeId)?.properties.isExported === true,
},
);
return {
exportedTypeMap,

View file

@ -30,6 +30,7 @@ import {
} from '../route-extractors/middleware.js';
import { processNextjsFetchRoutes } from '../call-processor.js';
import { reconcileDispatchGuardRoutes } from '../route-extractors/dispatch-guard.js';
import { DATA_ROUTE_TABLE_SOURCE } from '../route-extractors/data-route-table.js';
import {
normalizeExtractedRoutePath,
normalizeRouteMethod,
@ -76,6 +77,29 @@ export interface TemplateFetchCall {
lineNumber: number;
}
function handlerSymbolContent(
content: string,
handlerNode: { properties: Record<string, unknown> } | undefined,
): string | undefined {
if (handlerNode === undefined) return undefined;
const startLine = handlerNode.properties.startLine;
const endLine = handlerNode.properties.endLine;
if (
typeof startLine !== 'number' ||
typeof endLine !== 'number' ||
!Number.isInteger(startLine) ||
!Number.isInteger(endLine) ||
startLine < 0 ||
endLine < startLine
) {
return undefined;
}
return content
.split(/\r?\n/)
.slice(startLine, endLine + 1)
.join('\n');
}
const TEMPLATE_URL_PATTERNS: readonly RegExp[] = [
/\b(?:action|href)\s*=\s*["']([^"']+)["']/gi,
/\burl\s*:\s*["']([^"']+)["'](?!\s*\+)/g,
@ -255,36 +279,57 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
// their verb-less form is a declaration, not a weaker observation.
for (const dr of reconcileDispatchGuardRoutes(allDecoratorRoutes)) {
const url = normalizeExtractedRoutePath(dr.routePath, dr.prefix ?? null);
const method = normalizeRouteMethod(dr.httpMethod);
const routeKey = routeNodeKey(method, url);
// A data-table entry is only a provider route once its static handler has
// been proven. Other route sources retain their historical fallback.
if (dr.source === DATA_ROUTE_TABLE_SOURCE && !routeHandlerSymbols.has(routeKey)) continue;
addRoute(url, {
filePath: dr.filePath,
// A route extracted from a file's own AST is usually a decorator; a
// dispatch guard is the same transport with different provenance, and
// says so (`ExtractedDecoratorRoute.source`).
source: dr.source ?? `decorator-${dr.decoratorName}`,
method: normalizeRouteMethod(dr.httpMethod),
method,
});
}
let handlerContents: Map<string, string> | undefined;
if (routeRegistry.size > 0) {
const handlerPaths = [...routeRegistry.values()].map((e) => e.filePath);
const handlerPathFor = (routeKey: string, entry: RouteEntry): string => {
if (entry.source !== DATA_ROUTE_TABLE_SOURCE) return entry.filePath;
const handlerSymbolId = routeHandlerSymbols.get(routeKey);
const resolvedPath = handlerSymbolId
? ctx.graph.getNode(handlerSymbolId)?.properties.filePath
: undefined;
return typeof resolvedPath === 'string' ? resolvedPath : entry.filePath;
};
const handlerPaths = [...routeRegistry].map(([key, entry]) => handlerPathFor(key, entry));
handlerContents = await readFileContents(ctx.repoPath, handlerPaths);
for (const [routeKey, entry] of routeRegistry) {
const { filePath: handlerPath, source: routeSource, method: routeMethod, url } = entry;
const { source: routeSource, method: routeMethod, url } = entry;
const handlerPath = handlerPathFor(routeKey, entry);
const content = handlerContents.get(handlerPath);
const handlerSymbolId = routeHandlerSymbols.get(routeKey);
const analysisContent =
entry.source === DATA_ROUTE_TABLE_SOURCE && content
? handlerSymbolContent(
content,
handlerSymbolId ? ctx.graph.getNode(handlerSymbolId) : undefined,
)
: content;
const { responseKeys, errorKeys } = content
const { responseKeys, errorKeys } = analysisContent
? handlerPath.endsWith('.php')
? extractPHPResponseShapes(content)
: extractResponseShapes(content)
? extractPHPResponseShapes(analysisContent)
: extractResponseShapes(analysisContent)
: { responseKeys: undefined, errorKeys: undefined };
const mwResult = content ? extractMiddlewareChain(content) : undefined;
const mwResult = analysisContent ? extractMiddlewareChain(analysisContent) : undefined;
const middleware = mwResult?.chain;
const routeNodeId = generateId('Route', routeKey);
const handlerSymbolId = routeHandlerSymbols.get(routeKey);
ctx.graph.addNode({
id: routeNodeId,
label: 'Route',

View file

@ -0,0 +1,959 @@
/**
* Conservative extraction for explicit JavaScript-style route tables.
*
* A generic object containing `path`, `method`, and `handler` can also be an
* HTTP client request descriptor. Extraction therefore requires both a
* route-named binding and a static `for (... of table)` dispatch loop whose
* request guard compares the entry's path and method before directly invoking
* its handler.
*/
import type Parser from 'tree-sitter';
import type { SyntaxNode } from 'tree-sitter';
import type { ExtractedDecoratorRoute } from '../workers/parse-worker.js';
export const DATA_ROUTE_TABLE_SOURCE = 'data-route-table';
const ROUTE_BINDING_HINT = /route/i;
const ROUTE_TOKEN_HINTS = ['path', 'method', 'handler'] as const;
const HTTP_METHODS: ReadonlySet<string> = new Set([
'GET',
'POST',
'PUT',
'PATCH',
'DELETE',
'HEAD',
'OPTIONS',
]);
export interface DataRouteTableRoute {
path: string;
method: string;
/** Full static handler designator, e.g. `auth.getCurrentUser`. */
handlerDesignator: string;
handlerName: string;
/** Present only for a bare handler identifier, for named-import resolution. */
handlerLocalName?: string;
line: number;
}
const SIMPLE_STRING_ESCAPES: Readonly<Record<string, string>> = {
b: '\b',
f: '\f',
n: '\n',
r: '\r',
t: '\t',
v: '\v',
};
function decodeJavaScriptStringLiteral(raw: string): string | null {
if (raw.length < 2) return null;
const delimiter = raw[0];
if (
(delimiter !== "'" && delimiter !== '"' && delimiter !== '`') ||
raw[raw.length - 1] !== delimiter
) {
return null;
}
const body = raw.slice(1, -1);
let decoded = '';
for (let index = 0; index < body.length; index++) {
const char = body[index];
if (char !== '\\') {
if (delimiter !== '`' && (char === '\n' || char === '\r')) return null;
decoded += char;
continue;
}
const escaped = body[++index];
if (escaped === undefined) return null;
if (escaped === '\n' || escaped === '\u2028' || escaped === '\u2029') continue;
if (escaped === '\r') {
if (body[index + 1] === '\n') index++;
continue;
}
const simple = SIMPLE_STRING_ESCAPES[escaped];
if (simple !== undefined) {
decoded += simple;
continue;
}
if (escaped === '0') {
if (/\d/.test(body[index + 1] ?? '')) return null;
decoded += '\0';
continue;
}
if (/[1-9]/.test(escaped)) return null;
if (escaped === 'x') {
const hex = body.slice(index + 1, index + 3);
if (!/^[0-9A-Fa-f]{2}$/.test(hex)) return null;
decoded += String.fromCharCode(Number.parseInt(hex, 16));
index += 2;
continue;
}
if (escaped === 'u') {
if (body[index + 1] === '{') {
const close = body.indexOf('}', index + 2);
if (close === -1) return null;
const hex = body.slice(index + 2, close);
if (!/^[0-9A-Fa-f]{1,6}$/.test(hex)) return null;
const codePoint = Number.parseInt(hex, 16);
if (codePoint > 0x10ffff) return null;
decoded += String.fromCodePoint(codePoint);
index = close;
continue;
}
const hex = body.slice(index + 1, index + 5);
if (!/^[0-9A-Fa-f]{4}$/.test(hex)) return null;
decoded += String.fromCharCode(Number.parseInt(hex, 16));
index += 4;
continue;
}
// JavaScript treats an escaped non-special character as that character.
decoded += escaped;
}
return decoded;
}
function plainString(node: SyntaxNode): string | null {
if (node.type === 'string') return decodeJavaScriptStringLiteral(node.text);
if (
node.type === 'template_string' &&
node.namedChildren.every(
(child) => child.type === 'string_fragment' || child.type === 'escape_sequence',
)
) {
return decodeJavaScriptStringLiteral(node.text);
}
return null;
}
function propertyName(node: SyntaxNode): string | null {
if (node.type === 'identifier' || node.type === 'property_identifier') return node.text;
if (node.type === 'string') return plainString(node);
return null;
}
function handlerName(
node: SyntaxNode,
): { designator: string; name: string; localName?: string } | null {
if (node.type === 'identifier') {
return { designator: node.text, name: node.text, localName: node.text };
}
if (node.type !== 'member_expression') return null;
const object = node.childForFieldName('object');
const property = node.childForFieldName('property');
if (
object?.type !== 'identifier' ||
property === null ||
property.type !== 'property_identifier'
) {
return null;
}
return {
designator: `${object.text}.${property.text}`,
name: property.text,
};
}
const EXECUTING_VALUE_NODES = new Set([
'assignment_expression',
'augmented_assignment_expression',
'await_expression',
'call_expression',
'new_expression',
'spread_element',
'update_expression',
'yield_expression',
]);
function containsExecutingExpression(node: SyntaxNode): boolean {
if (EXECUTING_VALUE_NODES.has(node.type)) return true;
if (node.type === 'unary_expression' && node.text.trimStart().startsWith('delete ')) return true;
return node.namedChildren.some(containsExecutingExpression);
}
function routeFromObject(node: SyntaxNode): DataRouteTableRoute | null {
const values = new Map<string, SyntaxNode>();
for (const child of node.namedChildren) {
if (child.type === 'comment') continue;
// Spread properties, methods, computed keys, and other executable shapes
// make the entry non-declarative, so the whole entry is suppressed.
if (child.type !== 'pair') return null;
const keyNode = child.childForFieldName('key');
const valueNode = child.childForFieldName('value');
if (keyNode === null || valueNode === null) return null;
const key = propertyName(keyNode);
if (key === null) return null;
if (!ROUTE_TOKEN_HINTS.includes(key as (typeof ROUTE_TOKEN_HINTS)[number])) {
if (containsExecutingExpression(valueNode)) return null;
continue;
}
if (values.has(key)) return null;
values.set(key, valueNode);
}
const pathNode = values.get('path');
const methodNode = values.get('method');
const handlerNode = values.get('handler');
if (pathNode === undefined || methodNode === undefined || handlerNode === undefined) return null;
const path = plainString(pathNode);
const rawMethod = plainString(methodNode);
const handler = handlerName(handlerNode);
if (path === null || !path.startsWith('/') || rawMethod === null || handler === null) return null;
const method = rawMethod.toUpperCase();
if (!HTTP_METHODS.has(method)) return null;
return {
path,
method,
handlerDesignator: handler.designator,
handlerName: handler.name,
...(handler.localName === undefined ? {} : { handlerLocalName: handler.localName }),
line: node.startPosition.row + 1,
};
}
function staticRouteIdentity(node: SyntaxNode): string | null {
const values = new Map<string, SyntaxNode>();
let lastUnknownProperty = -1;
let lastPath = -1;
let lastMethod = -1;
for (const [index, child] of node.namedChildren.entries()) {
if (child.type === 'comment') continue;
if (child.type !== 'pair') {
lastUnknownProperty = index;
continue;
}
const keyNode = child.childForFieldName('key');
const valueNode = child.childForFieldName('value');
if (keyNode === null || valueNode === null) {
lastUnknownProperty = index;
continue;
}
const key = propertyName(keyNode);
if (key === null) {
lastUnknownProperty = index;
continue;
}
if (!ROUTE_TOKEN_HINTS.includes(key as (typeof ROUTE_TOKEN_HINTS)[number])) continue;
values.set(key, valueNode);
if (key === 'path') lastPath = index;
if (key === 'method') lastMethod = index;
}
const pathNode = values.get('path');
const methodNode = values.get('method');
if (pathNode === undefined || methodNode === undefined || !values.has('handler')) return null;
if (lastPath < lastUnknownProperty || lastMethod < lastUnknownProperty) return null;
const path = plainString(pathNode);
const rawMethod = plainString(methodNode);
if (path === null || !path.startsWith('/') || rawMethod === null) return null;
const method = rawMethod.toUpperCase();
return HTTP_METHODS.has(method) ? `${method}\0${path}` : null;
}
function lexicalContainer(node: SyntaxNode): SyntaxNode {
let current = node;
while (current.parent !== null) {
current = current.parent;
if (current.type === 'program' || current.type === 'statement_block') return current;
}
return current;
}
function loopBindingName(node: SyntaxNode): string | null {
const left = node.childForFieldName('left');
if (left?.type === 'identifier') return left.text;
if (left?.type !== 'lexical_declaration' && left?.type !== 'variable_declaration') return null;
const declarator = left.namedChildren.find((child) => child.type === 'variable_declarator');
const name = declarator?.childForFieldName('name');
return name?.type === 'identifier' ? name.text : null;
}
function entryMemberField(node: SyntaxNode, entryName: string): string | null {
if (node.type === 'parenthesized_expression') {
const nested = node.namedChildren[0];
return nested === undefined ? null : entryMemberField(nested, entryName);
}
if (node.type !== 'member_expression') return null;
const object = node.childForFieldName('object');
const property = node.childForFieldName('property');
return object?.type === 'identifier' &&
object.text === entryName &&
property?.type === 'property_identifier'
? property.text
: null;
}
const NESTED_EXECUTABLES = new Set([
'function_declaration',
'function_expression',
'generator_function_declaration',
'generator_function',
'arrow_function',
'method_definition',
'class_declaration',
'class',
]);
function referencesEntry(node: SyntaxNode, entryName: string): boolean {
if (node.type === 'identifier' && node.text === entryName) return true;
return node.namedChildren.some((child) => referencesEntry(child, entryName));
}
const REQUEST_ROOTS = new Set(['req', 'request', 'event', 'ctx', 'context', 'url']);
const REQUEST_PATH_FIELDS = new Set(['path', 'pathname', 'url', 'rawpath']);
const REQUEST_METHOD_FIELDS = new Set(['method', 'httpmethod', 'verb']);
function isEnclosingParameter(node: SyntaxNode, name: string): boolean {
let current: SyntaxNode | null = node.parent;
while (current !== null) {
if (NESTED_EXECUTABLES.has(current.type)) {
const names = new Set<string>();
collectPatternNames(current.childForFieldName('parameters'), (value) => names.add(value));
collectPatternNames(current.childForFieldName('parameter'), (value) => names.add(value));
return names.has(name);
}
current = current.parent;
}
return false;
}
function isRequestField(
node: SyntaxNode,
field: 'path' | 'method',
bindingCounts: ReadonlyMap<string, number>,
): boolean {
if (node.type === 'parenthesized_expression') {
const nested = node.namedChildren[0];
return nested !== undefined && isRequestField(nested, field, bindingCounts);
}
const accepted = field === 'path' ? REQUEST_PATH_FIELDS : REQUEST_METHOD_FIELDS;
if (node.type !== 'member_expression') return false;
const object = node.childForFieldName('object');
const property = node.childForFieldName('property');
const root = object?.type === 'identifier' ? object.text : null;
const bindingCount = root === null ? 0 : (bindingCounts.get(root) ?? 0);
return (
property?.type === 'property_identifier' &&
accepted.has(property.text.toLowerCase()) &&
root !== null &&
REQUEST_ROOTS.has(root.toLowerCase()) &&
(bindingCount === 0 || (bindingCount === 1 && isEnclosingParameter(node, root)))
);
}
interface ComparisonEvidence {
valid: boolean;
fields: Set<string>;
}
function comparisonFields(
condition: SyntaxNode,
entryName: string,
bindingCounts: ReadonlyMap<string, number>,
): ComparisonEvidence {
const text = condition.text.trim();
if (text === 'false' || text === '0' || text === 'null' || text === 'undefined') {
return { valid: false, fields: new Set() };
}
if (condition.type === 'parenthesized_expression') {
const nested = condition.namedChildren[0];
return nested === undefined
? { valid: true, fields: new Set() }
: comparisonFields(nested, entryName, bindingCounts);
}
if (condition.type === 'unary_expression') return { valid: false, fields: new Set() };
if (condition.type !== 'binary_expression') return { valid: false, fields: new Set() };
const operator = condition.children.find((child) => !child.isNamed)?.type;
const left = condition.childForFieldName('left');
const right = condition.childForFieldName('right');
if (left === null || right === null) return { valid: false, fields: new Set() };
if (operator === '||') return { valid: false, fields: new Set() };
if (operator === '&&') {
const leftEvidence = comparisonFields(left, entryName, bindingCounts);
const rightEvidence = comparisonFields(right, entryName, bindingCounts);
if (!leftEvidence.valid || !rightEvidence.valid) return { valid: false, fields: new Set() };
return {
valid: true,
fields: new Set([...leftEvidence.fields, ...rightEvidence.fields]),
};
}
if (operator !== '===' && operator !== '==') return { valid: false, fields: new Set() };
const leftField = entryMemberField(left, entryName);
const rightField = entryMemberField(right, entryName);
if ((leftField === null) === (rightField === null)) return { valid: false, fields: new Set() };
if (
(leftField !== null && referencesEntry(right, entryName)) ||
(rightField !== null && referencesEntry(left, entryName))
) {
return { valid: false, fields: new Set() };
}
const field = leftField ?? rightField;
const requestOperand = leftField !== null ? right : left;
const matchesRequest =
(field === 'path' || field === 'method') &&
isRequestField(requestOperand, field, bindingCounts);
return {
valid: matchesRequest,
fields: matchesRequest ? new Set([field]) : new Set(),
};
}
function directlyCallsHandler(node: SyntaxNode, entryName: string): boolean {
let found = false;
const visit = (child: SyntaxNode): void => {
if (found || (child !== node && NESTED_EXECUTABLES.has(child.type))) return;
if (child.type === 'statement_block') {
for (const statement of child.namedChildren) {
visit(statement);
if (found || definitelyTerminates(statement)) {
break;
}
}
return;
}
if (child.type === 'call_expression') {
const fn = child.childForFieldName('function');
if (fn !== null && entryMemberField(fn, entryName) === 'handler') {
let current: SyntaxNode = child;
let parent = current.parent;
while (parent !== null && current.id !== node.id) {
if (
parent.type !== 'await_expression' &&
parent.type !== 'parenthesized_expression' &&
parent.type !== 'expression_statement' &&
parent.type !== 'return_statement' &&
parent.type !== 'statement_block'
) {
return;
}
current = parent;
parent = current.parent;
}
found = current.id === node.id;
return;
}
}
for (const nested of child.namedChildren) visit(nested);
};
visit(node);
return found;
}
function isWriteTarget(node: SyntaxNode): boolean {
let current: SyntaxNode = node;
let parent = current.parent;
while (parent !== null) {
if (
parent.type === 'assignment_expression' ||
parent.type === 'augmented_assignment_expression'
) {
const left = parent.childForFieldName('left');
return left !== null && left.startIndex <= node.startIndex && left.endIndex >= node.endIndex;
}
if (parent.type === 'update_expression') return true;
if (parent.type === 'unary_expression' && parent.text.trimStart().startsWith('delete ')) {
return true;
}
if (
parent.type === 'expression_statement' ||
parent.type === 'variable_declarator' ||
parent.type === 'call_expression'
) {
return false;
}
current = parent;
parent = current.parent;
}
return false;
}
function hasOnlyIngressReferences(scope: SyntaxNode): boolean {
let valid = true;
const visit = (node: SyntaxNode): void => {
if (!valid) return;
if (node.type === 'identifier' && REQUEST_ROOTS.has(node.text.toLowerCase())) {
const member = node.parent;
const property = member?.childForFieldName('property');
if (
member?.type !== 'member_expression' ||
member.childForFieldName('object')?.id !== node.id ||
property?.type !== 'property_identifier' ||
(!REQUEST_PATH_FIELDS.has(property.text.toLowerCase()) &&
!REQUEST_METHOD_FIELDS.has(property.text.toLowerCase())) ||
isWriteTarget(member)
) {
valid = false;
return;
}
}
for (const child of node.namedChildren) visit(child);
};
visit(scope);
return valid;
}
function hasOnlyDispatchEntryReferences(node: SyntaxNode, entryName: string): boolean {
let valid = true;
const visit = (child: SyntaxNode): void => {
if (!valid) return;
if (child.type === 'identifier' && child.text === entryName) {
const member = child.parent;
if (
member?.type !== 'member_expression' ||
member.childForFieldName('object')?.id !== child.id
) {
valid = false;
return;
}
const property = member.childForFieldName('property');
if (property?.type !== 'property_identifier' || isWriteTarget(member)) {
valid = false;
return;
}
if (property.text === 'handler') {
const call = member.parent;
if (
call?.type !== 'call_expression' ||
call.childForFieldName('function')?.id !== member.id
) {
valid = false;
return;
}
} else if (property.text !== 'path' && property.text !== 'method') {
valid = false;
return;
}
}
for (const nested of child.namedChildren) visit(nested);
};
visit(node);
return valid;
}
function hasProviderDispatch(
body: SyntaxNode,
entryName: string,
bindingCounts: ReadonlyMap<string, number>,
): boolean {
if (!hasOnlyDispatchEntryReferences(body, entryName)) return false;
let found = false;
const visit = (node: SyntaxNode): void => {
if (found || (node !== body && NESTED_EXECUTABLES.has(node.type))) return;
if (node.type === 'statement_block') {
for (const statement of node.namedChildren) {
visit(statement);
if (found || definitelyTerminates(statement)) {
break;
}
}
return;
}
if (node.type === 'if_statement') {
const condition = node.childForFieldName('condition');
const consequence = node.childForFieldName('consequence');
if (condition !== null && isStaticallyFalse(condition)) {
const alternative = node.childForFieldName('alternative');
if (alternative !== null) visit(alternative);
return;
}
if (condition !== null && consequence !== null) {
const evidence = comparisonFields(condition, entryName, bindingCounts);
if (
evidence.valid &&
evidence.fields.has('path') &&
evidence.fields.has('method') &&
directlyCallsHandler(consequence, entryName)
) {
found = true;
return;
}
}
}
for (const child of node.namedChildren) visit(child);
};
visit(body);
return found;
}
function isStaticallyFalse(node: SyntaxNode): boolean {
if (node.type === 'parenthesized_expression') {
const nested = node.namedChildren[0];
return nested !== undefined && isStaticallyFalse(nested);
}
const text = node.text.trim();
return text === 'false' || text === '0' || text === 'null' || text === 'undefined';
}
function isStaticallyTrue(node: SyntaxNode): boolean {
if (node.type === 'parenthesized_expression') {
const nested = node.namedChildren[0];
return nested !== undefined && isStaticallyTrue(nested);
}
return node.text.trim() === 'true';
}
function definitelyTerminates(node: SyntaxNode): boolean {
if (
node.type === 'return_statement' ||
node.type === 'throw_statement' ||
node.type === 'break_statement' ||
node.type === 'continue_statement'
) {
return true;
}
if (node.type === 'statement_block') {
return node.namedChildren.some((statement) => definitelyTerminates(statement));
}
if (node.type !== 'if_statement') return false;
const condition = node.childForFieldName('condition');
const consequence = node.childForFieldName('consequence');
const alternative = node.childForFieldName('alternative');
if (condition === null) return false;
if (isStaticallyTrue(condition)) {
return consequence !== null && definitelyTerminates(consequence);
}
if (isStaticallyFalse(condition)) {
return alternative !== null && definitelyTerminates(alternative);
}
return (
consequence !== null &&
alternative !== null &&
definitelyTerminates(consequence) &&
definitelyTerminates(alternative)
);
}
function hasDispatchLoop(
scope: SyntaxNode,
tableName: string,
bindingCounts: ReadonlyMap<string, number>,
): boolean {
let found = false;
const visit = (node: SyntaxNode): void => {
if (found) return;
if (node !== scope && NESTED_EXECUTABLES.has(node.type)) return;
if (node.type === 'for_in_statement') {
const right = node.childForFieldName('right');
const isForOf = node.children.some((child) => child.type === 'of');
const hasConstBinding = node.children.some((child) => child.type === 'const');
if (isForOf && hasConstBinding && right?.type === 'identifier' && right.text === tableName) {
const entryName = loopBindingName(node);
const body = node.childForFieldName('body');
if (
entryName !== null &&
bindingCounts.get(entryName) === 1 &&
body !== null &&
hasProviderDispatch(body, entryName, bindingCounts)
) {
found = true;
return;
}
}
}
for (const child of node.namedChildren) visit(child);
};
visit(scope);
return found;
}
function collectPatternNames(node: SyntaxNode | null, add: (name: string) => void): void {
if (node === null) return;
if (node.type === 'identifier') {
add(node.text);
return;
}
for (const child of node.namedChildren) collectPatternNames(child, add);
}
function collectBindingCounts(root: SyntaxNode): ReadonlyMap<string, number> {
const counts = new Map<string, number>();
const add = (name: string): void => {
counts.set(name, (counts.get(name) ?? 0) + 1);
};
const visit = (node: SyntaxNode): void => {
if (node.type === 'import_statement') {
const clause = node.namedChildren.find((child) => child.type === 'import_clause');
collectPatternNames(clause ?? null, add);
return;
}
if (node.type === 'variable_declarator') {
collectPatternNames(node.childForFieldName('name'), add);
} else if (node.type === 'for_in_statement') {
const left = node.childForFieldName('left');
if (left?.type === 'identifier') add(left.text);
} else if (
node.type === 'function_declaration' ||
node.type === 'generator_function_declaration' ||
node.type === 'class_declaration'
) {
collectPatternNames(node.childForFieldName('name'), add);
}
if (
node.type === 'function_declaration' ||
node.type === 'function_expression' ||
node.type === 'generator_function_declaration' ||
node.type === 'generator_function' ||
node.type === 'arrow_function' ||
node.type === 'method_definition'
) {
collectPatternNames(node.childForFieldName('parameters'), add);
collectPatternNames(node.childForFieldName('parameter'), add);
} else if (node.type === 'catch_clause') {
collectPatternNames(node.childForFieldName('parameter'), add);
}
for (const child of node.namedChildren) visit(child);
};
visit(root);
return counts;
}
function isConstDeclarator(node: SyntaxNode): boolean {
const declaration = node.parent;
return (
declaration?.type === 'lexical_declaration' &&
declaration.children.some((child) => child.type === 'const')
);
}
function hasOnlyDispatchReferences(
scope: SyntaxNode,
declarator: SyntaxNode,
tableName: string,
): boolean {
const declarationName = declarator.childForFieldName('name');
let valid = true;
const visit = (node: SyntaxNode): void => {
if (!valid) return;
if (node.type === 'identifier' && node.text === tableName) {
if (declarationName?.id === node.id) return;
const parent = node.parent;
const allowedForOf =
parent?.type === 'for_in_statement' &&
parent.childForFieldName('right')?.id === node.id &&
parent.children.some((child) => child.type === 'of');
const lengthMember =
parent?.type === 'member_expression' &&
parent.childForFieldName('object')?.id === node.id &&
parent.childForFieldName('property')?.text === 'length'
? parent
: null;
const allowedLengthRead = lengthMember !== null && !isWriteTarget(lengthMember);
if (!allowedForOf && !allowedLengthRead) {
valid = false;
return;
}
}
for (const child of node.namedChildren) visit(child);
};
visit(scope);
return valid;
}
type HandlerReferenceIndex = ReadonlyMap<string, readonly SyntaxNode[]>;
function collectHandlerReferences(root: SyntaxNode): HandlerReferenceIndex {
const references = new Map<string, SyntaxNode[]>();
const visit = (node: SyntaxNode): void => {
if (
node.type === 'identifier' ||
node.type === 'shorthand_property_identifier' ||
node.type === 'shorthand_property_identifier_pattern'
) {
const matches = references.get(node.text);
if (matches === undefined) references.set(node.text, [node]);
else matches.push(node);
}
for (const child of node.namedChildren) visit(child);
};
visit(root);
return references;
}
function containsNode(container: SyntaxNode | null, node: SyntaxNode): boolean {
return (
container !== null &&
container.startIndex <= node.startIndex &&
container.endIndex >= node.endIndex
);
}
function isUnsafeHandlerReference(
reference: SyntaxNode,
handlerRoot: string,
routeEntry: SyntaxNode,
): boolean {
let current: SyntaxNode = reference;
let parent = current.parent;
while (parent !== null) {
if (parent.type === 'variable_declarator') {
const name = parent.childForFieldName('name');
const value = parent.childForFieldName('value');
if (
name?.type === 'identifier' &&
name.text !== handlerRoot &&
containsNode(value, reference) &&
!isRouteHandlerDesignatorReference(reference, routeEntry)
) {
return true;
}
}
// The remaining checks historically matched ordinary identifiers only.
if (reference.type === 'identifier') {
if (
parent.type === 'assignment_expression' ||
parent.type === 'augmented_assignment_expression' ||
parent.type === 'update_expression' ||
(parent.type === 'unary_expression' && parent.text.trimStart().startsWith('delete ')) ||
parent.type === 'return_statement' ||
parent.type === 'yield_expression' ||
parent.type === 'throw_statement'
) {
return true;
}
if (parent.type === 'call_expression') {
const args = parent.childForFieldName('arguments');
const fn = parent.childForFieldName('function');
const owner = fn?.childForFieldName('object');
const callsOwnerMember =
(fn?.type === 'member_expression' || fn?.type === 'subscript_expression') &&
identifierName(owner) === handlerRoot &&
containsNode(owner ?? null, reference);
if (containsNode(args, reference) || callsOwnerMember) return true;
}
}
current = parent;
parent = current.parent;
}
return false;
}
function hasStableHandlerBinding(
references: HandlerReferenceIndex,
handlerRoot: string,
routeEntry: SyntaxNode,
): boolean {
return !(references.get(handlerRoot) ?? []).some((reference) =>
isUnsafeHandlerReference(reference, handlerRoot, routeEntry),
);
}
function identifierName(node: SyntaxNode | null | undefined): string | null {
if (node?.type === 'identifier') return node.text;
if (node?.type === 'parenthesized_expression') {
return identifierName(node.namedChildren[0]);
}
return null;
}
function isRouteHandlerDesignatorReference(node: SyntaxNode, routeEntry: SyntaxNode): boolean {
let designator = node;
if (
node.parent?.type === 'member_expression' &&
node.parent.childForFieldName('object')?.id === node.id
) {
designator = node.parent;
}
const pair = designator.parent;
return (
pair?.type === 'pair' &&
pair.parent?.id === routeEntry.id &&
pair.childForFieldName('value')?.id === designator.id &&
propertyName(pair.childForFieldName('key')) === 'handler'
);
}
/** Return static route facts shared by ingestion and group contract extraction. */
export function scanDataRouteTables(tree: Parser.Tree): DataRouteTableRoute[] {
const source = tree.rootNode.text;
if (!ROUTE_TOKEN_HINTS.every((token) => source.includes(token))) return [];
const routes: DataRouteTableRoute[] = [];
const blockedIdentities = new Set<string>();
let hasUnknownTableEntries = false;
const bindingCounts = collectBindingCounts(tree.rootNode);
const handlerReferences = collectHandlerReferences(tree.rootNode);
const visit = (node: SyntaxNode): void => {
if (node.type === 'variable_declarator') {
const name = node.childForFieldName('name');
const value = node.childForFieldName('value');
if (
name?.type === 'identifier' &&
ROUTE_BINDING_HINT.test(name.text) &&
bindingCounts.get(name.text) === 1 &&
isConstDeclarator(node) &&
value?.type === 'array' &&
hasOnlyDispatchReferences(lexicalContainer(node), node, name.text) &&
hasOnlyIngressReferences(lexicalContainer(node)) &&
hasDispatchLoop(lexicalContainer(node), name.text, bindingCounts)
) {
const parsedRoutes: DataRouteTableRoute[] = [];
let blocksFollowingCandidates = false;
for (const entry of value.namedChildren) {
if (entry.type === 'comment') continue;
if (entry.type !== 'object') {
hasUnknownTableEntries = true;
continue;
}
const route = routeFromObject(entry);
if (route === null) {
const identity = staticRouteIdentity(entry);
if (identity !== null) blockedIdentities.add(identity);
if (identity === null) {
blocksFollowingCandidates = true;
}
continue;
}
const identity = `${route.method}\0${route.path}`;
if (blocksFollowingCandidates) {
blockedIdentities.add(identity);
continue;
}
const handlerRoot = route.handlerDesignator.split('.')[0];
if (
handlerRoot === undefined ||
(bindingCounts.get(handlerRoot) ?? 0) > 1 ||
!hasStableHandlerBinding(handlerReferences, handlerRoot, entry)
) {
blockedIdentities.add(identity);
continue;
}
parsedRoutes.push(route);
}
routes.push(...parsedRoutes);
}
}
for (const child of node.namedChildren) visit(child);
};
visit(tree.rootNode);
if (hasUnknownTableEntries) return [];
return routes.filter((route) => !blockedIdentities.has(`${route.method}\0${route.path}`));
}
export function extractDataRouteTableRoutes(
tree: Parser.Tree,
filePath: string,
lineOffset = 0,
): ExtractedDecoratorRoute[] {
return scanDataRouteTables(tree).map((route) => ({
filePath,
routePath: route.path,
httpMethod: route.method,
decoratorName: 'DataRouteTable',
lineNumber: route.line + lineOffset,
// This source preserves a static dotted designator in the existing field.
// Its resolver recognizes the source tag and proves the receiver owner;
// ordinary decorator routes continue to carry a simple handler name.
handlerName: route.handlerDesignator,
source: DATA_ROUTE_TABLE_SOURCE,
}));
}

View file

@ -534,13 +534,10 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// stale 2), which is the rule above: above every claim, not above origin/main.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
//
// 68 -> 70 for Spring non-HTTP handler side-channel facts (#2417 / #2891).
// Java and Kotlin ParsedFiles now persist scheduled, event, messaging, and
// managed-job handler syntax. A warm cache without these facts would stamp the
// analysis feature as complete while promoting zero handlers. This PR's former
// value 59 is now part of main's ledger, main currently holds 68, and open PR
// #2972 publishes 69; 70 is the next free value above every known claim.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
// 68 -> 69 added #2969's JS/TS data-route-table decoratorRoutes. A warm v68
// cache would replay unchanged worker results without those routes. Version 70
// then adds Spring non-HTTP handler side-channel facts (#2417 / #2891), so Java
// and Kotlin caches persist scheduled, event, messaging, and managed-job facts.
const SCHEMA_BUMP = 70;
const GITNEXUS_PKG_VERSION = (() => {
try {

View file

@ -0,0 +1,3 @@
export function handleUsers() {
return { wrong: true };
}

View file

@ -0,0 +1,13 @@
export function listUsers(_req, res) {
return res.json({ users: [] });
}
export function createUser(_req, res) {
return res.json({ createdId: 'new' });
}
export const auth = {
getCurrentUser(_req, res) {
return res.json({ accountId: 'current' });
},
};

View file

@ -0,0 +1,23 @@
import { auth, createUser, listUsers as handleUsers } from './handlers.js';
const runtimePath = '/dynamic';
const baseRoute = { path: '/spread', method: 'GET', handler: spreadHandler };
const apiRoutes = [
{ path: '/users', method: 'GET', handler: handleUsers },
{ path: '/users', method: 'POST', handler: createUser },
{ path: '/auth/me', method: 'GET', handler: auth.getCurrentUser, auth: true },
{ path: runtimePath, method: 'GET', handler: dynamicHandler },
{ ...baseRoute },
{ ['path']: '/computed', method: 'GET', handler: computedHandler },
{ path: '/inline', method: 'GET', handler: () => null },
{ path: '/unresolved', method: 'GET', handler: missing.handler },
];
for (const route of apiRoutes) {
if (route.path === request.path && route.method === request.method) route.handler();
}
export function routeCount() {
return apiRoutes.length;
}

View file

@ -0,0 +1,68 @@
/**
* JavaScript data-route-table handler-stability scaling benchmark.
*
* The scanner used to walk the complete AST once for every route handler,
* making extraction quadratic as a route table grew. This benchmark parses
* outside the timed region and co-scales unique handlers and route entries so
* the measured work isolates scanDataRouteTables.
*
* Run: GITNEXUS_BENCH=1 npx vitest run test/integration/data-route-table-benchmark.test.ts
*/
import { describe, expect, it } from 'vitest';
import Parser from 'tree-sitter';
import JavaScript from 'tree-sitter-javascript';
import { scanDataRouteTables } from '../../src/core/ingestion/route-extractors/data-route-table.js';
const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1';
const parser = new Parser();
parser.setLanguage(JavaScript);
interface BenchResult {
routes: number;
elapsedMs: number;
}
function fixture(routeCount: number): string {
const handlers = Array.from({ length: routeCount }, (_, i) => `function handler${i}() {}`).join(
'\n',
);
const entries = Array.from(
{ length: routeCount },
(_, i) => ` { path: '/route-${i}', method: 'GET', handler: handler${i} },`,
).join('\n');
return `${handlers}
const routes = [
${entries}
];
for (const route of routes) {
if (route.path === request.path && route.method === request.method) route.handler();
}`;
}
function benchmark(routeCount: number): BenchResult {
const tree = parser.parse(fixture(routeCount));
const started = performance.now();
const routes = scanDataRouteTables(tree);
const elapsedMs = performance.now() - started;
expect(routes).toHaveLength(routeCount);
return { routes: routeCount, elapsedMs };
}
describe.skipIf(!BENCH_ENABLED)('data-route-table scanner benchmark', () => {
it('scales sub-quadratically as handlers and route entries grow together', () => {
benchmark(10);
const small = benchmark(50);
const large = benchmark(200);
const routeRatio = large.routes / small.routes;
console.log('\nJavaScript data-route-table scanner benchmark');
console.log(` routes=${small.routes} wall=${small.elapsedMs.toFixed(2)}ms`);
console.log(` routes=${large.routes} wall=${large.elapsedMs.toFixed(2)}ms`);
if (small.elapsedMs >= 5) {
expect(large.elapsedMs / small.elapsedMs).toBeLessThan(Math.pow(routeRatio, 1.5));
}
expect(large.elapsedMs).toBeLessThan(5_000);
}, 120_000);
});

View file

@ -0,0 +1,263 @@
import { beforeAll, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../types/pipeline.js';
import { DATA_ROUTE_TABLE_SOURCE } from '../../src/core/ingestion/route-extractors/data-route-table.js';
import {
loadParseCache,
PARSE_CACHE_VERSION,
pruneCache,
saveParseCache,
type ParseCache,
} from '../../src/storage/parse-cache.js';
import {
getDurableParsedFileDir,
pruneAndSaveDurableParsedFileStore,
} from '../../src/storage/parsedfile-store.js';
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'data-route-table-app');
describe('data-driven route table ingestion', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(FIXTURE, () => {}, {});
}, 60_000);
const routes = (pipeline: PipelineResult = result) => {
const found: Array<{
path: string;
method?: string;
handler?: string;
filePath: string;
responseKeys?: string[];
}> = [];
pipeline.graph.forEachNode((node) => {
if (node.label !== 'Route') return;
found.push({
path: String(node.properties.name),
method: node.properties.method as string | undefined,
handler: node.properties.handlerSymbolId as string | undefined,
filePath: String(node.properties.filePath),
responseKeys: node.properties.responseKeys as string[] | undefined,
});
});
return found;
};
it('emits distinct GET and POST Route nodes for the same URL', () => {
expect(
routes()
.map((route) => `${route.method} ${route.path}`)
.sort(),
).toEqual(['GET /auth/me', 'GET /users', 'POST /users']);
});
it('resolves free and member handlers across files', () => {
expect(
routes().find((route) => route.path === '/users' && route.method === 'GET')?.handler,
).toMatch(/listUsers/);
expect(
routes().find((route) => route.path === '/users' && route.method === 'GET')?.handler,
).not.toMatch(/handleUsers/);
expect(
routes().find((route) => route.path === '/users' && route.method === 'POST')?.handler,
).toMatch(/createUser/);
expect(routes().find((route) => route.path === '/auth/me')?.handler).toMatch(/getCurrentUser/);
});
it('links every resolved route from the real cross-file handler file', () => {
const handled: Array<{ filePath: string; method?: string; path: string; reason: string }> = [];
result.graph.forEachRelationship((rel) => {
if (rel.type !== 'HANDLES_ROUTE' || rel.reason !== DATA_ROUTE_TABLE_SOURCE) return;
const source = result.graph.getNode(rel.sourceId);
const target = result.graph.getNode(rel.targetId);
if (source === undefined || target === undefined) return;
handled.push({
filePath: String(source.properties.filePath),
method: target.properties.method as string | undefined,
path: String(target.properties.name),
reason: String(rel.reason),
});
});
expect(
handled.sort((a, b) => `${a.method} ${a.path}`.localeCompare(`${b.method} ${b.path}`)),
).toEqual([
{
filePath: 'handlers.js',
method: 'GET',
path: '/auth/me',
reason: DATA_ROUTE_TABLE_SOURCE,
},
{
filePath: 'handlers.js',
method: 'GET',
path: '/users',
reason: DATA_ROUTE_TABLE_SOURCE,
},
{
filePath: 'handlers.js',
method: 'POST',
path: '/users',
reason: DATA_ROUTE_TABLE_SOURCE,
},
]);
});
it('extracts response shapes from each resolved handler only', () => {
const getUsers = routes().find((route) => route.path === '/users' && route.method === 'GET');
const postUsers = routes().find((route) => route.path === '/users' && route.method === 'POST');
const currentUser = routes().find((route) => route.path === '/auth/me');
expect(getUsers).toMatchObject({ filePath: 'handlers.js', responseKeys: ['users'] });
expect(postUsers).toMatchObject({ filePath: 'handlers.js', responseKeys: ['createdId'] });
expect(currentUser).toMatchObject({ filePath: 'handlers.js', responseKeys: ['accountId'] });
});
it('does not emit dynamic, spread, computed, or inline-handler entries', () => {
const paths = routes().map((route) => route.path);
expect(paths).not.toContain('/dynamic');
expect(paths).not.toContain('/spread');
expect(paths).not.toContain('/computed');
expect(paths).not.toContain('/inline');
expect(paths).not.toContain('/unresolved');
});
it('resolves declared jsconfig path aliases for handlers', async () => {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-table-alias-'));
try {
fs.mkdirSync(path.join(repoDir, 'src', 'handlers'), { recursive: true });
fs.writeFileSync(
path.join(repoDir, 'jsconfig.json'),
JSON.stringify({
compilerOptions: {
baseUrl: '.',
paths: { '@handler': ['src/handlers/user'] },
},
}),
);
fs.writeFileSync(
path.join(repoDir, 'src', 'routes.js'),
`import { aliasHandler } from '@handler';
const routes = [{ path: '/alias', method: 'GET', handler: aliasHandler }];
for (const route of routes) {
if (route.path === request.path && route.method === request.method) route.handler();
}`,
);
fs.writeFileSync(
path.join(repoDir, 'src', 'handlers', 'user.js'),
`export function aliasHandler(_req, res) { return res.json({ id: 'alias' }); }`,
);
const aliasResult = await runPipelineFromRepo(repoDir, () => {}, {});
expect(routes(aliasResult)).toContainEqual(
expect.objectContaining({
path: '/alias',
method: 'GET',
filePath: 'src/handlers/user.js',
responseKeys: ['id'],
}),
);
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
}
}, 60_000);
it('does not infer a default-export handler without explicit provenance', async () => {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-table-default-'));
try {
fs.writeFileSync(
path.join(repoDir, 'routes.js'),
`import handleDefault from './handler.js';
const routes = [{ path: '/default', method: 'GET', handler: handleDefault }];
for (const route of routes) {
if (route.path === request.path && route.method === request.method) route.handler();
}`,
);
fs.writeFileSync(
path.join(repoDir, 'handler.js'),
`export default function defaultHandler(_req, res) {
return res.json({ source: 'default' });
}`,
);
const defaultResult = await runPipelineFromRepo(repoDir, () => {}, {});
expect(routes(defaultResult)).toEqual([]);
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
}
}, 60_000);
it('replays data-table routes from a serialized warm parse cache', async () => {
const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-table-warm-'));
try {
const cold: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set<string>(),
storagePath: storageDir,
onDiskKeys: new Set<string>(),
};
const coldResult = await runPipelineFromRepo(FIXTURE, () => {}, {
parseCache: cold,
workerPoolSize: 1,
});
expect(coldResult.usedWorkerPool).toBe(true);
pruneCache(cold, cold.usedKeys);
const savedKeys = await saveParseCache(storageDir, cold);
await pruneAndSaveDurableParsedFileStore(
getDurableParsedFileDir(storageDir),
PARSE_CACHE_VERSION,
new Set(savedKeys),
);
const warm = await loadParseCache(storageDir);
expect(warm).not.toBeNull();
const replay = await runPipelineFromRepo(FIXTURE, () => {}, {
parseCache: warm ?? undefined,
workerPoolSize: 1,
});
expect(replay.usedWorkerPool).toBe(false);
const project = (pipeline: PipelineResult) =>
routes(pipeline)
.map((route) => ({
method: route.method,
path: route.path,
handler: route.handler,
filePath: route.filePath,
responseKeys: route.responseKeys,
}))
.sort((a, b) => `${a.method} ${a.path}`.localeCompare(`${b.method} ${b.path}`));
const coldProjection = project(coldResult);
expect(coldProjection).toEqual([
{
method: 'GET',
path: '/auth/me',
handler: expect.stringMatching(/getCurrentUser/),
filePath: 'handlers.js',
responseKeys: ['accountId'],
},
{
method: 'GET',
path: '/users',
handler: expect.stringMatching(/listUsers/),
filePath: 'handlers.js',
responseKeys: ['users'],
},
{
method: 'POST',
path: '/users',
handler: expect.stringMatching(/createUser/),
filePath: 'handlers.js',
responseKeys: ['createdId'],
},
]);
expect(project(replay)).toEqual(coldProjection);
} finally {
fs.rmSync(storageDir, { recursive: true, force: true });
}
}, 120_000);
});

View file

@ -20,7 +20,7 @@
import { it, expect, afterEach } from 'vitest';
import {
RESOLVE_BY_NAME_QUERY,
RESOLVE_IN_MODULE_QUERY,
RESOLVE_IN_EXACT_MODULE_QUERY,
} from '../../../src/core/group/extractors/http-route-extractor.js';
import { initLbug, executeParameterized, closeLbug } from '../../../src/core/lbug/pool-adapter.js';
import { withTestLbugDB } from '../../helpers/test-indexed-db.js';
@ -33,6 +33,7 @@ const SEED = [
`CREATE (:CodeElement {id:'ce:getOrders', name:'getOrders', filePath:''})`,
// IN_MODULE: same name in two modules — only the prefixed one resolves.
`CREATE (:Function {id:'fn:listUsers:handlers', name:'listUsers', filePath:'src/handlers/users.ts', startLine:1, endLine:9, content:'', description:''})`,
`CREATE (:Function {id:'fn:listUsers:test-decoy', name:'listUsers', filePath:'src/handlers/users.test.ts', startLine:1, endLine:9, content:'', description:''})`,
`CREATE (:Function {id:'fn:listUsers:admin', name:'listUsers', filePath:'src/admin/users.ts', startLine:1, endLine:9, content:'', description:''})`,
// IN_MODULE label-allowlist decoy: same name, SAME module prefix, wrong label.
// The STARTS-WITH prefix would match it, so only the `labels(n) IN [...]` filter
@ -66,12 +67,11 @@ withTestLbugDB(
expect(rows[0].filePath).toBe('src/handlers/orders.ts');
});
it('RESOLVE_IN_MODULE_QUERY resolves only the handler in the target module prefix', async () => {
it('RESOLVE_IN_EXACT_MODULE_QUERY excludes sibling module stems', async () => {
await initLbug(handle.repoId, handle.dbPath);
const rows = await executeParameterized(handle.repoId, RESOLVE_IN_MODULE_QUERY, {
const rows = await executeParameterized(handle.repoId, RESOLVE_IN_EXACT_MODULE_QUERY, {
name: 'listUsers',
fileDot: 'src/handlers/users.',
fileSlash: 'src/handlers/users/',
filePaths: ['src/handlers/users.ts', 'src/handlers/users/index.ts'],
});
expect(rows).toHaveLength(1);
expect(rows[0].uid).toBe('fn:listUsers:handlers');

View file

@ -0,0 +1,819 @@
import { describe, expect, it } from 'vitest';
import Parser from 'tree-sitter';
import JavaScript from 'tree-sitter-javascript';
import TypeScript from 'tree-sitter-typescript';
import {
DATA_ROUTE_TABLE_SOURCE,
extractDataRouteTableRoutes,
scanDataRouteTables,
} from '../../src/core/ingestion/route-extractors/data-route-table.js';
import { JAVASCRIPT_HTTP_PLUGIN } from '../../src/core/group/extractors/http-patterns/node.js';
const jsParser = new Parser();
jsParser.setLanguage(JavaScript);
const compact = (source: string) =>
extractDataRouteTableRoutes(jsParser.parse(source), 'src/routes.js').map((route) => ({
path: route.routePath,
method: route.httpMethod,
handler: route.handlerName,
source: route.source,
}));
const dispatch = (table: string) => `
for (const route of ${table}) {
if (route.path === request.path && route.method === request.method) route.handler();
}
`;
describe('data route table extraction', () => {
it('extracts literal entries and preserves method + URL identity', () => {
expect(
compact(`
const apiRoutes = [
{ path: '/users', method: 'get', handler: listUsers },
{ path: '/users', method: 'POST', handler: users.create, auth: true },
];
${dispatch('apiRoutes')}
`),
).toEqual([
{
path: '/users',
method: 'GET',
handler: 'listUsers',
source: DATA_ROUTE_TABLE_SOURCE,
},
{
path: '/users',
method: 'POST',
handler: 'users.create',
source: DATA_ROUTE_TABLE_SOURCE,
},
]);
});
it('supports quoted keys, static templates, and one-level member handlers', () => {
expect(
compact(`
const ROUTE_TABLE = [{
'path': \`/auth/me\`,
'method': 'GET',
'handler': auth.getCurrentUser,
}];
${dispatch('ROUTE_TABLE')}
`),
).toMatchObject([{ path: '/auth/me', method: 'GET', handler: 'auth.getCurrentUser' }]);
});
it('decodes JavaScript escapes in static route strings and quoted keys', () => {
expect(
compact(`
const routes = [{
'p\\u0061th': \`\\/users\`,
method: 'G\\x45T',
handler: escaped,
}];
${dispatch('routes')}
`),
).toMatchObject([{ path: '/users', method: 'GET', handler: 'escaped' }]);
});
it('rejects malformed or legacy-octal route string escapes', () => {
expect(
compact(`
const routes = [{ path: '/\\8users', method: 'GET', handler: malformed }];
${dispatch('routes')}
`),
).toEqual([]);
});
it('rejects executable values on extra route properties', () => {
expect(
compact(`
const routes = [{
path: '/users',
method: 'GET',
handler: listUsers,
metadata: buildMetadata(),
}];
${dispatch('routes')}
`),
).toEqual([]);
});
it('keeps declarative metadata on route entries', () => {
expect(
compact(`
const routes = [{
path: '/users',
method: 'GET',
handler: listUsers,
auth: true,
metadata: { audience: 'staff', flags: ['audit'] },
}];
${dispatch('routes')}
`),
).toMatchObject([{ path: '/users', method: 'GET', handler: 'listUsers' }]);
});
it('allows line and block comments between static route properties', () => {
expect(
compact(`
const routes = [{
path: '/comments',
// The verb remains a direct literal.
method: 'GET',
/* The handler remains a direct designator. */
handler: commented,
}];
${dispatch('routes')}
`),
).toMatchObject([{ path: '/comments', method: 'GET', handler: 'commented' }]);
});
it('has JavaScript / TypeScript grammar parity', () => {
const source = `
type Route = { path: string; method: string; handler: Function };
const routes: Route[] = [{ path: '/typed', method: 'GET', handler: typedHandler }];
${dispatch('routes')}
`;
const tsParser = new Parser();
tsParser.setLanguage(TypeScript.typescript);
expect(scanDataRouteTables(tsParser.parse(source))).toMatchObject([
{ path: '/typed', method: 'GET', handlerName: 'typedHandler' },
]);
});
it.each([
['dynamic path', `const routes = [{ path: runtimePath, method: 'GET', handler: h }]`, 'routes'],
[
'dynamic method',
`const routes = [{ path: '/x', method: runtimeMethod, handler: h }]`,
'routes',
],
[
'called handler',
`const routes = [{ path: '/x', method: 'GET', handler: makeHandler() }]`,
'routes',
],
[
'inline handler',
`const routes = [{ path: '/x', method: 'GET', handler: () => {} }]`,
'routes',
],
[
'spread entry',
`const routes = [{ ...base, path: '/x', method: 'GET', handler: h }]`,
'routes',
],
['computed key', `const routes = [{ ['path']: '/x', method: 'GET', handler: h }]`, 'routes'],
['unknown verb', `const routes = [{ path: '/x', method: 'CONNECT', handler: h }]`, 'routes'],
[
'multi-level member',
`const routes = [{ path: '/x', method: 'GET', handler: services.auth.h }]`,
'routes',
],
[
'non-route binding',
`const requests = [{ path: '/x', method: 'GET', handler: h }]`,
'requests',
],
])('suppresses %s', (_name, source, table) => {
expect(compact(`${source}; ${dispatch(table)}`)).toEqual([]);
});
it('suppresses an unconsumed route-named descriptor table', () => {
expect(
compact(`const mockRoutes = [{ path: '/admin', method: 'DELETE', handler: onResponse }];`),
).toEqual([]);
});
it('does not use a dispatch loop hidden in a nested function', () => {
expect(
compact(`
const routes = [{ path: '/admin', method: 'DELETE', handler: removeAdmin }];
function dispatch(path, method) {
for (const route of routes) {
if (route.path === request.path && route.method === request.method) route.handler();
}
}
`),
).toEqual([]);
});
it('suppresses outbound client tables that pass fields to fetch and then callbacks', () => {
expect(
compact(`
const routeRequests = [
{ path: '/admin', method: 'DELETE', handler: onResponse },
];
for (const route of routeRequests) {
fetch(route.path, { method: route.method }).then(route.handler);
}
`),
).toEqual([]);
});
it('requires path and method comparisons in the same guard as direct dispatch', () => {
expect(
compact(`
const routes = [{ path: '/admin', method: 'DELETE', handler: onResponse }];
for (const route of routes) {
if (route.path) console.log(route.method);
route.handler();
}
`),
).toEqual([]);
});
it('does not treat negated equality as a positive dispatch guard', () => {
expect(
compact(`
const routes = [{ path: '/admin', method: 'DELETE', handler: removeAdmin }];
for (const route of routes) {
if (!(route.path === request.path && route.method === request.method)) {
route.handler();
}
}
`),
).toEqual([]);
});
it('does not treat disjunctive comparisons as a complete dispatch guard', () => {
expect(
compact(`
const routes = [{ path: '/admin', method: 'DELETE', handler: removeAdmin }];
for (const route of routes) {
if (route.path === request.path || route.method === request.method) route.handler();
}
`),
).toEqual([]);
});
it.each([
['direct', 'route.path', 'route.method'],
['parenthesized', '(route.path)', '(route.method)'],
['computed', "route['path']", "route['method']"],
['unary', '+route.path', '+route.method'],
['self-derived', 'normalize(route.path)', 'normalize(route.method)'],
])(
'does not accept %s route-field self-comparisons as dispatch evidence',
(_name, path, method) => {
expect(
compact(`
const routes = [{ path: '/admin', method: 'DELETE', handler: removeAdmin }];
for (const route of routes) {
if (route.path === ${path} && route.method === ${method}) route.handler();
}
`),
).toEqual([]);
},
);
it('suppresses duplicate required keys but preserves route candidates for resolution', () => {
expect(
compact(`
const routes = [
{ path: '/bad', path: '/other', method: 'GET', handler: bad },
{ path: '/once', method: 'GET', handler: first },
{ path: '/once', method: 'GET', handler: second },
];
${dispatch('routes')}
`),
).toMatchObject([
{ path: '/once', method: 'GET', handler: 'first' },
{ path: '/once', method: 'GET', handler: 'second' },
]);
});
it('rejects literal-only guards that select only one table entry', () => {
expect(
compact(`
const routes = [
{ path: '/only', method: 'GET', handler: only },
{ path: '/never', method: 'POST', handler: never },
];
for (const route of routes) {
if (route.path === '/only' && route.method === 'GET') route.handler();
}
`),
).toEqual([]);
});
it('rejects route-derived aliases even when their names resemble request fields', () => {
expect(
compact(`
const routes = [{ path: '/copied', method: 'GET', handler: copied }];
for (const route of routes) {
const copiedPath = route.path;
const copiedMethod = route.method;
if (route.path === copiedPath && route.method === copiedMethod) route.handler();
}
`),
).toEqual([]);
});
it('requires request fields to come from an unshadowed ingress binding', () => {
expect(
compact(`
const request = { path: '/only', method: 'GET' };
const routes = [
{ path: '/only', method: 'GET', handler: only },
{ path: '/never', method: 'POST', handler: never },
];
${dispatch('routes')}
`),
).toEqual([]);
expect(
compact(`
const routes = [{ path: '/nested', method: 'GET', handler: nested }];
for (const route of routes) {
if (
route.path === request.config.path &&
route.method === request.options.method
) route.handler();
}
`),
).toEqual([]);
expect(
compact(`
function dispatchRequest(request) {
const routes = [{ path: '/parameter', method: 'GET', handler: parameterHandler }];
for (const route of routes) {
if (route.path === request.path && route.method === request.method) route.handler();
}
}
`),
).toMatchObject([{ path: '/parameter', method: 'GET', handler: 'parameterHandler' }]);
});
it.each([
['reassigned request parameter', 'request = { path: "/x", method: "GET" };'],
['mutated request fields', 'request.path = "/x"; request.method = "GET";'],
['escaped request parameter', 'observe(request);'],
[
'request mutation through a called closure',
'const overwrite = () => { request.path = "/x"; request.method = "GET"; }; overwrite();',
],
])('rejects an unstable ingress binding: %s', (_name, mutation) => {
expect(
compact(`
function dispatchRequest(request) {
const routes = [{ path: '/x', method: 'GET', handler: handler }];
${mutation}
for (const route of routes) {
if (route.path === request.path && route.method === request.method) route.handler();
}
}
`),
).toEqual([]);
});
it.each([
[
'a route-literal narrowing conjunct',
`route.path === request.path && route.method === request.method && route.path === '/only'`,
],
[
'a statically false conjunct',
`route.path === request.path && route.method === request.method && false`,
],
[
'a statically false equality',
`route.path === request.path && route.method === request.method && 1 === 2`,
],
])('rejects %s', (_name, condition) => {
expect(
compact(`
const routes = [
{ path: '/only', method: 'GET', handler: only },
{ path: '/other', method: 'GET', handler: other },
];
for (const route of routes) {
if (${condition}) route.handler();
}
`),
).toEqual([]);
});
it('rejects a conditionally unreachable handler call in the consequence', () => {
expect(
compact(`
const routes = [{ path: '/x', method: 'GET', handler: unreachable }];
for (const route of routes) {
if (route.path === request.path && route.method === request.method) {
false && route.handler();
}
}
`),
).toEqual([]);
});
it('rejects a handler call after a statically terminating branch', () => {
expect(
compact(`
const routes = [{ path: '/x', method: 'GET', handler: unreachable }];
for (const route of routes) {
if (route.path === request.path && route.method === request.method) {
if (true) return;
route.handler();
}
}
`),
).toEqual([]);
});
it.each(['return', 'throw new Error("stop")', 'break', 'continue'])(
'rejects a handler call after an unconditional %s',
(transfer) => {
expect(
compact(`
const routes = [{ path: '/x', method: 'GET', handler: unreachable }];
for (const route of routes) {
if (route.path === request.path && route.method === request.method) {
${transfer};
route.handler();
}
}
`),
).toEqual([]);
},
);
it.each(['return', 'throw new Error("stop")', 'break', 'continue'])(
'rejects a dispatch guard after an unconditional %s',
(transfer) => {
expect(
compact(`
const routes = [{ path: '/x', method: 'GET', handler: unreachable }];
for (const route of routes) {
${transfer};
if (route.path === request.path && route.method === request.method) {
route.handler();
}
}
`),
).toEqual([]);
},
);
it('rejects a dispatch guard nested in a statically false branch', () => {
expect(
compact(`
const routes = [{ path: '/x', method: 'GET', handler: unreachable }];
for (const route of routes) {
if (false) {
if (route.path === request.path && route.method === request.method) {
route.handler();
}
}
}
`),
).toEqual([]);
});
it('rejects mutation of the loop entry before dispatch', () => {
expect(
compact(`
const routes = [{ path: '/mutated', method: 'GET', handler: mutated }];
for (const route of routes) {
route.path = request.path;
route.method = request.method;
if (route.path === request.path && route.method === request.method) route.handler();
}
`),
).toEqual([]);
expect(
compact(`
const routes = [{ path: '/reassigned', method: 'GET', handler: original }];
for (let route of routes) {
route = replacement;
if (route.path === request.path && route.method === request.method) route.handler();
}
`),
).toEqual([]);
});
it.each([
['deleting an entry field', 'delete route.path;'],
['deleting a wrapped entry field', 'delete (route.path);'],
['writing through a destructuring target', '({ path: route.path } = request);'],
['augmenting the handler field', 'route.handler ||= replacement;'],
['aliasing the entry', 'const alias = route; alias.path = request.path;'],
])('rejects %s', (_name, mutation) => {
expect(
compact(`
const routes = [{ path: '/mutated', method: 'GET', handler: original }];
for (const route of routes) {
${mutation}
if (route.path === request.path && route.method === request.method) route.handler();
}
`),
).toEqual([]);
});
it.each([
[
'a mutable declaration',
`let routes = [{ path: '/stale', method: 'GET', handler: stale }];
${dispatch('routes')}`,
],
[
'a reassigned table',
`const routes = [{ path: '/stale', method: 'GET', handler: stale }];
routes = [];
${dispatch('routes')}`,
],
[
'an aliased table',
`const routes = [{ path: '/stale', method: 'GET', handler: stale }];
const alias = routes;
${dispatch('routes')}`,
],
[
'a truncated table',
`const routes = [{ path: '/stale', method: 'GET', handler: stale }];
routes.length = 0;
${dispatch('routes')}`,
],
])('rejects %s', (_name, source) => {
expect(compact(source)).toEqual([]);
});
it.each([
[
'a reassigned bare handler',
`function original() {}
const routes = [{ path: '/stale', method: 'GET', handler: original }];
original = replacement;
${dispatch('routes')}`,
],
[
'a reassigned member handler',
`const auth = {};
auth.handle = replacement;
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
${dispatch('routes')}`,
],
[
'a handler owner mutated through an alias',
`const auth = {};
const alias = auth;
alias.handle = replacement;
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
${dispatch('routes')}`,
],
[
'a handler owner hidden by parentheses',
`const auth = {};
const alias = (auth);
alias.handle = replacement;
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
${dispatch('routes')}`,
],
[
'a handler owner nested in an initializer',
`const auth = {};
const box = { auth };
box.auth.handle = replacement;
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
${dispatch('routes')}`,
],
[
'a handler owner stored under an unrelated handler property',
`const auth = {};
const box = { handler: auth };
box.handler.handle = replacement;
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
${dispatch('routes')}`,
],
[
'a handler owner assigned to an alias',
`const auth = {};
let alias;
alias = auth;
alias.handle = replacement;
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
${dispatch('routes')}`,
],
[
'an escaped handler owner',
`const auth = {};
auth.configure();
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
${dispatch('routes')}`,
],
[
'a handler owner escaped through a computed member call',
`const auth = {};
auth['configure']();
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
${dispatch('routes')}`,
],
[
'a parenthesized handler owner escaped through a member call',
`const auth = {};
(auth).configure();
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
${dispatch('routes')}`,
],
[
'a returned handler owner',
`const auth = {};
function expose() { return auth; }
expose().handle = replacement;
const routes = [{ path: '/stale', method: 'GET', handler: auth.handle }];
${dispatch('routes')}`,
],
])('rejects %s', (_name, source) => {
expect(compact(source)).toEqual([]);
});
it('preserves an unresolved identity tombstone for unsupported duplicate handlers', () => {
expect(
compact(`
function valid() {}
const routes = [
{ path: '/users', method: 'GET', handler: makeHandler() },
{ path: '/users', method: 'GET', handler: valid },
];
${dispatch('routes')}
`),
).toEqual([]);
});
it('tombstones recoverable identities from entries with duplicate route keys', () => {
expect(
compact(`
function first() {}
function second() {}
const routes = [
{ path: '/users', path: '/users', method: 'GET', handler: first },
{ path: '/users', method: 'GET', handler: second },
];
${dispatch('routes')}
`),
).toEqual([]);
});
it('uses the final duplicate route keys when tombstoning an invalid entry', () => {
expect(
compact(`
function first() {}
function second() {}
const routes = [
{ path: '/ignored', path: '/users', method: 'POST', method: 'GET', handler: first },
{ path: '/users', method: 'GET', handler: second },
];
${dispatch('routes')}
`),
).toEqual([]);
});
it('suppresses later candidates when a trailing spread leaves identity unknown', () => {
expect(
compact(`
function first() {}
function second() {}
const routes = [
{ path: '/unknown', method: 'POST', handler: first, ...dynamicRoute },
{ path: '/users', method: 'GET', handler: second },
];
${dispatch('routes')}
`),
).toEqual([]);
});
it.each([
['a dynamic path', `{ path: runtimePath, method: 'GET', handler: first }`],
[
'a computed overwrite',
`{ path: '/other', method: 'GET', handler: first, ['path']: '/users' }`,
],
])('suppresses later candidates after %s', (_name, firstEntry) => {
expect(
compact(`
function first() {}
function second() {}
const routes = [
${firstEntry},
{ path: '/users', method: 'GET', handler: second },
];
${dispatch('routes')}
`),
).toEqual([]);
});
it('rejects tables containing array-level spreads', () => {
expect(
compact(`
function second() {}
const routes = [
...baseRoutes,
{ path: '/users', method: 'GET', handler: second },
];
${dispatch('routes')}
`),
).toEqual([]);
});
it('propagates unresolved identity tombstones across route tables', () => {
expect(
compact(`
function first() {}
function second() {}
const primaryRoutes = [{ path: '/users', method: 'GET', handler: first }];
const fallbackRoutes = [{ path: '/users', method: 'GET', handler: makeHandler() }];
${dispatch('primaryRoutes')}
${dispatch('fallbackRoutes')}
`),
).toEqual([]);
});
it.each([
[
'an unstable duplicate handler binding',
`function first() {}
function second() {}
first = replacement;
const routes = [
{ path: '/users', method: 'GET', handler: first },
{ path: '/users', method: 'GET', handler: second },
];`,
],
[
'a spread-first duplicate entry',
`function second() {}
const routes = [
{ ...base, path: '/users', method: 'GET', handler: first },
{ path: '/users', method: 'GET', handler: second },
];`,
],
])('tombstones %s', (_name, table) => {
expect(compact(`${table} ${dispatch('routes')}`)).toEqual([]);
});
it('rejects shadowed table and loop-entry bindings', () => {
expect(
compact(`
const routes = [{ path: '/outer', method: 'GET', handler: outer }];
{
const routes = [{ path: '/inner', method: 'GET', handler: inner }];
for (const route of routes) {
if (route.path === request.path && route.method === request.method) route.handler();
}
}
`),
).toEqual([]);
expect(
compact(`
const routes = [{ path: '/outer', method: 'GET', handler: outer }];
for (const route of routes) {
{
const route = fakeRoute;
if (route.path === request.path && route.method === request.method) route.handler();
}
}
`),
).toEqual([]);
});
it('suppresses a named import shadowed anywhere in the file', () => {
const detections = JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse(`
import { listUsers } from './handlers.js';
function wrapper(listUsers) { return listUsers; }
const routes = [{ path: '/users', method: 'GET', handler: listUsers }];
${dispatch('routes')}
`),
);
expect(detections).not.toContainEqual(
expect.objectContaining({ framework: DATA_ROUTE_TABLE_SOURCE, path: '/users' }),
);
});
it('feeds the Node group scanner with named-import provenance', () => {
const detections = JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse(`
import { listUsers as handleUsers } from './handlers.js';
const routes = [{ path: '/users', method: 'GET', handler: handleUsers }];
${dispatch('routes')}
`),
);
expect(detections).toContainEqual({
role: 'provider',
framework: DATA_ROUTE_TABLE_SOURCE,
method: 'GET',
path: '/users',
name: 'listUsers',
handlerImport: { name: 'listUsers', module: './handlers.js' },
strictHandlerResolution: true,
line: 3,
confidence: 0.8,
});
});
});

View file

@ -0,0 +1,259 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as 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 { DATA_ROUTE_TABLE_SOURCE } from '../../../src/core/ingestion/route-extractors/data-route-table.js';
describe('data route table group contracts', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-table-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
const repo = (): RepoHandle => ({
id: 'route-table-test',
path: 'test/route-table',
repoPath: tmpDir,
storagePath: path.join(tmpDir, '.gitnexus'),
});
it('resolves imported handlers and leaves source-only members unattributed', async () => {
fs.writeFileSync(
path.join(tmpDir, 'routes.js'),
`import { listUsers as handleUsers } from './handlers.js';
const routes = [
{ path: '/users', method: 'GET', handler: handleUsers },
{ path: '/auth/me', method: 'GET', handler: auth.getCurrentUser },
];
for (const route of routes) {
if (route.path === request.path && route.method === request.method) route.handler();
}
`,
);
fs.writeFileSync(
path.join(tmpDir, 'handlers.js'),
`export function listUsers() { return []; }
export const auth = { getCurrentUser() { return {}; } };
`,
);
const queriedNames: string[] = [];
const db = async (_query: string, params?: Record<string, unknown>) => {
const name = String(params?.name ?? '');
queriedNames.push(name);
if (name !== 'listUsers' && name !== 'getCurrentUser') return [];
return [
{
uid: `symbol:${name}`,
name,
filePath: 'handlers.js',
startLine: 1,
endLine: 2,
labels: ['Function'],
},
];
};
const contracts = await new HttpRouteExtractor().extract(db, tmpDir, repo());
const providers = contracts.filter(
(contract) =>
contract.role === 'provider' && contract.meta.framework === DATA_ROUTE_TABLE_SOURCE,
);
expect(
providers
.map((provider) => ({
contractId: provider.contractId,
symbolUid: provider.symbolUid,
symbolName: provider.symbolName,
extractionStrategy: provider.meta.extractionStrategy,
}))
.sort((left, right) => left.contractId.localeCompare(right.contractId)),
).toEqual([
{
contractId: 'http::GET::/auth/me',
symbolUid: '',
symbolName: 'handler',
extractionStrategy: 'source_scan',
},
{
contractId: 'http::GET::/users',
symbolUid: 'symbol:listUsers',
symbolName: 'listUsers',
extractionStrategy: 'source_scan_resolved',
},
]);
expect(queriedNames).not.toContain('getCurrentUser');
});
it('keeps ambiguous handlers unattributed instead of guessing', async () => {
fs.writeFileSync(
path.join(tmpDir, 'routes.js'),
`const routes = [{ path: '/users', method: 'GET', handler: listUsers }];
for (const route of routes) {
if (route.path === request.path && route.method === request.method) route.handler();
}`,
);
const db = async (_query: string, params?: Record<string, unknown>) =>
params?.name === 'listUsers'
? [
{ uid: 'a', name: 'listUsers', filePath: 'a.js' },
{ uid: 'b', name: 'listUsers', filePath: 'b.js' },
]
: [];
const contracts = await new HttpRouteExtractor().extract(db, tmpDir, repo());
const provider = contracts.find((contract) => contract.contractId === 'http::GET::/users');
expect(provider).toMatchObject({ symbolUid: '', symbolName: 'listUsers' });
});
it('keeps duplicate same-file handlers unattributed', async () => {
fs.writeFileSync(
path.join(tmpDir, 'routes.js'),
`const routes = [{ path: '/users', method: 'GET', handler: listUsers }];
for (const route of routes) {
if (route.path === request.path && route.method === request.method) route.handler();
}`,
);
const db = async (query: string) =>
query.includes('UNION ALL')
? [
{ uid: 'a', name: 'listUsers', filePath: 'routes.js', labels: ['Function'] },
{ uid: 'b', name: 'listUsers', filePath: 'routes.js', labels: ['Function'] },
]
: [];
const contracts = await new HttpRouteExtractor().extract(db, tmpDir, repo());
const provider = contracts.find((contract) => contract.contractId === 'http::GET::/users');
expect(provider).toMatchObject({ symbolUid: '', symbolName: 'listUsers' });
});
it('does not bind an unimported handler to a unique same-name symbol in another file', async () => {
fs.writeFileSync(
path.join(tmpDir, 'routes.js'),
`const routes = [{ path: '/users', method: 'GET', handler: listUsers }];
for (const route of routes) {
if (route.path === request.path && route.method === request.method) route.handler();
}`,
);
const db = async (query: string, params?: Record<string, unknown>) => {
if (query.includes('UNION ALL')) return [];
return params?.name === 'listUsers'
? [{ uid: 'decoy', name: 'listUsers', filePath: 'unrelated.js' }]
: [];
};
const contracts = await new HttpRouteExtractor().extract(db, tmpDir, repo());
const provider = contracts.find((contract) => contract.contractId === 'http::GET::/users');
expect(provider).toMatchObject({ symbolUid: '', symbolName: 'listUsers' });
});
it('does not fall back from an unresolved import to a same-name local symbol', async () => {
fs.writeFileSync(
path.join(tmpDir, 'routes.js'),
`import { listUsers } from 'external-router-package';
const routes = [{ path: '/users', method: 'GET', handler: listUsers }];
for (const route of routes) {
if (route.path === request.path && route.method === request.method) route.handler();
}`,
);
const db = async (_query: string, params?: Record<string, unknown>) =>
params?.name === 'listUsers'
? [{ uid: 'local-decoy', name: 'listUsers', filePath: 'local.js' }]
: [];
const contracts = await new HttpRouteExtractor().extract(db, tmpDir, repo());
const provider = contracts.find((contract) => contract.contractId === 'http::GET::/users');
expect(provider).toMatchObject({ symbolUid: '', symbolName: 'listUsers' });
});
it('does not attribute an unresolved member handler to its registrar function', async () => {
fs.writeFileSync(
path.join(tmpDir, 'routes.js'),
`function setupRoutes(path, method) {
const routes = [{ path: '/me', method: 'GET', handler: auth.getCurrentUser }];
for (const route of routes) {
if (route.path === request.path && route.method === request.method) route.handler();
}
}
`,
);
const db = async (query: string) =>
query.includes('UNION ALL')
? [
{
uid: 'function:setupRoutes',
name: 'setupRoutes',
filePath: 'routes.js',
startLine: 0,
endLine: 5,
labels: ['Function'],
},
]
: [];
const contracts = await new HttpRouteExtractor().extract(db, tmpDir, repo());
const provider = contracts.find((contract) => contract.contractId === 'http::GET::/me');
expect(provider).toMatchObject({ symbolUid: '', symbolName: 'handler' });
});
it('suppresses duplicate identities when any data-table handler is unresolved', async () => {
fs.writeFileSync(
path.join(tmpDir, 'routes.js'),
`function second() {}
const routes = [
{ path: '/users', method: 'GET', handler: missing },
{ path: '/users', method: 'GET', handler: second },
];
for (const route of routes) {
if (route.path === request.path && route.method === request.method) route.handler();
}`,
);
const db = async (query: string) =>
query.includes('UNION ALL')
? [{ uid: 'second', name: 'second', filePath: 'routes.js', labels: ['Function'] }]
: [];
const contracts = await new HttpRouteExtractor().extract(db, tmpDir, repo());
expect(contracts.some((contract) => contract.contractId === 'http::GET::/users')).toBe(false);
});
it('suppresses duplicate identities that resolve to different handlers', async () => {
fs.writeFileSync(
path.join(tmpDir, 'routes.js'),
`function first() {}
function second() {}
const routes = [
{ path: '/users', method: 'GET', handler: first },
{ path: '/users', method: 'GET', handler: second },
];
for (const route of routes) {
if (route.path === request.path && route.method === request.method) route.handler();
}`,
);
const db = async (query: string) =>
query.includes('UNION ALL')
? [
{ uid: 'first', name: 'first', filePath: 'routes.js', labels: ['Function'] },
{ uid: 'second', name: 'second', filePath: 'routes.js', labels: ['Function'] },
]
: [];
const contracts = await new HttpRouteExtractor().extract(db, tmpDir, repo());
expect(contracts.some((contract) => contract.contractId === 'http::GET::/users')).toBe(false);
});
});

View file

@ -219,6 +219,7 @@ export default router;
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL'))
return String(params?.filePath ?? '').includes('routes.ts') ? routesFileSyms : [];
if (query.includes('filePath IN $filePaths')) return [];
if (query.includes('n.name = $name'))
return params?.name === 'listUsers'
? [{ uid: 'fn-listUsers-xfile', name: 'listUsers', filePath: 'src/handlers/users.ts' }]
@ -229,6 +230,36 @@ export default router;
expect(provider).toMatchObject({ symbolUid: 'fn-listUsers-xfile', symbolName: 'listUsers' });
});
it('retains unique-name fallback for a Flask handler imported from a bare module', async () => {
const dir = path.join(tmpDir, 'py-flask-bare-import');
fs.mkdirSync(path.join(dir, 'app'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'app/routes.py'),
`from flask import Flask
from shared_handlers import list_users
app = Flask(__name__)
app.add_url_rule('/api/users', view_func=list_users)
`,
);
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('UNION ALL') || query.includes('filePath IN $filePaths')) return [];
if (query.includes('n.name = $name') && params?.name === 'list_users') {
return [{ uid: 'fn-list-users', name: 'list_users', filePath: 'shared/handlers.py' }];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(contract) =>
contract.role === 'provider' && contract.contractId === 'http::GET::/api/users',
);
expect(provider).toMatchObject({ symbolUid: 'fn-list-users', symbolName: 'list_users' });
});
it('leaves symbolUid empty when the repo-wide name is AMBIGUOUS (multiple matches)', async () => {
const dir = writeCrossFile('xfile-ambiguous');
const mockDbExecutor = async (
@ -591,6 +622,49 @@ app.add_url_rule('/api/users', view_func=handle_users)
expect(queriedNames).not.toContain('module:handle_users');
});
it('retains legacy package-prefix resolution for a Python re-export', async () => {
const dir = path.join(tmpDir, 'py-flask-reexport');
fs.mkdirSync(path.join(dir, 'app', 'handlers'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'app/routes.py'),
`from flask import Flask
from .handlers import list_users
app = Flask(__name__)
app.add_url_rule('/api/users', view_func=list_users)
`,
);
fs.writeFileSync(
path.join(dir, 'app', 'handlers', '__init__.py'),
`from .users import list_users\n`,
);
fs.writeFileSync(
path.join(dir, 'app', 'handlers', 'users.py'),
`def list_users(): return []\n`,
);
const mockDbExecutor = async (
query: string,
params?: Record<string, unknown>,
): Promise<Record<string, unknown>[]> => {
if (query.includes('STARTS WITH') && String(params?.fileSlash) === 'app/handlers/') {
return [
{
uid: 'fn-list-users',
name: 'list_users',
filePath: 'app/handlers/users.py',
},
];
}
return [];
};
const contracts = await extractor.extract(mockDbExecutor, dir, makeRepo(dir));
const provider = contracts.find(
(contract) =>
contract.role === 'provider' && contract.contractId === 'http::GET::/api/users',
);
expect(provider).toMatchObject({ symbolUid: 'fn-list-users', symbolName: 'list_users' });
});
// ── Inline / closure provider handlers (#2276) ──────────────────────
// An inline provider handler has no name, so it must resolve by line-span
// containment to the symbol it lives in — exactly like a consumer. Mirrors

View file

@ -218,17 +218,16 @@ describe('PARSE_CACHE_VERSION', () => {
// the pre-fix fan-out. This branch staged 64 above the claims live at the
// time (61, 62, 63); all three landed and cascaded main to 67, so 68 is the
// next free value above every claim at merge — the rule, re-applied.
// Moved 68 -> 70 for Spring non-HTTP handler side-channel facts (#2417 /
// #2891). Main holds 68 and open PR #2972 publishes 69, so 70 is the next
// free value above every known claim at this merge.
// Version 69 added #2969's JS/TS data-route-table decoratorRoutes. Version 70
// adds Spring non-HTTP handler side-channel facts (#2417 / #2891), so it is
// the next free value after both cache payload changes.
it('pins SCHEMA_BUMP to 70 so concurrent bumps cannot silently collide (#2766)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(70);
// The PREVIOUS version must fail the reuse gate, not merely differ from the
// current one — a hardcoded number outside the conflict hunk rebases cleanly
// while being wrong, which is exactly how the 37/38 exact clashes landed.
// Every nearby historical or in-flight value is rejected: this branch
// previously published 59, origin/main advanced through 68, and #2972
// publishes 69. Rejecting all of them makes a bad conflict resolution loud.
// Every nearby historical or in-flight value is rejected, including 69,
// which carried the route-table payload before this merge.
for (const taken of [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69]) {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
}

View file

@ -4,9 +4,9 @@
* Pins the P2 fixes from the review:
* - ambiguity fail-open: a same-name lookup returning 1 yields NO
* handlerSymbolId (never an arbitrary `[0]` guess).
* - first-writer-wins reservation: the first route to claim a route identity
* reserves it even when its handler is unresolvable, so a later same-identity
* route can't stamp its handler onto the (node-winning) first route's slot.
* - first-writer-wins reservation: ordinary route declarations reserve an
* identity even when unresolved; unproven data-table entries do not because
* the routes phase suppresses them entirely.
* - happy path: a uniquely-resolvable handler is stamped, keyed by the route's
* `(method, url)` identity (`routeNodeKey`).
* - multi-verb identity (#2289): `GET /x` and `POST /x` are distinct keys, so
@ -18,6 +18,7 @@ import { resolveRouteHandlerSymbols } from '../../src/core/ingestion/call-proces
import { routeNodeKey } from '../../src/core/ingestion/route-extractors/route-path.js';
import type { ExtractedDecoratorRoute } from '../../src/core/ingestion/workers/parse-worker.js';
import type { ExtractedRoute } from '../../src/core/ingestion/route-extractors/laravel.js';
import { DATA_ROUTE_TABLE_SOURCE } from '../../src/core/ingestion/route-extractors/data-route-table.js';
const FILE = 'src/OrderController.java';
@ -119,6 +120,402 @@ describe('resolveRouteHandlerSymbols — decorator routes', () => {
expect(out.get(routeNodeKey('GET', '/orders'))).toBe('method:OrderController.list');
expect(out.get(routeNodeKey('POST', '/orders'))).toBe('method:OrderController.create');
});
it('data-table bare handlers resolve in their lexical file', () => {
const model = createSemanticModel();
model.symbols.add('src/routes.js', 'list', 'function:list', 'Function');
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'list',
}),
],
);
expect(out.get(GET_ORDERS)).toBe('function:list');
});
it('data-table named imports resolve only exported callables', () => {
const model = createSemanticModel();
const exported = model.symbols.add(
'src/handlers.js',
'listUsers',
'function:listUsers',
'Function',
);
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'handleUsers',
}),
],
{
files: [
{
filePath: 'src/routes.js',
localDefs: [],
parsedImports: [
{
kind: 'named',
localName: 'handleUsers',
importedName: 'listUsers',
targetRaw: './handlers.js',
},
],
},
],
resolveImportTarget: () => 'src/handlers.js',
isExportedSymbol: (nodeId) => nodeId === exported.nodeId,
},
);
expect(out.get(GET_ORDERS)).toBe(exported.nodeId);
});
it('data-table named imports reject private callables', () => {
const model = createSemanticModel();
model.symbols.add('src/handlers.js', 'listUsers', 'function:listUsers', 'Function');
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'handleUsers',
}),
],
{
files: [
{
filePath: 'src/routes.js',
localDefs: [],
parsedImports: [
{
kind: 'named',
localName: 'handleUsers',
importedName: 'listUsers',
targetRaw: './handlers.js',
},
],
},
],
resolveImportTarget: () => 'src/handlers.js',
isExportedSymbol: () => false,
},
);
expect(out.has(GET_ORDERS)).toBe(false);
});
it('data-table named-import members reject private owners', () => {
const model = createSemanticModel();
const owner = model.symbols.add('src/handlers.js', 'auth', 'object:auth', 'Variable');
model.methods.register(owner.nodeId, 'getCurrentUser', {
filePath: 'src/handlers.js',
name: 'getCurrentUser',
nodeId: 'method:auth.getCurrentUser',
type: 'Method',
ownerId: owner.nodeId,
});
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'authService.getCurrentUser',
}),
],
{
files: [
{
filePath: 'src/routes.js',
localDefs: [],
parsedImports: [
{
kind: 'named',
localName: 'authService',
importedName: 'auth',
targetRaw: './handlers.js',
},
],
},
],
resolveImportTarget: () => 'src/handlers.js',
isExportedSymbol: () => false,
},
);
expect(out.has(GET_ORDERS)).toBe(false);
});
it('data-table members resolve through their proven same-file owner', () => {
const model = createSemanticModel();
model.symbols.add('src/routes.js', 'auth', 'object:auth', 'Variable');
model.methods.register('object:auth', 'getCurrentUser', {
filePath: 'src/routes.js',
name: 'getCurrentUser',
nodeId: 'method:auth.getCurrentUser',
type: 'Method',
ownerId: 'object:auth',
});
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'auth.getCurrentUser',
}),
],
);
expect(out.get(GET_ORDERS)).toBe('method:auth.getCurrentUser');
});
it('data-table members refuse an unrelated terminal-name decoy', () => {
const model = createSemanticModel();
model.symbols.add('src/routes.js', 'getCurrentUser', 'function:decoy', 'Function');
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'externalAuth.getCurrentUser',
}),
],
);
expect(out.has(GET_ORDERS)).toBe(false);
});
it('data-table members suppress unsupported multi-level receiver chains', () => {
const model = createSemanticModel();
model.symbols.add('src/routes.js', 'services', 'object:services', 'Variable');
model.methods.register('object:services', 'getCurrentUser', {
filePath: 'src/routes.js',
name: 'getCurrentUser',
nodeId: 'method:decoy',
type: 'Method',
ownerId: 'object:services',
});
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'services.auth.getCurrentUser',
}),
],
);
expect(out.has(GET_ORDERS)).toBe(false);
});
it('an unresolved data-table entry does not reserve a valid framework route identity', () => {
const model = createSemanticModel();
model.symbols.add(FILE, 'list', 'method:OrderController.list', 'Method');
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'missing.handler',
}),
decoratorRoute({ handlerName: 'list' }),
],
);
expect(out.get(GET_ORDERS)).toBe('method:OrderController.list');
});
it('an unresolved data-table duplicate suppresses the route identity', () => {
const model = createSemanticModel();
model.symbols.add('src/routes.js', 'second', 'function:second', 'Function');
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'missing',
}),
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'second',
}),
],
);
expect(out.has(GET_ORDERS)).toBe(false);
});
it('different resolvable handlers for one data-table identity are suppressed', () => {
const model = createSemanticModel();
model.symbols.add('src/routes.js', 'first', 'function:first', 'Function');
model.symbols.add('src/routes.js', 'second', 'function:second', 'Function');
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'first',
}),
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'second',
}),
],
);
expect(out.has(GET_ORDERS)).toBe(false);
});
it('fails closed for default imports even when the target file has an exported callable', () => {
const model = createSemanticModel();
const exported = model.symbols.add(
'src/handlers.js',
'listUsers',
'function:listUsers',
'Function',
);
const privateHelper = model.symbols.add(
'src/handlers.js',
'helper',
'function:helper',
'Function',
);
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'handleUsers',
}),
],
{
files: [
{
filePath: 'src/routes.js',
localDefs: [],
parsedImports: [
{
kind: 'alias',
localName: 'handleUsers',
importedName: 'default',
alias: 'handleUsers',
targetRaw: './handlers.js',
},
],
},
{
filePath: 'src/handlers.js',
localDefs: [exported, privateHelper],
parsedImports: [],
},
],
resolveImportTarget: () => 'src/handlers.js',
isExportedSymbol: (nodeId) => nodeId === exported.nodeId,
},
);
expect(out.has(GET_ORDERS)).toBe(false);
});
it('does not infer a barrel helper as a default re-export target', () => {
const model = createSemanticModel();
const helper = model.symbols.add('src/barrel.js', 'helper', 'function:helper', 'Function');
const out = resolveRouteHandlerSymbols(
model,
[],
[
decoratorRoute({
filePath: 'src/routes.js',
source: DATA_ROUTE_TABLE_SOURCE,
handlerName: 'handleUsers',
}),
],
{
files: [
{
filePath: 'src/routes.js',
localDefs: [],
parsedImports: [
{
kind: 'alias',
localName: 'handleUsers',
importedName: 'default',
alias: 'handleUsers',
targetRaw: './barrel.js',
},
],
},
{
filePath: 'src/barrel.js',
localDefs: [{ ...helper, nodeId: 'def:helper', qualifiedName: 'helper' }],
parsedImports: [
{
kind: 'named',
localName: 'default',
importedName: 'default',
targetRaw: './actual.js',
},
],
},
],
resolveImportTarget: (parsedImport) =>
parsedImport.targetRaw === './barrel.js' ? 'src/barrel.js' : 'src/actual.js',
isExportedSymbol: (nodeId) => nodeId === helper.nodeId,
},
);
expect(out.has(GET_ORDERS)).toBe(false);
});
it('ordinary decorator routes do not gain the repo-wide fallback', () => {
const model = createSemanticModel();
model.symbols.add('src/other.js', 'list', 'function:other.list', 'Function');
const out = resolveRouteHandlerSymbols(model, [], [decoratorRoute()]);
expect(out.has(GET_ORDERS)).toBe(false);
});
});
describe('resolveRouteHandlerSymbols — Laravel framework routes', () => {