Merge remote-tracking branch 'origin/main' into fix/blind-spots-impact-honest-absence

# Conflicts:
#	gitnexus/src/storage/parse-cache.ts
#	gitnexus/test/unit/incremental-parse-cache.test.ts
This commit is contained in:
ReidenXerx 2026-08-07 16:39:53 +03:00
commit f8c75af15b
11 changed files with 626 additions and 69 deletions

View file

@ -7,7 +7,8 @@ import {
type LanguagePatterns,
} from '../tree-sitter-scanner.js';
import {
METHOD_ANNOTATION_TO_HTTP,
springAnnotationHttpMethods,
intersectSpringHttpMethods,
isRouteMemberKey,
findEnclosingClass,
joinPath,
@ -44,7 +45,7 @@ import type {
/**
* Java HTTP plugin. Handles:
* - Spring `@RequestMapping` class prefixes + `@(Get|Post|...)Mapping` method annotations
* - Spring `@RequestMapping` class prefixes + shortcut/`@RequestMapping` method annotations
* - Spring `RestTemplate.getForObject/...`, `exchange(...)`
* - Spring `WebClient.method(HttpMethod.X, ...)`, `WebClient.get().uri(...)`
* - OkHttp `new Request.Builder().url("...")`
@ -408,6 +409,35 @@ function simpleName(text: string): string {
return text.split('.').pop() ?? text;
}
function declarationAnnotations(node: Parser.SyntaxNode): Parser.SyntaxNode[] {
const modifiers = node.namedChildren.find((child) => child.type === 'modifiers');
if (!modifiers) return [];
return modifiers.namedChildren.filter(
(child) => child.type === 'annotation' || child.type === 'marker_annotation',
);
}
function annotationHasRouteMember(annotation: Parser.SyntaxNode): boolean {
const args = annotation.childForFieldName('arguments');
if (!args) return false;
for (const child of args.namedChildren) {
if (child.type !== 'element_value_pair') return true;
const key = child.childForFieldName('key');
if (isRouteMemberKey(key ?? undefined)) return true;
}
return false;
}
function typeRequestMethods(typeNode: Parser.SyntaxNode): readonly string[] {
const mappings = declarationAnnotations(typeNode).filter(
(annotation) =>
simpleName(annotation.childForFieldName('name')?.text ?? '') === 'RequestMapping',
);
if (mappings.length === 0) return ['*'];
if (mappings.length !== 1) return [];
return springAnnotationHttpMethods('RequestMapping', mappings[0].text);
}
function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[]): boolean {
const modifiers = node.namedChildren.find((child) => child.type === 'modifiers');
if (!modifiers) return false;
@ -437,6 +467,8 @@ interface MethodRouteAnnotation {
methodName: string | null;
httpMethod: string;
rawPath: string;
/** OpenFeign's single effective verb; null means its contract is invalid/ambiguous. */
feignHttpMethod?: string | null;
}
interface RequestLineAnnotation {
@ -452,7 +484,7 @@ interface RouteAnnotationScan {
feignPrefixByInterfaceId: Map<number, string[]>;
/** Spring HTTP Interface `@HttpExchange(url|value)` type-level prefixes per class/interface node id. */
httpExchangePrefixByTypeId: Map<number, string[]>;
/** One entry per resolved Spring `@(Get|...)Mapping` route — a method with N mappings yields N entries. */
/** Resolved Spring shortcut/`@RequestMapping` routes — paths × verbs yield one entry each. */
methodRoutes: MethodRouteAnnotation[];
/** One entry per OpenFeign `@RequestLine` whose value parses to a verb + path. */
requestLines: RequestLineAnnotation[];
@ -484,6 +516,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
const methodRoutes: MethodRouteAnnotation[] = [];
const requestLines: RequestLineAnnotation[] = [];
const exchangeRoutes: MethodRouteAnnotation[] = [];
const httpMethodsByAnnotationId = new Map<number, readonly string[]>();
// Interface `@RequestMapping` prefixes rank below `@FeignClient(path)`;
// collect them and apply only after the FeignClient pass below.
const interfaceRequestMappingPrefixes: Array<{ id: number; prefix: string }> = [];
@ -505,18 +538,29 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
const keyNode = captures.key; // undefined for the positional shape
if (node.type === 'method_declaration') {
// Method-level: a Spring `@(Get|...)Mapping` route, or native `@RequestLine`.
const httpMethod = METHOD_ANNOTATION_TO_HTTP[ann];
if (httpMethod) {
// Method-level: a Spring shortcut/`@RequestMapping` route, or native `@RequestLine`.
const annotationNode = annNode.parent;
if (!annotationNode) continue;
let httpMethods = httpMethodsByAnnotationId.get(annotationNode.id);
if (!httpMethods) {
httpMethods = springAnnotationHttpMethods(ann, annotationNode.text);
httpMethodsByAnnotationId.set(annotationNode.id, httpMethods);
}
if (httpMethods.length > 0) {
const feignHttpMethod =
httpMethods.length === 1 ? (httpMethods[0] === '*' ? 'GET' : httpMethods[0]) : null;
if (!isRouteMemberKey(keyNode)) continue;
const rawPath = unquoteLiteral(valueNode.text);
if (rawPath !== null) {
methodRoutes.push({
methodNode: node,
methodName: captures.member?.text ?? null,
httpMethod,
rawPath,
});
for (const httpMethod of httpMethods) {
methodRoutes.push({
methodNode: node,
methodName: captures.member?.text ?? null,
httpMethod,
rawPath,
feignHttpMethod,
});
}
}
} else if (ann === 'RequestLine') {
// Feign packs verb + path in one literal; its only named argument is `value`.
@ -573,6 +617,43 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
}
}
const classHttpMethodsByTypeId = new Map<number, readonly string[]>();
for (const match of runCompiledPatterns(SPRING_TYPE_DECLARATION_PATTERNS, tree)) {
const typeNode = match.captures.type;
if (!typeNode) continue;
const classMethods = typeRequestMethods(typeNode);
classHttpMethodsByTypeId.set(typeNode.id, classMethods);
for (const methodNode of collectDirectMethods(typeNode)) {
for (const annotationNode of declarationAnnotations(methodNode)) {
if (annotationHasRouteMember(annotationNode)) continue;
const ann = simpleName(annotationNode.childForFieldName('name')?.text ?? '');
const httpMethods = springAnnotationHttpMethods(ann, annotationNode.text);
if (httpMethods.length === 0) continue;
const feignHttpMethod =
httpMethods.length === 1 ? (httpMethods[0] === '*' ? 'GET' : httpMethods[0]) : null;
for (const httpMethod of httpMethods) {
methodRoutes.push({
methodNode,
methodName: getNodeName(methodNode),
httpMethod,
rawPath: '',
feignHttpMethod,
});
}
}
}
}
const constrainedMethodRoutes = methodRoutes.flatMap((route) => {
const typeNode =
findEnclosingInterface(route.methodNode) ?? findEnclosingClass(route.methodNode);
const classMethods = typeNode ? (classHttpMethodsByTypeId.get(typeNode.id) ?? ['*']) : ['*'];
return intersectSpringHttpMethods(classMethods, [route.httpMethod]).map((httpMethod) => ({
...route,
httpMethod,
}));
});
// `@RequestMapping` on a Feign interface is the fallback prefix, but only when
// the interface has no `@FeignClient(path)` of its own (path wins).
for (const { id, prefix } of interfaceRequestMappingPrefixes) {
@ -583,7 +664,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
prefixByTypeId,
feignPrefixByInterfaceId,
httpExchangePrefixByTypeId,
methodRoutes,
methodRoutes: constrainedMethodRoutes,
requestLines,
exchangeRoutes,
};
@ -705,7 +786,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
// ─── Spring providers + OpenFeign consumers (one query pass) ────
// `scanRouteAnnotations` resolves every route-defining annotation —
// class/interface prefixes, method `@(Get|...)Mapping`s and native
// class/interface prefixes, method shortcut/`@RequestMapping`s and native
// `@RequestLine`s — from a single `matches()` pass over the tree.
const {
prefixByTypeId,
@ -724,12 +805,13 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
for (const route of methodRoutes) {
const enclosingInterface = findEnclosingInterface(route.methodNode);
if (enclosingInterface && hasAnnotation(enclosingInterface, 'FeignClient')) {
if (!route.feignHttpMethod) continue;
const prefixes = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? [''];
for (const prefix of prefixes) {
out.push({
role: 'consumer',
framework: OPENFEIGN_FRAMEWORK,
method: route.httpMethod,
method: route.feignHttpMethod,
path: joinPath(prefix, route.rawPath),
name: route.methodName,
line: route.methodNode.startPosition.row + 1,

View file

@ -17,6 +17,7 @@
*/
import type Parser from 'tree-sitter';
import { parseSpringAnnotationArguments } from '../frameworks/spring/annotation-arguments.js';
/**
* Spring shortcut method-annotation → HTTP verb.
@ -34,6 +35,91 @@ export const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
PatchMapping: 'PATCH',
};
/**
* Parse one `RequestMethod.X` literal or a Java annotation array of literals.
* An empty array is valid and means Spring's unrestricted/default method set.
* Runtime expressions fail closed instead of producing a guessed route.
*/
function parseRequestMethodValues(value: string): readonly string[] | null {
let trimmed = '';
for (let index = 0; index < value.length; index += 1) {
const char = value[index];
const next = value[index + 1];
if (/\s/.test(char)) continue;
if (char === '/' && next === '*') {
const commentEnd = value.indexOf('*/', index + 2);
if (commentEnd < 0) return null;
index = commentEnd + 1;
continue;
}
if (char === '/' && next === '/') {
const lineEnd = value.slice(index + 2).search(/[\r\n]/);
if (lineEnd < 0) return null;
index += lineEnd + 1;
continue;
}
trimmed += char;
}
const hasOpeningBrace = trimmed.startsWith('{');
const hasClosingBrace = trimmed.endsWith('}');
if (hasOpeningBrace !== hasClosingBrace) return null;
const body = hasOpeningBrace ? trimmed.slice(1, -1).trim() : trimmed;
if (body.length === 0) return [];
if (!hasOpeningBrace && body.includes(',')) return null;
const methods: string[] = [];
const parts = body.split(',');
if (hasOpeningBrace && parts[parts.length - 1].trim() === '') parts.pop();
for (const rawPart of parts) {
const part = rawPart.trim();
const match =
/^(?:(?:[A-Za-z_$][A-Za-z0-9_$]*\.)*RequestMethod\.)?(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|TRACE)$/.exec(
part,
);
if (!match) return null;
if (!methods.includes(match[1])) methods.push(match[1]);
}
return methods;
}
/**
* Resolve a method-level Spring mapping annotation to its HTTP method(s).
*
* Shortcut annotations have one implicit verb. `@RequestMapping` may declare
* one or more static `RequestMethod.X` values; when its `method` member is
* absent or an empty array, `'*'` preserves Spring's method-agnostic semantics.
* A present but non-static method expression yields no methods (fail closed).
*/
export function springAnnotationHttpMethods(
annotationName: string,
annotationText: string,
): readonly string[] {
const shortcut = METHOD_ANNOTATION_TO_HTTP[annotationName];
if (shortcut) return [shortcut];
if (annotationName !== 'RequestMapping') return [];
const args = parseSpringAnnotationArguments(annotationText);
if (args === null) return [];
const methodArgs = args.filter((arg) => arg.name === 'method');
if (methodArgs.length === 0) return ['*'];
if (methodArgs.length !== 1) return [];
const methods = parseRequestMethodValues(methodArgs[0].value);
if (methods === null) return [];
return methods.length > 0 ? methods : ['*'];
}
/** Intersect class- and method-level Spring mapping constraints. */
export function intersectSpringHttpMethods(
classMethods: readonly string[],
methodMethods: readonly string[],
): readonly string[] {
if (classMethods.length === 0 || methodMethods.length === 0) return [];
if (classMethods.includes('*')) return methodMethods;
if (methodMethods.includes('*')) return classMethods;
return methodMethods.filter((method) => classMethods.includes(method));
}
/**
* A named annotation argument contributes a route only when its member key is
* `path` or `value`; a positional argument (no key node) always qualifies.

View file

@ -23,7 +23,8 @@ import Parser from 'tree-sitter';
import Java from 'tree-sitter-java';
import type { ExtractedDecoratorRoute } from '../workers/parse-worker.js';
import {
METHOD_ANNOTATION_TO_HTTP,
intersectSpringHttpMethods,
springAnnotationHttpMethods,
isRouteMemberKey,
findEnclosingType,
unquoteSpringLiteral,
@ -60,14 +61,14 @@ const ROUTE_ANNOTATION_QUERY = new Parser.Query(
(class_declaration
(modifiers
(annotation
name: (identifier) @ann
name: [(identifier) (scoped_identifier)] @ann
arguments: (annotation_argument_list
[(string_literal) @value
(element_value_array_initializer (string_literal) @value)])))) @node
(class_declaration
(modifiers
(annotation
name: (identifier) @ann
name: [(identifier) (scoped_identifier)] @ann
arguments: (annotation_argument_list
(element_value_pair
key: (identifier) @key
@ -76,14 +77,14 @@ const ROUTE_ANNOTATION_QUERY = new Parser.Query(
(method_declaration
(modifiers
(annotation
name: (identifier) @ann
name: [(identifier) (scoped_identifier)] @ann
arguments: (annotation_argument_list
[(string_literal) @value
(element_value_array_initializer (string_literal) @value)])))) @node
(method_declaration
(modifiers
(annotation
name: (identifier) @ann
name: [(identifier) (scoped_identifier)] @ann
arguments: (annotation_argument_list
(element_value_pair
key: (identifier) @key
@ -121,6 +122,13 @@ export function extractSpringRoutes(
// class-array cross-product support is out of scope here.
const prefixByClassId = new Map<number, string>();
const classesWithArrayPrefix = new Set<number>();
const classHttpMethodsById = new Map<number, readonly string[]>();
for (const match of TYPE_DECLARATION_QUERY.matches(tree.rootNode)) {
const typeNode = match.captures.find((capture) => capture.name === 'type')?.node;
if (typeNode?.type === 'class_declaration') {
classHttpMethodsById.set(typeNode.id, typeRequestMethods(typeNode));
}
}
for (const match of matches) {
const caps: Record<string, Parser.SyntaxNode> = {};
@ -133,7 +141,8 @@ export function extractSpringRoutes(
const keyNode = caps['key'];
if (!annNode || !node || !valueNode) continue;
if (node.type === 'class_declaration' && annNode.text === 'RequestMapping') {
const capturedAnnotationName = annNode.text.split('.').pop() ?? annNode.text;
if (node.type === 'class_declaration' && capturedAnnotationName === 'RequestMapping') {
if (!isRouteMemberKey(keyNode)) continue;
if (valueNode.parent?.type === 'element_value_array_initializer') {
classesWithArrayPrefix.add(node.id);
@ -146,6 +155,7 @@ export function extractSpringRoutes(
// Phase 2: collect method-level routes and resolve their class prefix
const routes: ExtractedDecoratorRoute[] = [];
const httpMethodsByAnnotationId = new Map<number, readonly string[]>();
for (const match of matches) {
const caps: Record<string, Parser.SyntaxNode> = {};
@ -160,9 +170,15 @@ export function extractSpringRoutes(
if (node.type !== 'method_declaration') continue;
const ann = annNode.text;
const httpMethod = METHOD_ANNOTATION_TO_HTTP[ann];
if (!httpMethod) continue; // skip @RequestMapping on methods (ambiguous verb)
const ann = annNode.text.split('.').pop() ?? annNode.text;
const annotationNode = annNode.parent;
if (!annotationNode) continue;
let methodMethods = httpMethodsByAnnotationId.get(annotationNode.id);
if (!methodMethods) {
methodMethods = springAnnotationHttpMethods(ann, annotationNode.text);
httpMethodsByAnnotationId.set(annotationNode.id, methodMethods);
}
if (methodMethods.length === 0) continue;
if (!isRouteMemberKey(keyNode)) continue;
const routePath = unquoteSpringLiteral(valueNode.text);
@ -177,6 +193,11 @@ export function extractSpringRoutes(
if (enclosingType?.kind === 'interface') continue;
const enclosingClass = enclosingType?.kind === 'class' ? enclosingType.node : null;
const httpMethods = intersectSpringHttpMethods(
enclosingClass ? (classHttpMethodsById.get(enclosingClass.id) ?? ['*']) : ['*'],
methodMethods,
);
if (httpMethods.length === 0) continue;
// Suppress a method-level *array-form* route nested under a class-level
// array-form @RequestMapping. The class prefix is one of several values that
// cannot be resolved to a single string here, so emitting the route would
@ -195,15 +216,47 @@ export function extractSpringRoutes(
// handler method name (resolved to a symbol UID later by the routes phase).
const handlerName = node.childForFieldName('name')?.text;
routes.push({
filePath,
routePath,
httpMethod,
decoratorName: ann,
lineNumber: annNode.startPosition.row + lineOffset,
...(classPrefix ? { prefix: classPrefix } : {}),
...(handlerName ? { handlerName } : {}),
});
for (const httpMethod of httpMethods) {
routes.push({
filePath,
routePath,
httpMethod,
decoratorName: ann,
lineNumber: annNode.startPosition.row + lineOffset,
...(classPrefix ? { prefix: classPrefix } : {}),
...(handlerName ? { handlerName } : {}),
});
}
}
// Mapping annotations without a path bind to the enclosing class prefix.
for (const match of TYPE_DECLARATION_QUERY.matches(tree.rootNode)) {
const typeNode = match.captures.find((capture) => capture.name === 'type')?.node;
if (typeNode?.type !== 'class_declaration') continue;
const classPrefix = prefixByClassId.get(typeNode.id) ?? '';
const classMethods = classHttpMethodsById.get(typeNode.id) ?? ['*'];
for (const methodNode of directMethods(typeNode)) {
const handlerName = methodNode.childForFieldName('name')?.text;
if (!handlerName) continue;
for (const annotationNode of declarationAnnotations(methodNode)) {
const ann = annotationName(annotationNode) ?? '';
if (annotationHasRouteMember(annotationNode)) continue;
const methodMethods = springAnnotationHttpMethods(ann, annotationNode.text);
const httpMethods = intersectSpringHttpMethods(classMethods, methodMethods);
if (httpMethods.length === 0) continue;
for (const httpMethod of httpMethods) {
routes.push({
filePath,
routePath: '',
httpMethod,
decoratorName: ann,
lineNumber: annotationNode.startPosition.row + lineOffset,
...(classPrefix ? { prefix: classPrefix } : {}),
handlerName,
});
}
}
}
}
return routes;
@ -272,6 +325,34 @@ function annotationRoutePaths(ann: Parser.SyntaxNode): string[] {
return out;
}
/** Whether an annotation explicitly supplies a positional or path/value member. */
function annotationHasRouteMember(ann: Parser.SyntaxNode): boolean {
const args = ann.childForFieldName('arguments');
if (!args) return false;
for (const child of args.namedChildren) {
if (child.type !== 'element_value_pair') return true;
const key = child.childForFieldName('key');
if (isRouteMemberKey(key ?? undefined)) return true;
}
return false;
}
/** Static class/interface-level RequestMapping method constraint, or wildcard by default. */
function typeRequestMethods(typeNode: Parser.SyntaxNode): readonly string[] {
const mappings = declarationAnnotations(typeNode).filter(
(ann) => annotationName(ann) === 'RequestMapping',
);
if (mappings.length === 0) return ['*'];
if (mappings.length !== 1) return [];
return springAnnotationHttpMethods('RequestMapping', mappings[0].text);
}
function annotationRoutePathsOrDefault(ann: Parser.SyntaxNode): string[] {
const paths = annotationRoutePaths(ann);
if (paths.length > 0) return paths;
return annotationHasRouteMember(ann) ? [] : [''];
}
/** Class-level `@RequestMapping` prefixes for a type (array-aware; may be []). */
function typeClassPrefixes(typeNode: Parser.SyntaxNode): string[] {
const prefixes: string[] = [];
@ -343,6 +424,7 @@ export function extractSpringTypes(tree: Parser.Tree, filePath: string): SharedS
const annNames = declarationAnnotations(typeNode).map(annotationName);
const isController =
kind === 'class' && (annNames.includes('RestController') || annNames.includes('Controller'));
const classMethods = typeRequestMethods(typeNode);
const methods = directMethods(typeNode)
.map((methodNode) => {
@ -350,9 +432,12 @@ export function extractSpringTypes(tree: Parser.Tree, filePath: string): SharedS
if (!methodName) return null;
const routes: Array<{ method: string; path: string }> = [];
for (const ann of declarationAnnotations(methodNode)) {
const verb = METHOD_ANNOTATION_TO_HTTP[annotationName(ann) ?? ''];
if (!verb) continue;
for (const path of annotationRoutePaths(ann)) routes.push({ method: verb, path });
const methodMethods = springAnnotationHttpMethods(annotationName(ann) ?? '', ann.text);
const verbs = intersectSpringHttpMethods(classMethods, methodMethods);
for (const verb of verbs) {
for (const path of annotationRoutePathsOrDefault(ann))
routes.push({ method: verb, path });
}
}
return { name: methodName, routes };
})

View file

@ -267,7 +267,23 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// store would have replayed pre-fix ParsedFiles verbatim for one of them.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
//
// 46 -> 47 for the round-2 capture work: object literals behind an
// 46 -> 47: method-level Spring `@RequestMapping` now emits wildcard routes
// and one route per static `RequestMethod.X` value. These decorator routes live
// in ParseWorkerResult and are replayed verbatim on warm cache hits, so keeping
// the previous version would make the fix a no-op for every unchanged Java file.
// PR #2856 claims 46, so this branch owns 47. Verified against upstream/main at
// 021ac3037 (still 45). RE-CHECK BEFORE MERGE.
//
// ── The FIFTH clash, and the first one the ledger's own convention prevented ──
// #2857 above merged while this branch sat waiting, and it did the right thing:
// it read this PR's claim on 46 and took 47 instead of colliding. That left the
// clash one step further up — 46 was safe, but THIS branch's own 47 (below) was
// not, and neither was anything after it. Every entry from here down has been
// renumbered +1 at merge time. Nothing about the capture sets changed; only the
// numbers did, which is the whole point of re-checking at merge rather than at
// review. Ledger entries 11 through 15.
//
// 47 -> 48 for the round-2 capture work: object literals behind an
// identity-preserving wrapper (`const X = Object.freeze({ ... })`) now mint
// `@definition.property` for their keys. Parse-time like every entry above, and
// this one was ALSO observed as a false negative first: `analyze --force`
@ -278,7 +294,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// for this bump.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
//
// 47 -> 48 for the TypeScript object-literal captures (R3-3): named
// 48 -> 49 for the TypeScript object-literal captures (R3-3): named
// object-literal keys and the identity-wrapper form now mint `@definition.property`
// in TYPESCRIPT_QUERIES, as they already did for JavaScript. Parse-time, so a
// warm cache would replay ParsedFiles carrying none of those matches and the
@ -289,39 +305,39 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// clashes. It is NOT on this branch, so until that one merges the re-check
// below is still manual.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
// 48 -> 49 for the return-shape and shorthand captures (R3-4): keys of an
// 49 -> 50 for the return-shape and shorthand captures (R3-4): keys of an
// anonymous literal in return position, and shorthand keys in both that and the
// variable-bound form. Parse-time again.
//
// The v34 hazard, and this branch has already tripped it: a build stamped 48
// was installed and used to analyze two repos BEFORE these captures existed, so
// caches stamped 48 exist that carry none of them. Within one PR the version
// (now 49) was installed and used to analyze two repos BEFORE these captures
// existed, so caches stamped 48 exist that carry none of them. Within one PR the version
// only has to differ from main's, but an INTERMEDIATE build of the same series
// is a different capture set wearing the same number — which is exactly what
// the note above records for 33/34.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
// 49 -> 50 is NOT needed for R3-5: that pass is scope-resolution, not
// 50 -> 51 is NOT needed for R3-5: that pass is scope-resolution, not
// parse-time capture, so a warm cache replays ParsedFiles that already carry
// everything it reads. Recorded because the reflex on this branch has been to
// bump, and a bump nobody needs still forces every user a full re-parse.
//
// 49 -> 50 IS needed for dispatch-guard routes (R3-7): the JS/TS providers now
// 50 -> 51 IS needed for dispatch-guard routes (R3-7): the JS/TS providers now
// implement `extractDecoratorRoutes`, and decorator routes are worker output
// carried in the parse cache. A warm cache replays a worker result whose
// `decoratorRoutes` predates the extractor entirely, so every hand-rolled route
// stays invisible and `route_map` keeps answering empty — the exact symptom the
// change exists to fix, wearing the mask of "the extractor does not work".
//
// 50 -> 51 for the same-file constant folding that followed it. The v34 hazard
// again, and this branch has now tripped it TWICE: a build stamped 50 was used
// to analyze before folding existed, so caches stamped 50 carry the unfolded
// 51 -> 52 for the same-file constant folding that followed it. The v34 hazard
// again, and this branch has now tripped it TWICE: a build stamped 50 (now 51)
// was used to analyze before folding existed, so those caches carry the unfolded
// route set. Caught by measuring — the post-folding run came back suspiciously
// fast and would have reported the pre-folding number, which is precisely how
// "an intermediate build of the same series is a different capture set wearing
// the same number" shows up in practice. Within one PR the version only has to
// differ from main's; against a cache YOU wrote, it has to differ from itself.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
const SCHEMA_BUMP = 51;
const SCHEMA_BUMP = 52;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -0,0 +1,24 @@
package com.example.controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/legacy")
public class LegacyController {
@RequestMapping("/all")
public String allMethods() {
return "all";
}
@RequestMapping(value = "/save", method = RequestMethod.POST)
public String save() {
return "saved";
}
@RequestMapping(path = "/inspect", method = {RequestMethod.GET, RequestMethod.HEAD})
public String inspect() {
return "inspected";
}
}

View file

@ -87,6 +87,48 @@ describe('Spring @RequestMapping route ingestion pipeline', () => {
expect(names).toContain('/api/admin/settings');
});
it('emits wildcard and explicit method-level @RequestMapping routes', () => {
const routes = new Set<string>();
const handlerNames = new Set<string>();
const handledLegacyRoutes = new Set<string>();
result.graph.forEachNode((node) => {
if (node.label !== 'Route') return;
routes.add(`${String(node.properties.method)} ${String(node.properties.name)}`);
if (node.properties.handlerSymbolId) {
const handler = result.graph.getNode(String(node.properties.handlerSymbolId));
if (handler) handlerNames.add(String(handler.properties.name));
}
});
result.graph.forEachRelationship((relationship) => {
if (relationship.type !== 'HANDLES_ROUTE') return;
const file = result.graph.getNode(relationship.sourceId);
const route = result.graph.getNode(relationship.targetId);
if (
file?.label === 'File' &&
String(file.properties.name).includes('LegacyController.java') &&
route?.label === 'Route'
) {
handledLegacyRoutes.add(
`${String(route.properties.method)} ${String(route.properties.name)}`,
);
}
});
const expectedRoutes = [
'* /api/legacy/all',
'POST /api/legacy/save',
'GET /api/legacy/inspect',
'HEAD /api/legacy/inspect',
];
for (const route of expectedRoutes) {
expect(routes).toContain(route);
expect(handledLegacyRoutes).toContain(route);
}
for (const handlerName of ['allMethods', 'save', 'inspect']) {
expect(handlerNames).toContain(handlerName);
}
});
it('emits HANDLES_ROUTE edges linking Route nodes to their handler files', () => {
const handlesRouteEdges: Array<{ routeName: string; filePath: string }> = [];
result.graph.forEachRelationship((r) => {

View file

@ -1605,10 +1605,8 @@ public class UserController {
expect(route).toBeDefined();
});
it('does NOT emit a provider for @GetMapping(produces = ...) without path/value', async () => {
// Anti-regression: without the `key:` constraint, the named-arg
// query would capture `produces = "application/json"` and emit
// a bogus `http::GET::/application/json` contract.
it('emits a root provider for pathless @GetMapping without leaking produces', async () => {
// A pathless mapping is valid, but produces metadata must never become a path.
const dir = path.join(tmpDir, 'spring-produces-only');
fs.mkdirSync(path.join(dir, 'src/controller'), { recursive: true });
fs.writeFileSync(
@ -1628,17 +1626,16 @@ public class MisleadingController {
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
// No GET provider should be emitted for this method — the only
// string literal in the annotation is a non-route attribute.
// The non-route string must not become an /application/json provider.
expect(
providers.find((c) => c.contractId === 'http::GET::/application/json'),
).toBeUndefined();
// And the controller has no other route, so providers list for
// this file should be empty.
// The mapping itself still contributes one pathless root provider.
const fromThisFile = providers.filter((c) =>
c.symbolRef.filePath.endsWith('MisleadingController.java'),
);
expect(fromThisFile).toHaveLength(0);
expect(fromThisFile).toHaveLength(1);
expect(fromThisFile[0].contractId).toBe('http::GET::/');
});
it('emits exactly one provider for @GetMapping(name = "...", value = "/users")', async () => {

View file

@ -61,6 +61,14 @@ function groupProviders(src: string): Set<string> {
);
}
function groupConsumers(src: string): Set<string> {
return new Set(
JAVA_HTTP_PLUGIN.scan(parse(src))
.filter((d) => d.role === 'consumer' && d.framework === 'openfeign')
.map((d) => canon(d.method, d.path)),
);
}
describe('Spring route extractor parity — ingestion spring.ts vs group java.ts', () => {
it('agree on bare, named-arg, and array-form method routes under a class prefix', () => {
const src = `package com.example;
@ -124,6 +132,109 @@ public class NamedArrayController {
expect(ingestionProviders(src)).toEqual(group);
});
it('agree on method-level @RequestMapping verbs and wildcard mappings', () => {
const src = `package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api")
public class LegacyController {
@RequestMapping("/bare") public Object bare() { return null; }
@RequestMapping(value = "/named") public Object named() { return null; }
@RequestMapping(value = "/save", method = RequestMethod.POST)
public Object save() { return null; }
@RequestMapping(path = "/multi", method = {RequestMethod.GET, RequestMethod.HEAD})
public Object multi() { return null; }
@RequestMapping(path = {"/one", "/two"}, method = {RequestMethod.GET, RequestMethod.POST})
public Object crossProduct() { return null; }
@RequestMapping(path = "/empty", method = {})
public Object empty() { return null; }
@RequestMapping(path = "/spaced", method = RequestMethod . PUT)
public Object spaced() { return null; }
@RequestMapping(path = "/commented", method = {
RequestMethod.GET, // read
/* write */ RequestMethod.POST,
})
public Object commented() { return null; }
@org.springframework.web.bind.annotation.RequestMapping(
path = "/fqn",
method = org.springframework.web.bind.annotation.RequestMethod.DELETE
)
public Object fqn() { return null; }
}
`;
const expected = new Set([
'* /api/bare',
'* /api/named',
'POST /api/save',
'GET /api/multi',
'HEAD /api/multi',
'GET /api/one',
'POST /api/one',
'GET /api/two',
'POST /api/two',
'* /api/empty',
'PUT /api/spaced',
'GET /api/commented',
'POST /api/commented',
'DELETE /api/fqn',
]);
expect(groupProviders(src)).toEqual(expected);
expect(ingestionProviders(src)).toEqual(expected);
});
it('intersects class verbs and emits pathless method mappings at the class prefix', () => {
const src = `package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping(path = "/api", method = RequestMethod.GET)
public class ConstrainedController {
@RequestMapping("/wild") public Object wildcard() { return null; }
@RequestMapping(method = RequestMethod.GET) public Object root() { return null; }
@GetMapping public Object shortcut() { return null; }
@PostMapping("/blocked") public Object blocked() { return null; }
}
`;
const expected = new Set(['GET /api/wild', 'GET /api']);
expect(groupProviders(src)).toEqual(expected);
expect(ingestionProviders(src)).toEqual(expected);
});
it('uses GET for verb-less Feign RequestMapping and rejects multi-method contracts', () => {
const src = `package com.example;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
@FeignClient(name = "orders")
@RequestMapping("/api")
public interface OrdersClient {
@RequestMapping("/default") Object defaultCall();
@RequestMapping(path = "/multi", method = {RequestMethod.GET, RequestMethod.POST})
Object ambiguous();
}
`;
expect(groupConsumers(src)).toEqual(new Set(['GET /api/default']));
});
it('fails closed when @RequestMapping method is not statically resolvable', () => {
const src = `package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
public class DynamicController {
private static final RequestMethod VERB = RequestMethod.POST;
@RequestMapping(path = "/dynamic", method = VERB)
public Object dynamic() { return null; }
}
`;
expect(groupProviders(src)).toEqual(new Set());
expect(ingestionProviders(src)).toEqual(new Set());
});
it('do not leak non-route arrays (consumes/produces) as routes — array analogue', () => {
// The scalar `produces` anti-regression already exists in the route tests;
// this is its array form. `consumes`/`produces` arrays must never surface as
@ -141,13 +252,12 @@ public class ContentTypeController {
`;
const ingestion = ingestionProviders(src);
const group = groupProviders(src);
// Only the explicit path leaks through; the consumes/produces arrays do not,
// and the path-less @PostMapping contributes nothing.
expect(group).toEqual(new Set(['GET /v']));
// Non-route arrays never become paths; the pathless POST binds to root.
expect(group).toEqual(new Set(['GET /v', 'POST /']));
expect(ingestion).toEqual(group);
// Pure-consumes controller (no path anywhere) → EMPTY provider set on both
// sides: a consumes/produces array must never be misread as a route path.
// Pure consumes/produces members still describe pathless root mappings;
// their media-type values must never be misread as route paths.
const consumesOnly = `package com.example;
import org.springframework.web.bind.annotation.*;
@ -159,8 +269,8 @@ public class ConsumesOnlyController {
public Object b() { return null; }
}
`;
expect(groupProviders(consumesOnly)).toEqual(new Set());
expect(ingestionProviders(consumesOnly)).toEqual(new Set());
expect(groupProviders(consumesOnly)).toEqual(new Set(['POST /', 'PUT /']));
expect(ingestionProviders(consumesOnly)).toEqual(new Set(['POST /', 'PUT /']));
});
it('pins the deliberate class-array divergence: ingestion suppresses, group emits cross-product (#2280)', () => {

View file

@ -141,15 +141,24 @@ describe('PARSE_CACHE_VERSION', () => {
// a row. Same lesson as the note above — the pin cannot detect the tie, since
// both sides asserted `toBe(45)` and that passes while main is already 45.
// Only the merge-time diff against origin/main surfaces it.
// Moved 49 -> 50 for dispatch-guard routes (R3-7): the JS/TS providers now
//
// Moved 46 -> 47 for method-level Spring `@RequestMapping` routes (#2857):
// cached ParseWorkerResults otherwise replay the pre-fix empty route set.
// That PR read this branch's claim on 46 and took 47 rather than colliding —
// the FIFTH clash, and the first the ledger's convention actually prevented.
// It only moved the collision up one step, though: this branch's own 47 and
// everything above it had to be renumbered +1 at merge time. Capture sets
// unchanged; only the numbers moved.
//
// Moved 50 -> 51 for dispatch-guard routes (R3-7): the JS/TS providers now
// implement `extractDecoratorRoutes`, and decorator routes are worker output
// carried in the cache. A v49 warm cache replays a worker result whose
// carried in the cache. A v50 warm cache replays a worker result whose
// `decoratorRoutes` predates the extractor, so `route_map` keeps answering
// empty — the exact symptom the change fixes, disguised as "it does not work".
// Moved 50 -> 51 for the same-file constant folding that followed, because a
// build stamped 50 had already been used to analyze without it.
it('pins SCHEMA_BUMP to 51 so concurrent bumps cannot silently collide (#2766)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(51);
// Moved 51 -> 52 for the same-file constant folding that followed, because a
// build stamped 50 (now 51) had already been used to analyze without it.
it('pins SCHEMA_BUMP to 52 so concurrent bumps cannot silently collide (#2766)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(52);
});
it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => {

View file

@ -15,6 +15,10 @@ import {
springResourceDefaultName,
springResourceInjectionMatch,
} from '../../src/core/ingestion/frameworks/spring/resource-injection.js';
import {
intersectSpringHttpMethods,
springAnnotationHttpMethods,
} from '../../src/core/ingestion/route-extractors/spring-shared.js';
describe('Spring annotation static arguments', () => {
it('parses Java and Kotlin named arrays without splitting nested values', () => {
@ -80,6 +84,77 @@ describe('Spring annotation static arguments', () => {
});
});
describe('Spring request mapping methods', () => {
it('resolves shortcut, wildcard, scalar, and array method declarations', () => {
expect(springAnnotationHttpMethods('GetMapping', '@GetMapping("/x")')).toEqual(['GET']);
expect(springAnnotationHttpMethods('RequestMapping', '@RequestMapping("/x")')).toEqual(['*']);
expect(
springAnnotationHttpMethods(
'RequestMapping',
'@RequestMapping(path = "/x", method = RequestMethod.POST)',
),
).toEqual(['POST']);
expect(
springAnnotationHttpMethods(
'RequestMapping',
'@RequestMapping(path = "/x", method = {RequestMethod.GET, RequestMethod.HEAD})',
),
).toEqual(['GET', 'HEAD']);
expect(
springAnnotationHttpMethods('RequestMapping', '@RequestMapping(path = "/x", method = {})'),
).toEqual(['*']);
});
it('accepts a terminal array comma and intersects class/method constraints', () => {
expect(
springAnnotationHttpMethods(
'RequestMapping',
'@RequestMapping(method = {RequestMethod.GET, RequestMethod.HEAD,})',
),
).toEqual(['GET', 'HEAD']);
expect(intersectSpringHttpMethods(['GET'], ['*'])).toEqual(['GET']);
expect(intersectSpringHttpMethods(['*'], ['POST'])).toEqual(['POST']);
expect(intersectSpringHttpMethods(['GET', 'HEAD'], ['HEAD', 'POST'])).toEqual(['HEAD']);
expect(intersectSpringHttpMethods(['GET'], ['POST'])).toEqual([]);
});
it('accepts Java whitespace and comments around static RequestMethod values', () => {
expect(
springAnnotationHttpMethods(
'RequestMapping',
'@RequestMapping(path = "/x", method = RequestMethod . GET)',
),
).toEqual(['GET']);
expect(
springAnnotationHttpMethods(
'RequestMapping',
`@RequestMapping(method = {
RequestMethod.GET, // read
/* write */ RequestMethod.POST,
})`,
),
).toEqual(['GET', 'POST']);
});
it('fails closed for runtime, malformed, and duplicate method members', () => {
expect(
springAnnotationHttpMethods('RequestMapping', '@RequestMapping(path = "/x", method = VERB)'),
).toEqual([]);
expect(
springAnnotationHttpMethods(
'RequestMapping',
'@RequestMapping(path = "/x", method = RequestMethod.GET, method = RequestMethod.POST)',
),
).toEqual([]);
expect(
springAnnotationHttpMethods(
'RequestMapping',
'@RequestMapping(path = "/x", method = RequestMethod./* unterminated)',
),
).toEqual([]);
});
});
describe('Spring Bean factory metadata', () => {
it('uses the method default and recognizes Java/Kotlin aliases', () => {
expect(springBeanNames('@Bean', 'gateway')).toEqual({

View file

@ -224,6 +224,37 @@ public class C implements Api {
expect(ingestionInheritedKeys(files)).toEqual(group);
});
it('agrees on inherited @RequestMapping verbs and wildcard mappings', () => {
const files = [
{
path: 'LegacyApi.java',
src: `package com.example;
import org.springframework.web.bind.annotation.*;
@RequestMapping("/api")
public interface LegacyApi {
@RequestMapping(path = "/save", method = RequestMethod.POST) Object save();
@RequestMapping("/all") Object all();
}
`,
},
{
path: 'LegacyController.java',
src: `package com.example;
import org.springframework.web.bind.annotation.*;
@RestController
public class LegacyController implements LegacyApi {
public Object save() { return null; }
public Object all() { return null; }
}
`,
},
];
const expected = new Set(['POST /api/save', '* /api/all']);
expect(groupInheritedKeys(files)).toEqual(expected);
expect(ingestionInheritedKeys(files)).toEqual(expected);
});
it('agrees on fully-qualified annotation names', () => {
// Both sides normalise an FQN annotation to its trailing segment, so an
// interface using `@org.springframework...GetMapping` still resolves.