mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-04 02:31:36 +00:00
Merge branch 'main' into fix/issue-1518-docker-local-path
This commit is contained in:
commit
f061d4e7bc
25 changed files with 848 additions and 11 deletions
|
|
@ -9,18 +9,26 @@ import {
|
|||
import type { HttpDetection, HttpLanguagePlugin } from './types.js';
|
||||
|
||||
/**
|
||||
* Kotlin HTTP plugin (Spring providers).
|
||||
* Kotlin HTTP plugin (Spring providers + consumers).
|
||||
*
|
||||
* Mirrors the Java plugin for Spring `@RequestMapping` class prefixes
|
||||
* and `@(Get|Post|...)Mapping` method annotations on Kotlin Spring
|
||||
* Boot controllers. Both positional shorthand (`@GetMapping("/x")`)
|
||||
* and named annotation arguments (`@GetMapping(value = "/x")` and
|
||||
* **Providers** (#1849) — Spring `@RequestMapping` class prefixes and
|
||||
* `@(Get|Post|...)Mapping` method annotations on Kotlin Spring Boot
|
||||
* controllers. Both positional shorthand (`@GetMapping("/x")`) and
|
||||
* named annotation arguments (`@GetMapping(value = "/x")` and
|
||||
* `@GetMapping(path = "/x")`) are supported.
|
||||
*
|
||||
* Consumer detection (RestTemplate / WebClient / OkHttp) is intentionally
|
||||
* out of scope for this plugin — Kotlin call-site ASTs are sufficiently
|
||||
* different from Java's `method_invocation` shape that they warrant a
|
||||
* separate, focused follow-up.
|
||||
* **Consumers** (this PR) — three call-site patterns common in Kotlin
|
||||
* Spring projects:
|
||||
*
|
||||
* 1. `restTemplate.getForObject("/x", ...)` and friends
|
||||
* 2. `webClient.get().uri("/x")` (short form, 1 verb hop + 1 uri hop)
|
||||
* 3. `Request.Builder().url("/x")` (OkHttp)
|
||||
*
|
||||
* The long-form `webClient.method(HttpMethod.X).uri("/y")` chain is
|
||||
* intentionally deferred to a follow-up: it requires walk-up logic
|
||||
* to recover the verb from a sibling `call_expression`, and we can
|
||||
* land 80% of real-world Kotlin Spring consumer coverage with the
|
||||
* three simpler patterns above.
|
||||
*
|
||||
* tree-sitter-kotlin (fwcd) AST shapes used here:
|
||||
* class_declaration
|
||||
|
|
@ -34,6 +42,20 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js';
|
|||
* string_literal
|
||||
* type_identifier ← class name
|
||||
*
|
||||
* Consumer call shape (Kotlin chains everything via `navigation_expression`):
|
||||
* call_expression ← outer `.uri("/x")` or `.url("/x")`
|
||||
* navigation_expression
|
||||
* call_expression ← inner `.get()` / `Request.Builder()` / `restTemplate.x`
|
||||
* navigation_expression
|
||||
* simple_identifier ← receiver: `webClient` / `Request` / `restTemplate`
|
||||
* navigation_suffix ← `.method` / `.Builder` / `.getForObject`
|
||||
* call_suffix (value_arguments)
|
||||
* navigation_suffix ← `.uri` / `.url`
|
||||
* call_suffix
|
||||
* value_arguments
|
||||
* value_argument
|
||||
* string_literal ← the path
|
||||
*
|
||||
* tree-sitter-kotlin is an optional npm dependency — when its native
|
||||
* binding is unavailable the plugin gracefully exports `null` and
|
||||
* `http-patterns/index.ts` skips registration for `.kt`/`.kts` files.
|
||||
|
|
@ -57,6 +79,36 @@ const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
|
|||
PatchMapping: 'PATCH',
|
||||
};
|
||||
|
||||
/**
|
||||
* RestTemplate method-name → HTTP verb. Mirrors the Java plugin's
|
||||
* `REST_TEMPLATE_TO_HTTP` (java.ts) so a polyglot repo emits the
|
||||
* same contract IDs from .java and .kt sources.
|
||||
*/
|
||||
const REST_TEMPLATE_TO_HTTP: Record<string, string> = {
|
||||
getForObject: 'GET',
|
||||
getForEntity: 'GET',
|
||||
postForObject: 'POST',
|
||||
postForEntity: 'POST',
|
||||
put: 'PUT',
|
||||
delete: 'DELETE',
|
||||
patchForObject: 'PATCH',
|
||||
};
|
||||
|
||||
/**
|
||||
* WebClient short-form verb → HTTP verb. The reactive WebClient API
|
||||
* exposes `.get()`, `.post()`, `.put()`, `.delete()`, `.patch()` as
|
||||
* one-liners that return a `RequestHeadersUriSpec` whose `.uri(...)`
|
||||
* carries the path. We capture both pieces in a single query (see
|
||||
* `WEB_CLIENT_SHORT_PATTERNS` below) and translate the verb here.
|
||||
*/
|
||||
const WEB_CLIENT_SHORT_TO_HTTP: Record<string, string> = {
|
||||
get: 'GET',
|
||||
post: 'POST',
|
||||
put: 'PUT',
|
||||
delete: 'DELETE',
|
||||
patch: 'PATCH',
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the plugin only if the Kotlin grammar is available. Compiling
|
||||
* the queries against a null grammar would throw at module load time
|
||||
|
|
@ -157,6 +209,130 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
// ─── Consumer: Spring RestTemplate ────────────────────────────────────
|
||||
// Kotlin call-site shape mirrors the Java plugin's
|
||||
// `REST_TEMPLATE_PATTERNS`, but goes through tree-sitter-kotlin's
|
||||
// `navigation_expression` instead of Java's `method_invocation`:
|
||||
//
|
||||
// restTemplate.getForObject("/x", User::class.java)
|
||||
//
|
||||
// becomes
|
||||
//
|
||||
// call_expression
|
||||
// navigation_expression
|
||||
// simple_identifier "restTemplate"
|
||||
// navigation_suffix → simple_identifier "getForObject"
|
||||
// call_suffix
|
||||
// value_arguments
|
||||
// value_argument . string_literal "/x" ← captured
|
||||
// value_argument User::class.java
|
||||
//
|
||||
// The receiver name is constrained to `restTemplate` (#eq? @obj),
|
||||
// matching the Java plugin's heuristic. This means a non-conventional
|
||||
// field name (e.g. `userServiceTemplate`) will not be picked up;
|
||||
// that's the same trade-off already accepted on the Java side.
|
||||
const REST_TEMPLATE_PATTERNS = compilePatterns({
|
||||
name: 'kotlin-rest-template',
|
||||
language,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(call_expression
|
||||
(navigation_expression
|
||||
(simple_identifier) @obj (#eq? @obj "restTemplate")
|
||||
(navigation_suffix (simple_identifier) @method))
|
||||
(call_suffix
|
||||
(value_arguments . (value_argument . (string_literal) @path))))
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
// ─── Consumer: Spring WebClient (short form) ──────────────────────────
|
||||
// Reactive WebClient exposes one-liner verb helpers:
|
||||
//
|
||||
// webClient.get().uri("/x").retrieve().awaitBody<T>()
|
||||
// webClient.post().uri("/x")...
|
||||
//
|
||||
// The chain `webClient.get().uri("/x")` parses as two nested
|
||||
// `call_expression` nodes — the OUTER call is `.uri("/x")` and the
|
||||
// INNER call is `webClient.get()`. We anchor on the outer call and
|
||||
// require:
|
||||
// - inner receiver is `webClient`
|
||||
// - inner suffix is one of the HTTP verbs (#match?)
|
||||
// - outer suffix is exactly `uri`
|
||||
// - outer call's first value_argument is a string literal
|
||||
//
|
||||
// The long-form `webClient.method(HttpMethod.GET).uri("/x")` chain
|
||||
// uses an extra navigation hop and an enum field access — it's
|
||||
// intentionally out of scope here (see file header).
|
||||
const WEB_CLIENT_SHORT_PATTERNS = compilePatterns({
|
||||
name: 'kotlin-web-client-short',
|
||||
language,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(call_expression
|
||||
(navigation_expression
|
||||
(call_expression
|
||||
(navigation_expression
|
||||
(simple_identifier) @obj (#eq? @obj "webClient")
|
||||
(navigation_suffix
|
||||
(simple_identifier) @verb (#match? @verb "^(get|post|put|delete|patch)$")))
|
||||
(call_suffix (value_arguments)))
|
||||
(navigation_suffix (simple_identifier) @uri (#eq? @uri "uri")))
|
||||
(call_suffix
|
||||
(value_arguments . (value_argument . (string_literal) @path))))
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
// ─── Consumer: OkHttp Request.Builder().url("/x") ─────────────────────
|
||||
// Kotlin parses `Request.Builder()` as a `call_expression` whose
|
||||
// callee is a `navigation_expression` (Request → .Builder), NOT as
|
||||
// Java's `object_creation_expression`. The chain `.url("/x")` then
|
||||
// wraps that in another `call_expression`. The query mirrors Java's
|
||||
// `OK_HTTP_PATTERNS` (java.ts) but adapts the node types.
|
||||
//
|
||||
// Receiver `Request` is constrained by name (#eq? @cls); a project
|
||||
// that imports OkHttp's `Request` under an alias (`import okhttp3.Request as OkRequest`)
|
||||
// would not be picked up — this matches the Java plugin's heuristic.
|
||||
//
|
||||
// **Known limitation — verb defaults to GET.** OkHttp encodes the
|
||||
// verb on a *sibling* call further down the builder chain (e.g.
|
||||
// `.post(body)` / `.get()` / `.delete()`), not on `.url(...)` itself.
|
||||
// This query intentionally does not walk the chain to recover the
|
||||
// verb — it emits `method: 'GET'` for every match, mirroring
|
||||
// `java.ts:OK_HTTP_PATTERNS`. So a `Request.Builder().url("/x").post(body).build()`
|
||||
// call becomes `http::GET::/x`, not `http::POST::/x`. This is the
|
||||
// same trade-off Java has accepted; pinned by an anti-overreach
|
||||
// test in `http-route-extractor.test.ts` so a future verb-walk
|
||||
// implementation has to update this comment in lockstep.
|
||||
const OK_HTTP_PATTERNS = compilePatterns({
|
||||
name: 'kotlin-okhttp',
|
||||
language,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(call_expression
|
||||
(navigation_expression
|
||||
(call_expression
|
||||
(navigation_expression
|
||||
(simple_identifier) @cls (#eq? @cls "Request")
|
||||
(navigation_suffix (simple_identifier) @builder (#eq? @builder "Builder")))
|
||||
(call_suffix (value_arguments)))
|
||||
(navigation_suffix (simple_identifier) @method (#eq? @method "url")))
|
||||
(call_suffix
|
||||
(value_arguments . (value_argument . (string_literal) @path))))
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
/**
|
||||
* Find the nearest enclosing class_declaration ancestor for a node, or
|
||||
* null if the node is top-level. Mirrors the Java plugin's helper.
|
||||
|
|
@ -223,6 +399,60 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
|||
});
|
||||
}
|
||||
|
||||
// ─── Consumers: RestTemplate ────────────────────────────────────
|
||||
for (const match of runCompiledPatterns(REST_TEMPLATE_PATTERNS, tree)) {
|
||||
const methodNode = match.captures.method;
|
||||
const pathNode = match.captures.path;
|
||||
if (!methodNode || !pathNode) continue;
|
||||
const httpMethod = REST_TEMPLATE_TO_HTTP[methodNode.text];
|
||||
if (!httpMethod) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
if (path === null) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'spring-rest-template',
|
||||
method: httpMethod,
|
||||
path,
|
||||
name: null,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Consumers: WebClient short form (.get()/.post()/etc → .uri) ─
|
||||
for (const match of runCompiledPatterns(WEB_CLIENT_SHORT_PATTERNS, tree)) {
|
||||
const verbNode = match.captures.verb;
|
||||
const pathNode = match.captures.path;
|
||||
if (!verbNode || !pathNode) continue;
|
||||
const httpMethod = WEB_CLIENT_SHORT_TO_HTTP[verbNode.text];
|
||||
if (!httpMethod) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
if (path === null) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'spring-web-client',
|
||||
method: httpMethod,
|
||||
path,
|
||||
name: null,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Consumers: OkHttp Request.Builder().url("path") ────────────
|
||||
for (const match of runCompiledPatterns(OK_HTTP_PATTERNS, tree)) {
|
||||
const pathNode = match.captures.path;
|
||||
if (!pathNode) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
if (path === null) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'okhttp',
|
||||
method: 'GET',
|
||||
path,
|
||||
name: null,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/as
|
|||
import { splitImportStatement } from './import-decomposer.js';
|
||||
import { getPythonParser, getPythonScopeQuery } from './query.js';
|
||||
import { synthesizeReceiverTypeBinding } from './receiver-binding.js';
|
||||
import { synthesizeDependsReferences } from './depends-references.js';
|
||||
import { computePythonArityMetadata } from './arity-metadata.js';
|
||||
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
|
|
@ -98,6 +99,7 @@ export function emitPythonScopeCaptures(
|
|||
if (fnNode !== null) {
|
||||
const synth = synthesizeReceiverTypeBinding(fnNode);
|
||||
if (synth !== null) out.push(synth);
|
||||
for (const depRef of synthesizeDependsReferences(fnNode)) out.push(depRef);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* Synthesize `@reference.call.free` captures for FastAPI `Depends(callable)`
|
||||
* parameter defaults.
|
||||
*
|
||||
* `Depends(get_db)` passes `get_db` as a callable that the DI framework
|
||||
* calls on every request. The route handler is functionally a caller of
|
||||
* the dependency — impact analysis needs that edge.
|
||||
*
|
||||
* Tree-sitter can't express "the first argument of a call named Depends
|
||||
* inside a parameter default" in a single static query, so we synthesize
|
||||
* reference captures in code, mirroring the receiver-binding pattern.
|
||||
*/
|
||||
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
/**
|
||||
* Inspect a `function_definition` node's parameters for `Depends(callable)`
|
||||
* defaults. Returns one `@reference.call.free` CaptureMatch per dependency.
|
||||
*/
|
||||
export function synthesizeDependsReferences(fnNode: SyntaxNode): readonly CaptureMatch[] {
|
||||
const params = fnNode.childForFieldName('parameters');
|
||||
if (params === null) return [];
|
||||
|
||||
const results: CaptureMatch[] = [];
|
||||
|
||||
for (let i = 0; i < params.namedChildCount; i++) {
|
||||
const param = params.namedChild(i);
|
||||
if (param === null) continue;
|
||||
|
||||
if (param.type !== 'typed_default_parameter' && param.type !== 'default_parameter') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const defaultValue = param.childForFieldName('value') ?? param.childForFieldName('default');
|
||||
if (defaultValue === null) continue;
|
||||
|
||||
const callNode = defaultValue.type === 'call' ? defaultValue : null;
|
||||
if (callNode === null) continue;
|
||||
|
||||
const fnIdent = callNode.childForFieldName('function');
|
||||
if (fnIdent === null || fnIdent.type !== 'identifier' || fnIdent.text !== 'Depends') continue;
|
||||
|
||||
const args = callNode.childForFieldName('arguments');
|
||||
if (args === null || args.namedChildCount === 0) continue;
|
||||
|
||||
const firstArg = args.namedChild(0);
|
||||
if (firstArg === null) continue;
|
||||
|
||||
if (firstArg.type === 'identifier') {
|
||||
results.push({
|
||||
'@reference.call.free': nodeToCapture('@reference.call.free', firstArg),
|
||||
'@reference.name': nodeToCapture('@reference.name', firstArg),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (firstArg.type === 'attribute') {
|
||||
const attrName = firstArg.childForFieldName('attribute');
|
||||
const obj = firstArg.childForFieldName('object');
|
||||
if (attrName !== null && obj !== null) {
|
||||
results.push({
|
||||
'@reference.call.member': nodeToCapture('@reference.call.member', attrName),
|
||||
'@reference.name': nodeToCapture('@reference.name', attrName),
|
||||
'@reference.receiver': nodeToCapture('@reference.receiver', obj),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
|
@ -53,6 +53,7 @@ import type {
|
|||
FileConstructorBindings,
|
||||
FileScopeBindings,
|
||||
ExtractedORMQuery,
|
||||
FetchWrapperDef,
|
||||
} from './workers/parse-worker.js';
|
||||
import {
|
||||
getTreeSitterBufferSize,
|
||||
|
|
@ -69,6 +70,7 @@ export interface WorkerExtractedData {
|
|||
heritage: ExtractedHeritage[];
|
||||
routes: ExtractedRoute[];
|
||||
fetchCalls: ExtractedFetchCall[];
|
||||
fetchWrapperDefs: FetchWrapperDef[];
|
||||
decoratorRoutes: ExtractedDecoratorRoute[];
|
||||
toolDefs: ExtractedToolDef[];
|
||||
ormQueries: ExtractedORMQuery[];
|
||||
|
|
@ -110,6 +112,7 @@ export const mergeChunkResults = (
|
|||
const allHeritage: ExtractedHeritage[] = [];
|
||||
const allRoutes: ExtractedRoute[] = [];
|
||||
const allFetchCalls: ExtractedFetchCall[] = [];
|
||||
const allFetchWrapperDefs: FetchWrapperDef[] = [];
|
||||
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
|
||||
const allToolDefs: ExtractedToolDef[] = [];
|
||||
const allORMQueries: ExtractedORMQuery[] = [];
|
||||
|
|
@ -147,6 +150,7 @@ export const mergeChunkResults = (
|
|||
for (const item of result.heritage) allHeritage.push(item);
|
||||
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.decoratorRoutes) allDecoratorRoutes.push(item);
|
||||
for (const item of result.toolDefs) allToolDefs.push(item);
|
||||
if (result.ormQueries) for (const item of result.ormQueries) allORMQueries.push(item);
|
||||
|
|
@ -163,6 +167,7 @@ export const mergeChunkResults = (
|
|||
heritage: allHeritage,
|
||||
routes: allRoutes,
|
||||
fetchCalls: allFetchCalls,
|
||||
fetchWrapperDefs: allFetchWrapperDefs,
|
||||
decoratorRoutes: allDecoratorRoutes,
|
||||
toolDefs: allToolDefs,
|
||||
ormQueries: allORMQueries,
|
||||
|
|
@ -203,6 +208,7 @@ const processParsingWithWorkers = async (
|
|||
heritage: [],
|
||||
routes: [],
|
||||
fetchCalls: [],
|
||||
fetchWrapperDefs: [],
|
||||
decoratorRoutes: [],
|
||||
toolDefs: [],
|
||||
ormQueries: [],
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ import type {
|
|||
ExtractedRoute,
|
||||
ExtractedToolDef,
|
||||
FileConstructorBindings,
|
||||
FetchWrapperDef,
|
||||
} from '../workers/parse-worker.js';
|
||||
import type { ExtractedHeritage } from '../model/heritage-map.js';
|
||||
import type { KnowledgeGraph } from '../../graph/types.js';
|
||||
|
|
@ -141,6 +142,7 @@ export async function runChunkedParseAndResolve(
|
|||
): Promise<{
|
||||
exportedTypeMap: ExportedTypeMap;
|
||||
allFetchCalls: ExtractedFetchCall[];
|
||||
allFetchWrapperDefs: FetchWrapperDef[];
|
||||
allExtractedRoutes: ExtractedRoute[];
|
||||
allDecoratorRoutes: ExtractedDecoratorRoute[];
|
||||
allToolDefs: ExtractedToolDef[];
|
||||
|
|
@ -352,6 +354,7 @@ export async function runChunkedParseAndResolve(
|
|||
// it, and later wildcard chunks re-run it themselves.
|
||||
let hasSynthesized = false;
|
||||
const allFetchCalls: ExtractedFetchCall[] = [];
|
||||
const allFetchWrapperDefs: FetchWrapperDef[] = [];
|
||||
const allExtractedRoutes: ExtractedRoute[] = [];
|
||||
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
|
||||
const allToolDefs: ExtractedToolDef[] = [];
|
||||
|
|
@ -663,6 +666,9 @@ export async function runChunkedParseAndResolve(
|
|||
if (chunkWorkerData.fetchCalls?.length) {
|
||||
for (const item of chunkWorkerData.fetchCalls) allFetchCalls.push(item);
|
||||
}
|
||||
if (chunkWorkerData.fetchWrapperDefs?.length) {
|
||||
for (const item of chunkWorkerData.fetchWrapperDefs) allFetchWrapperDefs.push(item);
|
||||
}
|
||||
if (chunkWorkerData.routes?.length) {
|
||||
for (const item of chunkWorkerData.routes) allExtractedRoutes.push(item);
|
||||
}
|
||||
|
|
@ -1082,6 +1088,7 @@ export async function runChunkedParseAndResolve(
|
|||
return {
|
||||
exportedTypeMap,
|
||||
allFetchCalls,
|
||||
allFetchWrapperDefs,
|
||||
allExtractedRoutes,
|
||||
allDecoratorRoutes,
|
||||
allToolDefs,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import type {
|
|||
ExtractedDecoratorRoute,
|
||||
ExtractedToolDef,
|
||||
ExtractedORMQuery,
|
||||
FetchWrapperDef,
|
||||
} from '../workers/parse-worker.js';
|
||||
import type { createResolutionContext } from '../model/resolution-context.js';
|
||||
import { runChunkedParseAndResolve } from './parse-impl.js';
|
||||
|
|
@ -45,6 +46,7 @@ export interface ParseOutput {
|
|||
*/
|
||||
readonly exportedTypeMap: ReadonlyMap<string, ReadonlyMap<string, string>>;
|
||||
readonly allFetchCalls: readonly ExtractedFetchCall[];
|
||||
readonly allFetchWrapperDefs: readonly FetchWrapperDef[];
|
||||
readonly allExtractedRoutes: readonly ExtractedRoute[];
|
||||
readonly allDecoratorRoutes: readonly ExtractedDecoratorRoute[];
|
||||
readonly allToolDefs: readonly ExtractedToolDef[];
|
||||
|
|
|
|||
|
|
@ -131,6 +131,10 @@ export function normalizeExtractedRoutePath(routePath: string, prefix: string |
|
|||
return joined.replace(/\/+/g, '/') || '/';
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export const routesPhase: PipelinePhase<RoutesOutput> = {
|
||||
name: 'routes',
|
||||
deps: ['parse'],
|
||||
|
|
@ -142,6 +146,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
|||
const {
|
||||
allPaths,
|
||||
allFetchCalls: parseFetchCalls,
|
||||
allFetchWrapperDefs,
|
||||
allExtractedRoutes,
|
||||
allDecoratorRoutes,
|
||||
} = getPhaseOutput<ParseOutput>(deps, 'parse');
|
||||
|
|
@ -357,6 +362,35 @@ 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.
|
||||
if (allFetchWrapperDefs && allFetchWrapperDefs.length > 0 && routeRegistry.size > 0) {
|
||||
const wrapperNames = new Set(allFetchWrapperDefs.map((d) => d.functionName));
|
||||
const jsFiles = allPaths.filter((p) => /\.[jt]sx?$/.test(p));
|
||||
if (jsFiles.length > 0 && wrapperNames.size > 0) {
|
||||
const jsContents = await readFileContents(ctx.repoPath, jsFiles);
|
||||
for (const [filePath, content] of jsContents) {
|
||||
for (const name of wrapperNames) {
|
||||
const regex = new RegExp(
|
||||
`\\b${escapeRegex(name)}\\s*\\(\\s*['"\`](/[^'"\`\\s)]+)['"\`]`,
|
||||
'g',
|
||||
);
|
||||
let match;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
allFetchCalls.push({
|
||||
filePath,
|
||||
fetchURL: match[1],
|
||||
lineNumber: content.substring(0, match.index).split('\n').length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (routeRegistry.size > 0 && allFetchCalls.length > 0) {
|
||||
const routeURLToFile = new Map<string, string>();
|
||||
for (const [url, entry] of routeRegistry) routeURLToFile.set(url, entry.filePath);
|
||||
|
|
|
|||
|
|
@ -241,6 +241,12 @@ export const TYPESCRIPT_QUERIES = `
|
|||
[(string (string_fragment) @route.url)
|
||||
(template_string) @route.template_url])) @route.fetch
|
||||
|
||||
; Custom fetch wrappers: apiFetch('/path'), fetchJSON('/api/data'), httpGet('/users'), etc.
|
||||
(call_expression
|
||||
function: (identifier) @_wrapper_fn (#match? @_wrapper_fn "^(api(Fetch|Get|Post|Put|Delete|Patch|Request)|fetch(API|JSON|Data|Endpoint|Resource|Url)|http(Fetch|Get|Post|Put|Delete|Patch|Request))$")
|
||||
arguments: (arguments
|
||||
(string (string_fragment) @route.url))) @route.fetch
|
||||
|
||||
; axios.get/post/put/delete/patch('/path'), $.get/post/ajax({url:'/path'})
|
||||
(call_expression
|
||||
function: (member_expression
|
||||
|
|
@ -434,6 +440,12 @@ export const JAVASCRIPT_QUERIES = `
|
|||
[(string (string_fragment) @route.url)
|
||||
(template_string) @route.template_url])) @route.fetch
|
||||
|
||||
; Custom fetch wrappers: apiFetch('/path'), fetchJSON('/api/data'), httpGet('/users'), etc.
|
||||
(call_expression
|
||||
function: (identifier) @_wrapper_fn (#match? @_wrapper_fn "^(api(Fetch|Get|Post|Put|Delete|Patch|Request)|fetch(API|JSON|Data|Endpoint|Resource|Url)|http(Fetch|Get|Post|Put|Delete|Patch|Request))$")
|
||||
arguments: (arguments
|
||||
(string (string_fragment) @route.url))) @route.fetch
|
||||
|
||||
; axios.get/post, $.get/post/ajax
|
||||
(call_expression
|
||||
function: (member_expression
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@ export interface ExtractedFetchCall {
|
|||
lineNumber: number;
|
||||
}
|
||||
|
||||
export interface FetchWrapperDef {
|
||||
filePath: string;
|
||||
functionName: string;
|
||||
}
|
||||
|
||||
export interface ExtractedDecoratorRoute {
|
||||
filePath: string;
|
||||
routePath: string;
|
||||
|
|
@ -268,6 +273,7 @@ export interface ParseWorkerResult {
|
|||
heritage: ExtractedHeritage[];
|
||||
routes: ExtractedRoute[];
|
||||
fetchCalls: ExtractedFetchCall[];
|
||||
fetchWrapperDefs: FetchWrapperDef[];
|
||||
decoratorRoutes: ExtractedDecoratorRoute[];
|
||||
toolDefs: ExtractedToolDef[];
|
||||
ormQueries: ExtractedORMQuery[];
|
||||
|
|
@ -732,6 +738,7 @@ const processBatch = (
|
|||
heritage: [],
|
||||
routes: [],
|
||||
fetchCalls: [],
|
||||
fetchWrapperDefs: [],
|
||||
decoratorRoutes: [],
|
||||
toolDefs: [],
|
||||
ormQueries: [],
|
||||
|
|
@ -842,6 +849,23 @@ const EXPRESS_ROUTE_METHODS = new Set([
|
|||
'route',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Walk a tree-sitter AST subtree looking for a call to the global `fetch()` function.
|
||||
* Returns `true` if found within `maxDepth` levels of nesting — keeps the check
|
||||
* lightweight so it doesn't slow down parse-worker on large function bodies.
|
||||
*/
|
||||
const checkForFetchCall = (node: SyntaxNode, depth = 0, maxDepth = 5): boolean => {
|
||||
if (depth > maxDepth) return false;
|
||||
if (node.type === 'call_expression') {
|
||||
const fn = node.childForFieldName('function');
|
||||
if (fn?.type === 'identifier' && fn.text === 'fetch') return true;
|
||||
}
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
if (checkForFetchCall(node.child(i)!, depth + 1, maxDepth)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// HTTP client methods that are ONLY used by clients, not Express route registration.
|
||||
// Methods like get/post/put/delete/patch overlap with Express — those are captured by
|
||||
// the express_route handler as route definitions, not consumers. The fetch() global
|
||||
|
|
@ -1944,6 +1968,21 @@ const processFileGroup = (
|
|||
: '',
|
||||
});
|
||||
}
|
||||
|
||||
// ── Fetch wrapper detection: record functions that call fetch() internally ──
|
||||
if (
|
||||
nodeLabel === 'Function' &&
|
||||
definitionNode &&
|
||||
nameNode &&
|
||||
(language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript)
|
||||
) {
|
||||
if (checkForFetchCall(definitionNode)) {
|
||||
result.fetchWrapperDefs.push({
|
||||
filePath: file.path,
|
||||
functionName: nameNode.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract framework routes via provider detection (e.g., Laravel routes.php)
|
||||
|
|
@ -1985,6 +2024,7 @@ let accumulated: ParseWorkerResult = {
|
|||
heritage: [],
|
||||
routes: [],
|
||||
fetchCalls: [],
|
||||
fetchWrapperDefs: [],
|
||||
decoratorRoutes: [],
|
||||
toolDefs: [],
|
||||
ormQueries: [],
|
||||
|
|
@ -2013,6 +2053,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
|
|||
appendAll(target.heritage, src.heritage);
|
||||
appendAll(target.routes, src.routes);
|
||||
appendAll(target.fetchCalls, src.fetchCalls);
|
||||
appendAll(target.fetchWrapperDefs, src.fetchWrapperDefs);
|
||||
appendAll(target.decoratorRoutes, src.decoratorRoutes);
|
||||
appendAll(target.toolDefs, src.toolDefs);
|
||||
appendAll(target.ormQueries, src.ormQueries);
|
||||
|
|
@ -2104,6 +2145,7 @@ parentPort!.on('message', (msg: WorkerIncomingMessage) => {
|
|||
heritage: [],
|
||||
routes: [],
|
||||
fetchCalls: [],
|
||||
fetchWrapperDefs: [],
|
||||
decoratorRoutes: [],
|
||||
toolDefs: [],
|
||||
ormQueries: [],
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
|
|||
* On version mismatch, `loadParseCache` returns an empty cache and the
|
||||
* next save overwrites the on-disk file with the new version baked in.
|
||||
*/
|
||||
const SCHEMA_BUMP = 1;
|
||||
const SCHEMA_BUMP = 2;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
|
|||
12
gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/api/calls.py
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/api/calls.py
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from fastapi import Depends, APIRouter
|
||||
from app.dependencies import get_current_user_record, get_db, User, Session
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/calls")
|
||||
async def list_calls(
|
||||
user: User = Depends(get_current_user_record),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return []
|
||||
14
gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/api/users.py
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/api/users.py
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from fastapi import Depends, APIRouter
|
||||
from app.dependencies import get_current_user_record, get_db, User, Session
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def get_user(user: User = Depends(get_current_user_record)):
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(db=Depends(get_db)):
|
||||
return {}
|
||||
22
gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/dependencies.py
vendored
Normal file
22
gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/dependencies.py
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from typing import Optional
|
||||
|
||||
|
||||
class Session:
|
||||
pass
|
||||
|
||||
|
||||
class User:
|
||||
id: int
|
||||
username: str
|
||||
|
||||
|
||||
async def get_db() -> Session:
|
||||
db = Session()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
pass
|
||||
|
||||
|
||||
async def get_current_user_record(db: Session) -> User:
|
||||
return User()
|
||||
4
gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/models.py
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/fastapi-depends/app/models.py
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
class CallRecord:
|
||||
id: int
|
||||
caller: str
|
||||
callee: str
|
||||
6
gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/app/api/grants/route.ts
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/app/api/grants/route.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
const grants = [{ id: 1, name: 'Research Grant' }];
|
||||
return NextResponse.json(grants);
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/app/api/users/route.ts
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/app/api/users/route.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
const users = [{ id: 1, username: 'admin' }];
|
||||
return NextResponse.json(users);
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/lib/api-client.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/lib/api-client.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const API_BASE = process.env.API_BASE || '';
|
||||
|
||||
export async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API_BASE}${path}`, opts);
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/pages/GrantsList.tsx
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/pages/GrantsList.tsx
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { apiFetch } from '../lib/api-client';
|
||||
|
||||
export default function GrantsList() {
|
||||
const loadGrants = async () => {
|
||||
const res = await apiFetch('/api/grants');
|
||||
return res.json();
|
||||
};
|
||||
return null;
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/pages/UserList.tsx
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/fetch-wrapper-consumers/pages/UserList.tsx
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { apiFetch } from '../lib/api-client';
|
||||
|
||||
export default function UserList() {
|
||||
const loadUsers = async () => {
|
||||
const res = await apiFetch('/api/users');
|
||||
return res.json();
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
|
@ -129,6 +129,7 @@ const accumulated = {
|
|||
heritage: [],
|
||||
routes: [],
|
||||
fetchCalls: [],
|
||||
fetchWrapperDefs: [],
|
||||
decoratorRoutes: [],
|
||||
toolDefs: [],
|
||||
ormQueries: [],
|
||||
|
|
|
|||
43
gitnexus/test/integration/resolvers/fastapi-depends.test.ts
Normal file
43
gitnexus/test/integration/resolvers/fastapi-depends.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
|
||||
|
||||
describe('FastAPI Depends() CALLS edge extraction', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'fastapi-depends'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('emits CALLS edges from route handlers to get_current_user_record via Depends()', () => {
|
||||
const edges = getRelationships(result, 'CALLS');
|
||||
const dependsEdges = edges.filter((e) => e.target === 'get_current_user_record');
|
||||
expect(dependsEdges.length).toBe(2);
|
||||
const sources = dependsEdges.map((e) => e.source).sort();
|
||||
expect(sources).toContain('list_calls');
|
||||
expect(sources).toContain('get_user');
|
||||
});
|
||||
|
||||
it('emits CALLS edges from route handlers to get_db via Depends()', () => {
|
||||
const edges = getRelationships(result, 'CALLS');
|
||||
const dependsEdges = edges.filter((e) => e.target === 'get_db');
|
||||
expect(dependsEdges.length).toBe(2);
|
||||
const sources = dependsEdges.map((e) => e.source).sort();
|
||||
expect(sources).toContain('list_calls');
|
||||
expect(sources).toContain('create_user');
|
||||
});
|
||||
|
||||
it('traces typed default parameter: user: User = Depends(get_current_user_record)', () => {
|
||||
const edges = getRelationships(result, 'CALLS');
|
||||
const edge = edges.find(
|
||||
(e) => e.target === 'get_current_user_record' && e.sourceFilePath.includes('calls.py'),
|
||||
);
|
||||
expect(edge).toBeDefined();
|
||||
});
|
||||
|
||||
it('traces untyped default parameter: db=Depends(get_db)', () => {
|
||||
const edges = getRelationships(result, 'CALLS');
|
||||
const edge = edges.find((e) => e.target === 'get_db' && e.sourceFilePath.includes('users.py'));
|
||||
expect(edge).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES,
|
||||
getRelationships,
|
||||
getNodesByLabel,
|
||||
runPipelineFromRepo,
|
||||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
describe('Fetch wrapper consumer FETCHES edge extraction', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'fetch-wrapper-consumers'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('creates Route nodes for API endpoints', () => {
|
||||
const routes = getNodesByLabel(result, 'Route');
|
||||
expect(routes).toContain('/api/grants');
|
||||
expect(routes).toContain('/api/users');
|
||||
});
|
||||
|
||||
it('creates FETCHES edge from GrantsList via apiFetch wrapper', () => {
|
||||
const edges = getRelationships(result, 'FETCHES');
|
||||
const grantsEdge = edges.find(
|
||||
(e) => e.sourceFilePath.includes('GrantsList') && e.target === '/api/grants',
|
||||
);
|
||||
expect(grantsEdge).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates FETCHES edge from UserList via apiFetch wrapper', () => {
|
||||
const edges = getRelationships(result, 'FETCHES');
|
||||
const usersEdge = edges.find(
|
||||
(e) => e.sourceFilePath.includes('UserList') && e.target === '/api/users',
|
||||
);
|
||||
expect(usersEdge).toBeDefined();
|
||||
});
|
||||
|
||||
it('produces the correct total number of FETCHES edges', () => {
|
||||
const edges = getRelationships(result, 'FETCHES');
|
||||
expect(edges.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -1397,6 +1397,259 @@ class ApiClient {
|
|||
).toBeDefined();
|
||||
});
|
||||
|
||||
// ─── Kotlin consumers (RestTemplate / WebClient short / OkHttp) ──
|
||||
// Same shape as the Java consumer test above, but parsed by the
|
||||
// tree-sitter-kotlin grammar via `KOTLIN_HTTP_PLUGIN`. Three
|
||||
// consumer flavors covered here (long-form WebClient
|
||||
// `webClient.method(HttpMethod.X).uri(...)` is intentionally
|
||||
// deferred to a follow-up — see kotlin.ts file header).
|
||||
//
|
||||
// tree-sitter-kotlin is an optionalDependency. If the binding is
|
||||
// unavailable, `getPluginForFile` returns undefined for `.kt` and
|
||||
// we skip the suite (matches the gating on the Provider tests).
|
||||
const kotlinConsumerAvailable = getPluginForFile('Probe.kt') !== undefined;
|
||||
const itKotlinConsumer = kotlinConsumerAvailable ? it : it.skip;
|
||||
|
||||
itKotlinConsumer('extracts Kotlin RestTemplate verbs', async () => {
|
||||
const dir = path.join(tmpDir, 'kotlin-rest-template');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'ApiClient.kt'),
|
||||
`package com.example
|
||||
import org.springframework.web.client.RestTemplate
|
||||
|
||||
class ApiClient(private val restTemplate: RestTemplate) {
|
||||
fun run() {
|
||||
restTemplate.getForObject("/api/users/1", User::class.java)
|
||||
restTemplate.getForEntity("/api/users/2", User::class.java)
|
||||
restTemplate.postForObject("/api/users", body, User::class.java)
|
||||
restTemplate.postForEntity("/api/users", body, User::class.java)
|
||||
restTemplate.put("/api/users/3", body)
|
||||
restTemplate.delete("/api/users/4")
|
||||
restTemplate.patchForObject("/api/users/5", body, User::class.java)
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}')).toBeDefined();
|
||||
expect(consumers.find((c) => c.contractId === 'http::POST::/api/users')).toBeDefined();
|
||||
expect(consumers.find((c) => c.contractId === 'http::PUT::/api/users/{param}')).toBeDefined();
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'),
|
||||
).toBeDefined();
|
||||
|
||||
// Framework label must be the same `spring-rest-template` used
|
||||
// by the Java plugin so polyglot repos coalesce on a single key.
|
||||
const restConsumers = consumers.filter((c) => c.meta.framework === 'spring-rest-template');
|
||||
expect(restConsumers.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
itKotlinConsumer('extracts Kotlin WebClient short-form verbs', async () => {
|
||||
const dir = path.join(tmpDir, 'kotlin-web-client-short');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'OrderClient.kt'),
|
||||
`package com.example
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import org.springframework.web.reactive.function.client.awaitBody
|
||||
import org.springframework.web.reactive.function.client.awaitBodilessEntity
|
||||
|
||||
class OrderClient(private val webClient: WebClient) {
|
||||
suspend fun run() {
|
||||
val r1 = webClient.get().uri("/api/orders/1").retrieve().awaitBody<Order>()
|
||||
val r2 = webClient.post().uri("/api/orders").retrieve().awaitBody<Order>()
|
||||
val r3 = webClient.put().uri("/api/orders/2").retrieve().awaitBody<Order>()
|
||||
val r4 = webClient.delete().uri("/api/orders/3").retrieve().awaitBodilessEntity()
|
||||
val r5 = webClient.patch().uri("/api/orders/4").retrieve().awaitBody<Order>()
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
|
||||
).toBeDefined();
|
||||
expect(consumers.find((c) => c.contractId === 'http::POST::/api/orders')).toBeDefined();
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::PUT::/api/orders/{param}'),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
consumers.find((c) => c.contractId === 'http::PATCH::/api/orders/{param}'),
|
||||
).toBeDefined();
|
||||
|
||||
const wcConsumers = consumers.filter((c) => c.meta.framework === 'spring-web-client');
|
||||
expect(wcConsumers.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
itKotlinConsumer('extracts Kotlin OkHttp Request.Builder().url(...)', async () => {
|
||||
const dir = path.join(tmpDir, 'kotlin-okhttp');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'OkClient.kt'),
|
||||
`package com.example
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
class OkClient(private val client: OkHttpClient) {
|
||||
fun fetch() {
|
||||
val req = Request.Builder().url("/api/items").build()
|
||||
val resp = client.newCall(req).execute()
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
const okConsumer = consumers.find((c) => c.contractId === 'http::GET::/api/items');
|
||||
expect(okConsumer).toBeDefined();
|
||||
expect(okConsumer!.meta.framework).toBe('okhttp');
|
||||
});
|
||||
|
||||
itKotlinConsumer(
|
||||
'OkHttp Request.Builder().url("/x").post(body) — verb defaults to GET (Java parity)',
|
||||
async () => {
|
||||
// Anti-overreach / known-limitation pin: OkHttp encodes the
|
||||
// HTTP verb on a sibling call (`.post(body)` / `.delete()` /
|
||||
// ...), not on `.url(...)`. The query at `kotlin.ts:OK_HTTP_PATTERNS`
|
||||
// intentionally does not walk the chain to recover the verb —
|
||||
// it emits `method: 'GET'` for every match, mirroring the Java
|
||||
// plugin's `OK_HTTP_PATTERNS` (java.ts).
|
||||
//
|
||||
// This test pins the accepted behavior so a future verb-walk
|
||||
// implementation must update kotlin.ts's known-limitation
|
||||
// comment in lockstep. Concretely:
|
||||
// - `Request.Builder().url("/api/users").post(body).build()`
|
||||
// → ONE consumer: `http::GET::/api/users` (heuristic-default)
|
||||
// → NO `http::POST::/api/users` consumer
|
||||
//
|
||||
// Test signal:
|
||||
// - if this becomes correct (POST detected) without updating
|
||||
// the kotlin.ts comment + java.ts behavior together, this
|
||||
// test goes red and the reviewer must reconcile both sides.
|
||||
const dir = path.join(tmpDir, 'kotlin-okhttp-post-chain');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'OkPostClient.kt'),
|
||||
`package com.example
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
|
||||
class OkPostClient(private val client: OkHttpClient, private val body: RequestBody) {
|
||||
fun create() {
|
||||
val req = Request.Builder().url("/api/users").post(body).build()
|
||||
client.newCall(req).execute()
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
const fromThisFile = consumers.filter((c) =>
|
||||
c.symbolRef.filePath.endsWith('OkPostClient.kt'),
|
||||
);
|
||||
|
||||
// Heuristic-default GET: exactly one consumer is emitted for
|
||||
// the .url("/x") capture, with method=GET regardless of the
|
||||
// sibling .post(body) call.
|
||||
expect(fromThisFile).toHaveLength(1);
|
||||
expect(fromThisFile[0].contractId).toBe('http::GET::/api/users');
|
||||
expect(fromThisFile[0].meta.method).toBe('GET');
|
||||
|
||||
// Anti-overreach: no second contract with POST should appear.
|
||||
// If a future verb-walk lands and this assertion needs to flip
|
||||
// (i.e. POST is now detected), bump kotlin.ts's known-limitation
|
||||
// comment and java.ts in the same PR.
|
||||
expect(fromThisFile.find((c) => c.contractId === 'http::POST::/api/users')).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
itKotlinConsumer(
|
||||
'does NOT match Kotlin WebClient long form (deferred to follow-up)',
|
||||
async () => {
|
||||
// Anti-overreach: confirm the short-form query does NOT
|
||||
// accidentally fire on the long-form chain
|
||||
// `webClient.method(HttpMethod.GET).uri(...)`. The long form
|
||||
// is intentionally unsupported in this PR; if a future change
|
||||
// to the short-form query starts capturing it we want a loud
|
||||
// signal here. Long-form support will arrive in a follow-up
|
||||
// with a dedicated query + verb walk-up helper.
|
||||
const dir = path.join(tmpDir, 'kotlin-web-client-long');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'LegacyClient.kt'),
|
||||
`package com.example
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import org.springframework.web.reactive.function.client.awaitBody
|
||||
|
||||
class LegacyClient(private val webClient: WebClient) {
|
||||
suspend fun run() {
|
||||
val r = webClient.method(HttpMethod.GET).uri("/api/legacy").retrieve().awaitBody<String>()
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
// No consumer should be emitted from this file by the
|
||||
// current short-form query. Documented as a known limitation.
|
||||
const fromLegacy = consumers.filter((c) =>
|
||||
c.symbolRef.filePath.endsWith('LegacyClient.kt'),
|
||||
);
|
||||
expect(fromLegacy).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
itKotlinConsumer(
|
||||
'does NOT pick up unrelated string-literal calls on a non-restTemplate receiver',
|
||||
async () => {
|
||||
// Anti-regression: the RestTemplate receiver constraint
|
||||
// (#eq? @obj "restTemplate") must hold. A field with a
|
||||
// different conventional name (e.g. `cacheClient`) calling
|
||||
// `.getForObject("/x", ...)` should NOT produce a route.
|
||||
const dir = path.join(tmpDir, 'kotlin-rest-template-other-receiver');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'CacheClient.kt'),
|
||||
`package com.example
|
||||
|
||||
class CacheClient(private val cacheClient: SomeCache) {
|
||||
fun run() {
|
||||
cacheClient.getForObject("/cache/key", String::class.java)
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(consumers.find((c) => c.contractId === 'http::GET::/cache/key')).toBeUndefined();
|
||||
const fromCache = consumers.filter((c) => c.symbolRef.filePath.endsWith('CacheClient.kt'));
|
||||
expect(fromCache).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('extracts Go stdlib and resty calls', async () => {
|
||||
const dir = path.join(tmpDir, 'go-consumer');
|
||||
fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const minimalResult = (overrides: Partial<ParseWorkerResult> = {}): ParseWorkerR
|
|||
heritage: [],
|
||||
routes: [],
|
||||
fetchCalls: [],
|
||||
fetchWrapperDefs: [],
|
||||
decoratorRoutes: [],
|
||||
toolDefs: [],
|
||||
ormQueries: [],
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ const emptyWorkerResult = (filePath: string, name: string): ParseWorkerResult =>
|
|||
heritage: [],
|
||||
routes: [],
|
||||
fetchCalls: [],
|
||||
fetchWrapperDefs: [],
|
||||
decoratorRoutes: [],
|
||||
toolDefs: [],
|
||||
ormQueries: [],
|
||||
|
|
@ -73,7 +74,7 @@ fs.writeFileSync(${JSON.stringify(markerPath)}, 'spawned');
|
|||
parentPort.postMessage({ type: 'ready' });
|
||||
const accumulated = {
|
||||
nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [],
|
||||
routes: [], fetchCalls: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], constructorBindings: [],
|
||||
routes: [], fetchCalls: [], fetchWrapperDefs: [], decoratorRoutes: [], toolDefs: [], ormQueries: [], constructorBindings: [],
|
||||
fileScopeBindings: [], parsedFiles: [], skippedLanguages: {}, fileCount: 0,
|
||||
};
|
||||
parentPort.on('message', (msg) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue