From a6c387158be442f839818d0f1a5872b74e0f8d7d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 21 Aug 2026 10:34:07 +0900 Subject: [PATCH 1/3] Reject invalid index numeric options (#5097) --- DEVELOPER_GUIDE.md | 23 ++ TESTING_GUIDE.md | 4 + changelog.d/unreleased/5097.fixed.md | 25 ++ src/CodeIndex/Cli/CliFlagSchema.cs | 14 +- .../Cli/IndexCommandRunner.DryRun.cs | 6 +- .../Cli/IndexCommandRunner.FullScan.cs | 1 + src/CodeIndex/Cli/IndexCommandRunner.Parse.cs | 210 ++++++----- .../Cli/IndexCommandRunner.Update.cs | 4 +- src/CodeIndex/Cli/IndexCommandRunner.cs | 1 + src/CodeIndex/Cli/IndexWatchJsonContracts.cs | 2 + .../Cli/IndexWatchRunner.Reporting.cs | 2 + .../IndexCommandRunnerTests.cs | 346 ++++++++++++++---- 12 files changed, 469 insertions(+), 169 deletions(-) create mode 100644 changelog.d/unreleased/5097.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 670cef7b6..05e1ed32e 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -269,6 +269,18 @@ Directory scan / shared path filter (built-in skip lists + `.gitignore` / `.cdid → Populate FTS5 index ``` +Explicit numeric `index` options are validated before database setup or worker +startup. The inclusive ranges are `--parallelism` 1..16, +`--max-file-bytes` 1..2147483647 bytes, `--max-symbols-per-file` 1..50000, +`--max-references-per-file` 1..1000000, `--dry-run-path-limit` 1..1000000, +`--watch-pending-path-limit` 1..262144, and `--debounce` 0..60000 ms. Any +explicit zero outside those ranges, negative, overflowed, non-numeric, or +over-limit value returns structured `E010_USAGE_ERROR` output and exit code 2; +an invalid occurrence is not hidden by a later duplicate. For compatibility, +invalid `CDIDX_INDEX_PARALLELISM`, `CDIDX_MAX_FILE_BYTES`, and +`CDIDX_INDEX_WATCH_PENDING_PATH_LIMIT` values still warn and fall back or clamp, +but JSON warnings identify the environment source and the effective value. + Every loaded text file is normalized and analyzed in one UTF-16 walk. The resulting `NormalizedContentFacts` is the shared source for normalized line count, oversized-line and FTS-token diagnostics, conflict-marker detection, @@ -4209,6 +4221,17 @@ query コマンドも JSON profile block 用の `--profile` と command-scoped p → FTS5インデックス反映 ``` +明示された数値 `index` オプションは、database setup や worker 起動より前に検証します。 +許容範囲(両端を含む)は `--parallelism` が 1..16、`--max-file-bytes` が +1..2147483647 byte、`--max-symbols-per-file` が 1..50000、 +`--max-references-per-file` が 1..1000000、`--dry-run-path-limit` が +1..1000000、`--watch-pending-path-limit` が 1..262144、`--debounce` が +0..60000 ms です。範囲外のゼロ、負数、overflow、非数値、上限超過を明示すると、 +構造化された `E010_USAGE_ERROR` と exit code 2 を返し、後続の重複指定が有効でも先行する +不正値を隠しません。互換性のため、不正な `CDIDX_INDEX_PARALLELISM`、 +`CDIDX_MAX_FILE_BYTES`、`CDIDX_INDEX_WATCH_PENDING_PATH_LIMIT` は引き続き警告して +fallback または clamp しますが、JSON warning には環境変数由来であることと実効値を含めます。 + 読み込んだ text file は UTF-16 上の1回の走査で正規化と解析を行います。得られた `NormalizedContentFacts` を、正規化後の行数、長すぎる行 / FTS token の診断、conflict marker、replacement character の件数 / 行、80行・10行 overlap の chunk slice に対する diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 90fb66537..c30bf2cb7 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -779,6 +779,8 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result - `.github/scripts/configure-windows-test-host.ps1` The `dotnet.yml` and `release.yml` Windows lanes share temp pinning and Defender exclusion setup here so both workflows keep the same test-host performance assumptions. General `TMP` / `TEMP` point to the runner's fast `RUNNER_TEMP\cdidx-temp` storage. Executable plugin, hook, and Git fixtures instead use `USERPROFILE\cdidx-trusted-test-temp`, whose protected current-user ACL and trusted ancestor chain satisfy the production executable-boundary contract; the script publishes this separate root as `CDIDX_TEST_TRUSTED_TEMP_ROOT`. Do not move ordinary SQLite or filesystem fixtures into that protected root, because placing the entire suite on the system drive materially increases Windows runtime. The script includes both roots in its normalized, de-duplicated Defender audit. When the `WinDefend` service is running, it submits the resulting string array in one `Add-MpPreference` invocation, reads Defender preferences back, and fails if any path is missing. When the service is stopped or unavailable, exclusions are irrelevant and the script emits a warning while continuing host setup; other Defender configuration or verification failures remain fatal. Update `CiWorkflowTests` when changing this split, batching, availability, audit, verification, or workflow call contract. - The `dotnet.yml` SDK setup has one conditional retry for transient SDK download failures. Keep the first attempt marked `continue-on-error` only while the retry is guarded by its failed outcome, so a second failure still fails the job. +- `IndexCommandRunnerTests.cs` + Numeric `index` option coverage owns inclusive boundaries, zero/negative/overflow/non-numeric forms, duplicate precedence, text and JSON `E010_USAGE_ERROR` output before database mutation, and structured environment fallback warnings with source/effective-value provenance. Keep every numeric option in the shared matrix when its range or parsing changes. - `DbRecoveryTests.cs` Database corruption recovery and graceful degradation behavior. Filesystem setup failures for `cdidx index` (read-only DB files and unwritable DB parent directories) are covered in `IndexCommandRunnerTests.cs` so they exercise the same CLI JSON/stderr boundary users see. - `Issue4857ManagedRestoreBackupTests.cs` @@ -1865,6 +1867,8 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" primary の `dotnet publish` 出力と `CodeIndex` build artifact は main branch への push または手動 dispatch でだけ materialize し、pull request では作成しません。coverage upload は `TestResults/**/coverage.cobertura.xml` が存在するときだけ起動し、そのfileをfailure向け`TestResults` artifactから除外して、TRX・text log・その他のXML blame evidenceを残しながらcoverageを1回だけ保存します。 - `.github/scripts/configure-windows-test-host.ps1` `dotnet.yml` と `release.yml` の Windows lane は、temp 固定と Defender 除外 setup をこのスクリプトで共有します。通常の `TMP` / `TEMP` は runner の高速な `RUNNER_TEMP\cdidx-temp` を使います。実行可能な plugin / hook / Git fixture だけは `USERPROFILE\cdidx-trusted-test-temp` を使い、current-user 限定の protected ACL と trusted な祖先 chain で production の executable-boundary contract を満たします。この専用 root は `CDIDX_TEST_TRUSTED_TEMP_ROOT` として helper へ渡します。Windows の実行時間を大きく増やすため、通常の SQLite / filesystem fixture を protected root へ移してはいけません。スクリプトは両 root を含む候補 path を正規化・重複排除して監査表示します。`WinDefend` service が稼働中なら、生成した string array を1回の `Add-MpPreference` 呼び出しで登録し、Defender preference を読み戻して欠けた path があれば失敗します。service が停止中または利用不能なら除外は意味を持たないため、warning を出して host setup を継続します。それ以外の Defender 設定失敗や検証失敗は引き続き fatal です。この split、batching、availability、audit、verification、または workflow 呼び出し contract を変更するときは `CiWorkflowTests` も更新してください。 +- `IndexCommandRunnerTests.cs` + 数値 `index` オプションの coverage は、両端を含む境界値、ゼロ / 負数 / overflow / 非数値、重複指定の優先順位、database mutation 前の text / JSON `E010_USAGE_ERROR`、および source / 実効値の provenance を持つ構造化環境変数 fallback warning を扱います。範囲や parse を変更するときは、共有 matrix にすべての数値オプションを残してください。 - `DbRecoveryTests.cs` DB破損からの復旧とグレースフル劣化のテスト。`cdidx index` の filesystem setup failure(read-only DB file や書き込み不可の DB 親ディレクトリ)は、ユーザーが見る CLI JSON/stderr 境界を通すため `IndexCommandRunnerTests.cs` で扱います。 - `Issue4857ManagedRestoreBackupTests.cs` diff --git a/changelog.d/unreleased/5097.fixed.md b/changelog.d/unreleased/5097.fixed.md new file mode 100644 index 000000000..30d69c221 --- /dev/null +++ b/changelog.d/unreleased/5097.fixed.md @@ -0,0 +1,25 @@ +--- +category: fixed +issues: + - 5097 +affected: + - src/CodeIndex/Cli/IndexCommandRunner.Parse.cs + - src/CodeIndex/Cli/IndexCommandRunner.cs + - src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs + - src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs + - src/CodeIndex/Cli/IndexCommandRunner.Update.cs + - src/CodeIndex/Cli/IndexWatchJsonContracts.cs + - src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Invalid explicit numeric `index` options now fail before database setup (#5097)** — zero outside an option's inclusive range, negative, overflowed, non-numeric, and over-limit CLI values return structured `E010_USAGE_ERROR` output instead of warning and silently using a fallback. Environment-variable compatibility fallbacks remain warnings and now report their source and effective value in JSON output. + +## 日本語 + +- **明示された不正な数値 `index` オプションを database setup 前に拒否するようになりました (#5097)** — オプションの許容範囲外のゼロ、負数、overflow、非数値、上限超過の CLI 値は、警告後に暗黙の fallback を使わず、構造化された `E010_USAGE_ERROR` を返します。環境変数の互換 fallback は警告として維持し、JSON 出力に source と実効値を含めます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 23b915553..95640c01e 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -649,7 +649,7 @@ private static IReadOnlyList BuildAll() Safety = CliOptionSafety.Preview, }, new() { Name = "--show-paths", Description = "Show resolved local paths in maintenance diagnostics, recovery commands, or status --config output", PrimaryCommands = Set(RecoveryPathCommands.Concat(["index", "backfill-fold", "optimize", "vacuum", "db", "status"]).ToArray()) }, - new() { Name = "--dry-run-path-limit", ValuePlaceholder = "", Description = "Dry run only: candidate path processing limit before truncated lower-bound estimates", PrimaryCommands = Set("index") }, + new() { Name = "--dry-run-path-limit", ValuePlaceholder = "", Description = "Dry run only: candidate path processing limit (1..1000000); explicit invalid values fail before database setup", PrimaryCommands = Set("index") }, new() { Name = "--checkpoint", Description = "Create a DB checkpoint even when backfill preflight is already complete", PrimaryCommands = Set("backfill-fold") }, new() { Name = "--no-checkpoint", Description = "Skip the automatic DB checkpoint before a required backfill mutation", PrimaryCommands = Set("backfill-fold") }, new() @@ -668,17 +668,17 @@ private static IReadOnlyList BuildAll() Safety = CliOptionSafety.Override, }, new() { Name = "--duration-format", ValueDomain = Values(["auto", "seconds", "hms"]), Description = "Index elapsed time display format", PrimaryCommands = Set("index") }, - new() { Name = "--max-file-bytes", ValuePlaceholder = "", Description = "Override the per-file indexing size limit", PrimaryCommands = Set("index") }, - new() { Name = "--max-symbols-per-file", ValuePlaceholder = "", Description = "Skip file content, symbols, and references when one file emits too many symbols (max 50000)", PrimaryCommands = Set("index") }, - new() { Name = "--max-references-per-file", ValuePlaceholder = "", Description = "Skip references when one file emits too many references (max 1000000)", PrimaryCommands = Set("index") }, - new() { Name = "--parallelism", ValuePlaceholder = "", Description = "Full-scan and authoritative C# update extraction worker count (default: CPU count capped at 8; explicit max: 16; also honors CDIDX_INDEX_PARALLELISM)", PrimaryCommands = Set("index") }, + new() { Name = "--max-file-bytes", ValuePlaceholder = "", Description = "Per-file indexing size limit (1..2147483647 bytes; optional B/K/M/G suffix); invalid CDIDX_MAX_FILE_BYTES warns and uses the built-in default", PrimaryCommands = Set("index") }, + new() { Name = "--max-symbols-per-file", ValuePlaceholder = "", Description = "Skip file content, symbols, and references when one file emits too many symbols (1..50000)", PrimaryCommands = Set("index") }, + new() { Name = "--max-references-per-file", ValuePlaceholder = "", Description = "Skip references when one file emits too many references (1..1000000)", PrimaryCommands = Set("index") }, + new() { Name = "--parallelism", ValuePlaceholder = "", Description = "Extraction worker count (1..16; default CPU count capped at 8); invalid CDIDX_INDEX_PARALLELISM warns and uses the documented effective value", PrimaryCommands = Set("index") }, new() { Name = "--memory-trace", Description = "Include phase memory samples in index JSON output", PrimaryCommands = Set("index") }, new() { Name = "--commits", ValuePlaceholder = "", Description = "Update files changed in given git commits", PrimaryCommands = Set("index") }, new() { Name = "--changed-between", ValuePlaceholder = " ", Description = "Update files changed between two git refs", PrimaryCommands = Set("index") }, new() { Name = "--files", ValuePlaceholder = "", ValueKind = CliOptionValueKind.FilePath, Description = "Update explicit files after validating the full selection atomically; invalid or duplicate paths reject before writes, while indexed missing paths remain deletion targets", PrimaryCommands = Set("index") }, new() { Name = "--watch", Description = "Continuous reindex on file changes (rejects --commits / --changed-between / --files / --dry-run)", PrimaryCommands = Set("index") }, - new() { Name = "--debounce", ValuePlaceholder = "", Description = "Watch only: coalesce file events into one update after of quiet (default 500)", PrimaryCommands = Set("index") }, - new() { Name = "--watch-pending-path-limit", ValuePlaceholder = "", Description = "Watch only: changed-path queue limit before full-rescan fallback", PrimaryCommands = Set("index") }, + new() { Name = "--debounce", ValuePlaceholder = "", Description = "Watch only: coalesce file events after 0..60000 ms of quiet (default 500)", PrimaryCommands = Set("index") }, + new() { Name = "--watch-pending-path-limit", ValuePlaceholder = "", Description = "Watch only: changed-path queue limit (1..262144); invalid CDIDX_INDEX_WATCH_PENDING_PATH_LIMIT warns and uses the documented effective value", PrimaryCommands = Set("index") }, new() { Name = "--output", ShortName = "-o", ValuePlaceholder = "", ValueKind = CliOptionValueKind.FilePath, Description = "Report bundle or suggestions export output path", PrimaryCommands = Set("report", "suggestions") }, new() { Name = "--redact-paths", Description = "Redact machine-specific paths (the default for recovery/config JSON and reports)", PrimaryCommands = Set(RecoveryPathCommands.Concat(["status", "report"]).ToArray()) }, new() { Name = "--no-log", Description = "Exclude global tool log from bundle", PrimaryCommands = Set("report") }, diff --git a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs index 1485d9111..61979bc0a 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs @@ -46,8 +46,10 @@ private static int RunDryRun( bool authoritativeFullScan; var errorSamples = new List(); var errorCount = 0; - var warningSamples = new List(); - var warningCount = 0; + var warningSamples = options.OptionWarnings + .Take(DryRunWarningSampleLimit) + .ToList(); + var warningCount = options.OptionWarnings.Count; var dryScanErrorKeys = new HashSet(StringComparer.Ordinal); DryRunScanMetadata dryScanMetadata; DryRunDbSnapshot dbSnapshot; diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index bdbd63840..0dca0e7b3 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -175,6 +175,7 @@ void ThrowIfFullScanCancelled(int filesProcessed, int? filesTotal) }) .ToList(); var warningList = discovery.WarningList; + warningList.InsertRange(0, options.OptionWarnings); AddProjectMarkerFingerprintWarnings(currentHotspotFamilyMarkerFingerprints, warningList, options); var scanCheckpointPath = discovery.ScanCheckpointPath; if (options.MemoryTrace) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs b/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs index 7a4fe6dc6..dbaaa1833 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs @@ -50,13 +50,17 @@ public static IndexCommandOptions ParseArgs(string[] args) bool symbolsOnly = false; bool memoryTrace = false; int? watchDebounceMs = null; - var watchPendingPathLimit = ReadWatchPendingPathLimitFromEnvironment(); + var optionWarnings = new List(); + var watchPendingPathLimit = ReadWatchPendingPathLimitFromEnvironment(optionWarnings); var durationFormat = DurationOutputFormat.Auto; var notifyMode = ReadCompletionNotificationModeFromEnvironment(); - long? maxFileSizeBytes = ReadMaxFileSizeBytesFromEnvironment(); + long? maxFileSizeBytes = ReadMaxFileSizeBytesFromEnvironment(optionWarnings); var maxSymbolsPerFile = DefaultMaxSymbolsPerFile; var maxReferencesPerFile = DefaultMaxReferencesPerFile; - var parallelism = ReadIndexParallelismFromEnvironment(); + var parallelism = ReadIndexParallelismFromEnvironment(optionWarnings); + var watchPendingPathLimitSpecifiedOnCli = false; + var maxFileSizeBytesSpecifiedOnCli = false; + var parallelismSpecifiedOnCli = false; var symlinkPolicy = FileIndexer.SymlinkPolicy.None; string? easterEgg = null; int spinnerFlagCount = 0; @@ -154,25 +158,14 @@ public static IndexCommandOptions ParseArgs(string[] args) memoryTrace = true; break; case "--debounce" when i + 1 < args.Length: - if (int.TryParse(args[i + 1], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsedDebounce) && parsedDebounce >= 0) - { - if (parsedDebounce <= IndexWatchRunner.MaxDebounceMs) - watchDebounceMs = parsedDebounce; - else - parseError ??= $"--debounce must be less than or equal to {IndexWatchRunner.MaxDebounceMs} ms, got '{args[i + 1]}'"; - i++; - } - else - { - var displayValue = ConsoleUi.FormatBoundedValue(args[i + 1]); - CommandErrorWriter.WriteStderr($"Warning: invalid --debounce value '{displayValue}' (ignored; must be a non-negative integer in milliseconds) / 不正な --debounce 値 '{displayValue}'(無視。ミリ秒の0以上の整数を指定)"); - i++; - } + watchDebounceMs = ParseWatchDebounce(args[++i], watchDebounceMs, ref parseError); break; case "--watch-pending-path-limit" when i + 1 < args.Length: + watchPendingPathLimitSpecifiedOnCli = true; watchPendingPathLimit = ParseWatchPendingPathLimit(args[++i], watchPendingPathLimit, "--watch-pending-path-limit", ref parseError); break; case var option when option.StartsWith("--watch-pending-path-limit=", StringComparison.Ordinal): + watchPendingPathLimitSpecifiedOnCli = true; watchPendingPathLimit = ParseWatchPendingPathLimit(option["--watch-pending-path-limit=".Length..], watchPendingPathLimit, "--watch-pending-path-limit", ref parseError); break; case "--duration-format" when i + 1 < args.Length: @@ -188,10 +181,12 @@ public static IndexCommandOptions ParseArgs(string[] args) notifyMode = ParseCompletionNotificationMode(option["--notify=".Length..], notifyMode, ref parseError); break; case "--max-file-bytes" when i + 1 < args.Length: - maxFileSizeBytes = ParseMaxFileBytes(args[++i], maxFileSizeBytes); + maxFileSizeBytesSpecifiedOnCli = true; + maxFileSizeBytes = ParseMaxFileBytes(args[++i], maxFileSizeBytes, ref parseError); break; case var option when option.StartsWith("--max-file-bytes=", StringComparison.Ordinal): - maxFileSizeBytes = ParseMaxFileBytes(option["--max-file-bytes=".Length..], maxFileSizeBytes); + maxFileSizeBytesSpecifiedOnCli = true; + maxFileSizeBytes = ParseMaxFileBytes(option["--max-file-bytes=".Length..], maxFileSizeBytes, ref parseError); break; case "--max-symbols-per-file" when i + 1 < args.Length: maxSymbolsPerFile = ParseMaxSymbolsPerFile(args[++i], maxSymbolsPerFile, "--max-symbols-per-file", ref parseError); @@ -206,10 +201,12 @@ public static IndexCommandOptions ParseArgs(string[] args) maxReferencesPerFile = ParseMaxReferencesPerFile(option["--max-references-per-file=".Length..], maxReferencesPerFile, "--max-references-per-file", ref parseError); break; case "--parallelism" when i + 1 < args.Length: - parallelism = ParseIndexParallelism(args[++i], parallelism, "--parallelism"); + parallelismSpecifiedOnCli = true; + parallelism = ParseIndexParallelism(args[++i], parallelism, "--parallelism", ref parseError); break; case var option when option.StartsWith("--parallelism=", StringComparison.Ordinal): - parallelism = ParseIndexParallelism(option["--parallelism=".Length..], parallelism, "--parallelism"); + parallelismSpecifiedOnCli = true; + parallelism = ParseIndexParallelism(option["--parallelism=".Length..], parallelism, "--parallelism", ref parseError); break; case "--follow-symlinks" when i + 1 < args.Length: symlinkPolicy = ParseSymlinkPolicy(args[++i], symlinkPolicy, ref parseError); @@ -343,6 +340,20 @@ public static IndexCommandOptions ParseArgs(string[] args) if (showPaths && !optimizeOnly) parseError ??= "--show-paths is only valid with `cdidx index --optimize`."; + RemoveOverriddenEnvironmentWarning(optionWarnings, WatchPendingPathLimitEnvironmentVariable, watchPendingPathLimitSpecifiedOnCli); + RemoveOverriddenEnvironmentWarning(optionWarnings, FileIndexer.MaxFileSizeEnvironmentVariable, maxFileSizeBytesSpecifiedOnCli); + RemoveOverriddenEnvironmentWarning(optionWarnings, IndexParallelismEnvironmentVariable, parallelismSpecifiedOnCli); + var finalParseError = parseError ?? generatedCodePatternError; + if (finalParseError != null) + { + optionWarnings.Clear(); + } + else + { + foreach (var warning in optionWarnings) + CommandErrorWriter.WriteStderr($"Warning: {warning.Message}"); + } + return new IndexCommandOptions { // Absolutize critical paths at the option-parsing boundary so a cwd shift after @@ -366,7 +377,8 @@ public static IndexCommandOptions ParseArgs(string[] args) ProjectFilters = projectFilters, SolutionPath = solutionPath, ProjectFilterError = projectFilterError, - ParseError = parseError ?? generatedCodePatternError, + ParseError = finalParseError, + OptionWarnings = optionWarnings, EasterEgg = easterEgg, DryRun = dryRun, DryRunPathLimit = dryRunPathLimit, @@ -533,34 +545,41 @@ internal static int DefaultIndexParallelism() internal static int CalculateDefaultIndexParallelism(int processorCount) => Math.Clamp(processorCount, 1, DefaultIndexParallelismCap); - private static int ReadIndexParallelismFromEnvironment() + private static int ReadIndexParallelismFromEnvironment(List warnings) { var fallback = DefaultIndexParallelism(); var value = CdidxEnvironment.GetProcessEnvironmentVariable(IndexParallelismEnvironmentVariable); if (string.IsNullOrWhiteSpace(value)) return fallback; - return ParseIndexParallelism(value, fallback, IndexParallelismEnvironmentVariable); - } - - private static int ParseIndexParallelism(string value, int fallback, string source) - { - if (int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsed) && parsed > 0) + if (int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsed) + && parsed >= 1) { if (parsed <= MaxIndexParallelism) return parsed; - var displayValue = ConsoleUi.FormatBoundedValue(value); - CommandErrorWriter.WriteStderr($"Warning: {source} value '{displayValue}' exceeds the maximum {MaxIndexParallelism}; using {MaxIndexParallelism} / {source} 値 '{displayValue}' は最大 {MaxIndexParallelism} を超えています。{MaxIndexParallelism} を使用します"); + AddEnvironmentOptionWarning( + warnings, + IndexParallelismEnvironmentVariable, + value, + $"must be between 1 and {MaxIndexParallelism} inclusive; using maximum clamp {MaxIndexParallelism}", + $"1 以上 {MaxIndexParallelism} 以下である必要があります。上限補正値 {MaxIndexParallelism} を使用します"); return MaxIndexParallelism; } - var invalidDisplayValue = ConsoleUi.FormatBoundedValue(value); - CommandErrorWriter.WriteStderr($"Warning: invalid {source} value '{invalidDisplayValue}' (ignored; use a positive integer) / 不正な {source} 値 '{invalidDisplayValue}'(無視。正の整数を指定)"); + AddEnvironmentOptionWarning( + warnings, + IndexParallelismEnvironmentVariable, + value, + $"must be between 1 and {MaxIndexParallelism} inclusive; using automatic CPU default {fallback}", + $"1 以上 {MaxIndexParallelism} 以下である必要があります。CPU 数から算出した既定値 {fallback} を使用します"); return fallback; } - private static int ReadWatchPendingPathLimitFromEnvironment() + private static int ParseIndexParallelism(string value, int fallback, string source, ref string? parseError) + => ParseIndexNumericOption(value, fallback, source, 1, MaxIndexParallelism, ref parseError); + + private static int ReadWatchPendingPathLimitFromEnvironment(List warnings) { var fallback = IndexWatchRunner.DefaultWatchPendingPathLimit; var value = CdidxEnvironment.GetEnvironmentVariable(WatchPendingPathLimitEnvironmentVariable); @@ -572,49 +591,35 @@ private static int ReadWatchPendingPathLimitFromEnvironment() if (parsed <= IndexWatchRunner.MaxWatchPendingPathLimit) return parsed; - var displayValue = ConsoleUi.FormatBoundedValue(value); - CommandErrorWriter.WriteStderr($"Warning: {WatchPendingPathLimitEnvironmentVariable} value '{displayValue}' exceeds the maximum {IndexWatchRunner.MaxWatchPendingPathLimit}; using {IndexWatchRunner.MaxWatchPendingPathLimit} / {WatchPendingPathLimitEnvironmentVariable} 値 '{displayValue}' は最大 {IndexWatchRunner.MaxWatchPendingPathLimit} を超えています。{IndexWatchRunner.MaxWatchPendingPathLimit} を使用します"); + AddEnvironmentOptionWarning( + warnings, + WatchPendingPathLimitEnvironmentVariable, + value, + $"must be between 1 and {IndexWatchRunner.MaxWatchPendingPathLimit} inclusive; using maximum clamp {IndexWatchRunner.MaxWatchPendingPathLimit}", + $"1 以上 {IndexWatchRunner.MaxWatchPendingPathLimit} 以下である必要があります。上限補正値 {IndexWatchRunner.MaxWatchPendingPathLimit} を使用します"); return IndexWatchRunner.MaxWatchPendingPathLimit; } - var invalidDisplayValue = ConsoleUi.FormatBoundedValue(value); - CommandErrorWriter.WriteStderr($"Warning: invalid {WatchPendingPathLimitEnvironmentVariable} value '{invalidDisplayValue}' (ignored; use a positive integer) / 不正な {WatchPendingPathLimitEnvironmentVariable} 値 '{invalidDisplayValue}'(無視。正の整数を指定)"); + AddEnvironmentOptionWarning( + warnings, + WatchPendingPathLimitEnvironmentVariable, + value, + $"must be between 1 and {IndexWatchRunner.MaxWatchPendingPathLimit} inclusive; using built-in default {fallback}", + $"1 以上 {IndexWatchRunner.MaxWatchPendingPathLimit} 以下である必要があります。組み込み既定値 {fallback} を使用します"); return fallback; } private static int ParseWatchPendingPathLimit(string value, int fallback, string source, ref string? parseError) { - if (int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsed) && parsed > 0) - { - if (parsed <= IndexWatchRunner.MaxWatchPendingPathLimit) - return parsed; - - parseError ??= $"{source} must be less than or equal to {IndexWatchRunner.MaxWatchPendingPathLimit}"; - return fallback; - } - - var displayValue = ConsoleUi.FormatBoundedValue(value); - CommandErrorWriter.WriteStderr($"Warning: invalid {source} value '{displayValue}' (ignored; use a positive integer) / 不正な {source} 値 '{displayValue}'(無視。正の整数を指定)"); - return fallback; + return ParseIndexNumericOption(value, fallback, source, 1, IndexWatchRunner.MaxWatchPendingPathLimit, ref parseError); } private static int ParseDryRunPathLimit(string value, int fallback, string source, ref string? parseError) { - if (int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsed) && parsed > 0) - { - if (parsed <= MaxDryRunPathLimit) - return parsed; - - parseError ??= $"{source} must be less than or equal to {MaxDryRunPathLimit}"; - return fallback; - } - - var displayValue = ConsoleUi.FormatBoundedValue(value); - CommandErrorWriter.WriteStderr($"Warning: invalid {source} value '{displayValue}' (ignored; use a positive integer) / 不正な {source} 値 '{displayValue}'(無視。正の整数を指定)"); - return fallback; + return ParseIndexNumericOption(value, fallback, source, 1, MaxDryRunPathLimit, ref parseError); } - private static long? ReadMaxFileSizeBytesFromEnvironment() + private static long? ReadMaxFileSizeBytesFromEnvironment(List warnings) { var value = CdidxEnvironment.GetProcessEnvironmentVariable(FileIndexer.MaxFileSizeEnvironmentVariable); if (string.IsNullOrWhiteSpace(value)) @@ -623,53 +628,88 @@ private static int ParseDryRunPathLimit(string value, int fallback, string sourc if (FileIndexer.TryParseMaxFileSizeBytes(value, out var parsed)) return parsed; - var displayValue = ConsoleUi.FormatBoundedValue(value); - CommandErrorWriter.WriteStderr($"Warning: invalid {FileIndexer.MaxFileSizeEnvironmentVariable} value '{displayValue}' (ignored; use positive bytes or K/M/G suffixes) / 不正な {FileIndexer.MaxFileSizeEnvironmentVariable} 値 '{displayValue}'(無視。正の byte 数または K/M/G 接尾辞を指定)"); + AddEnvironmentOptionWarning( + warnings, + FileIndexer.MaxFileSizeEnvironmentVariable, + value, + $"must be between 1 and {int.MaxValue} bytes inclusive, with an optional B/K/M/G suffix; using built-in default {FileIndexer.DefaultMaxFileSizeBytes} bytes", + $"B/K/M/G 接尾辞を任意で付けた 1 以上 {int.MaxValue} byte 以下である必要があります。組み込み既定値 {FileIndexer.DefaultMaxFileSizeBytes} byte を使用します"); return null; } - private static long? ParseMaxFileBytes(string value, long? fallback) + private static long? ParseMaxFileBytes(string value, long? fallback, ref string? parseError) { if (FileIndexer.TryParseMaxFileSizeBytes(value, out var parsed)) return parsed; - var displayValue = ConsoleUi.FormatBoundedValue(value); - CommandErrorWriter.WriteStderr($"Warning: invalid --max-file-bytes value '{displayValue}' (ignored; use positive bytes or K/M/G suffixes) / 不正な --max-file-bytes 値 '{displayValue}'(無視。正の byte 数または K/M/G 接尾辞を指定)"); + parseError ??= $"--max-file-bytes value '{ConsoleUi.FormatBoundedValue(value)}' must be between 1 and {int.MaxValue} inclusive (bytes, with an optional B/K/M/G suffix)"; return fallback; } private static int ParseMaxSymbolsPerFile(string value, int fallback, string source, ref string? parseError) { - if (int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsed) && parsed > 0) - { - if (parsed <= MaxSymbolsPerFileLimit) - return parsed; + return ParseIndexNumericOption(value, fallback, source, 1, MaxSymbolsPerFileLimit, ref parseError); + } - parseError ??= $"{source} must be less than or equal to {MaxSymbolsPerFileLimit}"; - return fallback; + private static int ParseMaxReferencesPerFile(string value, int fallback, string source, ref string? parseError) + { + return ParseIndexNumericOption(value, fallback, source, 1, MaxReferencesPerFileLimit, ref parseError); + } + + private static int ParseIndexNumericOption( + string value, + int fallback, + string source, + int minimum, + int maximum, + ref string? parseError) + { + if (int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsed) + && parsed >= minimum + && parsed <= maximum) + { + return parsed; } - var displayValue = ConsoleUi.FormatBoundedValue(value); - CommandErrorWriter.WriteStderr($"Warning: invalid {source} value '{displayValue}' (ignored; use a positive integer) / 不正な {source} 値 '{displayValue}'(無視。正の整数を指定)"); + parseError ??= $"{source} value '{ConsoleUi.FormatBoundedValue(value)}' must be between {minimum} and {maximum} inclusive"; return fallback; } - private static int ParseMaxReferencesPerFile(string value, int fallback, string source, ref string? parseError) + private static int? ParseWatchDebounce(string value, int? fallback, ref string? parseError) { - if (int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsed) && parsed > 0) + if (int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsed) + && parsed >= 0 + && parsed <= IndexWatchRunner.MaxDebounceMs) { - if (parsed <= MaxReferencesPerFileLimit) - return parsed; - - parseError ??= $"{source} must be less than or equal to {MaxReferencesPerFileLimit}"; - return fallback; + return parsed; } - var displayValue = ConsoleUi.FormatBoundedValue(value); - CommandErrorWriter.WriteStderr($"Warning: invalid {source} value '{displayValue}' (ignored; use a positive integer) / 不正な {source} 値 '{displayValue}'(無視。正の整数を指定)"); + parseError ??= $"--debounce value '{ConsoleUi.FormatBoundedValue(value)}' must be between 0 and {IndexWatchRunner.MaxDebounceMs} inclusive"; return fallback; } + private static void AddEnvironmentOptionWarning( + List warnings, + string source, + string value, + string englishDetail, + string japaneseDetail) + { + var displayValue = ConsoleUi.FormatBoundedValue(value); + warnings.Add(new CliJsonMessage( + $"", + $"{source} value '{displayValue}' {englishDetail} / {source} 値 '{displayValue}' は {japaneseDetail}")); + } + + private static void RemoveOverriddenEnvironmentWarning( + List warnings, + string source, + bool overridden) + { + if (overridden) + warnings.RemoveAll(warning => string.Equals(warning.File, $"", StringComparison.Ordinal)); + } + private static DurationOutputFormat ParseDurationFormat(string value, DurationOutputFormat fallback) { return value.Trim().ToLowerInvariant() switch diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs index 513f4a517..3a975b020 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs @@ -182,7 +182,7 @@ private static int RunUpdateMode( if (!options.Json && !options.Quiet) CommandOutputWriter.WriteLine($"Updating {ConsoleUi.Counted(targetPaths.Count, "file")}..."); - int updated = 0, removed = 0, skipped = 0, warnings = 0, errors = 0; + int updated = 0, removed = 0, skipped = 0, warnings = options.OptionWarnings.Count, errors = 0; var updateProgress = new IndexProgressReporter( options, "Updating...", @@ -190,7 +190,7 @@ private static int RunUpdateMode( CommandErrorWriter.WriteStderr); var errorList = new List(); var fileErrorList = new List(); - var warningList = new List(); + var warningList = new List(options.OptionWarnings); warnings += AddProjectMarkerFingerprintWarnings(currentHotspotFamilyMarkerFingerprints, warningList, options); var scanErrorKeys = new HashSet(StringComparer.Ordinal); var visitedFileIdentities = new HashSet(); diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 8cd9091a2..a4d1c201f 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -698,6 +698,7 @@ public sealed class IndexCommandOptions public string? SolutionPath { get; init; } public string? ProjectFilterError { get; init; } public string? ParseError { get; init; } + internal IReadOnlyList OptionWarnings { get; init; } = []; public string? EasterEgg { get; init; } public bool DryRun { get; init; } public int DryRunPathLimit { get; init; } = IndexCommandRunner.DefaultDryRunPathLimit; diff --git a/src/CodeIndex/Cli/IndexWatchJsonContracts.cs b/src/CodeIndex/Cli/IndexWatchJsonContracts.cs index 5e1a2e7a0..0fb64f70d 100644 --- a/src/CodeIndex/Cli/IndexWatchJsonContracts.cs +++ b/src/CodeIndex/Cli/IndexWatchJsonContracts.cs @@ -13,6 +13,8 @@ internal sealed class IndexWatchStartedJsonResult : IVersionedJsonResult public int? WatchPendingPathLimit { get; init; } public string? Backend { get; init; } public string? RecoveryReason { get; init; } + public int WarningsTotal { get; init; } + public IReadOnlyList? Warnings { get; init; } public IndexWatchContractJsonResult? WatchContract { get; init; } } diff --git a/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs b/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs index cc9e84e82..351e2bc72 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs @@ -197,6 +197,8 @@ private static void EmitWatchStarted( WatchPendingPathLimit = maxPendingPaths, Backend = safeBackend, RecoveryReason = safeRecoveryReason, + WarningsTotal = baseOptions.OptionWarnings.Count, + Warnings = baseOptions.OptionWarnings.Count > 0 ? baseOptions.OptionWarnings : null, WatchContract = BuildWatchContract(debounce, maxPendingPaths, ignoreCase), }, CliJsonSerializerContextFactory.Create(jsonOptions).IndexWatchStartedJsonResult)); } diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index b1f0c1d95..1cfaa2d61 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -4887,22 +4887,13 @@ public void ParseArgs_DebounceFlag_RejectsValueAboveMaximum_Issue3173() } [Fact] - public void ParseArgs_DebounceFlag_InvalidValue_IsIgnored() + public void ParseArgs_DebounceFlag_InvalidValue_IsRejected() { - var originalErr = Console.Error; - using var stderr = new StringWriter(); - try - { - Console.SetError(stderr); - var options = IndexCommandRunner.ParseArgs([".", "--watch", "--debounce", "not-a-number"]); - Assert.True(options.Watch); - Assert.Null(options.WatchDebounceMs); - Assert.Contains("invalid --debounce value", stderr.ToString()); - } - finally - { - Console.SetError(originalErr); - } + var options = IndexCommandRunner.ParseArgs([".", "--watch", "--debounce", "not-a-number"]); + + Assert.True(options.Watch); + Assert.Null(options.WatchDebounceMs); + Assert.Contains("--debounce value 'not-a-number' must be between 0", options.ParseError); } [Fact] @@ -5030,7 +5021,7 @@ public void ParseArgs_MaxSymbolsPerFileFlag_RejectsValueAboveMaximum_Issue3172() var options = IndexCommandRunner.ParseArgs([".", $"--max-symbols-per-file={aboveMaximum}"]); Assert.Equal(IndexCommandRunner.DefaultMaxSymbolsPerFile, options.MaxSymbolsPerFile); - Assert.Contains("--max-symbols-per-file must be less than or equal to 50000", options.ParseError); + Assert.Contains("--max-symbols-per-file value '50001' must be between 1 and 50000 inclusive", options.ParseError); } [Fact] @@ -5040,55 +5031,28 @@ public void ParseArgs_MaxReferencesPerFileFlag_RejectsValueAboveMaximum_Issue371 var options = IndexCommandRunner.ParseArgs([".", $"--max-references-per-file={aboveMaximum}"]); Assert.Equal(IndexCommandRunner.DefaultMaxReferencesPerFile, options.MaxReferencesPerFile); - Assert.Contains("--max-references-per-file must be less than or equal to 1000000", options.ParseError); + Assert.Contains("--max-references-per-file value '1000001' must be between 1 and 1000000 inclusive", options.ParseError); } [Fact] - public void ParseArgs_MaxFileBytesInvalidValue_IsIgnored() + public void ParseArgs_MaxFileBytesInvalidValue_IsRejected() { - lock (TestConsoleLock.Gate) - { - var originalErr = Console.Error; - using var stderr = new StringWriter(); - try - { - Console.SetError(stderr); - var options = IndexCommandRunner.ParseArgs([".", "--max-file-bytes", "0"]); + var options = IndexCommandRunner.ParseArgs([".", "--max-file-bytes", "0"]); - Assert.True(options.MaxFileSizeBytes is null or > 0); - Assert.Contains("invalid --max-file-bytes value", stderr.ToString()); - } - finally - { - Console.SetError(originalErr); - } - } + Assert.Null(options.MaxFileSizeBytes); + Assert.Contains("--max-file-bytes value '0' must be between 1", options.ParseError); } [Fact] public void ParseArgs_MaxFileBytesInvalidValue_TruncatesOversizedValue() { - lock (TestConsoleLock.Gate) - { - var originalErr = Console.Error; - using var stderr = new StringWriter(); - var value = new string('x', ConsoleUi.DefaultDiagnosticValueCharLimit + 1); - try - { - Console.SetError(stderr); + var value = new string('x', ConsoleUi.DefaultDiagnosticValueCharLimit + 1); - _ = IndexCommandRunner.ParseArgs([".", "--max-file-bytes", value]); + var options = IndexCommandRunner.ParseArgs([".", "--max-file-bytes", value]); - var warning = stderr.ToString(); - Assert.Contains("invalid --max-file-bytes value", warning); - Assert.Contains("", warning.File); } finally { @@ -5237,6 +5188,255 @@ public void ParseArgs_IndexParallelismEnvironmentClampsOversizedValue_Issue2904( } } + public static IEnumerable ValidIndexNumericOptionBoundaries() + { + foreach (var option in IndexNumericOptionCases()) + { + yield return [option.Flag, option.Minimum.ToString(CultureInfo.InvariantCulture), option.Minimum]; + yield return [option.Flag, option.Maximum.ToString(CultureInfo.InvariantCulture), option.Maximum]; + } + } + + public static IEnumerable InvalidIndexNumericOptionValues() + { + foreach (var option in IndexNumericOptionCases()) + { + if (option.Minimum > 0) + yield return [option.Flag, "0", option.Minimum, option.Maximum]; + yield return [option.Flag, "-1", option.Minimum, option.Maximum]; + yield return [option.Flag, "2147483648", option.Minimum, option.Maximum]; + yield return [option.Flag, "not-a-number", option.Minimum, option.Maximum]; + if (option.Maximum < int.MaxValue) + yield return [option.Flag, (option.Maximum + 1).ToString(CultureInfo.InvariantCulture), option.Minimum, option.Maximum]; + } + } + + public static IEnumerable InvalidIndexNumericOptionRunValues() + { + foreach (var option in IndexNumericOptionCases()) + { + yield return + [ + option.Flag, + option.Minimum == 0 ? "-1" : "0", + option.Minimum, + option.Maximum, + ]; + } + } + + [Theory] + [MemberData(nameof(ValidIndexNumericOptionBoundaries))] + public void ParseArgs_IndexNumericOptions_AcceptInclusiveBoundaries_Issue5097( + string flag, + string value, + long expected) + { + var options = IndexCommandRunner.ParseArgs([".", flag, value]); + + Assert.Null(options.ParseError); + Assert.Equal(expected, GetIndexNumericOptionValue(options, flag)); + } + + [Theory] + [MemberData(nameof(InvalidIndexNumericOptionValues))] + public void ParseArgs_IndexNumericOptions_RejectInvalidExplicitValues_Issue5097( + string flag, + string value, + long minimum, + long maximum) + { + var options = IndexCommandRunner.ParseArgs([".", flag, value]); + + Assert.NotNull(options.ParseError); + Assert.Contains(flag, options.ParseError); + Assert.Contains($"value '{value}'", options.ParseError); + Assert.Contains($"between {minimum} and {maximum} inclusive", options.ParseError); + } + + [Theory] + [InlineData("--parallelism", " +1 ", 1)] + [InlineData("--dry-run-path-limit", " +1 ", 1)] + [InlineData("--max-file-bytes", " +1 ", 1)] + [InlineData("--debounce", " +0 ", 0)] + public void ParseArgs_IndexNumericOptions_AcceptWhitespaceAndExplicitPositiveSign_Issue5097( + string flag, + string value, + long expected) + { + var options = IndexCommandRunner.ParseArgs([".", flag, value]); + + Assert.Null(options.ParseError); + Assert.Equal(expected, GetIndexNumericOptionValue(options, flag)); + } + + [Theory] + [InlineData("--parallelism", "2", "3", 3)] + [InlineData("--max-file-bytes", "2K", "3K", 3072)] + [InlineData("--max-references-per-file", "2", "3", 3)] + public void ParseArgs_IndexNumericOptions_LastValidDuplicateWins_Issue5097( + string flag, + string first, + string last, + long expected) + { + var options = IndexCommandRunner.ParseArgs([".", flag, first, flag, last]); + + Assert.Null(options.ParseError); + Assert.Equal(expected, GetIndexNumericOptionValue(options, flag)); + } + + [Theory] + [InlineData("--parallelism", "2", "0")] + [InlineData("--parallelism", "0", "2")] + [InlineData("--max-file-bytes", "2K", "invalid")] + [InlineData("--max-file-bytes", "invalid", "2K")] + public void ParseArgs_IndexNumericOptions_AnyInvalidDuplicateCausesFailure_Issue5097( + string flag, + string first, + string last) + { + var options = IndexCommandRunner.ParseArgs([".", flag, first, flag, last]); + + Assert.NotNull(options.ParseError); + Assert.Contains(flag, options.ParseError); + } + + [Theory] + [InlineData(IndexCommandRunner.IndexParallelismEnvironmentVariable, "--parallelism", "2", 2)] + [InlineData(IndexCommandRunner.WatchPendingPathLimitEnvironmentVariable, "--watch-pending-path-limit", "2", 2)] + [InlineData(FileIndexer.MaxFileSizeEnvironmentVariable, "--max-file-bytes", "2K", 2048)] + public void ParseArgs_ExplicitNumericOptionOverridesInvalidEnvironmentWithoutWarning_Issue5097( + string environmentVariable, + string flag, + string cliValue, + long expected) + { + using var env = EnvironmentVariableScope.Capture(environmentVariable); + Environment.SetEnvironmentVariable(environmentVariable, "invalid"); + + var options = IndexCommandRunner.ParseArgs([".", flag, cliValue]); + + Assert.Null(options.ParseError); + Assert.Empty(options.OptionWarnings); + Assert.Equal(expected, GetIndexNumericOptionValue(options, flag)); + } + + [Theory] + [InlineData(IndexCommandRunner.IndexParallelismEnvironmentVariable, "automatic CPU default")] + [InlineData(IndexCommandRunner.WatchPendingPathLimitEnvironmentVariable, "built-in default")] + [InlineData(FileIndexer.MaxFileSizeEnvironmentVariable, "built-in default")] + public void ParseArgs_InvalidNumericEnvironmentFallsBackWithStructuredProvenance_Issue5097( + string environmentVariable, + string expectedSource) + { + using var env = EnvironmentVariableScope.Capture(environmentVariable); + Environment.SetEnvironmentVariable(environmentVariable, "invalid"); + + var options = IndexCommandRunner.ParseArgs(["."]); + + Assert.Null(options.ParseError); + var warning = Assert.Single(options.OptionWarnings); + Assert.Equal($"", warning.File); + Assert.Contains("value 'invalid'", warning.Message); + Assert.Contains(expectedSource, warning.Message); + } + + [Theory] + [MemberData(nameof(InvalidIndexNumericOptionRunValues))] + public void Run_InvalidExplicitNumericOptionReturnsStructuredUsageErrorBeforeDbMutation_Issue5097( + string flag, + string value, + long minimum, + long maximum) + { + var projectRoot = CreateTempProject(); + try + { + var (exitCode, json) = RunAndCaptureJson([projectRoot, flag, value, "--json"]); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal("error", json.GetProperty("status").GetString()); + Assert.Equal(CommandErrorCodes.UsageError, json.GetProperty("error_code").GetString()); + var message = json.GetProperty("message").GetString(); + Assert.Contains(flag, message); + Assert.Contains($"value '{value}'", message); + Assert.Contains($"between {minimum} and {maximum} inclusive", message); + Assert.False(File.Exists(Path.Combine(projectRoot, ".cdidx", "codeindex.db"))); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_InvalidExplicitNumericOption_TextReturnsUsageErrorWithoutWarning_Issue5097() + { + var projectRoot = CreateTempProject(); + try + { + var (exitCode, _, stderr) = RunAndCaptureStreams([projectRoot, "--parallelism", "0"]); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains($"Error [{CommandErrorCodes.UsageError}]", stderr); + Assert.Contains("--parallelism value '0' must be between 1 and 16 inclusive", stderr); + Assert.DoesNotContain("Warning: invalid --parallelism", stderr); + Assert.False(File.Exists(Path.Combine(projectRoot, ".cdidx", "codeindex.db"))); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_InvalidNumericEnvironmentWarningAppearsInDryRunJson_Issue5097() + { + using var env = EnvironmentVariableScope.Capture(IndexCommandRunner.IndexParallelismEnvironmentVariable); + Environment.SetEnvironmentVariable(IndexCommandRunner.IndexParallelismEnvironmentVariable, "invalid"); + var projectRoot = CreateTempProject(); + try + { + var (exitCode, json) = RunAndCaptureJson([projectRoot, "--dry-run", "--json"]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.True(json.GetProperty("warnings_total").GetInt32() >= 1); + var warning = json.GetProperty("warnings") + .EnumerateArray() + .Single(item => item.GetProperty("file").GetString() == $""); + Assert.Contains("automatic CPU default", warning.GetProperty("message").GetString()); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + private static IEnumerable<(string Flag, int Minimum, int Maximum)> IndexNumericOptionCases() + { + yield return ("--parallelism", 1, IndexCommandRunner.MaxIndexParallelism); + yield return ("--max-file-bytes", 1, int.MaxValue); + yield return ("--max-symbols-per-file", 1, IndexCommandRunner.MaxSymbolsPerFileLimit); + yield return ("--max-references-per-file", 1, IndexCommandRunner.MaxReferencesPerFileLimit); + yield return ("--dry-run-path-limit", 1, IndexCommandRunner.MaxDryRunPathLimit); + yield return ("--watch-pending-path-limit", 1, IndexWatchRunner.MaxWatchPendingPathLimit); + yield return ("--debounce", 0, IndexWatchRunner.MaxDebounceMs); + } + + private static long GetIndexNumericOptionValue(IndexCommandOptions options, string flag) + => flag switch + { + "--parallelism" => options.Parallelism, + "--max-file-bytes" => options.MaxFileSizeBytes ?? FileIndexer.DefaultMaxFileSizeBytes, + "--max-symbols-per-file" => options.MaxSymbolsPerFile, + "--max-references-per-file" => options.MaxReferencesPerFile, + "--dry-run-path-limit" => options.DryRunPathLimit, + "--watch-pending-path-limit" => options.WatchPendingPathLimit, + "--debounce" => options.WatchDebounceMs ?? IndexWatchRunner.DefaultDebounceMs, + _ => throw new ArgumentOutOfRangeException(nameof(flag), flag, "Unknown numeric index option."), + }; + [Theory] [InlineData("--duration-format", "auto", DurationOutputFormat.Auto)] [InlineData("--duration-format", "seconds", DurationOutputFormat.Seconds)] From 02e2253683fb2c05dfc97b2ddaa09fa65e8dd0c0 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 21 Aug 2026 12:08:01 +0900 Subject: [PATCH 2/3] Address adversarial review findings (#5097) --- DEVELOPER_GUIDE.md | 4 ++-- tests/CodeIndex.Tests/IndexCommandRunnerTests.cs | 6 ++++++ .../IndexWatchRunnerIssue4169Tests.cs | 13 +++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 05e1ed32e..7021bae0a 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -275,7 +275,7 @@ startup. The inclusive ranges are `--parallelism` 1..16, `--max-references-per-file` 1..1000000, `--dry-run-path-limit` 1..1000000, `--watch-pending-path-limit` 1..262144, and `--debounce` 0..60000 ms. Any explicit zero outside those ranges, negative, overflowed, non-numeric, or -over-limit value returns structured `E010_USAGE_ERROR` output and exit code 2; +over-limit value returns structured `E010_USAGE_ERROR` output and exit code 1; an invalid occurrence is not hidden by a later duplicate. For compatibility, invalid `CDIDX_INDEX_PARALLELISM`, `CDIDX_MAX_FILE_BYTES`, and `CDIDX_INDEX_WATCH_PENDING_PATH_LIMIT` values still warn and fall back or clamp, @@ -4227,7 +4227,7 @@ query コマンドも JSON profile block 用の `--profile` と command-scoped p `--max-references-per-file` が 1..1000000、`--dry-run-path-limit` が 1..1000000、`--watch-pending-path-limit` が 1..262144、`--debounce` が 0..60000 ms です。範囲外のゼロ、負数、overflow、非数値、上限超過を明示すると、 -構造化された `E010_USAGE_ERROR` と exit code 2 を返し、後続の重複指定が有効でも先行する +構造化された `E010_USAGE_ERROR` と exit code 1 を返し、後続の重複指定が有効でも先行する 不正値を隠しません。互換性のため、不正な `CDIDX_INDEX_PARALLELISM`、 `CDIDX_MAX_FILE_BYTES`、`CDIDX_INDEX_WATCH_PENDING_PATH_LIMIT` は引き続き警告して fallback または clamp しますが、JSON warning には環境変数由来であることと実効値を含めます。 diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 1cfaa2d61..f7c778b81 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -5037,6 +5037,9 @@ public void ParseArgs_MaxReferencesPerFileFlag_RejectsValueAboveMaximum_Issue371 [Fact] public void ParseArgs_MaxFileBytesInvalidValue_IsRejected() { + using var env = EnvironmentVariableScope.Capture(FileIndexer.MaxFileSizeEnvironmentVariable); + env.Set(FileIndexer.MaxFileSizeEnvironmentVariable, null); + var options = IndexCommandRunner.ParseArgs([".", "--max-file-bytes", "0"]); Assert.Null(options.MaxFileSizeBytes); @@ -5136,6 +5139,9 @@ public void ParseArgs_ParallelismInlineFlag_ParsesPositiveValue() [Fact] public void ParseArgs_ParallelismFlagRejectsOversizedValue_Issue5097() { + using var env = EnvironmentVariableScope.Capture(IndexCommandRunner.IndexParallelismEnvironmentVariable); + env.Set(IndexCommandRunner.IndexParallelismEnvironmentVariable, null); + var options = IndexCommandRunner.ParseArgs([".", "--parallelism", "999"]); Assert.Equal(IndexCommandRunner.DefaultIndexParallelism(), options.Parallelism); diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerIssue4169Tests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerIssue4169Tests.cs index d0915782b..3f40841bf 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerIssue4169Tests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerIssue4169Tests.cs @@ -92,6 +92,13 @@ public void IndexWatchStartedJsonResult_SerializesWatchContract_Issue4169() WatchPendingPathLimit = 123, Backend = "fsevents", RecoveryReason = "backend_start_failed", + WarningsTotal = 1, + Warnings = + [ + new CliJsonMessage( + $"", + "invalid value 'invalid'; using automatic CPU default 8"), + ], WatchContract = IndexWatchRunner.BuildWatchContractForTesting( TimeSpan.FromMilliseconds(50), maxPendingPaths: 123, @@ -112,6 +119,12 @@ public void IndexWatchStartedJsonResult_SerializesWatchContract_Issue4169() Assert.Equal(123, watchStarted.RootElement.GetProperty("watch_pending_path_limit").GetInt32()); Assert.Equal("fsevents", watchStarted.RootElement.GetProperty("backend").GetString()); Assert.Equal("backend_start_failed", watchStarted.RootElement.GetProperty("recovery_reason").GetString()); + Assert.Equal(1, watchStarted.RootElement.GetProperty("warnings_total").GetInt32()); + var warning = Assert.Single(watchStarted.RootElement.GetProperty("warnings").EnumerateArray()); + Assert.Equal( + $"", + warning.GetProperty("file").GetString()); + Assert.Contains("automatic CPU default 8", warning.GetProperty("message").GetString()); var contract = watchStarted.RootElement.GetProperty("watch_contract"); Assert.Equal("quiet_window", contract.GetProperty("debounce").GetString()); From b03d3d9e399c7335910e937cabf8062b1d1a22f8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 21 Aug 2026 12:42:06 +0900 Subject: [PATCH 3/3] Suppress irrelevant optimize warnings (#5097) --- src/CodeIndex/Cli/IndexCommandRunner.Parse.cs | 9 +++++++++ .../CodeIndex.Tests/IndexCommandRunnerTests.cs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs b/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs index dbaaa1833..6b4b66a2f 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs @@ -343,6 +343,15 @@ public static IndexCommandOptions ParseArgs(string[] args) RemoveOverriddenEnvironmentWarning(optionWarnings, WatchPendingPathLimitEnvironmentVariable, watchPendingPathLimitSpecifiedOnCli); RemoveOverriddenEnvironmentWarning(optionWarnings, FileIndexer.MaxFileSizeEnvironmentVariable, maxFileSizeBytesSpecifiedOnCli); RemoveOverriddenEnvironmentWarning(optionWarnings, IndexParallelismEnvironmentVariable, parallelismSpecifiedOnCli); + if (optimizeOnly) + { + // Optimize does not consume indexing worker, file-size, or watch queue settings. + // Do not emit fallback warnings for environment values that cannot affect this mode. + // optimize は indexing worker / file size / watch queue 設定を使用しないため、 + // この mode に影響しない環境変数の fallback warning は出力しない。 + optionWarnings.Clear(); + } + var finalParseError = parseError ?? generatedCodePatternError; if (finalParseError != null) { diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index f7c778b81..2f7ae926d 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -5348,6 +5348,23 @@ public void ParseArgs_InvalidNumericEnvironmentFallsBackWithStructuredProvenance Assert.Contains(expectedSource, warning.Message); } + [Theory] + [InlineData(IndexCommandRunner.IndexParallelismEnvironmentVariable)] + [InlineData(IndexCommandRunner.WatchPendingPathLimitEnvironmentVariable)] + [InlineData(FileIndexer.MaxFileSizeEnvironmentVariable)] + public void ParseArgs_OptimizeSuppressesIrrelevantNumericEnvironmentWarnings_Issue5097( + string environmentVariable) + { + using var env = EnvironmentVariableScope.Capture(environmentVariable); + env.Set(environmentVariable, "invalid"); + + var options = IndexCommandRunner.ParseArgs([".", "--optimize", "--dry-run", "--json"]); + + Assert.True(options.OptimizeOnly); + Assert.Null(options.ParseError); + Assert.Empty(options.OptionWarnings); + } + [Theory] [MemberData(nameof(InvalidIndexNumericOptionRunValues))] public void Run_InvalidExplicitNumericOptionReturnsStructuredUsageErrorBeforeDbMutation_Issue5097(