From 00e9df4be0a22ccecca531789f459568abe22fa6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 30 Jul 2026 10:38:06 +0900 Subject: [PATCH 1/3] Fix lazy project config loading for #4886 --- DEVELOPER_GUIDE.md | 15 +++ USER_GUIDE.md | 8 +- changelog.d/unreleased/4886.fixed.md | 19 +++ src/CodeIndex/Cli/CliCommandMetadata.cs | 12 ++ src/CodeIndex/Cli/ProgramRunner.cs | 114 +++++++++++++----- tests/CodeIndex.Tests/CdidxConfigFileTests.cs | 96 ++++++++++++++- 6 files changed, 229 insertions(+), 35 deletions(-) create mode 100644 changelog.d/unreleased/4886.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 02e0d5b57..9fea074e1 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -355,6 +355,14 @@ previews are sanitized and bounded. JSON failures write the envelope to stdout and leave stderr empty. Human failures write the matching coded `Error`, `Hint`, and `Usage` lines to stderr and leave stdout empty. +Resolve project-config dependency before calling `CdidxConfigFile.Load`. +Process-static commands (`license`, `--version`, help forms, completions, and +subcommand help) must not discover or parse project config. `validate-config` +and `config show` own their config reads so they can report invalid files through +their command-specific contracts. Other commands load config before environment +consumers run; malformed config in JSON mode uses `E024_CONFIG_INVALID` / +`configuration` in the shared command-error envelope. + | Failure class | Exit code | Error code | Category | |---|---:|---|---| | Usage / invalid arguments | 1 or 7 | `E010_USAGE_ERROR` | `usage` | @@ -3744,6 +3752,13 @@ sanitization し、上限を適用してから merge します。JSON の失敗 出し、stderr を空に保ちます。human の失敗は対応する code 付き `Error`、`Hint`、 `Usage` を stderr に出し、stdout を空に保ちます。 +`CdidxConfigFile.Load` を呼ぶ前に project-config 依存性を解決します。process-static な +command(`license`、`--version`、help 形式、completion、subcommand help)は project +config を探索・parse しません。`validate-config` と `config show` は不正な file を +command 固有契約で報告できるよう config read を自身で所有します。それ以外の command +は environment consumer の実行前に config を load し、JSON mode の不正 config は共通の +command-error envelope で `E024_CONFIG_INVALID` / `configuration` を返します。 + | failure class | exit code | error code | category | |---|---:|---|---| | usage / 不正な引数 | 1 または 7 | `E010_USAGE_ERROR` | `usage` | diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 32305d81a..6bf00a1fb 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2417,9 +2417,9 @@ Run `cdidx status --log-path` to print the active log directory without opening ### Project-local configuration file (`.cdidx/config.json` / `.cdidxrc.json`) -You can check a `.cdidx/config.json` or `.cdidxrc.json` file into a repository to set per-project defaults instead of relying on shell-profile or CI env vars (#1571). On startup `cdidx` walks upward from the current working directory looking for the first project config file, validates its schema, and materializes recognized keys as process environment variables — so every existing env-var consumer picks them up without further changes. Discovery stops after checking a directory that contains `.git`, `.hg`, `.svn`, `cdidx.workspace.json`, or `.cdidx-workspace.json`, so a child workspace does not inherit a config file from an unrelated parent. +You can check a `.cdidx/config.json` or `.cdidxrc.json` file into a repository to set per-project defaults instead of relying on shell-profile or CI env vars (#1571). Before a config-dependent command runs, `cdidx` walks upward from the current working directory looking for the first project config file, validates its schema, and materializes recognized keys as scoped environment settings — so every existing env-var consumer picks them up without process-global mutation. Static commands that do not consume project settings (`license`, `--version`, help forms, shell completions, and any command's `--help`) skip config discovery and remain usable even when an unrelated project config is malformed. Discovery stops after checking a directory that contains `.git`, `.hg`, `.svn`, `cdidx.workspace.json`, or `.cdidx-workspace.json`, so a child workspace does not inherit a config file from an unrelated parent. -Precedence is **CLI flag > environment variable > config file > built-in default**. A config-file value is applied only when the matching env var is not already set in the process, so a value the user already exported in the shell or CI always wins. Config JSON is bounded to 64 KiB and a conservative nesting depth before schema validation. A malformed file (invalid JSON, unknown key, wrong type, or excessive nesting) is a hard error: cdidx exits `1` with the file path and all detected offending fields; set `CDIDX_DISABLE_CONFIG_FILE=1` to bypass the file entirely. +Precedence is **CLI flag > environment variable > config file > built-in default**. A config-file value is applied only when the matching env var is not already set in the process, so a value the user already exported in the shell or CI always wins. Config JSON is bounded to 64 KiB and a conservative nesting depth before schema validation. For config-dependent commands, a malformed file (invalid JSON, unknown key, wrong type, or excessive nesting) is a hard error: cdidx exits `1` with the file path and all detected offending fields. JSON mode returns the versioned command-error envelope with `error_code: "E024_CONFIG_INVALID"` and `category: "configuration"` instead of writing human-only text to stderr. Set `CDIDX_DISABLE_CONFIG_FILE=1` to bypass the file entirely. Secrets are intentionally **not** loadable from the file: `CDIDX_GITHUB_TOKEN`, `CDIDX_MCP_AUTH_TOKEN`, and `CDIDX_MCP_HTTP_TOKEN` are env-only so tokens never get checked into version control. @@ -5655,9 +5655,9 @@ MCP のレスポンスサイズ上限は、環境変数 override で guard が ### プロジェクト固有の設定ファイル (`.cdidx/config.json` / `.cdidxrc.json`) -シェルプロファイルや CI の環境変数に頼らず、プロジェクトごとの既定値を `.cdidx/config.json` または `.cdidxrc.json` ファイルとしてリポジトリにチェックインできます (#1571)。`cdidx` は起動時にカレントディレクトリから上方向に最初のプロジェクト設定ファイルを探索し、スキーマを検証してから既知のキーをプロセス環境変数として注入します。これにより、既存の環境変数コンシューマはコード変更なしに同じ値を受け取れます。探索は `.git`、`.hg`、`.svn`、`cdidx.workspace.json`、`.cdidx-workspace.json` を含むディレクトリを確認した後で停止するため、子 workspace が無関係な親ディレクトリの設定ファイルを継承しません。 +シェルプロファイルや CI の環境変数に頼らず、プロジェクトごとの既定値を `.cdidx/config.json` または `.cdidxrc.json` ファイルとしてリポジトリにチェックインできます (#1571)。config に依存する command の実行前に、`cdidx` はカレントディレクトリから上方向に最初のプロジェクト設定ファイルを探索し、スキーマを検証してから既知のキーを scoped environment setting として注入します。これにより、process-global な環境変数を変更せずに、既存の環境変数コンシューマが同じ値を受け取れます。プロジェクト設定を使用しない static command(`license`、`--version`、help 形式、shell completion、および各 command の `--help`)は config 探索を省略するため、無関係なプロジェクト設定が不正でも利用できます。探索は `.git`、`.hg`、`.svn`、`cdidx.workspace.json`、`.cdidx-workspace.json` を含むディレクトリを確認した後で停止するため、子 workspace が無関係な親ディレクトリの設定ファイルを継承しません。 -優先順位は **CLI フラグ > 環境変数 > 設定ファイル > 組み込み既定値** です。設定ファイル由来の値は、対応する環境変数がプロセスで未設定の場合にのみ適用されるため、シェルや CI で既に export されている値が常に優先されます。設定 JSON はスキーマ検証前に 64 KiB と保守的なネスト深度の上限で検査されます。不正なファイル(無効な JSON、未知のキー、型違い、過度なネスト)は hard error として扱われ、cdidx はファイルパスと検出できた該当フィールドすべてを示して終了コード `1` で終了します。完全にバイパスしたい場合は `CDIDX_DISABLE_CONFIG_FILE=1` を設定してください。 +優先順位は **CLI フラグ > 環境変数 > 設定ファイル > 組み込み既定値** です。設定ファイル由来の値は、対応する環境変数がプロセスで未設定の場合にのみ適用されるため、シェルや CI で既に export されている値が常に優先されます。設定 JSON はスキーマ検証前に 64 KiB と保守的なネスト深度の上限で検査されます。config に依存する command では、不正なファイル(無効な JSON、未知のキー、型違い、過度なネスト)は hard error として扱われ、cdidx はファイルパスと検出できた該当フィールドすべてを示して終了コード `1` で終了します。JSON mode では human-only text を stderr に書く代わりに、`error_code: "E024_CONFIG_INVALID"` と `category: "configuration"` を持つ versioned command-error envelope を返します。完全にバイパスしたい場合は `CDIDX_DISABLE_CONFIG_FILE=1` を設定してください。 シークレットは意図的に**ファイルから読み込めません**。`CDIDX_GITHUB_TOKEN` / `CDIDX_MCP_AUTH_TOKEN` / `CDIDX_MCP_HTTP_TOKEN` は環境変数専用としており、トークンがバージョン管理に混入するのを防ぎます。 diff --git a/changelog.d/unreleased/4886.fixed.md b/changelog.d/unreleased/4886.fixed.md new file mode 100644 index 000000000..d17928e6a --- /dev/null +++ b/changelog.d/unreleased/4886.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 4886 +affected: + - src/CodeIndex/Cli/CliCommandMetadata.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/CdidxConfigFileTests.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **Static commands stay usable and config failures remain machine-readable under malformed project config (#4886)** — `license`, `--version`, help forms, shell completions, and subcommand help now skip unrelated project-config parsing, while config-dependent commands return the versioned `E024_CONFIG_INVALID` JSON error envelope instead of human-only stderr. + +## 日本語 + +- **不正なプロジェクト設定があっても static command と機械可読な config error を利用できるようになりました (#4886)** — `license`、`--version`、help 形式、shell completion、subcommand help は無関係な project-config parse を省略し、config に依存する command は human-only な stderr の代わりに versioned `E024_CONFIG_INVALID` JSON error envelope を返します。 diff --git a/src/CodeIndex/Cli/CliCommandMetadata.cs b/src/CodeIndex/Cli/CliCommandMetadata.cs index 8ab93e7c7..9ef968938 100644 --- a/src/CodeIndex/Cli/CliCommandMetadata.cs +++ b/src/CodeIndex/Cli/CliCommandMetadata.cs @@ -41,6 +41,18 @@ internal static class CliCommandMetadata internal static IReadOnlySet OptionalSubcommandCommands { get; } = new[] { "recipes", "suggestions" }.ToFrozenSet(StringComparer.Ordinal); + // These commands render process-static metadata and must not discover or parse + // project configuration. validate-config owns config loading itself so it can + // report malformed files through its command-specific contract. + // これらの command は process-static metadata を描画するため、project config を + // 探索・parse しない。validate-config は不正な file を command 固有契約で報告するため、 + // config loading を自身で所有する。 + internal static IReadOnlySet ProjectConfigIndependentCommands { get; } = + new[] { "help", "completions", "license" }.ToFrozenSet(StringComparer.Ordinal); + + internal static IReadOnlySet ProjectConfigSelfManagedCommands { get; } = + new[] { "validate-config" }.ToFrozenSet(StringComparer.Ordinal); + private static ReadOnlyCollection ReadOnly(params string[] values) => Array.AsReadOnly(values); } diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 9bfd383b2..b5dbb6e56 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -51,6 +51,8 @@ internal static partial class ProgramRunner private static readonly TimeSpan McpHttpDisposeTimeout = TimeSpan.FromSeconds(5); private static readonly HashSet NonLogGlobalOptionNames = CliFlagSchema.GetTopLevelGlobalOptionNames(includeLogOptions: false); + private static readonly HashSet TopLevelGlobalOptionNames = + CliFlagSchema.GetTopLevelGlobalOptionNames(includeLogOptions: true); private static readonly HashSet TopLevelValueOptionNames = CliFlagSchema.GetTopLevelValueOptionNames(); private static readonly AsyncLocal ScopedTimeProviderForTesting = new(); @@ -149,19 +151,35 @@ internal static int Run( using var recoveryInvocationScope = ExcerptRecoveryCommandFormatter.UseCurrentProcessInvocation(); appVersion ??= ConsoleUi.LoadVersion(); + jsonOptions ??= CreateDefaultJsonOptions(); + EnsureRedirectedStdoutUsesUtf8(); - // Load project-local `.cdidxrc.json` before anything else reads env vars so log - // location, debug mode, and MCP gates honor the file (#1571). Hard-fail on - // validation errors so silent typos cannot quietly change behavior. - // 環境変数を読む処理より先に `.cdidxrc.json` を読み込み、ログ位置 / debug / MCP ゲート - // などが config を反映できるようにする (#1571)。スキーマ違反は黙って無視せず exit する。 - var configResult = CdidxConfigFile.Load(configStartDirectory ?? Environment.CurrentDirectory); - if (configResult.Failed && !IsConfigShowCommand(args) && !IsValidateConfigCommand(args)) + // Resolve the command's config dependency before discovery. Static metadata + // commands never parse project config, while validate-config and config show + // load it through their own reporting contracts. Config-dependent commands + // still load before any environment consumer so log/debug/MCP settings apply. + // discovery 前に command の config 依存性を解決する。static metadata command は + // project config を parse せず、validate-config / config show は固有の報告契約で + // 自ら load する。依存 command は environment consumer より前に load し、 + // log / debug / MCP 設定を従来どおり反映する。 + var configDependency = ResolveProjectConfigDependency(args); + var configResult = configDependency == ProjectConfigDependency.Required + ? CdidxConfigFile.Load(configStartDirectory ?? Environment.CurrentDirectory) + : new CdidxConfigFile.LoadResult(ConfigPath: null, Error: null); + if (configResult.Failed) { - return CommandErrorWriter.Write( + var configCommand = ResolveProjectConfigCommandName(args); + var usage = ConsoleUi.GetUsageLine(configCommand) ?? "cdidx [options]"; + return CommandErrorWriter.WriteJsonOrHuman( + ContainsJsonOutputFlag(args), + jsonOptions, StripErrorPrefix(configResult.Error ?? "configuration file validation failed."), CommandExitCodes.UsageError, - $"fix or remove `{CdidxConfigFile.FileName}`, or set `{CdidxConfigFile.DisableEnvVar}=1` to bypass it."); + $"fix or remove the discovered config file, or set `{CdidxConfigFile.DisableEnvVar}=1` to bypass it.", + usage, + CommandErrorCodes.ConfigInvalid, + "configuration", + configCommand); } using var configEnvironment = CdidxEnvironment.Push(configResult.Settings, configResult.Sources); @@ -176,8 +194,6 @@ internal static int Run( using var globalToolLog = GlobalToolLog.TryStart(args, appVersion); if (configResult.Loaded) GlobalToolLog.Info($"config_file_loaded path={configResult.ConfigPath}"); - jsonOptions ??= CreateDefaultJsonOptions(); - EnsureRedirectedStdoutUsesUtf8(); var quiet = TryConsumeQuietFlag(ref args) || IsTruthyEnvironmentVariable(QuietEnvironmentVariable); using var quietScope = quiet ? QuietStderrScope.Start() : null; @@ -288,34 +304,57 @@ internal static int Run( } } - private static bool IsConfigShowCommand(IReadOnlyList args) + private enum ProjectConfigDependency { - var commandIndex = 0; - while (commandIndex < args.Count && args[commandIndex].StartsWith("--", StringComparison.Ordinal)) + Required, + Independent, + SelfManaged, + } + + private static ProjectConfigDependency ResolveProjectConfigDependency(IReadOnlyList args) + { + var commandIndex = FindProjectConfigCommandIndex(args); + if (commandIndex >= args.Count) + return ProjectConfigDependency.Independent; + + var rawCommand = args[commandIndex]; + if (rawCommand is "--help" or "-h" or "--help-all" or "--help-extended" + or "help-all" or "help-extended" or "--help-flags" + or "--version" or "-V" or "--license" or "--completions") { - var option = args[commandIndex]; - var optionName = option.Split('=', 2)[0]; - commandIndex++; - if (!option.Contains('=', StringComparison.Ordinal) - && TopLevelValueOptionNames.Contains(optionName) - && commandIndex < args.Count) - { - commandIndex++; - } + return ProjectConfigDependency.Independent; } - return commandIndex + 1 < args.Count - && string.Equals(args[commandIndex], "config", StringComparison.Ordinal) - && string.Equals(args[commandIndex + 1], "show", StringComparison.Ordinal); + var command = CliCommandCatalog.NormalizePublicCommandName(rawCommand); + if (CliCommandMetadata.ProjectConfigIndependentCommands.Contains(command)) + return ProjectConfigDependency.Independent; + if (CliCommandMetadata.ProjectConfigSelfManagedCommands.Contains(command)) + return ProjectConfigDependency.SelfManaged; + if (command == "config" + && commandIndex + 1 < args.Count + && string.Equals(args[commandIndex + 1], "show", StringComparison.Ordinal)) + { + return ProjectConfigDependency.SelfManaged; + } + if (commandIndex + 1 < args.Count + && ArgHelper.WantsHelp(args.Skip(commandIndex + 1).ToArray())) + { + return ProjectConfigDependency.Independent; + } + + return ProjectConfigDependency.Required; } - private static bool IsValidateConfigCommand(IReadOnlyList args) + private static int FindProjectConfigCommandIndex(IReadOnlyList args) { var commandIndex = 0; - while (commandIndex < args.Count && args[commandIndex].StartsWith("--", StringComparison.Ordinal)) + while (commandIndex < args.Count) { var option = args[commandIndex]; var optionName = option.Split('=', 2)[0]; + if (!TopLevelGlobalOptionNames.Contains(optionName)) + break; + commandIndex++; if (!option.Contains('=', StringComparison.Ordinal) && TopLevelValueOptionNames.Contains(optionName) @@ -325,8 +364,23 @@ private static bool IsValidateConfigCommand(IReadOnlyList args) } } - return commandIndex < args.Count - && string.Equals(args[commandIndex], "validate-config", StringComparison.Ordinal); + return commandIndex; + } + + private static string ResolveProjectConfigCommandName(IReadOnlyList args) + { + var commandIndex = FindProjectConfigCommandIndex(args); + if (commandIndex >= args.Count) + return "unknown"; + + var rawCommand = args[commandIndex]; + if (IsProjectPathArg(rawCommand)) + return "index"; + + var command = CliCommandCatalog.NormalizePublicCommandName(rawCommand); + return CliCommandMetadata.PublicCommandNames.Contains(command, StringComparer.Ordinal) + ? command + : "unknown"; } diff --git a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs index 0dd1e49e0..d99323c63 100644 --- a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs +++ b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs @@ -847,14 +847,108 @@ public void Run_MalformedConfigFile_FailsWithUsageError() { File.WriteAllText(Path.Combine(dir, ".cdidxrc.json"), "{ not-json"); - var (exitCode, _, stderr) = CaptureConsole(() => ProgramRunner.Run( + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( ["definitely-not-a-command"], appVersion: "1.21.0", configStartDirectory: dir)); Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Empty(stdout); + Assert.Contains($"Error [{CommandErrorCodes.ConfigInvalid}]", stderr); Assert.Contains("Invalid JSON", stderr); Assert.Contains("CDIDX_DISABLE_CONFIG_FILE", stderr); + Assert.Contains("Usage:", stderr); + } + finally { TestProjectHelper.DeleteDirectory(dir); } + } + + [Fact] + public void Run_StaticCommandsIgnoreMalformedSupportedConfigs_Issue4886() + { + var root = CreateTempDir(); + try + { + var configRelativePaths = new[] + { + CdidxConfigFile.ProjectConfigRelativePath, + CdidxConfigFile.FileName, + }; + var commandCases = new (string Name, string[] Args)[] + { + ("license", ["license", "--json"]), + ("version_with_global_flag", ["--quiet", "--version", "--json"]), + ("help", ["help", "status"]), + ("subcommand_help", ["index", "--help"]), + ("completions", ["completions", "bash"]), + }; + + for (var configIndex = 0; configIndex < configRelativePaths.Length; configIndex++) + { + var project = Path.Combine(root, $"project-{configIndex}"); + Directory.CreateDirectory(project); + var configPath = Path.Combine(project, configRelativePaths[configIndex]); + Directory.CreateDirectory(Path.GetDirectoryName(configPath)!); + File.WriteAllText(configPath, "{ not-json"); + + foreach (var commandCase in commandCases) + { + var result = CaptureConsole(() => ProgramRunner.Run( + commandCase.Args, + appVersion: "1.40.3", + configStartDirectory: project)); + + Assert.True( + result.ExitCode == CommandExitCodes.Success, + $"{commandCase.Name} was blocked by {configRelativePaths[configIndex]}: {result.Stderr}"); + Assert.DoesNotContain("Invalid JSON", result.Stdout, StringComparison.Ordinal); + Assert.DoesNotContain("Invalid JSON", result.Stderr, StringComparison.Ordinal); + } + } + } + finally { TestProjectHelper.DeleteDirectory(root); } + } + + [Fact] + public void Run_ConfigDependentCommandsReturnTypedJsonForMalformedConfig_Issue4886() + { + var dir = CreateTempDir(); + try + { + var configPath = Path.Combine(dir, CdidxConfigFile.ProjectConfigRelativePath); + Directory.CreateDirectory(Path.GetDirectoryName(configPath)!); + File.WriteAllText(configPath, "{ not-json"); + + var commandCases = new (string Command, string[] Args)[] + { + ("index", ["index", dir, "--json"]), + ("search", ["search", "needle", "--json"]), + ("config", ["config", "unknown", "--json"]), + }; + + foreach (var commandCase in commandCases) + { + var result = CaptureConsole(() => ProgramRunner.Run( + commandCase.Args, + appVersion: "1.40.3", + configStartDirectory: dir)); + + Assert.Equal(CommandExitCodes.UsageError, result.ExitCode); + Assert.Empty(result.Stderr); + using var document = JsonDocument.Parse(result.Stdout); + var payload = document.RootElement; + Assert.Equal("1", payload.GetProperty("api_version").GetString()); + Assert.Equal("error", payload.GetProperty("status").GetString()); + Assert.Equal(CommandErrorCodes.ConfigInvalid, payload.GetProperty("error_code").GetString()); + Assert.Equal("configuration", payload.GetProperty("category").GetString()); + Assert.Equal(commandCase.Command, payload.GetProperty("command").GetString()); + Assert.Equal(CommandExitCodes.UsageError, payload.GetProperty("exit_code").GetInt32()); + Assert.Contains("Invalid JSON", payload.GetProperty("message").GetString(), StringComparison.Ordinal); + Assert.Contains( + CdidxConfigFile.DisableEnvVar, + payload.GetProperty("hint").GetString(), + StringComparison.Ordinal); + Assert.False(string.IsNullOrWhiteSpace(payload.GetProperty("usage").GetString())); + } } finally { TestProjectHelper.DeleteDirectory(dir); } } From b1a4f0d98d109a129589d6ce914885ba1525738e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 30 Jul 2026 12:32:27 +0900 Subject: [PATCH 2/3] Address adversarial review findings for #4886 --- DEVELOPER_GUIDE.md | 15 +-- src/CodeIndex/Cli/CliCommandMetadata.cs | 8 +- src/CodeIndex/Cli/ProgramRunner.cs | 78 +++++++++++---- tests/CodeIndex.Tests/CdidxConfigFileTests.cs | 95 +++++++++++++++++++ 4 files changed, 166 insertions(+), 30 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 9fea074e1..b44b7f41d 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -358,9 +358,9 @@ and leave stderr empty. Human failures write the matching coded `Error`, Resolve project-config dependency before calling `CdidxConfigFile.Load`. Process-static commands (`license`, `--version`, help forms, completions, and subcommand help) must not discover or parse project config. `validate-config` -and `config show` own their config reads so they can report invalid files through -their command-specific contracts. Other commands load config before environment -consumers run; malformed config in JSON mode uses `E024_CONFIG_INVALID` / +and `config show` own malformed-config reporting so they retain their +command-specific contracts while valid settings still reach environment consumers. +Other commands load config before those consumers run; malformed config in JSON mode uses `E024_CONFIG_INVALID` / `configuration` in the shared command-error envelope. | Failure class | Exit code | Error code | Category | @@ -3754,10 +3754,11 @@ sanitization し、上限を適用してから merge します。JSON の失敗 `CdidxConfigFile.Load` を呼ぶ前に project-config 依存性を解決します。process-static な command(`license`、`--version`、help 形式、completion、subcommand help)は project -config を探索・parse しません。`validate-config` と `config show` は不正な file を -command 固有契約で報告できるよう config read を自身で所有します。それ以外の command -は environment consumer の実行前に config を load し、JSON mode の不正 config は共通の -command-error envelope で `E024_CONFIG_INVALID` / `configuration` を返します。 +config を探索・parse しません。`validate-config` と `config show` は有効な設定を +environment consumer に適用しつつ、不正な file の reporting を command 固有契約で +所有します。それ以外の command は environment consumer の実行前に config を load し、 +JSON mode の不正 config は共通の command-error envelope で +`E024_CONFIG_INVALID` / `configuration` を返します。 | failure class | exit code | error code | category | |---|---:|---|---| diff --git a/src/CodeIndex/Cli/CliCommandMetadata.cs b/src/CodeIndex/Cli/CliCommandMetadata.cs index 9ef968938..7db0343e4 100644 --- a/src/CodeIndex/Cli/CliCommandMetadata.cs +++ b/src/CodeIndex/Cli/CliCommandMetadata.cs @@ -42,11 +42,11 @@ internal static class CliCommandMetadata new[] { "recipes", "suggestions" }.ToFrozenSet(StringComparer.Ordinal); // These commands render process-static metadata and must not discover or parse - // project configuration. validate-config owns config loading itself so it can - // report malformed files through its command-specific contract. + // project configuration. validate-config owns malformed-config reporting so it + // can preserve its command-specific contract. // これらの command は process-static metadata を描画するため、project config を - // 探索・parse しない。validate-config は不正な file を command 固有契約で報告するため、 - // config loading を自身で所有する。 + // 探索・parse しない。validate-config は command 固有契約を保つため、不正な config の + // reporting を自身で所有する。 internal static IReadOnlySet ProjectConfigIndependentCommands { get; } = new[] { "help", "completions", "license" }.ToFrozenSet(StringComparer.Ordinal); diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index b5dbb6e56..059975698 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -156,22 +156,22 @@ internal static int Run( // Resolve the command's config dependency before discovery. Static metadata // commands never parse project config, while validate-config and config show - // load it through their own reporting contracts. Config-dependent commands - // still load before any environment consumer so log/debug/MCP settings apply. + // report malformed files through their own contracts. Commands that can consume + // config still load before environment consumers so valid log/debug/MCP settings apply. // discovery 前に command の config 依存性を解決する。static metadata command は - // project config を parse せず、validate-config / config show は固有の報告契約で - // 自ら load する。依存 command は environment consumer より前に load し、 - // log / debug / MCP 設定を従来どおり反映する。 + // project config を parse せず、validate-config / config show は不正な file を固有の + // 契約で報告する。config を利用できる command は environment consumer より前に + // load し、有効な log / debug / MCP 設定を従来どおり反映する。 var configDependency = ResolveProjectConfigDependency(args); - var configResult = configDependency == ProjectConfigDependency.Required - ? CdidxConfigFile.Load(configStartDirectory ?? Environment.CurrentDirectory) - : new CdidxConfigFile.LoadResult(ConfigPath: null, Error: null); - if (configResult.Failed) + var configResult = configDependency == ProjectConfigDependency.Independent + ? new CdidxConfigFile.LoadResult(ConfigPath: null, Error: null) + : CdidxConfigFile.Load(configStartDirectory ?? Environment.CurrentDirectory); + if (configDependency == ProjectConfigDependency.Required && configResult.Failed) { var configCommand = ResolveProjectConfigCommandName(args); var usage = ConsoleUi.GetUsageLine(configCommand) ?? "cdidx [options]"; return CommandErrorWriter.WriteJsonOrHuman( - ContainsJsonOutputFlag(args), + RequestsProjectConfigJsonError(args), jsonOptions, StripErrorPrefix(configResult.Error ?? "configuration file validation failed."), CommandExitCodes.UsageError, @@ -330,9 +330,10 @@ private static ProjectConfigDependency ResolveProjectConfigDependency(IReadOnlyL return ProjectConfigDependency.Independent; if (CliCommandMetadata.ProjectConfigSelfManagedCommands.Contains(command)) return ProjectConfigDependency.SelfManaged; + var nestedCommandIndex = SkipProjectConfigGlobalOptions(args, commandIndex + 1); if (command == "config" - && commandIndex + 1 < args.Count - && string.Equals(args[commandIndex + 1], "show", StringComparison.Ordinal)) + && nestedCommandIndex < args.Count + && string.Equals(args[nestedCommandIndex], "show", StringComparison.Ordinal)) { return ProjectConfigDependency.SelfManaged; } @@ -347,7 +348,12 @@ private static ProjectConfigDependency ResolveProjectConfigDependency(IReadOnlyL private static int FindProjectConfigCommandIndex(IReadOnlyList args) { - var commandIndex = 0; + return SkipProjectConfigGlobalOptions(args, startIndex: 0); + } + + private static int SkipProjectConfigGlobalOptions(IReadOnlyList args, int startIndex) + { + var commandIndex = startIndex; while (commandIndex < args.Count) { var option = args[commandIndex]; @@ -367,6 +373,43 @@ private static int FindProjectConfigCommandIndex(IReadOnlyList args) return commandIndex; } + private static bool RequestsProjectConfigJsonError(string[] args) + { + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (arg == "--") + break; + if (GetQueryCommandTokenRole(args, i) == QueryCommandTokenRole.CommandOptionValue) + continue; + + if (arg == "--json" + || arg.StartsWith("--json=", StringComparison.Ordinal) + || arg is "--json-summary" or "--results-only" or "--compact" + || arg == JsonEnvelopeWrapper.EnvelopeFlag) + { + return true; + } + + if (arg.StartsWith("--format=", StringComparison.Ordinal) + && CliOutputFormatCapabilities.TryGet(arg["--format=".Length..], out var inlineCapability) + && inlineCapability.IsJsonContract) + { + return true; + } + + if (arg == "--format" + && i + 1 < args.Length + && CliOutputFormatCapabilities.TryGet(args[i + 1], out var separatedCapability) + && separatedCapability.IsJsonContract) + { + return true; + } + } + + return false; + } + private static string ResolveProjectConfigCommandName(IReadOnlyList args) { var commandIndex = FindProjectConfigCommandIndex(args); @@ -374,13 +417,10 @@ private static string ResolveProjectConfigCommandName(IReadOnlyList args return "unknown"; var rawCommand = args[commandIndex]; - if (IsProjectPathArg(rawCommand)) - return "index"; + if (CliCommandCatalog.TryResolvePublicCommand(rawCommand, out var command)) + return command; - var command = CliCommandCatalog.NormalizePublicCommandName(rawCommand); - return CliCommandMetadata.PublicCommandNames.Contains(command, StringComparer.Ordinal) - ? command - : "unknown"; + return IsProjectPathArg(rawCommand) ? "index" : "unknown"; } diff --git a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs index d99323c63..4ff1c13a7 100644 --- a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs +++ b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs @@ -922,6 +922,7 @@ public void Run_ConfigDependentCommandsReturnTypedJsonForMalformedConfig_Issue48 { ("index", ["index", dir, "--json"]), ("search", ["search", "needle", "--json"]), + ("search", ["search", "needle", "--format", "json"]), ("config", ["config", "unknown", "--json"]), }; @@ -949,6 +950,15 @@ public void Run_ConfigDependentCommandsReturnTypedJsonForMalformedConfig_Issue48 StringComparison.Ordinal); Assert.False(string.IsNullOrWhiteSpace(payload.GetProperty("usage").GetString())); } + + var optionValueResult = CaptureConsole(() => ProgramRunner.Run( + ["search", "--query", "--json"], + appVersion: "1.40.3", + configStartDirectory: dir)); + + Assert.Equal(CommandExitCodes.UsageError, optionValueResult.ExitCode); + Assert.Empty(optionValueResult.Stdout); + Assert.Contains($"Error [{CommandErrorCodes.ConfigInvalid}]", optionValueResult.Stderr); } finally { TestProjectHelper.DeleteDirectory(dir); } } @@ -1161,6 +1171,91 @@ public void RunValidate_InvalidConfigJson_ReturnsStructuredErrorThroughProgramRu } } + [Fact] + public void Run_SelfManagedCommandsApplyValidConfigBeforeDispatch_Issue4886() + { + var dir = TestProjectHelper.CreateTempProject("cdidx_config_self_managed_4886"); + var previous = Environment.CurrentDirectory; + var sourceEnvName = CdidxConfigFile.ConfigSourceEnvironmentVariablePrefix + MetricsSink.EnvVarName; + using var env = EnvironmentVariableScope.Capture( + MetricsSink.EnvVarName, + sourceEnvName, + CdidxConfigFile.DisableEnvVar); + env.Set(MetricsSink.EnvVarName, null); + env.Set(sourceEnvName, null); + env.Set(CdidxConfigFile.DisableEnvVar, null); + try + { + var metricsPath = Path.Combine(dir, "metrics.jsonl"); + File.WriteAllText( + Path.Combine(dir, CdidxConfigFile.FileName), + """{ "metrics_path": "./metrics.jsonl" }"""); + Environment.CurrentDirectory = dir; + + var validateResult = ConsoleCapture.Capture(() => ProgramRunner.Run( + ["validate-config", "--json"], + _jsonOptions, + appVersion: "test")); + var showResult = ConsoleCapture.Capture(() => ProgramRunner.Run( + ["config", "show", "--json"], + _jsonOptions, + appVersion: "test")); + + Assert.Equal(CommandExitCodes.Success, validateResult.ExitCode); + Assert.Equal(CommandExitCodes.Success, showResult.ExitCode); + Assert.Empty(validateResult.Stderr); + Assert.Empty(showResult.Stderr); + var metrics = File.ReadAllLines(metricsPath); + Assert.Contains(metrics, line => line.Contains("\"tool\":\"validate-config\"", StringComparison.Ordinal)); + Assert.Contains(metrics, line => line.Contains("\"tool\":\"config\"", StringComparison.Ordinal)); + } + finally + { + Environment.CurrentDirectory = previous; + TestProjectHelper.DeleteDirectory(dir); + } + } + + [Fact] + public void Run_MalformedConfigRoutingHonorsNestedGlobalFlagsAndCommandIdentity_Issue4886() + { + var dir = TestProjectHelper.CreateTempProject("cdidx_config_routing_4886"); + var previous = Environment.CurrentDirectory; + using var env = EnvironmentVariableScope.Capture(CdidxConfigFile.DisableEnvVar); + env.Set(CdidxConfigFile.DisableEnvVar, null); + try + { + File.WriteAllText(Path.Combine(dir, CdidxConfigFile.FileName), "{ invalid json"); + Directory.CreateDirectory(Path.Combine(dir, "search")); + Environment.CurrentDirectory = dir; + + var showResult = ConsoleCapture.Capture(() => ProgramRunner.Run( + ["config", "--quiet", "show", "--json"], + _jsonOptions, + appVersion: "test")); + var searchResult = ConsoleCapture.Capture(() => ProgramRunner.Run( + ["search", "needle", "--json"], + _jsonOptions, + appVersion: "test")); + + Assert.Equal(CommandExitCodes.Success, showResult.ExitCode); + Assert.Empty(showResult.Stderr); + using (var showDocument = JsonDocument.Parse(showResult.Stdout)) + Assert.Equal("invalid", showDocument.RootElement.GetProperty("config_file").GetProperty("status").GetString()); + + Assert.Equal(CommandExitCodes.UsageError, searchResult.ExitCode); + Assert.Empty(searchResult.Stderr); + using var searchDocument = JsonDocument.Parse(searchResult.Stdout); + Assert.Equal("search", searchDocument.RootElement.GetProperty("command").GetString()); + Assert.StartsWith("cdidx search ", searchDocument.RootElement.GetProperty("usage").GetString(), StringComparison.Ordinal); + } + finally + { + Environment.CurrentDirectory = previous; + TestProjectHelper.DeleteDirectory(dir); + } + } + [Fact] public void Run_ConfigFileAppliesScopedDefaultsWithoutMutatingEnvironment() { From eab2aac2cde2495718ba5d609aa722efc77ed9da Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 30 Jul 2026 14:50:45 +0900 Subject: [PATCH 3/3] Close final review findings for #4886 --- src/CodeIndex/Cli/ProgramRunner.cs | 18 ++++++++----- tests/CodeIndex.Tests/CdidxConfigFileTests.cs | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 059975698..383fe4308 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -326,6 +326,11 @@ private static ProjectConfigDependency ResolveProjectConfigDependency(IReadOnlyL } var command = CliCommandCatalog.NormalizePublicCommandName(rawCommand); + if (commandIndex + 1 < args.Count + && ArgHelper.WantsHelp(args.Skip(commandIndex + 1).ToArray())) + { + return ProjectConfigDependency.Independent; + } if (CliCommandMetadata.ProjectConfigIndependentCommands.Contains(command)) return ProjectConfigDependency.Independent; if (CliCommandMetadata.ProjectConfigSelfManagedCommands.Contains(command)) @@ -337,11 +342,6 @@ private static ProjectConfigDependency ResolveProjectConfigDependency(IReadOnlyL { return ProjectConfigDependency.SelfManaged; } - if (commandIndex + 1 < args.Count - && ArgHelper.WantsHelp(args.Skip(commandIndex + 1).ToArray())) - { - return ProjectConfigDependency.Independent; - } return ProjectConfigDependency.Required; } @@ -375,7 +375,13 @@ private static int SkipProjectConfigGlobalOptions(IReadOnlyList args, in private static bool RequestsProjectConfigJsonError(string[] args) { - for (var i = 0; i < args.Length; i++) + // Leading global options can legally take values that look like command JSON + // flags (for example `--metrics --json`). Consume those pairs before detecting + // the selected command's output contract. + // 先頭の global option は command の JSON flag に見える値(例: + // `--metrics --json`)を正当な値として取れるため、command の output 契約を + // 判定する前に option/value の組を消費する。 + for (var i = FindProjectConfigCommandIndex(args); i < args.Length; i++) { var arg = args[i]; if (arg == "--") diff --git a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs index 4ff1c13a7..eab89d6f9 100644 --- a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs +++ b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs @@ -879,6 +879,8 @@ public void Run_StaticCommandsIgnoreMalformedSupportedConfigs_Issue4886() ("version_with_global_flag", ["--quiet", "--version", "--json"]), ("help", ["help", "status"]), ("subcommand_help", ["index", "--help"]), + ("validate_config_help", ["validate-config", "--help"]), + ("config_show_help", ["config", "show", "--help"]), ("completions", ["completions", "bash"]), }; @@ -959,6 +961,15 @@ public void Run_ConfigDependentCommandsReturnTypedJsonForMalformedConfig_Issue48 Assert.Equal(CommandExitCodes.UsageError, optionValueResult.ExitCode); Assert.Empty(optionValueResult.Stdout); Assert.Contains($"Error [{CommandErrorCodes.ConfigInvalid}]", optionValueResult.Stderr); + + var globalOptionValueResult = CaptureConsole(() => ProgramRunner.Run( + ["--metrics", "--json", "search", "needle"], + appVersion: "1.40.3", + configStartDirectory: dir)); + + Assert.Equal(CommandExitCodes.UsageError, globalOptionValueResult.ExitCode); + Assert.Empty(globalOptionValueResult.Stdout); + Assert.Contains($"Error [{CommandErrorCodes.ConfigInvalid}]", globalOptionValueResult.Stderr); } finally { TestProjectHelper.DeleteDirectory(dir); } } @@ -1208,6 +1219,20 @@ public void Run_SelfManagedCommandsApplyValidConfigBeforeDispatch_Issue4886() var metrics = File.ReadAllLines(metricsPath); Assert.Contains(metrics, line => line.Contains("\"tool\":\"validate-config\"", StringComparison.Ordinal)); Assert.Contains(metrics, line => line.Contains("\"tool\":\"config\"", StringComparison.Ordinal)); + + var metricsCountBeforeHelp = metrics.Length; + var validateHelpResult = ConsoleCapture.Capture(() => ProgramRunner.Run( + ["validate-config", "--help"], + _jsonOptions, + appVersion: "test")); + var showHelpResult = ConsoleCapture.Capture(() => ProgramRunner.Run( + ["config", "show", "--help"], + _jsonOptions, + appVersion: "test")); + + Assert.Equal(CommandExitCodes.Success, validateHelpResult.ExitCode); + Assert.Equal(CommandExitCodes.Success, showHelpResult.ExitCode); + Assert.Equal(metricsCountBeforeHelp, File.ReadAllLines(metricsPath).Length); } finally {