diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index cd2fcc3f5..8d9b2eb29 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -222,7 +222,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Search raw-FTS/literal-mode conflict coverage shares one pre-dispatch case table across flag ordering, exact aliases, count/issue-draft output, and human/JSON errors; successful issue-draft replay must preserve only its selected matching mode. Symbols compact flag/alias and summary-only JSON envelopes share one editor-format fixture. Symbols JSON array, LSP, quickfix, and SARIF location formats share one editor-format fixture; definition SARIF severity coverage reuses that fixture and asserts informational `note` output separately from warning-level diagnostic output. - Validate JSON, compact, count, and SARIF pagination/severity coverage shares one mixed informational/actionable fixture so authoritative totals, limited rows, SARIF levels, and actionability metadata cannot drift across formats; keep missing-`file_issues` degradation coverage in a separate legacy-schema fixture because availability is a distinct mutable state. + Validate JSON, compact, count, and SARIF pagination/severity coverage shares one mixed informational/actionable fixture so authoritative totals, limited rows, SARIF levels, actionability metadata, and the count envelope's API version, filter scope, readiness, and legacy total mirror cannot drift across formats; keep missing-`file_issues` count/SARIF degradation and missing-severity-column filtered-count authority coverage in separate legacy-schema fixtures because table and filter availability are distinct mutable states. Command-specific output format coverage uses a command/format matrix that checks both parser acceptance and the matching usage line; recognized shared formats without a command implementation need a separate usage-error assertion. Ad-hoc search SARIF completion coverage shares one fixture across complete, 1-of-126 limited, facet-filtered occurrence-expanded limited, bounded guarded, empty, and synthetically merged multi-run documents. Assert source/emitted/omitted counts and source-count authority in SARIF result units, applied limits, conservative truncation, null cursor state, raw-FTS and option-like-query replay commands, guard-preserving replay, and unchanged rule/location/severity fields on every run. Recipe SARIF coverage must assert bounded result counts, `recipe/query` rule identity, source locations, severity mapping, confidence, conservative truncation metadata, and stable `fingerprints.cdidx/v1` values across identical runs. Byte-budget coverage must count the complete UTF-8 stdout including JSON escaping and the final newline, exercise exact-fit and one-byte-under boundaries, Unicode, empty and multi-query runs, an individually oversized result, captured/redirected stdout, and replay metadata. Every successful output must parse as complete SARIF, omit only whole results, retain matching rules and locations, and stay within the requested cap. Below-minimum failures must emit no SARIF; non-explicit JSON failures leave stdout empty, while explicit `--json` may emit a bounded versioned error object. Also cover counting-writer measurement and replay recovery when the complete size exceeds the maximum accepted byte cap. @@ -1174,7 +1174,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" search の raw-FTS/literal-mode conflict coverage は、flag 順序、exact alias、count/issue-draft output、human/JSON error を1つの pre-dispatch case table で共有してください。成功する issue-draft replay は選択した一致 mode だけを保持することも検証します。 symbols compact flag/aliasとsummary-only JSON envelopeは1つのeditor-format fixtureを共有してください。 symbols JSON array、LSP、quickfix、SARIF location format は1つの editor-format fixture を共有し、definition SARIF severity のテストも同じ fixture を再利用して、情報レベルの `note` 出力を warning レベルの診断出力とは分けて検証してください。 - validate の JSON、compact、count、SARIF における pagination / severity coverage は、informational finding と actionable finding が混在する1つの fixture を共有し、authoritative な総件数、limited row、SARIF level、actionability metadata が format 間で drift しないことを検証してください。`file_issues` 欠落時の degradation coverage は availability が独立した mutable state なので、別の legacy-schema fixture に分けてください。 + validate の JSON、compact、count、SARIF における pagination / severity coverage は、informational finding と actionable finding が混在する1つの fixture を共有し、authoritative な総件数、limited row、SARIF level、actionability metadata、および count envelope の API version、filter scope、readiness、legacy total mirror が format 間で drift しないことを検証してください。`file_issues` 欠落時の count / SARIF degradation coverage と severity column 欠落時の filtered count authority coverage は、table と filter の availability が別々の mutable state なので、独立した legacy-schema fixture に分けてください。 コマンド別の出力形式 coverage は command / format matrix で parser の受理と対応する usage line の両方を検証してください。共通 parser が認識してもコマンド側に実装がない形式には、別途 usage error の assertion が必要です。 ad-hoc search SARIF の completion coverage は complete、1-of-126 の limited、facet filter 付き occurrence 展開後の limited、bounded guard、empty、合成した multi-run document で1つの fixture を共有します。SARIF result 単位の source / emitted / omitted count と source count の確定性、適用済み limit、保守的な truncation、null cursor state、raw FTS と option のような query の replay command、guard を保持する replay、および各 run で rule / location / severity field が不変であることを検証してください。 Recipe SARIF coverage では、上限付き result count、`recipe/query` rule identity、source location、severity mapping、confidence、保守的な truncation metadata、同一 run 間で安定する `fingerprints.cdidx/v1` を検証してください。byte-budget coverage では JSON escape と末尾改行を含む完全な UTF-8 stdout を数え、exact-fit と1 byte不足の境界、Unicode、空 run と複数 query の run、単体で oversized な result、capture / redirect した stdout、replay metadata を扱ってください。成功した出力はすべて完全な SARIF として parse でき、result を1件単位でのみ省略し、対応する rule / location を維持し、要求 cap 以下でなければなりません。最小値未満の失敗では SARIF を出力せず、明示 JSON でない失敗は stdout を空にし、明示的な `--json` では上限内の version 付き error object を出力できることも検証してください。counting writer による計測と、完全な size が受理可能な最大 byte cap を超える場合の replay recovery も扱ってください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 388cb5629..74b0ca22a 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -656,6 +656,7 @@ cdidx validate --kind replacement_char --severity warning --path src/ cdidx validate --exclude-tests --exclude-path 'fixtures/**' cdidx validate --json=array --limit 50 --path legacy/ cdidx validate --json --limit 50 --path legacy/ +cdidx validate --format count --kind replacement_char --path src/ cdidx validate --format compact --limit 50 cdidx validate --format sarif --limit 50 ``` @@ -678,7 +679,17 @@ The default JSON object and `--format compact` expose authoritative pagination metadata as `returned`, `total`, `omitted`, and `truncated`. Their `count` is the number of emitted issue rows, while `summary` is computed over all matching issues before `--limit` and is grouped by kind, severity, origin, category, and -actionability. SARIF exposes the same pagination fields under each run's +actionability. `--format count` emits the common versioned count envelope: +`count` covers all matching validation issues before `--limit`, while +`query_context` records filters such as path, kind, and severity. The legacy +`total_estimated` field remains as a compatibility mirror of `count`. +`api_version`, freshness fields, `issues_table_available`, +`file_issues_data_current`, `severity_filter_available`, +`requested_filters_available`, `index_complete`, `degraded`, and +`authoritative_count` make old databases, unavailable requested filters, and +incomplete indexes explicit; +do not treat the count as exact when `authoritative_count` is `false`. +SARIF exposes the same pagination fields under each run's `properties`, together with `issues_table_available` and `degraded` so unavailable legacy validation data is not mistaken for an authoritative zero; each result maps `info` to `note`, preserves `warning` / `error`, and carries the original `severity`, `origin`, `category`, and `actionable` @@ -4005,6 +4016,7 @@ cdidx validate --kind replacement_char --severity warning --path src/ cdidx validate --exclude-tests --exclude-path 'fixtures/**' cdidx validate --json=array --limit 50 --path legacy/ cdidx validate --json --limit 50 --path legacy/ +cdidx validate --format count --kind replacement_char --path src/ cdidx validate --format compact --limit 50 cdidx validate --format sarif --limit 50 ``` @@ -4024,7 +4036,14 @@ validation issue row には `category` と `actionable` も入り、想定済み `test_fixture` が付きます。既定の JSON object と `--format compact` には、authoritative な pagination metadata として `returned`、`total`、`omitted`、`truncated` が入ります。`count` は 実際に出力した issue row 数で、`summary` は `--limit` を適用する前の全 matching issue を対象に -kind、severity、origin、category、actionability ごとに集計します。SARIF では同じ pagination field が +kind、severity、origin、category、actionability ごとに集計します。`--format count` は共通の +versioned count envelope を出力します。`count` は `--limit` を適用する前の全 matching validation +issue を対象とし、`query_context` には path、kind、severity などの filter が記録されます。従来の +`total_estimated` は `count` の互換 mirror として維持されます。`api_version`、freshness field、 +`issues_table_available`、`file_issues_data_current`、`severity_filter_available`、 +`requested_filters_available`、`index_complete`、`degraded`、`authoritative_count` によって +旧 database、利用できない指定 filter、incomplete index を明示するため、 +`authoritative_count` が `false` の count を exact として扱わないでください。SARIF では同じ pagination field が 各 run の `properties` に入り、`issues_table_available` と `degraded` も併記されるため、利用できない legacy validation data が authoritative な 0 件と誤認されることはありません。各 result は `info` を `note` に mapping し、`warning` / `error` は 維持したうえで、元の `severity`、`origin`、`category`、`actionable` を result properties に保持します。 diff --git a/changelog.d/unreleased/4908.fixed.md b/changelog.d/unreleased/4908.fixed.md new file mode 100644 index 000000000..67e887530 --- /dev/null +++ b/changelog.d/unreleased/4908.fixed.md @@ -0,0 +1,22 @@ +--- +category: fixed +issues: + - 4908 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs + - src/CodeIndex/Cli/QueryCommandRunner.Validate.cs + - src/CodeIndex/Database/DbReader.cs + - src/CodeIndex/Database/DbReader.IndexGenerationReadiness.cs + - tests/CodeIndex.Tests/QueryCommandRunnerValidateTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerValidateMetadataTests.cs + - USER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **`validate --format count` now uses the common versioned count envelope (#4908)** — count output preserves the legacy `total_estimated` mirror and pre-limit matching semantics while adding API version, filter scope and availability, freshness, index and validation-data readiness, and explicit authoritative/degraded signals. + +## 日本語 + +- **`validate --format count` が共通の versioned count envelope を使うようになりました (#4908)** — 従来の `total_estimated` mirror と limit 適用前の matching semantics を維持しながら、API version、filter scope / availability、freshness、index / validation data の readiness、明示的な authoritative / degraded signal を追加しました。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs b/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs index 013babefb..8cc7b2ba3 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ResultEnvelopes.cs @@ -209,6 +209,8 @@ private static JsonObject BuildQueryContextJson(QueryCommandOptions options, Jso query["lang"] = options.Lang; if (options.Kind != null) query["kind"] = options.Kind; + if (options.Severity != null) + query["severity"] = options.Severity; if (options.UnusedBucket != null) query["bucket"] = options.UnusedBucket; if (options.MinUnusedConfidence != null) @@ -261,7 +263,7 @@ private static JsonObject BuildQueryContextJson(QueryCommandOptions options, Jso query["generated_file_filter_available"] = generatedFileFilterAvailable.Value; if (options.Since.HasValue) query["since"] = options.Since.Value; - if (options.CountOnly) + if (options.CountOnly || options.OutputFormat == OutputFormatCount) query["count"] = true; if (options.FirstPerFile || options.SampleSize.HasValue) query["row_selectors"] = BuildSearchRowSelectorContextJson(options); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Validate.cs b/src/CodeIndex/Cli/QueryCommandRunner.Validate.cs index 3c33a2dd4..c6fe2f33c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Validate.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Validate.cs @@ -65,12 +65,52 @@ public static int RunValidate(string[] cmdArgs, JsonSerializerOptions jsonOption var issues = issueLimit.HasValue ? allIssues.Take(issueLimit.Value).ToList() : allIssues; - var issuesAvailable = reader._hasIssuesTable; if (options.CountOnly || options.OutputFormat == OutputFormatCount) { - WriteFormattedCount(allIssues.Count, jsonOptions); + var issuesTableAvailable = reader._hasIssuesPhysicalTable; + var fileIssuesDataCurrent = reader._hasIssuesTable; + var severityFilterAvailable = reader.HasIssueSeverityColumn; + var requestedFiltersAvailable = options.Severity == null || severityFilterAvailable; + var indexCompletion = reader.GetPersistedIndexCompletion(); + var payload = BuildCountJsonPayload( + reader, + jsonOptions, + allIssues.Count, + queryOptions: options, + degraded: !issuesTableAvailable + || !fileIssuesDataCurrent + || !requestedFiltersAvailable + || !indexCompletion.IndexComplete, + extraFields: countPayload => + { + // Kept for compatibility with the pre-envelope count shape (#4908). + countPayload["total_estimated"] = allIssues.Count; + countPayload["count_kind"] = "validation_issues"; + countPayload["count_scope"] = "all_matching_issues_before_limit"; + countPayload["issues_table_available"] = issuesTableAvailable; + countPayload["file_issues_data_current"] = fileIssuesDataCurrent; + countPayload["severity_filter_available"] = severityFilterAvailable; + countPayload["requested_filters_available"] = requestedFiltersAvailable; + if (!requestedFiltersAvailable) + { + countPayload["requested_filter_unavailable_reasons"] = new JsonArray + { + "severity_column_missing", + }; + } + countPayload["index_complete"] = indexCompletion.IndexComplete; + if (!indexCompletion.IndexComplete) + { + countPayload["index_incomplete_reasons"] = JsonSerializer.SerializeToNode( + indexCompletion.IndexIncompleteReasons.ToList(), + CliJsonSerializerContextFactory.Create(jsonOptions).ListString); + } + }); + AddActiveSqliteDiagnostics(payload); + CommandOutputWriter.WriteJsonNode(payload, jsonOptions); return CommandExitCodes.Success; } + var issuesAvailable = reader._hasIssuesTable; if (issues.Count == 0) { if (options.Json) diff --git a/src/CodeIndex/Database/DbReader.IndexGenerationReadiness.cs b/src/CodeIndex/Database/DbReader.IndexGenerationReadiness.cs index 0b0fd3967..333e37286 100644 --- a/src/CodeIndex/Database/DbReader.IndexGenerationReadiness.cs +++ b/src/CodeIndex/Database/DbReader.IndexGenerationReadiness.cs @@ -4,6 +4,11 @@ namespace CodeIndex.Database; +internal sealed record PersistedIndexCompletion( + bool IndexComplete, + IReadOnlyList IndexIncompleteReasons, + bool MigrationInProgress); + internal sealed record PersistedIndexGenerationReadiness( bool GraphTableAvailable, bool GraphDataCurrent, @@ -35,30 +40,10 @@ internal PersistedIndexGenerationReadiness GetPersistedIndexGenerationReadiness( bool? hdlGraphContractReady = null, SqliteTransaction? transaction = null) { - var indexCompleteness = TryGetMetaStringInternal(DbContext.IndexCompletenessMetaKey); - var indexIncompleteReasons = MergeDistinctReasons( - ParseMetaStringList(TryGetMetaStringInternal(DbContext.IndexIncompleteReasonsMetaKey)), - ReadPersistedIndexOmissionReasons( - _conn, - _hasIssuesPhysicalTable, - ParseMetaBool(TryGetMetaStringInternal(DbContext.SymbolsOnlyGraphOmittedMetaKey)) == true, - transaction)); - var migrationInProgress = string.Equals( - TryGetMetaStringInternal(DbContext.BatchInProgressMetaKey), - "true", - StringComparison.OrdinalIgnoreCase); - if (migrationInProgress) - AddDistinctReason(indexIncompleteReasons, BatchInProgressIncompleteReason); - - var explicitlyIncomplete = string.Equals( - indexCompleteness, - "incomplete", - StringComparison.OrdinalIgnoreCase); - if (explicitlyIncomplete && indexIncompleteReasons.Count == 0) - AddDistinctReason(indexIncompleteReasons, DegradationReasonCodes.IndexIncomplete); - var indexComplete = !migrationInProgress - && !explicitlyIncomplete - && indexIncompleteReasons.Count == 0; + var indexCompletion = GetPersistedIndexCompletion(transaction); + var indexComplete = indexCompletion.IndexComplete; + var indexIncompleteReasons = indexCompletion.IndexIncompleteReasons; + var migrationInProgress = indexCompletion.MigrationInProgress; var capHits = referenceExtractionCapHits ?? GetReferenceExtractionCapHits(); var languages = indexedLanguages ?? GetIndexedLanguageCounts(); @@ -113,6 +98,40 @@ internal PersistedIndexGenerationReadiness GetPersistedIndexGenerationReadiness( migrationInProgress); } + internal PersistedIndexCompletion GetPersistedIndexCompletion( + SqliteTransaction? transaction = null) + { + var indexCompleteness = TryGetMetaStringInternal(DbContext.IndexCompletenessMetaKey); + var indexIncompleteReasons = MergeDistinctReasons( + ParseMetaStringList(TryGetMetaStringInternal(DbContext.IndexIncompleteReasonsMetaKey)), + ReadPersistedIndexOmissionReasons( + _conn, + _hasIssuesPhysicalTable, + ParseMetaBool(TryGetMetaStringInternal(DbContext.SymbolsOnlyGraphOmittedMetaKey)) == true, + transaction)); + var migrationInProgress = string.Equals( + TryGetMetaStringInternal(DbContext.BatchInProgressMetaKey), + "true", + StringComparison.OrdinalIgnoreCase); + if (migrationInProgress) + AddDistinctReason(indexIncompleteReasons, BatchInProgressIncompleteReason); + + var explicitlyIncomplete = string.Equals( + indexCompleteness, + "incomplete", + StringComparison.OrdinalIgnoreCase); + if (explicitlyIncomplete && indexIncompleteReasons.Count == 0) + AddDistinctReason(indexIncompleteReasons, DegradationReasonCodes.IndexIncomplete); + var indexComplete = !migrationInProgress + && !explicitlyIncomplete + && indexIncompleteReasons.Count == 0; + + return new PersistedIndexCompletion( + indexComplete, + indexIncompleteReasons, + migrationInProgress); + } + internal static IReadOnlyList ReadPersistedIndexOmissionReasons( SqliteConnection connection, bool hasIssuesTable, diff --git a/src/CodeIndex/Database/DbReader.cs b/src/CodeIndex/Database/DbReader.cs index e8e080b14..8807fa7d4 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -110,6 +110,7 @@ public partial class DbReader : IDisposable internal readonly bool _hasReferenceLinesTable; internal readonly bool _canUseReferenceLines; private readonly IReadOnlySet _issueColumns; + internal bool HasIssueSeverityColumn => _issueColumns.Contains("severity"); public bool IncludeGenerated { get; set; } private static readonly AsyncLocal IncludeGeneratedScope = new(); private static readonly AsyncLocal GeneratedColumnAvailableScope = new(); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerValidateMetadataTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerValidateMetadataTests.cs index f26b35e6f..f41f2a272 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerValidateMetadataTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerValidateMetadataTests.cs @@ -83,7 +83,7 @@ public void RunValidate_HumanOutputLabelsSourceLiteralTestFixtures_Issue4068() } [Fact] - public void RunValidate_CrossFormatMetadataPreservesTotalsActionabilityAndSeverity_Issues4138And4583() + public void RunValidate_CrossFormatMetadataPreservesTotalsActionabilityAndSeverity_Issues4138_4583_And4908() { using var project = TestProjectHelper.CreateTempProjectScope("cdidx_validate_summary_4138"); var projectRoot = project.Root; @@ -129,6 +129,16 @@ public void RunValidate_CrossFormatMetadataPreservesTotalsActionabilityAndSeveri var (countExitCode, countStdout, countStderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( ["--db", dbPath, "--format", "count", "--kind", "replacement_char", "--limit", "1"], _jsonOptions)); + var (emptyCountExitCode, emptyCountStdout, emptyCountStderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( + ["--db", dbPath, "--format", "count", "--kind", "replacement_char", "--severity", "error"], + _jsonOptions)); + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + new DbWriter(db).MarkIndexIncomplete(["test_incomplete"]); + } + var (incompleteCountExitCode, incompleteCountStdout, incompleteCountStderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( + ["--db", dbPath, "--format", "count", "--kind", "replacement_char"], + _jsonOptions)); Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Equal(CommandExitCodes.Success, arrayExitCode); @@ -138,6 +148,8 @@ public void RunValidate_CrossFormatMetadataPreservesTotalsActionabilityAndSeveri Assert.Equal(CommandExitCodes.Success, sarifExitCode); Assert.Equal(CommandExitCodes.Success, limitedSarifExitCode); Assert.Equal(CommandExitCodes.Success, countExitCode); + Assert.Equal(CommandExitCodes.Success, emptyCountExitCode); + Assert.Equal(CommandExitCodes.Success, incompleteCountExitCode); Assert.Equal(string.Empty, stderr); Assert.Equal(string.Empty, arrayStderr); Assert.Equal(string.Empty, compactStderr); @@ -146,6 +158,8 @@ public void RunValidate_CrossFormatMetadataPreservesTotalsActionabilityAndSeveri Assert.Equal(string.Empty, sarifStderr); Assert.Equal(string.Empty, limitedSarifStderr); Assert.Equal(string.Empty, countStderr); + Assert.Equal(string.Empty, emptyCountStderr); + Assert.Equal(string.Empty, incompleteCountStderr); using var document = ParseJsonOutput(stdout); var root = document.RootElement; @@ -211,11 +225,47 @@ public void RunValidate_CrossFormatMetadataPreservesTotalsActionabilityAndSeveri Assert.Equal(1, limitedSarifRun.GetProperty("results").GetArrayLength()); using var countDocument = ParseJsonOutput(countStdout); - Assert.Equal(2, countDocument.RootElement.GetProperty("count").GetInt32()); + var countRoot = countDocument.RootElement; + Assert.Equal(summary.GetProperty("total").GetInt32(), countRoot.GetProperty("count").GetInt32()); + Assert.Equal(2, countRoot.GetProperty("total_estimated").GetInt32()); + Assert.Equal(JsonOutputContract.ApiVersion, countRoot.GetProperty("api_version").GetString()); + Assert.Equal("validation_issues", countRoot.GetProperty("count_kind").GetString()); + Assert.Equal("all_matching_issues_before_limit", countRoot.GetProperty("count_scope").GetString()); + Assert.True(countRoot.GetProperty("issues_table_available").GetBoolean()); + Assert.True(countRoot.GetProperty("file_issues_data_current").GetBoolean()); + Assert.True(countRoot.GetProperty("severity_filter_available").GetBoolean()); + Assert.True(countRoot.GetProperty("requested_filters_available").GetBoolean()); + Assert.True(countRoot.GetProperty("index_complete").GetBoolean()); + Assert.True(countRoot.GetProperty("freshness_available").GetBoolean()); + Assert.False(countRoot.GetProperty("degraded").GetBoolean()); + Assert.True(countRoot.GetProperty("authoritative_count").GetBoolean()); + Assert.True(countRoot.GetProperty("query_context").GetProperty("count").GetBoolean()); + Assert.Equal("replacement_char", countRoot.GetProperty("query_context").GetProperty("kind").GetString()); + Assert.Equal(1, countRoot.GetProperty("query_context").GetProperty("limit").GetInt32()); + Assert.False(countRoot.TryGetProperty("issues", out _)); + + using var emptyCountDocument = ParseJsonOutput(emptyCountStdout); + var emptyCountRoot = emptyCountDocument.RootElement; + Assert.Equal(0, emptyCountRoot.GetProperty("count").GetInt32()); + Assert.Equal(0, emptyCountRoot.GetProperty("total_estimated").GetInt32()); + Assert.Equal("error", emptyCountRoot.GetProperty("query_context").GetProperty("severity").GetString()); + Assert.True(emptyCountRoot.GetProperty("authoritative_count").GetBoolean()); + + using var incompleteCountDocument = ParseJsonOutput(incompleteCountStdout); + var incompleteCountRoot = incompleteCountDocument.RootElement; + Assert.Equal(2, incompleteCountRoot.GetProperty("count").GetInt32()); + Assert.True(incompleteCountRoot.GetProperty("issues_table_available").GetBoolean()); + Assert.True(incompleteCountRoot.GetProperty("file_issues_data_current").GetBoolean()); + Assert.False(incompleteCountRoot.GetProperty("index_complete").GetBoolean()); + Assert.Contains( + incompleteCountRoot.GetProperty("index_incomplete_reasons").EnumerateArray(), + reason => reason.GetString() == "test_incomplete"); + Assert.True(incompleteCountRoot.GetProperty("degraded").GetBoolean()); + Assert.False(incompleteCountRoot.GetProperty("authoritative_count").GetBoolean()); } [Fact] - public void RunValidate_EmptySarifReportsUnavailableIssueDataAsDegraded_Issue4583() + public void RunValidate_EmptySarifAndCountReportUnavailableIssueDataAsDegraded_Issues4583And4908() { using var project = TestProjectHelper.CreateTempProjectScope("cdidx_validate_sarif_degraded_4583"); var dbPath = TestProjectHelper.CreateProjectDb(project.Root); @@ -230,9 +280,14 @@ public void RunValidate_EmptySarifReportsUnavailableIssueDataAsDegraded_Issue458 var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( ["--db", dbPath, "--format", "sarif"], _jsonOptions)); + var (countExitCode, countStdout, countStderr) = CaptureConsole(() => QueryCommandRunner.RunValidate( + ["--db", dbPath, "--format", "count"], + _jsonOptions)); Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(CommandExitCodes.Success, countExitCode); Assert.Equal(string.Empty, stderr); + Assert.Equal(string.Empty, countStderr); using var document = ParseJsonOutput(stdout); var run = document.RootElement.GetProperty("runs")[0]; var properties = run.GetProperty("properties"); @@ -240,6 +295,74 @@ public void RunValidate_EmptySarifReportsUnavailableIssueDataAsDegraded_Issue458 Assert.False(properties.GetProperty("issues_table_available").GetBoolean()); Assert.True(properties.GetProperty("degraded").GetBoolean()); Assert.Empty(run.GetProperty("results").EnumerateArray()); + + using var countDocument = ParseJsonOutput(countStdout); + var countRoot = countDocument.RootElement; + Assert.Equal(0, countRoot.GetProperty("count").GetInt32()); + Assert.Equal(0, countRoot.GetProperty("total_estimated").GetInt32()); + Assert.False(countRoot.GetProperty("issues_table_available").GetBoolean()); + Assert.False(countRoot.GetProperty("file_issues_data_current").GetBoolean()); + Assert.False(countRoot.GetProperty("severity_filter_available").GetBoolean()); + Assert.True(countRoot.GetProperty("requested_filters_available").GetBoolean()); + Assert.True(countRoot.GetProperty("index_complete").GetBoolean()); + Assert.False(countRoot.TryGetProperty("index_incomplete_reasons", out _)); + Assert.True(countRoot.GetProperty("degraded").GetBoolean()); + Assert.False(countRoot.GetProperty("authoritative_count").GetBoolean()); + } + + [Fact] + public void RunValidate_CountMarksLegacySeverityFilterAsUnavailable_Issue4908() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_validate_count_legacy_severity_4908"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + WriteUtf8BomFile(project.Root, "src/bom.cs", "class Bom {}\n"); + + var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run( + [project.Root, "--db", dbPath, "--json", "--quiet"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, indexExitCode); + Assert.Equal(string.Empty, indexStderr); + + using (var connection = new SqliteConnection($"Data Source={dbPath}")) + { + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "ALTER TABLE file_issues DROP COLUMN severity"; + command.ExecuteNonQuery(); + } + + var (unfilteredExitCode, unfilteredStdout, unfilteredStderr) = CaptureConsole( + () => QueryCommandRunner.RunValidate( + ["--db", dbPath, "--format", "count"], + _jsonOptions)); + var (filteredExitCode, filteredStdout, filteredStderr) = CaptureConsole( + () => QueryCommandRunner.RunValidate( + ["--db", dbPath, "--format", "count", "--severity", "warning"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, unfilteredExitCode); + Assert.Equal(CommandExitCodes.Success, filteredExitCode); + Assert.Equal(string.Empty, unfilteredStderr); + Assert.Equal(string.Empty, filteredStderr); + + using var unfilteredDocument = ParseJsonOutput(unfilteredStdout); + var unfilteredRoot = unfilteredDocument.RootElement; + Assert.Equal(1, unfilteredRoot.GetProperty("count").GetInt32()); + Assert.False(unfilteredRoot.GetProperty("severity_filter_available").GetBoolean()); + Assert.True(unfilteredRoot.GetProperty("requested_filters_available").GetBoolean()); + Assert.False(unfilteredRoot.GetProperty("degraded").GetBoolean()); + Assert.True(unfilteredRoot.GetProperty("authoritative_count").GetBoolean()); + + using var filteredDocument = ParseJsonOutput(filteredStdout); + var filteredRoot = filteredDocument.RootElement; + Assert.Equal(0, filteredRoot.GetProperty("count").GetInt32()); + Assert.False(filteredRoot.GetProperty("severity_filter_available").GetBoolean()); + Assert.False(filteredRoot.GetProperty("requested_filters_available").GetBoolean()); + Assert.Contains( + filteredRoot.GetProperty("requested_filter_unavailable_reasons").EnumerateArray(), + reason => reason.GetString() == "severity_column_missing"); + Assert.True(filteredRoot.GetProperty("degraded").GetBoolean()); + Assert.False(filteredRoot.GetProperty("authoritative_count").GetBoolean()); } private static void AssertValidatePageMetadata( diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerValidateTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerValidateTests.cs index 9eb80541c..72056ca3b 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerValidateTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerValidateTests.cs @@ -120,7 +120,7 @@ public void RunValidate_JsonArrayEmptyEmitsEmptyArray_Issue3010() } [Fact] - public void RunValidate_FormatCountThenJsonKeepsCountShape_Issue3896() + public void RunValidate_FormatCountThenJsonKeepsCompatibleEnvelope_Issues3896And4908() { using var project = TestProjectHelper.CreateTempProjectScope("cdidx_validate_count_json_3896"); var projectRoot = project.Root; @@ -143,6 +143,10 @@ public void RunValidate_FormatCountThenJsonKeepsCountShape_Issue3896() var root = document.RootElement; Assert.Equal(1, root.GetProperty("count").GetInt32()); Assert.Equal(1, root.GetProperty("total_estimated").GetInt32()); + Assert.Equal(JsonOutputContract.ApiVersion, root.GetProperty("api_version").GetString()); + Assert.Equal("validation_issues", root.GetProperty("count_kind").GetString()); + Assert.Equal("all_matching_issues_before_limit", root.GetProperty("count_scope").GetString()); + Assert.True(root.GetProperty("authoritative_count").GetBoolean()); Assert.False(root.TryGetProperty("issues", out _)); }