diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b6697200c..cf72358c9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -166,7 +166,7 @@ export const myPhase: PipelinePhase = { ### Where routes come from -`route-extractors/` holds four independent ways a route can be discovered, all +`route-extractors/` holds five independent ways a route can be discovered, all converging on the routes phase's `(method, url)` registry: | Source | Shape | Examples | @@ -175,8 +175,9 @@ converging on the routes phase's `(method, url)` registry: | Single-file framework route | `isRouteFile` + worker extraction | Laravel `routes/*.php` | | Cross-file framework route | `discoverRootRouteFiles` + `extractRoutes` | Django `urlpatterns` | | AST-level route in a normal file | `extractDecoratorRoutes` | Spring, FastAPI, NestJS (`@Controller` + `@Get`/`@Post`/…; URLs are controller-relative — `setGlobalPrefix` and URI versioning live in the bootstrap file and are not applied), **JS/TS dispatch guards and static data route tables** | +| Cross-file AST route facts | `extractRouteFacts` + `resolveRouteFacts` | Express apps/routers and their mount paths; directly proven Hono registrations | -The last row is the one whose name undersells it. A route is DECLARED by a +The AST-level route hook is the one whose name undersells it. A route is DECLARED by a decorator, but it can also be **inferred** from a raw `node:http` server's own dispatch — `if (req.method === 'GET' && pathname === '/api/x')` is a route with a path, a verb and a handler, and nothing else in the pipeline could see it. @@ -200,10 +201,28 @@ without a verb, and any regex it cannot translate exactly are all dropped rather than guessed at. A missing route is a coverage limit; an invented one is a lie. Express `app.get(RegExp, handler)` registrations are likewise omitted: a regex matcher does not have one finite URL to use as a Route node key. -Express `app.use(path, middleware)` is middleware attachment, not an endpoint; -it does not create a Route node. Dispatch-guard inference also ignores stored -`route.routePath` metadata: comparing a recorded route name with a literal -does not prove that the request path is being dispatched. +Express `app.use(path, middleware)` does not itself create a Route node. When +both receivers are proven Express instances, mounting a router joins the literal +prefix to its registered paths before handler selection. Nested and repeated +mounts, literal path arrays, and middleware arrays share this composition. + +Express receiver facts reuse the existing AST and `ParsedFile` lexical identities. +They follow explicit ESM/CommonJS imports and exports through the configured +`ScopeResolver`, including stable aliases and barrels. An object named `app` or +`router` is not evidence; type-only imports, shadowed bindings, conflicting writes, +and unproven factories do not establish a receiver. Directly proven Hono verb +registrations remain supported, without applying Express mounting semantics. + +A standalone router retains its local paths. A known dynamic mount suppresses +that fallback, while independently proven static mounts remain available. Cycles +terminate, and exceeding the expansion budget omits the affected mount component +rather than publishing a partial set of paths. Per-file facts are cached, but +cross-file paths are recomputed each run. Group HTTP contracts use the same +resolver and composition, including in source-only mode. + +Dispatch-guard inference also ignores stored `route.routePath` metadata: +comparing a recorded route name with a literal does not prove that the request +path is being dispatched. Two rules there need more than one comparison to decide, and are worth knowing about before changing either: diff --git a/gitnexus/src/core/group/extractors/http-patterns/node.ts b/gitnexus/src/core/group/extractors/http-patterns/node.ts index 1cdd50644..4a247a731 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/node.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/node.ts @@ -9,7 +9,24 @@ import { type LanguagePatterns, type PatternSpec, } from '../tree-sitter-scanner.js'; -import type { HttpDetection, HttpLanguagePlugin, RepoContext } from './types.js'; +import type { + HttpDetection, + HttpLanguagePlugin, + HttpScanInput, + HttpFileDetections, + RepoContext, +} from './types.js'; +import { getProviderForFile } from '../../../ingestion/languages/index.js'; +import { extractParsedFile } from '../../../ingestion/scope-extractor-bridge.js'; +import { extractFrameworkRouteFacts } from '../../../ingestion/route-extractors/framework-route-facts.js'; +import { resolveFrameworkRoutePaths } from '../../../ingestion/route-extractors/framework-route-paths.js'; +import type { + FrameworkRoute, + FrameworkRouteFacts, +} from '../../../ingestion/route-extractors/framework-route-types.js'; +import { typescriptScopeResolver } from '../../../ingestion/languages/typescript/scope-resolver.js'; +import { javascriptScopeResolver } from '../../../ingestion/languages/javascript/scope-resolver.js'; +import { getLanguageFromFilename, SupportedLanguages, type ParsedFile } from 'gitnexus-shared'; import { MAX_FOLD_LENGTH } from '../../../ingestion/route-extractors/constant-resolver.js'; import { DATA_ROUTE_TABLE_SOURCE, @@ -49,18 +66,6 @@ import { // NestJS providers are not queried here at all — see the `extractNestRoutes` // call in `scanBundle`. -// ─── Provider: Express — router.get/app.post/... ───────────────────── -const EXPRESS_SPEC: PatternSpec> = { - meta: {}, - query: ` - (call_expression - function: (member_expression - object: (identifier) @obj (#match? @obj "^(router|app)$") - property: (property_identifier) @http_method (#match? @http_method "^(get|post|put|delete|patch)$")) - arguments: (arguments . [(string) (template_string)] @path . (_)? @handler)) - `, -}; - // ─── Consumer: fetch(url) with NO options ───────────────────────────── const FETCH_NO_OPTIONS_SPEC: PatternSpec> = { meta: {}, @@ -96,8 +101,8 @@ const FETCH_WITH_OPTIONS_SPEC: PatternSpec> = { // `scanBundle` admits a match only after PROVING the receiver is an axios // instance and resolving the argument to a path. // -// The proof gate is load-bearing, not belt-and-braces: EXPRESS_SPEC above -// matches `router.get('/x', handler)` / `app.post(...)` as PROVIDERS. A +// The proof gate is load-bearing: proven Express registrations such as +// `router.get('/x', handler)` / `app.post(...)` are PROVIDERS. A // receiver admitted on spelling alone would re-emit every Express route in the // repo as a consumer of itself, on both sides of every cross-repo pair. const HTTP_CLIENT_SPEC: PatternSpec> = { @@ -185,7 +190,6 @@ const REQUEST_OBJECT_SPEC: PatternSpec> = { const WRAPPED_REQUEST_RECEIVERS = new Set(['httpClient', '$http']); interface NodePatternBundle { - express: CompiledPatterns>; fetchNoOptions: CompiledPatterns>; fetchWithOptions: CompiledPatterns>; httpClient: CompiledPatterns>; @@ -203,7 +207,6 @@ function compileBundle(language: Parser.Language, name: string): NodePatternBund patterns: [spec], } satisfies LanguagePatterns>); return { - express: mk(EXPRESS_SPEC, 'express'), fetchNoOptions: mk(FETCH_NO_OPTIONS_SPEC, 'fetch-no-options'), fetchWithOptions: mk(FETCH_WITH_OPTIONS_SPEC, 'fetch-with-options'), httpClient: mk(HTTP_CLIENT_SPEC, 'http-client'), @@ -372,6 +375,9 @@ function buildImportMap(tree: Parser.Tree): Map; + readonly configurationAvailable: boolean; } /** @@ -408,11 +414,12 @@ function grammarForFile(rel: string): unknown | null { return null; } -function buildNodeRepoContext(args: { +async function buildNodeRepoContext(args: { + repoPath: string; files: string[]; readFile: (rel: string) => string | null; parseSource: (parser: Parser, src: string) => Parser.Tree | null; -}): NodeRepoContext { +}): Promise { const cached = REPO_CONTEXT_BY_FILE_LIST.get(args.files); if (cached) return cached; @@ -472,11 +479,100 @@ function buildNodeRepoContext(args: { } } - const ctx: NodeRepoContext = { facts: buildJsRepoFacts(byFile) }; + // Both Node resolvers consume the same tsconfig/jsconfig/workspace inputs; + // their import-target hooks retain their own extension ordering below. + let resolutionConfig: unknown; + let configurationAvailable = true; + try { + resolutionConfig = await typescriptScopeResolver.loadResolutionConfig?.(args.repoPath); + } catch { + configurationAvailable = false; + } + const ctx: NodeRepoContext = { + facts: buildJsRepoFacts(byFile), + resolutionConfig, + files: new Set(args.files.map(normalizeRel)), + configurationAvailable, + }; REPO_CONTEXT_BY_FILE_LIST.set(args.files, ctx); return ctx; } +/** Adapt exact lexical handler evidence without guessing from a missing name. */ +function frameworkDetection(route: FrameworkRoute, facts: FrameworkRouteFacts): HttpDetection { + const call = route.frameworkCall; + const handler = call?.arguments.at(-1); + let name = route.handlerName ?? null; + let handlerImport: HttpDetection['handlerImport']; + if (!name && handler?.kind === 'binding' && !facts.invalidBindings.includes(handler.id)) { + const bindings = facts.bindings.filter((binding) => binding.id === handler.id); + const value = bindings.length === 1 ? bindings[0].value : undefined; + if (value?.kind === 'import' && !['default', '*', 'require'].includes(value.name)) { + name = value.name; + handlerImport = { name: value.name, module: value.source }; + } + } + return { + role: 'provider', + framework: route.decoratorName?.startsWith('hono.') ? 'hono' : 'express', + method: route.httpMethod ?? '*', + path: route.routePath, + name, + ...(handlerImport ? { handlerImport } : { strictHandlerResolution: true }), + ...(!name && handler?.kind !== 'handler' ? { unresolvedHandler: true } : {}), + line: route.lineNumber + 1, + confidence: 0.8, + }; +} + +/** One project family for JS, TS and TSX, using the already parsed group trees. */ +function scanNodeProject( + files: readonly HttpScanInput[], + repoContext?: RepoContext, +): HttpFileDetections[] { + const context = repoContext as NodeRepoContext | undefined; + if (context?.configurationAvailable === false) return []; + const parsedFiles: ParsedFile[] = []; + const facts = new Map(); + for (const { filePath: rawPath, tree } of files) { + const filePath = normalizeRel(rawPath); + const provider = getProviderForFile(filePath); + if (!provider) continue; + try { + const parsed = extractParsedFile(provider, tree.rootNode.text, filePath, undefined, tree); + if (!parsed) continue; + parsedFiles.push(parsed); + facts.set(filePath, extractFrameworkRouteFacts(tree, parsed)); + } catch { + // A malformed file contributes no framework facts; other files survive. + } + } + const filePaths = context?.files ?? new Set(files.map((file) => normalizeRel(file.filePath))); + const routes = resolveFrameworkRoutePaths([...facts.values()], (fromFile, specifier) => { + const resolver = + getLanguageFromFilename(fromFile) === SupportedLanguages.JavaScript + ? javascriptScopeResolver + : typescriptScopeResolver; + const target = resolver.resolveImportTarget( + specifier, + fromFile, + filePaths, + context?.resolutionConfig, + { parsedFiles }, + ); + return typeof target === 'string' ? [target] : (target ?? []); + }); + const detections = new Map(); + for (const route of routes) { + const fileFacts = facts.get(route.filePath); + if (!fileFacts) continue; + const found = detections.get(route.filePath) ?? []; + found.push(frameworkDetection(route, fileFacts)); + detections.set(route.filePath, found); + } + return [...detections].map(([filePath, found]) => ({ filePath, detections: found })); +} + /** The repo facts to resolve against, or null when there was no pre-pass. */ function resolveFactsFor( repoContext: RepoContext | undefined, @@ -615,33 +711,16 @@ function scanBundle( }); } - // Express: router/app.(...) - for (const match of runCompiledPatterns(bundle.express, tree)) { - const methodNode = match.captures.http_method; - const pathNode = match.captures.path; - if (!methodNode || !pathNode) continue; - const path = unquoteLiteral(pathNode.text); - if (path === null) continue; - // Capture the handler argument identifier (`router.get('/x', listUsers)` - // → `listUsers`) so a named handler resolves by name. For an inline/anonymous - // handler emit `name: null` (NOT the sentinel `'handler'`) so the resolver - // does NOT match an unrelated function that happens to be named `handler` — - // it uses the registration line for containment instead. When the handler is - // an imported (possibly aliased) symbol, carry the resolved import so the - // extractor can pin it to the source module rather than the local alias text. - const handlerNode = match.captures.handler; - const localHandler = handlerNode?.type === 'identifier' ? handlerNode.text : null; - const imported = localHandler !== null ? importMap.get(localHandler) : undefined; - out.push({ - role: 'provider', - framework: 'express', - method: methodNode.text.toUpperCase(), - path, - name: imported ? imported.name : localHandler, - handlerImport: imported, - line: (handlerNode ?? pathNode).startPosition.row + 1, - confidence: 0.8, - }); + // Bare single-tree callers retain local framework routes. Repository scans + // receive only the composed project providers, never a raw-path fallback. + if (fileRel === undefined) { + const filePath = + bundle === JAVASCRIPT_BUNDLE + ? 'routes.js' + : bundle === TSX_BUNDLE + ? 'routes.tsx' + : 'routes.ts'; + out.push(...(scanNodeProject([{ filePath, tree }])[0]?.detections ?? [])); } // Consumer: fetch with options { method: 'X' } @@ -853,6 +932,7 @@ export const JAVASCRIPT_HTTP_PLUGIN: HttpLanguagePlugin = { name: 'javascript-http', language: JavaScript, prepareRepo: buildNodeRepoContext, + scanProject: scanNodeProject, scan: (tree, repoContext, fileRel) => scanBundle(JAVASCRIPT_BUNDLE, tree, repoContext, fileRel), }; @@ -860,6 +940,7 @@ export const TYPESCRIPT_HTTP_PLUGIN: HttpLanguagePlugin = { name: 'typescript-http', language: TypeScript.typescript, prepareRepo: buildNodeRepoContext, + scanProject: scanNodeProject, scan: (tree, repoContext, fileRel) => scanBundle(TYPESCRIPT_BUNDLE, tree, repoContext, fileRel), }; @@ -867,5 +948,6 @@ export const TSX_HTTP_PLUGIN: HttpLanguagePlugin = { name: 'tsx-http', language: TypeScript.tsx, prepareRepo: buildNodeRepoContext, + scanProject: scanNodeProject, scan: (tree, repoContext, fileRel) => scanBundle(TSX_BUNDLE, tree, repoContext, fileRel), }; diff --git a/gitnexus/src/core/group/extractors/http-patterns/types.ts b/gitnexus/src/core/group/extractors/http-patterns/types.ts index ca57dfc6f..06f8bf451 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/types.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/types.ts @@ -181,5 +181,5 @@ export interface HttpLanguagePlugin { * multiple files, such as Java controllers inheriting Spring mappings * from annotated interfaces. */ - scanProject?(files: readonly HttpScanInput[]): HttpFileDetections[]; + scanProject?(files: readonly HttpScanInput[], repoContext?: RepoContext): HttpFileDetections[]; } diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index 707ccdff7..6accf5694 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -403,7 +403,7 @@ export class HttpRouteExtractor implements ContractExtractor { { plugin: HttpLanguagePlugin; input: HttpScanInput; repoContext: unknown } | null >(); const projectDetections = new Map(); - let projectScanComplete = false; + const completedProjectScans = new Set>(); // Per-plugin cross-file context (e.g. Python's FastAPI router → // include_router(prefix=...) map). Built lazily on first @@ -418,7 +418,7 @@ export class HttpRouteExtractor implements ContractExtractor { if (!plugin || typeof plugin.prepareRepo !== 'function') return undefined; if (repoContextByPlugin.has(plugin.name)) return repoContextByPlugin.get(plugin.name); try { - const ctx = plugin.prepareRepo({ + const ctx = await plugin.prepareRepo({ repoPath, files: await getScannedFiles(), parser, @@ -466,14 +466,21 @@ export class HttpRouteExtractor implements ContractExtractor { }; const getDetections = async (rel: string): Promise => { - const cached = cachedDetections.get(rel); + // Graph enrichment needs composed providers too. Resolve only this + // project's plugin family; unrelated complete-coverage languages can + // still skip parsing when their graph handlers already resolved. + const projectScan = getPluginForFile(rel)?.scanProject; + if (projectScan && !completedProjectScans.has(projectScan)) + await collectProjectDetections(await getScannedFiles(), projectScan); + const key = normalizeRepoRelPath(rel); + const cached = cachedDetections.get(key); if (cached) return cached; const scanInput = await getScanInput(rel); const ownDetections = scanInput ? scanInput.plugin.scan(scanInput.input.tree, scanInput.repoContext, rel) : []; - const detections = [...ownDetections, ...(projectDetections.get(rel) ?? [])]; - cachedDetections.set(rel, detections); + const detections = [...ownDetections, ...(projectDetections.get(key) ?? [])]; + cachedDetections.set(key, detections); return detections; }; @@ -486,27 +493,41 @@ export class HttpRouteExtractor implements ContractExtractor { return scannedFiles; }; - const collectProjectDetections = async (files: string[]): Promise => { - if (projectScanComplete) return; - projectScanComplete = true; - const byPlugin = new Map(); + const collectProjectDetections = async ( + files: string[], + onlyScan?: NonNullable, + ): Promise => { + const byScan = new Map< + NonNullable, + { inputs: HttpScanInput[]; repoContext: unknown } + >(); for (const rel of files) { + const scanProject = getPluginForFile(rel)?.scanProject; + if ( + !scanProject || + completedProjectScans.has(scanProject) || + (onlyScan && onlyScan !== scanProject) + ) + continue; const scanInput = await getScanInput(rel); - if (!scanInput?.plugin.scanProject) continue; - const items = byPlugin.get(scanInput.plugin) ?? []; - items.push(scanInput.input); - byPlugin.set(scanInput.plugin, items); + if (!scanInput) continue; + const group = byScan.get(scanProject) ?? { inputs: [], repoContext: scanInput.repoContext }; + group.inputs.push(scanInput.input); + byScan.set(scanProject, group); } - for (const [plugin, inputs] of byPlugin) { - const results = plugin.scanProject?.(inputs) ?? []; + // Shared function identity pools grammar variants without teaching the + // orchestrator which languages belong to the same project family. + for (const [scanProject, { inputs, repoContext }] of byScan) { + completedProjectScans.add(scanProject); + const results = scanProject(inputs, repoContext); for (const result of results) { - const existing = projectDetections.get(result.filePath) ?? []; - projectDetections.set(result.filePath, [...existing, ...result.detections]); + const key = normalizeRepoRelPath(result.filePath); + const existing = projectDetections.get(key) ?? []; + projectDetections.set(key, [...existing, ...result.detections]); + cachedDetections.delete(key); } } - - cachedDetections.clear(); }; const files = await getScannedFiles(); diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index ee97bb1b7..3e5b4617a 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -20,6 +20,7 @@ import type { ScopeKind, ScopeTree, ParsedImport, + ParsedFile, ParsedTypeBinding, SymbolDefinition, Callsite, @@ -605,6 +606,23 @@ interface LanguageProviderConfig { lineOffset: number, ) => ExtractedDecoratorRoute[]; + /** JSON-safe provider-owned facts captured from the existing AST and lexical + * identities. Shared parsing transports these without interpreting them. */ + readonly extractRouteFacts?: ( + tree: Parser.Tree, + parsedFile: ParsedFile, + lineOffset: number, + ) => unknown; + + /** Resolve project-wide provider facts before route winner selection. The + * callback uses the language's configured ScopeResolver module resolution. */ + readonly resolveRouteFacts?: ( + files: readonly { filePath: string; facts: unknown }[], + context: { + resolveImportTargets: (fromFile: string, moduleSpecifier: string) => readonly string[]; + }, + ) => ExtractedDecoratorRoute[]; + /** URL-bearing call candidates from the existing AST, before wrapper names * are known across files. Routes selects known wrappers after parsing. */ readonly extractFetchWrapperCalls?: ( diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index e42827764..6964a4dcc 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -129,6 +129,8 @@ import { extractDispatchGuardRoutes } from '../route-extractors/dispatch-guard.j import { extractDataRouteTableRoutes } from '../route-extractors/data-route-table.js'; import { extractNestRoutes } from '../route-extractors/nest.js'; import { extractFetchWrapperCalls } from '../route-extractors/fetch-wrapper.js'; +import { extractFrameworkRouteFacts } from '../route-extractors/framework-route-facts.js'; +import { resolveProjectFrameworkRouteFacts } from '../route-extractors/framework-route-paths.js'; import { extractTrpcRoutes, shouldScanForTrpcRoutes } from '../route-extractors/trpc.js'; import { extractConvexEndpointProperties } from './typescript/convex-endpoint-metadata.js'; import { extractToolDefinitions } from './typescript/tool-definitions.js'; @@ -495,6 +497,8 @@ export const typescriptProvider = defineLanguage({ // to a literal; nothing else in this pipeline can see that shape. TS and JS // share the grammar, so they share the extractor. extractDecoratorRoutes: extractJsTsRoutes, + extractRouteFacts: extractFrameworkRouteFacts, + resolveRouteFacts: resolveProjectFrameworkRouteFacts, extractFetchWrapperCalls, // Content-based (not AST): tRPC procedure routers are scanned from source text. // Path-gate lives here (language provider), not in the shared parse worker. @@ -582,6 +586,8 @@ export const javascriptProvider = defineLanguage({ arityCompatibility: jsArityCompatibility, // See the TypeScript provider above. extractDecoratorRoutes: extractJsTsRoutes, + extractRouteFacts: extractFrameworkRouteFacts, + resolveRouteFacts: resolveProjectFrameworkRouteFacts, extractFetchWrapperCalls, // Content-based (not AST): tRPC procedure routers are scanned from source text. extractTextRoutes: extractJsTsTextRoutes, diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index d0de7589f..111861125 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -40,6 +40,7 @@ export interface WorkerExtractedData { fetchWrapperDefs: FetchWrapperDef[]; fetchWrapperCalls: FetchWrapperCall[]; decoratorRoutes: ExtractedDecoratorRoute[]; + routeFacts?: { filePath: string; facts: unknown; routePosition: number }[]; routerIncludes: ExtractedRouterInclude[]; routerImports: ExtractedRouterImport[]; routerConstructorPrefixes: ExtractedRouterConstructorPrefix[]; @@ -122,6 +123,7 @@ export const mergeChunkResults = ( const allFetchWrapperDefs: FetchWrapperDef[] = []; const allFetchWrapperCalls: FetchWrapperCall[] = []; const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; + const allRouteFacts: { filePath: string; facts: unknown; routePosition: number }[] = []; const allRouterIncludes: ExtractedRouterInclude[] = []; const allRouterImports: ExtractedRouterImport[] = []; const allRouterConstructorPrefixes: ExtractedRouterConstructorPrefix[] = []; @@ -172,6 +174,11 @@ export const mergeChunkResults = ( for (const item of result.fetchCalls) allFetchCalls.push(item); for (const item of result.fetchWrapperDefs ?? []) allFetchWrapperDefs.push(item); for (const item of result.fetchWrapperCalls ?? []) allFetchWrapperCalls.push(item); + for (const item of result.routeFacts ?? []) + allRouteFacts.push({ + ...item, + routePosition: item.routePosition + allDecoratorRoutes.length, + }); for (const item of result.decoratorRoutes) allDecoratorRoutes.push(item); for (const item of result.routerIncludes ?? []) allRouterIncludes.push(item); for (const item of result.routerImports ?? []) allRouterImports.push(item); @@ -197,6 +204,7 @@ export const mergeChunkResults = ( fetchWrapperDefs: allFetchWrapperDefs, fetchWrapperCalls: allFetchWrapperCalls, decoratorRoutes: allDecoratorRoutes, + routeFacts: allRouteFacts, routerIncludes: allRouterIncludes, routerImports: allRouterImports, routerConstructorPrefixes: allRouterConstructorPrefixes, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index c6d0f48c1..48fdedf37 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -15,6 +15,7 @@ * @module */ +import type { LanguageProvider } from '../language-provider.js'; import type { FetchWrapperCall } from '../route-extractors/fetch-wrapper.js'; import { BindingAccumulator, @@ -778,6 +779,7 @@ export async function runChunkedParseAndResolve( const allFetchWrapperCalls: FetchWrapperCall[] = []; const allExtractedRoutes: ExtractedRoute[] = []; const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; + const allRouteFacts: { filePath: string; facts: unknown; routePosition: number }[] = []; const allRouterIncludes: ExtractedRouterInclude[] = []; const allRouterImports: ExtractedRouterImport[] = []; const allRouterConstructorPrefixes: ExtractedRouterConstructorPrefix[] = []; @@ -1004,6 +1006,11 @@ export async function runChunkedParseAndResolve( if (chunkWorkerData.routes?.length) { for (const item of chunkWorkerData.routes) allExtractedRoutes.push(item); } + for (const item of chunkWorkerData.routeFacts ?? []) + allRouteFacts.push({ + ...item, + routePosition: item.routePosition + allDecoratorRoutes.length, + }); if (chunkWorkerData.decoratorRoutes?.length) { for (const item of chunkWorkerData.decoratorRoutes) allDecoratorRoutes.push(item); } @@ -1563,6 +1570,85 @@ export async function runChunkedParseAndResolve( for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports); } + const routeFilePaths = new Set(allPaths); + const routeResolutionConfigs = new Map(); + for (const filePath of new Set(allRouteFacts.map((file) => file.filePath))) { + const language = getLanguageFromFilename(filePath); + if (language === null || routeResolutionConfigs.has(language)) continue; + const resolver = SCOPE_RESOLVERS.get(language); + routeResolutionConfigs.set( + language, + capturedResolutionConfigs?.has(language) + ? capturedResolutionConfigs.get(language) + : resolver?.loadResolutionConfig === undefined + ? undefined + : await resolver.loadResolutionConfig(repoPath), + ); + } + let routeResolutionFiles = allParsedFiles; + const resolveRouteModuleTargets = ( + fromFile: string, + moduleSpecifier: string, + parsedImport?: ParsedImport, + ): readonly string[] => { + const language = getLanguageFromFilename(fromFile); + if (language === null) return []; + const target = SCOPE_RESOLVERS.get(language)?.resolveImportTarget( + moduleSpecifier, + fromFile, + routeFilePaths, + routeResolutionConfigs.get(language), + { parsedFiles: routeResolutionFiles, ...(parsedImport ? { parsedImport } : {}) }, + ); + if (typeof target === 'string') return [target]; + return target ?? []; + }; + const resolveRouteImportTargets = ( + parsedImport: ParsedImport, + fromFile: string, + ): readonly string[] => + resolveRouteModuleTargets(fromFile, parsedImport.targetRaw ?? '', parsedImport); + + // A resolver's identity defines a fact family, including providers sharing + // one language implementation. Shared ingestion never inspects the payload. + const routeFactGroups = new Map< + NonNullable, + typeof allRouteFacts + >(); + for (const file of allRouteFacts) { + const resolve = getProviderForFile(file.filePath)?.resolveRouteFacts; + if (!resolve) continue; + const group = routeFactGroups.get(resolve) ?? []; + group.push(file); + routeFactGroups.set(resolve, group); + } + const resolvedRoutesByFile = new Map(); + for (const [resolve, files] of routeFactGroups) { + for (const route of resolve(files, { resolveImportTargets: resolveRouteModuleTargets })) { + const routes = resolvedRoutesByFile.get(route.filePath) ?? []; + routes.push(route); + resolvedRoutesByFile.set(route.filePath, routes); + } + } + // Preserve the capture slot across sub-batch, chunk and cache boundaries. + // Insert before path expansion can change the decorator array's positions. + const routesAtPosition = new Map(); + for (const file of allRouteFacts) { + const routes = resolvedRoutesByFile.get(file.filePath); + if (!routes) continue; + const slot = routesAtPosition.get(file.routePosition) ?? []; + for (const route of routes) slot.push(route); + routesAtPosition.set(file.routePosition, slot); + resolvedRoutesByFile.delete(file.filePath); + } + const withResolvedRoutes: ExtractedDecoratorRoute[] = []; + for (let index = 0; index <= allDecoratorRoutes.length; index++) { + for (const route of routesAtPosition.get(index) ?? []) withResolvedRoutes.push(route); + if (index < allDecoratorRoutes.length) withResolvedRoutes.push(allDecoratorRoutes[index]); + } + allDecoratorRoutes.length = 0; + for (const route of withResolvedRoutes) allDecoratorRoutes.push(route); + // FastAPI router-prefix resolution (cross-file). // // #2391: resolve non-literal FastAPI decorator route paths (imported/composed @@ -1839,7 +1925,6 @@ export async function runChunkedParseAndResolve( 'parse-impl-return', `exportedTypeMap=${exportedTypeMap.size} parsedFiles=${allParsedFiles.length} nodes=${graph.nodeCount}`, ); - const routeFilePaths = new Set(allPaths); // Route files whose handlers resolve through imports: data route tables, and // routes of a language that resolves its own handlers (`resolveRouteHandler`). // Both need the file's parsed imports and its language's resolution config. @@ -1852,7 +1937,6 @@ export async function runChunkedParseAndResolve( ) .map((route) => route.filePath), ); - const routeResolutionConfigs = new Map(); for (const filePath of dataRouteFilePaths) { const language = getLanguageFromFilename(filePath); if (language === null || routeResolutionConfigs.has(language)) continue; @@ -1861,28 +1945,9 @@ export async function runChunkedParseAndResolve( language, capturedResolutionConfigs?.has(language) ? capturedResolutionConfigs.get(language) - : resolver?.loadResolutionConfig === undefined - ? undefined - : await resolver.loadResolutionConfig(repoPath), + : await resolver?.loadResolutionConfig?.(repoPath), ); } - let routeResolutionFiles = allParsedFiles; - const resolveRouteImportTargets = ( - parsedImport: ParsedImport, - fromFile: string, - ): readonly string[] => { - const language = getLanguageFromFilename(fromFile); - if (language === null) return []; - const target = SCOPE_RESOLVERS.get(language)?.resolveImportTarget( - parsedImport.targetRaw ?? '', - fromFile, - routeFilePaths, - routeResolutionConfigs.get(language), - { parsedFiles: routeResolutionFiles, parsedImport }, - ); - if (typeof target === 'string') return [target]; - return target ?? []; - }; const resolveRouteImportTarget = ( parsedImport: ParsedImport, fromFile: string, diff --git a/gitnexus/src/core/ingestion/route-extractors/framework-route-facts.ts b/gitnexus/src/core/ingestion/route-extractors/framework-route-facts.ts new file mode 100644 index 000000000..7876cecdb --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/framework-route-facts.ts @@ -0,0 +1,396 @@ +import type Parser from 'tree-sitter'; +import { buildPositionIndex, buildScopeTree, lookupLexicalName } from 'gitnexus-shared'; +import type { ParsedFile, ParsedImport } from 'gitnexus-shared'; +import { plainString } from './data-route-table.js'; +import type { FrameworkExpression, FrameworkRouteFacts } from './framework-route-types.js'; + +type Node = Parser.SyntaxNode; +const UNKNOWN: FrameworkExpression = { kind: 'unknown' }; +const HANDLER: FrameworkExpression = { kind: 'handler' }; +const METHODS = new Set(['get', 'post', 'put', 'delete', 'patch', 'all', 'route', 'use']); +const children = (node: Node | null | undefined): Node[] => + node?.namedChildren.filter((child) => child.type !== 'comment') ?? []; +const args = (node: Node): Node[] => children(node.childForFieldName('arguments')); + +function literalPaths(node: Node | undefined): string[] | null { + if (!node) return null; + const value = plainString(node); + if (value !== null) return value.startsWith('/') ? [value] : null; + if (node.type !== 'array') return null; + const paths = children(node).map((child) => plainString(child)); + return paths.length > 0 && paths.every((path) => path !== null && path.startsWith('/')) + ? [...new Set(paths as string[])] + : null; +} + +/** + * Reuses the parser's AST and ParsedFile lexical ownership. The only additional + * data is framework value flow; declarations/scopes are never reconstructed. + */ +export function extractFrameworkRouteFacts( + tree: Parser.Tree, + parsedFile: ParsedFile, + lineOffset = 0, +): FrameworkRouteFacts { + const filePath = parsedFile.filePath; + const facts: FrameworkRouteFacts = { + filePath, + bindings: [], + exports: [], + starExports: [], + invalidBindings: [], + mutations: [], + calls: [], + }; + const scopes = buildScopeTree(parsedFile.scopes); + const positions = buildPositionIndex(parsedFile.scopes); + const invalid = new Set(); + const importsAtPosition = new Map(); + for (const imported of parsedFile.parsedImports) { + const key = `${imported.atRange.startLine}:${imported.atRange.startCol}`; + const imports = importsAtPosition.get(key) ?? []; + imports.push(imported); + importsAtPosition.set(key, imports); + } + const expressionCache = new Map(); + const bindingKey = (scope: string, name: string): string => `${scope}\0${name}`; + const ownership = (node: Node, name = node.text, atUse = false) => { + const scope = positions.atPosition( + filePath, + node.startPosition.row + 1 + lineOffset, + node.startPosition.column, + ); + return scope === undefined + ? undefined + : lookupLexicalName( + scope, + name, + { scopes }, + { + purpose: 'value', + ...(atUse + ? { + position: { + startLine: node.startPosition.row + 1 + lineOffset, + startCol: node.startPosition.column, + }, + } + : {}), + }, + ); + }; + const bindingId = (node: Node, name = node.text, atUse = false): string | undefined => { + const owner = ownership(node, name, atUse); + // Pre-finalization imports and untyped parameters deliberately have no + // BindingRef. A future binding claim, however, is a real TDZ barrier. + if ( + atUse && + owner?.status === 'blocked' && + owner.claims.some( + (claim) => + claim.kind === 'blocked' || + (claim.kind === 'binding' && + claim.availableFrom && + (claim.availableFrom.startLine > node.startPosition.row + 1 + lineOffset || + (claim.availableFrom.startLine === node.startPosition.row + 1 + lineOffset && + claim.availableFrom.startCol > node.startPosition.column))), + ) + ) + return undefined; + return owner?.scope ? bindingKey(owner.scope.id, name) : undefined; + }; + const isGlobal = (node: Node, name: string) => ownership(node, name)?.status === 'absent'; + + const expression = (node: Node | null | undefined, depth = 0): FrameworkExpression => { + if (!node || depth > 64 || node.hasError) return UNKNOWN; + const cached = expressionCache.get(node.id); + if (cached) return cached; + let value: FrameworkExpression = UNKNOWN; + if (node.type === 'identifier' || node.type === 'shorthand_property_identifier') { + const id = bindingId(node, node.text, true); + if (id) value = { kind: 'binding', id }; + } else if (node.type === 'array') { + value = { + kind: 'array', + elements: children(node).map((child) => expression(child, depth + 1)), + }; + } else if ( + [ + 'parenthesized_expression', + 'as_expression', + 'satisfies_expression', + 'non_null_expression', + ].includes(node.type) + ) { + value = expression(children(node)[0], depth + 1); + } else if ( + [ + 'arrow_function', + 'function_expression', + 'function_declaration', + 'generator_function_declaration', + ].includes(node.type) + ) { + value = HANDLER; + } else if (node.type === 'member_expression') { + const name = node.childForFieldName('property'); + if (name?.type === 'property_identifier') + value = { + kind: 'member', + object: expression(node.childForFieldName('object'), depth + 1), + name: name.text, + }; + } else if (node.type === 'call_expression' || node.type === 'new_expression') { + const callee = node.childForFieldName( + node.type === 'new_expression' ? 'constructor' : 'function', + ); + const arguments_ = args(node); + const source = arguments_[0] && plainString(arguments_[0]); + if ( + callee?.type === 'identifier' && + callee.text === 'require' && + isGlobal(callee, 'require') && + source && + arguments_.length === 1 + ) { + value = { kind: 'import', source, name: 'require' }; + } else { + value = { + kind: 'call', + id: `${filePath}:${node.startIndex}`, + callee: expression(callee, depth + 1), + construct: node.type === 'new_expression', + argumentCount: arguments_.length, + paths: literalPaths(arguments_[0]), + }; + } + } + expressionCache.set(node.id, value); + return value; + }; + + const bind = (node: Node, value: FrameworkExpression): void => { + if (node.type === 'identifier' || node.type === 'shorthand_property_identifier_pattern') { + const id = bindingId(node); + if (id) facts.bindings.push({ id, value }); + } else if (node.type === 'object_pattern') { + for (const part of children(node)) { + const key = part.type === 'pair_pattern' ? part.childForFieldName('key') : part; + const target = part.type === 'pair_pattern' ? part.childForFieldName('value') : part; + if ( + key && + target && + ['identifier', 'property_identifier', 'shorthand_property_identifier_pattern'].includes( + key.type, + ) + ) { + bind(target, { kind: 'member', object: value, name: key.text }); + } + } + } + }; + const exported = (name: string, value: FrameworkExpression) => + facts.exports.push({ name, value }); + let cjsDefaultAssignments = 0; + let cjsShortcutDetached = false; + let cjsUnknownWrite = false; + const cjsShortcutExports: { name: string; value: FrameworkExpression }[] = []; + const cjsExports: { name: string; value: FrameworkExpression }[] = []; + const nodes: Node[] = [tree.rootNode]; + while (nodes.length) { + const node = nodes.pop()!; + if (node.type === 'ERROR') continue; + nodes.push(...children(node).reverse()); + if (node.type === 'import_statement') { + // ESM decomposition already carries precise aliases/type-only flags. CJS + // imports are intentionally handled at their AST calls to guard require. + const position = `${node.startPosition.row + 1 + lineOffset}:${node.startPosition.column}`; + for (const imported of importsAtPosition.get(position) ?? []) { + if (!('localName' in imported) || !('importedName' in imported)) continue; + const id = bindingKey( + imported.declaredAtScope ?? parsedFile.moduleScope, + imported.localName, + ); + facts.bindings.push({ + id, + value: + 'typeOnly' in imported && imported.typeOnly + ? UNKNOWN + : { + kind: 'import', + source: imported.targetRaw, + name: imported.kind === 'namespace' ? '*' : imported.importedName, + }, + }); + } + } else if (node.type === 'variable_declarator') { + const name = node.childForFieldName('name'); + if (name) bind(name, expression(node.childForFieldName('value'))); + } else if ( + node.type === 'function_declaration' || + node.type === 'generator_function_declaration' + ) { + const name = node.childForFieldName('name'); + if (name) bind(name, HANDLER); + } else if (node.type === 'export_statement') { + const sourceNode = node.childForFieldName('source'); + const source = sourceNode && plainString(sourceNode); + if (node.children.some((child) => child.type === 'type')) continue; + const clause = children(node).find((child) => child.type === 'export_clause'); + if (clause) { + for (const specifier of children(clause)) { + if (specifier.children.some((child) => child.type === 'type')) continue; + const name = specifier.childForFieldName('name'); + const alias = specifier.childForFieldName('alias'); + if (name) + exported( + alias?.text ?? name.text, + source ? { kind: 'import', source, name: name.text } : expression(name), + ); + } + } else if (node.children.some((child) => child.type === 'default')) { + exported( + 'default', + expression( + node.childForFieldName('value') ?? + node.childForFieldName('declaration') ?? + children(node)[0], + ), + ); + } else if (source) { + const namespace = children(node).find((child) => child.type === 'namespace_export'); + const name = namespace && children(namespace)[0]; + if (name) exported(name.text, { kind: 'import', source, name: '*' }); + else if (!node.hasError) facts.starExports.push(source); + } else { + const declaration = node.childForFieldName('declaration'); + if ( + declaration?.type === 'lexical_declaration' || + declaration?.type === 'variable_declaration' + ) { + for (const declarator of children(declaration)) { + const name = declarator.childForFieldName('name'); + if (name?.type === 'identifier') { + const id = bindingId(name); + if (id) exported(name.text, { kind: 'binding', id }); + } + } + } else { + const name = declaration?.childForFieldName('name'); + if (name) exported(name.text, expression(name)); + } + } + } else if ( + node.type === 'assignment_expression' || + node.type === 'augmented_assignment_expression' || + node.type === 'update_expression' || + node.type === 'for_in_statement' || + (node.type === 'unary_expression' && node.children.some((child) => child.type === 'delete')) + ) { + const left = node.childForFieldName('left') ?? node.childForFieldName('argument'); + const right = node.childForFieldName('right'); + const object = left?.childForFieldName('object'); + const property = left?.childForFieldName('property'); + if (left?.type === 'identifier' && left.text === 'exports' && isGlobal(left, 'exports')) + cjsShortcutDetached = true; + const moduleExports = (candidate: Node | null | undefined): boolean => + candidate?.type === 'member_expression' && + candidate.childForFieldName('object')?.text === 'module' && + candidate.childForFieldName('property')?.text === 'exports' && + isGlobal(candidate, 'module'); + const writesExportProperty = + moduleExports(object) || (object?.text === 'exports' && isGlobal(object, 'exports')); + if ( + (left?.type === 'subscript_expression' && writesExportProperty) || + (node.type !== 'assignment_expression' && (moduleExports(left) || writesExportProperty)) + ) + cjsUnknownWrite = true; + if (node.type === 'assignment_expression' && moduleExports(left)) { + cjsDefaultAssignments++; + cjsExports.length = 0; + if (right?.type === 'object') { + cjsExports.push({ name: 'default', value: UNKNOWN }); + for (const part of children(right)) { + const key = part.type === 'pair' ? part.childForFieldName('key') : part; + const value = part.type === 'pair' ? part.childForFieldName('value') : part; + if ( + key && + ['property_identifier', 'identifier', 'shorthand_property_identifier'].includes( + key.type, + ) + ) { + cjsExports.push({ name: key.text, value: expression(value) }); + } else { + // A spread, computed key, or other unsupported member can replace + // an earlier explicit export. Never retain a partial object shape. + cjsUnknownWrite = true; + } + } + } else cjsExports.push({ name: 'default', value: expression(right) }); + } else if ( + node.type === 'assignment_expression' && + left?.type === 'member_expression' && + property?.type === 'property_identifier' && + writesExportProperty + ) { + const record = { name: property.text, value: expression(right) }; + if (object?.text === 'exports') { + if (!cjsShortcutDetached) cjsShortcutExports.push(record); + } else { + // A replacement module.exports can itself be a Router. Writing one of + // its properties mutates that allocation, not only the export table. + const defaultValue = cjsExports.find((entry) => entry.name === 'default')?.value; + if (defaultValue) facts.mutations.push(defaultValue); + cjsExports.push(record); + } + } else if (left) { + const invalidate = (target: Node) => { + if ( + target.type === 'identifier' || + target.type === 'shorthand_property_identifier_pattern' + ) { + const id = bindingId(target); + if (id) invalid.add(id); + } else if ( + target.type === 'member_expression' || + target.type === 'subscript_expression' + ) { + let receiver = target.childForFieldName('object'); + while (receiver) { + // A namespace member can resolve to an exported allocation even + // when the namespace itself is only a module value. + facts.mutations.push(expression(receiver)); + if (receiver.type !== 'member_expression' && receiver.type !== 'subscript_expression') + break; + receiver = receiver.childForFieldName('object'); + } + } else for (const child of children(target)) invalidate(child); + }; + invalidate(left); + } + } else if (node.type === 'call_expression') { + const callee = node.childForFieldName('function'); + const method = callee?.childForFieldName('property')?.text; + if (callee?.type === 'member_expression' && method && METHODS.has(method)) { + const arguments_ = args(node); + const handler = arguments_.at(-1); + facts.calls.push({ + receiver: expression(callee.childForFieldName('object')), + method, + arguments: arguments_.map((argument) => expression(argument)), + paths: literalPaths(arguments_[0]), + lineNumber: node.startPosition.row + lineOffset, + ...(handler?.type === 'identifier' ? { handlerName: handler.text } : {}), + }); + } + } + } + // exports is only the original module.exports alias. A replacement breaks + // that alias; do not attribute its writes to the replacement object. + if (!cjsUnknownWrite) { + if (cjsDefaultAssignments === 0) facts.exports.push(...cjsShortcutExports); + if (cjsDefaultAssignments <= 1) facts.exports.push(...cjsExports); + } + facts.invalidBindings = [...invalid]; + return facts; +} diff --git a/gitnexus/src/core/ingestion/route-extractors/framework-route-paths.ts b/gitnexus/src/core/ingestion/route-extractors/framework-route-paths.ts new file mode 100644 index 000000000..bbacd6392 --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/framework-route-paths.ts @@ -0,0 +1,128 @@ +import type { LanguageProvider } from '../language-provider.js'; +import { resolveFrameworkRouteFacts } from './framework-route-resolver.js'; +import { normalizeExtractedRoutePath } from './route-path.js'; +import type { + FrameworkImportResolver, + FrameworkRoute, + FrameworkRouteFacts, + ResolvedFrameworkRoutes, +} from './framework-route-types.js'; + +/** Each connected mount component either finishes or contributes no routes. */ +export const FRAMEWORK_MOUNT_EXPANSION_LIMIT = 10_000; + +export function composeFrameworkRoutePaths( + resolved: ResolvedFrameworkRoutes, + expansionLimit = FRAMEWORK_MOUNT_EXPANSION_LIMIT, +): FrameworkRoute[] { + const outgoing = new Map(); + const neighbors = new Map>(); + const incoming = new Set(); + for (const mount of resolved.mounts) { + incoming.add(mount.childId); // Dynamic mounts also suppress standalone fallback. + const edges = outgoing.get(mount.parentId) ?? []; + edges.push(mount); + outgoing.set(mount.parentId, edges); + for (const [from, to] of [ + [mount.parentId, mount.childId], + [mount.childId, mount.parentId], + ]) { + const adjacent = neighbors.get(from) ?? new Set(); + adjacent.add(to); + neighbors.set(from, adjacent); + } + } + const prefixes = new Map>(); + const seen = new Set(); + for (const allocation of resolved.allocations) { + if (seen.has(allocation.id) || allocation.kind === 'hono') continue; + const component = new Set(); + const pending = [allocation.id]; + while (pending.length) { + const id = pending.pop()!; + if (component.has(id)) continue; + component.add(id); + seen.add(id); + for (const neighbor of neighbors.get(id) ?? []) pending.push(neighbor); + } + const roots = [...component].filter((id) => !incoming.has(id)).sort(); + const componentPrefixes = new Map>(); + const stack = roots.map((id) => ({ id, prefix: '', visiting: new Set() })); + let steps = 0; + let exhausted = false; + while (stack.length) { + if (++steps > expansionLimit) { + exhausted = true; + break; + } + const current = stack.pop()!; + if (current.visiting.has(current.id)) continue; + const paths = componentPrefixes.get(current.id) ?? new Set(); + paths.add(current.prefix); + componentPrefixes.set(current.id, paths); + const visiting = new Set(current.visiting).add(current.id); + for (const mount of outgoing.get(current.id) ?? []) { + if (visiting.has(mount.childId)) continue; + for (const prefix of mount.prefixes ?? []) { + // Count queued work too, so a large path array cannot allocate an + // unbounded frontier before the next expansion checks its budget. + if (steps + stack.length >= expansionLimit) { + exhausted = true; + break; + } + stack.push({ + id: mount.childId, + prefix: normalizeExtractedRoutePath(prefix, current.prefix), + visiting, + }); + } + if (exhausted) break; + } + if (exhausted) break; + } + if (!exhausted) for (const [id, paths] of componentPrefixes) prefixes.set(id, paths); + } + const routes = new Map(); + for (const registration of resolved.registrations) { + const paths = + registration.framework === 'hono' ? [''] : (prefixes.get(registration.receiverId) ?? []); + for (const prefix of paths) { + const route = { + ...registration.route, + routePath: normalizeExtractedRoutePath(registration.route.routePath, prefix), + }; + const key = JSON.stringify([ + route.filePath, + route.lineNumber, + route.httpMethod, + route.routePath, + route.handlerName, + ]); + routes.set(key, route); + } + } + return [...routes.values()].sort( + (left, right) => + left.filePath.localeCompare(right.filePath) || + left.lineNumber - right.lineNumber || + left.routePath.localeCompare(right.routePath) || + (left.httpMethod ?? '').localeCompare(right.httpMethod ?? ''), + ); +} + +/** Shared ingestion/group entry point. Inputs are already parsed provider facts. */ +export function resolveFrameworkRoutePaths( + files: readonly FrameworkRouteFacts[], + resolveImportTargets: FrameworkImportResolver, +): FrameworkRoute[] { + return composeFrameworkRoutePaths(resolveFrameworkRouteFacts(files, resolveImportTargets)); +} + +/** The same function identity intentionally groups JS/TS/TSX facts together. */ +export const resolveProjectFrameworkRouteFacts: NonNullable< + LanguageProvider['resolveRouteFacts'] +> = (files, context) => + resolveFrameworkRoutePaths( + files.map(({ facts }) => facts as FrameworkRouteFacts), + context.resolveImportTargets, + ); diff --git a/gitnexus/src/core/ingestion/route-extractors/framework-route-resolver.ts b/gitnexus/src/core/ingestion/route-extractors/framework-route-resolver.ts new file mode 100644 index 000000000..2a9372dd1 --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/framework-route-resolver.ts @@ -0,0 +1,384 @@ +import type { + FrameworkAllocation, + FrameworkCallFact, + FrameworkExpression, + FrameworkImportResolver, + FrameworkMount, + FrameworkRouteFacts, + ResolvedFrameworkRoutes, +} from './framework-route-types.js'; + +const VERBS = new Set(['get', 'post', 'put', 'delete', 'patch', 'all']); +const MAX_RESOLUTION_DEPTH = 128; +const MAX_RESOLUTION_STEPS = 10_000; +type Value = + | { kind: 'factory'; framework: 'express' | 'router' | 'hono' } + | { kind: 'module'; filePath: string } + | { kind: 'instance'; allocation: FrameworkAllocation } + | { kind: 'builder'; receiverId: string; paths: string[] } + | { kind: 'array'; elements: (Value | undefined)[] } + | { kind: 'handler' }; +type ExportResult = { found: boolean; value?: Value }; +const keyOf = (value: Value): string => { + switch (value.kind) { + case 'factory': + return `factory:${value.framework}`; + case 'module': + return `module:${value.filePath}`; + case 'instance': + return `instance:${value.allocation.id}`; + case 'builder': + return `builder:${value.receiverId}:${JSON.stringify(value.paths)}`; + case 'array': + return `array:${value.elements.map((element) => element && keyOf(element)).join(',')}`; + case 'handler': + return 'handler'; + } +}; + +/** Resolve explicit static provenance without parsing, filesystem guesses, or execution. */ +export function resolveFrameworkRouteFacts( + files: readonly FrameworkRouteFacts[], + resolveImportTargets: FrameworkImportResolver, +): ResolvedFrameworkRoutes { + const byFile = new Map(files.map((file) => [file.filePath, file])); + const bindings = new Map(); + const invalidBindings = new Set(files.flatMap((file) => file.invalidBindings)); + for (const file of files) + for (const binding of file.bindings) { + const entry = bindings.get(binding.id) ?? { filePath: file.filePath, values: [] }; + entry.values.push(binding.value); + bindings.set(binding.id, entry); + } + const allocations = new Map(); + const cache = new Map(); + let remainingSteps = MAX_RESOLUTION_STEPS; + const invalidValues = new Set(); + const moduleTargets = new Map(); + const targetFor = (filePath: string, source: string): string | undefined => { + const key = `${filePath}\0${source}`; + if (!moduleTargets.has(key)) { + moduleTargets.set(key, [...new Set(resolveImportTargets(filePath, source))]); + } + const targets = moduleTargets.get(key)!; + return targets.length === 1 && byFile.has(targets[0]) ? targets[0] : undefined; + }; + const exportValue = ( + filePath: string, + name: string, + visiting: Set, + depth: number, + ): ExportResult => { + const key = `export:${filePath}\0${name}`; + // Unknown due to a bound must compete with other star candidates; treating + // it as absent could publish a winner from an incompletely explored barrel. + if (--remainingSteps <= 0 || depth > MAX_RESOLUTION_DEPTH) return { found: true }; + if (visiting.has(key)) return { found: false }; + const file = byFile.get(filePath); + if (!file) return { found: false }; + const direct = file.exports.filter((entry) => entry.name === name); + const next = new Set(visiting).add(key); + if (direct.length) + return { + found: true, + ...(direct.length === 1 + ? { value: evaluate(direct[0].value, filePath, next, depth + 1) } + : {}), + }; + // A star export never republishes default. Explicit exports above win. + if (name === 'default') return { found: false }; + const candidates: ExportResult[] = []; + for (const source of file.starExports) { + const target = targetFor(filePath, source); + if (!target) { + candidates.push({ found: true }); + continue; + } + const candidate = exportValue(target, name, next, depth + 1); + if (candidate.found) candidates.push(candidate); + } + if (remainingSteps <= 0) return { found: true }; + if (!candidates.length) return { found: false }; + const first = candidates[0].value; + return { + found: true, + value: + first && + candidates.every((candidate) => candidate.value && keyOf(candidate.value) === keyOf(first)) + ? first + : undefined, + }; + }; + const imported = ( + filePath: string, + source: string, + name: string, + visiting: Set, + depth: number, + ): Value | undefined => { + // Configured repository targets are authoritative even when their module + // specifier matches a framework package name (e.g. a tsconfig path alias). + const target = targetFor(filePath, source); + if (!target) { + if (moduleTargets.get(`${filePath}\0${source}`)?.length) return undefined; + if (source === 'express') { + if (name === 'Router') return { kind: 'factory', framework: 'router' }; + if (name === 'default' || name === '*' || name === 'require') + return { kind: 'factory', framework: 'express' }; + return undefined; + } + if (source === 'hono') { + if (name === 'Hono') return { kind: 'factory', framework: 'hono' }; + if (name === '*' || name === 'require') return { kind: 'module', filePath: 'package:hono' }; + return undefined; + } + return undefined; + } + if (name === '*') return { kind: 'module', filePath: target }; + if (name === 'require') { + return ( + exportValue(target, 'default', visiting, depth + 1).value ?? { + kind: 'module', + filePath: target, + } + ); + } + return exportValue(target, name, visiting, depth + 1).value; + }; + const member = ( + value: Value | undefined, + name: string, + visiting: Set, + depth: number, + ): Value | undefined => { + if (value?.kind === 'factory' && value.framework === 'express') { + if (name === 'Router') return { kind: 'factory', framework: 'router' }; + if (name === 'default') return value; + } + if (value?.kind === 'module') { + if (value.filePath === 'package:hono') + return name === 'Hono' ? { kind: 'factory', framework: 'hono' } : undefined; + return exportValue(value.filePath, name, visiting, depth + 1).value; + } + return undefined; + }; + const evaluate = ( + expression: FrameworkExpression, + filePath: string, + visiting = new Set(), + depth = 0, + ): Value | undefined => { + if (depth === 0) remainingSteps = MAX_RESOLUTION_STEPS; + if (--remainingSteps <= 0 || depth > MAX_RESOLUTION_DEPTH) return undefined; + let value: Value | undefined; + switch (expression.kind) { + case 'unknown': + return undefined; + case 'handler': + return { kind: 'handler' }; + case 'array': + return { + kind: 'array', + elements: expression.elements.map((element) => + evaluate(element, filePath, visiting, depth + 1), + ), + }; + case 'binding': { + if (invalidBindings.has(expression.id) || visiting.has(expression.id)) return undefined; + const cached = cache.get(expression.id); + if (cached) return invalidValues.has(keyOf(cached)) ? undefined : cached; + const binding = bindings.get(expression.id); + if (!binding || binding.values.length !== 1) return undefined; + value = evaluate( + binding.values[0], + binding.filePath, + new Set(visiting).add(expression.id), + depth + 1, + ); + if (value) cache.set(expression.id, value); + break; + } + case 'import': + value = imported(filePath, expression.source, expression.name, visiting, depth + 1); + break; + case 'member': + value = member( + evaluate(expression.object, filePath, visiting, depth + 1), + expression.name, + visiting, + depth + 1, + ); + break; + case 'call': { + // A fluent call's receiver can itself be a call chain. Evaluate it once + // for both member lookup and fluent propagation to avoid exponential work. + const memberCallee = expression.callee.kind === 'member' ? expression.callee : undefined; + const receiver = memberCallee + ? evaluate(memberCallee.object, filePath, visiting, depth + 2) + : undefined; + const callee = memberCallee + ? member(receiver, memberCallee.name, visiting, depth + 2) + : evaluate(expression.callee, filePath, visiting, depth + 1); + if (remainingSteps <= 0 || (callee && invalidValues.has(keyOf(callee)))) return undefined; + if (callee?.kind === 'factory' && (callee.framework !== 'hono' || expression.construct)) { + const allocation: FrameworkAllocation = { + id: expression.id, + kind: callee.framework === 'express' ? 'app' : callee.framework, + }; + value = { kind: 'instance', allocation }; + if (!invalidValues.has(keyOf(value))) allocations.set(allocation.id, allocation); + } else if (memberCallee && !expression.construct) { + const method = memberCallee.name; + if ( + receiver?.kind === 'instance' && + receiver.allocation.kind !== 'hono' && + method === 'route' && + expression.paths + ) { + value = { + kind: 'builder', + receiverId: receiver.allocation.id, + paths: expression.paths, + }; + } else if ( + receiver?.kind === 'builder' && + VERBS.has(method) && + expression.argumentCount >= 1 + ) + value = receiver; + else if ( + receiver?.kind === 'instance' && + VERBS.has(method) && + expression.argumentCount >= 2 && + expression.paths + ) + value = receiver; + else if ( + receiver?.kind === 'instance' && + receiver.allocation.kind !== 'hono' && + method === 'use' && + expression.argumentCount >= 1 + ) + value = receiver; + } + } + } + return value && remainingSteps > 0 && !invalidValues.has(keyOf(value)) ? value : undefined; + }; + + const flatten = (value: Value | undefined): (Value | undefined)[] => + value?.kind === 'array' ? value.elements.flatMap(flatten) : [value]; + // Resolve all targets before poisoning any value: invalidating a namespace + // must not hide a later mutation of another Router exported by that module. + const mutatedValues = files.flatMap((file) => + file.mutations.map((mutation) => evaluate(mutation, file.filePath)), + ); + // Retain proven incoming mounts before invalidation. An uncertain parent + // cannot justify a path, but its child must not become a standalone Router. + const uncertainMounts = new Map(); + if (mutatedValues.some((value) => value !== undefined)) + for (const file of files) + for (const call of file.calls) { + if (call.method !== 'use') continue; + const parent = evaluate(call.receiver, file.filePath); + if (parent?.kind !== 'instance' || parent.allocation.kind === 'hono') continue; + const mounts: FrameworkMount[] = []; + for (const argument of call.arguments) + for (const child of flatten(evaluate(argument, file.filePath))) + if (child?.kind === 'instance' && child.allocation.kind !== 'hono') + mounts.push({ + parentId: parent.allocation.id, + childId: child.allocation.id, + prefixes: null, + }); + if (mounts.length) uncertainMounts.set(call, mounts); + } + for (const value of mutatedValues) + if (value) { + invalidValues.add(keyOf(value)); + if (value.kind === 'factory' && value.framework === 'express') + invalidValues.add('factory:router'); + } + cache.clear(); + allocations.clear(); + const result: ResolvedFrameworkRoutes = { allocations: [], registrations: [], mounts: [] }; + for (const file of files) { + for (const binding of file.bindings) + evaluate({ kind: 'binding', id: binding.id }, file.filePath); + for (const call of file.calls) { + const receiver = evaluate(call.receiver, file.filePath); + if (!receiver) { + result.mounts.push(...(uncertainMounts.get(call) ?? [])); + continue; + } + if ( + call.method === 'use' && + receiver.kind === 'instance' && + receiver.allocation.kind !== 'hono' + ) { + const values = call.arguments.flatMap((argument) => + flatten(evaluate(argument, file.filePath)), + ); + // An invalidated argument can hide a known child even when its parent + // still resolves. Keep that incoming edge without asserting a path. + if (values.some((value) => value === undefined)) + result.mounts.push(...(uncertainMounts.get(call) ?? [])); + const first = values[0]; + const prefixes = + call.paths ?? + (first?.kind === 'handler' || + (first?.kind === 'instance' && first.allocation.kind !== 'hono') + ? [''] + : null); + for (const child of values) + if (child?.kind === 'instance' && child.allocation.kind !== 'hono') { + result.mounts.push({ + parentId: receiver.allocation.id, + childId: child.allocation.id, + prefixes, + }); + } + continue; + } + if (!VERBS.has(call.method)) continue; + const builder = receiver.kind === 'builder'; + if (receiver.kind !== 'instance' && !builder) continue; + if (call.arguments.length < (builder ? 1 : 2)) continue; + const paths = builder ? receiver.paths : call.paths; + if (!paths) continue; + const framework = + receiver.kind === 'instance' && receiver.allocation.kind === 'hono' ? 'hono' : 'express'; + // The shared symbol model resolves a bare name only within this file. + // Retain that evidence only for the exact lexical callable declaration; + // parameters, imported aliases and unknown values must not borrow a + // same-named function elsewhere in the file. + const handler = call.arguments.at(-1); + const handlerBinding = handler?.kind === 'binding' ? bindings.get(handler.id) : undefined; + const handlerName = + call.handlerName && + handlerBinding?.filePath === file.filePath && + handlerBinding.values.length === 1 && + handlerBinding.values[0].kind === 'handler' && + handler?.kind === 'binding' && + evaluate(handler, file.filePath)?.kind === 'handler' + ? call.handlerName + : undefined; + for (const routePath of paths) + result.registrations.push({ + receiverId: builder ? receiver.receiverId : receiver.allocation.id, + framework, + route: { + frameworkCall: call, + filePath: file.filePath, + routePath, + httpMethod: call.method === 'all' ? '*' : call.method.toUpperCase(), + decoratorName: `${framework}.${call.method}`, + lineNumber: call.lineNumber, + ...(handlerName ? { handlerName } : {}), + }, + }); + } + } + result.allocations = [...allocations.values()]; + return result; +} diff --git a/gitnexus/src/core/ingestion/route-extractors/framework-route-types.ts b/gitnexus/src/core/ingestion/route-extractors/framework-route-types.ts new file mode 100644 index 000000000..e3ee44c5f --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/framework-route-types.ts @@ -0,0 +1,74 @@ +import type { ExtractedDecoratorRoute } from '../workers/parse-worker.js'; + +/** Provider-private facts. Only JSON values cross the worker/cache boundary. */ +export type FrameworkExpression = + | { kind: 'unknown' } + | { kind: 'handler' } + | { kind: 'array'; elements: FrameworkExpression[] } + | { kind: 'binding'; id: string } + | { kind: 'import'; source: string; name: string } + | { kind: 'member'; object: FrameworkExpression; name: string } + | { + kind: 'call'; + id: string; + callee: FrameworkExpression; + construct: boolean; + argumentCount: number; + paths: string[] | null; + }; + +export interface FrameworkCallFact { + receiver: FrameworkExpression; + method: string; + arguments: FrameworkExpression[]; + paths: string[] | null; + lineNumber: number; + handlerName?: string; +} + +export interface FrameworkRouteFacts { + filePath: string; + bindings: { id: string; value: FrameworkExpression }[]; + exports: { name: string; value: FrameworkExpression }[]; + starExports: string[]; + invalidBindings: string[]; + /** Property writes invalidate the value behind aliases, including imports. */ + mutations: FrameworkExpression[]; + calls: FrameworkCallFact[]; +} + +export interface FrameworkAllocation { + id: string; + kind: 'app' | 'router' | 'hono'; +} + +/** Source registration identity survives mount expansion for provider adapters. */ +export interface FrameworkRoute extends ExtractedDecoratorRoute { + frameworkCall?: FrameworkCallFact; +} + +export interface FrameworkRegistration { + receiverId: string; + framework: 'express' | 'hono'; + route: FrameworkRoute; +} + +export interface FrameworkMount { + parentId: string; + childId: string; + /** null records an incoming mount whose path cannot be established. */ + prefixes: string[] | null; +} + +/** Resolved provenance, ready for mount composition before emission. */ +export interface ResolvedFrameworkRoutes { + allocations: FrameworkAllocation[]; + registrations: FrameworkRegistration[]; + mounts: FrameworkMount[]; +} + +/** Existing ScopeResolver configuration remains the authority for module targets. */ +export type FrameworkImportResolver = ( + fromFile: string, + moduleSpecifier: string, +) => readonly string[]; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 885134f5c..8c3f01617 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -129,11 +129,7 @@ import { type SyntaxNode, } from '../utils/ast-helpers.js'; import { isPositionQualifiedLocalLabel } from '../utils/callable-labels.js'; -import { - countCallArguments, - extractCallArgTypes, - type MixedChainStep, -} from '../utils/call-analysis.js'; +import { extractCallArgTypes, type MixedChainStep } from '../utils/call-analysis.js'; import { buildTypeEnv } from '../type-env.js'; import type { ConstructorBinding } from '../type-env.js'; import { detectFrameworkFromAST } from '../framework-detection.js'; @@ -508,6 +504,8 @@ export interface ParseWorkerResult { /** URL-bearing candidates, kept separate from confirmed outward-call sinks. */ fetchWrapperCalls?: FetchWrapperCall[]; decoratorRoutes: ExtractedDecoratorRoute[]; + /** JSON-safe per-file provider facts; relationships are resolved on every run. */ + routeFacts?: { filePath: string; facts: unknown; routePosition: number }[]; routerIncludes: ExtractedRouterInclude[]; routerImports: ExtractedRouterImport[]; routerConstructorPrefixes?: ExtractedRouterConstructorPrefix[]; @@ -1403,18 +1401,6 @@ const processBatch = ( return result; }; -// Express/Hono method names that register routes -const EXPRESS_ROUTE_METHODS = new Set([ - 'get', - 'post', - 'put', - 'delete', - 'patch', - 'all', - 'use', - 'route', -]); - /** * Walk a tree-sitter AST subtree looking for a call to the global `fetch()` function. * Returns `true` if found within `maxDepth` levels of nesting — keeps the check @@ -1434,32 +1420,10 @@ const checkForFetchCall = (node: SyntaxNode, depth = 0, maxDepth = 5): boolean = // HTTP client methods that are ONLY used by clients, not Express route registration. // Methods like get/post/put/delete/patch overlap with Express — those are captured by -// the express_route handler as route definitions, not consumers. The fetch() global +// provider route facts as registrations, not consumers. The fetch() global // function is captured separately by the route.fetch query. const HTTP_CLIENT_ONLY_METHODS = new Set(['head', 'options', 'request', 'ajax']); -// Known HTTP client receivers u2014 skip these, they're API consumers not routes -const HTTP_CLIENT_RECEIVERS = new Set([ - 'axios', - 'request', - 'fetch', - 'http', - 'https', - 'got', - 'ky', - 'superagent', - 'needle', - 'undici', - 'apiclient', - 'client', - 'httpclient', - 'api', - '$http', - 'session', - 'httpservice', - 'conn', -]); - // Decorator names that indicate HTTP route handlers (NestJS, Flask, FastAPI, Spring) const ROUTE_DECORATOR_NAMES = new Set([ 'Get', @@ -1892,6 +1856,7 @@ const processFileGroup = ( } // Per-file map: decorator end-line → decorator info, for associating with definitions + const routePosition = result.decoratorRoutes.length; const fileDecorators = new Map(); // Track start indices of definition nodes already processed by higher-priority captures @@ -2031,8 +1996,7 @@ const processFileGroup = ( } // HTTP client calls: axios.get('/path'), $.post('/path'), requests.get('/path') - // Skip methods also in EXPRESS_ROUTE_METHODS to avoid double-registering Express - // routes as both route definitions AND consumers (both queries match same AST node) + // Verbs shared with provider registrations are handled by dedicated consumer captures. if (captureMap['http_client'] && captureMap['http_client.url']) { const method = captureMap['http_client.method']?.text; const url = captureMap['http_client.url'].text; @@ -2046,103 +2010,6 @@ const processFileGroup = ( continue; } - // Express/Hono route registration: app.get('/path', handler) - if ( - captureMap['express_route'] && - captureMap['express_route.method'] && - captureMap['express_route.path'] - ) { - const method = captureMap['express_route.method'].text; - const routePath = captureMap['express_route.path'].text; - if (EXPRESS_ROUTE_METHODS.has(method) && method !== 'use' && routePath.startsWith('/')) { - // Extract the receiver (the object the method is called on) to filter out - // HTTP client calls like axios.get('/api/users') that match the same pattern - // as Express route registrations. - const callNode = captureMap['express_route']; - // route(path) returns a builder; verb registrations need a handler. - // Count arguments without comments, which are also named AST children. - const minimumArguments = method === 'route' ? 1 : 2; - if ((countCallArguments(callNode) ?? 0) < minimumArguments) continue; - const funcNode = callNode.childForFieldName?.('function') ?? callNode.children?.[0]; - // Walk through nested member_expressions and call_expressions to - // reach the innermost receiver identifier. Handles chains like: - // this.httpService.get('/path') -> member chain -> 'httpservice' - // getClient().get('/path') -> call_expression -> 'getclient' - // axios.get('/path') -> bare identifier -> 'axios' - let receiverNode = funcNode?.childForFieldName?.('object') ?? funcNode?.children?.[0]; - while ( - receiverNode?.type === 'member_expression' || - receiverNode?.type === 'call_expression' - ) { - if (receiverNode.type === 'member_expression') { - // Drill into the property (rightmost part) of the member expression - const propNode = receiverNode.childForFieldName?.('property'); - if (propNode) { - receiverNode = propNode; - } else { - break; - } - } else { - // call_expression: unwrap to the function being called - const innerFunc = - receiverNode.childForFieldName?.('function') ?? receiverNode.children?.[0]; - if (innerFunc && innerFunc !== receiverNode) { - receiverNode = innerFunc; - } else { - break; - } - } - } - const receiverText = receiverNode?.text?.toLowerCase() ?? ''; - - if (HTTP_CLIENT_RECEIVERS.has(receiverText)) { - // This is an HTTP client call, not a route definition u2014 skip it - continue; - } - - const registrations: { method: string; node: SyntaxNode }[] = []; - if (method === 'route') { - // A builder alone registers nothing. Follow only direct chained - // verb calls, each of which must supply a non-comment handler arg. - let builderNode = callNode; - while (builderNode.parent?.type === 'member_expression') { - const member = builderNode.parent; - const registration = member.parent; - const verb = member.childForFieldName('property')?.text; - if ( - member.childForFieldName('object')?.id !== builderNode.id || - registration?.type !== 'call_expression' || - registration.childForFieldName('function')?.id !== member.id || - !verb || - !EXPRESS_ROUTE_METHODS.has(verb) || - verb === 'route' || - verb === 'use' - ) { - break; - } - if ((countCallArguments(registration) ?? 0) >= 1) { - registrations.push({ method: verb, node: registration }); - } - builderNode = registration; - } - } else { - registrations.push({ method, node: callNode }); - } - for (const registration of registrations) { - const httpMethod = - registration.method === 'all' ? '*' : registration.method.toUpperCase(); - result.decoratorRoutes.push({ - filePath: file.path, - routePath, - httpMethod, - decoratorName: `express.${registration.method}`, - lineNumber: registration.node.startPosition.row + lineOffset, - }); - } - } - continue; - } - // Extract call sites if (captureMap['call']) { const callNode = captureMap['call']; @@ -3368,6 +3235,14 @@ const processFileGroup = ( for (const candidate of candidates) (result.fetchWrapperCalls ??= []).push(candidate); } + if (parsedFile && provider.extractRouteFacts) { + (result.routeFacts ??= []).push({ + filePath: file.path, + facts: provider.extractRouteFacts(tree, parsedFile, lineOffset), + routePosition, + }); + } + // Language-specific decorator route extraction via provider hook. // The provider's extractDecoratorRoutes walks the AST for framework-specific // route patterns (e.g., Java Spring class-level prefix joining). Routes are diff --git a/gitnexus/src/core/ingestion/workers/result-merge.ts b/gitnexus/src/core/ingestion/workers/result-merge.ts index fe19f30c8..e06de8412 100644 --- a/gitnexus/src/core/ingestion/workers/result-merge.ts +++ b/gitnexus/src/core/ingestion/workers/result-merge.ts @@ -35,6 +35,11 @@ export const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult): appendAll(target.fetchCalls, src.fetchCalls); appendAll(target.fetchWrapperDefs, src.fetchWrapperDefs); if (src.fetchWrapperCalls) appendAll((target.fetchWrapperCalls ??= []), src.fetchWrapperCalls); + for (const file of src.routeFacts ?? []) + (target.routeFacts ??= []).push({ + ...file, + routePosition: file.routePosition + target.decoratorRoutes.length, + }); appendAll(target.decoratorRoutes, src.decoratorRoutes); if (src.routerIncludes) appendAll(target.routerIncludes, src.routerIncludes); if (src.routerImports) appendAll(target.routerImports, src.routerImports); diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 8fd0a2eaa..fbb1eacc5 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -872,7 +872,9 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // v143 (#3561): combine v142 wrapper candidates with the route corrections. // Older caches may retain phantom middleware and stored route-metadata edges. // The PR also used v142 without wrapper candidates; invalidate both histories. -const SCHEMA_BUMP = 143; +// v144: provider provenance facts replace receiver-name guesses; mount paths +// are recomputed from facts after cache replay, before route winner selection. +const SCHEMA_BUMP = 144; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/lang-resolution/express-route-priority/spec/stubs.ts b/gitnexus/test/fixtures/lang-resolution/express-route-priority/spec/stubs.ts index f7a273d60..daab17d53 100644 --- a/gitnexus/test/fixtures/lang-resolution/express-route-priority/spec/stubs.ts +++ b/gitnexus/test/fixtures/lang-resolution/express-route-priority/spec/stubs.ts @@ -1,3 +1,6 @@ +import express from 'express'; +const app = express(); + function fakeInfo() { return 'fake info'; } diff --git a/gitnexus/test/fixtures/lang-resolution/express-route-priority/src/server.ts b/gitnexus/test/fixtures/lang-resolution/express-route-priority/src/server.ts index 0fe8140e9..2570bd3d9 100644 --- a/gitnexus/test/fixtures/lang-resolution/express-route-priority/src/server.ts +++ b/gitnexus/test/fixtures/lang-resolution/express-route-priority/src/server.ts @@ -1,3 +1,6 @@ +import express from 'express'; +const app = express(); + function realInfo() { return 'info'; } function realQuery() { return 'query'; } function mcp() { return 'mcp'; } diff --git a/gitnexus/test/integration/resolvers/express-routes.test.ts b/gitnexus/test/integration/resolvers/express-routes.test.ts index fcc2fe5c4..236f328d7 100644 --- a/gitnexus/test/integration/resolvers/express-routes.test.ts +++ b/gitnexus/test/integration/resolvers/express-routes.test.ts @@ -21,6 +21,7 @@ import { getNodesByLabelFull, runPipelineFromRepo, type PipelineResult, + writeFixtureRepo, } from './helpers.js'; describe('Express/Hono route detection', () => { @@ -129,7 +130,7 @@ describe('Express route identity and source priority', () => { ); expect(chainedRoutes.map((node) => node.properties.method).sort()).toEqual(methods); const handled = getRelationships(result, 'HANDLES_ROUTE').filter( - (edge) => edge.target === route, + (edge) => edge.target === route && edge.sourceLabel === 'File', ); expect(handled).toHaveLength(methods.length); expect(handled.every((edge) => edge.sourceFilePath === 'src/server.ts')).toBe(true); @@ -214,6 +215,7 @@ describe('Express route identity and source priority', () => { a.path.localeCompare(b.path) || String(a.method).localeCompare(String(b.method)), ), handled: getRelationships(pipeline, 'HANDLES_ROUTE') + .filter((edge) => edge.sourceLabel === 'File') .map((edge) => ({ method: pipeline.graph.getNode(edge.rel.targetId)?.properties.method, path: edge.target, @@ -244,7 +246,140 @@ describe('Express route identity and source priority', () => { } }, 120_000); - it.each([137, 138, 139, 140])( + it('recomputes mixed-language mounts after mount-only, barrel-only and resolution-config edits', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-express-mount-cache-')); + const repoDir = path.join(tempDir, 'repo'); + const storageDir = path.join(tempDir, 'storage'); + const mountSource = (prefix: string) => ` + import express from 'express'; + import chosen from '@routes'; + const app = express(); + function before() {} function after() {} + app.use('${prefix}', [before, [chosen], after]); + app.use(['/v1', '/v2'], chosen); + `; + const config = (target: string) => + JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@routes': [target] } } }); + const project = (pipeline: PipelineResult) => + getNodesByLabelFull(pipeline, 'Route') + .map((route) => ({ + path: route.name, + file: route.properties.filePath, + handler: route.properties.handlerSymbolId, + })) + .sort((a, b) => a.path.localeCompare(b.path)); + const expectedPaths = (prefix: string) => + [ + `${prefix}/users`, + `${prefix}/nested/details`, + '/v1/users', + '/v1/nested/details', + '/v2/users', + '/v2/nested/details', + '/other', + ].sort(); + const paths = (pipeline: PipelineResult) => getNodesByLabel(pipeline, 'Route').sort(); + const persist = async (cache: ParseCache) => { + pruneCache(cache, cache.usedKeys); + const keys = await saveParseCache(storageDir, cache); + await pruneAndSaveDurableParsedFileStore( + getDurableParsedFileDir(storageDir), + PARSE_CACHE_VERSION, + new Set(keys), + ); + }; + try { + writeFixtureRepo(repoDir, { + 'mount.ts': mountSource('/api'), + 'barrel.ts': `export { default } from './via.js';`, + 'via.js': `export { default } from './router';`, + 'router.ts': ` + import { Router } from 'express'; import { child } from './nested'; + const router = Router(); + function users() {} router.get('/users', users); + router.use('/nested', child); export default router; + `, + 'nested.tsx': ` + import express from 'express'; export const child = express.Router(); + function details() {} child.get('/details', details); + `, + 'other.ts': ` + const express = require('express'); const other = express.Router(); + function alternate() {} other.get('/other', alternate); module.exports = other; + `, + 'tsconfig.json': config('barrel.ts'), + }); + const inMemory = await runPipelineFromRepo(repoDir, () => {}, { workerPoolSize: 1 }); + expect(paths(inMemory)).toEqual(expectedPaths('/api')); + const cold: ParseCache = { + version: PARSE_CACHE_VERSION, + entries: new Map(), + usedKeys: new Set(), + storagePath: storageDir, + onDiskKeys: new Set(), + }; + const durable = await runPipelineFromRepo(repoDir, () => {}, { + parseCache: cold, + workerPoolSize: 2, + }); + expect(durable.reparsedFileCount).toBe(6); + expect(project(durable)).toEqual(project(inMemory)); + expect(project(durable).every((route) => route.handler !== undefined)).toBe(true); + await persist(cold); + expect( + (await loadDurableParsedFileIndex(getDurableParsedFileDir(storageDir), PARSE_CACHE_VERSION)) + .size, + ).toBeGreaterThan(0); + const warm = await loadParseCache(storageDir); + const replay = await runPipelineFromRepo(repoDir, () => {}, { + parseCache: warm, + workerPoolSize: 2, + }); + expect(replay.reparsedFileCount).toBe(0); + expect(replay.parseCacheHitFileCount).toBe(6); + expect(project(replay)).toEqual(project(inMemory)); + + fs.writeFileSync(path.join(repoDir, 'mount.ts'), mountSource('/changed')); + const mountCache = await loadParseCache(storageDir); + const mountEdit = await runPipelineFromRepo(repoDir, () => {}, { + parseCache: mountCache, + workerPoolSize: 1, + }); + expect(mountEdit.reparsedFileCount).toBe(1); + expect(mountEdit.parseCacheHitFileCount).toBe(5); + expect(paths(mountEdit)).toEqual(expectedPaths('/changed')); + await persist(mountCache); + + fs.writeFileSync(path.join(repoDir, 'barrel.ts'), `export { default } from './other';`); + const barrelCache = await loadParseCache(storageDir); + const barrelEdit = await runPipelineFromRepo(repoDir, () => {}, { + parseCache: barrelCache, + workerPoolSize: 1, + }); + expect(barrelEdit.reparsedFileCount).toBe(1); + expect(barrelEdit.parseCacheHitFileCount).toBe(5); + expect(paths(barrelEdit)).toEqual( + ['/changed/other', '/v1/other', '/v2/other', '/users', '/nested/details'].sort(), + ); + await persist(barrelCache); + + // Only resolver configuration changes: every source fact comes from the + // warm cache, but the current alias target must win over cached guesses. + fs.writeFileSync(path.join(repoDir, 'tsconfig.json'), config('via.js')); + const configCache = await loadParseCache(storageDir); + const configEdit = await runPipelineFromRepo(repoDir, () => {}, { + parseCache: configCache, + workerPoolSize: 2, + }); + expect(configEdit.reparsedFileCount).toBe(0); + expect(configEdit.parseCacheHitFileCount).toBe(6); + expect(project(configEdit)).toEqual(project(mountEdit)); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }, 120_000); + + it.each([137, 138, 139, 140, 143])( 'reparses route captures from a same-package v%i cache on upgrade', async (previousSchema) => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-express-route-upgrade-')); diff --git a/gitnexus/test/unit/express-route-provenance-pipeline.test.ts b/gitnexus/test/unit/express-route-provenance-pipeline.test.ts new file mode 100644 index 000000000..a43c99d64 --- /dev/null +++ b/gitnexus/test/unit/express-route-provenance-pipeline.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + getNodesByLabel, + getRelationships, + runPipelineFromRepo, +} from '../integration/resolvers/helpers.js'; + +describe('framework receiver provenance through ingestion', () => { + it('does not attribute a shadowing handler parameter to a global function', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-handler-shadow-')); + try { + fs.writeFileSync( + path.join(root, 'server.ts'), + ` + import express from 'express'; + function handler() {} + function install(handler) { const app = express(); app.get('/shadow', handler); } + `, + ); + const result = await runPipelineFromRepo(root, () => {}); + expect(getNodesByLabel(result, 'Route')).toContain('/shadow'); + const edges = getRelationships(result, 'HANDLES_ROUTE').filter( + (edge) => edge.target === '/shadow', + ); + expect(edges).toHaveLength(1); + expect(result.graph.getNode(edges[0].rel.sourceId)?.label).toBe('File'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); + + it('preserves framework registration priority over a same-file data route table', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-source-priority-')); + try { + fs.writeFileSync( + path.join(root, 'server.ts'), + ` + import express from 'express'; + const app = express(); + function frameworkHandler() {} function tableHandler() {} + const routes = [{ path: '/same', method: 'GET', handler: tableHandler }]; + app.get('/same', frameworkHandler); + `, + ); + const result = await runPipelineFromRepo(root, () => {}); + const handlers = getRelationships(result, 'HANDLES_ROUTE').filter( + (edge) => edge.target === '/same' && edge.sourceLabel === 'Function', + ); + expect(handlers.map((edge) => edge.source)).toEqual(['frameworkHandler']); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); + + it('preserves cross-file declaration priority when a later registration is a data table', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-file-priority-')); + try { + for (const file of ['a.ts', 'b.ts']) + fs.writeFileSync( + path.join(root, file), + ` + import express from 'express'; const app = express(); + function handler() {} app.get('/same', handler); + `, + ); + const first = await runPipelineFromRepo(root, () => {}); + const winner = getRelationships(first, 'HANDLES_ROUTE').find( + (edge) => edge.target === '/same' && edge.sourceLabel === 'File', + )!.sourceFilePath; + const loser = winner === 'a.ts' ? 'b.ts' : 'a.ts'; + fs.writeFileSync( + path.join(root, loser), + ` + function handler() {} + const routes = [{ path: '/same', method: 'GET', handler }]; + `, + ); + const changed = await runPipelineFromRepo(root, () => {}); + expect( + getRelationships(changed, 'HANDLES_ROUTE') + .filter((edge) => edge.target === '/same' && edge.sourceLabel === 'File') + .map((edge) => edge.sourceFilePath), + ).toEqual([winner]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); + + it('keeps proven Hono routes and static mounts alongside unresolved Express mounts', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-dynamic-mount-')); + try { + fs.writeFileSync( + path.join(root, 'server.ts'), + ` + import express from 'express'; import { Hono } from 'hono'; + const app = express(); const hidden = express.Router(); const mixed = express.Router(); + function handler() {} + hidden.get('/hidden', handler); mixed.get('/visible', handler); + app.use(runtimePrefix, hidden); app.use(runtimePrefix, mixed); app.use('/static', mixed); + const edge = new Hono(); edge.get('/hono', handler); + `, + ); + const result = await runPipelineFromRepo(root, () => {}); + expect(getNodesByLabel(result, 'Route').sort()).toEqual(['/hono', '/static/visible']); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); + + it('honors configured local modules that shadow a framework package specifier', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-package-shadow-')); + try { + fs.writeFileSync( + path.join(root, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { express: ['./local.ts'] } }, + }), + ); + fs.writeFileSync( + path.join(root, 'local.ts'), + `export default function storage() { return new Map(); }`, + ); + fs.writeFileSync( + path.join(root, 'server.ts'), + ` + import express from 'express'; import { Hono } from 'hono'; + const storage = express(); storage.get('/not-http', 'fallback'); + const edge = new Hono(); edge.get('/hono', () => {}); + `, + ); + const result = await runPipelineFromRepo(root, () => {}); + expect(getNodesByLabel(result, 'Route')).toEqual(['/hono']); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); + + it('uses the mount prefix for an Express Router endpoint', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-mount-')); + try { + fs.writeFileSync( + path.join(root, 'server.ts'), + ` + import express from 'express'; + const app = express(); + const router = express.Router(); + router.get('/users', handler); + app.use('/api', router); + function handler() {} + `, + ); + const result = await runPipelineFromRepo(root, () => {}); + expect(getNodesByLabel(result, 'Route').sort()).toEqual(['/api/users']); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); + + it('keeps the proven Express endpoint and rejects an unrelated storage method', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-route-provenance-')); + try { + fs.writeFileSync( + path.join(root, 'server.ts'), + ` + import express from 'express'; + const app = express(); + app.get('/real', handler); + const storage = new Map(); + storage.get('/not-http', 'fallback'); + function handler() {} + `, + ); + const result = await runPipelineFromRepo(root, () => {}); + expect(getNodesByLabel(result, 'Route').sort()).toEqual(['/real']); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); +}); diff --git a/gitnexus/test/unit/framework-route-paths.test.ts b/gitnexus/test/unit/framework-route-paths.test.ts new file mode 100644 index 000000000..9a21e806a --- /dev/null +++ b/gitnexus/test/unit/framework-route-paths.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import { composeFrameworkRoutePaths } from '../../src/core/ingestion/route-extractors/framework-route-paths.js'; +import type { + FrameworkMount, + ResolvedFrameworkRoutes, +} from '../../src/core/ingestion/route-extractors/framework-route-types.js'; + +function project( + mounts: FrameworkMount[], + ids = ['app', 'a', 'b', 'c', 'standalone'], +): ResolvedFrameworkRoutes { + return { + allocations: ids.map((id) => ({ id, kind: id === 'app' ? 'app' : 'router' })), + mounts, + registrations: ids + .filter((id) => id !== 'app') + .map((id) => ({ + receiverId: id, + framework: 'express', + route: { + filePath: `${id}.ts`, + routePath: '/item', + httpMethod: 'GET', + decoratorName: 'express.get', + lineNumber: 7, + handlerName: `${id}Handler`, + }, + })), + }; +} +const paths = (resolved: ResolvedFrameworkRoutes, budget?: number) => + composeFrameworkRoutePaths(resolved, budget) + .map((route) => `${route.filePath} ${route.routePath}`) + .sort(); +const mount = (parentId: string, childId: string, ...prefixes: string[]): FrameworkMount => ({ + parentId, + childId, + prefixes, +}); + +describe('framework mounted route paths', () => { + it('composes root, nested, repeated and path-array mounts while preserving standalone routers', () => { + const resolved = project([ + mount('app', 'a', '/v1', '/v2'), + mount('a', 'b', '/admin'), + mount('b', 'c', '/'), + ]); + expect(paths(resolved)).toEqual([ + 'a.ts /v1/item', + 'a.ts /v2/item', + 'b.ts /v1/admin/item', + 'b.ts /v2/admin/item', + 'c.ts /v1/admin/item', + 'c.ts /v2/admin/item', + 'standalone.ts /item', + ]); + expect( + composeFrameworkRoutePaths(resolved).find((route) => route.filePath === 'c.ts'), + ).toMatchObject({ + lineNumber: 7, + handlerName: 'cHandler', + httpMethod: 'GET', + decoratorName: 'express.get', + }); + }); + + it('does not invent local paths from dynamic mounts; independently known static paths survive', () => { + const resolved = project([ + { parentId: 'app', childId: 'a', prefixes: null }, + mount('a', 'b', '/child'), + mount('app', 'c', '/known'), + { parentId: 'app', childId: 'c', prefixes: null }, + ]); + expect(paths(resolved)).toEqual(['c.ts /known/item', 'standalone.ts /item']); + }); + + it('keeps all acyclic paths through a diamond and cycle, deduplicating identical outcomes', () => { + const resolved = project( + [ + mount('app', 'a', '/a'), + mount('app', 'b', '/b'), + mount('a', 'c', '/c'), + mount('b', 'c', '/c'), + mount('c', 'a', '/again'), + mount('app', 'a', '/a'), + ], + ['app', 'a', 'b', 'c'], + ); + expect(paths(resolved)).toEqual([ + 'a.ts /a/item', + 'a.ts /b/c/again/item', + 'b.ts /b/item', + 'c.ts /a/c/item', + 'c.ts /b/c/item', + ]); + }); + + it('declines rootless cycles while retaining an unrelated standalone router', () => { + expect( + paths(project([mount('a', 'b', '/b'), mount('b', 'a', '/a')], ['a', 'b', 'standalone'])), + ).toEqual(['standalone.ts /item']); + }); + + it('fails an exhausted component atomically and independently of input ordering', () => { + const resolved = project( + [mount('app', 'a', '/a', '/b', '/c'), mount('a', 'b', '/nested')], + ['app', 'a', 'b', 'standalone'], + ); + expect(paths(resolved, 3)).toEqual(['standalone.ts /item']); + expect( + paths( + { + allocations: [...resolved.allocations].reverse(), + mounts: [...resolved.mounts].reverse(), + registrations: [...resolved.registrations].reverse(), + }, + 3, + ), + ).toEqual(paths(resolved, 3)); + expect(paths(resolved, 100)).toHaveLength(7); + }); + + it('preserves proven Hono routes without Express mounting semantics', () => { + const resolved: ResolvedFrameworkRoutes = { + allocations: [{ id: 'hono', kind: 'hono' }], + mounts: [], + registrations: [ + { + receiverId: 'hono', + framework: 'hono', + route: { + filePath: 'hono.ts', + routePath: '/hello', + httpMethod: 'GET', + lineNumber: 2, + decoratorName: 'hono.get', + }, + }, + ], + }; + expect(paths(resolved)).toEqual(['hono.ts /hello']); + }); +}); diff --git a/gitnexus/test/unit/framework-route-provenance.test.ts b/gitnexus/test/unit/framework-route-provenance.test.ts new file mode 100644 index 000000000..1e117f508 --- /dev/null +++ b/gitnexus/test/unit/framework-route-provenance.test.ts @@ -0,0 +1,426 @@ +import { describe, expect, it } from 'vitest'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import JavaScript from 'tree-sitter-javascript'; +import path from 'node:path'; +import { + typescriptProvider, + javascriptProvider, +} from '../../src/core/ingestion/languages/typescript.js'; +import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js'; +import { extractFrameworkRouteFacts } from '../../src/core/ingestion/route-extractors/framework-route-facts.js'; +import { resolveFrameworkRouteFacts } from '../../src/core/ingestion/route-extractors/framework-route-resolver.js'; +import { composeFrameworkRoutePaths } from '../../src/core/ingestion/route-extractors/framework-route-paths.js'; + +function facts(source: string, filePath = 'server.ts') { + const parser = new Parser(); + parser.setLanguage( + filePath.endsWith('.js') + ? JavaScript + : filePath.endsWith('.tsx') + ? TypeScript.tsx + : TypeScript.typescript, + ); + const tree = parser.parse(source); + const parsed = extractParsedFile( + filePath.endsWith('.js') ? javascriptProvider : typescriptProvider, + source, + filePath, + undefined, + tree, + ); + expect(parsed).toBeDefined(); + return extractFrameworkRouteFacts(tree, parsed!); +} +function resolve(sources: Record) { + const files = Object.entries(sources).map(([file, source]) => facts(source, file)); + const output = resolveFrameworkRouteFacts(files, (from, specifier) => { + const base = path.posix.join(path.posix.dirname(from), specifier); + return Object.keys(sources).filter( + (file) => + file === base || + file === `${base}.ts` || + file === `${base}.js` || + file === `${base}/index.ts`, + ); + }); + return output; +} +const routes = (source: string) => + resolve({ 'server.ts': source }).registrations.map((registration) => registration.route); +const urls = (source: string) => + routes(source).map((route) => `${route.httpMethod} ${route.routePath}`); + +describe('framework receiver provenance', () => { + it('accepts imported Express allocations and stable aliases, not receiver names', () => { + expect( + urls(` + import web from 'express'; + const service = web(); const stable = service; + stable.get('/real', handler); + const storage = new Map(); storage.get('/not-http', 'fallback'); + app.post('/undeclared', handler); router.get('/undeclared-router', handler); + `), + ).toEqual(['GET /real']); + }); + + it.each([ + `import { Router as Build } from 'express'; const r = Build();`, + `import * as web from 'express'; const r = web.Router();`, + `const web = require('express'); const r = web.Router();`, + `const { Router: Build } = require('express'); const r = Build();`, + `const Build = require('express').Router; const r = new Build();`, + `const r = require('express')();`, + `const r = require('express').Router();`, + ])('recognizes factory provenance: %s', (setup) => { + expect(urls(`${setup} r.get('/ok', handler);`)).toEqual(['GET /ok']); + }); + + it('preserves builders, chains, all, handler evidence, and argument comments', () => { + const result = routes(`import express from 'express'; +const router = express.Router(); +router.route('/bare'); +router.get('/setting'); +router.use('/middleware', middleware); +router.route('/chain').get(/* comment */ handler).post(other); +const builder = router.route('/assigned'); +const alias = builder; +alias.all(handler); +builder.delete(/* no handler */); +router.get('/direct', middleware, handler).put('/continued', other); +function handler() {} function other() {}`); + expect(result.map((route) => `${route.httpMethod} ${route.routePath}`)).toEqual([ + 'POST /chain', + 'GET /chain', + '* /assigned', + 'PUT /continued', + 'GET /direct', + ]); + expect(result.find((route) => route.routePath === '/assigned')).toMatchObject({ + filePath: 'server.ts', + lineNumber: 8, + handlerName: 'handler', + decoratorName: 'express.all', + }); + }); + + it.each([13, 24])( + 'resolves all %i fluent registrations without exhausting the work budget', + (count) => { + const paths = Array.from({ length: count }, (_, index) => `/route-${index}`); + const chain = paths.map((route) => `.get('${route}', () => {})`).join(''); + const extracted = facts(`import express from 'express'; const app = express(); app${chain};`); + expect(extracted.calls).toHaveLength(count); + const resolved = resolveFrameworkRouteFacts([extracted], () => []); + expect(resolved.registrations.map(({ route }) => route.routePath).sort()).toEqual( + [...paths].sort(), + ); + }, + ); + + it('withholds handler evidence for mutated callable bindings', () => { + const result = routes(`import express from 'express'; const app = express(); + let handler = () => {}; handler = replacement; app.get('/real', handler);`); + expect(result).toHaveLength(1); + expect(result[0].handlerName).toBeUndefined(); + }); + + it('respects parameter, block, catch, destructuring, and require shadows', () => { + expect( + urls(` + import express from 'express'; const router = express.Router(); + function f(router) { router.get('/parameter', handler); } + { const router = storage; router.get('/block', handler); } + try {} catch(router) { router.get('/catch', handler); } + function nested({ router }) { router.get('/destructured', handler); } + function wrong(require) { const r = require('express').Router(); r.get('/require', handler); } + router.get('/real', handler); + `), + ).toEqual(['GET /real']); + }); + + it.each([ + `import type express from 'express'; const r = express();`, + `import { type Router } from 'express'; const r = Router();`, + `import type * as express from 'express'; const r = express.Router();`, + `import express from 'express'; let r = express.Router(); r = storage;`, + `import express from 'express'; let r = express.Router(); function mutate() { r = storage; }`, + `import express from 'express'; let r = express.Router(); ({ r } = storage);`, + `import express from 'express'; const r = express.Router(); const alias = r; alias.get = other;`, + `import express from 'express'; express.Router = custom; const r = express.Router();`, + `import express, { Router } from 'express'; Router.custom = other; const r = express.Router();`, + `import express from 'express'; const r = express.Router(); delete r.get;`, + `import express from 'express'; let r = express.Router(); for (r of replacements) {}`, + ])('declines erased or conflicting provenance: %s', (setup) => { + expect(urls(`${setup} r.get('/unsafe', handler);`)).toEqual([]); + }); + + it('does not lend later allocations to calls in a temporal dead zone', () => { + expect( + urls(` + import express from 'express'; + api.get('/before', h); const api = express(); + const router = express.Router(); + { router.get('/block-before', h); const router = storage; } + function install() { later.get('/closure', h); } + const later = express.Router(); + api.get('/after', h); + `), + ).toEqual(['GET /closure', 'GET /after']); + }); + + it('keeps directly proven Hono registrations separate from Express builders and mounts', () => { + const result = resolve({ + 'server.ts': ` + import { Hono as Web } from 'hono'; + const api = new Web(); + api.get('/hono', handler); api.route('/builder').get(handler); + const router = api.route('/assigned'); router.post(handler); + api.use('/mount', api); + `, + }); + expect(result.registrations).toMatchObject([ + { framework: 'hono', route: { routePath: '/hono', decoratorName: 'hono.get' } }, + ]); + expect(result.registrations).toHaveLength(1); + expect(result.mounts).toEqual([]); + }); + + it('records literal, root, middleware and unknown mounts without emitting endpoints', () => { + const result = resolve({ + 'server.ts': ` + import express from 'express'; + const app = express(); const router = express.Router(); + function auth() {} + app.use(['/v1', '/v2'], auth, router, auth); + app.use(router); app.use(auth, router); app.use(runtimePrefix, auth, router); + app.use('/array', [auth, [router]]); app.use([auth, router]); + `, + }); + expect(result.registrations).toEqual([]); + expect(result.mounts.map((mount) => mount.prefixes)).toEqual([ + ['/v1', '/v2'], + [''], + [''], + null, + ['/array'], + [''], + ]); + expect(result.allocations.map((allocation) => allocation.kind).sort()).toEqual([ + 'app', + 'router', + ]); + }); + + it('follows explicit ESM named/default exports and transitive barrels across JS/TS/TSX', () => { + const result = resolve({ + 'router.js': `const { Router } = require('express'); const r = Router(); export { r as users }; export default r;`, + 'barrel.ts': `export { users as endpoints, default } from './router';`, + 'index.ts': `export * from './barrel'; export { default } from './barrel';`, + 'server.tsx': `import byDefault, { endpoints } from './index'; endpoints.get('/named', handler); byDefault.post('/default', handler);`, + }); + expect(result.registrations.map((registration) => registration.route.routePath)).toEqual([ + '/named', + '/default', + ]); + expect(new Set(result.registrations.map((registration) => registration.receiverId)).size).toBe( + 1, + ); + }); + + it.each([ + [`module.exports = r;`, `const api = require('./router');`], + [`exports.api = r;`, `const {api} = require('./router');`], + [`module.exports.api = r;`, `const api = require('./router').api;`], + [`module.exports = { api: r };`, `const {api} = require('./router');`], + ])('follows explicit CommonJS exports: %s', (publish, consume) => { + const result = resolve({ + 'router.js': `const express = require('express'); const r = express.Router(); ${publish}`, + 'server.js': `${consume} api.get('/ok', handler);`, + }); + expect(result.registrations.map((registration) => registration.route.routePath)).toEqual([ + '/ok', + ]); + }); + + it('does not infer default from a unique named export or accept conflicting stars', () => { + const result = resolve({ + 'one.ts': `import {Router} from 'express'; export const api = Router();`, + 'two.ts': `export const api = storage;`, + 'barrel.ts': `export * from './one'; export * from './two';`, + 'server.ts': `import guessed from './one'; import {api} from './barrel'; guessed.get('/default-guess', handler); api.get('/ambiguous', handler);`, + }); + expect(result.registrations).toEqual([]); + }); + + it('declines conflicting CJS writes, shadowed module exports, and detached exports aliases', () => { + const result = resolve({ + 'conflict.js': `const r = require('express').Router(); exports.api = r; exports.api = storage;`, + 'shadow.js': `const r = require('express').Router(); function f(module) { module.exports = r; }`, + 'detached.js': `const r = require('express').Router(); module.exports = storage; exports.api = r;`, + 'replaced.js': `const r = require('express').Router(); module.exports.api = r; module.exports = storage;`, + 'computed.js': `const r = require('express').Router(); module.exports = r; module.exports[key] = other;`, + 'server.js': `const e = require('./computed'); e.get('/computed', h); const d = require('./replaced').api; d.get('/replaced', h); const a = require('./conflict').api; a.get('/conflict', h); const b = require('./shadow'); b.get('/shadow', h); const c = require('./detached').api; c.get('/detached', h);`, + }); + expect(result.registrations).toEqual([]); + }); + + it.each([ + `exports.router = router; delete exports.router;`, + `exports.router = router; exports.router += 1;`, + `exports.router = router; exports.router++;`, + `module.exports.router = router; delete module.exports.router;`, + `module.exports.router = router; module.exports.router--;`, + `module.exports = { router }; delete module.exports;`, + `module.exports = { router, ...overrides };`, + `module.exports = { router, [key]: replacement };`, + ])('declines invalidated or dynamically overwritten CommonJS exports: %s', (publish) => { + const result = resolve({ + 'router.js': `const { Router } = require('express'); const router = Router(); ${publish}`, + 'server.js': `const { router } = require('./router'); router.get('/phantom', () => {});`, + }); + expect(result.registrations).toEqual([]); + }); + + it.each([ + [`exports = {}; exports.router = router;`, `const { router } = require('./router');`], + [ + `module.exports = router; module.exports.get = replacement;`, + `const router = require('./router');`, + ], + ])('declines detached or mutated CommonJS export values: %s', (publish, consume) => { + const result = resolve({ + 'router.js': `const { Router } = require('express'); const router = Router(); ${publish}`, + 'server.js': `${consume} router.get('/phantom', () => {});`, + }); + expect(result.registrations).toEqual([]); + }); + + it('declines unresolved stars that could conflict with a proven named export', () => { + const result = resolve({ + 'router.ts': `import {Router} from 'express'; export const api = Router();`, + 'barrel.ts': `export * from './router'; export * from './unavailable';`, + 'server.ts': `import {api} from './barrel'; api.get('/uncertain', h);`, + }); + expect(result.registrations).toEqual([]); + }); + + it('terminates seedless export cycles but retains an explicit seeded export', () => { + const result = resolve({ + 'one.ts': `export * from './two'; import {Router} from 'express'; export const api = Router();`, + 'two.ts': `export * from './one'; export {missing} from './three';`, + 'three.ts': `export {missing} from './two';`, + 'server.ts': `import {api, missing} from './two'; api.get('/seed', h); missing.get('/cycle', h);`, + }); + expect(result.registrations.map((registration) => registration.route.routePath)).toEqual([ + '/seed', + ]); + }); + + it('invalidates imported values when another file writes through an alias', () => { + const result = resolve({ + 'router.ts': `import {Router} from 'express'; export const api = Router(); api.get('/unsafe', h);`, + 'mutation.ts': `import {api as alias} from './router'; alias.get = replacement;`, + }); + expect(result.registrations).toEqual([]); + }); + + it.each([ + [ + `import {Router} from 'express'; export const api = Router(), other = Router();`, + `import * as routes from './router';`, + `import {api, other} from './router';`, + ], + [ + `const {Router} = require('express'); exports.api = Router(); exports.other = Router();`, + `const routes = require('./router');`, + `const {api, other} = require('./router');`, + ], + ])('invalidates Routers mutated through a module object: %#', (publish, namespace, consume) => { + const result = resolve({ + 'router.js': publish, + 'mutation.js': `${namespace} routes.api.get = () => {}; routes.other.post = () => {};`, + 'server.js': `${consume} api.get('/phantom', () => {}); other.post('/also-phantom', () => {});`, + }); + expect(composeFrameworkRoutePaths(result)).toEqual([]); + }); + + it.each([`app.locals.title = 'demo';`, `app.use = () => {};`, `delete app.use;`])( + 'does not revive local paths when a mount parent is mutated: %s', + (mutation) => { + const result = resolve({ + 'server.ts': `import express from 'express'; + const app = express(), router = express.Router(); + ${mutation} + app.use('/api', [router]); + router.get('/users', () => {});`, + }); + expect(composeFrameworkRoutePaths(result)).toEqual([]); + }, + ); + + it('preserves an independent valid mount when another parent is mutated', () => { + const result = resolve({ + 'server.ts': `import express from 'express'; + const app = express(), safe = express(), router = express.Router(); + app.locals.title = 'demo'; + app.use('/uncertain', router); safe.use('/safe', router); + router.get('/users', () => {});`, + }); + expect(composeFrameworkRoutePaths(result).map((route) => route.routePath)).toEqual([ + '/safe/users', + ]); + }); + + it.each([false, true])('retains uncertain array mounts alongside safe mount=%s', (safeMount) => { + const result = resolve({ + 'server.ts': `import express from 'express'; + const app = express(), safe = express(), router = express.Router(); + const list = [router]; list.push = other; + app.use('/api', list); + ${safeMount ? "safe.use('/safe', router);" : ''} + router.get('/users', () => {});`, + }); + expect(composeFrameworkRoutePaths(result).map((route) => route.routePath)).toEqual( + safeMount ? ['/safe/users'] : [], + ); + }); + + it('does not suppress standalone routes for an unproven mount receiver', () => { + const result = resolve({ + 'server.ts': `import express from 'express'; + const router = express.Router(); storage.use('/unknown', router); + router.get('/users', () => {});`, + }); + expect(composeFrameworkRoutePaths(result).map((route) => route.routePath)).toEqual(['/users']); + }); + + it('bounds repeated star-barrel exploration while preserving unrelated routes', () => { + const sources: Record = { + 'base.ts': `export const other = 1;`, + 'server.ts': `import {missing} from './a24'; import express from 'express'; missing.get('/unknown', h); const app = express(); app.get('/safe', h);`, + }; + for (let level = 0; level < 25; level++) { + const exports = + level === 0 + ? `export * from './base';` + : `export * from './a${level - 1}'; export * from './b${level - 1}';`; + sources[`a${level}.ts`] = exports; + sources[`b${level}.ts`] = exports; + } + expect( + resolve(sources).registrations.map((registration) => registration.route.routePath), + ).toEqual(['/safe']); + }); + + it('round-trips compact facts through JSON with identical resolved output', () => { + const file = facts( + `import express from 'express'; const app = express(); const r = express.Router(); r.get('/x', handler); app.use('/api', r);`, + ); + const original = resolveFrameworkRouteFacts([file], () => []); + expect(resolveFrameworkRouteFacts(JSON.parse(JSON.stringify([file])), () => [])).toEqual( + original, + ); + expect(JSON.stringify(file)).not.toContain('namedChildren'); + }); +}); diff --git a/gitnexus/test/unit/group/express-provider-provenance.test.ts b/gitnexus/test/unit/group/express-provider-provenance.test.ts new file mode 100644 index 000000000..22a1dca46 --- /dev/null +++ b/gitnexus/test/unit/group/express-provider-provenance.test.ts @@ -0,0 +1,248 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import * as globModule from 'glob'; +import * as fsUtils from '../../../src/core/group/extractors/fs-utils.js'; +import os from 'node:os'; +import path from 'node:path'; +import { HttpRouteExtractor } from '../../../src/core/group/extractors/http-route-extractor.js'; +import type { RepoHandle } from '../../../src/core/group/types.js'; +import { + getRelationships, + runPipelineFromRepo, + writeFixtureRepo, +} from '../../integration/resolvers/helpers.js'; + +vi.mock('glob', { spy: true }); + +describe('Express group provider provenance', () => { + let root: string; + const extractor = new HttpRouteExtractor(); + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-express-')); + }); + afterEach(() => { + vi.resetAllMocks(); + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + const write = (files: Record) => writeFixtureRepo(root, files); + const repo = (): RepoHandle => ({ + id: 'test', + path: root, + repoPath: root, + storagePath: path.join(root, '.gitnexus'), + }); + const providers = (contracts: Awaited>) => + contracts + .filter((contract) => contract.role === 'provider') + .map((contract) => contract.contractId) + .sort(); + + it('composes imported Express routers instead of publishing their local paths', async () => { + write({ + 'server.ts': `import express from 'express'; import router from './router'; const app = express(); app.use('/api', router);`, + 'router.ts': `import { Router } from 'express'; const router = Router(); router.get('/users', () => {}); export default router;`, + }); + expect(providers(await extractor.extract(null, root, repo()))).toEqual([ + 'http::GET::/api/users', + ]); + }); + + it('retains project providers when the filesystem supplies Windows separators', async () => { + write({ + 'src/server.ts': `import express from 'express'; import router from './router'; const app = express(); app.use('/api', router);`, + 'src/router.ts': `import { Router } from 'express'; const router = Router(); router.get('/users', () => {}); export default router;`, + }); + vi.spyOn(globModule, 'glob').mockResolvedValue(['src\\server.ts', 'src\\router.ts']); + const readSafe = fsUtils.readSafe; + vi.spyOn(fsUtils, 'readSafe').mockImplementation((repoPath, rel) => + readSafe(repoPath, rel.replace(/\\/g, '/')), + ); + expect(providers(await extractor.extract(null, root, repo()))).toEqual([ + 'http::GET::/api/users', + ]); + }); + + it('does not admit arbitrary receivers named app or router', async () => { + write({ + 'server.ts': `const router = new Map(); router.get('/not-http', 'fallback'); const app = { post() {} }; app.post('/also-not-http', () => {});`, + }); + expect(providers(await extractor.extract(null, root, repo()))).toEqual([]); + }); + + it('agrees with ingestion across mixed extensions, aliases and multiple mounts while preserving consumers and other providers', async () => { + write({ + 'tsconfig.json': JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '@routes/*': ['src/*'] } }, + }), + 'server.ts': `import express from 'express'; import { middle } from '@routes/middle'; import hidden from './src/hidden'; const app = express(); app.use(['/v1', '/v2'], auth, middle); app.use(runtimePrefix, middle); app.use(runtimePrefix, hidden);`, + 'src/middle.js': `import { Router } from 'express'; import { nested } from './leaf'; export const middle = Router(); middle.use('/admin', nested);`, + 'src/leaf.tsx': `import { Router } from 'express'; import axios from 'axios'; export const nested = Router(); nested.get('/users', () => {}); export async function outbound() { await fetch('/remote'); return axios.post('/outbound'); }`, + 'src/hidden.js': `import { Router } from 'express'; const hidden = Router(); hidden.get('/hidden', () => {}); export default hidden;`, + 'src/other.ts': `import { Hono } from 'hono'; import { Controller, Get } from '@nestjs/common'; const edge = new Hono(); edge.get('/hono', () => {}); @Controller('nest') class ControllerClass { @Get('list') list() {} } const routes = [{ path: '/table', method: 'POST', handler: tableHandler }]; function tableHandler() {} for (const route of routes) { if (route.path === request.path && route.method === request.method) route.handler(); }`, + }); + const expected = [ + 'http::GET::/hono', + 'http::GET::/nest/list', + 'http::GET::/v1/admin/users', + 'http::GET::/v2/admin/users', + 'http::POST::/table', + ]; + const sourceOnly = await extractor.extract(null, root, repo()); + expect(providers(sourceOnly)).toEqual(expected); + expect( + sourceOnly + .filter((contract) => contract.role === 'consumer') + .map((contract) => contract.contractId) + .sort(), + ).toEqual(['http::GET::/remote', 'http::POST::/outbound']); + + const ingested = await runPipelineFromRepo(root, () => {}); + const rows = getRelationships(ingested, 'HANDLES_ROUTE') + .filter((edge) => edge.sourceLabel === 'File') + .map((edge) => { + const route = ingested.graph.getNode(edge.rel.targetId)!; + return { + fileId: edge.rel.sourceId, + filePath: edge.sourceFilePath, + routePath: edge.target, + routeMethod: route.properties.method, + routeSource: edge.rel.reason, + handlerSymbolId: route.properties.handlerSymbolId, + }; + }); + expect(rows.map((row) => `http::${row.routeMethod}::${row.routePath}`).sort()).toEqual( + expected, + ); + const db = async (query: string) => (query.includes("type: 'HANDLES_ROUTE'") ? rows : []); + expect(providers(await extractor.extract(db, root, repo()))).toEqual(expected); + + // A later run rebuilds project relationships even when router files are unchanged. + write({ + 'server.ts': `import express from 'express'; import { middle } from '@routes/middle'; import hidden from './src/hidden'; const app = express(); app.use('/v3', middle); app.use(runtimePrefix, hidden);`, + }); + expect(providers(await extractor.extract(null, root, repo()))).toEqual([ + 'http::GET::/hono', + 'http::GET::/nest/list', + 'http::GET::/v3/admin/users', + 'http::POST::/table', + ]); + }, 60000); + + it('enriches composed graph routes from exact same-line registrations and imported handler aliases', async () => { + write({ + 'server.ts': `import express from 'express'; import router from './router'; const app = express(); app.use('/api', router);`, + 'router.ts': `import { Router } from 'express'; import { list as imported } from './handlers'; const router = Router(); function local() {} router.get('/local', local); router.get('/imported', imported); export default router;`, + 'handlers.ts': `export function list() {}`, + }); + const db = async ( + query: string, + params?: Record, + ): Promise[]> => { + if (query.includes("type: 'HANDLES_ROUTE'")) + return ['/local', '/imported'].map((route) => ({ + fileId: 'router-file', + filePath: 'router.ts', + routePath: `/api${route}`, + routeMethod: 'GET', + handlerSymbolId: '', + })); + if (query.includes('UNION ALL') && params?.filePath === 'router.ts') + return [ + { + uid: 'local-uid', + name: 'local', + filePath: 'router.ts', + startLine: 0, + endLine: 0, + labels: ['Function'], + }, + ]; + if (query.includes('STARTS WITH') && params?.name === 'list') + return [{ uid: 'list-uid', name: 'list', filePath: 'handlers.ts' }]; + return []; + }; + const contracts = await extractor.extract(db, root, repo()); + expect(providers(contracts)).toEqual(['http::GET::/api/imported', 'http::GET::/api/local']); + expect( + contracts.find((contract) => contract.contractId === 'http::GET::/api/imported'), + ).toMatchObject({ + symbolUid: 'list-uid', + symbolRef: { filePath: 'handlers.ts' }, + meta: { extractionStrategy: 'graph_assisted' }, + }); + expect( + contracts.find((contract) => contract.contractId === 'http::GET::/api/local')?.symbolUid, + ).toBe('local-uid'); + }); + + it('does not borrow global handlers or the enclosing registrar for unresolved handler designators', async () => { + write({ + 'router.ts': `import { Router } from 'express'; +import { imported } from './handlers'; +const router = Router(); +function handler() {} +function install(handler, imported, object) { + router.get('/parameter', handler); router.get('/shadowed-import', imported); router.get('/member', object.handler); + router.get('/inline', () => {}); +} +function unknown() { router.get('/unknown', missing); } +export default router;`, + }); + const db = async (query: string): Promise[]> => + query.includes('UNION ALL') + ? [ + { + uid: 'global-handler', + name: 'handler', + filePath: 'router.ts', + startLine: 3, + endLine: 3, + labels: ['Function'], + }, + { + uid: 'registrar', + name: 'install', + filePath: 'router.ts', + startLine: 4, + endLine: 7, + labels: ['Function'], + }, + { + uid: 'unknown-registrar', + name: 'unknown', + filePath: 'router.ts', + startLine: 8, + endLine: 8, + labels: ['Function'], + }, + ] + : []; + const contracts = await extractor.extract(db, root, repo()); + expect(providers(contracts)).toEqual([ + 'http::GET::/inline', + 'http::GET::/member', + 'http::GET::/parameter', + 'http::GET::/shadowed-import', + 'http::GET::/unknown', + ]); + for (const contract of contracts.filter( + (contract) => contract.contractId !== 'http::GET::/inline', + )) + expect(contract.symbolUid).toBe(''); + expect( + contracts.find((contract) => contract.contractId === 'http::GET::/inline')?.symbolUid, + ).toBe('registrar'); + }); + + it('fails closed when configured internal modules shadow framework package names', async () => { + write({ + 'tsconfig.json': JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { express: ['./local.ts'], hono: ['./local.ts'] } }, + }), + 'local.ts': `export default function storage() { return new Map(); } export class Hono { get() {} }`, + 'server.ts': `import express from 'express'; import { Hono } from 'hono'; const app = express(); const edge = new Hono(); app.get('/not-http', 'fallback'); edge.get('/also-not-http', () => {});`, + }); + expect(providers(await extractor.extract(null, root, repo()))).toEqual([]); + }); +}); diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index d623b521a..6fb95f9af 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -2537,12 +2537,14 @@ export default router; fs.writeFileSync( path.join(dir, 'src/routes/health-a.ts'), ` +import { Router } from 'express'; const router = Router(); router.get('/api/health', healthA); `, ); fs.writeFileSync( path.join(dir, 'src/routes/health-b.ts'), ` +import { Router } from 'express'; const router = Router(); router.get('/api/health', healthB); `, ); @@ -7566,7 +7568,11 @@ export async function fetchHealth() { it('handles graph queries that throw gracefully', async () => { const dir = path.join(tmpDir, 'graph-error'); fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); - fs.writeFileSync(path.join(dir, 'src/routes.ts'), `router.get('/api/health', handler);`); + fs.writeFileSync( + path.join(dir, 'src/routes.ts'), + `import { Router } from 'express'; const router = Router(); +router.get('/api/health', handler);`, + ); const throwingExecutor = async () => { throw new Error('DB unavailable'); @@ -7586,6 +7592,7 @@ export async function fetchHealth() { fs.writeFileSync( path.join(dir, 'src/router.ts'), ` +import { Router } from 'express'; const router = Router(); router.get('/api/users/', handler); `, ); @@ -7601,6 +7608,7 @@ router.get('/api/users/', handler); fs.writeFileSync( path.join(dir, 'src/router.ts'), ` +import { Router } from 'express'; const router = Router(); router.get('/api/users/:id', handler1); router.get('/api/posts/{postId}', handler2); `, diff --git a/gitnexus/test/unit/group/js-http-consumer-resolution.test.ts b/gitnexus/test/unit/group/js-http-consumer-resolution.test.ts index c39dda791..9da51e3e4 100644 --- a/gitnexus/test/unit/group/js-http-consumer-resolution.test.ts +++ b/gitnexus/test/unit/group/js-http-consumer-resolution.test.ts @@ -7,6 +7,7 @@ import { JAVASCRIPT_HTTP_PLUGIN, } from '../../../src/core/group/extractors/http-patterns/node.js'; import { resolveJsImport } from '../../../src/core/ingestion/route-extractors/js-const-resolver.js'; +import { normalizeRepoRelPath } from '../../../src/core/group/extractors/http-route-extractor.js'; import type { HttpDetection } from '../../../src/core/group/extractors/http-patterns/types.js'; const tsParser = new Parser(); @@ -21,27 +22,37 @@ jsParser.setLanguage(JavaScript); * Drive the plugin the way the orchestrator does: a `prepareRepo` pre-pass over * a virtual repo, then a per-file `scan` with the resulting context. */ -function scanRepo(files: Record, target: string): HttpDetection[] { +async function scanRepo(files: Record, target: string): Promise { const paths = Object.keys(files); - const repoContext = TYPESCRIPT_HTTP_PLUGIN.prepareRepo?.({ + const repoContext = await TYPESCRIPT_HTTP_PLUGIN.prepareRepo?.({ repoPath: '/repo', files: paths, parser: tsParser, readFile: (rel) => files[rel] ?? null, parseSource: (parser, src) => parser.parse(src), }); - return TYPESCRIPT_HTTP_PLUGIN.scan(tsParser.parse(files[target]), repoContext, target); + const inputs = paths.map((filePath) => ({ filePath, tree: tsParser.parse(files[filePath]) })); + return [ + ...TYPESCRIPT_HTTP_PLUGIN.scan( + inputs.find((input) => input.filePath === target)!.tree, + repoContext, + target, + ), + ...(TYPESCRIPT_HTTP_PLUGIN.scanProject?.(inputs, repoContext).find( + (file) => normalizeRepoRelPath(file.filePath) === normalizeRepoRelPath(target), + )?.detections ?? []), + ]; } const consumers = (detections: HttpDetection[]) => detections.filter((d) => d.role === 'consumer'); /** `scanRepo`, but the pre-pass may fail on chosen files. */ -function scanRepoWithParse( +async function scanRepoWithParse( files: Record, target: string, parseSource: (parser: Parser, src: string) => Parser.Tree | null, -): HttpDetection[] { - const repoContext = TYPESCRIPT_HTTP_PLUGIN.prepareRepo?.({ +): Promise { + const repoContext = await TYPESCRIPT_HTTP_PLUGIN.prepareRepo?.({ repoPath: '/repo', files: Object.keys(files), parser: tsParser, @@ -69,8 +80,8 @@ const API_ROUTES = ` `; describe('JS/TS HTTP consumer resolution', () => { - it('resolves a configured client and a table path imported from other files', () => { - const detections = scanRepo( + it('resolves a configured client and a table path imported from other files', async () => { + const detections = await scanRepo( { 'src/lib/axios.config.ts': AXIOS_CONFIG, 'src/api-modules/shared/api-routes.ts': API_ROUTES, @@ -90,8 +101,8 @@ describe('JS/TS HTTP consumer resolution', () => { ); }); - it('resolves relative imports for the client and the route table', () => { - const detections = scanRepo( + it('resolves relative imports for the client and the route table', async () => { + const detections = await scanRepo( { 'src/lib/axios.config.ts': AXIOS_CONFIG, 'src/api/routes.ts': API_ROUTES, @@ -109,8 +120,8 @@ describe('JS/TS HTTP consumer resolution', () => { ); }); - it('folds a template partially, keeping the resolved prefix', () => { - const detections = scanRepo( + it('folds a template partially, keeping the resolved prefix', async () => { + const detections = await scanRepo( { 'src/lib/axios.config.ts': AXIOS_CONFIG, 'src/api/routes.ts': API_ROUTES, @@ -131,8 +142,8 @@ describe('JS/TS HTTP consumer resolution', () => { ); }); - it('resolves a `+` concatenation against an imported base constant', () => { - const detections = scanRepo( + it('resolves a `+` concatenation against an imported base constant', async () => { + const detections = await scanRepo( { 'src/lib/axios.config.ts': AXIOS_CONFIG, 'src/api/base.ts': `export const BASE = "/api/v1";`, @@ -150,8 +161,8 @@ describe('JS/TS HTTP consumer resolution', () => { ); }); - it('follows a barrel re-export to the defining module', () => { - const detections = scanRepo( + it('follows a barrel re-export to the defining module', async () => { + const detections = await scanRepo( { 'src/lib/axios.config.ts': AXIOS_CONFIG, 'src/api/routes.ts': API_ROUTES, @@ -172,8 +183,8 @@ describe('JS/TS HTTP consumer resolution', () => { // ─── Shapes real applications actually ship ──────────────────────── - it('proves a client built by a factory wrapper, not just a bare axios.create', () => { - const detections = scanRepo( + it('proves a client built by a factory wrapper, not just a bare axios.create', async () => { + const detections = await scanRepo( { // The shape Sourcerer-fe ships: the instance is an argument to a // decorator that returns the configured client. @@ -200,8 +211,8 @@ describe('JS/TS HTTP consumer resolution', () => { ); }); - it('follows `export *` through a directory barrel', () => { - const detections = scanRepo( + it('follows `export *` through a directory barrel', async () => { + const detections = await scanRepo( { 'src/lib/axios.config.ts': AXIOS_CONFIG, 'src/api-modules/shared/api-routes.ts': API_ROUTES, @@ -224,8 +235,8 @@ describe('JS/TS HTTP consumer resolution', () => { ); }); - it('recognizes an aliased axios import', () => { - const detections = scanRepo( + it('recognizes an aliased axios import', async () => { + const detections = await scanRepo( { 'src/lib/client.ts': ` import ax from 'axios'; @@ -246,8 +257,8 @@ describe('JS/TS HTTP consumer resolution', () => { ); }); - it('keeps literal segments of a template nested inside a substitution', () => { - const detections = scanRepo( + it('keeps literal segments of a template nested inside a substitution', async () => { + const detections = await scanRepo( { 'src/lib/axios.config.ts': AXIOS_CONFIG, 'src/api/routes.ts': API_ROUTES, @@ -266,8 +277,8 @@ describe('JS/TS HTTP consumer resolution', () => { ); }); - it('does not let a client built inside a callback vouch for the outer name', () => { - const detections = scanRepo( + it('does not let a client built inside a callback vouch for the outer name', async () => { + const detections = await scanRepo( { 'src/thing.ts': ` const thing = configure(() => axios.create({ baseURL: '/' })); @@ -282,12 +293,13 @@ describe('JS/TS HTTP consumer resolution', () => { // ─── Precision guards ────────────────────────────────────────────── - it('does NOT emit an Express provider route as a consumer of itself', () => { - const detections = scanRepo( + it('does NOT emit an Express provider route as a consumer of itself', async () => { + const detections = await scanRepo( { 'src/server.ts': ` import express from 'express'; const router = express.Router(); + const app = express(); router.get('/users', listUsers); app.post('/orders', createOrder); `, @@ -302,8 +314,8 @@ describe('JS/TS HTTP consumer resolution', () => { ); }); - it('does NOT claim an unproven receiver that merely has a .get method', () => { - const detections = scanRepo( + it('does NOT claim an unproven receiver that merely has a .get method', async () => { + const detections = await scanRepo( { 'src/cache.ts': ` const cache = new Map(); @@ -317,8 +329,8 @@ describe('JS/TS HTTP consumer resolution', () => { expect(consumers(detections)).toEqual([]); }); - it('refuses to resolve an import whose specifier matches two files', () => { - const detections = scanRepo( + it('refuses to resolve an import whose specifier matches two files', async () => { + const detections = await scanRepo( { 'src/lib/axios.config.ts': AXIOS_CONFIG, 'a/shared/routes.ts': API_ROUTES, @@ -366,7 +378,7 @@ describe('JS/TS HTTP consumer resolution', () => { // ─── Review findings: precision, termination and keying ──────────── - it('keys the fact map the same way on a platform that hands it backslashes', () => { + it('keys the fact map the same way on a platform that hands it backslashes', async () => { // glob v13 is called without `posix: true` and its walker joins with the // platform separator, so on Windows every path here arrives backslashed. const files = { @@ -379,13 +391,13 @@ describe('JS/TS HTTP consumer resolution', () => { `, }; - expect(consumers(scanRepo(files, 'src\\api\\links.api.ts'))).toContainEqual( + expect(consumers(await scanRepo(files, 'src\\api\\links.api.ts'))).toContainEqual( expect.objectContaining({ method: 'GET', path: '/links' }), ); }); - it('does NOT treat a container that merely HOLDS an axios instance as a client', () => { - const detections = scanRepo( + it('does NOT treat a container that merely HOLDS an axios instance as a client', async () => { + const detections = await scanRepo( { 'src/stores.ts': ` import axios from 'axios'; @@ -405,8 +417,8 @@ describe('JS/TS HTTP consumer resolution', () => { expect(consumers(detections)).toEqual([]); }); - it('still proves the factory shape the containment rule existed for', () => { - const detections = scanRepo( + it('still proves the factory shape the containment rule existed for', async () => { + const detections = await scanRepo( { 'src/lib/client.ts': ` import axios from 'axios'; @@ -427,8 +439,8 @@ describe('JS/TS HTTP consumer resolution', () => { ); }); - it('refuses a resolved constant that is not path-shaped', () => { - const detections = scanRepo( + it('refuses a resolved constant that is not path-shaped', async () => { + const detections = await scanRepo( { 'src/lib/axios.config.ts': AXIOS_CONFIG, 'src/api/strings.ts': ` @@ -450,8 +462,8 @@ describe('JS/TS HTTP consumer resolution', () => { expect(consumers(detections)).toEqual([]); }); - it('keeps an all-numeric path that is written as a path', () => { - const detections = scanRepo( + it('keeps an all-numeric path that is written as a path', async () => { + const detections = await scanRepo( { 'src/lib/axios.config.ts': AXIOS_CONFIG, 'src/api/legacy.api.ts': ` @@ -469,8 +481,8 @@ describe('JS/TS HTTP consumer resolution', () => { ); }); - it('refuses a path whose leading term never resolved', () => { - const detections = scanRepo( + it('refuses a path whose leading term never resolved', async () => { + const detections = await scanRepo( { 'src/lib/axios.config.ts': AXIOS_CONFIG, 'src/api/unanchored.api.ts': ` @@ -488,9 +500,9 @@ describe('JS/TS HTTP consumer resolution', () => { expect(consumers(detections)).toEqual([]); }); - it('caps the folded output instead of building a path of unbounded length', () => { + it('caps the folded output instead of building a path of unbounded length', async () => { const pad = 'a'.repeat(4000); - const detections = scanRepo( + const detections = await scanRepo( { 'src/lib/axios.config.ts': AXIOS_CONFIG, 'src/api/big.api.ts': ` @@ -507,7 +519,7 @@ describe('JS/TS HTTP consumer resolution', () => { expect(consumers(detections)).toEqual([]); }); - it('terminates on expressions deep enough to overflow the stack', () => { + it('terminates on expressions deep enough to overflow the stack', async () => { // `scan` is contractually non-throwing: `sync.ts` turns a throw here into an // unexplained "missing repo" that silently drops every contract of every // kind for that repo. Both shapes recursed once per term before this. @@ -518,12 +530,16 @@ describe('JS/TS HTTP consumer resolution', () => { let nested = '`/x`'; for (let i = 0; i < 4000; i++) nested = '`${' + nested + '}`'; - expect(() => scanRepo({ 'src/a.ts': `axios.get(${chain});` }, 'src/a.ts')).not.toThrow(); - expect(() => scanRepo({ 'src/b.ts': `axios.get(${nested});` }, 'src/b.ts')).not.toThrow(); + await expect( + scanRepo({ 'src/a.ts': `axios.get(${chain});` }, 'src/a.ts'), + ).resolves.toBeDefined(); + await expect( + scanRepo({ 'src/b.ts': `axios.get(${nested});` }, 'src/b.ts'), + ).resolves.toBeDefined(); }); - it('survives a file whose parse throws, and still resolves the rest of the repo', () => { - const detections = scanRepoWithParse( + it('survives a file whose parse throws, and still resolves the rest of the repo', async () => { + const detections = await scanRepoWithParse( { 'src/lib/axios.config.ts': AXIOS_CONFIG, 'src/api/routes.ts': API_ROUTES, @@ -546,8 +562,8 @@ describe('JS/TS HTTP consumer resolution', () => { ); }); - it('sees an axios import declared below the binding that uses it', () => { - const detections = scanRepo( + it('sees an axios import declared below the binding that uses it', async () => { + const detections = await scanRepo( { // ES module bindings are hoisted, so this is legal and binds the same `ax`. 'src/lib/late.ts': ` @@ -570,8 +586,8 @@ describe('JS/TS HTTP consumer resolution', () => { ); }); - it('keeps a partially folded path whose unresolved term contains spaces', () => { - const detections = scanRepo( + it('keeps a partially folded path whose unresolved term contains spaces', async () => { + const detections = await scanRepo( { 'src/lib/axios.config.ts': AXIOS_CONFIG, 'src/api/routes.ts': API_ROUTES, @@ -605,9 +621,9 @@ describe('JS/TS HTTP consumer resolution', () => { expect(consumers(detections).map((d) => d.path)).toEqual(['${API_BASE}/users', '${a}${b}']); }); - it('caps the literal fallback of an oversized template too', () => { + it('caps the literal fallback of an oversized template too', async () => { const pad = 'a'.repeat(9000); - const detections = scanRepo( + const detections = await scanRepo( { 'src/lib/axios.config.ts': AXIOS_CONFIG, 'src/api/big.api.ts': ` @@ -621,8 +637,8 @@ describe('JS/TS HTTP consumer resolution', () => { expect(consumers(detections)).toEqual([]); }); - it('proves a client handed to a factory inside a nested options object', () => { - const detections = scanRepo( + it('proves a client handed to a factory inside a nested options object', async () => { + const detections = await scanRepo( { 'src/lib/client.ts': ` import axios from 'axios'; @@ -649,8 +665,8 @@ describe('JS/TS HTTP consumer resolution', () => { ); }); - it('does NOT trust the spelling `axios` when the file binds that name itself', () => { - const detections = scanRepo( + it('does NOT trust the spelling `axios` when the file binds that name itself', async () => { + const detections = await scanRepo( { 'src/shadow.ts': ` const axios = fakeFactory; @@ -671,7 +687,7 @@ describe('JS/TS HTTP consumer resolution', () => { expect(consumers(detections)).toEqual([]); expect( consumers( - scanRepo( + await scanRepo( { 'src/mock.ts': ` const axios = { create: () => ({ get: (u: string) => u }) }; @@ -685,7 +701,7 @@ describe('JS/TS HTTP consumer resolution', () => { ).toEqual([]); }); - it('resolves a CommonJS require of axios, aliased or not', () => { + it('resolves a CommonJS require of axios, aliased or not', async () => { const cjs = (local: string) => ` const ${local} = require('axios'); const api = ${local}.create({ baseURL: '/' }); @@ -694,15 +710,15 @@ describe('JS/TS HTTP consumer resolution', () => { `; for (const local of ['axios', 'ax']) { - const detections = scanRepo({ 'src/cjs.ts': cjs(local) }, 'src/cjs.ts'); + const detections = await scanRepo({ 'src/cjs.ts': cjs(local) }, 'src/cjs.ts'); expect(consumers(detections).map((d) => d.path)).toEqual( expect.arrayContaining(['/instance', '/module']), ); } }); - it('resolves the axios module used directly under an import alias', () => { - const detections = scanRepo( + it('resolves the axios module used directly under an import alias', async () => { + const detections = await scanRepo( { 'src/aliased.ts': ` import ax from 'axios'; @@ -717,8 +733,8 @@ describe('JS/TS HTTP consumer resolution', () => { ); }); - it('refuses a name two `export *` barrels both provide', () => { - const detections = scanRepo( + it('refuses a name two `export *` barrels both provide', async () => { + const detections = await scanRepo( { 'src/lib/axios.config.ts': AXIOS_CONFIG, 'src/api/a.ts': `export const API_ROUTE_PATH = { LINKS: "/links-a" } as const;`, @@ -739,8 +755,8 @@ describe('JS/TS HTTP consumer resolution', () => { expect(consumers(detections)).toEqual([]); }); - it('does NOT bind a Node builtin specifier to a same-named repo file', () => { - const detections = scanRepo( + it('does NOT bind a Node builtin specifier to a same-named repo file', async () => { + const detections = await scanRepo( { 'src/lib/http.ts': ` import axios from 'axios'; @@ -757,10 +773,10 @@ describe('JS/TS HTTP consumer resolution', () => { expect(consumers(detections)).toEqual([]); }); - it('measures the pre-pass ceiling in bytes, not UTF-16 code units', () => { + it('measures the pre-pass ceiling in bytes, not UTF-16 code units', async () => { // Under 512 Ki code units, over 512 KiB of UTF-8 — the ceiling mirrors the // analyzer's byte-size limit, so this file must be skipped. - const detections = scanRepo( + const detections = await scanRepo( { 'src/lib/huge.ts': ` import axios from 'axios'; @@ -971,9 +987,9 @@ api.request({ url: '/api/users', method: 'POST' }); expect(detections[0]?.path).toBe('https://host/api/x'); }); - it('admits axios.create instances calling .request', () => { + it('admits axios.create instances calling .request', async () => { const detections = consumers( - scanRepo( + await scanRepo( { 'src/lib/client.ts': ` import axios from 'axios'; @@ -992,9 +1008,9 @@ api.request({ url: '/api/users', method: 'POST' }); ); }); - it('admits member-verb calls with a gateway-prefixed template', () => { + it('admits member-verb calls with a gateway-prefixed template', async () => { const detections = consumers( - scanRepo( + await scanRepo( { 'src/lib/client.ts': ` import axios from 'axios'; diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 2fe2a0e2c..8062aec26 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -325,8 +325,9 @@ describe('PARSE_CACHE_VERSION', () => { // Moved 140 -> 141 for detached Go ownership captures used by rename (#3559). // Moved 141 -> 142 to combine Go ownership with fetch-wrapper candidates (#3567). // Moved 142 -> 143 to combine fetch-wrapper candidates with #3561 route corrections. - it('pins SCHEMA_BUMP to 143 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402, #3446, #3450, #3499, #3502, #3487, #3505, #3504, #3497, #3486, #3559, #3567, #3561)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(143); + // Moved 143 -> 144 for provider provenance facts and recomputed mounted paths. + it('pins SCHEMA_BUMP to 144 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402, #3446, #3450, #3499, #3502, #3487, #3505, #3504, #3497, #3486, #3559, #3567, #3561)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(144); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly diff --git a/gitnexus/test/unit/result-merge.test.ts b/gitnexus/test/unit/result-merge.test.ts index 32957849e..53518e063 100644 --- a/gitnexus/test/unit/result-merge.test.ts +++ b/gitnexus/test/unit/result-merge.test.ts @@ -33,6 +33,29 @@ function emptyResult(): ParseWorkerResult { } describe('mergeResult', () => { + it('retains opaque route facts across worker sub-batches', () => { + const target = emptyResult(); + const first = { filePath: 'mount.ts', routePosition: 0, facts: { privateMountData: ['/api'] } }; + const second = { + filePath: 'router.js', + routePosition: 0, + facts: { privateRegistrationData: ['/users'] }, + }; + mergeResult(target, { ...emptyResult(), routeFacts: [first] }); + const declaration = { + filePath: 'middle.ts', + routePath: '/existing', + httpMethod: 'GET', + decoratorName: 'other', + lineNumber: 1, + }; + mergeResult(target, { ...emptyResult(), decoratorRoutes: [declaration] }); + mergeResult(target, { ...emptyResult(), routeFacts: [second] }); + expect(target.routeFacts).toEqual([first, { ...second, routePosition: 1 }]); + expect(target.decoratorRoutes).toEqual([declaration]); + expect(second.routePosition).toBe(0); + }); + it('retains wrapper candidates across sub-batches without treating them as HTTP sinks', () => { const target = emptyResult(); const first = { filePath: 'a.ts', functionName: 'request', fetchURL: '/a', lineNumber: 1 };