mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-04 02:31:36 +00:00
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:
parent
aac7515d2a
commit
41d874dcb3
7 changed files with 903 additions and 6 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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 (`**/<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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
307
gitnexus/test/unit/java-route-const-resolver.test.ts
Normal file
307
gitnexus/test/unit/java-route-const-resolver.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue