GitNexus/gitnexus/test/unit/group/java-const-route-parity.test.ts
ChunxueLi 72ff2b9453
feat(jvm): fold Java static wildcards and Kotlin star imports for route constants (#3110)
* feat(java): expand wildcard static imports + POSIX-space import resolution

- `import static a.b.C.*` records the class FQN at extract time
  (ModuleConstants.wildcardImports) and expandJavaWildcardStaticImports
  materializes the bare-name bindings once the repo constants map exists,
  mirroring explicit single-member static imports. Wired in both the
  group-side prepareRepo fold and the ingestion-side parse-impl pass so
  the two query surfaces cannot diverge. Without this, wildcard-imported
  route constants (~693 routes on our monorepo) silently failed to fold
  and their provider contracts were dropped.
- resolveJavaImport compares in POSIX space (backslash-normalized repo
  keys) — on Windows the '/'-joined class file never matched a
  backslash-keyed repo (observed: 675 calls, zero hits).
- Wildcard bindings never overwrite single imports (a member shadowing
  its own wildcard is honored); unresolved wildcards degrade to the
  existing skip floor.

Rebased onto current main: the parse-worker gate this originally carried
is superseded by the provider moduleConstantHeuristic architecture; only
the resolver-side wildcard expansion and POSIX tolerance remain.

* chore(cache): claim SCHEMA_BUMP 83 (82 taken upstream by Spring lookup facts)

* style: prettier

* fix(java): make wildcard static imports actually resolve route constants

extractJavaModuleConstants never populated wildcardImports, so
`import static a.b.C.*;` was inert: the asterisk is a sibling of
scoped_identifier in tree-sitter-java, not a path segment. Record the
class FQN there and stop binding the class simple name as a field.

Wildcard-only files also fell through every harvest gate (the java
provider heuristic regex, the parse worker emit check, and the group
prepareRepo filter), so the constants never reached either layer.
Expansion now resolves targets against constant-defining files only,
keeping ingestion and group in parity (#2980 R4).

Adds unit coverage for extraction, expansion/shadowing, the unresolved
skip floor, the harvest heuristic, Windows path keys, and group <->
ingestion parity.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(kotlin): fold package-star imports for route constants

Kotlin `import pkg.*` now records star scopes and resolves unique
top-level names after local and explicit imports, matching the Java
wildcard path without accepting invalid object-star imports.

Reuse a Java constant-file suffix index across expansion so
wildcard materialization stays linear as both constants and
importers scale. Add named-vs-star benches and CI --check gates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(jvm): fold wildcard imports without shadowing locals or skipping same-package names

Keep Java unfoldable declarations from being resurrected by static wildcards, bind only the target type's members, and prefer Kotlin same-package names over package-star imports. Move repo-wide preparation behind a language-provider hook so the shared parse phase stays language-agnostic.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3110)

- Stop treating a Java static wildcard as a type import for qualified refs.
- Harvest only class and static (including on-demand) imports, not import pkg.*.
- Let harvested Kotlin top-level names shadow same-package star imports.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3110)

- Fold Kotlin classifier-star imports (`import Type.*`) the same way package stars already fold.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3110)

- Rebuild the Kotlin constant index when overlay replaces a contributing file.
- Document the wildcard shape in the Java pipeline e2e fixture.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: l.cx <l.cx@winning.com.cn>
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>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 21:58:40 +00:00

295 lines
11 KiB
TypeScript

/**
* Group ↔ ingestion parity for Java constant-valued Spring route paths (#2980).
*
* Drives `JAVA_HTTP_PLUGIN.prepareRepo` + `scan(tree, ctx, rel)` with all three
* arguments and compares the result against what `extractSpringRoutes` + the
* Java operand fold produce on the ingestion side. The existing Spring parity
* guards call `scan(tree)` with ONE argument, which makes them structurally
* blind here: without a repo context the plugin drops every constant-valued
* route, so no fixture they carry can exercise this feature.
*
* Asserted:
* • a constant-valued mapping resolves to the SAME path on both sides;
* • a CONSTANT class prefix suppresses the method route on both sides — the
* prefix cannot be folded at extraction time, and emitting the route
* unprefixed would publish a path the application does not serve;
* • without a repo context the group side emits nothing (the documented skip
* floor, and the branch that makes the 1-arg guards blind);
* • literal routes are untouched.
*/
import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import Java from 'tree-sitter-java';
import { JAVA_HTTP_PLUGIN } from '../../../src/core/group/extractors/http-patterns/java.js';
import type { HttpDetection } from '../../../src/core/group/extractors/http-patterns/types.js';
import { extractSpringRoutes } from '../../../src/core/ingestion/route-extractors/spring.js';
import { javaProvider } from '../../../src/core/ingestion/languages/java.js';
import {
expandJavaWildcardStaticImports,
extractJavaModuleConstants,
foldJavaOperands,
type RepoConstants,
} from '../../../src/core/ingestion/route-extractors/java-const-resolver.js';
const parser = new Parser();
const parseSource = (p: Parser, src: string): Parser.Tree => {
p.setLanguage(Java);
return p.parse(src);
};
const parse = (src: string): Parser.Tree => parseSource(parser, src);
/** Group side: prepareRepo + a 3-argument scan over every .java file. */
function groupProviders(files: Record<string, string>): string[] {
const ctx = JAVA_HTTP_PLUGIN.prepareRepo?.({
files: Object.keys(files),
parser: new Parser(),
readFile: (rel: string) => files[rel] ?? null,
parseSource,
});
const out: string[] = [];
for (const rel of Object.keys(files)) {
const detections: HttpDetection[] = JAVA_HTTP_PLUGIN.scan(parse(files[rel]), ctx, rel);
for (const d of detections) {
if (d.role === 'provider') out.push(`${d.method} ${d.path}`);
}
}
return out.sort();
}
/** Ingestion side: extract routes, then fold operands against the same map. */
function ingestionRoutes(files: Record<string, string>): string[] {
const repo: RepoConstants = new Map();
for (const [rel, src] of Object.entries(files)) {
repo.set(rel, extractJavaModuleConstants(parse(src)));
}
for (const [rel, mc] of repo) {
expandJavaWildcardStaticImports(mc, rel, repo);
}
const out: string[] = [];
for (const [rel, src] of Object.entries(files)) {
for (const route of extractSpringRoutes(parse(src), rel, 0)) {
const path = route.routePathOperands
? foldJavaOperands(rel, route.routePathOperands, repo)
: route.routePath;
if (path === null) continue;
out.push(`${route.httpMethod} ${`${route.prefix ?? ''}${path}`.replace(/\/{2,}/g, '/')}`);
}
}
return out.sort();
}
const CONSTS = 'src/main/java/com/example/ApiPaths.java';
const CTL = 'src/main/java/com/example/OrderController.java';
const CONSTS_SRC = `package com.example;
public class ApiPaths {
public static final String BASE = "/api/v1";
public static final String ORDERS = "/api/v1/orders";
}`;
describe('Java constant-valued routes: group ↔ ingestion parity (#2980)', () => {
it('resolves a constant-valued mapping to the same path on both sides', () => {
const files = {
[CONSTS]: CONSTS_SRC,
[CTL]: `package com.example;
import com.example.ApiPaths;
public class OrderController {
@GetMapping(ApiPaths.ORDERS)
public void list() {}
}`,
};
expect(groupProviders(files)).toEqual(['GET /api/v1/orders']);
expect(ingestionRoutes(files)).toEqual(groupProviders(files));
});
it('suppresses the method route under a CONSTANT class prefix on both sides', () => {
// The class prefix needs the repo-wide constant map, which does not exist
// at extraction time on either side. Emitting the method route would drop
// the prefix and publish `GET /api/v1/orders`-without-its-base — a path the
// application never serves. On base such a route was not emitted at all, so
// shipping it unprefixed would turn a missing fact into a wrong one.
const files = {
[CONSTS]: CONSTS_SRC,
[CTL]: `package com.example;
import com.example.ApiPaths;
@RequestMapping(ApiPaths.BASE)
public class OrderController {
@GetMapping(ApiPaths.ORDERS)
public void list() {}
@GetMapping("/literal")
public void literal() {}
}`,
};
expect(groupProviders(files)).toEqual([]);
expect(ingestionRoutes(files)).toEqual([]);
});
it('still applies a LITERAL class prefix', () => {
const files = {
[CONSTS]: CONSTS_SRC,
[CTL]: `package com.example;
import com.example.ApiPaths;
@RequestMapping("/api/v1")
public class OrderController {
@GetMapping("/orders")
public void list() {}
}`,
};
expect(groupProviders(files)).toEqual(['GET /api/v1/orders']);
expect(ingestionRoutes(files)).toEqual(['GET /api/v1/orders']);
});
it('emits nothing for a constant route when scanned without a repo context', () => {
// This is the branch that makes the 1-argument parity guards blind to the
// whole feature; pin it so it is not silently dead in the suite.
const src = `package com.example;
import com.example.ApiPaths;
public class OrderController {
@GetMapping(ApiPaths.ORDERS)
public void list() {}
}`;
const detections = JAVA_HTTP_PLUGIN.scan(parse(src));
expect(detections.filter((d) => d.role === 'provider')).toEqual([]);
});
it('suppresses a NO-ARGUMENT mapping under a constant class prefix on both sides', () => {
// A bare `@GetMapping` IS the class prefix, so an unfoldable class prefix
// leaves nothing to emit. Ingestion routes these through a separate loop
// from the path-carrying ones, and that loop needs the same guard — without
// it ingestion emitted an empty-path Route where the group emitted nothing.
const files = {
[CONSTS]: CONSTS_SRC,
[CTL]: `package com.example;
import com.example.ApiPaths;
@RequestMapping(ApiPaths.BASE)
public class OrderController {
@GetMapping public void list() {}
@PostMapping public void create() {}
}`,
};
expect(ingestionRoutes(files)).toEqual([]);
expect(groupProviders(files)).toEqual([]);
});
it('measures import ambiguity over the same candidate set on both sides', () => {
// Ingestion's harvest gate also admits import-only files, so its repo map is
// a superset of the group's. When ambiguity was measured over every key, a
// duplicate FQN belonging to a class that defines NOTHING was invisible to
// the group and made ingestion alone floor to skip — reopening the very
// parity break this feature exists to close. Both sides now measure over
// constant-DEFINING files only.
const files = {
'svc-a/src/main/java/com/x/ApiPaths.java': `package com.x;
public class ApiPaths { public static final String ORDERS = "/api/v1/orders"; }`,
// Same FQN, different module, defines no constant — must not create ambiguity.
'svc-b/src/main/java/com/x/ApiPaths.java': `package com.x;
import java.util.List;
public class ApiPaths {}`,
'svc-a/src/main/java/com/x/web/OrderController.java': `package com.x.web;
import com.x.ApiPaths;
public class OrderController {
@GetMapping(ApiPaths.ORDERS)
public void list() {}
}`,
};
// Guard the premise: the two maps really are different sizes.
const ingestionKeys = Object.entries(files).filter(([, src]) =>
javaProvider.moduleConstantHeuristic?.(src),
).length;
expect(ingestionKeys).toBe(3);
expect(groupProviders(files)).toEqual(['GET /api/v1/orders']);
expect(ingestionRoutes(files)).toEqual(['GET /api/v1/orders']);
});
it('resolves a wildcard-static-imported mapping on both sides', () => {
const files = {
[CONSTS]: CONSTS_SRC,
[CTL]: `package com.example;
import static com.example.ApiPaths.*;
public class OrderController {
@GetMapping(ORDERS)
public void list() {}
}`,
};
expect(javaProvider.moduleConstantHeuristic?.(files[CTL])).toBe(true);
expect(groupProviders(files)).toEqual(['GET /api/v1/orders']);
expect(ingestionRoutes(files)).toEqual(groupProviders(files));
});
it('keeps an unfoldable local field above a wildcard member on both sides', () => {
const files = {
[CONSTS]: CONSTS_SRC,
[CTL]: `package com.example;
import static com.example.ApiPaths.*;
public class OrderController {
static final String ORDERS = runtimePath();
@GetMapping(ORDERS)
public void list() {}
}`,
};
expect(groupProviders(files)).toEqual([]);
expect(ingestionRoutes(files)).toEqual([]);
});
it('imports only members owned by the wildcard target type on both sides', () => {
const files = {
[CONSTS]: `package com.example;
public class ApiPaths { public static final String ORDERS = "/right"; }
class Other { public static final String ORDERS = "/wrong"; }`,
[CTL]: `package com.example;
import static com.example.ApiPaths.*;
public class OrderController {
@GetMapping(ORDERS)
public void list() {}
}`,
};
expect(groupProviders(files)).toEqual(['GET /right']);
expect(ingestionRoutes(files)).toEqual(['GET /right']);
});
it('floors duplicate wildcard members on both sides', () => {
const files = {
'src/main/java/a/A.java': `package a;
public class A { public static final String ROUTE = "/a"; }`,
'src/main/java/b/B.java': `package b;
public class B { public static final String ROUTE = "/b"; }`,
[CTL]: `package com.example;
import static a.A.*;
import static b.B.*;
public class OrderController {
@GetMapping(ROUTE)
public void list() {}
}`,
};
expect(groupProviders(files)).toEqual([]);
expect(ingestionRoutes(files)).toEqual([]);
});
it('does not fold a type-qualified ref from a static wildcard alone', () => {
const files = {
[CONSTS]: CONSTS_SRC,
[CTL]: `package com.example;
import static com.example.ApiPaths.*;
public class OrderController {
@GetMapping(ApiPaths.ORDERS)
public void list() {}
}`,
};
expect(groupProviders(files)).toEqual([]);
expect(ingestionRoutes(files)).toEqual([]);
});
it('leaves literal routes unchanged with no constant map at all', () => {
const files = {
[CTL]: `package com.example;
public class OrderController {
@PostMapping("/api/v1/orders")
public void create() {}
}`,
};
expect(groupProviders(files)).toEqual(['POST /api/v1/orders']);
expect(ingestionRoutes(files)).toEqual(['POST /api/v1/orders']);
});
});