mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-29 01:41:42 +00:00
Merge branch 'main' into feat/multi-cli-devcontainer
This commit is contained in:
commit
dab73c6e5d
5 changed files with 532 additions and 59 deletions
|
|
@ -8,7 +8,13 @@ import { PYTHON_HTTP_PLUGIN } from './python.js';
|
|||
import { PHP_HTTP_PLUGIN } from './php.js';
|
||||
import { JAVASCRIPT_HTTP_PLUGIN, TYPESCRIPT_HTTP_PLUGIN, TSX_HTTP_PLUGIN } from './node.js';
|
||||
|
||||
export type { HttpDetection, HttpLanguagePlugin, HttpRole } from './types.js';
|
||||
export type {
|
||||
HttpDetection,
|
||||
HttpFileDetections,
|
||||
HttpLanguagePlugin,
|
||||
HttpRole,
|
||||
HttpScanInput,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* File-extension → HTTP language plugin registry. The top-level
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ import {
|
|||
unquoteLiteral,
|
||||
type LanguagePatterns,
|
||||
} from '../tree-sitter-scanner.js';
|
||||
import type { HttpDetection, HttpLanguagePlugin } from './types.js';
|
||||
import type {
|
||||
HttpDetection,
|
||||
HttpFileDetections,
|
||||
HttpLanguagePlugin,
|
||||
HttpScanInput,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Java HTTP plugin. Handles:
|
||||
|
|
@ -46,31 +51,85 @@ const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
|
|||
// route prefixes — e.g. `produces = "application/json"` would corrupt
|
||||
// every method route under that controller). The sibling
|
||||
// `topic-patterns/java.ts` uses the same `key:` constraint approach.
|
||||
const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({
|
||||
name: 'java-spring-class-prefix',
|
||||
interface SpringRouteBinding {
|
||||
method: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface SpringMethodInfo {
|
||||
name: string;
|
||||
routes: SpringRouteBinding[];
|
||||
}
|
||||
|
||||
interface SpringTypeInfo {
|
||||
filePath: string;
|
||||
kind: 'class' | 'interface';
|
||||
name: string;
|
||||
classPrefix: string;
|
||||
implementedInterfaces: string[];
|
||||
isController: boolean;
|
||||
methods: SpringMethodInfo[];
|
||||
}
|
||||
|
||||
// ─── Provider: Spring class/interface-level @RequestMapping prefix ───
|
||||
const SPRING_TYPE_PREFIX_PATTERNS = compilePatterns({
|
||||
name: 'java-spring-type-prefix',
|
||||
language: Java,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
arguments: (annotation_argument_list (string_literal) @prefix)))) @class
|
||||
[
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
arguments: (annotation_argument_list (string_literal) @prefix)))) @type
|
||||
(interface_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
arguments: (annotation_argument_list (string_literal) @prefix)))) @type
|
||||
]
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key (#match? @key "^(path|value)$")
|
||||
value: (string_literal) @prefix))))) @class
|
||||
[
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key (#match? @key "^(path|value)$")
|
||||
value: (string_literal) @prefix))))) @type
|
||||
(interface_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key (#match? @key "^(path|value)$")
|
||||
value: (string_literal) @prefix))))) @type
|
||||
]
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
const SPRING_TYPE_DECLARATION_PATTERNS = compilePatterns({
|
||||
name: 'java-spring-type-declaration',
|
||||
language: Java,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
[
|
||||
(class_declaration name: (identifier) @type_name) @type
|
||||
(interface_declaration name: (identifier) @type_name) @type
|
||||
]
|
||||
`,
|
||||
},
|
||||
],
|
||||
|
|
@ -315,9 +374,9 @@ const APACHE_HTTP_CLIENT_PATTERNS = compilePatterns({
|
|||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
/**
|
||||
* Find the nearest enclosing class_declaration ancestor for a node, or
|
||||
* null if the node is top-level. Tree-sitter's SyntaxNode.parent walks
|
||||
* one level at a time.
|
||||
* Find the nearest enclosing class/interface declaration ancestor for
|
||||
* a node, or null if the node is top-level. Tree-sitter's
|
||||
* SyntaxNode.parent walks one level at a time.
|
||||
*/
|
||||
function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
|
||||
let cur: Parser.SyntaxNode | null = node.parent;
|
||||
|
|
@ -337,20 +396,6 @@ function findEnclosingInterface(node: Parser.SyntaxNode): Parser.SyntaxNode | nu
|
|||
return null;
|
||||
}
|
||||
|
||||
function hasAnnotation(node: Parser.SyntaxNode, annotationName: string): boolean {
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type !== 'modifiers') continue;
|
||||
for (const modifier of child.namedChildren) {
|
||||
if (modifier.type !== 'annotation') continue;
|
||||
const nameNode = modifier.childForFieldName('name');
|
||||
if (!nameNode) continue;
|
||||
const simpleName = nameNode.text.split('.').pop();
|
||||
if (nameNode.text === annotationName || simpleName === annotationName) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a class-level prefix and a method-level path into a single URL
|
||||
* path. Mirrors the semantics of the original regex implementation:
|
||||
|
|
@ -364,6 +409,184 @@ function joinPath(prefix: string, methodPath: string): string {
|
|||
return `/${cleanPrefix}/${cleanSub}`;
|
||||
}
|
||||
|
||||
function getNodeName(node: Parser.SyntaxNode): string | null {
|
||||
return node.childForFieldName('name')?.text ?? null;
|
||||
}
|
||||
|
||||
function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[]): boolean {
|
||||
const modifiers = node.namedChildren.find((child) => child.type === 'modifiers');
|
||||
if (!modifiers) return false;
|
||||
const allowed = new Set(typeof names === 'string' ? [names] : names);
|
||||
const stack = [...modifiers.namedChildren];
|
||||
while (stack.length > 0) {
|
||||
const cur = stack.pop()!;
|
||||
const annotationName = cur.childForFieldName('name')?.text ?? '';
|
||||
const simpleName = annotationName.split('.').pop() ?? annotationName;
|
||||
if (
|
||||
(cur.type === 'annotation' || cur.type === 'marker_annotation') &&
|
||||
(allowed.has(annotationName) || allowed.has(simpleName))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
stack.push(...cur.namedChildren);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectTypePrefixes(tree: Parser.Tree): Map<number, string> {
|
||||
const prefixByTypeId = new Map<number, string>();
|
||||
for (const match of runCompiledPatterns(SPRING_TYPE_PREFIX_PATTERNS, tree)) {
|
||||
const prefixNode = match.captures.prefix;
|
||||
const typeNode = match.captures.type;
|
||||
if (!prefixNode || !typeNode) continue;
|
||||
const prefix = unquoteLiteral(prefixNode.text);
|
||||
if (prefix !== null) prefixByTypeId.set(typeNode.id, prefix);
|
||||
}
|
||||
return prefixByTypeId;
|
||||
}
|
||||
|
||||
function collectMethodRoutes(tree: Parser.Tree): Map<number, SpringRouteBinding[]> {
|
||||
const routesByMethodId = new Map<number, SpringRouteBinding[]>();
|
||||
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
|
||||
const annNode = match.captures.ann;
|
||||
const pathNode = match.captures.path;
|
||||
const methodNode = match.captures.method;
|
||||
if (!annNode || !pathNode || !methodNode) continue;
|
||||
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
|
||||
if (!httpMethod) continue;
|
||||
const rawPath = unquoteLiteral(pathNode.text);
|
||||
if (rawPath === null) continue;
|
||||
const routes = routesByMethodId.get(methodNode.id) ?? [];
|
||||
routes.push({ method: httpMethod, path: rawPath });
|
||||
routesByMethodId.set(methodNode.id, routes);
|
||||
}
|
||||
return routesByMethodId;
|
||||
}
|
||||
|
||||
function collectDirectMethods(typeNode: Parser.SyntaxNode): Parser.SyntaxNode[] {
|
||||
const out: Parser.SyntaxNode[] = [];
|
||||
const visit = (node: Parser.SyntaxNode): void => {
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type === 'method_declaration') {
|
||||
out.push(child);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
child !== typeNode &&
|
||||
(child.type === 'class_declaration' || child.type === 'interface_declaration')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(typeNode);
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectImplementedInterfaces(typeNode: Parser.SyntaxNode): string[] {
|
||||
const interfacesNode = typeNode.childForFieldName('interfaces');
|
||||
if (!interfacesNode) return [];
|
||||
const out: string[] = [];
|
||||
const visit = (node: Parser.SyntaxNode): void => {
|
||||
if (node.type === 'type_identifier' || node.type === 'scoped_type_identifier') {
|
||||
out.push(node.text.split('.').pop() ?? node.text);
|
||||
return;
|
||||
}
|
||||
for (const child of node.namedChildren) visit(child);
|
||||
};
|
||||
visit(interfacesNode);
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectSpringTypes(filePath: string, tree: Parser.Tree): SpringTypeInfo[] {
|
||||
const prefixByTypeId = collectTypePrefixes(tree);
|
||||
const routesByMethodId = collectMethodRoutes(tree);
|
||||
const out: SpringTypeInfo[] = [];
|
||||
|
||||
for (const match of runCompiledPatterns(SPRING_TYPE_DECLARATION_PATTERNS, tree)) {
|
||||
const typeNode = match.captures.type;
|
||||
const typeNameNode = match.captures.type_name;
|
||||
if (!typeNode || !typeNameNode) continue;
|
||||
const kind = typeNode.type === 'interface_declaration' ? 'interface' : 'class';
|
||||
const methods = collectDirectMethods(typeNode)
|
||||
.map((methodNode) => ({
|
||||
name: getNodeName(methodNode),
|
||||
routes: routesByMethodId.get(methodNode.id) ?? [],
|
||||
}))
|
||||
.filter((method): method is SpringMethodInfo => method.name !== null);
|
||||
|
||||
out.push({
|
||||
filePath,
|
||||
kind,
|
||||
name: typeNameNode.text,
|
||||
classPrefix: prefixByTypeId.get(typeNode.id) ?? '',
|
||||
implementedInterfaces: kind === 'class' ? collectImplementedInterfaces(typeNode) : [],
|
||||
isController: kind === 'class' && hasAnnotation(typeNode, ['RestController', 'Controller']),
|
||||
methods,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function scanSpringProject(files: readonly HttpScanInput[]): HttpFileDetections[] {
|
||||
const types = files.flatMap((file) => collectSpringTypes(file.filePath, file.tree));
|
||||
const interfaceRoutes = new Map<string, Map<string, SpringRouteBinding[]> | null>();
|
||||
|
||||
for (const type of types) {
|
||||
if (type.kind !== 'interface') continue;
|
||||
if (interfaceRoutes.has(type.name)) {
|
||||
interfaceRoutes.set(type.name, null);
|
||||
continue;
|
||||
}
|
||||
const methodMap = new Map<string, SpringRouteBinding[]>();
|
||||
for (const method of type.methods) {
|
||||
const routes = method.routes.map((route) => ({
|
||||
method: route.method,
|
||||
path: type.classPrefix ? joinPath(type.classPrefix, route.path) : route.path,
|
||||
}));
|
||||
if (routes.length > 0) methodMap.set(method.name, routes);
|
||||
}
|
||||
interfaceRoutes.set(type.name, methodMap);
|
||||
}
|
||||
|
||||
const detectionsByFile = new Map<string, HttpDetection[]>();
|
||||
for (const type of types) {
|
||||
if (type.kind !== 'class' || !type.isController) continue;
|
||||
for (const method of type.methods) {
|
||||
if (method.routes.length > 0) continue;
|
||||
const inheritedRoutes = type.implementedInterfaces.flatMap((interfaceName) => {
|
||||
const routeMap = interfaceRoutes.get(interfaceName);
|
||||
if (!routeMap) return [];
|
||||
const routes = routeMap.get(method.name) ?? [];
|
||||
return routes.map((route) => ({
|
||||
method: route.method,
|
||||
path: joinPath(type.classPrefix, route.path),
|
||||
}));
|
||||
});
|
||||
|
||||
for (const route of inheritedRoutes) {
|
||||
const detections = detectionsByFile.get(type.filePath) ?? [];
|
||||
detections.push({
|
||||
role: 'provider',
|
||||
framework: 'spring',
|
||||
method: route.method,
|
||||
path: route.path,
|
||||
name: method.name,
|
||||
confidence: 0.8,
|
||||
});
|
||||
detectionsByFile.set(type.filePath, detections);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...detectionsByFile.entries()].map(([filePath, detections]) => ({
|
||||
filePath,
|
||||
detections,
|
||||
}));
|
||||
}
|
||||
|
||||
export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
name: 'java-http',
|
||||
language: Java,
|
||||
|
|
@ -371,14 +594,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
const out: HttpDetection[] = [];
|
||||
|
||||
// ─── Providers: Spring class prefix + method annotations ────────
|
||||
const prefixByClassId = new Map<number, string>();
|
||||
for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) {
|
||||
const prefixNode = match.captures.prefix;
|
||||
const classNode = match.captures.class;
|
||||
if (!prefixNode || !classNode) continue;
|
||||
const prefix = unquoteLiteral(prefixNode.text);
|
||||
if (prefix !== null) prefixByClassId.set(classNode.id, prefix);
|
||||
}
|
||||
const prefixByTypeId = collectTypePrefixes(tree);
|
||||
|
||||
const feignPrefixByInterfaceId = new Map<number, string>();
|
||||
for (const match of runCompiledPatterns(FEIGN_INTERFACE_PREFIX_PATTERNS, tree)) {
|
||||
|
|
@ -415,7 +631,8 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
continue;
|
||||
}
|
||||
const enclosingClass = findEnclosingClass(methodNode);
|
||||
const prefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : '';
|
||||
if (!enclosingClass) continue;
|
||||
const prefix = prefixByTypeId.get(enclosingClass.id) ?? '';
|
||||
const fullPath = joinPath(prefix, rawPath);
|
||||
out.push({
|
||||
role: 'provider',
|
||||
|
|
@ -540,4 +757,5 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
|
||||
return out;
|
||||
},
|
||||
scanProject: scanSpringProject,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -40,6 +40,16 @@ export interface HttpDetection {
|
|||
confidence: number;
|
||||
}
|
||||
|
||||
export interface HttpScanInput {
|
||||
filePath: string;
|
||||
tree: Parser.Tree;
|
||||
}
|
||||
|
||||
export interface HttpFileDetections {
|
||||
filePath: string;
|
||||
detections: HttpDetection[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One language-scoped HTTP plugin. The plugin owns the tree-sitter
|
||||
* grammar and the `scan` function that translates a parsed tree into
|
||||
|
|
@ -95,4 +105,10 @@ export interface HttpLanguagePlugin {
|
|||
* single-file plugins can keep their unary `scan(tree)` shape.
|
||||
*/
|
||||
scan(tree: Parser.Tree, repoContext?: RepoContext, fileRel?: string): HttpDetection[];
|
||||
/**
|
||||
* Optional project-level scan hook for language rules that require
|
||||
* multiple files, such as Java controllers inheriting Spring mappings
|
||||
* from annotated interfaces.
|
||||
*/
|
||||
scanProject?(files: readonly HttpScanInput[]): HttpFileDetections[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@ import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js
|
|||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
import { readSafe } from './fs-utils.js';
|
||||
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
|
||||
import { getPluginForFile, HTTP_SCAN_GLOB, type HttpDetection } from './http-patterns/index.js';
|
||||
import {
|
||||
getPluginForFile,
|
||||
HTTP_SCAN_GLOB,
|
||||
type HttpDetection,
|
||||
type HttpLanguagePlugin,
|
||||
type HttpScanInput,
|
||||
} from './http-patterns/index.js';
|
||||
|
||||
/**
|
||||
* Language-agnostic orchestrator for HTTP route (provider + consumer)
|
||||
|
|
@ -160,6 +166,12 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
// both graph-assisted enrichment and source-scan emission.
|
||||
const parser = new Parser();
|
||||
const cachedDetections = new Map<string, HttpDetection[]>();
|
||||
const cachedInputs = new Map<
|
||||
string,
|
||||
{ plugin: HttpLanguagePlugin; input: HttpScanInput; repoContext: unknown } | null
|
||||
>();
|
||||
const projectDetections = new Map<string, HttpDetection[]>();
|
||||
let projectScanComplete = false;
|
||||
|
||||
// Per-plugin cross-file context (e.g. Python's FastAPI router →
|
||||
// include_router(prefix=...) map). Built lazily on first
|
||||
|
|
@ -189,32 +201,50 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
}
|
||||
};
|
||||
|
||||
const getDetections = async (rel: string): Promise<HttpDetection[]> => {
|
||||
const cached = cachedDetections.get(rel);
|
||||
if (cached) return cached;
|
||||
const getScanInput = async (
|
||||
rel: string,
|
||||
): Promise<{
|
||||
plugin: HttpLanguagePlugin;
|
||||
input: HttpScanInput;
|
||||
repoContext: unknown;
|
||||
} | null> => {
|
||||
if (cachedInputs.has(rel)) return cachedInputs.get(rel) ?? null;
|
||||
const plugin = getPluginForFile(rel);
|
||||
if (!plugin) {
|
||||
cachedDetections.set(rel, []);
|
||||
return [];
|
||||
cachedInputs.set(rel, null);
|
||||
return null;
|
||||
}
|
||||
const repoContext = await ensureRepoContext(plugin);
|
||||
const content = readSafe(repoPath, rel);
|
||||
if (!content) {
|
||||
cachedDetections.set(rel, []);
|
||||
return [];
|
||||
cachedInputs.set(rel, null);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
parser.setLanguage(plugin.language);
|
||||
const tree = parseSourceSafe(parser, content);
|
||||
const detections = plugin.scan(tree, repoContext, rel);
|
||||
cachedDetections.set(rel, detections);
|
||||
return detections;
|
||||
const input = { filePath: rel, tree };
|
||||
const item = { plugin, input, repoContext };
|
||||
cachedInputs.set(rel, item);
|
||||
return item;
|
||||
} catch {
|
||||
cachedDetections.set(rel, []);
|
||||
return [];
|
||||
cachedInputs.set(rel, null);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getDetections = async (rel: string): Promise<HttpDetection[]> => {
|
||||
const cached = cachedDetections.get(rel);
|
||||
if (cached) return cached;
|
||||
const scanInput = await getScanInput(rel);
|
||||
const ownDetections = scanInput
|
||||
? scanInput.plugin.scan(scanInput.input.tree, scanInput.repoContext, rel)
|
||||
: [];
|
||||
const detections = [...ownDetections, ...(projectDetections.get(rel) ?? [])];
|
||||
cachedDetections.set(rel, detections);
|
||||
return detections;
|
||||
};
|
||||
|
||||
// Glob the source-scan file list at most once per extract() —
|
||||
// both provider and consumer fallback paths share the same list.
|
||||
let scannedFiles: string[] | null = null;
|
||||
|
|
@ -224,20 +254,46 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
return scannedFiles;
|
||||
};
|
||||
|
||||
const collectProjectDetections = async (files: string[]): Promise<void> => {
|
||||
if (projectScanComplete) return;
|
||||
projectScanComplete = true;
|
||||
const byPlugin = new Map<HttpLanguagePlugin, HttpScanInput[]>();
|
||||
for (const rel of files) {
|
||||
const scanInput = await getScanInput(rel);
|
||||
if (!scanInput?.plugin.scanProject) continue;
|
||||
const items = byPlugin.get(scanInput.plugin) ?? [];
|
||||
items.push(scanInput.input);
|
||||
byPlugin.set(scanInput.plugin, items);
|
||||
}
|
||||
|
||||
for (const [plugin, inputs] of byPlugin) {
|
||||
const results = plugin.scanProject?.(inputs) ?? [];
|
||||
for (const result of results) {
|
||||
const existing = projectDetections.get(result.filePath) ?? [];
|
||||
projectDetections.set(result.filePath, [...existing, ...result.detections]);
|
||||
}
|
||||
}
|
||||
|
||||
cachedDetections.clear();
|
||||
};
|
||||
|
||||
const files = await getScannedFiles();
|
||||
await collectProjectDetections(files);
|
||||
|
||||
const graphProviders =
|
||||
dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, getDetections) : [];
|
||||
// Source scan always runs to capture routes in languages/files not covered
|
||||
// by graph edges; the glob and per-file parse results are cached above.
|
||||
const providers = this.mergeGraphAndSourceContracts(
|
||||
graphProviders,
|
||||
await this.extractProvidersSourceScan(await getScannedFiles(), getDetections),
|
||||
await this.extractProvidersSourceScan(files, getDetections),
|
||||
);
|
||||
|
||||
const graphConsumers =
|
||||
dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, getDetections) : [];
|
||||
const consumers = this.mergeGraphAndSourceContracts(
|
||||
graphConsumers,
|
||||
await this.extractConsumersSourceScan(await getScannedFiles(), getDetections),
|
||||
await this.extractConsumersSourceScan(files, getDetections),
|
||||
);
|
||||
|
||||
return [...providers, ...consumers];
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ describe('HttpRouteExtractor', () => {
|
|||
});
|
||||
});
|
||||
|
||||
const toPosixPath = (filePath: string): string => filePath.replace(/\\/g, '/');
|
||||
|
||||
describe('provider extraction — graph-first (Strategy A)', () => {
|
||||
it('extracts routes from Route/HANDLES_ROUTE graph + source scan for method', async () => {
|
||||
const dir = path.join(tmpDir, 'graph-first');
|
||||
|
|
@ -832,6 +834,181 @@ class UserController {
|
|||
},
|
||||
);
|
||||
|
||||
it('does not emit annotated Java interfaces as concrete Spring provider routes', async () => {
|
||||
const dir = path.join(tmpDir, 'spring-interface-only');
|
||||
fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src/rest/DepartmentApi.java'),
|
||||
`
|
||||
package com.example.rest;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RequestMapping("/departments")
|
||||
public interface DepartmentApi {
|
||||
@GetMapping("")
|
||||
Object list();
|
||||
|
||||
@GetMapping("/{name}")
|
||||
Object getByName(@PathVariable String name);
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
|
||||
expect(providers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('inherits Spring interface route mappings when controller methods omit annotations', async () => {
|
||||
const dir = path.join(tmpDir, 'spring-interface-inherited-methods');
|
||||
fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src/rest/StatusApi.java'),
|
||||
`
|
||||
package com.example.rest;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RequestMapping("/status")
|
||||
public interface StatusApi {
|
||||
@GetMapping("")
|
||||
Object getStatus();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src/controller/StatusController.java'),
|
||||
`
|
||||
package com.example.controller;
|
||||
import com.example.rest.StatusApi;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
public class StatusController implements StatusApi {
|
||||
@Override
|
||||
public Object getStatus() { return null; }
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
|
||||
const statusRoute = providers.find((c) => c.contractId === 'http::GET::/status');
|
||||
expect(statusRoute).toBeDefined();
|
||||
expect(toPosixPath(statusRoute!.symbolRef.filePath)).toBe(
|
||||
'src/controller/StatusController.java',
|
||||
);
|
||||
expect(statusRoute!.symbolName).toBe('getStatus');
|
||||
expect(providers.filter((c) => c.symbolRef.filePath.includes('StatusApi.java'))).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('combines controller class mapping with inherited interface method mapping', async () => {
|
||||
const dir = path.join(tmpDir, 'spring-interface-controller-prefix');
|
||||
fs.mkdirSync(path.join(dir, 'src/rest'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src/rest/UserApi.java'),
|
||||
`
|
||||
package com.example.rest;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
public interface UserApi {
|
||||
@GetMapping("/users")
|
||||
Object listUsers();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src/controller/UserController.java'),
|
||||
`
|
||||
package com.example.controller;
|
||||
import com.example.rest.UserApi;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class UserController implements UserApi {
|
||||
@Override
|
||||
public Object listUsers() { return null; }
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
|
||||
const usersRoute = providers.find((c) => c.contractId === 'http::GET::/api/users');
|
||||
expect(usersRoute).toBeDefined();
|
||||
expect(toPosixPath(usersRoute!.symbolRef.filePath)).toBe(
|
||||
'src/controller/UserController.java',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips ambiguous inherited routes when interfaces share a simple name', async () => {
|
||||
const dir = path.join(tmpDir, 'spring-interface-simple-name-collision');
|
||||
fs.mkdirSync(path.join(dir, 'src/a'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'src/b'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src/a/StatusApi.java'),
|
||||
`
|
||||
package com.example.a;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
public interface StatusApi {
|
||||
@GetMapping("/a/status")
|
||||
Object getStatus();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src/b/StatusApi.java'),
|
||||
`
|
||||
package com.example.b;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
public interface StatusApi {
|
||||
@GetMapping("/b/status")
|
||||
Object getStatus();
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src/controller/StatusController.java'),
|
||||
`
|
||||
package com.example.controller;
|
||||
import com.example.a.StatusApi;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
public class StatusController implements StatusApi {
|
||||
@Override
|
||||
public Object getStatus() { return null; }
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, dir, makeRepo(dir));
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
|
||||
expect(providers.find((c) => c.contractId === 'http::GET::/b/status')).toBeUndefined();
|
||||
expect(providers.find((c) => c.contractId === 'http::GET::/a/status')).toBeUndefined();
|
||||
expect(
|
||||
providers.filter((c) => c.symbolRef.filePath.includes('StatusController.java')),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('extracts Express router.get patterns', async () => {
|
||||
const dir = path.join(tmpDir, 'express');
|
||||
fs.mkdirSync(path.join(dir, 'src/routes'), { recursive: true });
|
||||
|
|
@ -1537,7 +1714,7 @@ interface NotFeignClient {
|
|||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
|
||||
expect(consumers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeUndefined();
|
||||
expect(providers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeDefined();
|
||||
expect(providers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('extracts OpenFeign clients with @RequestMapping interface prefixes', async () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue