From 0e24295813524d28a4e9598dac16b55770df098c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 09:59:37 +0900 Subject: [PATCH 01/14] Fix query DB resolution from subdirectories (#1806) --- changelog.d/unreleased/1806.fixed.md | 15 ++++++ src/CodeIndex/Cli/DbPathResolver.cs | 48 +++++++++++++++++++- tests/CodeIndex.Tests/DbPathResolverTests.cs | 42 +++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/1806.fixed.md diff --git a/changelog.d/unreleased/1806.fixed.md b/changelog.d/unreleased/1806.fixed.md new file mode 100644 index 0000000000..f703b72b1a --- /dev/null +++ b/changelog.d/unreleased/1806.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 1806 +affected: + - src/CodeIndex/Cli/DbPathResolver.cs +--- + +## English + +- **Query commands now prefer the workspace-root `.cdidx/codeindex.db` when run from nested directories (#1806)** — implicit query DB resolution walks ancestor `.cdidx` directories and uses the outermost workspace root before falling back to the current directory. + +## 日本語 + +- **ネストしたディレクトリからの query コマンドが workspace-root の `.cdidx/codeindex.db` を優先するようになりました (#1806)** — 明示指定なしの query DB 解決は祖先の `.cdidx` を辿り、最上位の workspace root を使ってから current directory にフォールバックします。 diff --git a/src/CodeIndex/Cli/DbPathResolver.cs b/src/CodeIndex/Cli/DbPathResolver.cs index c59310363b..ee8ce3d163 100644 --- a/src/CodeIndex/Cli/DbPathResolver.cs +++ b/src/CodeIndex/Cli/DbPathResolver.cs @@ -39,7 +39,7 @@ public static DbPathResolution ResolveForQuery(string workspacePath, string? exp if (!string.IsNullOrWhiteSpace(explicitDbPath)) return new DbPathResolution(explicitDbPath, null, null); - return ResolveDataDir(workspacePath, explicitDataDir, Environment.GetEnvironmentVariable(DataDirEnvironmentVariable), Environment.GetEnvironmentVariable("XDG_DATA_HOME")); + return ResolveDataDirForQuery(workspacePath, explicitDataDir, Environment.GetEnvironmentVariable(DataDirEnvironmentVariable), Environment.GetEnvironmentVariable("XDG_DATA_HOME")); } internal static DbPathResolution ResolveDataDir(string workspacePath, string? explicitDataDir, string? environmentDataDir, string? xdgDataHome) @@ -60,6 +60,28 @@ internal static DbPathResolution ResolveDataDir(string workspacePath, string? ex return BuildDataDirResolution(Path.Combine(fullWorkspacePath, ".cdidx"), DataDirSourceWorkspace); } + internal static DbPathResolution ResolveDataDirForQuery(string workspacePath, string? explicitDataDir, string? environmentDataDir, string? xdgDataHome) + { + var fullWorkspacePath = Path.GetFullPath(workspacePath); + if (!string.IsNullOrWhiteSpace(explicitDataDir)) + return BuildDataDirResolution(explicitDataDir, DataDirSourceFlag); + + if (!string.IsNullOrWhiteSpace(environmentDataDir)) + return BuildDataDirResolution(environmentDataDir, DataDirSourceEnv); + + if (!string.IsNullOrWhiteSpace(xdgDataHome)) + { + var workspaceHash = ComputeWorkspaceHash(fullWorkspacePath); + return BuildDataDirResolution(Path.Combine(xdgDataHome, "cdidx", workspaceHash), DataDirSourceXdg); + } + + var workspaceRootDataDir = TryResolveOutermostAncestorDataDir(fullWorkspacePath); + if (workspaceRootDataDir != null) + return BuildDataDirResolution(workspaceRootDataDir, DataDirSourceWorkspace); + + return BuildDataDirResolution(Path.Combine(fullWorkspacePath, ".cdidx"), DataDirSourceWorkspace); + } + private static DbPathResolution BuildDataDirResolution(string dataDir, string source) { var fullDataDir = Path.GetFullPath(dataDir); @@ -72,6 +94,30 @@ private static string ComputeWorkspaceHash(string workspacePath) return Convert.ToHexString(bytes, 0, 8).ToLowerInvariant(); } + private static string? TryResolveOutermostAncestorDataDir(string workspacePath) + { + DirectoryInfo? current; + try + { + current = new DirectoryInfo(Path.GetFullPath(workspacePath)); + } + catch + { + return null; + } + + string? selected = null; + while (current is not null) + { + var candidate = Path.Combine(current.FullName, ".cdidx"); + if (Directory.Exists(LongPath.EnsureWindowsPrefix(candidate))) + selected = candidate; + current = current.Parent; + } + + return selected; + } + /// /// Resolve the most likely project root for query commands from the DB path. /// クエリ系コマンドのDBパスから、もっとも可能性が高いプロジェクトルートを解決する。 diff --git a/tests/CodeIndex.Tests/DbPathResolverTests.cs b/tests/CodeIndex.Tests/DbPathResolverTests.cs index 44ab2676ee..31b3d0b5f6 100644 --- a/tests/CodeIndex.Tests/DbPathResolverTests.cs +++ b/tests/CodeIndex.Tests/DbPathResolverTests.cs @@ -76,6 +76,48 @@ public void ResolveDataDir_UsesStableXdgWorkspaceHashBeforeWorkspaceDefault() Assert.Equal(DbPathResolver.DataDirSourceXdg, first.DataDirSource); } + [Fact] + public void ResolveDataDirForQuery_PrefersOutermostAncestorCdidx() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_root_db"); + try + { + var child = Path.Combine(projectRoot, "src", "App"); + Directory.CreateDirectory(child); + Directory.CreateDirectory(Path.Combine(projectRoot, ".cdidx")); + Directory.CreateDirectory(Path.Combine(projectRoot, "src", ".cdidx")); + + var resolved = DbPathResolver.ResolveDataDirForQuery(child, explicitDataDir: null, environmentDataDir: null, xdgDataHome: null); + + Assert.Equal(Path.Combine(projectRoot, ".cdidx", "codeindex.db"), resolved.DbPath); + Assert.Equal(DbPathResolver.DataDirSourceWorkspace, resolved.DataDirSource); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void ResolveDataDirForQuery_FallsBackToCurrentDirectoryWhenNoAncestorCdidxExists() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_no_root_db"); + try + { + var child = Path.Combine(projectRoot, "src", "App"); + Directory.CreateDirectory(child); + + var resolved = DbPathResolver.ResolveDataDirForQuery(child, explicitDataDir: null, environmentDataDir: null, xdgDataHome: null); + + Assert.Equal(Path.Combine(child, ".cdidx", "codeindex.db"), resolved.DbPath); + Assert.Equal(DbPathResolver.DataDirSourceWorkspace, resolved.DataDirSource); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void ResolveProjectRootForQuery_UsesParentOfCdidxDirectory() { From dfd4a5905e32a905eb9a06a52a5c5ee15e283c7a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 10:00:12 +0900 Subject: [PATCH 02/14] Document workspace DB resolution (#1806) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 87df9e57bc..710268a7df 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ file completion. | Project scoping | `.sln` / `.csproj`-aware --project <name|path> filters for indexing and queries, plus `--solution ` when a workspace has multiple solution files. | | MCP integration | MCP server support for AI clients such as Claude Code, Cursor, and Windsurf, including tools, indexed-file resources, starter prompts, and `Language support:` descriptions sourced from the same registries as `cdidx languages`. | | Freshness | Parallel full-scan extraction with `--parallelism`, incremental refreshes with `--files` and `--commits`, continuous `--watch`, exact `status --check`, and configurable stale thresholds via `--stale-after` / `CDIDX_STALE_AFTER`. | -| Storage | Local-first `.cdidx/codeindex.db` storage. `--data-dir `, `CDIDX_DATA_DIR`, or `XDG_DATA_HOME` can move default SQLite storage outside the workspace; explicit `--db ` still wins. | +| Storage | Local-first `.cdidx/codeindex.db` storage. Query commands run from nested directories prefer the outermost ancestor `.cdidx/codeindex.db` before falling back to the current directory. `--data-dir `, `CDIDX_DATA_DIR`, or `XDG_DATA_HOME` can move default SQLite storage outside the workspace; explicit `--db ` still wins. | | DB maintenance | New indexes use SQLite incremental auto-vacuum. `cdidx vacuum` reclaims free pages from existing DBs, including a one-time full `VACUUM` conversion for legacy no-autovacuum DBs, and `status --json` reports metrics under `db_pragma_settings`. | | Security defaults | On POSIX systems, `.cdidx` is created with `0700` permissions and `status --json` reports the effective `data_dir_mode` when available. | | Diagnostics | `status --explain ` describes readiness fields and remediation. Read commands support `--profile`, `--slow-query-ms `, and --trace=stderr|file|none; file traces write daily `query-trace-YYYYMMDD.jsonl` files next to the lifecycle log. | @@ -278,7 +278,7 @@ cdidx mcp | project scope | `.sln` / `.csproj` を使った --project <name|path> filter で index と query を .NET project 配下へ絞り込めます。workspace に solution が複数ある場合は `--solution ` を指定します。 | | MCP 連携 | Claude Code、Cursor、Windsurf などの AI クライアント向け MCP server。tools、インデックス済みファイル resources、starter prompts、`cdidx languages` と同じ言語レジストリ由来の `Language support:` 説明を提供します。 | | freshness | `--parallelism` による parallel full-scan、`--files` / `--commits` による差分更新、`--watch` による継続更新、`status --check` による完全一致確認、`--stale-after` / `CDIDX_STALE_AFTER` による age threshold 上書きに対応します。 | -| storage | `.cdidx/codeindex.db` に保存する local-first 設計。既定の SQLite 保存先は `--data-dir `、`CDIDX_DATA_DIR`、`XDG_DATA_HOME` で workspace 外へ移せます。明示的な `--db ` は引き続き最優先です。 | +| storage | `.cdidx/codeindex.db` に保存する local-first 設計。ネストしたディレクトリからの query コマンドは、current directory にフォールバックする前に最上位祖先の `.cdidx/codeindex.db` を優先します。既定の SQLite 保存先は `--data-dir `、`CDIDX_DATA_DIR`、`XDG_DATA_HOME` で workspace 外へ移せます。明示的な `--db ` は引き続き最優先です。 | | DB maintenance | 新規 index DB は SQLite incremental auto-vacuum を使います。既存 DB は `cdidx vacuum` で free page を回収でき、legacy no-autovacuum DB は初回だけ full `VACUUM` で変換します。`status --json` は `db_pragma_settings` 配下に metrics を出力します。 | | security defaults | POSIX では `.cdidx` を `0700` 権限で作成します。`status --json` は利用可能な場合に実効 POSIX mode を `data_dir_mode` として報告します。 | | diagnostics | `status --explain ` は readiness field の意味と対処を説明します。read 系コマンドは `--profile`、`--slow-query-ms `、--trace=stderr|file|none に対応し、file trace は lifecycle log と同じ場所に日次 `query-trace-YYYYMMDD.jsonl` を書きます。 | From 71d9ba2838f9e005251d0ac19a21ccbe75067121 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 10:00:34 +0900 Subject: [PATCH 03/14] Add query default environment variables (#2036) --- README.md | 4 +- changelog.d/unreleased/2036.added.md | 15 ++++++ src/CodeIndex/Cli/QueryCommandRunner.cs | 54 +++++++++++++++++-- .../QueryCommandRunnerTests.cs | 52 ++++++++++++++++++ 4 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 changelog.d/unreleased/2036.added.md diff --git a/README.md b/README.md index 710268a7df..dbf9e24d33 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ file completion. | Area | What cdidx provides | |---|---| | Search surfaces | CLI-first output for humans and machines; full-text, symbol, reference, caller/callee, dependency, map, inspect, and excerpt commands. | -| Ranking and filters | Public/exported symbol matches rank ahead of protected, internal, and private matches. Use `--no-visibility-rank` for legacy order, and `--visibility` / `--exclude-visibility` with `symbols`, `definition`, `unused`, and `hotspots`. | +| Ranking and filters | Public/exported symbol matches rank ahead of protected, internal, and private matches. Use `--no-visibility-rank` for legacy order, and `--visibility` / `--exclude-visibility` with `symbols`, `definition`, `unused`, and `hotspots`. Query defaults can be adjusted with `CDIDX_DEFAULT_LIMIT`, `CDIDX_DEFAULT_SNIPPET_LINES`, and `CDIDX_DEFAULT_MAX_LINE_WIDTH`; explicit CLI flags still win. | | Project scoping | `.sln` / `.csproj`-aware --project <name|path> filters for indexing and queries, plus `--solution ` when a workspace has multiple solution files. | | MCP integration | MCP server support for AI clients such as Claude Code, Cursor, and Windsurf, including tools, indexed-file resources, starter prompts, and `Language support:` descriptions sourced from the same registries as `cdidx languages`. | | Freshness | Parallel full-scan extraction with `--parallelism`, incremental refreshes with `--files` and `--commits`, continuous `--watch`, exact `status --check`, and configurable stale thresholds via `--stale-after` / `CDIDX_STALE_AFTER`. | @@ -274,7 +274,7 @@ cdidx mcp | 分野 | 内容 | |---|---| | 検索面 | CLI-first の人間向け / 機械処理向け出力。全文検索、シンボル、参照、caller/callee、依存関係、map、inspect、excerpt コマンドを提供します。 | -| 順位と filter | public/exported なシンボル一致を protected、internal、private より優先します。従来順は `--no-visibility-rank`、可視性の include / exclude は `symbols`、`definition`、`unused`、`hotspots` の `--visibility` / `--exclude-visibility` で指定できます。 | +| 順位と filter | public/exported なシンボル一致を protected、internal、private より優先します。従来順は `--no-visibility-rank`、可視性の include / exclude は `symbols`、`definition`、`unused`、`hotspots` の `--visibility` / `--exclude-visibility` で指定できます。query 既定値は `CDIDX_DEFAULT_LIMIT`、`CDIDX_DEFAULT_SNIPPET_LINES`、`CDIDX_DEFAULT_MAX_LINE_WIDTH` で調整でき、明示 CLI flag が常に優先されます。 | | project scope | `.sln` / `.csproj` を使った --project <name|path> filter で index と query を .NET project 配下へ絞り込めます。workspace に solution が複数ある場合は `--solution ` を指定します。 | | MCP 連携 | Claude Code、Cursor、Windsurf などの AI クライアント向け MCP server。tools、インデックス済みファイル resources、starter prompts、`cdidx languages` と同じ言語レジストリ由来の `Language support:` 説明を提供します。 | | freshness | `--parallelism` による parallel full-scan、`--files` / `--commits` による差分更新、`--watch` による継続更新、`status --check` による完全一致確認、`--stale-after` / `CDIDX_STALE_AFTER` による age threshold 上書きに対応します。 | diff --git a/changelog.d/unreleased/2036.added.md b/changelog.d/unreleased/2036.added.md new file mode 100644 index 0000000000..0cc587c000 --- /dev/null +++ b/changelog.d/unreleased/2036.added.md @@ -0,0 +1,15 @@ +--- +category: added +issues: + - 2036 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs +--- + +## English + +- **Numeric query defaults can now be set with environment variables (#2036)** — `CDIDX_DEFAULT_LIMIT`, `CDIDX_DEFAULT_SNIPPET_LINES`, and `CDIDX_DEFAULT_MAX_LINE_WIDTH` set the default values that CLI flags can still override. + +## 日本語 + +- **数値 query 既定値を環境変数で設定できるようになりました (#2036)** — `CDIDX_DEFAULT_LIMIT`、`CDIDX_DEFAULT_SNIPPET_LINES`、`CDIDX_DEFAULT_MAX_LINE_WIDTH` で既定値を設定でき、CLI フラグは引き続きそれらを上書きします。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 73fa381b1f..2e9d094c50 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -15,6 +15,9 @@ namespace CodeIndex.Cli; /// public static class QueryCommandRunner { + internal const string DefaultLimitEnvironmentVariable = "CDIDX_DEFAULT_LIMIT"; + internal const string DefaultSnippetLinesEnvironmentVariable = "CDIDX_DEFAULT_SNIPPET_LINES"; + internal const string DefaultMaxLineWidthEnvironmentVariable = "CDIDX_DEFAULT_MAX_LINE_WIDTH"; internal const string StaleAfterEnvironmentVariable = "CDIDX_STALE_AFTER"; internal static readonly TimeSpan DefaultStaleAfter = TimeSpan.FromHours(24); [ThreadStatic] @@ -3798,7 +3801,7 @@ public static QueryCommandOptions ParseArgs(string[] args, bool jsonDefault, boo string? dataDir = null; bool? json = null; string jsonOutputFormat = JsonOutputFormatNdjson; - int limit = 20; + int limit = ResolveDefaultPositiveInt(DefaultLimitEnvironmentVariable, 20, "--limit", out var defaultLimitError); string? lang = null; string? kind = null; string? query = null; @@ -3813,9 +3816,9 @@ public static QueryCommandOptions ParseArgs(string[] args, bool jsonDefault, boo int? focusLine = null; int? focusColumn = null; int focusLength = 1; - int snippetLines = SearchSnippetFormatter.DefaultSnippetLines; + int snippetLines = ResolveDefaultPositiveInt(DefaultSnippetLinesEnvironmentVariable, SearchSnippetFormatter.DefaultSnippetLines, "--snippet-lines", out var defaultSnippetLinesError); var snippetFocus = SearchSnippetFocusMode.Quality; - int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth; + int maxLineWidth = ResolveDefaultNonNegativeInt(DefaultMaxLineWidthEnvironmentVariable, LineWidthFormatter.DefaultMaxLineWidth, "--max-line-width", out var defaultMaxLineWidthError); bool contextAfterExplicit = false; var pathPatterns = new List(); var userPathPatterns = new List(); @@ -3858,6 +3861,13 @@ void AddParseError(string error) parseErrors.Add(error); } + if (defaultLimitError != null) + AddParseError(defaultLimitError); + if (defaultSnippetLinesError != null) + AddParseError(defaultSnippetLinesError); + if (defaultMaxLineWidthError != null) + AddParseError(defaultMaxLineWidthError); + void AddStatusCheckScopes(string rawScopes) { if (string.IsNullOrWhiteSpace(rawScopes)) @@ -6622,6 +6632,44 @@ private static string BuildMissingOptionValueError(string optionName, params str return sb.ToString(); } + private static int ResolveDefaultPositiveInt(string environmentVariable, int fallback, string optionName, out string? error) + { + var raw = Environment.GetEnvironmentVariable(environmentVariable); + if (string.IsNullOrWhiteSpace(raw)) + { + error = null; + return fallback; + } + + if (TryParsePositiveInt(raw, optionName, out var value, out var parseError)) + { + error = null; + return value; + } + + error = parseError!.Replace(optionName, environmentVariable, StringComparison.Ordinal); + return fallback; + } + + private static int ResolveDefaultNonNegativeInt(string environmentVariable, int fallback, string optionName, out string? error) + { + var raw = Environment.GetEnvironmentVariable(environmentVariable); + if (string.IsNullOrWhiteSpace(raw)) + { + error = null; + return fallback; + } + + if (TryParseNonNegativeInt(raw, optionName, out var value, out var parseError)) + { + error = null; + return value; + } + + error = parseError!.Replace(optionName, environmentVariable, StringComparison.Ordinal); + return fallback; + } + private static bool TryParsePositiveInt(string rawValue, string optionName, out int value, out string? error) { if (string.Equals(optionName, "--max-line-width", StringComparison.Ordinal)) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index eed901a04b..8eeeb55e0f 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -86,6 +86,58 @@ public void ParseArgs_AllowsZeroMaxLineWidth() Assert.Equal(0, options.MaxLineWidth); } + [Fact] + public void ParseArgs_UsesNumericDefaultEnvironmentVariables() + { + using var env = EnvironmentVariableScope.Capture( + QueryCommandRunner.DefaultLimitEnvironmentVariable, + QueryCommandRunner.DefaultSnippetLinesEnvironmentVariable, + QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable); + Environment.SetEnvironmentVariable(QueryCommandRunner.DefaultLimitEnvironmentVariable, "42"); + Environment.SetEnvironmentVariable(QueryCommandRunner.DefaultSnippetLinesEnvironmentVariable, "6"); + Environment.SetEnvironmentVariable(QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable, "120"); + + var options = QueryCommandRunner.ParseArgs(["RunSearch"], jsonDefault: false, allowNamedQuery: true); + + Assert.Equal(42, options.Limit); + Assert.Equal(6, options.SnippetLines); + Assert.Equal(120, options.MaxLineWidth); + Assert.Null(options.ParseError); + } + + [Fact] + public void ParseArgs_CliNumericFlagsOverrideDefaultEnvironmentVariables() + { + using var env = EnvironmentVariableScope.Capture( + QueryCommandRunner.DefaultLimitEnvironmentVariable, + QueryCommandRunner.DefaultSnippetLinesEnvironmentVariable, + QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable); + Environment.SetEnvironmentVariable(QueryCommandRunner.DefaultLimitEnvironmentVariable, "42"); + Environment.SetEnvironmentVariable(QueryCommandRunner.DefaultSnippetLinesEnvironmentVariable, "6"); + Environment.SetEnvironmentVariable(QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable, "120"); + + var options = QueryCommandRunner.ParseArgs( + ["RunSearch", "--limit", "7", "--snippet-lines", "3", "--max-line-width", "80"], + jsonDefault: false, + allowNamedQuery: true); + + Assert.Equal(7, options.Limit); + Assert.Equal(3, options.SnippetLines); + Assert.Equal(80, options.MaxLineWidth); + Assert.Null(options.ParseError); + } + + [Fact] + public void ParseArgs_InvalidNumericDefaultEnvironmentVariableReportsParseError() + { + using var env = EnvironmentVariableScope.Capture(QueryCommandRunner.DefaultLimitEnvironmentVariable); + Environment.SetEnvironmentVariable(QueryCommandRunner.DefaultLimitEnvironmentVariable, "0"); + + var options = QueryCommandRunner.ParseArgs(["RunSearch"], jsonDefault: false, allowNamedQuery: true); + + Assert.Contains(QueryCommandRunner.DefaultLimitEnvironmentVariable, options.ParseError); + } + [Fact] public void ParseArgs_ProjectFilterExpandsSolutionProjectToPathGlob_Issue1707() { From dda5adf2897a2d5806e2d52ce90b9acf671887bf Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 10:09:06 +0900 Subject: [PATCH 04/14] Add status config audit output (#1813) --- changelog.d/unreleased/1813.added.md | 15 ++++ src/CodeIndex/Cli/CliFlagSchema.cs | 1 + src/CodeIndex/Cli/QueryCommandRunner.cs | 89 +++++++++++++++++++ .../QueryCommandRunnerTests.cs | 23 +++++ 4 files changed, 128 insertions(+) create mode 100644 changelog.d/unreleased/1813.added.md diff --git a/changelog.d/unreleased/1813.added.md b/changelog.d/unreleased/1813.added.md new file mode 100644 index 0000000000..4db3ebfa1b --- /dev/null +++ b/changelog.d/unreleased/1813.added.md @@ -0,0 +1,15 @@ +--- +category: added +issues: + - 1813 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs +--- + +## English + +- **`status --config` now prints effective configuration with source attribution (#1813)** — the JSON output includes resolved DB/data-dir settings, numeric query defaults, log directory, and version without requiring the DB to exist. + +## 日本語 + +- **`status --config` が source attribution 付きの effective configuration を出力するようになりました (#1813)** — JSON 出力に解決済み DB/data-dir 設定、数値 query 既定値、log directory、version が含まれ、DB が存在しなくても確認できます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 60c68433ea..d963c29781 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -241,6 +241,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--group-by", ValuePlaceholder = "", Description = "Hotspots: choose grouping unit", Commands = Set("hotspots") }, new() { Name = "--group-by-name", Description = "Hotspots: collapse same-name rows across files", Commands = Set("hotspots") }, new() { Name = "--check", Description = "Verify status freshness/readiness", Commands = Set("status") }, + new() { Name = "--config", Description = "Print effective configuration with source attribution", Commands = Set("status") }, new() { Name = "--stale-after", ValuePlaceholder = "", Description = "Status: freshness age threshold (e.g. 30m, 2h, 7d)", Commands = Set("status") }, new() { Name = "--explain", ValuePlaceholder = "", Description = "Explain one status readiness field", Commands = Set("status") }, new() { Name = "--log-path", Description = "Print the active persistent log directory", Commands = Set("status") }, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 2e9d094c50..81bb863d99 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -2140,6 +2140,17 @@ public static int RunStatus(string[] cmdArgs, JsonSerializerOptions jsonOptions, return CommandExitCodes.UsageError; if (TryWriteUnexpectedPositionals("status", options)) return CommandExitCodes.UsageError; + if (options.StatusConfig) + { + if (options.CheckWorkspace || options.StatusLogPath || options.StatusExplainField != null) + { + Console.Error.WriteLine("Error: status --config cannot be combined with --check, --log-path, or --explain."); + return CommandExitCodes.UsageError; + } + + Console.WriteLine(BuildEffectiveConfigJson(options, cmdArgs, appVersion).ToJsonString(jsonOptions)); + return CommandExitCodes.Success; + } if (options.StatusLogPath) { if (options.CheckWorkspace) @@ -2379,6 +2390,69 @@ public static int RunStatus(string[] cmdArgs, JsonSerializerOptions jsonOptions, }); } + private static JsonObject BuildEffectiveConfigJson(QueryCommandOptions options, string[] cmdArgs, string? appVersion) + { + JsonObject Entry(T? value, string source) => new() + { + ["value"] = JsonSerializer.SerializeToNode(value), + ["source"] = source, + }; + + var payload = new JsonObject + { + ["api_version"] = "1", + ["effective_config"] = new JsonObject + { + ["db_path"] = Entry(options.DbPath, ResolveDbPathConfigSource(options)), + ["data_dir"] = Entry(options.DataDir, options.DataDirSource ?? "flag"), + ["limit"] = Entry(options.Limit, ResolveNumericConfigSource(cmdArgs, "--limit", "--top", DefaultLimitEnvironmentVariable)), + ["snippet_lines"] = Entry(options.SnippetLines, ResolveNumericConfigSource(cmdArgs, "--snippet-lines", null, DefaultSnippetLinesEnvironmentVariable)), + ["max_line_width"] = Entry(options.MaxLineWidth, ResolveNumericConfigSource(cmdArgs, "--max-line-width", null, DefaultMaxLineWidthEnvironmentVariable)), + ["json"] = Entry(options.Json, HasOption(cmdArgs, "--json") ? "flag" : "default"), + ["stale_after"] = Entry(options.StaleAfter?.ToString(), options.StaleAfter.HasValue ? "flag" : Environment.GetEnvironmentVariable(StaleAfterEnvironmentVariable) is null ? "default" : $"env:{StaleAfterEnvironmentVariable}"), + ["global_tool_log_dir"] = Entry(GlobalToolLog.ResolveLogDirectoryForStatus(), ResolveEnvSource("CDIDX_GLOBAL_TOOL_LOG_DIR")), + ["version"] = Entry(appVersion ?? ConsoleUi.LoadVersion(), "build"), + }, + }; + return payload; + } + + private static string ResolveDbPathConfigSource(QueryCommandOptions options) + { + if (options.DbPathExplicit) + return "flag"; + return options.DataDirSource switch + { + DbPathResolver.DataDirSourceFlag => "flag", + DbPathResolver.DataDirSourceEnv => $"env:{DbPathResolver.DataDirEnvironmentVariable}", + DbPathResolver.DataDirSourceXdg => "env:XDG_DATA_HOME", + DbPathResolver.DataDirSourceWorkspace => "workspace", + _ => "default", + }; + } + + private static string ResolveNumericConfigSource(string[] args, string primaryFlag, string? aliasFlag, string envName) + { + if (HasOption(args, primaryFlag) || (aliasFlag != null && HasOption(args, aliasFlag))) + return "flag"; + return Environment.GetEnvironmentVariable(envName) is null ? "default" : $"env:{envName}"; + } + + private static string ResolveEnvSource(string envName) => + Environment.GetEnvironmentVariable(envName) is null ? "default" : $"env:{envName}"; + + private static bool HasOption(string[] args, string optionName) + { + foreach (var arg in args) + { + if (string.Equals(arg, optionName, StringComparison.Ordinal)) + return true; + if (arg.StartsWith(optionName + "=", StringComparison.Ordinal)) + return true; + } + return false; + } + public static int RunVacuum(string[] cmdArgs, JsonSerializerOptions jsonOptions) { var options = ParseArgs(cmdArgs, jsonDefault: false); @@ -3851,6 +3925,7 @@ public static QueryCommandOptions ParseArgs(string[] args, bool jsonDefault, boo int? slowQueryMs = null; string? statusExplainField = null; bool statusLogPath = false; + bool statusConfig = false; var rankMode = ReferenceRankMode.Weighted; var extraNames = new List(); bool impactDeprecatedDepthUsed = false; @@ -4220,6 +4295,16 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) AddParseError("Error: --log-path is not supported by this command."); } break; + case "--config": + if (allowStatusCheck) + { + statusConfig = true; + } + else + { + AddParseError("Error: --config is only supported by status."); + } + break; case "--path": if (TryReadStringOptionValue(args, ref i, "--path", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var pathPattern, out var pathError)) { @@ -4480,6 +4565,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) SlowQueryMs = slowQueryMs, StatusExplainField = statusExplainField, StatusLogPath = statusLogPath, + StatusConfig = statusConfig, RankMode = rankMode, ExtraNames = extraNames, ParseError = parseErrors == null ? null : string.Join(Environment.NewLine, parseErrors), @@ -5106,6 +5192,8 @@ private static bool TryWriteParseError(QueryCommandOptions options, string comma private static string? BuildExplicitDbPathParseError(QueryCommandOptions options) { + if (options.StatusConfig) + return null; if (!options.DbPathExplicit) return null; if (string.IsNullOrWhiteSpace(options.DbPath)) @@ -6983,6 +7071,7 @@ public sealed class QueryCommandOptions public int? SlowQueryMs { get; init; } public string? StatusExplainField { get; init; } public bool StatusLogPath { get; init; } + public bool StatusConfig { get; init; } public ReferenceRankMode RankMode { get; init; } = ReferenceRankMode.Weighted; public List ExtraNames { get; init; } = []; public string? ParseError { get; init; } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 8eeeb55e0f..7781fb28f7 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -203,6 +203,29 @@ public void ParseArgs_StatusStaleAfterStoresDuration() Assert.Equal(TimeSpan.FromHours(2), options.StaleAfter); } + [Fact] + public void RunStatusConfig_PrintsEffectiveConfigWithoutOpeningDb() + { + using var env = EnvironmentVariableScope.Capture(QueryCommandRunner.DefaultLimitEnvironmentVariable); + Environment.SetEnvironmentVariable(QueryCommandRunner.DefaultLimitEnvironmentVariable, "33"); + var missingDb = Path.Combine(Path.GetTempPath(), $"cdidx_missing_{Guid.NewGuid():N}.db"); + var parsed = QueryCommandRunner.ParseArgs(["--config", "--db", missingDb, "--json"], jsonDefault: false, allowStatusCheck: true); + Assert.True(parsed.StatusConfig); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--config", "--db", missingDb, "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var effective = document.RootElement.GetProperty("effective_config"); + Assert.Equal(missingDb, effective.GetProperty("db_path").GetProperty("value").GetString()); + Assert.Equal("flag", effective.GetProperty("db_path").GetProperty("source").GetString()); + Assert.Equal(33, effective.GetProperty("limit").GetProperty("value").GetInt32()); + Assert.Equal($"env:{QueryCommandRunner.DefaultLimitEnvironmentVariable}", effective.GetProperty("limit").GetProperty("source").GetString()); + } + [Fact] public void RunStatusJson_ReportsSqlitePageMetrics_Issue1631() { From 85f700f3df6d2e1469d58510f29e6a0b291313fc Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 10:09:25 +0900 Subject: [PATCH 05/14] Add project config validation surface (#1926) --- README.md | 6 +- changelog.d/unreleased/1926.added.md | 15 ++++ src/CodeIndex/Cli/CdidxConfigFile.cs | 85 +++++++++++++++++++ src/CodeIndex/Cli/ProgramRunner.cs | 1 + tests/CodeIndex.Tests/CdidxConfigFileTests.cs | 34 ++++++++ 5 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/1926.added.md diff --git a/README.md b/README.md index dbf9e24d33..6d48c4c758 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Output controls: | Need | Option | |---|---| | Owner-only persistent stderr logs on POSIX | Global tool stderr logs are forced to `0600` permissions on every open, including existing date-stamped log files. | +| Checked-in configuration | Use `.cdidx/config.json` for repository defaults such as `search.limit`, `search.snippet_lines`, and `search.max_line_width`; run `cdidx validate-config` to validate the discovered file. | | ASCII-only terminal output | Use `--ascii`, `CDIDX_ASCII=1`, `NO_UNICODE`, `TERM=dumb`, accessibility env hints, or a non-UTF-8 locale. Spinners use pipe, slash, dash, and backslash frames; progress bars use `#` / `-`; very narrow terminals fall back to percentage-only progress. | | Script-friendly query pipelines | Use `--quiet`, `-q`, `--silent`, or `CDIDX_QUIET=1` to suppress informational stderr text while preserving errors. `--quiet` takes precedence over `--verbose`. | @@ -98,7 +99,7 @@ file completion. | Storage | Local-first `.cdidx/codeindex.db` storage. Query commands run from nested directories prefer the outermost ancestor `.cdidx/codeindex.db` before falling back to the current directory. `--data-dir `, `CDIDX_DATA_DIR`, or `XDG_DATA_HOME` can move default SQLite storage outside the workspace; explicit `--db ` still wins. | | DB maintenance | New indexes use SQLite incremental auto-vacuum. `cdidx vacuum` reclaims free pages from existing DBs, including a one-time full `VACUUM` conversion for legacy no-autovacuum DBs, and `status --json` reports metrics under `db_pragma_settings`. | | Security defaults | On POSIX systems, `.cdidx` is created with `0700` permissions and `status --json` reports the effective `data_dir_mode` when available. | -| Diagnostics | `status --explain ` describes readiness fields and remediation. Read commands support `--profile`, `--slow-query-ms `, and --trace=stderr|file|none; file traces write daily `query-trace-YYYYMMDD.jsonl` files next to the lifecycle log. | +| Diagnostics | `status --config` prints effective configuration with source attribution, and `status --explain ` describes readiness fields and remediation. Read commands support `--profile`, `--slow-query-ms `, and --trace=stderr|file|none; file traces write daily `query-trace-YYYYMMDD.jsonl` files next to the lifecycle log. | | Query exit codes | Valid zero-result query commands exit `0` by default. Pass `--strict-not-found` when scripts should treat zero rows as exit code `2`. | | Drift checks | `cdidx diff ` compares schema, file, symbol, and reference deltas with stable exit codes: `0` identical, `1` drift, `2` schema mismatch, `3` unreadable DB. | | Extensibility and feedback | Post-extraction hooks from `~/.config/cdidx/hooks/*.dll` or `CDIDX_HOOKS_DIR` can enrich symbols and references. `cdidx suggestions` lists, inspects, and exports local suggestion history, with fuzzy MCP suggestion deduplication controlled by CLI, env, or `.cdidxrc.json`. | @@ -256,6 +257,7 @@ cdidx mcp | 目的 | option / 動作 | |---|---| | POSIX の persistent stderr log を owner-only にする | global tool stderr log は開くたびに `0600` 権限へ補正され、既存の日付付き log file も同じ扱いになります。 | +| checked-in configuration | repository 既定値には `.cdidx/config.json` を使えます。例: `search.limit`、`search.snippet_lines`、`search.max_line_width`。検出された file は `cdidx validate-config` で検証できます。 | | ASCII-only 端末で崩れない表示にする | `--ascii`、`CDIDX_ASCII=1`、`NO_UNICODE`、`TERM=dumb`、accessibility 系の環境変数、非 UTF-8 locale を使います。スピナーは pipe、slash、dash、backslash の frame、進捗バーは `#` / `-` になり、幅が非常に狭い端末では percentage-only になります。 | | script 向け query pipeline の stderr を静かにする | `--quiet`、`-q`、`--silent`、`CDIDX_QUIET=1` で informational stderr を抑制し、error 行だけを残します。`--quiet` は `--verbose` より優先されます。 | @@ -281,7 +283,7 @@ cdidx mcp | storage | `.cdidx/codeindex.db` に保存する local-first 設計。ネストしたディレクトリからの query コマンドは、current directory にフォールバックする前に最上位祖先の `.cdidx/codeindex.db` を優先します。既定の SQLite 保存先は `--data-dir `、`CDIDX_DATA_DIR`、`XDG_DATA_HOME` で workspace 外へ移せます。明示的な `--db ` は引き続き最優先です。 | | DB maintenance | 新規 index DB は SQLite incremental auto-vacuum を使います。既存 DB は `cdidx vacuum` で free page を回収でき、legacy no-autovacuum DB は初回だけ full `VACUUM` で変換します。`status --json` は `db_pragma_settings` 配下に metrics を出力します。 | | security defaults | POSIX では `.cdidx` を `0700` 権限で作成します。`status --json` は利用可能な場合に実効 POSIX mode を `data_dir_mode` として報告します。 | -| diagnostics | `status --explain ` は readiness field の意味と対処を説明します。read 系コマンドは `--profile`、`--slow-query-ms `、--trace=stderr|file|none に対応し、file trace は lifecycle log と同じ場所に日次 `query-trace-YYYYMMDD.jsonl` を書きます。 | +| diagnostics | `status --config` は source attribution 付きの effective configuration を出力し、`status --explain ` は readiness field の意味と対処を説明します。read 系コマンドは `--profile`、`--slow-query-ms `、--trace=stderr|file|none に対応し、file trace は lifecycle log と同じ場所に日次 `query-trace-YYYYMMDD.jsonl` を書きます。 | | drift checks | `cdidx diff ` は schema、file、symbol、reference の差分を比較します。exit code は `0` identical、`1` drift、`2` schema mismatch、`3` unreadable DB です。 | | extensibility / feedback | `~/.config/cdidx/hooks/*.dll` または `CDIDX_HOOKS_DIR` の post-extraction hook で永続化前のシンボルと参照を拡張できます。`cdidx suggestions` はローカル提案履歴の一覧表示、詳細表示、エクスポートに対応し、MCP 提案の近似重複排除しきい値は CLI、env、`.cdidxrc.json` で調整できます。 | | language coverage | 78 言語を検出し、対応言語ではシンボルとグラフも利用可能です。 | diff --git a/changelog.d/unreleased/1926.added.md b/changelog.d/unreleased/1926.added.md new file mode 100644 index 0000000000..8fdbf8e3d3 --- /dev/null +++ b/changelog.d/unreleased/1926.added.md @@ -0,0 +1,15 @@ +--- +category: added +issues: + - 1926 +affected: + - src/CodeIndex/Cli/CdidxConfigFile.cs +--- + +## English + +- **Project `.cdidx/config.json` is now a supported config-file surface (#1926)** — cdidx validates the versioned sections and can materialize search defaults from checked-in config; `cdidx validate-config` reports whether the discovered config is valid. + +## 日本語 + +- **project `.cdidx/config.json` を config-file surface としてサポートしました (#1926)** — cdidx は versioned section を検証し、checked-in config から search 既定値を反映できます。`cdidx validate-config` で検出された config が valid か確認できます。 diff --git a/src/CodeIndex/Cli/CdidxConfigFile.cs b/src/CodeIndex/Cli/CdidxConfigFile.cs index 2197366c07..71adb7d24c 100644 --- a/src/CodeIndex/Cli/CdidxConfigFile.cs +++ b/src/CodeIndex/Cli/CdidxConfigFile.cs @@ -23,6 +23,7 @@ namespace CodeIndex.Cli; internal static class CdidxConfigFile { internal const string FileName = ".cdidxrc.json"; + internal static readonly string ProjectConfigRelativePath = Path.Combine(".cdidx", "config.json"); internal const string DisableEnvVar = "CDIDX_DISABLE_CONFIG_FILE"; private static readonly IReadOnlyList KnownTopLevelKeys = new[] @@ -34,11 +35,19 @@ internal static class CdidxConfigFile "global_tool_log_dir", "stale_after", "indexing", + "search", + "output", + "graph", + "folding", "suggestion_dedup_threshold", "mcp", }; private static readonly IReadOnlyList KnownIndexingKeys = new[] { "includeKinds", "excludeKinds" }; + private static readonly IReadOnlyList KnownSearchKeys = new[] { "limit", "snippet_lines", "max_line_width" }; + private static readonly IReadOnlyList KnownOutputKeys = new[] { "format", "locale" }; + private static readonly IReadOnlyList KnownGraphKeys = new[] { "max_hops" }; + 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" }; @@ -179,6 +188,37 @@ internal static LoadResult LoadAndApply( } } + if (root.TryGetProperty("search", out var search)) + { + 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)}."); + if (search.TryGetProperty("limit", out var limit)) + { + if (!TryReadNumberAsString(limit, "search.limit", path, out var value, out var err)) + return new LoadResult(Path: path, Error: err); + pending.Add((QueryCommandRunner.DefaultLimitEnvironmentVariable, value!)); + } + if (search.TryGetProperty("snippet_lines", out var snippetLines)) + { + if (!TryReadNumberAsString(snippetLines, "search.snippet_lines", path, out var value, out var err)) + return new LoadResult(Path: path, Error: err); + pending.Add((QueryCommandRunner.DefaultSnippetLinesEnvironmentVariable, value!)); + } + if (search.TryGetProperty("max_line_width", out var maxLineWidth)) + { + if (!TryReadNumberAsString(maxLineWidth, "search.max_line_width", path, out var value, out var err)) + return new LoadResult(Path: path, Error: err); + pending.Add((QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable, value!)); + } + } + + 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); + if (root.TryGetProperty("mcp", out var mcp)) { if (mcp.ValueKind != JsonValueKind.Object) @@ -265,6 +305,9 @@ internal static LoadResult LoadAndApply( while (current is not null) { + var projectCandidate = Path.Combine(current.FullName, ProjectConfigRelativePath); + if (File.Exists(LongPath.EnsureWindowsPrefix(projectCandidate))) + return projectCandidate; var candidate = Path.Combine(current.FullName, FileName); if (File.Exists(LongPath.EnsureWindowsPrefix(candidate))) return candidate; @@ -273,6 +316,48 @@ internal static LoadResult LoadAndApply( return null; } + internal static int RunValidate(string[] args, JsonSerializerOptions jsonOptions) + { + if (args.Length > 0) + { + CommandErrorWriter.Write("validate-config does not accept positional arguments.", "run `cdidx validate-config` from the workspace whose config should be validated."); + return CommandExitCodes.UsageError; + } + + var result = LoadAndApply(Environment.CurrentDirectory, name => name == DisableEnvVar ? null : Environment.GetEnvironmentVariable(name), (_, _) => { }); + if (result.Failed) + { + Console.Error.WriteLine(result.Error); + return CommandExitCodes.UsageError; + } + + var payload = new Dictionary + { + ["valid"] = true, + ["path"] = result.Path, + }; + Console.WriteLine(JsonSerializer.Serialize(payload, jsonOptions)); + return CommandExitCodes.Success; + } + + private static bool ValidateOptionalObject(JsonElement root, string key, IReadOnlyList knownKeys, string path, out string? error) + { + error = null; + if (!root.TryGetProperty(key, out var value)) + return true; + if (value.ValueKind != JsonValueKind.Object) + { + error = $"[cdidx] {path}: `{key}` must be a JSON object."; + return false; + } + if (TryFindUnknownKey(value, knownKeys, out var unknownKey)) + { + error = $"[cdidx] {path}: unknown key `{key}.{unknownKey}`. Supported keys: {string.Join(", ", knownKeys)}."; + return false; + } + return true; + } + private static bool TryFindUnknownKey(JsonElement obj, IReadOnlyList knownKeys, out string? unknown) { foreach (var property in obj.EnumerateObject()) diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 0b6be8c144..f3c0196c3d 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -209,6 +209,7 @@ internal static int Run( "backfill-fold" => IndexCommandRunner.RunBackfillFold(subArgs, jsonOptions), "optimize" => IndexCommandRunner.RunOptimizeFts(subArgs, jsonOptions), "vacuum" => QueryCommandRunner.RunVacuum(subArgs, jsonOptions), + "validate-config" => CdidxConfigFile.RunValidate(subArgs, jsonOptions), "db" => DbCommandRunner.RunIntegrityCheck(subArgs, jsonOptions), "report" => ReportCommandRunner.Run(subArgs, jsonOptions, appVersion), _ when IsProjectPathArg(commandName) diff --git a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs index 871e01899e..46cc60a8f3 100644 --- a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs +++ b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs @@ -67,6 +67,40 @@ public void LoadAndApply_MaterializesKnownKeysIntoEnvironment() finally { TestProjectHelper.DeleteDirectory(dir); } } + [Fact] + public void LoadAndApply_ProjectConfigJsonMaterializesSearchDefaults() + { + var dir = CreateTempDir(); + try + { + Directory.CreateDirectory(Path.Combine(dir, ".cdidx")); + Directory.CreateDirectory(Path.Combine(dir, "src")); + File.WriteAllText(Path.Combine(dir, ".cdidx", "config.json"), """ + { + "$schema": "https://example.invalid/cdidx.schema.json", + "search": { + "limit": 41, + "snippet_lines": 5, + "max_line_width": 120 + }, + "output": { "format": "json", "locale": "en" }, + "graph": { "max_hops": 4 }, + "folding": { "fold_key_version": 1 } + } + """); + + var env = new TestEnvironment(); + var result = CdidxConfigFile.LoadAndApply(Path.Combine(dir, "src"), env.Read, env.Write); + + Assert.True(result.Loaded); + Assert.EndsWith(Path.Combine(".cdidx", "config.json"), result.Path); + Assert.Equal("41", env.Writes[QueryCommandRunner.DefaultLimitEnvironmentVariable]); + Assert.Equal("5", env.Writes[QueryCommandRunner.DefaultSnippetLinesEnvironmentVariable]); + Assert.Equal("120", env.Writes[QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable]); + } + finally { TestProjectHelper.DeleteDirectory(dir); } + } + [Fact] public void LoadAndApply_RealEnvVarWinsOverConfigFile() { From 9bad09c67bc251526fa508e2edaacd554f968ff8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 10:23:15 +0900 Subject: [PATCH 06/14] Wire config validation help output (#1926) --- src/CodeIndex/Cli/ConsoleUi.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index a4cd0b082c..53042af943 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -80,7 +80,8 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("map", "cdidx map [--db ] [--json] [--verbose] [--limit ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes]"), ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--verbose] [--limit ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--max-line-width ] [--exact|--exact-name]"), ("outline", "cdidx outline [--db ] [--json] [--verbose]"), - ("status", "cdidx status [--db ] [--json] [--verbose] [--check[=workspace,fold,graph,issues,hotspot,csharp,sql,newer]] [--stale-after ] [--explain ] [--log-path]"), + ("status", "cdidx status [--db ] [--json] [--verbose] [--check[=workspace,fold,graph,issues,hotspot,csharp,sql,newer]] [--stale-after ] [--explain ] [--log-path] [--config]"), + ("validate-config", "cdidx validate-config"), ("db", "cdidx db --integrity-check [--db ] [--json]"), ("diff", "cdidx diff [--json] [--summary-only] [--detailed] [--limit ]"), ("report", "cdidx report --output [--db ] [--json] [--log-lines ] [--no-log] [--include-args]"), @@ -648,7 +649,8 @@ void WriteHelpLine(string line = "") Console.WriteLine(" map Show a repo-level overview for AI orientation"); Console.WriteLine(" inspect Bundle definition, graph, and nearby symbol context"); Console.WriteLine(" outline Show a file outline ordered by line, start column, kind, and name"); - Console.WriteLine(" status Show database statistics; add --check for freshness, --explain for readiness, or --log-path for logs"); + Console.WriteLine(" status Show database statistics; add --check for freshness, --config for effective config, --explain for readiness, or --log-path for logs"); + Console.WriteLine(" validate-config Validate .cdidx/config.json or .cdidxrc.json"); Console.WriteLine(" db --integrity-check Run SQLite `PRAGMA integrity_check` and report findings"); Console.WriteLine(" diff Compare two index databases; exit 0 identical, 1 drift, 2 schema mismatch, 3 unreadable"); Console.WriteLine(" report --output Build a redacted crash-repro tarball (.tgz) for bug reports"); @@ -793,6 +795,8 @@ void WriteHelpLine(string line = "") Console.WriteLine(" cdidx files --lang python List Python files"); Console.WriteLine(" cdidx files --since 2024-01-01 Files modified since a date"); Console.WriteLine(" cdidx status --json DB stats as JSON"); + Console.WriteLine(" cdidx status --config Effective configuration as JSON"); + Console.WriteLine(" cdidx validate-config Validate checked-in config"); Console.WriteLine(" cdidx languages Show supported languages"); Console.WriteLine(" cdidx --completions zsh > ~/.zfunc/_cdidx Generate a zsh completion script"); Console.WriteLine(" cdidx license Show licensing and commercial-use terms"); @@ -1087,7 +1091,7 @@ private static string GetCompletionKinds() => // generic catch-all となるよう揃える。テストもこの並びを前提にしている。 private static readonly string[] EnumeratedCompletionCommands = [ - "find", "excerpt", "references", "inspect", "hotspots", "status", "db", "report", "search", + "find", "excerpt", "references", "inspect", "hotspots", "status", "validate-config", "db", "report", "search", ]; // Generic-branch representative set: union of completion flags from these commands populates From 0229cca1de11725a881b0aae6f0fd8353913d436 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 10:30:25 +0900 Subject: [PATCH 07/14] Validate search defaults in project config (#1926) --- src/CodeIndex/Cli/CdidxConfigFile.cs | 38 +++++++++++++++++-- tests/CodeIndex.Tests/CdidxConfigFileTests.cs | 25 ++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/Cli/CdidxConfigFile.cs b/src/CodeIndex/Cli/CdidxConfigFile.cs index 71adb7d24c..f8a493f88c 100644 --- a/src/CodeIndex/Cli/CdidxConfigFile.cs +++ b/src/CodeIndex/Cli/CdidxConfigFile.cs @@ -25,6 +25,7 @@ internal static class CdidxConfigFile internal const string FileName = ".cdidxrc.json"; internal static readonly string ProjectConfigRelativePath = Path.Combine(".cdidx", "config.json"); internal const string DisableEnvVar = "CDIDX_DISABLE_CONFIG_FILE"; + internal const string ConfigSourceEnvironmentVariablePrefix = "CDIDX_CONFIG_SOURCE__"; private static readonly IReadOnlyList KnownTopLevelKeys = new[] { @@ -196,19 +197,19 @@ internal static LoadResult LoadAndApply( return new LoadResult(Path: path, Error: $"[cdidx] {path}: unknown key `search.{unknownSearchKey}`. Supported keys: {string.Join(", ", KnownSearchKeys)}."); if (search.TryGetProperty("limit", out var limit)) { - if (!TryReadNumberAsString(limit, "search.limit", path, out var value, out var err)) + 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!)); } if (search.TryGetProperty("snippet_lines", out var snippetLines)) { - if (!TryReadNumberAsString(snippetLines, "search.snippet_lines", path, out var value, out var err)) + 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!)); } if (search.TryGetProperty("max_line_width", out var maxLineWidth)) { - if (!TryReadNumberAsString(maxLineWidth, "search.max_line_width", path, out var value, out var err)) + 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!)); } @@ -281,7 +282,10 @@ internal static LoadResult LoadAndApply( foreach (var (name, value) in pending) { if (envReader(name) is null) + { envWriter(name, value); + envWriter(ConfigSourceEnvironmentVariablePrefix + name, path); + } } return new LoadResult(Path: path, Error: null); @@ -438,4 +442,32 @@ private static bool TryReadNumberAsString(JsonElement element, string key, strin value = element.GetRawText(); return true; } + + private static bool TryReadSearchInteger(JsonElement element, string key, string optionName, bool allowZero, string path, out string? value, out string? error) + { + value = null; + error = null; + if (element.ValueKind != JsonValueKind.Number) + { + error = $"[cdidx] {path}: `{key}` must be a number."; + return false; + } + + if (!element.TryGetInt32(out var parsed) || parsed < 0 || (!allowZero && parsed == 0)) + { + error = allowZero + ? $"[cdidx] {path}: `{key}` must be a non-negative integer." + : $"[cdidx] {path}: `{key}` must be a positive integer."; + return false; + } + + if (QueryCommandRunner.NumericFlagUpperBounds.TryGetValue(optionName, out var maxAllowed) && parsed > maxAllowed) + { + error = $"[cdidx] {path}: `{key}` must be <= {maxAllowed}."; + return false; + } + + value = parsed.ToString(System.Globalization.CultureInfo.InvariantCulture); + return true; + } } diff --git a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs index 46cc60a8f3..031a3d1fa1 100644 --- a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs +++ b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs @@ -97,6 +97,31 @@ public void LoadAndApply_ProjectConfigJsonMaterializesSearchDefaults() Assert.Equal("41", env.Writes[QueryCommandRunner.DefaultLimitEnvironmentVariable]); Assert.Equal("5", env.Writes[QueryCommandRunner.DefaultSnippetLinesEnvironmentVariable]); Assert.Equal("120", env.Writes[QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable]); + Assert.EndsWith(Path.Combine(".cdidx", "config.json"), env.Writes[CdidxConfigFile.ConfigSourceEnvironmentVariablePrefix + QueryCommandRunner.DefaultLimitEnvironmentVariable]); + } + finally { TestProjectHelper.DeleteDirectory(dir); } + } + + [Theory] + [InlineData("""{ "search": { "limit": 0 } }""", "positive integer")] + [InlineData("""{ "search": { "snippet_lines": -1 } }""", "positive integer")] + [InlineData("""{ "search": { "max_line_width": -1 } }""", "non-negative integer")] + [InlineData("""{ "search": { "limit": 1.5 } }""", "positive integer")] + [InlineData("""{ "search": { "limit": 10001 } }""", "<= 10000")] + public void LoadAndApply_ProjectConfigJsonRejectsInvalidSearchDefaults(string json, string expectedError) + { + var dir = CreateTempDir(); + try + { + Directory.CreateDirectory(Path.Combine(dir, ".cdidx")); + File.WriteAllText(Path.Combine(dir, ".cdidx", "config.json"), json); + + var env = new TestEnvironment(); + var result = CdidxConfigFile.LoadAndApply(dir, env.Read, env.Write); + + Assert.True(result.Failed); + Assert.Contains(expectedError, result.Error); + Assert.Empty(env.Writes); } finally { TestProjectHelper.DeleteDirectory(dir); } } From c19ff4186167aeee90f2bcddb37c9f672c005771 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 10:30:32 +0900 Subject: [PATCH 08/14] Report config-file sources in status config (#1813) --- src/CodeIndex/Cli/QueryCommandRunner.cs | 13 +++++-- .../QueryCommandRunnerTests.cs | 36 ++++++++++++++++++- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 81bb863d99..06e7304e75 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -2435,11 +2435,18 @@ private static string ResolveNumericConfigSource(string[] args, string primaryFl { if (HasOption(args, primaryFlag) || (aliasFlag != null && HasOption(args, aliasFlag))) return "flag"; - return Environment.GetEnvironmentVariable(envName) is null ? "default" : $"env:{envName}"; + if (Environment.GetEnvironmentVariable(envName) is null) + return "default"; + var configSource = Environment.GetEnvironmentVariable(CdidxConfigFile.ConfigSourceEnvironmentVariablePrefix + envName); + if (!string.IsNullOrWhiteSpace(configSource)) + return $"config:{configSource}"; + return $"env:{envName}"; } - private static string ResolveEnvSource(string envName) => - Environment.GetEnvironmentVariable(envName) is null ? "default" : $"env:{envName}"; + private static string ResolveEnvSource(string envName) + { + return Environment.GetEnvironmentVariable(envName) is null ? "default" : $"env:{envName}"; + } private static bool HasOption(string[] args, string optionName) { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 7781fb28f7..488cea7cc1 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -206,7 +206,9 @@ public void ParseArgs_StatusStaleAfterStoresDuration() [Fact] public void RunStatusConfig_PrintsEffectiveConfigWithoutOpeningDb() { - using var env = EnvironmentVariableScope.Capture(QueryCommandRunner.DefaultLimitEnvironmentVariable); + using var env = EnvironmentVariableScope.Capture( + QueryCommandRunner.DefaultLimitEnvironmentVariable, + CdidxConfigFile.ConfigSourceEnvironmentVariablePrefix + QueryCommandRunner.DefaultLimitEnvironmentVariable); Environment.SetEnvironmentVariable(QueryCommandRunner.DefaultLimitEnvironmentVariable, "33"); var missingDb = Path.Combine(Path.GetTempPath(), $"cdidx_missing_{Guid.NewGuid():N}.db"); var parsed = QueryCommandRunner.ParseArgs(["--config", "--db", missingDb, "--json"], jsonDefault: false, allowStatusCheck: true); @@ -226,6 +228,38 @@ public void RunStatusConfig_PrintsEffectiveConfigWithoutOpeningDb() Assert.Equal($"env:{QueryCommandRunner.DefaultLimitEnvironmentVariable}", effective.GetProperty("limit").GetProperty("source").GetString()); } + [Fact] + public void RunStatusConfig_ReportsConfigFileSourceForSearchDefaults() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_status_config_source"); + using var env = EnvironmentVariableScope.Capture( + QueryCommandRunner.DefaultLimitEnvironmentVariable, + CdidxConfigFile.ConfigSourceEnvironmentVariablePrefix + QueryCommandRunner.DefaultLimitEnvironmentVariable); + try + { + var configDir = Path.Combine(projectRoot, ".cdidx"); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "config.json"); + File.WriteAllText(configPath, """{ "search": { "limit": 44 } }"""); + + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["status", "--config", "--json"], + appVersion: "test-version", + configStartDirectory: projectRoot)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var limit = document.RootElement.GetProperty("effective_config").GetProperty("limit"); + Assert.Equal(44, limit.GetProperty("value").GetInt32()); + Assert.Equal($"config:{configPath}", limit.GetProperty("source").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunStatusJson_ReportsSqlitePageMetrics_Issue1631() { From 9bb0280ed6dd852eef3b2f85aebaa3a1b5f40d0c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 10:39:13 +0900 Subject: [PATCH 09/14] Register config validation command lists (#1926) --- src/CodeIndex/Cli/CliFlagSchema.cs | 2 +- src/CodeIndex/Cli/ConsoleUi.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index d963c29781..9e26b563c4 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -53,7 +53,7 @@ internal static class CliFlagSchema public static IReadOnlyList AllCommands { get; } = [ "index", "backfill-fold", "optimize", "search", "definition", "references", "callers", "callees", - "symbols", "files", "find", "excerpt", "map", "inspect", "outline", "status", + "symbols", "files", "find", "excerpt", "map", "inspect", "outline", "status", "validate-config", "validate", "deps", "impact", "unused", "hotspots", "languages", "batch", "mcp", "completions", "db", "vacuum", "report", "license", ]; diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 53042af943..45bc2741ec 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -1023,7 +1023,7 @@ private static int DamerauLevenshteinDistance(string s, string t) private static readonly string[] Commands = [ "index", "backfill-fold", "optimize", "search", "definition", "references", "callers", "callees", - "symbols", "files", "find", "excerpt", "map", "inspect", "outline", "status", + "symbols", "files", "find", "excerpt", "map", "inspect", "outline", "status", "validate-config", "validate", "deps", "impact", "unused", "hotspots", "languages", "batch", "mcp", "completions", "db", "vacuum", "report", "license", ]; From b9afdca07e73888bd5d0be3b4c2276beac3add15 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 10:42:28 +0900 Subject: [PATCH 10/14] Report config sources for status defaults (#1813) --- src/CodeIndex/Cli/QueryCommandRunner.cs | 10 +++++++-- .../QueryCommandRunnerTests.cs | 21 +++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 06e7304e75..c7bcaf131b 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -2397,6 +2397,7 @@ private static JsonObject BuildEffectiveConfigJson(QueryCommandOptions options, ["value"] = JsonSerializer.SerializeToNode(value), ["source"] = source, }; + var staleAfterEnvValue = Environment.GetEnvironmentVariable(StaleAfterEnvironmentVariable); var payload = new JsonObject { @@ -2409,7 +2410,7 @@ private static JsonObject BuildEffectiveConfigJson(QueryCommandOptions options, ["snippet_lines"] = Entry(options.SnippetLines, ResolveNumericConfigSource(cmdArgs, "--snippet-lines", null, DefaultSnippetLinesEnvironmentVariable)), ["max_line_width"] = Entry(options.MaxLineWidth, ResolveNumericConfigSource(cmdArgs, "--max-line-width", null, DefaultMaxLineWidthEnvironmentVariable)), ["json"] = Entry(options.Json, HasOption(cmdArgs, "--json") ? "flag" : "default"), - ["stale_after"] = Entry(options.StaleAfter?.ToString(), options.StaleAfter.HasValue ? "flag" : Environment.GetEnvironmentVariable(StaleAfterEnvironmentVariable) is null ? "default" : $"env:{StaleAfterEnvironmentVariable}"), + ["stale_after"] = Entry(options.StaleAfter?.ToString() ?? staleAfterEnvValue, options.StaleAfter.HasValue ? "flag" : ResolveEnvSource(StaleAfterEnvironmentVariable)), ["global_tool_log_dir"] = Entry(GlobalToolLog.ResolveLogDirectoryForStatus(), ResolveEnvSource("CDIDX_GLOBAL_TOOL_LOG_DIR")), ["version"] = Entry(appVersion ?? ConsoleUi.LoadVersion(), "build"), }, @@ -2445,7 +2446,12 @@ private static string ResolveNumericConfigSource(string[] args, string primaryFl private static string ResolveEnvSource(string envName) { - return Environment.GetEnvironmentVariable(envName) is null ? "default" : $"env:{envName}"; + if (Environment.GetEnvironmentVariable(envName) is null) + return "default"; + var configSource = Environment.GetEnvironmentVariable(CdidxConfigFile.ConfigSourceEnvironmentVariablePrefix + envName); + if (!string.IsNullOrWhiteSpace(configSource)) + return $"config:{configSource}"; + return $"env:{envName}"; } private static bool HasOption(string[] args, string optionName) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 488cea7cc1..ccd2fd700d 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -234,13 +234,24 @@ public void RunStatusConfig_ReportsConfigFileSourceForSearchDefaults() var projectRoot = TestProjectHelper.CreateTempProject("cdidx_status_config_source"); using var env = EnvironmentVariableScope.Capture( QueryCommandRunner.DefaultLimitEnvironmentVariable, - CdidxConfigFile.ConfigSourceEnvironmentVariablePrefix + QueryCommandRunner.DefaultLimitEnvironmentVariable); + QueryCommandRunner.StaleAfterEnvironmentVariable, + "CDIDX_GLOBAL_TOOL_LOG_DIR", + CdidxConfigFile.ConfigSourceEnvironmentVariablePrefix + QueryCommandRunner.DefaultLimitEnvironmentVariable, + CdidxConfigFile.ConfigSourceEnvironmentVariablePrefix + QueryCommandRunner.StaleAfterEnvironmentVariable, + CdidxConfigFile.ConfigSourceEnvironmentVariablePrefix + "CDIDX_GLOBAL_TOOL_LOG_DIR"); try { var configDir = Path.Combine(projectRoot, ".cdidx"); Directory.CreateDirectory(configDir); var configPath = Path.Combine(configDir, "config.json"); - File.WriteAllText(configPath, """{ "search": { "limit": 44 } }"""); + var logDir = Path.Combine(projectRoot, "logs"); + File.WriteAllText(configPath, $$""" + { + "search": { "limit": 44 }, + "stale_after": "2h", + "global_tool_log_dir": {{JsonSerializer.Serialize(logDir)}} + } + """); var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( ["status", "--config", "--json"], @@ -253,6 +264,12 @@ public void RunStatusConfig_ReportsConfigFileSourceForSearchDefaults() var limit = document.RootElement.GetProperty("effective_config").GetProperty("limit"); Assert.Equal(44, limit.GetProperty("value").GetInt32()); Assert.Equal($"config:{configPath}", limit.GetProperty("source").GetString()); + var staleAfter = document.RootElement.GetProperty("effective_config").GetProperty("stale_after"); + Assert.Equal("2h", staleAfter.GetProperty("value").GetString()); + Assert.Equal($"config:{configPath}", staleAfter.GetProperty("source").GetString()); + var logPath = document.RootElement.GetProperty("effective_config").GetProperty("global_tool_log_dir"); + Assert.Equal(logDir, logPath.GetProperty("value").GetString()); + Assert.Equal($"config:{configPath}", logPath.GetProperty("source").GetString()); } finally { From 5f96f4e8226c96cf41c02fba7d25b497e3f5f735 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 10:45:24 +0900 Subject: [PATCH 11/14] Honor CLI overrides for default env errors (#2036) --- src/CodeIndex/Cli/QueryCommandRunner.cs | 20 ++++++++----- .../QueryCommandRunnerTests.cs | 29 +++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index c7bcaf131b..5dcf7bba07 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -3939,6 +3939,9 @@ public static QueryCommandOptions ParseArgs(string[] args, bool jsonDefault, boo string? statusExplainField = null; bool statusLogPath = false; bool statusConfig = false; + bool limitExplicit = false; + bool snippetLinesExplicit = false; + bool maxLineWidthExplicit = false; var rankMode = ReferenceRankMode.Weighted; var extraNames = new List(); bool impactDeprecatedDepthUsed = false; @@ -3949,13 +3952,6 @@ void AddParseError(string error) parseErrors.Add(error); } - if (defaultLimitError != null) - AddParseError(defaultLimitError); - if (defaultSnippetLinesError != null) - AddParseError(defaultSnippetLinesError); - if (defaultMaxLineWidthError != null) - AddParseError(defaultMaxLineWidthError); - void AddStatusCheckScopes(string rawScopes) { if (string.IsNullOrWhiteSpace(rawScopes)) @@ -4083,6 +4079,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) { WarnIfDuplicateSingleValueOption("--limit", limitValue!); limit = parsedLimit; + limitExplicit = true; } else AddParseError(limitError!); @@ -4455,6 +4452,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) { WarnIfDuplicateSingleValueOption("--snippet-lines", snippetLinesValue!); snippetLines = parsedSnippetLines; + snippetLinesExplicit = true; } else AddParseError(snippetLinesError!); @@ -4481,6 +4479,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) { WarnIfDuplicateSingleValueOption("--max-line-width", maxLineWidthValue!); maxLineWidth = parsedMaxLineWidth; + maxLineWidthExplicit = true; } else AddParseError(maxLineWidthError!); @@ -4520,6 +4519,13 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) ValidateQueryPathOptionValues(userPathPatterns, excludePaths, AddParseError); + if (!limitExplicit && defaultLimitError != null) + AddParseError(defaultLimitError); + if (!snippetLinesExplicit && defaultSnippetLinesError != null) + AddParseError(defaultSnippetLinesError); + if (!maxLineWidthExplicit && defaultMaxLineWidthError != null) + AddParseError(defaultMaxLineWidthError); + var dbResolution = DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, dbPath, dataDir); return new QueryCommandOptions diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index ccd2fd700d..4175791a7a 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -138,6 +138,35 @@ public void ParseArgs_InvalidNumericDefaultEnvironmentVariableReportsParseError( Assert.Contains(QueryCommandRunner.DefaultLimitEnvironmentVariable, options.ParseError); } + [Theory] + [InlineData("limit")] + [InlineData("snippet-lines")] + [InlineData("max-line-width")] + public void ParseArgs_CliNumericFlagsOverrideInvalidDefaultEnvironmentVariables(string option) + { + using var env = EnvironmentVariableScope.Capture( + QueryCommandRunner.DefaultLimitEnvironmentVariable, + QueryCommandRunner.DefaultSnippetLinesEnvironmentVariable, + QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable); + Environment.SetEnvironmentVariable(QueryCommandRunner.DefaultLimitEnvironmentVariable, "0"); + Environment.SetEnvironmentVariable(QueryCommandRunner.DefaultSnippetLinesEnvironmentVariable, "0"); + Environment.SetEnvironmentVariable(QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable, "-1"); + + var args = option switch + { + "limit" => new[] { "RunSearch", "--limit", "7", "--snippet-lines", "3", "--max-line-width", "80" }, + "snippet-lines" => new[] { "RunSearch", "--limit", "7", "--snippet-lines", "3", "--max-line-width", "80" }, + _ => new[] { "RunSearch", "--limit", "7", "--snippet-lines", "3", "--max-line-width", "80" }, + }; + + var options = QueryCommandRunner.ParseArgs(args, jsonDefault: false, allowNamedQuery: true); + + Assert.Equal(7, options.Limit); + Assert.Equal(3, options.SnippetLines); + Assert.Equal(80, options.MaxLineWidth); + Assert.Null(options.ParseError); + } + [Fact] public void ParseArgs_ProjectFilterExpandsSolutionProjectToPathGlob_Issue1707() { From ce8df10918fd3a4ba624b0cf7c9f9ef7d623ff9f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 10:48:58 +0900 Subject: [PATCH 12/14] Limit default env validation to relevant commands (#2036) --- src/CodeIndex/Cli/QueryCommandRunner.cs | 16 ++++++++-------- .../CodeIndex.Tests/QueryCommandRunnerTests.cs | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 5dcf7bba07..d23b45dbfc 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -2133,7 +2133,7 @@ public static int RunStatus(string[] cmdArgs, JsonSerializerOptions jsonOptions, Console.Error.WriteLine(previewOptionError); return CommandExitCodes.UsageError; } - var options = ParseArgs(cmdArgs, jsonDefault: false, allowStatusCheck: true); + var options = ParseArgs(cmdArgs, jsonDefault: false, allowStatusCheck: true, validateDefaultNumericOptions: false); if (TryWriteUnsupportedOptionError("status", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("status"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "status")) @@ -2468,7 +2468,7 @@ private static bool HasOption(string[] args, string optionName) public static int RunVacuum(string[] cmdArgs, JsonSerializerOptions jsonOptions) { - var options = ParseArgs(cmdArgs, jsonDefault: false); + var options = ParseArgs(cmdArgs, jsonDefault: false, validateDefaultNumericOptions: false); if (TryWriteUnsupportedOptionError("vacuum", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("vacuum"))) return CommandExitCodes.UsageError; var explicitDbPathError = BuildExplicitDbPathParseError(options); @@ -3752,7 +3752,7 @@ public static int RunValidate(string[] cmdArgs, JsonSerializerOptions jsonOption Console.Error.WriteLine(previewOptionError); return CommandExitCodes.UsageError; } - var options = ParseArgs(cmdArgs, jsonDefault: false); + var options = ParseArgs(cmdArgs, jsonDefault: false, validateDefaultNumericOptions: false); if (TryWriteUnsupportedOptionError("validate", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("validate"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "validate")) @@ -3808,7 +3808,7 @@ public static int RunValidate(string[] cmdArgs, JsonSerializerOptions jsonOption public static int RunLanguages(string[] cmdArgs, JsonSerializerOptions jsonOptions) { - var options = ParseArgs(cmdArgs, jsonDefault: false); + var options = ParseArgs(cmdArgs, jsonDefault: false, validateDefaultNumericOptions: false); if (TryWriteUnsupportedOptionError("languages", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("languages"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "languages")) @@ -3882,7 +3882,7 @@ public static int RunLanguages(string[] cmdArgs, JsonSerializerOptions jsonOptio return CommandExitCodes.Success; } - public static QueryCommandOptions ParseArgs(string[] args, bool jsonDefault, bool allowNamedQuery = false, bool allowStatusCheck = false) + public static QueryCommandOptions ParseArgs(string[] args, bool jsonDefault, bool allowNamedQuery = false, bool allowStatusCheck = false, bool validateDefaultNumericOptions = true) { string? dbPath = null; string? dataDir = null; @@ -4519,11 +4519,11 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) ValidateQueryPathOptionValues(userPathPatterns, excludePaths, AddParseError); - if (!limitExplicit && defaultLimitError != null) + if (validateDefaultNumericOptions && !limitExplicit && defaultLimitError != null) AddParseError(defaultLimitError); - if (!snippetLinesExplicit && defaultSnippetLinesError != null) + if (validateDefaultNumericOptions && !snippetLinesExplicit && defaultSnippetLinesError != null) AddParseError(defaultSnippetLinesError); - if (!maxLineWidthExplicit && defaultMaxLineWidthError != null) + if (validateDefaultNumericOptions && !maxLineWidthExplicit && defaultMaxLineWidthError != null) AddParseError(defaultMaxLineWidthError); var dbResolution = DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, dbPath, dataDir); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 4175791a7a..118fda3576 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -167,6 +167,24 @@ public void ParseArgs_CliNumericFlagsOverrideInvalidDefaultEnvironmentVariables( Assert.Null(options.ParseError); } + [Fact] + public void RunLanguages_IgnoresInvalidNumericDefaultEnvironmentVariables() + { + using var env = EnvironmentVariableScope.Capture( + QueryCommandRunner.DefaultLimitEnvironmentVariable, + QueryCommandRunner.DefaultSnippetLinesEnvironmentVariable, + QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable); + Environment.SetEnvironmentVariable(QueryCommandRunner.DefaultLimitEnvironmentVariable, "0"); + Environment.SetEnvironmentVariable(QueryCommandRunner.DefaultSnippetLinesEnvironmentVariable, "0"); + Environment.SetEnvironmentVariable(QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable, "-1"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunLanguages([], _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Contains("Language", stdout); + Assert.DoesNotContain(QueryCommandRunner.DefaultLimitEnvironmentVariable, stderr); + } + [Fact] public void ParseArgs_ProjectFilterExpandsSolutionProjectToPathGlob_Issue1707() { From 63a46d078165f9d6e77798bff580964027a0f887 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 11:01:12 +0900 Subject: [PATCH 13/14] Scope default env validation by command (#2036) --- src/CodeIndex/Cli/QueryCommandRunner.cs | 114 +++++++++++++++--- .../QueryCommandRunnerTests.cs | 38 ++++++ 2 files changed, 133 insertions(+), 19 deletions(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index d23b45dbfc..2a4ca8aeb3 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -468,7 +468,12 @@ public static int RunDefinition(string[] cmdArgs, JsonSerializerOptions jsonOpti Console.Error.WriteLine(previewOptionError); return CommandExitCodes.UsageError; } - var options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true); + var options = ParseArgs( + cmdArgs, + jsonDefault: false, + allowNamedQuery: true, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); if (TryWriteUnsupportedOptionError("definition", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("definition"), options.Query)) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "definition")) @@ -1166,7 +1171,12 @@ public static int RunSymbols(string[] cmdArgs, JsonSerializerOptions jsonOptions Console.Error.WriteLine(previewOptionError); return CommandExitCodes.UsageError; } - var options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true); + var options = ParseArgs( + cmdArgs, + jsonDefault: false, + allowNamedQuery: true, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); if (TryWriteUnsupportedOptionError("symbols", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("symbols"), options.Query)) return CommandExitCodes.UsageError; if (TryWriteInvalidKindFilterError(options, "symbols", KnownSymbolKindFilters)) @@ -1318,7 +1328,12 @@ public static int RunFiles(string[] cmdArgs, JsonSerializerOptions jsonOptions) Console.Error.WriteLine(previewOptionError); return CommandExitCodes.UsageError; } - var options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true); + var options = ParseArgs( + cmdArgs, + jsonDefault: false, + allowNamedQuery: true, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); if (TryWriteUnsupportedOptionError("files", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("files"), options.Query)) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "files")) @@ -1385,7 +1400,11 @@ public static int RunExcerpt(string[] cmdArgs, JsonSerializerOptions jsonOptions Console.Error.WriteLine(previewOptionError); return CommandExitCodes.UsageError; } - var options = ParseArgs(cmdArgs, jsonDefault: false); + var options = ParseArgs( + cmdArgs, + jsonDefault: false, + validateDefaultLimit: false, + validateDefaultSnippetLines: false); if (TryWriteUnsupportedOptionError("excerpt", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("excerpt"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "excerpt")) @@ -1506,7 +1525,11 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) return CommandExitCodes.UsageError; } - var options = ParseArgs(preparedFindArgs, jsonDefault: false, allowNamedQuery: true); + var options = ParseArgs( + preparedFindArgs, + jsonDefault: false, + allowNamedQuery: true, + validateDefaultSnippetLines: false); if (options.ParseError != null) { Console.Error.WriteLine(options.ParseError); @@ -1755,7 +1778,11 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) Console.Error.WriteLine(previewOptionError); return CommandExitCodes.UsageError; } - var options = ParseArgs(cmdArgs, jsonDefault: false); + var options = ParseArgs( + cmdArgs, + jsonDefault: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); if (TryWriteUnsupportedOptionError("map", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("map"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "map")) @@ -1835,7 +1862,11 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions Console.Error.WriteLine(previewOptionError); return CommandExitCodes.UsageError; } - var options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true); + var options = ParseArgs( + cmdArgs, + jsonDefault: false, + allowNamedQuery: true, + validateDefaultSnippetLines: false); if (TryWriteUnsupportedOptionError("inspect", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("inspect"), options.Query)) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "inspect")) @@ -1959,7 +1990,12 @@ public static int RunOutline(string[] cmdArgs, JsonSerializerOptions jsonOptions Console.Error.WriteLine(previewOptionError); return CommandExitCodes.UsageError; } - var options = ParseArgs(cmdArgs[1..], jsonDefault: false); + var options = ParseArgs( + cmdArgs[1..], + jsonDefault: false, + validateDefaultLimit: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); if (TryWriteUnsupportedOptionError("outline", cmdArgs[1..], CliFlagSchema.GetAcceptedFlagNamesForCommand("outline"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "outline")) @@ -2133,7 +2169,13 @@ public static int RunStatus(string[] cmdArgs, JsonSerializerOptions jsonOptions, Console.Error.WriteLine(previewOptionError); return CommandExitCodes.UsageError; } - var options = ParseArgs(cmdArgs, jsonDefault: false, allowStatusCheck: true, validateDefaultNumericOptions: false); + var options = ParseArgs( + cmdArgs, + jsonDefault: false, + allowStatusCheck: true, + validateDefaultLimit: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); if (TryWriteUnsupportedOptionError("status", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("status"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "status")) @@ -2468,7 +2510,12 @@ private static bool HasOption(string[] args, string optionName) public static int RunVacuum(string[] cmdArgs, JsonSerializerOptions jsonOptions) { - var options = ParseArgs(cmdArgs, jsonDefault: false, validateDefaultNumericOptions: false); + var options = ParseArgs( + cmdArgs, + jsonDefault: false, + validateDefaultLimit: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); if (TryWriteUnsupportedOptionError("vacuum", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("vacuum"))) return CommandExitCodes.UsageError; var explicitDbPathError = BuildExplicitDbPathParseError(options); @@ -2854,7 +2901,11 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions) Console.Error.WriteLine(previewOptionError); return CommandExitCodes.UsageError; } - var options = ParseArgs(cmdArgs, jsonDefault: false); + var options = ParseArgs( + cmdArgs, + jsonDefault: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); if (TryWriteUnsupportedOptionError("deps", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("deps"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "deps")) @@ -3079,7 +3130,11 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption Console.Error.WriteLine(previewOptionError); return CommandExitCodes.UsageError; } - var options = ParseArgs(cmdArgs, jsonDefault: false); + var options = ParseArgs( + cmdArgs, + jsonDefault: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); if (TryWriteUnsupportedOptionError("hotspots", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("hotspots"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "hotspots")) @@ -3484,7 +3539,11 @@ public static int RunUnused(string[] cmdArgs, JsonSerializerOptions jsonOptions) Console.Error.WriteLine(previewOptionError); return CommandExitCodes.UsageError; } - var options = ParseArgs(cmdArgs, jsonDefault: false); + var options = ParseArgs( + cmdArgs, + jsonDefault: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); if (TryWriteUnsupportedOptionError("unused", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("unused"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "unused")) @@ -3752,7 +3811,12 @@ public static int RunValidate(string[] cmdArgs, JsonSerializerOptions jsonOption Console.Error.WriteLine(previewOptionError); return CommandExitCodes.UsageError; } - var options = ParseArgs(cmdArgs, jsonDefault: false, validateDefaultNumericOptions: false); + var options = ParseArgs( + cmdArgs, + jsonDefault: false, + validateDefaultLimit: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); if (TryWriteUnsupportedOptionError("validate", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("validate"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "validate")) @@ -3808,7 +3872,12 @@ public static int RunValidate(string[] cmdArgs, JsonSerializerOptions jsonOption public static int RunLanguages(string[] cmdArgs, JsonSerializerOptions jsonOptions) { - var options = ParseArgs(cmdArgs, jsonDefault: false, validateDefaultNumericOptions: false); + var options = ParseArgs( + cmdArgs, + jsonDefault: false, + validateDefaultLimit: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); if (TryWriteUnsupportedOptionError("languages", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("languages"))) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "languages")) @@ -3882,7 +3951,14 @@ public static int RunLanguages(string[] cmdArgs, JsonSerializerOptions jsonOptio return CommandExitCodes.Success; } - public static QueryCommandOptions ParseArgs(string[] args, bool jsonDefault, bool allowNamedQuery = false, bool allowStatusCheck = false, bool validateDefaultNumericOptions = true) + public static QueryCommandOptions ParseArgs( + string[] args, + bool jsonDefault, + bool allowNamedQuery = false, + bool allowStatusCheck = false, + bool validateDefaultLimit = true, + bool validateDefaultSnippetLines = true, + bool validateDefaultMaxLineWidth = true) { string? dbPath = null; string? dataDir = null; @@ -4519,11 +4595,11 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) ValidateQueryPathOptionValues(userPathPatterns, excludePaths, AddParseError); - if (validateDefaultNumericOptions && !limitExplicit && defaultLimitError != null) + if (validateDefaultLimit && !limitExplicit && defaultLimitError != null) AddParseError(defaultLimitError); - if (validateDefaultNumericOptions && !snippetLinesExplicit && defaultSnippetLinesError != null) + if (validateDefaultSnippetLines && !snippetLinesExplicit && defaultSnippetLinesError != null) AddParseError(defaultSnippetLinesError); - if (validateDefaultNumericOptions && !maxLineWidthExplicit && defaultMaxLineWidthError != null) + if (validateDefaultMaxLineWidth && !maxLineWidthExplicit && defaultMaxLineWidthError != null) AddParseError(defaultMaxLineWidthError); var dbResolution = DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, dbPath, dataDir); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 118fda3576..42a8d74b35 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -185,6 +185,44 @@ public void RunLanguages_IgnoresInvalidNumericDefaultEnvironmentVariables() Assert.DoesNotContain(QueryCommandRunner.DefaultLimitEnvironmentVariable, stderr); } + [Fact] + public void ParseArgs_ScopesNumericDefaultValidationByOption() + { + using var env = EnvironmentVariableScope.Capture( + QueryCommandRunner.DefaultLimitEnvironmentVariable, + QueryCommandRunner.DefaultSnippetLinesEnvironmentVariable, + QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable); + Environment.SetEnvironmentVariable(QueryCommandRunner.DefaultLimitEnvironmentVariable, "0"); + Environment.SetEnvironmentVariable(QueryCommandRunner.DefaultSnippetLinesEnvironmentVariable, "0"); + Environment.SetEnvironmentVariable(QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable, "-1"); + + var limitOnly = QueryCommandRunner.ParseArgs( + [], + jsonDefault: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); + Assert.Contains(QueryCommandRunner.DefaultLimitEnvironmentVariable, limitOnly.ParseError); + Assert.DoesNotContain(QueryCommandRunner.DefaultSnippetLinesEnvironmentVariable, limitOnly.ParseError); + Assert.DoesNotContain(QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable, limitOnly.ParseError); + + var maxLineWidthOnly = QueryCommandRunner.ParseArgs( + [], + jsonDefault: false, + validateDefaultLimit: false, + validateDefaultSnippetLines: false); + Assert.Contains(QueryCommandRunner.DefaultMaxLineWidthEnvironmentVariable, maxLineWidthOnly.ParseError); + Assert.DoesNotContain(QueryCommandRunner.DefaultLimitEnvironmentVariable, maxLineWidthOnly.ParseError); + Assert.DoesNotContain(QueryCommandRunner.DefaultSnippetLinesEnvironmentVariable, maxLineWidthOnly.ParseError); + + var none = QueryCommandRunner.ParseArgs( + [], + jsonDefault: false, + validateDefaultLimit: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); + Assert.Null(none.ParseError); + } + [Fact] public void ParseArgs_ProjectFilterExpandsSolutionProjectToPathGlob_Issue1707() { From b6ab4016c3bfe1ee5d9b42c33e8848bfa34efb21 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 11:08:34 +0900 Subject: [PATCH 14/14] Resolve XDG query DBs from subdirectories (#1806) --- src/CodeIndex/Cli/DbPathResolver.cs | 37 +++++++++++++++++--- tests/CodeIndex.Tests/DbPathResolverTests.cs | 25 +++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/CodeIndex/Cli/DbPathResolver.cs b/src/CodeIndex/Cli/DbPathResolver.cs index ee8ce3d163..96f7126989 100644 --- a/src/CodeIndex/Cli/DbPathResolver.cs +++ b/src/CodeIndex/Cli/DbPathResolver.cs @@ -53,8 +53,7 @@ internal static DbPathResolution ResolveDataDir(string workspacePath, string? ex if (!string.IsNullOrWhiteSpace(xdgDataHome)) { - var workspaceHash = ComputeWorkspaceHash(fullWorkspacePath); - return BuildDataDirResolution(Path.Combine(xdgDataHome, "cdidx", workspaceHash), DataDirSourceXdg); + return BuildDataDirResolution(BuildXdgDataDir(xdgDataHome, fullWorkspacePath), DataDirSourceXdg); } return BuildDataDirResolution(Path.Combine(fullWorkspacePath, ".cdidx"), DataDirSourceWorkspace); @@ -71,8 +70,10 @@ internal static DbPathResolution ResolveDataDirForQuery(string workspacePath, st if (!string.IsNullOrWhiteSpace(xdgDataHome)) { - var workspaceHash = ComputeWorkspaceHash(fullWorkspacePath); - return BuildDataDirResolution(Path.Combine(xdgDataHome, "cdidx", workspaceHash), DataDirSourceXdg); + var ancestorXdgDataDir = TryResolveOutermostAncestorXdgDataDir(fullWorkspacePath, xdgDataHome); + if (ancestorXdgDataDir != null) + return BuildDataDirResolution(ancestorXdgDataDir, DataDirSourceXdg); + return BuildDataDirResolution(BuildXdgDataDir(xdgDataHome, fullWorkspacePath), DataDirSourceXdg); } var workspaceRootDataDir = TryResolveOutermostAncestorDataDir(fullWorkspacePath); @@ -88,6 +89,9 @@ private static DbPathResolution BuildDataDirResolution(string dataDir, string so return new DbPathResolution(Path.Combine(fullDataDir, "codeindex.db"), fullDataDir, source); } + private static string BuildXdgDataDir(string xdgDataHome, string workspacePath) + => Path.Combine(xdgDataHome, "cdidx", ComputeWorkspaceHash(workspacePath)); + private static string ComputeWorkspaceHash(string workspacePath) { var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(Path.GetFullPath(workspacePath))); @@ -118,6 +122,31 @@ private static string ComputeWorkspaceHash(string workspacePath) return selected; } + private static string? TryResolveOutermostAncestorXdgDataDir(string workspacePath, string xdgDataHome) + { + string? current; + try + { + current = Path.GetFullPath(workspacePath); + } + catch + { + return null; + } + + string? selected = null; + while (!string.IsNullOrWhiteSpace(current)) + { + var candidate = BuildXdgDataDir(xdgDataHome, current); + if (Directory.Exists(LongPath.EnsureWindowsPrefix(candidate)) || + File.Exists(LongPath.EnsureWindowsPrefix(Path.Combine(candidate, "codeindex.db")))) + selected = candidate; + current = Path.GetDirectoryName(current); + } + + return selected; + } + /// /// Resolve the most likely project root for query commands from the DB path. /// クエリ系コマンドのDBパスから、もっとも可能性が高いプロジェクトルートを解決する。 diff --git a/tests/CodeIndex.Tests/DbPathResolverTests.cs b/tests/CodeIndex.Tests/DbPathResolverTests.cs index 31b3d0b5f6..47cff4fbb8 100644 --- a/tests/CodeIndex.Tests/DbPathResolverTests.cs +++ b/tests/CodeIndex.Tests/DbPathResolverTests.cs @@ -76,6 +76,31 @@ public void ResolveDataDir_UsesStableXdgWorkspaceHashBeforeWorkspaceDefault() Assert.Equal(DbPathResolver.DataDirSourceXdg, first.DataDirSource); } + [Fact] + public void ResolveDataDirForQuery_WithXdgPrefersAncestorWorkspaceDataDir() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_xdg_root_db"); + var xdgDir = Path.Combine(Path.GetTempPath(), $"cdidx_xdg_dir_{Guid.NewGuid():N}"); + try + { + var child = Path.Combine(projectRoot, "src", "App"); + Directory.CreateDirectory(child); + var indexedRootResolution = DbPathResolver.ResolveDataDir(projectRoot, explicitDataDir: null, environmentDataDir: null, xdgDataHome: xdgDir); + Directory.CreateDirectory(indexedRootResolution.DataDir!); + + var resolved = DbPathResolver.ResolveDataDirForQuery(child, explicitDataDir: null, environmentDataDir: null, xdgDataHome: xdgDir); + + Assert.Equal(indexedRootResolution.DbPath, resolved.DbPath); + Assert.Equal(indexedRootResolution.DataDir, resolved.DataDir); + Assert.Equal(DbPathResolver.DataDirSourceXdg, resolved.DataDirSource); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(xdgDir); + } + } + [Fact] public void ResolveDataDirForQuery_PrefersOutermostAncestorCdidx() {