Skip to content

Commit 012db33

Browse files
aksOpsclaude
andcommitted
test(resolver/java): add Layer 3 + Layer 6 — concurrency stress + determinism
Phase 7 of the resolver-and-Java-pilot plan, top two highest-leverage aggressive-testing layers. Layer 6 — JavaSymbolResolverDeterminismTest (4 tests): - sameInputResolvesToSameFqnEveryTime — single resolver, 25 iterations over the same source must produce the same resolved FQN ("com.example.a.Owner"). Pins the value-stable contract under repeated calls (different identity, same value). - twoResolverInstancesOverSameProjectAgree — two independent resolver instances bootstrapped against the same root must produce the same FQN for the same source — establishes that bootstrap is value-stable across instances, not just within one. - rebootstrapStillProducesSameFqn — resolve, rebootstrap, resolve again; FQN is unchanged. The orchestrator calls bootstrap once, but if the resolver were ever refreshed mid-run, the value contract must still hold. - deeperFqnsAreAlsoStable — same shape on a 3-segment package (com.example.inner.deep.Marker) so a divergence on a deeper lookup can't hide behind a 1-level passing test. Layer 3 — JavaSymbolResolverConcurrencyTest (3 tests): - parallelResolveNeverThrowsAndAlwaysAgrees — 256 virtual threads each resolve the same source; the aggregated FQN set must be of size 1. Catches "thread X's CU bleeds into thread Y" / shared-mutable-state classes of races. Runs cleanly on the per-call-fresh-JavaParser contract. - parallelResolveAcrossDistinctFilesProducesPerFileResults — 200 distinct Consumer files each resolved on a virtual thread; aggregate FQN set = {com.example.api.Target}. Catches "one thread's resolved state survives into another thread's resolution" classes of bugs. - parallelResolveOnGarbageInputDoesNotThrow — 256 virtual threads each pass garbage strings; no exceptions escape and no thread returns null. The resolver's "no throw, no null" contract holds under concurrency. Plan: docs/plans/2026-04-27-sub-project-1-resolver-spi-and-java-pilot.md (tasks 30 + 31). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a82b4db commit 012db33

2 files changed

Lines changed: 383 additions & 0 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package io.github.randomcodespace.iq.intelligence.resolver.java;
2+
3+
import com.github.javaparser.ast.CompilationUnit;
4+
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
5+
import com.github.javaparser.ast.body.FieldDeclaration;
6+
import com.github.javaparser.ast.type.ClassOrInterfaceType;
7+
import com.github.javaparser.ast.type.Type;
8+
import com.github.javaparser.resolution.types.ResolvedType;
9+
import io.github.randomcodespace.iq.analyzer.DiscoveredFile;
10+
import io.github.randomcodespace.iq.intelligence.resolver.ResolutionException;
11+
import io.github.randomcodespace.iq.intelligence.resolver.Resolved;
12+
import org.junit.jupiter.api.BeforeEach;
13+
import org.junit.jupiter.api.Test;
14+
import org.junit.jupiter.api.io.TempDir;
15+
16+
import java.io.IOException;
17+
import java.nio.file.Files;
18+
import java.nio.file.Path;
19+
import java.util.List;
20+
import java.util.Set;
21+
import java.util.concurrent.ConcurrentHashMap;
22+
import java.util.concurrent.ExecutorService;
23+
import java.util.concurrent.Executors;
24+
import java.util.concurrent.Future;
25+
import java.util.concurrent.TimeUnit;
26+
import java.util.stream.Collectors;
27+
import java.util.stream.IntStream;
28+
29+
import static org.junit.jupiter.api.Assertions.*;
30+
31+
/**
32+
* Phase 7 Layer 3 — virtual-thread concurrency stress for the resolver.
33+
*
34+
* <p>Production analysis fans every {@code Analyzer.run()} file across virtual
35+
* threads — every {@link JavaSymbolResolver#resolve} call therefore happens
36+
* on a different carrier with no synchronization. This test fires a lot of
37+
* concurrent {@code resolve()} calls against a bootstrapped resolver and
38+
* asserts:
39+
* <ul>
40+
* <li>no exceptions escape (the virtual-thread fan-out is exception-clean),</li>
41+
* <li>every concurrent call produces the same resolved FQN for the same
42+
* source — concurrency does not corrupt resolution,</li>
43+
* <li>per-call {@code JavaParser} allocation (not a shared instance) is
44+
* safe — JavaParser instances aren't thread-safe and the resolver's
45+
* contract is "fresh JavaParser per call".</li>
46+
* </ul>
47+
*
48+
* <p>Total time bound: kept loose ({@code timeout 60s}) — the goal is to
49+
* catch races / deadlocks, not benchmark throughput.
50+
*/
51+
class JavaSymbolResolverConcurrencyTest {
52+
53+
private static final int N_FILES = 200; // distinct files
54+
private static final int CONCURRENT_CALLS = 256; // virtual threads
55+
56+
@TempDir Path repoRoot;
57+
58+
private JavaSymbolResolver resolver;
59+
60+
@BeforeEach
61+
void setUp() throws IOException, ResolutionException {
62+
// Single-source-root layout with a target type the per-file content
63+
// imports + uses, plus N_FILES different "consumer" files that each
64+
// resolve the same target.
65+
Files.createDirectories(repoRoot.resolve("src/main/java/com/example/api"));
66+
Files.writeString(repoRoot.resolve("pom.xml"), "<project/>");
67+
Files.writeString(repoRoot.resolve("src/main/java/com/example/api/Target.java"),
68+
"""
69+
package com.example.api;
70+
public class Target {}
71+
""");
72+
73+
Path pkg = repoRoot.resolve("src/main/java/com/example/consumers");
74+
Files.createDirectories(pkg);
75+
for (int i = 0; i < N_FILES; i++) {
76+
Files.writeString(pkg.resolve("Consumer" + i + ".java"),
77+
"package com.example.consumers;\n"
78+
+ "import com.example.api.Target;\n"
79+
+ "public class Consumer" + i + " {\n"
80+
+ " private Target t;\n"
81+
+ "}\n");
82+
}
83+
84+
resolver = new JavaSymbolResolver(new JavaSourceRootDiscovery());
85+
resolver.bootstrap(repoRoot);
86+
}
87+
88+
@Test
89+
void parallelResolveNeverThrowsAndAlwaysAgrees() throws Exception {
90+
// Same content resolved CONCURRENT_CALLS times across virtual threads.
91+
// Race signal: any divergence in resolved FQN means the resolver isn't
92+
// safe under concurrent fan-out.
93+
String relPath = "src/main/java/com/example/consumers/Consumer0.java";
94+
String content = Files.readString(repoRoot.resolve(relPath));
95+
DiscoveredFile file = new DiscoveredFile(Path.of(relPath), "java", content.length());
96+
97+
Set<String> fqns = ConcurrentHashMap.newKeySet();
98+
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
99+
List<Future<String>> futures = IntStream.range(0, CONCURRENT_CALLS)
100+
.mapToObj(i -> exec.submit(() -> {
101+
Resolved r = resolver.resolve(file, content);
102+
String fqn = targetFieldFqn((JavaResolved) r);
103+
fqns.add(fqn);
104+
return fqn;
105+
}))
106+
.toList();
107+
108+
// Drain — assertAll will surface any task exception explicitly.
109+
for (Future<String> f : futures) {
110+
f.get(60, TimeUnit.SECONDS);
111+
}
112+
}
113+
114+
assertEquals(1, fqns.size(),
115+
"all concurrent resolutions must agree on the FQN — got " + fqns);
116+
assertEquals("com.example.api.Target", fqns.iterator().next());
117+
}
118+
119+
@Test
120+
void parallelResolveAcrossDistinctFilesProducesPerFileResults() throws Exception {
121+
// Each virtual thread resolves a distinct file. Aggregated set of FQNs
122+
// must still be {Target}: every consumer's field resolves to the same
123+
// target type. Catches "thread X's resolver state leaked into thread Y"
124+
// class of bugs where one thread's CU bleeds into another's resolution.
125+
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
126+
List<Future<String>> futures = IntStream.range(0, N_FILES)
127+
.mapToObj(i -> {
128+
String relPath = "src/main/java/com/example/consumers/Consumer" + i + ".java";
129+
String content;
130+
try {
131+
content = Files.readString(repoRoot.resolve(relPath));
132+
} catch (IOException e) {
133+
throw new RuntimeException(e);
134+
}
135+
DiscoveredFile file = new DiscoveredFile(Path.of(relPath), "java", content.length());
136+
return exec.submit(() ->
137+
targetFieldFqn((JavaResolved) resolver.resolve(file, content)));
138+
})
139+
.toList();
140+
141+
Set<String> distinct = futures.stream()
142+
.map(f -> {
143+
try {
144+
return f.get(60, TimeUnit.SECONDS);
145+
} catch (Exception e) {
146+
throw new RuntimeException(e);
147+
}
148+
})
149+
.collect(Collectors.toSet());
150+
151+
assertEquals(Set.of("com.example.api.Target"), distinct,
152+
"every Consumer's field resolves to the single Target FQN — concurrent runs agree");
153+
}
154+
}
155+
156+
@Test
157+
void parallelResolveOnGarbageInputDoesNotThrow() throws Exception {
158+
// The contract is "no exceptions, no nulls" even for unparseable
159+
// input. JavaParser is permissive and may produce a CU; our resolver
160+
// returns either JavaResolved (with errors attached) or
161+
// EmptyResolved.INSTANCE. Both are valid; the test asserts no
162+
// RuntimeException leaks from the executor.
163+
DiscoveredFile file = new DiscoveredFile(Path.of("Bad.java"), "java", 50);
164+
165+
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
166+
List<Future<Resolved>> futures = IntStream.range(0, CONCURRENT_CALLS)
167+
.mapToObj(i -> exec.submit(() -> resolver.resolve(file, "@@@@@ garbage input " + i)))
168+
.toList();
169+
170+
for (Future<Resolved> f : futures) {
171+
Resolved r = f.get(60, TimeUnit.SECONDS);
172+
assertNotNull(r, "resolver must never return null even under garbage input");
173+
}
174+
}
175+
}
176+
177+
// ── Helpers ──────────────────────────────────────────────────────────────
178+
179+
/** Resolve the Consumer's "t" field's declared-type FQN via the carried solver. */
180+
private static String targetFieldFqn(JavaResolved r) {
181+
CompilationUnit cu = r.cu();
182+
ClassOrInterfaceDeclaration cls = cu.findFirst(ClassOrInterfaceDeclaration.class)
183+
.orElseThrow();
184+
FieldDeclaration field = cls.getFields().stream().findFirst().orElseThrow();
185+
Type fieldType = field.getVariable(0).getType();
186+
ResolvedType rt = fieldType.asClassOrInterfaceType().resolve();
187+
return rt.isReferenceType()
188+
? rt.asReferenceType().getQualifiedName()
189+
: rt.describe();
190+
}
191+
192+
@SuppressWarnings("unused") // Reserved for future test additions that need raw type access.
193+
private static ClassOrInterfaceType firstClassOrInterfaceType(CompilationUnit cu) {
194+
return cu.findFirst(ClassOrInterfaceType.class).orElseThrow();
195+
}
196+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package io.github.randomcodespace.iq.intelligence.resolver.java;
2+
3+
import com.github.javaparser.ast.CompilationUnit;
4+
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
5+
import com.github.javaparser.ast.type.ClassOrInterfaceType;
6+
import com.github.javaparser.resolution.types.ResolvedType;
7+
import io.github.randomcodespace.iq.analyzer.DiscoveredFile;
8+
import io.github.randomcodespace.iq.intelligence.resolver.ResolutionException;
9+
import io.github.randomcodespace.iq.intelligence.resolver.Resolved;
10+
import org.junit.jupiter.api.BeforeEach;
11+
import org.junit.jupiter.api.Test;
12+
import org.junit.jupiter.api.io.TempDir;
13+
14+
import java.io.IOException;
15+
import java.nio.file.Files;
16+
import java.nio.file.Path;
17+
import java.util.List;
18+
import java.util.stream.IntStream;
19+
20+
import static org.junit.jupiter.api.Assertions.*;
21+
22+
/**
23+
* Phase 7 Layer 6 — determinism gate for the symbol resolver.
24+
*
25+
* <p>The graph-build determinism contract (same input → byte-identical graph,
26+
* every run) extends to the resolver: same project root + same source content
27+
* must produce the same {@link Resolved} shape, and the same field/type
28+
* reference must resolve to the same FQN every time.
29+
*
30+
* <p>Tested invariants:
31+
* <ol>
32+
* <li>Same source string resolved N times → identical resolved FQN.</li>
33+
* <li>Two independent resolver instances over the same project root →
34+
* identical resolved FQN for the same source.</li>
35+
* <li>Re-bootstrap on the same root → identical resolution behaviour
36+
* (the registry-side determinism guarantee, but checked at the resolver
37+
* boundary too).</li>
38+
* </ol>
39+
*/
40+
class JavaSymbolResolverDeterminismTest {
41+
42+
@TempDir Path repoRoot;
43+
44+
private static final String PET_PATH = "src/main/java/com/example/Pet.java";
45+
private static final String PET_SOURCE = """
46+
package com.example;
47+
import com.example.a.Owner;
48+
public class Pet {
49+
private Owner owner;
50+
}
51+
""";
52+
53+
@BeforeEach
54+
void setUp() throws IOException {
55+
Files.createDirectories(repoRoot.resolve("src/main/java/com/example/a"));
56+
Files.writeString(repoRoot.resolve("pom.xml"), "<project/>");
57+
Files.writeString(repoRoot.resolve("src/main/java/com/example/a/Owner.java"),
58+
"""
59+
package com.example.a;
60+
public class Owner {}
61+
""");
62+
Path absPet = repoRoot.resolve(PET_PATH);
63+
Files.createDirectories(absPet.getParent());
64+
Files.writeString(absPet, PET_SOURCE);
65+
}
66+
67+
@Test
68+
void sameInputResolvesToSameFqnEveryTime() throws ResolutionException {
69+
JavaSymbolResolver resolver = new JavaSymbolResolver(new JavaSourceRootDiscovery());
70+
resolver.bootstrap(repoRoot);
71+
DiscoveredFile file = new DiscoveredFile(Path.of(PET_PATH), "java", PET_SOURCE.length());
72+
73+
// Resolve 25 times — every call must produce the same FQN. JavaParser's
74+
// identity-not-value semantics means the JavaResolved instances differ,
75+
// but the resolved type's FQN must be stable.
76+
List<String> fqns = IntStream.range(0, 25)
77+
.mapToObj(i -> {
78+
Resolved r = resolver.resolve(file, PET_SOURCE);
79+
return ownerFieldFqn((JavaResolved) r);
80+
})
81+
.toList();
82+
83+
// All elements are the same FQN.
84+
String first = fqns.get(0);
85+
assertEquals("com.example.a.Owner", first,
86+
"first resolution must pin the imported Owner FQN");
87+
for (int i = 1; i < fqns.size(); i++) {
88+
assertEquals(first, fqns.get(i),
89+
"resolution #" + i + " diverged — determinism gate broken");
90+
}
91+
}
92+
93+
@Test
94+
void twoResolverInstancesOverSameProjectAgree() throws ResolutionException {
95+
JavaSymbolResolver a = new JavaSymbolResolver(new JavaSourceRootDiscovery());
96+
JavaSymbolResolver b = new JavaSymbolResolver(new JavaSourceRootDiscovery());
97+
a.bootstrap(repoRoot);
98+
b.bootstrap(repoRoot);
99+
100+
DiscoveredFile file = new DiscoveredFile(Path.of(PET_PATH), "java", PET_SOURCE.length());
101+
102+
String fqnA = ownerFieldFqn((JavaResolved) a.resolve(file, PET_SOURCE));
103+
String fqnB = ownerFieldFqn((JavaResolved) b.resolve(file, PET_SOURCE));
104+
105+
assertEquals("com.example.a.Owner", fqnA);
106+
assertEquals(fqnA, fqnB, "two independent resolver instances must agree on the FQN");
107+
}
108+
109+
@Test
110+
void rebootstrapStillProducesSameFqn() throws ResolutionException {
111+
// The contract: rebootstrap is allowed (idempotent in observable
112+
// behaviour). After a second bootstrap on the same root, the resolver
113+
// resolves the same input the same way.
114+
JavaSymbolResolver resolver = new JavaSymbolResolver(new JavaSourceRootDiscovery());
115+
116+
resolver.bootstrap(repoRoot);
117+
DiscoveredFile file = new DiscoveredFile(Path.of(PET_PATH), "java", PET_SOURCE.length());
118+
String first = ownerFieldFqn((JavaResolved) resolver.resolve(file, PET_SOURCE));
119+
120+
resolver.bootstrap(repoRoot); // second bootstrap on same root
121+
String second = ownerFieldFqn((JavaResolved) resolver.resolve(file, PET_SOURCE));
122+
123+
assertEquals("com.example.a.Owner", first);
124+
assertEquals(first, second, "rebootstrap must not change resolution behaviour");
125+
}
126+
127+
@Test
128+
void deeperFqnsAreAlsoStable() throws Exception {
129+
// Add a slightly deeper hierarchy to widen the determinism check —
130+
// the test is small enough that a divergence on a 1-level lookup
131+
// could hide one on a 2-level one.
132+
Files.createDirectories(repoRoot.resolve("src/main/java/com/example/inner/deep"));
133+
Files.writeString(repoRoot.resolve("src/main/java/com/example/inner/deep/Marker.java"),
134+
"""
135+
package com.example.inner.deep;
136+
public class Marker {}
137+
""");
138+
String depPath = "src/main/java/com/example/Dep.java";
139+
String depSource = """
140+
package com.example;
141+
import com.example.inner.deep.Marker;
142+
public class Dep {
143+
private Marker marker;
144+
}
145+
""";
146+
Files.writeString(repoRoot.resolve(depPath), depSource);
147+
148+
JavaSymbolResolver resolver = new JavaSymbolResolver(new JavaSourceRootDiscovery());
149+
resolver.bootstrap(repoRoot);
150+
DiscoveredFile file = new DiscoveredFile(Path.of(depPath), "java", depSource.length());
151+
152+
for (int i = 0; i < 10; i++) {
153+
JavaResolved r = (JavaResolved) resolver.resolve(file, depSource);
154+
assertEquals("com.example.inner.deep.Marker",
155+
fieldFqn(r, "marker"),
156+
"deep FQN diverged on iteration " + i);
157+
}
158+
}
159+
160+
// ── Helpers ──────────────────────────────────────────────────────────────
161+
162+
/** Resolve the Pet.owner field's declared-type FQN via the carried solver. */
163+
private static String ownerFieldFqn(JavaResolved r) {
164+
return fieldFqn(r, "owner");
165+
}
166+
167+
private static String fieldFqn(JavaResolved r, String fieldName) {
168+
CompilationUnit cu = r.cu();
169+
ClassOrInterfaceDeclaration cls = cu.findFirst(ClassOrInterfaceDeclaration.class)
170+
.orElseThrow();
171+
return cls.getFields().stream()
172+
.filter(f -> f.getVariables().stream()
173+
.anyMatch(v -> v.getNameAsString().equals(fieldName)))
174+
.findFirst()
175+
.map(f -> f.getVariable(0).getType())
176+
.filter(t -> t.isClassOrInterfaceType())
177+
.map(t -> resolveFqn(t.asClassOrInterfaceType()))
178+
.orElseThrow(() -> new AssertionError("field '" + fieldName + "' not found"));
179+
}
180+
181+
private static String resolveFqn(ClassOrInterfaceType type) {
182+
ResolvedType rt = type.resolve();
183+
return rt.isReferenceType()
184+
? rt.asReferenceType().getQualifiedName()
185+
: rt.describe();
186+
}
187+
}

0 commit comments

Comments
 (0)