diff --git a/README.md b/README.md index 8fdbe28abc..a9968f12f8 100644 --- a/README.md +++ b/README.md @@ -425,11 +425,13 @@ GPG 検証が成功した後にこの fingerprint の設定も必須です。 ### Validate -`cdidx validate [--db ] [--json] [--verbose] [--kind ] [--path ]` +`cdidx validate [--db ] [--json[=array]] [--verbose] [--limit |--top ] [--kind ] [--severity ] [--path ]` は、index 済みファイルの replacement character (`U+FFFD`)、BOM、NUL byte、 混在改行、UTF-16 BOM、非 UTF-8 らしい内容などを報告します。validation finding は 出力で報告され、それ自体では command failure になりません。DB を読めない場合や -引数が不正な場合は non-zero で終了します。機械処理には `--json` を使えます。 +引数が不正な場合は non-zero で終了します。`--severity warning` を使うと、 +意図的な U+FFFD literal などの informational finding を除外できます。 +機械処理には `--json` を使え、配列だけが必要な pipeline では `--json=array` を使えます。 ### シェル補完 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 59a7135545..e7471cca2f 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -405,7 +405,9 @@ conflicting instructions. ```bash cdidx validate cdidx validate --kind replacement_char --path src/ -cdidx validate --json --path legacy/ +cdidx validate --kind replacement_char --severity warning --path src/ +cdidx validate --json=array --limit 50 --path legacy/ +cdidx validate --json --limit 50 --path legacy/ ``` `validate` reports indexed files that are likely to produce misleading snippets @@ -414,6 +416,10 @@ CR-only line endings, likely non-UTF-8 content, and Git LFS pointer placeholders For `replacement_char`, JSON and MCP responses include `origin` (`source_literal` or `decode_replacement`) and `severity` so agents can distinguish intentional U+FFFD literals from likely encoding damage. +Use `--severity warning` to hide informational source literals and focus on +findings that indicate likely encoding damage. +Use `--json=array` when a pipeline expects a bare issue array instead of the +default `{ "count": ..., "issues": [...] }` object. LFS pointers are recorded as `lfs_pointer_skipped` and their placeholder body is not indexed; run `git lfs pull` and then `cdidx index .` to index the real file content. @@ -2593,7 +2599,9 @@ render できます。 ```bash cdidx validate cdidx validate --kind replacement_char --path src/ -cdidx validate --json --path legacy/ +cdidx validate --kind replacement_char --severity warning --path src/ +cdidx validate --json=array --limit 50 --path legacy/ +cdidx validate --json --limit 50 --path legacy/ ``` `validate` は、snippet や symbol name を誤らせやすい indexed file を報告します。 @@ -2601,7 +2609,10 @@ cdidx validate --json --path legacy/ ending、likely non-UTF-8 content、Git LFS pointer placeholder などです。 `replacement_char` の JSON / MCP response には `origin` (`source_literal` / `decode_replacement`) と `severity` が入り、意図的な U+FFFD literal と -エンコーディング破損の可能性を agent が区別できます。LFS pointer +エンコーディング破損の可能性を agent が区別できます。`--severity warning` +を使うと、informational な source literal を隠して、エンコーディング破損の +可能性がある finding に集中できます。pipeline が既定の `{ "count": ..., "issues": [...] }` +object ではなく bare issue array を期待する場合は `--json=array` を使えます。LFS pointer は `lfs_pointer_skipped` として記録され、placeholder 本文は index されません。 実体を index するには `git lfs pull` の後に `cdidx index .` を再実行してください。 diff --git a/changelog.d/unreleased/2992.fixed.md b/changelog.d/unreleased/2992.fixed.md new file mode 100644 index 0000000000..71d906720e --- /dev/null +++ b/changelog.d/unreleased/2992.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 2992 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Database/DbReader.cs + - tests/CodeIndex.Tests/QueryCommandRunnerValidateTests.cs +--- + +## English + +- **`validate` now supports explicit result limits (#2992)** — pass `--limit ` or `--top ` to cap reported validation issues for large repositories and agent audit loops while preserving the existing all-results default. + +## 日本語 + +- **`validate` が明示的な結果件数制限に対応しました (#2992)** — `--limit ` または `--top ` で、大きなリポジトリや agent audit loop の validation issue 表示件数を制限できます。既定の全件表示は維持されます。 diff --git a/changelog.d/unreleased/3008.fixed.md b/changelog.d/unreleased/3008.fixed.md new file mode 100644 index 0000000000..099d277227 --- /dev/null +++ b/changelog.d/unreleased/3008.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3008 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Database/DbReader.cs + - tests/CodeIndex.Tests/QueryCommandRunnerValidateMetadataTests.cs +--- + +## English + +- **`validate` can filter findings by severity (#3008)** — `--severity warning` focuses JSON and human output on warning-level findings such as likely decode replacements while hiding informational source literals. + +## 日本語 + +- **`validate` で finding の severity filter を指定できるようにしました (#3008)** — `--severity warning` により、informational な source literal を隠し、decode replacement の可能性がある warning-level finding に集中できます。 diff --git a/changelog.d/unreleased/3010.fixed.md b/changelog.d/unreleased/3010.fixed.md new file mode 100644 index 0000000000..af46d50dbb --- /dev/null +++ b/changelog.d/unreleased/3010.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3010 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - tests/CodeIndex.Tests/QueryCommandRunnerValidateTests.cs +--- + +## English + +- **`validate` supports JSON array shaping (#3010)** — `--json=array` now returns a bare issue array, including `[]` for clean indexes, while plain `--json` keeps the existing `{ count, issues }` object. + +## 日本語 + +- **`validate` が JSON array shaping に対応しました (#3010)** — `--json=array` は clean index では `[]`、finding がある場合は bare issue array を返し、通常の `--json` は従来どおり `{ count, issues }` object を維持します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index cd32f0c8bb..b25addc18b 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -75,10 +75,14 @@ internal static class CliFlagSchema private static readonly string[] LimitCapableCommands = [ "search", "definition", "goto", "references", "callers", "callees", "symbols", - "files", "find", "map", "inspect", "deps", "impact", "unused", "hotspots", + "files", "find", "map", "inspect", "deps", "impact", "unused", "hotspots", "validate", ]; - private static readonly string[] LangCapableCommands = LimitCapableCommands; + private static readonly string[] LangCapableCommands = + [ + "search", "definition", "goto", "references", "callers", "callees", "symbols", + "files", "find", "map", "inspect", "deps", "impact", "unused", "hotspots", + ]; private static readonly string[] PathFilterCommands = [ @@ -107,6 +111,7 @@ internal static class CliFlagSchema [ "definition", "goto", "references", "callers", "callees", "symbols", "unused", "hotspots", "validate", ]; + private static readonly string[] SeverityCommands = ["validate"]; private static readonly string[] VisibilityCommands = [ "definition", "symbols", "unused", "hotspots", @@ -207,7 +212,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--immutable", Description = "Alias for --read-only", Commands = Set(ReadOnlyDbCommands) }, new() { Name = "--workspace-db", ValuePlaceholder = "", Description = "Additional workspace member database path for dependency aggregation", Commands = Set(WorkspaceDbCommands) }, new() { Name = "--data-dir", ValuePlaceholder = "", Description = "Directory containing codeindex.db; overrides CDIDX_DATA_DIR/XDG/workspace defaults", Commands = Set(DataDirCommands) }, - new() { Name = "--json", Description = "JSON output; search/files also accept --json=array for a single JSON array", Commands = Set(JsonCommands) }, + new() { Name = "--json", Description = "JSON output; search/files/validate also accept --json=array for a single JSON array", Commands = Set(JsonCommands) }, new() { Name = "--format", ValuePlaceholder = "", Description = "Standard output format for token budgets, editor integrations, and CI", Commands = Set(FormatCommands) }, new() { Name = "--quiet", ShortName = "-q", Description = "Suppress informational stderr output; errors still print", Commands = Set(AllCommands.ToArray()) }, new() { Name = "--silent", Description = "Alias for --quiet", Commands = Set(AllCommands.ToArray()) }, @@ -226,6 +231,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--exclude-tests", Description = "Exclude tests", Commands = Set(ExcludeFilterCommands) }, new() { Name = "--include-generated", Description = "Include generated files", Commands = Set(ExcludeFilterCommands) }, new() { Name = "--kind", ValuePlaceholder = "", Description = "Filter by kind", Commands = Set(KindCommands) }, + new() { Name = "--severity", ValuePlaceholder = "", Description = "Validate: filter validation issues by severity", Commands = Set(SeverityCommands) }, new() { Name = "--visibility", ValuePlaceholder = "", Description = "Filter by symbol visibility", Commands = Set(VisibilityCommands) }, new() { Name = "--exclude-visibility", ValuePlaceholder = "", Description = "Exclude symbol visibility", Commands = Set(VisibilityCommands) }, new() { Name = "--by-bucket", Description = "Unused: include per-bucket grouped result arrays in JSON output", Commands = Set(ByBucketCommands) }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index a4802a25c2..193ba4221d 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -99,7 +99,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("db", "cdidx db --integrity-check|schema|prune [--dry-run|--apply] [--db ] [--json] | cdidx db checkpoint [name] [--db ] [--json] | cdidx db checkpoints --list [--db ] [--json] | cdidx db restore [--db ] [--json]"), ("diff", "cdidx diff [--json] [--summary-only] [--detailed] [--limit ]"), ("report", "cdidx report --output [--db ] [--json] [--log-lines ] [--no-log] [--include-args]"), - ("validate", "cdidx validate [--db ] [--json] [--format ] [--verbose] [--kind ] [--path ]"), + ("validate", "cdidx validate [--db ] [--json[=array]] [--format ] [--verbose] [--limit |--top ] [--kind ] [--severity ] [--path ]"), ("impact", "cdidx impact |--query |-- [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--max-hops ] [--count] [--with-paths]"), ("deps", "cdidx deps [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--reverse] [--cycles]"), ("unused", "cdidx unused [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]"), @@ -943,7 +943,7 @@ private static void PrintFlagReference(Action WriteHelpLine) Console.WriteLine(); Console.WriteLine("Query options:"); Console.WriteLine(" --db Database file path (default: .cdidx/codeindex.db in current directory)"); - WriteHelpLine(" --json Output as JSON (search/files stream ndjson by default; use --json=array for one array)"); + WriteHelpLine(" --json Output as JSON (search/files stream ndjson by default; search/files/validate accept --json=array for one array)"); WriteHelpLine(" --verbose Query commands: emit debug diagnostics to stderr; with --json, append an _debug JSON object"); WriteHelpLine(" --quiet, -q, --silent Query commands: suppress informational stderr output, including zero-result hints and summaries; errors still print. Overrides --verbose stderr text."); WriteHelpLine(" --profile Read commands: append SQL timing, row-count, and EXPLAIN QUERY PLAN JSON after the normal result"); @@ -975,6 +975,7 @@ private static void PrintFlagReference(Action WriteHelpLine) Console.WriteLine(" Legacy/stale-fold DBs fall back to ASCII NOCASE;"); Console.WriteLine(" run `cdidx backfill-fold` or check fold_ready."); WriteHelpLine(" --kind definition/symbols/hotspots/unused: symbol kind; references: reference kind (call/instantiate/subscribe/attribute/annotation); callers/callees: call-graph kinds only (call/instantiate/subscribe — metadata kinds rejected, use references instead); validate: issue kind"); + Console.WriteLine(" --severity validate only: filter issues by severity: info, warning, error"); Console.WriteLine(" --visibility Filter symbols/definitions/unused/hotspots by visibility: public, protected, internal, private"); WriteHelpLine(" --exclude-visibility Exclude symbols/definitions/unused/hotspots by visibility"); WriteHelpLine(" --count Count only; search/definition/references/callers/callees/symbols/files/find/unused ignore --limit, impact/hotspots still use visible page counts"); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 4fd7dc0883..74b055176f 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -84,6 +84,7 @@ public static class QueryCommandRunner "--top", "--lang", "--kind", + "--severity", "--visibility", "--exclude-visibility", "--since", @@ -4906,6 +4907,8 @@ private static JsonObject BuildUnusedResultsByBucketJson(IEnumerable]"); + return CommandExitCodes.UsageError; + } return WithDb(options, jsonOptions, reader => { - var issues = reader.GetIssues(options.Kind, options.PathPatterns); + var issueLimit = HasOption(cmdArgs, "--limit") || HasOption(cmdArgs, "--top") + ? options.Limit + : (int?)null; + var issues = reader.GetIssues(options.Kind, options.PathPatterns, issueLimit, options.Severity); var issuesAvailable = reader._hasIssuesTable; if (issues.Count == 0) { @@ -4938,6 +4952,13 @@ public static int RunValidate(string[] cmdArgs, JsonSerializerOptions jsonOption { if (TryWriteEmptyFormattedResult(options, jsonOptions)) return CommandExitCodes.Success; + if (options.OutputFormat == OutputFormatJson && options.JsonOutputFormat == JsonOutputFormatArray) + { + Console.WriteLine(JsonSerializer.Serialize( + new List(), + CliJsonSerializerContextFactory.Create(jsonOptions).ListFileIssue)); + return CommandExitCodes.Success; + } Console.WriteLine(new JsonObject { ["count"] = 0, @@ -4978,6 +4999,13 @@ public static int RunValidate(string[] cmdArgs, JsonSerializerOptions jsonOption WriteSarif(issues.Select(i => (i.Path, i.Line, 1, i.Message, i.Kind)), jsonOptions); return CommandExitCodes.Success; } + if (options.OutputFormat == OutputFormatJson && options.JsonOutputFormat == JsonOutputFormatArray) + { + Console.WriteLine(JsonSerializer.Serialize( + issues, + CliJsonSerializerContextFactory.Create(jsonOptions).ListFileIssue)); + return CommandExitCodes.Success; + } Console.WriteLine(new JsonObject { ["count"] = issues.Count, @@ -5131,6 +5159,7 @@ public static QueryCommandOptions ParseArgs( int limit = ResolveDefaultPositiveInt(DefaultLimitEnvironmentVariable, DefaultQueryLimit, "--limit", out var defaultLimitError); string? lang = null; string? kind = null; + string? severity = null; string? query = null; bool rawFts = false; bool includeBody = false; @@ -5485,6 +5514,17 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(kindError!); break; + case "--severity": + if (TryReadStringOptionValue(args, ref i, "--severity", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var severityValue, out var severityError)) + { + WarnIfDuplicateSingleValueOption("--severity", severityValue!); + severity = severityValue?.ToLowerInvariant(); + } + else + { + AddParseError(severityError!); + } + break; case "--visibility": if (TryReadStringOptionValue(args, ref i, "--visibility", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var visibilityValue, out var visibilityError)) AddVisibilityFilterValues("--visibility", visibilityValue!, visibilityFilters, AddParseError); @@ -5946,6 +5986,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) Limit = limit, Lang = lang, Kind = kind, + Severity = severity, Query = query, RawFts = rawFts, IncludeBody = includeBody, @@ -6886,9 +6927,10 @@ private static bool TryWriteUnsupportedOptionError(string commandName, string[] continue; } - var normalizedArg = TrySplitInlineOptionValue(arg, out var inlineOptionName) - ? inlineOptionName! - : arg; + var inlineValue = TrySplitInlineOptionValue(arg, out var inlineOptionName) + ? arg[(inlineOptionName!.Length + 1)..] + : null; + var normalizedArg = inlineOptionName ?? arg; if (arg.StartsWith("--check=", StringComparison.Ordinal) && supported.Contains("--check")) normalizedArg = "--check"; if (normalizedArg == "--json" @@ -6896,9 +6938,18 @@ private static bool TryWriteUnsupportedOptionError(string commandName, string[] && commandName != "search" && commandName != "files") { + if (commandName == "validate" && string.Equals(inlineValue, JsonOutputFormatArray, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + CommandErrorWriter.Write( - "--json= is only supported by 'search' and 'files'.", - "use plain `--json` here, or rerun search/files with `--json=array`.", + commandName == "validate" + ? "--json= for validate only supports 'array'." + : "--json= is only supported by 'search', 'files', and validate's array output.", + commandName == "validate" + ? "use plain `--json` or `--json=array`." + : "use plain `--json` here, rerun search/files with `--json=array`, or rerun validate with `--json=array`.", GetUsageLineOrThrow(commandName)); return true; } @@ -8734,6 +8785,7 @@ public sealed class QueryCommandOptions public int Limit { get; init; } = 20; public string? Lang { get; init; } public string? Kind { get; init; } + public string? Severity { get; init; } public List VisibilityFilters { get; init; } = []; public List ExcludeVisibilityFilters { get; init; } = []; public string? Query { get; init; } diff --git a/src/CodeIndex/Database/DbReader.cs b/src/CodeIndex/Database/DbReader.cs index f4f870d365..4728280b11 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -1747,7 +1747,11 @@ string text when DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeS /// Get all file validation issues from the index. /// インデックスから全ファイル検証問題を取得する。 /// - public List GetIssues(string? kind = null, IReadOnlyList? pathPatterns = null) + public List GetIssues( + string? kind = null, + IReadOnlyList? pathPatterns = null, + int? limit = null, + string? severity = null) { if (!_hasIssuesTable) return new List(); using var cmd = _conn.CreateCommand(); @@ -1760,6 +1764,8 @@ FROM file_issues i WHERE 1=1"; if (kind != null) sql += " AND i.kind = @kind"; + if (severity != null) + sql += " AND " + severityColumn + " = @severity"; if (pathPatterns is { Count: > 0 }) { // OR multiple path filters / 複数パスフィルタを OR で結合 @@ -1769,15 +1775,21 @@ FROM file_issues i sql += " AND (" + string.Join(" OR ", ors) + ")"; } sql += " ORDER BY f.path, i.line"; + if (limit.HasValue) + sql += " LIMIT @limit"; cmd.CommandText = sql; if (kind != null) cmd.Parameters.AddWithValue("@kind", kind); + if (severity != null) + cmd.Parameters.AddWithValue("@severity", severity); if (pathPatterns is { Count: > 0 }) { for (int i = 0; i < pathPatterns.Count; i++) cmd.Parameters.AddWithValue($"@pathPattern{i}", BuildPathLikePattern(pathPatterns[i])); } + if (limit.HasValue) + cmd.Parameters.AddWithValue("@limit", limit.Value); var results = new List(); using var reader = cmd.ExecuteTrackedReader(); diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 50981bafcd..40ecf7486b 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -137,6 +137,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains(" Legacy/stale-fold DBs fall back to ASCII NOCASE;", output); Assert.Contains(" run `cdidx backfill-fold` or check fold_ready.", output); Assert.Contains("--kind definition/symbols/hotspots/unused: symbol kind; references: reference kind (call/instantiate/subscribe/attribute/annotation); callers/callees: call-graph kinds only (call/instantiate/subscribe — metadata kinds rejected, use references instead); validate: issue kind", output); + Assert.Contains("--severity validate only: filter issues by severity: info, warning, error", output); Assert.Contains("--count Count only; search/definition/references/callers/callees/symbols/files/find/unused ignore --limit, impact/hotspots still use visible page counts", output); Assert.Contains("--no-dedup search only: return every raw overlapping chunk hit (debug/density)", output); Assert.Contains("--commits [commit-ref ...]", output); @@ -151,13 +152,13 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx map [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes]", output); Assert.Contains("cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]", output); Assert.Contains("cdidx files [query|--query |-- ] [--db ] [--json[=ndjson|array]] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]", output); - Assert.Contains("cdidx validate [--db ] [--json] [--format ] [--verbose] [--kind ] [--path ]", output); + Assert.Contains("cdidx validate [--db ] [--json[=array]] [--format ] [--verbose] [--limit |--top ] [--kind ] [--severity ] [--path ]", output); Assert.Contains("Note: if a query itself starts with '-', pass it with --query or -- ", output); Assert.DoesNotContain("cdidx validate [--db ] [--json] [--limit ] [--lang ]", output); Assert.Contains("cdidx unused [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]", output); Assert.Contains("cdidx hotspots [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--group-by ] [--group-by-name]", output); Assert.Contains("--json Output as JSON (search/files stream ndjson by", output); - Assert.Contains("--json=array for one array)", output); + Assert.Contains("search/files/validate accept --json=array for one array)", output); Assert.Contains("--lang Filter by language (aliases: bat, cmd, cshtml, razor, ts, tsx, cts, mts)", output); Assert.Contains("--bytes files: sort by size and show raw byte counts in human", output); Assert.Contains("map: show raw byte counts; JSON always keeps raw", output); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 940a471005..c47af17ee3 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -1990,8 +1990,6 @@ public void QueryEntrypoints_UnsupportedSinceReturnUsageError(string command) [InlineData("validate", "--lang", "javascript")] [InlineData("validate", "--exclude-path", "src/")] [InlineData("validate", "--exclude-tests")] - [InlineData("validate", "--limit", "nope")] - [InlineData("validate", "--top", "nope")] public void QueryEntrypoints_UnsupportedOptionsReturnUsageError(string command, string flag, string? value = null) { var args = value == null diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerValidateMetadataTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerValidateMetadataTests.cs index a0e497ea3c..617844fe30 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerValidateMetadataTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerValidateMetadataTests.cs @@ -46,4 +46,67 @@ public void RunValidate_ReplacementCharJson_IncludesOriginAndSeverity() TestProjectHelper.DeleteDirectory(projectRoot); } } + + [Fact] + public void RunValidate_SeverityFilterNarrowsIssues_Issue3008() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_validate_severity_filter"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + Directory.CreateDirectory(Path.Combine(projectRoot, "src")); + File.WriteAllText( + Path.Combine(projectRoot, "src", "literal.cs"), + "class Literal { const char Value = '\uFFFD'; }\n"); + + var bytes = new List(); + void AddUtf8(string text) => bytes.AddRange(System.Text.Encoding.UTF8.GetBytes(text)); + AddUtf8("line1 clean\n"); + AddUtf8("line2 has "); + bytes.Add(0xFF); + AddUtf8(" here\n"); + for (var i = 0; i < 200; i++) + AddUtf8("filler ascii ascii ascii\n"); + File.WriteAllBytes(Path.Combine(projectRoot, "src", "decode.cs"), bytes.ToArray()); + + var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run( + [projectRoot, "--db", dbPath, "--json", "--quiet"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, indexExitCode); + Assert.Equal(string.Empty, indexStderr); + + var (warningExitCode, warningStdout, warningStderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( + ["--db", dbPath, "--json", "--kind", "replacement_char", "--severity", "warning"], + _jsonOptions)); + var (infoExitCode, infoStdout, infoStderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( + ["--db", dbPath, "--json", "--kind", "replacement_char", "--severity", "info"], + _jsonOptions)); + + using var warningDocument = ParseJsonOutput(warningStdout); + using var infoDocument = ParseJsonOutput(infoStdout); + var warningIssues = warningDocument.RootElement.GetProperty("issues"); + var infoIssues = infoDocument.RootElement.GetProperty("issues"); + + Assert.Equal(CommandExitCodes.Success, warningExitCode); + Assert.Equal(CommandExitCodes.Success, infoExitCode); + Assert.Equal(string.Empty, warningStderr); + Assert.Equal(string.Empty, infoStderr); + Assert.True(warningIssues.GetArrayLength() > 0); + Assert.Equal(warningIssues.GetArrayLength(), warningDocument.RootElement.GetProperty("count").GetInt32()); + Assert.All(warningIssues.EnumerateArray(), issue => + { + Assert.Equal("replacement_char", issue.GetProperty("kind").GetString()); + Assert.Equal(FileIssue.SeverityWarning, issue.GetProperty("severity").GetString()); + Assert.Equal(FileIssue.OriginDecodeReplacement, issue.GetProperty("origin").GetString()); + }); + Assert.Equal(1, infoDocument.RootElement.GetProperty("count").GetInt32()); + Assert.Equal(1, infoIssues.GetArrayLength()); + Assert.Equal(FileIssue.SeverityInfo, infoIssues[0].GetProperty("severity").GetString()); + Assert.Equal(FileIssue.OriginSourceLiteral, infoIssues[0].GetProperty("origin").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerValidateTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerValidateTests.cs index 7acc526210..bc0f0b61c3 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerValidateTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerValidateTests.cs @@ -8,6 +8,145 @@ namespace CodeIndex.Tests; public partial class QueryCommandRunnerTests { + [Fact] + public void RunValidate_LimitAndTopCapReturnedIssues_Issue2992() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_validate_limit"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + Directory.CreateDirectory(Path.Combine(projectRoot, "src")); + File.WriteAllBytes( + Path.Combine(projectRoot, "src", "bom.cs"), + [0xEF, 0xBB, 0xBF, .. System.Text.Encoding.UTF8.GetBytes("class Bom {}\n")]); + File.WriteAllText( + Path.Combine(projectRoot, "src", "mixed.cs"), + "class Mixed {}\r\nclass Other {}\n"); + + var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run( + [projectRoot, "--db", dbPath, "--json", "--quiet"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, indexExitCode); + Assert.Equal(string.Empty, indexStderr); + + var (limitExitCode, limitStdout, limitStderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( + ["--db", dbPath, "--json", "--limit", "1"], + _jsonOptions)); + var (topExitCode, topStdout, topStderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( + ["--db", dbPath, "--json", "--top", "1"], + _jsonOptions)); + + using var limitDocument = ParseJsonOutput(limitStdout); + using var topDocument = ParseJsonOutput(topStdout); + + Assert.Equal(CommandExitCodes.Success, limitExitCode); + Assert.Equal(CommandExitCodes.Success, topExitCode); + Assert.Equal(string.Empty, limitStderr); + Assert.Equal(string.Empty, topStderr); + Assert.Equal(1, limitDocument.RootElement.GetProperty("count").GetInt32()); + Assert.Equal(1, limitDocument.RootElement.GetProperty("issues").GetArrayLength()); + Assert.Equal(1, topDocument.RootElement.GetProperty("count").GetInt32()); + Assert.Equal(1, topDocument.RootElement.GetProperty("issues").GetArrayLength()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Theory] + [InlineData("--limit")] + [InlineData("--top")] + public void RunValidate_InvalidLimitOrTopReturnsUsageError_Issue2992(string flag) + { + var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( + [flag, "nope"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("requires an integer between 1 and 10000", stderr); + Assert.Contains("got 'nope'", stderr); + Assert.Contains($"Usage: {ConsoleUi.GetUsageLine("validate")}", stderr); + Assert.DoesNotContain("is not supported for validate", stderr); + Assert.DoesNotContain("database not found", stderr); + } + + [Fact] + public void RunValidate_JsonArrayEmitsIssueArray_Issue3010() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_validate_json_array"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + Directory.CreateDirectory(Path.Combine(projectRoot, "src")); + File.WriteAllBytes( + Path.Combine(projectRoot, "src", "bom.cs"), + [0xEF, 0xBB, 0xBF, .. System.Text.Encoding.UTF8.GetBytes("class Bom {}\n")]); + File.WriteAllText( + Path.Combine(projectRoot, "src", "clean.cs"), + "class Clean {}\n"); + + var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run( + [projectRoot, "--db", dbPath, "--json", "--quiet"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, indexExitCode); + Assert.Equal(string.Empty, indexStderr); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( + ["--db", dbPath, "--json=array", "--limit", "1"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var root = document.RootElement; + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(JsonValueKind.Array, root.ValueKind); + Assert.Equal(1, root.GetArrayLength()); + Assert.Equal("bom", root[0].GetProperty("kind").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunValidate_JsonArrayEmptyEmitsEmptyArray_Issue3010() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_validate_json_array_empty"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + Directory.CreateDirectory(Path.Combine(projectRoot, "src")); + File.WriteAllText( + Path.Combine(projectRoot, "src", "clean.cs"), + "class Clean {}\n"); + + var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run( + [projectRoot, "--db", dbPath, "--json", "--quiet"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, indexExitCode); + Assert.Equal(string.Empty, indexStderr); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( + ["--db", dbPath, "--json=array"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var root = document.RootElement; + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(JsonValueKind.Array, root.ValueKind); + Assert.Empty(root.EnumerateArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunValidate_KindFilterNarrowsIssues() {