Skip to content

Commit 66bd4fd

Browse files
fix(intelligence): Phase 5 PE re-review round 2 — 4 extractor fixes (RAN-175)
- LanguageEnricher: replace HashMap with TreeMap for nodesByFile to ensure deterministic file-iteration order (violates CLAUDE.md otherwise) - GoLanguageExtractor: apply lookupUnambiguous() on both pkgName and full-path fallback — prevents false-positive IMPORTS edges for ambiguous short names - PythonLanguageExtractor: add Set<String> seen dedup guard so FROM_IMPORT + PLAIN_IMPORT cannot emit duplicate IMPORTS edges for the same target - PythonLanguageExtractor: strip alias before lookupUnambiguous in FROM_IMPORT loop ("Y as Z" → "Y") so aliased imports are resolved rather than silently dropped Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent 6003cb8 commit 66bd4fd

6 files changed

Lines changed: 135 additions & 14 deletions

File tree

src/main/java/io/github/randomcodespace/iq/intelligence/extractor/LanguageEnricher.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import java.util.HashMap;
1616
import java.util.List;
1717
import java.util.Map;
18+
import java.util.TreeMap;
1819

1920
/**
2021
* Runs all {@link LanguageExtractor} beans after {@link io.github.randomcodespace.iq.intelligence.lexical.LexicalEnricher}
@@ -67,8 +68,9 @@ public void enrich(List<CodeNode> nodes, List<CodeEdge> edges, Path rootPath) {
6768
extractorByLanguage.put(extractor.getLanguage(), extractor);
6869
}
6970

70-
// Group nodes by file path (read each file only once)
71-
Map<String, List<CodeNode>> nodesByFile = new HashMap<>();
71+
// Group nodes by file path (read each file only once).
72+
// TreeMap guarantees deterministic iteration order (alphabetical by path).
73+
Map<String, List<CodeNode>> nodesByFile = new TreeMap<>();
7274
for (CodeNode node : nodes) {
7375
if (node.getFilePath() != null) {
7476
nodesByFile.computeIfAbsent(node.getFilePath(), k -> new ArrayList<>()).add(node);

src/main/java/io/github/randomcodespace/iq/intelligence/extractor/go/GoLanguageExtractor.java

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,14 @@ private List<CodeEdge> extractImportEdges(DetectorContext ctx, CodeNode node,
8787
List<String> importPaths = collectImportPaths(ctx.content());
8888

8989
for (String importPath : importPaths) {
90-
// Match by last path segment (package name)
90+
// Match by last path segment (package name) with ambiguity guard.
91+
// Short names like "db", "log", "config" can match multiple nodes —
92+
// skip the edge if more than one node shares the label.
9193
String pkgName = importPath.substring(importPath.lastIndexOf('/') + 1);
92-
CodeNode target = registry.get(pkgName);
94+
CodeNode target = lookupUnambiguous(pkgName, registry);
9395
if (target == null) {
94-
// Try full path
95-
target = registry.get(importPath);
96+
// Try full path as direct registry key fallback
97+
target = lookupUnambiguous(importPath, registry);
9698
}
9799
if (target != null && !target.getId().equals(node.getId())) {
98100
String edgeId = "imports:%s:%s".formatted(node.getId(), target.getId());
@@ -155,4 +157,20 @@ private Map<String, String> extractInterfaceHints(DetectorContext ctx, CodeNode
155157
}
156158
return Map.of();
157159
}
160+
161+
/**
162+
* Look up a node by label, returning null if zero or more than one node matches.
163+
* Prevents false-positive IMPORTS edges for short package names like {@code db},
164+
* {@code log}, {@code config} that may match multiple nodes in the registry.
165+
*/
166+
private CodeNode lookupUnambiguous(String label, Map<String, CodeNode> registry) {
167+
CodeNode match = null;
168+
for (CodeNode candidate : registry.values()) {
169+
if (label.equals(candidate.getLabel())) {
170+
if (match != null) return null; // ambiguous
171+
match = candidate;
172+
}
173+
}
174+
return match;
175+
}
158176
}

src/main/java/io/github/randomcodespace/iq/intelligence/extractor/python/PythonLanguageExtractor.java

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111

1212
import java.util.ArrayList;
1313
import java.util.LinkedHashMap;
14+
import java.util.LinkedHashSet;
1415
import java.util.List;
1516
import java.util.Map;
17+
import java.util.Set;
1618
import java.util.regex.Matcher;
1719
import java.util.regex.Pattern;
1820

@@ -70,19 +72,25 @@ private List<CodeEdge> extractImportEdges(DetectorContext ctx, CodeNode node,
7072
if (ctx.content() == null || registry.isEmpty()) return List.of();
7173

7274
List<CodeEdge> edges = new ArrayList<>();
75+
Set<String> seen = new LinkedHashSet<>();
7376

7477
Matcher from = FROM_IMPORT.matcher(ctx.content());
7578
while (from.find()) {
7679
for (String symbol : from.group(1).split(",")) {
7780
String sym = symbol.trim();
7881
if (sym.isEmpty()) continue;
82+
// Strip alias: "Foo as bar" → "Foo"
83+
int asIdx = sym.indexOf(" as ");
84+
if (asIdx >= 0) sym = sym.substring(0, asIdx).trim();
7985
CodeNode target = lookupUnambiguous(sym, registry);
8086
if (target != null && !target.getId().equals(node.getId())) {
8187
String edgeId = "imports:%s:%s".formatted(node.getId(), target.getId());
82-
CodeEdge edge = new CodeEdge(edgeId, EdgeKind.IMPORTS, node.getId(), target);
83-
edge.getProperties().put("confidence", "PARTIAL");
84-
edge.getProperties().put("extractorName", "python_language_extractor");
85-
edges.add(edge);
88+
if (seen.add(edgeId)) {
89+
CodeEdge edge = new CodeEdge(edgeId, EdgeKind.IMPORTS, node.getId(), target);
90+
edge.getProperties().put("confidence", "PARTIAL");
91+
edge.getProperties().put("extractorName", "python_language_extractor");
92+
edges.add(edge);
93+
}
8694
}
8795
}
8896
}
@@ -93,10 +101,12 @@ private List<CodeEdge> extractImportEdges(DetectorContext ctx, CodeNode node,
93101
CodeNode target = lookupUnambiguous(sym, registry);
94102
if (target != null && !target.getId().equals(node.getId())) {
95103
String edgeId = "imports:%s:%s".formatted(node.getId(), target.getId());
96-
CodeEdge edge = new CodeEdge(edgeId, EdgeKind.IMPORTS, node.getId(), target);
97-
edge.getProperties().put("confidence", "PARTIAL");
98-
edge.getProperties().put("extractorName", "python_language_extractor");
99-
edges.add(edge);
104+
if (seen.add(edgeId)) {
105+
CodeEdge edge = new CodeEdge(edgeId, EdgeKind.IMPORTS, node.getId(), target);
106+
edge.getProperties().put("confidence", "PARTIAL");
107+
edge.getProperties().put("extractorName", "python_language_extractor");
108+
edges.add(edge);
109+
}
100110
}
101111
}
102112

src/test/java/io/github/randomcodespace/iq/intelligence/extractor/LanguageEnricherTest.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,45 @@ public LanguageExtractionResult extract(DetectorContext ctx, CodeNode n) {
162162
assertThat(calledFor.get(0)).isEqualTo("javascript");
163163
}
164164

165+
@Test
166+
void enrich_nodesByFile_processedInDeterministicOrder() throws IOException {
167+
Path aFile = tempDir.resolve("a.java");
168+
Path bFile = tempDir.resolve("b.java");
169+
Files.writeString(aFile, "class A {}", StandardCharsets.UTF_8);
170+
Files.writeString(bFile, "class B {}", StandardCharsets.UTF_8);
171+
172+
CodeNode nodeA = node("n:a.java:class:A", NodeKind.CLASS, "A", "a.java");
173+
CodeNode nodeB = node("n:b.java:class:B", NodeKind.CLASS, "B", "b.java");
174+
175+
List<String> run1Order = new ArrayList<>();
176+
List<String> run2Order = new ArrayList<>();
177+
178+
LanguageEnricher enricher1 = new LanguageEnricher(List.of(new LanguageExtractor() {
179+
@Override public String getLanguage() { return "java"; }
180+
@Override
181+
public LanguageExtractionResult extract(DetectorContext ctx, CodeNode n) {
182+
run1Order.add(ctx.filePath());
183+
return LanguageExtractionResult.empty();
184+
}
185+
}));
186+
187+
LanguageEnricher enricher2 = new LanguageEnricher(List.of(new LanguageExtractor() {
188+
@Override public String getLanguage() { return "java"; }
189+
@Override
190+
public LanguageExtractionResult extract(DetectorContext ctx, CodeNode n) {
191+
run2Order.add(ctx.filePath());
192+
return LanguageExtractionResult.empty();
193+
}
194+
}));
195+
196+
// Input node order differs between runs; file iteration must be alphabetical in both.
197+
enricher1.enrich(List.of(nodeA, nodeB), new ArrayList<>(), tempDir);
198+
enricher2.enrich(List.of(nodeB, nodeA), new ArrayList<>(), tempDir);
199+
200+
assertThat(run1Order).isEqualTo(run2Order);
201+
assertThat(run1Order).containsExactly("a.java", "b.java");
202+
}
203+
165204
@Test
166205
void detectLanguage_mapsExtensionsCorrectly() {
167206
assertThat(LanguageEnricher.detectLanguage("Foo.java")).isEqualTo("java");

src/test/java/io/github/randomcodespace/iq/intelligence/extractor/go/GoLanguageExtractorTest.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,26 @@ void extract_determinism_sameTwice() {
146146
}
147147
}
148148

149+
@Test
150+
void extract_ambiguousPackageName_noFalsePositiveEdge() {
151+
CodeNode source = node("go:main.go:fn:main", NodeKind.METHOD, "main");
152+
// Two unrelated nodes share the short label "db" — common in Go codebases.
153+
CodeNode db1 = node("go:db/conn.go:module:db", NodeKind.MODULE, "db");
154+
CodeNode db2 = node("go:dbutil/query.go:module:db", NodeKind.MODULE, "db");
155+
156+
Map<String, CodeNode> registry = new LinkedHashMap<>();
157+
registry.put(db1.getId(), db1);
158+
registry.put(db2.getId(), db2);
159+
160+
String content = "import \"myapp/db\"\n";
161+
162+
DetectorContext ctx = new DetectorContext("main.go", "go", content, registry, null);
163+
LanguageExtractionResult result = extractor.extract(ctx, source);
164+
165+
// Ambiguous: two nodes labelled "db" → lookupUnambiguous returns null → no edge.
166+
assertThat(result.symbolReferences()).isEmpty();
167+
}
168+
149169
@Test
150170
void extract_satisfiesInterfaces_hintIsDeterministicallySorted() {
151171
CodeNode struct = node("go:s.go:struct:Worker", NodeKind.CLASS, "Worker");

src/test/java/io/github/randomcodespace/iq/intelligence/extractor/python/PythonLanguageExtractorTest.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,38 @@ void extract_determinism_sameTwice() {
122122
assertThat(r1.typeHints()).isEqualTo(r2.typeHints());
123123
}
124124

125+
@Test
126+
void extract_fromImportAndPlainImportSameSymbol_noDuplicateEdges() {
127+
CodeNode source = node("py:app.py:fn:run", NodeKind.METHOD, "run");
128+
CodeNode target = node("py:utils.py:class:Foo", NodeKind.CLASS, "Foo");
129+
Map<String, CodeNode> registry = Map.of(target.getLabel(), target);
130+
131+
// Both FROM_IMPORT and PLAIN_IMPORT match Foo → same edge id must deduplicate.
132+
String content = "from utils import Foo\nimport Foo\n";
133+
134+
DetectorContext ctx = new DetectorContext("app.py", "python", content, registry, null);
135+
LanguageExtractionResult result = extractor.extract(ctx, source);
136+
137+
assertThat(result.symbolReferences()).hasSize(1);
138+
assertThat(result.symbolReferences().get(0).getTarget().getId()).isEqualTo(target.getId());
139+
}
140+
141+
@Test
142+
void extract_aliasedFromImport_edgeCreatedUsingOriginalName() {
143+
CodeNode source = node("py:app.py:fn:run", NodeKind.METHOD, "run");
144+
CodeNode target = node("py:models.py:class:User", NodeKind.CLASS, "User");
145+
Map<String, CodeNode> registry = Map.of(target.getLabel(), target);
146+
147+
// Alias "as u" must be stripped so lookup is on "User", not "User as u".
148+
String content = "from models import User as u\n";
149+
150+
DetectorContext ctx = new DetectorContext("app.py", "python", content, registry, null);
151+
LanguageExtractionResult result = extractor.extract(ctx, source);
152+
153+
assertThat(result.symbolReferences()).hasSize(1);
154+
assertThat(result.symbolReferences().get(0).getTarget().getId()).isEqualTo(target.getId());
155+
}
156+
125157
@Test
126158
void extract_ambiguousShortSymbolName_noFalsePositiveEdge() {
127159
CodeNode source = node("py:app.py:fn:run", NodeKind.METHOD, "run");

0 commit comments

Comments
 (0)