Skip to content

Commit ca0d932

Browse files
authored
feat(intelligence): Phase 5 language-specific enrichment — merge to main (RAN-162)
feat(intelligence): Phase 5 language-specific enrichment (RAN-162)
2 parents f06ff60 + 66bd4fd commit ca0d932

14 files changed

Lines changed: 1798 additions & 4 deletions

File tree

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import io.github.randomcodespace.iq.cache.AnalysisCache;
77
import io.github.randomcodespace.iq.config.CodeIqConfig;
88
import io.github.randomcodespace.iq.intelligence.RepositoryIdentity;
9+
import io.github.randomcodespace.iq.intelligence.extractor.LanguageEnricher;
910
import io.github.randomcodespace.iq.intelligence.lexical.LexicalEnricher;
1011
import io.github.randomcodespace.iq.model.CodeEdge;
1112
import io.github.randomcodespace.iq.model.CodeNode;
@@ -61,13 +62,16 @@ public class EnrichCommand implements Callable<Integer> {
6162
private final LayerClassifier layerClassifier;
6263
private final List<Linker> linkers;
6364
private final LexicalEnricher lexicalEnricher;
65+
private final LanguageEnricher languageEnricher;
6466

6567
public EnrichCommand(CodeIqConfig config, LayerClassifier layerClassifier,
66-
List<Linker> linkers, LexicalEnricher lexicalEnricher) {
68+
List<Linker> linkers, LexicalEnricher lexicalEnricher,
69+
LanguageEnricher languageEnricher) {
6770
this.config = config;
6871
this.layerClassifier = layerClassifier;
6972
this.linkers = linkers;
7073
this.lexicalEnricher = lexicalEnricher;
74+
this.languageEnricher = languageEnricher;
7175
}
7276

7377
@Override
@@ -151,6 +155,10 @@ private int enrichFromCache(AnalysisCache cache, Path root, NumberFormat nf, Ins
151155
CliOutput.step("\uD83D\uDD0D", "Enriching lexical metadata...");
152156
lexicalEnricher.enrich(enrichedNodes, root);
153157

158+
// 3b2. Language-specific enrichment (call graph, type hints, import resolution)
159+
CliOutput.step("\uD83D\uDD0D", "Running language-specific enrichment...");
160+
languageEnricher.enrich(enrichedNodes, enrichedEdges, root);
161+
154162
// 3c. Detect services
155163
CliOutput.step("\uD83C\uDFD7\uFE0F", "Detecting service boundaries...");
156164
var serviceDetector = new io.github.randomcodespace.iq.analyzer.ServiceDetector();
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package io.github.randomcodespace.iq.intelligence.extractor;
2+
3+
import io.github.randomcodespace.iq.detector.DetectorContext;
4+
import io.github.randomcodespace.iq.model.CodeEdge;
5+
import io.github.randomcodespace.iq.model.CodeNode;
6+
import org.slf4j.Logger;
7+
import org.slf4j.LoggerFactory;
8+
import org.springframework.stereotype.Component;
9+
10+
import java.io.IOException;
11+
import java.nio.charset.StandardCharsets;
12+
import java.nio.file.Files;
13+
import java.nio.file.Path;
14+
import java.util.ArrayList;
15+
import java.util.HashMap;
16+
import java.util.List;
17+
import java.util.Map;
18+
import java.util.TreeMap;
19+
20+
/**
21+
* Runs all {@link LanguageExtractor} beans after {@link io.github.randomcodespace.iq.intelligence.lexical.LexicalEnricher}
22+
* during the {@code enrich} command.
23+
*
24+
* <p>Builds a combined node registry (by id and fqn), groups nodes by source file,
25+
* reads each file once, and dispatches to matching extractors. Results (edges, type hints)
26+
* are written back into the in-memory node/edge lists before Neo4j bulk-load.
27+
*
28+
* <p>Extraction failures log a warning and are skipped — the pipeline never aborts.
29+
*/
30+
@Component
31+
public class LanguageEnricher {
32+
33+
private static final Logger log = LoggerFactory.getLogger(LanguageEnricher.class);
34+
35+
/**
36+
* Language alias map: normalises file-extension languages to extractor language keys.
37+
* e.g. "javascript" nodes are handled by the "typescript" extractor.
38+
*/
39+
private static final Map<String, String> LANGUAGE_ALIASES = Map.of(
40+
"javascript", "typescript"
41+
);
42+
43+
private final List<LanguageExtractor> extractors;
44+
45+
public LanguageEnricher(List<LanguageExtractor> extractors) {
46+
this.extractors = List.copyOf(extractors);
47+
}
48+
49+
/**
50+
* Enrich nodes with language-specific intelligence and add new edges.
51+
*
52+
* @param nodes All enriched nodes (post-linker, post-classifier, post-lexical).
53+
* @param edges Mutable edge list — new edges are appended in place.
54+
* @param rootPath Absolute root path of the analysed repository (for file reads).
55+
*/
56+
public void enrich(List<CodeNode> nodes, List<CodeEdge> edges, Path rootPath) {
57+
if (extractors.isEmpty()) {
58+
log.debug("No LanguageExtractor beans registered — skipping language enrichment");
59+
return;
60+
}
61+
62+
// Build combined node registry: id → node, fqn → node
63+
Map<String, CodeNode> nodeRegistry = buildRegistry(nodes);
64+
65+
// Build extractor lookup: normalised language → extractor
66+
Map<String, LanguageExtractor> extractorByLanguage = new HashMap<>();
67+
for (LanguageExtractor extractor : extractors) {
68+
extractorByLanguage.put(extractor.getLanguage(), extractor);
69+
}
70+
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<>();
74+
for (CodeNode node : nodes) {
75+
if (node.getFilePath() != null) {
76+
nodesByFile.computeIfAbsent(node.getFilePath(), k -> new ArrayList<>()).add(node);
77+
}
78+
}
79+
80+
int edgesAdded = 0;
81+
int typeHintsAdded = 0;
82+
83+
for (Map.Entry<String, List<CodeNode>> entry : nodesByFile.entrySet()) {
84+
String filePath = entry.getKey();
85+
List<CodeNode> fileNodes = entry.getValue();
86+
87+
String language = detectLanguage(filePath);
88+
if (language == null) continue;
89+
90+
String resolvedLanguage = LANGUAGE_ALIASES.getOrDefault(language, language);
91+
LanguageExtractor extractor = extractorByLanguage.get(resolvedLanguage);
92+
if (extractor == null) continue;
93+
94+
String content = readFile(rootPath, filePath);
95+
if (content == null) continue;
96+
97+
DetectorContext ctx = new DetectorContext(filePath, language, content, nodeRegistry, null);
98+
99+
for (CodeNode node : fileNodes) {
100+
try {
101+
LanguageExtractionResult result = extractor.extract(ctx, node);
102+
edges.addAll(result.callEdges());
103+
edges.addAll(result.symbolReferences());
104+
edgesAdded += result.callEdges().size() + result.symbolReferences().size();
105+
for (Map.Entry<String, String> hint : result.typeHints().entrySet()) {
106+
node.getProperties().put(hint.getKey(), hint.getValue());
107+
typeHintsAdded++;
108+
}
109+
} catch (Exception e) {
110+
log.warn("LanguageExtractor {} failed on node {} in {}: {}",
111+
extractor.getClass().getSimpleName(), node.getId(), filePath, e.getMessage());
112+
}
113+
}
114+
}
115+
116+
log.info("Language enrichment: {} edges added, {} type hints added across {} extractors",
117+
edgesAdded, typeHintsAdded, extractorByLanguage.size());
118+
}
119+
120+
private Map<String, CodeNode> buildRegistry(List<CodeNode> nodes) {
121+
Map<String, CodeNode> registry = new HashMap<>();
122+
for (CodeNode node : nodes) {
123+
if (node.getId() != null) {
124+
registry.put(node.getId(), node);
125+
}
126+
if (node.getFqn() != null && !node.getFqn().isEmpty()) {
127+
registry.put(node.getFqn(), node);
128+
}
129+
}
130+
return registry;
131+
}
132+
133+
private String readFile(Path rootPath, String filePath) {
134+
try {
135+
Path resolved = rootPath.resolve(filePath);
136+
if (!Files.exists(resolved)) return null;
137+
return Files.readString(resolved, StandardCharsets.UTF_8);
138+
} catch (IOException e) {
139+
log.debug("Could not read file {}: {}", filePath, e.getMessage());
140+
return null;
141+
}
142+
}
143+
144+
/**
145+
* Map file extension to language string (mirrors FileDiscovery conventions).
146+
*/
147+
static String detectLanguage(String filePath) {
148+
if (filePath == null) return null;
149+
int dot = filePath.lastIndexOf('.');
150+
if (dot < 0) return null;
151+
return switch (filePath.substring(dot + 1).toLowerCase()) {
152+
case "java" -> "java";
153+
case "ts", "tsx" -> "typescript";
154+
case "js", "jsx", "mjs", "cjs" -> "javascript";
155+
case "py", "pyw" -> "python";
156+
case "go" -> "go";
157+
default -> null;
158+
};
159+
}
160+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package io.github.randomcodespace.iq.intelligence.extractor;
2+
3+
import io.github.randomcodespace.iq.intelligence.CapabilityLevel;
4+
import io.github.randomcodespace.iq.model.CodeEdge;
5+
6+
import java.util.List;
7+
import java.util.Map;
8+
9+
/**
10+
* Result of a single {@link LanguageExtractor#extract} call.
11+
*
12+
* @param callEdges CALLS edges discovered for this node (method invocations, function calls).
13+
* @param symbolReferences IMPORTS / DEPENDS_ON edges from import/symbol resolution.
14+
* @param typeHints Type annotation key-value pairs to store in node properties
15+
* (e.g. {@code "param_types" -> "int, str"}, {@code "return_type" -> "str"}).
16+
* @param confidence Confidence level of this extraction result.
17+
*/
18+
public record LanguageExtractionResult(
19+
List<CodeEdge> callEdges,
20+
List<CodeEdge> symbolReferences,
21+
Map<String, String> typeHints,
22+
CapabilityLevel confidence
23+
) {
24+
public LanguageExtractionResult {
25+
callEdges = List.copyOf(callEdges);
26+
symbolReferences = List.copyOf(symbolReferences);
27+
typeHints = Map.copyOf(typeHints);
28+
}
29+
30+
/** Empty result with PARTIAL confidence. */
31+
public static LanguageExtractionResult empty() {
32+
return new LanguageExtractionResult(List.of(), List.of(), Map.of(), CapabilityLevel.PARTIAL);
33+
}
34+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package io.github.randomcodespace.iq.intelligence.extractor;
2+
3+
import io.github.randomcodespace.iq.detector.DetectorContext;
4+
import io.github.randomcodespace.iq.model.CodeNode;
5+
6+
/**
7+
* Strategy interface for language-specific enrichment extractors.
8+
*
9+
* <p>Implementations are stateless Spring {@code @Component} beans auto-discovered
10+
* via classpath scan. Each extractor targets a single language and deepens the
11+
* capability matrix beyond what the general intelligence layer provides.
12+
*
13+
* <p>Extractors run during {@code enrich} (after {@link io.github.randomcodespace.iq.intelligence.lexical.LexicalEnricher})
14+
* and must never run during {@code index}.
15+
*/
16+
public interface LanguageExtractor {
17+
18+
/**
19+
* The primary language this extractor targets (e.g. "java", "typescript", "python", "go").
20+
* Matches the language values produced by {@code FileDiscovery}.
21+
*/
22+
String getLanguage();
23+
24+
/**
25+
* Extract additional intelligence for the given node from its source file.
26+
*
27+
* <p>The {@code ctx} carries file content and a node registry via {@code parsedData}
28+
* (cast to {@code Map<String, CodeNode>} — a combined id + fqn index built by
29+
* {@link LanguageEnricher}).
30+
*
31+
* @param ctx Detector context for the node's source file; {@code parsedData} contains
32+
* the node registry as {@code Map<String, CodeNode>}.
33+
* @param node The specific node to enrich.
34+
* @return Extraction result; never {@code null}.
35+
*/
36+
LanguageExtractionResult extract(DetectorContext ctx, CodeNode node);
37+
}

0 commit comments

Comments
 (0)