mirror of
https://github.com/giancarloerra/socraticode.git
synced 2026-07-03 14:05:21 +02:00
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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user