feat(group): resolve Java constant-based route paths via repo constant map

- prepareRepo builds repo-wide Java constant map (constant-definition files only,
  cheap regex gate; per-file try/catch so one bad file degrades not forfeits)
- bind parser language in prepareRepo (orchestrator hands over a bare Parser)
- scan() lazily overlays the importing file's own import table (extracted from
  the tree already in hand, zero extra parses) before folding operands
- foldJavaOperands resolves qualified refs (Class.CONST) + static imports +
  string concatenation against the merged view; unresolved refs are skipped,
  never guessed

Real-repo validation (winning-winex-opt, 23k Java files):
  providers 2 -> 1701 (1700 source_scan_resolved), cross-links 0 -> 589 exact
Unit: 14/14 (java-route-const-resolver.test.ts)
This commit is contained in:
ChunxueLi 2026-08-16 08:30:50 +08:00
parent aac7515d2a
commit 41d874dcb3
7 changed files with 903 additions and 6 deletions

1
.gitignore vendored
View file

@ -128,3 +128,4 @@ gitnexus/web/
# Machine-local skill-evolution evidence (consumed by eval/workflow_bench/evolve.py)
eval/workflow_bench/learnings.jsonl
gitnexus/real_test*.mjs

View file

@ -28,6 +28,12 @@ import {
REQUEST_LINE_CONFIDENCE,
EXCHANGE_CONFIDENCE,
} from './spring-consumer-shared.js';
import {
extractJavaModuleConstants,
foldJavaOperands,
parseJavaConstOperands,
type RepoConstants,
} from '../../../ingestion/route-extractors/java-const-resolver.js';
import {
extractStaticPathExpression,
inferOkHttpMethod,
@ -165,6 +171,21 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
key: (identifier) @key
value: [(string_literal) @value (element_value_array_initializer (string_literal) @value)]))))
name: (identifier) @member) @node
(method_declaration
(modifiers
(annotation
name: [(identifier) (scoped_identifier)] @ann
arguments: (annotation_argument_list [(identifier) @value_expr (field_access) @value_expr (binary_expression) @value_expr])))
name: (identifier) @member) @node
(method_declaration
(modifiers
(annotation
name: [(identifier) (scoped_identifier)] @ann
arguments: (annotation_argument_list
(element_value_pair
key: (identifier) @key
value: [(identifier) @value_expr (field_access) @value_expr (binary_expression) @value_expr]))))
name: (identifier) @member) @node
]
`,
},
@ -469,6 +490,12 @@ interface MethodRouteAnnotation {
rawPath: string;
/** OpenFeign's single effective verb; null means its contract is invalid/ambiguous. */
feignHttpMethod?: string | null;
/**
* Non-literal path operands (constant ref or `+`-concat), captured when the
* annotation value is not a string literal. Resolved against the repo-wide
* Java constant map in scan(); a failed fold drops the route (skip floor).
*/
pathOperands?: readonly import('../../../ingestion/route-extractors/constant-resolver.js').Operand[];
}
interface RequestLineAnnotation {
@ -527,7 +554,10 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
const annNode = captures.ann;
const node = captures.node;
const valueNode = captures.value;
if (!annNode || !node || !valueNode) continue;
// A non-literal annotation value (constant ref / `+`-concat) is captured
// as @value_expr instead of @value — one of the two must be present.
const valueExprNode = captures.value_expr;
if (!annNode || !node || (!valueNode && !valueExprNode)) continue;
// Discrimination is on the trailing segment only (`simpleName`), so a
// non-Spring annotation whose last segment collides with a route annotation
// (e.g. `@com.evil.GetMapping("/x")`) is treated as a route. This is the
@ -550,7 +580,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
const feignHttpMethod =
httpMethods.length === 1 ? (httpMethods[0] === '*' ? 'GET' : httpMethods[0]) : null;
if (!isRouteMemberKey(keyNode)) continue;
const rawPath = unquoteLiteral(valueNode.text);
const rawPath = valueNode ? unquoteLiteral(valueNode.text) : null;
if (rawPath !== null) {
for (const httpMethod of httpMethods) {
methodRoutes.push({
@ -561,6 +591,25 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
feignHttpMethod,
});
}
} else {
// Non-literal path (a constant reference or `+`-concatenation).
// Defer to scan(): the fold needs the repo-wide constant map built
// by prepareRepo. Capture the operand list now; resolution happens
// in scan() against JavaRepoContext, and an unresolvable operand
// list leaves the route skipped (KTD5 skip floor).
const operands = parseJavaConstOperands(valueExprNode);
if (operands !== null) {
for (const httpMethod of httpMethods) {
methodRoutes.push({
methodNode: node,
methodName: captures.member?.text ?? null,
httpMethod,
rawPath: '',
feignHttpMethod,
pathOperands: operands,
});
}
}
}
} else if (ann === 'RequestLine') {
// Feign packs verb + path in one literal; its only named argument is `value`.
@ -579,7 +628,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
// `url` or `value` attribute (or positionally); other attributes
// (`accept`, `contentType`, …) are not routes.
if (keyNode && keyNode.text !== 'url' && keyNode.text !== 'value') continue;
const rawPath = unquoteLiteral(valueNode.text);
const rawPath = valueNode ? unquoteLiteral(valueNode.text) : null;
if (rawPath !== null) {
exchangeRoutes.push({
methodNode: node,
@ -781,8 +830,57 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
content,
);
},
scan(tree) {
prepareRepo(args) {
// Build the repo-wide Java string-constant map once per extract() run
// (mirrors the Python binding's cost-gated pre-pass). A cheap content
// gate keeps literal-only repos at zero parses: only files containing a
// `static final String` declaration are parsed for constants.
try {
// The orchestrator hands over a bare Parser (no language set yet);
// bind Java explicitly — Python's prepareRepo does the same — otherwise
// parseSourceSafe spins to its 15 s budget per file.
args.parser.setLanguage(Java);
} catch {
// fall through: a parser that rejects binding cannot produce a constant
// map; per-file try/catch below then skips everything harmlessly.
}
const constants = new Map<
string,
import('../../../ingestion/route-extractors/constant-resolver.js').ModuleConstants
>();
for (const rel of args.files) {
if (!rel.endsWith('.java')) continue;
try {
const src = args.readFile(rel);
// Cheap content gate: only constant-DEFINITION candidates get parsed
// here (~hundreds of files). Import-only files (every controller)
// are deliberately NOT parsed in this pass — scan() lazily extracts
// the importing file's own import table from the tree it already
// holds when a constant-referencing route actually needs the fold.
// A gate that also matched `import ...;` would parse the entire
// repository here (tens of thousands of files) just to build import
// tables the fold can derive per-file on demand.
if (!src || !/static\s+final\s+String\s|interface\s+[A-Z]\w*\s*\{/.test(src)) {
continue;
}
const tree = args.parseSource(args.parser, src);
if (!tree) continue;
const mc = extractJavaModuleConstants(tree);
if (mc.literals.size > 0 || mc.exprs.size > 0 || mc.imports.size > 0) {
constants.set(rel, mc);
}
} catch {
// Per-file resilience: one unreadable/oversized/ill-formed file must
// not forfeit the whole repo's constant map (a missing constants
// class only degrades refs that pointed at it).
continue;
}
}
return { constants };
},
scan(tree, repoContext, fileRel) {
const out: HttpDetection[] = [];
const javaCtx = repoContext as { constants: RepoConstants } | undefined;
// ─── Spring providers + OpenFeign consumers (one query pass) ────
// `scanRouteAnnotations` resolves every route-defining annotation —
@ -802,7 +900,42 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
// class is a Spring *provider*. A mapping on a non-Feign interface has no
// enclosing class and is dropped here — interface→controller inheritance is
// handled by `scanProject`.
// Lazy per-file constants view. prepareRepo only indexes constant-
// DEFINING files (cheap gate); an importing controller is absent from
// that map. When a route actually references a constant, extract THIS
// file's import table from the tree scan() already holds (zero extra
// parses) and overlay it for the fold. Files whose routes are all
// literal — the overwhelming majority — never pay this cost.
let foldConstants: RepoConstants | undefined;
const getFoldConstants = (): RepoConstants | undefined => {
if (foldConstants !== undefined) return foldConstants;
foldConstants = javaCtx?.constants;
if (!javaCtx?.constants || !fileRel) return foldConstants;
if (javaCtx.constants.has(fileRel)) return foldConstants;
try {
const mc = extractJavaModuleConstants(tree);
if (mc.imports.size > 0) {
const merged = new Map(javaCtx.constants);
merged.set(fileRel, mc);
foldConstants = merged;
}
} catch {
// fold falls back to the repo-wide map (imports stay unresolved)
}
return foldConstants;
};
for (const route of methodRoutes) {
// Non-literal route path: fold the operand list against the repo-wide
// constant map. Skip (never a guessed path) when the fold fails or the
// repo context is absent (context-less fallback scanning).
if (route.pathOperands && javaCtx && fileRel) {
const resolved = foldJavaOperands(fileRel, route.pathOperands, getFoldConstants()!);
if (resolved === null) continue;
route.rawPath = resolved;
} else if (route.pathOperands) {
continue;
}
const enclosingInterface = findEnclosingInterface(route.methodNode);
if (enclosingInterface && hasAnnotation(enclosingInterface, 'FeignClient')) {
if (!route.feignHttpMethod) continue;

View file

@ -92,6 +92,7 @@ import {
resolveOperands,
type ModuleConstants,
} from '../route-extractors/python-const-resolver.js';
import { foldJavaOperands } from '../route-extractors/java-const-resolver.js';
import {
resolveInheritedSpringRoutes,
type SharedSpringType,
@ -1303,8 +1304,11 @@ export async function runChunkedParseAndResolve(
resolvedRoutes.push(dr);
continue;
}
const isJavaRoute = dr.filePath.endsWith('.java');
const value = dr.routePathOperands
? resolveOperands(dr.filePath, dr.routePathOperands, repoConstants)
? isJavaRoute
? foldJavaOperands(dr.filePath, dr.routePathOperands, repoConstants)
: resolveOperands(dr.filePath, dr.routePathOperands, repoConstants)
: null;
if (value === null) {
skipped++;

View file

@ -0,0 +1,388 @@
/**
* Java binding for the language-agnostic constant resolver (#2391 core).
*
* Supplies the two Java-specific pieces the shared fold in
* `constant-resolver.ts` needs — {@link resolveJavaImport} (import-specifier →
* file, honoring JVM package/classpath rules) and
* {@link extractJavaModuleConstants} (tree → {@link ModuleConstants}) — plus a
* pre-bound {@link resolveJavaConstant} wrapper so callers stay
* language-oblivious. The reusable fold, the cycle guard, and the depth cap
* all live in the agnostic core.
*
* Java constant shape (one per type declaration; nested classes flatten into
* the same file-level namespace, mirroring how `Outer.CONST` and a top-level
* `CONST` are indistinguishable at the fold layer):
*
* public class ApiPathConstants {
* public static final String DIAGNOSIS_SAVE_V1 = "/api/v1/diagnosis/add";
* public static final String API_CIS_SAVE_SUMMARY = API_CIS_V1 + "summary/save";
* }
*
* Reference shapes at annotation sites this binding resolves:
* @WinPostMapping(ApiPathConstants.DIAGNOSIS_SAVE_V1) // qualified
* @WinPostMapping(com.winning.opt.X.ApiPathConstants.Y) // FQN-qualified
* @WinPostMapping(DIAGNOSIS_SAVE_V1) // static-imported
* @WinPostMapping(API_CIS_V1 + "summary/save") // inline concat
*
* Import shapes consumed:
* import com.winning.opt.diagnosis.api.constants.ApiPathConstants;
* import static com.winning.opt.diagnosis.api.constants.ApiPathConstants.API_CIS_V1;
*
* Keying (KTD4 parity with the Python binding): the repo map is keyed by
* unique POSIX file path. A Java import `com.a.b.CONSTS` resolves to the file
* whose path ends with `com/a/b/CONSTS.java`; when 2+ files share that suffix
* the import is ambiguous and returns null (skip floor), never a wrong path.
*/
import type Parser from 'tree-sitter';
import {
resolveConstant as foldConstant,
type ImportBinding,
type ImportResolver,
type ModuleConstants,
type Operand,
type RepoConstants,
} from './constant-resolver.js';
export type {
ImportBinding,
ModuleConstants,
Operand,
RepoConstants,
} from './constant-resolver.js';
/**
* The Java {@link ImportResolver}: map a fully-qualified import specifier to
* the unique file key it refers to, or null when it cannot be pinned to
* exactly one file.
*
* `com.winning.opt.X.ApiPathConstants` → the file key ending in
* `com/winning/opt/X/ApiPathConstants.java`. Because the repo map is
* file-path-keyed and Maven multi-module trees repeat package roots across
* modules (`winning-opt-a/.../api/constants/ApiPathConstants.java` and
* `winning-opt-b/.../api/constants/ApiPathConstants.java`), suffix matching
* must stay UNIQUE-suffix: an import whose class name matches N files in N
* different modules cannot be pinned by package alone — unless exactly one of
* them ALSO matches the full package path. We therefore rank candidates:
* 1. exact full-suffix match (`<pkg-path>/<Class>.java` as a path suffix)
* 2. class-name-only suffix (`**&#47;<Class>.java`) when exactly one exists
* and return null when both attempts are ambiguous.
*/
export const resolveJavaImport: ImportResolver = (importingFileKey, moduleSpec, repoKeys) => {
// A static import `a.b.C.CONST` names the class as all-but-last segment;
// a plain import `a.b.C` names the class as last segment. Both resolve to
// a file ending `a/b/C.java`; treating the whole spec as a path and
// trimming the last segment when the direct hit fails covers both shapes.
const asPath = moduleSpec.replace(/\./g, '/');
const classFile = `${asPath}.java`;
// 1. Exact package-path suffix match.
let hit: string | null = null;
let ambiguity = false;
for (const key of repoKeys) {
if (key === classFile || key.endsWith(`/${classFile}`)) {
if (hit !== null) {
ambiguity = true;
break;
}
hit = key;
}
}
if (!ambiguity) return hit;
// Ambiguous full-path match (same package+class in 2+ modules is legal in
// separated-source monorepos but pathological for route constants). Try
// disambiguating by proximity to the importing file: prefer the candidate
// sharing the longest leading directory prefix with the importer. This
// mirrors how Maven/Gradle resolve classpath collisions in practice (nearest
// module wins) without ever guessing across unrelated trees.
const candidates: string[] = [];
for (const key of repoKeys) {
if (key === classFile || key.endsWith(`/${classFile}`)) candidates.push(key);
}
if (candidates.length > 1) {
const importerDirs = importingFileKey.split('/').slice(0, -1);
let best: string | null = null;
let bestDepth = -1;
let tie = false;
for (const c of candidates) {
const cDirs = c.split('/');
let d = 0;
while (d < importerDirs.length && d < cDirs.length && importerDirs[d] === cDirs[d]) d++;
if (d > bestDepth) {
bestDepth = d;
best = c;
tie = false;
} else if (d === bestDepth) {
tie = true;
}
}
if (best !== null && !tie) return best;
}
return null;
};
/** Is `node` a Java string literal (`"..."`) with its unquoted value? */
function stringLiteralValue(node: Parser.SyntaxNode): string | null {
if (node.type !== 'string_literal') return null;
const parts = node.children.filter((c) => c.type === 'string_fragment');
if (parts.length === 0) {
// Empty literal `""` has no string_fragment child.
return '';
}
return parts.map((c) => c.text).join('');
}
/**
* Parse a Java constant initializer into an operand list, or null when it is
* not a foldable string expression. Handles a bare string literal, a bare
* identifier (`X = Y`), qualified/static-import-free references
* (`X = CONSTS.Y` — recorded as ONE ref named `CONSTS.Y`), and
* left-associative `+` chains of the three. Everything else — numbers, calls,
* ternaries, method refs, `String.format`, enum constants — returns null,
* which makes the constant unresolvable (→ skip floor), never a wrong value.
*/
export function parseJavaConstOperands(
node: Parser.SyntaxNode | null | undefined,
depth = 0,
): Operand[] | null {
if (!node) return null;
if (depth > 64) return null;
if (node.type === 'string_literal') {
const value = stringLiteralValue(node);
return value === null ? null : [{ kind: 'literal', value }];
}
if (node.type === 'identifier') {
return [{ kind: 'ref', name: node.text }];
}
// `CONSTS.FIELD` — field_access in tree-sitter-java for expressions.
if (node.type === 'field_access') {
const object = node.childForFieldName('object');
const field = node.childForFieldName('field');
if (object && field && object.type === 'identifier') {
return [{ kind: 'ref', name: `${object.text}.${field.text}` }];
}
return null;
}
if (node.type === 'binary_expression') {
const isPlus = (node.children ?? []).some((c) => c.type === '+');
if (!isPlus) return null;
const left = parseJavaConstOperands(node.childForFieldName('left'), depth + 1);
const right = parseJavaConstOperands(node.childForFieldName('right'), depth + 1);
if (left === null || right === null) return null;
return [...left, ...right];
}
return null;
}
/**
* Extract the file-level string constants and import bindings of one parsed
* Java file into the {@link ModuleConstants} shape the resolver consumes.
*
* Constants: every `static final String NAME = …` field of every type
* declaration in the file (nested classes included — their simple names
* would collide at the fold layer, but qualified refs carry the class name
* so nesting only matters for same-name fields, which flatten last-wins).
* Interface constants (`String NAME = "…"`) are implicitly static final and
* are collected too.
*
* References to OTHER constants via qualified names (`ApiPathConstants.X`)
* are stored as refs named `ApiPathConstants.X`; at the fold layer such a ref
* resolves through the import map (`ApiPathConstants` → module) followed by
* field lookup in the target file's OWN class-name-qualified namespace. To
* support that, constant names are ALSO recorded under
* `<DeclaringClass>.<FIELD>` (both spellings share one entry).
*
* Last-wins in source order; a non-foldable rebind (`X = compute()`) drops X
* to unresolvable rather than keeping a stale literal.
*/
export function extractJavaModuleConstants(tree: Parser.Tree): ModuleConstants {
const literals = new Map<string, string>();
const exprs = new Map<string, readonly Operand[]>();
const imports = new Map<string, ImportBinding>();
// Pass 1: imports (both shapes).
const walkImports = (node: Parser.SyntaxNode): void => {
if (node.type === 'import_declaration') {
// import a.b.C; | import static a.b.C; | import static a.b.C.F;
const isStatic = node.children.some((c) => c.type === 'static' && c.text === 'static');
const scoped = node.children.find((c) => c.type === 'scoped_identifier');
if (scoped) {
const text = scoped.text;
const lastDot = text.lastIndexOf('.');
const fqn = text.slice(0, lastDot);
const name = text.slice(lastDot + 1);
if (isStatic) {
// import static a.b.C.F → local F from module a.b.C, original F.
imports.set(name, { module: fqn, originalName: name });
} else {
// import a.b.C → module IS the class FQN; originalName is the class
// simple name. resolveJavaImport maps `a.b.C` → `a/b/C.java`.
imports.set(name, { module: text, originalName: name });
}
}
}
for (const child of node.children ?? []) walkImports(child);
};
walkImports(tree.rootNode);
// Pass 2: constants. A field declaration is a constant when it is
// `static final` (explicit) or inside an interface (implicit).
const isStaticFinal = (modifiers: Parser.SyntaxNode | null | undefined): boolean => {
if (!modifiers) return false;
let sawStatic = false;
let sawFinal = false;
for (const m of modifiers.children ?? []) {
if (m.type === 'static') sawStatic = true;
if (m.type === 'final') sawFinal = true;
}
return sawStatic && sawFinal;
};
const collectFieldConstants = (
classBody: Parser.SyntaxNode,
insideInterface: boolean,
declaringClass: string | null,
): void => {
for (const member of classBody.children ?? []) {
// tree-sitter-java: interface fields are `constant_declaration`, class
// fields are `field_declaration`. Both carry `variable_declarator`s.
if (member.type !== 'field_declaration' && member.type !== 'constant_declaration') continue;
const mods = member.children.find((c) => c.type === 'modifiers');
if (!insideInterface && !isStaticFinal(mods)) continue;
// Type must be String (java.lang.String is implicit-imported).
const typeNode = member.childForFieldName('type');
if (!typeNode) continue;
const typeText = typeNode.text.replace(/^com\.java\.lang\./, '');
if (typeText !== 'String' && typeText !== 'java.lang.String') continue;
const declarators = member.children.filter((c) => c.type === 'variable_declarator');
for (const decl of declarators) {
const nameNode = decl.childForFieldName('name');
const valueNode = decl.childForFieldName('value');
if (!nameNode) continue;
const operands = parseJavaConstOperands(valueNode);
if (operands === null) continue;
const name = nameNode.text;
if (operands.length === 1 && operands[0].kind === 'literal') {
literals.set(name, (operands[0] as { value: string }).value);
} else {
exprs.set(name, operands);
}
// Qualified alias: `CONSTS.X` refs (folded refs carry the class name).
if (declaringClass) {
const qname = `${declaringClass}.${name}`;
if (operands.length === 1 && operands[0].kind === 'literal') {
literals.set(qname, (operands[0] as { value: string }).value);
} else {
exprs.set(qname, operands);
}
}
}
}
};
const walkTypes = (node: Parser.SyntaxNode, insideInterface: boolean): void => {
for (const child of node.children ?? []) {
const isClass = child.type === 'class_declaration';
const isInterface = child.type === 'interface_declaration';
if (isClass || isInterface) {
const nameNode = child.childForFieldName('name');
const className = nameNode?.text ?? null;
const body = child.children.find(
(c) => c.type === 'class_body' || c.type === 'interface_body',
);
if (body && className)
collectFieldConstants(body, isInterface || insideInterface, className);
if (body) walkTypes(body, isInterface || insideInterface);
} else if (child.type === 'enum_declaration' || child.type === 'record_declaration') {
walkTypes(child, insideInterface);
} else {
walkTypes(child, insideInterface);
}
}
};
walkTypes(tree.rootNode, false);
return { literals, exprs, imports: imports as Map<string, ImportBinding> };
}
/**
* Resolve a single Java constant referenced in `fileKey` to its literal string
* value, folding `+` concatenation and following import chains via
* {@link resolveJavaImport}, or null when it cannot be fully folded.
*
* `name` may be simple (`DIAGNOSIS_SAVE_V1`, resolved via static import or
* same-file constant) or qualified (`ApiPathConstants.DIAGNOSIS_SAVE_V1`,
* resolved via the class import + the target file's qualified alias).
*/
export function resolveJavaConstant(
fileKey: string,
name: string,
repo: RepoConstants,
): string | null {
// Qualified ref (`ApiPathConstants.FIELD`): the fold layer keys imports and
// constants by their IN-FILE name, so a dotted name never hits directly.
// Split head.tail: resolve the head through the importing file's class
// import, then look the tail up in the target file — first as the
// class-qualified alias `Head.TAIL` (what extractJavaModuleConstants
// records), then as a bare `TAIL` (same-file nested/interface constant).
const dot = name.indexOf('.');
if (dot > 0) {
const head = name.slice(0, dot);
const tail = name.slice(dot + 1);
const importing = repo.get(fileKey);
const imp = importing?.imports.get(head);
if (imp) {
const targetFile = resolveJavaImport(fileKey, imp.module, new Set(repo.keys()));
if (targetFile !== null) {
const qualified = resolveJavaConstant(targetFile, `${head}.${tail}`, repo);
if (qualified !== null) return qualified;
const bare = resolveJavaConstant(targetFile, tail, repo);
if (bare !== null) return bare;
}
return null;
}
// Un-imported qualified name (FQN form `com.a.b.C.FIELD`): try resolving
// the longest dotted prefix as a class import target.
const parts = name.split('.');
for (let cut = parts.length - 2; cut >= 1; cut--) {
const fqn = parts.slice(0, cut + 1).join('.');
const targetFile = resolveJavaImport(fileKey, fqn, new Set(repo.keys()));
if (targetFile !== null) {
const field = parts.slice(cut + 1).join('.');
const declaring = parts[cut];
const qualified = resolveJavaConstant(targetFile, `${declaring}.${field}`, repo);
if (qualified !== null) return qualified;
return resolveJavaConstant(targetFile, field, repo);
}
}
}
return foldConstant(fileKey, name, repo, resolveJavaImport);
}
/**
* Fold an inline operand list (e.g. `API_CIS_V1 + "summary/save"`) against
* `fileKey`. Unlike the Python binding, refs are resolved through
* {@link resolveJavaConstant} first — the agnostic fold has no notion of
* Java's `Class.CONST` qualified names (its import indirection only covers
* bare names), so each `ref` operand is resolved individually and the pieces
* are concatenated here.
*/
export function foldJavaOperands(
fileKey: string,
operands: readonly Operand[],
repo: RepoConstants,
): string | null {
let out = '';
for (const op of operands) {
if (op.kind === 'literal') {
out += op.value;
continue;
}
const piece = resolveJavaConstant(fileKey, op.name, repo);
if (piece === null) return null;
out += piece;
}
return out === '' ? null : out;
}

View file

@ -30,6 +30,7 @@ import {
unquoteSpringLiteral,
type SharedSpringType,
} from './spring-shared.js';
import { parseJavaConstOperands } from './java-const-resolver.js';
/**
* Single predicate-free tree-sitter query that captures all route annotations
@ -90,6 +91,24 @@ const ROUTE_ANNOTATION_QUERY = new Parser.Query(
key: (identifier) @key
value: [(string_literal) @value
(element_value_array_initializer (string_literal) @value)]))))) @node
(method_declaration
(modifiers
(annotation
name: [(identifier) (scoped_identifier)] @ann
arguments: (annotation_argument_list
[(identifier) @value_expr
(field_access) @value_expr
(binary_expression) @value_expr])))) @node
(method_declaration
(modifiers
(annotation
name: [(identifier) (scoped_identifier)] @ann
arguments: (annotation_argument_list
(element_value_pair
key: (identifier) @key
value: [(identifier) @value_expr
(field_access) @value_expr
(binary_expression) @value_expr]))))) @node
]
`,
);
@ -181,8 +200,12 @@ export function extractSpringRoutes(
if (methodMethods.length === 0) continue;
if (!isRouteMemberKey(keyNode)) continue;
// #2391-style non-literal path (constant ref or `+`-concat): emit with
// operands for cross-file folding in the parse phase. The match carries
// either @value (literal) or @value_expr (non-literal) — never both.
const valueExprNode = match.captures.find((c) => c.name === 'value_expr')?.node ?? null;
const routePath = unquoteSpringLiteral(valueNode.text);
if (routePath === null) continue;
if (routePath === null && !valueExprNode) continue;
const enclosingType = findEnclosingType(node);
// Interface-declared `@*Mapping`s are not concrete routes on their own — the
@ -217,6 +240,25 @@ export function extractSpringRoutes(
const handlerName = node.childForFieldName('name')?.text;
for (const httpMethod of httpMethods) {
if (routePath === null && valueExprNode) {
// Non-literal annotation value: parse operands now; the parse phase
// folds them against the repo-wide Java constant map (KTD5 skip floor
// on failure — never a phantom `POST /`).
const operands = parseJavaConstOperands(valueExprNode);
if (operands === null) continue;
routes.push({
filePath,
routePath: '',
routePathExpr: valueExprNode.text,
routePathOperands: operands,
httpMethod,
decoratorName: ann,
lineNumber: annNode.startPosition.row + lineOffset,
...(classPrefix ? { prefix: classPrefix } : {}),
...(handlerName ? { handlerName } : {}),
});
continue;
}
routes.push({
filePath,
routePath,

View file

@ -1426,6 +1426,7 @@ import {
type ModuleConstants,
type Operand,
} from '../route-extractors/python-const-resolver.js';
import { extractJavaModuleConstants } from '../route-extractors/java-const-resolver.js';
/**
* Report a non-fatal worker issue to the pool over IPC so a caught error is not
@ -2973,6 +2974,27 @@ const processFileGroup = (
}
}
// Java parity of the #2391 constant harvest: static-final String fields +
// class/static imports, folded cross-file by parse-impl for non-literal
// Spring mapping paths (`@WinPostMapping(ApiPathConstants.SAVE_V1)`).
// Cost-gated on file content — a file with no `static final String` and no
// constants-bearing import is not parsed for constants.
if (language === SupportedLanguages.Java) {
if (
/static\s+final\s+String\s/.test(parseContent) ||
/import\s+(static\s+)?[\w.]*Constants/.test(parseContent)
) {
const javaConstants = extractJavaModuleConstants(tree);
if (
javaConstants.literals.size > 0 ||
javaConstants.exprs.size > 0 ||
javaConstants.imports.size > 0
) {
(result.moduleConstants ??= []).push({ filePath: file.path, constants: javaConstants });
}
}
}
// Language-specific decorator route extraction via provider hook.
// The provider's extractDecoratorRoutes walks the AST for framework-specific
// route patterns (e.g., Java Spring class-level prefix joining). Routes are

View file

@ -0,0 +1,307 @@
/**
* Java route-path constant resolution (#2391 Java binding).
*
* Fixtures sampled from REAL Winning Health WiNEX-Outpatient source shapes
* (lesson from the vendor-alias PR #2883 review: hand-written textbook
* fixtures missed the dominant real-world spelling — 1198 constant-ref
* routes vs 2 literals in the real repo).
*
* Real shapes covered (counts from the live repo):
* - `@WinPostMapping(ApiPathConstants.DIAGNOSIS_SAVE_V1)` — qualified ref,
* 1063 occurrences
* - `@WinPostMapping(value = ApiPathConstants.X)` / `(path = X)` — named
* argument, 414+ occurrences
* - `@WinPostMapping(API_CIS_GET_TREATMENT_ORDER_V1)` — static-imported bare
* name, 79 files
* - `public static final String API = OTHER + "suffix"` — composed constant
* - interface constants (implicitly static final)
* - same-package simple-name collision handled by unique-suffix import
* resolution across Maven modules
* - FQN-qualified annotation value (4 occurrences)
* - unresolvable references floor to skip (never a phantom path)
*/
import { describe, expect, it } from 'vitest';
import Parser from 'tree-sitter';
import Java from 'tree-sitter-java';
import {
extractJavaModuleConstants,
parseJavaConstOperands,
resolveJavaConstant,
resolveJavaImport,
type RepoConstants,
} from '../../src/core/ingestion/route-extractors/java-const-resolver.js';
const parser = new Parser();
parser.setLanguage(Java);
function parse(src: string): Parser.Tree {
return parser.parse(src);
}
/** Build a RepoConstants map from virtual files: { 'a/b/C.java': source }. */
function repoOf(files: Record<string, string>): RepoConstants {
const map = new Map();
for (const [key, src] of Object.entries(files)) {
map.set(key, extractJavaModuleConstants(parse(src)));
}
return map;
}
// ─── Real WiNEX shapes ────────────────────────────────────────────────────
const CONSTANTS_FILE = `package com.winning.opt.diagnosis.api.constants;
import static com.winning.opt.common.constants.api.ApiPath.API_CIS_V1;
public class ApiPathConstants {
private ApiPathConstants() {
}
public static final String DIAGNOSIS_SAVE_V1 = "/api/v1/app_record_cis_outpatient_diagnosis/encounter_diagnosis/add";
public static final String DIAGNOSIS_SAVE_V2 = "/api/v2/app_record_cis_outpatient_diagnosis/encounter_diagnosis/add";
public static final String API_CIS_SAVE_SUMMARY = API_CIS_V1 + "summary/save";
}`;
const COMMON_API_FILE = `package com.winning.opt.common.constants.api;
public class ApiPath {
public static final String API_CIS_V1 = "/api/v1/cis/";
}`;
const CONTROLLER_FILE = `package com.winning.opt.diagnosis.controller;
import com.winning.opt.diagnosis.api.constants.ApiPathConstants;
public class DiagnosisController {
@WinPostMapping(ApiPathConstants.DIAGNOSIS_SAVE_V1)
public String save() { return "{}"; }
@WinPostMapping(value = ApiPathConstants.DIAGNOSIS_SAVE_V2)
public String saveV2() { return "{}"; }
@WinPostMapping(path = ApiPathConstants.API_CIS_SAVE_SUMMARY)
public String saveSummary() { return "{}"; }
}`;
const STATIC_IMPORT_CONTROLLER = `package com.winning.opt.cis.controller;
import static com.winning.opt.diagnosis.api.constants.ApiPathConstants.DIAGNOSIS_SAVE_V1;
public class CisController {
@WinPostMapping(DIAGNOSIS_SAVE_V1)
public String save() { return "{}"; }
}`;
const INTERFACE_CONSTANTS_FILE = `package com.winning.opt.labtest.api.constants;
public interface LabApiPath {
String LAB_QUERY_V1 = "/api/v1/labtest/query";
}`;
const FQN_CONTROLLER = `package com.winning.opt.other;
public class FqnController {
@WinPostMapping(com.winning.opt.diagnosis.api.constants.ApiPathConstants.DIAGNOSIS_SAVE_V1)
public String save() { return "{}"; }
}`;
const WIN_POST_MAPPING = `package com.winning.opt.annotations;
public @interface WinPostMapping {
String value() default "";
String path() default "";
}`;
// Fake minimal annotation so fixtures parse — the alias layer treats any
// *Mapping-suffixed annotation as a route annotation.
describe('extractJavaModuleConstants', () => {
it('collects static final String literals with class-qualified aliases', () => {
const mc = extractJavaModuleConstants(parse(CONSTANTS_FILE));
expect(mc.literals.get('DIAGNOSIS_SAVE_V1')).toBe(
'/api/v1/app_record_cis_outpatient_diagnosis/encounter_diagnosis/add',
);
expect(mc.literals.get('ApiPathConstants.DIAGNOSIS_SAVE_V1')).toBe(
'/api/v1/app_record_cis_outpatient_diagnosis/encounter_diagnosis/add',
);
});
it('records composed constants as operand expressions', () => {
const mc = extractJavaModuleConstants(parse(CONSTANTS_FILE));
const expr = mc.exprs.get('API_CIS_SAVE_SUMMARY');
expect(expr).toEqual([
{ kind: 'ref', name: 'API_CIS_V1' },
{ kind: 'literal', value: 'summary/save' },
]);
});
it('records class and static imports', () => {
const mc = extractJavaModuleConstants(parse(CONTROLLER_FILE));
expect(mc.imports.get('ApiPathConstants')).toEqual({
module: 'com.winning.opt.diagnosis.api.constants.ApiPathConstants',
originalName: 'ApiPathConstants',
});
const mcStatic = extractJavaModuleConstants(parse(STATIC_IMPORT_CONTROLLER));
expect(mcStatic.imports.get('DIAGNOSIS_SAVE_V1')).toEqual({
module: 'com.winning.opt.diagnosis.api.constants.ApiPathConstants',
originalName: 'DIAGNOSIS_SAVE_V1',
});
});
it('collects interface constants (implicitly static final)', () => {
const mc = extractJavaModuleConstants(parse(INTERFACE_CONSTANTS_FILE));
expect(mc.literals.get('LAB_QUERY_V1')).toBe('/api/v1/labtest/query');
});
it('ignores non-static or non-String fields', () => {
const src = `package p;
public class C {
public static final int COUNT = 5;
public String instance = "x";
static final String PRIVATE_OK = "/ok";
}`;
const mc = extractJavaModuleConstants(parse(src));
expect(mc.literals.has('COUNT')).toBe(false);
expect(mc.literals.has('instance')).toBe(false);
expect(mc.literals.get('PRIVATE_OK')).toBe('/ok');
});
});
describe('resolveJavaImport', () => {
const keys = new Set([
'winning-opt-diagnosis/src/main/java/com/winning/opt/diagnosis/api/constants/ApiPathConstants.java',
'winning-opt-common/src/main/java/com/winning/opt/common/constants/api/ApiPath.java',
]);
it('resolves a package import to the unique path-suffix file', () => {
const hit = resolveJavaImport(
'winning-opt-diagnosis/src/main/java/com/winning/opt/diagnosis/controller/DiagnosisController.java',
'com.winning.opt.diagnosis.api.constants.ApiPathConstants',
keys,
);
expect(hit).toBe(
'winning-opt-diagnosis/src/main/java/com/winning/opt/diagnosis/api/constants/ApiPathConstants.java',
);
});
it('resolves a static import (class.member → class file)', () => {
const hit = resolveJavaImport(
'winning-opt-cis/src/main/java/com/winning/opt/cis/controller/CisController.java',
'com.winning.opt.diagnosis.api.constants.ApiPathConstants',
keys,
);
expect(hit).toBe(
'winning-opt-diagnosis/src/main/java/com/winning/opt/diagnosis/api/constants/ApiPathConstants.java',
);
});
it('returns null when the class does not exist in the repo map', () => {
const hit = resolveJavaImport('a/A.java', 'com.example.notthere.NoConst', keys);
expect(hit).toBeNull();
});
});
describe('resolveJavaConstant end-to-end (real repo shapes)', () => {
const repo = repoOf({
'winning-opt-diagnosis/src/main/java/com/winning/opt/diagnosis/api/constants/ApiPathConstants.java':
CONSTANTS_FILE,
'winning-opt-common/src/main/java/com/winning/opt/common/constants/api/ApiPath.java':
COMMON_API_FILE,
});
const controllerKey =
'winning-opt-diagnosis/src/main/java/com/winning/opt/diagnosis/controller/DiagnosisController.java';
it('resolves qualified refs via the class import chain', () => {
// The controller imports ApiPathConstants; the ref name is qualified.
// Hand-rolled two-step: import resolves the class, qualified alias carries the field.
const mc = extractJavaModuleConstants(parse(CONTROLLER_FILE));
const targetFile = resolveJavaImport(
controllerKey,
mc.imports.get('ApiPathConstants')!.module,
new Set(repo.keys()),
);
expect(targetFile).toBeTruthy();
const value = resolveJavaConstant(targetFile!, 'ApiPathConstants.DIAGNOSIS_SAVE_V1', repo);
expect(value).toBe('/api/v1/app_record_cis_outpatient_diagnosis/encounter_diagnosis/add');
});
it('folds composed constants across files (static import + concat)', () => {
const mc = extractJavaModuleConstants(parse(CONSTANTS_FILE));
const targetFile = resolveJavaImport(
'winning-opt-diagnosis/src/main/java/com/winning/opt/diagnosis/api/constants/ApiPathConstants.java',
mc.imports.get('API_CIS_V1')!.module,
new Set(repo.keys()),
);
expect(targetFile).toBe(
'winning-opt-common/src/main/java/com/winning/opt/common/constants/api/ApiPath.java',
);
const value = resolveJavaConstant(
'winning-opt-diagnosis/src/main/java/com/winning/opt/diagnosis/api/constants/ApiPathConstants.java',
'API_CIS_SAVE_SUMMARY',
repo,
);
expect(value).toBe('/api/v1/cis/summary/save');
});
it('floors to null on unresolvable names (skip, never guess)', () => {
expect(resolveJavaConstant(controllerKey, 'NOT_A_THING', repo)).toBeNull();
});
});
describe('parseJavaConstOperands', () => {
it('parses a bare identifier ref', () => {
const tree = parse(`package p; public class C { static final String X = Y; }`);
let valueNode: Parser.SyntaxNode | null = null;
const walk = (n: Parser.SyntaxNode): void => {
if (n.type === 'variable_declarator') {
const v = n.childForFieldName('value');
if (v) valueNode = v;
}
for (const c of n.children ?? []) walk(c);
};
walk(tree.rootNode);
expect(parseJavaConstOperands(valueNode)).toEqual([{ kind: 'ref', name: 'Y' }]);
});
it('parses left-associative + chains', () => {
const tree = parse(`package p; public class C { static final String X = A + "/b" + C; }`);
let valueNode: Parser.SyntaxNode | null = null;
const walk = (n: Parser.SyntaxNode): void => {
if (n.type === 'variable_declarator') {
const v = n.childForFieldName('value');
if (v) valueNode = v;
}
for (const c of n.children ?? []) walk(c);
};
walk(tree.rootNode);
expect(parseJavaConstOperands(valueNode)).toEqual([
{ kind: 'ref', name: 'A' },
{ kind: 'literal', value: '/b' },
{ kind: 'ref', name: 'C' },
]);
});
it('returns null for calls and non-string shapes', () => {
const tree = parse(
`package p; public class C { static final String X = String.format("%s", a); }`,
);
let valueNode: Parser.SyntaxNode | null = null;
const walk = (n: Parser.SyntaxNode): void => {
if (n.type === 'variable_declarator') {
const v = n.childForFieldName('value');
if (v) valueNode = v;
}
for (const c of n.children ?? []) walk(c);
};
walk(tree.rootNode);
expect(parseJavaConstOperands(valueNode)).toBeNull();
});
});