From 1a5d0dc47ce8b9c21ceed175d9a11f50b01dc924 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 6 Sep 2026 05:19:27 +0900 Subject: [PATCH 1/3] Add conservative local audit baselines and review annotations (#5261) --- DEVELOPER_GUIDE.md | 12 + README.md | 20 ++ TESTING_GUIDE.md | 4 + changelog.d/unreleased/5261.added.md | 16 ++ src/CodeIndex/Cli/AuditBaselineStore.cs | 207 +++++++++++++++++ src/CodeIndex/Cli/CliCommandMetadata.cs | 1 + src/CodeIndex/Cli/CliFlagSchema.cs | 8 + src/CodeIndex/Cli/ConsoleUi.Help.cs | 3 + src/CodeIndex/Cli/ConsoleUi.cs | 7 + .../Cli/QueryCommandRunner.AuditAll.cs | 26 ++- .../Cli/QueryCommandRunner.AuditBaseline.cs | 214 ++++++++++++++++++ .../Cli/QueryCommandRunner.SearchAliases.cs | 2 + .../AuditBaselineIssue5261Tests.cs | 206 +++++++++++++++++ .../golden/audit-baseline-compare.json | 38 ++++ .../golden/audit-baseline-export.json | 9 + 15 files changed, 764 insertions(+), 9 deletions(-) create mode 100644 changelog.d/unreleased/5261.added.md create mode 100644 src/CodeIndex/Cli/AuditBaselineStore.cs create mode 100644 src/CodeIndex/Cli/QueryCommandRunner.AuditBaseline.cs create mode 100644 tests/CodeIndex.Tests/AuditBaselineIssue5261Tests.cs create mode 100644 tests/CodeIndex.Tests/golden/audit-baseline-compare.json create mode 100644 tests/CodeIndex.Tests/golden/audit-baseline-export.json diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 682ab852f..34e093e35 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2,6 +2,12 @@ > **[日本語版はこちら / Japanese version](#開発者ガイド)** +## Audit baseline contract + +`AuditBaselineStore` implements the local v1 baseline schema; `QueryCommandRunner.AuditBaseline.cs` consumes the same bounded recipe runs as `audit --all`. The `baseline-export`, `baseline-compare`, and `baseline-review` audit subcommands do not migrate SQLite or use GitHub. Preserve SHA-256 match/context identities without line coordinates, canonical case-preserving relative paths, schema/identity versions, effective scope and recipe fingerprints, workspace and index-generation provenance, coverage reasons, and count authority. Index generations may differ after a valid refresh; workspace/scope/recipe/identity contracts must match. Missing legacy provenance degrades comparison conservatively. + +Absence can become resolved only when both snapshots have complete coverage. Retain unknown classifications for capped, stale, partial, failed, cancelled, changed-scope, changed-recipe, insufficient-evidence, duplicate, or possible-rename cases. Review annotations bind actor/reason/time to the stored context; changed evidence cannot inherit a safe decision. Comparison counts distinct identity groups and separately exposes observation counts. Bound files to 8 MiB, depth to 16, observations to 10,000, and comparison rows to 200 with exact omission accounting. Baselines omit source snippets, use the existing atomic sensitive writer, and require explicit overwrite. Windows paths emitted by the index already use slash separators; ambiguous literal backslashes fail closed. Keep command help, contextual completions, README, and behavioral tests synchronized. + ## Indexed file-size policy For indexing, a nonblank invalid environment value retains the existing warning-and-default behavior; an explicit valid limit still takes precedence. @@ -4326,6 +4332,12 @@ For symmetry, the MCP server no longer echoes raw `Exception.Message` content in # 開発者ガイド +## 監査 baseline 契約 + +`AuditBaselineStore` がローカル v1 baseline スキーマを実装し、`QueryCommandRunner.AuditBaseline.cs` は `audit --all` と同じ上限付きレシピ実行を利用します。audit の `baseline-export`、`baseline-compare`、`baseline-review` サブコマンドは SQLite の移行や GitHub 接続を行いません。行番号を含まない SHA-256 の一致・文脈識別、大小文字を保持する正規相対パス、スキーマ・識別バージョン、実効範囲・レシピの指紋、ワークスペース・索引世代の由来、完全性の理由、件数の確実性を維持してください。正しい索引更新後の世代差は許容しますが、ワークスペース・範囲・レシピ・識別契約は一致必須です。旧データの由来情報が欠ける場合は保守的に比較を降格します。 + +不在を解決済みとできるのは両スナップショットが完全な場合だけです。上限到達・古い索引・部分実行・失敗・取消・範囲変更・レシピ変更・証拠不足・重複・リネーム候補は不明のまま保持します。注釈は担当者・理由・時刻を保存された文脈に結び付け、証拠変更後は安全判定を継承しません。比較では識別グループ数と観測数を別々に示します。ファイル8 MiB、深度16、10,000観測、比較出力200行の上限と正確な省略数を維持してください。ソース抜粋は保存せず、既存のアトミックな機密ファイル保存処理と明示的な上書きを使用します。索引の Windows パスは既にスラッシュ区切りであり、曖昧なリテラルのバックスラッシュは拒否します。ヘルプ・文脈別補完・README・振る舞いのテストを同期してください。 + ### 索引のファイルサイズ方針 索引作成時の空白以外の不正な環境変数値は、従来どおり警告して既定値へ戻します。有効な上限の明示指定があれば、そちらを優先します。 diff --git a/README.md b/README.md index 440240bac..fae7cab35 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,16 @@ agents, MCP clients, and LSP-native editors can run fast full-text, symbol, dependency, and inspection queries without rescanning the same tree for every query. +## Local audit baselines + +`cdidx audit baseline-export .cdidx/audit-baseline.json --recipe risky-code` runs the existing audit engine and saves a local baseline. Omit `--recipe` to select all registered recipes. Refresh the index after source changes, then run `cdidx audit baseline-compare .cdidx/audit-baseline.json --recipe risky-code --json`. Use the same filters and limits for comparable runs. Each command accepts `--db`, `--lang`, `--path`, `--exclude-path`, `--exclude-tests`, `--audit-scope`, `--since`, `--limit`, and `--total-limit` as shown in command help; defaults are 1,000 rows per query and 10,000 total rows. + +Comparison reports bounded `new`, `unchanged`, `resolved`, and `unknown` identity groups, totals, observation counts, and omissions. Missing findings stay `unknown` when either run is stale, partial, capped, failed, cancelled, or has different recipes, filters, workspace, or identity contracts. Duplicate evidence and possible renames are never guessed. Line numbers are excluded from identity; changed context requires review. Exit `11` identifies incomplete coverage or unknown classifications; export may save an incomplete baseline, explicitly marked as such. Cancellation never publishes an export. + +To record a safe finding, copy its `id` from the baseline or comparison and run `cdidx audit baseline-review .cdidx/audit-baseline.json --actor --reason --overwrite`. This requires a complete baseline and an unambiguous entry. The reason, actor, time, and evidence remain traceable; `review_applies` is true only for unchanged compatible evidence. Compare never edits the baseline. Export refuses replacement without `--overwrite`; explicit replacement starts a new baseline without previous annotations. + +Files contain hashes of bounded match/context evidence, normalized relative paths, effective filters, and index/recipe provenance, with no source snippets. Store them under `.cdidx/` or outside the indexed source scope. Limits are 8 MiB, JSON depth 16, 10,000 observations, and 200 comparison rows with omission counts. Writes are atomic and use POSIX mode `0600` (Windows inherits directory ACLs). Paths retain case; ambiguous backslashes, absolute paths, and parent segments are rejected. This CLI-only workflow needs neither GitHub credentials nor a database migration. Regenerate installed shell completions after upgrading. + ## File-size limits and freshness For indexing, a nonblank invalid `CDIDX_MAX_FILE_BYTES` value falls back to the 4 MiB default, as the warning indicates, unless an explicit valid limit is supplied. @@ -287,6 +297,16 @@ For commercial use, integration, and naming guidance, see # cdidx(日本語) +### ローカル監査 baseline + +`cdidx audit baseline-export .cdidx/audit-baseline.json --recipe risky-code` は既存の監査エンジンを実行し、ローカル baseline を保存します。`--recipe` を省略すると登録済みの全レシピが対象です。ソース変更後に索引を更新し、`cdidx audit baseline-compare .cdidx/audit-baseline.json --recipe risky-code --json` で比較します。比較時は同じフィルターと上限を指定してください。各コマンドはヘルプ記載の `--db`、`--lang`、`--path`、`--exclude-path`、`--exclude-tests`、`--audit-scope`、`--since`、`--limit`、`--total-limit` に対応します。既定値はクエリごとに1,000行、全体で10,000行です。 + +比較は `new`、`unchanged`、`resolved`、`unknown` の識別グループ、総数、観測数、省略数を返します。どちらかの実行が古い、不完全、上限到達、失敗、取消済み、またはレシピ・フィルター・ワークスペース・識別契約が異なる場合、消えた検出結果は `unknown` のままです。重複した証拠やリネーム候補を推測で対応付けません。行番号は識別から除外し、文脈が変化した場合は再レビューが必要です。不完全な監査または不明な分類は終了コード `11` となります。export は不完全であることを明記した baseline を保存できますが、取消時は公開しません。 + +安全確認済みの記録には baseline または比較結果の `id` を用い、`cdidx audit baseline-review .cdidx/audit-baseline.json --actor --reason --overwrite` を実行します。完全な baseline 内の一意な検出結果だけが対象です。理由・担当者・時刻・証拠を保存し、互換性のある証拠が変わっていない場合だけ `review_applies` が真になります。compare は baseline を変更しません。export による置換には `--overwrite` が必要で、明示的な置換後は以前の注釈を含まない新しい baseline になります。 + +ファイルには上限付きの一致・文脈のハッシュ、正規化した相対パス、実効フィルター、索引・レシピの由来を記録し、ソースの抜粋は保存しません。`.cdidx/` または索引対象外に保存してください。上限は8 MiB、JSON 深度16、10,000観測、比較出力200行で、省略数を明示します。保存はアトミックで、POSIX は `0600`、Windows は親ディレクトリの ACL を継承します。パスの大文字小文字を保持し、曖昧なバックスラッシュ・絶対パス・親ディレクトリ要素は拒否します。CLI 専用で、GitHub 認証や DB 移行は不要です。更新後はインストール済みのシェル補完を再生成してください。 + ### ファイルサイズ上限と鮮度チェック 索引作成時に空白以外の不正な`CDIDX_MAX_FILE_BYTES`が指定されている場合、有効な上限の明示指定がなければ、警告の案内どおり既定の4 MiBに戻ります。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 6e4c9ead8..45c339a91 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -8,6 +8,8 @@ If you change test code, test helpers, test execution flow, or testing conventio ## Quick Start +`AuditBaselineIssue5261Tests` covers compatible deltas, moved lines, changed reviewed evidence, incomplete/legacy metadata, ambiguous duplicates/renames, bounded counts, private atomic writes, invalid paths and malformed/oversized input, plus actual index/recipe CLI export and compare. Keep human/JSON error identity and contextual help/completion checks on both net8.0 and net9.0. Use the console-sensitive collection for CLI capture and isolated temporary projects for baseline files. + Issue #5258 coverage in `IndexCommandRunnerIssue5258Tests.cs` checks explicit/environment/default size limits, ordinary root and shared-workspace checks, scoped updates, full refreshes, missing/invalid legacy policy, actual deletion, failed reads, MCP persistence/dry runs, and the retained read ceiling on both frameworks. Keep environment and current-directory changes inside the SQLite pool sensitive collection and restore them in `finally`. ```bash @@ -1178,6 +1180,8 @@ Check the following: # テストガイド +`AuditBaselineIssue5261Tests` は互換性のある差分、行移動、レビュー済み証拠の変化、不完全・旧形式のメタデータ、曖昧な重複・リネーム、上限付き件数、機密ファイルのアトミック保存、不正パス・壊れた入力・過大入力、および実際の索引・レシピによる CLI 保存と比較を検証します。human/JSON のエラー識別と文脈別ヘルプ・補完を net8.0 と net9.0 の両方で維持してください。CLI 出力の捕捉は console-sensitive コレクションに置き、baseline は分離した一時プロジェクトに保存してください。 + `IndexCommandRunnerIssue5258Tests.cs`のIssue #5258検証は、明示指定・環境変数・既定のサイズ上限、通常のルート/共有workspaceチェック、部分更新、全体更新、旧方針の欠落・不正値、実際の削除、読み取り失敗、MCPの保存とdry run、読み取り上限の維持を両フレームワークで確認します。環境変数とカレントディレクトリの変更はSQLite pool sensitiveコレクション内に置き、`finally`で復元してください。 このドキュメントは、`cdidx` のテストスイートがどう構成されているか、どのように安全にテストを追加・更新するか、そして挙動やテスト基盤を変更したときに従うべき規約をまとめたものです。 diff --git a/changelog.d/unreleased/5261.added.md b/changelog.d/unreleased/5261.added.md new file mode 100644 index 000000000..65446a14c --- /dev/null +++ b/changelog.d/unreleased/5261.added.md @@ -0,0 +1,16 @@ +--- +category: added +issues: + - 5261 +affected: + - src/CodeIndex/Cli/AuditBaselineStore.cs + - src/CodeIndex/Cli/QueryCommandRunner.AuditBaseline.cs +--- + +## English + +- Added local audit baseline export, comparison, and reviewed-safe annotations. Versioned private files retain evidence hashes and coverage provenance; incomplete or ambiguous absence remains unknown, changed evidence requires review, and bounded comparison output reports new, unchanged, resolved, and unknown totals with omissions. + +## 日本語 + +- ローカル監査 baseline の保存・比較・安全確認済み注釈を追加しました。バージョン付きの非公開ファイルに証拠ハッシュと完全性の由来を記録し、不完全または曖昧な不在は不明のまま扱い、証拠変更時は再レビューを必要とします。上限付き比較出力には新規・変更なし・解決済み・不明の総数と省略数を表示します。 diff --git a/src/CodeIndex/Cli/AuditBaselineStore.cs b/src/CodeIndex/Cli/AuditBaselineStore.cs new file mode 100644 index 000000000..8345e966b --- /dev/null +++ b/src/CodeIndex/Cli/AuditBaselineStore.cs @@ -0,0 +1,207 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Indexer.Extensibility; + +namespace CodeIndex.Cli; + +// Baselines are local evidence records, never executable recipe definitions. +internal static class AuditBaselineStore +{ + internal const int MaxBytes = 8 * 1024 * 1024; + internal const int MaxEntries = 10_000; + internal const int ResultLimit = 200; + internal const string Recovery = "Refresh the index and repeat the same recipes and filters with sufficient --limit and --total-limit. Review unknown or changed evidence manually; never treat unknown as resolved."; + + internal static string Hash(params string[] parts) + => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("\0", parts)))).ToLowerInvariant(); + + internal static string NormalizePath(string path) + { + // A POSIX literal backslash is ambiguous with a Windows separator: fail closed. + if (path.Length is 0 or > 1024 || path.Any(char.IsControl) + || path.StartsWith('/') || path.Contains(':') || path.Contains('\\')) + throw new InvalidDataException("Baseline requires unambiguous repository-relative slash paths."); + if (path.Split('/').Any(part => part is "" or "." or "..")) + throw new InvalidDataException("Baseline path is not canonical."); + return path; + } + + internal static JsonObject Read(string path) + { + if ((File.GetAttributes(path) & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device)) != 0 + || !ExecutableExtensionBoundary.IsRegularFilePath(path)) + throw new InvalidDataException("Baseline input must be a regular file."); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + if (stream.Length > MaxBytes) + throw new InvalidDataException("Baseline exceeds the 8 MiB input limit."); + using var buffer = new MemoryStream(); + var block = new byte[8192]; + int read; + while ((read = stream.Read(block, 0, Math.Min(block.Length, MaxBytes + 1 - (int)buffer.Length))) > 0) + { + buffer.Write(block, 0, read); + if (buffer.Length > MaxBytes) + throw new InvalidDataException("Baseline exceeds the 8 MiB input limit."); + } + var root = JsonNode.Parse(buffer.ToArray(), documentOptions: new JsonDocumentOptions { MaxDepth = 16 }) as JsonObject + ?? throw new InvalidDataException("Expected a baseline JSON object."); + Validate(root); + return root; + } + + internal static void Validate(JsonObject root) + { + if (Text(root, "format") != "cdidx-audit-baseline" || Number(root, "schema_version") != 1 + || root["entries"] is not JsonArray entries || entries.Count > MaxEntries) + throw new InvalidDataException("Unsupported or invalid baseline schema; export a new baseline."); + if (root["coverage_reasons"] != null) + { + if (root["coverage_reasons"] is not JsonArray coverage || coverage.Count > 32 + || coverage.Any(reason => reason is not JsonValue value || !value.TryGetValue(out var text) + || text.Length is 0 or > 64 || text.Any(character => !char.IsAsciiLetterOrDigit(character) && character != '_'))) + throw new InvalidDataException("Invalid baseline coverage reasons."); + } + foreach (var node in entries) + { + if (node is not JsonObject entry) + throw new InvalidDataException("Invalid baseline entry."); + NormalizePath(Text(entry, "path")); + foreach (var key in new[] { "recipe", "query", "id", "match", "context" }) + { + var value = Text(entry, key); + if (value.Length is 0 or > 256 || value.Any(char.IsControl)) + throw new InvalidDataException("Invalid baseline identity."); + } + if (Number(entry, "line") < 1 + || Text(entry, "id") != Hash(Text(entry, "recipe"), Text(entry, "query"), Text(entry, "path"), Text(entry, "match"))) + throw new InvalidDataException("Invalid baseline identity or location."); + if (entry["review"] is JsonObject review) + { + CheckAnnotation(Text(review, "actor")); + CheckAnnotation(Text(review, "reason")); + if (Text(review, "recorded_at").Length > 64 || !DateTimeOffset.TryParse(Text(review, "recorded_at"), + System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind, out _)) + throw new InvalidDataException("Invalid review timestamp."); + if (Text(review, "context") != Text(entry, "context") || Text(review, "state") != "reviewed_safe") + throw new InvalidDataException("Review evidence does not match its entry."); + } + else if (entry["review"] != null) + throw new InvalidDataException("Invalid review annotation."); + } + } + + internal static void Write(string path, JsonObject baseline, bool overwrite) + { + Validate(baseline); + var json = baseline.ToJsonString(); + if (Encoding.UTF8.GetByteCount(json) > MaxBytes) + throw new InvalidDataException("Baseline exceeds the 8 MiB output limit; narrow the audit scope."); + AtomicFileWriter.WriteText(path, json, new UTF8Encoding(false), AtomicFileWriter.WriteProfile.Sensitive, overwrite); + } + + internal static void Review(JsonObject baseline, string id, string actor, string reason) + { + CheckAnnotation(actor); + CheckAnnotation(reason); + var matches = baseline["entries"]!.AsArray().OfType().Where(entry => Text(entry, "id") == id).ToArray(); + if (matches.Length != 1 || !Flag(matches[0], "identity_complete") || !CoverageComplete(baseline)) + throw new InvalidDataException("Review requires one unambiguous entry in a complete baseline."); + matches[0]["review"] = new JsonObject + { + ["state"] = "reviewed_safe", + ["actor"] = actor, + ["reason"] = reason, + ["context"] = Text(matches[0], "context"), + ["recorded_at"] = DateTimeOffset.UtcNow.ToString("O"), + }; + } + + internal static JsonObject Compare(JsonObject baseline, JsonObject current) + { + Validate(baseline); + Validate(current); + var reasons = new JsonArray(); + foreach (var key in new[] { "identity_version", "recipe_schema_version", "scope_fingerprint", "recipe_fingerprint", "workspace_fingerprint" }) + if (Text(baseline, key).Length == 0 || Text(baseline, key) != Text(current, key)) + reasons.Add(key + "_incomparable"); + if (!CoverageComplete(baseline)) reasons.Add("baseline_coverage_incomplete"); + if (!CoverageComplete(current)) reasons.Add("current_coverage_incomplete"); + if (Text(baseline, "index_generation").Length == 0 || Text(current, "index_generation").Length == 0) + reasons.Add("index_provenance_unavailable"); + var compatible = reasons.Count == 0; + var oldEntries = baseline["entries"]!.AsArray().OfType().ToArray(); + var newEntries = current["entries"]!.AsArray().OfType().ToArray(); + var oldGroups = oldEntries.GroupBy(entry => Text(entry, "id")).ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal); + var newGroups = newEntries.GroupBy(entry => Text(entry, "id")).ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal); + var moved = oldEntries.Concat(newEntries).GroupBy(entry => Hash(Text(entry, "recipe"), Text(entry, "query"), Text(entry, "match"))) + .Where(group => group.Select(entry => Text(entry, "path")).Distinct(StringComparer.Ordinal).Count() > 1) + .Select(group => group.Key).ToHashSet(StringComparer.Ordinal); + var totals = new JsonObject { ["new"] = 0, ["unchanged"] = 0, ["resolved"] = 0, ["unknown"] = 0 }; + var results = new JsonArray(); + foreach (var id in oldGroups.Keys.Union(newGroups.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal)) + { + oldGroups.TryGetValue(id, out var oldGroup); + newGroups.TryGetValue(id, out var newGroup); + var entry = (newGroup ?? oldGroup)![0]; + var ambiguous = oldGroup?.Length > 1 || newGroup?.Length > 1 + || moved.Contains(Hash(Text(entry, "recipe"), Text(entry, "query"), Text(entry, "match"))); + var evidenceComplete = (oldGroup ?? []).Concat(newGroup ?? []).All(item => Flag(item, "identity_complete")); + var contextChanged = oldGroup != null && newGroup != null && Text(oldGroup[0], "context") != Text(newGroup[0], "context"); + var classification = !compatible || ambiguous || !evidenceComplete || contextChanged ? "unknown" + : oldGroup == null ? "new" : newGroup == null ? "resolved" : "unchanged"; + totals[classification] = Number(totals, classification) + 1; + if (results.Count >= ResultLimit) continue; + results.Add(new JsonObject + { + ["id"] = id, + ["recipe"] = Text(entry, "recipe"), + ["query"] = Text(entry, "query"), + ["path"] = Text(entry, "path"), + ["line"] = Number(entry, "line"), + ["classification"] = classification, + ["reason"] = !compatible ? "coverage_or_contract_incomparable" : ambiguous ? "ambiguous_identity_or_rename" + : !evidenceComplete ? "identity_evidence_incomplete" : contextChanged ? "evidence_changed_requires_review" : "compatible_evidence", + ["baseline_observation_count"] = oldGroup?.Length ?? 0, + ["current_observation_count"] = newGroup?.Length ?? 0, + ["review"] = oldGroup?.Length == 1 ? oldGroup[0]["review"]?.DeepClone() : null, + ["review_applies"] = classification == "unchanged" && oldGroup![0]["review"] != null, + }); + } + var total = totals.Sum(pair => pair.Value!.GetValue()); + return new JsonObject + { + ["api_version"] = "1", + ["mode"] = "audit_baseline_compare", + ["comparable"] = compatible, + ["reasons"] = reasons, + ["baseline_coverage_reasons"] = baseline["coverage_reasons"]?.DeepClone(), + ["current_coverage_reasons"] = current["coverage_reasons"]?.DeepClone(), + ["totals"] = totals, + ["count_semantics"] = "distinct_identity_groups", + ["total"] = total, + ["returned"] = results.Count, + ["omitted_count"] = total - results.Count, + ["truncated"] = total > results.Count, + ["limit"] = ResultLimit, + ["baseline_observation_count"] = oldEntries.Length, + ["current_observation_count"] = newEntries.Length, + ["results"] = results, + ["recovery_guidance"] = Recovery, + }; + } + + internal static string Text(JsonObject node, string key) => node[key] is JsonValue value && value.TryGetValue(out var text) ? text : ""; + internal static int Number(JsonObject node, string key) => node[key] is JsonValue value && value.TryGetValue(out var number) ? number : -1; + internal static bool Flag(JsonObject node, string key) => node[key] is JsonValue value && value.TryGetValue(out var flag) && flag; + + private static bool CoverageComplete(JsonObject node) => Flag(node, "complete") && Flag(node, "count_authoritative") + && node["coverage_reasons"] is JsonArray reasons && reasons.Count == 0; + + private static void CheckAnnotation(string value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 512 || value.Any(char.IsControl)) + throw new InvalidDataException("Review actor and reason must be nonblank, control-free text of at most 512 characters."); + } +} diff --git a/src/CodeIndex/Cli/CliCommandMetadata.cs b/src/CodeIndex/Cli/CliCommandMetadata.cs index 14c6cbe3f..15a303798 100644 --- a/src/CodeIndex/Cli/CliCommandMetadata.cs +++ b/src/CodeIndex/Cli/CliCommandMetadata.cs @@ -34,6 +34,7 @@ internal static class CliCommandMetadata ("config", ReadOnly("show")), ("db", ReadOnly("integrity", "schema", "prune", "checkpoint", "checkpoints", "restore", "restore-backups")), ("recipes", ReadOnly("list")), + ("audit", ReadOnly("baseline-export", "baseline-compare", "baseline-review")), ("suggestions", ReadOnly("list", "show", "export", "add", "update", "delete")), ("export", ReadOnly("ctags")), ]); diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 5b77e750c..f44dcebda 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -890,6 +890,14 @@ public static IReadOnlySet GetAcceptedFlagNamesForCommand(string command /// public static IReadOnlyList GetCompletionFlagsForCommand(string command, string? subcommand = null) { + if (command == "audit" && subcommand is "baseline-export" or "baseline-compare" or "baseline-review") + { + var names = subcommand == "baseline-review" + ? Set("--actor", "--reason", "--overwrite", "--json") + : Set("--recipe", "--db", "--lang", "--path", "--exclude-path", "--exclude-tests", "--audit-scope", "--since", "--limit", "--total-limit", "--json"); + return All.Where(flag => names.Contains(flag.Name) || subcommand == "baseline-export" && flag.Name == "--overwrite") + .Select(flag => flag with { ShortName = null }).ToList(); + } return All.Where(f => f.AppliesToCompletionContext(command, subcommand)).ToList(); } diff --git a/src/CodeIndex/Cli/ConsoleUi.Help.cs b/src/CodeIndex/Cli/ConsoleUi.Help.cs index c1b3a5ac7..4ca2afcbc 100644 --- a/src/CodeIndex/Cli/ConsoleUi.Help.cs +++ b/src/CodeIndex/Cli/ConsoleUi.Help.cs @@ -246,6 +246,9 @@ private static void PrintExamples() Console.WriteLine(" cdidx search authenticate --verbose Emit query debug diagnostics on stderr"); Console.WriteLine(" cdidx audit --all --format compact --total-limit 200"); Console.WriteLine(" Audit every registered recipe in deterministic order"); + Console.WriteLine(" cdidx audit baseline-export .cdidx/audit-baseline.json --recipe risky-code"); + Console.WriteLine(" cdidx audit baseline-compare .cdidx/audit-baseline.json --recipe risky-code"); + Console.WriteLine(" Compare bounded local findings; incomplete absence stays unknown"); Console.WriteLine(" cdidx definition ResolveGitCommonDir --body Show a symbol definition and body"); Console.WriteLine(" cdidx references ResolveGitCommonDir Find indexed references"); Console.WriteLine(" cdidx references DbContext --kind instantiate Filter constructor sites by reference kind"); diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 7dd1b8440..76cc27c7d 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -86,7 +86,11 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("search", "cdidx search |--query |-- |--named-query = [--named-query = ...]|--recipe [--include-query ] [--exclude-query ]|--list-recipes [--query ] [--names|--summary-only] [--cursor ] [--audit-scope ] [--source-only] [--show-excluded] [--db ] [--json[=ndjson|array]] [--pretty] [--format ] [--open-issues ] [--repo ] [--duplicate-confidence |--duplicate-threshold ] [--issue-title ] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>|--max-results <n>] [--total-limit <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exclude-comments] [--exclude-strings] [--exclude-fixtures] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring|--token-boundary] [--prefix] [--count] [--group-by <file|symbol|origin|return-type|subsystem>] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>] [--guard-scope <window|same-line>] [--unique <path|file|symbol|origin|return-type|subsystem>] [--count-by <path|file|symbol|origin|return-type|subsystem>] [--origin <origin>] [--match-origin <origin>] [--exclude-origin <origin>] [--result-kind <kind>] [--search-fields <csv>] [--results-only] [--first-per-file] [--sample <n>] [--per-file-limit <n>] [--max-json-bytes <n>] [--allow-partial] [--next-steps]"), ("recipes", "cdidx recipes [list] [--query <filter>] [--names|--summary-only] [--json] [--pretty] [--format <text|json|compact>] [--max-json-bytes <n>]"), ("recipes-list", "cdidx recipes list [--query <filter>] [--names|--summary-only] [--json] [--pretty] [--format <text|json|compact>] [--max-json-bytes <n>]"), + ("audit-baseline-export", "cdidx audit baseline-export <baseline.json> [--recipe <name>] [--db <path>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--audit-scope <source|all>] [--since <datetime>] [--limit <n>] [--total-limit <n>] [--overwrite] [--json]"), + ("audit-baseline-compare", "cdidx audit baseline-compare <baseline.json> [--recipe <name>] [--db <path>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--audit-scope <source|all>] [--since <datetime>] [--limit <n>] [--total-limit <n>] [--json]"), + ("audit-baseline-review", "cdidx audit baseline-review <baseline.json> <id> --actor <actor> --reason <reason> --overwrite [--json]"), ("audit", "cdidx audit (<recipe|recipe/query>|--all) [search filters] [--json[=ndjson]] [--format <text|json|count|compact|issue-drafts>] [--summary-only] [--limit <n>] [--total-limit <n>] [--allow-partial] [--results-only] [--search-fields <csv>] [--first-per-file] [--sample <n>] [--max-json-bytes <n>] [--snippet-lines <n>]"), + ("audit", QueryCommandRunner.AuditBaselineUsage), ("definition", "cdidx definition <query>|--query <query>|-- <query> [--db <path>] [--json] [--redact-paths|--show-paths] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--fields <csv>] [--cursor <next_cursor>] [--max-json-bytes <n>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--group-partials] [--since <datetime>]"), ("goto", "cdidx goto <query>|--query <query>|-- <query> [--db <path>] [--json] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exact-name] [--all]"), ("references", "cdidx references <query>|--query <query>|-- <query>|--selector <id:n@g:fingerprint> [--db <path>] [--json] [--redact-paths|--show-paths] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--fields <csv>] [--cursor <next_cursor>] [--max-json-bytes <n>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--include-qualified-common-calls] [--body] [--snippet-lines <n>] [--max-line-width <n>] [--exact|--exact-name] [--count]"), @@ -220,6 +224,9 @@ private static readonly (string Command, string Note)[] CommandUsageNotes = ("config-show", "Example: `cdidx config show --json --show-paths`."), ("recipes-list", "Read-only alias form for recipe discovery; filters and bounded JSON options match the parent `recipes` command."), ("recipes-list", "Example: `cdidx recipes list --names --json`."), + ("audit-baseline-export", "Example: `cdidx audit baseline-export .cdidx/audit-baseline.json --recipe risky-code --json`. Existing files require --overwrite; incomplete coverage remains explicit."), + ("audit-baseline-compare", "Example: `cdidx audit baseline-compare .cdidx/audit-baseline.json --recipe risky-code --json`. Refresh the index first and preserve filters. Incomplete absence stays unknown."), + ("audit-baseline-review", "Example: `cdidx audit baseline-review .cdidx/audit-baseline.json <id> --actor reviewer --reason 'Validated guard' --overwrite`. Only unchanged compatible evidence inherits the annotation."), ("db", "schema defaults to the full sqlite_master dump for support bundles; use --summary-only, --limit, --max-sql-chars, and --exclude-internal for bounded diagnostics."), ("db", "checkpoint --dry-run separates source DB/WAL/SHM bytes from every planned output, including the versioned manifest, its SHA-256, estimated final bytes, destination/conflict policy, and uncertainty."), ("db", "checkpoint names must be non-blank single file names of at most 128 characters and cannot contain C0 control characters, directory separators, or platform-invalid file-name characters."), diff --git a/src/CodeIndex/Cli/QueryCommandRunner.AuditAll.cs b/src/CodeIndex/Cli/QueryCommandRunner.AuditAll.cs index bced79140..56b1cfeda 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.AuditAll.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.AuditAll.cs @@ -62,6 +62,7 @@ private sealed class AuditAllRunState( internal bool ByteBudgetReached { get; set; } internal string IndexState { get; set; } = "unknown"; internal string? IndexReason { get; set; } + internal string? BaselineStartGeneration { get; set; } internal long ElapsedMilliseconds { get; set; } } @@ -140,7 +141,8 @@ private static int RunAuditAll( CancellationToken cancellationToken, SearchAuditRecipeRegistry registry, Action? afterQueryForTesting = null, - Action<DbReader>? beforeQueryForTesting = null) + Action<DbReader>? beforeQueryForTesting = null, + Func<DbReader?, QueryCommandOptions, AuditAllRunState, int>? consume = null) { var selectedRecipes = registry.Recipes .OrderBy(recipe => recipe.Name, StringComparer.Ordinal) @@ -188,7 +190,8 @@ private static int RunAuditAll( registry.Diagnostics, cancellationToken, afterQueryForTesting, - beforeQueryForTesting); + beforeQueryForTesting, + consume); } private static string[] AddAuditAllSummaryFormatIfNeeded(string[] args) @@ -271,7 +274,8 @@ private static int ExecuteAuditAll( IReadOnlyList<string> registryDiagnostics, CancellationToken cancellationToken, Action? afterQueryForTesting, - Action<DbReader>? beforeQueryForTesting) + Action<DbReader>? beforeQueryForTesting, + Func<DbReader?, QueryCommandOptions, AuditAllRunState, int>? consume = null) { var effectiveTotalLimit = options.TotalLimit ?? DefaultAuditAllTotalLimit; var timeBudget = AuditAllTimeBudgetForTesting ?? DefaultAuditAllTimeBudget; @@ -280,7 +284,7 @@ private static int ExecuteAuditAll( { state.Cancelled = true; AddOmittedAuditAllRecipes(state, 0, "cancelled"); - return WriteAuditAllOutput(options, jsonOptions, state); + return consume != null ? consume(null, options, state) : WriteAuditAllOutput(options, jsonOptions, state); } return WithDb( options, @@ -293,7 +297,8 @@ private static int ExecuteAuditAll( state, cancellationToken, afterQueryForTesting, - beforeQueryForTesting), + beforeQueryForTesting, + consume), cancellationToken: cancellationToken); } @@ -305,9 +310,12 @@ private static int ExecuteAuditAllWithReader( AuditAllRunState state, CancellationToken cancellationToken, Action? afterQueryForTesting, - Action<DbReader>? beforeQueryForTesting) + Action<DbReader>? beforeQueryForTesting, + Func<DbReader?, QueryCommandOptions, AuditAllRunState, int>? consume = null) { var stopwatch = Stopwatch.StartNew(); + if (consume != null) + state.BaselineStartGeneration = reader.GetPaginationGeneration().Identity; var includeRows = !options.CountOnly && !options.SummaryOnly; var indexState = ResolveSearchQueryIndexFreshness(reader, options, out var indexReason); state.IndexState = indexState; @@ -470,7 +478,7 @@ private static int ExecuteAuditAllWithReader( stopwatch.Stop(); state.ElapsedMilliseconds = stopwatch.ElapsedMilliseconds; - return WriteAuditAllOutput(options, jsonOptions, state); + return consume != null ? consume(reader, options, state) : WriteAuditAllOutput(options, jsonOptions, state); } private static void TrimAuditAllQueryResultsToByteBudget( @@ -989,14 +997,14 @@ private static JsonObject BuildAuditAllPayload( }; } - private static string BuildAuditAllRecoveryCommand(string recipeName, QueryCommandOptions options) + private static string BuildAuditAllRecoveryCommand(string recipeName, QueryCommandOptions options, bool includeDb = true) { var args = new List<string>(); options.InvocationContext.AddRecipeCommandPrefix(args, recipeName); args.Add("--format"); args.Add(OutputFormatCompact); AddReplayValueOption(args, "--limit", options.Limit.ToString(CultureInfo.InvariantCulture)); - if (options.DbPathExplicit) + if (includeDb && options.DbPathExplicit) AddReplayValueOption(args, "--db", options.DbPath); if (options.SourceOnly) args.Add("--source-only"); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.AuditBaseline.cs b/src/CodeIndex/Cli/QueryCommandRunner.AuditBaseline.cs new file mode 100644 index 000000000..a459b0763 --- /dev/null +++ b/src/CodeIndex/Cli/QueryCommandRunner.AuditBaseline.cs @@ -0,0 +1,214 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Database; + +namespace CodeIndex.Cli; + +public static partial class QueryCommandRunner +{ + internal const string AuditBaselineUsage = "cdidx audit baseline-export|baseline-compare <baseline.json> [--recipe <name>] [--db <path>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--audit-scope <source|all>] [--since <datetime>] [--limit <n>] [--total-limit <n>] [--overwrite] [--json]; cdidx audit baseline-review <baseline.json> <id> --actor <actor> --reason <reason> --overwrite [--json]"; + + internal static int RunAuditBaseline(string[] args, JsonSerializerOptions jsonOptions, CancellationToken cancellationToken = default, + SearchAuditRecipeRegistry? registryForTesting = null) + { + var json = args.Contains("--json", StringComparer.Ordinal); + try + { + if (args.Length < 2 || args[0] is not ("export" or "compare" or "review") || args[1].StartsWith('-')) + throw new InvalidDataException("Specify baseline export, compare, or review and a file path."); + var verb = args[0]; + var path = args[1]; + var overwrite = false; + string? recipeName = null, actor = null, reason = null, id = null; + var forwarded = new List<string> { "--all", "--limit", "1000", "--total-limit", "10000", "--snippet-lines", "20" }; + if (json) forwarded.Add("--json"); + for (var i = 2; i < args.Length; i++) + { + var flag = args[i]; + if (flag == "--json") continue; + if (flag == "--overwrite") { overwrite = true; continue; } + if (verb == "review" && i == 2 && !flag.StartsWith('-')) { id = flag; continue; } + if (flag == "--exclude-tests" && verb != "review") { forwarded.Add(flag); continue; } + if (flag is not ("--recipe" or "--actor" or "--reason" or "--db" or "--lang" or "--path" + or "--exclude-path" or "--audit-scope" or "--since" or "--limit" or "--total-limit") + || i + 1 >= args.Length || args[i + 1].StartsWith("--", StringComparison.Ordinal)) + throw new InvalidDataException("Unsupported baseline option or missing option value."); + var value = args[++i]; + if (verb == "review" && flag is not ("--actor" or "--reason") + || verb != "review" && flag is "--actor" or "--reason") + throw new InvalidDataException("Option is not supported for this baseline operation."); + if (flag == "--recipe") recipeName = value; + else if (flag == "--actor") actor = value; + else if (flag == "--reason") reason = value; + else forwarded.AddRange([flag, value]); + } + if (verb == "review") + { + if (!overwrite || id == null || actor == null || reason == null) + throw new InvalidDataException("Review requires an entry ID, --actor, --reason, and explicit --overwrite."); + var baseline = AuditBaselineStore.Read(path); + AuditBaselineStore.Review(baseline, id, actor, reason); + cancellationToken.ThrowIfCancellationRequested(); + AuditBaselineStore.Write(path, baseline, overwrite: true); + return WriteBaselineResult(new JsonObject { ["api_version"] = "1", ["mode"] = "audit_baseline_review", ["status"] = "saved", ["id"] = id }, json); + } + if (overwrite && verb != "export") + throw new InvalidDataException("--overwrite is only valid for export and review."); + var previous = verb == "compare" ? AuditBaselineStore.Read(path) : null; + if (verb == "export" && File.Exists(path) && !overwrite) + throw new InvalidDataException("Baseline already exists; use --overwrite to replace it explicitly."); + var registry = registryForTesting ?? SearchAuditRecipes.Load(); + if (recipeName != null) + { + var selected = registry.Recipes.Where(recipe => recipe.Name == recipeName).ToArray(); + if (selected.Length != 1) throw new InvalidDataException("Unknown or ambiguous recipe; run cdidx recipes."); + registry = new SearchAuditRecipeRegistry(selected, registry.Diagnostics); + } + return RunAuditAll(forwarded.ToArray(), jsonOptions, cancellationToken, registry, consume: (reader, options, state) => + { + var snapshot = BuildAuditBaseline(reader, options, state); + JsonObject output; + if (previous != null) output = AuditBaselineStore.Compare(previous, snapshot); + else + { + cancellationToken.ThrowIfCancellationRequested(); + AuditBaselineStore.Write(path, snapshot, overwrite); + output = new JsonObject + { + ["api_version"] = "1", + ["mode"] = "audit_baseline_export", + ["status"] = "saved", + ["complete"] = snapshot["complete"]!.DeepClone(), + ["coverage_reasons"] = snapshot["coverage_reasons"]!.DeepClone(), + ["entry_count"] = snapshot["entries"]!.AsArray().Count, + ["recovery_guidance"] = AuditBaselineStore.Recovery, + }; + } + WriteBaselineResult(output, json); + return state.Cancelled ? CommandExitCodes.CancelledBySignal + : !AuditBaselineStore.Flag(snapshot, "complete") || previous != null && AuditBaselineStore.Number(output["totals"]!.AsObject(), "unknown") > 0 + ? CommandExitCodes.PartialResult : CommandExitCodes.Success; + }); + } + catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException or JsonException or InvalidOperationException or ArgumentException) + { + return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, + "Baseline operation failed: " + CommandErrorWriter.FormatSanitizedExceptionMessage(ex), + CommandExitCodes.UsageError, "Check the bounded baseline schema, input options, and destination permissions. " + AuditBaselineStore.Recovery, + AuditBaselineUsage, CommandErrorCodes.UsageError, command: "audit"); + } + } + + private static int WriteBaselineResult(JsonObject result, bool json) + { + if (json) Console.WriteLine(result.ToJsonString()); + else if (result["totals"] is JsonObject totals) + { + Console.WriteLine($"Audit baseline: new={totals["new"]}, unchanged={totals["unchanged"]}, resolved={totals["resolved"]}, unknown={totals["unknown"]}; returned={result["returned"]}, omitted={result["omitted_count"]}."); + foreach (var row in result["results"]!.AsArray().OfType<JsonObject>()) + Console.WriteLine($"{row["classification"]}: {row["path"]}:{row["line"]} {row["recipe"]}/{row["query"]} id={row["id"]} reason={row["reason"]} reviewed_safe={row["review_applies"]}"); + Console.WriteLine($"Comparison reasons: {result["reasons"]}; baseline coverage: {result["baseline_coverage_reasons"]}; current coverage: {result["current_coverage_reasons"]}."); + Console.WriteLine(AuditBaselineStore.Recovery); + } + else if (result["id"] != null) Console.WriteLine($"Reviewed safe: {result["id"]}. Annotation saved."); + else + { + Console.WriteLine($"{result["mode"]}: saved. Entries={result["entry_count"]}; complete={result["complete"]}; coverage reasons={result["coverage_reasons"]}."); + Console.WriteLine(AuditBaselineStore.Recovery); + } + return CommandExitCodes.Success; + } + + private static JsonObject BuildAuditBaseline(DbReader? reader, QueryCommandOptions options, AuditAllRunState state) + { + var reasons = new HashSet<string>(StringComparer.Ordinal); + if (reader != null && state.BaselineStartGeneration != reader.GetPaginationGeneration().Identity) + reasons.Add("index_changed_during_audit"); + if (reader != null && ResolveSearchQueryIndexFreshness(reader, options, out _) != "current") + reasons.Add("index_not_current_after_audit"); + if (state.IndexState != "current") reasons.Add("index_not_current"); + if (reader?.GetPersistedIndexCompletion().IndexComplete != true) reasons.Add("index_incomplete"); + if (state.Cancelled) reasons.Add("cancelled"); + if (state.TimeBudgetExceeded) reasons.Add("time_budget"); + if (state.ResultLimitReached || state.ByteBudgetReached) reasons.Add("run_limit"); + if (state.RegistryDiagnostics.Count > 0) reasons.Add("recipe_registry_diagnostics"); + var entries = new JsonArray(); + var definitions = new List<string>(); + var scopes = new JsonArray(); + var seen = new HashSet<string>(StringComparer.Ordinal); + var omittedEntries = 0; + foreach (var recipe in state.Recipes) + { + if (recipe.Status != "completed" || recipe.Queries.Count != recipe.Recipe.Queries.Count) reasons.Add("recipe_incomplete"); + // Reuse the canonical replay builder so every effective search filter participates. + var replay = BuildAuditAllRecoveryCommand(recipe.Recipe.Name, options, includeDb: false); + scopes.Add(new JsonObject + { + ["replay"] = replay, + ["scope"] = recipe.Scope == null ? null : JsonSerializer.SerializeToNode(recipe.Scope, + CliJsonSerializerContextFactory.Create(options.InvocationJsonOptions!).SearchRecipeScopeJsonResult), + }); + foreach (var query in recipe.Queries) + { + definitions.Add(recipe.Recipe.Name + "/" + query.Query.Name + ":" + query.Freshness?.DefinitionVersion); + if (query.Freshness?.FreshnessState != "clean" || string.IsNullOrEmpty(query.Freshness?.DefinitionVersion)) reasons.Add("query_not_current"); + if (query.Result == null || query.Status != "completed" || query.Result.Truncated + || !query.Result.SourceTotalAuthoritative || query.Result.MinimumOmittedResultCount > 0 || query.ByteOmittedResultCount > 0) + reasons.Add("query_coverage_incomplete"); + foreach (var row in query.Result?.Results ?? []) + { + string path; + try { path = AuditBaselineStore.NormalizePath(row.Path); } + catch (InvalidDataException) { reasons.Add("path_identity_ambiguous"); continue; } + var identityComplete = row.TruncatedLineCount == 0 && row.DroppedMatchLineCount == 0 && row.Highlights.Count > 0; + if (!identityComplete) reasons.Add("identity_evidence_incomplete"); + foreach (var highlight in row.Highlights) + { + var match = AuditBaselineStore.Hash(highlight.Text.TrimEnd('\r'), row.EnclosingContainerName ?? "", row.EnclosingSymbolName ?? ""); + var id = AuditBaselineStore.Hash(recipe.Recipe.Name, query.Query.Name, path, match); + if (!seen.Add(id + ":" + highlight.Line)) continue; + if (entries.Count >= AuditBaselineStore.MaxEntries) { reasons.Add("entry_limit"); omittedEntries++; continue; } + // No line coordinates enter the evidence hash. Store hashes, never source snippets. + var contextLines = row.Snippet.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'); + var context = AuditBaselineStore.Hash(string.Join('\n', contextLines + .SkipWhile(string.IsNullOrWhiteSpace).Reverse().SkipWhile(string.IsNullOrWhiteSpace).Reverse()), match); + entries.Add(new JsonObject + { + ["id"] = id, + ["recipe"] = recipe.Recipe.Name, + ["query"] = query.Query.Name, + ["path"] = path, + ["line"] = highlight.Line, + ["match"] = match, + ["context"] = context, + ["identity_complete"] = identityComplete && !highlight.Truncated, + }); + } + } + } + } + var coverage = new JsonArray(); + foreach (var reason in reasons.Order(StringComparer.Ordinal)) coverage.Add(reason); + return new JsonObject + { + ["format"] = "cdidx-audit-baseline", + ["schema_version"] = 1, + ["identity_version"] = "1", + ["recipe_schema_version"] = "1", + ["created_at"] = DateTimeOffset.UtcNow.ToString("O"), + ["scope_fingerprint"] = AuditBaselineStore.Hash(scopes.ToJsonString()), + ["effective_filters"] = scopes, + ["recipe_fingerprint"] = AuditBaselineStore.Hash(definitions.ToArray()), + ["workspace_fingerprint"] = AuditBaselineStore.Hash(Path.GetFullPath(options.DbPath)), + ["index_generation"] = reader == null ? "" : AuditBaselineStore.Hash(reader.GetPaginationGeneration().Identity), + ["complete"] = reasons.Count == 0, + ["count_authoritative"] = reasons.Count == 0, + ["coverage_reasons"] = coverage, + ["entry_limit"] = AuditBaselineStore.MaxEntries, + ["omitted_entry_count"] = omittedEntries, + ["omitted_entry_count_authoritative"] = reasons.Count == 0 || reasons.SetEquals(["entry_limit"]), + ["entries_truncated"] = omittedEntries > 0, + ["entries"] = entries, + }; + } +} diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchAliases.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchAliases.cs index 015388755..74e47738e 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchAliases.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchAliases.cs @@ -20,6 +20,8 @@ internal static int RunAudit( JsonSerializerOptions jsonOptions, CancellationToken cancellationToken = default) { + if (subArgs.Length > 0 && subArgs[0] is "baseline-export" or "baseline-compare" or "baseline-review") + return RunAuditBaseline([subArgs[0]["baseline-".Length..], .. subArgs[1..]], jsonOptions, cancellationToken); if (HasAuditAllFlag(subArgs)) return RunAuditAll(subArgs, jsonOptions, cancellationToken); diff --git a/tests/CodeIndex.Tests/AuditBaselineIssue5261Tests.cs b/tests/CodeIndex.Tests/AuditBaselineIssue5261Tests.cs new file mode 100644 index 000000000..28e46a14d --- /dev/null +++ b/tests/CodeIndex.Tests/AuditBaselineIssue5261Tests.cs @@ -0,0 +1,206 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Cli; +using Xunit; +using static CodeIndex.Tests.QueryCommandTestSupport; + +namespace CodeIndex.Tests; + +[Collection("Console sensitive")] +public sealed class AuditBaselineIssue5261Tests +{ + [Fact] + public void Compare_ReconcilesMovementChangesReviewsAndIncompleteCoverage() + { + var baseline = Snapshot(Entry("src/One.cs", "first"), Entry("src/Two.cs", "second")); + var id = AuditBaselineStore.Text(baseline["entries"]![0]!.AsObject(), "id"); + AuditBaselineStore.Review(baseline, id, "reviewer", "Validated size guard."); + var current = baseline.DeepClone().AsObject(); + current["entries"]![0]!["line"] = 500; + var same = AuditBaselineStore.Compare(baseline, current); + AssertCounts(same, 0, 2, 0, 0); + Assert.Contains(same["results"]!.AsArray(), row => row!["review_applies"]!.GetValue<bool>()); + + current["entries"]!.AsArray().RemoveAt(1); + current["entries"]!.AsArray().Add(Entry("src/Three.cs", "third")); + AssertCounts(AuditBaselineStore.Compare(baseline, current), 1, 1, 1, 0); + current["entries"]![0]!["context"] = AuditBaselineStore.Hash("changed guard"); + current["entries"]![0]!.AsObject().Remove("review"); + var changed = AuditBaselineStore.Compare(baseline, current); + AssertCounts(changed, 1, 0, 1, 1); + Assert.All(changed["results"]!.AsArray(), row => Assert.False(row!["review_applies"]!.GetValue<bool>())); + + foreach (var key in new[] { "scope_fingerprint", "recipe_fingerprint", "identity_version", "workspace_fingerprint", "index_generation" }) + { + var incompatible = current.DeepClone().AsObject(); + incompatible.Remove(key); + AssertCounts(AuditBaselineStore.Compare(baseline, incompatible), 0, 0, 0, 3); + } + foreach (var reason in new[] { "stale", "partial", "cancelled", "failed", "capped" }) + { + var incomplete = current.DeepClone().AsObject(); + incomplete["complete"] = false; + incomplete["coverage_reasons"] = new JsonArray(reason); + AssertCounts(AuditBaselineStore.Compare(baseline, incomplete), 0, 0, 0, 3); + AssertCounts(AuditBaselineStore.Compare(incomplete, baseline), 0, 0, 0, 3); + } + } + + [Fact] + public void Compare_DuplicatesRenamesAndOutputBoundsRemainExplicit() + { + var duplicate = Entry("src/One.cs", "same"); + var baseline = Snapshot(duplicate.DeepClone().AsObject(), duplicate.DeepClone().AsObject()); + AssertCounts(AuditBaselineStore.Compare(baseline, Snapshot(duplicate.DeepClone().AsObject())), 0, 0, 0, 1); + Assert.Throws<InvalidDataException>(() => AuditBaselineStore.Review(baseline, AuditBaselineStore.Text(duplicate, "id"), "actor", "reason")); + AssertCounts(AuditBaselineStore.Compare(Snapshot(Entry("old/One.cs", "same")), Snapshot(Entry("new/One.cs", "same"))), 0, 0, 0, 2); + var many = Snapshot(Enumerable.Range(0, AuditBaselineStore.ResultLimit + 1).Select(i => Entry($"src/File{i}.cs", $"match{i}")).ToArray()); + var delta = AuditBaselineStore.Compare(Snapshot(), many); + AssertCounts(delta, AuditBaselineStore.ResultLimit + 1, 0, 0, 0); + Assert.Equal(AuditBaselineStore.ResultLimit, delta["returned"]!.GetValue<int>()); + Assert.Equal(1, delta["omitted_count"]!.GetValue<int>()); + Assert.True(delta["truncated"]!.GetValue<bool>()); + } + + [Fact] + public void Store_BoundsInputValidatesPathsAndPublishesPrivatelyWithoutImplicitOverwrite() + { + using var project = TestProjectHelper.CreateTempProjectScope("audit_baseline_store_5261"); + var path = Path.Combine(project.Root, "baseline.json"); + var baseline = Snapshot(Entry("src/日本語.cs", "secret-source-not-stored")); + AuditBaselineStore.Write(path, baseline, false); + Assert.DoesNotContain("secret-source-not-stored", File.ReadAllText(path), StringComparison.Ordinal); + if (!OperatingSystem.IsWindows()) + Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(path)); + Assert.ThrowsAny<IOException>(() => AuditBaselineStore.Write(path, Snapshot(), false)); + Assert.Single(AuditBaselineStore.Read(path)["entries"]!.AsArray()); + AuditBaselineStore.Write(path, Snapshot(), true); + Assert.Empty(AuditBaselineStore.Read(path)["entries"]!.AsArray()); + foreach (var badPath in new[] { "../file.cs", "/root/file.cs", "C:/file.cs", "src\\file.cs", "src//file.cs", "src/./file.cs", "src/\u001bfile.cs" }) + Assert.Throws<InvalidDataException>(() => AuditBaselineStore.NormalizePath(badPath)); + foreach (var malformed in new[] { "{", "[]", "{\"schema_version\":99}", new string('[', 17) + new string(']', 17) }) + { + File.WriteAllText(path, malformed); + Assert.ThrowsAny<Exception>(() => AuditBaselineStore.Read(path)); + } + using (var stream = File.Create(path)) stream.SetLength(AuditBaselineStore.MaxBytes + 1); + Assert.Throws<InvalidDataException>(() => AuditBaselineStore.Read(path)); + var tooMany = Snapshot(Enumerable.Range(0, AuditBaselineStore.MaxEntries + 1).Select(i => Entry("src/A.cs", i.ToString())).ToArray()); + Assert.Throws<InvalidDataException>(() => AuditBaselineStore.Write(path, tooMany, true)); + } + + [Fact] + public void Cli_ExportsAndComparesExistingRecipeExecutionAndRejectsUnsafeOptions() + { + using var project = TestProjectHelper.CreateTempProjectScope("audit_baseline_cli_5261"); + var source = Path.Combine(project.Root, "One.cs"); + File.WriteAllText(source, "class One { string Value = \"Issue5261Needle\"; }\n"); + var db = Path.Combine(project.Root, ".cdidx", "codeindex.db"); + var baseline = Path.Combine(project.Root, ".cdidx", "baseline.json"); + var registry = new SearchAuditRecipeRegistry([new SearchAuditRecipe("fixture", "fixture", + [new SearchAuditRecipeQuery("needle", "Issue5261Needle", "fixture", [], "Review.")])], []); + var (indexExit, _, _) = CaptureConsole(() => IndexCommandRunner.Run([project.Root, "--db", db, "--json"], JsonOptions)); + Assert.Equal(0, indexExit); + var (exportExit, exportOut, exportError) = CaptureConsole(() => QueryCommandRunner.RunAuditBaseline( + ["export", baseline, "--db", db, "--json"], JsonOptions, registryForTesting: registry)); + Assert.Equal(string.Empty, exportError); + Assert.Equal(0, exportExit); + Assert.True(JsonNode.Parse(exportOut)!["complete"]!.GetValue<bool>()); + JsonOutputSnapshotHelper.AssertMatches("audit-baseline-export.json", exportOut); + var stored = AuditBaselineStore.Read(baseline); + Assert.Single(stored["entries"]!.AsArray()); + var (compareExit, compareOut, _) = CaptureConsole(() => QueryCommandRunner.RunAuditBaseline( + ["compare", baseline, "--db", db, "--json"], JsonOptions, registryForTesting: registry)); + Assert.Equal(0, compareExit); + AssertCounts(JsonNode.Parse(compareOut)!.AsObject(), 0, 1, 0, 0); + JsonOutputSnapshotHelper.AssertMatches("audit-baseline-compare.json", compareOut); + var id = stored["entries"]![0]!["id"]!.GetValue<string>(); + var (reviewExit, _, _) = CaptureConsole(() => QueryCommandRunner.RunAudit( + ["baseline-review", baseline, id, "--actor", "reviewer", "--reason", "Checked the guard.", "--overwrite", "--json"], JsonOptions)); + Assert.Equal(0, reviewExit); + File.WriteAllText(source, new string('\n', 50) + "class One { string Value = \"Issue5261Needle\"; }\n"); + CaptureConsole(() => IndexCommandRunner.Run([project.Root, "--db", db, "--json"], JsonOptions)); + var (movedExit, movedOut, _) = CaptureConsole(() => QueryCommandRunner.RunAuditBaseline( + ["compare", baseline, "--db", db, "--json"], JsonOptions, registryForTesting: registry)); + Assert.Equal(0, movedExit); + AssertCounts(JsonNode.Parse(movedOut)!.AsObject(), 0, 1, 0, 0); + Assert.True(JsonNode.Parse(movedOut)!["results"]![0]!["review_applies"]!.GetValue<bool>()); + File.WriteAllText(source, "class One { }\n"); + File.WriteAllText(Path.Combine(project.Root, "Two.cs"), "class Two { string Value = \"Issue5261Needle\"; }\n"); + var (staleExit, staleOut, _) = CaptureConsole(() => QueryCommandRunner.RunAuditBaseline( + ["compare", baseline, "--db", db, "--json"], JsonOptions, registryForTesting: registry)); + Assert.Equal(CommandExitCodes.PartialResult, staleExit); + AssertCounts(JsonNode.Parse(staleOut)!.AsObject(), 0, 0, 0, 1); + CaptureConsole(() => IndexCommandRunner.Run([project.Root, "--db", db, "--json"], JsonOptions)); + var (deltaExit, deltaOut, _) = CaptureConsole(() => QueryCommandRunner.RunAuditBaseline( + ["compare", baseline, "--db", db, "--json"], JsonOptions, registryForTesting: registry)); + Assert.Equal(0, deltaExit); + AssertCounts(JsonNode.Parse(deltaOut)!.AsObject(), 1, 0, 1, 0); + foreach (var extra in new[] { new[] { "--format", "compact" }, new[] { "--summary-only" }, new[] { "--overwrite" } }) + { + var (exit, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunAudit( + ["baseline-compare", baseline, "--json", .. extra], JsonOptions)); + Assert.Equal(CommandExitCodes.UsageError, exit); + Assert.Equal(string.Empty, stderr); + Assert.Equal("audit", JsonNode.Parse(stdout)!["command"]!.GetValue<string>()); + } + } + + [Fact] + public void HelpAndCompletionsExposeOnlyAcceptedBaselineFlags() + { + foreach (var verb in new[] { "baseline-export", "baseline-compare", "baseline-review" }) + { + var flags = CliFlagSchema.GetCompletionFlagsForCommand("audit", verb).Select(flag => flag.Name).Order(StringComparer.Ordinal).ToArray(); + var expected = verb == "baseline-review" ? new[] { "--actor", "--reason", "--overwrite", "--json" } + : new[] { "--recipe", "--db", "--lang", "--path", "--exclude-path", "--exclude-tests", "--audit-scope", "--since", "--limit", "--total-limit", "--json" } + .Concat(verb == "baseline-export" ? ["--overwrite"] : Array.Empty<string>()).ToArray(); + Assert.Equal(expected.Order(StringComparer.Ordinal), flags); + var (_, help, _) = CaptureConsole(() => ConsoleUi.PrintCommandUsage("audit-" + verb) ? 0 : 1); + Assert.Contains("cdidx audit " + verb, help, StringComparison.Ordinal); + foreach (var shell in new[] { "bash", "zsh", "fish", "powershell" }) + Assert.Contains(verb, ConsoleCompletionRenderer.GetCompletionScript(shell), StringComparison.Ordinal); + } + } + + private static JsonObject Snapshot(params JsonObject[] entries) => new() + { + ["format"] = "cdidx-audit-baseline", + ["schema_version"] = 1, + ["identity_version"] = "1", + ["recipe_schema_version"] = "1", + ["scope_fingerprint"] = "scope", + ["recipe_fingerprint"] = "recipe", + ["workspace_fingerprint"] = "workspace", + ["index_generation"] = "generation", + ["complete"] = true, + ["count_authoritative"] = true, + ["coverage_reasons"] = new JsonArray(), + ["entries"] = new JsonArray(entries.Cast<JsonNode>().ToArray()), + }; + + private static JsonObject Entry(string path, string evidence) + { + var match = AuditBaselineStore.Hash(evidence); + return new JsonObject + { + ["recipe"] = "recipe", + ["query"] = "query", + ["path"] = path, + ["line"] = 1, + ["match"] = match, + ["context"] = AuditBaselineStore.Hash("context", evidence), + ["id"] = AuditBaselineStore.Hash("recipe", "query", path, match), + ["identity_complete"] = true, + }; + } + + private static void AssertCounts(JsonObject result, int added, int unchanged, int resolved, int unknown) + { + Assert.Equal(added, result["totals"]!["new"]!.GetValue<int>()); + Assert.Equal(unchanged, result["totals"]!["unchanged"]!.GetValue<int>()); + Assert.Equal(resolved, result["totals"]!["resolved"]!.GetValue<int>()); + Assert.Equal(unknown, result["totals"]!["unknown"]!.GetValue<int>()); + Assert.Equal(added + unchanged + resolved + unknown, result["total"]!.GetValue<int>()); + } +} diff --git a/tests/CodeIndex.Tests/golden/audit-baseline-compare.json b/tests/CodeIndex.Tests/golden/audit-baseline-compare.json new file mode 100644 index 000000000..fa92c77cd --- /dev/null +++ b/tests/CodeIndex.Tests/golden/audit-baseline-compare.json @@ -0,0 +1,38 @@ +{ + "api_version": "1", + "mode": "audit_baseline_compare", + "comparable": true, + "reasons": [], + "baseline_coverage_reasons": [], + "current_coverage_reasons": [], + "totals": { + "new": 0, + "unchanged": 1, + "resolved": 0, + "unknown": 0 + }, + "count_semantics": "distinct_identity_groups", + "total": 1, + "returned": 1, + "omitted_count": 0, + "truncated": false, + "limit": 200, + "baseline_observation_count": 1, + "current_observation_count": 1, + "results": [ + { + "id": "be7a95cffb1f10620a2d26423b9cae80717988b4fcf4d69de4a8ef4ec03d6aa0", + "recipe": "fixture", + "query": "needle", + "path": "One.cs", + "line": 1, + "classification": "unchanged", + "reason": "compatible_evidence", + "baseline_observation_count": 1, + "current_observation_count": 1, + "review": null, + "review_applies": false + } + ], + "recovery_guidance": "Refresh the index and repeat the same recipes and filters with sufficient --limit and --total-limit. Review unknown or changed evidence manually; never treat unknown as resolved." +} diff --git a/tests/CodeIndex.Tests/golden/audit-baseline-export.json b/tests/CodeIndex.Tests/golden/audit-baseline-export.json new file mode 100644 index 000000000..d8ee42541 --- /dev/null +++ b/tests/CodeIndex.Tests/golden/audit-baseline-export.json @@ -0,0 +1,9 @@ +{ + "api_version": "1", + "mode": "audit_baseline_export", + "status": "saved", + "complete": true, + "coverage_reasons": [], + "entry_count": 1, + "recovery_guidance": "Refresh the index and repeat the same recipes and filters with sufficient --limit and --total-limit. Review unknown or changed evidence manually; never treat unknown as resolved." +} From 7944df285f7801ed1735110e6a937f7712a29138 Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Sun, 6 Sep 2026 05:33:43 +0900 Subject: [PATCH 2/3] Harden baseline coverage and workspace provenance (#5261) --- DEVELOPER_GUIDE.md | 4 ++ README.md | 4 ++ TESTING_GUIDE.md | 4 ++ src/CodeIndex/Cli/AuditBaselineStore.cs | 12 +++- src/CodeIndex/Cli/IndexFreshnessChecker.cs | 4 +- .../Cli/QueryCommandRunner.AuditBaseline.cs | 68 ++++++++++++++++++- .../AuditBaselineIssue5261Tests.cs | 50 ++++++++++++++ 7 files changed, 139 insertions(+), 7 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 34e093e35..0d1f6e708 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -4,6 +4,8 @@ ## Audit baseline contract +Workspace identity comes from the indexed project root, independently of the database location. Saved size/symlink policy participates in scope comparison. For prior paths that no longer have indexed rows, reuse indexing path filters and sparse-checkout evidence: only verified physical deletions within the current indexing scope may resolve. Existing excluded files and unverifiable paths make comparison unknown. Reject contradictory truncation/count metadata; missing coverage fields and empty incomparable comparisons remain partial (exit `11`). + `AuditBaselineStore` implements the local v1 baseline schema; `QueryCommandRunner.AuditBaseline.cs` consumes the same bounded recipe runs as `audit --all`. The `baseline-export`, `baseline-compare`, and `baseline-review` audit subcommands do not migrate SQLite or use GitHub. Preserve SHA-256 match/context identities without line coordinates, canonical case-preserving relative paths, schema/identity versions, effective scope and recipe fingerprints, workspace and index-generation provenance, coverage reasons, and count authority. Index generations may differ after a valid refresh; workspace/scope/recipe/identity contracts must match. Missing legacy provenance degrades comparison conservatively. Absence can become resolved only when both snapshots have complete coverage. Retain unknown classifications for capped, stale, partial, failed, cancelled, changed-scope, changed-recipe, insufficient-evidence, duplicate, or possible-rename cases. Review annotations bind actor/reason/time to the stored context; changed evidence cannot inherit a safe decision. Comparison counts distinct identity groups and separately exposes observation counts. Bound files to 8 MiB, depth to 16, observations to 10,000, and comparison rows to 200 with exact omission accounting. Baselines omit source snippets, use the existing atomic sensitive writer, and require explicit overwrite. Windows paths emitted by the index already use slash separators; ambiguous literal backslashes fail closed. Keep command help, contextual completions, README, and behavioral tests synchronized. @@ -4334,6 +4336,8 @@ For symmetry, the MCP server no longer echoes raw `Exception.Message` content in ## 監査 baseline 契約 +ワークスペースの識別には DB の保存先とは独立した索引対象のプロジェクトルートを使用します。保存済みサイズ・シンボリックリンク方針も範囲比較に含めます。以前のパスが索引行を失った場合は索引のパスフィルターと sparse-checkout の情報を再利用し、現在の索引範囲内で確認できた物理的削除だけを解決済みとします。存在する除外ファイルや検証不能なパスは不明扱いです。省略・件数の矛盾したメタデータは拒否し、完全性情報の欠落や空の比較不能結果も部分結果(終了コード `11`)とします。 + `AuditBaselineStore` がローカル v1 baseline スキーマを実装し、`QueryCommandRunner.AuditBaseline.cs` は `audit --all` と同じ上限付きレシピ実行を利用します。audit の `baseline-export`、`baseline-compare`、`baseline-review` サブコマンドは SQLite の移行や GitHub 接続を行いません。行番号を含まない SHA-256 の一致・文脈識別、大小文字を保持する正規相対パス、スキーマ・識別バージョン、実効範囲・レシピの指紋、ワークスペース・索引世代の由来、完全性の理由、件数の確実性を維持してください。正しい索引更新後の世代差は許容しますが、ワークスペース・範囲・レシピ・識別契約は一致必須です。旧データの由来情報が欠ける場合は保守的に比較を降格します。 不在を解決済みとできるのは両スナップショットが完全な場合だけです。上限到達・古い索引・部分実行・失敗・取消・範囲変更・レシピ変更・証拠不足・重複・リネーム候補は不明のまま保持します。注釈は担当者・理由・時刻を保存された文脈に結び付け、証拠変更後は安全判定を継承しません。比較では識別グループ数と観測数を別々に示します。ファイル8 MiB、深度16、10,000観測、比較出力200行の上限と正確な省略数を維持してください。ソース抜粋は保存せず、既存のアトミックな機密ファイル保存処理と明示的な上書きを使用します。索引の Windows パスは既にスラッシュ区切りであり、曖昧なリテラルのバックスラッシュは拒否します。ヘルプ・文脈別補完・README・振る舞いのテストを同期してください。 diff --git a/README.md b/README.md index fae7cab35..71496cc35 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ dependency, and inspection queries without rescanning the same tree for every qu ## Local audit baselines +Changing index exclusions cannot turn an existing excluded file into a resolution. Comparison verifies prior path coverage, distinguishes physical deletion from sparse/ignored paths, and detects when the same database location now indexes another project. Empty incomparable comparisons also return exit `11`. + `cdidx audit baseline-export .cdidx/audit-baseline.json --recipe risky-code` runs the existing audit engine and saves a local baseline. Omit `--recipe` to select all registered recipes. Refresh the index after source changes, then run `cdidx audit baseline-compare .cdidx/audit-baseline.json --recipe risky-code --json`. Use the same filters and limits for comparable runs. Each command accepts `--db`, `--lang`, `--path`, `--exclude-path`, `--exclude-tests`, `--audit-scope`, `--since`, `--limit`, and `--total-limit` as shown in command help; defaults are 1,000 rows per query and 10,000 total rows. Comparison reports bounded `new`, `unchanged`, `resolved`, and `unknown` identity groups, totals, observation counts, and omissions. Missing findings stay `unknown` when either run is stale, partial, capped, failed, cancelled, or has different recipes, filters, workspace, or identity contracts. Duplicate evidence and possible renames are never guessed. Line numbers are excluded from identity; changed context requires review. Exit `11` identifies incomplete coverage or unknown classifications; export may save an incomplete baseline, explicitly marked as such. Cancellation never publishes an export. @@ -299,6 +301,8 @@ For commercial use, integration, and naming guidance, see ### ローカル監査 baseline +索引の除外設定を変更しても、存在する除外ファイルを解決済みとは判定しません。以前のパスの対象範囲を確認し、物理的削除と sparse/除外パスを区別し、同じ DB 保存先が別プロジェクトの索引に置き換わった場合も検出します。空の比較不能結果も終了コード `11` を返します。 + `cdidx audit baseline-export .cdidx/audit-baseline.json --recipe risky-code` は既存の監査エンジンを実行し、ローカル baseline を保存します。`--recipe` を省略すると登録済みの全レシピが対象です。ソース変更後に索引を更新し、`cdidx audit baseline-compare .cdidx/audit-baseline.json --recipe risky-code --json` で比較します。比較時は同じフィルターと上限を指定してください。各コマンドはヘルプ記載の `--db`、`--lang`、`--path`、`--exclude-path`、`--exclude-tests`、`--audit-scope`、`--since`、`--limit`、`--total-limit` に対応します。既定値はクエリごとに1,000行、全体で10,000行です。 比較は `new`、`unchanged`、`resolved`、`unknown` の識別グループ、総数、観測数、省略数を返します。どちらかの実行が古い、不完全、上限到達、失敗、取消済み、またはレシピ・フィルター・ワークスペース・識別契約が異なる場合、消えた検出結果は `unknown` のままです。重複した証拠やリネーム候補を推測で対応付けません。行番号は識別から除外し、文脈が変化した場合は再レビューが必要です。不完全な監査または不明な分類は終了コード `11` となります。export は不完全であることを明記した baseline を保存できますが、取消時は公開しません。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 45c339a91..1ff4c19e2 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -8,6 +8,8 @@ If you change test code, test helpers, test execution flow, or testing conventio ## Quick Start +Baseline review regressions also exercise indexing exclusion changes, a different indexed root at the same DB path, contradictory/missing omission metadata, and the partial exit code for empty incomparable snapshots. + `AuditBaselineIssue5261Tests` covers compatible deltas, moved lines, changed reviewed evidence, incomplete/legacy metadata, ambiguous duplicates/renames, bounded counts, private atomic writes, invalid paths and malformed/oversized input, plus actual index/recipe CLI export and compare. Keep human/JSON error identity and contextual help/completion checks on both net8.0 and net9.0. Use the console-sensitive collection for CLI capture and isolated temporary projects for baseline files. Issue #5258 coverage in `IndexCommandRunnerIssue5258Tests.cs` checks explicit/environment/default size limits, ordinary root and shared-workspace checks, scoped updates, full refreshes, missing/invalid legacy policy, actual deletion, failed reads, MCP persistence/dry runs, and the retained read ceiling on both frameworks. Keep environment and current-directory changes inside the SQLite pool sensitive collection and restore them in `finally`. @@ -1180,6 +1182,8 @@ Check the following: <a id="テストガイド"></a> # テストガイド +baseline レビューの回帰テストでは、索引除外設定の変更、同一 DB パスでの索引ルートの変更、矛盾・欠落した省略メタデータ、空の比較不能スナップショットの部分終了コードも検証します。 + `AuditBaselineIssue5261Tests` は互換性のある差分、行移動、レビュー済み証拠の変化、不完全・旧形式のメタデータ、曖昧な重複・リネーム、上限付き件数、機密ファイルのアトミック保存、不正パス・壊れた入力・過大入力、および実際の索引・レシピによる CLI 保存と比較を検証します。human/JSON のエラー識別と文脈別ヘルプ・補完を net8.0 と net9.0 の両方で維持してください。CLI 出力の捕捉は console-sensitive コレクションに置き、baseline は分離した一時プロジェクトに保存してください。 `IndexCommandRunnerIssue5258Tests.cs`のIssue #5258検証は、明示指定・環境変数・既定のサイズ上限、通常のルート/共有workspaceチェック、部分更新、全体更新、旧方針の欠落・不正値、実際の削除、読み取り失敗、MCPの保存とdry run、読み取り上限の維持を両フレームワークで確認します。環境変数とカレントディレクトリの変更はSQLite pool sensitiveコレクション内に置き、`finally`で復元してください。 diff --git a/src/CodeIndex/Cli/AuditBaselineStore.cs b/src/CodeIndex/Cli/AuditBaselineStore.cs index 8345e966b..0de0d0c4a 100644 --- a/src/CodeIndex/Cli/AuditBaselineStore.cs +++ b/src/CodeIndex/Cli/AuditBaselineStore.cs @@ -56,6 +56,10 @@ internal static void Validate(JsonObject root) if (Text(root, "format") != "cdidx-audit-baseline" || Number(root, "schema_version") != 1 || root["entries"] is not JsonArray entries || entries.Count > MaxEntries) throw new InvalidDataException("Unsupported or invalid baseline schema; export a new baseline."); + if (root["omitted_entry_count"] != null && Number(root, "omitted_entry_count") < 0 + || Flag(root, "complete") && (Flag(root, "entries_truncated") || Number(root, "omitted_entry_count") > 0 + || root["omitted_entry_count_authoritative"] != null && !Flag(root, "omitted_entry_count_authoritative"))) + throw new InvalidDataException("Contradictory baseline coverage metadata."); if (root["coverage_reasons"] != null) { if (root["coverage_reasons"] is not JsonArray coverage || coverage.Count > 32 @@ -79,6 +83,8 @@ internal static void Validate(JsonObject root) throw new InvalidDataException("Invalid baseline identity or location."); if (entry["review"] is JsonObject review) { + if (review.Count != 5 || review.Any(property => property.Key is not ("state" or "actor" or "reason" or "context" or "recorded_at"))) + throw new InvalidDataException("Unsupported review annotation fields."); CheckAnnotation(Text(review, "actor")); CheckAnnotation(Text(review, "reason")); if (Text(review, "recorded_at").Length > 64 || !DateTimeOffset.TryParse(Text(review, "recorded_at"), @@ -123,7 +129,7 @@ internal static JsonObject Compare(JsonObject baseline, JsonObject current) Validate(baseline); Validate(current); var reasons = new JsonArray(); - foreach (var key in new[] { "identity_version", "recipe_schema_version", "scope_fingerprint", "recipe_fingerprint", "workspace_fingerprint" }) + foreach (var key in new[] { "identity_version", "recipe_schema_version", "scope_fingerprint", "recipe_fingerprint", "workspace_fingerprint", "index_scope_fingerprint" }) if (Text(baseline, key).Length == 0 || Text(baseline, key) != Text(current, key)) reasons.Add(key + "_incomparable"); if (!CoverageComplete(baseline)) reasons.Add("baseline_coverage_incomplete"); @@ -197,7 +203,9 @@ internal static JsonObject Compare(JsonObject baseline, JsonObject current) internal static bool Flag(JsonObject node, string key) => node[key] is JsonValue value && value.TryGetValue<bool>(out var flag) && flag; private static bool CoverageComplete(JsonObject node) => Flag(node, "complete") && Flag(node, "count_authoritative") - && node["coverage_reasons"] is JsonArray reasons && reasons.Count == 0; + && node["coverage_reasons"] is JsonArray reasons && reasons.Count == 0 + && node["entries_truncated"] is JsonValue truncated && truncated.TryGetValue<bool>(out var isTruncated) && !isTruncated + && Number(node, "omitted_entry_count") == 0 && Flag(node, "omitted_entry_count_authoritative"); private static void CheckAnnotation(string value) { diff --git a/src/CodeIndex/Cli/IndexFreshnessChecker.cs b/src/CodeIndex/Cli/IndexFreshnessChecker.cs index 99f96d9ee..089c91675 100644 --- a/src/CodeIndex/Cli/IndexFreshnessChecker.cs +++ b/src/CodeIndex/Cli/IndexFreshnessChecker.cs @@ -248,7 +248,7 @@ void AddMissingIndexedPath(string path) } } - private static bool IsSkipWorktreePath(HashSet<string> skipWorktreePaths, string path) + internal static bool IsSkipWorktreePath(HashSet<string> skipWorktreePaths, string path) { if (skipWorktreePaths.Contains(path) || skipWorktreePaths.Contains("/")) return true; @@ -262,7 +262,7 @@ private static bool IsSkipWorktreePath(HashSet<string> skipWorktreePaths, string return false; } - private static FileIndexer.SymlinkPolicy ReadIndexedSymlinkPolicy(DbReader reader) + internal static FileIndexer.SymlinkPolicy ReadIndexedSymlinkPolicy(DbReader reader) { var raw = reader.GetMetaString(DbContext.IndexedFollowSymlinksPolicyMetaKey); if (string.IsNullOrWhiteSpace(raw)) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.AuditBaseline.cs b/src/CodeIndex/Cli/QueryCommandRunner.AuditBaseline.cs index a459b0763..f18732406 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.AuditBaseline.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.AuditBaseline.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using CodeIndex.Database; +using CodeIndex.Indexer; namespace CodeIndex.Cli; @@ -68,7 +69,12 @@ internal static int RunAuditBaseline(string[] args, JsonSerializerOptions jsonOp { var snapshot = BuildAuditBaseline(reader, options, state); JsonObject output; - if (previous != null) output = AuditBaselineStore.Compare(previous, snapshot); + if (previous != null) + { + if (!state.Cancelled) + VerifyBaselinePriorPathCoverage(previous, snapshot, reader, options, cancellationToken); + output = AuditBaselineStore.Compare(previous, snapshot); + } else { cancellationToken.ThrowIfCancellationRequested(); @@ -86,7 +92,8 @@ internal static int RunAuditBaseline(string[] args, JsonSerializerOptions jsonOp } WriteBaselineResult(output, json); return state.Cancelled ? CommandExitCodes.CancelledBySignal - : !AuditBaselineStore.Flag(snapshot, "complete") || previous != null && AuditBaselineStore.Number(output["totals"]!.AsObject(), "unknown") > 0 + : !AuditBaselineStore.Flag(snapshot, "complete") || previous != null + && (!AuditBaselineStore.Flag(output, "comparable") || AuditBaselineStore.Number(output["totals"]!.AsObject(), "unknown") > 0) ? CommandExitCodes.PartialResult : CommandExitCodes.Success; }); } @@ -99,6 +106,56 @@ internal static int RunAuditBaseline(string[] args, JsonSerializerOptions jsonOp } } + private static void VerifyBaselinePriorPathCoverage(JsonObject previous, JsonObject current, DbReader? reader, + QueryCommandOptions options, CancellationToken cancellationToken) + { + var root = reader?.GetIndexedProjectRoot(); + if (reader == null || string.IsNullOrWhiteSpace(root)) return; + FileIndexer? indexer = null; + HashSet<string>? sparsePaths = null; + var policyLoaded = false; + string? repositoryRoot = null; + var requiresGitEvidence = !string.IsNullOrWhiteSpace(reader.GetMetaString(DbContext.IndexedHeadCommitMetaKey)); + foreach (var path in previous["entries"]!.AsArray().OfType<JsonObject>() + .Select(entry => AuditBaselineStore.Text(entry, "path")).Distinct(StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (reader.GetFileByPath(path) != null) continue; + if (!policyLoaded) + { + repositoryRoot = GitHelper.TryGetRepositoryRoot(root, cancellationToken); + sparsePaths = GitHelper.TryGetSkipWorktreePaths(root, cancellationToken); + indexer = new FileIndexer(root, GitHelper.ResolveIgnoreCase(root, cancellationToken), repositoryRoot ?? Path.GetFullPath(root), + maxFileSizeBytes: IndexedFileSizePolicy.Resolve(reader, freshness: true), directoryIgnoreCaseProbe: null, + symlinkPolicy: IndexFreshnessChecker.ReadIndexedSymlinkPolicy(reader), internalIndexDatabasePath: options.DbPath); + policyLoaded = true; + } + var coveredDeletion = false; + try + { + var absolutePath = Path.Combine(root, path); + if (!indexer!.EvaluatePathFilter(absolutePath).ShouldSkip + && (repositoryRoot == null && !requiresGitEvidence + || sparsePaths != null && !IndexFreshnessChecker.IsSkipWorktreePath(sparsePaths, path))) + { + try { _ = File.GetAttributes(absolutePath); } + catch (FileNotFoundException) { coveredDeletion = true; } + catch (DirectoryNotFoundException) { coveredDeletion = true; } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) + { + // Failed coverage probes must not turn an unindexed path into a resolved finding. + coveredDeletion = false; + } + if (coveredDeletion) continue; + current["complete"] = false; + current["count_authoritative"] = false; + current["coverage_reasons"]!.AsArray().Add("prior_path_coverage_unverified"); + return; + } + } + private static int WriteBaselineResult(JsonObject result, bool json) { if (json) Console.WriteLine(result.ToJsonString()); @@ -122,6 +179,8 @@ private static int WriteBaselineResult(JsonObject result, bool json) private static JsonObject BuildAuditBaseline(DbReader? reader, QueryCommandOptions options, AuditAllRunState state) { var reasons = new HashSet<string>(StringComparer.Ordinal); + var indexedRoot = reader?.GetIndexedProjectRoot(); + if (string.IsNullOrWhiteSpace(indexedRoot)) reasons.Add("workspace_identity_unavailable"); if (reader != null && state.BaselineStartGeneration != reader.GetPaginationGeneration().Identity) reasons.Add("index_changed_during_audit"); if (reader != null && ResolveSearchQueryIndexFreshness(reader, options, out _) != "current") @@ -199,7 +258,10 @@ private static JsonObject BuildAuditBaseline(DbReader? reader, QueryCommandOptio ["scope_fingerprint"] = AuditBaselineStore.Hash(scopes.ToJsonString()), ["effective_filters"] = scopes, ["recipe_fingerprint"] = AuditBaselineStore.Hash(definitions.ToArray()), - ["workspace_fingerprint"] = AuditBaselineStore.Hash(Path.GetFullPath(options.DbPath)), + ["workspace_fingerprint"] = string.IsNullOrWhiteSpace(indexedRoot) ? "" : AuditBaselineStore.Hash(Path.GetFullPath(indexedRoot)), + ["index_scope_fingerprint"] = reader == null ? "" : AuditBaselineStore.Hash( + IndexedFileSizePolicy.Resolve(reader, freshness: true).ToString(System.Globalization.CultureInfo.InvariantCulture), + IndexFreshnessChecker.ReadIndexedSymlinkPolicy(reader).ToString()), ["index_generation"] = reader == null ? "" : AuditBaselineStore.Hash(reader.GetPaginationGeneration().Identity), ["complete"] = reasons.Count == 0, ["count_authoritative"] = reasons.Count == 0, diff --git a/tests/CodeIndex.Tests/AuditBaselineIssue5261Tests.cs b/tests/CodeIndex.Tests/AuditBaselineIssue5261Tests.cs index 28e46a14d..0ba37da1d 100644 --- a/tests/CodeIndex.Tests/AuditBaselineIssue5261Tests.cs +++ b/tests/CodeIndex.Tests/AuditBaselineIssue5261Tests.cs @@ -44,6 +44,17 @@ public void Compare_ReconcilesMovementChangesReviewsAndIncompleteCoverage() AssertCounts(AuditBaselineStore.Compare(baseline, incomplete), 0, 0, 0, 3); AssertCounts(AuditBaselineStore.Compare(incomplete, baseline), 0, 0, 0, 3); } + foreach (var key in new[] { "entries_truncated", "omitted_entry_count", "omitted_entry_count_authoritative" }) + { + var missing = baseline.DeepClone().AsObject(); + missing.Remove(key); + Assert.False(AuditBaselineStore.Compare(missing, current)["comparable"]!.GetValue<bool>()); + Assert.Throws<InvalidDataException>(() => AuditBaselineStore.Review(missing, id, "actor", "reason")); + var contradictory = baseline.DeepClone().AsObject(); + contradictory[key] = key == "entries_truncated" ? JsonValue.Create(true) + : key == "omitted_entry_count" ? JsonValue.Create(1) : JsonValue.Create(false); + Assert.Throws<InvalidDataException>(() => AuditBaselineStore.Compare(contradictory, current)); + } } [Fact] @@ -136,6 +147,41 @@ public void Cli_ExportsAndComparesExistingRecipeExecutionAndRejectsUnsafeOptions ["compare", baseline, "--db", db, "--json"], JsonOptions, registryForTesting: registry)); Assert.Equal(0, deltaExit); AssertCounts(JsonNode.Parse(deltaOut)!.AsObject(), 1, 0, 1, 0); + File.WriteAllText(Path.Combine(project.Root, ".gitignore"), "One.cs\n"); + CaptureConsole(() => IndexCommandRunner.Run([project.Root, "--db", db, "--json"], JsonOptions)); + var (ignoredExit, ignoredOut, _) = CaptureConsole(() => QueryCommandRunner.RunAuditBaseline( + ["compare", baseline, "--db", db, "--json"], JsonOptions, registryForTesting: registry)); + Assert.Equal(CommandExitCodes.PartialResult, ignoredExit); + AssertCounts(JsonNode.Parse(ignoredOut)!.AsObject(), 0, 0, 0, 2); + Assert.Contains("prior_path_coverage_unverified", ignoredOut, StringComparison.Ordinal); + File.Delete(Path.Combine(project.Root, ".gitignore")); + File.Delete(source); + CaptureConsole(() => IndexCommandRunner.Run([project.Root, "--db", db, "--json"], JsonOptions)); + var (deletedExit, deletedOut, _) = CaptureConsole(() => QueryCommandRunner.RunAuditBaseline( + ["compare", baseline, "--db", db, "--json"], JsonOptions, registryForTesting: registry)); + Assert.Equal(0, deletedExit); + AssertCounts(JsonNode.Parse(deletedOut)!.AsObject(), 1, 0, 1, 0); + + using var replacement = TestProjectHelper.CreateTempProjectScope("audit_baseline_other_workspace_5261"); + File.WriteAllText(Path.Combine(replacement.Root, "One.cs"), "class One { string Value = \"Issue5261Needle\"; }\n"); + var (replacementExit, _, _) = CaptureConsole(() => IndexCommandRunner.Run( + [replacement.Root, "--db", db, "--rebuild", "--yes", "--json"], JsonOptions)); + Assert.Equal(0, replacementExit); + var (otherExit, otherOut, _) = CaptureConsole(() => QueryCommandRunner.RunAuditBaseline( + ["compare", baseline, "--db", db, "--json"], JsonOptions, registryForTesting: registry)); + Assert.Equal(CommandExitCodes.PartialResult, otherExit); + Assert.Contains("workspace_fingerprint_incomparable", otherOut, StringComparison.Ordinal); + Assert.False(JsonNode.Parse(otherOut)!["results"]![0]!["review_applies"]!.GetValue<bool>()); + + var emptyBaseline = Path.Combine(project.Root, ".cdidx", "empty.json"); + var (emptyExportExit, _, _) = CaptureConsole(() => QueryCommandRunner.RunAuditBaseline( + ["export", emptyBaseline, "--db", db, "--path", "missing/**", "--json"], JsonOptions, registryForTesting: registry)); + Assert.Equal(0, emptyExportExit); + var (emptyCompareExit, emptyCompareOut, _) = CaptureConsole(() => QueryCommandRunner.RunAuditBaseline( + ["compare", emptyBaseline, "--db", db, "--path", "different/**", "--json"], JsonOptions, registryForTesting: registry)); + Assert.Equal(CommandExitCodes.PartialResult, emptyCompareExit); + Assert.False(JsonNode.Parse(emptyCompareOut)!["comparable"]!.GetValue<bool>()); + AssertCounts(JsonNode.Parse(emptyCompareOut)!.AsObject(), 0, 0, 0, 0); foreach (var extra in new[] { new[] { "--format", "compact" }, new[] { "--summary-only" }, new[] { "--overwrite" } }) { var (exit, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunAudit( @@ -172,10 +218,14 @@ public void HelpAndCompletionsExposeOnlyAcceptedBaselineFlags() ["scope_fingerprint"] = "scope", ["recipe_fingerprint"] = "recipe", ["workspace_fingerprint"] = "workspace", + ["index_scope_fingerprint"] = "policy", ["index_generation"] = "generation", ["complete"] = true, ["count_authoritative"] = true, ["coverage_reasons"] = new JsonArray(), + ["entries_truncated"] = false, + ["omitted_entry_count"] = 0, + ["omitted_entry_count_authoritative"] = true, ["entries"] = new JsonArray(entries.Cast<JsonNode>().ToArray()), }; From 6c24462373bee6389ba4376734376adf13ea70df Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Sun, 6 Sep 2026 05:44:03 +0900 Subject: [PATCH 3/3] Register audit baseline JSON contracts (#5261) --- src/CodeIndex/Cli/CliContractManifest.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/CodeIndex/Cli/CliContractManifest.cs b/src/CodeIndex/Cli/CliContractManifest.cs index 44b7b90ad..df4adb2e9 100644 --- a/src/CodeIndex/Cli/CliContractManifest.cs +++ b/src/CodeIndex/Cli/CliContractManifest.cs @@ -79,6 +79,8 @@ internal static class CliContractManifest new("excerpt", "excerpt.json"), new("deps-cycles-summary", "deps-cycles-summary.json"), new("suggestions-compact", "suggestions-compact.json"), + new("audit-baseline-export", "audit-baseline-export.json"), + new("audit-baseline-compare", "audit-baseline-compare.json"), ]; private static IReadOnlyList<Type> LoadCliJsonRootTypes() =>