mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-17 23:52:36 +00:00
* 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>
270 lines
12 KiB
TypeScript
270 lines
12 KiB
TypeScript
/**
|
|
* #2980 review round-2: COLD and WARM pipeline e2e for the provider-hook
|
|
* constant harvest (`extractModuleConstants` / `moduleConstantHeuristic` /
|
|
* `foldRoutePathOperands`).
|
|
*
|
|
* The maintainer's blocking finding: unit tests only exercised worker-gated
|
|
* helpers — never the REAL pipeline. A controller referencing constants from
|
|
* a class NOT named `*Constants` (e.g. `ApiPaths`) was silently dropped:
|
|
* the old content gate `/import ... [\\w.]*Constants/` never matched, the
|
|
* constants file never entered the import map, the route resolved to null and
|
|
* got skipped.
|
|
*
|
|
* This file drives the REAL `runChunkedParseAndResolve` with the REAL compiled
|
|
* dist worker (vitest auto-falls back to dist/core/ingestion/workers/
|
|
* parse-worker.js) over a fixture repo shaped like the reviewer's example:
|
|
*
|
|
* repo/
|
|
* src/main/java/com/example/ApiPaths.java — constants class NOT named
|
|
* *Constants (the High bug)
|
|
* src/main/java/com/example/UserController.java — @RequestMapping prefix +
|
|
* @PostMapping(ApiPaths.X) +
|
|
* FQN form + concat over a
|
|
* named static import +
|
|
* wildcard static import
|
|
*
|
|
* Assertions (both runs):
|
|
* - the emitted Route node carries the FOLDED literal path, not the expr;
|
|
* - ALL FOUR non-literal shapes survive (qualified, FQN-qualified, wildcard, concat);
|
|
* - a phantom `POST ` / empty path never appears (skip floor);
|
|
* - the warm run yields the IDENTICAL route set AND is a genuine replay
|
|
* (`usedWorkerPool === false`) — the harvest result survives the
|
|
* structured-clone cache round trip (ModuleConstants uses Map, exercised
|
|
* through mapReplacer/mapReviver). Asserting the route set alone would pass
|
|
* on a cache MISS that silently reparsed.
|
|
*
|
|
* Rebuild gate: this test requires dist/ to be current; when dist/ is stale
|
|
* (older than src/) it self-skips with a loud message rather than silently
|
|
* asserting against the old binary. (CI builds before vitest, so it runs.)
|
|
*/
|
|
import { beforeEach, afterEach, describe, expect, it } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
|
import { runChunkedParseAndResolve } from '../../src/core/ingestion/pipeline-phases/parse-impl.js';
|
|
import { PARSE_CACHE_VERSION, type ParseCache } from '../../src/storage/parse-cache.js';
|
|
import {
|
|
getDurableParsedFileDir,
|
|
pruneAndSaveDurableParsedFileStore,
|
|
} from '../../src/storage/parsedfile-store.js';
|
|
|
|
// ── dist freshness gate ───────────────────────────────────────────────────
|
|
// The worker is one emitted file among many: TypeScript emits every module in
|
|
// this feature separately, so comparing dist/parse-worker.js against
|
|
// src/parse-worker.ts alone passes while the resolver, the Spring extractor or
|
|
// the provider behind it are stale — and the test then asserts against the
|
|
// PREVIOUS build's harvest. Gate on the newest mtime across every source this
|
|
// pipeline actually loads.
|
|
const repoRoot = path.resolve(__dirname, '..', '..');
|
|
const distWorker = path.join(repoRoot, 'dist', 'core', 'ingestion', 'workers', 'parse-worker.js');
|
|
const GATED_SOURCES = [
|
|
'core/ingestion/workers/parse-worker.ts',
|
|
'core/ingestion/route-extractors/java-const-resolver.ts',
|
|
'core/ingestion/route-extractors/constant-resolver.ts',
|
|
'core/ingestion/route-extractors/spring.ts',
|
|
'core/ingestion/languages/java.ts',
|
|
'core/ingestion/languages/python.ts',
|
|
'core/ingestion/language-provider.ts',
|
|
'core/ingestion/pipeline-phases/parse-impl.ts',
|
|
];
|
|
const newestSourceMs = Math.max(
|
|
...GATED_SOURCES.map((rel) => fs.statSync(path.join(repoRoot, 'src', rel)).mtimeMs),
|
|
);
|
|
const distStale = !fs.existsSync(distWorker) || fs.statSync(distWorker).mtimeMs < newestSourceMs;
|
|
|
|
if (distStale) {
|
|
// `describe.skip` prints only vitest's ordinary skip marker, so without this
|
|
// the docblock's promised "loud message" did not exist and a stale/absent
|
|
// dist/ looked like a passing run.
|
|
console.warn(
|
|
'[#2980 e2e] SKIPPED: dist/ is missing or older than src/ — run `npm run build` to exercise the real pipeline.',
|
|
);
|
|
}
|
|
|
|
const maybeDescribe = distStale ? describe.skip : describe;
|
|
|
|
// ── fixture repo (reviewer's exact High-finding shape) ────────────────────
|
|
const API_PATHS = `package com.example.common;
|
|
|
|
public class ApiPaths {
|
|
public static final String USERS = "/api/v1/users";
|
|
public static final String ORDERS = "/api/v1/orders";
|
|
public static final String V1 = "/api/v1";
|
|
}
|
|
`;
|
|
|
|
const USER_CONTROLLER = `package com.example;
|
|
|
|
import com.example.common.ApiPaths;
|
|
import static com.example.common.ApiPaths.V1;
|
|
import static com.example.common.ApiPaths.*;
|
|
import org.springframework.web.bind.annotation.RequestMapping;
|
|
import org.springframework.web.bind.annotation.PostMapping;
|
|
import org.springframework.web.bind.annotation.GetMapping;
|
|
|
|
@RequestMapping("/users")
|
|
public class UserController {
|
|
|
|
// Qualified ref via a class NOT named *Constants (High finding): the old
|
|
// gate dropped the whole route because ApiPaths fails the name pattern.
|
|
@PostMapping(ApiPaths.USERS)
|
|
public void create() {}
|
|
|
|
// FQN-qualified form (F3): multi-segment field_access chain.
|
|
@GetMapping(com.example.common.ApiPaths.ORDERS)
|
|
public void list() {}
|
|
|
|
// Bare ref materialized by the provider's repo-wide wildcard prep hook.
|
|
@GetMapping(ORDERS)
|
|
public void listViaWildcard() {}
|
|
|
|
// Inline concat with a STATIC-IMPORTED bare ref — the shape this fixture
|
|
// used to only claim: it spelled the operand as the full FQN chain, which
|
|
// just re-tested the FQN branch above, so bare-name resolution through the
|
|
// import table had no coverage anywhere in the suite.
|
|
@PostMapping(V1 + "/orders")
|
|
public void createOrders() {}
|
|
}
|
|
`;
|
|
|
|
let repoDir: string;
|
|
let storageDir: string;
|
|
|
|
function writeFixture(): { path: string; size: number }[] {
|
|
const files: [string, string][] = [
|
|
['src/main/java/com/example/common/ApiPaths.java', API_PATHS],
|
|
['src/main/java/com/example/UserController.java', USER_CONTROLLER],
|
|
];
|
|
const out: { path: string; size: number }[] = [];
|
|
for (const [rel, content] of files) {
|
|
const full = path.join(repoDir, rel);
|
|
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
fs.writeFileSync(full, content);
|
|
out.push({ path: rel, size: Buffer.byteLength(content) });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The parse phase does not emit Route nodes itself — it returns the folded
|
|
* `decoratorRoutes` (the routes phase emits them downstream). Asserting on the
|
|
* folded paths at THIS seam is exactly the regression the maintainer asked
|
|
* for: the worker's harvest → provider heuristic → parse-impl fold, with the
|
|
* real dist worker.
|
|
*/
|
|
type PipelineResult = Awaited<ReturnType<typeof runChunkedParseAndResolve>>;
|
|
|
|
function foldedRoutesOf(result: PipelineResult): Array<{ path: string; method: string }> {
|
|
return (result.allDecoratorRoutes ?? [])
|
|
.filter((r) => typeof r.routePath === 'string')
|
|
.map((r) => ({ path: r.routePath, method: r.httpMethod }));
|
|
}
|
|
|
|
async function runPipeline(
|
|
cache: ParseCache,
|
|
files: { path: string; size: number }[],
|
|
): Promise<PipelineResult> {
|
|
const kg = createKnowledgeGraph();
|
|
return await runChunkedParseAndResolve(
|
|
kg,
|
|
files,
|
|
files.map((f) => f.path),
|
|
files.length,
|
|
repoDir,
|
|
Date.now(),
|
|
() => {},
|
|
{ workerPoolSize: 1, parseCache: cache },
|
|
);
|
|
}
|
|
|
|
maybeDescribe('#2980 provider-hook constant harvest — real pipeline (cold + warm)', () => {
|
|
beforeEach(() => {
|
|
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gnx-2980-cold-'));
|
|
storageDir = path.join(repoDir, '.gitnexus');
|
|
});
|
|
afterEach(() => {
|
|
for (const d of [repoDir]) fs.rmSync(d, { recursive: true, force: true });
|
|
});
|
|
|
|
it('cold run: folds qualified / FQN / concat paths from a non-*Constants class', async () => {
|
|
const files = writeFixture();
|
|
const cache: ParseCache = {
|
|
version: PARSE_CACHE_VERSION,
|
|
entries: new Map(),
|
|
usedKeys: new Set(),
|
|
storagePath: storageDir,
|
|
onDiskKeys: new Set(),
|
|
};
|
|
|
|
const result = await runPipeline(cache, files);
|
|
expect(result.usedWorkerPool).toBe(true);
|
|
const routes = foldedRoutesOf(result);
|
|
|
|
// All three non-literal shapes resolve to folded literals. (The class-level
|
|
// @RequestMapping("/users") prefix join happens in the downstream routes
|
|
// phase — at this seam we assert the method-level folded paths.)
|
|
const paths = routes.map((r) => r.path).sort();
|
|
expect(paths).toContain('/api/v1/users'); // qualified ref via import
|
|
expect(paths).toContain('/api/v1/orders'); // FQN multi-segment chain
|
|
// FQN, wildcard, and concat routes fold to the same literal.
|
|
expect(paths.filter((p) => p === '/api/v1/orders').length).toBeGreaterThanOrEqual(3);
|
|
// Skip floor: no phantom empty/raw-expr paths.
|
|
for (const p of paths) {
|
|
expect(p.length).toBeGreaterThan(1);
|
|
expect(p).not.toContain('ApiPaths');
|
|
expect(p).not.toContain('com.example');
|
|
}
|
|
}, 120_000);
|
|
|
|
it('warm run: parse-cache replay yields the identical folded route set', async () => {
|
|
const files = writeFixture();
|
|
const cache: ParseCache = {
|
|
version: PARSE_CACHE_VERSION,
|
|
entries: new Map(),
|
|
usedKeys: new Set(),
|
|
storagePath: storageDir,
|
|
onDiskKeys: new Set(),
|
|
};
|
|
|
|
// Run #1 populates the cache; persist it like run-analyze does — BOTH the
|
|
// chunk shards and the durable ParsedFile store. `slimParseWorkerResultsForCache`
|
|
// blanks `parsedFiles` before writing a shard, so a warm run without the
|
|
// durable store cannot replay the chunk and silently falls back to the
|
|
// workers — which is what this test used to do while still passing.
|
|
const run1 = await runPipeline(cache, files);
|
|
const { saveParseCache, pruneCache } = await import('../../src/storage/parse-cache.js');
|
|
pruneCache(cache, cache.usedKeys);
|
|
const savedKeys = await saveParseCache(storageDir, cache);
|
|
expect(savedKeys.length).toBeGreaterThan(0);
|
|
await pruneAndSaveDurableParsedFileStore(
|
|
getDurableParsedFileDir(storageDir),
|
|
PARSE_CACHE_VERSION,
|
|
new Set(savedKeys),
|
|
);
|
|
|
|
// Run #2 — warm: every chunk is a cache HIT, no worker spawn, the cached
|
|
// ParseWorkerResult (moduleConstants included) is replayed from disk.
|
|
const { loadParseCache } = await import('../../src/storage/parse-cache.js');
|
|
const warm = await loadParseCache(storageDir);
|
|
expect(warm.onDiskKeys).toEqual(new Set(savedKeys));
|
|
const run2 = await runPipeline(warm, files);
|
|
|
|
const cold = foldedRoutesOf(run1)
|
|
.map((r) => `${r.method} ${r.path}`)
|
|
.sort();
|
|
const hot = foldedRoutesOf(run2)
|
|
.map((r) => `${r.method} ${r.path}`)
|
|
.sort();
|
|
expect(hot).toEqual(cold);
|
|
expect(hot.length).toBeGreaterThan(0);
|
|
// Without this the test proves nothing about the cache: `loadParseCache`
|
|
// returns an EMPTY cache on any failure (missing file, corrupt JSON,
|
|
// version mismatch) and never throws, so a broken Map round-trip through
|
|
// mapReplacer/mapReviver — the exact regression this test exists for —
|
|
// would silently reparse through the workers and produce the same routes.
|
|
expect(run1.usedWorkerPool).toBe(true);
|
|
expect(run2.usedWorkerPool).toBe(false);
|
|
}, 120_000);
|
|
});
|