From 41d874dcb36815e5fd411a4916aae65e98fc33a9 Mon Sep 17 00:00:00 2001 From: ChunxueLi Date: Sun, 16 Aug 2026 08:30:50 +0800 Subject: [PATCH] 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) --- .gitignore | 1 + .../group/extractors/http-patterns/java.ts | 141 ++++++- .../ingestion/pipeline-phases/parse-impl.ts | 6 +- .../route-extractors/java-const-resolver.ts | 388 ++++++++++++++++++ .../core/ingestion/route-extractors/spring.ts | 44 +- .../core/ingestion/workers/parse-worker.ts | 22 + .../unit/java-route-const-resolver.test.ts | 307 ++++++++++++++ 7 files changed, 903 insertions(+), 6 deletions(-) create mode 100644 gitnexus/src/core/ingestion/route-extractors/java-const-resolver.ts create mode 100644 gitnexus/test/unit/java-route-const-resolver.test.ts diff --git a/.gitignore b/.gitignore index e16544f71..2844816bd 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index 4eba0c1f1..2e62d77b8 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index 2a3d21612..64f246121 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -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++; diff --git a/gitnexus/src/core/ingestion/route-extractors/java-const-resolver.ts b/gitnexus/src/core/ingestion/route-extractors/java-const-resolver.ts new file mode 100644 index 000000000..033326fb8 --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/java-const-resolver.ts @@ -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 (`/.java` as a path suffix) + * 2. class-name-only suffix (`**/.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 + * `.` (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(); + const exprs = new Map(); + const imports = new Map(); + + // 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 }; +} + +/** + * 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; +} diff --git a/gitnexus/src/core/ingestion/route-extractors/spring.ts b/gitnexus/src/core/ingestion/route-extractors/spring.ts index 5c0b0637b..5161acf79 100644 --- a/gitnexus/src/core/ingestion/route-extractors/spring.ts +++ b/gitnexus/src/core/ingestion/route-extractors/spring.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index d9fb0fd67..ee698b6e7 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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 diff --git a/gitnexus/test/unit/java-route-const-resolver.test.ts b/gitnexus/test/unit/java-route-const-resolver.test.ts new file mode 100644 index 000000000..e4bda047e --- /dev/null +++ b/gitnexus/test/unit/java-route-const-resolver.test.ts @@ -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): 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(); + }); +});