fix(routes): match configured fetch wrapper calls that pass type arguments (#3567)
Some checks are pending
Devcontainer Smoke / Config-transform unit tests (push) Waiting to run
Devcontainer Smoke / Build devcontainer image (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Skill copy sync / shipped skills drift guard (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run

This commit is contained in:
Richie Wee 2026-10-16 01:08:56 +08:00 • committed by GitHub
parent b37d4f246d
commit b51f25606c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 394 additions and 85 deletions

View file

@ -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.

View file

@ -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,

View file

@ -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,

View file

@ -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<ExtractedRoute | ExtractedDecoratorRoute>;
@ -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,

View file

@ -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<string, ReadonlyMap<string, string>>;
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. */

View file

@ -161,10 +161,6 @@ export function extractTemplateStaticFetchCalls(
return calls;
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export const routesPhase: PipelinePhase<RoutesOutput> = {
name: 'routes',
deps: ['parse'],
@ -177,6 +173,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
allPaths,
allFetchCalls: parseFetchCalls,
allFetchWrapperDefs,
allFetchWrapperCalls,
allExtractedRoutes,
allDecoratorRoutes,
routeHandlerSymbols,
@ -501,22 +498,10 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
}
}
// ── 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<string>(
[...(allFetchWrapperDefs ?? []).map((d) => d.functionName), ...configuredWrappers].filter(
@ -524,61 +509,8 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
),
);
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<string, string>();
// 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(
`(?<![.\\p{L}\\p{N}_$])(?:${alternation})\\s*\\(\\s*['"\`](/[^'"\`\\s)]+)['"\`]`,
'gu',
);
const scanContent = (filePath: string, content: string): void => {
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);
}
}

View file

@ -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<T>(...)` 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;
}

View file

@ -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

View file

@ -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);

View file

@ -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

View file

@ -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<string, unknown>) {
return axios.get(`${API_BASE}${path}`, opts);
export async function doRequest<T = unknown>(path: string, opts?: Record<string, unknown>) {
return axios.get<T>(`${API_BASE}${path}`, opts);
}

View file

@ -0,0 +1,3 @@
// doRequest<string>('/api/things');
/* doRequest('/api/things'); */
export const description = 'no request';

View file

@ -0,0 +1,3 @@
import { doRequest } from '../lib/http';
const client = { doRequest };
export const load = () => client /* receiver */ . doRequest /* type */ <string> /* args */ (/* url */ '/api/things');

View file

@ -0,0 +1,3 @@
import { doRequest } from '../lib/http';
declare const suffix: string;
export const load = () => doRequest('/api/things' + suffix);

View file

@ -0,0 +1,2 @@
import { doRequest } from '../lib/http';
export const load = () => doRequest<() => string>('/api/things');

View file

@ -0,0 +1,6 @@
import { doRequest } from '../lib/http';
export class Client {
doRequest = doRequest;
load() { return this.doRequest<string[]>('/api/things'); }
}

View file

@ -0,0 +1,2 @@
import { doRequest } from '../lib/http';
export const load = () => doRequest<{ kind: 'thing'; compare: '>' }>('/api/things');

View file

@ -0,0 +1,5 @@
import { doRequest } from '../lib/http';
type Thing = { id: number; name: string };
export const load = () => doRequest<Array<Thing>>('/api/things');

View file

@ -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');

View file

@ -0,0 +1,2 @@
declare const other: { doRequest: (url: string) => unknown };
export const load = () => other.doRequest('/api/things');

View file

@ -0,0 +1,8 @@
import { doRequest } from '../lib/http';
type Thing = { id: number; name: string };
declare function makeLoader<T>(key: string): typeof doRequest<T>;
// Specializing the wrapper does not call it. The following call is unrelated.
export const typedRequest = doRequest<Thing[]>;
export const load = makeLoader<Thing[]>('/api/things');

View file

@ -0,0 +1 @@
export const example = "doRequest('/api/things')";

View file

@ -0,0 +1,8 @@
import { doRequest } from '../lib/http';
class BaseClient {
doRequest<T>(url: string) { return doRequest<T>(url); }
}
export class Client extends BaseClient {
load() { return super.doRequest<string[]>('/api/things'); }
}

View file

@ -0,0 +1,7 @@
import { doRequest } from '../lib/http';
type Thing = { id: number; name: string };
declare function makeLoader<T>(key: string): typeof doRequest<T>;
// The configured wrapper is only a type reference, not the function being called.
export const load: typeof doRequest<Thing[]> = makeLoader<Thing[]>('/api/things');

View file

@ -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<Thing[]>('/api/things');
return res.data;
};
return null;
}

View file

@ -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',

View file

@ -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<typeof import('../../src/core/embeddings/runtime-support.js')>()),
assessLocalEmbeddingRuntime: () => ({ status: 'ready' }),
}));
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
closeLbug: vi.fn(async () => undefined),
closeLbugBeforeExit: vi.fn(async () => undefined),

View file

@ -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<string> }>',
'(value: string) => Promise<{ kind: "thing" }>',
'{ compare: ">"; callback: () => void }',
'[string, number]',
'T extends string ? Array<T> : 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<string[]>('/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<string>;
const load: typeof doRequest<string> = makeLoader<string>('/api/things');
`).map((call) => call.functionName),
).toEqual(['makeLoader']);
});
}
});
}

View file

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

View file

@ -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 = {