diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index f211b12c3..255eaf1a3 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 138530d24..357d337ae 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/gitnexus/src/core/group/extractors/http-patterns/node.ts b/gitnexus/src/core/group/extractors/http-patterns/node.ts index 0d1298f72..edd0921f1 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/node.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/node.ts @@ -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; } diff --git a/gitnexus/src/core/group/extractors/http-patterns/types.ts b/gitnexus/src/core/group/extractors/http-patterns/types.ts index ffe59cc59..38c197704 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/types.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/types.ts @@ -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.7–0.8. */ confidence: number; } diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index cd0494441..6aa4c8476 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -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[], name: string): Res return null; } +function resolveFileSymbolByNameUnique( + rows: Record[], + 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(); const resolveImportedSymbol = async ( fromFile: string, imp: { name: string; module: string }, + strict = false, ): Promise => { 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[] = []; 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 => { 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, resolveSymbol: (filePath: string, d: HttpDetection) => Promise, ): Promise { - 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(); + 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(); + 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); } diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index ffce0a538..2a7f450d7 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -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>; +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 { const out = new Map(); // 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(); + const dataHandlersByIdentity = new Map< + string, + { handlers: Set; 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(), + 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); } diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index c24ab6ae8..eb757a7b5 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -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) => [ + ...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, }); diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index 55611848a..2a3d21612 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -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(); + 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(); + 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, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts index eca248fad..8115f5b40 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts @@ -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 } | 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 = { // 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 | 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', diff --git a/gitnexus/src/core/ingestion/route-extractors/data-route-table.ts b/gitnexus/src/core/ingestion/route-extractors/data-route-table.ts new file mode 100644 index 000000000..053d207fe --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/data-route-table.ts @@ -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 = 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> = { + 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(); + 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(); + 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(); + 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, +): 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; +} + +function comparisonFields( + condition: SyntaxNode, + entryName: string, + bindingCounts: ReadonlyMap, +): 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, +): 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, +): 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 { + const counts = new Map(); + 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; + +function collectHandlerReferences(root: SyntaxNode): HandlerReferenceIndex { + const references = new Map(); + 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(); + 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, + })); +} diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 6ea3f25d7..f5c8be27f 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -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 { diff --git a/gitnexus/test/fixtures/data-route-table-app/decoy.js b/gitnexus/test/fixtures/data-route-table-app/decoy.js new file mode 100644 index 000000000..7b01fd0bb --- /dev/null +++ b/gitnexus/test/fixtures/data-route-table-app/decoy.js @@ -0,0 +1,3 @@ +export function handleUsers() { + return { wrong: true }; +} diff --git a/gitnexus/test/fixtures/data-route-table-app/handlers.js b/gitnexus/test/fixtures/data-route-table-app/handlers.js new file mode 100644 index 000000000..943960ae5 --- /dev/null +++ b/gitnexus/test/fixtures/data-route-table-app/handlers.js @@ -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' }); + }, +}; diff --git a/gitnexus/test/fixtures/data-route-table-app/routes.js b/gitnexus/test/fixtures/data-route-table-app/routes.js new file mode 100644 index 000000000..c5d426b89 --- /dev/null +++ b/gitnexus/test/fixtures/data-route-table-app/routes.js @@ -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; +} diff --git a/gitnexus/test/integration/data-route-table-benchmark.test.ts b/gitnexus/test/integration/data-route-table-benchmark.test.ts new file mode 100644 index 000000000..64f6da63d --- /dev/null +++ b/gitnexus/test/integration/data-route-table-benchmark.test.ts @@ -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); +}); diff --git a/gitnexus/test/integration/data-route-table-pipeline.test.ts b/gitnexus/test/integration/data-route-table-pipeline.test.ts new file mode 100644 index 000000000..adff70250 --- /dev/null +++ b/gitnexus/test/integration/data-route-table-pipeline.test.ts @@ -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(), + storagePath: storageDir, + onDiskKeys: new Set(), + }; + 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); +}); diff --git a/gitnexus/test/integration/group/http-route-resolve-symbol.test.ts b/gitnexus/test/integration/group/http-route-resolve-symbol.test.ts index efc5fb99d..1f06d1bcc 100644 --- a/gitnexus/test/integration/group/http-route-resolve-symbol.test.ts +++ b/gitnexus/test/integration/group/http-route-resolve-symbol.test.ts @@ -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'); diff --git a/gitnexus/test/unit/data-route-table-routes.test.ts b/gitnexus/test/unit/data-route-table-routes.test.ts new file mode 100644 index 000000000..a094a8d80 --- /dev/null +++ b/gitnexus/test/unit/data-route-table-routes.test.ts @@ -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, + }); + }); +}); diff --git a/gitnexus/test/unit/group/data-route-table.test.ts b/gitnexus/test/unit/group/data-route-table.test.ts new file mode 100644 index 000000000..421c26cb3 --- /dev/null +++ b/gitnexus/test/unit/group/data-route-table.test.ts @@ -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) => { + 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) => + 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) => { + 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) => + 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); + }); +}); diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index b56b08d32..68e7d8669 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -219,6 +219,7 @@ export default router; ): Promise[]> => { 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, + ): Promise[]> => { + 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, + ): Promise[]> => { + 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 diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 0dfe29599..17595f15b 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -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); } diff --git a/gitnexus/test/unit/resolve-route-handler-symbols.test.ts b/gitnexus/test/unit/resolve-route-handler-symbols.test.ts index b6ff406f7..bd754927e 100644 --- a/gitnexus/test/unit/resolve-route-handler-symbols.test.ts +++ b/gitnexus/test/unit/resolve-route-handler-symbols.test.ts @@ -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', () => {