GitNexus/gitnexus/test/unit/java-route-const-resolver.test.ts
ChunxueLi 3f5fbb05e0
feat(group+ingestion): resolve Java constant-based route paths (@PostMapping(ApiPathConstants.X)) (#2980)
* 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)

* fix(review): address bot review findings on PR #2980

- P2-1 (real): spring.ts route loop dropped every @value_expr match — the
  '!valueNode' guard ran before the operand branch, so ingestion emitted zero
  constant-referencing routes. Guard now accepts @value_expr when @value is
  absent; two downstream valueNode dereferences made conditional.
  Added 2 extractor-level regression tests (16 total).
- P2-2 (real): collectSpringTypes copied rawPath:'' for constant routes into
  the shared Spring inheritance view — now skipped there (fold happens in
  scan(); empty-path noise would leak into inheritance-based providers).
- P1-1 (false positive): Java 'static final' allows exactly one initializer
  (duplicate declarations are compile errors), so the Python-style rebinding
  shadowing cleanup does not apply — documented at the site.
- P1-2 (false positive): constant-resolver.ts and prepareDurableParsedFileChunk
  both exist on upstream main (#2391 / parsedfile-store.ts:562); the bot's
  'repository lookup' appears to have compared against a stale index.
- P3: removed dead FQN_CONTROLLER fixture.

Real-repo regression: 589 cross-links / 2423 contracts (was 2424 — the
dropped contract is the empty-path inheritance artifact fixed above).

* docs(cache): note Java constant-route capture set in the SCHEMA_BUMP ledger

The Java constant-route harvest (route-extractors/java-const-resolver.ts +
the spring.ts operand branch + the parse-worker Java constant harvest)
changes the worker capture set: a warm pre-feature cache replays
moduleConstants=0 captures verbatim and silently drops every constant-based
Spring route on unchanged files. After rebasing onto current main the
ledger already sits at 70, whose capture set post-dates and includes this
harvest, so v70 invalidates those caches — no additional bump is needed.

* fix(feign): guard @RequestLine against the constant-valued shape

A constant-valued `@RequestLine(SOME_CONST)` is captured as @value_expr,
not @value, so `valueNode` is undefined in that shape and the literal
dereference crashed the scan. Skip instead — folding verb+path literals
through the constant map is out of scope for this PR.

Found in maintainer review of #2980.

* fix(resolver): bound qualified-ref recursion depth for self/mutual import cycles

Maintainer review point: the qualified branch of resolveJavaConstant
recurses through resolveJavaImport without a guard — a self-import
(X = SelfConsts.X + ...) or a pair of mutually-importing constants
would recurse without bound before reaching the shared fold's
visited-stack, which only guards the bare-name path.

Bound the Java-qualified walk with a depth cap (32) and thread it
through every recursive call. Two regression tests use real repo
shapes (repoOf fixtures): self-import and mutual-import cycles both
terminate with null (skip floor), as before, but promptly.

Also drops the stray machine-local .gitignore entry that rode along
from the fork's dev branch.

* fix(routes): address round-2 review — provider hooks, FQN fold, interface nesting

F1 (High): production harvest silently dropped routes when the constants
class is not named *Constants (e.g. ApiPaths). The content gate is now
SYNTAX-driven (static-final String field or any class import) and lives in
the provider (moduleConstantHeuristic), not a shared-layer regex.

F2: shared ingestion layers no longer branch on language. The harvest and
the qualified-ref fold run through new provider hooks
(extractModuleConstants / foldRoutePathOperands); parse-impl resolves the
provider by filePath (getProviderForFile). Python wires the same hooks for
architecture parity.

F3: multi-segment FQN chains (com.example.ApiPaths.USERS) now flatten
recursively; verified via tree-sitter that the existing query already
captures the whole nested field_access — the gap was resolver-side only.

F4: implicit-final interface semantics no longer leak into nested classes
at type boundaries (JLS 9.5).

F5: nested same-name shadowing now drops the stale entry (rebind-drop,
matching Python #2391 semantics) instead of keeping the first binding.

Tests: 9 new unit tests (27/27) + real-pipeline e2e over a reviewer-shaped
fixture (non-*Constants class, cold run + warm parse-cache replay) — the
exact production gap unit tests missed.

* style: prettier --write on the two touched test files (CI format gate)

* fix(routes): address the open review findings on Java constant route folding

Answers every reproduced finding still open on #2980, plus the defects an
adversarial pass found in the first round of those fixes. The wrong-path group
each turned a *missing* fact into a *wrong* one, which is what this module's
skip-or-correct contract exists to prevent.

Wrong-path fixes

* Escapes were deleted from constant values. tree-sitter-java splits a
  `string_literal` around its `escape_sequence` children, so joining
  `string_fragment`s alone folded `"/user/{id:\\d+}"` — the standard Spring
  path-variable constraint — to `/user/{id:d+}`, and a pure-escape literal to
  the empty string. Worse, the LITERAL path keeps escapes verbatim, so one Java
  route had two irreconcilable spellings. `stringLiteralValue` now reuses
  `unquoteSpringLiteral`, the helper that literal path already uses. Java text
  blocks are excluded: that helper's `"""` arm would hand back the raw block,
  newline and incidental indentation included, so they keep the old skip.

* A constant-valued class prefix produced a truncated route. The new
  `@value_expr` query branches were `method_declaration`-only, so
  `@RequestMapping(ApiPaths.BASE)` left the prefix empty and the method route
  was emitted unprefixed — a path the application does not serve, where the base
  emitted nothing at all. Both subsystems now detect such a class and suppress
  its method routes, the rule `classesWithArrayPrefix` already encodes for the
  array form. The suppression covers ingestion's separate no-argument-mapping
  loop too, without which a bare `@GetMapping` under a constant prefix still
  shipped an empty-path Route while the group emitted nothing.

* A shadowed static import survived a non-foldable rebind. The rebind-drop
  deleted `literals`/`exprs` but not `imports`, so a name both static-imported
  and locally redeclared resolved through the stale import to the imported
  value instead of skipping (#2393's Python defect, reproduced for Java).

* `resolveJavaImport` guessed where its own docstring promised null. The
  nearest-shared-directory tie-break is gone: javac resolves duplicate FQNs by
  classpath order, so proximity can return a src/test fixture copy.

Parity and coverage fixes

* One constant-file gate, exported as `isJavaConstantFile` and used by both the
  ingestion provider and the group `prepareRepo` pre-pass. The two spellings
  disagreed on a constant INTERFACE — implicitly `public static final`, so it
  carries neither keyword — which the group admitted and ingestion rejected, so
  the group published a contract while the graph got no Route node. It is also
  modifier-order agnostic now, and its interface arm requires a String
  assignment so a javadoc mentioning "interface" no longer costs a parse.

* Import ambiguity is measured over constant-DEFINING files on both sides.
  Ingestion's harvest gate also admits import-only files, so handing
  `resolveJavaImport` every repo key let a duplicate FQN that defines nothing
  make ingestion alone floor to skip — reopening the same parity break in the
  same losing direction.

* Python's constant harvest is unconditional again. The gate added here
  required NAME immediately followed by `=`, so it dropped `API: str = "/api"`,
  `API: Final[str] = "/api"` and every composed constant whose RHS starts with
  an identifier — routes that already resolve on main. The worker now treats a
  missing heuristic as "harvest" rather than "skip".

* Enum and record declarations were traversed but never collected, so a
  `static final String` declared in one was absent from the map. The walk still
  descends the whole body, so a type nested in an enum-constant body is kept.

* Constants composed across files through a qualified ref never resolved:
  operands found inside an initializer went to the agnostic core, which only
  knows bare names, so `X = BConsts.Y + "/tail"` floored to null even
  acyclically. The Java binding now folds its own expressions — and carries the
  core's guards with them: a `visited` stack popped on unwind, a memo of
  successes, and `MAX_FOLD_LENGTH`. Without the memo a shared-descendant DAG
  re-folds each child per reference; because a chain of empty strings never
  accumulates output, the length cap could not stop it, and one route over a
  31-line constants file took 11 s at 28 levels on the main thread.

* Dropped the dead `com.java.lang.` type normalization.

Cache

* `SCHEMA_BUMP` 70 -> 72. Leaving it at 70 was justified by "the ledger already
  sits at 70, whose capture set post-dates and includes this harvest" — it does
  not: 70 was cut by fe3d7e56b for #2417/#2891, an ancestor of this base. With
  package.json untouched, `PARSE_CACHE_VERSION` was byte-identical across the
  merge, so every same-version warm cache replayed pre-feature captures and the
  feature was inert. 72 rather than 71 because open PR #3017 already claims 71
  with an identical pin test — the ledger's rule is the next value above every
  in-flight claim, not above origin/main.

Tests

* Regression cover for each fix above, including a gate-level test (the gate
  itself had none), an import-ambiguity test, a text-block test, and a 30-level
  shared-descendant DAG that fails by timeout if the memo is ever removed.
* New `group/java-const-route-parity.test.ts` drives `prepareRepo` + a
  three-argument `scan`. Every existing Spring parity guard calls `scan(tree)`
  with ONE argument, and the plugin drops constant-valued routes without a repo
  context — so those guards were structurally blind to this whole feature.
* The pipeline e2e now proves the warm run is a REPLAY (`usedWorkerPool` false)
  instead of only comparing route sets. It was not one: the test never persisted
  the durable ParsedFile store, so the "warm" run reparsed through the workers
  and would have passed with the cache round-trip completely broken.
* Its dist freshness gate covers every source the pipeline loads, not just
  parse-worker.ts, and prints the loud message the docblock promised.
* The self-import cycle fixture now actually self-imports, so it reaches the
  qualified-ref recursion and its depth cap.
* Removed the dead `WIN_POST_MAPPING` fixture and the claim behind it: Spring
  alias recognition is an exact-name map on this base, so `@WinPostMapping`
  extracts zero routes no matter how its value folds (#2883 is still open).
  Fixtures now use annotations this branch actually recognises.

* fix(routes): widen the Java constant-file gate to match its extractor

Answers the gitnexus-check round on 43a0ff290.

The gate was still narrower than the extractor it feeds, in two ways the
extractor explicitly supports:

* `static final String` was matched as an ADJACENT pair, but the extractor
  scans modifiers independently (`isStaticFinal`), so `static public final
  String PATH = "/x";` — legal Java — was extracted when parsed and never
  parsed, because the gate returned false.
* the type had to be the bare token `String`, but the extractor also accepts
  `java.lang.String`, so `public static final java.lang.String PATH = "/x";`
  was skipped the same way.

Both are the same defect class as the ingestion/group divergence this predicate
was introduced to prevent, one layer down: a cost gate that is narrower than
the thing it gates silently drops facts. The modifier run is now matched as a
span excluding `;{}()`, so every legal order and the qualified type name are
admitted while precision holds — a local `String s = "x"` inside
`static void f() { … }` still does not match, because reaching it from `static`
crosses `(`, `)` and `{`. `final` is deliberately not required: the gate may be
wider than the extractor, never narrower.

Also: the worker's harvest condition moves into `shouldHarvestModuleConstants`
in `language-provider.ts`. The rule that is easy to get backwards — a provider
declaring no `moduleConstantHeuristic` harvests unconditionally — was only
reachable by booting a worker, so the Python tests could assert the extractor
harvests and the provider declares no heuristic while a regression to
`provider.moduleConstantHeuristic?.(content)` still turned the hook off. The
tests now drive the predicate itself, plus the two branches around it.

One finding in that round is not reproducible: the parity helper is not made
unresolvable by its import-only fixture. Every `resolveJavaImport` call site
passes the fold state's `constantKeys` — files with `literals`/`exprs` — not
`repo.keys()`, so a same-FQN class defining nothing creates no ambiguity. That
filtering is what the helper exists to exercise, and the test is green.

---------

Co-authored-by: ChunxueLi <mecoloud@users.noreply.gitee.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-08-25 09:41:57 +01:00

794 lines
31 KiB
TypeScript

/**
* 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).
*
* Value shapes covered, spelled with the Spring annotations this branch
* actually recognises (`@PostMapping` & co., bare or fully qualified):
* - `@PostMapping(ApiPathConstants.DIAGNOSIS_SAVE_V1)` — qualified ref, the
* dominant real-world spelling (1063 occurrences in the source corpus)
* - `@PostMapping(value = ApiPathConstants.X)` / `(path = X)` — named
* argument, 414+ occurrences
* - `@PostMapping(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)
* - escaped characters survive folding identically to the literal path
* - same-package simple-name collision floors to skip across Maven modules
* - FQN-qualified annotation value (4 occurrences)
* - unresolvable references floor to skip (never a phantom path)
*
* NOT covered, deliberately: the vendor alias `@WinPostMapping`. The corpus is
* dominated by it, but Spring alias recognition is an EXACT-NAME map
* (`spring-shared.ts`) on this base — there is no `*Mapping`-suffix rule, #2883
* is still open — so `@PostMapping(...)` extracts zero routes here no matter
* how the constant folds. A fixture written in that spelling would be dead
* (one was, and CodeQL flagged it). Constant folding and alias recognition are
* independent: when #2883 lands, every shape below works unchanged for aliases.
*/
import { describe, expect, it } from 'vitest';
import Parser from 'tree-sitter';
import Java from 'tree-sitter-java';
import {
extractJavaModuleConstants,
foldJavaOperands,
isJavaConstantFile,
parseJavaConstOperands,
resolveJavaConstant,
resolveJavaImport,
type RepoConstants,
} from '../../src/core/ingestion/route-extractors/java-const-resolver.js';
import { javaProvider } from '../../src/core/ingestion/languages/java.js';
import { unquoteSpringLiteral } from '../../src/core/ingestion/route-extractors/spring-shared.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 {
@PostMapping(ApiPathConstants.DIAGNOSIS_SAVE_V1)
public String save() { return "{}"; }
@PostMapping(value = ApiPathConstants.DIAGNOSIS_SAVE_V2)
public String saveV2() { return "{}"; }
@PostMapping(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 {
@PostMapping(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";
}`;
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();
});
});
// ── Ingestion extractor level: constant-referencing annotation values ──
// (regression for the review finding where the route loop's `!valueNode`
// guard dropped every @value_expr match before the operand branch ran)
describe('extractSpringRoutes constant value', () => {
it('emits routePathExpr + operands for @Mapping(CONSTS.X)', async () => {
const { extractSpringRoutes } =
await import('../../src/core/ingestion/route-extractors/spring.js');
const tree = parser.parse(`
package com.winning.opt.demo;
public class DemoController {
@org.springframework.web.bind.annotation.PostMapping(ApiPathConstants.DIAGNOSIS_SAVE_V1)
public String save() { return "ok"; }
}`);
const routes = extractSpringRoutes(tree, 'DemoController.java', 0);
expect(routes.length).toBe(1);
expect(routes[0].httpMethod).toBe('POST');
expect(routes[0].routePathExpr).toBe('ApiPathConstants.DIAGNOSIS_SAVE_V1');
expect(routes[0].routePathOperands && routes[0].routePathOperands.length > 0).toBeTruthy();
expect(routes[0].routePath).toBe('');
});
it('keeps literal routes unchanged', async () => {
const { extractSpringRoutes } =
await import('../../src/core/ingestion/route-extractors/spring.js');
const tree = parser.parse(`
package com.winning.opt.demo;
public class DemoController {
@org.springframework.web.bind.annotation.PostMapping("/literal/path")
public String save() { return "ok"; }
}`);
const routes = extractSpringRoutes(tree, 'DemoController.java', 0);
expect(routes.length).toBe(1);
expect(routes[0].routePath).toBe('/literal/path');
expect(routes[0].routePathExpr).toBe(undefined);
});
});
describe('qualified-ref recursion cycle guard (maintainer point 5)', () => {
it('self-import: qualified self-reference terminates with null, not a stack overflow', () => {
const repo = repoOf({
'src/main/java/com/example/SelfConsts.java': `package com.example;
import com.example.SelfConsts;
public class SelfConsts {
public static final String X = SelfConsts.X + "/x";
}`,
});
// In-file expr records the qualified ref `SelfConsts.X`; resolving it
// re-enters the same file via the (self) import head — must hit the depth
// cap, not the V8 stack.
expect(
resolveJavaConstant('src/main/java/com/example/SelfConsts.java', 'SelfConsts.X', repo),
).toBeNull();
});
it('mutual imports: A.X -> B.Y -> A.X terminates with null', () => {
const repo = repoOf({
'src/main/java/com/example/AConsts.java': `package com.example;
import com.example.BConsts;
public class AConsts {
public static final String X = BConsts.Y;
}`,
'src/main/java/com/example/BConsts.java': `package com.example;
import com.example.AConsts;
public class BConsts {
public static final String Y = AConsts.X;
}`,
});
expect(
resolveJavaConstant('src/main/java/com/example/AConsts.java', 'AConsts.X', repo),
).toBeNull();
});
});
// ─── Review round 2 regressions (#2980) ───────────────────────────────────
describe('F4: class nested in an interface is NOT implicitly final', () => {
const SRC = `package p;
public interface Api {
String BASE = "/api";
class Holder {
String mutable = "/mutable";
static final String OK = "/ok";
}
interface Inner {
String IMPLICIT = "/implicit";
class Deep {
String alsoMutable = "/also";
}
}
}`;
it('harvests the interface own fields and explicit static final nested fields', () => {
const mc = extractJavaModuleConstants(parse(SRC));
expect(mc.literals.get('BASE')).toBe('/api');
expect(mc.literals.get('OK')).toBe('/ok');
expect(mc.literals.get('Holder.OK')).toBe('/ok');
});
it('does NOT harvest mutable fields of a class nested in an interface', () => {
const mc = extractJavaModuleConstants(parse(SRC));
expect(mc.literals.has('mutable')).toBe(false);
expect(mc.literals.has('alsoMutable')).toBe(false);
expect(mc.literals.has('Holder.mutable')).toBe(false);
expect(mc.exprs.has('mutable')).toBe(false);
});
it('still harvests a class directly nested in an interface (own implicit semantics recomputed at each boundary)', () => {
const mc = extractJavaModuleConstants(parse(SRC));
expect(mc.literals.get('IMPLICIT')).toBe('/implicit');
expect(mc.literals.get('Inner.IMPLICIT')).toBe('/implicit');
});
});
describe('F5: same-name shadowing across nested types drops the stale entry', () => {
const SRC = `package p;
public class Outer {
public static final String PATH = "/v1";
static class Inner {
// shadows Outer.PATH with a non-foldable initializer
public static final String PATH = compute();
static String compute() { return "/v2"; }
}
}`;
it('a non-foldable shadow must drop the outer literal, not keep it (skip floor)', () => {
const mc = extractJavaModuleConstants(parse(SRC));
expect(mc.literals.has('PATH')).toBe(false);
expect(mc.exprs.has('PATH')).toBe(false);
});
it('qualified aliases survive per class (Outer.PATH resolvable, Inner.PATH not)', () => {
const mc = extractJavaModuleConstants(parse(SRC));
expect(mc.literals.get('Outer.PATH')).toBe('/v1');
expect(mc.literals.has('Inner.PATH')).toBe(false);
});
it('a foldable shadow REPLACES the outer value (last binding wins in source order)', () => {
const src = `package p;
public class Outer {
public static final String PATH = "/v1";
static class Inner {
public static final String PATH = "/v2";
}
}`;
const mc = extractJavaModuleConstants(parse(src));
expect(mc.literals.get('PATH')).toBe('/v2');
expect(mc.literals.get('Outer.PATH')).toBe('/v1');
expect(mc.literals.get('Inner.PATH')).toBe('/v2');
});
});
describe('F3: multi-segment FQN annotation values and constant initializers', () => {
const constValueOf = (src: string): Parser.SyntaxNode => {
const cls = parse(src).rootNode.descendantsOfType('class_declaration')[0]!;
const body = cls.childForFieldName('body')!;
const field = body.children.find((c) => c.type === 'field_declaration')!;
const decl = field.children.find((c) => c.type === 'variable_declarator')!;
return decl.childForFieldName('value')!;
};
it('parses com.example.ApiPaths.USERS as ONE ref (nested field_access chain flattened)', () => {
const ops = parseJavaConstOperands(
constValueOf(`package p;
public class W {
public static final String X = com.example.ApiPaths.USERS;
}`),
);
expect(ops).toEqual([{ kind: 'ref', name: 'com.example.ApiPaths.USERS' }]);
});
it('still rejects call/object-side chains: f().X, this.X, arr[0].X', () => {
expect(
parseJavaConstOperands(
constValueOf(`package p;
public class W { public static final String A = f().X; static Object f(){return null;} }`),
),
).toBeNull();
expect(
parseJavaConstOperands(
constValueOf(`package p;
public class W { public static final String B = this.Y; String Y = "y"; }`),
),
).toBeNull();
expect(
parseJavaConstOperands(
constValueOf(`package p;
public class W { public static final String C = arr[0].Z; }`),
),
).toBeNull();
});
it('resolves an FQN-qualified annotation constant end-to-end (query → operands → fold)', () => {
const repo = repoOf({
'src/main/java/com/example/ApiPaths.java': `package com.example;
public class ApiPaths {
public static final String USERS = "/api/v1/users";
}`,
'src/main/java/com/example/Ctl.java': `package com.example;
import org.springframework.web.bind.annotation.PostMapping;
public class Ctl {
@PostMapping(com.example.ApiPaths.USERS)
public void list() {}
}`,
});
// The whole FQN arrives as one ref operand (verified against the real
// tree-sitter-java parse shape); the resolver must follow it via the
// longest-prefix import fallback.
expect(
resolveJavaConstant('src/main/java/com/example/Ctl.java', 'com.example.ApiPaths.USERS', repo),
).toBe('/api/v1/users');
});
});
describe('escaped characters survive folding (review P1)', () => {
// tree-sitter-java splits a string_literal AROUND its escape_sequence
// children, so a string_fragment-only join silently deleted every escape:
// the standard Spring path-variable constraint `{id:\\d+}` folded to
// `{id:d+}` and a pure-escape literal folded to ''. Worse, the LITERAL path
// keeps escapes verbatim, so one Java route had two spellings.
const cases = [
['"/user/{id:\\d+}"', '/user/{id:\\d+}'],
['"/a\\tb"', '/a\\tb'],
['"/a\\u002Fb"', '/a\\u002Fb'],
['"\\t"', '\\t'],
['""', ''],
['"/plain"', '/plain'],
] as const;
it.each(cases)('keeps %s intact through the constant path', (literal, expected) => {
const mc = extractJavaModuleConstants(
parse(`public class C { public static final String X = ${literal}; }`),
);
expect(mc.literals.get('X')).toBe(expected);
});
it.each(cases)('agrees with the literal path for %s', (literal, expected) => {
// The constant path and `unquoteSpringLiteral` (what a literal-valued
// @GetMapping goes through) must produce the SAME string, or the graph
// carries two irreconcilable spellings of one route.
expect(unquoteSpringLiteral(literal)).toBe(expected);
});
});
describe('a non-foldable rebind drops the static import too (review P1)', () => {
it('returns null rather than the shadowed imported value', () => {
const repo = repoOf({
'src/main/java/com/x/Base.java': `package com.x;
public class Base { public static final String PATH = "/WRONG-imported"; }`,
'src/main/java/com/y/C.java': `package com.y;
import static com.x.Base.PATH;
public class C { public static final String PATH = compute(); }`,
});
// A local `static final` shadows a static import of the same simple name
// inside that class (JLS 6.4.1), so the only correct answer is
// "unresolvable". Leaving the import alive made the fold fall through to
// it and return the imported literal — a wrong path where the skip floor
// is owed (#2393's Python defect, reproduced for Java).
expect(repo.get('src/main/java/com/y/C.java')!.imports.has('PATH')).toBe(false);
expect(
foldJavaOperands('src/main/java/com/y/C.java', [{ kind: 'ref', name: 'PATH' }], repo),
).toBeNull();
});
it('the drop is file-scoped: a sibling class floors to skip, never to a wrong value', () => {
// These maps are file-level by design (nested types flatten into one
// namespace), so dropping the import costs a sibling class that
// legitimately uses it. javac would answer `/imported/b` here; we answer
// null. Pinned deliberately — the alternative direction is a wrong path.
const repo = repoOf({
'src/main/java/com/x/Base.java': `package com.x;
public class Base { public static final String PATH = "/imported"; }`,
'src/main/java/com/y/Two.java': `package com.y;
import static com.x.Base.PATH;
class A { public static final String PATH = compute(); }
class B { public static final String USE = PATH + "/b"; }`,
});
expect(
foldJavaOperands('src/main/java/com/y/Two.java', [{ kind: 'ref', name: 'B.USE' }], repo),
).toBeNull();
});
it('a FOLDABLE rebind still wins over the import', () => {
const repo = repoOf({
'src/main/java/com/x/Base.java': `package com.x;
public class Base { public static final String PATH = "/imported"; }`,
'src/main/java/com/y/C.java': `package com.y;
import static com.x.Base.PATH;
public class C { public static final String PATH = "/local"; }`,
});
expect(
foldJavaOperands('src/main/java/com/y/C.java', [{ kind: 'ref', name: 'PATH' }], repo),
).toBe('/local');
});
});
describe('isJavaConstantFile — one gate, both subsystems (review P1)', () => {
// The ingestion provider and the group extractor's prepareRepo pre-pass used
// to spell this gate differently. A constant INTERFACE passed the group's and
// failed ingestion's, so the group published a provider contract while the
// graph got no Route node — an R4 parity break in the losing direction.
const shapes = [
[
'constant interface (implicitly static final, no import)',
`package com.x;
public interface ApiPathConstants { String SAVE = "/api/v1/save"; }`,
],
[
'lowercase interface name',
`package com.x;
public interface apiPaths { String SAVE = "/api/v1/save"; }`,
],
[
'reversed modifier order',
`package com.x;
public class P { public final static String SAVE = "/api/v1/save"; }`,
],
[
'conventional order',
`package com.x;
public class P { public static final String SAVE = "/api/v1/save"; }`,
],
[
'modifiers interleaved',
`package com.x;
public class P { static public final String SAVE = "/api/v1/save"; }`,
],
[
'fully-qualified java.lang.String',
`package com.x;
public class P { public static final java.lang.String SAVE = "/api/v1/save"; }`,
],
[
'fully-qualified type in an interface',
`package com.x;
public interface P { java.lang.String SAVE = "/api/v1/save"; }`,
],
] as const;
it.each(shapes)('admits %s on BOTH sides', (_name, src) => {
expect(isJavaConstantFile(src)).toBe(true);
// The provider hook is what the parse worker actually calls — drive it,
// not just the regex, so the gate itself is covered and not only the
// extractor behind it.
expect(javaProvider.moduleConstantHeuristic?.(src)).toBe(true);
expect(extractJavaModuleConstants(parse(src)).literals.get('SAVE')).toBe('/api/v1/save');
});
it.each([
[
'no constant-bearing syntax',
`package com.x;
public class P { void run() { System.out.println("/not-a-constant"); } }`,
],
[
'a local String inside a static method',
`package com.x;
public class P { static void run() { String s = "/local"; } }`,
],
[
'prose that merely mentions an interface',
`/** interface EXTENDS (#1951). */
public class A { void f() {} }`,
],
])('still skips %s', (_name, src) => {
expect(isJavaConstantFile(src)).toBe(false);
expect(extractJavaModuleConstants(parse(src)).literals.size).toBe(0);
});
});
describe('resolveJavaImport honours the documented skip floor (review P2)', () => {
it('returns null when the same package+class exists in two modules', () => {
// A nearest-shared-directory tie-break used to pick one. javac resolves
// duplicate FQNs by classpath order, so proximity can hand back a
// src/test fixture copy — a silently wrong literal in a resolver whose
// contract is skip-or-correct.
const keys = new Set([
'svc-order/src/main/java/com/x/ApiPaths.java',
'svc-user/src/main/java/com/x/ApiPaths.java',
]);
expect(
resolveJavaImport(
'svc-order/src/main/java/com/x/web/OrderController.java',
'com.x.ApiPaths',
keys,
),
).toBeNull();
});
it('still resolves a unique full-suffix match', () => {
const keys = new Set([
'svc-order/src/main/java/com/x/ApiPaths.java',
'svc-user/src/main/java/com/y/ApiPaths.java',
]);
expect(
resolveJavaImport(
'svc-order/src/main/java/com/x/web/OrderController.java',
'com.x.ApiPaths',
keys,
),
).toBe('svc-order/src/main/java/com/x/ApiPaths.java');
});
});
describe('enum and record constants are collected', () => {
it.each([
['enum', 'public enum E { A, B; public static final String P = "/e"; }', 'E'],
['record', 'public record R(int x) { public static final String P = "/r"; }', 'R'],
])('harvests a static final String declared in a %s', (_kind, src, owner) => {
const mc = extractJavaModuleConstants(parse(src));
expect(mc.literals.get('P')).toBe(src.includes('enum') ? '/e' : '/r');
expect(mc.literals.get(`${owner}.P`)).toBe(src.includes('enum') ? '/e' : '/r');
});
it('does not harvest a non-static field of a record', () => {
const mc = extractJavaModuleConstants(parse('public record R(int x) { String p = "/r"; }'));
expect(mc.literals.has('p')).toBe(false);
});
});
describe('constants composed across files through a qualified ref', () => {
it('folds `X = BConsts.Y + "/tail"` across the import', () => {
// Operands found INSIDE an initializer used to go straight to the agnostic
// fold, which only knows bare names — so a qualified operand missed and
// floored the whole chain to null, even acyclically.
const repo = repoOf({
'src/com/example/AConsts.java': `package com.example;
import com.example.BConsts;
public class AConsts { public static final String X = BConsts.Y + "/tail"; }`,
'src/com/example/BConsts.java': `package com.example;
public class BConsts { public static final String Y = "/y"; }`,
});
expect(resolveJavaConstant('src/com/example/AConsts.java', 'X', repo)).toBe('/y/tail');
expect(
foldJavaOperands('src/com/example/AConsts.java', [{ kind: 'ref', name: 'AConsts.X' }], repo),
).toBe('/y/tail');
});
it('a missing link in the chain still floors to null', () => {
const repo = repoOf({
'src/com/example/AConsts.java': `package com.example;
import com.example.BConsts;
public class AConsts { public static final String X = BConsts.MISSING + "/tail"; }`,
'src/com/example/BConsts.java': `package com.example;
public class BConsts { public static final String Y = "/y"; }`,
});
expect(resolveJavaConstant('src/com/example/AConsts.java', 'X', repo)).toBeNull();
});
});
describe('the fold is bounded in time as well as depth', () => {
it('folds a 30-level shared-descendant DAG instead of exploring 2^30 paths', () => {
// `X_k = X_{k+1} + X_{k+1}` re-folds each child once per reference without a
// memo — O(2^depth). MAX_FOLD_LENGTH cannot save it here because every
// intermediate value is the EMPTY string, so nothing ever accumulates.
// Un-memoized this took 2.7 s at 26 levels and 11 s at 28, on the main
// thread, for one route. The assertion is the explicit timeout below: a
// regression does not fail this test slowly, it fails it.
const lines = ['public static final String X30 = "";'];
for (let i = 29; i >= 0; i--) {
lines.push(`public static final String X${i} = X${i + 1} + X${i + 1};`);
}
const repo = repoOf({ 'C.java': `public class C {\n${lines.join('\n')}\n}` });
expect(resolveJavaConstant('C.java', 'X0', repo)).toBe('');
}, 5_000);
it('still caps a chain that genuinely produces a huge string', () => {
const lines = ['public static final String X30 = "a";'];
for (let i = 29; i >= 0; i--) {
lines.push(`public static final String X${i} = X${i + 1} + X${i + 1};`);
}
const repo = repoOf({ 'C.java': `public class C {\n${lines.join('\n')}\n}` });
expect(resolveJavaConstant('C.java', 'X0', repo)).toBeNull();
}, 5_000);
});
describe('text blocks keep the skip floor', () => {
it('does not fold a text-block constant into a path with newlines and indentation', () => {
// `unquoteSpringLiteral` has a `"""` arm that slices 3/-3, which would hand
// back the raw block — leading newline and incidental indentation included,
// both of which Java strips — and nothing downstream normalizes it. The old
// fragment-join returned '' here, i.e. a skip; keep the skip.
const src = [
'public class C {',
' public static final String X = """',
' /api/v1/tb',
' """;',
'}',
].join('\n');
expect(extractJavaModuleConstants(parse(src)).literals.has('X')).toBe(false);
});
});