fix(routes): link consumers to wrapper-composed URLs (#3697)

* fix(routes): resolve wrapper URL prefixes

Carry proven URL composition through lexical bindings and wrapper calls so consumers match the runtime mount path.

Refs #2858

* fix(review): apply review findings

* fix(routes): address PR review feedback (#3697)

* fix(routes): avoid native parent walks in URL facts

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
This commit is contained in:
Abhigyan Patwari 2026-10-23 22:55:20 +05:30 • committed by GitHub
parent 62a6f67083
commit f3fa67b144
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 840 additions and 14 deletions

View file

@ -574,8 +574,9 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
if (routeRegistry.size > 0 && wrapperCalls.length > 0) { if (routeRegistry.size > 0 && wrapperCalls.length > 0) {
const wrappers = buildFetchWrapperIndex(allFetchWrapperDefs ?? [], configuredWrappers); const wrappers = buildFetchWrapperIndex(allFetchWrapperDefs ?? [], configuredWrappers);
for (const call of wrapperCalls) { for (const call of wrapperCalls) {
if (wrappers.isWrapperCall(call)) allFetchCalls.push(call); const urls = wrappers.urlsForCall(call);
else unprovenCalls.push(call); if (urls.length === 0 && !wrappers.isWrapperCall(call)) unprovenCalls.push(call);
for (const fetchURL of urls) allFetchCalls.push({ ...call, fetchURL });
} }
} }

View file

@ -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<string>();
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;
}

View file

@ -1,5 +1,11 @@
import type { FetchWrapperDef } from '../workers/parse-worker.js'; 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 { interface NameGroup {
remainingCarriers: number; remainingCarriers: number;
@ -19,6 +25,7 @@ export function buildFetchWrapperIndex(
configuredNames: readonly string[] = [], configuredNames: readonly string[] = [],
): { ): {
isWrapperCall(call: Pick<FetchWrapperCall, 'filePath' | 'functionName' | 'target'>): boolean; isWrapperCall(call: Pick<FetchWrapperCall, 'filePath' | 'functionName' | 'target'>): boolean;
urlsForCall(call: FetchWrapperCall): string[];
} { } {
const configured = new Set(configuredNames); const configured = new Set(configuredNames);
const seen = new Set<string>(); const seen = new Set<string>();
@ -93,7 +100,10 @@ export function buildFetchWrapperIndex(
for (let index = 0; index < defs.length; index++) { for (let index = 0; index < defs.length; index++) {
const definition = defs[index]; const definition = defs[index];
if (definition.forwardsUrl) activate(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)) { if (configured.has(forwarded.callee)) {
activate(index); activate(index);
} else if (forwarded.target.kind === 'member') { } else if (forwarded.target.kind === 'member') {
@ -117,12 +127,148 @@ export function buildFetchWrapperIndex(
} }
} }
const isWrapperCall = ({
filePath,
functionName,
target,
}: Pick<FetchWrapperCall, '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));
};
// Collapse identity forwarding iteratively so long wrapper chains retain
// the carrier pass's linear behavior without consuming transformation depth.
const identityRoots = new Map<number, number>();
const identityRoot = (start: number): number => {
const path: number[] = [];
const visiting = new Set<number>();
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<number>,
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<string>();
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<number>,
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 { return {
isWrapperCall: ({ filePath, functionName, target }) => { isWrapperCall,
if (configured.has(functionName)) return true; urlsForCall: (call) => {
if (target.kind === 'member') return names.get(functionName)?.hasWrapper ?? false; if (!isWrapperCall(call)) return [];
const index = targetDefinition(filePath, functionName, target); const index = targetDefinition(call.filePath, call.functionName, call.target);
return index !== undefined && index !== null && (defs[index].seed || carriers.has(index)); // 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 // Aggregates share facts with the parse cache. Keep its raw specifiers intact
// so a warm parse resolves imports against the current project configuration. // so a warm parse resolves imports against the current project configuration.
return { return {
definitions: definitions.map((definition) => 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, ...definition,
forwardsTo: definition.forwardsTo.map((forwarded) => ({ forwardsTo: definition.forwardsTo.map((forwarded) => ({
...forwarded, ...forwarded,
target: resolve(definition.filePath, forwarded.target), 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, : definition,
), ),

View file

@ -3,6 +3,11 @@ import type Parser from 'tree-sitter';
import type { ParsedFile } from 'gitnexus-shared'; import type { ParsedFile } from 'gitnexus-shared';
import type { ExtractedFetchCall } from '../workers/parse-worker.js'; import type { ExtractedFetchCall } from '../workers/parse-worker.js';
import { stripLeadingTemplatePrefix } from './route-path.js'; import { stripLeadingTemplatePrefix } from './route-path.js';
import {
readFetchUrlExpression,
type FetchUrlArgument,
type FetchUrlExpression,
} from './fetch-url-expression.js';
import { import {
bindingKey, bindingKey,
createLexicalBindings, createLexicalBindings,
@ -42,6 +47,10 @@ export interface FetchWrapperFacts {
seed: boolean; seed: boolean;
forwardsUrl: boolean; forwardsUrl: boolean;
forwardsTo: ForwardedCallee[]; 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. */ /** A module export relevant to barrel resolution. */
export interface FetchWrapperReexport { export interface FetchWrapperReexport {
@ -115,6 +124,7 @@ const INSPECTED_TYPES: ReadonlySet<string> = new Set([
'export_statement', 'export_statement',
'export_specifier', 'export_specifier',
'call_expression', 'call_expression',
'return_statement',
]); ]);
/** Fetch implementations imported from packages, by module specifier and export name. /** Fetch implementations imported from packages, by module specifier and export name.
* A namespace or `require` value is recorded under the specifier itself. */ * A namespace or `require` value is recorded under the specifier itself. */
@ -130,6 +140,7 @@ interface Region {
node: Node; node: Node;
edges: Set<Region>; edges: Set<Region>;
calls: Node[]; calls: Node[];
returns: Node[];
} }
/** A visited ancestor of the cursor. */ /** A visited ancestor of the cursor. */
interface Frame { interface Frame {
@ -245,6 +256,7 @@ export function createFetchWrapperContext(
const aliases = new Set<string>(); const aliases = new Set<string>();
const invalid = new Set<string>(); const invalid = new Set<string>();
const initialized = new Map<string, number>(); const initialized = new Map<string, number>();
const bindingValues = new Map<string, Node>();
const parameters = new Set<string>(); const parameters = new Set<string>();
const exports = new Map<string, Set<string>>(); const exports = new Map<string, Set<string>>();
const boundNames = new Set<string>(); const boundNames = new Set<string>();
@ -262,6 +274,7 @@ export function createFetchWrapperContext(
// Native parent lookup descends from the tree root, so the walk records the // Native parent lookup descends from the tree root, so the walk records the
// ancestor facts that later queries need. // ancestor facts that later queries need.
const storedNames = new Map<number, Node | null>(); const storedNames = new Map<number, Node | null>();
const directReturns = new Set<number>();
const defaultExports = new Set<number>(); const defaultExports = new Set<number>();
const declarators = new Map<number, Node>(); const declarators = new Map<number, Node>();
const region = (node: Node): Region => { const region = (node: Node): Region => {
@ -269,6 +282,7 @@ export function createFetchWrapperContext(
node, node,
edges: new Set<Region>(), edges: new Set<Region>(),
calls: [] as Node[], calls: [] as Node[],
returns: [] as Node[],
}; };
regions.push(result); regions.push(result);
return result; return result;
@ -303,6 +317,7 @@ export function createFetchWrapperContext(
if (!value) continue; if (!value) continue;
initialized.set(id, (initialized.get(id) ?? 0) + 1); initialized.set(id, (initialized.get(id) ?? 0) + 1);
if (!parameter && parameters.has(id)) invalid.add(id); 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); if (pattern?.type === 'identifier' && namesFetch(value)) aliases.add(id);
} }
}; };
@ -555,6 +570,10 @@ export function createFetchWrapperContext(
) { ) {
const name = node.childForFieldName('name'); const name = node.childForFieldName('name');
if (name) exported(name, node.childForFieldName('alias')?.text ?? name.text); 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) { } else if (type === 'call_expression' && !node.hasError) {
active.calls.push(node); active.calls.push(node);
allCalls.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 // A rebound binding no longer names this function, so it proves no wrapper
// across files either; its names are kept so they still collide. // across files either; its names are kept so they still collide.
const rebound = !!id && invalid.has(id); 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 = { const result: FetchWrapperFacts = {
definitionId: `${filePath}:${fn.startPosition.row + lineOffset + 1}:${fn.startPosition.column}:${fn.endPosition.row + lineOffset + 1}:${fn.endPosition.column}`, definitionId: `${filePath}:${fn.startPosition.row + lineOffset + 1}:${fn.startPosition.column}:${fn.endPosition.row + lineOffset + 1}:${fn.endPosition.column}`,
...(id ? { bindingId: id } : {}), ...(id ? { bindingId: id } : {}),
@ -792,6 +882,8 @@ export function createFetchWrapperContext(
seed: seed && !rebound, seed: seed && !rebound,
forwardsUrl: forwardsUrl && !rebound, forwardsUrl: forwardsUrl && !rebound,
forwardsTo: rebound ? [] : [...forwardsTo.values()], forwardsTo: rebound ? [] : [...forwardsTo.values()],
...(!rebound && (seed || urlArguments.length > 0) ? { urlArguments } : {}),
...(urlReturn !== undefined ? { urlReturn } : {}),
}; };
factsCache.set(key, result); factsCache.set(key, result);
return result; return result;

View file

@ -3214,11 +3214,14 @@ const processFileGroup = (
parsedFile, parsedFile,
lineOffset, lineOffset,
); );
// Keep possible wrappers and the functions other files can name; a
// nested non-wrapper can neither be imported nor carry a URL.
if ( if (
facts && 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 }); wrapperDefs.push({ filePath: file.path, functionName: nodeName, ...facts });
} catch (err) { } catch (err) {

View file

@ -1,13 +1,24 @@
import { describe, it, expect, beforeAll } from 'vitest'; import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'path'; 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 { import {
FIXTURES, FIXTURES,
getRelationships, getRelationships,
getNodesByLabel, getNodesByLabel,
runPipelineFromRepo, runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult, type PipelineResult,
} from './helpers.js'; } from './helpers.js';
const parseIdentity = await runtimeParseIdentity();
describe('Fetch wrapper consumer FETCHES edge extraction', () => { describe('Fetch wrapper consumer FETCHES edge extraction', () => {
let result: PipelineResult; 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`)` // GitNexus's own web client (#3488): `fetchWithTimeout(`${_backendUrl}/api/repos`)`
// reaches fetch through `resilientFetch` and a local `fetchImpl` alias, and the // reaches fetch through `resilientFetch` and a local `fetchImpl` alias, and the
// URL starts with a base-URL binding. // URL starts with a base-URL binding.

View file

@ -3,6 +3,8 @@ import Parser from 'tree-sitter';
import JavaScript from 'tree-sitter-javascript'; import JavaScript from 'tree-sitter-javascript';
import TypeScript from 'tree-sitter-typescript'; import TypeScript from 'tree-sitter-typescript';
import { createFetchWrapperContext } from '../../src/core/ingestion/route-extractors/fetch-wrapper.js'; 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 { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js';
import { import {
@ -43,6 +45,122 @@ for (const [name, grammar, provider] of grammars) {
describe(`${name} fetch wrapper calls`, () => { describe(`${name} fetch wrapper calls`, () => {
const { context, extract } = harness(grammar, provider); 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', () => { it('preserves exact callee names and source lines, including comments and optional calls', () => {
expect( expect(
extract( extract(

View file

@ -30,6 +30,26 @@ function call(target: CalleeTarget, functionName = 'request'): FetchWrapperCall
} }
describe('fetch wrapper target classification', () => { 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', () => { it('uses the local binding rather than a same-named wrapper elsewhere', () => {
const index = buildFetchWrapperIndex([ const index = buildFetchWrapperIndex([
definition('wrapper', { seed: true }), 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']);
});
});