Skip to content

Commit d3692cd

Browse files
aksOpsPaperclip-Paperclipclaude
authored
chore(hygiene): batch 5 LOW-severity follow-ups from RAN-6 review (RAN-33) (#70)
Bundles five low-severity hygiene items so reviewers don't pay per-PR overhead on items that aren't individually severe. 1. Determinism — Analyzer breakdown-map ordering `nodeBreakdown`, `edgeBreakdown`, `frameworkBreakdown`, and `languageBreakdown` were `HashMap`s, which made the JSON serialization order of `AnalysisResult` non-deterministic across runs. Switched the 12 declarations across `run`, `runBatchedIndex`, and `runSmartIndex` to `TreeMap`. Adds `breakdownMapsAreSortedDeterministically` test covering all four breakdowns. 2. Branding hygiene — VersionCommand + BundleCommand - VersionCommand banner: stale `Code IQ` → `codeiq` (matches the post-rename CLI / package / repo branding). - BundleCommand: added a footnote on the bundle-structure javadoc explaining that `code-iq-*-cli.jar` keeps the historical filename because it tracks the Maven `artifactId` (intentionally unchanged per `CLAUDE.md`). Future readers won't trip over it. Other remaining `code-iq` strings (the `<artifactId>` snippet in the README and the JAR/Maven-URL templates inside BundleCommand) are intentional Maven-coordinate references and stay as-is. 3. CORS defaults explicit — CorsConfig Promoted defaults to named constants (`DEFAULT_ALLOWED_ORIGIN_PATTERNS`, `API_ALLOWED_METHODS`, `MCP_ALLOWED_METHODS`, `ALLOWED_HEADERS`) and added a javadoc explaining the read-only API posture (no `PUT`/`PATCH`/`DELETE`) and the MCP `GET`/`POST` requirement. Behaviour unchanged — existing `CorsConfigTest` still passes verbatim. The `@Value` default still inlines the constant, and the field initializer keeps direct `new CorsConfig()` test usage working. 4. ExecutorService close/lifecycle regression coverage Promoted `Analyzer.BoundedExecutor` from `private` to package-private so its bounded-shutdown contract can be tested without spinning the full pipeline. Added `AnalyzerBoundedExecutorTest` with three cases: - long-running task: `close()` returns inside the graceful (10s) + force (5s) window and the task is interrupted; - idle executor: `close()` returns promptly; - interrupted closer thread: delegate is still shut down and the caller's interrupt flag is restored. 5. IntelligenceController.evidence — symlink hardening The `/api/intelligence/evidence` endpoint had only the lexical `normalize` + `startsWith` guard. Aligned it with the two-stage `toRealPath` guard that already protects `/api/file` (RAN-8): after the lexical check, resolve symlinks via `Path.toRealPath()` and re-check containment against the canonical root. `NoSuchFileException` is treated as "logical-only graph reference" and the lexical guard is sufficient (no symlink to traverse). Added `evidenceEndpointRejectsSymlinkEscapingRoot` and `evidenceEndpointAllowsInRepoSymlink`, both skip gracefully on filesystems without symlink support — same shape as the existing GraphController symlink tests. Verification - `mvn test -Dnpm.skip=true ...` → 3401 tests run, 0 failures, 0 errors, 31 skipped (E2E without external repos). - Targeted reruns of `AnalyzerTest`, `AnalyzerBoundedExecutorTest`, `CorsConfigTest`, `IntelligenceControllerTest`, `VersionCommandTest` all green. Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b6648c0 commit d3692cd

9 files changed

Lines changed: 295 additions & 27 deletions

File tree

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

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ private AnalysisResult runWithCache(Path root, Integer parallelism, AnalysisCach
275275
RepositoryIdentity repoIdentity = RepositoryIdentity.resolve(root);
276276

277277
// Compute language breakdown
278-
Map<String, Integer> languageBreakdown = new HashMap<>();
278+
Map<String, Integer> languageBreakdown = new TreeMap<>();
279279
for (DiscoveredFile f : files) {
280280
languageBreakdown.merge(f.language(), 1, Integer::sum);
281281
}
@@ -425,21 +425,21 @@ private AnalysisResult runWithCache(Path root, Integer parallelism, AnalysisCach
425425
}
426426

427427
// 7. Compute node breakdown
428-
Map<String, Integer> nodeBreakdown = new HashMap<>();
428+
Map<String, Integer> nodeBreakdown = new TreeMap<>();
429429
for (CodeNode node : allNodes) {
430430
String kindValue = node.getKind().getValue();
431431
nodeBreakdown.merge(kindValue, 1, Integer::sum);
432432
}
433433

434434
// 8. Compute edge breakdown
435-
Map<String, Integer> edgeBreakdown = new HashMap<>();
435+
Map<String, Integer> edgeBreakdown = new TreeMap<>();
436436
for (var edge : builder.getEdges()) {
437437
String kindValue = edge.getKind().getValue();
438438
edgeBreakdown.merge(kindValue, 1, Integer::sum);
439439
}
440440

441441
// 7b. Compute framework breakdown from node properties
442-
Map<String, Integer> frameworkBreakdown = new HashMap<>();
442+
Map<String, Integer> frameworkBreakdown = new TreeMap<>();
443443
for (CodeNode node : allNodes) {
444444
Object fw = node.getProperties().get(PROP_FRAMEWORK);
445445
if (fw != null && !fw.toString().isEmpty()) {
@@ -561,7 +561,7 @@ private AnalysisResult runBatchedWithCache(Path root, Integer parallelism, int b
561561
report.accept("Found " + totalFiles + " files");
562562

563563
// Compute language breakdown
564-
Map<String, Integer> languageBreakdown = new HashMap<>();
564+
Map<String, Integer> languageBreakdown = new TreeMap<>();
565565
for (DiscoveredFile f : files) {
566566
languageBreakdown.merge(f.language(), 1, Integer::sum);
567567
}
@@ -575,9 +575,9 @@ private AnalysisResult runBatchedWithCache(Path root, Integer parallelism, int b
575575
int filesAnalyzed = 0;
576576
int cacheHits = 0;
577577
int batchNumber = 0;
578-
Map<String, Integer> nodeBreakdown = new HashMap<>();
579-
Map<String, Integer> edgeBreakdown = new HashMap<>();
580-
Map<String, Integer> frameworkBreakdown = new HashMap<>();
578+
Map<String, Integer> nodeBreakdown = new TreeMap<>();
579+
Map<String, Integer> edgeBreakdown = new TreeMap<>();
580+
Map<String, Integer> frameworkBreakdown = new TreeMap<>();
581581

582582
// Clear previous index data if not incremental
583583
if (!incremental) {
@@ -852,7 +852,7 @@ private AnalysisResult runSmartWithCache(Path root, Integer parallelism, int bat
852852
int totalFiles = allFiles.size();
853853

854854
// Compute language breakdown
855-
Map<String, Integer> languageBreakdown = new HashMap<>();
855+
Map<String, Integer> languageBreakdown = new TreeMap<>();
856856
for (DiscoveredFile f : allFiles) {
857857
languageBreakdown.merge(f.language(), 1, Integer::sum);
858858
}
@@ -875,9 +875,9 @@ private AnalysisResult runSmartWithCache(Path root, Integer parallelism, int bat
875875
int filesSkipped = 0;
876876
int cacheHits = 0;
877877
int batchNumber = 0;
878-
Map<String, Integer> nodeBreakdown = new HashMap<>();
879-
Map<String, Integer> edgeBreakdown = new HashMap<>();
880-
Map<String, Integer> frameworkBreakdown = new HashMap<>();
878+
Map<String, Integer> nodeBreakdown = new TreeMap<>();
879+
Map<String, Integer> edgeBreakdown = new TreeMap<>();
880+
Map<String, Integer> frameworkBreakdown = new TreeMap<>();
881881

882882
// Process modules in sorted order for determinism
883883
List<String> sortedModuleKeys = new ArrayList<>(modules.keySet());
@@ -1354,8 +1354,11 @@ DetectorResult analyzeFileWithRegistry(DiscoveredFile file, Path repoPath,
13541354
* Wrapper around ExecutorService that implements AutoCloseable with a bounded
13551355
* shutdown — prevents the default close() from hanging up to 24 hours on stuck
13561356
* ANTLR threads.
1357+
*
1358+
* <p>Package-private so the close/lifecycle behaviour can be regression-tested
1359+
* directly without spinning the full Analyzer pipeline.
13571360
*/
1358-
private record BoundedExecutor(java.util.concurrent.ExecutorService delegate) implements AutoCloseable {
1361+
record BoundedExecutor(java.util.concurrent.ExecutorService delegate) implements AutoCloseable {
13591362
<T> Future<T> submit(java.util.concurrent.Callable<T> task) { return delegate.submit(task); }
13601363

13611364
@Override

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

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
import org.springframework.web.bind.annotation.RestController;
1616
import org.springframework.web.server.ResponseStatusException;
1717

18+
import java.io.IOException;
19+
import java.nio.file.NoSuchFileException;
20+
import java.nio.file.Path;
1821
import java.util.Map;
1922

2023
/**
@@ -45,7 +48,10 @@ public IntelligenceController(
4548
* Assemble an evidence pack for a symbol or file path.
4649
*
4750
* <p>At least one of {@code symbol} or {@code file} must be provided.
48-
* The {@code file} parameter is path-traversal guarded.
51+
* The {@code file} parameter is path-traversal guarded with the same two-stage
52+
* (lexical {@code normalize} then {@link Path#toRealPath} re-check) guard used
53+
* by {@code GraphController.readFile} and the MCP {@code read_file} tool, so a
54+
* symlink inside the indexed repo cannot be used to leak off-tree files.
4955
*
5056
* @param symbol symbol name to look up
5157
* @param file file path relative to repo root (path traversal guarded)
@@ -68,15 +74,35 @@ public EvidencePack getEvidence(
6874
"At least one of 'symbol' or 'file' must be provided.");
6975
}
7076

71-
// Path traversal guard on file param
77+
// Path-traversal guard on file param: two-stage lexical + symlink check.
7278
if (file != null && !file.isBlank()) {
73-
java.nio.file.Path root = java.nio.file.Path.of(config.getRootPath())
74-
.toAbsolutePath().normalize();
75-
java.nio.file.Path resolved = root.resolve(file).normalize();
76-
if (!resolved.startsWith(root)) {
79+
Path root;
80+
try {
81+
root = Path.of(config.getRootPath()).toRealPath();
82+
} catch (IOException e) {
83+
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR,
84+
"Failed to resolve codebase root: " + e.getMessage());
85+
}
86+
Path candidate = root.resolve(file).normalize();
87+
if (!candidate.startsWith(root)) {
7788
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
7889
"Invalid file path: path traversal detected.");
7990
}
91+
// Resolve symlinks if the file exists on disk and re-check containment.
92+
// If the file is logical-only (graph reference, no on-disk file), the
93+
// lexical guard above is sufficient — there is no symlink to traverse.
94+
try {
95+
Path real = candidate.toRealPath();
96+
if (!real.startsWith(root)) {
97+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
98+
"Invalid file path: path traversal detected.");
99+
}
100+
} catch (NoSuchFileException ignored) {
101+
// file may exist only as a graph reference — lexical guard already passed
102+
} catch (IOException e) {
103+
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR,
104+
"Failed to resolve file path: " + e.getMessage());
105+
}
80106
}
81107

82108
EvidencePackRequest request = new EvidencePackRequest(symbol, file, maxSnippetLines, includeRefs);

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@
4646
* ├── flow.html (interactive architecture diagram)
4747
* └── code-iq-*-cli.jar (optional)
4848
* </pre>
49+
* <p>
50+
* The bundled CLI JAR keeps the historical {@code code-iq-*-cli.jar} filename because
51+
* it tracks the Maven artifactId ({@code io.github.randomcodespace.iq:code-iq}), which
52+
* is intentionally unchanged across the codeiq rename. See {@code CLAUDE.md}.
4953
*/
5054
@Component
5155
@Command(name = "bundle", mixinStandardHelpOptions = true,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public Integer call() {
5858
allLanguages.addAll(d.getSupportedLanguages());
5959
}
6060

61-
CliOutput.bold("Code IQ " + version);
61+
CliOutput.bold("codeiq " + version);
6262
CliOutput.info(" Java: " + System.getProperty("java.version")
6363
+ " (" + System.getProperty("java.vendor") + ")");
6464
CliOutput.info(" Runtime: " + System.getProperty("java.runtime.name"));

src/main/java/io/github/randomcodespace/iq/config/CorsConfig.java

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,38 @@
77
import org.springframework.web.servlet.config.annotation.CorsRegistry;
88
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
99

10+
/**
11+
* CORS configuration for the {@code serving} profile.
12+
*
13+
* <p>The serving layer is strictly read-only: clients only need {@code GET} on the REST API
14+
* and {@code GET}/{@code POST} on the MCP streamable-HTTP endpoint. Mutating verbs
15+
* ({@code PUT}, {@code PATCH}, {@code DELETE}) are intentionally not allowed —
16+
* analysis happens locally via the CLI ({@code codeiq index} / {@code codeiq enrich})
17+
* and the server never accepts data manipulation.
18+
*
19+
* <p>Default origin patterns cover the common local-dev cases (loopback on any port).
20+
* Override via {@code codeiq.cors.allowed-origin-patterns} (CSV) when serving over a
21+
* trusted network or behind a reverse proxy.
22+
*/
1023
@Configuration
1124
@Profile("serving")
1225
public class CorsConfig {
1326

14-
@Value("${codeiq.cors.allowed-origin-patterns:http://localhost:[*],http://127.0.0.1:[*]}")
15-
private String allowedOriginPatterns = "http://localhost:[*],http://127.0.0.1:[*]";
27+
/** Default allowed origin patterns: loopback on any port (covers local dev / IDE proxies). */
28+
static final String DEFAULT_ALLOWED_ORIGIN_PATTERNS =
29+
"http://localhost:[*],http://127.0.0.1:[*]";
30+
31+
/** Read-only REST API: only safe / preflight verbs. */
32+
static final String[] API_ALLOWED_METHODS = {"GET", "OPTIONS"};
33+
34+
/** MCP streamable-HTTP: GET for SSE/handshake, POST for JSON-RPC frames, OPTIONS for preflight. */
35+
static final String[] MCP_ALLOWED_METHODS = {"GET", "POST", "OPTIONS"};
36+
37+
/** Allow all request headers — clients commonly send custom MCP / Auth headers. */
38+
static final String ALLOWED_HEADERS = "*";
39+
40+
@Value("${codeiq.cors.allowed-origin-patterns:" + DEFAULT_ALLOWED_ORIGIN_PATTERNS + "}")
41+
private String allowedOriginPatterns = DEFAULT_ALLOWED_ORIGIN_PATTERNS;
1642

1743
@Bean
1844
public WebMvcConfigurer corsConfigurer() {
@@ -22,12 +48,12 @@ public WebMvcConfigurer corsConfigurer() {
2248
public void addCorsMappings(CorsRegistry registry) {
2349
registry.addMapping("/api/**")
2450
.allowedOriginPatterns(patterns)
25-
.allowedMethods("GET", "OPTIONS")
26-
.allowedHeaders("*");
51+
.allowedMethods(API_ALLOWED_METHODS)
52+
.allowedHeaders(ALLOWED_HEADERS);
2753
registry.addMapping("/mcp/**")
2854
.allowedOriginPatterns(patterns)
29-
.allowedMethods("GET", "POST", "OPTIONS")
30-
.allowedHeaders("*");
55+
.allowedMethods(MCP_ALLOWED_METHODS)
56+
.allowedHeaders(ALLOWED_HEADERS);
3157
}
3258
};
3359
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package io.github.randomcodespace.iq.analyzer;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.time.Duration;
6+
import java.time.Instant;
7+
import java.util.concurrent.CountDownLatch;
8+
import java.util.concurrent.ExecutorService;
9+
import java.util.concurrent.Executors;
10+
import java.util.concurrent.Future;
11+
import java.util.concurrent.TimeUnit;
12+
import java.util.concurrent.atomic.AtomicBoolean;
13+
14+
import static org.junit.jupiter.api.Assertions.assertFalse;
15+
import static org.junit.jupiter.api.Assertions.assertNotNull;
16+
import static org.junit.jupiter.api.Assertions.assertTrue;
17+
18+
/**
19+
* Regression coverage for {@link Analyzer.BoundedExecutor}: the default
20+
* {@code ExecutorService.close()} can block up to 24 hours waiting for stuck
21+
* ANTLR parser threads. The wrapper enforces a graceful 10s shutdown followed
22+
* by a 5s {@code shutdownNow} window.
23+
*/
24+
class AnalyzerBoundedExecutorTest {
25+
26+
/** Hard upper bound on close() = graceful 10s + force 5s + scheduling slack. */
27+
private static final long MAX_CLOSE_SECONDS = 18;
28+
29+
@Test
30+
void close_completes_within_bounded_window_for_long_running_task() throws Exception {
31+
ExecutorService delegate = Executors.newSingleThreadExecutor();
32+
Analyzer.BoundedExecutor executor = new Analyzer.BoundedExecutor(delegate);
33+
34+
AtomicBoolean wasInterrupted = new AtomicBoolean();
35+
CountDownLatch started = new CountDownLatch(1);
36+
Future<Void> task = executor.submit(() -> {
37+
started.countDown();
38+
try {
39+
// Far longer than the bounded close() window.
40+
Thread.sleep(TimeUnit.MINUTES.toMillis(5));
41+
} catch (InterruptedException e) {
42+
wasInterrupted.set(true);
43+
Thread.currentThread().interrupt();
44+
}
45+
return null;
46+
});
47+
assertNotNull(task);
48+
assertTrue(started.await(5, TimeUnit.SECONDS), "submitted task should start");
49+
50+
Instant t0 = Instant.now();
51+
executor.close();
52+
Duration elapsed = Duration.between(t0, Instant.now());
53+
54+
assertTrue(delegate.isShutdown(),
55+
"delegate must be shutdown after close()");
56+
assertTrue(elapsed.toSeconds() < MAX_CLOSE_SECONDS,
57+
"close() must respect bounded shutdown window (max "
58+
+ MAX_CLOSE_SECONDS + "s), got " + elapsed);
59+
// shutdownNow should have interrupted the sleeping task.
60+
assertTrue(wasInterrupted.get(),
61+
"blocked task must be interrupted by shutdownNow");
62+
}
63+
64+
@Test
65+
void close_is_immediate_when_executor_is_idle() {
66+
ExecutorService delegate = Executors.newSingleThreadExecutor();
67+
Analyzer.BoundedExecutor executor = new Analyzer.BoundedExecutor(delegate);
68+
69+
Instant t0 = Instant.now();
70+
executor.close();
71+
Duration elapsed = Duration.between(t0, Instant.now());
72+
73+
assertTrue(delegate.isShutdown());
74+
assertTrue(delegate.isTerminated());
75+
// Idle close should return well under the graceful window.
76+
assertTrue(elapsed.toMillis() < 2_000,
77+
"idle close() should return promptly, got " + elapsed);
78+
}
79+
80+
@Test
81+
void close_propagates_interrupt_to_caller_thread() throws Exception {
82+
ExecutorService delegate = Executors.newSingleThreadExecutor();
83+
Analyzer.BoundedExecutor executor = new Analyzer.BoundedExecutor(delegate);
84+
85+
CountDownLatch started = new CountDownLatch(1);
86+
executor.submit(() -> {
87+
started.countDown();
88+
try {
89+
Thread.sleep(TimeUnit.MINUTES.toMillis(5));
90+
} catch (InterruptedException ignored) {
91+
// swallow — we're testing the wrapper, not the task
92+
}
93+
return null;
94+
});
95+
assertTrue(started.await(5, TimeUnit.SECONDS));
96+
97+
AtomicBoolean closerInterrupted = new AtomicBoolean();
98+
Thread closer = new Thread(() -> {
99+
executor.close();
100+
closerInterrupted.set(Thread.currentThread().isInterrupted());
101+
});
102+
closer.start();
103+
// Let the closer enter awaitTermination, then interrupt it.
104+
Thread.sleep(200);
105+
closer.interrupt();
106+
closer.join(TimeUnit.SECONDS.toMillis(MAX_CLOSE_SECONDS));
107+
108+
assertFalse(closer.isAlive(), "closer thread should finish after interrupt");
109+
assertTrue(delegate.isShutdown(), "interrupt path must still shutdown the delegate");
110+
assertTrue(closerInterrupted.get(),
111+
"wrapper must restore the caller's interrupt flag");
112+
}
113+
}

src/test/java/io/github/randomcodespace/iq/analyzer/AnalyzerTest.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,44 @@ void analyzesJavaFiles() throws IOException {
8585
assertTrue(result.elapsed().toMillis() >= 0);
8686
}
8787

88+
/**
89+
* Regression: AnalysisResult breakdown maps must iterate in deterministic
90+
* sorted order so that JSON serialization is byte-stable across runs.
91+
*/
92+
@Test
93+
void breakdownMapsAreSortedDeterministically() throws IOException {
94+
// File names chosen so SERVICE / CLASS / and the kind values would not
95+
// appear in sorted order under the previous HashMap implementation.
96+
Files.writeString(tempDir.resolve("Zeta.java"), "public class Zeta {}");
97+
Files.writeString(tempDir.resolve("Alpha.java"), "public class Alpha {}");
98+
Files.writeString(tempDir.resolve("Mu.java"), "public class Mu {}");
99+
100+
AnalysisResult result = analyzer.run(tempDir, progressMessages::add);
101+
102+
assertSortedKeys(result.languageBreakdown().keySet().stream().toList(),
103+
"languageBreakdown");
104+
assertSortedKeys(result.nodeBreakdown().keySet().stream().toList(),
105+
"nodeBreakdown");
106+
assertSortedKeys(result.edgeBreakdown().keySet().stream().toList(),
107+
"edgeBreakdown");
108+
assertSortedKeys(result.frameworkBreakdown().keySet().stream().toList(),
109+
"frameworkBreakdown");
110+
111+
// Sanity: at least the kinds we expect should be present.
112+
assertTrue(result.nodeBreakdown().keySet().contains("class"));
113+
assertTrue(result.nodeBreakdown().keySet().contains("service"));
114+
}
115+
116+
private static void assertSortedKeys(List<String> keys, String name) {
117+
for (int i = 1; i < keys.size(); i++) {
118+
String prev = keys.get(i - 1);
119+
String cur = keys.get(i);
120+
assertTrue(prev.compareTo(cur) < 0,
121+
name + " not in sorted order at index " + i + ": '"
122+
+ prev + "' >= '" + cur + "' (full: " + keys + ")");
123+
}
124+
}
125+
88126
@Test
89127
void reportsProgress() throws IOException {
90128
Files.writeString(tempDir.resolve("App.java"), "public class App {}");

0 commit comments

Comments
 (0)