fix: resolve JVM imports in multi-module Maven/Gradle projects

Single-module resolution (src/main/java/…) already worked.
Multi-module layouts like:

  module-a/sub/src/main/java/com/example/Foo.java

were silently unresolved because the three fixed src-dir prefixes
never matched. The dependency graph therefore always produced 0
edges for any Java/Kotlin/Scala project that follows the standard
Maven/Gradle multi-module layout.

Fix: introduce `buildJvmSuffixMap()` which scans `fileSet` once
(O(n)) and registers every JVM source file by its class-path key
(i.e. everything after src/main/<lang>/). `resolveImport` accepts
the map as an optional last argument and falls back to it when the
existing prefix-based approach yields no result.

- `buildJvmSuffixMap` exported for reuse / testing.
- Map is built once per `buildCodeGraph` call, only when the
  project contains at least one JVM file — zero cost for other
  language projects.
- Lookup is O(1) per import, replacing a worst-case O(n) linear
  scan on every miss.
- Works on both Windows (backslash) and POSIX (forward-slash)
  because the map key is built with `path.sep`.
- 7 new unit tests covering: map construction, test-source
  exclusion, multi-module Java resolution, Kotlin resolution,
  unresolvable class, stdlib guard.

Fixes: enterprise Java/Spring Boot codebases (30+ Maven modules)
reporting 0 edges in codebase_graph_stats.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jason.ma
2026-04-09 20:23:03 +08:00
parent 0896164442
commit 5a734eb301
3 changed files with 199 additions and 5 deletions
+12 -2
View File
@@ -10,7 +10,7 @@ import { EXTRA_EXTENSIONS, getLanguageFromExtension, MAX_GRAPH_FILE_BYTES } from
import type { CodeGraph, CodeGraphEdge, CodeGraphNode } from "../types.js";
import { loadPathAliases } from "./graph-aliases.js";
import { extractImports } from "./graph-imports.js";
import { resolveImport } from "./graph-resolution.js";
import { buildJvmSuffixMap, resolveImport } from "./graph-resolution.js";
import { createIgnoreFilter, shouldIgnore } from "./ignore.js";
import { logger } from "./logger.js";
import { deleteGraphData, getGraphMetadata, loadGraphData, saveGraphData } from "./qdrant.js";
@@ -390,6 +390,16 @@ export async function buildCodeGraph(
const nodesMap = new Map<string, CodeGraphNode>();
const edges: CodeGraphEdge[] = [];
// Build a suffix lookup map for JVM multi-module projects (Java/Kotlin/Scala).
// This resolves FQNs like com.example.Foo when the class lives under a nested
// src/main/java/ tree (e.g. module-a/sub/src/main/java/com/example/Foo.java).
// Cost: O(n) once here, O(1) per import lookup — negligible vs. full AST parse.
const hasJvm = files.some((f) => {
const e = path.extname(f).toLowerCase();
return e === ".java" || e === ".kt" || e === ".kts" || e === ".scala";
});
const jvmSuffixMap = hasJvm ? buildJvmSuffixMap(fileSet) : undefined;
for (const relPath of files) {
const ext = path.extname(relPath).toLowerCase();
const lang = getAstGrepLang(ext);
@@ -448,7 +458,7 @@ export async function buildCodeGraph(
// Try to resolve to a project file
// CSS imports from <style> blocks use CSS resolution even when the source file is Svelte/Vue
const resolutionLanguage = imp.isCssImport ? "css" : language;
const resolved = resolveImport(imp.moduleSpecifier, absolutePath, resolvedPath, fileSet, resolutionLanguage, aliases);
const resolved = resolveImport(imp.moduleSpecifier, absolutePath, resolvedPath, fileSet, resolutionLanguage, aliases, jvmSuffixMap);
if (resolved) {
node.dependencies.push(resolved);
+62 -2
View File
@@ -5,6 +5,52 @@ import type { PathAliases } from "./graph-aliases.js";
// ── Module resolution ────────────────────────────────────────────────────
/**
* Build a suffix lookup map for JVM (Java/Kotlin/Scala) files in multi-module projects.
*
* For a Maven/Gradle multi-module layout such as:
* module-a/sub-module/src/main/java/com/example/Foo.java
* the map entry is:
* key: "com/example/Foo.java" (platform-normalised with path.sep)
* value: "module-a/sub-module/src/main/java/com/example/Foo.java"
*
* This enables O(1) resolution of fully-qualified class names that cannot be
* found via the standard prefix-based scan (e.g. src/main/java/…).
*
* Call this once per graph build and pass the result to resolveImport.
*/
export function buildJvmSuffixMap(fileSet: Set<string>): Map<string, string> {
const map = new Map<string, string>();
const jvmExts = new Set([".java", ".kt", ".kts", ".scala"]);
for (const f of fileSet) {
if (!jvmExts.has(path.extname(f))) continue;
// Split on either separator so the logic works on Windows and POSIX.
const parts = f.split(/[\\/]/);
// Find the first occurrence of src/main/<lang> boundary.
const jvmLangs = new Set(["java", "kotlin", "scala"]);
const idx = parts.findIndex(
(p, i) =>
p === "src" &&
parts[i + 1] === "main" &&
jvmLangs.has(parts[i + 2]),
);
if (idx !== -1) {
// classPath = everything after src/main/<lang>, e.g. com/example/Foo.java
const classPath = parts.slice(idx + 3).join(path.sep);
// Only register the first match to avoid ambiguity for duplicate class names.
if (!map.has(classPath)) {
map.set(classPath, f);
}
}
}
return map;
}
/**
* Resolve a module specifier to a relative file path within the project.
* Returns null if the module is external (e.g., npm package, stdlib).
@@ -16,6 +62,7 @@ export function resolveImport(
fileSet: Set<string>,
language: string,
aliases?: PathAliases,
jvmSuffixMap?: Map<string, string>,
): string | null {
// Skip obvious external/stdlib modules
if (isExternalModule(moduleSpecifier, language)) return null;
@@ -89,11 +136,12 @@ export function resolveImport(
// com.example.Foo → com/example/Foo.java (or .kt, .scala)
const filePath = moduleSpecifier.replace(/\./g, "/");
const exts = language === "java" ? [".java"] : language === "kotlin" ? [".kt", ".kts"] : [".scala"];
// Try direct resolution from project root
// 1. Try direct resolution from project root (single-module layout).
const direct = resolveRelativePath(filePath, projectPath, projectPath, fileSet, exts);
if (direct) return direct;
// Try common source directories (Maven/Gradle convention)
// 2. Try common source directories (Maven/Gradle single-module convention).
const jvmSrcDirs = [
`src/main/${language}`, // src/main/java, src/main/kotlin, src/main/scala
"src/main",
@@ -105,6 +153,18 @@ export function resolveImport(
);
if (inSrc) return inSrc;
}
// 3. Fallback: suffix-map lookup for multi-module Maven/Gradle projects.
// e.g. module-a/sub/src/main/java/com/example/Foo.java
// The map is built once per graph build (O(n)) and looked up in O(1).
if (jvmSuffixMap) {
for (const ext of exts) {
const classPath = filePath.replace(/\//g, path.sep) + ext;
const found = jvmSuffixMap.get(classPath);
if (found) return found;
}
}
return null;
}
+125 -1
View File
@@ -5,7 +5,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveImport } from "../../src/services/graph-resolution.js";
import { buildJvmSuffixMap, resolveImport } from "../../src/services/graph-resolution.js";
// ── Helper to create temp project layouts ─────────────────────────────
@@ -1206,4 +1206,128 @@ describe("graph-resolution", () => {
expect(result).toBeNull();
});
});
// ── buildJvmSuffixMap + multi-module resolution ──────────────────────────
describe("JVM multi-module resolution (buildJvmSuffixMap)", () => {
it("builds a suffix map keyed by class path after src/main/java", () => {
project = createTempProject({
[`module-a${path.sep}sub${path.sep}src${path.sep}main${path.sep}java${path.sep}com${path.sep}example${path.sep}Foo.java`]: "",
[`module-b${path.sep}src${path.sep}main${path.sep}kotlin${path.sep}com${path.sep}example${path.sep}Bar.kt`]: "",
[`module-c${path.sep}src${path.sep}main${path.sep}scala${path.sep}com${path.sep}example${path.sep}Baz.scala`]: "",
});
const map = buildJvmSuffixMap(project.fileSet);
expect(map.has(`com${path.sep}example${path.sep}Foo.java`)).toBe(true);
expect(map.has(`com${path.sep}example${path.sep}Bar.kt`)).toBe(true);
expect(map.has(`com${path.sep}example${path.sep}Baz.scala`)).toBe(true);
});
it("returns empty map when project has no JVM files", () => {
project = createTempProject({ "index.ts": "", "style.css": "" });
const map = buildJvmSuffixMap(project.fileSet);
expect(map.size).toBe(0);
});
it("ignores JVM files outside src/main/<lang> (e.g. test sources)", () => {
project = createTempProject({
// test source — should be ignored
[`module-a${path.sep}src${path.sep}test${path.sep}java${path.sep}com${path.sep}example${path.sep}FooTest.java`]: "",
// main source — should be registered
[`module-a${path.sep}src${path.sep}main${path.sep}java${path.sep}com${path.sep}example${path.sep}Foo.java`]: "",
});
const map = buildJvmSuffixMap(project.fileSet);
expect(map.has(`com${path.sep}example${path.sep}Foo.java`)).toBe(true);
expect(map.has(`com${path.sep}example${path.sep}FooTest.java`)).toBe(false);
});
it("resolves a Java import in a multi-module Maven project via suffix map", () => {
// Simulate: module-sso/module-sso-service/src/main/java/cn/sino/sso/UserService.java
const userServicePath =
`module-sso${path.sep}module-sso-service${path.sep}src${path.sep}main${path.sep}java${path.sep}cn${path.sep}sino${path.sep}sso${path.sep}UserService.java`;
const callerPath =
`module-opt${path.sep}src${path.sep}main${path.sep}java${path.sep}cn${path.sep}sino${path.sep}opt${path.sep}Service.java`;
project = createTempProject({
[userServicePath]: "",
[callerPath]: "",
});
const jvmSuffixMap = buildJvmSuffixMap(project.fileSet);
const result = resolveImport(
"cn.sino.sso.UserService",
path.join(project.root, callerPath),
project.root,
project.fileSet,
"java",
undefined,
jvmSuffixMap,
);
expect(result).toBe(userServicePath);
});
it("resolves Kotlin import in multi-module project via suffix map", () => {
const barPath =
`module-core${path.sep}src${path.sep}main${path.sep}kotlin${path.sep}com${path.sep}example${path.sep}Bar.kt`;
const callerPath =
`module-api${path.sep}src${path.sep}main${path.sep}kotlin${path.sep}com${path.sep}example${path.sep}Caller.kt`;
project = createTempProject({ [barPath]: "", [callerPath]: "" });
const jvmSuffixMap = buildJvmSuffixMap(project.fileSet);
const result = resolveImport(
"com.example.Bar",
path.join(project.root, callerPath),
project.root,
project.fileSet,
"kotlin",
undefined,
jvmSuffixMap,
);
expect(result).toBe(barPath);
});
it("returns null when class exists nowhere in the project", () => {
project = createTempProject({
[`module-a${path.sep}src${path.sep}main${path.sep}java${path.sep}com${path.sep}example${path.sep}Foo.java`]: "",
});
const jvmSuffixMap = buildJvmSuffixMap(project.fileSet);
const result = resolveImport(
"com.example.NonExistent",
path.join(project.root, `module-a${path.sep}src${path.sep}main${path.sep}java${path.sep}com${path.sep}example${path.sep}Foo.java`),
project.root,
project.fileSet,
"java",
undefined,
jvmSuffixMap,
);
expect(result).toBeNull();
});
it("still returns null for java stdlib even with suffix map", () => {
project = createTempProject({
[`module-a${path.sep}src${path.sep}main${path.sep}java${path.sep}java${path.sep}util${path.sep}List.java`]: "",
});
const jvmSuffixMap = buildJvmSuffixMap(project.fileSet);
const result = resolveImport(
"java.util.List",
path.join(project.root, `module-a${path.sep}src${path.sep}main${path.sep}java${path.sep}Caller.java`),
project.root,
project.fileSet,
"java",
undefined,
jvmSuffixMap,
);
expect(result).toBeNull();
});
});
});