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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ae7094f1..acdc9072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,8 +82,107 @@ 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). + +- **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 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`. diff --git a/pom.xml b/pom.xml index 8c610860..ff8077d5 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/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java index d066126d..707f03b3 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,65 @@ 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). + * + *

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, String content) { + Object payload = parsedAst != null ? parsedAst : content; + SymbolResolver resolver = resolverRegistry.resolverFor(file.language()); + try { + Resolved r = resolver.resolve(file, payload); + 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; + } 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; + } } /** @@ -201,6 +271,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 +573,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 +858,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 +1371,7 @@ DetectorResult analyzeFileWithRegistry(DiscoveredFile file, Path repoPath, parsedData, moduleName, infraRegistry - ); + ).withResolved(resolveFor(file, parsedData, content)); List detectors = detectorRegistry.detectorsForLanguage(file.language()); if (detectors.isEmpty()) { @@ -1503,7 +1579,7 @@ DetectorResult analyzeFile(DiscoveredFile file, Path repoPath, DetectorRegistry content, parsedData, moduleName - ); + ).withResolved(resolveFor(file, parsedData, content)); // Run matching detectors and merge results List detectors = detectorRegistry.detectorsForLanguage(file.language()); @@ -1593,7 +1669,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, content)); List detectors = detectorRegistry.detectorsForLanguage(file.language()); var allNodes = new ArrayList(); 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/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/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/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/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 4cf2e66b..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 @@ -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; @@ -37,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; @@ -71,15 +81,44 @@ 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); + // 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(); + } 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 new file mode 100644 index 00000000..ef1f4405 --- /dev/null +++ b/src/test/java/io/github/randomcodespace/iq/analyzer/AnalyzerResolverWiringTest.java @@ -0,0 +1,230 @@ +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 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; + +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"); + } + + // ── 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 + // 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())); } // ------------------------------------------------------------------------- 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); + } +} 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); } +} 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); + } +} 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(); + } +} 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 00000000..c1883fb8 Binary files /dev/null and b/src/test/java/io/github/randomcodespace/iq/intelligence/resolver/java/JavaSymbolResolverAdversarialTest.java differ 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(); + } +} 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"; + } +} 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