diff --git a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts index b4b034eb8..c9b614099 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts @@ -574,8 +574,9 @@ export const routesPhase: PipelinePhase = { if (routeRegistry.size > 0 && wrapperCalls.length > 0) { const wrappers = buildFetchWrapperIndex(allFetchWrapperDefs ?? [], configuredWrappers); for (const call of wrapperCalls) { - if (wrappers.isWrapperCall(call)) allFetchCalls.push(call); - else unprovenCalls.push(call); + const urls = wrappers.urlsForCall(call); + if (urls.length === 0 && !wrappers.isWrapperCall(call)) unprovenCalls.push(call); + for (const fetchURL of urls) allFetchCalls.push({ ...call, fetchURL }); } } diff --git a/gitnexus/src/core/ingestion/route-extractors/fetch-url-expression.ts b/gitnexus/src/core/ingestion/route-extractors/fetch-url-expression.ts new file mode 100644 index 000000000..bb6ee5197 --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/fetch-url-expression.ts @@ -0,0 +1,149 @@ +import type Parser from 'tree-sitter'; +import type { ForwardedCallee } from './fetch-wrapper.js'; + +type Node = Parser.SyntaxNode; + +/** Serializable string operations; no source code is evaluated. */ +export type FetchUrlExpression = + | string + | { kind: 'parameter' } + | { kind: 'concat'; parts: FetchUrlExpression[] } + | { kind: 'call'; callee: ForwardedCallee; argument: FetchUrlExpression } + | { + kind: 'leading-slash'; + value: FetchUrlExpression; + consequence: FetchUrlExpression; + alternative: FetchUrlExpression; + }; + +export interface FetchUrlArgument { + callee: ForwardedCallee; + argument: FetchUrlExpression; + fetch: boolean; +} + +/** Follow only stable lexical bindings and supported string syntax. */ +export function readFetchUrlExpression( + expression: Node | null | undefined, + parameter: string | undefined, + binding: (node: Node) => string | undefined, + valueOf: (binding: string) => Node | undefined, + targetOf: (node: Node | null) => ForwardedCallee | undefined, + mayBeBase: (node: Node) => boolean, +): FetchUrlExpression | undefined { + const active = new Set(); + let remaining = 256; + const read = (node: Node | null | undefined, depth = 0): FetchUrlExpression | undefined => { + if (!node || depth > 32 || --remaining < 0) return undefined; + const next = (child: Node | null | undefined) => read(child, depth + 1); + if ( + [ + 'parenthesized_expression', + 'as_expression', + 'satisfies_expression', + 'non_null_expression', + ].includes(node.type) + ) + return next(node.namedChildren.find((child) => child.type !== 'comment')); + if (node.type === 'identifier') { + const id = binding(node); + if (!id) return undefined; + if (id === parameter) return { kind: 'parameter' }; + if (active.has(id)) return undefined; + active.add(id); + const result = next(valueOf(id)); + active.delete(id); + return result; + } + // Escapes require runtime decoding; do not mistake their source spelling for a path. + if (node.type === 'string') + return node.text.includes('\\') ? undefined : node.text.slice(1, -1); + if (node.type === 'binary_expression') { + if (node.childForFieldName('operator')?.text !== '+') return undefined; + const left = next(node.childForFieldName('left')); + const right = next(node.childForFieldName('right')); + return left === undefined || right === undefined + ? undefined + : { kind: 'concat', parts: [left, right] }; + } + if (node.type === 'template_string') { + const parts: FetchUrlExpression[] = []; + for (const child of node.namedChildren) { + if (child.type === 'string_fragment') parts.push(child.text); + else if (child.type === 'template_substitution') { + const inner = child.namedChildren.find((item) => item.type !== 'comment'); + const value = next(inner); + if (value !== undefined) parts.push(value); + // Preserve the existing leading base-URL convention. An unknown + // interpolation anywhere after the base cannot prove a static URL. + else if (parts.length === 0 && inner && mayBeBase(inner)) parts.push('${base}'); + else return undefined; + } else if (child.type !== 'comment') return undefined; + } + return { kind: 'concat', parts }; + } + if (node.type === 'call_expression') { + const callee = targetOf(node.childForFieldName('function')); + const argument = next( + node + .childForFieldName('arguments') + ?.namedChildren.find((child) => child.type !== 'comment'), + ); + return callee && argument !== undefined ? { kind: 'call', callee, argument } : undefined; + } + if (node.type === 'ternary_expression') { + const condition = node.childForFieldName('condition'); + const callee = condition?.childForFieldName('function'); + const args = condition + ?.childForFieldName('arguments') + ?.namedChildren.filter((child) => child.type !== 'comment'); + if ( + condition?.type !== 'call_expression' || + callee?.type !== 'member_expression' || + callee.childForFieldName('property')?.text !== 'startsWith' || + args?.length !== 1 || + next(args[0]) !== '/' + ) + return undefined; + const value = next(callee.childForFieldName('object')); + const consequence = next(node.childForFieldName('consequence')); + const alternative = next(node.childForFieldName('alternative')); + return value === undefined || consequence === undefined || alternative === undefined + ? undefined + : { kind: 'leading-slash', value, consequence, alternative }; + } + return undefined; + }; + return read(expression); +} + +/** Substitute a caller's URL, resolving only a proven URL-building function. */ +export function evaluateFetchUrlExpression( + expression: FetchUrlExpression, + parameter: string, + returned: (callee: ForwardedCallee, argument: string) => string | undefined, + depth = 0, +): string | undefined { + if (depth > 32) return undefined; + if (typeof expression === 'string') return expression; + const evaluate = (value: FetchUrlExpression) => + evaluateFetchUrlExpression(value, parameter, returned, depth + 1); + if (expression.kind === 'parameter') return parameter; + if (expression.kind === 'call') { + const argument = evaluate(expression.argument); + return argument === undefined ? undefined : returned(expression.callee, argument); + } + if (expression.kind === 'leading-slash') { + const value = evaluate(expression.value); + return value === undefined || value.startsWith('${') + ? undefined + : evaluate(value.startsWith('/') ? expression.consequence : expression.alternative); + } + let result = ''; + for (const part of expression.parts) { + const value = evaluate(part); + if (value === undefined || result.length + value.length > 8192) return undefined; + result += value; + } + return result; +} diff --git a/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper-index.ts b/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper-index.ts index 45f0e76ca..cbcafb9ea 100644 --- a/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper-index.ts +++ b/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper-index.ts @@ -1,5 +1,11 @@ import type { FetchWrapperDef } from '../workers/parse-worker.js'; -import type { CalleeTarget, FetchWrapperCall, FetchWrapperReexport } from './fetch-wrapper.js'; +import type { + CalleeTarget, + FetchWrapperCall, + FetchWrapperReexport, + ForwardedCallee, +} from './fetch-wrapper.js'; +import { evaluateFetchUrlExpression, type FetchUrlExpression } from './fetch-url-expression.js'; interface NameGroup { remainingCarriers: number; @@ -19,6 +25,7 @@ export function buildFetchWrapperIndex( configuredNames: readonly string[] = [], ): { isWrapperCall(call: Pick): boolean; + urlsForCall(call: FetchWrapperCall): string[]; } { const configured = new Set(configuredNames); const seen = new Set(); @@ -93,7 +100,10 @@ export function buildFetchWrapperIndex( for (let index = 0; index < defs.length; index++) { const definition = defs[index]; if (definition.forwardsUrl) activate(index); - for (const forwarded of definition.forwardsTo) { + for (const forwarded of [ + ...definition.forwardsTo, + ...(definition.urlArguments ?? []).map((flow) => flow.callee), + ]) { if (configured.has(forwarded.callee)) { activate(index); } else if (forwarded.target.kind === 'member') { @@ -117,12 +127,148 @@ export function buildFetchWrapperIndex( } } + const isWrapperCall = ({ + filePath, + functionName, + target, + }: Pick) => { + if (configured.has(functionName)) return true; + if (target.kind === 'member') return names.get(functionName)?.hasWrapper ?? false; + const index = targetDefinition(filePath, functionName, target); + return index !== undefined && index !== null && (defs[index].seed || carriers.has(index)); + }; + // Collapse identity forwarding iteratively so long wrapper chains retain + // the carrier pass's linear behavior without consuming transformation depth. + const identityRoots = new Map(); + const identityRoot = (start: number): number => { + const path: number[] = []; + const visiting = new Set(); + let index = start; + while (!identityRoots.has(index)) { + if (visiting.has(index)) return start; + visiting.add(index); + path.push(index); + const flows = defs[index].urlArguments; + const flow = flows?.length === 1 ? flows[0] : undefined; + if ( + !flow || + flow.fetch || + typeof flow.argument === 'string' || + flow.argument.kind !== 'parameter' || + (flow.callee.target.kind !== 'binding' && flow.callee.target.kind !== 'resolved-import') + ) + break; + const target = targetDefinition(defs[index].filePath, flow.callee.callee, flow.callee.target); + if ( + target === undefined || + target === null || + (!defs[target].seed && !carriers.has(target)) || + configured.has(defs[index].functionName) + ) + break; + index = target; + } + const root = identityRoots.get(index) ?? index; + for (const entry of path) identityRoots.set(entry, root); + return root; + }; + const urlsForDefinition = ( + index: number, + input: string, + active: Set, + budget: { remaining: number }, + ): string[] => { + index = identityRoot(index); + if (active.has(index) || active.size >= 32 || --budget.remaining < 0) return []; + const definition = defs[index]; + // Existing/configured wrappers without string-operation evidence retain + // their literal path. New facts with an empty list explicitly prove none. + if (definition.urlArguments === undefined) return [input]; + active.add(index); + const evaluate = (expression: FetchUrlExpression): string | undefined => + evaluateFetchUrlExpression(expression, input, (callee, argument) => + returnedUrl(index, callee, argument, active, budget), + ); + const urls = new Set(); + for (const flow of definition.urlArguments) { + if (--budget.remaining < 0) break; + const argument = evaluate(flow.argument); + if (argument === undefined) continue; + if (flow.fetch) urls.add(argument); + else if (flow.callee.target.kind === 'member') { + const group = names.get(flow.callee.callee); + if ( + configured.has(flow.callee.callee) || + (group?.hasWrapper && group.remainingCarriers === 0) + ) + urls.add(argument); + } else { + const target = targetDefinition( + definition.filePath, + flow.callee.callee, + flow.callee.target, + ); + if ( + target !== undefined && + target !== null && + (defs[target].seed || carriers.has(target)) + ) { + if ( + flow.callee.target.kind === 'binding' || + flow.callee.target.kind === 'resolved-import' + ) + for (const url of urlsForDefinition(target, argument, active, budget)) urls.add(url); + else urls.add(argument); // Legacy name-only classification cannot prove a transformation. + } else if (configured.has(flow.callee.callee)) urls.add(argument); + } + } + active.delete(index); + return urls.size === 0 && configured.has(definition.functionName) ? [input] : [...urls]; + }; + const returnedUrl = ( + from: number, + callee: ForwardedCallee, + input: string, + active: Set, + budget: { remaining: number }, + ): string | undefined => { + if (callee.target.kind !== 'binding' && callee.target.kind !== 'resolved-import') + return undefined; + const target = targetDefinition(defs[from].filePath, callee.callee, callee.target); + if ( + target === undefined || + target === null || + active.has(target) || + active.size >= 32 || + --budget.remaining < 0 + ) + return undefined; + const expression = defs[target].urlReturn; + if (expression === undefined) return undefined; + active.add(target); + const result = evaluateFetchUrlExpression(expression, input, (nested, value) => + returnedUrl(target, nested, value, active, budget), + ); + active.delete(target); + return result; + }; return { - isWrapperCall: ({ filePath, functionName, target }) => { - if (configured.has(functionName)) return true; - if (target.kind === 'member') return names.get(functionName)?.hasWrapper ?? false; - const index = targetDefinition(filePath, functionName, target); - return index !== undefined && index !== null && (defs[index].seed || carriers.has(index)); + isWrapperCall, + urlsForCall: (call) => { + if (!isWrapperCall(call)) return []; + const index = targetDefinition(call.filePath, call.functionName, call.target); + // Configuration explicitly establishes an opaque HTTP sink, including + // custom clients whose internals are outside auto-detection's evidence. + if ( + configured.has(call.functionName) && + (index === undefined || index === null || (!defs[index].seed && !carriers.has(index))) + ) + return [call.fetchURL]; + return index === undefined || + index === null || + (call.target.kind !== 'binding' && call.target.kind !== 'resolved-import') + ? [call.fetchURL] + : urlsForDefinition(index, call.fetchURL, new Set(), { remaining: 256 }); }, }; } @@ -273,17 +419,59 @@ export function resolveFetchWrapperTargets( } ); }; + const resolveExpression = ( + filePath: string, + expression: FetchUrlExpression, + ): FetchUrlExpression => { + if (typeof expression === 'string' || expression.kind === 'parameter') return expression; + if (expression.kind === 'call') + return { + ...expression, + callee: { ...expression.callee, target: resolve(filePath, expression.callee.target) }, + argument: resolveExpression(filePath, expression.argument), + }; + if (expression.kind === 'concat') + return { + ...expression, + parts: expression.parts.map((part) => resolveExpression(filePath, part)), + }; + return { + ...expression, + value: resolveExpression(filePath, expression.value), + consequence: resolveExpression(filePath, expression.consequence), + alternative: resolveExpression(filePath, expression.alternative), + }; + }; // Aggregates share facts with the parse cache. Keep its raw specifiers intact // so a warm parse resolves imports against the current project configuration. return { definitions: definitions.map((definition) => - definition.forwardsTo.some((forwarded) => forwarded.target.kind === 'import') + definition.forwardsTo.some((forwarded) => forwarded.target.kind === 'import') || + definition.urlArguments !== undefined || + definition.urlReturn !== undefined ? { ...definition, forwardsTo: definition.forwardsTo.map((forwarded) => ({ ...forwarded, target: resolve(definition.filePath, forwarded.target), })), + ...(definition.urlArguments !== undefined + ? { + urlArguments: definition.urlArguments.map((flow) => ({ + ...flow, + callee: { + ...flow.callee, + target: resolve(definition.filePath, flow.callee.target), + }, + argument: resolveExpression(definition.filePath, flow.argument), + })), + } + : {}), + ...(definition.urlReturn !== undefined + ? { + urlReturn: resolveExpression(definition.filePath, definition.urlReturn), + } + : {}), } : definition, ), diff --git a/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper.ts b/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper.ts index 556382279..d0ea2bc19 100644 --- a/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper.ts +++ b/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper.ts @@ -3,6 +3,11 @@ import type Parser from 'tree-sitter'; import type { ParsedFile } from 'gitnexus-shared'; import type { ExtractedFetchCall } from '../workers/parse-worker.js'; import { stripLeadingTemplatePrefix } from './route-path.js'; +import { + readFetchUrlExpression, + type FetchUrlArgument, + type FetchUrlExpression, +} from './fetch-url-expression.js'; import { bindingKey, createLexicalBindings, @@ -42,6 +47,10 @@ export interface FetchWrapperFacts { seed: boolean; forwardsUrl: boolean; forwardsTo: ForwardedCallee[]; + /** Proven direct URL operations, retained through worker/cache serialization. */ + urlArguments?: FetchUrlArgument[]; + /** A single returned string expression, for helpers such as getApiUrl(path). */ + urlReturn?: FetchUrlExpression; } /** A module export relevant to barrel resolution. */ export interface FetchWrapperReexport { @@ -115,6 +124,7 @@ const INSPECTED_TYPES: ReadonlySet = new Set([ 'export_statement', 'export_specifier', 'call_expression', + 'return_statement', ]); /** Fetch implementations imported from packages, by module specifier and export name. * A namespace or `require` value is recorded under the specifier itself. */ @@ -130,6 +140,7 @@ interface Region { node: Node; edges: Set; calls: Node[]; + returns: Node[]; } /** A visited ancestor of the cursor. */ interface Frame { @@ -245,6 +256,7 @@ export function createFetchWrapperContext( const aliases = new Set(); const invalid = new Set(); const initialized = new Map(); + const bindingValues = new Map(); const parameters = new Set(); const exports = new Map>(); const boundNames = new Set(); @@ -262,6 +274,7 @@ export function createFetchWrapperContext( // Native parent lookup descends from the tree root, so the walk records the // ancestor facts that later queries need. const storedNames = new Map(); + const directReturns = new Set(); const defaultExports = new Set(); const declarators = new Map(); const region = (node: Node): Region => { @@ -269,6 +282,7 @@ export function createFetchWrapperContext( node, edges: new Set(), calls: [] as Node[], + returns: [] as Node[], }; regions.push(result); return result; @@ -303,6 +317,7 @@ export function createFetchWrapperContext( if (!value) continue; initialized.set(id, (initialized.get(id) ?? 0) + 1); if (!parameter && parameters.has(id)) invalid.add(id); + if (!parameter && pattern?.type === 'identifier') bindingValues.set(id, value); if (pattern?.type === 'identifier' && namesFetch(value)) aliases.add(id); } }; @@ -555,6 +570,10 @@ export function createFetchWrapperContext( ) { const name = node.childForFieldName('name'); if (name) exported(name, node.childForFieldName('alias')?.text ?? name.text); + } else if (type === 'return_statement') { + active.returns.push(node); + if (parent.type === 'statement_block' && frames[frames.length - 2]?.children === 'callable') + directReturns.add(node.id); } else if (type === 'call_expression' && !node.hasError) { active.calls.push(node); allCalls.push(node); @@ -775,6 +794,77 @@ export function createFetchWrapperContext( // A rebound binding no longer names this function, so it proves no wrapper // across files either; its names are kept so they still collide. const rebound = !!id && invalid.has(id); + const readUrl = (node: Node | null | undefined) => + readFetchUrlExpression( + node, + url, + (value) => { + const key = stableBinding(value); + return key && !invalid.has(key) ? key : undefined; + }, + (key) => bindingValues.get(key), + (callee) => { + const name = calleeName(callee); + return name ? { callee: name, target: targetOf(callee) } : undefined; + }, + (value) => { + if (value.type !== 'identifier') return false; + const key = stableBinding(value); + return bindings.isGlobal(value, value.text) || (!!key && !invalid.has(key)); + }, + ); + const dependsOnParameter = (expression: FetchUrlExpression): boolean => { + if (typeof expression === 'string') return false; + switch (expression.kind) { + case 'parameter': + return true; + case 'concat': + return expression.parts.some(dependsOnParameter); + case 'call': + return dependsOnParameter(expression.argument); + case 'leading-slash': + return ( + dependsOnParameter(expression.value) || + dependsOnParameter(expression.consequence) || + dependsOnParameter(expression.alternative) + ); + } + }; + const urlArguments: FetchUrlArgument[] = []; + const recordUrl = ( + callee: Node | null, + argumentNode: Node | null | undefined, + fetch: boolean, + ) => { + const name = calleeName(callee); + if (!name) return; + const argument = readUrl(argumentNode); + if (argument !== undefined && dependsOnParameter(argument)) + urlArguments.push({ callee: { callee: name, target: targetOf(callee) }, argument, fetch }); + }; + for (const call of root.calls) { + const callee = unwrap(call.childForFieldName('function')); + const name = calleeName(callee); + if (!name) continue; + const alias = callee?.type === 'identifier' ? stableBinding(callee) : undefined; + const fetch = platformFetch(callee) || !!(alias && aliases.has(alias)); + recordUrl(callee, firstNamed(call.childForFieldName('arguments')), fetch); + } + for (const call of url ? (parameterCalls.get(url) ?? []) : []) + if (call.region !== root && reaches(call.region)) + recordUrl(call.callee, call.argument, call.seed); + const body = fn.childForFieldName('body'); + const explicitReturn = root.returns.length === 1 ? root.returns[0] : undefined; + const returnNode = + body?.type !== 'statement_block' + ? body + : explicitReturn && directReturns.has(explicitReturn.id) + ? firstNamed(explicitReturn) + : undefined; + const returnsString = + !fn.type.startsWith('generator_') && + !fn.children.some((child) => child.type === 'async' || child.type === '*'); + const urlReturn = !rebound && url && returnsString ? readUrl(returnNode) : undefined; const result: FetchWrapperFacts = { definitionId: `${filePath}:${fn.startPosition.row + lineOffset + 1}:${fn.startPosition.column}:${fn.endPosition.row + lineOffset + 1}:${fn.endPosition.column}`, ...(id ? { bindingId: id } : {}), @@ -792,6 +882,8 @@ export function createFetchWrapperContext( seed: seed && !rebound, forwardsUrl: forwardsUrl && !rebound, forwardsTo: rebound ? [] : [...forwardsTo.values()], + ...(!rebound && (seed || urlArguments.length > 0) ? { urlArguments } : {}), + ...(urlReturn !== undefined ? { urlReturn } : {}), }; factsCache.set(key, result); return result; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 71aec66ae..81d543fa9 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -3214,11 +3214,14 @@ const processFileGroup = ( parsedFile, lineOffset, ); - // Keep possible wrappers and the functions other files can name; a - // nested non-wrapper can neither be imported nor carry a URL. if ( facts && - (facts.seed || facts.forwardsUrl || facts.forwardsTo.length > 0 || facts.moduleScope) + (facts.seed || + facts.forwardsUrl || + facts.forwardsTo.length > 0 || + facts.moduleScope || + facts.urlReturn !== undefined || + (facts.urlArguments?.length ?? 0) > 0) ) wrapperDefs.push({ filePath: file.path, functionName: nodeName, ...facts }); } catch (err) { diff --git a/gitnexus/test/integration/resolvers/fetch-wrapper-consumers.test.ts b/gitnexus/test/integration/resolvers/fetch-wrapper-consumers.test.ts index a72641483..8653ad058 100644 --- a/gitnexus/test/integration/resolvers/fetch-wrapper-consumers.test.ts +++ b/gitnexus/test/integration/resolvers/fetch-wrapper-consumers.test.ts @@ -1,13 +1,24 @@ -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 { runtimeParseIdentity } from '../../helpers/runtime-parse-identity.js'; +import { loadParseCache, saveParseCache } from '../../../src/storage/parse-cache.js'; +import { + getDurableParsedFileDir, + pruneAndSaveDurableParsedFileStore, +} from '../../../src/storage/parsedfile-store.js'; import { FIXTURES, getRelationships, getNodesByLabel, runPipelineFromRepo, + writeFixtureRepo, type PipelineResult, } from './helpers.js'; +const parseIdentity = await runtimeParseIdentity(); + describe('Fetch wrapper consumer FETCHES edge extraction', () => { let result: PipelineResult; @@ -43,6 +54,122 @@ describe('Fetch wrapper consumer FETCHES edge extraction', () => { }); }); +describe('Mount-prefixed wrapper consumers (#2858)', () => { + let root: string; + let cacheDir: string; + let result: PipelineResult; + + beforeAll(async () => { + root = await mkdtemp(path.join(tmpdir(), 'consumer-mount-prefix-')); + writeFixtureRepo(root, { + 'backend/main.py': `from fastapi import FastAPI +app = FastAPI() +@app.get('/api/models') +def models(): return [] +@app.get('/api/agents/{id}') +def agent(id: str): return {} +@app.get('/models') +def other_models(): return [] +@app.get('/unsupported') +def unsupported(): return [] +`, + 'web/config.ts': `declare const VITE_API_BASE_URL: string; +export function getApiUrl(path: string) { + const base = VITE_API_BASE_URL || ''; + const prefix = '/api'; + const normalized = path.startsWith('/') ? path : '/' + path; + return \`\${base}\${prefix}\${normalized}\`; +}`, + 'web/request.ts': `import { getApiUrl } from './config'; +export function request(path: string) { + const url = getApiUrl(path); + return fetch(url); +} +export function direct(path: string) { return fetch('/api' + path); } +export function versioned(path: string) { return fetch(\`/api\${path}\`); } +export function forward(path: string) { return direct(path); } +`, + 'web/clients/models.ts': `import { request } from '../request'; +export function models() { return request('/models'); }`, + 'web/clients/direct.ts': `import { direct } from '../request'; +export function models() { return direct('/models'); }`, + 'web/clients/agents.ts': `import { versioned } from '../request'; +export function agent(id: string) { return versioned(\`/agents/\${id}\`); }`, + 'web/clients/nested.ts': `import { forward } from '../request'; +export function models() { + function request(path: string) { forward(path + ''); return 1; } + return request('/models'); +}`, + 'web/clients/forward.ts': `import { forward } from '../request'; +export function models() { return forward('/models'); }`, + 'web/unsupported.ts': `function send(path: string) { return fetch(path.toUpperCase()); } +send('/unsupported');`, + 'web/decoy.ts': `function request(channel: string) { return channel; } +request('/models'); +const cache = new Map(); cache.get('/models');`, + }); + cacheDir = await mkdtemp(path.join(tmpdir(), 'consumer-mount-cache-')); + const parseCache = await loadParseCache(cacheDir, parseIdentity, cacheDir); + result = await runPipelineFromRepo(root, () => {}, { parseCache }); + const savedKeys = await saveParseCache(cacheDir, parseCache); + await pruneAndSaveDurableParsedFileStore( + getDurableParsedFileDir(cacheDir), + parseIdentity.base, + new Set(savedKeys), + ); + }, 60_000); + + afterAll(async () => { + if (root) await rm(root, { recursive: true, force: true }); + if (cacheDir) await rm(cacheDir, { recursive: true, force: true }); + }); + + it('links the runtime URL through literals, forwarding, and an imported URL builder', () => { + expect( + getRelationships(result, 'FETCHES') + .filter((edge) => edge.sourceFilePath.startsWith('web/clients/')) + .map((edge) => `${edge.sourceFilePath} -> ${edge.target}`) + .sort(), + ).toEqual([ + 'web/clients/agents.ts -> /api/agents/{id}', + 'web/clients/direct.ts -> /api/models', + 'web/clients/forward.ts -> /api/models', + 'web/clients/models.ts -> /api/models', + 'web/clients/nested.ts -> /api/models', + ]); + }); + + it('does not link the caller literal to an unrelated unprefixed route', () => { + expect(getRelationships(result, 'FETCHES').filter((edge) => edge.target === '/models')).toEqual( + [], + ); + }); + + it('does not count an unknown transformed destination as the raw route consumer', () => { + const route = [...result.graph.iterNodes()].find( + (node) => node.label === 'Route' && node.properties.name === '/unsupported', + ); + expect(route).toBeDefined(); + expect(route?.properties.unverifiedConsumers).toBeUndefined(); + expect( + getRelationships(result, 'FETCHES').filter((edge) => edge.target === '/unsupported'), + ).toEqual([]); + }); + + it('preserves composed consumers when replaying a persisted parse cache', async () => { + const warm = await runPipelineFromRepo(root, () => {}, { + parseCache: await loadParseCache(cacheDir, parseIdentity, cacheDir), + }); + expect(warm.reparsedFileCount).toBe(0); + expect(warm.parseCacheHitFileCount).toBeGreaterThan(0); + const consumers = (value: PipelineResult) => + getRelationships(value, 'FETCHES') + .map((edge) => `${edge.sourceFilePath} -> ${edge.target}`) + .sort(); + expect(consumers(warm)).toEqual(consumers(result)); + }, 60_000); +}); + // GitNexus's own web client (#3488): `fetchWithTimeout(`${_backendUrl}/api/repos`)` // reaches fetch through `resilientFetch` and a local `fetchImpl` alias, and the // URL starts with a base-URL binding. diff --git a/gitnexus/test/unit/fetch-wrapper-calls.test.ts b/gitnexus/test/unit/fetch-wrapper-calls.test.ts index d901487d3..77b5d5181 100644 --- a/gitnexus/test/unit/fetch-wrapper-calls.test.ts +++ b/gitnexus/test/unit/fetch-wrapper-calls.test.ts @@ -3,6 +3,8 @@ import Parser from 'tree-sitter'; import JavaScript from 'tree-sitter-javascript'; import TypeScript from 'tree-sitter-typescript'; import { createFetchWrapperContext } from '../../src/core/ingestion/route-extractors/fetch-wrapper.js'; +import { buildFetchWrapperIndex } from '../../src/core/ingestion/route-extractors/fetch-wrapper-index.js'; +import { normalizeFetchURL } from '../../src/core/ingestion/route-extractors/nextjs.js'; import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js'; import { @@ -43,6 +45,122 @@ for (const [name, grammar, provider] of grammars) { describe(`${name} fetch wrapper calls`, () => { const { context, extract } = harness(grammar, provider); + it.each([ + { + shape: 'a parameter-independent refresh alongside a URL fetch', + source: `function request(path) { fetch('/api/refresh'); return fetch('/api' + path); }`, + expected: ['/api/models'], + }, + { + shape: 'a literal-only call that must not activate a URL carrier', + source: `function send(path) { return fetch(path); } + function request(event) { return send('/api/telemetry'); }`, + expected: [], + }, + { + shape: 'an unknown-leading caller URL through slash normalization', + source: `function build(path) { return '/api' + (path.startsWith('/') ? path : '/' + path); } + function request(path) { return fetch(build(path)); }`, + input: '${BASE}/models', + expected: [], + }, + { + shape: 'a conditional-only URL-builder return with fallthrough', + source: `function build(path) { if (runtime()) return '/api' + path; } + function request(path) { return fetch(build(path)); }`, + expected: [], + }, + { + shape: 'a URL builder with an early bare return', + source: `function build(path) { if (runtime()) return; return '/api' + path; } + function request(path) { return fetch(build(path)); }`, + expected: [], + }, + { + shape: 'literal concatenation', + source: `function request(path) { return fetch('/api' + path); }`, + expected: ['/api/models'], + }, + { + shape: 'a stable local prefix and URL alias', + source: `function request(path) { const prefix = '/api'; const url = prefix + path; return fetch(url); }`, + expected: ['/api/models'], + }, + { + shape: 'a returned URL through a helper chain', + source: `function prefix(path) { return '/api' + path; } + function build(path) { return prefix(path); } + function request(path) { const url = build(path); return fetch(url); }`, + expected: ['/api/models'], + }, + { + shape: 'a transformed argument forwarded to another wrapper', + source: `function send(path) { return fetch(path); } + function request(path) { return send('/api' + path); }`, + expected: ['/api/models'], + }, + { + shape: 'a mount prefix inside a reachable retry callback', + source: `function request(path) { return retry(() => fetch('/api' + path)); }`, + expected: ['/api/models'], + }, + { + shape: 'an unused nested fetch helper', + source: `function request(path) { const unused = () => fetch('/api' + path); return path; }`, + expected: [], + }, + { + shape: 'startsWith with an explicit offset', + source: `function build(path) { const normalized = path.startsWith('/', 1) ? path : '/' + path; return '/api' + normalized; } + function request(path) { return fetch(build(path)); }`, + expected: [], + }, + { + shape: 'a rebound prefix', + source: `function request(path) { let prefix = '/api'; prefix = runtime(); return fetch(prefix + path); }`, + expected: [], + }, + { + shape: 'a rebound URL parameter', + source: `function request(path) { path = runtime(); return fetch('/api' + path); }`, + expected: [], + }, + { + shape: 'a URL builder with different branch returns', + source: `function build(path) { if (runtime()) return '/private' + path; return '/api' + path; } + function request(path) { return fetch(build(path)); }`, + expected: [], + }, + { + shape: 'a recursive URL builder', + source: `function build(path) { return build(path); } + function request(path) { return fetch(build(path)); }`, + expected: [], + }, + { + shape: 'an async URL builder returning a Promise', + source: `async function build(path) { return '/api' + path; } + function request(path) { return fetch(build(path)); }`, + expected: [], + }, + { + shape: 'a generator URL builder returning an iterator', + source: `function* build(path) { return '/api' + path; } + function request(path) { return fetch(build(path)); }`, + expected: [], + }, + ])('resolves the actual URL for $shape (#2858)', ({ source, expected, input = '/models' }) => { + const { root, context: wrapper } = context(`${source}\nrequest('${input}');`); + const definitions = root.namedChildren.flatMap((node) => { + const facts = wrapper.facts(node); + const name = node.childForFieldName('name')?.text; + return facts && name ? [{ ...facts, filePath: 'consumer.ts', functionName: name }] : []; + }); + const index = buildFetchWrapperIndex(definitions); + const call = wrapper.calls().find((candidate) => candidate.functionName === 'request')!; + expect(index.urlsForCall(call).map(normalizeFetchURL)).toEqual(expected); + }); + it('preserves exact callee names and source lines, including comments and optional calls', () => { expect( extract( diff --git a/gitnexus/test/unit/fetch-wrapper-index.test.ts b/gitnexus/test/unit/fetch-wrapper-index.test.ts index 30c9fa408..f3589b866 100644 --- a/gitnexus/test/unit/fetch-wrapper-index.test.ts +++ b/gitnexus/test/unit/fetch-wrapper-index.test.ts @@ -30,6 +30,26 @@ function call(target: CalleeTarget, functionName = 'request'): FetchWrapperCall } describe('fetch wrapper target classification', () => { + it('preserves configured opaque clients when their bodies have no proven HTTP sink', () => { + const index = buildFetchWrapperIndex( + [ + definition('custom', { + urlArguments: [ + { + callee: { callee: 'axios.get', target: { kind: 'member' } }, + argument: { kind: 'parameter' }, + fetch: false, + }, + ], + }), + ], + ['request'], + ); + expect(index.urlsForCall(call({ kind: 'binding', bindingId: 'custom' }))).toEqual([ + '/api/items', + ]); + }); + it('uses the local binding rather than a same-named wrapper elsewhere', () => { const index = buildFetchWrapperIndex([ definition('wrapper', { seed: true }), @@ -514,3 +534,131 @@ describe('fetch wrapper barrel resolution depth', () => { }); }); }); + +describe('composed URL compatibility (#2858)', () => { + const parameter = { kind: 'parameter' } as const; + const sink = { callee: 'fetch', target: { kind: 'unresolved' } } as const; + const ref = (bindingId: string) => ({ + callee: bindingId, + target: { kind: 'binding', bindingId } as const, + }); + const prefixed = definition('request', { + seed: true, + forwardsUrl: true, + urlArguments: [ + { callee: sink, fetch: true, argument: { kind: 'concat', parts: ['/api', parameter] } }, + ], + }); + + it('resolves a configured wrapper prefix through a forwarder', () => { + const index = buildFetchWrapperIndex( + [ + prefixed, + definition('outer', { + functionName: 'outer', + urlArguments: [{ callee: ref('request'), argument: parameter, fetch: false }], + }), + ], + ['request'], + ); + expect( + index.urlsForCall({ + ...call({ kind: 'binding', bindingId: 'outer' }, 'outer'), + fetchURL: '/items', + }), + ).toEqual(['/api/items']); + }); + + it('keeps explicitly configured seed wrappers opaque when their URL is unreadable', () => { + const index = buildFetchWrapperIndex( + [ + definition('request', { seed: true, urlArguments: [] }), + definition('outer', { + functionName: 'outer', + urlArguments: [{ callee: ref('request'), argument: parameter, fetch: false }], + }), + ], + ['request'], + ); + expect(index.urlsForCall(call({ kind: 'binding', bindingId: 'request' }))).toEqual([ + '/api/items', + ]); + expect(index.urlsForCall(call({ kind: 'binding', bindingId: 'outer' }, 'outer'))).toEqual([ + '/api/items', + ]); + }); + + it('retains member forwarding with serialized URL facts', () => { + const index = buildFetchWrapperIndex([ + definition('send', { functionName: 'api.send', seed: true, forwardsUrl: true }), + definition('outer', { + functionName: 'outer', + urlArguments: [ + { + callee: { callee: 'api.send', target: { kind: 'member' } }, + argument: parameter, + fetch: false, + }, + ], + }), + ]); + expect(index.urlsForCall(call({ kind: 'binding', bindingId: 'outer' }, 'outer'))).toEqual([ + '/api/items', + ]); + }); + + it('substitutes only a precisely resolved URL-builder declaration', () => { + const builder = definition('builder', { + filePath: 'other.ts', + functionName: 'build', + exportedNames: ['build'], + urlReturn: { kind: 'concat', parts: ['/private', parameter] }, + }); + const make = (target: CalleeTarget) => + definition('request', { + seed: true, + urlArguments: [ + { + callee: sink, + fetch: true, + argument: { kind: 'call', callee: { callee: 'build', target }, argument: parameter }, + }, + ], + }); + for (const target of [ + { kind: 'import', specifier: 'external-http', importedName: 'build' }, + { kind: 'unresolved' }, + ] as const) + expect( + buildFetchWrapperIndex([builder, make(target)]).urlsForCall( + call({ kind: 'binding', bindingId: 'request' }), + ), + ).toEqual([]); + expect( + buildFetchWrapperIndex([ + builder, + make({ kind: 'resolved-import', filePath: 'other.ts', importedName: 'build' }), + ]).urlsForCall(call({ kind: 'binding', bindingId: 'request' })), + ).toEqual(['/private/api/items']); + }); + + it('preserves deep identity forwarding without spending the transformation depth budget', () => { + const definitions = Array.from({ length: 5000 }, (_, i) => + definition(`f${i}`, { + functionName: `f${i}`, + ...(i === 4999 + ? { + seed: true, + forwardsUrl: true, + urlArguments: [{ callee: sink, fetch: true, argument: parameter }], + } + : { urlArguments: [{ callee: ref(`f${i + 1}`), fetch: false, argument: parameter }] }), + }), + ); + expect( + buildFetchWrapperIndex(definitions).urlsForCall( + call({ kind: 'binding', bindingId: 'f0' }, 'f0'), + ), + ).toEqual(['/api/items']); + }); +});