diff --git a/USER_GUIDE.md b/USER_GUIDE.md index eaf1c6417e..c15879e468 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -462,6 +462,7 @@ cdidx unused --lang csharp --exclude-tests cdidx unused --kind function --path src/ --limit 50 cdidx unused --bucket likely_unused_private --min-confidence medium cdidx unused --json --count +cdidx unused --compact --bucket likely_unused_private --min-confidence medium cdidx unused --json --by-bucket ``` @@ -472,6 +473,11 @@ and `bucket_taxonomy` for the `likely_unused_private`, `reflection_or_config_suspect` buckets; `--by-bucket` also groups returned symbols under those bucket keys. Use `--bucket ` to return only one bucket, and `--min-confidence ` to omit lower-confidence classes. +JSON output includes `query_context` so applied bucket and confidence filters +are visible to downstream audit tooling. Use `--compact` for audit summaries +that keep counts, confidence buckets, taxonomy, and filter context without +returning the full `symbols` array; add `--by-bucket` only when grouped symbol +arrays are explicitly needed. Public APIs, framework entrypoints, generated hooks, reflection, and configuration-based usage can be false positives. C# `nameof(...)`, `typeof(...)`, and direct reflection member-name literals such as @@ -1066,10 +1072,16 @@ cdidx symbols --kind class # all classes cdidx symbols --kind function --lang python cdidx symbols --visibility public,internal # public/internal symbols cdidx symbols --exclude-visibility private # hide private symbols +cdidx symbols --kind function --sort hotspot --json # hotspot-ranked audit stream +cdidx symbols --kind function --sort size --json # largest definitions first ``` Use `--exact-name` when you already have a precise candidate list (e.g. names returned from an earlier `search` / `inspect` / `map` call). Names are compared case-insensitively for equality instead of substring, so `Run` will not also pull in `RunAsync`, `RunImpact`, etc. `--exact-name` composes with `--name`, positional names, and all existing filters. The older `--exact` spelling still works on these commands for backward compatibility, but `--exact-name` avoids the semantic clash with `search`. For C#, pass the canonical extracted symbol name: operators are stored as `operator +` / `operator checked +`, conversion operators as `explicit operator Money` / `implicit operator decimal`, and indexers as `Item`. If your DB was created before the canonical C# operator/indexer rename landed, a normal `cdidx index .` rewrites unchanged C# rows once to upgrade them; `--rebuild` is not required for that change. `status --json` also exposes `csharp_symbol_name_ready` so you can verify that the canonical C# rename has been applied to the current DB. The fold is NFKC + Unicode CaseFold: common non-ASCII pairs such as `Ä` / `ä`, fullwidth `Run` / `Run`, ligatures, sharp-S (`Straße` / `STRASSE`), and Greek final sigma (`Σ` / `ς` / `σ`) now collapse correctly. Unicode CaseFold remains locale-invariant, so Turkish dotted `İ` still folds to `i\u0307` rather than plain `i`. DBs with stale fold metadata fall back to ASCII `COLLATE NOCASE` until the DB contains only current folded keys. Prefer `cdidx backfill-fold` to refresh stored folded keys without reparsing. A plain `cdidx index .` is also enough if the scan rewrites or purges every stale row; otherwise use `cdidx index . --rebuild`. Use `status --json` → `fold_ready` to detect which path is active. +For audit passes, add `--sort hotspot|references|size|complexity|path`. +`--json` rows include `sort_mode`, `reference_count`, `hotspot_score`, +`size_lines`, and `complexity_score` whenever an audit sort is active. + Output: ``` @@ -1237,9 +1249,11 @@ Without `--check`, the `status` summary freshness indicator is based on stored ` ```bash cdidx map --path src/ --exclude-tests cdidx map --path src/ --exclude-tests --json +cdidx map --summary-only --json +cdidx map --sections hotspots,metrics --json ``` -`map` is the fastest way to orient both a human and an AI agent before deeper queries. Use it to get languages, modules, hot files, and likely entrypoints, then narrow with `inspect`, `search`, or `definition`. For the full freshness and metadata contract of `status --json`, `map --json`, `inspect --json`, and MCP `analyze_symbol`, see [DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md). +`map` is the fastest way to orient both a human and an AI agent before deeper queries. Use it to get languages, modules, hot files, and likely entrypoints, then narrow with `inspect`, `search`, or `definition`. Use `--summary-only` when only aggregate counts and freshness metadata are needed, or `--sections ` to request only selected detail sections. For the full freshness and metadata contract of `status --json`, `map --json`, `inspect --json`, and MCP `analyze_symbol`, see [DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md). ### Build a bug-report bundle @@ -2885,6 +2899,7 @@ cdidx unused --lang csharp --exclude-tests cdidx unused --kind function --path src/ --limit 50 cdidx unused --bucket likely_unused_private --min-confidence medium cdidx unused --json --count +cdidx unused --compact --bucket likely_unused_private --min-confidence medium cdidx unused --json --by-bucket ``` @@ -2894,7 +2909,7 @@ cdidx unused --json --by-bucket `summary.by_bucket`、`summary.by_confidence`、`bucket_taxonomy` が含まれます。 `--by-bucket` は返却された symbols も bucket key ごとに grouped します。 `--bucket ` で単一 bucket だけを返し、`--min-confidence ` で -より低い confidence class を除外できます。Public API、framework entrypoint、generated hook、reflection、config 経由の使用は false positive +より低い confidence class を除外できます。JSON 出力には `query_context` も含まれるため、audit tooling は適用された bucket と confidence filter を直接確認できます。count、confidence bucket、taxonomy、filter context だけが必要な場合は `--compact` を使い、grouped symbol arrays が明示的に必要な場合だけ `--by-bucket` を追加してください。Public API、framework entrypoint、generated hook、reflection、config 経由の使用は false positive になりえます。C# の `nameof(...)`、`typeof(...)`、`GetMethod("Foo")` のような 直接的な reflection member-name literal は indexed されますが、動的に組み立てられる 名前は手動確認が必要です。 @@ -3509,10 +3524,16 @@ cdidx symbols --kind class # すべてのクラス cdidx symbols --kind function --lang python cdidx symbols --visibility public,internal # public/internal シンボル cdidx symbols --exclude-visibility private # private シンボルを除外 +cdidx symbols --kind function --sort hotspot --json # hotspot ranking の audit stream +cdidx symbols --kind function --sort size --json # 大きい definition から表示 ``` `--exact-name` は、すでに解決済みの候補リスト(例: `search` / `inspect` / `map` の結果)を渡して正確にその行だけ取り返したいときに使う。部分一致ではなく大文字小文字を無視した完全一致で比較するため、`Run` を指定しても `RunAsync`、`RunImpact` 等には広がらない。`--exact-name` は `--name`、positional 名、他の全フィルタと組み合わせ可能。従来の `--exact` も後方互換で引き続き使えるが、`search` と意味がぶつからない `--exact-name` を推奨する。C# では抽出済みの canonical symbol name を渡す必要があり、演算子は `operator +` / `operator checked +`、変換演算子は `explicit operator Money` / `implicit operator decimal`、インデクサは `Item` で引く。canonical な C# operator/indexer 名へ変わる前に作った DB でも、通常の `cdidx index .` を 1 回流せば unchanged な C# 行を自動で再抽出して更新するため、この変更だけのために `--rebuild` は不要。upgrade 済みかどうかは `status --json` の `csharp_symbol_name_ready` で判定できる。fold は NFKC 正規化 + Unicode CaseFold で、`Ä` / `ä`、全角 `Run` / `Run`、合字、sharp-S(`Straße` / `STRASSE`)、Greek final sigma(`Σ` / `ς` / `σ`)などの非 ASCII 差分も正しく一致する。Unicode CaseFold は locale-invariant のため、トルコ語の dotted `İ` は依然 plain `i` ではなく `i\u0307` に fold される。stale な fold metadata を含む DB は、DB 内が current folded key のみになるまで ASCII `COLLATE NOCASE` に黙ってフォールバックする。stored folded key を再解析なしで更新したいなら `cdidx backfill-fold` を優先し、scan が stale row をすべて rewrite / purge できるなら通常の `cdidx index .` でも復帰できる。stale row が残る場合だけ `cdidx index . --rebuild` が必要。`status --json` の `fold_ready` で現在の経路を判定可能。 +audit では `--sort hotspot|references|size|complexity|path` を追加できます。 +audit sort が有効な `--json` row には `sort_mode`、`reference_count`、 +`hotspot_score`、`size_lines`、`complexity_score` が含まれます。 + 出力: ``` @@ -3682,9 +3703,11 @@ AI agent の作業開始時はこれを先に実行し、`.cdidx/codeindex.db` ```bash cdidx map --path src/ --exclude-tests cdidx map --path src/ --exclude-tests --json +cdidx map --summary-only --json +cdidx map --sections hotspots,metrics --json ``` -`map` は、人と AI のどちらにも最短で全体像を渡すための入口です。言語、モジュール、ホットなファイル、推定エントリポイントを把握したら、`inspect`、`search`、`definition` に進んでください。`status --json`、`map --json`、`inspect --json`、MCP `analyze_symbol` の詳細なメタデータ契約は [DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md#開発者ガイド) にまとめています。 +`map` は、人と AI のどちらにも最短で全体像を渡すための入口です。言語、モジュール、ホットなファイル、推定エントリポイントを把握したら、`inspect`、`search`、`definition` に進んでください。集計値と freshness メタデータだけが必要な場合は `--summary-only`、必要な詳細セクションだけを取りたい場合は `--sections ` を使えます。`status --json`、`map --json`、`inspect --json`、MCP `analyze_symbol` の詳細なメタデータ契約は [DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md#開発者ガイド) にまとめています。 ### バグ報告用バンドルを作る diff --git a/changelog.d/unreleased/3393.added.md b/changelog.d/unreleased/3393.added.md new file mode 100644 index 0000000000..f114de8f9d --- /dev/null +++ b/changelog.d/unreleased/3393.added.md @@ -0,0 +1,19 @@ +--- +category: added +issues: + - 3393 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs + - USER_GUIDE.md +--- + +## English + +- **Added summary-only map output (#3393)** — `cdidx map --summary-only --json` now returns only aggregate counts and freshness metadata, while `--sections` remains available for selected detail sections. + +## 日本語 + +- **map の summary-only 出力を追加しました (#3393)** — `cdidx map --summary-only --json` が集計値と freshness メタデータのみを返し、必要な詳細セクションには引き続き `--sections` を使えます。 diff --git a/changelog.d/unreleased/3395.changed.md b/changelog.d/unreleased/3395.changed.md new file mode 100644 index 0000000000..9560513a7e --- /dev/null +++ b/changelog.d/unreleased/3395.changed.md @@ -0,0 +1,19 @@ +--- +category: changed +issues: + - 3395 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs + - USER_GUIDE.md +--- + +## English + +- **Clarified unused-symbol audit JSON filters (#3395)** — `cdidx unused` JSON now includes `query_context` for bucket and confidence filters, and `--compact` keeps audit counts and taxonomy without returning the full `symbols` array. + +## 日本語 + +- **unused-symbol audit JSON の filter 表示を明確にしました (#3395)** — `cdidx unused` JSON に bucket/confidence filter の `query_context` を含め、`--compact` では full `symbols` array を返さず audit count と taxonomy を維持します。 diff --git a/changelog.d/unreleased/3417.internal.md b/changelog.d/unreleased/3417.internal.md new file mode 100644 index 0000000000..e7e992259d --- /dev/null +++ b/changelog.d/unreleased/3417.internal.md @@ -0,0 +1,20 @@ +--- +category: internal +issues: + - 3417 +affected: + - src/CodeIndex/Cli/CommandErrorWriter.cs + - src/CodeIndex/Cli/DbCommandRunner.cs + - src/CodeIndex/Cli/ReportCommandRunner.cs + - src/CodeIndex/Cli/SuggestionsCommandRunner.cs + - src/CodeIndex/Cli/HookCommandRunner.cs + - src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs +--- + +## English + +- **Centralized more CLI stdout/stderr writing through the shared command writer (#3417)** — command runners now route duplicated error, warning, and JSON/human error output paths through `CommandErrorWriter`. + +## 日本語 + +- **CLI の stdout/stderr 出力を共有 command writer へさらに集約しました (#3417)** — command runner の重複した error、warning、JSON/human error 出力経路を `CommandErrorWriter` 経由にしました。 diff --git a/changelog.d/unreleased/3451.added.md b/changelog.d/unreleased/3451.added.md new file mode 100644 index 0000000000..11079eceed --- /dev/null +++ b/changelog.d/unreleased/3451.added.md @@ -0,0 +1,21 @@ +--- +category: added +issues: + - 3451 +affected: + - src/CodeIndex/Database/DbSymbolReader.cs + - src/CodeIndex/Models/QueryResults.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs + - USER_GUIDE.md +--- + +## English + +- **Added audit-oriented symbol ordering (#3451)** — `cdidx symbols --sort hotspot|references|size|complexity|path` now orders symbol output for audit workflows and emits JSON ranking metadata such as reference counts, hotspot scores, size, and complexity. + +## 日本語 + +- **audit 向けの symbols ordering を追加しました (#3451)** — `cdidx symbols --sort hotspot|references|size|complexity|path` で audit workflow 向けに symbol output を並べ替え、reference count、hotspot score、size、complexity などの JSON ranking metadata を出力します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 02f5bb5ca3..4914f5edcc 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -125,6 +125,7 @@ internal static class CliFlagSchema ]; private static readonly string[] RawKindsCommands = ["callers", "callees"]; private static readonly string[] RankByCommands = ["callers", "callees"]; + private static readonly string[] SymbolSortCommands = ["symbols"]; private static readonly string[] ByBucketCommands = ["unused"]; private static readonly string[] UnusedFilterCommands = ["unused"]; private static readonly string[] AllResultCommands = ["goto", "find"]; @@ -133,6 +134,7 @@ internal static class CliFlagSchema private static readonly string[] ByteFormatCommands = ["files", "map"]; private static readonly string[] EntrypointConfidenceCommands = ["map"]; private static readonly string[] MapSectionCommands = ["map"]; + private static readonly string[] SummaryOnlyCommands = ["map"]; private static readonly string[] DependencyCycleCommands = ["deps"]; private static readonly string[] LanguagesFilterCommands = ["languages"]; @@ -195,7 +197,7 @@ internal static class CliFlagSchema "validate", "deps", "impact", "unused", "hotspots", "suggestions", "languages", "db", "report", "upgrade", ]; - private static readonly string[] CompactJsonCommands = ["map", "inspect", "outline"]; + private static readonly string[] CompactJsonCommands = ["map", "inspect", "outline", "unused"]; private static readonly string[] FormatCommands = [ @@ -264,6 +266,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--min-confidence", ValuePlaceholder = "", Description = "Unused: return symbols at or above this confidence", Commands = Set(UnusedFilterCommands) }, new() { Name = "--all", Description = "goto: return all matching LSP locations; find: search all indexed files instead of requiring --path", Commands = Set(AllResultCommands) }, new() { Name = "--rank-by", ValuePlaceholder = "", Description = "Rank callers/callees by weighted structural score, raw count, or kind bucket", Commands = Set(RankByCommands) }, + new() { Name = "--sort", ValuePlaceholder = "", Description = "Symbols: order audit output by a ranking signal", Commands = Set(SymbolSortCommands) }, new() { Name = "--raw-kinds", Description = "Show raw reference kinds instead of logical graph kinds", Commands = Set(RawKindsCommands) }, new() { Name = "--count", Description = "Count only", Commands = Set(CountCommands) }, new() { Name = "--strict-not-found", Description = "Return exit code 2 when a valid query has zero rows", Commands = Set(StrictNotFoundCommands) }, @@ -272,6 +275,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--bytes", Description = "Files: sort by size and show raw byte counts in human output; map: show raw byte counts", Commands = Set(ByteFormatCommands) }, new() { Name = "--min-entrypoint-confidence", ValuePlaceholder = "<0.0..1.0>", Description = "Map: omit entrypoint candidates below this confidence", Commands = Set(EntrypointConfidenceCommands) }, new() { Name = "--sections", ValuePlaceholder = "", Description = "Map: comma-separated response sections to include", Commands = Set(MapSectionCommands) }, + new() { Name = "--summary-only", Description = "Map/Diff: return only aggregate summary fields", Commands = Set(SummaryOnlyCommands) }, new() { Name = "--cycles", Description = "Deps: return dependency cycles instead of edge rows", Commands = Set(DependencyCycleCommands) }, new() { Name = "--indexed-only", Description = "Languages: list only languages present in the current index", Commands = Set(LanguagesFilterCommands) }, new() { Name = "--capability", ValuePlaceholder = "", Description = "Languages: filter by language capability or capability gap", Commands = Set(LanguagesFilterCommands) }, diff --git a/src/CodeIndex/Cli/CommandErrorWriter.cs b/src/CodeIndex/Cli/CommandErrorWriter.cs index e61084e644..2ff066088e 100644 --- a/src/CodeIndex/Cli/CommandErrorWriter.cs +++ b/src/CodeIndex/Cli/CommandErrorWriter.cs @@ -7,13 +7,22 @@ internal static class CommandErrorWriter internal const string DefaultHint = "Run ' --help' for usage information."; private const int SanitizedExceptionTypeNameLimit = 120; + internal static void WriteStdout(string message = "") + => Console.WriteLine(message); + + internal static void WriteStderr(string message = "") + => Console.Error.WriteLine(message); + + internal static void WriteWarning(string message) + => WriteStderr($"Warning: {message}"); + internal static void Write(string message, string? hint = null, string? usage = null, string? errorCode = null) { var prefix = errorCode is null ? "Error" : $"Error [{errorCode}]"; - Console.Error.WriteLine($"{prefix}: {message}"); - Console.Error.WriteLine($"Hint: {hint ?? DefaultHint}"); + WriteStderr($"{prefix}: {message}"); + WriteStderr($"Hint: {hint ?? DefaultHint}"); if (usage != null) - Console.Error.WriteLine($"Usage: {usage}"); + WriteStderr($"Usage: {usage}"); } internal static int Write( @@ -38,7 +47,7 @@ internal static int WriteJsonOrHuman( { if (json) { - Console.WriteLine(JsonSerializer.Serialize( + WriteStdout(JsonSerializer.Serialize( new CommandErrorJsonResult("error", message, hint, errorCode), CliJsonSerializerContextFactory.Create(jsonOptions).CommandErrorJsonResult)); return exitCode; diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index ab6b88f794..22bc75d560 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -93,11 +93,11 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("references", "cdidx references |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), ("callers", "cdidx callers |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--rank-by ] [--raw-kinds] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), ("callees", "cdidx callees |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--rank-by ] [--raw-kinds] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), - ("symbols", "cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]"), + ("symbols", "cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--sort ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]"), ("files", "cdidx files [query|--query |-- ] [--db ] [--json[=ndjson|array]] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]"), ("find", "cdidx find (--path |--all) [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--exact] [--regex] [--count]"), ("excerpt", "cdidx excerpt --start [--end ] [--before ] [--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--verbose]"), - ("map", "cdidx map [--db ] [--json] [--format ] [--pretty] [--compact] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>]"), + ("map", "cdidx map [--db ] [--json] [--format ] [--pretty] [--compact] [--summary-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>]"), ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--body-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines ] [--max-line-width ] [--exact|--exact-name]"), ("outline", "cdidx outline [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ]"), ("status", "cdidx status [--db ] [--json] [--verbose] [--check[=workspace,fold,graph,issues,hotspot,csharp,sql,newer]] [--stale-after ] [--explain ] [--log-path] [--config] [--check-updates]"), @@ -111,7 +111,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("validate", "cdidx validate [--db ] [--json[=array]] [--format ] [--verbose] [--limit |--top ] [--kind ] [--severity ] [--path ]"), ("impact", "cdidx impact |--query |-- [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--max-hops ] [--count] [--with-paths]"), ("deps", "cdidx deps [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--reverse] [--cycles]"), - ("unused", "cdidx unused [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--bucket ] [--min-confidence ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]"), + ("unused", "cdidx unused [--db ] [--json] [--compact] [--verbose] [--limit |--top ] [--kind ] [--bucket ] [--min-confidence ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--by-bucket]"), ("hotspots", "cdidx hotspots [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--group-by ] [--group-by-name]"), ("suggestions", "cdidx suggestions [id] [--db ] [--json] [--status ] [--language ] [--category ] [--since ] [--agent ] [--limit ] [--offset ] [--format ] [--open-issues ] [--repo ]"), ("export", "cdidx export [--db ] [--json]"), diff --git a/src/CodeIndex/Cli/DbCommandRunner.cs b/src/CodeIndex/Cli/DbCommandRunner.cs index 3641c41587..72dda58412 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.cs @@ -154,8 +154,8 @@ private static int RunIntegrityCheck(DbCommandOptions options, JsonSerializerOpt foreach (var line in issues) Console.WriteLine($" - {line}"); Console.WriteLine(); - Console.Error.WriteLine($"Error [{CommandErrorCodes.DbIntegrityFailed}]: SQLite reported integrity_check failures."); - Console.Error.WriteLine("Hint: rebuild with `cdidx index --rebuild` to discard the corrupted DB and start fresh."); + CommandErrorWriter.WriteStderr($"Error [{CommandErrorCodes.DbIntegrityFailed}]: SQLite reported integrity_check failures."); + CommandErrorWriter.WriteStderr("Hint: rebuild with `cdidx index --rebuild` to discard the corrupted DB and start fresh."); } } @@ -1084,7 +1084,7 @@ internal static void TryDeleteTemporaryDirectory(string path, string cleanupDesc } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) { - Console.Error.WriteLine($"Warning: failed to delete {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); + CommandErrorWriter.WriteWarning($"failed to delete {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); } } @@ -1228,18 +1228,7 @@ internal static DbCommandOptions ParseArgs(string[] args) private static int WriteCommandError(bool json, JsonSerializerOptions jsonOptions, string message, int exitCode, string? hint = null, string? errorCode = null) { - if (json) - Console.WriteLine(JsonSerializer.Serialize( - new CommandErrorJsonResult("error", message, hint, errorCode), - CliJsonSerializerContextFactory.Create(jsonOptions).CommandErrorJsonResult)); - else - { - var prefix = errorCode is null ? "Error" : $"Error [{errorCode}]"; - Console.Error.WriteLine($"{prefix}: {message}"); - if (hint != null) - Console.Error.WriteLine($"Hint: {hint}"); - } - return exitCode; + return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, message, exitCode, hint, errorCode: errorCode); } } diff --git a/src/CodeIndex/Cli/HookCommandRunner.cs b/src/CodeIndex/Cli/HookCommandRunner.cs index 78082aa26b..93edb01250 100644 --- a/src/CodeIndex/Cli/HookCommandRunner.cs +++ b/src/CodeIndex/Cli/HookCommandRunner.cs @@ -259,15 +259,15 @@ private static int WriteResult(bool json, JsonSerializerOptions jsonOptions, str } else if (exitCode == CommandExitCodes.Success) { - Console.WriteLine(message); + CommandErrorWriter.WriteStdout(message); if (hookPath != null) - Console.WriteLine($"Hook: {hookPath}"); + CommandErrorWriter.WriteStdout($"Hook: {hookPath}"); if (chainedHookPath != null) - Console.WriteLine($"Chained hook: {chainedHookPath}"); + CommandErrorWriter.WriteStdout($"Chained hook: {chainedHookPath}"); } else { - Console.Error.WriteLine($"Error: {message}"); + CommandErrorWriter.WriteStderr($"Error: {message}"); } return exitCode; diff --git a/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs b/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs index 8d573a6820..55852adacf 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.UpdateTargets.cs @@ -114,7 +114,7 @@ private static void WriteIndexJsonLiveness(IndexCommandOptions options, string m if (!options.Json || options.Quiet) return; - Console.Error.WriteLine($"cdidx: {message}"); + CommandErrorWriter.WriteStderr($"cdidx: {message}"); } private static (CancellationTokenSource Cts, Task Task)? StartIndexJsonPhaseHeartbeat( diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 579366165d..604e26fde7 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -187,6 +187,7 @@ public static class QueryCommandRunner "--stale-after", "--explain", "--rank-by", + "--sort", "--slow-query-ms", "--format", "--min-entrypoint-confidence", @@ -289,6 +290,7 @@ private sealed record StatusReadinessField( "--silent", "--by-bucket", "--all", + "--summary-only", "--cycles", "--group-by-name", "--with-paths", @@ -3572,7 +3574,7 @@ public static int RunSymbols(string[] cmdArgs, JsonSerializerOptions jsonOptions return CommandExitCodes.Success; } - var results = reader.SearchSymbols(symbolQueries, options.Limit, options.Kind, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, options.Since, exact, visibilityFilters: options.VisibilityFilters, excludeVisibilityFilters: options.ExcludeVisibilityFilters); + var results = reader.SearchSymbols(symbolQueries, options.Limit, options.Kind, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, options.Since, exact, visibilityFilters: options.VisibilityFilters, excludeVisibilityFilters: options.ExcludeVisibilityFilters, sortMode: options.SymbolSortMode); var hasExactPredicate = exact && symbolQueries is { Count: > 0 }; var exactSignal = reader.GetSymbolsExactQuerySignal(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, options.Since); var multiNameExactHint = symbolQueries != null && symbolQueries.Count > 1; @@ -3620,15 +3622,34 @@ public static int RunSymbols(string[] cmdArgs, JsonSerializerOptions jsonOptions var lineRange = r.EndLine > r.StartLine ? $"{r.StartLine}-{r.EndLine}" : r.StartLine.ToString(); - Console.WriteLine($"{ConsoleUi.ColorizeKind(r.Kind, 10)} {r.Name,-40} {r.Path}:{lineRange}"); + Console.WriteLine($"{ConsoleUi.ColorizeKind(r.Kind, 10)} {r.Name,-40} {r.Path}:{lineRange}{FormatSymbolRankSuffix(r)}"); } var symFileCount = results.Select(r => r.Path).Distinct().Count(); - Console.Error.WriteLine($"({results.Count} symbols in {symFileCount} files)"); + var sortSummary = options.SymbolSortMode == SymbolSortMode.Name ? string.Empty : $"; sort={options.SymbolSortMode.ToString().ToLowerInvariant()}"; + Console.Error.WriteLine($"({results.Count} symbols in {symFileCount} files{sortSummary})"); } return CommandExitCodes.Success; }); } + private static string FormatSymbolRankSuffix(SymbolResult result) + { + if (result.SortMode == null) + return string.Empty; + + var parts = new List(); + if (result.ReferenceCount.HasValue) + parts.Add($"refs={result.ReferenceCount.Value}"); + if (result.HotspotScore.HasValue) + parts.Add($"hotspot={result.HotspotScore.Value.ToString("0.###", CultureInfo.InvariantCulture)}"); + if (result.SizeLines.HasValue) + parts.Add($"size={result.SizeLines.Value}"); + if (result.ComplexityScore.HasValue) + parts.Add($"complexity={result.ComplexityScore.Value.ToString("0.###", CultureInfo.InvariantCulture)}"); + + return parts.Count == 0 ? string.Empty : $" [{string.Join(", ", parts)}]"; + } + public static int RunFiles(string[] cmdArgs, JsonSerializerOptions jsonOptions) { var previewOptionError = ValidatePreviewOptions("files", cmdArgs, allowMaxLineWidth: false, allowFocusOptions: false); @@ -4279,6 +4300,12 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) return CommandExitCodes.UsageError; if (TryWriteUnexpectedPositionals("map", options)) return CommandExitCodes.UsageError; + if (options.MapSummaryOnly && options.MapSections != null) + return CommandErrorWriter.Write( + "--summary-only cannot be combined with --sections.", + CommandExitCodes.UsageError, + "choose --summary-only for aggregate fields only, or --sections for selected detail sections.", + ConsoleUi.GetUsageLine("map")); return WithDb(options, jsonOptions, reader => { @@ -4358,7 +4385,7 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) } private static bool MapSectionEnabled(QueryCommandOptions options, string section) - => options.MapSections == null || options.MapSections.Contains(section, StringComparer.Ordinal); + => !options.MapSummaryOnly && (options.MapSections == null || options.MapSections.Contains(section, StringComparer.Ordinal)); private static void ApplyRepoMapDepth(RepoMapResult map, int depth) { @@ -4373,6 +4400,14 @@ private static int GetPathDepth(string path) private static JsonObject BuildRepoMapJsonPayload(RepoMapResult map, QueryCommandOptions options, JsonSerializerOptions jsonOptions, JsonObject? compactTruncation = null) { var payload = JsonSerializer.SerializeToNode(map, CliJsonSerializerContextFactory.Create(jsonOptions).RepoMapResult)!.AsObject(); + if (options.MapSummaryOnly) + { + KeepRepoMapJsonProperties(payload, RepoMapSummaryJsonProperties); + payload["summary_only"] = true; + payload["sections"] = new JsonArray(); + return payload; + } + if (options.MapSections == null) { if (options.ContextAfterExplicit) @@ -4382,24 +4417,7 @@ private static JsonObject BuildRepoMapJsonPayload(RepoMapResult map, QueryComman return payload; } - var keep = new HashSet(StringComparer.Ordinal) - { - "api_version", - "fileCount", - "totalLines", - "totalSymbols", - "totalReferences", - "indexedAt", - "latestModified", - "workspaceIndexedAt", - "workspaceLatestModified", - "projectRoot", - "gitHead", - "gitIsDirty", - "indexed_head_commit", - "worktree_head_changed", - "graphTableAvailable", - }; + var keep = new HashSet(RepoMapSummaryJsonProperties, StringComparer.Ordinal); if (MapSectionEnabled(options, "languages")) keep.Add("languages"); if (MapSectionEnabled(options, "tree")) @@ -4414,8 +4432,7 @@ private static JsonObject BuildRepoMapJsonPayload(RepoMapResult map, QueryComman if (MapSectionEnabled(options, "metrics")) keep.Add("largestFiles"); - foreach (var propertyName in payload.Select(property => property.Key).Where(key => !keep.Contains(key)).ToList()) - payload.Remove(propertyName); + KeepRepoMapJsonProperties(payload, keep); payload["sections"] = new JsonArray(options.MapSections.Select(section => JsonValue.Create(section)).ToArray()); if (options.ContextAfterExplicit) payload["depth"] = options.ContextAfter; @@ -4424,6 +4441,31 @@ private static JsonObject BuildRepoMapJsonPayload(RepoMapResult map, QueryComman return payload; } + private static readonly HashSet RepoMapSummaryJsonProperties = new(StringComparer.Ordinal) + { + "api_version", + "file_count", + "total_lines", + "total_symbols", + "total_references", + "indexed_at", + "latest_modified", + "workspace_indexed_at", + "workspace_latest_modified", + "project_root", + "git_head", + "git_is_dirty", + "indexed_head_commit", + "worktree_head_changed", + "graph_table_available", + }; + + private static void KeepRepoMapJsonProperties(JsonObject payload, IReadOnlySet keep) + { + foreach (var propertyName in payload.Select(property => property.Key).Where(key => !keep.Contains(key)).ToList()) + payload.Remove(propertyName); + } + private static int GetCompactSectionLimit(QueryCommandOptions options) => options.LimitExplicit ? options.Limit : DefaultCompactSectionLimit; @@ -6719,6 +6761,12 @@ public static int RunHotspots(string[] cmdArgs, JsonSerializerOptions jsonOption }; AddHotspotFamilyJsonFields(payload, fileHotspotSignal); AddSqlGraphContractJsonFields(payload, effectiveSqlGraphSignal); + payload["query_context"] = BuildQueryContextJson(options, jsonOptions); + if (options.Compact) + { + payload["compact"] = true; + payload["omitted_sections"] = new JsonArray(); + } Console.WriteLine(payload.ToJsonString(jsonOptions)); } else @@ -6944,6 +6992,12 @@ public static int RunUnused(string[] cmdArgs, JsonSerializerOptions jsonOptions) ["degraded"] = !reader._hasReferencesTable }; AddSqlGraphContractJsonFields(payload, effectiveSqlGraphSignal); + payload["query_context"] = BuildQueryContextJson(options, jsonOptions); + if (options.Compact) + { + payload["compact"] = true; + payload["omitted_sections"] = new JsonArray(); + } Console.WriteLine(payload.ToJsonString(jsonOptions)); } else @@ -6999,7 +7053,7 @@ public static int RunUnused(string[] cmdArgs, JsonSerializerOptions jsonOptions) if (options.Json) { - Console.WriteLine(BuildUnusedJsonPayload(results, graphSupported, graphSupportReason, sqlGraphSignal, reader._hasReferencesTable, jsonOptions, byBucket: byBucket)); + Console.WriteLine(BuildUnusedJsonPayload(results, graphSupported, graphSupportReason, sqlGraphSignal, reader._hasReferencesTable, jsonOptions, options, byBucket: byBucket)); } else { @@ -7116,8 +7170,16 @@ private static string BuildUnusedJsonPayload(IEnumerable res ["returned_bucket_counts"] = JsonSerializer.SerializeToNode(BuildUnusedBucketCounts(resultList), CliJsonSerializerContextFactory.Create(jsonOptions).DictionaryStringInt32), ["summary"] = BuildUnusedSummaryJson(resultList, jsonOptions), ["bucket_taxonomy"] = BuildUnusedBucketTaxonomyJson(), - ["symbols"] = JsonSerializer.SerializeToNode(resultList, CliJsonSerializerContextFactory.Create(jsonOptions).ListUnusedSymbolResult) }; + if (queryOptions?.Compact == true) + { + payload["compact"] = true; + payload["omitted_sections"] = new JsonArray(JsonValue.Create("symbols")); + } + else + { + payload["symbols"] = JsonSerializer.SerializeToNode(resultList, CliJsonSerializerContextFactory.Create(jsonOptions).ListUnusedSymbolResult); + } if (byBucket) payload["by_bucket"] = BuildUnusedResultsByBucketJson(resultList, jsonOptions); @@ -7543,9 +7605,11 @@ public static QueryCommandOptions ParseArgs( bool maxLineWidthExplicit = false; bool strict = false; var rankMode = ReferenceRankMode.Weighted; + var symbolSortMode = SymbolSortMode.Name; var extraNames = new List(); bool impactDeprecatedDepthUsed = false; List? mapSections = null; + bool mapSummaryOnly = false; bool dependencyCycles = false; string? recipeName = null; var includeRecipeQueries = new List(); @@ -8097,6 +8161,18 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(rankByError!); break; + case "--sort": + if (TryReadStringOptionValue(args, ref i, "--sort", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var sortValue, out var sortError)) + { + WarnIfDuplicateSingleValueOption("--sort", sortValue!); + if (TryParseSymbolSortMode(sortValue!, out var parsedSortMode)) + symbolSortMode = parsedSortMode; + else + AddParseError($"Error: --sort must be one of hotspot, references, size, complexity, path; got '{sortValue}'."); + } + else + AddParseError(sortError!); + break; case "--sections": if (TryReadStringOptionValue(args, ref i, "--sections", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var sectionsValue, out var sectionsError)) { @@ -8106,6 +8182,9 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(sectionsError!); break; + case "--summary-only": + mapSummaryOnly = true; + break; case "--fields": if (TryReadStringOptionValue(args, ref i, "--fields", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var fieldsValue, out var fieldsError)) { @@ -8663,8 +8742,10 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) StatusLogPath = statusLogPath, StatusConfig = statusConfig, RankMode = rankMode, + SymbolSortMode = symbolSortMode, ExtraNames = extraNames, MapSections = mapSections, + MapSummaryOnly = mapSummaryOnly, DependencyCycles = dependencyCycles, RecipeName = recipeName, IncludeRecipeQueries = includeRecipeQueries, @@ -9011,6 +9092,36 @@ internal static bool TryParseReferenceRankMode(string value, out ReferenceRankMo } } + internal static bool TryParseSymbolSortMode(string value, out SymbolSortMode sortMode) + { + switch (value.Trim().ToLowerInvariant()) + { + case "name": + sortMode = SymbolSortMode.Name; + return true; + case "hotspot": + sortMode = SymbolSortMode.Hotspot; + return true; + case "references": + case "reference": + case "refs": + sortMode = SymbolSortMode.References; + return true; + case "size": + sortMode = SymbolSortMode.Size; + return true; + case "complexity": + sortMode = SymbolSortMode.Complexity; + return true; + case "path": + sortMode = SymbolSortMode.Path; + return true; + default: + sortMode = SymbolSortMode.Name; + return false; + } + } + private static bool TryParseConfidence(string value, out double confidence) { if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out confidence) && @@ -10245,6 +10356,8 @@ private static JsonObject BuildQueryContextJson(QueryCommandOptions options, Jso query["min_confidence"] = options.MinUnusedConfidence; if (options.RankMode != ReferenceRankMode.Weighted) query["rank_by"] = FormatReferenceRankMode(options.RankMode); + if (options.SymbolSortMode != SymbolSortMode.Name) + query["sort"] = options.SymbolSortMode.ToString().ToLowerInvariant(); if (options.ExcludeTests) query["exclude_tests"] = true; if (options.ExcludeComments) @@ -12083,8 +12196,10 @@ public sealed class QueryCommandOptions public bool StatusLogPath { get; init; } public bool StatusConfig { get; init; } public ReferenceRankMode RankMode { get; init; } = ReferenceRankMode.Weighted; + public SymbolSortMode SymbolSortMode { get; init; } = SymbolSortMode.Name; public List ExtraNames { get; init; } = []; public List? MapSections { get; init; } + public bool MapSummaryOnly { get; init; } public bool DependencyCycles { get; init; } public string? RecipeName { get; init; } public List IncludeRecipeQueries { get; init; } = []; diff --git a/src/CodeIndex/Cli/ReportCommandRunner.cs b/src/CodeIndex/Cli/ReportCommandRunner.cs index 7b8b35a4e5..13c514401a 100644 --- a/src/CodeIndex/Cli/ReportCommandRunner.cs +++ b/src/CodeIndex/Cli/ReportCommandRunner.cs @@ -507,18 +507,7 @@ internal static ReportCommandOptions ParseArgs(string[] args) private static int WriteCommandError(bool json, JsonSerializerOptions jsonOptions, string message, int exitCode, string? hint = null, string? errorCode = null) { - if (json) - Console.WriteLine(JsonSerializer.Serialize( - new CommandErrorJsonResult("error", message, hint, errorCode), - CliJsonSerializerContextFactory.Create(jsonOptions).CommandErrorJsonResult)); - else - { - var prefix = errorCode is null ? "Error" : $"Error [{errorCode}]"; - Console.Error.WriteLine($"{prefix}: {message}"); - if (hint != null) - Console.Error.WriteLine($"Hint: {hint}"); - } - return exitCode; + return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, message, exitCode, hint, errorCode: errorCode); } } diff --git a/src/CodeIndex/Cli/SuggestionsCommandRunner.cs b/src/CodeIndex/Cli/SuggestionsCommandRunner.cs index 3fdc15fbb9..876bb9703e 100644 --- a/src/CodeIndex/Cli/SuggestionsCommandRunner.cs +++ b/src/CodeIndex/Cli/SuggestionsCommandRunner.cs @@ -28,8 +28,8 @@ public static int Run(string[] args, JsonSerializerOptions jsonOptions) var options = Parse(args[1..]); if (options.Error != null) { - Console.Error.WriteLine(options.Error); - Console.Error.WriteLine(Usage); + CommandErrorWriter.WriteStderr(options.Error); + CommandErrorWriter.WriteStderr(Usage); return CommandExitCodes.UsageError; } if (options.OpenIssuesPath != null && (verb != "export" || options.ExportFormat != "issue-drafts")) @@ -72,7 +72,7 @@ private static int RunList(List records, Options options, Json if (records.Count == 0) { - Console.Error.WriteLine("No suggestions found."); + CommandErrorWriter.WriteStderr("No suggestions found."); return CommandExitCodes.NotFound; } @@ -533,8 +533,8 @@ private static string BoundSuggestionOutputValue(string value, int maxLength) private static int WriteUsageError(string message) { - Console.Error.WriteLine($"Error: {message}"); - Console.Error.WriteLine(Usage); + CommandErrorWriter.WriteStderr($"Error: {message}"); + CommandErrorWriter.WriteStderr(Usage); return CommandExitCodes.UsageError; } diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index 2838ca227d..0babfb3396 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -248,10 +248,10 @@ public List GetDistinctKinds() /// Search symbols by name pattern, optionally filtered by kind and language. /// シンボルを名前パターンで検索(種別・言語でフィルタ可能)。 /// - public List SearchSymbols(string? query = null, int limit = 20, string? kind = null, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool exact = false, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) + public List SearchSymbols(string? query = null, int limit = 20, string? kind = null, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool exact = false, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null, SymbolSortMode sortMode = SymbolSortMode.Name) { var normalizedQuery = NormalizeSymbolSearchQueryForSymbolSearch(query, lang, exact); - return SearchSymbols(normalizedQuery == null ? null : new[] { normalizedQuery }, limit, kind, lang, pathPatterns, excludePathPatterns, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters); + return SearchSymbols(normalizedQuery == null ? null : new[] { normalizedQuery }, limit, kind, lang, pathPatterns, excludePathPatterns, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters, sortMode); } public int CountSearchSymbols(string? query = null, int limit = 20, string? kind = null, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool exact = false, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) @@ -566,7 +566,7 @@ FROM symbols s /// 複数名前パターン(OR結合)でシンボルを検索。空/null なら他フィルタに一致する全シンボルを返す。 /// が true の場合、部分一致ではなく大文字小文字を無視した完全一致になる。 /// - public List SearchSymbols(IReadOnlyList? queries, int limit = 20, string? kind = null, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool exact = false, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) + public List SearchSymbols(IReadOnlyList? queries, int limit = 20, string? kind = null, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool exact = false, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null, SymbolSortMode sortMode = SymbolSortMode.Name) { lang = DbReader.NormalizeQueryLanguage(lang); // Multi-name queries: run one search per name to guarantee per-name candidate coverage @@ -580,7 +580,7 @@ public List SearchSymbols(IReadOnlyList? queries, int limi { var perName = new List>(validQueries.Count); foreach (var q in validQueries) - perName.Add(SearchSymbols(new[] { q! }, limit, kind, lang, pathPatterns, excludePathPatterns, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters)); + perName.Add(SearchSymbols(new[] { q! }, limit, kind, lang, pathPatterns, excludePathPatterns, excludeTests, since, exact, visibilityFilters, excludeVisibilityFilters, sortMode)); var seen = new HashSet<(string Path, int Line, string Name, string Kind)>(); var merged = new List(); @@ -608,19 +608,61 @@ public List SearchSymbols(IReadOnlyList? queries, int limi using var cmd = _conn.CreateCommand(); + var startLineSql = GetSymbolColumnSql("start_line", "s.line"); + var endLineSql = GetSymbolColumnSql("end_line", "s.line"); + var bodyStartLineSql = GetSymbolColumnSql("body_start_line"); + var bodyEndLineSql = GetSymbolColumnSql("body_end_line"); + var signatureSql = GetSymbolColumnSql("signature"); + var containerKindSql = GetSymbolColumnSql("container_kind"); + var containerNameSql = GetSymbolColumnSql("container_name"); + var visibilitySql = GetSymbolColumnSql("visibility"); + var returnTypeSql = GetSymbolColumnSql("return_type"); + var startColumnSql = GetSymbolColumnSql("start_column", "CAST(2147483647 AS INTEGER)"); + var sizeLinesSql = $"CASE WHEN ({endLineSql}) >= ({startLineSql}) THEN ({endLineSql}) - ({startLineSql}) + 1 ELSE 1 END"; + var includeRankSignals = sortMode != SymbolSortMode.Name && _hasReferencesTable; + var symbolRankJoin = includeRankSignals + ? $@" + LEFT JOIN ( + SELECT rf.lang AS lang, + sr.symbol_name AS symbol_name, + COUNT(*) AS reference_count, + SUM({GetHotspotReferenceWeightSql("sr.reference_kind")}) AS hotspot_score + FROM symbol_references sr + JOIN files rf ON rf.id = sr.file_id + WHERE sr.reference_kind IN {CallGraphReferenceKindsSql} + AND sr.symbol_name IS NOT NULL + AND sr.symbol_name <> '' + GROUP BY rf.lang, sr.symbol_name + ) symbol_rank + ON symbol_rank.lang = f.lang + AND symbol_rank.symbol_name = s.name COLLATE NOCASE" + : string.Empty; + var referenceCountSql = includeRankSignals ? "COALESCE(symbol_rank.reference_count, 0)" : "CAST(0 AS INTEGER)"; + var hotspotScoreSql = includeRankSignals ? "COALESCE(symbol_rank.hotspot_score, 0.0)" : "CAST(0.0 AS REAL)"; + var complexityScoreSql = $@"(({sizeLinesSql}) + ({referenceCountSql} * 4.0) + ({hotspotScoreSql} * 2.0) + CASE + WHEN {visibilitySql} IN ('public', 'pub', 'open', 'export') THEN 8.0 + WHEN {visibilitySql} IN ('protected', 'internal', 'protected internal') THEN 4.0 + ELSE 0.0 + END)"; + var sql = $@" SELECT f.path, f.lang, s.kind, {GetSymbolColumnSql("sub_kind")} AS sub_kind, s.name, s.line, - {GetSymbolColumnSql("start_line", "s.line")} AS start_line, - {GetSymbolColumnSql("end_line", "s.line")} AS end_line, - {GetSymbolColumnSql("body_start_line")} AS body_start_line, - {GetSymbolColumnSql("body_end_line")} AS body_end_line, - {GetSymbolColumnSql("signature")} AS signature, - {GetSymbolColumnSql("container_kind")} AS container_kind, - {GetSymbolColumnSql("container_name")} AS container_name, - {GetSymbolColumnSql("visibility")} AS visibility, - {GetSymbolColumnSql("return_type")} AS return_type + {startLineSql} AS start_line, + {endLineSql} AS end_line, + {bodyStartLineSql} AS body_start_line, + {bodyEndLineSql} AS body_end_line, + {signatureSql} AS signature, + {containerKindSql} AS container_kind, + {containerNameSql} AS container_name, + {visibilitySql} AS visibility, + {returnTypeSql} AS return_type, + {referenceCountSql} AS reference_count, + {hotspotScoreSql} AS hotspot_score, + {sizeLinesSql} AS size_lines, + {complexityScoreSql} AS complexity_score FROM symbols s JOIN files f ON s.file_id = f.id + {symbolRankJoin} WHERE 1=1"; var effectiveQueries = validQueries; @@ -676,15 +718,15 @@ FROM symbols s sql += " AND f.modified >= @since"; AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); AppendVisibilityFilters(ref sql, visibilityFilters, excludeVisibilityFilters); - sql += $" ORDER BY CASE " + + var exactNameOrderSql = "CASE " + "WHEN @preferLiteralExactMatch = 1 AND s.name = @rawQuery THEN 0 " + "WHEN @preferLiteralNormalizedSqlMatch = 1 AND f.lang = 'sql' AND sql_segment_count(s.name) = @rawQuerySegmentCount AND sql_normalize_name(s.name) = @rawQueryNormalized THEN 1 " + "WHEN @preferCaseInsensitiveExactMatch = 1 AND s.name = @rawQuery COLLATE NOCASE THEN 2 " + "WHEN @preferCaseInsensitiveNormalizedSqlMatch = 1 AND f.lang = 'sql' AND sql_segment_count(s.name) = @rawQuerySegmentCount AND sql_normalize_name_folded(s.name) = @rawQueryNormalizedFolded THEN 3 " + "WHEN @preferCaseInsensitiveSqlLeafMatch = 1 AND f.lang = 'sql' AND sql_leaf_name_folded(s.name) = @rawQueryLeafFolded THEN 4 " + - "ELSE 5 END, " + - $"{PathBucketOrder}, {VisibilityOrder}, s.name, f.path, s.line, " + - $"{GetSymbolColumnSql("start_column", "CAST(2147483647 AS INTEGER)")} ASC, s.id ASC LIMIT @limit"; + "ELSE 5 END"; + sql += BuildSymbolSortOrderBy(sortMode, exactNameOrderSql, referenceCountSql, hotspotScoreSql, sizeLinesSql, complexityScoreSql, startColumnSql); + sql += " LIMIT @limit"; cmd.CommandText = sql; if (effectiveQueries != null) @@ -748,6 +790,8 @@ FROM symbols s AddVisibilityFilterParameters(cmd, visibilityFilters, excludeVisibilityFilters); cmd.Parameters.AddWithValue("@limit", limit); + var includeRankingMetadata = sortMode != SymbolSortMode.Name; + var sortModeName = sortMode.ToString().ToLowerInvariant(); var results = new List(); using var reader = cmd.ExecuteTrackedReader(); while (reader.TrackedRead()) @@ -769,11 +813,37 @@ FROM symbols s ContainerName = GetNullableString(reader, 12), Visibility = GetNullableString(reader, 13), ReturnType = GetNullableString(reader, 14), + SortMode = includeRankingMetadata ? sortModeName : null, + ReferenceCount = includeRankingMetadata ? Convert.ToInt32(reader.GetInt64(15)) : null, + HotspotScore = includeRankingMetadata ? Math.Round(reader.GetDouble(16), 3) : null, + SizeLines = includeRankingMetadata ? Convert.ToInt32(reader.GetInt64(17)) : null, + ComplexityScore = includeRankingMetadata ? Math.Round(reader.GetDouble(18), 3) : null, }); } return results; } + private string BuildSymbolSortOrderBy( + SymbolSortMode sortMode, + string exactNameOrderSql, + string referenceCountSql, + string hotspotScoreSql, + string sizeLinesSql, + string complexityScoreSql, + string startColumnSql) + { + var stableTieBreakers = $"{PathBucketOrder}, {VisibilityOrder}, s.name, f.path, s.line, {startColumnSql} ASC, s.id ASC"; + return sortMode switch + { + SymbolSortMode.Hotspot => $" ORDER BY {hotspotScoreSql} DESC, {referenceCountSql} DESC, {sizeLinesSql} DESC, {stableTieBreakers}", + SymbolSortMode.References => $" ORDER BY {referenceCountSql} DESC, {hotspotScoreSql} DESC, {sizeLinesSql} DESC, {stableTieBreakers}", + SymbolSortMode.Size => $" ORDER BY {sizeLinesSql} DESC, {referenceCountSql} DESC, {hotspotScoreSql} DESC, {stableTieBreakers}", + SymbolSortMode.Complexity => $" ORDER BY {complexityScoreSql} DESC, {hotspotScoreSql} DESC, {referenceCountSql} DESC, {sizeLinesSql} DESC, {stableTieBreakers}", + SymbolSortMode.Path => $" ORDER BY f.path, s.line, {startColumnSql} ASC, s.name, s.id ASC", + _ => $" ORDER BY {exactNameOrderSql}, {stableTieBreakers}", + }; + } + /// /// Resolve symbol definitions with reconstructed excerpts. /// シンボル定義を抜粋付きで解決する。 diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 89ea19a203..131b705c1f 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -151,6 +151,16 @@ public enum ReferenceRankMode Kind, } +public enum SymbolSortMode +{ + Name, + Hotspot, + References, + Size, + Complexity, + Path, +} + public class SymbolResult { [JsonPropertyName("api_version")] @@ -175,6 +185,16 @@ public class SymbolResult public string? ContainerName { get; set; } public string? Visibility { get; set; } public string? ReturnType { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SortMode { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? ReferenceCount { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? HotspotScore { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? SizeLines { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? ComplexityScore { get; set; } } public class UnusedSymbolResult : SymbolResult diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 2d9a2e141b..9e50f7720f 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -150,13 +150,13 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx excerpt --start [--end ] [--before ] [--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--verbose]", output); Assert.Contains("--focus-column find/excerpt: focus a specific 1-based column", output); Assert.Contains("--focus-line find/excerpt: focus a specific line", output); - Assert.Contains("cdidx map [--db ] [--json] [--format ] [--pretty] [--compact] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>]", output); - Assert.Contains("cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]", output); + Assert.Contains("cdidx map [--db ] [--json] [--format ] [--pretty] [--compact] [--summary-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>]", output); + Assert.Contains("cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--sort ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]", output); Assert.Contains("cdidx files [query|--query |-- ] [--db ] [--json[=ndjson|array]] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]", output); Assert.Contains("cdidx validate [--db ] [--json[=array]] [--format ] [--verbose] [--limit |--top ] [--kind ] [--severity ] [--path ]", output); Assert.Contains("Note: if a query itself starts with '-', pass it with --query or -- ", output); Assert.DoesNotContain("cdidx validate [--db ] [--json] [--limit ] [--lang ]", output); - Assert.Contains("cdidx unused [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--bucket ] [--min-confidence ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]", output); + Assert.Contains("cdidx unused [--db ] [--json] [--compact] [--verbose] [--limit |--top ] [--kind ] [--bucket ] [--min-confidence ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--by-bucket]", output); Assert.Contains("cdidx hotspots [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--group-by ] [--group-by-name]", output); Assert.Contains("--json Output as JSON (search/files stream ndjson by", output); Assert.Contains("search/files/validate accept --json=array for one array)", output); @@ -270,10 +270,10 @@ public void PrintUsage_QueryLinesMatchImplementedOptions() var output = CaptureFullUsageOutput(showBanner: false); Assert.Contains("cdidx search |--query |-- |--recipe |--list-recipes|--named-query = [--named-query = ...] [--include-query ] [--exclude-query ] [--cursor ] [--audit-scope ] [--db ] [--json[=ndjson|array]] [--pretty] [--format ] [--open-issues ] [--repo ] [--issue-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>] [--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 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); - Assert.Contains("cdidx unused [--db <path>] [--json] [--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]", output); + Assert.Contains("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]", output); Assert.Contains("cdidx license", output); Assert.Contains("cdidx completions <shell>", output); Assert.Contains("cdidx --completions <shell>", output); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs index 150777be3b..790e32ccb2 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs @@ -24,6 +24,71 @@ public void RunMap_ParseSectionsAndDepth_StoresSelectors() Assert.Null(options.ParseError); } + [Fact] + public void RunMap_ParseSummaryOnly_StoresSelector() + { + var options = QueryCommandRunner.ParseArgs( + ["--summary-only"], + jsonDefault: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); + + Assert.True(options.MapSummaryOnly); + Assert.Null(options.ParseError); + } + + [Fact] + public void RunMap_SummaryOnlyAndSections_ReturnsUsageError() + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunMap( + ["--summary-only", "--sections", "tree"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("--summary-only cannot be combined with --sections", stderr); + Assert.Contains("Usage: cdidx map", stderr); + } + + [Fact] + public void RunMap_SummaryOnlyJson_OmitsDetailSections_Issue3393() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_map_summary_only"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/App.cs", + "csharp", + "namespace App; public class Program { public static void Main() { } }\n"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunMap( + ["--db", dbPath, "--summary-only", "--json"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.True(json.GetProperty("summary_only").GetBoolean()); + Assert.Equal(1, json.GetProperty("file_count").GetInt32()); + Assert.Empty(json.GetProperty("sections").EnumerateArray()); + Assert.False(json.TryGetProperty("languages", out _)); + Assert.False(json.TryGetProperty("modules", out _)); + Assert.False(json.TryGetProperty("top_files", out _)); + Assert.False(json.TryGetProperty("largest_files", out _)); + Assert.False(json.TryGetProperty("symbol_rich_files", out _)); + Assert.False(json.TryGetProperty("reference_rich_files", out _)); + Assert.False(json.TryGetProperty("entrypoints", out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunMap_ParseCompact_ImpliesJsonAndPreservesExplicitLimit_Issue3009() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs index 53ce5fdc06..9909e52852 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs @@ -183,6 +183,159 @@ import package.submodule as alias } } + [Theory] + [InlineData("hotspot")] + [InlineData("references")] + public void RunSymbols_SortByReferenceSignalsAddsRankingMetadata_Issue3451(string sortMode) + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_symbols_sort_reference_signals"); + try + { + var dbPath = CreateSymbolSortFixtureDb(projectRoot); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSymbols( + ["--db", dbPath, "--json", "--kind", "function", "--lang", "csharp", "--path", "src/Beta.cs", "--sort", sortMode, "--limit", "4"], + _jsonOptions)); + + Assert.True(exitCode == CommandExitCodes.Success, $"exit={exitCode}; stderr={stderr}; stdout={stdout}"); + Assert.Equal(string.Empty, stderr); + var rows = ParseJsonLines(stdout).Select(document => document.RootElement).ToList(); + Assert.Equal("ShortHot", rows[0].GetProperty("name").GetString()); + Assert.Equal(sortMode, rows[0].GetProperty("sort_mode").GetString()); + Assert.True(rows[0].GetProperty("reference_count").GetInt32() >= 2); + Assert.True(rows[0].GetProperty("hotspot_score").GetDouble() > 0); + Assert.True(rows[0].GetProperty("size_lines").GetInt32() > 0); + Assert.True(rows[0].GetProperty("complexity_score").GetDouble() > 0); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSymbols_SortBySizeAndPathOrdersAuditRows_Issue3451() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_symbols_sort_size_path"); + try + { + var dbPath = CreateSymbolSortFixtureDb(projectRoot); + + var (sizeExitCode, sizeStdout, sizeStderr) = CaptureConsole(() => QueryCommandRunner.RunSymbols( + ["--db", dbPath, "--json", "--kind", "function", "--lang", "csharp", "--path", "src/Beta.cs", "--sort", "size", "--limit", "1"], + _jsonOptions)); + Assert.True(sizeExitCode == CommandExitCodes.Success, $"exit={sizeExitCode}; stderr={sizeStderr}; stdout={sizeStdout}"); + Assert.Equal(string.Empty, sizeStderr); + var sizeRow = Assert.Single(ParseJsonLines(sizeStdout)).RootElement; + + var (pathExitCode, pathStdout, pathStderr) = CaptureConsole(() => QueryCommandRunner.RunSymbols( + ["--db", dbPath, "--json", "--kind", "function", "--lang", "csharp", "--sort", "path", "--limit", "1"], + _jsonOptions)); + Assert.True(pathExitCode == CommandExitCodes.Success, $"exit={pathExitCode}; stderr={pathStderr}; stdout={pathStdout}"); + Assert.Equal(string.Empty, pathStderr); + var pathRow = Assert.Single(ParseJsonLines(pathStdout)).RootElement; + + Assert.Equal("LongLow", sizeRow.GetProperty("name").GetString()); + Assert.Equal("size", sizeRow.GetProperty("sort_mode").GetString()); + Assert.True(sizeRow.GetProperty("size_lines").GetInt32() > 5); + + Assert.Equal(CommandExitCodes.Success, pathExitCode); + Assert.Equal(string.Empty, pathStderr); + Assert.Equal("src/Alpha.cs", pathRow.GetProperty("path").GetString()); + Assert.Equal("path", pathRow.GetProperty("sort_mode").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSymbols_SortByComplexityAddsMetadata_Issue3451() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_symbols_sort_complexity"); + try + { + var dbPath = CreateSymbolSortFixtureDb(projectRoot); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSymbols( + ["--db", dbPath, "--json", "--kind", "function", "--lang", "csharp", "--sort", "complexity", "--limit", "1"], + _jsonOptions)); + Assert.True(exitCode == CommandExitCodes.Success, $"exit={exitCode}; stderr={stderr}; stdout={stdout}"); + Assert.Equal(string.Empty, stderr); + var row = Assert.Single(ParseJsonLines(stdout)).RootElement; + Assert.Equal("complexity", row.GetProperty("sort_mode").GetString()); + Assert.True(row.GetProperty("complexity_score").GetDouble() > 0); + Assert.True(row.GetProperty("reference_count").GetInt32() >= 0); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSymbols_InvalidSortReturnsUsageError_Issue3451() + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSymbols( + ["--sort", "missing"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("--sort must be one of hotspot, references, size, complexity, path", stderr); + Assert.Contains("Usage: cdidx symbols", stderr); + } + + private static string CreateSymbolSortFixtureDb(string projectRoot) + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Alpha.cs", + "csharp", + """ + public class Alpha + { + public void AFirst() { } + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Beta.cs", + "csharp", + """ + public class Beta + { + public void ShortHot() { } + + public void MediumRef() { } + + public void LongLow() + { + var total = 0; + total += 1; + total += 2; + total += 3; + total += 4; + total += 5; + } + + public void Caller() + { + ShortHot(); + ShortHot(); + MediumRef(); + } + } + """); + using var db = new DbContext(dbPath); + db.InitializeSchema(); + var writer = new DbWriter(db.Connection); + writer.MarkGraphReady(); + return dbPath; + } + [Fact] public void RunSymbols_ExactNameFindsPythonFromImportQualifiedNames() { @@ -867,6 +1020,38 @@ public void RunUnused_WithJsonByBucketGroupsReturnedSymbolsByTaxonomyBucket() } } + [Fact] + public void RunUnused_CompactJsonOmitsSymbolBodiesAndShowsFilters_Issue3395() + { + var (projectRoot, dbPath) = CreateUnusedFixtureDb(); + try + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( + ["--db", dbPath, "--compact", "--lang", "csharp", "--bucket", "likely_unused_private", "--min-confidence", "medium"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + var query = json.GetProperty("query_context"); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.True(json.GetProperty("compact").GetBoolean()); + Assert.False(json.TryGetProperty("symbols", out _)); + Assert.Contains(json.GetProperty("omitted_sections").EnumerateArray(), section => section.GetString() == "symbols"); + Assert.Equal(1, json.GetProperty("returned_bucket_counts").GetProperty("likely_unused_private").GetInt32()); + Assert.Equal(1, json.GetProperty("summary").GetProperty("by_confidence").GetProperty("medium").GetInt32()); + Assert.Equal("medium", json.GetProperty("bucket_taxonomy").GetProperty("likely_unused_private").GetProperty("confidence").GetString()); + Assert.Equal("csharp", query.GetProperty("lang").GetString()); + Assert.Equal("likely_unused_private", query.GetProperty("bucket").GetString()); + Assert.Equal("medium", query.GetProperty("min_confidence").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunUnused_WithJsonBucketFilterReturnsOnlyRequestedBucket() { @@ -928,16 +1113,19 @@ public void RunUnused_CountJsonWithBucketFilterCountsFilteredSymbols() try { var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( - ["--db", dbPath, "--json", "--lang", "csharp", "--bucket", "public_or_exported_no_refs", "--count"], + ["--db", dbPath, "--json", "--lang", "csharp", "--bucket", "public_or_exported_no_refs", "--min-confidence", "low", "--count"], _jsonOptions)); using var document = ParseJsonOutput(stdout); var json = document.RootElement; + var query = json.GetProperty("query_context"); Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Equal(string.Empty, stderr); Assert.Equal(6, json.GetProperty("count").GetInt32()); Assert.Equal(1, json.GetProperty("files").GetInt32()); + Assert.Equal("public_or_exported_no_refs", query.GetProperty("bucket").GetString()); + Assert.Equal("low", query.GetProperty("min_confidence").GetString()); } finally {