mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +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) * 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 byfe3d7e56bfor #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 on43a0ff290. 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>
794 lines
31 KiB
TypeScript
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);
|
|
});
|
|
});
|