diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 5610c438a..ee97bb1b7 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -48,6 +48,7 @@ import type { RepoConstants, } from './route-extractors/constant-resolver.js'; import type Parser from 'tree-sitter'; +import type { FetchWrapperCall } from './route-extractors/fetch-wrapper.js'; import type { ExtractedDecoratorRoute, ExtractedToolDef } from './workers/parse-worker.js'; import type { SemanticModel } from './model/semantic-model.js'; @@ -604,6 +605,14 @@ interface LanguageProviderConfig { lineOffset: number, ) => ExtractedDecoratorRoute[]; + /** URL-bearing call candidates from the existing AST, before wrapper names + * are known across files. Routes selects known wrappers after parsing. */ + readonly extractFetchWrapperCalls?: ( + tree: Parser.Tree, + filePath: string, + lineOffset: number, + ) => FetchWrapperCall[]; + /** * Name of the function a route decorator captured by the worker's generic * `@decorator` query applies to, given the decorator's own AST node. diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index c5f9ff5d9..e42827764 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -128,6 +128,7 @@ import { import { extractDispatchGuardRoutes } from '../route-extractors/dispatch-guard.js'; 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 { extractTrpcRoutes, shouldScanForTrpcRoutes } from '../route-extractors/trpc.js'; import { extractConvexEndpointProperties } from './typescript/convex-endpoint-metadata.js'; import { extractToolDefinitions } from './typescript/tool-definitions.js'; @@ -494,6 +495,7 @@ 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, + 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. extractTextRoutes: extractJsTsTextRoutes, @@ -580,6 +582,7 @@ export const javascriptProvider = defineLanguage({ arityCompatibility: jsArityCompatibility, // See the TypeScript provider above. extractDecoratorRoutes: extractJsTsRoutes, + extractFetchWrapperCalls, // Content-based (not AST): tRPC procedure routers are scanned from source text. extractTextRoutes: extractJsTsTextRoutes, extractToolDefinitions, diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 8aafd23a7..d0de7589f 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -1,3 +1,4 @@ +import type { FetchWrapperCall } from './route-extractors/fetch-wrapper.js'; import type { NodeLabel } from 'gitnexus-shared'; import { KnowledgeGraph } from '../graph/types.js'; import type { SymbolTableWriter } from './model/index.js'; @@ -37,6 +38,7 @@ export interface WorkerExtractedData { routes: ExtractedRoute[]; fetchCalls: ExtractedFetchCall[]; fetchWrapperDefs: FetchWrapperDef[]; + fetchWrapperCalls: FetchWrapperCall[]; decoratorRoutes: ExtractedDecoratorRoute[]; routerIncludes: ExtractedRouterInclude[]; routerImports: ExtractedRouterImport[]; @@ -118,6 +120,7 @@ export const mergeChunkResults = ( const allRoutes: ExtractedRoute[] = []; const allFetchCalls: ExtractedFetchCall[] = []; const allFetchWrapperDefs: FetchWrapperDef[] = []; + const allFetchWrapperCalls: FetchWrapperCall[] = []; const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; const allRouterIncludes: ExtractedRouterInclude[] = []; const allRouterImports: ExtractedRouterImport[] = []; @@ -168,6 +171,7 @@ export const mergeChunkResults = ( for (const item of result.routes) allRoutes.push(item); 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.decoratorRoutes) allDecoratorRoutes.push(item); for (const item of result.routerIncludes ?? []) allRouterIncludes.push(item); for (const item of result.routerImports ?? []) allRouterImports.push(item); @@ -191,6 +195,7 @@ export const mergeChunkResults = ( routes: allRoutes, fetchCalls: allFetchCalls, fetchWrapperDefs: allFetchWrapperDefs, + fetchWrapperCalls: allFetchWrapperCalls, decoratorRoutes: allDecoratorRoutes, routerIncludes: allRouterIncludes, routerImports: allRouterImports, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index cc72a04d5..c6d0f48c1 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 { FetchWrapperCall } from '../route-extractors/fetch-wrapper.js'; import { BindingAccumulator, enrichExportedTypeMap, @@ -456,6 +457,7 @@ export async function runChunkedParseAndResolve( exportedTypeMap: ExportedTypeMap; allFetchCalls: ExtractedFetchCall[]; allFetchWrapperDefs: FetchWrapperDef[]; + allFetchWrapperCalls: FetchWrapperCall[]; allExtractedRoutes: ExtractedRoute[]; allDecoratorRoutes: ExtractedDecoratorRoute[]; selectedRoutes: ReadonlySet; @@ -773,6 +775,7 @@ export async function runChunkedParseAndResolve( const bindingAccumulator = new BindingAccumulator(); const allFetchCalls: ExtractedFetchCall[] = []; const allFetchWrapperDefs: FetchWrapperDef[] = []; + const allFetchWrapperCalls: FetchWrapperCall[] = []; const allExtractedRoutes: ExtractedRoute[] = []; const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; const allRouterIncludes: ExtractedRouterInclude[] = []; @@ -994,6 +997,7 @@ export async function runChunkedParseAndResolve( if (chunkWorkerData.fetchCalls?.length) { for (const item of chunkWorkerData.fetchCalls) allFetchCalls.push(item); } + for (const item of chunkWorkerData.fetchWrapperCalls ?? []) allFetchWrapperCalls.push(item); if (chunkWorkerData.fetchWrapperDefs?.length) { for (const item of chunkWorkerData.fetchWrapperDefs) allFetchWrapperDefs.push(item); } @@ -1955,6 +1959,7 @@ export async function runChunkedParseAndResolve( exportedTypeMap, allFetchCalls, allFetchWrapperDefs, + allFetchWrapperCalls, allExtractedRoutes, allDecoratorRoutes, selectedRoutes, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts index 0c4946afd..6e43352a3 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse.ts @@ -31,6 +31,7 @@ import type { } from '../workers/parse-worker.js'; import { runChunkedParseAndResolve } from './parse-impl.js'; import type { MutableSemanticModel } from '../model/index.js'; +import type { FetchWrapperCall } from '../route-extractors/fetch-wrapper.js'; import type { ModuleConstants } from '../route-extractors/constant-resolver.js'; export interface ParseOutput { @@ -48,6 +49,7 @@ export interface ParseOutput { readonly exportedTypeMap: ReadonlyMap>; readonly allFetchCalls: readonly ExtractedFetchCall[]; readonly allFetchWrapperDefs: readonly FetchWrapperDef[]; + readonly allFetchWrapperCalls: readonly FetchWrapperCall[]; readonly allExtractedRoutes: readonly ExtractedRoute[]; readonly allDecoratorRoutes: readonly ExtractedDecoratorRoute[]; /** Exact admitted declarations that own Route nodes and handler attribution. */ diff --git a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts index 0d526bb5c..f6ac5931a 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts @@ -161,10 +161,6 @@ export function extractTemplateStaticFetchCalls( return calls; } -function escapeRegex(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - export const routesPhase: PipelinePhase = { name: 'routes', deps: ['parse'], @@ -177,6 +173,7 @@ export const routesPhase: PipelinePhase = { allPaths, allFetchCalls: parseFetchCalls, allFetchWrapperDefs, + allFetchWrapperCalls, allExtractedRoutes, allDecoratorRoutes, routeHandlerSymbols, @@ -501,22 +498,10 @@ export const routesPhase: PipelinePhase = { } } - // ── Cross-file fetch wrapper consumer extraction ── - // When the parse phase discovered functions that internally call fetch(), - // scan JS/TS consumer files for calls to those wrapper functions with - // URL-like string arguments and add them to allFetchCalls so - // processNextjsFetchRoutes can create FETCHES edges. - // Wrapper names come from two sources: functions the parse phase - // auto-detected as calling the bare global `fetch()`, plus any names the - // user declared in `.gitnexusrc` `fetchWrappers` (#1589/#1852 residual). - // Config names let an axios/custom-client wrapper — or one named outside the - // built-in convention — still produce route_map consumers; without them it - // silently falls back to `consumers: []`. Configured names alone are enough - // to run the scan even when nothing was auto-detected. - // Configured names are already validated/trimmed/de-duped/capped by - // analyze-config.ts — trusted as-is (#1589/#1852 review F9, dropped the - // redundant re-trim/re-filter). The single filter below guards only the - // auto-detected `functionName`s, which have no shape guarantee. + // Match AST candidates only after all wrapper definitions are known. The + // provider captures each call's complete callee and first argument during + // parsing; no source reread, reparse, or type-argument scanning is needed. + // Configured names also cover wrappers built on axios/custom clients. const configuredWrappers = ctx.options?.fetchWrappers ?? []; const wrapperNames = new Set( [...(allFetchWrapperDefs ?? []).map((d) => d.functionName), ...configuredWrappers].filter( @@ -524,61 +509,8 @@ export const routesPhase: PipelinePhase = { ), ); if (wrapperNames.size > 0 && routeRegistry.size > 0) { - const jsFiles = allPaths.filter((p) => /\.[jt]sx?$/.test(p)); - if (jsFiles.length > 0) { - // Reuse contents already read for handler extraction; only read the - // remainder (mirrors the Expo block above). Avoids a second full read of - // files we already have in memory. - const unreadJsFiles = jsFiles.filter((p) => !handlerContents?.has(p)); - const extraContents = - unreadJsFiles.length > 0 - ? await readFileContents(ctx.repoPath, unreadJsFiles) - : new Map(); - // One alternation regex over every wrapper name per file — O(files), not - // O(files × wrappers) (#1852 review F3). Names are escaped and grouped - // non-capturing so capture group 1 stays the URL. The left boundary is a - // negative lookbehind, not `\b`: a bare configured name like `get` must - // match the free call `get('/x')` but NOT a member access `client.get(` - // (a `.get(` on an unrelated object), and `apiFetch` must not match - // `myApiFetch`. Member-style wrappers are configured with the dot - // (`client.get`), where the `.` is part of the pattern. The `u` flag + - // Unicode property classes make the boundary cover non-ASCII identifier - // characters too — ASCII `\w` would let `caféget('/x')` match `get` - // (#1852 review F10). - const alternation = [...wrapperNames].map(escapeRegex).join('|'); - const wrapperCallRegex = new RegExp( - `(? { - wrapperCallRegex.lastIndex = 0; - // 1-based line number via a running newline counter: matches arrive in - // ascending index, so accumulate newlines incrementally instead of - // re-allocating `content.substring(0, match.index).split('\n')` on - // every match (#1852 review F12). Output is identical. - let line = 1; - let scanned = 0; - let match; - while ((match = wrapperCallRegex.exec(content)) !== null) { - for (; scanned < match.index; scanned++) { - if (content.charCodeAt(scanned) === 10 /* '\n' */) line++; - } - allFetchCalls.push({ - filePath, - fetchURL: match[1], - lineNumber: line, - }); - } - }; - for (const [filePath, content] of extraContents) scanContent(filePath, content); - // Also scan already-read JS/TS handler files (a handler can itself - // consume another route through a wrapper). - if (handlerContents) { - for (const p of jsFiles) { - const cached = handlerContents.get(p); - if (cached !== undefined) scanContent(p, cached); - } - } + for (const call of allFetchWrapperCalls ?? []) { + if (wrapperNames.has(call.functionName)) allFetchCalls.push(call); } } diff --git a/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper.ts b/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper.ts new file mode 100644 index 000000000..cbe1bf410 --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper.ts @@ -0,0 +1,54 @@ +/** Collect URL-bearing calls while the provider's existing AST is available. */ +import type Parser from 'tree-sitter'; +import type { ExtractedFetchCall } from '../workers/parse-worker.js'; + +/** A candidate is not an HTTP sink until its name matches a known wrapper. */ +export interface FetchWrapperCall extends ExtractedFetchCall { + functionName: string; +} + +function calleeName(node: Parser.SyntaxNode | null): string | undefined { + // The TS grammar puts `await f(...)` inside the call's function field. + while (node?.type === 'await_expression') { + node = node.namedChildren.find((child) => child.type !== 'comment') ?? null; + } + // Unlike constant names, configured wrapper names may start with this/super. + const parts: string[] = []; + while (node?.type === 'member_expression') { + const property = node.childForFieldName('property'); + if (property?.type !== 'property_identifier') return undefined; + parts.push(property.text); + node = node.childForFieldName('object'); + } + if (!node || !['identifier', 'this', 'super'].includes(node.type)) return undefined; + parts.push(node.text); + return parts.reverse().join('.'); +} + +export function extractFetchWrapperCalls( + tree: Parser.Tree, + filePath: string, + lineOffset: number, +): FetchWrapperCall[] { + const calls: FetchWrapperCall[] = []; + for (const call of tree.rootNode.descendantsOfType('call_expression')) { + if (call.hasError) continue; + const argument = call + .childForFieldName('arguments') + ?.namedChildren.find((child) => child.type !== 'comment'); + if (argument?.type !== 'string' && argument?.type !== 'template_string') continue; + // Keep template substitutions for the existing route URL normalizer. + // Requiring the whole argument to be a literal excludes concatenation. + const fetchURL = argument.text.slice(1, -1); + if (!fetchURL.startsWith('/')) continue; + const functionName = calleeName(call.childForFieldName('function')); + if (!functionName) continue; + calls.push({ + filePath, + functionName, + fetchURL, + lineNumber: call.startPosition.row + lineOffset + 1, + }); + } + return calls; +} diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 3c61f88db..29a443bc8 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -1,3 +1,4 @@ +import type { FetchWrapperCall } from '../route-extractors/fetch-wrapper.js'; import { parentPort, threadId, workerData } from 'node:worker_threads'; import { boundCallableStartPosition, @@ -504,6 +505,8 @@ export interface ParseWorkerResult { routes: ExtractedRoute[]; fetchCalls: ExtractedFetchCall[]; fetchWrapperDefs: FetchWrapperDef[]; + /** URL-bearing candidates, kept separate from confirmed outward-call sinks. */ + fetchWrapperCalls?: FetchWrapperCall[]; decoratorRoutes: ExtractedDecoratorRoute[]; routerIncludes: ExtractedRouterInclude[]; routerImports: ExtractedRouterImport[]; @@ -3364,6 +3367,11 @@ const processFileGroup = ( } } + if (provider.extractFetchWrapperCalls) { + const candidates = provider.extractFetchWrapperCalls(tree, file.path, lineOffset); + for (const candidate of candidates) (result.fetchWrapperCalls ??= []).push(candidate); + } + // 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 7631189b8..fe19f30c8 100644 --- a/gitnexus/src/core/ingestion/workers/result-merge.ts +++ b/gitnexus/src/core/ingestion/workers/result-merge.ts @@ -34,6 +34,7 @@ export const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult): appendAll(target.routes, src.routes); appendAll(target.fetchCalls, src.fetchCalls); appendAll(target.fetchWrapperDefs, src.fetchWrapperDefs); + if (src.fetchWrapperCalls) appendAll((target.fetchWrapperCalls ??= []), src.fetchWrapperCalls); 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 92d89cceb..57f2041d3 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -866,7 +866,10 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // Older worker and durable captures cannot prove property-reader dispatch. // v141: detached method owners retain names without fabricated graph ids. // Re-extract both worker and durable captures before workspace ownership runs. -const SCHEMA_BUMP = 141; +// v142: fetch-wrapper consumers are AST candidates emitted by providers. +// Re-extract v141 shards: main used 141 for detached Go ownership, while +// this branch used it for wrapper candidates. Neither contains both changes. +const SCHEMA_BUMP = 142; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/lib/http.ts b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/lib/http.ts index 9d27dda22..d2bf0a3ff 100644 --- a/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/lib/http.ts +++ b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/lib/http.ts @@ -6,6 +6,6 @@ const API_BASE = process.env.API_BASE || ''; // `fetch()`, so the parse-phase auto-detector cannot flag it. Listing // "doRequest" in `.gitnexusrc` `fetchWrappers` is what lets the routes phase // trace its consumers (#1589/#1852 residual). -export async function doRequest(path: string, opts?: Record) { - return axios.get(`${API_BASE}${path}`, opts); +export async function doRequest(path: string, opts?: Record) { + return axios.get(`${API_BASE}${path}`, opts); } diff --git a/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/CommentOnly.ts b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/CommentOnly.ts new file mode 100644 index 000000000..1fec08d74 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/CommentOnly.ts @@ -0,0 +1,3 @@ +// doRequest('/api/things'); +/* doRequest('/api/things'); */ +export const description = 'no request'; diff --git a/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/CommentedCall.ts b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/CommentedCall.ts new file mode 100644 index 000000000..fbe345006 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/CommentedCall.ts @@ -0,0 +1,3 @@ +import { doRequest } from '../lib/http'; +const client = { doRequest }; +export const load = () => client /* receiver */ . doRequest /* type */ /* args */ (/* url */ '/api/things'); diff --git a/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/ConcatenatedUrl.ts b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/ConcatenatedUrl.ts new file mode 100644 index 000000000..b3a960ae1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/ConcatenatedUrl.ts @@ -0,0 +1,3 @@ +import { doRequest } from '../lib/http'; +declare const suffix: string; +export const load = () => doRequest('/api/things' + suffix); diff --git a/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/FunctionTypes.ts b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/FunctionTypes.ts new file mode 100644 index 000000000..50c89120c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/FunctionTypes.ts @@ -0,0 +1,2 @@ +import { doRequest } from '../lib/http'; +export const load = () => doRequest<() => string>('/api/things'); diff --git a/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/InstanceWrapper.ts b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/InstanceWrapper.ts new file mode 100644 index 000000000..dc35a2af4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/InstanceWrapper.ts @@ -0,0 +1,6 @@ +import { doRequest } from '../lib/http'; + +export class Client { + doRequest = doRequest; + load() { return this.doRequest('/api/things'); } +} diff --git a/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/LiteralTypes.ts b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/LiteralTypes.ts new file mode 100644 index 000000000..6a6496678 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/LiteralTypes.ts @@ -0,0 +1,2 @@ +import { doRequest } from '../lib/http'; +export const load = () => doRequest<{ kind: 'thing'; compare: '>' }>('/api/things'); diff --git a/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/NestedTypes.ts b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/NestedTypes.ts new file mode 100644 index 000000000..9416cc46d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/NestedTypes.ts @@ -0,0 +1,5 @@ +import { doRequest } from '../lib/http'; + +type Thing = { id: number; name: string }; + +export const load = () => doRequest>('/api/things'); diff --git a/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/ObjectTypes.ts b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/ObjectTypes.ts new file mode 100644 index 000000000..081d0b1ba --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/ObjectTypes.ts @@ -0,0 +1,6 @@ +import { doRequest } from '../lib/http'; + +type Thing = { id: number; name: string }; +const client = { doRequest }; + +export const load = () => client.doRequest<{ data?: Thing[]; total: number }>('/api/things'); diff --git a/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/OtherReceiver.ts b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/OtherReceiver.ts new file mode 100644 index 000000000..7bfa570c2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/OtherReceiver.ts @@ -0,0 +1,2 @@ +declare const other: { doRequest: (url: string) => unknown }; +export const load = () => other.doRequest('/api/things'); diff --git a/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/StoredReference.ts b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/StoredReference.ts new file mode 100644 index 000000000..60a1c1bcc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/StoredReference.ts @@ -0,0 +1,8 @@ +import { doRequest } from '../lib/http'; + +type Thing = { id: number; name: string }; +declare function makeLoader(key: string): typeof doRequest; + +// Specializing the wrapper does not call it. The following call is unrelated. +export const typedRequest = doRequest; +export const load = makeLoader('/api/things'); diff --git a/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/StringOnly.ts b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/StringOnly.ts new file mode 100644 index 000000000..92bf8b16d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/StringOnly.ts @@ -0,0 +1 @@ +export const example = "doRequest('/api/things')"; diff --git a/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/SuperWrapper.ts b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/SuperWrapper.ts new file mode 100644 index 000000000..8f7a7e8c3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/SuperWrapper.ts @@ -0,0 +1,8 @@ +import { doRequest } from '../lib/http'; + +class BaseClient { + doRequest(url: string) { return doRequest(url); } +} +export class Client extends BaseClient { + load() { return super.doRequest('/api/things'); } +} diff --git a/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/TypeOnlyReference.ts b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/TypeOnlyReference.ts new file mode 100644 index 000000000..456926bbb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/TypeOnlyReference.ts @@ -0,0 +1,7 @@ +import { doRequest } from '../lib/http'; + +type Thing = { id: number; name: string }; +declare function makeLoader(key: string): typeof doRequest; + +// The configured wrapper is only a type reference, not the function being called. +export const load: typeof doRequest = makeLoader('/api/things'); diff --git a/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/TypedThings.tsx b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/TypedThings.tsx new file mode 100644 index 000000000..95864a78c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/TypedThings.tsx @@ -0,0 +1,11 @@ +import { doRequest } from '../lib/http'; + +type Thing = { id: number; name: string }; + +export default function TypedThings() { + const loadThings = async () => { + const res = await doRequest('/api/things'); + return res.data; + }; + return null; +} diff --git a/gitnexus/test/integration/resolvers/configurable-fetch-wrapper.test.ts b/gitnexus/test/integration/resolvers/configurable-fetch-wrapper.test.ts index e9730e7eb..4668f9726 100644 --- a/gitnexus/test/integration/resolvers/configurable-fetch-wrapper.test.ts +++ b/gitnexus/test/integration/resolvers/configurable-fetch-wrapper.test.ts @@ -11,8 +11,19 @@ * FETCHES edge. The control run (no config) proves the gap; the configured run * proves the fix. */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import path from 'path'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { + loadParseCache, + saveParseCache, + PARSE_CACHE_VERSION, +} from '../../../src/storage/parse-cache.js'; +import { + getDurableParsedFileDir, + pruneAndSaveDurableParsedFileStore, +} from '../../../src/storage/parsedfile-store.js'; import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; const FIXTURE = path.join(FIXTURES, 'configurable-fetch-wrapper'); @@ -20,14 +31,44 @@ const FIXTURE = path.join(FIXTURES, 'configurable-fetch-wrapper'); describe('Configurable fetch wrapper consumer extraction', () => { let withConfig: PipelineResult; let withoutConfig: PipelineResult; + let warmConfig: PipelineResult; + let cacheDir: string; beforeAll(async () => { - withoutConfig = await runPipelineFromRepo(FIXTURE, () => {}); + cacheDir = await mkdtemp(path.join(tmpdir(), 'fetch-wrapper-cache-')); + const parseCache = await loadParseCache(cacheDir); + withoutConfig = await runPipelineFromRepo(FIXTURE, () => {}, { parseCache }); + await saveParseCache(cacheDir, parseCache); + await pruneAndSaveDurableParsedFileStore( + getDurableParsedFileDir(cacheDir), + PARSE_CACHE_VERSION, + parseCache.usedKeys, + ); + warmConfig = await runPipelineFromRepo(FIXTURE, () => {}, { + parseCache: await loadParseCache(cacheDir), + fetchWrappers: ['doRequest', 'client.doRequest', 'this.doRequest', 'super.doRequest'], + }); withConfig = await runPipelineFromRepo(FIXTURE, () => {}, { - fetchWrappers: ['doRequest'], + fetchWrappers: ['doRequest', 'client.doRequest', 'this.doRequest', 'super.doRequest'], }); }, 60000); + afterAll(async () => { + if (cacheDir) await rm(cacheDir, { recursive: true, force: true }); + }); + + it('reuses cached candidates when wrapper configuration changes', () => { + expect(warmConfig.usedWorkerPool).toBe(false); + expect(warmConfig.reparsedFileCount).toBe(0); + expect(warmConfig.parseCacheHitFileCount).toBeGreaterThan(0); + const consumers = (result: PipelineResult) => + getRelationships(result, 'FETCHES') + .map((edge) => `${edge.sourceFilePath}:${edge.target}`) + .sort(); + expect(consumers(warmConfig)).toEqual(consumers(withConfig)); + expect(consumers(warmConfig).length).toBeGreaterThan(0); + }); + it('does NOT trace an axios-based wrapper without configuration (the gap)', () => { const edges = getRelationships(withoutConfig, 'FETCHES'); const thingsEdge = edges.find((e) => e.target === '/api/things'); @@ -42,9 +83,44 @@ describe('Configurable fetch wrapper consumer extraction', () => { expect(thingsEdge).toBeDefined(); }); + it('traces a configured wrapper called with type arguments', () => { + const edges = getRelationships(withConfig, 'FETCHES'); + const thingsEdge = edges.find( + (e) => e.sourceFilePath.includes('TypedThings') && e.target === '/api/things', + ); + expect(thingsEdge).toBeDefined(); + }); + + it.each([ + 'NestedTypes.ts', + 'ObjectTypes.ts', + 'FunctionTypes.ts', + 'LiteralTypes.ts', + 'CommentedCall.ts', + 'InstanceWrapper.ts', + 'SuperWrapper.ts', + ])('traces supported type arguments in %s', (fileName) => { + const edges = getRelationships(withConfig, 'FETCHES'); + expect( + edges.some((e) => e.sourceFilePath.endsWith(fileName) && e.target === '/api/things'), + ).toBe(true); + }); + + it.each([ + 'TypeOnlyReference.ts', + 'StoredReference.ts', + 'CommentOnly.ts', + 'StringOnly.ts', + 'ConcatenatedUrl.ts', + 'OtherReceiver.ts', + ])('does not trace a wrapper consumer in %s', (fileName) => { + const edges = getRelationships(withConfig, 'FETCHES'); + expect(edges.filter((e) => e.sourceFilePath.endsWith(fileName))).toEqual([]); + }); + it('does NOT match a configured bare name inside a longer non-ASCII identifier (#1852 review F10)', () => { // Accented.tsx calls `cafédoRequest('/api/things')` — `doRequest` preceded by - // the non-ASCII letter `é`. The Unicode-aware lookbehind must reject it. + // the non-ASCII letter `é`. The exact callee name must reject it. const edges = getRelationships(withConfig, 'FETCHES'); const spurious = edges.find( (e) => e.sourceFilePath.includes('Accented') && e.target === '/api/things', diff --git a/gitnexus/test/unit/analyze-embedding-endpoint-flags.test.ts b/gitnexus/test/unit/analyze-embedding-endpoint-flags.test.ts index ee1a8b22c..53ead617c 100644 --- a/gitnexus/test/unit/analyze-embedding-endpoint-flags.test.ts +++ b/gitnexus/test/unit/analyze-embedding-endpoint-flags.test.ts @@ -6,6 +6,13 @@ vi.mock('../../src/core/run-analyze.js', () => ({ runFullAnalysis: runFullAnalysisMock, })); +// Flag validation must not install the optional runtime from the npm registry. +// Runtime readiness and installation have their own analyze-local-embedding-error tests. +vi.mock('../../src/core/embeddings/runtime-support.js', async (importOriginal) => ({ + ...(await importOriginal()), + assessLocalEmbeddingRuntime: () => ({ status: 'ready' }), +})); + vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ closeLbug: vi.fn(async () => undefined), closeLbugBeforeExit: vi.fn(async () => undefined), diff --git a/gitnexus/test/unit/fetch-wrapper-calls.test.ts b/gitnexus/test/unit/fetch-wrapper-calls.test.ts new file mode 100644 index 000000000..b2c290322 --- /dev/null +++ b/gitnexus/test/unit/fetch-wrapper-calls.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import Parser from 'tree-sitter'; +import JavaScript from 'tree-sitter-javascript'; +import TypeScript from 'tree-sitter-typescript'; +import { extractFetchWrapperCalls } from '../../src/core/ingestion/route-extractors/fetch-wrapper.js'; + +for (const [name, grammar] of [ + ['JavaScript', JavaScript], + ['TypeScript', TypeScript.typescript], + ['TSX', TypeScript.tsx], +] as const) { + describe(`${name} fetch wrapper calls`, () => { + const parser = new Parser(); + parser.setLanguage(grammar); + const extract = (source: string, lineOffset = 0) => + extractFetchWrapperCalls(parser.parse(source), 'consumer.ts', lineOffset); + + it('preserves exact callee names and source lines, including comments and optional calls', () => { + expect( + extract( + ` + doRequest('/api/one'); + client /* receiver */ . doRequest(/* url */ '/api/two'); + api.client?.doRequest?.('/api/three'); + cafédoRequest('/api/four'); + `, + 10, + ), + ).toEqual([ + { + filePath: 'consumer.ts', + functionName: 'doRequest', + fetchURL: '/api/one', + lineNumber: 12, + }, + { + filePath: 'consumer.ts', + functionName: 'client.doRequest', + fetchURL: '/api/two', + lineNumber: 13, + }, + { + filePath: 'consumer.ts', + functionName: 'api.client.doRequest', + fetchURL: '/api/three', + lineNumber: 14, + }, + { + filePath: 'consumer.ts', + functionName: 'cafédoRequest', + fetchURL: '/api/four', + lineNumber: 15, + }, + ]); + }); + + it('preserves configured instance and inherited wrappers', () => { + expect( + extract(`class Client extends Base { + load() { + this.doRequest('/api/one'); + this.http.doRequest('/api/two'); + super.doRequest('/api/three'); + } + }`).map((call) => call.functionName), + ).toEqual(['this.doRequest', 'this.http.doRequest', 'super.doRequest']); + }); + + it('keeps template substitutions for route normalization', () => { + expect(extract('doRequest(`/api/users/${id}`)')).toMatchObject([ + { functionName: 'doRequest', fetchURL: '/api/users/${id}' }, + ]); + }); + + it.each([ + '// doRequest("/api/things")', + '/* doRequest("/api/things") */', + '`doRequest("/api/things")`', + 'doRequest("/api/things" + suffix)', + 'doRequest(url, "/api/things")', + 'doRequest(...["/api/things"])', + 'new doRequest("/api/things")', + 'client["doRequest"]("/api/things")', + 'getClient().doRequest("/api/things")', + 'doRequest("https://example.com/api/things")', + ])('does not invent a candidate for %s', (source) => { + expect(extract(source)).toEqual([]); + }); + + if (name !== 'JavaScript') { + it.each([ + 'Array<{ result: Promise }>', + '(value: string) => Promise<{ kind: "thing" }>', + '{ compare: ">"; callback: () => void }', + '[string, number]', + 'T extends string ? Array : never', + ])('accepts type arguments %s', (types) => { + expect(extract(`doRequest<${types}>('/api/things')`)).toMatchObject([ + { functionName: 'doRequest', fetchURL: '/api/things' }, + ]); + }); + + it('handles awaited generic calls in the installed grammar', () => { + expect( + extract(`async function load() { + await /* request */ doRequest('/api/one'); + await client.doRequest<() => void>('/api/two'); + }`).map((call) => [call.functionName, call.fetchURL]), + ).toEqual([ + ['doRequest', '/api/one'], + ['client.doRequest', '/api/two'], + ]); + }); + + it('captures only the actual callee after generic references', () => { + expect( + extract(` + const typed = doRequest; + const load: typeof doRequest = makeLoader('/api/things'); + `).map((call) => call.functionName), + ).toEqual(['makeLoader']); + }); + } + }); +} diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 9526547e1..a70c7d5d5 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -323,8 +323,9 @@ describe('PARSE_CACHE_VERSION', () => { // Moved 138 -> 139 for provider graph/capture fixes exercised by real rename fixtures (#3486). // Moved 139 -> 140 for Ruby accessor ownership captures used by rename (#3557). // Moved 140 -> 141 for detached Go ownership captures used by rename (#3559). - it('pins SCHEMA_BUMP to 141 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)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(141); + // Moved 141 -> 142 to combine Go ownership with fetch-wrapper candidates (#3567). + it('pins SCHEMA_BUMP to 142 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)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(142); 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 @@ -334,7 +335,7 @@ describe('PARSE_CACHE_VERSION', () => { 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, - 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, + 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } diff --git a/gitnexus/test/unit/result-merge.test.ts b/gitnexus/test/unit/result-merge.test.ts index ebefec081..32957849e 100644 --- a/gitnexus/test/unit/result-merge.test.ts +++ b/gitnexus/test/unit/result-merge.test.ts @@ -33,6 +33,17 @@ function emptyResult(): ParseWorkerResult { } describe('mergeResult', () => { + 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 }; + const second = { filePath: 'b.ts', functionName: 'client.get', fetchURL: '/b', lineNumber: 2 }; + mergeResult(target, { ...emptyResult(), fetchWrapperCalls: [first] }); + mergeResult(target, emptyResult()); + mergeResult(target, { ...emptyResult(), fetchWrapperCalls: [second] }); + expect(target.fetchWrapperCalls).toEqual([first, second]); + expect(target.fetchCalls).toEqual([]); + }); + it('sums every CFG skip bucket by language across worker sub-batches', () => { const target = emptyResult(); const first = {