Skip to content

Commit 6fe7cf6

Browse files
aksOpsclaude
andcommitted
feat: add YAML export, --parallelism flag, /api/file endpoint, .osscodeiq.yml config loading
- GraphCommand: add YAML output format using SnakeYAML serialization - AnalyzeCommand: add --parallelism/-p flag to control max parallel threads - Analyzer: add 3-arg run() accepting optional parallelism (null = virtual threads) - GraphController: add /api/file endpoint with path traversal protection - ProjectConfigLoader: load .osscodeiq.yml/.osscodeiq.yaml from project root - FlowCommand: refactor to use FlowEngine (fixes pre-existing compilation errors) - Fix FlowCommandTest and CliExtendedTest to use FlowEngine instead of GraphStore - All 1093 tests pass Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f80a7dd commit 6fe7cf6

24 files changed

Lines changed: 6384 additions & 181 deletions

src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.util.Map;
2525
import java.util.Set;
2626
import java.util.concurrent.ExecutionException;
27+
import java.util.concurrent.ExecutorService;
2728
import java.util.concurrent.Executors;
2829
import java.util.concurrent.Future;
2930
import java.util.function.Consumer;
@@ -86,6 +87,18 @@ public Analyzer(
8687
* @return the analysis result containing graph data and statistics
8788
*/
8889
public AnalysisResult run(Path repoPath, Consumer<String> onProgress) {
90+
return run(repoPath, null, onProgress);
91+
}
92+
93+
/**
94+
* Execute the analysis pipeline with optional parallelism control.
95+
*
96+
* @param repoPath root of the repository to analyze
97+
* @param parallelism max parallel threads, or null for adaptive (virtual threads)
98+
* @param onProgress optional callback for progress reporting (may be null)
99+
* @return the analysis result containing graph data and statistics
100+
*/
101+
public AnalysisResult run(Path repoPath, Integer parallelism, Consumer<String> onProgress) {
89102
Instant start = Instant.now();
90103
Consumer<String> report = onProgress != null ? onProgress : msg -> {};
91104

@@ -107,7 +120,10 @@ public AnalysisResult run(Path repoPath, Consumer<String> onProgress) {
107120
report.accept("Analyzing " + totalFiles + " files...");
108121
DetectorResult[] resultSlots = new DetectorResult[files.size()];
109122

110-
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
123+
var executorService = parallelism != null && parallelism > 0
124+
? Executors.newFixedThreadPool(parallelism)
125+
: Executors.newVirtualThreadPerTaskExecutor();
126+
try (var executor = executorService) {
111127
List<Future<?>> futures = new ArrayList<>(files.size());
112128
for (int i = 0; i < files.size(); i++) {
113129
final int idx = i;
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package io.github.randomcodespace.iq.api;
2+
3+
import io.github.randomcodespace.iq.flow.FlowEngine;
4+
import io.github.randomcodespace.iq.flow.FlowModels.FlowDiagram;
5+
import org.springframework.http.HttpStatus;
6+
import org.springframework.http.MediaType;
7+
import org.springframework.http.ResponseEntity;
8+
import org.springframework.web.bind.annotation.GetMapping;
9+
import org.springframework.web.bind.annotation.PathVariable;
10+
import org.springframework.web.bind.annotation.RequestMapping;
11+
import org.springframework.web.bind.annotation.RequestParam;
12+
import org.springframework.web.bind.annotation.RestController;
13+
import org.springframework.web.server.ResponseStatusException;
14+
15+
import java.util.LinkedHashMap;
16+
import java.util.Map;
17+
18+
/**
19+
* REST API controller for architecture flow diagrams.
20+
* Supports drill-down and drill-up navigation.
21+
*/
22+
@RestController
23+
@RequestMapping("/api/flow")
24+
public class FlowController {
25+
26+
private final FlowEngine flowEngine;
27+
28+
public FlowController(FlowEngine flowEngine) {
29+
this.flowEngine = flowEngine;
30+
}
31+
32+
/**
33+
* Get all flow views as JSON diagrams.
34+
*/
35+
@GetMapping
36+
public Map<String, Object> getAllFlows() {
37+
var allViews = flowEngine.generateAll();
38+
var result = new LinkedHashMap<String, Object>();
39+
for (var entry : allViews.entrySet()) {
40+
result.put(entry.getKey(), entry.getValue().toMap());
41+
}
42+
return result;
43+
}
44+
45+
/**
46+
* Get a specific flow view, optionally in a different format.
47+
*/
48+
@GetMapping("/{view}")
49+
public ResponseEntity<?> getFlow(
50+
@PathVariable String view,
51+
@RequestParam(defaultValue = "json") String format) {
52+
try {
53+
FlowDiagram diagram = flowEngine.generate(view);
54+
55+
return switch (format.toLowerCase()) {
56+
case "json" -> ResponseEntity.ok(diagram.toMap());
57+
case "mermaid" -> ResponseEntity.ok()
58+
.contentType(MediaType.TEXT_PLAIN)
59+
.body(flowEngine.render(diagram, "mermaid"));
60+
case "html" -> ResponseEntity.ok()
61+
.contentType(MediaType.TEXT_HTML)
62+
.body(flowEngine.renderInteractive("Project"));
63+
default -> throw new IllegalArgumentException(
64+
"Unknown format: " + format + ". Available: json, mermaid, html");
65+
};
66+
} catch (IllegalArgumentException e) {
67+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
68+
}
69+
}
70+
71+
/**
72+
* Drill down into a node within a view -- returns the child view's diagram.
73+
*/
74+
@GetMapping("/{view}/{nodeId}/children")
75+
public Map<String, Object> getChildren(
76+
@PathVariable String view,
77+
@PathVariable String nodeId) {
78+
Map<String, Object> children = flowEngine.getChildren(view, nodeId);
79+
if (children == null) {
80+
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
81+
"No drill-down available for node " + nodeId + " in view " + view);
82+
}
83+
return children;
84+
}
85+
86+
/**
87+
* Drill up from a node -- returns the parent context.
88+
*/
89+
@GetMapping("/{view}/{nodeId}/parent")
90+
public Map<String, Object> getParent(
91+
@PathVariable String view,
92+
@PathVariable String nodeId) {
93+
Map<String, Object> parent = flowEngine.getParentContext(nodeId);
94+
if (parent == null) {
95+
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
96+
"No parent context found for node " + nodeId);
97+
}
98+
return parent;
99+
}
100+
}

src/main/java/io/github/randomcodespace/iq/api/GraphController.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import io.github.randomcodespace.iq.config.CodeIqConfig;
66
import io.github.randomcodespace.iq.query.QueryService;
77
import org.springframework.http.HttpStatus;
8+
import org.springframework.http.MediaType;
9+
import org.springframework.http.ResponseEntity;
810
import org.springframework.web.bind.annotation.GetMapping;
911
import org.springframework.web.bind.annotation.PathVariable;
1012
import org.springframework.web.bind.annotation.PostMapping;
@@ -13,6 +15,9 @@
1315
import org.springframework.web.bind.annotation.RestController;
1416
import org.springframework.web.server.ResponseStatusException;
1517

18+
import java.io.IOException;
19+
import java.nio.charset.StandardCharsets;
20+
import java.nio.file.Files;
1621
import java.nio.file.Path;
1722
import java.util.LinkedHashMap;
1823
import java.util.List;
@@ -155,6 +160,30 @@ public List<Map<String, Object>> searchGraph(
155160
return queryService.searchGraph(q, limit);
156161
}
157162

163+
@GetMapping("/file")
164+
public ResponseEntity<String> readFile(@RequestParam String path) {
165+
Path codebasePath = Path.of(config.getRootPath()).toAbsolutePath().normalize();
166+
Path resolved = codebasePath.resolve(path).normalize();
167+
if (!resolved.startsWith(codebasePath)) {
168+
return ResponseEntity.status(403)
169+
.contentType(MediaType.TEXT_PLAIN)
170+
.body("Path traversal blocked");
171+
}
172+
if (!Files.isRegularFile(resolved)) {
173+
return ResponseEntity.notFound().build();
174+
}
175+
try {
176+
String content = Files.readString(resolved, StandardCharsets.UTF_8);
177+
return ResponseEntity.ok()
178+
.contentType(MediaType.TEXT_PLAIN)
179+
.body(content);
180+
} catch (IOException e) {
181+
return ResponseEntity.status(500)
182+
.contentType(MediaType.TEXT_PLAIN)
183+
.body("Failed to read file: " + e.getMessage());
184+
}
185+
}
186+
158187
@PostMapping("/analyze")
159188
public Map<String, Object> triggerAnalysis(
160189
@RequestParam(defaultValue = "false") boolean incremental) {

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io.github.randomcodespace.iq.analyzer.AnalysisResult;
44
import io.github.randomcodespace.iq.analyzer.Analyzer;
55
import io.github.randomcodespace.iq.config.CodeIqConfig;
6+
import io.github.randomcodespace.iq.config.ProjectConfigLoader;
67
import org.springframework.stereotype.Component;
78
import picocli.CommandLine.Command;
89
import picocli.CommandLine.Option;
@@ -28,6 +29,10 @@ public class AnalyzeCommand implements Callable<Integer> {
2829
@Option(names = {"--no-cache"}, description = "Skip incremental cache")
2930
private boolean noCache;
3031

32+
@Option(names = {"--parallelism", "-p"},
33+
description = "Max parallel threads (default: auto-detect from CPU)")
34+
private Integer parallelism;
35+
3136
private final Analyzer analyzer;
3237
private final CodeIqConfig config;
3338

@@ -39,12 +44,16 @@ public AnalyzeCommand(Analyzer analyzer, CodeIqConfig config) {
3944
@Override
4045
public Integer call() {
4146
Path root = path.toAbsolutePath().normalize();
47+
48+
// Load project-level config overrides from .osscodeiq.yml if present
49+
ProjectConfigLoader.loadIfPresent(root, config);
50+
4251
NumberFormat nf = NumberFormat.getIntegerInstance(Locale.US);
43-
int cores = Runtime.getRuntime().availableProcessors();
52+
int cores = parallelism != null ? parallelism : Runtime.getRuntime().availableProcessors();
4453

4554
CliOutput.step("\uD83D\uDD0D", "Scanning " + root + " ...");
4655

47-
AnalysisResult result = analyzer.run(root, msg -> {
56+
AnalysisResult result = analyzer.run(root, parallelism, msg -> {
4857
if (msg.startsWith("Discovering")) {
4958
CliOutput.step("\uD83D\uDD0D", msg);
5059
} else if (msg.startsWith("Found")) {

0 commit comments

Comments
 (0)