diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 5d66e81fed..45e5ca2f66 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -990,6 +990,13 @@ The MCP `search` tool exposes the same mode as camelCase arguments: `requireBefore`, `requireAfter`, `rejectBefore`, `rejectAfter`, and `guardWindow`. +Machine-readable search exports include enough context for downstream tools to +triage results without reparsing human text. `--format csv` and `--format tsv` +emit stable columns for file location, label, query, recipe/query names when +present, language, visibility, enclosing symbol, and `match_lines`. `--format sarif` +emits SARIF 2.1.0 with rule metadata/help text, result levels, and +normalized repository-relative artifact URIs. + Search audit recipes expand one named recipe into multiple curated search queries. `--list-recipes` reports the available names, descriptions, recommended labels, query text, exact-match mode, and false-positive guidance. @@ -1009,6 +1016,10 @@ Recipe runs support text output, `--json` / `--format json`, `--format compact`, and `--format issue-drafts`; `--list-recipes` supports text or JSON. Other search export formats and `--json=array` are rejected for recipe modes because recipe output is grouped by query or list metadata. +Recipe JSON and compact output apply `--limit` per query, include a `summary` +with emitted/truncated counts, and mark truncated child queries with +`next_cursor`; rerun a single child query as +`--recipe / --cursor ` to page the next result set. The MCP `search` tool exposes the same recipe surface with `{"listRecipes":true}` for discovery and `{"recipe":"risky-code"}` for execution. MCP recipe runs apply the same default source scope as the CLI; pass @@ -1016,13 +1027,20 @@ execution. MCP recipe runs apply the same default source scope as the CLI; pass recipe definitions. Set `CDIDX_SEARCH_RECIPE_PATHS` to one or more JSON files separated by the platform path separator to add configured recipe sources; each file may be a recipe array or `{ "recipes": [...] }`, and invalid sources are -reported as bounded `recipe_source_diagnostics`. +reported as bounded `recipe_source_diagnostics`. External recipes may declare +recipe-level `default_scope`, `default_path_patterns`, and +`default_exclude_paths`; each query may declare `severity`, `path_patterns`, and +`exclude_paths` to narrow a query independently of the recipe default scope. For triage automation, `--format issue-drafts` emits draft issue objects with -titles, labels, evidence paths, Markdown bodies, and duplicate-preflight -metadata. `--open-issues ` accepts an open-issue JSON list such as +titles, labels, evidence paths, severity/confidence/evidence-count triage +metadata, Markdown bodies, and duplicate-preflight metadata. `--open-issues ` accepts an open-issue JSON list such as `gh issue list --state open --json number,title,labels,url`; when omitted, -the payload still includes `duplicate_preflight.checked: false`. Draft bodies -include evidence paths and recipe metadata but not source snippets. +the payload still includes `duplicate_preflight.checked: false`. Use +`--duplicate-confidence low|medium|high` or `--duplicate-threshold <0..1>` to +tune duplicate preflight strictness; the JSON summary reports `confidence` and +`minimum_score`. Draft bodies include evidence paths and recipe metadata but not +source snippets. These drafts are triage aids; review duplicate guidance and +current open issues before filing. ### Debugging queries @@ -1359,6 +1377,7 @@ same source location. | `--offset ` | `suggestions list`, `suggestions export` | Skip filtered suggestion records after newest-first ordering before emitting results. | | `--format ` | `suggestions export` | Choose export format. JSON is the default, markdown is intended for human triage, and issue-drafts emits issue-ready draft objects. | | `--open-issues ` / `--repo ` | `search --format issue-drafts`, `suggestions export --format issue-drafts` | Preflight drafts against an open-issues JSON file such as `gh issue list --state open --json number,title,labels,url`; both commands can also fetch live open GitHub issues with `--open-issues github --repo owner/name` or `--open-issues github:owner/name`. Inputs are capped at 8 MiB, 32 JSON nesting levels, 1000 issue entries, 32 labels per issue, and bounded title/URL/label strings. | +| `--duplicate-confidence ` / `--duplicate-threshold ` | `search --format issue-drafts`, `suggestions export --format issue-drafts` | Tune duplicate-preflight matching. `low`, `medium`, and `high` map to score thresholds of 0.35, 0.45, and 0.7; `--duplicate-threshold` accepts an explicit 0.0-1.0 score and reports `confidence: "custom"` in the JSON summary. | | `--issue-title ` / `--issue-label <label>` | `search --format issue-drafts` | Set the title for an ad hoc search draft and add label hints. `--issue-label` can be repeated or comma-separated. | | `--check` | `status` | Verify that `.cdidx/codeindex.db` exactly matches the current indexable workspace by comparing DB file paths/checksums against a fresh filesystem scan. Matching indexes exit `0`; stale indexes exit `5`. | | `--dry-run` | `index` | Scan files and report what would change without writing to the database | @@ -1685,7 +1704,7 @@ Run `cdidx status --log-path` to print the active log directory without opening ### Project-local configuration file (`.cdidx/config.json` / `.cdidxrc.json`) -You can check a `.cdidx/config.json` or `.cdidxrc.json` file into a repository to set per-project defaults instead of relying on shell-profile or CI env vars (#1571). On startup `cdidx` walks upward from the current working directory looking for the first project config file, validates its schema, and materializes recognized keys as process environment variables — so every existing env-var consumer picks them up without further changes. +You can check a `.cdidx/config.json` or `.cdidxrc.json` file into a repository to set per-project defaults instead of relying on shell-profile or CI env vars (#1571). On startup `cdidx` walks upward from the current working directory looking for the first project config file, validates its schema, and materializes recognized keys as process environment variables — so every existing env-var consumer picks them up without further changes. Discovery stops after checking a directory that contains `.git`, `.hg`, `.svn`, `cdidx.workspace.json`, or `.cdidx-workspace.json`, so a child workspace does not inherit a config file from an unrelated parent. Precedence is **CLI flag > environment variable > config file > built-in default**. A config-file value is applied only when the matching env var is not already set in the process, so a value the user already exported in the shell or CI always wins. Config JSON is bounded to 64 KiB and a conservative nesting depth before schema validation. A malformed file (invalid JSON, unknown key, wrong type, or excessive nesting) is a hard error: cdidx exits `1` with the file path and all detected offending fields; set `CDIDX_DISABLE_CONFIG_FILE=1` to bypass the file entirely. @@ -2424,7 +2443,7 @@ MCP security-sensitive environment variables share validation diagnostics. Token cdidx includes a `suggest_improvement` MCP tool for AI agents that hit gaps or bugs. Suggestions are saved locally beside the selected DB (`.cdidx/suggestions-codeindex.json` by default), and are sent to GitHub only when the user explicitly provides `CDIDX_GITHUB_TOKEN`. GitHub submission runs outside the suggestion-store file lock and uses a 10-second timeout by default; set `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS=<seconds>` to tune that deadline up to 300 seconds. Non-positive, non-numeric, and larger values fall back to the 10-second default. GitHub HTTP calls use .NET's default proxy discovery, but they do not forward OS/default proxy credentials by default; set `CDIDX_GITHUB_PROXY_USE_DEFAULT_CREDENTIALS=1` only when an enterprise proxy explicitly requires those credentials. Local records include lifecycle metadata: `draft`, `submitted_pending_triage`, `open_in_upstream`, `resolved_in_upstream`, `wont_fix`, `duplicate`, or `superseded`, plus upstream issue URL/number fields when known. They also persist GitHub submission diagnostics (`last_submit_attempt`, `submit_attempt_count`, `last_submit_error`, and rate-limit `next_retry_at`) so operators can tell whether a suggestion was never attempted, failed transiently, is waiting for a rate-limit window, or was rejected by the API. New records also store attribution metadata: the MCP `initialize.clientInfo` name/version when available, an opaque cdidx session id, the cdidx version that recorded the suggestion, optional natural-language `toolInvocationContext`, and optional repository-relative `evidencePaths` supplied by the caller. Payload details and source-code leak guardrails are documented in the [Developer Guide](DEVELOPER_GUIDE.md#ai-feedback-implementation). -Use `cdidx suggestions list` to review recorded suggestions, `cdidx suggestions show <id>` to inspect one entry, and `cdidx suggestions export --format markdown` to share a filtered triage bundle with a team. Use `cdidx suggestions export --format issue-drafts --open-issues open-issues.json` to emit issue-ready drafts with title, labels, evidence paths, body text, and duplicate matches from an open-issues JSON preflight. The command reads the suggestion store beside the selected DB (`.cdidx/suggestions-codeindex.json` by default), supports filters such as `--status`, `--language`, `--category`, `--since`, and `--agent`, and prints JSON with `--json` for scripts. By default, `suggestions list` and `suggestions export` emit every matching record in newest-first order; pass `--limit <n>` and `--offset <n>` to page or cap large stores. Exported JSON, markdown bundles, and issue-draft bodies cap long description/context/tool-invocation text with a `[truncated]` marker; use `cdidx suggestions show <id>` when you need the full local record body. +Use `cdidx suggestions list` to review recorded suggestions, `cdidx suggestions show <id>` to inspect one entry, and `cdidx suggestions export --format markdown` to share a filtered triage bundle with a team. Use `cdidx suggestions export --format issue-drafts --open-issues open-issues.json` to emit issue-ready drafts with title, labels, evidence paths, severity/confidence/evidence-count triage metadata, body text, and duplicate matches from an open-issues JSON preflight. Add `--duplicate-confidence low|medium|high` or `--duplicate-threshold <0..1>` when issue-draft exports need looser or stricter duplicate matching. The command reads the suggestion store beside the selected DB (`.cdidx/suggestions-codeindex.json` by default), supports filters such as `--status`, `--language`, `--category`, `--since`, and `--agent`, and prints JSON with `--json` for scripts. By default, `suggestions list` and `suggestions export` emit every matching record in newest-first order; pass `--limit <n>` and `--offset <n>` to page or cap large stores. Exported JSON, markdown bundles, and issue-draft bodies cap long description/context/tool-invocation text with a `[truncated]` marker; use `cdidx suggestions show <id>` when you need the full local record body. Treat exported issue drafts as triage aids and review duplicate guidance plus current open issues before filing. Suggestion history readers can query the local store by lifecycle status, created-at threshold, category, language, or stored-order pages. These query APIs stream records from disk so tools that only need a narrow slice do not have to deserialize the whole suggestions file first. @@ -3516,6 +3535,12 @@ query text、`--lang`、`--path`、`--exclude-tests` で絞り込むか、MCP cu MCP `search` tool では同じ mode を camelCase 引数 `requireBefore`, `requireAfter`, `rejectBefore`, `rejectAfter`, `guardWindow` で指定できます。 +機械可読な search export は、下流ツールが人間向けテキストを再解析せずに triage できる +文脈を含みます。`--format csv` と `--format tsv` は file location、label、query、 +該当する場合の recipe/query 名、language、visibility、enclosing symbol、`match_lines` +の安定した列を出力します。`--format sarif` は SARIF 2.1.0 として rule metadata/help text、 +result level、正規化済みの repository-relative artifact URI を出力します。 + search audit recipe は、名前付き recipe を複数の curated search query に展開します。 組み込み recipe には `risky-code`、`json-parse-apis`、`dotnet-risk-patterns`、`xml-parser-security`、 `filesystem-traversal`、`broad-token-audit` があります。`--list-recipes` は利用可能な名前、 @@ -3534,19 +3559,30 @@ recipe run が対応する形式は text output、`--json` / `--format json`、` `--format issue-drafts` です。`--list-recipes` は text または JSON に対応します。 その他の search export format と `--json=array` は、recipe output が query または list metadata ごとに grouped されるため usage error で拒否します。 +recipe の JSON/compact output は `--limit` を query ごとに適用し、emitted/truncated +count を含む `summary` を出力し、truncated な child query には `next_cursor` を付けます。 +次の result set を取得するには、単一 child query を +`--recipe <recipe>/<query> --cursor <next_cursor>` として再実行してください。 MCP `search` tool では `{"listRecipes":true}` で recipe を発見し、 `{"recipe":"risky-code"}` で実行できます。MCP の recipe run も CLI と同じ既定の source scope を適用します。docs、tests、changelog、recipe definitions を意図的に audit する場合は `{"auditScope":"all"}` を指定してください。`CDIDX_SEARCH_RECIPE_PATHS` に platform path separator 区切りの JSON file を指定すると、設定済み recipe source を追加できます。各 file は recipe array または `{ "recipes": [...] }` を受け付け、不正な source は bounded な -`recipe_source_diagnostics` として報告されます。 +`recipe_source_diagnostics` として報告されます。外部 recipe は recipe-level の +`default_scope`、`default_path_patterns`、`default_exclude_paths` を宣言できます。 +各 query は `severity`、`path_patterns`、`exclude_paths` を宣言でき、recipe の既定 scope +とは独立して query ごとの対象を狭められます。 triage automation では `--format issue-drafts` を使うと、title、label、evidence path、 -Markdown body、duplicate-preflight metadata を持つ issue draft object を出力します。 +severity / confidence / evidence-count の triage metadata、Markdown body、 +duplicate-preflight metadata を持つ issue draft object を出力します。 `--open-issues <path>` は `gh issue list --state open --json number,title,labels,url` のような open issue JSON list を受け取り、未指定の場合も payload には -`duplicate_preflight.checked: false` が含まれます。draft body は evidence path と -recipe metadata を含みますが、source snippet は含めません。 +`duplicate_preflight.checked: false` が含まれます。duplicate preflight の厳しさは +`--duplicate-confidence low|medium|high` または `--duplicate-threshold <0..1>` で調整でき、 +JSON summary には `confidence` と `minimum_score` が出力されます。draft body は evidence path と +recipe metadata を含みますが、source snippet は含めません。これらの draft は triage aid なので、 +起票前に duplicate guidance と現在の open issue を確認してください。 ### クエリのデバッグ @@ -3880,6 +3916,7 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--offset <n>` | `suggestions list`, `suggestions export` | filter と新しい順の並び替え後、出力前に指定件数の提案レコードをスキップします。 | | `--format <json\|markdown\|issue-drafts>` | `suggestions export` | エクスポート形式を選びます。既定は JSON、markdown は人間の triage 共有向け、issue-drafts は Issue 作成用の draft object を出力します。 | | `--open-issues <path\|github\|github:owner/name>` / `--repo <owner/name>` | `search --format issue-drafts`, `suggestions export --format issue-drafts` | `gh issue list --state open --json number,title,labels,url` などの open issue JSON と照合して draft を事前重複確認します。どちらのコマンドも `--open-issues github --repo owner/name` または `--open-issues github:owner/name` で GitHub の live open issue も取得できます。入力は 8 MiB、JSON ネスト 32 段、issue entry 1000 件、issue ごとの label 32 件、title / URL / label 文字列長の上限に制限されます。 | +| `--duplicate-confidence <low\|medium\|high>` / `--duplicate-threshold <score>` | `search --format issue-drafts`, `suggestions export --format issue-drafts` | duplicate preflight の一致しきい値を調整します。`low`、`medium`、`high` は score threshold 0.35、0.45、0.7 に対応します。`--duplicate-threshold` は明示的な 0.0-1.0 の score を受け取り、JSON summary では `confidence: "custom"` を出力します。 | | `--issue-title <title>` / `--issue-label <label>` | `search --format issue-drafts` | ad hoc search draft の title を指定し、label hint を追加します。`--issue-label` は繰り返し指定またはカンマ区切りに対応します。 | | `--check` | `status` | DB のファイル path/checksum と現在の index 対象 workspace を比較し、`.cdidx/codeindex.db` が完全一致するか確認。完全一致なら終了コード `0`、stale なら `5` | | `--dry-run` | `index` | DB に書き込まず、どの変更が発生するかだけを走査して報告 | @@ -4203,7 +4240,7 @@ MCP のレスポンスサイズ上限は、環境変数 override で guard が ### プロジェクト固有の設定ファイル (`.cdidx/config.json` / `.cdidxrc.json`) -シェルプロファイルや CI の環境変数に頼らず、プロジェクトごとの既定値を `.cdidx/config.json` または `.cdidxrc.json` ファイルとしてリポジトリにチェックインできます (#1571)。`cdidx` は起動時にカレントディレクトリから上方向に最初のプロジェクト設定ファイルを探索し、スキーマを検証してから既知のキーをプロセス環境変数として注入します。これにより、既存の環境変数コンシューマはコード変更なしに同じ値を受け取れます。 +シェルプロファイルや CI の環境変数に頼らず、プロジェクトごとの既定値を `.cdidx/config.json` または `.cdidxrc.json` ファイルとしてリポジトリにチェックインできます (#1571)。`cdidx` は起動時にカレントディレクトリから上方向に最初のプロジェクト設定ファイルを探索し、スキーマを検証してから既知のキーをプロセス環境変数として注入します。これにより、既存の環境変数コンシューマはコード変更なしに同じ値を受け取れます。探索は `.git`、`.hg`、`.svn`、`cdidx.workspace.json`、`.cdidx-workspace.json` を含むディレクトリを確認した後で停止するため、子 workspace が無関係な親ディレクトリの設定ファイルを継承しません。 優先順位は **CLI フラグ > 環境変数 > 設定ファイル > 組み込み既定値** です。設定ファイル由来の値は、対応する環境変数がプロセスで未設定の場合にのみ適用されるため、シェルや CI で既に export されている値が常に優先されます。設定 JSON はスキーマ検証前に 64 KiB と保守的なネスト深度の上限で検査されます。不正なファイル(無効な JSON、未知のキー、型違い、過度なネスト)は hard error として扱われ、cdidx はファイルパスと検出できた該当フィールドすべてを示して終了コード `1` で終了します。完全にバイパスしたい場合は `CDIDX_DISABLE_CONFIG_FILE=1` を設定してください。 @@ -4927,7 +4964,7 @@ MCP の security-sensitive な環境変数は共通の validation 診断を使 cdidx には、AI エージェントがギャップや不具合に気づいたときに使える `suggest_improvement` MCP ツールがあります。提案は選択した DB の隣(既定は `.cdidx/suggestions-codeindex.json`)にローカル保存され、`CDIDX_GITHUB_TOKEN` を明示設定した場合に限って GitHub へ送信されます。GitHub 送信は suggestion-store のファイルロック外で実行され、既定では 10 秒で timeout します。この deadline は `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS=<秒>` で最大 300 秒まで調整できます。0 以下、数値以外、または上限を超える値は 10 秒の既定値へ戻ります。GitHub HTTP 呼び出しは .NET の既定 proxy 検出を使いますが、既定では OS/default proxy 資格情報を転送しません。企業 proxy が明示的にその資格情報を必要とする場合だけ `CDIDX_GITHUB_PROXY_USE_DEFAULT_CREDENTIALS=1` を設定してください。ローカルレコードには lifecycle metadata として `draft`、`submitted_pending_triage`、`open_in_upstream`、`resolved_in_upstream`、`wont_fix`、`duplicate`、`superseded` と、判明している upstream issue URL/番号が保存されます。さらに GitHub 送信診断として `last_submit_attempt`、`submit_attempt_count`、`last_submit_error`、rate-limit 時の `next_retry_at` も永続化されるため、提案が未試行なのか、一時的に失敗したのか、rate-limit window 待ちなのか、API に拒否されたのかを運用者が判断できます。新規レコードには attribution metadata も保存されます。取得可能な場合は MCP `initialize.clientInfo` の name/version、不透明な cdidx セッション ID、提案を記録した cdidx バージョン、呼び出し元が任意で渡す自然言語の `toolInvocationContext`、任意のリポジトリ相対 `evidencePaths` が含まれます。ペイロード詳細とソースコード漏えいガードは [DEVELOPER_GUIDE.md#aiフィードバックの実装](DEVELOPER_GUIDE.md#aiフィードバックの実装) にまとめています。 -記録済みの提案は `cdidx suggestions list` で確認し、`cdidx suggestions show <id>` で1件を詳細表示し、`cdidx suggestions export --format markdown` でチーム triage 用に共有できます。`cdidx suggestions export --format issue-drafts --open-issues open-issues.json` は、title、labels、evidence paths、body text、open issue JSON との重複候補を含む Issue 作成用 draft を出力します。このコマンドは選択した DB の隣にある提案ストア(既定は `.cdidx/suggestions-codeindex.json`)を読み、`--status`、`--language`、`--category`、`--since`、`--agent` で絞り込めます。スクリプト向けには `--json` を使います。既定では `suggestions list` と `suggestions export` は一致した全レコードを新しい順に出力します。大きなストアでは `--limit <n>` と `--offset <n>` でページングまたは出力上限を指定できます。export JSON、markdown bundle、issue draft body は長い description / context / tool-invocation text を `[truncated]` marker 付きで制限します。ローカルレコード本文をすべて確認する場合は `cdidx suggestions show <id>` を使ってください。 +記録済みの提案は `cdidx suggestions list` で確認し、`cdidx suggestions show <id>` で1件を詳細表示し、`cdidx suggestions export --format markdown` でチーム triage 用に共有できます。`cdidx suggestions export --format issue-drafts --open-issues open-issues.json` は、title、labels、evidence paths、severity / confidence / evidence-count の triage metadata、body text、open issue JSON との重複候補を含む Issue 作成用 draft を出力します。issue-draft export で重複一致を緩く、または厳しくしたい場合は `--duplicate-confidence low|medium|high` または `--duplicate-threshold <0..1>` を追加します。このコマンドは選択した DB の隣にある提案ストア(既定は `.cdidx/suggestions-codeindex.json`)を読み、`--status`、`--language`、`--category`、`--since`、`--agent` で絞り込めます。スクリプト向けには `--json` を使います。既定では `suggestions list` と `suggestions export` は一致した全レコードを新しい順に出力します。大きなストアでは `--limit <n>` と `--offset <n>` でページングまたは出力上限を指定できます。export JSON、markdown bundle、issue draft body は長い description / context / tool-invocation text を `[truncated]` marker 付きで制限します。ローカルレコード本文をすべて確認する場合は `cdidx suggestions show <id>` を使ってください。出力された issue draft は triage aid として扱い、起票前に duplicate guidance と現在の open issue を確認してください。 提案履歴を読む側は、ライフサイクル状態、作成日時のしきい値、カテゴリ、言語、保存順ページでローカルストアを絞り込めます。これらのクエリ API はディスクからレコードをストリーミングするため、必要な範囲が小さいツールでも suggestions ファイル全体を先にデシリアライズする必要がありません。 diff --git a/changelog.d/unreleased/3667.changed.md b/changelog.d/unreleased/3667.changed.md new file mode 100644 index 0000000000..ce3c20451a --- /dev/null +++ b/changelog.d/unreleased/3667.changed.md @@ -0,0 +1,18 @@ +--- +category: changed +issues: + - 3667 +affected: + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - USER_GUIDE.md +--- + +## English + +- **Recipe JSON output now includes bounded run summaries (#3667)** — recipe JSON and compact responses report per-query limits, emitted/truncated counts, minimum omitted counts, and cursor availability so large audit runs can be paged intentionally. + +## 日本語 + +- **recipe JSON output に bounded run summary を追加しました (#3667)** — recipe JSON と compact response は query ごとの limit、emitted/truncated count、最小 omitted count、cursor 利用可否を返し、大きな audit run を意図的に page できるようにしました。 diff --git a/changelog.d/unreleased/3668.changed.md b/changelog.d/unreleased/3668.changed.md new file mode 100644 index 0000000000..2611591b4f --- /dev/null +++ b/changelog.d/unreleased/3668.changed.md @@ -0,0 +1,18 @@ +--- +category: changed +issues: + - 3668 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - src/CodeIndex/Cli/SuggestionsCommandRunner.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - tests/CodeIndex.Tests/ProgramCliTests.cs + - USER_GUIDE.md +--- +## English +- **Issue-draft exports now include triage metadata (#3668)** - Search and suggestion issue-draft JSON now expose severity, confidence, evidence count, and duplicate guidance, and generated bodies include the same metadata before filing guidance. + +## 日本語 +- **Issue draft export に triage metadata を追加 (#3668)** - search と suggestions の issue-draft JSON が severity、confidence、evidence count、duplicate guidance を出力し、生成 body にも起票前確認用の同じ metadata を含めるようになりました。 diff --git a/changelog.d/unreleased/3807.changed.md b/changelog.d/unreleased/3807.changed.md new file mode 100644 index 0000000000..25d72ea795 --- /dev/null +++ b/changelog.d/unreleased/3807.changed.md @@ -0,0 +1,16 @@ +--- +category: changed +issues: + - 3807 +affected: + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +--- + +## English + +- **External search recipes now honor declared defaults and hide raw source paths in diagnostics (#3807)** — `default_scope`, `default_path_patterns`, and `default_exclude_paths` are applied to recipe runs, while load diagnostics identify configured recipe sources by ordinal label instead of echoing filesystem paths. + +## 日本語 + +- **外部検索レシピの defaults を反映し、診断で生の source path を隠すようになりました (#3807)** — `default_scope`、`default_path_patterns`、`default_exclude_paths` を recipe 実行に適用し、読み込み診断では filesystem path をそのまま出さず番号付き source label で識別します。 diff --git a/changelog.d/unreleased/3826-3674.changed.md b/changelog.d/unreleased/3826-3674.changed.md new file mode 100644 index 0000000000..65c3b38340 --- /dev/null +++ b/changelog.d/unreleased/3826-3674.changed.md @@ -0,0 +1,21 @@ +--- +category: changed +issues: + - 3826 + - 3674 +affected: + - src/CodeIndex/Cli/CdidxConfigFile.cs + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - tests/CodeIndex.Tests/CdidxConfigFileTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - USER_GUIDE.md +--- + +## English + +- **Search recipe and config discovery boundaries are stricter (#3826, #3674)** — external recipe sources are read through a bounded helper, recipe queries can declare severity plus query-local include/exclude globs while still honoring explicit user `--path` filters, built-in audit recipes cover additional IO/sleep/HTTP patterns, and project config discovery stops at repository/workspace boundaries. + +## 日本語 + +- **検索レシピと設定探索の境界を強化しました (#3826, #3674)** — 外部 recipe source は bounded helper 経由で読み込み、recipe query は severity と query-local include/exclude glob を宣言しつつ明示的なユーザー `--path` filter も尊重し、built-in audit recipe は追加の IO/sleep/HTTP pattern を扱い、project config discovery は repository/workspace boundary で停止します。 diff --git a/changelog.d/unreleased/3827.changed.md b/changelog.d/unreleased/3827.changed.md new file mode 100644 index 0000000000..2869820853 --- /dev/null +++ b/changelog.d/unreleased/3827.changed.md @@ -0,0 +1,22 @@ +--- +category: changed +issues: + - 3827 +affected: + - src/CodeIndex/Cli/IssueDuplicatePreflight.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/SuggestionsCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - tests/CodeIndex.Tests/ProgramCliTests.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs + - USER_GUIDE.md +--- +## English +- **Machine-readable search exports carry richer metadata (#3827)** - Search CSV/TSV output now includes query, language, visibility, enclosing symbol, match line, and recipe/query columns, while SARIF output includes rule metadata, help text, result levels, and normalized artifact URIs. +- **Issue-draft duplicate preflight is tunable (#3827)** - Search and suggestion issue-draft exports accept duplicate confidence presets or an explicit score threshold and report the effective confidence/minimum score in JSON. + +## 日本語 +- **機械可読 search export の metadata を拡充 (#3827)** - search の CSV/TSV 出力に query、language、visibility、enclosing symbol、match line、recipe/query 列を追加し、SARIF 出力には rule metadata、help text、result level、正規化済み artifact URI を含めるようにしました。 +- **Issue draft の duplicate preflight を調整可能にしました (#3827)** - search と suggestions の issue-draft export が duplicate confidence preset または明示的な score threshold を受け取り、有効な confidence / minimum score を JSON に出力します。 diff --git a/src/CodeIndex/Cli/CdidxConfigFile.cs b/src/CodeIndex/Cli/CdidxConfigFile.cs index 3b30c4d49a..6f00881dea 100644 --- a/src/CodeIndex/Cli/CdidxConfigFile.cs +++ b/src/CodeIndex/Cli/CdidxConfigFile.cs @@ -64,6 +64,12 @@ internal static class CdidxConfigFile private static readonly IReadOnlyList<string> KnownMcpKeys = new[] { "tools", "rate_limit" }; private static readonly IReadOnlyList<string> KnownMcpToolsKeys = new[] { "allow", "deny" }; private static readonly IReadOnlyList<string> KnownMcpRateLimitKeys = new[] { "rps", "burst", "bucket_idle_seconds" }; + private static readonly IReadOnlyList<string> ConfigDiscoveryBoundaryDirectories = new[] { ".git", ".hg", ".svn" }; + private static readonly IReadOnlyList<string> ConfigDiscoveryBoundaryFiles = new[] + { + WorkspaceManifestLoader.FileName, + WorkspaceManifestLoader.DotFileName, + }; internal sealed record LoadResult(string? Path, string? Error) { @@ -465,11 +471,32 @@ private static (IReadOnlyDictionary<string, string> Settings, IReadOnlyDictionar var candidate = Path.Combine(current.FullName, FileName); if (File.Exists(LongPath.EnsureWindowsPrefix(candidate))) return candidate; + if (IsConfigDiscoveryBoundary(current)) + break; current = current.Parent; } return null; } + private static bool IsConfigDiscoveryBoundary(DirectoryInfo directory) + { + foreach (var directoryName in ConfigDiscoveryBoundaryDirectories) + { + var path = Path.Combine(directory.FullName, directoryName); + if (Directory.Exists(LongPath.EnsureWindowsPrefix(path))) + return true; + } + + foreach (var fileName in ConfigDiscoveryBoundaryFiles) + { + var path = Path.Combine(directory.FullName, fileName); + if (File.Exists(LongPath.EnsureWindowsPrefix(path))) + return true; + } + + return false; + } + private static string ResolveConfigWorkspaceRoot(string configPath) { var fullConfigPath = Path.GetFullPath(configPath); diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 812e0c7a1c..a585253431 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -289,6 +289,8 @@ private static IReadOnlyList<CliFlag> BuildAll() new() { Name = "--named-query", ValuePlaceholder = "<name>=<query>", Description = "Search: add one named ad hoc batch query", Commands = Set("search") }, new() { Name = "--open-issues", ValuePlaceholder = "<path|github|github:owner/name>", Description = "Preflight issue drafts against open issue JSON or GitHub open issues", Commands = Set("search", "suggestions") }, new() { Name = "--repo", ValuePlaceholder = "<owner/name>", Description = "Issue-drafts: GitHub repository for --open-issues github", Commands = Set("search", "suggestions") }, + new() { Name = "--duplicate-confidence", ValuePlaceholder = "<low|medium|high>", Description = "Issue-drafts: preset duplicate-preflight match threshold", Commands = Set("search", "suggestions") }, + new() { Name = "--duplicate-threshold", ValuePlaceholder = "<score>", Description = "Issue-drafts: explicit duplicate-preflight minimum score from 0 to 1", Commands = Set("search", "suggestions") }, new() { Name = "--issue-title", ValuePlaceholder = "<title>", Description = "Search issue-drafts: override the title for an ad hoc search draft", Commands = Set("search") }, new() { Name = "--issue-label", ValuePlaceholder = "<label>", Description = "Search issue-drafts: add a label hint; repeat or comma-separate values", Commands = Set("search") }, new() { Name = "--cursor", ValuePlaceholder = "<cursor>", Description = "Search recipe: pagination cursor returned as next_cursor", Commands = Set("search") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index e220f14d3a..a7bb08c0de 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -88,7 +88,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("index-commits", "cdidx index <projectPath> --commits <commit-ref> [commit-ref ...] [--db <path>] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]]"), ("index-changed-between", "cdidx index <projectPath> --changed-between <old-ref> <new-ref> [--db <path>] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]]"), ("index-files", "cdidx index <projectPath> --files <path> [path ...] [--db <path>] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]]"), - ("search", "cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes|--named-query <name>=<query> [--named-query <name>=<query> ...] [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--audit-scope <source|all>] [--show-excluded] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>|--max-results <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] [--prefix] [--count] [--group-by <file|symbol>] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]"), + ("search", "cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes|--named-query <name>=<query> [--named-query <name>=<query> ...] [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--audit-scope <source|all>] [--show-excluded] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--duplicate-confidence <low|medium|high>|--duplicate-threshold <score>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>|--max-results <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] [--prefix] [--count] [--group-by <file|symbol>] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]"), ("definition", "cdidx definition <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--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] [--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|--exact-name] [--all]"), ("references", "cdidx references <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--snippet-lines <n>] [--max-line-width <n>] [--exact|--exact-name] [--count]"), @@ -114,7 +114,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("deps", "cdidx deps [--db <path>] [--json] [--format <dot|graphml|json-graph|edgelist>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--reverse] [--cycles]"), ("unused", "cdidx unused [--db <path>] [--json] [--compact] [--verbose] [--limit <n>|--top <n>] [--kind <kind>] [--bucket <bucket>] [--min-confidence <medium|low>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--count] [--by-bucket]"), ("hotspots", "cdidx hotspots [--db <path>] [--json] [--verbose] [--limit <n>|--top <n>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--count] [--group-by <symbol|file|statement>] [--group-by-name]"), - ("suggestions", "cdidx suggestions <list|show|export> [id] [--db <path>] [--json] [--status <all|submitted|unsubmitted>] [--language <lang>] [--category <category>] [--since <datetime>] [--agent <name>] [--limit <n>] [--offset <n>] [--format <json|markdown|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>]"), + ("suggestions", "cdidx suggestions <list|show|export> [id] [--db <path>] [--json] [--status <all|submitted|unsubmitted>] [--language <lang>] [--category <category>] [--since <datetime>] [--agent <name>] [--limit <n>] [--offset <n>] [--format <json|markdown|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--duplicate-confidence <low|medium|high>|--duplicate-threshold <score>]"), ("export", "cdidx export <archive> [--db <path>] [--json]"), ("export", "cdidx export ctags [--output <path>] [--db <path>] [--json] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests]"), ("import", "cdidx import <archive> [--db <path>] [--prune-paths] [--dry-run|--check] [--json]"), diff --git a/src/CodeIndex/Cli/IssueDuplicatePreflight.cs b/src/CodeIndex/Cli/IssueDuplicatePreflight.cs index 40e1a7d916..415470e517 100644 --- a/src/CodeIndex/Cli/IssueDuplicatePreflight.cs +++ b/src/CodeIndex/Cli/IssueDuplicatePreflight.cs @@ -18,6 +18,13 @@ internal sealed class IssueDuplicatePreflight internal const int MaxOpenIssueNumberLength = 32; internal const int MaxTitleTokenizationInputLength = MaxOpenIssueTitleLength; internal const int MaxGitHubRepositoryLength = 200; + internal const double LowDuplicateThreshold = 0.35; + internal const double DefaultDuplicateThreshold = 0.45; + internal const double HighDuplicateThreshold = 0.7; + internal const string LowDuplicateConfidence = "low"; + internal const string DefaultDuplicateConfidence = "medium"; + internal const string HighDuplicateConfidence = "high"; + internal const string CustomDuplicateConfidence = "custom"; private const int GitHubOpenIssuesPerPage = 100; private const int MaxGitHubOpenIssuePages = (MaxOpenIssueCount / GitHubOpenIssuesPerPage) + 1; private const string GitHubSourceName = "github"; @@ -129,7 +136,26 @@ public static bool TryLoad(string? source, string? repository, out IssueDuplicat return TryLoadFromGitHub(normalizedRepository, out preflight, out error); } + public static bool TryNormalizeDuplicateConfidence(string value, out string confidence) + { + confidence = value.Trim().ToLowerInvariant(); + return confidence is LowDuplicateConfidence or DefaultDuplicateConfidence or HighDuplicateConfidence; + } + + public static double ThresholdForDuplicateConfidence(string confidence) => confidence switch + { + LowDuplicateConfidence => LowDuplicateThreshold, + HighDuplicateConfidence => HighDuplicateThreshold, + _ => DefaultDuplicateThreshold, + }; + public List<SuggestionIssueDraftDuplicateMatchJsonResult> FindMatches(string draftTitle, IReadOnlyList<string> draftLabels) + => FindMatches(draftTitle, draftLabels, DefaultDuplicateThreshold); + + public List<SuggestionIssueDraftDuplicateMatchJsonResult> FindMatches( + string draftTitle, + IReadOnlyList<string> draftLabels, + double minimumScore) { if (!Checked || _issues.Count == 0) return []; @@ -153,13 +179,14 @@ public List<SuggestionIssueDraftDuplicateMatchJsonResult> FindMatches(string dra string? reason = null; if (normalizedIssueTitle.Length > 0 && normalizedIssueTitle == normalizedDraftTitle) { - reason = "title_exact"; score = 1.0; + if (score >= minimumScore) + reason = "title_exact"; } else if (overlappingLabels.Count > 0) { score = ScoreTitleSimilarity(draftTokens, TokenizeTitle(issue.Title)); - if (score >= 0.45) + if (score >= minimumScore) { reason = "title_label_similarity"; } @@ -168,8 +195,9 @@ public List<SuggestionIssueDraftDuplicateMatchJsonResult> FindMatches(string dra && (normalizedIssueTitle.Contains(normalizedDraftTitle, StringComparison.Ordinal) || normalizedDraftTitle.Contains(normalizedIssueTitle, StringComparison.Ordinal))) { - reason = "title_label_contains"; - score = Math.Max(score, 0.45); + score = Math.Max(score, DefaultDuplicateThreshold); + if (score >= minimumScore) + reason = "title_label_contains"; } } diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 6150f85441..8c6a8b8f41 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -605,6 +605,7 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(SearchIssueDraftExportJsonResult))] [JsonSerializable(typeof(SearchIssueDraftJsonResult))] [JsonSerializable(typeof(SearchIssueDraftSourceJsonResult))] +[JsonSerializable(typeof(IssueDraftTriageMetadataJsonResult))] [JsonSerializable(typeof(SearchNextMatchHint))] [JsonSerializable(typeof(SearchResult))] [JsonSerializable(typeof(SearchTermOccurrence))] diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 0d5cd98fbd..58ee4f5789 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -185,6 +185,8 @@ public static partial class QueryCommandRunner "--named-query", "--open-issues", "--repo", + "--duplicate-confidence", + "--duplicate-threshold", "--issue-title", "--issue-label", "--cursor", @@ -398,6 +400,14 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) "Use `--open-issues github --repo owner/name` to fetch open issues directly from GitHub."); return CommandExitCodes.UsageError; } + if (options.DuplicatePreflightTuningExplicit && options.OutputFormat != OutputFormatIssueDrafts) + { + WriteUsageError( + "--duplicate-confidence and --duplicate-threshold can only be used with `cdidx search --format issue-drafts`.", + GetUsageLineOrThrow("search"), + "Use these controls when exporting issue draft JSON with duplicate-preflight metadata."); + return CommandExitCodes.UsageError; + } if ((options.IncludeRecipeQueries.Count > 0 || options.ExcludeRecipeQueries.Count > 0) && options.RecipeName == null) { WriteUsageError( @@ -816,6 +826,11 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) displayRows = selection.Rows; if (displayRows.Count == 0) { + if (options.Json && (options.OutputFormat == OutputFormatCsv || options.OutputFormat == OutputFormatTsv)) + { + WriteDelimitedSearchResults([], options); + return ZeroResultExitCode(options); + } if (options.Json && TryWriteEmptyFormattedResult(options, jsonOptions)) return ZeroResultExitCode(options); if (options.Json) @@ -869,6 +884,11 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) WriteGroupedSearchResults(displayRows, options, jsonOptions); return CommandExitCodes.Success; } + if (options.OutputFormat == OutputFormatCsv || options.OutputFormat == OutputFormatTsv) + { + WriteDelimitedSearchResults(displayRows, options); + return CommandExitCodes.Success; + } if (TryWriteFormattedLocations( options, displayRows.SelectMany(row => ToSearchFormattedLocations(row, options.Query, exact)), @@ -1550,6 +1570,7 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp scope, selection.Queries.Count, compactTotal, + BuildSearchRecipeRunSummary(compactQueryResults, options.Limit, compactTotal), compactQueryResults), CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeCompactRunJsonResult)); return CommandExitCodes.Success; @@ -1566,6 +1587,7 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp scope, selection.Queries.Count, total, + BuildSearchRecipeRunSummary(queryResults, options.Limit, total), queryResults), CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeRunJsonResult)); return CommandExitCodes.Success; @@ -1652,7 +1674,9 @@ private static int RunSearchRecipeIssueDrafts(QueryCommandOptions options, JsonS new SuggestionIssueDraftPreflightSummaryJsonResult( preflight.Checked, preflight.Source, - preflight.OpenIssueCount), + preflight.OpenIssueCount, + options.DuplicateConfidence, + options.DuplicateThreshold), drafts), CliJsonSerializerContextFactory.Create(jsonOptions).SearchIssueDraftExportJsonResult)); return CommandExitCodes.Success; @@ -1695,7 +1719,12 @@ private static int RunSearchIssueDrafts(QueryCommandOptions options, JsonSeriali BuildAdHocIssueDraftLabels(options), "Review the evidence paths and surrounding code before filing.", exact, + SearchAuditRecipes.DefaultQuerySeverity, + [], + [], rows.Count, + options.Limit, + 0, BuildSearchRecipeTopFiles(rows), false, null, @@ -1715,7 +1744,9 @@ private static int RunSearchIssueDrafts(QueryCommandOptions options, JsonSeriali new SuggestionIssueDraftPreflightSummaryJsonResult( preflight.Checked, preflight.Source, - preflight.OpenIssueCount), + preflight.OpenIssueCount, + options.DuplicateConfidence, + options.DuplicateThreshold), drafts), CliJsonSerializerContextFactory.Create(jsonOptions).SearchIssueDraftExportJsonResult)); return CommandExitCodes.Success; @@ -1735,14 +1766,15 @@ private static List<SearchRecipeQueryResultJsonResult> CollectSearchRecipeQueryR foreach (var recipeQuery in recipeQueries) { var exact = userExact || recipeQuery.ExactSubstring; + var queryScope = BuildSearchRecipeQueryScope(scope, recipeQuery); var results = reader.Search( recipeQuery.Query, FetchLimitForSearchEnvelope(options.Limit), options.Lang, false, - scope.PathPatterns, - scope.ExcludePaths, - scope.ExcludeTests, + queryScope.PathPatterns, + queryScope.ExcludePaths, + queryScope.ExcludeTests, !options.NoDedup, options.Since, exact, @@ -1750,9 +1782,12 @@ private static List<SearchRecipeQueryResultJsonResult> CollectSearchRecipeQueryR !options.NoVisibilityRank, cursor: options.SearchCursor, guardFilters: options.GuardFilters, - guardWindow: options.GuardWindow); + guardWindow: options.GuardWindow, + requiredPathPatterns: GetSearchRecipeRequiredPathPatterns(options, recipeQuery)); var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query, rawFtsOverride: false); + var availableCount = rows.Count; var truncated = TrimSearchRowsToRequestedLimit(rows, options.Limit); + var minimumOmitted = truncated ? Math.Max(1, availableCount - rows.Count) : 0; total += rows.Count; queryResults.Add(new SearchRecipeQueryResultJsonResult( recipeQuery.Name, @@ -1761,7 +1796,12 @@ private static List<SearchRecipeQueryResultJsonResult> CollectSearchRecipeQueryR recipeQuery.RecommendedLabels, recipeQuery.FalsePositiveGuidance, exact, + recipeQuery.Severity, + [.. recipeQuery.PathPatterns], + [.. recipeQuery.ExcludePaths], rows.Count, + options.Limit, + minimumOmitted, BuildSearchRecipeTopFiles(rows), truncated, truncated && rows.Count > 0 ? FormatSearchCursor(rows[^1].Result) : null, @@ -1784,14 +1824,15 @@ private static List<SearchRecipeCompactQueryResultJsonResult> CollectSearchRecip foreach (var recipeQuery in recipeQueries) { var exact = userExact || recipeQuery.ExactSubstring; + var queryScope = BuildSearchRecipeQueryScope(scope, recipeQuery); var results = reader.Search( recipeQuery.Query, FetchLimitForSearchEnvelope(options.Limit), options.Lang, false, - scope.PathPatterns, - scope.ExcludePaths, - scope.ExcludeTests, + queryScope.PathPatterns, + queryScope.ExcludePaths, + queryScope.ExcludeTests, !options.NoDedup, options.Since, exact, @@ -1799,15 +1840,23 @@ private static List<SearchRecipeCompactQueryResultJsonResult> CollectSearchRecip !options.NoVisibilityRank, cursor: options.SearchCursor, guardFilters: options.GuardFilters, - guardWindow: options.GuardWindow); + guardWindow: options.GuardWindow, + requiredPathPatterns: GetSearchRecipeRequiredPathPatterns(options, recipeQuery)); var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query); + var availableCount = rows.Count; var truncated = TrimSearchRowsToRequestedLimit(rows, options.Limit); + var minimumOmitted = truncated ? Math.Max(1, availableCount - rows.Count) : 0; total += rows.Count; queryResults.Add(new SearchRecipeCompactQueryResultJsonResult( recipeQuery.Name, recipeQuery.Query, recipeQuery.Description, + recipeQuery.Severity, + [.. recipeQuery.PathPatterns], + [.. recipeQuery.ExcludePaths], rows.Count, + options.Limit, + minimumOmitted, BuildSearchRecipeTopFiles(rows), truncated, truncated && rows.Count > 0 ? FormatSearchCursor(rows[^1].Result) : null, @@ -1825,6 +1874,30 @@ private static List<SearchRecipeCompactQueryResultJsonResult> CollectSearchRecip return queryResults; } + private static SearchRecipeRunSummaryJsonResult BuildSearchRecipeRunSummary( + IReadOnlyList<SearchRecipeQueryResultJsonResult> queryResults, + int limitPerQuery, + int emittedResultCount) + => new( + limitPerQuery, + emittedResultCount, + queryResults.Count(query => query.Truncated), + queryResults.Sum(query => query.MinimumOmittedResultCount), + queryResults.Any(query => query.Truncated && !string.IsNullOrWhiteSpace(query.NextCursor)), + "When a query is truncated, rerun a single child query with --recipe <recipe>/<query> --cursor <next_cursor> to page the next result set."); + + private static SearchRecipeRunSummaryJsonResult BuildSearchRecipeRunSummary( + IReadOnlyList<SearchRecipeCompactQueryResultJsonResult> queryResults, + int limitPerQuery, + int emittedResultCount) + => new( + limitPerQuery, + emittedResultCount, + queryResults.Count(query => query.Truncated), + queryResults.Sum(query => query.MinimumOmittedResultCount), + queryResults.Any(query => query.Truncated && !string.IsNullOrWhiteSpace(query.NextCursor)), + "When a query is truncated, rerun a single child query with --recipe <recipe>/<query> --cursor <next_cursor> to page the next result set."); + private static SearchRecipeScopeJsonResult BuildSearchRecipeScope(SearchAuditRecipe recipe, QueryCommandOptions options) { var scopeName = options.AuditScopeExplicit ? options.AuditScope : recipe.DefaultScope; @@ -1850,6 +1923,23 @@ private static SearchRecipeScopeJsonResult BuildSearchRecipeScope(SearchAuditRec options.ShowExcluded ? BuildSearchRecipeExcludedDiagnostics(recipe, options, scopeName, excludeTests) : null); } + private static SearchRecipeScopeJsonResult BuildSearchRecipeQueryScope( + SearchRecipeScopeJsonResult scope, + SearchAuditRecipeQuery query) + { + var pathPatterns = query.PathPatterns.Count > 0 + ? [.. query.PathPatterns] + : new List<string>(scope.PathPatterns); + var excludePaths = new List<string>(scope.ExcludePaths); + AddDistinct(excludePaths, query.ExcludePaths); + + return scope with + { + PathPatterns = pathPatterns, + ExcludePaths = excludePaths + }; + } + private static List<SearchRecipeExcludedDiagnosticJsonResult> BuildSearchRecipeExcludedDiagnostics( SearchAuditRecipe recipe, QueryCommandOptions options, @@ -1902,6 +1992,11 @@ private static List<SearchRecipeTopFileJsonResult> BuildSearchRecipeTopFiles(IRe .Take(10) .ToList(); + private static IReadOnlyList<string>? GetSearchRecipeRequiredPathPatterns(QueryCommandOptions options, SearchAuditRecipeQuery recipeQuery) + => options.PathPatterns.Count > 0 && recipeQuery.PathPatterns.Count > 0 + ? options.PathPatterns + : null; + private static int FetchLimitForSearchEnvelope(int limit) { if (limit >= int.MaxValue) @@ -1987,13 +2082,15 @@ private static SearchIssueDraftJsonResult ToSearchIssueDraft( .Distinct(StringComparer.Ordinal) .Take(10) .ToList(); - var duplicateMatches = preflight.FindMatches(title, labels); + var duplicateMatches = preflight.FindMatches(title, labels, options.DuplicateThreshold); + var triage = BuildSearchIssueDraftTriage(queryResult, preflight.Checked, duplicateMatches.Count); return new SearchIssueDraftJsonResult( $"{recipe.Name}/{queryResult.Name}", title, labels, evidencePaths, - BuildSearchIssueDraftBody(recipe, queryResult, evidencePaths, options), + triage, + BuildSearchIssueDraftBody(recipe, queryResult, evidencePaths, triage, options), new SearchIssueDraftSourceJsonResult( recipe.Name, queryResult.Name, @@ -2021,13 +2118,15 @@ private static SearchIssueDraftJsonResult ToAdHocSearchIssueDraft( .Distinct(StringComparer.Ordinal) .Take(10) .ToList(); - var duplicateMatches = preflight.FindMatches(title, labels); + var duplicateMatches = preflight.FindMatches(title, labels, options.DuplicateThreshold); + var triage = BuildSearchIssueDraftTriage(queryResult, preflight.Checked, duplicateMatches.Count); return new SearchIssueDraftJsonResult( "search/ad-hoc", title, labels, evidencePaths, - BuildAdHocSearchIssueDraftBody(queryResult, evidencePaths), + triage, + BuildAdHocSearchIssueDraftBody(queryResult, evidencePaths, triage), new SearchIssueDraftSourceJsonResult( null, null, @@ -2045,6 +2144,25 @@ private static SearchIssueDraftJsonResult ToAdHocSearchIssueDraft( private static string BuildSearchIssueDraftTitle(SearchAuditRecipe recipe, SearchRecipeQueryResultJsonResult queryResult) => $"Search audit recipe {recipe.Name}: {queryResult.Name}"; + private static IssueDraftTriageMetadataJsonResult BuildSearchIssueDraftTriage( + SearchRecipeQueryResultJsonResult queryResult, + bool duplicatePreflightChecked, + int duplicateMatchCount) + => new( + queryResult.Severity, + queryResult.Count >= 3 ? "high" : queryResult.Count >= 2 ? "medium" : "low", + queryResult.Count, + BuildSearchIssueDraftDuplicateGuidance(duplicatePreflightChecked, duplicateMatchCount)); + + private static string BuildSearchIssueDraftDuplicateGuidance(bool duplicatePreflightChecked, int duplicateMatchCount) + { + if (!duplicatePreflightChecked) + return "Duplicate preflight was not checked; search open issues before filing."; + if (duplicateMatchCount > 0) + return "Review duplicate_preflight.matches before filing; merge evidence into an existing issue when the same root cause is already tracked."; + return "No duplicate candidates were found by preflight; still verify open issues before filing."; + } + private static string BuildAdHocSearchIssueDraftTitle(QueryCommandOptions options) => string.IsNullOrWhiteSpace(options.IssueTitle) ? $"Search issue draft: {options.Query}" @@ -2062,6 +2180,7 @@ private static string BuildSearchIssueDraftBody( SearchAuditRecipe recipe, SearchRecipeQueryResultJsonResult queryResult, IReadOnlyList<string> evidencePaths, + IssueDraftTriageMetadataJsonResult triage, QueryCommandOptions options) { var sb = new StringBuilder(); @@ -2085,6 +2204,8 @@ private static string BuildSearchIssueDraftBody( sb.AppendLine($"- {path}"); } sb.AppendLine(); + AppendSearchIssueDraftTriageMetadata(sb, triage); + sb.AppendLine(); sb.AppendLine("## False-positive guidance"); sb.AppendLine(queryResult.FalsePositiveGuidance); sb.AppendLine(); @@ -2101,6 +2222,15 @@ private static string BuildSearchIssueDraftBody( return sb.ToString().TrimEnd(); } + private static void AppendSearchIssueDraftTriageMetadata(StringBuilder sb, IssueDraftTriageMetadataJsonResult triage) + { + sb.AppendLine("## Triage metadata"); + sb.AppendLine($"- severity: `{triage.Severity}`"); + sb.AppendLine($"- confidence: `{triage.Confidence}`"); + sb.AppendLine($"- evidence_count: `{triage.EvidenceCount}`"); + sb.AppendLine($"- duplicate_guidance: {triage.DuplicateGuidance}"); + } + private static string BuildSearchRecipeReplayCommand(SearchAuditRecipe recipe, QueryCommandOptions options, string? queryName = null) { var recipeSelector = string.IsNullOrWhiteSpace(queryName) @@ -2149,6 +2279,13 @@ private static string BuildSearchRecipeReplayCommand(SearchAuditRecipe recipe, Q AddReplayValueOption(args, "--open-issues", options.OpenIssuesPath); if (!string.IsNullOrWhiteSpace(options.OpenIssuesRepository)) AddReplayValueOption(args, "--repo", options.OpenIssuesRepository); + if (options.DuplicatePreflightTuningExplicit) + { + if (string.Equals(options.DuplicateConfidence, IssueDuplicatePreflight.CustomDuplicateConfidence, StringComparison.Ordinal)) + AddReplayValueOption(args, "--duplicate-threshold", options.DuplicateThreshold.ToString("0.###", CultureInfo.InvariantCulture)); + else + AddReplayValueOption(args, "--duplicate-confidence", options.DuplicateConfidence); + } if (queryName == null) { foreach (var includeQuery in options.IncludeRecipeQueries) @@ -2210,7 +2347,8 @@ private static string QuoteReplayShellArg(string arg) private static string BuildAdHocSearchIssueDraftBody( SearchRecipeQueryResultJsonResult queryResult, - IReadOnlyList<string> evidencePaths) + IReadOnlyList<string> evidencePaths, + IssueDraftTriageMetadataJsonResult triage) { var sb = new StringBuilder(); sb.AppendLine("## Summary"); @@ -2230,6 +2368,8 @@ private static string BuildAdHocSearchIssueDraftBody( sb.AppendLine($"- {path}"); } sb.AppendLine(); + AppendSearchIssueDraftTriageMetadata(sb, triage); + sb.AppendLine(); sb.AppendLine("## Review guidance"); sb.AppendLine(queryResult.FalsePositiveGuidance); sb.AppendLine(); @@ -2256,6 +2396,9 @@ private static string BuildAdHocSearchIssueDraftBody( query.Description, query.RecommendedLabels, query.FalsePositiveGuidance, + query.Severity, + [.. query.PathPatterns], + [.. query.ExcludePaths], query.ExactSubstring)).ToList()); private static List<SearchDisplayRow> BuildSearchDisplayRows( @@ -2846,6 +2989,47 @@ private static void WriteDelimitedLocations(IEnumerable<FormattedLocation> locat } } + private static void WriteDelimitedSearchResults(IEnumerable<SearchDisplayRow> rows, QueryCommandOptions options) + { + var delimiter = options.OutputFormat == OutputFormatTsv ? "\t" : ","; + Console.WriteLine(string.Join(delimiter, + [ + "file", + "line", + "column", + "label", + "query", + "recipe", + "query_name", + "lang", + "visibility", + "enclosing_symbol_name", + "enclosing_symbol_kind", + "match_lines", + ])); + foreach (var row in rows) + { + var result = row.Result; + var compact = row.Compact; + var values = new[] + { + result.Path, + result.StartLine.ToString(CultureInfo.InvariantCulture), + "1", + $"search match: {options.Query}", + options.Query ?? string.Empty, + string.Empty, + string.Empty, + result.Lang ?? string.Empty, + result.Visibility ?? string.Empty, + compact.EnclosingSymbolName ?? string.Empty, + compact.EnclosingSymbolKind ?? string.Empty, + string.Join(";", compact.MatchLines.Select(line => line.ToString(CultureInfo.InvariantCulture))), + }; + Console.WriteLine(string.Join(delimiter, values.Select(value => EscapeDelimitedValue(value, options.OutputFormat)))); + } + } + private static string EscapeDelimitedValue(string value, string outputFormat) { if (outputFormat == OutputFormatTsv) @@ -2868,9 +3052,19 @@ private static void WriteSarif(IEnumerable<(string Path, int Line, int Column, s { var writer = Console.Out; var itemOptions = GetCompactJsonOptions(jsonOptions); - writer.Write("{\"version\":\"2.1.0\",\"runs\":[{\"tool\":{\"driver\":{\"name\":\"cdidx\",\"informationUri\":\"https://github.com/Widthdom/CodeIndex\"}},\"results\":"); + var itemList = items.ToList(); + writer.Write("{\"version\":\"2.1.0\",\"runs\":[{\"tool\":{\"driver\":{\"name\":\"cdidx\",\"informationUri\":\"https://github.com/Widthdom/CodeIndex\",\"rules\":"); + WriteJsonArrayInline( + itemList + .Select(item => item.RuleId) + .Where(ruleId => !string.IsNullOrWhiteSpace(ruleId)) + .Distinct(StringComparer.Ordinal) + .OrderBy(ruleId => ruleId, StringComparer.Ordinal), + (ruleWriter, ruleId) => WriteSarifRule(ruleWriter, ruleId, itemOptions), + separator: ","); + writer.Write("}},\"results\":"); WriteJsonArrayInline( - items, + itemList, (resultWriter, item) => WriteSarifResult(resultWriter, item, itemOptions), separator: ","); writer.WriteLine("}]}"); @@ -2891,14 +3085,29 @@ private static void WriteJsonArrayInline<T>(IEnumerable<T> items, Action<TextWri writer.Write(']'); } + private static void WriteSarifRule(TextWriter writer, string ruleId, JsonSerializerOptions jsonOptions) + { + writer.Write("{\"id\":"); + writer.Write(JsonSerializer.Serialize(ruleId, jsonOptions)); + writer.Write(",\"name\":"); + writer.Write(JsonSerializer.Serialize($"cdidx {ruleId}", jsonOptions)); + writer.Write(",\"shortDescription\":{\"text\":"); + writer.Write(JsonSerializer.Serialize($"cdidx {ruleId} result", jsonOptions)); + writer.Write("},\"fullDescription\":{\"text\":"); + writer.Write(JsonSerializer.Serialize("A machine-readable cdidx finding emitted from an indexed code query.", jsonOptions)); + writer.Write("},\"helpUri\":\"https://github.com/Widthdom/CodeIndex\",\"help\":{\"text\":"); + writer.Write(JsonSerializer.Serialize("Review the referenced location and surrounding code before filing or acting on this result.", jsonOptions)); + writer.Write("},\"defaultConfiguration\":{\"level\":\"warning\"},\"properties\":{\"tags\":[\"cdidx\",\"code-search\"]}}"); + } + private static void WriteSarifResult(TextWriter writer, (string Path, int Line, int Column, string Message, string RuleId) item, JsonSerializerOptions jsonOptions) { writer.Write("{\"ruleId\":"); writer.Write(JsonSerializer.Serialize(item.RuleId, jsonOptions)); - writer.Write(",\"message\":{\"text\":"); + writer.Write(",\"level\":\"warning\",\"message\":{\"text\":"); writer.Write(JsonSerializer.Serialize(item.Message, jsonOptions)); writer.Write("},\"locations\":[{\"physicalLocation\":{\"artifactLocation\":{\"uri\":"); - writer.Write(JsonSerializer.Serialize(item.Path, jsonOptions)); + writer.Write(JsonSerializer.Serialize(NormalizeSarifArtifactUri(item.Path), jsonOptions)); writer.Write("},\"region\":{\"startLine\":"); writer.Write(Math.Max(1, item.Line).ToString(CultureInfo.InvariantCulture)); writer.Write(",\"startColumn\":"); @@ -2906,6 +3115,14 @@ private static void WriteSarifResult(TextWriter writer, (string Path, int Line, writer.Write("}}}]}"); } + private static string NormalizeSarifArtifactUri(string path) + { + var normalized = path.Replace('\\', '/'); + while (normalized.StartsWith("./", StringComparison.Ordinal)) + normalized = normalized[2..]; + return normalized; + } + public static int RunDefinition(string[] cmdArgs, JsonSerializerOptions jsonOptions) { var previewOptionError = ValidatePreviewOptions("definition", cmdArgs, allowMaxLineWidth: false, allowFocusOptions: false); @@ -8056,6 +8273,10 @@ public static QueryCommandOptions ParseArgs( string auditScope = SearchAuditRecipes.DefaultAuditScope; bool auditScopeExplicit = false; string? openIssuesRepository = null; + string duplicateConfidence = IssueDuplicatePreflight.DefaultDuplicateConfidence; + double duplicateThreshold = IssueDuplicatePreflight.DefaultDuplicateThreshold; + bool duplicateConfidenceExplicit = false; + bool duplicateThresholdExplicit = false; string? issueTitle = null; var issueLabels = new List<string>(); SearchCursor? searchCursor = null; @@ -8439,6 +8660,42 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(repoError!); break; + case "--duplicate-confidence": + if (TryReadStringOptionValue(args, ref i, "--duplicate-confidence", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var duplicateConfidenceValue, out var duplicateConfidenceError)) + { + WarnIfDuplicateSingleValueOption("--duplicate-confidence", duplicateConfidenceValue!); + if (IssueDuplicatePreflight.TryNormalizeDuplicateConfidence(duplicateConfidenceValue!, out var normalizedDuplicateConfidence)) + { + duplicateConfidence = normalizedDuplicateConfidence; + duplicateThreshold = IssueDuplicatePreflight.ThresholdForDuplicateConfidence(normalizedDuplicateConfidence); + duplicateConfidenceExplicit = true; + } + else + { + AddParseError($"Error: --duplicate-confidence must be one of low, medium, high; got '{ConsoleUi.FormatBoundedValue(duplicateConfidenceValue)}'."); + } + } + else + { + AddParseError(duplicateConfidenceError!); + } + break; + case "--duplicate-threshold": + if (!TryReadRawOptionValue(args, ref i, "--duplicate-threshold", inlineValue, out var duplicateThresholdValue, out var missingDuplicateThresholdError)) + { + AddParseError(missingDuplicateThresholdError!); + } + else if (TryParseConfidence(duplicateThresholdValue!, out var parsedDuplicateThreshold)) + { + WarnIfDuplicateSingleValueOption("--duplicate-threshold", duplicateThresholdValue!); + duplicateThreshold = parsedDuplicateThreshold; + duplicateThresholdExplicit = true; + } + else + { + AddParseError($"Error: --duplicate-threshold must be a number between 0 and 1; got '{ConsoleUi.FormatBoundedValue(duplicateThresholdValue)}'."); + } + break; case "--issue-title": if (TryReadStringOptionValue(args, ref i, "--issue-title", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var issueTitleValue, out var issueTitleError)) { @@ -9172,6 +9429,8 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) .FirstOrDefault(group => group.Count() > 1); if (duplicateNamedQuery != null) AddParseError($"Error: duplicate --named-query name '{ConsoleUi.FormatBoundedValue(duplicateNamedQuery.Key)}'. Use unique names so grouped results are unambiguous."); + if (duplicateConfidenceExplicit && duplicateThresholdExplicit) + AddParseError("Error: --duplicate-confidence and --duplicate-threshold cannot be combined; use the preset or the explicit score threshold."); if (validateDefaultLimit && !limitExplicit && defaultLimitError != null) AddParseError(defaultLimitError); @@ -9293,6 +9552,9 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) AuditScope = auditScope, AuditScopeExplicit = auditScopeExplicit, OpenIssuesRepository = openIssuesRepository, + DuplicateConfidence = duplicateThresholdExplicit ? IssueDuplicatePreflight.CustomDuplicateConfidence : duplicateConfidence, + DuplicateThreshold = duplicateThreshold, + DuplicatePreflightTuningExplicit = duplicateConfidenceExplicit || duplicateThresholdExplicit, IssueTitle = issueTitle, IssueLabels = issueLabels, SearchCursor = searchCursor, @@ -12989,6 +13251,9 @@ public sealed class QueryCommandOptions public string AuditScope { get; init; } = SearchAuditRecipes.DefaultAuditScope; public bool AuditScopeExplicit { get; init; } public string? OpenIssuesRepository { get; init; } + public string DuplicateConfidence { get; init; } = IssueDuplicatePreflight.DefaultDuplicateConfidence; + public double DuplicateThreshold { get; init; } = IssueDuplicatePreflight.DefaultDuplicateThreshold; + public bool DuplicatePreflightTuningExplicit { get; init; } public string? IssueTitle { get; init; } public List<string> IssueLabels { get; init; } = []; public SearchCursor? SearchCursor { get; init; } diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index d896727f5c..34300c272f 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -1,3 +1,4 @@ +using CodeIndex; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -8,9 +9,10 @@ internal static class SearchAuditRecipes { internal const string DefaultAuditScope = "source"; internal const string AllAuditScope = "all"; + internal const string DefaultQuerySeverity = "medium"; internal const string RecipePathsEnvironmentVariable = "CDIDX_SEARCH_RECIPE_PATHS"; private const int MaxRecipeSourceFiles = 8; - private const long MaxRecipeSourceBytes = 128 * 1024; + internal const int MaxRecipeSourceBytes = 128 * 1024; private const int MaxExternalRecipesPerFile = 32; private const int MaxExternalQueriesPerRecipe = 32; private const int MaxExternalNameLength = 80; @@ -18,8 +20,11 @@ internal static class SearchAuditRecipes private const int MaxExternalFalsePositiveGuidanceLength = 512; private const int MaxExternalLabelCount = 16; private const int MaxExternalLabelLength = 64; + private const int MaxExternalPathPatternCount = 32; + private const int MaxExternalPathPatternLength = 256; private const int MaxRecipeDiagnosticCount = 64; private const int MaxRecipeDiagnosticLength = 512; + private static readonly string[] SupportedQuerySeverities = ["info", "low", "medium", "high", "critical"]; private static readonly string[] DefaultSourcePathPatterns = ["src/**"]; private static readonly string[] DefaultSourceExcludePaths = [ @@ -55,6 +60,18 @@ internal static class SearchAuditRecipes "Find full stream/string materialization that may need bounded reads or incremental processing.", ["audit", "performance"], "False positives include bounded in-memory test fixtures and tiny diagnostic payloads."), + new( + "file-read-all-text", + "File.ReadAllText", + "Find whole-file text reads that may need size caps, sharing policy, or streaming alternatives.", + ["audit", "performance"], + "False positives include bounded test fixtures and small files guarded by explicit size checks."), + new( + "file-read-all-bytes", + "File.ReadAllBytes", + "Find whole-file byte reads that may need size caps, sharing policy, or streaming alternatives.", + ["audit", "performance"], + "False positives include bounded test fixtures and small files guarded by explicit size checks."), new( "max-value-probe", "int.MaxValue", @@ -109,6 +126,12 @@ internal static class SearchAuditRecipes "Find infinite waits that may need bounded timeouts, cancellation, or liveness reporting.", ["audit", "bug"], "False positives include deliberate sentinel values that are never passed to blocking waits."), + new( + "thread-sleep", + "Thread.Sleep", + "Find blocking sleeps that may need cancellation-aware waits, bounded retry policy, or test-only isolation.", + ["audit", "bug"], + "False positives include tiny test synchronization probes and documented compatibility waits."), new( "path-case-heuristic", "OrdinalIgnoreCase", @@ -139,6 +162,12 @@ internal static class SearchAuditRecipes "Find authorization header or auth-boundary handling that may need redaction and egress review.", ["audit", "security"], "False positives include documentation, tests, and already-redacted header-name-only handling."), + new( + "http-client-construction", + "new HttpClient", + "Find direct HTTP client construction that may need lifetime, timeout, and outbound-boundary review.", + ["audit", "security"], + "False positives include tests, short-lived CLI probes with explicit timeouts, and shared factory wrappers."), new( "bearer-token-handling", "Bearer", @@ -318,14 +347,14 @@ internal static SearchAuditRecipeRegistry Load() foreach (var sourcePath in ReadConfiguredRecipeSourcePaths(diagnostics)) { - if (!TryLoadExternalRecipes(sourcePath, diagnostics, out var externalRecipes)) + if (!TryLoadExternalRecipes(sourcePath.Path, sourcePath.Label, diagnostics, out var externalRecipes)) continue; foreach (var recipe in externalRecipes) { if (!knownNames.Add(recipe.Name)) { - AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' defines duplicate recipe '{recipe.Name}'; keeping the first definition."); + AddDiagnostic(diagnostics, $"{sourcePath.Label} defines duplicate recipe '{recipe.Name}'; keeping the first definition."); continue; } @@ -343,13 +372,13 @@ internal static bool TryGet(string name, out SearchAuditRecipe recipe) return recipe != null; } - private static List<string> ReadConfiguredRecipeSourcePaths(List<string> diagnostics) + private static List<RecipeSourcePath> ReadConfiguredRecipeSourcePaths(List<string> diagnostics) { var raw = Environment.GetEnvironmentVariable(RecipePathsEnvironmentVariable); if (string.IsNullOrWhiteSpace(raw)) return []; - var paths = new List<string>(); + var paths = new List<RecipeSourcePath>(); foreach (var part in raw.Split(Path.PathSeparator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) { if (paths.Count >= MaxRecipeSourceFiles) @@ -358,13 +387,13 @@ private static List<string> ReadConfiguredRecipeSourcePaths(List<string> diagnos break; } - paths.Add(part); + paths.Add(new RecipeSourcePath(part, $"recipe source #{paths.Count + 1}")); } return paths; } - private static bool TryLoadExternalRecipes(string sourcePath, List<string> diagnostics, out List<SearchAuditRecipe> recipes) + private static bool TryLoadExternalRecipes(string sourcePath, string sourceLabel, List<string> diagnostics, out List<SearchAuditRecipe> recipes) { recipes = []; string fullPath; @@ -374,55 +403,57 @@ private static bool TryLoadExternalRecipes(string sourcePath, List<string> diagn } catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) { - AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' is not a valid path: {ex.Message}"); - return false; - } - - if (!File.Exists(fullPath)) - { - AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' does not exist."); + AddDiagnostic(diagnostics, $"{sourceLabel} is not a valid path ({SafeDiagnosticFormatter.FormatExceptionCategory("invalid_recipe_path", ex)})."); return false; } try { - var info = new FileInfo(fullPath); - if (info.Length > MaxRecipeSourceBytes) + var text = DataDirectorySecurity.ReadTextWithinLimit( + fullPath, + MaxRecipeSourceBytes, + FileShare.ReadWrite | FileShare.Delete); + if (text is null) { - AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' is too large ({info.Length} bytes; max {MaxRecipeSourceBytes})."); + AddDiagnostic(diagnostics, $"{sourceLabel} is too large (max {MaxRecipeSourceBytes} bytes)."); return false; } var root = JsonNode.Parse( - File.ReadAllText(fullPath), + text, documentOptions: new JsonDocumentOptions { MaxDepth = 16 }); var recipeArray = root as JsonArray ?? root?["recipes"] as JsonArray; if (recipeArray is null) { - AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' must be a JSON array or an object with a 'recipes' array."); + AddDiagnostic(diagnostics, $"{sourceLabel} must be a JSON array or an object with a 'recipes' array."); return false; } for (var i = 0; i < recipeArray.Count && i < MaxExternalRecipesPerFile; i++) { - if (TryParseRecipe(recipeArray[i], sourcePath, i, diagnostics, out var recipe)) + if (TryParseRecipe(recipeArray[i], sourceLabel, i, diagnostics, out var recipe)) recipes.Add(recipe); } if (recipeArray.Count > MaxExternalRecipesPerFile) - AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' has more than {MaxExternalRecipesPerFile} recipes; extra entries are ignored."); + AddDiagnostic(diagnostics, $"{sourceLabel} has more than {MaxExternalRecipesPerFile} recipes; extra entries are ignored."); return true; } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + AddDiagnostic(diagnostics, $"{sourceLabel} does not exist."); + return false; + } catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or InvalidOperationException) { - AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' could not be loaded: {ex.Message}"); + AddDiagnostic(diagnostics, $"{sourceLabel} could not be loaded ({SafeDiagnosticFormatter.FormatExceptionCategory("recipe_load", ex)})."); return false; } } private static bool TryParseRecipe( JsonNode? node, - string sourcePath, + string sourceLabel, int recipeIndex, List<string> diagnostics, out SearchAuditRecipe recipe) @@ -430,44 +461,52 @@ private static bool TryParseRecipe( recipe = null!; if (node is not JsonObject obj) { - AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' recipe #{recipeIndex + 1} must be an object."); + AddDiagnostic(diagnostics, $"{sourceLabel} recipe #{recipeIndex + 1} must be an object."); return false; } - if (!TryReadRequiredString(obj, "name", MaxExternalNameLength, sourcePath, recipeIndex, diagnostics, out var name) - || !TryReadRequiredString(obj, "description", MaxExternalDescriptionLength, sourcePath, recipeIndex, diagnostics, out var description)) + if (!TryReadRequiredString(obj, "name", MaxExternalNameLength, sourceLabel, recipeIndex, diagnostics, out var name) + || !TryReadRequiredString(obj, "description", MaxExternalDescriptionLength, sourceLabel, recipeIndex, diagnostics, out var description) + || !TryReadOptionalScope(obj, sourceLabel, recipeIndex, name, diagnostics, out var defaultScope) + || !TryReadPathPatterns(obj, "defaultPathPatterns", "default_path_patterns", sourceLabel, recipeIndex, name, diagnostics, out var defaultPathPatterns) + || !TryReadPathPatterns(obj, "defaultExcludePaths", "default_exclude_paths", sourceLabel, recipeIndex, name, diagnostics, out var defaultExcludePaths)) { return false; } if (obj["queries"] is not JsonArray queryArray) { - AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' recipe '{name}' must include a 'queries' array."); + AddDiagnostic(diagnostics, $"{sourceLabel} recipe '{name}' must include a 'queries' array."); return false; } var queries = new List<SearchAuditRecipeQuery>(); for (var i = 0; i < queryArray.Count && i < MaxExternalQueriesPerRecipe; i++) { - if (TryParseRecipeQuery(queryArray[i], sourcePath, name, i, diagnostics, out var query)) + if (TryParseRecipeQuery(queryArray[i], sourceLabel, name, i, diagnostics, out var query)) queries.Add(query); } if (queryArray.Count > MaxExternalQueriesPerRecipe) - AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' recipe '{name}' has more than {MaxExternalQueriesPerRecipe} queries; extra entries are ignored."); + AddDiagnostic(diagnostics, $"{sourceLabel} recipe '{name}' has more than {MaxExternalQueriesPerRecipe} queries; extra entries are ignored."); if (queries.Count == 0) { - AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' recipe '{name}' has no valid queries and was ignored."); + AddDiagnostic(diagnostics, $"{sourceLabel} recipe '{name}' has no valid queries and was ignored."); return false; } - recipe = new SearchAuditRecipe(name, description, queries); + recipe = new SearchAuditRecipe(name, description, queries) + { + DefaultScope = defaultScope, + DefaultPathPatterns = defaultPathPatterns, + DefaultExcludePaths = defaultExcludePaths + }; return true; } private static bool TryParseRecipeQuery( JsonNode? node, - string sourcePath, + string sourceLabel, string recipeName, int queryIndex, List<string> diagnostics, @@ -476,18 +515,21 @@ private static bool TryParseRecipeQuery( query = null!; if (node is not JsonObject obj) { - AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' recipe '{recipeName}' query #{queryIndex + 1} must be an object."); + AddDiagnostic(diagnostics, $"{sourceLabel} recipe '{recipeName}' query #{queryIndex + 1} must be an object."); return false; } - if (!TryReadRequiredString(obj, "name", MaxExternalNameLength, sourcePath, queryIndex, diagnostics, out var name) - || !TryReadRequiredString(obj, "query", QueryLimits.MaxQueryLength, sourcePath, queryIndex, diagnostics, out var queryText) - || !TryReadRequiredString(obj, "description", MaxExternalDescriptionLength, sourcePath, queryIndex, diagnostics, out var description)) + if (!TryReadRequiredString(obj, "name", MaxExternalNameLength, sourceLabel, queryIndex, diagnostics, out var name) + || !TryReadRequiredString(obj, "query", QueryLimits.MaxQueryLength, sourceLabel, queryIndex, diagnostics, out var queryText) + || !TryReadRequiredString(obj, "description", MaxExternalDescriptionLength, sourceLabel, queryIndex, diagnostics, out var description) + || !TryReadOptionalSeverity(obj, sourceLabel, recipeName, queryIndex, name, diagnostics, out var severity) + || !TryReadPathPatterns(obj, "pathPatterns", "path_patterns", sourceLabel, queryIndex, name, diagnostics, out var pathPatterns) + || !TryReadPathPatterns(obj, "excludePaths", "exclude_paths", sourceLabel, queryIndex, name, diagnostics, out var excludePaths)) { return false; } - var labels = ReadLabels(obj, sourcePath, recipeName, name, diagnostics); + var labels = ReadLabels(obj, sourceLabel, recipeName, name, diagnostics); var falsePositiveGuidance = TryReadString(obj["falsePositiveGuidance"] ?? obj["false_positive_guidance"], out var guidance) && !string.IsNullOrWhiteSpace(guidance) ? guidance.Trim() @@ -498,7 +540,12 @@ private static bool TryParseRecipeQuery( ? exactValue : true; - query = new SearchAuditRecipeQuery(name, queryText, description, labels, falsePositiveGuidance, exactSubstring); + query = new SearchAuditRecipeQuery(name, queryText, description, labels, falsePositiveGuidance, exactSubstring) + { + Severity = severity, + PathPatterns = pathPatterns, + ExcludePaths = excludePaths + }; return true; } @@ -506,7 +553,7 @@ private static bool TryReadRequiredString( JsonObject obj, string propertyName, int maxLength, - string sourcePath, + string sourceLabel, int itemIndex, List<string> diagnostics, out string value) @@ -514,7 +561,7 @@ private static bool TryReadRequiredString( value = string.Empty; if (!TryReadString(obj[propertyName], out var raw) || string.IsNullOrWhiteSpace(raw)) { - AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' item #{itemIndex + 1} must include a non-empty '{propertyName}' string."); + AddDiagnostic(diagnostics, $"{sourceLabel} item #{itemIndex + 1} must include a non-empty '{propertyName}' string."); return false; } @@ -522,14 +569,129 @@ private static bool TryReadRequiredString( if (value.Length <= maxLength) return true; - AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' item #{itemIndex + 1} field '{propertyName}' exceeds {maxLength} characters."); + AddDiagnostic(diagnostics, $"{sourceLabel} item #{itemIndex + 1} field '{propertyName}' exceeds {maxLength} characters."); value = string.Empty; return false; } + private static bool TryReadOptionalScope( + JsonObject obj, + string sourceLabel, + int recipeIndex, + string recipeName, + List<string> diagnostics, + out string scope) + { + scope = DefaultAuditScope; + var node = obj["defaultScope"] ?? obj["default_scope"]; + if (node is null) + return true; + + if (!TryReadString(node, out var raw) || string.IsNullOrWhiteSpace(raw)) + { + AddDiagnostic(diagnostics, $"{sourceLabel} recipe '{recipeName}' item #{recipeIndex + 1} has an invalid default scope."); + return false; + } + + var normalized = raw.Trim().ToLowerInvariant(); + if (StringComparer.Ordinal.Equals(normalized, DefaultAuditScope) || StringComparer.Ordinal.Equals(normalized, AllAuditScope)) + { + scope = normalized; + return true; + } + + AddDiagnostic(diagnostics, $"{sourceLabel} recipe '{recipeName}' item #{recipeIndex + 1} has unsupported default scope '{normalized}'."); + return false; + } + + private static bool TryReadPathPatterns( + JsonObject obj, + string camelCasePropertyName, + string snakeCasePropertyName, + string sourceLabel, + int recipeIndex, + string recipeName, + List<string> diagnostics, + out List<string> patterns) + { + patterns = []; + var node = obj[camelCasePropertyName] ?? obj[snakeCasePropertyName]; + if (node is null) + return true; + + if (node is not JsonArray array) + { + AddDiagnostic(diagnostics, $"{sourceLabel} recipe '{recipeName}' item #{recipeIndex + 1} field '{snakeCasePropertyName}' must be an array."); + return false; + } + + if (array.Count > MaxExternalPathPatternCount) + { + AddDiagnostic( + diagnostics, + $"{sourceLabel} recipe '{recipeName}' item #{recipeIndex + 1} field '{snakeCasePropertyName}' has more than {MaxExternalPathPatternCount} entries."); + return false; + } + + var seen = new HashSet<string>(StringComparer.Ordinal); + for (var i = 0; i < array.Count; i++) + { + if (!TryReadString(array[i], out var raw) || string.IsNullOrWhiteSpace(raw)) + { + AddDiagnostic(diagnostics, $"{sourceLabel} recipe '{recipeName}' item #{recipeIndex + 1} field '{snakeCasePropertyName}' has an invalid entry."); + return false; + } + + var pattern = raw.Trim(); + if (pattern.Length > MaxExternalPathPatternLength) + { + AddDiagnostic( + diagnostics, + $"{sourceLabel} recipe '{recipeName}' item #{recipeIndex + 1} field '{snakeCasePropertyName}' entry exceeds {MaxExternalPathPatternLength} characters."); + return false; + } + + if (seen.Add(pattern)) + patterns.Add(pattern); + } + + return true; + } + + private static bool TryReadOptionalSeverity( + JsonObject obj, + string sourceLabel, + string recipeName, + int queryIndex, + string queryName, + List<string> diagnostics, + out string severity) + { + severity = DefaultQuerySeverity; + var node = obj["severity"]; + if (node is null) + return true; + + if (!TryReadString(node, out var raw) || string.IsNullOrWhiteSpace(raw)) + { + AddDiagnostic(diagnostics, $"{sourceLabel} recipe '{recipeName}' query '{queryName}' item #{queryIndex + 1} has an invalid severity."); + return false; + } + + var normalized = raw.Trim().ToLowerInvariant(); + if (SupportedQuerySeverities.Contains(normalized, StringComparer.Ordinal)) + { + severity = normalized; + return true; + } + + AddDiagnostic(diagnostics, $"{sourceLabel} recipe '{recipeName}' query '{queryName}' item #{queryIndex + 1} has unsupported severity '{normalized}'."); + return false; + } + private static List<string> ReadLabels( JsonObject obj, - string sourcePath, + string sourceLabel, string recipeName, string queryName, List<string> diagnostics) @@ -539,7 +701,7 @@ private static List<string> ReadLabels( return []; if (labelsNode is not JsonArray labelArray) { - AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' recipe '{recipeName}' query '{queryName}' labels must be an array."); + AddDiagnostic(diagnostics, $"{sourceLabel} recipe '{recipeName}' query '{queryName}' labels must be an array."); return []; } @@ -557,7 +719,7 @@ private static List<string> ReadLabels( } if (labelArray.Count > MaxExternalLabelCount) - AddDiagnostic(diagnostics, $"recipe source '{sourcePath}' recipe '{recipeName}' query '{queryName}' has more than {MaxExternalLabelCount} labels; extra entries are ignored."); + AddDiagnostic(diagnostics, $"{sourceLabel} recipe '{recipeName}' query '{queryName}' has more than {MaxExternalLabelCount} labels; extra entries are ignored."); return labels; } @@ -606,6 +768,8 @@ private static bool TryReadBool(JsonNode? node, out bool value) return false; } } + + private sealed record RecipeSourcePath(string Path, string Label); } internal sealed record SearchAuditRecipeRegistry( @@ -635,7 +799,12 @@ internal sealed record SearchAuditRecipeQuery( string Description, List<string> RecommendedLabels, string FalsePositiveGuidance, - bool ExactSubstring = true); + bool ExactSubstring = true) +{ + public string Severity { get; init; } = SearchAuditRecipes.DefaultQuerySeverity; + public List<string> PathPatterns { get; init; } = []; + public List<string> ExcludePaths { get; init; } = []; +} internal sealed record SearchRecipeListJsonResult( [property: JsonPropertyName("api_version")] string ApiVersion, @@ -677,6 +846,9 @@ internal sealed record SearchRecipeQueryListItemJsonResult( [property: JsonPropertyName("description")] string Description, [property: JsonPropertyName("recommended_labels")] List<string> RecommendedLabels, [property: JsonPropertyName("false_positive_guidance")] string FalsePositiveGuidance, + [property: JsonPropertyName("severity")] string Severity, + [property: JsonPropertyName("path_patterns")] List<string> PathPatterns, + [property: JsonPropertyName("exclude_paths")] List<string> ExcludePaths, [property: JsonPropertyName("exact_substring")] bool ExactSubstring); internal sealed record SearchRecipeRunJsonResult( @@ -685,8 +857,17 @@ internal sealed record SearchRecipeRunJsonResult( [property: JsonPropertyName("scope")] SearchRecipeScopeJsonResult Scope, [property: JsonPropertyName("query_count")] int QueryCount, [property: JsonPropertyName("result_count")] int ResultCount, + [property: JsonPropertyName("summary")] SearchRecipeRunSummaryJsonResult Summary, [property: JsonPropertyName("queries")] List<SearchRecipeQueryResultJsonResult> Queries); +internal sealed record SearchRecipeRunSummaryJsonResult( + [property: JsonPropertyName("limit_per_query")] int LimitPerQuery, + [property: JsonPropertyName("emitted_result_count")] int EmittedResultCount, + [property: JsonPropertyName("truncated_query_count")] int TruncatedQueryCount, + [property: JsonPropertyName("minimum_omitted_result_count")] int MinimumOmittedResultCount, + [property: JsonPropertyName("cursoring_available")] bool CursoringAvailable, + [property: JsonPropertyName("cursoring_hint")] string CursoringHint); + internal sealed record SearchNamedBatchRunJsonResult( [property: JsonPropertyName("api_version")] string ApiVersion, [property: JsonPropertyName("query_count")] int QueryCount, @@ -710,7 +891,12 @@ internal sealed record SearchRecipeQueryResultJsonResult( [property: JsonPropertyName("recommended_labels")] List<string> RecommendedLabels, [property: JsonPropertyName("false_positive_guidance")] string FalsePositiveGuidance, [property: JsonPropertyName("exact_substring")] bool ExactSubstring, + [property: JsonPropertyName("severity")] string Severity, + [property: JsonPropertyName("path_patterns")] List<string> PathPatterns, + [property: JsonPropertyName("exclude_paths")] List<string> ExcludePaths, [property: JsonPropertyName("count")] int Count, + [property: JsonPropertyName("result_limit")] int ResultLimit, + [property: JsonPropertyName("minimum_omitted_result_count")] int MinimumOmittedResultCount, [property: JsonPropertyName("top_files")] List<SearchRecipeTopFileJsonResult> TopFiles, [property: JsonPropertyName("truncated")] bool Truncated, [property: JsonPropertyName("next_cursor")] string? NextCursor, @@ -722,13 +908,19 @@ internal sealed record SearchRecipeCompactRunJsonResult( [property: JsonPropertyName("scope")] SearchRecipeScopeJsonResult Scope, [property: JsonPropertyName("query_count")] int QueryCount, [property: JsonPropertyName("result_count")] int ResultCount, + [property: JsonPropertyName("summary")] SearchRecipeRunSummaryJsonResult Summary, [property: JsonPropertyName("queries")] List<SearchRecipeCompactQueryResultJsonResult> Queries); internal sealed record SearchRecipeCompactQueryResultJsonResult( [property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("query")] string Query, [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("severity")] string Severity, + [property: JsonPropertyName("path_patterns")] List<string> PathPatterns, + [property: JsonPropertyName("exclude_paths")] List<string> ExcludePaths, [property: JsonPropertyName("count")] int Count, + [property: JsonPropertyName("result_limit")] int ResultLimit, + [property: JsonPropertyName("minimum_omitted_result_count")] int MinimumOmittedResultCount, [property: JsonPropertyName("top_files")] List<SearchRecipeTopFileJsonResult> TopFiles, [property: JsonPropertyName("truncated")] bool Truncated, [property: JsonPropertyName("next_cursor")] string? NextCursor, @@ -763,6 +955,7 @@ internal sealed record SearchIssueDraftJsonResult( [property: JsonPropertyName("title")] string Title, [property: JsonPropertyName("labels")] List<string> Labels, [property: JsonPropertyName("evidence_paths")] List<string> EvidencePaths, + [property: JsonPropertyName("triage")] IssueDraftTriageMetadataJsonResult Triage, [property: JsonPropertyName("body")] string Body, [property: JsonPropertyName("source")] SearchIssueDraftSourceJsonResult Source, [property: JsonPropertyName("duplicate_preflight")] SuggestionIssueDraftDuplicatePreflightJsonResult DuplicatePreflight); diff --git a/src/CodeIndex/Cli/SuggestionsCommandRunner.cs b/src/CodeIndex/Cli/SuggestionsCommandRunner.cs index e20fff5a0d..ee41915c34 100644 --- a/src/CodeIndex/Cli/SuggestionsCommandRunner.cs +++ b/src/CodeIndex/Cli/SuggestionsCommandRunner.cs @@ -9,7 +9,7 @@ namespace CodeIndex.Cli; internal static class SuggestionsCommandRunner { - private const string Usage = "Usage: cdidx suggestions <list|show|export> [id] [--db <path>] [--json] [--status <all|draft|submitted_pending_triage|open_in_upstream|resolved_in_upstream|wont_fix|duplicate|superseded|submitted|unsubmitted>] [--language <lang>] [--category <category>] [--since <datetime>] [--agent <name>] [--limit <n>] [--offset <n>] [--format <json|markdown|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>]"; + private const string Usage = "Usage: cdidx suggestions <list|show|export> [id] [--db <path>] [--json] [--status <all|draft|submitted_pending_triage|open_in_upstream|resolved_in_upstream|wont_fix|duplicate|superseded|submitted|unsubmitted>] [--language <lang>] [--category <category>] [--since <datetime>] [--agent <name>] [--limit <n>] [--offset <n>] [--format <json|markdown|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--duplicate-confidence <low|medium|high>|--duplicate-threshold <score>]"; internal const int MaxOpenIssuesJsonBytes = IssueDuplicatePreflight.MaxOpenIssuesJsonBytes; internal const int MaxOpenIssuesJsonDepth = IssueDuplicatePreflight.MaxOpenIssuesJsonDepth; internal const int MaxSuggestionExportTextFieldLength = 4096; @@ -32,6 +32,9 @@ public static int Run(string[] args, JsonSerializerOptions jsonOptions) CommandErrorWriter.WriteStderr(Usage); return CommandExitCodes.UsageError; } + if ((options.DuplicateConfidenceSpecified || options.DuplicateThresholdSpecified) + && (verb != "export" || options.ExportFormat != "issue-drafts")) + return WriteUsageError("--duplicate-confidence and --duplicate-threshold can only be used with `suggestions export --format issue-drafts`."); if (options.OpenIssuesPath != null && (verb != "export" || options.ExportFormat != "issue-drafts")) return WriteUsageError("--open-issues can only be used with `suggestions export --format issue-drafts`."); if (options.OpenIssuesRepository != null && (verb != "export" || options.ExportFormat != "issue-drafts")) @@ -170,14 +173,16 @@ private static int RunIssueDraftExport(List<SuggestionRecord> records, Options o if (!IssueDuplicatePreflight.TryLoad(options.OpenIssuesPath, options.OpenIssuesRepository, out var preflight, out var error)) return WriteUsageError(error!); - var drafts = records.Select(record => ToIssueDraft(record, preflight)).ToList(); + var drafts = records.Select(record => ToIssueDraft(record, preflight, options)).ToList(); var payload = new SuggestionIssueDraftExportJsonResult( JsonOutputContract.ApiVersion, drafts.Count, new SuggestionIssueDraftPreflightSummaryJsonResult( preflight.Checked, preflight.Source, - preflight.OpenIssueCount), + preflight.OpenIssueCount, + options.DuplicateConfidence, + options.DuplicateThreshold), drafts); Console.WriteLine(JsonSerializer.Serialize( payload, @@ -347,19 +352,21 @@ private static string FormatTitle(string description, int maxLength) record.SubmitAttemptCount, record.LastSubmitError); - private static SuggestionIssueDraftJsonResult ToIssueDraft(SuggestionRecord record, IssueDuplicatePreflight preflight) + private static SuggestionIssueDraftJsonResult ToIssueDraft(SuggestionRecord record, IssueDuplicatePreflight preflight, Options options) { var title = BuildIssueDraftTitle(record); var labels = GitHubIssueReporter.BuildIssueLabels(record).ToList(); var evidencePaths = NormalizeEvidencePaths(record); - var duplicateMatches = preflight.FindMatches(title, labels); + var duplicateMatches = preflight.FindMatches(title, labels, options.DuplicateThreshold); + var triage = BuildSuggestionIssueDraftTriage(record, evidencePaths, preflight.Checked, duplicateMatches.Count); return new SuggestionIssueDraftJsonResult( record.Hash, ShortId(record.Hash), title, labels, evidencePaths, - BuildIssueDraftBody(record, evidencePaths), + triage, + BuildIssueDraftBody(record, evidencePaths, triage), new SuggestionIssueDraftSourceJsonResult( record.Category, record.Language, @@ -372,6 +379,39 @@ private static SuggestionIssueDraftJsonResult ToIssueDraft(SuggestionRecord reco duplicateMatches)); } + private static IssueDraftTriageMetadataJsonResult BuildSuggestionIssueDraftTriage( + SuggestionRecord record, + IReadOnlyList<string> evidencePaths, + bool duplicatePreflightChecked, + int duplicateMatchCount) + { + var severity = record.Category switch + { + "crash_report" or "unexpected_error" => "high", + "other" => "low", + _ => "medium", + }; + var confidence = evidencePaths.Count > 0 + ? "medium" + : !string.IsNullOrWhiteSpace(record.SampledTitle) || !string.IsNullOrWhiteSpace(record.Context) + ? "medium" + : "low"; + return new IssueDraftTriageMetadataJsonResult( + severity, + confidence, + evidencePaths.Count, + BuildSuggestionIssueDraftDuplicateGuidance(duplicatePreflightChecked, duplicateMatchCount)); + } + + private static string BuildSuggestionIssueDraftDuplicateGuidance(bool duplicatePreflightChecked, int duplicateMatchCount) + { + if (!duplicatePreflightChecked) + return "Duplicate preflight was not checked; search open issues before filing."; + if (duplicateMatchCount > 0) + return "Review duplicate_preflight.matches before filing; merge evidence into an existing issue when the same root cause is already tracked."; + return "No duplicate candidates were found by preflight; still verify open issues before filing."; + } + private static string BuildIssueDraftTitle(SuggestionRecord record) { var titleSource = !string.IsNullOrWhiteSpace(record.SampledTitle) @@ -390,7 +430,10 @@ private static List<string> RedactSuggestionOutputArray(string[]? values) .Cast<string>() .ToList(); - private static string BuildIssueDraftBody(SuggestionRecord record, IReadOnlyList<string> evidencePaths) + private static string BuildIssueDraftBody( + SuggestionRecord record, + IReadOnlyList<string> evidencePaths, + IssueDraftTriageMetadataJsonResult triage) { var sb = new StringBuilder(); sb.AppendLine("## Summary"); @@ -413,6 +456,8 @@ private static string BuildIssueDraftBody(SuggestionRecord record, IReadOnlyList sb.AppendLine($"- {path}"); } sb.AppendLine(); + AppendSuggestionIssueDraftTriageMetadata(sb, triage); + sb.AppendLine(); sb.AppendLine("## Context"); sb.AppendLine(record.Context != null ? BoundSuggestionOutputValue(GitHubIssueReporter.ScrubInlineCode(record.Context), capTextFields: true) @@ -436,6 +481,15 @@ private static string BuildIssueDraftBody(SuggestionRecord record, IReadOnlyList return BoundSuggestionOutputValue(sb.ToString().TrimEnd(), MaxSuggestionIssueDraftBodyLength); } + private static void AppendSuggestionIssueDraftTriageMetadata(StringBuilder sb, IssueDraftTriageMetadataJsonResult triage) + { + sb.AppendLine("## Triage metadata"); + sb.AppendLine($"- severity: `{triage.Severity}`"); + sb.AppendLine($"- confidence: `{triage.Confidence}`"); + sb.AppendLine($"- evidence_count: `{triage.EvidenceCount}`"); + sb.AppendLine($"- duplicate_guidance: {triage.DuplicateGuidance}"); + } + private static List<string> NormalizeEvidencePaths(SuggestionRecord record) => SuggestionEvidencePaths.Normalize(record.EvidencePaths); @@ -656,6 +710,40 @@ private static Options Parse(string[] args) } options.OpenIssuesRepository = repository; break; + case "--duplicate-confidence": + if (!TryReadValue(args, ref i, "--duplicate-confidence", out var duplicateConfidence, out var duplicateConfidenceError)) + { + options.Error = duplicateConfidenceError; + return options; + } + if (IssueDuplicatePreflight.TryNormalizeDuplicateConfidence(duplicateConfidence, out var normalizedDuplicateConfidence)) + { + options.DuplicateConfidence = normalizedDuplicateConfidence; + options.DuplicateThreshold = IssueDuplicatePreflight.ThresholdForDuplicateConfidence(normalizedDuplicateConfidence); + options.DuplicateConfidenceSpecified = true; + } + else + { + options.Error = $"Error: --duplicate-confidence must be one of low, medium, high; got '{duplicateConfidence}'."; + } + break; + case "--duplicate-threshold": + if (!TryReadValue(args, ref i, "--duplicate-threshold", out var duplicateThreshold, out var duplicateThresholdError)) + { + options.Error = duplicateThresholdError; + return options; + } + if (TryParseScoreThreshold("--duplicate-threshold", duplicateThreshold, out var parsedDuplicateThreshold, out var parsedDuplicateThresholdError)) + { + options.DuplicateThreshold = parsedDuplicateThreshold; + options.DuplicateConfidence = IssueDuplicatePreflight.CustomDuplicateConfidence; + options.DuplicateThresholdSpecified = true; + } + else + { + options.Error = parsedDuplicateThresholdError; + } + break; default: if (arg.StartsWith("--db=", StringComparison.Ordinal)) options.DbPath = arg["--db=".Length..]; @@ -696,6 +784,34 @@ private static Options Parse(string[] args) options.OpenIssuesPath = arg["--open-issues=".Length..]; else if (arg.StartsWith("--repo=", StringComparison.Ordinal)) options.OpenIssuesRepository = arg["--repo=".Length..]; + else if (arg.StartsWith("--duplicate-confidence=", StringComparison.Ordinal)) + { + var inlineConfidence = arg["--duplicate-confidence=".Length..]; + if (IssueDuplicatePreflight.TryNormalizeDuplicateConfidence(inlineConfidence, out var normalizedInlineConfidence)) + { + options.DuplicateConfidence = normalizedInlineConfidence; + options.DuplicateThreshold = IssueDuplicatePreflight.ThresholdForDuplicateConfidence(normalizedInlineConfidence); + options.DuplicateConfidenceSpecified = true; + } + else + { + options.Error = $"Error: --duplicate-confidence must be one of low, medium, high; got '{inlineConfidence}'."; + } + } + else if (arg.StartsWith("--duplicate-threshold=", StringComparison.Ordinal)) + { + var inlineThreshold = arg["--duplicate-threshold=".Length..]; + if (TryParseScoreThreshold("--duplicate-threshold", inlineThreshold, out var parsedInlineThreshold, out var parsedInlineThresholdError)) + { + options.DuplicateThreshold = parsedInlineThreshold; + options.DuplicateConfidence = IssueDuplicatePreflight.CustomDuplicateConfidence; + options.DuplicateThresholdSpecified = true; + } + else + { + options.Error = parsedInlineThresholdError; + } + } else if (arg.StartsWith("--since=", StringComparison.Ordinal)) { var inlineSince = arg["--since=".Length..]; @@ -723,6 +839,10 @@ private static Options Parse(string[] args) options.Error = "Error: --status must be one of all, draft, submitted_pending_triage, open_in_upstream, resolved_in_upstream, wont_fix, duplicate, superseded, submitted, unsubmitted."; if (!IsValidExportFormat(options.ExportFormat)) options.Error = "Error: --format must be one of json, markdown, issue-drafts."; + if (options.DuplicateConfidenceSpecified && options.DuplicateThresholdSpecified) + options.Error = "Error: --duplicate-confidence and --duplicate-threshold cannot be combined; use the preset or the explicit score threshold."; + if ((options.DuplicateConfidenceSpecified || options.DuplicateThresholdSpecified) && options.ExportFormat != "issue-drafts") + options.Error = "Error: --duplicate-confidence and --duplicate-threshold can only be used with --format issue-drafts."; return options; } @@ -741,6 +861,23 @@ private static bool TryParseNonNegativeInt(string option, string rawValue, out i return false; } + private static bool TryParseScoreThreshold(string option, string rawValue, out double value, out string? error) + { + if (double.TryParse(rawValue, NumberStyles.Float, CultureInfo.InvariantCulture, out value) && + !double.IsNaN(value) && + !double.IsInfinity(value) && + value >= 0 && + value <= 1) + { + error = null; + return true; + } + + value = IssueDuplicatePreflight.DefaultDuplicateThreshold; + error = $"Error: {option} must be a number between 0 and 1."; + return false; + } + private static bool TryReadValue(string[] args, ref int i, string option, out string value, out string? error) { value = string.Empty; @@ -770,6 +907,10 @@ private sealed class Options public bool OffsetSpecified { get; set; } public string? OpenIssuesPath { get; set; } public string? OpenIssuesRepository { get; set; } + public string DuplicateConfidence { get; set; } = IssueDuplicatePreflight.DefaultDuplicateConfidence; + public double DuplicateThreshold { get; set; } = IssueDuplicatePreflight.DefaultDuplicateThreshold; + public bool DuplicateConfidenceSpecified { get; set; } + public bool DuplicateThresholdSpecified { get; set; } public DateTimeOffset? Since { get; set; } public string? Error { get; set; } public bool HasPagination => Limit.HasValue || OffsetSpecified; @@ -841,7 +982,15 @@ internal sealed record SuggestionIssueDraftExportJsonResult( internal sealed record SuggestionIssueDraftPreflightSummaryJsonResult( [property: JsonPropertyName("checked")] bool Checked, [property: JsonPropertyName("source")] string? Source, - [property: JsonPropertyName("open_issue_count")] int OpenIssueCount); + [property: JsonPropertyName("open_issue_count")] int OpenIssueCount, + [property: JsonPropertyName("confidence")] string Confidence, + [property: JsonPropertyName("minimum_score")] double MinimumScore); + +internal sealed record IssueDraftTriageMetadataJsonResult( + [property: JsonPropertyName("severity")] string Severity, + [property: JsonPropertyName("confidence")] string Confidence, + [property: JsonPropertyName("evidence_count")] int EvidenceCount, + [property: JsonPropertyName("duplicate_guidance")] string DuplicateGuidance); internal sealed record SuggestionIssueDraftJsonResult( [property: JsonPropertyName("suggestion_id")] string SuggestionId, @@ -849,6 +998,7 @@ internal sealed record SuggestionIssueDraftJsonResult( [property: JsonPropertyName("title")] string Title, [property: JsonPropertyName("labels")] List<string> Labels, [property: JsonPropertyName("evidence_paths")] List<string> EvidencePaths, + [property: JsonPropertyName("triage")] IssueDraftTriageMetadataJsonResult Triage, [property: JsonPropertyName("body")] string Body, [property: JsonPropertyName("source")] SuggestionIssueDraftSourceJsonResult Source, [property: JsonPropertyName("duplicate_preflight")] SuggestionIssueDraftDuplicatePreflightJsonResult DuplicatePreflight); diff --git a/src/CodeIndex/Database/DbReader.cs b/src/CodeIndex/Database/DbReader.cs index c407da4b5d..0d099d4bf1 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -1681,6 +1681,26 @@ internal static void AddPathFilterParameters(SqliteCommand cmd, IReadOnlyList<st } } + private static void AppendAdditionalPathIncludeFilters(ref string sql, IReadOnlyList<string>? pathPatterns, string parameterPrefix) + { + if (pathPatterns == null || pathPatterns.Count == 0) + return; + + var ors = new List<string>(pathPatterns.Count); + for (int i = 0; i < pathPatterns.Count; i++) + ors.Add($"f.path LIKE @{parameterPrefix}{i} ESCAPE '\\'"); + sql += " AND (" + string.Join(" OR ", ors) + ")"; + } + + private static void AddPathIncludeFilterParameters(SqliteCommand cmd, IReadOnlyList<string>? pathPatterns, string parameterPrefix) + { + if (pathPatterns == null) + return; + + for (int i = 0; i < pathPatterns.Count; i++) + cmd.Parameters.AddWithValue($"@{parameterPrefix}{i}", BuildPathLikePattern(pathPatterns[i])); + } + internal static string BuildPathFiltersSql(string fileAlias, IReadOnlyList<string>? pathPatterns, IReadOnlyList<string>? excludePathPatterns, bool excludeTests) { var sql = string.Empty; diff --git a/src/CodeIndex/Database/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index 8239e3c2d5..9e2c6ecc3b 100644 --- a/src/CodeIndex/Database/DbSearchReader.cs +++ b/src/CodeIndex/Database/DbSearchReader.cs @@ -109,7 +109,7 @@ private static string FormatFtsToken(string token, bool prefix) /// Full-text search across indexed chunks using FTS5. /// FTS5を使ったチャンク全文検索。 /// </summary> - public List<SearchResult> Search(string query, int limit = 20, string? lang = null, bool rawQuery = false, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, bool deduplicate = true, DateTime? since = null, bool exact = false, bool prefix = false, bool visibilityRank = true, SearchCursor? cursor = null, IReadOnlyList<SearchGuardFilter>? guardFilters = null, int guardWindow = DefaultSearchGuardWindow, int? guardRequestedLimit = null) + public List<SearchResult> Search(string query, int limit = 20, string? lang = null, bool rawQuery = false, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, bool deduplicate = true, DateTime? since = null, bool exact = false, bool prefix = false, bool visibilityRank = true, SearchCursor? cursor = null, IReadOnlyList<SearchGuardFilter>? guardFilters = null, int guardWindow = DefaultSearchGuardWindow, int? guardRequestedLimit = null, IReadOnlyList<string>? requiredPathPatterns = null) { // Guard against empty/whitespace queries that would match everything // 空白のみのクエリが全件マッチするのを防止 @@ -166,6 +166,7 @@ FROM fts_chunks if (since != null && _fileColumns.Contains("modified")) sql += " AND f.modified >= @since"; AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); + AppendAdditionalPathIncludeFilters(ref sql, requiredPathPatterns, "requiredPathPattern"); sql += $" ORDER BY {GetSearchOrderSql(coverageTokens.Count, exactLiteralBoost)}"; if (hasGuardFilters) sql += " LIMIT @candidateFetchLimit"; @@ -194,6 +195,7 @@ FROM fts_chunks cmd.Parameters.AddWithValue("@cursorOffset", searchCursorParameter.Offset); } AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); + AddPathIncludeFilterParameters(cmd, requiredPathPatterns, "requiredPathPattern"); var raw = new List<SearchResult>(); var nextOffset = hasGuardFilters ? 0 : cursor?.Offset ?? 0; diff --git a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs index 9bc4a977be..2330a94d45 100644 --- a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs +++ b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs @@ -25,6 +25,29 @@ public void LoadAndApply_NoFile_NoOp() finally { TestProjectHelper.DeleteDirectory(dir); } } + [Fact] + public void LoadAndApply_StopsAtRepositoryBoundary_Issue3826() + { + var dir = CreateTempDir(); + try + { + var repoRoot = Path.Combine(dir, "repo"); + var nested = Path.Combine(repoRoot, "src", "app"); + Directory.CreateDirectory(Path.Combine(repoRoot, ".git")); + Directory.CreateDirectory(nested); + File.WriteAllText(Path.Combine(dir, ".cdidxrc.json"), """{ "debug": "1" }"""); + + var env = new TestEnvironment(); + var result = CdidxConfigFile.Load(nested, env.Read); + + Assert.False(result.Loaded); + Assert.False(result.Failed); + Assert.Null(result.Path); + Assert.Empty(result.Settings); + } + finally { TestProjectHelper.DeleteDirectory(dir); } + } + [Fact] public void LoadAndApply_MaterializesKnownKeysIntoEnvironment() { diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index e6fa58acbf..3bb88e964d 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -115,7 +115,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx references <query>|--query <query>|-- <query>", output); Assert.Contains("cdidx callers <query>|--query <query>|-- <query>", output); Assert.Contains("cdidx callees <query>|--query <query>|-- <query>", output); - Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes|--named-query <name>=<query> [--named-query <name>=<query> ...] [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--audit-scope <source|all>] [--show-excluded] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>|--max-results <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] [--prefix] [--count] [--group-by <file|symbol>] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); + Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes|--named-query <name>=<query> [--named-query <name>=<query> ...] [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--audit-scope <source|all>] [--show-excluded] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--duplicate-confidence <low|medium|high>|--duplicate-threshold <score>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>|--max-results <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] [--prefix] [--count] [--group-by <file|symbol>] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); Assert.Contains("cdidx definition <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--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] [--since <datetime>]", output); Assert.Contains("cdidx references <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--snippet-lines <n>] [--max-line-width <n>] [--exact|--exact-name] [--count]", output); Assert.Contains("cdidx inspect <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|compact>] [--pretty] [--compact] [--fields <csv>] [--body-only] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--body-start <line>] [--body-lines <n>] [--max-line-width <n>] [--exact|--exact-name]", output); @@ -269,7 +269,7 @@ public void PrintUsage_QueryLinesMatchImplementedOptions() { var output = CaptureFullUsageOutput(showBanner: false); - Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes|--named-query <name>=<query> [--named-query <name>=<query> ...] [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--audit-scope <source|all>] [--show-excluded] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>|--max-results <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] [--prefix] [--count] [--group-by <file|symbol>] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); + Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes|--named-query <name>=<query> [--named-query <name>=<query> ...] [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--audit-scope <source|all>] [--show-excluded] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--duplicate-confidence <low|medium|high>|--duplicate-threshold <score>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>|--max-results <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] [--prefix] [--count] [--group-by <file|symbol>] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); Assert.Contains("cdidx symbols [query|--query <query>|-- <query>] [--name <name>] [--db <path>] [--json] [--format <text|json|count>] [--verbose] [--limit <n>|--top <n>] [--sort <hotspot|references|size|complexity|path>] [--lang <lang>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exact|--exact-name] [--count] [--since <datetime>]", output); Assert.Contains("cdidx files [query|--query <query>|-- <query>] [--db <path>] [--json[=ndjson|array]] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--count] [--since <datetime>] [--bytes]", output); Assert.Contains("cdidx hotspots [--db <path>] [--json] [--verbose] [--limit <n>|--top <n>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--count]", output); diff --git a/tests/CodeIndex.Tests/ProgramCliTests.cs b/tests/CodeIndex.Tests/ProgramCliTests.cs index 6e4c7a27b5..662f5b3396 100644 --- a/tests/CodeIndex.Tests/ProgramCliTests.cs +++ b/tests/CodeIndex.Tests/ProgramCliTests.cs @@ -1072,17 +1072,96 @@ public void Suggestions_ExportIssueDraftsIncludesEvidenceAndDuplicatePreflight() var root = doc.RootElement; Assert.True(root.GetProperty("duplicate_preflight").GetProperty("checked").GetBoolean()); Assert.Equal(1, root.GetProperty("duplicate_preflight").GetProperty("open_issue_count").GetInt32()); + Assert.Equal("medium", root.GetProperty("duplicate_preflight").GetProperty("confidence").GetString()); + Assert.Equal(0.45, root.GetProperty("duplicate_preflight").GetProperty("minimum_score").GetDouble()); var draft = root.GetProperty("drafts")[0]; Assert.Equal(record.Hash, draft.GetProperty("suggestion_id").GetString()); Assert.Equal("enhancement", draft.GetProperty("labels")[0].GetString()); Assert.Equal("src/CodeIndex/Cli/SuggestionsCommandRunner.cs", draft.GetProperty("evidence_paths")[0].GetString()); - Assert.Contains("## Evidence paths", draft.GetProperty("body").GetString()); + var triage = draft.GetProperty("triage"); + var body = draft.GetProperty("body").GetString(); + Assert.Equal("medium", triage.GetProperty("severity").GetString()); + Assert.Equal("medium", triage.GetProperty("confidence").GetString()); + Assert.Equal(2, triage.GetProperty("evidence_count").GetInt32()); + Assert.Contains("merge evidence", triage.GetProperty("duplicate_guidance").GetString(), StringComparison.Ordinal); + Assert.Contains("## Evidence paths", body); + Assert.Contains("## Triage metadata", body); + Assert.Contains("evidence_count: `2`", body); var preflight = draft.GetProperty("duplicate_preflight"); Assert.Equal(1, preflight.GetProperty("match_count").GetInt32()); Assert.Equal(2878, preflight.GetProperty("matches")[0].GetProperty("number").GetInt32()); Assert.Equal("title_exact", preflight.GetProperty("matches")[0].GetProperty("reason").GetString()); } + [Fact] + public void Suggestions_ExportIssueDraftsDuplicateThresholdFiltersMatches_Issue3827() + { + using var fixture = SuggestionFixture.Create(); + fixture.Add( + "output_format", + "csharp", + "Issue draft duplicate threshold should filter weaker title candidates", + submitted: false, + sampledTitle: "Tune duplicate thresholds", + evidencePaths: ["src/CodeIndex/Cli/SuggestionsCommandRunner.cs"]); + var openIssuesPath = fixture.WriteOpenIssuesJson(""" + [ + { + "number": 3827, + "title": "[AI Suggestion] output_format: Tune duplicate thresholds follow-up review backlog noisy candidate validation scheduler evidence report", + "url": "https://github.com/Widthdom/CodeIndex/issues/3827", + "labels": [{ "name": "enhancement" }] + } + ] + """); + + var (highExitCode, highStdout, highStderr) = RunCliInSubprocess([ + "suggestions", "export", "--db", fixture.DbPath, "--format", "issue-drafts", + "--open-issues", openIssuesPath, "--duplicate-confidence", "high" + ]); + var (customExitCode, customStdout, customStderr) = RunCliInSubprocess([ + "suggestions", "export", "--db", fixture.DbPath, "--format", "issue-drafts", + "--open-issues", openIssuesPath, "--duplicate-threshold", "0.4" + ]); + + Assert.Equal(0, highExitCode); + Assert.Equal(string.Empty, highStderr); + using var highDoc = JsonDocument.Parse(highStdout); + var highRoot = highDoc.RootElement; + var highDraft = highRoot.GetProperty("drafts")[0]; + Assert.Equal("high", highRoot.GetProperty("duplicate_preflight").GetProperty("confidence").GetString()); + Assert.Equal(0.7, highRoot.GetProperty("duplicate_preflight").GetProperty("minimum_score").GetDouble()); + Assert.Equal(0, highDraft.GetProperty("duplicate_preflight").GetProperty("match_count").GetInt32()); + + Assert.Equal(0, customExitCode); + Assert.Equal(string.Empty, customStderr); + using var customDoc = JsonDocument.Parse(customStdout); + var customRoot = customDoc.RootElement; + var customDraft = customRoot.GetProperty("drafts")[0]; + var customMatch = customDraft.GetProperty("duplicate_preflight").GetProperty("matches")[0]; + Assert.Equal("custom", customRoot.GetProperty("duplicate_preflight").GetProperty("confidence").GetString()); + Assert.Equal(0.4, customRoot.GetProperty("duplicate_preflight").GetProperty("minimum_score").GetDouble()); + Assert.Equal(1, customDraft.GetProperty("duplicate_preflight").GetProperty("match_count").GetInt32()); + Assert.Equal(3827, customMatch.GetProperty("number").GetInt32()); + Assert.Equal("title_label_contains", customMatch.GetProperty("reason").GetString()); + Assert.Equal(0.45, customMatch.GetProperty("score").GetDouble()); + } + + [Fact] + public void Suggestions_DuplicateTuningRequiresIssueDraftExport_Issue3827() + { + using var fixture = SuggestionFixture.Create(); + fixture.Add("output_format", "csharp", "Duplicate tuning should not be ignored by list.", submitted: false); + + var (exitCode, stdout, stderr) = RunCliInSubprocess([ + "suggestions", "list", "--db", fixture.DbPath, "--format", "issue-drafts", "--duplicate-threshold", "0.4" + ]); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("suggestions export --format issue-drafts", stderr); + } + [Fact] public void Suggestions_ExportIssueDraftsGitHubOpenIssuesRequiresRepository_Issue3449() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index fe4249641a..9f3d4d425f 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -845,8 +845,17 @@ public void RunSearch_FormatSarifEmitsResultsArray() using var document = ParseJsonOutput(stdout); var root = document.RootElement; Assert.Equal("2.1.0", root.GetProperty("version").GetString()); - var result = Assert.Single(root.GetProperty("runs")[0].GetProperty("results").EnumerateArray()); + var run = root.GetProperty("runs")[0]; + var rule = Assert.Single(run.GetProperty("tool").GetProperty("driver").GetProperty("rules").EnumerateArray()); + var result = Assert.Single(run.GetProperty("results").EnumerateArray()); + + Assert.Equal("search", rule.GetProperty("id").GetString()); + Assert.Equal("cdidx search", rule.GetProperty("name").GetString()); + Assert.Equal("https://github.com/Widthdom/CodeIndex", rule.GetProperty("helpUri").GetString()); + Assert.Contains("surrounding code", rule.GetProperty("help").GetProperty("text").GetString(), StringComparison.Ordinal); + Assert.Contains(rule.GetProperty("properties").GetProperty("tags").EnumerateArray(), tag => tag.GetString() == "cdidx"); Assert.Equal("search", result.GetProperty("ruleId").GetString()); + Assert.Equal("warning", result.GetProperty("level").GetString()); Assert.Equal("src/app.cs", result.GetProperty("locations")[0].GetProperty("physicalLocation").GetProperty("artifactLocation").GetProperty("uri").GetString()); } finally @@ -875,9 +884,18 @@ public void RunSearch_FormatCsvEmitsDelimitedRows_Issue1941() Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Equal(string.Empty, stderr); var lines = stdout.Trim().Split(Environment.NewLine); - Assert.Equal("file,line,column,label", lines[0]); - Assert.Contains("src/app.cs", lines[1]); - Assert.Contains("search match: Authenticate", lines[1]); + Assert.Equal("file,line,column,label,query,recipe,query_name,lang,visibility,enclosing_symbol_name,enclosing_symbol_kind,match_lines", lines[0]); + var cells = lines[1].Split(','); + Assert.Equal(12, cells.Length); + Assert.Equal("src/app.cs", cells[0]); + Assert.Equal("search match: Authenticate", cells[3]); + Assert.Equal("Authenticate", cells[4]); + Assert.Equal(string.Empty, cells[5]); + Assert.Equal(string.Empty, cells[6]); + Assert.Equal("csharp", cells[7]); + Assert.Equal("Run", cells[9]); + Assert.Equal("function", cells[10]); + Assert.Equal("1", cells[11]); } finally { @@ -1088,6 +1106,10 @@ public void RunSearch_ListRecipesJsonIncludesBuiltInAuditMetadata_Issue3144() Assert.Contains("False positives", query.GetProperty("false_positive_guidance").GetString(), StringComparison.OrdinalIgnoreCase); Assert.Equal("auth token", tokenQuery.GetProperty("query").GetString()); Assert.Contains("broad-token-audit", tokenQuery.GetProperty("false_positive_guidance").GetString(), StringComparison.Ordinal); + Assert.Contains(recipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "file-read-all-text"); + Assert.Contains(recipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "file-read-all-bytes"); + Assert.Contains(recipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "thread-sleep"); + Assert.Contains(recipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "http-client-construction"); Assert.Contains(jsonRecipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "json-node-parse"); Assert.Contains(jsonRecipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "json-serializer-deserialize"); Assert.Contains(jsonRecipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "json-async-deserialize"); @@ -1101,6 +1123,235 @@ public void RunSearch_ListRecipesJsonIncludesBuiltInAuditMetadata_Issue3144() Assert.Contains(broadTokenRecipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "token-term-broad"); } + [Fact] + public void RunSearch_ExternalRecipeDefaultsApplyToScope_Issue3807() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_recipe_defaults_3807"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var recipePath = Path.Combine(projectRoot, "external-recipes.json"); + File.WriteAllText( + recipePath, + """ + { + "recipes": [ + { + "name": "local-defaults", + "description": "Exercise external defaults.", + "default_scope": "source", + "default_path_patterns": ["docs/**"], + "default_exclude_paths": ["docs/private/**"], + "queries": [ + { + "name": "config-needle", + "query": "ConfigNeedle", + "description": "Find the configured marker.", + "recommended_labels": ["audit"], + "false_positive_guidance": "Review surrounding context." + } + ] + } + ] + } + """); + + TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", "public class App { string Value = \"ConfigNeedle\"; }\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "docs/public.md", "markdown", "ConfigNeedle in public docs.\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "docs/private/secret.md", "markdown", "ConfigNeedle in private docs.\n"); + + using var env = EnvironmentVariableScope.Capture(SearchAuditRecipes.RecipePathsEnvironmentVariable); + env.Set(SearchAuditRecipes.RecipePathsEnvironmentVariable, recipePath); + + var (listExitCode, listStdout, listStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--list-recipes", "--json"], + _jsonOptions)); + var (runExitCode, runStdout, runStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "local-defaults", "--db", dbPath, "--json", "--limit", "10"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, listExitCode); + Assert.Equal(string.Empty, listStderr); + using var listDocument = ParseJsonOutput(listStdout); + var recipe = listDocument.RootElement + .GetProperty("recipes") + .EnumerateArray() + .Single(item => item.GetProperty("name").GetString() == "local-defaults"); + Assert.Equal("source", recipe.GetProperty("default_scope").GetString()); + Assert.Contains(recipe.GetProperty("default_path_patterns").EnumerateArray(), path => path.GetString() == "docs/**"); + Assert.Contains(recipe.GetProperty("default_exclude_paths").EnumerateArray(), path => path.GetString() == "docs/private/**"); + + Assert.Equal(CommandExitCodes.Success, runExitCode); + Assert.Equal(string.Empty, runStderr); + using var runDocument = ParseJsonOutput(runStdout); + var root = runDocument.RootElement; + var query = Assert.Single(root.GetProperty("queries").EnumerateArray()); + var result = Assert.Single(query.GetProperty("results").EnumerateArray()); + Assert.Equal("docs/public.md", result.GetProperty("path").GetString()); + Assert.Contains(root.GetProperty("scope").GetProperty("path_patterns").EnumerateArray(), path => path.GetString() == "docs/**"); + Assert.Contains(root.GetProperty("scope").GetProperty("exclude_paths").EnumerateArray(), path => path.GetString() == "docs/private/**"); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_ExternalRecipeDuplicateDiagnosticsHideRawSourcePath_Issue3807() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_recipe_diagnostics_3807"); + try + { + var recipePath = Path.Combine(projectRoot, "secret-recipe-path-3807.json"); + File.WriteAllText( + recipePath, + """ + [ + { + "name": "risky-code", + "description": "Duplicate a built-in recipe.", + "queries": [ + { + "name": "duplicate-query", + "query": "DuplicateNeedle", + "description": "Find a duplicate marker." + } + ] + } + ] + """); + + using var env = EnvironmentVariableScope.Capture(SearchAuditRecipes.RecipePathsEnvironmentVariable); + env.Set(SearchAuditRecipes.RecipePathsEnvironmentVariable, recipePath); + + var registry = SearchAuditRecipes.Load(); + + var diagnostic = Assert.Single(registry.Diagnostics); + Assert.Equal("recipe source #1 defines duplicate recipe 'risky-code'; keeping the first definition.", diagnostic); + Assert.DoesNotContain(recipePath, diagnostic, StringComparison.Ordinal); + Assert.DoesNotContain("secret-recipe-path", diagnostic, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_ExternalRecipeQueryScopeAndSeverityApply_Issue3826() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_recipe_query_scope_3826"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var recipePath = Path.Combine(projectRoot, "query-scope-recipes.json"); + File.WriteAllText( + recipePath, + """ + { + "recipes": [ + { + "name": "query-scoped", + "description": "Exercise query-local scope metadata.", + "default_scope": "source", + "default_path_patterns": ["src/**"], + "queries": [ + { + "name": "docs-only", + "query": "BoundaryNeedle", + "description": "Find the marker in docs only.", + "severity": "high", + "path_patterns": ["docs/**"], + "exclude_paths": ["docs/private/**"] + } + ] + } + ] + } + """); + + TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", "public class App { string Value = \"BoundaryNeedle\"; }\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "docs/public.md", "markdown", "BoundaryNeedle in public docs.\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "docs/private/secret.md", "markdown", "BoundaryNeedle in private docs.\n"); + + using var env = EnvironmentVariableScope.Capture(SearchAuditRecipes.RecipePathsEnvironmentVariable); + env.Set(SearchAuditRecipes.RecipePathsEnvironmentVariable, recipePath); + + var (listExitCode, listStdout, listStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--list-recipes", "--json"], + _jsonOptions)); + var (runExitCode, runStdout, runStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "query-scoped", "--db", dbPath, "--json", "--limit", "10"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, listExitCode); + Assert.Equal(string.Empty, listStderr); + using var listDocument = ParseJsonOutput(listStdout); + var listedQuery = listDocument.RootElement + .GetProperty("recipes") + .EnumerateArray() + .Single(item => item.GetProperty("name").GetString() == "query-scoped") + .GetProperty("queries") + .EnumerateArray() + .Single(); + Assert.Equal("high", listedQuery.GetProperty("severity").GetString()); + Assert.Contains(listedQuery.GetProperty("path_patterns").EnumerateArray(), path => path.GetString() == "docs/**"); + Assert.Contains(listedQuery.GetProperty("exclude_paths").EnumerateArray(), path => path.GetString() == "docs/private/**"); + + Assert.Equal(CommandExitCodes.Success, runExitCode); + Assert.Equal(string.Empty, runStderr); + using var runDocument = ParseJsonOutput(runStdout); + var query = Assert.Single(runDocument.RootElement.GetProperty("queries").EnumerateArray()); + var result = Assert.Single(query.GetProperty("results").EnumerateArray()); + Assert.Equal("high", query.GetProperty("severity").GetString()); + Assert.Contains(query.GetProperty("path_patterns").EnumerateArray(), path => path.GetString() == "docs/**"); + Assert.Contains(query.GetProperty("exclude_paths").EnumerateArray(), path => path.GetString() == "docs/private/**"); + Assert.Equal("docs/public.md", result.GetProperty("path").GetString()); + + TestProjectHelper.InsertIndexedFile(dbPath, "docs/other.md", "markdown", "BoundaryNeedle in another public doc.\n"); + + var (userPathExitCode, userPathStdout, userPathStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "query-scoped", "--db", dbPath, "--json", "--limit", "10", "--path", "docs/public.md"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, userPathExitCode); + Assert.Equal(string.Empty, userPathStderr); + using var userPathDocument = ParseJsonOutput(userPathStdout); + var userPathQuery = Assert.Single(userPathDocument.RootElement.GetProperty("queries").EnumerateArray()); + var userPathResult = Assert.Single(userPathQuery.GetProperty("results").EnumerateArray()); + Assert.Equal("docs/public.md", userPathResult.GetProperty("path").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_ExternalRecipeSourceReadIsBounded_Issues3826_3674() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_recipe_bounded_read_3826"); + try + { + var recipePath = Path.Combine(projectRoot, "oversized-recipes.json"); + File.WriteAllText(recipePath, new string(' ', SearchAuditRecipes.MaxRecipeSourceBytes + 1)); + + using var env = EnvironmentVariableScope.Capture(SearchAuditRecipes.RecipePathsEnvironmentVariable); + env.Set(SearchAuditRecipes.RecipePathsEnvironmentVariable, recipePath); + + var registry = SearchAuditRecipes.Load(); + + var diagnostic = Assert.Single(registry.Diagnostics); + Assert.Equal($"recipe source #1 is too large (max {SearchAuditRecipes.MaxRecipeSourceBytes} bytes).", diagnostic); + Assert.DoesNotContain(recipePath, diagnostic, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Theory] [InlineData("count")] [InlineData("csv")] @@ -1167,7 +1418,7 @@ public void Run(Exception ex, CancellationToken token) .Single(item => item.GetProperty("name").GetString() == "unbounded-json-parse"); Assert.Equal("risky-code", root.GetProperty("recipe").GetProperty("name").GetString()); - Assert.Equal(20, root.GetProperty("query_count").GetInt32()); + Assert.Equal(24, root.GetProperty("query_count").GetInt32()); Assert.Equal("source", root.GetProperty("scope").GetProperty("name").GetString()); Assert.Contains(root.GetProperty("scope").GetProperty("path_patterns").EnumerateArray(), path => path.GetString() == "src/**"); Assert.Contains(root.GetProperty("scope").GetProperty("exclude_paths").EnumerateArray(), path => path.GetString() == "src/CodeIndex/Cli/SearchAuditRecipes.cs"); @@ -1578,7 +1829,7 @@ public void RunSearch_RecipeUnknownChildSelectorReturnsUsage_Issue3519() } [Fact] - public void RunSearch_RecipeCompactJsonEmitsSummaryAndCursor_Issue3392() + public void RunSearch_RecipeCompactJsonEmitsSummaryAndCursor_Issues3392_3667() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_recipe_compact_json"); try @@ -1621,12 +1872,19 @@ public void Run(Exception ex) var firstRoot = firstDocument.RootElement; var firstQuery = Assert.Single(firstRoot.GetProperty("queries").EnumerateArray()); var firstResult = Assert.Single(firstQuery.GetProperty("results").EnumerateArray()); + var firstSummary = firstRoot.GetProperty("summary"); var firstPath = firstResult.GetProperty("path").GetString(); var nextCursor = firstQuery.GetProperty("next_cursor").GetString(); Assert.Equal(1, firstRoot.GetProperty("query_count").GetInt32()); Assert.Equal(1, firstRoot.GetProperty("result_count").GetInt32()); + Assert.Equal(1, firstSummary.GetProperty("limit_per_query").GetInt32()); + Assert.Equal(1, firstSummary.GetProperty("emitted_result_count").GetInt32()); + Assert.Equal(1, firstSummary.GetProperty("truncated_query_count").GetInt32()); + Assert.True(firstSummary.GetProperty("cursoring_available").GetBoolean()); Assert.Equal("raw-diagnostic-echo", firstQuery.GetProperty("name").GetString()); + Assert.Equal(1, firstQuery.GetProperty("result_limit").GetInt32()); + Assert.Equal(1, firstQuery.GetProperty("minimum_omitted_result_count").GetInt32()); Assert.Equal(1, firstQuery.GetProperty("top_files")[0].GetProperty("count").GetInt32()); Assert.True(firstResult.TryGetProperty("match_lines", out _)); Assert.False(firstResult.TryGetProperty("snippet", out _)); @@ -1639,8 +1897,12 @@ public void Run(Exception ex) Assert.Equal(CommandExitCodes.Success, jsonExitCode); Assert.Equal(string.Empty, jsonStderr); using var jsonDocument = ParseJsonOutput(jsonStdout); + var jsonSummary = jsonDocument.RootElement.GetProperty("summary"); var jsonQuery = Assert.Single(jsonDocument.RootElement.GetProperty("queries").EnumerateArray()); + Assert.Equal(1, jsonSummary.GetProperty("limit_per_query").GetInt32()); + Assert.Equal(1, jsonSummary.GetProperty("minimum_omitted_result_count").GetInt32()); Assert.True(jsonQuery.GetProperty("truncated").GetBoolean()); + Assert.Equal(1, jsonQuery.GetProperty("minimum_omitted_result_count").GetInt32()); Assert.False(string.IsNullOrWhiteSpace(jsonQuery.GetProperty("next_cursor").GetString())); var (secondExitCode, secondStdout, secondStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( @@ -1816,6 +2078,7 @@ public void Run() using var document = ParseJsonOutput(stdout); var root = document.RootElement; var draft = Assert.Single(root.GetProperty("drafts").EnumerateArray()); + var triage = draft.GetProperty("triage"); var duplicatePreflight = draft.GetProperty("duplicate_preflight"); var match = Assert.Single(duplicatePreflight.GetProperty("matches").EnumerateArray()); var body = draft.GetProperty("body").GetString(); @@ -1823,11 +2086,20 @@ public void Run() Assert.Equal(1, root.GetProperty("count").GetInt32()); Assert.True(root.GetProperty("duplicate_preflight").GetProperty("checked").GetBoolean()); Assert.Equal(1, root.GetProperty("duplicate_preflight").GetProperty("open_issue_count").GetInt32()); + Assert.Equal("medium", root.GetProperty("duplicate_preflight").GetProperty("confidence").GetString()); + Assert.Equal(0.45, root.GetProperty("duplicate_preflight").GetProperty("minimum_score").GetDouble()); Assert.Equal("Search audit recipe risky-code: unbounded-json-parse", draft.GetProperty("title").GetString()); Assert.Contains(draft.GetProperty("labels").EnumerateArray(), label => label.GetString() == "audit"); Assert.Contains(draft.GetProperty("labels").EnumerateArray(), label => label.GetString() == "bug"); Assert.Equal("src/app.cs", draft.GetProperty("evidence_paths")[0].GetString()); + Assert.Equal("medium", triage.GetProperty("severity").GetString()); + Assert.Equal("low", triage.GetProperty("confidence").GetString()); + Assert.Equal(1, triage.GetProperty("evidence_count").GetInt32()); + Assert.Contains("merge evidence", triage.GetProperty("duplicate_guidance").GetString(), StringComparison.Ordinal); Assert.Contains("JsonDocument.Parse", body, StringComparison.Ordinal); + Assert.Contains("## Triage metadata", body, StringComparison.Ordinal); + Assert.Contains("severity: `medium`", body, StringComparison.Ordinal); + Assert.Contains("confidence: `low`", body, StringComparison.Ordinal); Assert.Contains("False-positive guidance", body, StringComparison.Ordinal); Assert.Contains("## Replay command", body, StringComparison.Ordinal); Assert.Contains("cdidx search --recipe risky-code/unbounded-json-parse --format issue-drafts --limit 5", body, StringComparison.Ordinal); @@ -1898,6 +2170,7 @@ public void Run() var root = document.RootElement; var draft = Assert.Single(root.GetProperty("drafts").EnumerateArray()); var labels = draft.GetProperty("labels").EnumerateArray().Select(label => label.GetString()).ToList(); + var triage = draft.GetProperty("triage"); var duplicatePreflight = draft.GetProperty("duplicate_preflight"); var match = Assert.Single(duplicatePreflight.GetProperty("matches").EnumerateArray()); var body = draft.GetProperty("body").GetString(); @@ -1905,12 +2178,18 @@ public void Run() Assert.Equal(1, root.GetProperty("query_count").GetInt32()); Assert.Equal(JsonValueKind.Null, root.GetProperty("recipe").ValueKind); Assert.Equal(1, root.GetProperty("count").GetInt32()); + Assert.Equal("medium", root.GetProperty("duplicate_preflight").GetProperty("confidence").GetString()); + Assert.Equal(0.45, root.GetProperty("duplicate_preflight").GetProperty("minimum_score").GetDouble()); Assert.Equal("Thread.Yield audit", draft.GetProperty("title").GetString()); Assert.Contains("audit", labels); Assert.Contains("bug", labels); Assert.Contains("needs-triage", labels); Assert.Equal("src/scheduler.cs", draft.GetProperty("evidence_paths")[0].GetString()); + Assert.Equal("medium", triage.GetProperty("severity").GetString()); + Assert.Equal("low", triage.GetProperty("confidence").GetString()); + Assert.Equal(1, triage.GetProperty("evidence_count").GetInt32()); Assert.Contains("Thread.Yield", body, StringComparison.Ordinal); + Assert.Contains("## Triage metadata", body, StringComparison.Ordinal); Assert.DoesNotContain("public sealed class Scheduler", body, StringComparison.Ordinal); Assert.Equal(JsonValueKind.Null, draft.GetProperty("source").GetProperty("recipe").ValueKind); Assert.Equal(JsonValueKind.Null, draft.GetProperty("source").GetProperty("query_name").ValueKind); @@ -1926,6 +2205,91 @@ public void Run() } } + [Fact] + public void RunSearch_AdHocIssueDraftDuplicateThresholdFiltersMatches_Issue3827() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_issue_draft_threshold_3827"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var openIssuesPath = Path.Combine(projectRoot, "open-issues.json"); + File.WriteAllText( + openIssuesPath, + """ + [ + { + "number": 3827, + "title": "Search issue draft: Thread.Yield stale match follow-up scheduler report review backlog duplicate candidate validation note", + "labels": [{"name": "bug"}], + "url": "https://example.test/issues/3827" + } + ] + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/scheduler.cs", + "csharp", + """ + public sealed class Scheduler + { + public void Run() + { + Thread.Yield(); + } + } + """); + + var (highExitCode, highStdout, highStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + [ + "Thread.Yield", + "--db", dbPath, + "--format", "issue-drafts", + "--exact-substring", + "--issue-label", "bug", + "--open-issues", openIssuesPath, + "--duplicate-confidence", "high" + ], + _jsonOptions)); + var (customExitCode, customStdout, customStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + [ + "Thread.Yield", + "--db", dbPath, + "--format", "issue-drafts", + "--exact-substring", + "--issue-label", "bug", + "--open-issues", openIssuesPath, + "--duplicate-threshold", "0.4" + ], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, highExitCode); + Assert.Equal(string.Empty, highStderr); + using var highDocument = ParseJsonOutput(highStdout); + var highRoot = highDocument.RootElement; + var highDraft = Assert.Single(highRoot.GetProperty("drafts").EnumerateArray()); + Assert.Equal("high", highRoot.GetProperty("duplicate_preflight").GetProperty("confidence").GetString()); + Assert.Equal(0.7, highRoot.GetProperty("duplicate_preflight").GetProperty("minimum_score").GetDouble()); + Assert.Equal(0, highDraft.GetProperty("duplicate_preflight").GetProperty("match_count").GetInt32()); + + Assert.Equal(CommandExitCodes.Success, customExitCode); + Assert.Equal(string.Empty, customStderr); + using var customDocument = ParseJsonOutput(customStdout); + var customRoot = customDocument.RootElement; + var customDraft = Assert.Single(customRoot.GetProperty("drafts").EnumerateArray()); + var customMatch = Assert.Single(customDraft.GetProperty("duplicate_preflight").GetProperty("matches").EnumerateArray()); + Assert.Equal("custom", customRoot.GetProperty("duplicate_preflight").GetProperty("confidence").GetString()); + Assert.Equal(0.4, customRoot.GetProperty("duplicate_preflight").GetProperty("minimum_score").GetDouble()); + Assert.Equal(1, customDraft.GetProperty("duplicate_preflight").GetProperty("match_count").GetInt32()); + Assert.Equal(3827, customMatch.GetProperty("number").GetInt32()); + Assert.Equal("title_label_contains", customMatch.GetProperty("reason").GetString()); + Assert.Equal(0.45, customMatch.GetProperty("score").GetDouble()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_ProfileEmitsSqlPhasesAndQueryPlan_Issue1643() {