mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-16 04:28:11 +00:00
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
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:
parent
b37d4f246d
commit
b51f25606c
30 changed files with 394 additions and 85 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
3
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/CommentOnly.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/CommentOnly.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// doRequest<string>('/api/things');
|
||||
/* doRequest('/api/things'); */
|
||||
export const description = 'no request';
|
||||
3
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/CommentedCall.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/CommentedCall.ts
vendored
Normal 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');
|
||||
3
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/ConcatenatedUrl.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/ConcatenatedUrl.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { doRequest } from '../lib/http';
|
||||
declare const suffix: string;
|
||||
export const load = () => doRequest('/api/things' + suffix);
|
||||
2
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/FunctionTypes.ts
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/FunctionTypes.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { doRequest } from '../lib/http';
|
||||
export const load = () => doRequest<() => string>('/api/things');
|
||||
6
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/InstanceWrapper.ts
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/InstanceWrapper.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { doRequest } from '../lib/http';
|
||||
|
||||
export class Client {
|
||||
doRequest = doRequest;
|
||||
load() { return this.doRequest<string[]>('/api/things'); }
|
||||
}
|
||||
2
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/LiteralTypes.ts
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/LiteralTypes.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { doRequest } from '../lib/http';
|
||||
export const load = () => doRequest<{ kind: 'thing'; compare: '>' }>('/api/things');
|
||||
5
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/NestedTypes.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/NestedTypes.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { doRequest } from '../lib/http';
|
||||
|
||||
type Thing = { id: number; name: string };
|
||||
|
||||
export const load = () => doRequest<Array<Thing>>('/api/things');
|
||||
6
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/ObjectTypes.ts
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/ObjectTypes.ts
vendored
Normal 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');
|
||||
2
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/OtherReceiver.ts
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/OtherReceiver.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
declare const other: { doRequest: (url: string) => unknown };
|
||||
export const load = () => other.doRequest('/api/things');
|
||||
8
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/StoredReference.ts
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/StoredReference.ts
vendored
Normal 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');
|
||||
1
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/StringOnly.ts
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/StringOnly.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const example = "doRequest('/api/things')";
|
||||
8
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/SuperWrapper.ts
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/SuperWrapper.ts
vendored
Normal 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'); }
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/TypeOnlyReference.ts
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/TypeOnlyReference.ts
vendored
Normal 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');
|
||||
11
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/TypedThings.tsx
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/configurable-fetch-wrapper/pages/TypedThings.tsx
vendored
Normal 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
125
gitnexus/test/unit/fetch-wrapper-calls.test.ts
Normal file
125
gitnexus/test/unit/fetch-wrapper-calls.test.ts
Normal 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']);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue