From c012d4d7cf888abff79ce2654ca1249869c373dd Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 00:11:20 +0900 Subject: [PATCH 1/3] Fix config validation diagnostics (#3432) --- USER_GUIDE.md | 18 +- changelog.d/unreleased/3432.fixed.md | 17 ++ src/CodeIndex/Cli/CdidxConfigFile.cs | 262 ++++++++++-------- tests/CodeIndex.Tests/CdidxConfigFileTests.cs | 47 +++- 4 files changed, 214 insertions(+), 130 deletions(-) create mode 100644 changelog.d/unreleased/3432.fixed.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 4c49a86911..2c8969ac11 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1535,7 +1535,7 @@ Run `cdidx status --log-path` to print the active log directory without opening You can check a `.cdidx/config.json` or `.cdidxrc.json` file into a repository to set per-project defaults instead of relying on shell-profile or CI env vars (#1571). On startup `cdidx` walks upward from the current working directory looking for the first project config file, validates its schema, and materializes recognized keys as process environment variables — so every existing env-var consumer picks them up without further changes. -Precedence is **CLI flag > environment variable > config file > built-in default**. A config-file value is applied only when the matching env var is not already set in the process, so a value the user already exported in the shell or CI always wins. Config JSON is bounded to 64 KiB and a conservative nesting depth before schema validation. A malformed file (invalid JSON, unknown key, wrong type, or excessive nesting) is a hard error: cdidx exits `1` with the file path and the offending field; set `CDIDX_DISABLE_CONFIG_FILE=1` to bypass the file entirely. +Precedence is **CLI flag > environment variable > config file > built-in default**. A config-file value is applied only when the matching env var is not already set in the process, so a value the user already exported in the shell or CI always wins. Config JSON is bounded to 64 KiB and a conservative nesting depth before schema validation. A malformed file (invalid JSON, unknown key, wrong type, or excessive nesting) is a hard error: cdidx exits `1` with the file path and all detected offending fields; set `CDIDX_DISABLE_CONFIG_FILE=1` to bypass the file entirely. Secrets are intentionally **not** loadable from the file: `CDIDX_GITHUB_TOKEN`, `CDIDX_MCP_AUTH_TOKEN`, and `CDIDX_MCP_HTTP_TOKEN` are env-only so tokens never get checked into version control. @@ -1562,14 +1562,15 @@ Supported schema (top-level keys are snake_case; nested indexing kind keys keep "deny": ["index", "backfill_fold"] // → CDIDX_MCP_TOOLS_DENY }, "rate_limit": { - "rps": 5, // → CDIDX_MCP_RATE_LIMIT_RPS - "burst": 10 // → CDIDX_MCP_RATE_LIMIT_BURST + "rps": 5, // → CDIDX_MCP_RATE_LIMIT_RPS + "burst": 10, // → CDIDX_MCP_RATE_LIMIT_BURST + "bucket_idle_seconds": 900 // → CDIDX_MCP_RATE_LIMIT_BUCKET_IDLE_SECONDS } } } ``` -JSON5-style line comments (`//`) and trailing commas are accepted so the file stays human-editable. The optional `$schema` key is ignored at runtime; it is honored only so editors that recognize JSON Schema references can offer completion. Setting `disable_persistent_log` to `false` is a no-op (absence already means "logging enabled") — only `true` exports `CDIDX_DISABLE_PERSISTENT_LOG=1`. Config-sourced `metrics_path` and `global_tool_log_dir` values are resolved from the config workspace root and must stay inside that workspace; use the CLI flag or a real environment variable when you intentionally need an outside destination. `stale_after` uses the same compact duration format as `status --check --stale-after`: `30m`, `2h`, or `7d`, up to `30d`. `suggestion_dedup_threshold` sets the MCP suggestion fuzzy-deduplication cutoff as a number from `0` to `1`; the built-in default is `0.85`, and `cdidx mcp --suggestion-dedup-threshold <0..1>` overrides it for one MCP session. `suggestion_max_age_days` and `suggestion_max_count` bound the live `.cdidx/suggestions-*.json` store; pruned records are appended to `.cdidx/suggestions-*.archive.jsonl`, whose active file is capped at 8 MiB and rotates up to three retained generations (`.1` through `.3`). Defaults are 365 days and 5000 records, and config-file values may not exceed 3650 days or 100000 records. Matching environment variables above those caps fall back to the defaults. String-array settings such as `indexing.includeKinds`, `indexing.excludeKinds`, `mcp.tools.allow`, and `mcp.tools.deny` are capped at 128 entries and 256 characters per item before they are joined into environment variables. `indexing.includeKinds` and `indexing.excludeKinds` set the default symbol-kind filter for `cdidx index`; CLI flags `--include-symbol-kind [,]` and `--exclude-symbol-kind [,]` override those env-backed defaults for a single run. +JSON5-style line comments (`//`) and trailing commas are accepted so the file stays human-editable. The optional `$schema` key is ignored at runtime; it is honored only so editors that recognize JSON Schema references can offer completion. Setting `disable_persistent_log` to `false` is a no-op (absence already means "logging enabled") — only `true` exports `CDIDX_DISABLE_PERSISTENT_LOG=1`. Config-sourced `metrics_path` and `global_tool_log_dir` values are resolved from the config workspace root and must stay inside that workspace; use the CLI flag or a real environment variable when you intentionally need an outside destination. `stale_after` uses the same compact duration format as `status --check --stale-after`: `30m`, `2h`, or `7d`, up to `30d`. `suggestion_dedup_threshold` sets the MCP suggestion fuzzy-deduplication cutoff as a number from `0` to `1`; the built-in default is `0.85`, and `cdidx mcp --suggestion-dedup-threshold <0..1>` overrides it for one MCP session. `suggestion_max_age_days` and `suggestion_max_count` bound the live `.cdidx/suggestions-*.json` store; pruned records are appended to `.cdidx/suggestions-*.archive.jsonl`, whose active file is capped at 8 MiB and rotates up to three retained generations (`.1` through `.3`). Defaults are 365 days and 5000 records, and config-file values may not exceed 3650 days or 100000 records. Matching environment variables above those caps fall back to the defaults. `mcp.rate_limit.bucket_idle_seconds` sets the same idle bucket TTL as `CDIDX_MCP_RATE_LIMIT_BUCKET_IDLE_SECONDS`; invalid runtime values fall back to the default with a warning. String-array settings such as `indexing.includeKinds`, `indexing.excludeKinds`, `mcp.tools.allow`, and `mcp.tools.deny` are capped at 128 entries and 256 characters per item before they are joined into environment variables. `indexing.includeKinds` and `indexing.excludeKinds` set the default symbol-kind filter for `cdidx index`; CLI flags `--include-symbol-kind [,]` and `--exclude-symbol-kind [,]` override those env-backed defaults for a single run. ## How it works @@ -3830,7 +3831,7 @@ MCP のレスポンスサイズ上限は、環境変数 override で guard が シェルプロファイルや CI の環境変数に頼らず、プロジェクトごとの既定値を `.cdidx/config.json` または `.cdidxrc.json` ファイルとしてリポジトリにチェックインできます (#1571)。`cdidx` は起動時にカレントディレクトリから上方向に最初のプロジェクト設定ファイルを探索し、スキーマを検証してから既知のキーをプロセス環境変数として注入します。これにより、既存の環境変数コンシューマはコード変更なしに同じ値を受け取れます。 -優先順位は **CLI フラグ > 環境変数 > 設定ファイル > 組み込み既定値** です。設定ファイル由来の値は、対応する環境変数がプロセスで未設定の場合にのみ適用されるため、シェルや CI で既に export されている値が常に優先されます。設定 JSON はスキーマ検証前に 64 KiB と保守的なネスト深度の上限で検査されます。不正なファイル(無効な JSON、未知のキー、型違い、過度なネスト)は hard error として扱われ、cdidx はファイルパスと該当フィールドを示して終了コード `1` で終了します。完全にバイパスしたい場合は `CDIDX_DISABLE_CONFIG_FILE=1` を設定してください。 +優先順位は **CLI フラグ > 環境変数 > 設定ファイル > 組み込み既定値** です。設定ファイル由来の値は、対応する環境変数がプロセスで未設定の場合にのみ適用されるため、シェルや CI で既に export されている値が常に優先されます。設定 JSON はスキーマ検証前に 64 KiB と保守的なネスト深度の上限で検査されます。不正なファイル(無効な JSON、未知のキー、型違い、過度なネスト)は hard error として扱われ、cdidx はファイルパスと検出できた該当フィールドすべてを示して終了コード `1` で終了します。完全にバイパスしたい場合は `CDIDX_DISABLE_CONFIG_FILE=1` を設定してください。 シークレットは意図的に**ファイルから読み込めません**。`CDIDX_GITHUB_TOKEN` / `CDIDX_MCP_AUTH_TOKEN` / `CDIDX_MCP_HTTP_TOKEN` は環境変数専用としており、トークンがバージョン管理に混入するのを防ぎます。 @@ -3857,14 +3858,15 @@ MCP のレスポンスサイズ上限は、環境変数 override で guard が "deny": ["index", "backfill_fold"] // → CDIDX_MCP_TOOLS_DENY }, "rate_limit": { - "rps": 5, // → CDIDX_MCP_RATE_LIMIT_RPS - "burst": 10 // → CDIDX_MCP_RATE_LIMIT_BURST + "rps": 5, // → CDIDX_MCP_RATE_LIMIT_RPS + "burst": 10, // → CDIDX_MCP_RATE_LIMIT_BURST + "bucket_idle_seconds": 900 // → CDIDX_MCP_RATE_LIMIT_BUCKET_IDLE_SECONDS } } } ``` -人手で編集しやすいよう JSON5 形式の行コメント(`//`)と末尾カンマを許容します。任意の `$schema` キーはランタイムでは無視され、JSON Schema 参照をサポートするエディタが補完を提供するためだけに認識されます。`disable_persistent_log` を `false` に設定しても何も起きません(不在のままで "ログ有効" が既定)— `true` の場合のみ `CDIDX_DISABLE_PERSISTENT_LOG=1` を export します。config 由来の `metrics_path` と `global_tool_log_dir` は設定ファイルの workspace root から解決され、その workspace 内に収まる必要があります。意図的に外部の出力先を使う場合は CLI フラグまたは実際の環境変数を使ってください。`stale_after` は `status --check --stale-after` と同じ compact duration 形式(`30m` / `2h` / `7d`、最大 `30d`)です。`suggestion_dedup_threshold` は MCP suggestion の fuzzy deduplication しきい値を `0` から `1` の数値で設定します。組み込み既定値は `0.85` で、`cdidx mcp --suggestion-dedup-threshold <0..1>` は 1 回の MCP session だけこの値を上書きします。`suggestion_max_age_days` と `suggestion_max_count` は live の `.cdidx/suggestions-*.json` store の上限を設定し、prune された record は `.cdidx/suggestions-*.archive.jsonl` に追記されます。この active archive は 8 MiB で上限管理され、最大 3 世代(`.1` から `.3`)までローテーションされます。既定値は 365 日と 5000 件で、config-file 値は 3650 日または 100000 件を超えられません。同じ環境変数がこの上限を超えた場合は既定値へ戻ります。`indexing.includeKinds`、`indexing.excludeKinds`、`mcp.tools.allow`、`mcp.tools.deny` のような string array 設定は、環境変数へ join される前に 128 件、1 要素 256 文字までに制限されます。`indexing.includeKinds` と `indexing.excludeKinds` は `cdidx index` の symbol-kind filter 既定値を設定し、CLI フラグ `--include-symbol-kind [,]` / `--exclude-symbol-kind [,]` はその env 経由の既定値を 1 回の実行だけ上書きします。 +人手で編集しやすいよう JSON5 形式の行コメント(`//`)と末尾カンマを許容します。任意の `$schema` キーはランタイムでは無視され、JSON Schema 参照をサポートするエディタが補完を提供するためだけに認識されます。`disable_persistent_log` を `false` に設定しても何も起きません(不在のままで "ログ有効" が既定)— `true` の場合のみ `CDIDX_DISABLE_PERSISTENT_LOG=1` を export します。config 由来の `metrics_path` と `global_tool_log_dir` は設定ファイルの workspace root から解決され、その workspace 内に収まる必要があります。意図的に外部の出力先を使う場合は CLI フラグまたは実際の環境変数を使ってください。`stale_after` は `status --check --stale-after` と同じ compact duration 形式(`30m` / `2h` / `7d`、最大 `30d`)です。`suggestion_dedup_threshold` は MCP suggestion の fuzzy deduplication しきい値を `0` から `1` の数値で設定します。組み込み既定値は `0.85` で、`cdidx mcp --suggestion-dedup-threshold <0..1>` は 1 回の MCP session だけこの値を上書きします。`suggestion_max_age_days` と `suggestion_max_count` は live の `.cdidx/suggestions-*.json` store の上限を設定し、prune された record は `.cdidx/suggestions-*.archive.jsonl` に追記されます。この active archive は 8 MiB で上限管理され、最大 3 世代(`.1` から `.3`)までローテーションされます。既定値は 365 日と 5000 件で、config-file 値は 3650 日または 100000 件を超えられません。同じ環境変数がこの上限を超えた場合は既定値へ戻ります。`mcp.rate_limit.bucket_idle_seconds` は `CDIDX_MCP_RATE_LIMIT_BUCKET_IDLE_SECONDS` と同じ idle bucket TTL を設定します。不正な runtime 値は警告付きで既定値へ戻ります。`indexing.includeKinds`、`indexing.excludeKinds`、`mcp.tools.allow`、`mcp.tools.deny` のような string array 設定は、環境変数へ join される前に 128 件、1 要素 256 文字までに制限されます。`indexing.includeKinds` と `indexing.excludeKinds` は `cdidx index` の symbol-kind filter 既定値を設定し、CLI フラグ `--include-symbol-kind [,]` / `--exclude-symbol-kind [,]` はその env 経由の既定値を 1 回の実行だけ上書きします。 ## 動作の仕組み diff --git a/changelog.d/unreleased/3432.fixed.md b/changelog.d/unreleased/3432.fixed.md new file mode 100644 index 0000000000..7cf733a16b --- /dev/null +++ b/changelog.d/unreleased/3432.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3432 +affected: + - src/CodeIndex/Cli/CdidxConfigFile.cs + - tests/CodeIndex.Tests/CdidxConfigFileTests.cs + - USER_GUIDE.md +--- + +## English + +- **Config validation now reports multiple diagnostics and covers the MCP bucket idle TTL (#3432)** — project config files now list all detected schema/value errors in one validation pass, and `mcp.rate_limit.bucket_idle_seconds` maps to `CDIDX_MCP_RATE_LIMIT_BUCKET_IDLE_SECONDS`. + +## 日本語 + +- **config validation が複数診断を返し、MCP bucket idle TTL も設定できるようになりました (#3432)** — project config file は 1 回の検証で検出できた schema/value error をまとめて返し、`mcp.rate_limit.bucket_idle_seconds` が `CDIDX_MCP_RATE_LIMIT_BUCKET_IDLE_SECONDS` に対応するようになりました。 diff --git a/src/CodeIndex/Cli/CdidxConfigFile.cs b/src/CodeIndex/Cli/CdidxConfigFile.cs index 0b330d60c3..fbf8af4eda 100644 --- a/src/CodeIndex/Cli/CdidxConfigFile.cs +++ b/src/CodeIndex/Cli/CdidxConfigFile.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text.Json; using CodeIndex.Indexer; +using CodeIndex.Mcp; namespace CodeIndex.Cli; @@ -59,7 +60,7 @@ internal static class CdidxConfigFile private static readonly IReadOnlyList KnownFoldingKeys = new[] { "fold_key_version" }; private static readonly IReadOnlyList KnownMcpKeys = new[] { "tools", "rate_limit" }; private static readonly IReadOnlyList KnownMcpToolsKeys = new[] { "allow", "deny" }; - private static readonly IReadOnlyList KnownMcpRateLimitKeys = new[] { "rps", "burst" }; + private static readonly IReadOnlyList KnownMcpRateLimitKeys = new[] { "rps", "burst", "bucket_idle_seconds" }; internal sealed record LoadResult(string? Path, string? Error) { @@ -120,27 +121,21 @@ internal static LoadResult LoadAndApply( if (root.ValueKind != JsonValueKind.Object) return new LoadResult(Path: path, Error: $"[cdidx] {path}: top-level value must be a JSON object."); - if (TryFindUnknownKey(root, KnownTopLevelKeys, out var unknownTopKey)) - return new LoadResult(Path: path, Error: $"[cdidx] {path}: unknown key `{unknownTopKey}`. Supported keys: {string.Join(", ", KnownTopLevelKeys.Where(k => k != "$schema"))}."); - var pending = new List<(string EnvName, string Value)>(); + var errors = new List(); - if (AddTopLevelEnvironmentSettings(root, path, pending) is { } topLevelError) - return topLevelError; - if (AddSuggestionEnvironmentSettings(root, path, pending) is { } suggestionError) - return suggestionError; - if (AddIndexingEnvironmentSettings(root, path, pending) is { } indexingError) - return indexingError; - if (AddSearchEnvironmentSettings(root, path, pending) is { } searchError) - return searchError; - - if (!ValidateOptionalObject(root, "output", KnownOutputKeys, path, out var optionalObjectError) - || !ValidateOptionalObject(root, "graph", KnownGraphKeys, path, out optionalObjectError) - || !ValidateOptionalObject(root, "folding", KnownFoldingKeys, path, out optionalObjectError)) - return new LoadResult(Path: path, Error: optionalObjectError); + AddUnknownKeyDiagnostics(root, KnownTopLevelKeys, null, path, string.Join(", ", KnownTopLevelKeys.Where(k => k != "$schema")), errors); + AddTopLevelEnvironmentSettings(root, path, pending, errors); + AddSuggestionEnvironmentSettings(root, path, pending, errors); + AddIndexingEnvironmentSettings(root, path, pending, errors); + AddSearchEnvironmentSettings(root, path, pending, errors); + ValidateOptionalObject(root, "output", KnownOutputKeys, path, errors); + ValidateOptionalObject(root, "graph", KnownGraphKeys, path, errors); + ValidateOptionalObject(root, "folding", KnownFoldingKeys, path, errors); + AddMcpEnvironmentSettings(root, path, pending, errors); - if (AddMcpEnvironmentSettings(root, path, pending) is { } mcpError) - return mcpError; + if (errors.Count > 0) + return new LoadResult(Path: path, Error: string.Join(Environment.NewLine, errors)); // Apply only when the matching env var is not present (null), preserving the // documented precedence (real env wins over config-file value). An explicit @@ -153,218 +148,247 @@ internal static LoadResult LoadAndApply( } } - private static LoadResult? AddTopLevelEnvironmentSettings(JsonElement root, string path, List<(string EnvName, string Value)> pending) + private static void AddTopLevelEnvironmentSettings(JsonElement root, string path, List<(string EnvName, string Value)> pending, List errors) { if (root.TryGetProperty("debug", out var debug)) { if (!TryReadString(debug, "debug", path, out var value, out var err)) - return new LoadResult(Path: path, Error: err); - pending.Add(("CDIDX_DEBUG", value!)); + errors.Add(err!); + else + pending.Add(("CDIDX_DEBUG", value!)); } if (root.TryGetProperty("metrics_path", out var metrics)) { if (!TryReadWorkspaceOutputPath(metrics, "metrics_path", path, out var value, out var err)) - return new LoadResult(Path: path, Error: err); - pending.Add(("CDIDX_METRICS", value!)); + errors.Add(err!); + else + pending.Add(("CDIDX_METRICS", value!)); } if (root.TryGetProperty("disable_persistent_log", out var disableLog)) { if (disableLog.ValueKind != JsonValueKind.True && disableLog.ValueKind != JsonValueKind.False) - return new LoadResult(Path: path, Error: $"[cdidx] {path}: `disable_persistent_log` must be a boolean."); - if (disableLog.GetBoolean()) + errors.Add($"[cdidx] {path}: `disable_persistent_log` must be a boolean."); + else if (disableLog.GetBoolean()) pending.Add(("CDIDX_DISABLE_PERSISTENT_LOG", "1")); } if (root.TryGetProperty("global_tool_log_dir", out var logDir)) { if (!TryReadWorkspaceOutputPath(logDir, "global_tool_log_dir", path, out var value, out var err)) - return new LoadResult(Path: path, Error: err); - pending.Add(("CDIDX_GLOBAL_TOOL_LOG_DIR", value!)); + errors.Add(err!); + else + pending.Add(("CDIDX_GLOBAL_TOOL_LOG_DIR", value!)); } if (root.TryGetProperty("stale_after", out var staleAfter)) { if (!TryReadString(staleAfter, "stale_after", path, out var value, out var err)) - return new LoadResult(Path: path, Error: err); - pending.Add((QueryCommandRunner.StaleAfterEnvironmentVariable, value!)); + errors.Add(err!); + else + pending.Add((QueryCommandRunner.StaleAfterEnvironmentVariable, value!)); } - - return null; } - private static LoadResult? AddSuggestionEnvironmentSettings(JsonElement root, string path, List<(string EnvName, string Value)> pending) + private static void AddSuggestionEnvironmentSettings(JsonElement root, string path, List<(string EnvName, string Value)> pending, List errors) { if (root.TryGetProperty("suggestion_dedup_threshold", out var suggestionDedupThreshold)) { if (!TryReadNumberAsString(suggestionDedupThreshold, "suggestion_dedup_threshold", path, out var value, out var err)) - return new LoadResult(Path: path, Error: err); - if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var threshold) + errors.Add(err!); + else if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var threshold) || threshold < 0 || threshold > 1) { - return new LoadResult(Path: path, Error: $"[cdidx] {path}: `suggestion_dedup_threshold` must be between 0 and 1."); + errors.Add($"[cdidx] {path}: `suggestion_dedup_threshold` must be between 0 and 1."); + } + else + { + pending.Add((SuggestionStore.DedupThresholdEnvironmentVariable, value!)); } - - pending.Add((SuggestionStore.DedupThresholdEnvironmentVariable, value!)); } if (root.TryGetProperty("suggestion_max_age_days", out var suggestionMaxAgeDays)) { if (!TryReadPositiveIntegerAsString(suggestionMaxAgeDays, "suggestion_max_age_days", path, out var value, out var err)) - return new LoadResult(Path: path, Error: err); - var parsedMaxAgeDays = int.Parse(value!, CultureInfo.InvariantCulture); - if (parsedMaxAgeDays > SuggestionStore.MaximumMaxAgeDays) - return new LoadResult(Path: path, Error: $"[cdidx] {path}: `suggestion_max_age_days` must be <= {SuggestionStore.MaximumMaxAgeDays}."); - pending.Add((SuggestionStore.MaxAgeDaysEnvironmentVariable, value!)); + errors.Add(err!); + else + { + var parsedMaxAgeDays = int.Parse(value!, CultureInfo.InvariantCulture); + if (parsedMaxAgeDays > SuggestionStore.MaximumMaxAgeDays) + errors.Add($"[cdidx] {path}: `suggestion_max_age_days` must be <= {SuggestionStore.MaximumMaxAgeDays}."); + else + pending.Add((SuggestionStore.MaxAgeDaysEnvironmentVariable, value!)); + } } if (root.TryGetProperty("suggestion_max_count", out var suggestionMaxCount)) { if (!TryReadPositiveIntegerAsString(suggestionMaxCount, "suggestion_max_count", path, out var value, out var err)) - return new LoadResult(Path: path, Error: err); - var parsedMaxCount = int.Parse(value!, CultureInfo.InvariantCulture); - if (parsedMaxCount > SuggestionStore.MaximumMaxCount) - return new LoadResult(Path: path, Error: $"[cdidx] {path}: `suggestion_max_count` must be <= {SuggestionStore.MaximumMaxCount}."); - pending.Add((SuggestionStore.MaxCountEnvironmentVariable, value!)); + errors.Add(err!); + else + { + var parsedMaxCount = int.Parse(value!, CultureInfo.InvariantCulture); + if (parsedMaxCount > SuggestionStore.MaximumMaxCount) + errors.Add($"[cdidx] {path}: `suggestion_max_count` must be <= {SuggestionStore.MaximumMaxCount}."); + else + pending.Add((SuggestionStore.MaxCountEnvironmentVariable, value!)); + } } - - return null; } - private static LoadResult? AddIndexingEnvironmentSettings(JsonElement root, string path, List<(string EnvName, string Value)> pending) + private static void AddIndexingEnvironmentSettings(JsonElement root, string path, List<(string EnvName, string Value)> pending, List errors) { if (!root.TryGetProperty("indexing", out var indexing)) - return null; + return; if (indexing.ValueKind != JsonValueKind.Object) - return new LoadResult(Path: path, Error: $"[cdidx] {path}: `indexing` must be a JSON object."); - if (TryFindUnknownKey(indexing, KnownIndexingKeys, out var unknownIndexingKey)) - return new LoadResult(Path: path, Error: $"[cdidx] {path}: unknown key `indexing.{unknownIndexingKey}`. Supported keys: {string.Join(", ", KnownIndexingKeys)}."); + { + errors.Add($"[cdidx] {path}: `indexing` must be a JSON object."); + return; + } + + AddUnknownKeyDiagnostics(indexing, KnownIndexingKeys, "indexing", path, string.Join(", ", KnownIndexingKeys), errors); if (indexing.TryGetProperty("includeKinds", out var includeKinds)) { if (!TryReadStringArray(includeKinds, "indexing.includeKinds", path, out var value, out var err)) - return new LoadResult(Path: path, Error: err); - if (value!.Length > 0) + errors.Add(err!); + else if (value!.Length > 0) pending.Add((IndexCommandRunner.IncludeSymbolKindsEnvironmentVariable, string.Join(",", value))); } if (indexing.TryGetProperty("excludeKinds", out var excludeKinds)) { if (!TryReadStringArray(excludeKinds, "indexing.excludeKinds", path, out var value, out var err)) - return new LoadResult(Path: path, Error: err); - if (value!.Length > 0) + errors.Add(err!); + else if (value!.Length > 0) pending.Add((IndexCommandRunner.ExcludeSymbolKindsEnvironmentVariable, string.Join(",", value))); } - - return null; } - private static LoadResult? AddSearchEnvironmentSettings(JsonElement root, string path, List<(string EnvName, string Value)> pending) + private static void AddSearchEnvironmentSettings(JsonElement root, string path, List<(string EnvName, string Value)> pending, List errors) { if (!root.TryGetProperty("search", out var search)) - return null; + return; if (search.ValueKind != JsonValueKind.Object) - return new LoadResult(Path: path, Error: $"[cdidx] {path}: `search` must be a JSON object."); - if (TryFindUnknownKey(search, KnownSearchKeys, out var unknownSearchKey)) - return new LoadResult(Path: path, Error: $"[cdidx] {path}: unknown key `search.{unknownSearchKey}`. Supported keys: {string.Join(", ", KnownSearchKeys)}."); + { + errors.Add($"[cdidx] {path}: `search` must be a JSON object."); + return; + } + + AddUnknownKeyDiagnostics(search, KnownSearchKeys, "search", path, string.Join(", ", KnownSearchKeys), errors); if (search.TryGetProperty("limit", out var limit)) { if (!TryReadSearchInteger(limit, "search.limit", "--limit", allowZero: false, path, out var value, out var err)) - return new LoadResult(Path: path, Error: err); - pending.Add((QueryCommandRunner.DefaultLimitEnvironmentVariable, value!)); + errors.Add(err!); + else + pending.Add((QueryCommandRunner.DefaultLimitEnvironmentVariable, value!)); } if (search.TryGetProperty("snippet_lines", out var snippetLines)) { if (!TryReadSearchInteger(snippetLines, "search.snippet_lines", "--snippet-lines", allowZero: false, path, out var value, out var err)) - return new LoadResult(Path: path, Error: err); - pending.Add((QueryCommandRunner.DefaultSnippetLinesEnvironmentVariable, value!)); + errors.Add(err!); + else + pending.Add((QueryCommandRunner.DefaultSnippetLinesEnvironmentVariable, value!)); } if (search.TryGetProperty("max_line_width", out var maxLineWidth)) { if (!TryReadSearchInteger(maxLineWidth, "search.max_line_width", "--max-line-width", allowZero: true, path, out var value, out var err)) - return new LoadResult(Path: path, Error: err); - pending.Add((QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable, value!)); + errors.Add(err!); + else + pending.Add((QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable, value!)); } - - return null; } - private static LoadResult? AddMcpEnvironmentSettings(JsonElement root, string path, List<(string EnvName, string Value)> pending) + private static void AddMcpEnvironmentSettings(JsonElement root, string path, List<(string EnvName, string Value)> pending, List errors) { if (!root.TryGetProperty("mcp", out var mcp)) - return null; + return; if (mcp.ValueKind != JsonValueKind.Object) - return new LoadResult(Path: path, Error: $"[cdidx] {path}: `mcp` must be a JSON object."); - if (TryFindUnknownKey(mcp, KnownMcpKeys, out var unknownMcpKey)) - return new LoadResult(Path: path, Error: $"[cdidx] {path}: unknown key `mcp.{unknownMcpKey}`. Supported keys: {string.Join(", ", KnownMcpKeys)}."); + { + errors.Add($"[cdidx] {path}: `mcp` must be a JSON object."); + return; + } - if (AddMcpToolEnvironmentSettings(mcp, path, pending) is { } toolsError) - return toolsError; - return AddMcpRateLimitEnvironmentSettings(mcp, path, pending); + AddUnknownKeyDiagnostics(mcp, KnownMcpKeys, "mcp", path, string.Join(", ", KnownMcpKeys), errors); + + AddMcpToolEnvironmentSettings(mcp, path, pending, errors); + AddMcpRateLimitEnvironmentSettings(mcp, path, pending, errors); } - private static LoadResult? AddMcpToolEnvironmentSettings(JsonElement mcp, string path, List<(string EnvName, string Value)> pending) + private static void AddMcpToolEnvironmentSettings(JsonElement mcp, string path, List<(string EnvName, string Value)> pending, List errors) { if (!mcp.TryGetProperty("tools", out var tools)) - return null; + return; if (tools.ValueKind != JsonValueKind.Object) - return new LoadResult(Path: path, Error: $"[cdidx] {path}: `mcp.tools` must be a JSON object."); - if (TryFindUnknownKey(tools, KnownMcpToolsKeys, out var unknownToolsKey)) - return new LoadResult(Path: path, Error: $"[cdidx] {path}: unknown key `mcp.tools.{unknownToolsKey}`. Supported keys: {string.Join(", ", KnownMcpToolsKeys)}."); + { + errors.Add($"[cdidx] {path}: `mcp.tools` must be a JSON object."); + return; + } + + AddUnknownKeyDiagnostics(tools, KnownMcpToolsKeys, "mcp.tools", path, string.Join(", ", KnownMcpToolsKeys), errors); if (tools.TryGetProperty("allow", out var allow)) { if (!TryReadStringArray(allow, "mcp.tools.allow", path, out var value, out var err)) - return new LoadResult(Path: path, Error: err); - if (value!.Length > 0) + errors.Add(err!); + else if (value!.Length > 0) pending.Add(("CDIDX_MCP_TOOLS_ALLOW", string.Join(",", value))); } if (tools.TryGetProperty("deny", out var deny)) { if (!TryReadStringArray(deny, "mcp.tools.deny", path, out var value, out var err)) - return new LoadResult(Path: path, Error: err); - if (value!.Length > 0) + errors.Add(err!); + else if (value!.Length > 0) pending.Add(("CDIDX_MCP_TOOLS_DENY", string.Join(",", value))); } - - return null; } - private static LoadResult? AddMcpRateLimitEnvironmentSettings(JsonElement mcp, string path, List<(string EnvName, string Value)> pending) + private static void AddMcpRateLimitEnvironmentSettings(JsonElement mcp, string path, List<(string EnvName, string Value)> pending, List errors) { if (!mcp.TryGetProperty("rate_limit", out var rateLimit)) - return null; + return; if (rateLimit.ValueKind != JsonValueKind.Object) - return new LoadResult(Path: path, Error: $"[cdidx] {path}: `mcp.rate_limit` must be a JSON object."); - if (TryFindUnknownKey(rateLimit, KnownMcpRateLimitKeys, out var unknownRlKey)) - return new LoadResult(Path: path, Error: $"[cdidx] {path}: unknown key `mcp.rate_limit.{unknownRlKey}`. Supported keys: {string.Join(", ", KnownMcpRateLimitKeys)}."); + { + errors.Add($"[cdidx] {path}: `mcp.rate_limit` must be a JSON object."); + return; + } + + AddUnknownKeyDiagnostics(rateLimit, KnownMcpRateLimitKeys, "mcp.rate_limit", path, string.Join(", ", KnownMcpRateLimitKeys), errors); if (rateLimit.TryGetProperty("rps", out var rps)) { if (!TryReadNumberAsString(rps, "mcp.rate_limit.rps", path, out var value, out var err)) - return new LoadResult(Path: path, Error: err); - pending.Add(("CDIDX_MCP_RATE_LIMIT_RPS", value!)); + errors.Add(err!); + else + pending.Add((RateLimiterOptions.RpsEnvVar, value!)); } if (rateLimit.TryGetProperty("burst", out var burst)) { if (!TryReadNumberAsString(burst, "mcp.rate_limit.burst", path, out var value, out var err)) - return new LoadResult(Path: path, Error: err); - pending.Add(("CDIDX_MCP_RATE_LIMIT_BURST", value!)); + errors.Add(err!); + else + pending.Add((RateLimiterOptions.BurstEnvVar, value!)); } - return null; + if (rateLimit.TryGetProperty("bucket_idle_seconds", out var bucketIdleSeconds)) + { + if (!TryReadNumberAsString(bucketIdleSeconds, "mcp.rate_limit.bucket_idle_seconds", path, out var value, out var err)) + errors.Add(err!); + else + pending.Add((RateLimiterOptions.BucketIdleSecondsEnvVar, value!)); + } } private static void ApplyPendingEnvironmentSettings( @@ -474,25 +498,26 @@ internal static int RunShow(string[] args, JsonSerializerOptions jsonOptions) return CommandExitCodes.Success; } - private static bool ValidateOptionalObject(JsonElement root, string key, IReadOnlyList knownKeys, string path, out string? error) + private static void ValidateOptionalObject(JsonElement root, string key, IReadOnlyList knownKeys, string path, List errors) { - error = null; if (!root.TryGetProperty(key, out var value)) - return true; + return; if (value.ValueKind != JsonValueKind.Object) { - error = $"[cdidx] {path}: `{key}` must be a JSON object."; - return false; + errors.Add($"[cdidx] {path}: `{key}` must be a JSON object."); + return; } - if (TryFindUnknownKey(value, knownKeys, out var unknownKey)) - { - error = $"[cdidx] {path}: unknown key `{key}.{unknownKey}`. Supported keys: {string.Join(", ", knownKeys)}."; - return false; - } - return true; + + AddUnknownKeyDiagnostics(value, knownKeys, key, path, string.Join(", ", knownKeys), errors); } - private static bool TryFindUnknownKey(JsonElement obj, IReadOnlyList knownKeys, out string? unknown) + private static void AddUnknownKeyDiagnostics( + JsonElement obj, + IReadOnlyList knownKeys, + string? prefix, + string path, + string supportedKeys, + List errors) { foreach (var property in obj.EnumerateObject()) { @@ -505,14 +530,13 @@ private static bool TryFindUnknownKey(JsonElement obj, IReadOnlyList kno break; } } + if (!matched) { - unknown = property.Name; - return true; + var qualifiedName = prefix is null ? property.Name : $"{prefix}.{property.Name}"; + errors.Add($"[cdidx] {path}: unknown key `{qualifiedName}`. Supported keys: {supportedKeys}."); } } - unknown = null; - return false; } private static bool TryReadString(JsonElement element, string key, string path, out string? value, out string? error) diff --git a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs index fd1ed28b62..8049e966ed 100644 --- a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs +++ b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs @@ -1,4 +1,5 @@ using CodeIndex.Cli; +using CodeIndex.Mcp; using System.Text; namespace CodeIndex.Tests; @@ -47,7 +48,7 @@ public void LoadAndApply_MaterializesKnownKeysIntoEnvironment() }, "mcp": { "tools": { "allow": ["search", "definition"], "deny": ["index"] }, - "rate_limit": { "rps": 5, "burst": 10 } + "rate_limit": { "rps": 5, "burst": 10, "bucket_idle_seconds": 120 } } } """); @@ -69,8 +70,9 @@ public void LoadAndApply_MaterializesKnownKeysIntoEnvironment() Assert.Equal("test_method,generated_parser", env.Writes["CDIDX_INDEX_EXCLUDE_SYMBOL_KINDS"]); Assert.Equal("search,definition", env.Writes["CDIDX_MCP_TOOLS_ALLOW"]); Assert.Equal("index", env.Writes["CDIDX_MCP_TOOLS_DENY"]); - Assert.Equal("5", env.Writes["CDIDX_MCP_RATE_LIMIT_RPS"]); - Assert.Equal("10", env.Writes["CDIDX_MCP_RATE_LIMIT_BURST"]); + Assert.Equal("5", env.Writes[RateLimiterOptions.RpsEnvVar]); + Assert.Equal("10", env.Writes[RateLimiterOptions.BurstEnvVar]); + Assert.Equal("120", env.Writes[RateLimiterOptions.BucketIdleSecondsEnvVar]); } finally { TestProjectHelper.DeleteDirectory(dir); } } @@ -383,6 +385,45 @@ public void LoadAndApply_UnknownNestedMcpKey_ReturnsError() finally { TestProjectHelper.DeleteDirectory(dir); } } + [Fact] + public void LoadAndApply_InvalidConfigReportsMultipleDiagnostics_Issue3432() + { + var dir = CreateTempDir(); + try + { + File.WriteAllText(Path.Combine(dir, ".cdidxrc.json"), """ + { + "debug": "1", + "github_token": "secret", + "disable_persistent_log": "yes", + "search": { + "limit": 0, + "typo": true + }, + "mcp": { + "rate_limit": { + "burst": "fast", + "unknown": 1 + } + } + } + """); + + var env = new TestEnvironment(); + var result = CdidxConfigFile.LoadAndApply(dir, env.Read, env.Write); + + Assert.True(result.Failed); + Assert.Contains("github_token", result.Error); + Assert.Contains("disable_persistent_log", result.Error); + Assert.Contains("search.limit", result.Error); + Assert.Contains("search.typo", result.Error); + Assert.Contains("mcp.rate_limit.burst", result.Error); + Assert.Contains("mcp.rate_limit.unknown", result.Error); + Assert.Empty(env.Writes); + } + finally { TestProjectHelper.DeleteDirectory(dir); } + } + [Fact] public void LoadAndApply_StringArrayAboveMaximumItemCount_ReturnsError() { From 23358ef56d6e4ce5e5b390ed3893f0d18c38b4d7 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 00:37:24 +0900 Subject: [PATCH 2/3] Fix workspace manifest validation (#3429) --- DEVELOPER_GUIDE.md | 7 +- changelog.d/unreleased/3429.fixed.md | 17 +++ src/CodeIndex/Cli/WorkspaceManifest.cs | 95 +++++++++++--- .../WorkspaceCommandRunnerTests.cs | 118 ++++++++++++++++++ 4 files changed, 216 insertions(+), 21 deletions(-) create mode 100644 changelog.d/unreleased/3429.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index e37d469404..15e5e06500 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -139,7 +139,7 @@ ownership boundaries so behavior changes remain reviewable and testable. ### Workspaces -`cdidx.workspace.json` and `.cdidx-workspace.json` declare monorepo members without adding a YAML dependency. Workspace manifests are capped at 64 KiB, 16 JSON nesting levels, 1024 members, 4096 characters per member path, and 255 characters for `default_db_name`. The supported schema is additive: `members` is an array of member paths that must be relative to and resolve under the manifest directory, `index_strategy` is `per_member` or `single` with unknown values rejected, `default_db_name` is a plain file name that overrides `codeindex.db`, and `shared_ignores` is reserved for shared ignore policy. `cdidx workspace list` and `cdidx workspace status` report member DB paths. +`cdidx.workspace.json` and `.cdidx-workspace.json` declare monorepo members without adding a YAML dependency. Workspace manifests are capped at 64 KiB, 16 JSON nesting levels, 1024 members, 4096 characters per member path, and 255 characters for `default_db_name`. The supported schema is additive: `members` is an array of member paths that must be relative to and resolve under the manifest directory, `index_strategy` is `per_member` or `single` with unknown values rejected, `default_db_name` is a plain file name that overrides `codeindex.db`, and `shared_ignores` is reserved for shared ignore policy. Invalid `members` entries are rejected with bounded diagnostics, and valid entries are normalized and deduplicated with the workspace path casing policy before DB paths are materialized. `cdidx workspace list` and `cdidx workspace status` report member DB paths. `cdidx workspace use ` writes an existing manifest member or `default` workspace to the per-user config directory, rejects missing manifest members, and rejects ambiguous member directory names. Query DB resolution keeps existing precedence: explicit `--db`, then explicit `--data-dir` / `CDIDX_DATA_DIR`, then active workspace state, then ancestor/CWD discovery. @@ -2249,11 +2249,14 @@ ownership boundary を分けるときは、挙動変更を review しやすく t ### ワークスペース `cdidx.workspace.json` と `.cdidx-workspace.json` は YAML dependency を増やさずに monorepo -member を宣言します。workspace manifest は 64 KiB、JSON nesting 16 level、1024 members に制限されます。 +member を宣言します。workspace manifest は 64 KiB、JSON nesting 16 level、1024 members、 +member path 4096 characters、`default_db_name` 255 characters に制限されます。 schema は additive で、`members` は manifest directory からの相対 path かつ正規化後も manifest directory 配下に残る member path、 `index_strategy` は `per_member` または `single`、`default_db_name` は `codeindex.db` を上書きする plain file name、`shared_ignores` は共有 ignore policy 用の予約 field です。 +invalid な `members` entries は件数を制限した diagnostics で拒否され、有効な entry は DB path を +作る前に workspace path casing policy で正規化・重複排除されます。 `cdidx workspace list` と `cdidx workspace status` は member DB path を報告します。 `cdidx workspace use ` は active workspace を per-user config directory に保存します。 diff --git a/changelog.d/unreleased/3429.fixed.md b/changelog.d/unreleased/3429.fixed.md new file mode 100644 index 0000000000..c7c9e05643 --- /dev/null +++ b/changelog.d/unreleased/3429.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3429 +affected: + - src/CodeIndex/Cli/WorkspaceManifest.cs + - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Workspace manifest member validation is stricter and more deterministic (#3429)** — invalid member entries are reported with bounded diagnostics, while valid member paths are normalized and deduplicated consistently before DB paths are materialized. + +## 日本語 + +- **workspace manifest member validation を厳密かつ決定的にしました (#3429)** — invalid member entries を件数制限つき diagnostics で報告しつつ、有効な member path を DB path 作成前に一貫して正規化・重複排除するようにしました。 diff --git a/src/CodeIndex/Cli/WorkspaceManifest.cs b/src/CodeIndex/Cli/WorkspaceManifest.cs index 4a30d620cd..d720aa5bdc 100644 --- a/src/CodeIndex/Cli/WorkspaceManifest.cs +++ b/src/CodeIndex/Cli/WorkspaceManifest.cs @@ -30,6 +30,8 @@ internal static class WorkspaceManifestLoader internal const int MaxManifestDepth = 16; internal const int MaxManifestMembers = 1024; internal const int MaxManifestMemberPathChars = 4096; + internal const int MaxManifestMemberDiagnostics = 8; + internal const int MaxManifestDiscoveryAncestors = 256; internal const int MaxDefaultDbNameChars = 255; internal static WorkspaceManifest? Find(string startingDirectory) @@ -39,13 +41,22 @@ internal static class WorkspaceManifestLoader { current = new DirectoryInfo(Path.GetFullPath(startingDirectory)); } - catch + catch (Exception ex) when (ex is ArgumentException + or IOException + or NotSupportedException + or PathTooLongException + or UnauthorizedAccessException) { - return null; + throw new InvalidDataException($"Workspace manifest discovery start directory is invalid: {startingDirectory}", ex); } + var searchedAncestors = 0; while (current is not null) { + if (searchedAncestors >= MaxManifestDiscoveryAncestors) + throw new InvalidDataException($"Workspace manifest discovery exceeded the {MaxManifestDiscoveryAncestors} ancestor limit from {startingDirectory}."); + searchedAncestors++; + foreach (var name in new[] { DotFileName, FileName }) { var candidate = Path.Combine(current.FullName, name); @@ -76,10 +87,10 @@ internal static WorkspaceManifest Load(string path) var strategy = ValidateIndexStrategy(ReadString(element, "index_strategy") ?? "per_member"); var dbName = ValidateDefaultDbName(ReadString(element, "default_db_name") ?? "codeindex.db"); var rawMembers = ReadMembers(element); + var uniqueMembers = NormalizeAndDedupeMembers(root, rawMembers); - var members = rawMembers.Select(member => + var members = uniqueMembers.Select(fullMember => { - var fullMember = ResolveMemberPath(root, member); var dbPath = string.Equals(strategy, "single", StringComparison.OrdinalIgnoreCase) ? Path.Combine(root, ".cdidx", dbName) : Path.Combine(fullMember, ".cdidx", dbName); @@ -126,26 +137,76 @@ private static string ValidateDefaultDbName(string dbName) private static IReadOnlyList ReadMembers(JsonElement element) { - if (!element.TryGetProperty("members", out var membersElement) || membersElement.ValueKind != JsonValueKind.Array) + if (!element.TryGetProperty("members", out var membersElement)) return Array.Empty(); + if (membersElement.ValueKind != JsonValueKind.Array) + throw new InvalidDataException("Workspace manifest members must be an array of relative path strings."); var members = new List(); + var diagnostics = new List(); + var invalidCount = 0; + var memberIndex = 0; foreach (var member in membersElement.EnumerateArray()) { if (member.ValueKind != JsonValueKind.String) + { + AddMemberDiagnostic(diagnostics, ref invalidCount, $"members[{memberIndex}] must be a string."); + memberIndex++; continue; + } var value = member.GetString(); if (string.IsNullOrWhiteSpace(value)) + { + AddMemberDiagnostic(diagnostics, ref invalidCount, $"members[{memberIndex}] must be a non-empty relative path string."); + memberIndex++; continue; + } if (value.Length > MaxManifestMemberPathChars) - throw new InvalidDataException($"Workspace manifest member path exceeds the {MaxManifestMemberPathChars} character limit."); + { + AddMemberDiagnostic(diagnostics, ref invalidCount, $"members[{memberIndex}] exceeds the {MaxManifestMemberPathChars} character limit."); + memberIndex++; + continue; + } if (members.Count >= MaxManifestMembers) throw new InvalidDataException($"Workspace manifest members exceed the {MaxManifestMembers} member limit."); members.Add(value); + memberIndex++; + } + + if (diagnostics.Count > 0) + { + var suffix = invalidCount > diagnostics.Count + ? $" and {invalidCount - diagnostics.Count} more invalid member entr{(invalidCount - diagnostics.Count == 1 ? "y" : "ies")}" + : string.Empty; + throw new InvalidDataException($"Workspace manifest members contain invalid entries: {string.Join("; ", diagnostics)}{suffix}."); + } + + return members; + } + + private static void AddMemberDiagnostic(List diagnostics, ref int invalidCount, string diagnostic) + { + invalidCount++; + if (diagnostics.Count < MaxManifestMemberDiagnostics) + diagnostics.Add(diagnostic); + } + + private static IReadOnlyList NormalizeAndDedupeMembers(string root, IReadOnlyList rawMembers) + { + if (rawMembers.Count == 0) + return Array.Empty(); + + var members = new List(rawMembers.Count); + var seen = new HashSet(StringComparer.FromComparison(PathCasing.ComparisonFor(root))); + foreach (var member in rawMembers) + { + var fullMember = ResolveMemberPath(root, member); + if (seen.Add(fullMember)) + members.Add(fullMember); } return members; @@ -156,24 +217,20 @@ private static string ResolveMemberPath(string root, string member) if (Path.IsPathRooted(member)) throw new InvalidDataException($"Workspace manifest member path must be relative: {member}"); - var fullMember = Path.GetFullPath(Path.Combine(root, member)); - if (!IsSameOrDescendant(root, fullMember)) + var normalizedRoot = NormalizeBoundaryPath(Path.GetFullPath(root)); + var fullMember = NormalizeBoundaryPath(Path.GetFullPath(Path.Combine(normalizedRoot, member))); + if (!PathCasing.IsPathEqualOrParent(normalizedRoot, fullMember)) throw new InvalidDataException($"Workspace manifest member path escapes the manifest root: {member}"); return fullMember; } - private static bool IsSameOrDescendant(string root, string path) + private static string NormalizeBoundaryPath(string path) { - var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - var normalizedRoot = Path.GetFullPath(root); - var normalizedPath = Path.GetFullPath(path); - if (string.Equals(normalizedRoot, normalizedPath, comparison)) - return true; - - var rootWithSeparator = Path.EndsInDirectorySeparator(normalizedRoot) - ? normalizedRoot - : normalizedRoot + Path.DirectorySeparatorChar; - return normalizedPath.StartsWith(rootWithSeparator, comparison); + var fullPath = Path.GetFullPath(path); + var root = Path.GetPathRoot(fullPath); + if (!string.IsNullOrEmpty(root) && string.Equals(fullPath, root, StringComparison.Ordinal)) + return fullPath; + return fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } } diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index b1df9d03c5..7a7d6ca74f 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -130,6 +130,71 @@ public void WorkspaceManifestLoader_Load_RejectsOverlongMemberPath() } } + [Fact] + public void WorkspaceManifestLoader_Load_RejectsInvalidMemberEntriesWithBoundedDiagnostics_Issue3429() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_manifest_invalid_members"); + try + { + var manifestPath = Path.Combine(root, "cdidx.workspace.json"); + var longMember = new string('a', WorkspaceManifestLoader.MaxManifestMemberPathChars + 1); + File.WriteAllText(manifestPath, $$""" + { + "members": [ + "src/A", + "", + 42, + true, + " ", + {{JsonSerializer.Serialize(longMember)}} + ] + } + """); + + var ex = Assert.Throws(() => WorkspaceManifestLoader.Load(manifestPath)); + + Assert.Contains("members contain invalid entries", ex.Message); + Assert.Contains("members[1]", ex.Message); + Assert.Contains("members[2]", ex.Message); + Assert.Contains("members[3]", ex.Message); + Assert.Contains("members[4]", ex.Message); + Assert.Contains("members[5]", ex.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(root); + } + } + + [Fact] + public void WorkspaceManifestLoader_Load_NormalizesAndDedupesMembers_Issue3429() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_manifest_dedupe"); + try + { + var manifestPath = Path.Combine(root, "cdidx.workspace.json"); + File.WriteAllText(manifestPath, """ + { + "members": [ + "src/A", + "src/./A/", + "src/B/" + ] + } + """); + + var manifest = WorkspaceManifestLoader.Load(manifestPath); + + Assert.Equal(2, manifest.Members.Count); + Assert.Equal(Path.GetFullPath(Path.Combine(root, "src", "A")), manifest.Members[0].Path); + Assert.Equal(Path.GetFullPath(Path.Combine(root, "src", "B")), manifest.Members[1].Path); + } + finally + { + TestProjectHelper.DeleteDirectory(root); + } + } + [Fact] public void WorkspaceManifestLoader_Load_RejectsRootedMemberPath() { @@ -177,6 +242,36 @@ public void WorkspaceManifestLoader_Load_RejectsEscapingMemberPath() } } + [Fact] + public void WorkspaceManifestLoader_Load_UsesPathCasingForContainment_Issue3429() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_manifest_case"); + try + { + var manifestPath = Path.Combine(root, "cdidx.workspace.json"); + var alternateRootName = SwapAsciiCase(Path.GetFileName(root)); + Assert.NotEqual(Path.GetFileName(root), alternateRootName); + var member = Path.Combine("..", alternateRootName, "src"); + File.WriteAllText(manifestPath, $$""" + { + "members": [{{JsonSerializer.Serialize(member)}}] + } + """); + + PathCasing.ResetCacheForTests(); + PathCasing.SeedFromWorkspace(root, ignoreCase: false); + + var ex = Assert.Throws(() => WorkspaceManifestLoader.Load(manifestPath)); + + Assert.Contains("member path escapes the manifest root", ex.Message); + } + finally + { + PathCasing.ResetCacheForTests(); + TestProjectHelper.DeleteDirectory(root); + } + } + [Fact] public void WorkspaceManifestLoader_Load_RejectsOverlongDefaultDbName() { @@ -302,6 +397,14 @@ public void WorkspaceManifestLoader_Load_RejectsUnknownIndexStrategy() } } + [Fact] + public void WorkspaceManifestLoader_Find_RejectsInvalidStartDirectory_Issue3429() + { + var ex = Assert.Throws(() => WorkspaceManifestLoader.Find("\0")); + + Assert.Contains("discovery start directory is invalid", ex.Message); + } + [Fact] public void WorkspaceErrors_HonorJsonFlag() { @@ -743,4 +846,19 @@ public void WorkspaceUseDefault_DoesNotSelectFirstManifestMember() TestProjectHelper.DeleteDirectory(configHome); } } + + private static string SwapAsciiCase(string value) + { + var chars = value.ToCharArray(); + for (var i = 0; i < chars.Length; i++) + { + var ch = chars[i]; + if (ch is >= 'a' and <= 'z') + chars[i] = (char)(ch - ('a' - 'A')); + else if (ch is >= 'A' and <= 'Z') + chars[i] = (char)(ch + ('a' - 'A')); + } + + return new string(chars); + } } From 8cdf56adb580be3df65ab07cf314dd8839acae9c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 01:12:39 +0900 Subject: [PATCH 3/3] Expose project filter fallback diagnostics (#3461) --- DEVELOPER_GUIDE.md | 4 +- USER_GUIDE.md | 1 + changelog.d/unreleased/3461.fixed.md | 21 ++++++ src/CodeIndex/Cli/QueryCommandRunner.cs | 38 ++++++++-- src/CodeIndex/Mcp/McpServer.cs | 21 +++++- src/CodeIndex/Mcp/McpToolHandlers.cs | 31 ++++++-- tests/CodeIndex.Tests/McpServerTests.cs | 74 +++++++++++++++++++ .../QueryCommandRunnerSearchTests.cs | 41 ++++++++++ .../QueryCommandRunnerTests.cs | 38 ++++++++++ 9 files changed, 252 insertions(+), 17 deletions(-) create mode 100644 changelog.d/unreleased/3461.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 15e5e06500..76d4fed3f8 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -216,7 +216,7 @@ Interactive terminal controls are allowed only when stdout is not redirected or `SolutionProjectResolver` parses the plain-text `.sln` `Project(...) = "...", "...csproj"` entries with a non-regex parser and resolves C# / F# / VB project files. Project entries that normalize outside the active workspace root are ignored before filesystem probing or path-filter evaluation. Solution parsing rejects `.sln` files above 8 MiB, lines above 16,384 characters, and more than 4096 .NET project references with clear diagnostics. Automatic root-level `.sln` discovery samples at most 128 candidates before sorting and reports a clear error when that cap is exceeded, so callers should pass `--solution ` in solution-heavy workspaces. When exactly one `.sln` exists at the workspace root within that cap, `--project ` uses it automatically; otherwise callers can pass `--solution `. Fallback project discovery caps traversal at 4096 directories and 65,536 files with a clear `--solution ` recovery hint. Fallback project discovery and project-file expansion use long-path-safe per-directory enumeration, skip unreadable subtrees, and include bounded traversal diagnostics when a project filter cannot be resolved. -Query commands that accept path filters (`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots`, and `validate`) expand `--project` into the matching project directory glob before hitting `DbReader`, so all existing SQL path predicates keep working. `index --project` expands to the files under the selected project directory and reuses the existing `--files` update path, but rejects expansions above 65,536 files for one project or 131,072 unique files across all requested projects with an explicit-files recovery hint. +Query commands that accept path filters (`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots`, and `validate`) expand `--project` into the matching project directory glob before hitting `DbReader`, so all existing SQL path predicates keep working. When the indexed project root cannot be resolved and project expansion falls back to the process current directory, CLI query context and MCP structured payloads include `project_filter_root` and `project_filter_root_fallback_reason`. `index --project` expands to the files under the selected project directory and reuses the existing `--files` update path, but rejects expansions above 65,536 files for one project or 131,072 unique files across all requested projects with an explicit-files recovery hint. `cdidx batch` is a CLI-side query loop for editor integrations and scripts that need several query commands against the same DB without spawning `cdidx` repeatedly. It opens one `DbContext` / `DbReader`, reads newline-delimited JSON string arrays from stdin, caps each decoded string argument at 8,192 characters, and dispatches only query commands through the existing `QueryCommandRunner` paths so output and validation stay identical to the standalone command shape. @@ -2363,7 +2363,7 @@ override が文書化されていない限り ANSI/progress control を抑止す `SolutionProjectResolver` は plain-text の `.sln` に含まれる `Project(...) = "...", "...csproj"` 行を non-regex parser で読み、C# / F# / VB の project file を解決する。active workspace root の外側へ正規化される project entry は、filesystem probe や path-filter 評価の前に無視する。solution parsing は 8 MiB を超える `.sln`、16,384 文字を超える行、4096 件を超える .NET project reference を明確な diagnostic とともに拒否する。root 直下の `.sln` 自動検出は sort 前に最大 128 candidates で打ち切り、その上限を超えた場合は明確な error を返すため、solution が多い workspace では `--solution ` を渡す。上限内で workspace root に `.sln` が 1 つだけある場合、`--project ` は自動でそれを使う。複数ある場合は caller が `--solution ` を渡せる。fallback project discovery は 4096 directories / 65,536 files で traversal を打ち切り、`--solution ` を示す明確な recovery hint を返す。fallback project discovery と project-file expansion は long-path-safe な per-directory 列挙を使い、読めない subtree を skip し、project filter を解決できない場合は bounded traversal diagnostics を含める。 -path filter を受け付ける query コマンド(`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots`, `validate`)は、`--project` を対応する project directory glob に展開してから `DbReader` に渡す。これにより既存の SQL path predicate をそのまま利用できる。`index --project` は選択された project directory 配下のファイルに展開し、既存の `--files` 更新経路を再利用する。ただし 1 project で 65,536 files、requested projects 全体で 131,072 unique files を超える展開は拒否し、明示的な `--files` を使う recovery hint を返す。 +path filter を受け付ける query コマンド(`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots`, `validate`)は、`--project` を対応する project directory glob に展開してから `DbReader` に渡す。これにより既存の SQL path predicate をそのまま利用できる。indexed project root を解決できず process current directory に fallback して project expansion する場合、CLI query context と MCP structured payload は `project_filter_root` と `project_filter_root_fallback_reason` を含める。`index --project` は選択された project directory 配下のファイルに展開し、既存の `--files` 更新経路を再利用する。ただし 1 project で 65,536 files、requested projects 全体で 131,072 unique files を超える展開は拒否し、明示的な `--files` を使う recovery hint を返す。 `cdidx batch` は、同じ DB に複数の query command を投げる editor integration や script 向けの CLI 側 query loop である。1 つの `DbContext` / `DbReader` を開き、stdin から newline-delimited JSON 文字列配列を読み、デコード後の各文字列引数を 8,192 文字に制限し、query command だけを既存の `QueryCommandRunner` 経路へ dispatch するため、出力と validation は単発コマンドと同じ形を保つ。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 2c8969ac11..89822d0bc2 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1765,6 +1765,7 @@ CLI JSON and MCP compatibility: | CLI metadata | CLI commands keep CLI-oriented metadata such as `api_version` and command result fields. | | MCP metadata | MCP tools return JSON-RPC tool results with camelCase field names and may include MCP-specific metadata. | | Grouped graph rows | Graph tools that group reference rows (`callers`, `callees`, and bundled `analyze_symbol` caller/callee rows) expose a backward-compatible scalar summary kind plus a sorted kind array and mixed-kind flag. CLI JSON uses `reference_kind` / `reference_kinds` / `has_mixed_reference_kinds`, while MCP uses `referenceKind` / `referenceKinds` / `hasMixedReferenceKinds`. | +| Project filters | When `--project` / MCP `project` expansion cannot resolve the indexed project root and uses the process current directory, structured payloads expose `project_filter_root` and `project_filter_root_fallback_reason`. | | Consumer guidance | Consumers that need every underlying kind should read the array for the surface they call and ignore unknown future fields. See [INTEGRATION_POLICY.md](INTEGRATION_POLICY.md#cli-json-and-mcp-response-compatibility) for the CLI/MCP compatibility table. | | Slow search profiling | Add `--profile` to read commands to append one JSON object after the normal results. It contains `profile.phases` (`name`, `elapsed_ms`, `rows_scanned`), `profile.query_plan` (`EXPLAIN QUERY PLAN` rows), and `profile.queries` (SQL text). With `--slow-query-ms `, profiled SQL at or above the threshold is written to the persistent tool log. | diff --git a/changelog.d/unreleased/3461.fixed.md b/changelog.d/unreleased/3461.fixed.md new file mode 100644 index 0000000000..5e81ace650 --- /dev/null +++ b/changelog.d/unreleased/3461.fixed.md @@ -0,0 +1,21 @@ +--- +category: fixed +issues: + - 3461 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/QueryCommandRunnerTests.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - DEVELOPER_GUIDE.md + - USER_GUIDE.md +--- + +## English + +- **Project filter current-directory fallback is now explicit (#3461)** — CLI query context and MCP structured payloads now expose the effective `project_filter_root` and `project_filter_root_fallback_reason` when project expansion falls back to the process current directory. + +## 日本語 + +- **project filter の current-directory fallback を明示しました (#3461)** — project expansion が process current directory に fallback した場合、CLI query context と MCP structured payload が有効な `project_filter_root` と `project_filter_root_fallback_reason` を返すようにしました。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 3b0a8f6536..3dafcdae2f 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -57,6 +57,10 @@ public static class QueryCommandRunner [ThreadStatic] private static string? s_activeQueryProjectRoot; + internal const string ProjectFilterRootFallbackReasonCurrentDirectory = "project_root_unresolved_using_current_directory"; + + internal readonly record struct ProjectFilterRootResolution(string Root, string? FallbackReason); + private static DateTime GetUtcNow() => TimeProvider.GetUtcNow().UtcDateTime; // Cap OR-joined `symbols` names well below SQLite's 1000 expression-tree depth so oversized @@ -6176,6 +6180,7 @@ public static QueryCommandOptions ParseArgs( string? openIssuesPath = null; bool languagesIndexedOnly = false; var languageCapabilities = new List(); + ProjectFilterRootResolution? projectFilterRootResolution = null; void AddParseError(string error) { @@ -6979,8 +6984,8 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) { try { - var projectRoot = ResolveProjectFilterRoot(resolvedDbPath, dbPathExplicit); - foreach (var glob in SolutionProjectResolver.ResolveProjectDirectoryGlobs(projectRoot, projectFilters, solutionFilter)) + projectFilterRootResolution = ResolveProjectFilterRoot(resolvedDbPath, dbPathExplicit); + foreach (var glob in SolutionProjectResolver.ResolveProjectDirectoryGlobs(projectFilterRootResolution.Value.Root, projectFilters, solutionFilter)) pathPatterns.Add(glob); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) @@ -7043,6 +7048,8 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) PathPatterns = pathPatterns, WorkspaceDbPaths = workspaceDbPaths, ProjectFilters = projectFilters, + ProjectFilterRoot = projectFilterRootResolution?.Root, + ProjectFilterRootFallbackReason = projectFilterRootResolution?.FallbackReason, SolutionFilter = solutionFilter, ExcludePaths = excludePaths, VisibilityFilters = visibilityFilters, @@ -7091,7 +7098,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) }; } - private static string ResolveProjectFilterRoot(string dbPath, bool dbPathExplicit) + internal static ProjectFilterRootResolution ResolveProjectFilterRoot(string dbPath, bool dbPathExplicit) { var effectiveDbPath = s_batchReader != null && !string.IsNullOrWhiteSpace(s_batchDbPath) ? s_batchDbPath! @@ -7099,8 +7106,13 @@ private static string ResolveProjectFilterRoot(string dbPath, bool dbPathExplici var effectiveDbPathExplicit = s_batchReader != null && !string.IsNullOrWhiteSpace(s_batchDbPath) ? s_batchDbPathExplicit : dbPathExplicit; - return DbPathResolver.ResolveProjectRootForQuery(effectiveDbPath, effectiveDbPathExplicit) - ?? Environment.CurrentDirectory; + var projectRoot = DbPathResolver.ResolveProjectRootForQuery(effectiveDbPath, effectiveDbPathExplicit); + if (!string.IsNullOrWhiteSpace(projectRoot)) + return new ProjectFilterRootResolution(Path.GetFullPath(projectRoot), null); + + return new ProjectFilterRootResolution( + Path.GetFullPath(Environment.CurrentDirectory), + ProjectFilterRootFallbackReasonCurrentDirectory); } private static List ParseMapSections(string rawValue, Action addParseError) @@ -7672,7 +7684,7 @@ private static int WithDb( reader.IncludeGenerated = options.IncludeGenerated; var previousProjectRoot = s_activeQueryProjectRoot; - s_activeQueryProjectRoot = ResolveProjectFilterRoot(dbPath, options.DbPathExplicit); + s_activeQueryProjectRoot = ResolveProjectFilterRoot(dbPath, options.DbPathExplicit).Root; int exitCode; try { @@ -8421,6 +8433,12 @@ private static IEnumerable BuildQueryContextParts(QueryCommandOptions op yield return $"query: \"{options.Query}\""; if (options.PathPatterns.Count > 0) yield return $"path: {string.Join(", ", options.PathPatterns)}"; + if (options.ProjectFilters.Count > 0) + yield return $"project: {string.Join(", ", options.ProjectFilters)}"; + if (!string.IsNullOrWhiteSpace(options.ProjectFilterRoot)) + yield return $"project-root: {options.ProjectFilterRoot}"; + if (!string.IsNullOrWhiteSpace(options.ProjectFilterRootFallbackReason)) + yield return $"project-root-fallback: {options.ProjectFilterRootFallbackReason}"; if (options.ExcludePaths.Count > 0) yield return $"exclude-path: {string.Join(", ", options.ExcludePaths)}"; if (options.Lang != null) @@ -8465,6 +8483,12 @@ private static JsonObject BuildQueryContextJson(QueryCommandOptions options, Jso query["text"] = options.Query; if (options.PathPatterns.Count > 0) query["path"] = JsonSerializer.SerializeToNode(options.PathPatterns, CliJsonSerializerContextFactory.Create(jsonOptions).ListString); + if (options.ProjectFilters.Count > 0) + query["project"] = JsonSerializer.SerializeToNode(options.ProjectFilters, CliJsonSerializerContextFactory.Create(jsonOptions).ListString); + if (!string.IsNullOrWhiteSpace(options.ProjectFilterRoot)) + query["project_filter_root"] = options.ProjectFilterRoot; + if (!string.IsNullOrWhiteSpace(options.ProjectFilterRootFallbackReason)) + query["project_filter_root_fallback_reason"] = options.ProjectFilterRootFallbackReason; if (options.ExcludePaths.Count > 0) query["exclude_path"] = JsonSerializer.SerializeToNode(options.ExcludePaths, CliJsonSerializerContextFactory.Create(jsonOptions).ListString); if (options.Lang != null) @@ -10028,6 +10052,8 @@ public sealed class QueryCommandOptions public List PathPatterns { get; init; } = []; public List WorkspaceDbPaths { get; init; } = []; public List ProjectFilters { get; init; } = []; + public string? ProjectFilterRoot { get; init; } + public string? ProjectFilterRootFallbackReason { get; init; } public string? SolutionFilter { get; init; } public List ExcludePaths { get; init; } = []; public bool ExcludeTests { get; init; } diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 3f3629b697..e61ae861ec 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -3737,8 +3737,20 @@ private JsonObject CreateToolResult(JsonNode? id, string text, JsonNode? structu } } }; - if (structuredContent != null) + if (structuredContent is JsonObject structuredObject) + { + AddProjectFilterRootDiagnostics(structuredObject); + result["structuredContent"] = structuredContent; + } + else if (structuredContent != null) + { + ClearProjectFilterRootDiagnostics(); result["structuredContent"] = structuredContent; + } + else + { + ClearProjectFilterRootDiagnostics(); + } var response = CreateSuccessResponse(true, id, result); var responseLimit = GetMaxResponseBytes(); if (TryMeasureJsonUtf8BytesWithinLimit(response, _jsonOptions, responseLimit, out var responseBytes)) @@ -3904,7 +3916,7 @@ private static int ReadPositiveIntEnvironmentLimit(string envVar, int defaultVal /// data.similar_values 配列を添えるので、MCP クライアントは /// 人間向けメッセージを解析せずに代替候補を提示できる (#1582)。 /// - private static JsonObject CreateToolErrorResponse(JsonNode? id, string message, + private JsonObject CreateToolErrorResponse(JsonNode? id, string message, string category, string suggestion, bool retrySafe, JsonObject? extraData = null, IReadOnlyList? similarValues = null) => CreateToolErrorResponse(id is not null, id, message, category, suggestion, retrySafe, extraData, similarValues); @@ -3920,7 +3932,7 @@ private static JsonObject CreateToolErrorResponse(JsonNode? id, string message, // / retry_safe=false とする。任意の `similarValues` は未知 enum 値に対する構造化された // did-you-mean 候補 (#1582)。より具体的なカテゴリを持てる呼び出し元は明示オーバーロード // を使う。 - private static JsonObject CreateToolErrorResponse(JsonNode? id, string message, + private JsonObject CreateToolErrorResponse(JsonNode? id, string message, IReadOnlyList? similarValues = null) => CreateToolErrorResponse(id, message, category: McpErrorEnvelope.CategoryInvalidArgument, @@ -3935,10 +3947,11 @@ private static JsonObject CreateToolErrorResponse(JsonNode? id, string message, // #1581: ツール結果エラーにも JSON-RPC エラーと同じ `category` / `suggestion` / `retry_safe` // を `result.structuredContent` に載せる。既存の `content[0].text` + `isError` だけを読む // クライアントは互換のまま、新規クライアントは `structuredContent` でカテゴリ分岐できる。 - private static JsonObject CreateToolErrorResponse(bool hasId, JsonNode? id, string message, + private JsonObject CreateToolErrorResponse(bool hasId, JsonNode? id, string message, string category, string suggestion, bool retrySafe, JsonObject? extraData = null, IReadOnlyList? similarValues = null) { + ClearProjectFilterRootDiagnostics(); var result = new JsonObject { ["content"] = new JsonArray diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 409970d778..e4fbb6b50f 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -36,6 +36,7 @@ public partial class McpServer "rankBy", }; internal const int MaxMcpIndexFailureMessageLength = 512; + private QueryCommandRunner.ProjectFilterRootResolution? _projectFilterRootResolutionForCurrentToolCall; // --- Tool implementations / ツール実装 --- @@ -1009,6 +1010,7 @@ private JsonObject ToAnalyzeSymbolJsonObject(SymbolAnalysisResult analysis) private List? ReadScopedPathList(JsonNode? args) { + _projectFilterRootResolutionForCurrentToolCall = null; var paths = ReadPathList(args, "path") ?? []; var projects = ReadPathList(args, "project") ?? []; if (projects.Count == 0) @@ -1016,7 +1018,8 @@ private JsonObject ToAnalyzeSymbolJsonObject(SymbolAnalysisResult analysis) var solution = args?["solution"]?.GetValue(); var projectRoot = ResolveProjectFilterRoot(); - foreach (var glob in SolutionProjectResolver.ResolveProjectDirectoryGlobs(projectRoot, projects, solution)) + _projectFilterRootResolutionForCurrentToolCall = projectRoot; + foreach (var glob in SolutionProjectResolver.ResolveProjectDirectoryGlobs(projectRoot.Root, projects, solution)) paths.Add(glob); return paths.Count == 0 ? null : paths; } @@ -1028,9 +1031,10 @@ private JsonObject ToAnalyzeSymbolJsonObject(SymbolAnalysisResult analysis) return null; var solution = args?["solution"]?.GetValue(); + var projectRoot = ResolveProjectFilterRoot(); try { - _ = SolutionProjectResolver.ResolveProjectDirectoryGlobs(ResolveProjectFilterRoot(), projects, solution); + _ = SolutionProjectResolver.ResolveProjectDirectoryGlobs(projectRoot.Root, projects, solution); return null; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) @@ -1041,15 +1045,32 @@ private JsonObject ToAnalyzeSymbolJsonObject(SymbolAnalysisResult analysis) ["message"] = $"Project filter could not be resolved: {diagnostic.Text}", ["parameter"] = "project", ["diagnostic"] = diagnostic.Text, + ["project_filter_root"] = projectRoot.Root, }; + if (!string.IsNullOrWhiteSpace(projectRoot.FallbackReason)) + error["project_filter_root_fallback_reason"] = projectRoot.FallbackReason; diagnostic.AddMetadata(error, "diagnostic"); return error; } } - private string ResolveProjectFilterRoot() - => DbPathResolver.ResolveProjectRootForQuery(_dbPath, _dbPathExplicit) - ?? Environment.CurrentDirectory; + private QueryCommandRunner.ProjectFilterRootResolution ResolveProjectFilterRoot() + => QueryCommandRunner.ResolveProjectFilterRoot(_dbPath, _dbPathExplicit); + + private void AddProjectFilterRootDiagnostics(JsonObject payload) + { + var projectRoot = _projectFilterRootResolutionForCurrentToolCall; + _projectFilterRootResolutionForCurrentToolCall = null; + if (!projectRoot.HasValue) + return; + + payload["project_filter_root"] = projectRoot.Value.Root; + if (!string.IsNullOrWhiteSpace(projectRoot.Value.FallbackReason)) + payload["project_filter_root_fallback_reason"] = projectRoot.Value.FallbackReason; + } + + private void ClearProjectFilterRootDiagnostics() + => _projectFilterRootResolutionForCurrentToolCall = null; private static bool TryReadSinceArgument(JsonNode? args, out DateTime? since, out string? error) { diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 798a93e3f6..7d7de0df90 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -10513,6 +10513,80 @@ public void ToolsCall_ProjectScopeUsesIndexedProjectRootWhenCurrentDirectoryDiff } } + [Fact] + public void ToolsCall_ProjectScopeFallbackReportsEffectiveRoot_Issue3461() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_mcp_project_scope_fallback_root"); + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_mcp_project_scope_fallback_{Guid.NewGuid():N}.db"); + var originalCurrentDirectory = Environment.CurrentDirectory; + try + { + Directory.CreateDirectory(Path.Combine(projectRoot, "src", "App")); + File.WriteAllText(Path.Combine(projectRoot, "Repo.sln"), """ + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "src\App\App.csproj", "{11111111-1111-1111-1111-111111111111}" + EndProject + """); + File.WriteAllText(Path.Combine(projectRoot, "src", "App", "App.csproj"), ""); + TestProjectHelper.InsertIndexedFile(dbPath, "src/App/ServiceA.cs", "csharp", "public class ServiceA { }\n"); + + Environment.CurrentDirectory = projectRoot; + var expectedProjectRoot = Path.GetFullPath(Environment.CurrentDirectory); + using var server = new McpServer(dbPath, ConsoleUi.LoadVersion(), dbPathExplicit: true); + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"ServiceA","project":"App","exactSubstring":true}}}""")!; + var response = server.HandleMessage(request)!; + + Assert.False(response["result"]!["isError"]?.GetValue() ?? false); + var structured = response["result"]!["structuredContent"]!; + var result = Assert.Single(structured["results"]!.AsArray()); + Assert.Equal("src/App/ServiceA.cs", result!["path"]!.GetValue()); + Assert.Equal(expectedProjectRoot, structured["project_filter_root"]!.GetValue()); + Assert.Equal(QueryCommandRunner.ProjectFilterRootFallbackReasonCurrentDirectory, structured["project_filter_root_fallback_reason"]!.GetValue()); + } + finally + { + Environment.CurrentDirectory = originalCurrentDirectory; + DeleteFileRobust(dbPath); + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void ToolsCall_ProjectScopeErrorDoesNotLeakRootDiagnosticToNextResult_Issue3461() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_mcp_project_scope_error_root"); + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_mcp_project_scope_error_{Guid.NewGuid():N}.db"); + var originalCurrentDirectory = Environment.CurrentDirectory; + try + { + Directory.CreateDirectory(Path.Combine(projectRoot, "src", "App")); + File.WriteAllText(Path.Combine(projectRoot, "Repo.sln"), """ + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "src\App\App.csproj", "{11111111-1111-1111-1111-111111111111}" + EndProject + """); + File.WriteAllText(Path.Combine(projectRoot, "src", "App", "App.csproj"), ""); + + Environment.CurrentDirectory = projectRoot; + using var server = new McpServer(dbPath, ConsoleUi.LoadVersion(), dbPathExplicit: true); + var invalidSearch = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"ServiceA","project":"App","since":"not-a-timestamp"}}}""")!; + var invalidSearchResponse = server.HandleMessage(invalidSearch)!; + var ping = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ping","arguments":{}}}""")!; + var pingResponse = server.HandleMessage(ping)!; + + Assert.True(invalidSearchResponse["result"]!["isError"]!.GetValue()); + Assert.False(pingResponse["result"]!["isError"]?.GetValue() ?? false); + Assert.False(pingResponse["result"]!["structuredContent"]!.AsObject().ContainsKey("project_filter_root")); + Assert.False(pingResponse["result"]!["structuredContent"]!.AsObject().ContainsKey("project_filter_root_fallback_reason")); + } + finally + { + Environment.CurrentDirectory = originalCurrentDirectory; + DeleteFileRobust(dbPath); + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void ToolsCall_Index_Rebuild_IgnoresUnreadableDirectoriesWhenCollectingMarkerFingerprints() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 8f1cd7d55d..c579aa1a7c 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -1276,6 +1276,47 @@ public void RunSearch_ZeroResultsJsonIncludesStructuredQueryContext() } } + [Fact] + public void RunSearch_ProjectFilterFallbackJsonIncludesStructuredDiagnostic_Issue3461() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_project_fallback_json"); + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_search_project_fallback_{Guid.NewGuid():N}.db"); + var originalCurrentDirectory = Environment.CurrentDirectory; + try + { + Directory.CreateDirectory(Path.Combine(projectRoot, "src", "App")); + File.WriteAllText(Path.Combine(projectRoot, "CodeIndex.sln"), """ + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "src\App\App.csproj", "{11111111-1111-1111-1111-111111111111}" + EndProject + """); + File.WriteAllText(Path.Combine(projectRoot, "src", "App", "App.csproj"), ""); + TestProjectHelper.InsertIndexedFile(dbPath, "src/App/ServiceA.cs", "csharp", "public class ServiceA { }\n"); + + Environment.CurrentDirectory = projectRoot; + var expectedProjectRoot = Path.GetFullPath(Environment.CurrentDirectory); + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["missing-token", "--db", dbPath, "--project", "App", "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var queryContext = document.RootElement.GetProperty("query_context"); + + Assert.Equal("App", queryContext.GetProperty("project")[0].GetString()); + Assert.Equal("src/App/*", queryContext.GetProperty("path")[0].GetString()); + Assert.Equal(expectedProjectRoot, queryContext.GetProperty("project_filter_root").GetString()); + Assert.Equal(QueryCommandRunner.ProjectFilterRootFallbackReasonCurrentDirectory, queryContext.GetProperty("project_filter_root_fallback_reason").GetString()); + } + finally + { + Environment.CurrentDirectory = originalCurrentDirectory; + TestProjectHelper.DeleteFile(dbPath); + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_AllowsPathValueThatLooksLikePreviewOption() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index c8e7a8dda2..e0465daa8c 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -335,6 +335,44 @@ public void ParseArgs_ProjectFilterUsesIndexedProjectRootForExplicitDb_Issue3189 } } + [Fact] + public void ParseArgs_ProjectFilterFallbackReportsEffectiveRoot_Issue3461() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_solution_filter_fallback"); + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_solution_filter_fallback_{Guid.NewGuid():N}.db"); + var originalCurrentDirectory = Environment.CurrentDirectory; + try + { + Directory.CreateDirectory(Path.Combine(projectRoot, "src", "App")); + File.WriteAllText(Path.Combine(projectRoot, "CodeIndex.sln"), """ + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "src\App\App.csproj", "{11111111-1111-1111-1111-111111111111}" + EndProject + """); + File.WriteAllText(Path.Combine(projectRoot, "src", "App", "App.csproj"), ""); + + Environment.CurrentDirectory = projectRoot; + var expectedProjectRoot = Path.GetFullPath(Environment.CurrentDirectory); + var options = QueryCommandRunner.ParseArgs( + ["Auth", "--db", dbPath, "--project", "App"], + jsonDefault: false, + allowNamedQuery: true); + + Assert.Equal("Auth", options.Query); + Assert.Equal(["App"], options.ProjectFilters); + Assert.Equal(["src/App/*"], options.PathPatterns); + Assert.Equal(expectedProjectRoot, options.ProjectFilterRoot); + Assert.Equal(QueryCommandRunner.ProjectFilterRootFallbackReasonCurrentDirectory, options.ProjectFilterRootFallbackReason); + Assert.Null(options.ParseError); + } + finally + { + Environment.CurrentDirectory = originalCurrentDirectory; + TestProjectHelper.DeleteFile(dbPath); + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunDefinition_LspFormatUsesIndexedProjectRootForExplicitDb_Issue3151() {