Skip to content

Commit 046ea70

Browse files
authored
chore(config): freeze CodeIqConfig mutation surface (#49) (#53)
* refactor(config): centralize CLI startup overrides in a config-package helper Collapse the four production call sites that mutate the CodeIqConfig Spring singleton (ServeCommand, EnrichCommand, CliOutput, Analyzer) through a single package-adjacent helper. This pins the "mutation happens once at CLI startup" contract in one place and sets up a follow-up commit to tighten the bean's setter visibility to package-private. - New CliStartupConfigOverrides with applyServeOverrides / applyCacheDir / applyServiceName. Null/blank inputs are no-ops — never overwrite an in-code default with an absent value. - Analyzer.runSmartWithCache now routes service-name propagation through the helper (same semantics, same guard condition). - Six unit tests verify each helper mutates only the intended fields on a freshly-adapted CodeIqConfig and that null/blank inputs are no-ops. * refactor(config): tighten CodeIqConfig setter visibility to package-private Drop `public` from every setter on CodeIqConfig and its Graph inner class. Production mutation is now restricted at compile time to: - UnifiedConfigAdapter.toCodeIqConfig (once, at Spring startup) - CliStartupConfigOverrides (once per JVM, at CLI entry) Both live in `io.github.randomcodespace.iq.config` and reach the package-private setters directly. Every other code path — controllers, MCP tools, background workers — loses the compile-time ability to mutate the Spring singleton. This is the mutation hazard cleanup #49 called for. Test migration: - Two in-package tests (CodeIqConfigTest, GraphBootstrapperTest) keep working unchanged. - 15 out-of-package test classes across `iq.api`, `iq.cli`, `iq.intelligence.*`, `iq.mcp`, `iq.query` are migrated to route setter calls through a new test-only helper CodeIqConfigTestSupport (lives in src/test/java/io/github/randomcodespace/iq/config/, so tests compile against the package-private setters). Fluent API keeps call sites readable: `CodeIqConfigTestSupport.override(config).rootPath(x).done();` The name makes the test-only intent unmistakable and the helper is not reachable from production code paths. - 51 call sites rewritten; semantics preserved verbatim. Full suite green: 3278 tests, 0 failures, 31 skipped (baseline unchanged).
1 parent 65aac67 commit 046ea70

23 files changed

Lines changed: 329 additions & 77 deletions

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import io.github.randomcodespace.iq.cache.AnalysisCache;
66
import io.github.randomcodespace.iq.cache.FileHasher;
77
import io.github.randomcodespace.iq.cli.VersionCommand;
8+
import io.github.randomcodespace.iq.config.CliStartupConfigOverrides;
89
import io.github.randomcodespace.iq.config.CodeIqConfig;
910
import io.github.randomcodespace.iq.config.unified.CodeIqUnifiedConfig;
1011
import io.github.randomcodespace.iq.detector.AbstractAntlrDetector;
@@ -814,7 +815,7 @@ private AnalysisResult runSmartWithCache(Path root, Integer parallelism, int bat
814815
report.accept("Service: " + infraRegistry.getServiceName());
815816
// Propagate to config if not already set
816817
if (config.getServiceName() == null || config.getServiceName().isBlank()) {
817-
config.setServiceName(infraRegistry.getServiceName());
818+
CliStartupConfigOverrides.applyServiceName(config, infraRegistry.getServiceName());
818819
}
819820
}
820821

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,13 @@ static void configureFromOptions(io.github.randomcodespace.iq.config.CodeIqConfi
9595
java.nio.file.Path graphDir, String serviceName) {
9696
if (graphDir != null) {
9797
java.nio.file.Path sharedDir = graphDir.toAbsolutePath().normalize();
98-
config.setCacheDir(sharedDir.toString());
98+
io.github.randomcodespace.iq.config.CliStartupConfigOverrides.applyCacheDir(
99+
config, sharedDir.toString());
99100
info(" Graph dir: " + sharedDir + " (shared multi-repo)");
100101
}
101102
if (serviceName != null && !serviceName.isBlank()) {
102-
config.setServiceName(serviceName);
103+
io.github.randomcodespace.iq.config.CliStartupConfigOverrides.applyServiceName(
104+
config, serviceName);
103105
info(" Service name: " + serviceName);
104106
}
105107
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import io.github.randomcodespace.iq.analyzer.LayerClassifier;
55
import io.github.randomcodespace.iq.analyzer.linker.Linker;
66
import io.github.randomcodespace.iq.cache.AnalysisCache;
7+
import io.github.randomcodespace.iq.config.CliStartupConfigOverrides;
78
import io.github.randomcodespace.iq.config.CodeIqConfig;
89
import io.github.randomcodespace.iq.intelligence.RepositoryIdentity;
910
import io.github.randomcodespace.iq.intelligence.extractor.LanguageEnricher;
@@ -91,8 +92,9 @@ public Integer call() {
9192

9293
// If --graph is set, override cache directory to shared location
9394
if (graphDir != null) {
94-
config.setCacheDir(graphDir.toAbsolutePath().normalize().toString());
95-
CliOutput.info(" Graph dir: " + graphDir.toAbsolutePath().normalize() + " (shared multi-repo)");
95+
Path sharedDir = graphDir.toAbsolutePath().normalize();
96+
CliStartupConfigOverrides.applyCacheDir(config, sharedDir.toString());
97+
CliOutput.info(" Graph dir: " + sharedDir + " (shared multi-repo)");
9698
}
9799

98100
// 1. Open H2 file

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.github.randomcodespace.iq.cli;
22

3+
import io.github.randomcodespace.iq.config.CliStartupConfigOverrides;
34
import io.github.randomcodespace.iq.config.CodeIqConfig;
45
import io.github.randomcodespace.iq.config.GraphBootstrapper;
56
import io.github.randomcodespace.iq.graph.GraphStore;
@@ -71,10 +72,7 @@ public class ServeCommand implements Callable<Integer> {
7172
@Override
7273
public Integer call() {
7374
Path root = path.toAbsolutePath().normalize();
74-
config.setRootPath(root.toString());
75-
if (readOnly) {
76-
config.setReadOnly(true);
77-
}
75+
CliStartupConfigOverrides.applyServeOverrides(config, root, readOnly);
7876
NumberFormat nf = NumberFormat.getIntegerInstance(Locale.US);
7977

8078
// Bootstrap Neo4j from the H2 analysis cache if Neo4j is empty. This is
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package io.github.randomcodespace.iq.config;
2+
3+
import java.nio.file.Path;
4+
5+
/**
6+
* Centralised, CLI-startup-only mutation of the {@link CodeIqConfig} Spring
7+
* singleton.
8+
*
9+
* <p><b>Call contract:</b> these helpers are invoked exactly once per JVM
10+
* invocation, from a Picocli command's {@code call()} entry point, <em>before</em>
11+
* any downstream consumer reads config state. Treat the config as frozen
12+
* afterwards.
13+
*
14+
* <p>Do <b>not</b> invoke from request handlers, background workers, controllers,
15+
* MCP tools, or any serving-layer code path. The {@link CodeIqConfig} bean is a
16+
* Spring singleton shared across every consumer — mutating it at runtime is a
17+
* correctness hazard and was the motivation for collapsing all existing call
18+
* sites into this one package-private surface.
19+
*
20+
* <p>Visibility is package-private by design: only other classes inside
21+
* {@code io.github.randomcodespace.iq.config} can reach {@link CodeIqConfig}'s
22+
* package-private setters via this helper. CLI callers in
23+
* {@code io.github.randomcodespace.iq.cli} and analyzer callers in
24+
* {@code io.github.randomcodespace.iq.analyzer} route through the public
25+
* {@code apply*} methods below.
26+
*/
27+
public final class CliStartupConfigOverrides {
28+
29+
private CliStartupConfigOverrides() {}
30+
31+
/**
32+
* Apply the {@code serve} command's startup overrides to the config bean:
33+
* absolute root path, and read-only mode when the {@code --read-only} flag
34+
* was set.
35+
*
36+
* @param config the Spring-managed {@link CodeIqConfig} singleton
37+
* @param root absolute, normalised root path (must not be {@code null})
38+
* @param readOnly whether the {@code --read-only} CLI flag was set
39+
*/
40+
public static void applyServeOverrides(CodeIqConfig config, Path root, boolean readOnly) {
41+
if (config == null || root == null) {
42+
return;
43+
}
44+
config.setRootPath(root.toString());
45+
if (readOnly) {
46+
config.setReadOnly(true);
47+
}
48+
}
49+
50+
/**
51+
* Override the cache directory. No-op if {@code cacheDir} is {@code null}
52+
* or blank — we never overwrite the in-code default with an absent value.
53+
*/
54+
public static void applyCacheDir(CodeIqConfig config, String cacheDir) {
55+
if (config == null || cacheDir == null || cacheDir.isBlank()) {
56+
return;
57+
}
58+
config.setCacheDir(cacheDir);
59+
}
60+
61+
/**
62+
* Override the service-name tag used in multi-repo graph mode. No-op if
63+
* {@code name} is {@code null} or blank.
64+
*/
65+
public static void applyServiceName(CodeIqConfig config, String name) {
66+
if (config == null || name == null || name.isBlank()) {
67+
return;
68+
}
69+
config.setServiceName(name);
70+
}
71+
}

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

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,19 @@
77
* Task 11 moved bean production to {@link UnifiedConfigBeans#codeIqConfig}, which
88
* adapts a {@link io.github.randomcodespace.iq.config.unified.CodeIqUnifiedConfig}
99
* (single source of truth) via {@link UnifiedConfigAdapter#toCodeIqConfig}. The
10-
* getter/setter surface is preserved unchanged so the ~100 call sites that still
11-
* depend on this bean continue to work.
10+
* getter surface is preserved unchanged so the ~100 call sites that read this
11+
* bean continue to work.
1212
*
1313
* <p>This class is intentionally a plain POJO (no {@code @Configuration},
1414
* no {@code @ConfigurationProperties}); Spring Boot no longer instantiates it
15-
* from {@code application.yml}. Instantiable directly in tests via the public
16-
* no-arg constructor and setters.
15+
* from {@code application.yml}.
16+
*
17+
* <p><b>Setters are package-private.</b> Only {@link UnifiedConfigAdapter}
18+
* (at Spring startup) and {@link CliStartupConfigOverrides} (once per JVM at
19+
* CLI entry) mutate instances of this class. External-package callers go
20+
* through {@link CliStartupConfigOverrides}. External-package tests that need
21+
* a populated instance construct one via
22+
* {@link UnifiedConfigAdapter#toCodeIqConfig(io.github.randomcodespace.iq.config.unified.CodeIqUnifiedConfig)}.
1723
*/
1824
public class CodeIqConfig {
1925

@@ -48,7 +54,7 @@ public static class Graph {
4854
private String path = ".code-iq/graph/graph.db";
4955

5056
public String getPath() { return path; }
51-
public void setPath(String path) { this.path = path; }
57+
void setPath(String path) { this.path = path; }
5258
}
5359

5460
/** Read-only mode for serving — no lock files, no writes. For read-only filesystems (AKS). */
@@ -57,93 +63,93 @@ public static class Graph {
5763
/** Service name tag for multi-repo graph mode. */
5864
private String serviceName;
5965

60-
// --- Getters and Setters ---
66+
// --- Getters (public) and Setters (package-private) ---
6167

6268
public String getRootPath() {
6369
return rootPath;
6470
}
6571

66-
public void setRootPath(String rootPath) {
72+
void setRootPath(String rootPath) {
6773
this.rootPath = rootPath;
6874
}
6975

7076
public String getCacheDir() {
7177
return cacheDir;
7278
}
7379

74-
public void setCacheDir(String cacheDir) {
80+
void setCacheDir(String cacheDir) {
7581
this.cacheDir = cacheDir;
7682
}
7783

7884
public int getMaxDepth() {
7985
return maxDepth;
8086
}
8187

82-
public void setMaxDepth(int maxDepth) {
88+
void setMaxDepth(int maxDepth) {
8389
this.maxDepth = maxDepth;
8490
}
8591

8692
public int getMaxFiles() {
8793
return maxFiles;
8894
}
8995

90-
public void setMaxFiles(int maxFiles) {
96+
void setMaxFiles(int maxFiles) {
9197
this.maxFiles = Math.max(1, maxFiles);
9298
}
9399

94100
public int getMaxRadius() {
95101
return maxRadius;
96102
}
97103

98-
public void setMaxRadius(int maxRadius) {
104+
void setMaxRadius(int maxRadius) {
99105
this.maxRadius = maxRadius;
100106
}
101107

102108
public int getBatchSize() {
103109
return batchSize;
104110
}
105111

106-
public void setBatchSize(int batchSize) {
112+
void setBatchSize(int batchSize) {
107113
this.batchSize = Math.max(1, batchSize);
108114
}
109115

110116
public boolean isReadOnly() {
111117
return readOnly;
112118
}
113119

114-
public void setReadOnly(boolean readOnly) {
120+
void setReadOnly(boolean readOnly) {
115121
this.readOnly = readOnly;
116122
}
117123

118124
public String getServiceName() {
119125
return serviceName;
120126
}
121127

122-
public void setServiceName(String serviceName) {
128+
void setServiceName(String serviceName) {
123129
this.serviceName = serviceName;
124130
}
125131

126132
public Graph getGraph() {
127133
return graph;
128134
}
129135

130-
public void setGraph(Graph graph) {
136+
void setGraph(Graph graph) {
131137
this.graph = graph;
132138
}
133139

134140
public boolean isUiEnabled() {
135141
return uiEnabled;
136142
}
137143

138-
public void setUiEnabled(boolean uiEnabled) {
144+
void setUiEnabled(boolean uiEnabled) {
139145
this.uiEnabled = uiEnabled;
140146
}
141147

142148
public int getMaxSnippetLines() {
143149
return maxSnippetLines;
144150
}
145151

146-
public void setMaxSnippetLines(int maxSnippetLines) {
152+
void setMaxSnippetLines(int maxSnippetLines) {
147153
this.maxSnippetLines = Math.max(1, maxSnippetLines);
148154
}
149155
}

src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import static org.mockito.Mockito.when;
3030
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
3131
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
32+
import io.github.randomcodespace.iq.config.CodeIqConfigTestSupport;
3233

3334
/**
3435
* Tests for the REST API controller using standalone MockMvc (no Spring context needed).
@@ -46,9 +47,9 @@ class GraphControllerTest {
4647
@BeforeEach
4748
void setUp() {
4849
config = new CodeIqConfig();
49-
config.setMaxDepth(10);
50-
config.setMaxRadius(10);
51-
config.setRootPath(".");
50+
CodeIqConfigTestSupport.override(config).maxDepth(10).done();
51+
CodeIqConfigTestSupport.override(config).maxRadius(10).done();
52+
CodeIqConfigTestSupport.override(config).rootPath(".").done();
5253
var controller = new GraphController(queryService, config);
5354
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
5455
}
@@ -499,7 +500,7 @@ void searchGraphShouldReturnResults() throws Exception {
499500
@Test
500501
void readFileShouldReturnContent(@TempDir Path tempDir) throws Exception {
501502
Files.writeString(tempDir.resolve("hello.txt"), "Hello World", StandardCharsets.UTF_8);
502-
config.setRootPath(tempDir.toAbsolutePath().toString());
503+
CodeIqConfigTestSupport.override(config).rootPath(tempDir.toAbsolutePath().toString()).done();
503504
var controller = new GraphController(queryService, config);
504505
var fileMvc = MockMvcBuilders.standaloneSetup(controller).build();
505506

@@ -510,7 +511,7 @@ void readFileShouldReturnContent(@TempDir Path tempDir) throws Exception {
510511

511512
@Test
512513
void readFileShouldReturn404ForMissing(@TempDir Path tempDir) throws Exception {
513-
config.setRootPath(tempDir.toAbsolutePath().toString());
514+
CodeIqConfigTestSupport.override(config).rootPath(tempDir.toAbsolutePath().toString()).done();
514515
var controller = new GraphController(queryService, config);
515516
var fileMvc = MockMvcBuilders.standaloneSetup(controller).build();
516517

@@ -520,7 +521,7 @@ void readFileShouldReturn404ForMissing(@TempDir Path tempDir) throws Exception {
520521

521522
@Test
522523
void readFileShouldBlockPathTraversal(@TempDir Path tempDir) throws Exception {
523-
config.setRootPath(tempDir.toAbsolutePath().toString());
524+
CodeIqConfigTestSupport.override(config).rootPath(tempDir.toAbsolutePath().toString()).done();
524525
var controller = new GraphController(queryService, config);
525526
var fileMvc = MockMvcBuilders.standaloneSetup(controller).build();
526527

@@ -533,7 +534,7 @@ void readFileShouldBlockPathTraversal(@TempDir Path tempDir) throws Exception {
533534
void readFileShouldReturnLineRange(@TempDir Path tempDir) throws Exception {
534535
Files.writeString(tempDir.resolve("multi.txt"), "line1\nline2\nline3\nline4\nline5",
535536
StandardCharsets.UTF_8);
536-
config.setRootPath(tempDir.toAbsolutePath().toString());
537+
CodeIqConfigTestSupport.override(config).rootPath(tempDir.toAbsolutePath().toString()).done();
537538
var controller = new GraphController(queryService, config);
538539
var fileMvc = MockMvcBuilders.standaloneSetup(controller).build();
539540

@@ -548,7 +549,7 @@ void readFileShouldReturnLineRange(@TempDir Path tempDir) throws Exception {
548549
@Test
549550
void readFileShouldReturnFullContentWithoutLineParams(@TempDir Path tempDir) throws Exception {
550551
Files.writeString(tempDir.resolve("full.txt"), "aaa\nbbb\nccc", StandardCharsets.UTF_8);
551-
config.setRootPath(tempDir.toAbsolutePath().toString());
552+
CodeIqConfigTestSupport.override(config).rootPath(tempDir.toAbsolutePath().toString()).done();
552553
var controller = new GraphController(queryService, config);
553554
var fileMvc = MockMvcBuilders.standaloneSetup(controller).build();
554555

src/test/java/io/github/randomcodespace/iq/api/IntelligenceControllerTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
2323
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
2424
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
25+
import io.github.randomcodespace.iq.config.CodeIqConfigTestSupport;
2526

2627
class IntelligenceControllerTest {
2728

@@ -41,7 +42,7 @@ void setUp() {
4142
when(metadataProvider.current()).thenReturn(metadata);
4243

4344
CodeIqConfig config = new CodeIqConfig();
44-
config.setRootPath(System.getProperty("java.io.tmpdir"));
45+
CodeIqConfigTestSupport.override(config).rootPath(System.getProperty("java.io.tmpdir")).done();
4546

4647
IntelligenceController controller = new IntelligenceController(assembler, metadataProvider, config);
4748
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();

src/test/java/io/github/randomcodespace/iq/api/TopologyControllerExtendedTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import static org.mockito.Mockito.*;
3232
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
3333
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
34+
import io.github.randomcodespace.iq.config.CodeIqConfigTestSupport;
3435

3536
/**
3637
* Extended tests for TopologyController that exercise the actual REST endpoints
@@ -60,7 +61,7 @@ class TopologyControllerExtendedTest {
6061
void setUp() {
6162
var config = new CodeIqConfig();
6263
// Use the temp dir as rootPath so H2 fallback finds no cache file
63-
config.setRootPath(tempDir.toString());
64+
CodeIqConfigTestSupport.override(config).rootPath(tempDir.toString()).done();
6465
controller = new TopologyController(topologyService, graphStore, config);
6566
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
6667
}

0 commit comments

Comments
 (0)