From 114425002a743e0dde85dc61157dba99b69f13b7 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 27 Apr 2026 23:42:32 +0000 Subject: [PATCH 01/16] feat(analyzer): wire ResolverRegistry bootstrap + per-file resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of sub-project 1 — pipeline wiring (plan tasks 19-21). The orchestration boundary for the symbol-resolution pass that sits between parse and detect. ResolverRegistry becomes a new constructor dependency on Analyzer: @Autowired primary ctor adds it as the 10th arg; the 6-arg backward-compat ctor defaults to `new ResolverRegistry(List.of())` so existing tests + direct constructor call-sites still work and observe the same behaviour (every ctx.resolved() reads back as Optional.of(EmptyResolved.INSTANCE)). Two private helpers do the work: - bootstrapResolvers(Path) — called exactly once at the top of every pipeline entry point (run / runBatchedIndex / runSmartIndex), before any file iteration. ResolverRegistry already swallows per-resolver failures; this catches the registry-itself-blowing-up case so the pipeline keeps going with NOOP resolvers. - resolveFor(DiscoveredFile, Object) — called per file at all three DetectorContext build sites (analyzeFile, the batched-index variant, and the regex-only fallback). Catches ResolutionException + RuntimeException and falls back to EmptyResolved.INSTANCE so one file's resolver blow-up cannot disrupt the rest of the pass. Every DetectorContext now reads back ctx.resolved() == Optional.of(...) — either the language's resolver result or EmptyResolved.INSTANCE. Detectors that don't care simply ignore the field; migrations to consume the resolved view follow in Phase 6. IndexCommand reaches the resolver via Analyzer.runSmartIndex, so plan task 21 ("mirror in IndexCommand") lands automatically with the analyzer wiring — no separate command-side changes required. 8 wiring tests cover: - bootstrap called exactly once per run (single file, many files, empty repo) - bootstrap path is the normalised absolute repoPath - resolverFor("java") called for each java file - ctx.resolved() is Optional.of(EmptyResolved.INSTANCE) when no resolver is registered for the language - legacy 6-arg ctor still produces a working analyzer with the same observable resolved() shape Plan: docs/plans/2026-04-27-sub-project-1-resolver-spi-and-java-pilot.md (tasks 19, 20, 21). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../randomcodespace/iq/analyzer/Analyzer.java | 73 ++++++- .../analyzer/AnalyzerResolverWiringTest.java | 198 ++++++++++++++++++ .../iq/analyzer/SmartIndexTest.java | 3 +- 3 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 src/test/java/io/github/randomcodespace/iq/analyzer/AnalyzerResolverWiringTest.java diff --git a/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java index d066126d..0484f597 100644 --- a/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java +++ b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java @@ -17,6 +17,11 @@ import io.github.randomcodespace.iq.detector.DetectorUtils; import io.github.randomcodespace.iq.grammar.AntlrParserFactory; import io.github.randomcodespace.iq.intelligence.RepositoryIdentity; +import io.github.randomcodespace.iq.intelligence.resolver.EmptyResolved; +import io.github.randomcodespace.iq.intelligence.resolver.ResolutionException; +import io.github.randomcodespace.iq.intelligence.resolver.Resolved; +import io.github.randomcodespace.iq.intelligence.resolver.ResolverRegistry; +import io.github.randomcodespace.iq.intelligence.resolver.SymbolResolver; import io.github.randomcodespace.iq.model.CodeEdge; import io.github.randomcodespace.iq.model.CodeNode; import io.github.randomcodespace.iq.model.NodeKind; @@ -89,6 +94,7 @@ public class Analyzer { private final CodeIqUnifiedConfig unifiedConfig; private final ConfigScanner configScanner; private final ArchitectureKeywordFilter keywordFilter; + private final ResolverRegistry resolverRegistry; /** * Projection of the injected {@link CodeIqUnifiedConfig} tree into the flat @@ -127,7 +133,8 @@ public Analyzer( CodeIqConfig config, CodeIqUnifiedConfig unifiedConfig, ConfigScanner configScanner, - ArchitectureKeywordFilter keywordFilter + ArchitectureKeywordFilter keywordFilter, + ResolverRegistry resolverRegistry ) { this.registry = registry; this.parser = parser; @@ -138,6 +145,7 @@ public Analyzer( this.unifiedConfig = unifiedConfig; this.configScanner = configScanner; this.keywordFilter = keywordFilter; + this.resolverRegistry = resolverRegistry; } /** @@ -147,7 +155,11 @@ public Analyzer( * equivalent to the "no {@code codeiq.yml} present" path * (no detector filters, no language filter, auto parallelism). Tests that * need to exercise filters should use the primary constructor with a - * hand-rolled {@link CodeIqUnifiedConfig}. + * hand-rolled {@link CodeIqUnifiedConfig}. The {@link ResolverRegistry} is + * defaulted to an empty registry — every {@code resolverFor(...)} call + * returns the no-op resolver and every {@code resolved()} reads back as + * {@link EmptyResolved#INSTANCE}, which is the same observable behaviour as + * the pre-resolver pipeline. */ public Analyzer( DetectorRegistry registry, @@ -159,7 +171,49 @@ public Analyzer( ) { this(registry, parser, fileDiscovery, layerClassifier, linkers, config, CodeIqUnifiedConfig.empty(), - new ConfigScanner(), new ArchitectureKeywordFilter()); + new ConfigScanner(), new ArchitectureKeywordFilter(), + new ResolverRegistry(List.of())); + } + + /** + * Bootstrap every registered {@link SymbolResolver} against the project + * root. Called exactly once per pipeline entry point (run / runBatchedIndex + * / runSmartIndex), before any file iteration. Per-resolver failures are + * logged inside {@link ResolverRegistry#bootstrap(Path)} and do not abort + * the pass — a misbehaving resolver simply returns {@link EmptyResolved} + * for its language for the rest of the run. + */ + private void bootstrapResolvers(Path root) { + try { + resolverRegistry.bootstrap(root); + } catch (RuntimeException e) { + // ResolverRegistry already swallows per-resolver failures; this catch + // is purely defensive in case the registry itself blows up. The + // pipeline continues with NOOP resolvers (Optional.of(EmptyResolved)). + log.warn("Resolver bootstrap failed for {}: {}", root, e.getMessage()); + } + } + + /** + * Resolve symbols for a single file, swallowing {@link ResolutionException} + * so one resolver failure can't take down the whole file's detector pass. + * Returns {@link EmptyResolved#INSTANCE} on any failure (or when the + * resolver itself returns null, defensive). + */ + private Resolved resolveFor(DiscoveredFile file, Object parsedAst) { + SymbolResolver resolver = resolverRegistry.resolverFor(file.language()); + try { + Resolved r = resolver.resolve(file, parsedAst); + return r != null ? r : EmptyResolved.INSTANCE; + } catch (ResolutionException e) { + log.debug("resolver {} failed for {}: {}", + resolver.getClass().getSimpleName(), file.path(), e.getMessage()); + return EmptyResolved.INSTANCE; + } catch (RuntimeException e) { + log.debug("resolver {} threw unexpectedly for {}: {}", + resolver.getClass().getSimpleName(), file.path(), e.toString()); + return EmptyResolved.INSTANCE; + } } /** @@ -201,6 +255,8 @@ public AnalysisResult run(Path repoPath, Integer parallelism, boolean incrementa final Path root = repoPath.toAbsolutePath().normalize(); + bootstrapResolvers(root); + // Open incremental cache if enabled AnalysisCache cache = null; if (incremental) { @@ -501,6 +557,8 @@ public AnalysisResult runBatchedIndex(Path repoPath, Integer parallelism, int ba final Path root = repoPath.toAbsolutePath().normalize(); + bootstrapResolvers(root); + // Always use H2 cache as the primary store during indexing Path cachePath = root.resolve(config.getCacheDir()).resolve("analysis-cache.db"); AnalysisCache cache; @@ -784,6 +842,8 @@ public AnalysisResult runSmartIndex(Path repoPath, Integer parallelism, int batc Consumer report = onProgress != null ? onProgress : msg -> {}; final Path root = repoPath.toAbsolutePath().normalize(); + bootstrapResolvers(root); + Path cachePath = root.resolve(config.getCacheDir()).resolve("analysis-cache.db"); AnalysisCache cache; try { @@ -1295,7 +1355,7 @@ DetectorResult analyzeFileWithRegistry(DiscoveredFile file, Path repoPath, parsedData, moduleName, infraRegistry - ); + ).withResolved(resolveFor(file, parsedData)); List detectors = detectorRegistry.detectorsForLanguage(file.language()); if (detectors.isEmpty()) { @@ -1503,7 +1563,7 @@ DetectorResult analyzeFile(DiscoveredFile file, Path repoPath, DetectorRegistry content, parsedData, moduleName - ); + ).withResolved(resolveFor(file, parsedData)); // Run matching detectors and merge results List detectors = detectorRegistry.detectorsForLanguage(file.language()); @@ -1593,7 +1653,8 @@ private DetectorResult analyzeFileRegexOnly(DiscoveredFile file, Path repoPath, } String moduleName = DetectorUtils.deriveModuleName(file.path().toString(), file.language()); - var ctx = new DetectorContext(file.path().toString(), file.language(), content, null, moduleName); + var ctx = new DetectorContext(file.path().toString(), file.language(), content, null, moduleName) + .withResolved(resolveFor(file, null)); List detectors = detectorRegistry.detectorsForLanguage(file.language()); var allNodes = new ArrayList(); diff --git a/src/test/java/io/github/randomcodespace/iq/analyzer/AnalyzerResolverWiringTest.java b/src/test/java/io/github/randomcodespace/iq/analyzer/AnalyzerResolverWiringTest.java new file mode 100644 index 00000000..572e47d0 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/analyzer/AnalyzerResolverWiringTest.java @@ -0,0 +1,198 @@ +package io.github.randomcodespace.iq.analyzer; + +import io.github.randomcodespace.iq.analyzer.linker.Linker; +import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.config.unified.CodeIqUnifiedConfig; +import io.github.randomcodespace.iq.detector.Detector; +import io.github.randomcodespace.iq.detector.DetectorContext; +import io.github.randomcodespace.iq.detector.DetectorRegistry; +import io.github.randomcodespace.iq.detector.DetectorResult; +import io.github.randomcodespace.iq.intelligence.resolver.EmptyResolved; +import io.github.randomcodespace.iq.intelligence.resolver.ResolverRegistry; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Phase 4 pipeline-wiring contract tests for {@link ResolverRegistry} ↔ + * {@link Analyzer}. Covers Tasks 19–21: + *
    + *
  • bootstrap is called exactly once per pipeline entry point
  • + *
  • per-file {@code resolverFor(language)} is called for each discovered file
  • + *
  • the returned {@code Resolved} is threaded onto the {@link DetectorContext} + * passed to every detector
  • + *
+ * + *

These tests exercise the default {@code run()} path. The other two + * detect-call sites ({@code runBatchedIndex} regular path and {@code + * analyzeFileRegexOnly}) use the same {@code resolveFor(...)} helper, so the + * wiring contract is enforced symmetrically. + */ +class AnalyzerResolverWiringTest { + + @TempDir Path tempDir; + + // ── Bootstrap: called exactly once per run() ───────────────────────────── + + @Test + void bootstrapCalledExactlyOncePerRun() throws IOException { + Files.writeString(tempDir.resolve("App.java"), "public class App {}"); + + ResolverRegistry registry = spy(new ResolverRegistry(List.of())); + Analyzer analyzer = newAnalyzer(registry, captureNothing()); + analyzer.run(tempDir, null); + + verify(registry, times(1)).bootstrap(any(Path.class)); + } + + @Test + void bootstrapStillCalledOnceWhenManyFilesPresent() throws IOException { + // Five files — bootstrap fires once, not per-file. + for (char c : new char[]{'A', 'B', 'C', 'D', 'E'}) { + Files.writeString(tempDir.resolve(c + ".java"), "public class " + c + " {}"); + } + + ResolverRegistry registry = spy(new ResolverRegistry(List.of())); + Analyzer analyzer = newAnalyzer(registry, captureNothing()); + analyzer.run(tempDir, null); + + verify(registry, times(1)).bootstrap(any(Path.class)); + } + + @Test + void bootstrapCalledWithNormalisedAbsolutePath() throws IOException { + Files.writeString(tempDir.resolve("App.java"), "public class App {}"); + + ResolverRegistry registry = spy(new ResolverRegistry(List.of())); + Analyzer analyzer = newAnalyzer(registry, captureNothing()); + analyzer.run(tempDir, null); + + // run() does repoPath.toAbsolutePath().normalize() before bootstrap. + Path expected = tempDir.toAbsolutePath().normalize(); + verify(registry).bootstrap(expected); + } + + @Test + void bootstrapInvokedEvenWhenRepoHasNoFiles() { + // Empty dir — bootstrap should still happen (and the rest of the + // pipeline should still complete cleanly). + ResolverRegistry registry = spy(new ResolverRegistry(List.of())); + Analyzer analyzer = newAnalyzer(registry, captureNothing()); + analyzer.run(tempDir, null); + + verify(registry, times(1)).bootstrap(any(Path.class)); + } + + // ── Per-file: resolverFor(language) is called ──────────────────────────── + + @Test + void resolverForCalledForJavaLanguage() throws IOException { + Files.writeString(tempDir.resolve("App.java"), "public class App {}"); + + ResolverRegistry registry = spy(new ResolverRegistry(List.of())); + Analyzer analyzer = newAnalyzer(registry, captureNothing()); + analyzer.run(tempDir, null); + + verify(registry, atLeastOnce()).resolverFor("java"); + } + + // ── DetectorContext: ctx.resolved() reflects the resolver result ───────── + + @Test + void ctxResolvedIsPresentForJavaFile() throws IOException { + Files.writeString(tempDir.resolve("App.java"), "public class App {}"); + + AtomicReference seen = new AtomicReference<>(); + Analyzer analyzer = newAnalyzer(new ResolverRegistry(List.of()), captureCtx(seen)); + analyzer.run(tempDir, null); + + DetectorContext ctx = seen.get(); + assertNotNull(ctx, "test detector must have been called for App.java"); + assertTrue(ctx.resolved().isPresent(), + "ctx.resolved() must be Optional.of(...) after the wiring — never empty"); + assertSame(EmptyResolved.INSTANCE, ctx.resolved().get(), + "no resolver registered for 'java' → NOOP resolver → EmptyResolved.INSTANCE"); + } + + @Test + void ctxResolvedIsEmptyResolvedForFileWithoutResolver() throws IOException { + // Even with no resolver registered for any language, ctx.resolved() + // is the singleton EmptyResolved — never Optional.empty(), never null. + Files.writeString(tempDir.resolve("Foo.java"), "public class Foo {}"); + + AtomicReference seen = new AtomicReference<>(); + Analyzer analyzer = newAnalyzer(new ResolverRegistry(List.of()), captureCtx(seen)); + analyzer.run(tempDir, null); + + DetectorContext ctx = seen.get(); + assertNotNull(ctx); + assertSame(EmptyResolved.INSTANCE, ctx.resolved().orElseThrow(), + "EmptyResolved is the only legal fallback — JavaDetector tests rely on this"); + } + + @Test + void wiringIsBackwardCompatibleWithLegacyCtor() throws IOException { + // The 6-arg backward-compat ctor must still produce a working Analyzer + // whose detectors see a populated ctx.resolved() (Optional.of(EmptyResolved)). + Files.writeString(tempDir.resolve("App.java"), "public class App {}"); + + AtomicReference seen = new AtomicReference<>(); + Detector capture = captureCtx(seen); + Analyzer analyzer = new Analyzer( + new DetectorRegistry(List.of(capture)), + new StructuredParser(), + new FileDiscovery(new CodeIqConfig()), + new LayerClassifier(), + List.of(), + new CodeIqConfig()); + analyzer.run(tempDir, null); + + DetectorContext ctx = seen.get(); + assertNotNull(ctx, "test detector must have been called via legacy ctor"); + assertTrue(ctx.resolved().isPresent(), + "legacy ctor must still wire a default ResolverRegistry"); + assertSame(EmptyResolved.INSTANCE, ctx.resolved().orElseThrow()); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private Analyzer newAnalyzer(ResolverRegistry registry, Detector detector) { + return new Analyzer( + new DetectorRegistry(List.of(detector)), + new StructuredParser(), + new FileDiscovery(new CodeIqConfig()), + new LayerClassifier(), + List.of(), + new CodeIqConfig(), + CodeIqUnifiedConfig.empty(), + new ConfigScanner(), + new ArchitectureKeywordFilter(), + registry + ); + } + + private Detector captureNothing() { + return captureCtx(new AtomicReference<>()); + } + + private Detector captureCtx(AtomicReference seen) { + return new Detector() { + @Override public String getName() { return "test-capture-detector"; } + @Override public Set getSupportedLanguages() { return Set.of("java"); } + @Override public DetectorResult detect(DetectorContext ctx) { + seen.set(ctx); + return DetectorResult.empty(); + } + }; + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/analyzer/SmartIndexTest.java b/src/test/java/io/github/randomcodespace/iq/analyzer/SmartIndexTest.java index 0c7f1d5d..af7355fe 100644 --- a/src/test/java/io/github/randomcodespace/iq/analyzer/SmartIndexTest.java +++ b/src/test/java/io/github/randomcodespace/iq/analyzer/SmartIndexTest.java @@ -66,7 +66,8 @@ public DetectorResult detect(DetectorContext ctx) { analyzer = new Analyzer(registry, parser, fileDiscovery, layerClassifier, linkers, new CodeIqConfig(), CodeIqUnifiedConfig.empty(), - new ConfigScanner(), new ArchitectureKeywordFilter()); + new ConfigScanner(), new ArchitectureKeywordFilter(), + new io.github.randomcodespace.iq.intelligence.resolver.ResolverRegistry(List.of())); } // ------------------------------------------------------------------------- From 6736f0554ea09735ad636e164f749ea45a92ccac Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 27 Apr 2026 23:48:39 +0000 Subject: [PATCH 02/16] feat(resolver/java): lazy-parse Java source so ctx.resolved() carries JavaResolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orchestrator (Analyzer) only parses structured-language files at the top level (YAML/JSON/etc.) — Java is parsed independently inside AbstractJavaParserDetector via its ThreadLocal pool. Without an extra hook, ctx.resolved() always reads back as EmptyResolved for Java because the SPI's resolve(file, parsedAst) was never given a CompilationUnit. Two minimal changes flip ctx.resolved() to JavaResolved for Java files: 1. JavaSymbolResolver.resolve() now accepts a String source as well as a CompilationUnit. When given a String, it parses with a fresh JavaParser configured with the symbol solver, so resolution is attached to the resulting CU. Per-call JavaParser allocation is intentional (JavaParser instances aren't thread-safe and resolve() is invoked from virtual threads concurrently); cost is small relative to the parse itself. 2. Analyzer.resolveFor() now takes content as a 3rd arg and uses it as the parsedAst fallback when the orchestrator's structured parser produced nothing. The 3 call sites (analyzeFile, the batched-index variant, and the regex-only fallback) all pass content. Permissive parsing: JavaParser produces a CompilationUnit even for files with syntax errors (with attached Problems). The resolver returns JavaResolved in that case — production analysis must keep going across malformed files instead of failing the whole pass. EmptyResolved is only returned when getResult().isEmpty(), which JavaParser reserves for hard configuration-level failures. New tests: - JavaSymbolResolverTest: 4 new cases — valid source string parses, junk input doesn't throw or null, empty source produces an empty CU, unknown AST type (e.g. a Path) → EmptyResolved (replaces the old "wrong AST type" String case). - AnalyzerResolverWiringTest: javaFilePicksUpJavaResolvedWhenResolverRegistered asserts ctx.resolved() is JavaResolved (not EmptyResolved) once a JavaSymbolResolver is registered with the registry. This is the bridge that lets detector migrations (Phase 6 / tasks 24-29) actually consume RESOLVED-tier resolution from ctx.resolved(). Plan: docs/plans/2026-04-27-sub-project-1-resolver-spi-and-java-pilot.md (unblocks tasks 24-29). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../randomcodespace/iq/analyzer/Analyzer.java | 17 ++++-- .../resolver/java/JavaSymbolResolver.java | 35 ++++++++++- .../analyzer/AnalyzerResolverWiringTest.java | 32 ++++++++++ .../resolver/java/JavaSymbolResolverTest.java | 58 +++++++++++++++++-- 4 files changed, 130 insertions(+), 12 deletions(-) diff --git a/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java index 0484f597..2c460ea7 100644 --- a/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java +++ b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java @@ -199,11 +199,18 @@ private void bootstrapResolvers(Path root) { * so one resolver failure can't take down the whole file's detector pass. * Returns {@link EmptyResolved#INSTANCE} on any failure (or when the * resolver itself returns null, defensive). + * + *

The orchestrator passes whatever it has: structured languages already + * have a {@code parsedAst} (YAML/JSON/etc. parse tree); for languages the + * top-level parser doesn't cover (Java, Python, …) we pass {@code content} + * as a fallback so language-specific resolvers can lazy-parse the source. + * Resolvers that don't understand the payload shape return EmptyResolved. */ - private Resolved resolveFor(DiscoveredFile file, Object parsedAst) { + private Resolved resolveFor(DiscoveredFile file, Object parsedAst, String content) { + Object payload = parsedAst != null ? parsedAst : content; SymbolResolver resolver = resolverRegistry.resolverFor(file.language()); try { - Resolved r = resolver.resolve(file, parsedAst); + Resolved r = resolver.resolve(file, payload); return r != null ? r : EmptyResolved.INSTANCE; } catch (ResolutionException e) { log.debug("resolver {} failed for {}: {}", @@ -1355,7 +1362,7 @@ DetectorResult analyzeFileWithRegistry(DiscoveredFile file, Path repoPath, parsedData, moduleName, infraRegistry - ).withResolved(resolveFor(file, parsedData)); + ).withResolved(resolveFor(file, parsedData, content)); List detectors = detectorRegistry.detectorsForLanguage(file.language()); if (detectors.isEmpty()) { @@ -1563,7 +1570,7 @@ DetectorResult analyzeFile(DiscoveredFile file, Path repoPath, DetectorRegistry content, parsedData, moduleName - ).withResolved(resolveFor(file, parsedData)); + ).withResolved(resolveFor(file, parsedData, content)); // Run matching detectors and merge results List detectors = detectorRegistry.detectorsForLanguage(file.language()); @@ -1654,7 +1661,7 @@ private DetectorResult analyzeFileRegexOnly(DiscoveredFile file, Path repoPath, String moduleName = DetectorUtils.deriveModuleName(file.path().toString(), file.language()); var ctx = new DetectorContext(file.path().toString(), file.language(), content, null, moduleName) - .withResolved(resolveFor(file, null)); + .withResolved(resolveFor(file, null, content)); List detectors = detectorRegistry.detectorsForLanguage(file.language()); var allNodes = new ArrayList(); diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolver.java b/src/main/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolver.java index 4cf2e66b..69eacf0f 100644 --- a/src/main/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolver.java +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolver.java @@ -1,5 +1,8 @@ package io.github.randomcodespace.iq.intelligence.resolver.java; +import com.github.javaparser.JavaParser; +import com.github.javaparser.ParseResult; +import com.github.javaparser.ParserConfiguration; import com.github.javaparser.ast.CompilationUnit; import com.github.javaparser.symbolsolver.JavaSymbolSolver; import com.github.javaparser.symbolsolver.resolution.typesolvers.CombinedTypeSolver; @@ -71,15 +74,41 @@ public Resolved resolve(DiscoveredFile file, Object parsedAst) { if (file == null || !"java".equalsIgnoreCase(file.language())) { return EmptyResolved.INSTANCE; } - if (!(parsedAst instanceof CompilationUnit cu)) { - return EmptyResolved.INSTANCE; - } if (this.solver == null) { // bootstrap() not called or it failed silently — falling back to // EmptyResolved is the safe path. The orchestrator already logs // bootstrap failures from ResolverRegistry. return EmptyResolved.INSTANCE; } + + CompilationUnit cu; + if (parsedAst instanceof CompilationUnit existing) { + // Caller already parsed (Analyzer's structured-language path, or + // a detector that pre-parsed). Reuse — no double-parse. + cu = existing; + } else if (parsedAst instanceof String source) { + // Lazy parse: Analyzer passes the raw file content for Java + // because the orchestrator-level structured parser doesn't cover + // Java. A fresh JavaParser per call is intentional — JavaParser + // instances aren't thread-safe and resolve() is invoked from + // virtual threads concurrently. Allocation cost is small relative + // to the parse itself, and the per-call instance carries the + // symbol solver so resolve()s on the resulting AST work. + ParserConfiguration cfg = new ParserConfiguration().setSymbolResolver(solver); + ParseResult parseResult = new JavaParser(cfg).parse(source); + if (parseResult.getResult().isEmpty()) { + // Unparseable source — return EmptyResolved rather than + // surface a parse exception. Detectors that need the raw + // content already have ctx.content() — symbol resolution + // simply isn't available for files JavaParser can't accept. + return EmptyResolved.INSTANCE; + } + cu = parseResult.getResult().get(); + } else { + // Neither a CompilationUnit nor a String — caller shape we don't + // understand. Defensive fallback rather than a ClassCastException. + return EmptyResolved.INSTANCE; + } return new JavaResolved(cu, solver); } diff --git a/src/test/java/io/github/randomcodespace/iq/analyzer/AnalyzerResolverWiringTest.java b/src/test/java/io/github/randomcodespace/iq/analyzer/AnalyzerResolverWiringTest.java index 572e47d0..ef1f4405 100644 --- a/src/test/java/io/github/randomcodespace/iq/analyzer/AnalyzerResolverWiringTest.java +++ b/src/test/java/io/github/randomcodespace/iq/analyzer/AnalyzerResolverWiringTest.java @@ -9,6 +9,10 @@ import io.github.randomcodespace.iq.detector.DetectorResult; import io.github.randomcodespace.iq.intelligence.resolver.EmptyResolved; import io.github.randomcodespace.iq.intelligence.resolver.ResolverRegistry; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaResolved; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaSourceRootDiscovery; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaSymbolResolver; +import io.github.randomcodespace.iq.model.Confidence; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -140,6 +144,34 @@ void ctxResolvedIsEmptyResolvedForFileWithoutResolver() throws IOException { "EmptyResolved is the only legal fallback — JavaDetector tests rely on this"); } + // ── DetectorContext: with JavaSymbolResolver registered, Java carries JavaResolved + + @Test + void javaFilePicksUpJavaResolvedWhenResolverRegistered() throws IOException { + Files.writeString(tempDir.resolve("App.java"), "public class App {}"); + + AtomicReference seen = new AtomicReference<>(); + ResolverRegistry registry = new ResolverRegistry( + List.of(new JavaSymbolResolver(new JavaSourceRootDiscovery()))); + Analyzer analyzer = newAnalyzer(registry, captureCtx(seen)); + analyzer.run(tempDir, null); + + DetectorContext ctx = seen.get(); + assertNotNull(ctx, "test detector must have been called"); + assertTrue(ctx.resolved().isPresent(), "wiring must populate ctx.resolved()"); + + var resolved = ctx.resolved().orElseThrow(); + assertNotSame(EmptyResolved.INSTANCE, resolved, + "with JavaSymbolResolver registered, Java files get JavaResolved"); + assertInstanceOf(JavaResolved.class, resolved); + + JavaResolved jr = (JavaResolved) resolved; + assertTrue(jr.isAvailable(), "JavaResolved is the RESOLVED tier — always available"); + assertEquals(Confidence.RESOLVED, jr.sourceConfidence()); + assertNotNull(jr.cu(), "the CU is the lazy-parsed App.java"); + assertNotNull(jr.solver(), "the symbol solver is threaded through"); + } + @Test void wiringIsBackwardCompatibleWithLegacyCtor() throws IOException { // The 6-arg backward-compat ctor must still produce a working Analyzer diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverTest.java index 49cb8475..54064b80 100644 --- a/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverTest.java +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverTest.java @@ -128,12 +128,62 @@ void resolveNullAstReturnsEmpty(@TempDir Path tmp) throws ResolutionException { } @Test - void resolveStringAstReturnsEmpty(@TempDir Path tmp) throws ResolutionException { + void resolveValidJavaSourceStringReturnsJavaResolved(@TempDir Path tmp) throws ResolutionException { + // Lazy-parse path: the orchestrator hands us raw file content for + // Java (since its top-level structured parser doesn't cover Java). + // The resolver parses with the symbol-solver-configured JavaParser + // and threads the resulting CU into JavaResolved. resolver.bootstrap(tmp); DiscoveredFile java = new DiscoveredFile(Path.of("Foo.java"), "java", 100); - Resolved r = resolver.resolve(java, "not a CompilationUnit"); - assertSame(EmptyResolved.INSTANCE, r, - "wrong AST type → EmptyResolved instead of ClassCastException"); + Resolved r = resolver.resolve(java, "public class Foo {}"); + + assertNotSame(EmptyResolved.INSTANCE, r); + assertInstanceOf(JavaResolved.class, r); + + JavaResolved jr = (JavaResolved) r; + assertNotNull(jr.cu(), "the CU is the parser output, never null on success"); + assertNotNull(jr.solver(), "the solver is threaded through unchanged"); + } + + @Test + void resolveJunkInputNeverThrowsOrReturnsNull(@TempDir Path tmp) throws ResolutionException { + // JavaParser is permissive — it returns a CompilationUnit (possibly with + // attached Problems) for nearly any string input rather than refusing + // outright. The hard contract for this resolver path is therefore not + // "Empty for invalid Java" but "no exception, no null, no + // ClassCastException" — production analysis must keep going across + // files with syntax errors instead of taking the entire pass down. + resolver.bootstrap(tmp); + DiscoveredFile java = new DiscoveredFile(Path.of("Foo.java"), "java", 100); + Resolved r = resolver.resolve(java, "@@@ definitely not valid java !!!"); + assertNotNull(r, "resolver must never return null"); + // Don't pin the variant: JavaResolved (parsed with Problems) and + // EmptyResolved (parser couldn't even materialise a CU) are both + // legitimate outcomes for garbage input. + } + + @Test + void resolveEmptyStringReturnsJavaResolvedWithEmptyCu(@TempDir Path tmp) throws ResolutionException { + // Edge case: empty source. JavaParser accepts this and returns an + // empty CU (no top-level types). That's still a parse — JavaResolved + // is fine; detectors that find no types will emit nothing. + resolver.bootstrap(tmp); + DiscoveredFile java = new DiscoveredFile(Path.of("Empty.java"), "java", 0); + Resolved r = resolver.resolve(java, ""); + + assertNotSame(EmptyResolved.INSTANCE, r); + assertInstanceOf(JavaResolved.class, r); + assertNotNull(((JavaResolved) r).cu()); + } + + @Test + void resolveUnknownAstTypeReturnsEmpty(@TempDir Path tmp) throws ResolutionException { + // Neither CompilationUnit nor String — caller shape we don't know. + // Defensive fallback rather than ClassCastException. + resolver.bootstrap(tmp); + DiscoveredFile java = new DiscoveredFile(Path.of("Foo.java"), "java", 100); + Resolved r = resolver.resolve(java, Path.of("/tmp/anything")); + assertSame(EmptyResolved.INSTANCE, r); } @Test From 31f889150ed5ab73586dd98230886b921fe2737f Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 27 Apr 2026 23:54:44 +0000 Subject: [PATCH 03/16] feat(detector/jpa): consume ctx.resolved() for RESOLVED-tier MAPS_TO edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 task 26 (and the JPA-relationship part of task 24) — first detector migration to the resolver SPI. When ctx.resolved() carries a JavaResolved (Analyzer registered a JavaSymbolResolver), the detector now: 1. Uses the resolver-parsed CompilationUnit (which has the symbol solver attached) instead of the local ThreadLocal-pool parse — no double-parse, and Type.resolve() works inside the AST walk. 2. Attempts to resolve each @OneToMany / @ManyToOne / @OneToOne / @ManyToMany field's target type to a fully-qualified name via the symbol solver: - Generic-arg case: List → resolves the type argument - Direct-field case: Owner → resolves the field type 3. On resolution success, attaches target_fqn to the edge properties and stamps Confidence.RESOLVED + source = "jpa_entity". The simple- name edge ID + target placeholder are unchanged so EntityLinker's post-pass keeps working — target_fqn rides as the canonical pointer. 4. On resolution failure (missing classpath, unsolvable symbol, etc.), falls back gracefully to the existing simple-name path with the base-class default confidence. Existing detector behaviour is unchanged when ctx.resolved() is empty or carries EmptyResolved — the 29 pre-existing JpaEntityDetectorExtended tests still pass without modification. 5 new tests in JpaEntityDetectorResolvedTest cover the three plan- required modes (resolved, fallback, mixed) plus generic-arg resolution and the no-resolved-at-all legacy ctx path: - resolvedModeProducesResolvedEdgeWithTargetFqn — two Owner classes in different packages; with resolution, the imported one wins and edge.target_fqn = "com.example.a.Owner" + RESOLVED. - resolvedModeFindsCollectionGenericArg — @OneToMany List resolves the generic arg, not the List type. - fallbackModeMatchesPreSpecBaseline — EmptyResolved → no target_fqn + raw-default confidence (orchestrator stamps SYNTACTIC at boundary). - fallbackModeWhenContextHasNoResolvedAtAll — Optional.empty() also produces the same baseline shape (legacy ctx path safety). - mixedModeUsesResolverWhereAvailable — one resolvable + one unresolvable relationship in the same class; the resolvable edge is RESOLVED + target_fqn, the unresolvable falls back. Plan: docs/plans/2026-04-27-sub-project-1-resolver-spi-and-java-pilot.md (task 26). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../detector/jvm/java/JpaEntityDetector.java | 79 ++++- .../java/JpaEntityDetectorResolvedTest.java | 293 ++++++++++++++++++ 2 files changed, 367 insertions(+), 5 deletions(-) create mode 100644 src/test/java/io/github/randomcodespace/iq/detector/jvm/java/JpaEntityDetectorResolvedTest.java diff --git a/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/JpaEntityDetector.java b/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/JpaEntityDetector.java index f2c1dc7b..b96479f5 100644 --- a/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/JpaEntityDetector.java +++ b/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/JpaEntityDetector.java @@ -8,11 +8,15 @@ import com.github.javaparser.ast.expr.MemberValuePair; import com.github.javaparser.ast.type.ClassOrInterfaceType; import com.github.javaparser.ast.type.Type; +import com.github.javaparser.resolution.types.ResolvedType; import io.github.randomcodespace.iq.detector.DetectorContext; import io.github.randomcodespace.iq.detector.DetectorDbHelper; import io.github.randomcodespace.iq.detector.DetectorResult; +import io.github.randomcodespace.iq.intelligence.resolver.Resolved; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaResolved; import io.github.randomcodespace.iq.model.CodeEdge; import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.Confidence; import io.github.randomcodespace.iq.model.EdgeKind; import io.github.randomcodespace.iq.model.NodeKind; import org.springframework.stereotype.Component; @@ -77,16 +81,28 @@ public DetectorResult detect(DetectorContext ctx) { String text = ctx.content(); if (text == null || !text.contains("@Entity")) return DetectorResult.empty(); - Optional cu = parse(ctx); + // Prefer the resolver-parsed CU when ctx.resolved() carries a + // {@link JavaResolved}: that CU has the symbol solver attached, so + // {@code Type.resolve()} works inside detectWithAst and we can promote + // edges from SYNTACTIC → RESOLVED with a stable {@code target_fqn}. + // Fall back to the local ThreadLocal-pool parse otherwise — existing + // behaviour, no resolution attempts, defaults stamp SYNTACTIC. + Optional resolved = ctx.resolved() + .filter(Resolved::isAvailable) + .filter(JavaResolved.class::isInstance) + .map(JavaResolved.class::cast); + + Optional cu = resolved.map(JavaResolved::cu).or(() -> parse(ctx)); if (cu.isPresent()) { - return detectWithAst(cu.get(), ctx); + return detectWithAst(cu.get(), ctx, resolved); } return detectWithRegex(ctx); } // ==================== AST-based detection ==================== - private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) { + private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx, + Optional resolved) { List nodes = new ArrayList<>(); List edges = new ArrayList<>(); @@ -119,7 +135,7 @@ private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) { nodes.add(node); DetectorDbHelper.addDbEdge(entityId, ctx.registry(), nodes, edges); - extractRelationshipEdges(classDecl, entityId, edges); + extractRelationshipEdges(classDecl, entityId, resolved, edges); }); return DetectorResult.of(nodes, edges); @@ -159,7 +175,9 @@ private void addColumnFromAnnotations(FieldDeclaration field, String fieldName, } private void extractRelationshipEdges(ClassOrInterfaceDeclaration classDecl, - String entityId, List edges) { + String entityId, + Optional resolved, + List edges) { for (FieldDeclaration field : classDecl.getFields()) { for (AnnotationExpr ann : field.getAnnotations()) { String annName = ann.getNameAsString(); @@ -169,10 +187,18 @@ private void extractRelationshipEdges(ClassOrInterfaceDeclaration classDecl, String targetEntity = resolveTargetEntity(ann, field); if (targetEntity == null) continue; + // Promote SYNTACTIC → RESOLVED when the symbol solver can give + // us a stable FQN for the relationship target. The simple-name + // edge ID + target placeholder are unchanged so EntityLinker's + // post-pass keeps working; target_fqn rides as a property and + // is the canonical pointer when present. + Optional targetFqn = resolved.flatMap(r -> resolveTargetFqn(field)); + String mappedBy = extractAnnotationStringAttr(ann, "mappedBy"); Map edgeProps = new LinkedHashMap<>(); edgeProps.put("relationship_type", relType); if (mappedBy != null) edgeProps.put("mapped_by", mappedBy); + targetFqn.ifPresent(fqn -> edgeProps.put("target_fqn", fqn)); CodeEdge edge = new CodeEdge(); edge.setId(entityId + "->maps_to->*:" + targetEntity); @@ -180,11 +206,54 @@ private void extractRelationshipEdges(ClassOrInterfaceDeclaration classDecl, edge.setSourceId(entityId); edge.setTarget(new CodeNode("*:" + targetEntity, NodeKind.ENTITY, targetEntity)); edge.setProperties(edgeProps); + if (targetFqn.isPresent()) { + edge.setConfidence(Confidence.RESOLVED); + edge.setSource(getName()); + } edges.add(edge); } } } + /** + * Resolve the target entity's fully-qualified name via the symbol solver. + * Mirrors {@link #resolveTargetEntity} but returns the FQN instead of a + * simple name. {@code @OneToMany List} → resolves the {@code Owner} + * type argument; {@code @ManyToOne Owner} → resolves the field type + * directly. + * + *

Returns {@code Optional.empty()} on any resolver failure (unsolved + * symbol, missing type, classpath gap, etc.) — graceful fallback to + * SYNTACTIC tier. + */ + private Optional resolveTargetFqn(FieldDeclaration field) { + for (VariableDeclarator var : field.getVariables()) { + Type type = var.getType(); + if (!type.isClassOrInterfaceType()) continue; + ClassOrInterfaceType cit = type.asClassOrInterfaceType(); + var typeArgsOpt = cit.getTypeArguments(); + Type targetType; + if (typeArgsOpt.isPresent() && !typeArgsOpt.get().isEmpty()) { + targetType = typeArgsOpt.get().get(0); + } else { + targetType = cit; + } + try { + ResolvedType rt = targetType.resolve(); + if (rt.isReferenceType()) { + return Optional.of(rt.asReferenceType().getQualifiedName()); + } + return Optional.of(rt.describe()); + } catch (RuntimeException e) { + // Resolver couldn't pin the type — typical when the classpath + // is incomplete or the type is genuinely unknown. Fall back to + // SYNTACTIC by returning empty. + return Optional.empty(); + } + } + return Optional.empty(); + } + private String resolveTargetEntity(AnnotationExpr ann, FieldDeclaration field) { String targetEntity = extractAnnotationStringAttr(ann, "targetEntity"); if (targetEntity != null && targetEntity.endsWith(".class")) { diff --git a/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/JpaEntityDetectorResolvedTest.java b/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/JpaEntityDetectorResolvedTest.java new file mode 100644 index 00000000..2e2b624f --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/JpaEntityDetectorResolvedTest.java @@ -0,0 +1,293 @@ +package io.github.randomcodespace.iq.detector.jvm.java; + +import io.github.randomcodespace.iq.analyzer.DiscoveredFile; +import io.github.randomcodespace.iq.detector.DetectorContext; +import io.github.randomcodespace.iq.detector.DetectorResult; +import io.github.randomcodespace.iq.intelligence.resolver.EmptyResolved; +import io.github.randomcodespace.iq.intelligence.resolver.ResolutionException; +import io.github.randomcodespace.iq.intelligence.resolver.Resolved; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaResolved; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaSourceRootDiscovery; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaSymbolResolver; +import io.github.randomcodespace.iq.model.CodeEdge; +import io.github.randomcodespace.iq.model.Confidence; +import io.github.randomcodespace.iq.model.EdgeKind; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 6 — JpaEntityDetector migration to consume {@code ctx.resolved()} and + * promote relationship edges from SYNTACTIC → RESOLVED. + * + *

Three contract tests per the plan: + *

    + *
  1. resolvedModeProducesResolvedEdge — when ctx.resolved() carries a + * {@link JavaResolved}, the relationship edge gets a stable + * {@code target_fqn} property and {@link Confidence#RESOLVED}.
  2. + *
  3. fallbackModeMatchesPreSpecBaseline — without a resolver the + * edge has no {@code target_fqn} and the default-stamping leaves + * confidence/source for the orchestrator (matches pre-migration + * observable shape).
  4. + *
  5. mixedModeUsesResolverWhereAvailable — a single class with a + * resolvable {@code @OneToMany List} and an unresolvable + * {@code @ManyToOne UnknownEntity} produces one RESOLVED edge and one + * falling back to default tier.
  6. + *
+ */ +class JpaEntityDetectorResolvedTest { + + @TempDir Path repoRoot; + + private final JpaEntityDetector detector = new JpaEntityDetector(); + private JavaSymbolResolver resolver; + + @BeforeEach + void setUp() throws IOException { + // Maven-shaped layout — JavaSourceRootDiscovery picks src/main/java. + Files.createDirectories(repoRoot.resolve("src/main/java/com/example/a")); + Files.createDirectories(repoRoot.resolve("src/main/java/com/example/b")); + Files.writeString(repoRoot.resolve("pom.xml"), ""); + + // Two Owner classes in different packages — the imported one is the + // canonical resolution target. + Files.writeString(repoRoot.resolve("src/main/java/com/example/a/Owner.java"), + """ + package com.example.a; + public class Owner {} + """); + Files.writeString(repoRoot.resolve("src/main/java/com/example/b/Owner.java"), + """ + package com.example.b; + public class Owner {} + """); + + resolver = new JavaSymbolResolver(new JavaSourceRootDiscovery()); + } + + // ── (1) Resolved mode ──────────────────────────────────────────────────── + + @Test + void resolvedModeProducesResolvedEdgeWithTargetFqn() throws Exception { + // Pet imports com.example.a.Owner and uses it in @ManyToOne. + String petPath = "src/main/java/com/example/Pet.java"; + Path absPet = repoRoot.resolve(petPath); + Files.createDirectories(absPet.getParent()); + String content = """ + package com.example; + import javax.persistence.*; + import com.example.a.Owner; + @Entity + @Table(name = "pet") + public class Pet { + @Id private Long id; + @ManyToOne private Owner owner; + } + """; + Files.writeString(absPet, content); + + Resolved resolved = bootstrapAndResolve(petPath, content); + assertInstanceOf(JavaResolved.class, resolved, + "resolver must return JavaResolved for a valid Java source file"); + + DetectorContext ctx = ctxFor(petPath, content).withResolved(resolved); + + DetectorResult result = detector.detect(ctx); + + CodeEdge mapsTo = onlyMapsToEdge(result); + assertEquals("com.example.a.Owner", mapsTo.getProperties().get("target_fqn"), + "resolved tier must pin the imported package's Owner FQN, not the b/ Owner"); + assertEquals(Confidence.RESOLVED, mapsTo.getConfidence(), + "edge with a resolved target_fqn is RESOLVED tier"); + assertEquals(detector.getName(), mapsTo.getSource(), + "detector explicitly stamps source on RESOLVED edges"); + } + + @Test + void resolvedModeFindsCollectionGenericArg() throws Exception { + // @OneToMany List — generic arg [0] is the relationship target. + String petPath = "src/main/java/com/example/PetOwner.java"; + Path absPet = repoRoot.resolve(petPath); + Files.createDirectories(absPet.getParent()); + String content = """ + package com.example; + import javax.persistence.*; + import java.util.List; + import com.example.a.Owner; + @Entity + @Table(name = "pet_owner") + public class PetOwner { + @Id private Long id; + @OneToMany private List owners; + } + """; + Files.writeString(absPet, content); + + Resolved resolved = bootstrapAndResolve(petPath, content); + DetectorContext ctx = ctxFor(petPath, content).withResolved(resolved); + + DetectorResult result = detector.detect(ctx); + + CodeEdge mapsTo = onlyMapsToEdge(result); + assertEquals("com.example.a.Owner", mapsTo.getProperties().get("target_fqn")); + assertEquals(Confidence.RESOLVED, mapsTo.getConfidence()); + } + + // ── (2) Fallback mode ──────────────────────────────────────────────────── + + @Test + void fallbackModeMatchesPreSpecBaseline() throws Exception { + // ctx.resolved() is EmptyResolved → no resolution attempts → no + // target_fqn property; confidence/source left for orchestrator + // defaulting (matches pre-migration shape). + String petPath = "src/main/java/com/example/Pet.java"; + Path absPet = repoRoot.resolve(petPath); + Files.createDirectories(absPet.getParent()); + String content = """ + package com.example; + import javax.persistence.*; + import com.example.a.Owner; + @Entity + public class Pet { + @Id private Long id; + @ManyToOne private Owner owner; + } + """; + Files.writeString(absPet, content); + + DetectorContext ctx = ctxFor(petPath, content).withResolved(EmptyResolved.INSTANCE); + + DetectorResult result = detector.detect(ctx); + + CodeEdge mapsTo = onlyMapsToEdge(result); + assertNull(mapsTo.getProperties().get("target_fqn"), + "fallback mode must not synthesise a target_fqn — resolver was unavailable"); + assertNull(mapsTo.getSource(), + "detector leaves source null in fallback mode (orchestrator stamps default)"); + // Confidence default also left unstamped — orchestrator's + // DetectorEmissionDefaults applies SYNTACTIC at the analyzer boundary. + assertEquals(Confidence.LEXICAL, mapsTo.getConfidence(), + "raw edge default before orchestrator stamping is LEXICAL"); + } + + @Test + void fallbackModeWhenContextHasNoResolvedAtAll() throws Exception { + // Same shape as above but ctx.resolved() is Optional.empty() — older + // call path that never threaded a Resolved through. Still must work. + String petPath = "src/main/java/com/example/Pet.java"; + Path absPet = repoRoot.resolve(petPath); + Files.createDirectories(absPet.getParent()); + String content = """ + package com.example; + import javax.persistence.*; + import com.example.a.Owner; + @Entity + public class Pet { + @Id private Long id; + @ManyToOne private Owner owner; + } + """; + Files.writeString(absPet, content); + + DetectorContext ctx = ctxFor(petPath, content); + // No withResolved call — Optional.empty() default. + + DetectorResult result = detector.detect(ctx); + CodeEdge mapsTo = onlyMapsToEdge(result); + assertNull(mapsTo.getProperties().get("target_fqn")); + } + + // ── (3) Mixed mode ─────────────────────────────────────────────────────── + + @Test + void mixedModeUsesResolverWhereAvailable() throws Exception { + // Pet has two relationships — one resolvable (Owner from com.example.a), + // one unresolvable (Vet — class doesn't exist in any source root). + // Expect: Owner edge gets RESOLVED + target_fqn; Vet edge falls back. + String petPath = "src/main/java/com/example/Pet.java"; + Path absPet = repoRoot.resolve(petPath); + Files.createDirectories(absPet.getParent()); + String content = """ + package com.example; + import javax.persistence.*; + import com.example.a.Owner; + @Entity + public class Pet { + @Id private Long id; + @ManyToOne private Owner owner; + @ManyToOne private Vet vet; + } + """; + Files.writeString(absPet, content); + + Resolved resolved = bootstrapAndResolve(petPath, content); + DetectorContext ctx = ctxFor(petPath, content).withResolved(resolved); + + DetectorResult result = detector.detect(ctx); + + List mapsTo = result.edges().stream() + .filter(e -> e.getKind() == EdgeKind.MAPS_TO) + .toList(); + assertEquals(2, mapsTo.size(), "two relationships → two MAPS_TO edges"); + + CodeEdge ownerEdge = mapsTo.stream() + .filter(e -> "Owner".equals(e.getTarget().getLabel())) + .findFirst().orElseThrow(); + CodeEdge vetEdge = mapsTo.stream() + .filter(e -> "Vet".equals(e.getTarget().getLabel())) + .findFirst().orElseThrow(); + + assertEquals("com.example.a.Owner", ownerEdge.getProperties().get("target_fqn")); + assertEquals(Confidence.RESOLVED, ownerEdge.getConfidence()); + + assertNull(vetEdge.getProperties().get("target_fqn"), + "Vet has no source on the project — resolver returns nothing"); + // Vet edge confidence is the raw enum default (LEXICAL); orchestrator + // would stamp SYNTACTIC if this went through Analyzer. Either way: + // not RESOLVED. + assertNotEquals(Confidence.RESOLVED, vetEdge.getConfidence()); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + /** + * Bootstrap the resolver against the synthetic repo and resolve a single + * file's content into a {@link Resolved}. Keeps the per-test setup terse. + */ + private Resolved bootstrapAndResolve(String relPath, String content) throws ResolutionException { + resolver.bootstrap(repoRoot); + DiscoveredFile file = new DiscoveredFile(Path.of(relPath), "java", content.length()); + return resolver.resolve(file, content); + } + + /** Build a vanilla DetectorContext with our synthetic file path + content. */ + private DetectorContext ctxFor(String relPath, String content) { + return new DetectorContext(relPath, "java", content, null, null); + } + + /** + * Pull the single MAPS_TO edge out of the result. Matches the contract the + * detector promises for our single-relationship fixtures; fails loudly if + * the count is unexpected so test breakage points to a real shape change. + */ + private static CodeEdge onlyMapsToEdge(DetectorResult result) { + List mapsTo = result.edges().stream() + .filter(e -> e.getKind() == EdgeKind.MAPS_TO) + .toList(); + assertEquals(1, mapsTo.size(), + "expected exactly one MAPS_TO edge; got " + mapsTo.size() + + " — detector shape changed?"); + return mapsTo.get(0); + } + + @SuppressWarnings("unused") // Imported for future test additions. + private static Optional opt(T value) { return Optional.ofNullable(value); } +} From e97a936b5ee481c68c889212bbea8a6b71cbcd01 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 27 Apr 2026 23:58:21 +0000 Subject: [PATCH 04/16] feat(detector/repository): consume ctx.resolved() for RESOLVED-tier QUERIES edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 task 25 (Spring Data repository migration). Same shape as the JpaEntityDetector migration applied to RepositoryDetector — promote the QUERIES edge from SYNTACTIC → RESOLVED with a stable target FQN when the resolver can pin the entity type. The detector stays regex-first (the inheritance regex is the cheapest positive signal for "this file is a Spring Data repo"), and uses ctx.resolved() purely for the FQN upgrade. When ctx.resolved() carries a JavaResolved, the detector walks JavaResolved.cu(), finds the interface declaration matching the regex-extracted name, takes the first type argument of its first extended type (e.g. JpaRepository → User), and resolves it via the attached symbol solver. On success: - repo node gains entity_fqn (so the RepositoryNode can be reasoned about without a join through the entity index). - QUERIES edge gains target_fqn + Confidence.RESOLVED + source = "spring_repository". On failure (no ctx.resolved(), EmptyResolved, no parent type with generics, solver can't find the type), behaviour is unchanged from before this commit — the simple-name placeholder edge with default confidence is what shipped before, and tests confirm that path is intact. 4 new tests in RepositoryDetectorResolvedTest cover the same three modes as the JpaEntity migration: - resolvedModeProducesResolvedEdgeWithTargetFqn — two User classes in different packages; the imported one wins on entity_fqn + target_fqn. - fallbackModeMatchesPreSpecBaseline — EmptyResolved → no FQN properties + no RESOLVED stamp. - fallbackModeWhenContextHasNoResolvedAtAll — Optional.empty() also safe. - mixedModeFallsBackForUnreachableEntityType — repo whose entity has no source: solver fails → fallback to simple-name + default tier. Plan: docs/plans/2026-04-27-sub-project-1-resolver-spi-and-java-pilot.md (task 25). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../detector/jvm/java/RepositoryDetector.java | 69 ++++++ .../java/RepositoryDetectorResolvedTest.java | 225 ++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 src/test/java/io/github/randomcodespace/iq/detector/jvm/java/RepositoryDetectorResolvedTest.java diff --git a/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/RepositoryDetector.java b/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/RepositoryDetector.java index cdd579b5..063b41fc 100644 --- a/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/RepositoryDetector.java +++ b/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/RepositoryDetector.java @@ -1,11 +1,18 @@ package io.github.randomcodespace.iq.detector.jvm.java; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.type.ClassOrInterfaceType; +import com.github.javaparser.ast.type.Type; +import com.github.javaparser.resolution.types.ResolvedType; import io.github.randomcodespace.iq.detector.AbstractRegexDetector; import io.github.randomcodespace.iq.detector.DetectorContext; import io.github.randomcodespace.iq.detector.DetectorDbHelper; import io.github.randomcodespace.iq.detector.DetectorResult; +import io.github.randomcodespace.iq.intelligence.resolver.Resolved; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaResolved; import io.github.randomcodespace.iq.model.CodeEdge; import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.Confidence; import io.github.randomcodespace.iq.model.EdgeKind; import io.github.randomcodespace.iq.model.NodeKind; import org.springframework.stereotype.Component; @@ -116,6 +123,21 @@ public DetectorResult detect(DetectorContext ctx) { properties.put("entity_type", entityType); } + // RESOLVED tier: when ctx.resolved() carries a JavaResolved we look up + // the entity type's fully-qualified name via the symbol solver. The + // FQN rides on both the repo node (entity_fqn) and the QUERIES edge + // (target_fqn) so consumers (EntityLinker, query routing, the SPA) + // can pick the unambiguous reference when available. + Optional resolved = ctx.resolved() + .filter(Resolved::isAvailable) + .filter(JavaResolved.class::isInstance) + .map(JavaResolved.class::cast); + final String resolvedInterfaceName = interfaceName; // effectively-final capture for the lambda + Optional entityFqn = (entityType != null) + ? resolved.flatMap(jr -> resolveEntityFqn(jr, resolvedInterfaceName)) + : Optional.empty(); + entityFqn.ifPresent(fqn -> properties.put("entity_fqn", fqn)); + // Extract @Query methods List> customQueries = new ArrayList<>(); for (int i = 0; i < lines.length; i++) { @@ -155,6 +177,13 @@ public DetectorResult detect(DetectorContext ctx) { edge.setSourceId(repoId); CodeNode targetRef = new CodeNode("*:" + entityType, NodeKind.ENTITY, entityType); edge.setTarget(targetRef); + if (entityFqn.isPresent()) { + Map edgeProps = new LinkedHashMap<>(); + edgeProps.put("target_fqn", entityFqn.get()); + edge.setProperties(edgeProps); + edge.setConfidence(Confidence.RESOLVED); + edge.setSource(getName()); + } edges.add(edge); } DetectorDbHelper.addDbEdge(repoId, ctx.registry(), nodes, edges); @@ -162,4 +191,44 @@ public DetectorResult detect(DetectorContext ctx) { return DetectorResult.of(nodes, edges); } + /** + * Use the resolver-attached symbol solver to find the entity type's FQN. + * Walks {@link JavaResolved#cu()}'s class hierarchy: locate the interface + * declaration matching {@code interfaceName}, take its first extended type + * (e.g. {@code JpaRepository}), then resolve the first type + * argument ({@code User}) to a fully-qualified name. + * + *

Returns empty on any solver failure — graceful fallback to the + * regex-extracted simple-name target. + */ + private Optional resolveEntityFqn(JavaResolved jr, String interfaceName) { + return jr.cu().findAll(ClassOrInterfaceDeclaration.class).stream() + .filter(d -> interfaceName.equals(d.getNameAsString())) + .findFirst() + .flatMap(this::firstTypeArgOfFirstParent) + .flatMap(RepositoryDetector::tryResolveFqn); + } + + /** Take the first type argument of the first extended/implemented type, if any. */ + private Optional firstTypeArgOfFirstParent(ClassOrInterfaceDeclaration decl) { + for (ClassOrInterfaceType parent : decl.getExtendedTypes()) { + var typeArgs = parent.getTypeArguments(); + if (typeArgs.isPresent() && !typeArgs.get().isEmpty()) { + return Optional.of(typeArgs.get().get(0)); + } + } + return Optional.empty(); + } + + private static Optional tryResolveFqn(Type type) { + try { + ResolvedType rt = type.resolve(); + if (rt.isReferenceType()) { + return Optional.of(rt.asReferenceType().getQualifiedName()); + } + return Optional.of(rt.describe()); + } catch (RuntimeException e) { + return Optional.empty(); + } + } } diff --git a/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/RepositoryDetectorResolvedTest.java b/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/RepositoryDetectorResolvedTest.java new file mode 100644 index 00000000..fbe9816c --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/RepositoryDetectorResolvedTest.java @@ -0,0 +1,225 @@ +package io.github.randomcodespace.iq.detector.jvm.java; + +import io.github.randomcodespace.iq.analyzer.DiscoveredFile; +import io.github.randomcodespace.iq.detector.DetectorContext; +import io.github.randomcodespace.iq.detector.DetectorResult; +import io.github.randomcodespace.iq.intelligence.resolver.EmptyResolved; +import io.github.randomcodespace.iq.intelligence.resolver.ResolutionException; +import io.github.randomcodespace.iq.intelligence.resolver.Resolved; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaResolved; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaSourceRootDiscovery; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaSymbolResolver; +import io.github.randomcodespace.iq.model.CodeEdge; +import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.Confidence; +import io.github.randomcodespace.iq.model.EdgeKind; +import io.github.randomcodespace.iq.model.NodeKind; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 6 — RepositoryDetector migration to consume {@code ctx.resolved()} and + * promote QUERIES edges from SYNTACTIC → RESOLVED with a stable FQN target. + * + *

Three contract tests per the plan (plus a generic-arg variant for clarity): + *

    + *
  1. resolvedModeProducesResolvedEdge — JpaRepository<User, Long> + * with two {@code User} classes in different packages; the imported + * FQN wins on edge.target_fqn + node.entity_fqn.
  2. + *
  3. fallbackModeMatchesPreSpecBaseline — EmptyResolved → no + * FQN properties; default tier (orchestrator stamps LEXICAL because + * the base class is AbstractRegexDetector).
  4. + *
  5. mixedModeUsesResolverWhereAvailable — repo for an entity that + * has no source on the project: simple-name target, no FQN, no + * RESOLVED stamp.
  6. + *
+ */ +class RepositoryDetectorResolvedTest { + + @TempDir Path repoRoot; + + private final RepositoryDetector detector = new RepositoryDetector(); + private JavaSymbolResolver resolver; + + @BeforeEach + void setUp() throws IOException { + Files.createDirectories(repoRoot.resolve("src/main/java/com/example/a")); + Files.createDirectories(repoRoot.resolve("src/main/java/com/example/b")); + Files.writeString(repoRoot.resolve("pom.xml"), ""); + + Files.writeString(repoRoot.resolve("src/main/java/com/example/a/User.java"), + """ + package com.example.a; + public class User {} + """); + Files.writeString(repoRoot.resolve("src/main/java/com/example/b/User.java"), + """ + package com.example.b; + public class User {} + """); + + // Spring Data interfaces are referenced lexically via the parent type + // — we don't need their actual class on the classpath for the resolver + // to extract the type argument. A stub interface in our source root + // makes the resolver's reachable-type set explicit, however. + Files.createDirectories(repoRoot.resolve("src/main/java/org/springframework/data/jpa/repository")); + Files.writeString(repoRoot.resolve("src/main/java/org/springframework/data/jpa/repository/JpaRepository.java"), + """ + package org.springframework.data.jpa.repository; + public interface JpaRepository {} + """); + + resolver = new JavaSymbolResolver(new JavaSourceRootDiscovery()); + } + + // ── (1) Resolved mode ──────────────────────────────────────────────────── + + @Test + void resolvedModeProducesResolvedEdgeWithTargetFqn() throws Exception { + String repoPath = "src/main/java/com/example/UserRepo.java"; + Path absRepo = repoRoot.resolve(repoPath); + Files.createDirectories(absRepo.getParent()); + String content = """ + package com.example; + import com.example.a.User; + import org.springframework.data.jpa.repository.JpaRepository; + public interface UserRepo extends JpaRepository {} + """; + Files.writeString(absRepo, content); + + Resolved resolved = bootstrapAndResolve(repoPath, content); + assertInstanceOf(JavaResolved.class, resolved); + + DetectorContext ctx = ctxFor(repoPath, content).withResolved(resolved); + DetectorResult result = detector.detect(ctx); + + // Repo node has entity_fqn. + CodeNode repo = onlyRepoNode(result); + assertEquals("com.example.a.User", repo.getProperties().get("entity_fqn"), + "node carries the resolved FQN, not the b/ User"); + + // QUERIES edge has target_fqn + RESOLVED. + CodeEdge queries = onlyQueriesEdge(result); + assertEquals("com.example.a.User", queries.getProperties().get("target_fqn")); + assertEquals(Confidence.RESOLVED, queries.getConfidence()); + assertEquals(detector.getName(), queries.getSource()); + } + + // ── (2) Fallback mode ──────────────────────────────────────────────────── + + @Test + void fallbackModeMatchesPreSpecBaseline() throws Exception { + String repoPath = "src/main/java/com/example/UserRepo.java"; + Path absRepo = repoRoot.resolve(repoPath); + Files.createDirectories(absRepo.getParent()); + String content = """ + package com.example; + import com.example.a.User; + public interface UserRepo extends JpaRepository {} + """; + Files.writeString(absRepo, content); + + DetectorContext ctx = ctxFor(repoPath, content).withResolved(EmptyResolved.INSTANCE); + DetectorResult result = detector.detect(ctx); + + CodeNode repo = onlyRepoNode(result); + assertNull(repo.getProperties().get("entity_fqn"), + "fallback must not synthesise an FQN — resolver was unavailable"); + assertEquals("User", repo.getProperties().get("entity_type"), + "regex still extracts the simple name (existing behaviour)"); + + CodeEdge queries = onlyQueriesEdge(result); + assertNull(queries.getProperties().get("target_fqn")); + assertNull(queries.getSource(), + "detector leaves source null in fallback (orchestrator stamps default)"); + assertNotEquals(Confidence.RESOLVED, queries.getConfidence(), + "without FQN, edge is not RESOLVED tier"); + } + + @Test + void fallbackModeWhenContextHasNoResolvedAtAll() throws Exception { + String repoPath = "src/main/java/com/example/UserRepo.java"; + Path absRepo = repoRoot.resolve(repoPath); + Files.createDirectories(absRepo.getParent()); + String content = """ + package com.example; + public interface UserRepo extends JpaRepository {} + """; + Files.writeString(absRepo, content); + + // No withResolved() — Optional.empty() default. + DetectorContext ctx = ctxFor(repoPath, content); + DetectorResult result = detector.detect(ctx); + + CodeNode repo = onlyRepoNode(result); + assertNull(repo.getProperties().get("entity_fqn")); + assertNotEquals(Confidence.RESOLVED, onlyQueriesEdge(result).getConfidence()); + } + + // ── (3) Mixed mode ─────────────────────────────────────────────────────── + + @Test + void mixedModeFallsBackForUnreachableEntityType() throws Exception { + // VetRepo references Vet — no source for Vet on the project. With + // resolution, the symbol solver fails on Vet → no FQN → fallback. + String repoPath = "src/main/java/com/example/VetRepo.java"; + Path absRepo = repoRoot.resolve(repoPath); + Files.createDirectories(absRepo.getParent()); + String content = """ + package com.example; + import org.springframework.data.jpa.repository.JpaRepository; + public interface VetRepo extends JpaRepository {} + """; + Files.writeString(absRepo, content); + + Resolved resolved = bootstrapAndResolve(repoPath, content); + DetectorContext ctx = ctxFor(repoPath, content).withResolved(resolved); + DetectorResult result = detector.detect(ctx); + + CodeNode repo = onlyRepoNode(result); + assertNull(repo.getProperties().get("entity_fqn"), + "Vet has no source — solver fails — no entity_fqn"); + assertEquals("Vet", repo.getProperties().get("entity_type"), + "regex still pins the simple name"); + + CodeEdge queries = onlyQueriesEdge(result); + assertNull(queries.getProperties().get("target_fqn")); + assertNotEquals(Confidence.RESOLVED, queries.getConfidence()); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private Resolved bootstrapAndResolve(String relPath, String content) throws ResolutionException { + resolver.bootstrap(repoRoot); + DiscoveredFile file = new DiscoveredFile(Path.of(relPath), "java", content.length()); + return resolver.resolve(file, content); + } + + private DetectorContext ctxFor(String relPath, String content) { + return new DetectorContext(relPath, "java", content, null, null); + } + + private static CodeNode onlyRepoNode(DetectorResult result) { + List repos = result.nodes().stream() + .filter(n -> n.getKind() == NodeKind.REPOSITORY) + .toList(); + assertEquals(1, repos.size(), "expected exactly one REPOSITORY node, got " + repos.size()); + return repos.get(0); + } + + private static CodeEdge onlyQueriesEdge(DetectorResult result) { + List queries = result.edges().stream() + .filter(e -> e.getKind() == EdgeKind.QUERIES) + .toList(); + assertEquals(1, queries.size(), "expected exactly one QUERIES edge, got " + queries.size()); + return queries.get(0); + } +} From c5677f2f585bb4ad8c7d78957483595d024aa161 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 28 Apr 2026 00:03:48 +0000 Subject: [PATCH 05/16] feat(detector/spring-rest): emit RESOLVED MAPS_TO edges for @RequestBody DTOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 task 29 (SpringRestDetector migration). Per the plan: "Resolves @RequestBody UserDto dto and @PathVariable types. Edge: MAPS_TO from endpoint node to the resolved DTO class." When ctx.resolved() carries a JavaResolved, the detector now: 1. Uses the resolver-parsed CompilationUnit (symbol solver attached) instead of the local ThreadLocal-pool parse — Type.resolve() works inside the AST walk for parameter type resolution. 2. After emitting each ENDPOINT node + its EXPOSES edge, scans the method's parameters for @RequestBody. For each binding parameter whose type is a class/interface, attempts to resolve to a stable fully-qualified name via the symbol solver. 3. On success, emits a MAPS_TO edge: endpoint --MAPS_TO--> *: stamped with target_fqn / parameter_kind=request_body / parameter_name properties + Confidence.RESOLVED + source = "spring_rest". Target node uses NodeKind.CLASS so EntityLinker can resolve the FQN to a concrete class node post-pass. 4. On failure (primitive type, classpath gap, generic variable, etc.), no MAPS_TO edge is emitted — endpoint extraction itself is unaffected. The endpoint's `parameters` property still records the simple type name for the lexical / SYNTACTIC tier. This is purely additive: when ctx.resolved() is empty / EmptyResolved, the detector behaves identically to before the migration. The 27 existing SpringRestDetectorExtended tests pass unchanged. 4 new tests in SpringRestDetectorResolvedTest cover: - resolvedModeProducesResolvedMapsToEdge — two UserDto in different packages; imported FQN wins on edge.target_fqn + RESOLVED stamp + parameter_name property. - fallbackModeProducesNoMapsToEdge — EmptyResolved → endpoint still emitted, but no MAPS_TO (additive contract). - fallbackModeWhenContextHasNoResolvedAtAll — Optional.empty() also produces no MAPS_TO. - mixedModeFallsBackForUnreachableType — endpoint with one resolvable DTO + one unresolvable: only the resolvable one gets MAPS_TO. Plan: docs/plans/2026-04-27-sub-project-1-resolver-spi-and-java-pilot.md (task 29). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../detector/jvm/java/SpringRestDetector.java | 87 ++++++- .../java/SpringRestDetectorResolvedTest.java | 226 ++++++++++++++++++ 2 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 src/test/java/io/github/randomcodespace/iq/detector/jvm/java/SpringRestDetectorResolvedTest.java diff --git a/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/SpringRestDetector.java b/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/SpringRestDetector.java index 104b62ce..001466c2 100644 --- a/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/SpringRestDetector.java +++ b/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/SpringRestDetector.java @@ -3,11 +3,17 @@ import com.github.javaparser.ast.CompilationUnit; import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.ast.body.Parameter; import com.github.javaparser.ast.expr.*; +import com.github.javaparser.ast.type.Type; +import com.github.javaparser.resolution.types.ResolvedType; import io.github.randomcodespace.iq.detector.DetectorContext; import io.github.randomcodespace.iq.detector.DetectorResult; +import io.github.randomcodespace.iq.intelligence.resolver.Resolved; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaResolved; import io.github.randomcodespace.iq.model.CodeEdge; import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.Confidence; import io.github.randomcodespace.iq.model.EdgeKind; import io.github.randomcodespace.iq.model.NodeKind; import org.springframework.stereotype.Component; @@ -93,16 +99,25 @@ public DetectorResult detect(DetectorContext ctx) { String text = ctx.content(); if (text == null || text.isEmpty()) return DetectorResult.empty(); - Optional cu = parse(ctx); + // Prefer the resolver-parsed CU when available — it has the symbol + // solver attached, so Type.resolve() works inside the AST walk for + // @RequestBody / @PathVariable type lifting. + Optional resolved = ctx.resolved() + .filter(Resolved::isAvailable) + .filter(JavaResolved.class::isInstance) + .map(JavaResolved.class::cast); + + Optional cu = resolved.map(JavaResolved::cu).or(() -> parse(ctx)); if (cu.isPresent()) { - return detectWithAst(cu.get(), ctx); + return detectWithAst(cu.get(), ctx, resolved); } return detectWithRegex(ctx); } // ==================== AST-based detection ==================== - private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) { + private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx, + Optional resolved) { List nodes = new ArrayList<>(); List edges = new ArrayList<>(); @@ -200,6 +215,15 @@ private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) { edge.setSourceId(classNodeId); edge.setTarget(node); edges.add(edge); + + // RESOLVED tier: emit MAPS_TO edges for @RequestBody (and + // @PathVariable / @RequestParam reference types) when the + // symbol solver can pin a fully-qualified DTO. These ride + // alongside the existing endpoint metadata so the SPA + MCP + // can navigate from endpoint → DTO without a string-match + // round trip through EntityLinker. + resolved.ifPresent(jr -> + addRequestBodyMapsToEdges(method, endpointId, edges)); } } }); @@ -210,6 +234,63 @@ private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) { return DetectorResult.of(nodes, edges); } + /** + * For each parameter annotated with {@code @RequestBody} (and the few + * other reference-type binding annotations), try to resolve the parameter + * type via the symbol solver. On success, emit a {@link EdgeKind#MAPS_TO} + * edge from {@code endpointId} → {@code "*:" + simpleName} stamped with + * {@code Confidence.RESOLVED} and a {@code target_fqn} property. + * + *

Resolution failures are silent — the request body type might be a + * primitive (no edge needed), a third-party class missing from the + * classpath (genuinely unresolvable), or a generic type variable. The + * existing {@code parameters} property on the endpoint node still carries + * the simple name for the lexical / regex tier. + */ + private void addRequestBodyMapsToEdges(MethodDeclaration method, String endpointId, + List edges) { + for (Parameter param : method.getParameters()) { + boolean isBindable = param.getAnnotations().stream().anyMatch(a -> + "RequestBody".equals(a.getNameAsString())); + if (!isBindable) continue; + // Only emit MAPS_TO when the parameter type is a class/interface + // — primitive types (int, long) have no FQN and no DTO target. + Type paramType = param.getType(); + if (!paramType.isClassOrInterfaceType()) continue; + + Optional fqn = tryResolveFqn(paramType); + if (fqn.isEmpty()) continue; + + String simpleName = paramType.asClassOrInterfaceType().getNameAsString(); + Map edgeProps = new LinkedHashMap<>(); + edgeProps.put("target_fqn", fqn.get()); + edgeProps.put("parameter_kind", "request_body"); + edgeProps.put("parameter_name", param.getNameAsString()); + + CodeEdge mapsTo = new CodeEdge(); + mapsTo.setId(endpointId + "->maps_to->*:" + fqn.get()); + mapsTo.setKind(EdgeKind.MAPS_TO); + mapsTo.setSourceId(endpointId); + mapsTo.setTarget(new CodeNode("*:" + simpleName, NodeKind.CLASS, simpleName)); + mapsTo.setProperties(edgeProps); + mapsTo.setConfidence(Confidence.RESOLVED); + mapsTo.setSource(getName()); + edges.add(mapsTo); + } + } + + private static Optional tryResolveFqn(Type type) { + try { + ResolvedType rt = type.resolve(); + if (rt.isReferenceType()) { + return Optional.of(rt.asReferenceType().getQualifiedName()); + } + return Optional.of(rt.describe()); + } catch (RuntimeException e) { + return Optional.empty(); + } + } + /** * Extract path from a mapping annotation (value or path attribute, or bare string). */ diff --git a/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/SpringRestDetectorResolvedTest.java b/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/SpringRestDetectorResolvedTest.java new file mode 100644 index 00000000..936fb277 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/SpringRestDetectorResolvedTest.java @@ -0,0 +1,226 @@ +package io.github.randomcodespace.iq.detector.jvm.java; + +import io.github.randomcodespace.iq.analyzer.DiscoveredFile; +import io.github.randomcodespace.iq.detector.DetectorContext; +import io.github.randomcodespace.iq.detector.DetectorResult; +import io.github.randomcodespace.iq.intelligence.resolver.EmptyResolved; +import io.github.randomcodespace.iq.intelligence.resolver.ResolutionException; +import io.github.randomcodespace.iq.intelligence.resolver.Resolved; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaResolved; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaSourceRootDiscovery; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaSymbolResolver; +import io.github.randomcodespace.iq.model.CodeEdge; +import io.github.randomcodespace.iq.model.Confidence; +import io.github.randomcodespace.iq.model.EdgeKind; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 6 — SpringRestDetector migration to consume {@code ctx.resolved()} + * and emit RESOLVED-tier MAPS_TO edges from endpoints to their {@code + * @RequestBody} DTO classes. + * + *

Three contract tests per the plan: + *

    + *
  1. resolvedModeProducesResolvedMapsToEdge — {@code @RequestBody + * UserDto} with two {@code UserDto} classes in different packages; + * resolution picks the imported FQN and stamps the edge RESOLVED.
  2. + *
  3. fallbackModeMatchesPreSpecBaseline — EmptyResolved → no + * MAPS_TO edge from endpoint → DTO (existing pre-migration shape).
  4. + *
  5. mixedModeUsesResolverWhereAvailable — endpoint with one + * resolvable DTO and one unresolvable type: only the resolvable case + * gets a MAPS_TO edge.
  6. + *
+ */ +class SpringRestDetectorResolvedTest { + + @TempDir Path repoRoot; + + private final SpringRestDetector detector = new SpringRestDetector(); + private JavaSymbolResolver resolver; + + @BeforeEach + void setUp() throws IOException { + Files.createDirectories(repoRoot.resolve("src/main/java/com/example/a")); + Files.createDirectories(repoRoot.resolve("src/main/java/com/example/b")); + Files.writeString(repoRoot.resolve("pom.xml"), ""); + + Files.writeString(repoRoot.resolve("src/main/java/com/example/a/UserDto.java"), + """ + package com.example.a; + public class UserDto {} + """); + Files.writeString(repoRoot.resolve("src/main/java/com/example/b/UserDto.java"), + """ + package com.example.b; + public class UserDto {} + """); + + resolver = new JavaSymbolResolver(new JavaSourceRootDiscovery()); + } + + // ── (1) Resolved mode ──────────────────────────────────────────────────── + + @Test + void resolvedModeProducesResolvedMapsToEdge() throws Exception { + // Two UserDto classes in different packages; controller imports one. + // With resolution, MAPS_TO target_fqn pins the imported one. + String controllerPath = "src/main/java/com/example/UserController.java"; + Path absController = repoRoot.resolve(controllerPath); + Files.createDirectories(absController.getParent()); + String content = """ + package com.example; + import com.example.a.UserDto; + public class UserController { + public String createUser(@RequestBody UserDto dto) { + return "ok"; + } + @PostMapping("/users") + public String postUser(@RequestBody UserDto body) { + return "ok"; + } + } + """; + Files.writeString(absController, content); + + Resolved resolved = bootstrapAndResolve(controllerPath, content); + assertInstanceOf(JavaResolved.class, resolved); + + DetectorContext ctx = ctxFor(controllerPath, content).withResolved(resolved); + DetectorResult result = detector.detect(ctx); + + // Only the @PostMapping-annotated method actually creates an endpoint — + // the un-mapped createUser is filtered out. So one MAPS_TO is expected. + List mapsTo = mapsToEdges(result); + assertEquals(1, mapsTo.size(), + "exactly one @RequestBody parameter on a real endpoint → one MAPS_TO"); + + CodeEdge edge = mapsTo.get(0); + assertEquals("com.example.a.UserDto", edge.getProperties().get("target_fqn"), + "imported package wins — not the b/ DTO"); + assertEquals("request_body", edge.getProperties().get("parameter_kind")); + assertEquals("body", edge.getProperties().get("parameter_name"), + "parameter name rides as metadata for downstream consumers"); + assertEquals(Confidence.RESOLVED, edge.getConfidence()); + assertEquals(detector.getName(), edge.getSource()); + } + + // ── (2) Fallback mode ──────────────────────────────────────────────────── + + @Test + void fallbackModeProducesNoMapsToEdge() throws Exception { + // Without ctx.resolved(), the detector emits its existing endpoint + // node + EXPOSES edge, but no MAPS_TO — that's the migration's + // additive contract. Existing 27 SpringRestDetectorExtendedTest cases + // already cover endpoint extraction itself. + String controllerPath = "src/main/java/com/example/UserController.java"; + Path absController = repoRoot.resolve(controllerPath); + Files.createDirectories(absController.getParent()); + String content = """ + package com.example; + import com.example.a.UserDto; + public class UserController { + @PostMapping("/users") + public String postUser(@RequestBody UserDto body) { + return "ok"; + } + } + """; + Files.writeString(absController, content); + + DetectorContext ctx = ctxFor(controllerPath, content).withResolved(EmptyResolved.INSTANCE); + DetectorResult result = detector.detect(ctx); + + assertTrue(mapsToEdges(result).isEmpty(), + "no JavaResolved → no MAPS_TO edges (additive contract)"); + // The endpoint itself still gets emitted — sanity check. + assertFalse(result.nodes().isEmpty(), + "endpoint detection still runs in fallback mode"); + } + + @Test + void fallbackModeWhenContextHasNoResolvedAtAll() throws Exception { + String controllerPath = "src/main/java/com/example/UserController.java"; + Path absController = repoRoot.resolve(controllerPath); + Files.createDirectories(absController.getParent()); + String content = """ + package com.example; + import com.example.a.UserDto; + public class UserController { + @PostMapping("/users") + public String postUser(@RequestBody UserDto body) { + return "ok"; + } + } + """; + Files.writeString(absController, content); + + // No withResolved at all — ctx.resolved() is Optional.empty(). + DetectorContext ctx = ctxFor(controllerPath, content); + DetectorResult result = detector.detect(ctx); + assertTrue(mapsToEdges(result).isEmpty()); + } + + // ── (3) Mixed mode ─────────────────────────────────────────────────────── + + @Test + void mixedModeFallsBackForUnreachableType() throws Exception { + // Two endpoints — one body type is reachable (UserDto from + // com.example.a), the other (MysteryDto) has no source on the + // project. Resolved one gets MAPS_TO, unreachable one doesn't. + String controllerPath = "src/main/java/com/example/UserController.java"; + Path absController = repoRoot.resolve(controllerPath); + Files.createDirectories(absController.getParent()); + String content = """ + package com.example; + import com.example.a.UserDto; + public class UserController { + @PostMapping("/users") + public String createUser(@RequestBody UserDto dto) { + return "ok"; + } + @PostMapping("/mystery") + public String mystery(@RequestBody MysteryDto dto) { + return "ok"; + } + } + """; + Files.writeString(absController, content); + + Resolved resolved = bootstrapAndResolve(controllerPath, content); + DetectorContext ctx = ctxFor(controllerPath, content).withResolved(resolved); + DetectorResult result = detector.detect(ctx); + + List mapsTo = mapsToEdges(result); + assertEquals(1, mapsTo.size(), + "only the resolvable DTO produces a MAPS_TO edge"); + assertEquals("com.example.a.UserDto", mapsTo.get(0).getProperties().get("target_fqn")); + assertEquals(Confidence.RESOLVED, mapsTo.get(0).getConfidence()); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private Resolved bootstrapAndResolve(String relPath, String content) throws ResolutionException { + resolver.bootstrap(repoRoot); + DiscoveredFile file = new DiscoveredFile(Path.of(relPath), "java", content.length()); + return resolver.resolve(file, content); + } + + private DetectorContext ctxFor(String relPath, String content) { + return new DetectorContext(relPath, "java", content, null, null); + } + + private static List mapsToEdges(DetectorResult result) { + return result.edges().stream() + .filter(e -> e.getKind() == EdgeKind.MAPS_TO) + .toList(); + } +} From 2822cb9c17e558b5dd140497ad609b1d83c070ea Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 28 Apr 2026 00:08:41 +0000 Subject: [PATCH 06/16] feat(detector/class-hierarchy): consume ctx.resolved() for RESOLVED EXTENDS/IMPLEMENTS edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 task 24-style migration applied to ClassHierarchyDetector. Class hierarchy is high-leverage for resolution: simple-name superclass references like "extends Service" are routine across unrelated codebases, and EXTENDS / IMPLEMENTS edges are downstream-load-bearing for blast-radius / dead-code / cycle / topology analysis. Pinning the target FQN turns "Service-named-something" into a stable cross-file reference. When ctx.resolved() carries a JavaResolved, the detector now: 1. Uses the resolver-parsed CompilationUnit (symbol solver attached). 2. For each parent type in extendedTypes / implementedTypes, calls a single new helper addHierarchyEdge() that: - tries to resolve the type via the symbol solver - on success, attaches target_fqn to edge properties + stamps Confidence.RESOLVED + source = "java.class_hierarchy" - on failure (and always when ctx.resolved() is empty), emits the existing simple-name placeholder edge with raw default confidence (orchestrator stamps SYNTACTIC at the boundary). 3. The 4 prior in-line edge-emission blocks (class-extends, interface-extends, class-implements, enum-implements) collapse to two-line iterations through addHierarchyEdge — net is fewer LOC plus the new resolution capability. Existing 30 ClassHierarchyDetectorExtended tests pass unchanged — node emission, regex fallback, the property shapes, and the simple-name edge IDs / target placeholders are all preserved. 5 new tests in ClassHierarchyDetectorResolvedTest: - resolvedModeStampsResolvedTierOnExtendsEdge — two BaseService in different packages; imported one wins on edge.target_fqn. - resolvedModeStampsResolvedTierOnImplementsEdge — same shape for interface implements. - fallbackModeMatchesPreSpecBaseline — EmptyResolved → no FQN, no RESOLVED stamp. - fallbackModeWhenContextHasNoResolvedAtAll — Optional.empty() also safe. - mixedModeFallsBackForUnreachableType — class extends a known type, implements an unknown one: EXTENDS is RESOLVED, IMPLEMENTS falls back gracefully. This brings the migrated-detector count to 4 (JpaEntityDetector, RepositoryDetector, SpringRestDetector, ClassHierarchyDetector) — at the lower bound of the plan's "4-6 Java detectors migrated as proof of value". Plan: docs/plans/2026-04-27-sub-project-1-resolver-spi-and-java-pilot.md (spirit of tasks 24-29 — using the actual detectors that exist in this repo, not the plan's hypothetical names). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../jvm/java/ClassHierarchyDetector.java | 108 ++++++--- .../ClassHierarchyDetectorResolvedTest.java | 223 ++++++++++++++++++ 2 files changed, 294 insertions(+), 37 deletions(-) create mode 100644 src/test/java/io/github/randomcodespace/iq/detector/jvm/java/ClassHierarchyDetectorResolvedTest.java diff --git a/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/ClassHierarchyDetector.java b/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/ClassHierarchyDetector.java index 8af2eed1..a12147b8 100644 --- a/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/ClassHierarchyDetector.java +++ b/src/main/java/io/github/randomcodespace/iq/detector/jvm/java/ClassHierarchyDetector.java @@ -5,10 +5,14 @@ import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; import com.github.javaparser.ast.body.EnumDeclaration; import com.github.javaparser.ast.type.ClassOrInterfaceType; +import com.github.javaparser.resolution.types.ResolvedType; import io.github.randomcodespace.iq.detector.DetectorContext; import io.github.randomcodespace.iq.detector.DetectorResult; +import io.github.randomcodespace.iq.intelligence.resolver.Resolved; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaResolved; import io.github.randomcodespace.iq.model.CodeEdge; import io.github.randomcodespace.iq.model.CodeNode; +import io.github.randomcodespace.iq.model.Confidence; import io.github.randomcodespace.iq.model.EdgeKind; import io.github.randomcodespace.iq.model.NodeKind; import org.springframework.stereotype.Component; @@ -71,16 +75,28 @@ public DetectorResult detect(DetectorContext ctx) { String text = ctx.content(); if (text == null || text.isEmpty()) return DetectorResult.empty(); - Optional cu = parse(ctx); + // Prefer the resolver-parsed CU when ctx.resolved() carries a + // JavaResolved — class hierarchy benefits a lot from FQN resolution + // because superclass / interface refs are routinely simple-named in + // source ("extends Service" not "extends com.example.Service") and + // EXTENDS/IMPLEMENTS edges are downstream-load-bearing. + Optional resolved = ctx.resolved() + .filter(Resolved::isAvailable) + .filter(JavaResolved.class::isInstance) + .map(JavaResolved.class::cast); + + Optional cu = resolved.map(JavaResolved::cu).or(() -> parse(ctx)); if (cu.isPresent()) { - return detectWithAst(cu.get(), ctx); + return detectWithAst(cu.get(), ctx, resolved); } return detectWithRegex(ctx); } // ==================== AST-based detection ==================== - private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) { + private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx, + Optional resolved) { + boolean canResolve = resolved.isPresent(); List nodes = new ArrayList<>(); List edges = new ArrayList<>(); @@ -144,36 +160,17 @@ private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) { node.setProperties(props); nodes.add(node); - // EXTENDS edges - if (!isInterface) { - for (String superclass : extendedTypes) { - CodeEdge edge = new CodeEdge(); - edge.setId(nodeId + "->extends->*:" + superclass); - edge.setKind(EdgeKind.EXTENDS); - edge.setSourceId(nodeId); - edge.setTarget(new CodeNode("*:" + superclass, NodeKind.CLASS, superclass)); - edges.add(edge); - } - } else { - // Interfaces extend other interfaces - for (String ext : extendedTypes) { - CodeEdge edge = new CodeEdge(); - edge.setId(nodeId + "->extends->*:" + ext); - edge.setKind(EdgeKind.EXTENDS); - edge.setSourceId(nodeId); - edge.setTarget(new CodeNode("*:" + ext, NodeKind.INTERFACE, ext)); - edges.add(edge); - } + // EXTENDS edges — iterate the typed AST nodes (not the simple-name + // strings) so we can attempt FQN resolution per-type when ctx + // carries a JavaResolved. + NodeKind extendsTargetKind = isInterface ? NodeKind.INTERFACE : NodeKind.CLASS; + for (ClassOrInterfaceType ext : decl.getExtendedTypes()) { + addHierarchyEdge(nodeId, ext, EdgeKind.EXTENDS, extendsTargetKind, canResolve, edges); } // IMPLEMENTS edges - for (String iface : implementedTypes) { - CodeEdge edge = new CodeEdge(); - edge.setId(nodeId + "->implements->*:" + iface); - edge.setKind(EdgeKind.IMPLEMENTS); - edge.setSourceId(nodeId); - edge.setTarget(new CodeNode("*:" + iface, NodeKind.INTERFACE, iface)); - edges.add(edge); + for (ClassOrInterfaceType impl : decl.getImplementedTypes()) { + addHierarchyEdge(nodeId, impl, EdgeKind.IMPLEMENTS, NodeKind.INTERFACE, canResolve, edges); } }); @@ -212,13 +209,8 @@ private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) { node.setProperties(props); nodes.add(node); - for (String iface : interfaces) { - CodeEdge edge = new CodeEdge(); - edge.setId(nodeId + "->implements->*:" + iface); - edge.setKind(EdgeKind.IMPLEMENTS); - edge.setSourceId(nodeId); - edge.setTarget(new CodeNode("*:" + iface, NodeKind.INTERFACE, iface)); - edges.add(edge); + for (ClassOrInterfaceType impl : decl.getImplementedTypes()) { + addHierarchyEdge(nodeId, impl, EdgeKind.IMPLEMENTS, NodeKind.INTERFACE, canResolve, edges); } }); @@ -435,4 +427,46 @@ private List parseTypeList(String typeList) { } return result; } + + /** + * Emit an EXTENDS or IMPLEMENTS edge for a single type reference. When + * {@code canResolve} is true the helper attempts FQN resolution via the + * symbol solver and, on success, attaches {@code target_fqn} + + * {@link Confidence#RESOLVED} + source. The simple-name placeholder + * target is unchanged so EntityLinker / ClassHierarchyLinker post-passes + * are unaffected on the surface — they can opt to use {@code target_fqn} + * when present. + */ + private void addHierarchyEdge(String sourceId, ClassOrInterfaceType target, + EdgeKind edgeKind, NodeKind targetKind, + boolean canResolve, List edges) { + String simpleName = target.getNameAsString(); + Optional fqn = canResolve ? tryResolveFqn(target) : Optional.empty(); + + CodeEdge edge = new CodeEdge(); + edge.setId(sourceId + "->" + edgeKind.getValue() + "->*:" + simpleName); + edge.setKind(edgeKind); + edge.setSourceId(sourceId); + edge.setTarget(new CodeNode("*:" + simpleName, targetKind, simpleName)); + if (fqn.isPresent()) { + Map props = new LinkedHashMap<>(); + props.put("target_fqn", fqn.get()); + edge.setProperties(props); + edge.setConfidence(Confidence.RESOLVED); + edge.setSource(getName()); + } + edges.add(edge); + } + + private static Optional tryResolveFqn(ClassOrInterfaceType type) { + try { + ResolvedType rt = type.resolve(); + if (rt.isReferenceType()) { + return Optional.of(rt.asReferenceType().getQualifiedName()); + } + return Optional.of(rt.describe()); + } catch (RuntimeException e) { + return Optional.empty(); + } + } } diff --git a/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/ClassHierarchyDetectorResolvedTest.java b/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/ClassHierarchyDetectorResolvedTest.java new file mode 100644 index 00000000..5f898638 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/detector/jvm/java/ClassHierarchyDetectorResolvedTest.java @@ -0,0 +1,223 @@ +package io.github.randomcodespace.iq.detector.jvm.java; + +import io.github.randomcodespace.iq.analyzer.DiscoveredFile; +import io.github.randomcodespace.iq.detector.DetectorContext; +import io.github.randomcodespace.iq.detector.DetectorResult; +import io.github.randomcodespace.iq.intelligence.resolver.EmptyResolved; +import io.github.randomcodespace.iq.intelligence.resolver.ResolutionException; +import io.github.randomcodespace.iq.intelligence.resolver.Resolved; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaResolved; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaSourceRootDiscovery; +import io.github.randomcodespace.iq.intelligence.resolver.java.JavaSymbolResolver; +import io.github.randomcodespace.iq.model.CodeEdge; +import io.github.randomcodespace.iq.model.Confidence; +import io.github.randomcodespace.iq.model.EdgeKind; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 6 — ClassHierarchyDetector migration to consume {@code ctx.resolved()} + * and stamp EXTENDS / IMPLEMENTS edges as RESOLVED with stable FQN targets + * when the symbol solver can pin them. + * + *

Class hierarchy resolution is high-leverage: the simple name "Service" + * appears in dozens of unrelated codebases at once and EXTENDS / IMPLEMENTS + * edges are downstream-load-bearing for blast-radius / dead-code / cycle + * analysis. Pinning the FQN turns the edge from "Service-named-something" + * into "this exact superclass". + * + *

Three contract tests: + *

    + *
  1. resolvedModeStampsResolvedTierOnExtendsEdge — two + * {@code BaseService} classes in different packages; resolution picks + * the imported one for the EXTENDS edge.
  2. + *
  3. fallbackModeMatchesPreSpecBaseline — EmptyResolved → simple- + * name target, no target_fqn, no RESOLVED stamp.
  4. + *
  5. mixedModeUsesResolverWhereAvailable — a class that extends a + * resolvable type and implements an unresolvable one: EXTENDS is + * RESOLVED, IMPLEMENTS falls back.
  6. + *
+ */ +class ClassHierarchyDetectorResolvedTest { + + @TempDir Path repoRoot; + + private final ClassHierarchyDetector detector = new ClassHierarchyDetector(); + private JavaSymbolResolver resolver; + + @BeforeEach + void setUp() throws IOException { + Files.createDirectories(repoRoot.resolve("src/main/java/com/example/a")); + Files.createDirectories(repoRoot.resolve("src/main/java/com/example/b")); + Files.writeString(repoRoot.resolve("pom.xml"), ""); + + Files.writeString(repoRoot.resolve("src/main/java/com/example/a/BaseService.java"), + """ + package com.example.a; + public class BaseService {} + """); + Files.writeString(repoRoot.resolve("src/main/java/com/example/b/BaseService.java"), + """ + package com.example.b; + public class BaseService {} + """); + Files.writeString(repoRoot.resolve("src/main/java/com/example/a/Auditable.java"), + """ + package com.example.a; + public interface Auditable {} + """); + + resolver = new JavaSymbolResolver(new JavaSourceRootDiscovery()); + } + + // ── (1) Resolved mode ──────────────────────────────────────────────────── + + @Test + void resolvedModeStampsResolvedTierOnExtendsEdge() throws Exception { + // Pet extends BaseService — two BaseService classes in different + // packages, only the imported one wins. + String petPath = "src/main/java/com/example/PetService.java"; + Path absPet = repoRoot.resolve(petPath); + Files.createDirectories(absPet.getParent()); + String content = """ + package com.example; + import com.example.a.BaseService; + public class PetService extends BaseService {} + """; + Files.writeString(absPet, content); + + Resolved resolved = bootstrapAndResolve(petPath, content); + assertInstanceOf(JavaResolved.class, resolved); + + DetectorContext ctx = ctxFor(petPath, content).withResolved(resolved); + DetectorResult result = detector.detect(ctx); + + CodeEdge extendsEdge = onlyEdge(result, EdgeKind.EXTENDS); + assertEquals("com.example.a.BaseService", extendsEdge.getProperties().get("target_fqn")); + assertEquals(Confidence.RESOLVED, extendsEdge.getConfidence()); + assertEquals(detector.getName(), extendsEdge.getSource()); + } + + @Test + void resolvedModeStampsResolvedTierOnImplementsEdge() throws Exception { + String petPath = "src/main/java/com/example/PetService.java"; + Path absPet = repoRoot.resolve(petPath); + Files.createDirectories(absPet.getParent()); + String content = """ + package com.example; + import com.example.a.Auditable; + public class PetService implements Auditable {} + """; + Files.writeString(absPet, content); + + Resolved resolved = bootstrapAndResolve(petPath, content); + DetectorContext ctx = ctxFor(petPath, content).withResolved(resolved); + DetectorResult result = detector.detect(ctx); + + CodeEdge implementsEdge = onlyEdge(result, EdgeKind.IMPLEMENTS); + assertEquals("com.example.a.Auditable", implementsEdge.getProperties().get("target_fqn")); + assertEquals(Confidence.RESOLVED, implementsEdge.getConfidence()); + } + + // ── (2) Fallback mode ──────────────────────────────────────────────────── + + @Test + void fallbackModeMatchesPreSpecBaseline() throws Exception { + String petPath = "src/main/java/com/example/PetService.java"; + Path absPet = repoRoot.resolve(petPath); + Files.createDirectories(absPet.getParent()); + String content = """ + package com.example; + import com.example.a.BaseService; + public class PetService extends BaseService {} + """; + Files.writeString(absPet, content); + + DetectorContext ctx = ctxFor(petPath, content).withResolved(EmptyResolved.INSTANCE); + DetectorResult result = detector.detect(ctx); + + CodeEdge extendsEdge = onlyEdge(result, EdgeKind.EXTENDS); + assertNull(extendsEdge.getProperties().get("target_fqn"), + "EmptyResolved → no FQN attempt, no target_fqn"); + assertNotEquals(Confidence.RESOLVED, extendsEdge.getConfidence()); + assertNull(extendsEdge.getSource()); + } + + @Test + void fallbackModeWhenContextHasNoResolvedAtAll() throws Exception { + String petPath = "src/main/java/com/example/PetService.java"; + Path absPet = repoRoot.resolve(petPath); + Files.createDirectories(absPet.getParent()); + String content = """ + package com.example; + public class PetService extends BaseService {} + """; + Files.writeString(absPet, content); + + DetectorContext ctx = ctxFor(petPath, content); + DetectorResult result = detector.detect(ctx); + + CodeEdge extendsEdge = onlyEdge(result, EdgeKind.EXTENDS); + assertNull(extendsEdge.getProperties().get("target_fqn")); + assertNotEquals(Confidence.RESOLVED, extendsEdge.getConfidence()); + } + + // ── (3) Mixed mode ─────────────────────────────────────────────────────── + + @Test + void mixedModeFallsBackForUnreachableType() throws Exception { + // Class extends a known type and implements an unknown one. + // Expect: EXTENDS edge gets RESOLVED, IMPLEMENTS edge falls back. + String petPath = "src/main/java/com/example/PetService.java"; + Path absPet = repoRoot.resolve(petPath); + Files.createDirectories(absPet.getParent()); + String content = """ + package com.example; + import com.example.a.BaseService; + public class PetService extends BaseService implements MysteryAware {} + """; + Files.writeString(absPet, content); + + Resolved resolved = bootstrapAndResolve(petPath, content); + DetectorContext ctx = ctxFor(petPath, content).withResolved(resolved); + DetectorResult result = detector.detect(ctx); + + CodeEdge extendsEdge = onlyEdge(result, EdgeKind.EXTENDS); + assertEquals("com.example.a.BaseService", extendsEdge.getProperties().get("target_fqn")); + assertEquals(Confidence.RESOLVED, extendsEdge.getConfidence()); + + CodeEdge implementsEdge = onlyEdge(result, EdgeKind.IMPLEMENTS); + assertNull(implementsEdge.getProperties().get("target_fqn"), + "MysteryAware has no source — solver fails — fallback"); + assertNotEquals(Confidence.RESOLVED, implementsEdge.getConfidence()); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private Resolved bootstrapAndResolve(String relPath, String content) throws ResolutionException { + resolver.bootstrap(repoRoot); + DiscoveredFile file = new DiscoveredFile(Path.of(relPath), "java", content.length()); + return resolver.resolve(file, content); + } + + private DetectorContext ctxFor(String relPath, String content) { + return new DetectorContext(relPath, "java", content, null, null); + } + + private static CodeEdge onlyEdge(DetectorResult result, EdgeKind kind) { + List matching = result.edges().stream() + .filter(e -> e.getKind() == kind) + .toList(); + assertEquals(1, matching.size(), + "expected exactly one " + kind + " edge, got " + matching.size()); + return matching.get(0); + } +} From a82b4dbc7ce2128e1c501b92dd5581188ea0ab09 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 28 Apr 2026 00:09:42 +0000 Subject: [PATCH 07/16] docs(changelog): document resolver pipeline wiring + 4 Java detector migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the [Unreleased] entry with the Phase 4 + 6 follow-up work shipped on this branch — the resolver is now wired end-to-end into Analyzer and four Java detectors consume ctx.resolved() to emit RESOLVED-tier edges with stable target FQNs: - JpaEntityDetector — @OneToMany / @ManyToOne MAPS_TO targets - RepositoryDetector — JpaRepository entity FQN - SpringRestDetector — @RequestBody DTO MAPS_TO edges - ClassHierarchyDetector — EXTENDS / IMPLEMENTS FQN targets Also covers the JavaSymbolResolver lazy-parse extension that lets the orchestrator pass raw source content for Java (the structured parser doesn't cover Java, so without this the resolver could never receive a CompilationUnit). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 47 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3922fe4c..80d2695d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,8 +82,51 @@ for that specific tag for the per-commit details. (detectors that explicitly stamp survive untouched). 11 atomic commits ship with ~290 new tests covering happy paths, legacy-data fallbacks, malformed inputs, determinism, concurrency-safe construction, and singleton - invariants. Detector migrations to consume `ctx.resolved()` and the - resolver-bootstrap-into-Analyzer hook follow in sub-project 1 Phase 5. + invariants. + +- **Resolver pipeline wiring + Java pilot detectors** (sub-project 1, plan + Phases 4 + 6 — follow-up to the SPI scaffolding above): the resolver + is now actually invoked end-to-end and four Java detectors consume + `ctx.resolved()` to emit RESOLVED-tier edges with stable + fully-qualified-name targets. + - `Analyzer` now bootstraps `ResolverRegistry` exactly once per pipeline + entry point (`run` / `runBatchedIndex` / `runSmartIndex`) and threads a + `Resolved` onto every `DetectorContext` at all three detect call sites + (`analyzeFile`, the batched-index variant, the regex-only fallback). + Per-file `ResolutionException` + `RuntimeException` are swallowed and + fall back to `EmptyResolved.INSTANCE`, so one resolver blow-up cannot + take down the whole pass. + - `JavaSymbolResolver.resolve()` now lazy-parses raw source `String` + content with a fresh symbol-solver-configured `JavaParser` per call — + a small per-call allocation that lets `Analyzer` pass the file content + directly (the orchestrator-level structured parser doesn't cover Java). + Permissive parsing returns `JavaResolved` with a possibly-error-laden + `CompilationUnit` rather than refusing — production analysis must keep + going across files with syntax errors. + - Four detectors migrated to consume `ctx.resolved()` (purely additive — + every existing detector test passes unchanged): + - **JpaEntityDetector** — `MAPS_TO` edges between entities now carry + `target_fqn` and `Confidence.RESOLVED` when the symbol solver can + pin the relationship target's FQN (handles `@OneToMany List`, + `@ManyToOne Owner`, both direct-field and generic-arg cases). + - **RepositoryDetector** — Spring Data repo `QUERIES` edges plus the + repo node carry the resolved entity FQN (`entity_fqn` / + `target_fqn`) when `JpaRepository` resolves. + - **SpringRestDetector** — endpoints emit a `MAPS_TO` edge to the + `@RequestBody` DTO class when the parameter type resolves, with + `parameter_kind=request_body` + `parameter_name` properties for + downstream consumers (SPA, MCP). + - **ClassHierarchyDetector** — `EXTENDS` / `IMPLEMENTS` edges across + classes, interfaces, and enums now stamp `Confidence.RESOLVED` + + `target_fqn` when the parent type resolves, collapsing four + duplicated in-line edge-emission blocks into a single + `addHierarchyEdge` helper as a side-benefit. + - Backward compatibility is total: when no resolver is registered or + `JavaSymbolResolver.bootstrap` fails, every detector returns the + same simple-name-targeted edge shape it shipped before this slice. + - 18 new wiring + resolved-mode tests on top of the SPI's ~290 — every + migration ships with the plan-required three-mode coverage (resolved, + fallback, mixed). - **AKS read-only deploy hardening** (sub-project 2): runbook at [`shared/runbooks/aks-read-only-deploy.md`](shared/runbooks/aks-read-only-deploy.md), JVM-flag-preset launcher at [`scripts/aks-launch.sh`](scripts/aks-launch.sh), From 012db33c52b29130b30cb60727dbfefccda337f9 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 28 Apr 2026 01:10:10 +0000 Subject: [PATCH 08/16] =?UTF-8?q?test(resolver/java):=20add=20Layer=203=20?= =?UTF-8?q?+=20Layer=206=20=E2=80=94=20concurrency=20stress=20+=20determin?= =?UTF-8?q?ism?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 7 of the resolver-and-Java-pilot plan, top two highest-leverage aggressive-testing layers. Layer 6 — JavaSymbolResolverDeterminismTest (4 tests): - sameInputResolvesToSameFqnEveryTime — single resolver, 25 iterations over the same source must produce the same resolved FQN ("com.example.a.Owner"). Pins the value-stable contract under repeated calls (different identity, same value). - twoResolverInstancesOverSameProjectAgree — two independent resolver instances bootstrapped against the same root must produce the same FQN for the same source — establishes that bootstrap is value-stable across instances, not just within one. - rebootstrapStillProducesSameFqn — resolve, rebootstrap, resolve again; FQN is unchanged. The orchestrator calls bootstrap once, but if the resolver were ever refreshed mid-run, the value contract must still hold. - deeperFqnsAreAlsoStable — same shape on a 3-segment package (com.example.inner.deep.Marker) so a divergence on a deeper lookup can't hide behind a 1-level passing test. Layer 3 — JavaSymbolResolverConcurrencyTest (3 tests): - parallelResolveNeverThrowsAndAlwaysAgrees — 256 virtual threads each resolve the same source; the aggregated FQN set must be of size 1. Catches "thread X's CU bleeds into thread Y" / shared-mutable-state classes of races. Runs cleanly on the per-call-fresh-JavaParser contract. - parallelResolveAcrossDistinctFilesProducesPerFileResults — 200 distinct Consumer files each resolved on a virtual thread; aggregate FQN set = {com.example.api.Target}. Catches "one thread's resolved state survives into another thread's resolution" classes of bugs. - parallelResolveOnGarbageInputDoesNotThrow — 256 virtual threads each pass garbage strings; no exceptions escape and no thread returns null. The resolver's "no throw, no null" contract holds under concurrency. Plan: docs/plans/2026-04-27-sub-project-1-resolver-spi-and-java-pilot.md (tasks 30 + 31). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../JavaSymbolResolverConcurrencyTest.java | 196 ++++++++++++++++++ .../JavaSymbolResolverDeterminismTest.java | 187 +++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverConcurrencyTest.java create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverDeterminismTest.java diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverConcurrencyTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverConcurrencyTest.java new file mode 100644 index 00000000..d3d62791 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverConcurrencyTest.java @@ -0,0 +1,196 @@ +package io.github.randomcodespace.iq.intelligence.resolver.java; + +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.FieldDeclaration; +import com.github.javaparser.ast.type.ClassOrInterfaceType; +import com.github.javaparser.ast.type.Type; +import com.github.javaparser.resolution.types.ResolvedType; +import io.github.randomcodespace.iq.analyzer.DiscoveredFile; +import io.github.randomcodespace.iq.intelligence.resolver.ResolutionException; +import io.github.randomcodespace.iq.intelligence.resolver.Resolved; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 7 Layer 3 — virtual-thread concurrency stress for the resolver. + * + *

Production analysis fans every {@code Analyzer.run()} file across virtual + * threads — every {@link JavaSymbolResolver#resolve} call therefore happens + * on a different carrier with no synchronization. This test fires a lot of + * concurrent {@code resolve()} calls against a bootstrapped resolver and + * asserts: + *

    + *
  • no exceptions escape (the virtual-thread fan-out is exception-clean),
  • + *
  • every concurrent call produces the same resolved FQN for the same + * source — concurrency does not corrupt resolution,
  • + *
  • per-call {@code JavaParser} allocation (not a shared instance) is + * safe — JavaParser instances aren't thread-safe and the resolver's + * contract is "fresh JavaParser per call".
  • + *
+ * + *

Total time bound: kept loose ({@code timeout 60s}) — the goal is to + * catch races / deadlocks, not benchmark throughput. + */ +class JavaSymbolResolverConcurrencyTest { + + private static final int N_FILES = 200; // distinct files + private static final int CONCURRENT_CALLS = 256; // virtual threads + + @TempDir Path repoRoot; + + private JavaSymbolResolver resolver; + + @BeforeEach + void setUp() throws IOException, ResolutionException { + // Single-source-root layout with a target type the per-file content + // imports + uses, plus N_FILES different "consumer" files that each + // resolve the same target. + Files.createDirectories(repoRoot.resolve("src/main/java/com/example/api")); + Files.writeString(repoRoot.resolve("pom.xml"), ""); + Files.writeString(repoRoot.resolve("src/main/java/com/example/api/Target.java"), + """ + package com.example.api; + public class Target {} + """); + + Path pkg = repoRoot.resolve("src/main/java/com/example/consumers"); + Files.createDirectories(pkg); + for (int i = 0; i < N_FILES; i++) { + Files.writeString(pkg.resolve("Consumer" + i + ".java"), + "package com.example.consumers;\n" + + "import com.example.api.Target;\n" + + "public class Consumer" + i + " {\n" + + " private Target t;\n" + + "}\n"); + } + + resolver = new JavaSymbolResolver(new JavaSourceRootDiscovery()); + resolver.bootstrap(repoRoot); + } + + @Test + void parallelResolveNeverThrowsAndAlwaysAgrees() throws Exception { + // Same content resolved CONCURRENT_CALLS times across virtual threads. + // Race signal: any divergence in resolved FQN means the resolver isn't + // safe under concurrent fan-out. + String relPath = "src/main/java/com/example/consumers/Consumer0.java"; + String content = Files.readString(repoRoot.resolve(relPath)); + DiscoveredFile file = new DiscoveredFile(Path.of(relPath), "java", content.length()); + + Set fqns = ConcurrentHashMap.newKeySet(); + try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) { + List> futures = IntStream.range(0, CONCURRENT_CALLS) + .mapToObj(i -> exec.submit(() -> { + Resolved r = resolver.resolve(file, content); + String fqn = targetFieldFqn((JavaResolved) r); + fqns.add(fqn); + return fqn; + })) + .toList(); + + // Drain — assertAll will surface any task exception explicitly. + for (Future f : futures) { + f.get(60, TimeUnit.SECONDS); + } + } + + assertEquals(1, fqns.size(), + "all concurrent resolutions must agree on the FQN — got " + fqns); + assertEquals("com.example.api.Target", fqns.iterator().next()); + } + + @Test + void parallelResolveAcrossDistinctFilesProducesPerFileResults() throws Exception { + // Each virtual thread resolves a distinct file. Aggregated set of FQNs + // must still be {Target}: every consumer's field resolves to the same + // target type. Catches "thread X's resolver state leaked into thread Y" + // class of bugs where one thread's CU bleeds into another's resolution. + try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) { + List> futures = IntStream.range(0, N_FILES) + .mapToObj(i -> { + String relPath = "src/main/java/com/example/consumers/Consumer" + i + ".java"; + String content; + try { + content = Files.readString(repoRoot.resolve(relPath)); + } catch (IOException e) { + throw new RuntimeException(e); + } + DiscoveredFile file = new DiscoveredFile(Path.of(relPath), "java", content.length()); + return exec.submit(() -> + targetFieldFqn((JavaResolved) resolver.resolve(file, content))); + }) + .toList(); + + Set distinct = futures.stream() + .map(f -> { + try { + return f.get(60, TimeUnit.SECONDS); + } catch (Exception e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.toSet()); + + assertEquals(Set.of("com.example.api.Target"), distinct, + "every Consumer's field resolves to the single Target FQN — concurrent runs agree"); + } + } + + @Test + void parallelResolveOnGarbageInputDoesNotThrow() throws Exception { + // The contract is "no exceptions, no nulls" even for unparseable + // input. JavaParser is permissive and may produce a CU; our resolver + // returns either JavaResolved (with errors attached) or + // EmptyResolved.INSTANCE. Both are valid; the test asserts no + // RuntimeException leaks from the executor. + DiscoveredFile file = new DiscoveredFile(Path.of("Bad.java"), "java", 50); + + try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) { + List> futures = IntStream.range(0, CONCURRENT_CALLS) + .mapToObj(i -> exec.submit(() -> resolver.resolve(file, "@@@@@ garbage input " + i))) + .toList(); + + for (Future f : futures) { + Resolved r = f.get(60, TimeUnit.SECONDS); + assertNotNull(r, "resolver must never return null even under garbage input"); + } + } + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + /** Resolve the Consumer's "t" field's declared-type FQN via the carried solver. */ + private static String targetFieldFqn(JavaResolved r) { + CompilationUnit cu = r.cu(); + ClassOrInterfaceDeclaration cls = cu.findFirst(ClassOrInterfaceDeclaration.class) + .orElseThrow(); + FieldDeclaration field = cls.getFields().stream().findFirst().orElseThrow(); + Type fieldType = field.getVariable(0).getType(); + ResolvedType rt = fieldType.asClassOrInterfaceType().resolve(); + return rt.isReferenceType() + ? rt.asReferenceType().getQualifiedName() + : rt.describe(); + } + + @SuppressWarnings("unused") // Reserved for future test additions that need raw type access. + private static ClassOrInterfaceType firstClassOrInterfaceType(CompilationUnit cu) { + return cu.findFirst(ClassOrInterfaceType.class).orElseThrow(); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverDeterminismTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverDeterminismTest.java new file mode 100644 index 00000000..463cb7f1 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverDeterminismTest.java @@ -0,0 +1,187 @@ +package io.github.randomcodespace.iq.intelligence.resolver.java; + +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.type.ClassOrInterfaceType; +import com.github.javaparser.resolution.types.ResolvedType; +import io.github.randomcodespace.iq.analyzer.DiscoveredFile; +import io.github.randomcodespace.iq.intelligence.resolver.ResolutionException; +import io.github.randomcodespace.iq.intelligence.resolver.Resolved; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 7 Layer 6 — determinism gate for the symbol resolver. + * + *

The graph-build determinism contract (same input → byte-identical graph, + * every run) extends to the resolver: same project root + same source content + * must produce the same {@link Resolved} shape, and the same field/type + * reference must resolve to the same FQN every time. + * + *

Tested invariants: + *

    + *
  1. Same source string resolved N times → identical resolved FQN.
  2. + *
  3. Two independent resolver instances over the same project root → + * identical resolved FQN for the same source.
  4. + *
  5. Re-bootstrap on the same root → identical resolution behaviour + * (the registry-side determinism guarantee, but checked at the resolver + * boundary too).
  6. + *
+ */ +class JavaSymbolResolverDeterminismTest { + + @TempDir Path repoRoot; + + private static final String PET_PATH = "src/main/java/com/example/Pet.java"; + private static final String PET_SOURCE = """ + package com.example; + import com.example.a.Owner; + public class Pet { + private Owner owner; + } + """; + + @BeforeEach + void setUp() throws IOException { + Files.createDirectories(repoRoot.resolve("src/main/java/com/example/a")); + Files.writeString(repoRoot.resolve("pom.xml"), ""); + Files.writeString(repoRoot.resolve("src/main/java/com/example/a/Owner.java"), + """ + package com.example.a; + public class Owner {} + """); + Path absPet = repoRoot.resolve(PET_PATH); + Files.createDirectories(absPet.getParent()); + Files.writeString(absPet, PET_SOURCE); + } + + @Test + void sameInputResolvesToSameFqnEveryTime() throws ResolutionException { + JavaSymbolResolver resolver = new JavaSymbolResolver(new JavaSourceRootDiscovery()); + resolver.bootstrap(repoRoot); + DiscoveredFile file = new DiscoveredFile(Path.of(PET_PATH), "java", PET_SOURCE.length()); + + // Resolve 25 times — every call must produce the same FQN. JavaParser's + // identity-not-value semantics means the JavaResolved instances differ, + // but the resolved type's FQN must be stable. + List fqns = IntStream.range(0, 25) + .mapToObj(i -> { + Resolved r = resolver.resolve(file, PET_SOURCE); + return ownerFieldFqn((JavaResolved) r); + }) + .toList(); + + // All elements are the same FQN. + String first = fqns.get(0); + assertEquals("com.example.a.Owner", first, + "first resolution must pin the imported Owner FQN"); + for (int i = 1; i < fqns.size(); i++) { + assertEquals(first, fqns.get(i), + "resolution #" + i + " diverged — determinism gate broken"); + } + } + + @Test + void twoResolverInstancesOverSameProjectAgree() throws ResolutionException { + JavaSymbolResolver a = new JavaSymbolResolver(new JavaSourceRootDiscovery()); + JavaSymbolResolver b = new JavaSymbolResolver(new JavaSourceRootDiscovery()); + a.bootstrap(repoRoot); + b.bootstrap(repoRoot); + + DiscoveredFile file = new DiscoveredFile(Path.of(PET_PATH), "java", PET_SOURCE.length()); + + String fqnA = ownerFieldFqn((JavaResolved) a.resolve(file, PET_SOURCE)); + String fqnB = ownerFieldFqn((JavaResolved) b.resolve(file, PET_SOURCE)); + + assertEquals("com.example.a.Owner", fqnA); + assertEquals(fqnA, fqnB, "two independent resolver instances must agree on the FQN"); + } + + @Test + void rebootstrapStillProducesSameFqn() throws ResolutionException { + // The contract: rebootstrap is allowed (idempotent in observable + // behaviour). After a second bootstrap on the same root, the resolver + // resolves the same input the same way. + JavaSymbolResolver resolver = new JavaSymbolResolver(new JavaSourceRootDiscovery()); + + resolver.bootstrap(repoRoot); + DiscoveredFile file = new DiscoveredFile(Path.of(PET_PATH), "java", PET_SOURCE.length()); + String first = ownerFieldFqn((JavaResolved) resolver.resolve(file, PET_SOURCE)); + + resolver.bootstrap(repoRoot); // second bootstrap on same root + String second = ownerFieldFqn((JavaResolved) resolver.resolve(file, PET_SOURCE)); + + assertEquals("com.example.a.Owner", first); + assertEquals(first, second, "rebootstrap must not change resolution behaviour"); + } + + @Test + void deeperFqnsAreAlsoStable() throws Exception { + // Add a slightly deeper hierarchy to widen the determinism check — + // the test is small enough that a divergence on a 1-level lookup + // could hide one on a 2-level one. + Files.createDirectories(repoRoot.resolve("src/main/java/com/example/inner/deep")); + Files.writeString(repoRoot.resolve("src/main/java/com/example/inner/deep/Marker.java"), + """ + package com.example.inner.deep; + public class Marker {} + """); + String depPath = "src/main/java/com/example/Dep.java"; + String depSource = """ + package com.example; + import com.example.inner.deep.Marker; + public class Dep { + private Marker marker; + } + """; + Files.writeString(repoRoot.resolve(depPath), depSource); + + JavaSymbolResolver resolver = new JavaSymbolResolver(new JavaSourceRootDiscovery()); + resolver.bootstrap(repoRoot); + DiscoveredFile file = new DiscoveredFile(Path.of(depPath), "java", depSource.length()); + + for (int i = 0; i < 10; i++) { + JavaResolved r = (JavaResolved) resolver.resolve(file, depSource); + assertEquals("com.example.inner.deep.Marker", + fieldFqn(r, "marker"), + "deep FQN diverged on iteration " + i); + } + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + /** Resolve the Pet.owner field's declared-type FQN via the carried solver. */ + private static String ownerFieldFqn(JavaResolved r) { + return fieldFqn(r, "owner"); + } + + private static String fieldFqn(JavaResolved r, String fieldName) { + CompilationUnit cu = r.cu(); + ClassOrInterfaceDeclaration cls = cu.findFirst(ClassOrInterfaceDeclaration.class) + .orElseThrow(); + return cls.getFields().stream() + .filter(f -> f.getVariables().stream() + .anyMatch(v -> v.getNameAsString().equals(fieldName))) + .findFirst() + .map(f -> f.getVariable(0).getType()) + .filter(t -> t.isClassOrInterfaceType()) + .map(t -> resolveFqn(t.asClassOrInterfaceType())) + .orElseThrow(() -> new AssertionError("field '" + fieldName + "' not found")); + } + + private static String resolveFqn(ClassOrInterfaceType type) { + ResolvedType rt = type.resolve(); + return rt.isReferenceType() + ? rt.asReferenceType().getQualifiedName() + : rt.describe(); + } +} From cc3b9386b8d848f4e532a23d8720c79e2da23e26 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 28 Apr 2026 01:16:10 +0000 Subject: [PATCH 09/16] checkpoint: pre-yolo 2026-04-28T01:16:10 --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index c9c986d9..80dbccf4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,3 +41,12 @@ If the task asks for product/feature work and `shared/runbooks/release.md` is mi ## Auth escalation If you hit something requiring GitHub App / PAT / OAuth that the runtime cannot satisfy (org admin escalation, Sonatype Central re-namespace, OpenSSF Best Practices form, etc.), do **not** improvise auth: PATCH the issue to `blocked` with the exact ask and `@`-mention the board. + + + +# Memory Context + +# [codeiq] recent context, 2026-04-28 1:14am UTC + +No previous sessions found. + \ No newline at end of file From 60864e382c142bee2863cb9b363aee3e3998fae4 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 28 Apr 2026 01:20:26 +0000 Subject: [PATCH 10/16] checkpoint: pre-yolo 2026-04-28T01:20:26 From 1d8bd2352e8f961db9ad2ad5c990a288c54f1017 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 28 Apr 2026 01:31:33 +0000 Subject: [PATCH 11/16] fix(resolver): four robustness fixes from dual-agent (superpowers + codex) brainstorm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reviewers independently identified the same four corner-cases in the Phase 4 + 6 wiring; this lands the converged fix list. 1. JavaSymbolResolver — `volatile` on `solver` and `combined` bootstrap() publishes; resolve() and the public accessors read from arbitrary virtual-thread carriers. The JLS Thread Start Rule covers the executor.submit() path but does NOT cover callers that read the public accessors after bootstrap on a different thread. Cheap fence, closes the visibility hole. 2. JavaSymbolResolver.resolve(String) — strict parse-success check JavaParser is permissive and may return a partial CompilationUnit even when the source has parse problems. Resolving against a partial CU silently emits simple-name-only edges and looks like coverage even though resolution is broken. Treat any non-success as EmptyResolved so the graph never carries phantom RESOLVED-tier edges from broken parses. 3. Analyzer.resolveFor — catch StackOverflowError Pathological generic / type-cycle inputs can blow JavaSymbolSolver's recursion stack. Catching the Error keeps the virtual-thread worker alive and degrades that file's resolution to lexical. Other Errors (OOM, ThreadDeath) remain fatal and propagate. 4. JavaSourceRootDiscovery.containsJavaFile — try-with-resources on Files.walk Files.walk holds an open directory stream; without a close, the file descriptor leaks for every plain-layout fallback scan. Cheap fix. mvn test: 3592 tests / 0 failures / 31 skipped (full suite, no regressions). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../randomcodespace/iq/analyzer/Analyzer.java | 9 +++++++ .../java/JavaSourceRootDiscovery.java | 7 ++++-- .../resolver/java/JavaSymbolResolver.java | 24 +++++++++++++------ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java index 2c460ea7..707f03b3 100644 --- a/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java +++ b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java @@ -220,6 +220,15 @@ private Resolved resolveFor(DiscoveredFile file, Object parsedAst, String conten log.debug("resolver {} threw unexpectedly for {}: {}", resolver.getClass().getSimpleName(), file.path(), e.toString()); return EmptyResolved.INSTANCE; + } catch (StackOverflowError e) { + // Pathological generic / type-cycle inputs can blow JavaSymbolSolver's + // recursion stack. Catching the Error keeps the virtual-thread + // worker alive and the file's resolution simply degrades to lexical. + // Other Errors (OOM, ThreadDeath) are not caught — they're fatal and + // should propagate. + log.warn("resolver {} stack-overflowed for {} — falling back to lexical", + resolver.getClass().getSimpleName(), file.path()); + return EmptyResolved.INSTANCE; } } diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSourceRootDiscovery.java b/src/main/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSourceRootDiscovery.java index f6c111c4..b471482e 100644 --- a/src/main/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSourceRootDiscovery.java +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSourceRootDiscovery.java @@ -123,8 +123,11 @@ private static String nameOrEmpty(Path p) { /** Cheap probe: does the directory tree under {@code root} have any {@code *.java}? */ private static boolean containsJavaFile(Path root) { - try { - return Files.walk(root) + // try-with-resources: Files.walk holds an open directory stream; without + // a close, the file descriptor leaks for every plain-layout fallback + // scan. Cheap fix. + try (java.util.stream.Stream stream = Files.walk(root)) { + return stream .filter(p -> !Files.isDirectory(p)) .anyMatch(p -> p.toString().endsWith(".java")); } catch (IOException e) { diff --git a/src/main/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolver.java b/src/main/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolver.java index 69eacf0f..a52565b5 100644 --- a/src/main/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolver.java +++ b/src/main/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolver.java @@ -40,8 +40,15 @@ public class JavaSymbolResolver implements SymbolResolver { private final JavaSourceRootDiscovery discovery; - private CombinedTypeSolver combined; - private JavaSymbolSolver solver; + // volatile: bootstrap() publishes the solver; resolve() and the public + // accessors read it from arbitrary virtual-thread carriers. Without + // volatile a reader could see a half-initialized JavaSymbolSolver — a + // narrow race that the JLS Thread Start Rule covers for the + // executor.submit() path but does NOT cover for callers that read the + // public accessors after bootstrap on a different thread. The fence is + // cheap; the alternative is a quiet correctness hole. + private volatile CombinedTypeSolver combined; + private volatile JavaSymbolSolver solver; public JavaSymbolResolver(JavaSourceRootDiscovery discovery) { this.discovery = discovery; @@ -96,11 +103,14 @@ public Resolved resolve(DiscoveredFile file, Object parsedAst) { // symbol solver so resolve()s on the resulting AST work. ParserConfiguration cfg = new ParserConfiguration().setSymbolResolver(solver); ParseResult parseResult = new JavaParser(cfg).parse(source); - if (parseResult.getResult().isEmpty()) { - // Unparseable source — return EmptyResolved rather than - // surface a parse exception. Detectors that need the raw - // content already have ctx.content() — symbol resolution - // simply isn't available for files JavaParser can't accept. + // Strict success check: JavaParser is permissive and may hand + // back a partial CompilationUnit even when the source has parse + // problems. Resolving against a partial CU silently emits + // simple-name-only edges and looks like coverage even though + // symbol resolution is broken. Treat any non-success as + // "EmptyResolved, fall back to lexical" so the downstream graph + // never carries phantom RESOLVED-tier edges from broken parses. + if (!parseResult.isSuccessful() || parseResult.getResult().isEmpty()) { return EmptyResolved.INSTANCE; } cu = parseResult.getResult().get(); From a583a961ec28fe577b049997b31480bb55227acb Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 28 Apr 2026 01:32:51 +0000 Subject: [PATCH 12/16] checkpoint: pre-yolo 2026-04-28T01:32:51 From a95c537a3a9ca900fce63e31a73fdbddde40f58c Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 28 Apr 2026 01:42:48 +0000 Subject: [PATCH 13/16] checkpoint: pre-yolo 2026-04-28T01:42:48 From 6c4fe6898b577d691ed52aa08b928b96e80b1cdd Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 28 Apr 2026 01:46:14 +0000 Subject: [PATCH 14/16] test(resolver): aggressive-testing layers 1, 4, 5, 7, 8 + Layer 9 PIT profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 7 of the sub-project 1 plan. Spec §12's testing matrix lands as five new test classes (26 tests) plus a non-default Maven profile. Layers 3 + 6 were already shipped in the prior commit on this branch. Layer 1 — JavaSymbolResolverLayer1ExtendedTest (16): Spec §12 Layer 1 cases not exercised by the existing JavaSymbolResolverTest — deep generics (Map>>), inner classes (static + non-static), records, sealed hierarchies, enums with abstract methods, default-method interfaces, abstract classes, annotation types, same simple name in different packages pinned by import direction, JDK Optional/Stream/List via ReflectionTypeSolver, multi-source-root cross-reference (src/main ↔ src/test), wildcard imports, cyclic imports both directions. Layer 4 — JavaSymbolResolverPathologicalTest (3): 10K-line class, 1000 imports (most unresolvable), 10-deep generic nesting (programmatically built so brackets are provably balanced). @Timeout per-test is the regression sentinel against quadratic memoization; Surefire's default heap covers the spec's -Xmx512m target many times over so we don't pin it explicitly. Layer 5 — JavaSymbolResolverAdversarialTest (5): Unbalanced braces (strict-success → EmptyResolved, strong assertion), mis-tagged Kotlin (no exception/null, branch-agnostic — JavaParser's permissiveness for "fun ... { }" is implementation-specific), mis-tagged random bytes, mixed source root with .java + .txt siblings (only .java enters the solver), empty source root (no Java files anywhere) bootstraps via ReflectionTypeSolver alone. Layer 7 — E2EResolverPetclinicTest (1, env-gated): Runs JavaSymbolResolver against every .java under $E2E_PETCLINIC_DIR and asserts bootstrap < 10 s (spec §9 budget), no exception, > 50% files produce JavaResolved (i.e. strict-success isn't false-rejecting valid Java). Lighter than spec §12 Layer 7's full precision/recall comparison — that needs a pre-resolver baseline JSON checked into test resources, captured at implementation time. This stand-in is the strongest signal we have until that baseline lands. Layer 8 — JavaSymbolResolverRandomizedTest (1, 100 samples): Hand-rolled randomized generator with fixed seed (0xC0DE197042L). Per the plan's license guidance, jqwik (EPL-2.0) isn't on the project's preferred-license list (~/.claude/rules/dependencies.md prefers MIT/Apache/BSD); this is the documented JUnit + java.util.Random fallback. Properties: never throws unchecked, never returns null, completes per-file in < 1 s budget. Layer 9 — mutation Maven profile (non-default): Adds pitest-maven 1.18.0 (Apache-2.0) targeting intelligence.resolver.* and model.Confidence. Run with mvn -P mutation org.pitest:pitest-maven:mutationCoverage \ -Dfrontend.skip=true -Ddependency-check.skip=true Reports under target/pit-reports/. Non-gating per the plan; the ≥ 80% target is a follow-up signal once a first run lands. Full suite: mvn test → 3618 / 0 failures / 32 skipped (1 new skip is the env-gated E2EResolverPetclinicTest). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 56 ++++ pom.xml | 36 +++ .../java/E2EResolverPetclinicTest.java | 96 +++++++ .../JavaSymbolResolverAdversarialTest.java | Bin 0 -> 5880 bytes .../JavaSymbolResolverLayer1ExtendedTest.java | 261 ++++++++++++++++++ .../JavaSymbolResolverPathologicalTest.java | 104 +++++++ .../JavaSymbolResolverRandomizedTest.java | 143 ++++++++++ 7 files changed, 696 insertions(+) create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/E2EResolverPetclinicTest.java create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverAdversarialTest.java create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverLayer1ExtendedTest.java create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverPathologicalTest.java create mode 100644 src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverRandomizedTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d2695d..dd6060fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,6 +140,62 @@ for that specific tag for the per-commit details. `-XX:ErrorFile` / `-XX:HeapDumpPath` overrides. Spec at [`docs/specs/2026-04-28-aks-read-only-deploy-design.md`](docs/specs/2026-04-28-aks-read-only-deploy-design.md). +- **Resolver aggressive-testing layers** (sub-project 1, plan Phase 7 — + Layers 1, 3, 4, 5, 6, 7, 8, 9): the spec §12 testing matrix lands as + six new test classes plus a non-default Maven profile. + - **Layer 1** — `JavaSymbolResolverLayer1ExtendedTest` (16 tests): + deeply-nested generics, static / non-static inner classes, records, + sealed hierarchies, enum-with-abstract-methods, default-method + interfaces, abstract classes, annotation types, same simple name in + different packages by import, JDK `Optional` / `Stream` / `List` via + `ReflectionTypeSolver`, multi-source-root cross-references + (`src/main` ↔ `src/test`), wildcard imports, cyclic imports. + - **Layer 3** — `JavaSymbolResolverConcurrencyTest` (already shipped + in the prior commit): virtual-thread fan-out under `N=200` files / + `256` concurrent calls, garbage-input variant. + - **Layer 4** — `JavaSymbolResolverPathologicalTest` (3 tests): + 10K-line class, 1000 imports (most unresolvable), 10-deep generic + nesting; per-test `@Timeout` is the regression sentinel against + quadratic memoization. + - **Layer 5** — `JavaSymbolResolverAdversarialTest` (5 tests): + unbalanced braces (strict-success → `EmptyResolved`), mis-tagged + Kotlin / random-bytes (no exception, no null), mixed source root + with `.java` + `.txt` siblings, empty source root (no Java files + anywhere) bootstraps via `ReflectionTypeSolver` alone. + - **Layer 6** — `JavaSymbolResolverDeterminismTest` (already shipped): + same input → same FQN 25× in a row, two independent resolvers + agree, rebootstrap is observably idempotent, deeper FQNs are stable. + - **Layer 7** — `E2EResolverPetclinicTest` (env-gated): runs the + resolver against every `.java` under `$E2E_PETCLINIC_DIR`, asserts + bootstrap < 10 s, no exception, > 50% files produce `JavaResolved` + (i.e. strict-success isn't false-rejecting valid Java). Lighter than + spec §12 Layer 7's full precision/recall comparison — that requires + a pre-resolver baseline JSON checked into test resources, captured + at implementation time. This stand-in is the strongest signal until + that baseline lands. + - **Layer 8** — `JavaSymbolResolverRandomizedTest` (1 test, 100 + samples): hand-rolled randomized generator with fixed seed; per the + plan's license guidance, jqwik (EPL-2.0) is not on the preferred- + license list, and this is the documented JUnit + `java.util.Random` + fallback. Properties: never throws, never returns null, completes + per file in < 1 s. + - **Layer 9** — `mutation` Maven profile (non-default): adds + `pitest-maven` 1.18.0 (Apache-2.0) targeting + `intelligence.resolver.*` and `model.Confidence`. Run with + `mvn -P mutation org.pitest:pitest-maven:mutationCoverage + -Dfrontend.skip=true -Ddependency-check.skip=true`. Reports under + `target/pit-reports/`. + - Four robustness fixes from a dual-agent (superpowers + codex) + brainstorm landed on the same branch: `volatile` on + `JavaSymbolResolver`'s `solver` / `combined` fields, strict + parse-success check in the String-source branch (was silently + emitting partial-CU edges on broken parses), `StackOverflowError` + catch in `Analyzer.resolveFor` (pathological generics no longer kill + virtual threads), `try-with-resources` on the `Files.walk` in + `JavaSourceRootDiscovery.containsJavaFile` (fd leak fix). 26 new + tests on top of the resolver wiring slice's 18 — full suite at 3618 + / 0 / 32 skipped, +1 skip is the env-gated E2E petclinic test. + ### Changed - Documentation count drift fixed: detector total updated from **97 → 99** diff --git a/pom.xml b/pom.xml index b67d6355..52f132d9 100644 --- a/pom.xml +++ b/pom.xml @@ -489,6 +489,42 @@ + + + mutation + + + + org.pitest + pitest-maven + 1.18.0 + + + io.github.randomcodespace.iq.intelligence.resolver.* + io.github.randomcodespace.iq.intelligence.resolver.java.* + io.github.randomcodespace.iq.model.Confidence + + + io.github.randomcodespace.iq.intelligence.resolver.* + io.github.randomcodespace.iq.intelligence.resolver.java.* + io.github.randomcodespace.iq.model.ConfidenceTest + + + HTML + XML + + false + + + + + release diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/E2EResolverPetclinicTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/E2EResolverPetclinicTest.java new file mode 100644 index 00000000..3a965518 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/E2EResolverPetclinicTest.java @@ -0,0 +1,96 @@ +package io.github.randomcodespace.iq.intelligence.resolver.java; + +import io.github.randomcodespace.iq.analyzer.DiscoveredFile; +import io.github.randomcodespace.iq.intelligence.resolver.EmptyResolved; +import io.github.randomcodespace.iq.intelligence.resolver.ResolutionException; +import io.github.randomcodespace.iq.intelligence.resolver.Resolved; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 7 Layer 7 — E2E resolver regression gate against a real Spring app. + * + *

Runs {@link JavaSymbolResolver} against every {@code .java} file in + * {@code $E2E_PETCLINIC_DIR} (typically a clone of {@code spring-petclinic}) + * and asserts: + *

    + *
  • bootstrap completes within 10 s (spec §9 budget),
  • + *
  • no file produces a thrown exception,
  • + *
  • a non-trivial fraction (> 50%) of files produces a {@link JavaResolved} + * (i.e. the strict-success check isn't false-rejecting valid Java),
  • + *
  • a known petclinic FQN (one of the entity classes — {@code Owner}/ + * {@code Pet}/{@code Vet}) is resolvable end-to-end.
  • + *
+ * + *

This is a lightweight stand-in for spec §12 Layer 7's full + * precision/recall comparison. That comparison requires a pre-resolver + * baseline JSON checked into test resources (captured on the same + * petclinic SHA pre-resolver), which is implementation-time work. Until + * the baseline lands, this test is the strongest signal we have that the + * resolver works on a real-world codebase. + */ +@Tag("e2e") +@EnabledIfEnvironmentVariable(named = "E2E_PETCLINIC_DIR", matches = ".+") +class E2EResolverPetclinicTest { + + @Test + void resolverBootstrapsAndResolvesPetclinicWithinBudget() throws IOException, ResolutionException { + Path repoRoot = Path.of(System.getenv("E2E_PETCLINIC_DIR")); + assertTrue(Files.isDirectory(repoRoot), + "E2E_PETCLINIC_DIR must point at a real directory: " + repoRoot); + + JavaSymbolResolver resolver = new JavaSymbolResolver(new JavaSourceRootDiscovery()); + long bootstrapStart = System.currentTimeMillis(); + resolver.bootstrap(repoRoot); + long bootstrapMs = System.currentTimeMillis() - bootstrapStart; + assertTrue(bootstrapMs < 10_000, + "bootstrap exceeded 10 s budget: " + bootstrapMs + " ms (spec §9)"); + + List javaFiles; + try (Stream walk = Files.walk(repoRoot)) { + javaFiles = walk + .filter(p -> !Files.isDirectory(p)) + .filter(p -> p.toString().endsWith(".java")) + .filter(p -> !p.toString().contains("/target/")) + .filter(p -> !p.toString().contains("/build/")) + .toList(); + } + assertFalse(javaFiles.isEmpty(), + "no .java files found under " + repoRoot + + " — point E2E_PETCLINIC_DIR at a Java repo"); + + int total = 0; + int resolved = 0; + for (Path p : javaFiles) { + String content = Files.readString(p); + DiscoveredFile file = new DiscoveredFile( + repoRoot.relativize(p), "java", content.length()); + Resolved r; + try { + r = resolver.resolve(file, content); + } catch (Throwable t) { + throw new AssertionError("resolver threw on " + p + ": " + t, t); + } + assertNotNull(r, "resolver returned null on " + p); + total++; + if (r != EmptyResolved.INSTANCE) { + resolved++; + } + } + + assertTrue(total > 0, "no .java files scanned"); + double frac = ((double) resolved) / total; + assertTrue(frac > 0.5, + "only " + resolved + "/" + total + " (" + frac + ") files produced JavaResolved — " + + "strict-success check too aggressive on real-world Java, or solver setup broken"); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverAdversarialTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverAdversarialTest.java new file mode 100644 index 0000000000000000000000000000000000000000..c1883fb85a95e35d6ce443ea2bcc413186ff3545 GIT binary patch literal 5880 zcmcIo+ioMr5l!-_uPDFT}W%{Ag%HwybJe(j(m7dClvGQDG ztM;({FJ4RMf94g$?phY*nX+9tI5;U5-h8`jRanzlTp$K12g0={4AFwDec{>Bs+&m;(S#SS50r(#kjw4M^#- z@-D?D-gjJ{T$M7T|NZw*e{MHtkmX*4r&RdoFiL0_qIu}Jj8f7b)-4GVb_+A5cveH*pJGy(BD}6%) z^nL#3;_CX<#q0C?^mqz=9%xo5H}PV*lyee}zCvJVR(aCK$1j;ddTIZH_#Z1Wl%J2OQ?j+`D5RjJz+Gn3a`7;q9uB=y^}W=NM_ z%0(_S&WdY1y$WXCck)GUv)h!L9QFa~kO&xp&BB4>{i>6soPOq!DnOyiL=J~kPA;k(J13r*D8z@gRRyBj zhioTgw8Z|f3x3wT!%CO~z^{TzCI*-m))X~z+-Tgz@pu~3dwLKw46(Zk-mbB2Uv(71 z{782P2b{exYZ@fxEu?H|E)n6QuvS{NW{nvLLt%I^MiI0Xq;%XIzORh}z@>jzCSw?5 z&8?#ztsYX)YFI&_5sTsvvy)42EvWwr#G;o;qgYc)lTitTZHjy}KBUnxyvtFVo{Yv( z^-fA=C2RoEW8GLK-5y!aX43+RGi9uf#yuI0o%981dAr9GN&7gYGdr&-*xO{Eq_oYT zR+0L=`!*uZcV4XYOyuA!IX`8HxXZ|@12%<|JLgTO^iGRnrk0hdKvB$9v;fvCq(2uE z4rgSrn&w4A^oKhnAHqQdnLV069CCR>^eJ7hK*RV6gygLO{cvi%VE$->3t6l|Q)zBh zoaOQuvV}ONG&MwwgOGv5sf+|G!cm{ zRA9q8pu?4Z&81%Y)gbzH7S74y;09DJn3-go z-97MuYCzUkqL8s%HA2#+cpj2H+);THBOXdhQ_4aCA3%nZSkI}h=P|zHCV%#05w-3* zROPRYM{zkb(p?z;+6o72hY)Au4&wi1O%=Ca>RNoy9Z`i*>y(F19;k1}mCz^f;)b=+um! zG^Lx-ENLa|ywBLN$>_3TPn)p=;G5tqm$Qn|jriHR&>Lg1<%2$H^6nbXVTnnW@JEp{FQsmVxtPXVG%r14c!|INdbt!}%3^ zfZgg1aXou>I|ZzFXMAZ)=Ua^CVQ%N%baO<5YJwN(?tb_Deh)H`jLr-#ir@Ia|6I73 zOYoFFz&D>D=v2gOfgtBG74hN&6+w%aa?|FwDrj~ zH=ryw7PW)j5ivY4d&D_xj$#?`!FMxuf&RV$$^Kd^`Ld?VzSDN@dZp)Q65|3coBmM` z4LMrPrp?}$hXBgO-gyqQ=DZ#t>lQjtY3Sej10|sSp{l!5PR@Y5-8n67S{tY%mMH$1 zGlb?rsE~!R+q!k*+l#zK8f4vK@`}^*S7%>O!nkWCy9jMB{8+0t$>myduh^`Fu9iHe z{`vz^wfEcBqJcXW;%!8~FF?1*v)+S?jZRqXd0{+8`q))@RiIPjG!OF+@%M3;TrB*- zq_ltrT>0BMVK9~b;_)F+X}|=J7?9WJ$9jef6V5gn@kONie$tpe3EL(O+}J#iSacHu zKiMVH_OOmle}3j-QNiNgn9~aCWg5RXq?r0nl(Ra;V3?=hD4v}MEd3pNCVqSzx1Lab zG-#Mu>X2YQrPt@(AL;kM>#E4(w8Jtk>l@b4>qlqQp-7mNACeAlX^CghFY(GfEWk}L p8k&9an#){z`DSsxfmdA8dU{XD9ymY7`(yiiFXQfo)i}66_&?M1dE)>8 literal 0 HcmV?d00001 diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverLayer1ExtendedTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverLayer1ExtendedTest.java new file mode 100644 index 00000000..655429d2 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverLayer1ExtendedTest.java @@ -0,0 +1,261 @@ +package io.github.randomcodespace.iq.intelligence.resolver.java; + +import com.github.javaparser.JavaParser; +import com.github.javaparser.ParserConfiguration; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.body.FieldDeclaration; +import com.github.javaparser.resolution.types.ResolvedType; +import io.github.randomcodespace.iq.intelligence.resolver.ResolutionException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 7 Layer 1 — additional resolver unit tests covering spec §12 Layer 1 + * cases not exercised by {@link JavaSymbolResolverTest}. + * + *

Each test bootstraps the resolver against a tiny TempDir tree, then + * parses an assertion source through the resolver's + * {@link JavaSymbolResolver#symbolSolver()} and resolves the type of a named + * field. The point is end-to-end SymbolSolver wiring, not language coverage — + * if any case here breaks, the resolver is missing a configuration step. + */ +class JavaSymbolResolverLayer1ExtendedTest { + + @TempDir Path repoRoot; + private JavaSymbolResolver resolver; + + @BeforeEach + void setUp() throws IOException, ResolutionException { + Files.writeString(repoRoot.resolve("pom.xml"), ""); + Files.createDirectories(repoRoot.resolve("src/main/java")); + resolver = new JavaSymbolResolver(new JavaSourceRootDiscovery()); + } + + // ── Generics (≥3-level nesting per spec) ───────────────────────────────── + + @Test + void resolvesDeeplyNestedGenericTypeFromJdk() throws Exception { + // Map>> — all four types are JDK and resolve + // via ReflectionTypeSolver alone. + resolver.bootstrap(repoRoot); + ResolvedType rt = resolveFieldType( + "import java.util.*; import java.util.UUID; class Z { Map>> deep; }", + "deep"); + String d = rt.describe(); + assertTrue(d.contains("Map"), "expected Map in " + d); + assertTrue(d.contains("List"), "expected List in " + d); + assertTrue(d.contains("Set"), "expected Set in " + d); + assertTrue(d.contains("UUID"), "expected UUID in " + d); + } + + // ── Inner classes ─────────────────────────────────────────────────────── + + @Test + void resolvesStaticInnerClass() throws Exception { + Files.writeString(repoRoot.resolve("src/main/java/Outer.java"), + "public class Outer { public static class Inner {} }"); + resolver.bootstrap(repoRoot); + ResolvedType rt = resolveFieldType("class Z { Outer.Inner i; }", "i"); + assertTrue(rt.describe().contains("Outer.Inner"), + "expected Outer.Inner in " + rt.describe()); + } + + @Test + void resolvesNonStaticInnerClass() throws Exception { + Files.writeString(repoRoot.resolve("src/main/java/Outer.java"), + "public class Outer { public class Inner {} }"); + resolver.bootstrap(repoRoot); + ResolvedType rt = resolveFieldType("class Z { Outer.Inner i; }", "i"); + assertTrue(rt.describe().contains("Outer.Inner")); + } + + // ── Records ────────────────────────────────────────────────────────────── + + @Test + void resolvesRecord() throws Exception { + Files.writeString(repoRoot.resolve("src/main/java/Pair.java"), + "public record Pair(String a, int b) {}"); + resolver.bootstrap(repoRoot); + ResolvedType rt = resolveFieldType("class Z { Pair p; }", "p"); + assertTrue(rt.describe().contains("Pair")); + } + + // ── Sealed classes ────────────────────────────────────────────────────── + + @Test + void resolvesSealedHierarchy() throws Exception { + Files.writeString(repoRoot.resolve("src/main/java/Shape.java"), + "public sealed interface Shape permits Circle, Square {}"); + Files.writeString(repoRoot.resolve("src/main/java/Circle.java"), + "public final class Circle implements Shape {}"); + Files.writeString(repoRoot.resolve("src/main/java/Square.java"), + "public final class Square implements Shape {}"); + resolver.bootstrap(repoRoot); + ResolvedType rt = resolveFieldType("class Z { Shape s; }", "s"); + assertTrue(rt.describe().contains("Shape")); + } + + // ── Enum with abstract methods ────────────────────────────────────────── + + @Test + void resolvesEnumWithAbstractMethods() throws Exception { + Files.writeString(repoRoot.resolve("src/main/java/Op.java"), + """ + public enum Op { + ADD { @Override public int apply(int a, int b) { return a + b; } }; + public abstract int apply(int a, int b); + } + """); + resolver.bootstrap(repoRoot); + ResolvedType rt = resolveFieldType("class Z { Op op; }", "op"); + assertTrue(rt.describe().contains("Op")); + } + + // ── Interface with default methods ────────────────────────────────────── + + @Test + void resolvesInterfaceWithDefaultMethod() throws Exception { + Files.writeString(repoRoot.resolve("src/main/java/Greeter.java"), + "public interface Greeter { default String hello() { return \"hi\"; } }"); + resolver.bootstrap(repoRoot); + ResolvedType rt = resolveFieldType("class Z { Greeter g; }", "g"); + assertTrue(rt.describe().contains("Greeter")); + } + + // ── Abstract class ────────────────────────────────────────────────────── + + @Test + void resolvesAbstractClass() throws Exception { + Files.writeString(repoRoot.resolve("src/main/java/Base.java"), + "public abstract class Base { public abstract void go(); }"); + resolver.bootstrap(repoRoot); + ResolvedType rt = resolveFieldType("class Z { Base b; }", "b"); + assertTrue(rt.describe().contains("Base")); + } + + // ── Annotations (definition + use) ────────────────────────────────────── + + @Test + void resolvesAnnotationType() throws Exception { + Files.writeString(repoRoot.resolve("src/main/java/Tag.java"), + "public @interface Tag { String value() default \"\"; }"); + resolver.bootstrap(repoRoot); + ResolvedType rt = resolveFieldType("class Z { Tag t; }", "t"); + assertTrue(rt.describe().contains("Tag")); + } + + // ── Same simple name in different packages ────────────────────────────── + + @Test + void resolvesSameSimpleNameInDifferentPackagesByImport() throws Exception { + Files.createDirectories(repoRoot.resolve("src/main/java/com/a")); + Files.createDirectories(repoRoot.resolve("src/main/java/com/b")); + Files.writeString(repoRoot.resolve("src/main/java/com/a/Foo.java"), + "package com.a; public class Foo {}"); + Files.writeString(repoRoot.resolve("src/main/java/com/b/Foo.java"), + "package com.b; public class Foo {}"); + resolver.bootstrap(repoRoot); + + // Importing com.a.Foo pins the resolution. + ResolvedType rtA = resolveFieldType( + "package com.x; import com.a.Foo; class Z { Foo f; }", "f"); + assertEquals("com.a.Foo", rtA.asReferenceType().getQualifiedName()); + + // Importing com.b.Foo pins the OTHER one — not just whichever happens first. + ResolvedType rtB = resolveFieldType( + "package com.x; import com.b.Foo; class Z { Foo f; }", "f"); + assertEquals("com.b.Foo", rtB.asReferenceType().getQualifiedName()); + } + + // ── JDK symbols via ReflectionTypeSolver ──────────────────────────────── + + @Test + void resolvesJdkOptional() throws Exception { + resolver.bootstrap(repoRoot); + ResolvedType rt = resolveFieldType( + "import java.util.Optional; class Z { Optional o; }", "o"); + assertTrue(rt.describe().contains("Optional")); + } + + @Test + void resolvesJdkStream() throws Exception { + resolver.bootstrap(repoRoot); + ResolvedType rt = resolveFieldType( + "import java.util.stream.Stream; class Z { Stream s; }", "s"); + assertTrue(rt.describe().contains("Stream")); + } + + @Test + void resolvesJdkList() throws Exception { + resolver.bootstrap(repoRoot); + ResolvedType rt = resolveFieldType( + "import java.util.List; class Z { List l; }", "l"); + assertTrue(rt.describe().contains("List")); + } + + // ── Multi-source-root: src/main/java referencing src/test/java ────────── + + @Test + void resolvesAcrossMainAndTestSourceRoots() throws Exception { + Files.createDirectories(repoRoot.resolve("src/test/java")); + Files.writeString(repoRoot.resolve("src/test/java/TestHelper.java"), + "public class TestHelper {}"); + Files.writeString(repoRoot.resolve("src/main/java/Main.java"), + "public class Main { TestHelper helper; }"); + resolver.bootstrap(repoRoot); + ResolvedType rt = resolveFieldType("class Z { TestHelper t; }", "t"); + assertTrue(rt.describe().contains("TestHelper")); + } + + // ── Wildcard import ───────────────────────────────────────────────────── + + @Test + void resolvesViaWildcardImport() throws Exception { + Files.createDirectories(repoRoot.resolve("src/main/java/com/x")); + Files.writeString(repoRoot.resolve("src/main/java/com/x/Foo.java"), + "package com.x; public class Foo {}"); + resolver.bootstrap(repoRoot); + ResolvedType rt = resolveFieldType( + "package com.y; import com.x.*; class Z { Foo f; }", "f"); + assertEquals("com.x.Foo", rt.asReferenceType().getQualifiedName()); + } + + // ── Cyclic imports (legal in Java) ────────────────────────────────────── + + @Test + void resolvesCyclicImportsBothDirections() throws Exception { + Files.createDirectories(repoRoot.resolve("src/main/java/com/cycle")); + Files.writeString(repoRoot.resolve("src/main/java/com/cycle/A.java"), + "package com.cycle; public class A { B b; }"); + Files.writeString(repoRoot.resolve("src/main/java/com/cycle/B.java"), + "package com.cycle; public class B { A a; }"); + resolver.bootstrap(repoRoot); + ResolvedType rtA = resolveFieldType( + "package com.cycle; class Z { A a; }", "a"); + ResolvedType rtB = resolveFieldType( + "package com.cycle; class Z { B b; }", "b"); + assertEquals("com.cycle.A", rtA.asReferenceType().getQualifiedName()); + assertEquals("com.cycle.B", rtB.asReferenceType().getQualifiedName()); + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private ResolvedType resolveFieldType(String source, String fieldName) { + ParserConfiguration cfg = new ParserConfiguration().setSymbolResolver(resolver.symbolSolver()); + CompilationUnit cu = new JavaParser(cfg).parse(source).getResult().orElseThrow(); + return cu.findAll(FieldDeclaration.class).stream() + .flatMap(f -> f.getVariables().stream()) + .filter(v -> v.getNameAsString().equals(fieldName)) + .findFirst() + .orElseThrow(() -> new AssertionError("field " + fieldName + " not found in source")) + .getType() + .resolve(); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverPathologicalTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverPathologicalTest.java new file mode 100644 index 00000000..d3dd0e32 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverPathologicalTest.java @@ -0,0 +1,104 @@ +package io.github.randomcodespace.iq.intelligence.resolver.java; + +import io.github.randomcodespace.iq.analyzer.DiscoveredFile; +import io.github.randomcodespace.iq.intelligence.resolver.EmptyResolved; +import io.github.randomcodespace.iq.intelligence.resolver.ResolutionException; +import io.github.randomcodespace.iq.intelligence.resolver.Resolved; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 7 Layer 4 — pathological / memory-pressure inputs. + * + *

Spec §12 Layer 4 cases: + *

    + *
  • 10K-line synthetic class.
  • + *
  • File with 1000 imports (most unresolvable).
  • + *
  • 10-deep generic nesting.
  • + *
+ * + *

The hard contract is "no exception, never null" — Surefire's default heap + * covers the spec's {@code -Xmx512m} target several times over, so we don't + * pin it explicitly. Per-test wall-clock {@code @Timeout} is the regression + * sentinel: if a future change makes JavaSymbolSolver memoization quadratic, + * the timeout trips before OOM does. + */ +class JavaSymbolResolverPathologicalTest { + + @TempDir Path repoRoot; + private JavaSymbolResolver resolver; + + @BeforeEach + void setUp() throws IOException, ResolutionException { + Files.writeString(repoRoot.resolve("pom.xml"), ""); + Files.createDirectories(repoRoot.resolve("src/main/java")); + resolver = new JavaSymbolResolver(new JavaSourceRootDiscovery()); + resolver.bootstrap(repoRoot); + } + + @Test + @Timeout(value = 30, unit = TimeUnit.SECONDS) + void tenThousandLineClassResolvesWithinBudget() { + StringBuilder src = new StringBuilder("package x; public class Big {\n"); + for (int i = 0; i < 10_000; i++) { + src.append(" public int m").append(i).append("() { return ").append(i).append("; }\n"); + } + src.append("}\n"); + + DiscoveredFile file = new DiscoveredFile( + Path.of("src/main/java/x/Big.java"), "java", src.length()); + Resolved r = resolver.resolve(file, src.toString()); + assertNotNull(r, "10K-line class must not return null"); + // Both JavaResolved (parser succeeded) and EmptyResolved (strict-success + // tripped) are legal — the contract is "no exception, no null". + } + + @Test + @Timeout(value = 30, unit = TimeUnit.SECONDS) + void fileWithThousandImportsResolvesWithinBudget() { + StringBuilder src = new StringBuilder("package x;\n"); + for (int i = 0; i < 1_000; i++) { + // Most of these point at packages that don't exist. JavaParser is + // permissive at the syntax layer (it accepts the import); the + // symbol solver later fails to resolve them but resolve() still + // returns. The pathology is the symbol solver's memo footprint. + src.append("import com.nonexistent.pkg").append(i).append(".Foo").append(i).append(";\n"); + } + src.append("public class Imp {}\n"); + + DiscoveredFile file = new DiscoveredFile( + Path.of("src/main/java/x/Imp.java"), "java", src.length()); + Resolved r = resolver.resolve(file, src.toString()); + assertNotNull(r); + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + void tenDeepGenericNestingResolvesWithinBudget() { + // Built programmatically so the bracket count is provably balanced — + // hand-counted literals are a notorious source of off-by-one bugs. + int depth = 10; + StringBuilder src = new StringBuilder(); + src.append("import java.util.List; import java.util.UUID; class Z { "); + for (int i = 0; i < depth; i++) src.append("List<"); + src.append("UUID"); + for (int i = 0; i < depth; i++) src.append(">"); + src.append(" deep; }"); + + DiscoveredFile file = new DiscoveredFile( + Path.of("src/main/java/Z.java"), "java", src.length()); + Resolved r = resolver.resolve(file, src.toString()); + assertNotNull(r); + assertNotSame(EmptyResolved.INSTANCE, r, + "deep generics still parse — JavaParser handles arbitrary nesting"); + } +} diff --git a/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverRandomizedTest.java b/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverRandomizedTest.java new file mode 100644 index 00000000..a9d6a88d --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverRandomizedTest.java @@ -0,0 +1,143 @@ +package io.github.randomcodespace.iq.intelligence.resolver.java; + +import io.github.randomcodespace.iq.analyzer.DiscoveredFile; +import io.github.randomcodespace.iq.intelligence.resolver.ResolutionException; +import io.github.randomcodespace.iq.intelligence.resolver.Resolved; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 7 Layer 8 — hand-rolled randomized testing. + * + *

Per the implementation plan (Task 35), jqwik (the recommended + * property-based test library) is EPL-2.0 — not on the project's + * preferred-license list (MIT/Apache/BSD per + * {@code ~/.claude/rules/dependencies.md}). The plan's documented fallback + * is "hand-rolled randomized generators using existing JUnit + + * {@link Random}" and that's what lands here. + * + *

Properties exercised over a fixed-seed corpus of {@value #N_SAMPLES} + * generated files: + *

    + *
  • {@code resolve()} never throws unchecked.
  • + *
  • {@code resolve()} never returns null.
  • + *
  • {@code resolve()} completes per file within a generous wall-clock + * budget — production budget is 500 ms (spec §9 + * {@code max_per_file_resolve_ms}); we use 1 s here to absorb CI + * variance.
  • + *
+ * + *

Seed is fixed so failures are reproducible. To explore a different + * region of input space, change {@link #SEED} and re-run. + */ +class JavaSymbolResolverRandomizedTest { + + private static final int N_SAMPLES = 100; + private static final long SEED = 0xC0DE19_70_42L; // change to explore + private static final long PER_FILE_BUDGET_MS = 1_000; + + @TempDir Path repoRoot; + private JavaSymbolResolver resolver; + + @BeforeEach + void setUp() throws IOException, ResolutionException { + Files.writeString(repoRoot.resolve("pom.xml"), ""); + Files.createDirectories(repoRoot.resolve("src/main/java")); + resolver = new JavaSymbolResolver(new JavaSourceRootDiscovery()); + resolver.bootstrap(repoRoot); + } + + @Test + void randomizedJavaSourcesNeverThrowAndCompleteUnderBudget() { + Random rnd = new Random(SEED); + long globalStartNs = System.nanoTime(); + for (int i = 0; i < N_SAMPLES; i++) { + String src = generateRandomJava(rnd, i); + DiscoveredFile file = new DiscoveredFile( + Path.of("src/main/java/Gen" + i + ".java"), "java", src.length()); + long startNs = System.nanoTime(); + Resolved r; + try { + r = resolver.resolve(file, src); + } catch (Throwable t) { + fail("sample #" + i + " threw " + t.getClass().getSimpleName() + + " on source:\n" + src, t); + return; + } + long durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs); + assertNotNull(r, "sample #" + i + " returned null"); + assertTrue(durationMs < PER_FILE_BUDGET_MS, + "sample #" + i + " took " + durationMs + " ms (>" + + PER_FILE_BUDGET_MS + " ms budget)\nsource:\n" + src); + } + long totalMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - globalStartNs); + // Soft sanity: total wall time well under N × budget — no single file + // pegging the loop. (Median per-file time should be ≪ budget.) + assertTrue(totalMs < N_SAMPLES * PER_FILE_BUDGET_MS, + "total time " + totalMs + " ms exceeded global budget"); + } + + /** + * Minimal generator: small classes with a varying mix of fields, methods, + * and imports. Not exhaustive — diverse enough to surface obvious panics + * in {@code resolve()}. + */ + private static String generateRandomJava(Random rnd, int idx) { + StringBuilder src = new StringBuilder(); + src.append("package gen;\n"); + // Random imports — mix of resolvable JDK types and unresolvable ones, + // so the generated corpus exercises both paths through the symbol solver. + String[] importPool = { + "java.util.List", + "java.util.Map", + "java.util.Set", + "java.util.UUID", + "java.util.Optional", + "com.absent.Absent" + idx, + }; + int nImports = rnd.nextInt(5); + for (int i = 0; i < nImports; i++) { + src.append("import ").append(importPool[rnd.nextInt(importPool.length)]).append(";\n"); + } + src.append("public class Gen").append(idx).append(" {\n"); + int nFields = rnd.nextInt(8); + for (int i = 0; i < nFields; i++) { + String type = randomType(rnd); + src.append(" private ").append(type).append(" f").append(i).append(";\n"); + } + int nMethods = rnd.nextInt(8); + for (int i = 0; i < nMethods; i++) { + String returnType = randomType(rnd); + src.append(" public ").append(returnType) + .append(" m").append(i).append("() { return ") + .append(defaultFor(returnType)).append("; }\n"); + } + src.append("}\n"); + return src.toString(); + } + + private static String randomType(Random rnd) { + return switch (rnd.nextInt(6)) { + case 0 -> "int"; + case 1 -> "String"; + case 2 -> "java.util.List"; + case 3 -> "java.util.Map"; + case 4 -> "java.util.Optional"; + default -> "java.util.Set>"; + }; + } + + private static String defaultFor(String type) { + if ("int".equals(type)) return "0"; + return "null"; + } +} From d2595b6fe35c2d017e5ce0f58e4c14bca576612b Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 28 Apr 2026 01:47:06 +0000 Subject: [PATCH 15/16] checkpoint: pre-yolo 2026-04-28T01:47:06 From 1470182074552cd818a62f3441bdb68b2ad44cf7 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 28 Apr 2026 01:52:34 +0000 Subject: [PATCH 16/16] docs(claude+summary): close out sub-project 1 plan tasks 40 + 41 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §40 / §41 close-out — the resolver SPI, Confidence schema, CACHE_VERSION bump, and runtime lifecycle gotchas now land in CLAUDE.md and PROJECT_SUMMARY.md, not just CHANGELOG.md. CLAUDE.md Gotchas: - Cache versioning bullet updated 4 → 5 with reason (Confidence/source schema), so future agents reading the gotcha don't propagate the stale "4" forward. - "Symbol resolver runs at index-time only" — bootstrapResolvers and resolveFor are wired into run / runBatchedIndex / runSmartIndex only. Never reached at serve. Prevents future agents from reaching for ResolverRegistry from serve-mode code paths. - "Confidence + source mandatory on every CodeNode/CodeEdge" — DetectorEmissionDefaults stamps the floor; RESOLVED is opt-in via ctx.resolved(). Reading legacy data is non-throwing. - "JavaSymbolResolver.resolve() allocates a fresh JavaParser per call" — intentional thread-safety boundary for virtual-thread fan-out, not a perf bug. - "Strict parse-success check" — resolve(String) returns EmptyResolved on any JavaParser problem so the graph never carries phantom RESOLVED-tier edges from partial-CU outputs. - "Volatile fields on JavaSymbolResolver" — closes the public-accessor visibility race per the dual-agent brainstorm fix. PROJECT_SUMMARY.md: - Tech-stack row updated to "AST + symbols (Java) | JavaParser 3.28.0 + javaparser-symbol-solver-core 3.28.0". - Cache dir line updated CACHE_VERSION=4 → 5. - Two new gotchas (resolver-is-index-time-only, strict parse-success) cross-referencing the canonical CLAUDE.md entries. No source changes. Full mvn test was last green at 3618 / 0 / 32 skipped (unchanged for this docs-only commit). Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 7 ++++++- PROJECT_SUMMARY.md | 8 +++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 304342ac..be136f3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -421,7 +421,12 @@ bean for code paths that haven't been ported yet. - **`@ActiveProfiles("test")`**: Required on any `@SpringBootTest` to avoid Neo4j startup conflicts. - **Dead code detection**: Must filter by semantic edges only (calls, imports, depends_on). Exclude structural edges (contains, defines) and entry points (endpoints, config files). - **H2 reserved words**: `key`, `value`, `order` are reserved in H2 SQL. Use `meta_key`, `meta_value` etc. in CREATE TABLE statements. -- **Cache versioning**: `AnalysisCache` has a `CACHE_VERSION` constant (currently `4`). Bump it when changing the hash algorithm or H2 schema so stale caches are auto-cleared on next run. +- **Cache versioning**: `AnalysisCache` has a `CACHE_VERSION` constant (currently `5`, bumped from `4` for the resolver `confidence` + `source` schema). Bump it when changing the hash algorithm, H2 schema, or any field that becomes mandatory on cached nodes/edges so stale caches are auto-cleared on next run. +- **Symbol resolver runs at index-time only.** `Analyzer.bootstrapResolvers()` and `Analyzer.resolveFor()` are wired into `run` / `runBatchedIndex` / `runSmartIndex` paths only — never at `serve`. The resolver SPI lives under `intelligence/resolver/`. If you find yourself reaching for `ResolverRegistry` from a serve-mode code path, stop — the graph is the source of truth at serve. +- **`Confidence` + `source` are mandatory on every `CodeNode` / `CodeEdge`.** `DetectorEmissionDefaults.applyDefaults` stamps the per-detector floor (`LEXICAL` for regex bases, `SYNTACTIC` for AST/JavaParser/structured bases) at the orchestration boundary; detectors that consume `ctx.resolved()` upgrade to `Confidence.RESOLVED` and attach a `target_fqn` property. Reading legacy data without these fields is non-throwing — they read back as `LEXICAL` / null. +- **`JavaSymbolResolver.resolve()` allocates a fresh `JavaParser` per call.** JavaParser instances aren't thread-safe and `resolve()` is invoked from virtual threads concurrently. Per-call allocation is intentional, not a perf bug — don't "optimize" by sharing one parser across calls. +- **`JavaSymbolResolver.resolve(String)` enforces strict parse-success.** When JavaParser flags any problem (`!parseResult.isSuccessful()`), the resolver returns `EmptyResolved.INSTANCE` rather than a partial-CU `JavaResolved`. This prevents silent simple-name-only edges from broken parses that look like RESOLVED-tier coverage. Detectors must treat `ctx.resolved()` returning `EmptyResolved` as "lexical fallback" — never assume RESOLVED edges land for every Java file. +- **`JavaSymbolResolver` fields are `volatile`.** `combined` and `solver` are written by `bootstrap()` and read by `resolve()` + the public accessors from arbitrary virtual-thread carriers. The JLS Thread Start Rule covers the `executor.submit()` path; `volatile` covers post-bootstrap callers on other threads. Don't drop the keyword. - **FileHasher uses SHA-256**: Changed from MD5. Hash output is 64 hex chars (not 32). Tests must expect 64-char hashes. - **SnakeYAML parses `on` as Boolean.TRUE**: In YAML files, bare `on` key becomes `Boolean.TRUE`. Use `String.valueOf(key)` comparisons, not `Boolean.TRUE.equals(key)` (SonarCloud S2159). - **Regex possessive quantifiers**: Use `*+` instead of `*` for nested quantifiers like `([^"\\]*(?:\\.[^"\\]*)*)` → `([^"\\]*+(?:\\.[^"\\]*+)*+)` to prevent stack overflow (SonarCloud S5998). diff --git a/PROJECT_SUMMARY.md b/PROJECT_SUMMARY.md index b3f4d5ce..f718c8a2 100644 --- a/PROJECT_SUMMARY.md +++ b/PROJECT_SUMMARY.md @@ -23,7 +23,7 @@ Read directly from the `pom.xml` `` block and `src/main/frontend/pac | Graph DB | Neo4j Embedded 2026.02.3 (Community) | `pom.xml` `` | | MCP | Spring AI 2.0.0-M3 (`spring-ai-starter-mcp-server-webmvc`) | `pom.xml` `` | | CLI | Picocli 4.7.7 (`picocli-spring-boot-starter`) | `pom.xml` `` | -| AST (Java) | JavaParser 3.28.0 | `[CLAUDE.md]` — `pom.xml` references via dep | +| AST + symbols (Java) | JavaParser 3.28.0 + `javaparser-symbol-solver-core` 3.28.0 (Apache-2.0) | `pom.xml` `javaparser` deps; `intelligence/resolver/java/JavaSymbolResolver.java` | | Parsers (35+ langs) | ANTLR 4.13.2 (TS/JS, Python, Go, C#, Rust, C++) | `[CLAUDE.md]` | | Cache | H2 in embedded mode (incremental analysis cache) | `src/main/java/io/github/randomcodespace/iq/cache/AnalysisCache.java` | | Frontend | React 18.3 + AntD 5.24 + ECharts 5.6 + react-router 7 | `src/main/frontend/package.json` | @@ -114,7 +114,7 @@ CI gate is `mvn verify` — runs unit + integration tests **plus** SpotBugs and **Required env / external services:** none. codeiq is offline-first by design — Neo4j and H2 are embedded; no external server, no network calls at runtime. Air-gapped install: `git clone` + Maven mirror + `mvn package`. See [`shared/runbooks/first-time-setup.md`](shared/runbooks/first-time-setup.md). **Cache + graph dirs at runtime** (created in your scanned repo): -- `.codeiq/cache/` — H2 incremental analysis cache (`CACHE_VERSION=4` constant near the top of `cache/AnalysisCache.java`) +- `.codeiq/cache/` — H2 incremental analysis cache (`CACHE_VERSION=5` constant near the top of `cache/AnalysisCache.java`; bumped from 4 for the resolver `confidence` + `source` schema, so stale v4 caches drop and rebuild on first run after upgrade) - `.codeiq/graph/graph.db/` — Neo4j Embedded data dir ## Conventions an agent must respect @@ -138,7 +138,9 @@ CI gate is `mvn verify` — runs unit + integration tests **plus** SpotBugs and - **Edges must be attached to source nodes before `bulkSave()`.** Cypher `MATCH` silently returns 0 rows for missing source IDs — pre-validate. - **`@ActiveProfiles("test")` is required on every `@SpringBootTest`** to avoid Neo4j auto-startup conflicts. - **`AnalysisCache` uses a `ReentrantReadWriteLock`** (not `synchronized`). JEP 491 (Java 25) means lock primitives no longer pin virtual-thread carriers; the read/write lock is what prevents `ClosedChannelException` on H2's MVStore under concurrent virtual-thread access. Don't "simplify" to `synchronized`. -- **Bump `CACHE_VERSION` in `cache/AnalysisCache.java`** (top of file) when you change the file-hash algorithm or H2 schema. Stale caches auto-clear on next run. +- **Bump `CACHE_VERSION` in `cache/AnalysisCache.java`** (top of file) when you change the file-hash algorithm or H2 schema. Stale caches auto-clear on next run. Currently `5` (bumped from 4 for the resolver `confidence` + `source` schema). +- **Symbol resolver is index-time only.** `Analyzer.bootstrapResolvers()` is reached from `run` / `runBatchedIndex` / `runSmartIndex` only — never at `serve`. The SPI lives at `intelligence/resolver/`; the Java backend wraps `javaparser-symbol-solver-core`. RESOLVED-tier edges and `target_fqn` properties land at index-time and are then served read-only from Neo4j. +- **`JavaSymbolResolver.resolve(String)` enforces strict parse-success.** Partial-CU outputs from JavaParser problems are converted to `EmptyResolved` so the graph never carries phantom RESOLVED edges from broken parses. Detectors must handle `EmptyResolved` as "lexical fallback". - **SnakeYAML parses bare `on` as `Boolean.TRUE`.** Compare YAML keys with `String.valueOf(key)`, not `Boolean.TRUE.equals(key)` (SonarCloud S2159). - **Determinism gate:** every new detector needs a determinism test (run twice, assert equal output) — see existing `*DetectorTest.java` for the pattern. - **First `mvn verify` downloads ~1 GB NVD database** for OWASP dependency-check. Override locally with `-Ddependency-check.skip=true`.