Skip to content

Commit 541fc81

Browse files
authored
Merge pull request #31 from RandomCodeSpace/fix/major-issues-phase5
2 parents ca0d932 + 99d5e89 commit 541fc81

7 files changed

Lines changed: 171 additions & 43 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,4 @@ helm/
8282
# Neo4j embedded data
8383
neo4j-data/
8484
graph.db/
85+
.worktrees/

CLAUDE.md

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Remote server (or local):
4444

4545
```
4646
index: FileDiscovery → Parsers → Detectors (virtual threads) → GraphBuilder → H2 cache
47-
enrich: H2 → Linkers → LayerClassifier → ServiceDetector → Neo4j (UNWIND bulk-load)
47+
enrich: H2 → Linkers → LayerClassifier → LexicalEnricher → LanguageEnricher → ServiceDetector → Neo4j (UNWIND bulk-load)
4848
serve: Neo4j → GraphStore → QueryService → REST API / MCP / Web UI
4949
```
5050

@@ -96,8 +96,17 @@ io.github.randomcodespace.iq
9696
|-- grammar/ # ANTLR parser factory + generated parsers
9797
|-- graph/ # GraphStore (Neo4j facade), GraphRepository (SDN, writes only)
9898
|-- health/ # GraphHealthIndicator (Spring Actuator)
99-
|-- mcp/ # McpTools (31 @McpTool methods, read-only)
99+
|-- mcp/ # McpTools (34 @McpTool methods, read-only)
100100
|-- model/ # CodeNode, CodeEdge, NodeKind (32), EdgeKind (27)
101+
|-- intelligence/ # Intelligence enrichment (Phase 2-5)
102+
| |-- lexical/ # LexicalEnricher, LexicalQueryService, DocCommentExtractor, SnippetStore
103+
| |-- extractor/ # LanguageEnricher, LanguageExtractor, LanguageExtractionResult
104+
| | |-- java/ # JavaLanguageExtractor
105+
| | |-- typescript/ # TypeScriptLanguageExtractor
106+
| | |-- python/ # PythonLanguageExtractor
107+
| | |-- go/ # GoLanguageExtractor
108+
| |-- evidence/ # EvidencePack, EvidencePackAssembler
109+
| |-- query/ # QueryPlanner, QueryRoute, QueryPlan
101110
|-- query/ # QueryService, StatsService (categorized), TopologyService
102111
|-- web/ # Static resource serving (React SPA)
103112
```
@@ -162,7 +171,7 @@ code-iq serve /path/to/repo # needs enrich if using index
162171

163172
## Server Endpoints (all read-only)
164173

165-
### REST API (`/api`) -- 34 endpoints
174+
### REST API (`/api`) -- 37 endpoints
166175

167176
**GraphController** (`/api`):
168177
- `GET /api/stats` -- Rich categorized statistics (graph, languages, frameworks, infra, connections, auth, architecture)
@@ -200,8 +209,13 @@ code-iq serve /path/to/repo # needs enrich if using index
200209
- `GET /api/flow/{view}/{nodeId}/children` -- Node children in flow
201210
- `GET /api/flow/{view}/{nodeId}/parent` -- Node parent in flow
202211

203-
### MCP Tools (31, via `@McpTool` annotation)
204-
`get_stats`, `get_detailed_stats`, `query_nodes`, `query_edges`, `get_node_neighbors`, `get_ego_graph`, `find_cycles`, `find_shortest_path`, `find_consumers`, `find_producers`, `find_callers`, `find_dependencies`, `find_dependents`, `find_dead_code`, `generate_flow`, `run_cypher`, `find_component_by_file`, `trace_impact`, `find_related_endpoints`, `search_graph`, `read_file`, `get_topology`, `service_detail`, `service_dependencies`, `service_dependents`, `blast_radius`, `find_path`, `find_bottlenecks`, `find_circular_deps`, `find_dead_services`, `find_node`
212+
**IntelligenceController** (`/api/intelligence`):
213+
- `GET /api/intelligence/evidence` -- Evidence pack for a node
214+
- `GET /api/intelligence/manifest` -- Artifact manifest
215+
- `GET /api/intelligence/capabilities` -- Capability matrix
216+
217+
### MCP Tools (34, via `@McpTool` annotation)
218+
`get_stats`, `get_detailed_stats`, `query_nodes`, `query_edges`, `get_node_neighbors`, `get_ego_graph`, `find_cycles`, `find_shortest_path`, `find_consumers`, `find_producers`, `find_callers`, `find_dependencies`, `find_dependents`, `find_dead_code`, `generate_flow`, `run_cypher`, `find_component_by_file`, `trace_impact`, `find_related_endpoints`, `search_graph`, `read_file`, `get_topology`, `service_detail`, `service_dependencies`, `service_dependents`, `blast_radius`, `find_path`, `find_bottlenecks`, `find_circular_deps`, `find_dead_services`, `find_node`, `get_evidence_pack`, `get_artifact_metadata`, `get_capabilities`
205219

206220
## Adding a New Detector
207221

@@ -309,13 +323,18 @@ mvn dependency-check:check
309323
| `config/JacksonConfig.java` | Jackson config (FAIL_ON_UNKNOWN_PROPERTIES disabled for MCP compat) |
310324
| `cache/AnalysisCache.java` | H2 incremental cache |
311325
| `api/GraphController.java` | REST API endpoints (read-only) |
312-
| `mcp/McpTools.java` | 31 MCP tool definitions (`@McpTool`, read-only) |
326+
| `mcp/McpTools.java` | 34 MCP tool definitions (`@McpTool`, read-only) |
313327
| `query/QueryService.java` | Graph query operations with Spring caching |
314328
| `query/StatsService.java` | Rich categorized statistics (7 categories) |
315329
| `query/TopologyService.java` | Service topology queries |
316330
| `cli/IndexCommand.java` | Memory-efficient batched indexing to H2 |
317331
| `cli/EnrichCommand.java` | H2 → Neo4j with linkers, layers, services |
318332
| `cli/ServeCommand.java` | Read-only server startup |
333+
| `intelligence/extractor/LanguageEnricher.java` | Language-specific enrichment orchestrator (Phase 5) |
334+
| `intelligence/extractor/LanguageExtractor.java` | Language extractor interface |
335+
| `intelligence/evidence/EvidencePackAssembler.java` | Evidence pack generation |
336+
| `intelligence/query/QueryPlanner.java` | Intelligent query routing |
337+
| `intelligence/lexical/LexicalEnricher.java` | Doc comment + snippet enrichment |
319338

320339
## Code Conventions
321340

src/main/java/io/github/randomcodespace/iq/cli/EnrichCommand.java

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,8 @@ private int enrichFromCache(AnalysisCache cache, Path root, NumberFormat nf, Ins
137137
GraphBuilder.FlushResult flushed = builder.flush();
138138
List<CodeEdge> recoveredEdges = builder.flushDeferred();
139139

140-
List<CodeNode> enrichedNodes = builder.getNodes();
141-
List<CodeEdge> enrichedEdges = builder.getEdges();
140+
List<CodeNode> enrichedNodes = new ArrayList<>(builder.getNodes());
141+
List<CodeEdge> enrichedEdges = new ArrayList<>(builder.getEdges());
142142

143143
int linkerNodeDelta = enrichedNodes.size() - allNodes.size();
144144
int linkerEdgeDelta = enrichedEdges.size() - allEdges.size();
@@ -169,8 +169,8 @@ private int enrichFromCache(AnalysisCache cache, Path root, NumberFormat nf, Ins
169169
// Add service nodes and edges to the builder
170170
builder.addNodes(serviceResult.serviceNodes());
171171
builder.addEdges(serviceResult.serviceEdges());
172-
enrichedNodes = builder.getNodes();
173-
enrichedEdges = builder.getEdges();
172+
enrichedNodes = new ArrayList<>(builder.getNodes());
173+
enrichedEdges = new ArrayList<>(builder.getEdges());
174174
CliOutput.info(" Detected " + serviceResult.serviceNodes().size() + " service(s)");
175175
}
176176

@@ -374,11 +374,4 @@ private int enrichFromCache(AnalysisCache cache, Path root, NumberFormat nf, Ins
374374
}
375375
}
376376

377-
/**
378-
* Sanitize relationship type for Neo4j Cypher.
379-
* Neo4j relationship types must be alphanumeric + underscore.
380-
*/
381-
private static String sanitizeRelType(String kind) {
382-
return kind.replaceAll("[^A-Za-z0-9_]", "_").toUpperCase();
383-
}
384377
}

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

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,11 @@ public class GoLanguageExtractor implements LanguageExtractor {
5656
Pattern.compile("^import\\s+(?:\\w+\\s+)?\"([^\"]+)\"", Pattern.MULTILINE);
5757

5858
/**
59-
* Method signature in an interface: {@code MethodName(params) ReturnType}.
59+
* Go receiver method: {@code func (varName StructName) MethodName(}.
60+
* Captures: group(1) = struct name (with optional pointer *), group(2) = method name.
6061
*/
61-
private static final Pattern INTERFACE_METHOD =
62-
Pattern.compile("^\\s+(\\w+)\\s*\\(", Pattern.MULTILINE);
62+
private static final Pattern RECEIVER_METHOD =
63+
Pattern.compile("func\\s+\\(\\w+\\s+(\\*?\\w+)\\)\\s+(\\w+)\\s*\\(", Pattern.MULTILINE);
6364

6465
@Override
6566
public String getLanguage() {
@@ -84,6 +85,7 @@ private List<CodeEdge> extractImportEdges(DetectorContext ctx, CodeNode node,
8485
if (ctx.content() == null || registry.isEmpty()) return List.of();
8586

8687
List<CodeEdge> edges = new ArrayList<>();
88+
Set<String> seen = new LinkedHashSet<>();
8789
List<String> importPaths = collectImportPaths(ctx.content());
8890

8991
for (String importPath : importPaths) {
@@ -98,10 +100,12 @@ private List<CodeEdge> extractImportEdges(DetectorContext ctx, CodeNode node,
98100
}
99101
if (target != null && !target.getId().equals(node.getId())) {
100102
String edgeId = "imports:%s:%s".formatted(node.getId(), target.getId());
101-
CodeEdge edge = new CodeEdge(edgeId, EdgeKind.IMPORTS, node.getId(), target);
102-
edge.getProperties().put("confidence", "PARTIAL");
103-
edge.getProperties().put("extractorName", "go_language_extractor");
104-
edges.add(edge);
103+
if (seen.add(edgeId)) {
104+
CodeEdge edge = new CodeEdge(edgeId, EdgeKind.IMPORTS, node.getId(), target);
105+
edge.getProperties().put("confidence", "PARTIAL");
106+
edge.getProperties().put("extractorName", "go_language_extractor");
107+
edges.add(edge);
108+
}
105109
}
106110
}
107111

@@ -129,8 +133,13 @@ private List<String> collectImportPaths(String content) {
129133

130134
/**
131135
* Structural interface satisfaction: if this node is a CLASS/COMPONENT (struct),
132-
* find INTERFACE nodes whose method names all appear in the struct's source file.
133-
* Records satisfied interface names as a type hint.
136+
* find INTERFACE nodes in the registry that this struct likely satisfies.
137+
*
138+
* <p>Strategy: extract all method names defined via Go receiver syntax
139+
* ({@code func (v StructName) MethodName(...)}) then check whether any interface
140+
* label matches using Go naming convention — e.g. {@code Closer} is satisfied by
141+
* method {@code Close}, {@code Reader} by {@code Read}, {@code Stringer} by
142+
* {@code String}.
134143
*/
135144
private Map<String, String> extractInterfaceHints(DetectorContext ctx, CodeNode node,
136145
Map<String, CodeNode> registry) {
@@ -139,14 +148,24 @@ private Map<String, String> extractInterfaceHints(DetectorContext ctx, CodeNode
139148
return Map.of();
140149
}
141150

151+
// Collect all method names defined on this struct via Go receiver syntax
152+
Set<String> receiverMethods = new LinkedHashSet<>();
153+
Matcher rm = RECEIVER_METHOD.matcher(ctx.content());
154+
while (rm.find()) {
155+
String structName = rm.group(1).replace("*", "");
156+
if (structName.equals(node.getLabel())) {
157+
receiverMethods.add(rm.group(2));
158+
}
159+
}
160+
if (receiverMethods.isEmpty()) return Map.of();
161+
162+
// Best-effort: interface "satisfied" if struct has a receiver method whose name
163+
// starts with the interface label (Go convention: Closer→Close, Reader→Read, Stringer→String)
142164
List<String> satisfied = new ArrayList<>();
143165
for (CodeNode candidate : registry.values()) {
144166
if (candidate.getKind() != NodeKind.INTERFACE) continue;
145-
if (candidate.getFilePath() == null) continue;
146-
// We can only do best-effort matching without the interface file content here.
147-
// Check by label match (struct label appears as receiver type).
148-
if (node.getLabel() != null && candidate.getLabel() != null
149-
&& ctx.content().contains(node.getLabel() + ") " + candidate.getLabel())) {
167+
if (candidate.getLabel() == null) continue;
168+
if (receiverMethods.stream().anyMatch(m -> candidate.getLabel().startsWith(m))) {
150169
satisfied.add(candidate.getLabel());
151170
}
152171
}
@@ -159,16 +178,21 @@ private Map<String, String> extractInterfaceHints(DetectorContext ctx, CodeNode
159178
}
160179

161180
/**
162-
* Look up a node by label, returning null if zero or more than one node matches.
181+
* Look up a node by label, returning null if zero or more than one distinct node matches.
163182
* Prevents false-positive IMPORTS edges for short package names like {@code db},
164183
* {@code log}, {@code config} that may match multiple nodes in the registry.
184+
* Deduplicates by node ID so that the same node stored under multiple keys is not
185+
* counted as ambiguous.
165186
*/
166187
private CodeNode lookupUnambiguous(String label, Map<String, CodeNode> registry) {
167188
CodeNode match = null;
168189
for (CodeNode candidate : registry.values()) {
169190
if (label.equals(candidate.getLabel())) {
170-
if (match != null) return null; // ambiguous
171-
match = candidate;
191+
if (match == null) {
192+
match = candidate;
193+
} else if (!match.getId().equals(candidate.getId())) {
194+
return null; // genuinely ambiguous — two distinct nodes share the label
195+
}
172196
}
173197
}
174198
return match;

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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@
3939
public class JavaLanguageExtractor implements LanguageExtractor {
4040

4141
private static final Logger log = LoggerFactory.getLogger(JavaLanguageExtractor.class);
42-
private static final ThreadLocal<JavaParser> PARSER = ThreadLocal.withInitial(JavaParser::new);
4342

4443
@Override
4544
public String getLanguage() {
@@ -127,12 +126,10 @@ private void extractTypeHierarchyHints(CompilationUnit cu, CodeNode classNode,
127126
private Optional<CompilationUnit> parse(DetectorContext ctx) {
128127
try {
129128
if (ctx.content() == null || ctx.content().isEmpty()) return Optional.empty();
130-
return PARSER.get().parse(ctx.content()).getResult();
129+
return new JavaParser().parse(ctx.content()).getResult();
131130
} catch (Exception | AssertionError e) {
132131
log.debug("JavaParser failed for {}: {}", ctx.filePath(), e.getMessage());
133132
return Optional.empty();
134-
} finally {
135-
PARSER.remove();
136133
}
137134
}
138135

src/test/java/io/github/randomcodespace/iq/cli/EnrichCommandTest.java

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import io.github.randomcodespace.iq.config.CodeIqConfig;
77
import io.github.randomcodespace.iq.intelligence.extractor.LanguageEnricher;
88
import io.github.randomcodespace.iq.intelligence.lexical.LexicalEnricher;
9+
import io.github.randomcodespace.iq.intelligence.extractor.java.JavaLanguageExtractor;
910
import io.github.randomcodespace.iq.model.CodeEdge;
1011
import io.github.randomcodespace.iq.model.CodeNode;
1112
import io.github.randomcodespace.iq.model.EdgeKind;
@@ -56,10 +57,7 @@ void enrichFailsWhenNoIndexExists(@TempDir Path tempDir) {
5657
var cmdLine = new picocli.CommandLine(cmd);
5758
int exitCode = cmdLine.execute(tempDir.toString());
5859

59-
// Should fail because no H2 index exists (or succeed creating empty DB)
60-
// The command tries to load from H2 which may be empty
61-
// At minimum it should not crash
62-
assertTrue(exitCode == 0 || exitCode == 1);
60+
assertEquals(1, exitCode, "enrich with no H2 index should fail with exit code 1");
6361
}
6462

6563
@Test
@@ -129,4 +127,33 @@ void enrichClassifiesLayers(@TempDir Path tempDir) throws Exception {
129127

130128
assertEquals(0, exitCode);
131129
}
130+
131+
@Test
132+
void enrichedEdgesAreMutableForLanguageEnricher(@TempDir Path tempDir) throws Exception {
133+
// Create a minimal H2 index so enrich has data to process
134+
Path cacheDir = tempDir.resolve(".code-intelligence");
135+
Files.createDirectories(cacheDir);
136+
Path cachePath = cacheDir.resolve("analysis-cache.db");
137+
138+
try (var cache = new AnalysisCache(cachePath)) {
139+
var node = new CodeNode("test:Foo.java:class:Foo", NodeKind.CLASS, "Foo");
140+
node.setFilePath("Foo.java");
141+
cache.storeResults("abc123", "Foo.java", "java", List.of(node), List.of());
142+
}
143+
144+
var config = new CodeIqConfig();
145+
var layerClassifier = new LayerClassifier();
146+
List<Linker> linkers = List.of();
147+
148+
// Use a real LanguageEnricher with extractors to trigger edges.addAll()
149+
var enricher = new LanguageEnricher(List.of(new JavaLanguageExtractor()));
150+
var cmd = new EnrichCommand(config, layerClassifier, linkers, new LexicalEnricher(), enricher);
151+
var cmdLine = new picocli.CommandLine(cmd);
152+
153+
// This should NOT throw UnsupportedOperationException
154+
int exitCode = cmdLine.execute(tempDir.toString());
155+
// May fail for other reasons (no source files to read), but must not crash on immutable list
156+
assertTrue(exitCode == 0 || exitCode == 1,
157+
"EnrichCommand crashed — likely UnsupportedOperationException on immutable edges list");
158+
}
132159
}

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

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,9 @@ void extract_satisfiesInterfaces_hintIsDeterministicallySorted() {
182182
registry.put(ifaceReader.getLabel(), ifaceReader);
183183
registry.put(ifaceCloser.getLabel(), ifaceCloser);
184184

185-
String content = "Worker) Reader\nWorker) Closer\n";
185+
// Real Go receiver syntax: func (w Worker) Read(...) and func (w Worker) Close()
186+
String content = "func (w Worker) Read(p []byte) (int, error) { return 0, nil }\n"
187+
+ "func (w Worker) Close() error { return nil }\n";
186188

187189
DetectorContext ctx = new DetectorContext("s.go", "go", content, registry, null);
188190
LanguageExtractionResult r1 = extractor.extract(ctx, struct);
@@ -193,6 +195,71 @@ void extract_satisfiesInterfaces_hintIsDeterministicallySorted() {
193195
assertThat(r1.typeHints()).isEqualTo(r2.typeHints());
194196
}
195197

198+
@Test
199+
void extract_realGoReceiverSyntax_detectsInterfaceSatisfaction() {
200+
var extractor = new GoLanguageExtractor();
201+
String content = """
202+
package main
203+
204+
type Worker struct {}
205+
206+
func (w Worker) Read(p []byte) (int, error) {
207+
return 0, nil
208+
}
209+
210+
func (w Worker) Close() error {
211+
return nil
212+
}
213+
""";
214+
215+
CodeNode structNode = node("go:main.go:class:Worker", NodeKind.CLASS, "Worker");
216+
structNode.setFilePath("main.go");
217+
CodeNode readerIface = node("go:io/reader.go:interface:Reader", NodeKind.INTERFACE, "Reader");
218+
readerIface.setFilePath("io/reader.go");
219+
CodeNode closerIface = node("go:io/closer.go:interface:Closer", NodeKind.INTERFACE, "Closer");
220+
closerIface.setFilePath("io/closer.go");
221+
222+
Map<String, CodeNode> registry = new java.util.LinkedHashMap<>();
223+
registry.put(structNode.getId(), structNode);
224+
registry.put(readerIface.getId(), readerIface);
225+
registry.put(closerIface.getId(), closerIface);
226+
227+
var ctx = new DetectorContext("main.go", "go", content, registry, null);
228+
var result = extractor.extract(ctx, structNode);
229+
230+
assertThat(result.typeHints()).containsKey("satisfies_interfaces");
231+
assertThat(result.typeHints().get("satisfies_interfaces")).contains("Closer");
232+
assertThat(result.typeHints().get("satisfies_interfaces")).contains("Reader");
233+
}
234+
235+
@Test
236+
void extract_samePkgMatchedByNameAndPath_noDuplicateEdges() {
237+
var extractor = new GoLanguageExtractor();
238+
String content = """
239+
package main
240+
import "github.com/myorg/utils"
241+
import utils "github.com/myorg/utils"
242+
""";
243+
244+
CodeNode source = node("go:main.go:package:main", NodeKind.MODULE, "main");
245+
source.setFilePath("main.go");
246+
CodeNode target = node("go:utils/utils.go:package:utils", NodeKind.MODULE, "utils");
247+
target.setFilePath("utils/utils.go");
248+
249+
Map<String, CodeNode> registry = new java.util.LinkedHashMap<>();
250+
registry.put(target.getId(), target);
251+
registry.put(target.getLabel(), target);
252+
registry.put("github.com/myorg/utils", target);
253+
254+
var ctx = new DetectorContext("main.go", "go", content, registry, null);
255+
var result = extractor.extract(ctx, source);
256+
257+
long importEdges = result.symbolReferences().stream()
258+
.filter(e -> e.getKind() == EdgeKind.IMPORTS)
259+
.count();
260+
assertThat(importEdges).isEqualTo(1);
261+
}
262+
196263
private static CodeNode node(String id, NodeKind kind, String label) {
197264
CodeNode n = new CodeNode(id, kind, label);
198265
n.setFqn(id);

0 commit comments

Comments
 (0)