diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 7ab0a97ce..a03e8d882 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -177,6 +177,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding End-to-end upgrade path: seeds a pre-column legacy DB, opens it through `TryMigrateForRead`, and exercises the read paths that touch nullable symbol ordinals (outline, symbol search, nearby, unused, analyze bundle) to lock in the real-world failure mode behind #58 / #49. Migration ownership coverage must also distinguish caller-owned transactions from owned transactions, propagate unrelated `BEGIN` errors, preserve populated foreign-key rows across rebuilds, and verify rollback, foreign-key restoration, and successful retry after injected failures. - `IndexCommandRunner*Tests.cs`, `QueryCommandRunner*Tests.cs`, `ProgramCliTests.cs`, `InstallScriptTests.cs` CLI parsing, command execution, and installer behavior. Index command coverage is split by run mode or feature area, and query command coverage is split by command family with partial test classes so shared console and fixture helpers stay centralized. Keep repeated query-result fixtures, such as overlapping chunk content used by multiple search deduplication tests, in narrow class-level helpers instead of duplicating local builders. `ProgramCliTests.cs` covers top-level entrypoint behavior that must be exercised through a subprocess, while `InstallScriptTests.cs` runs focused bash snippets against `install.sh` in library mode to lock in release-installer regressions without performing real network installs. Installer bundle-generation tests must also verify that `install.sh` is marked generated while every canonical `install_modules/` source remains unmarked. + C# `unused` partial-family coverage must reuse one multi-file fixture across regular JSON, compact, `--by-bucket`, and `--actionable`; include top-level and nested partial types, genuinely unused private members, an unrelated same-named family, a containing-type generic-arity collision, and a family-external occurrence in a matched peer file so semantic membership cannot regress into name-only, flattened-qualified-name, or whole-file evidence sharing. `ConsoleUiTests.cs` keeps each generated shell's complete long-flag catalog aligned with `CliFlagSchema` and checks every Fish command scope against the shared per-command completion projection. Add flags to the schema rather than weakening this Bash / Zsh / Fish / PowerShell parity contract. `ProgramRunnerTests.cs` enumerates `CliCommandCatalog.CommandSubcommands` and requires every valid nested verb to resolve to verb-specific usage with an example. It also pins destructive index confirmation and aggregate dependency-filter help, plus the read-only GitHub duplicate-preflight boundary for suggestion exports. Installer cancellation coverage must wait until the PID file contains a complete positive integer before cancelling, using a bounded dedicated waiter instead of a fixed-delay timer so parallel load cannot expose a redirection-created empty file. Global-tool-log home-shorthand coverage resolves the normalized first candidate through the test-only no-write-probe seam, so sandbox permissions cannot turn an expansion assertion into a writability-fallback assertion. @@ -1070,6 +1071,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" エンドツーエンドのアップグレード経路: カラム追加前のレガシー DB を用意し、`TryMigrateForRead` 経由で開いてから NULL になりうるシンボル列を触る read path(outline、シンボル検索、近傍、unused、analyze バンドル)を一通り叩き、#58 / #49 の実機失敗モードを固定する。migration ownership の coverage では、caller-owned transaction と cdidx-owned transaction を区別し、無関係な `BEGIN` error を伝播させ、foreign key が設定された既存 row を rebuild 後も保持し、注入 failure 後の rollback、foreign key 復元、retry 成功まで検証する。 - `IndexCommandRunner*Tests.cs`、`QueryCommandRunner*Tests.cs`、`ProgramCliTests.cs`、`InstallScriptTests.cs` CLI の引数解析、コマンド実行、installer 挙動のテスト。Index command coverage は run mode または機能領域ごとの partial suite に分割し、Query command coverage は command family ごとの partial test class に分割して、共有 console / fixture helper は一箇所に保ちます。`ProgramCliTests.cs` はグローバル引数の解釈や完全な CLI 起動フローのように subprocess 経由で確認すべき Program エントリポイント挙動を扱い、`InstallScriptTests.cs` は `install.sh` を library mode で source した bash snippet を実行して、実ネットワーク install を行わずに release installer の回帰を固定する。installer bundle 生成テストでは、`install.sh` が generated と判定される一方、canonical な `install_modules/` source はすべて unmarked のままであることも検証してください。 + C# `unused` の partial-family coverage では、通常の JSON、compact、`--by-bucket`、`--actionable` で1つの multi-file fixture を共有してください。top-level / nested partial type、本当に未使用の private member、無関係な同名 family、containing type の generic-arity collision、matched peer file 内の family 外 occurrence を含め、semantic membership が name-only、平坦化された qualified name、または file 全体の evidence 共有へ戻らないことを固定します。 `ConsoleUiTests.cs` は各生成シェルの long flag 全カタログを `CliFlagSchema` と同期させ、Fish の全 command scope を共有の command 別 completion 射影と照合します。この Bash / Zsh / Fish / PowerShell parity 契約を弱めず、新しい flag は schema に追加してください。 `ProgramRunnerTests.cs` は `CliCommandCatalog.CommandSubcommands` を列挙し、すべての有効な nested verb が例を含む verb 固有 usage に解決されることを必須とします。さらに destructive な index confirmation、aggregate dependency filter help、suggestion export の read-only GitHub duplicate-preflight 境界を固定します。installer cancellation coverage は固定時間タイマーではなく上限付きの専用 waiter を使い、PID ファイルが完全な正の整数を含むまで待ってから cancel してください。これにより並列負荷下でも redirection によって作成された空ファイルを読みません。 global tool log の home shorthand coverage は、test 専用の write probe なし seam から正規化済みの先頭 candidate を解決します。これにより sandbox permission の影響で、展開の assertion が writability fallback の assertion に変わることを防ぎます。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index a11536d6b..6e21889c7 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -657,6 +657,13 @@ When `unused` returns `next_cursor`, pass that opaque value back unchanged. The cursor is bound to the effective audit scope, filters, ordering, and index generation. Changing those inputs or refreshing the index requires restarting without `--cursor`; JSON pages also expose `result_stable_at`. +For C# private members declared in partial types, `unused` aggregates use +evidence across sibling files by fully qualified logical type name, including +nested partial types. Same-named types in other namespaces or containing types +do not share evidence; containing-type kind and generic arity also remain part +of the logical identity. Genuinely unreferenced members in the family remain +actionable candidates. Regular JSON, compact, `--by-bucket`, and +`--actionable` all use the same family-aware classification. Public APIs, framework entrypoints, DTOs, serialization contracts, generated hooks, test-only hooks, Markdown headings and fenced-code language markers, reflection, and configuration-based usage can be false positives and are @@ -3745,6 +3752,12 @@ filter context だけが必要な場合は `--compact` を使ってください `unused` が `next_cursor` を返した場合は、その opaque 値を変更せず次の呼び出しへ渡してください。 cursor は有効な audit scope、filter、ordering、index generation に束縛されます。条件を変更した場合や index を更新した場合は `--cursor` なしで再開する必要があり、JSON page は `result_stable_at` も返します。 +C# の partial type で宣言された private member について、`unused` は nested partial type を含む +完全修飾された logical type 名を使い、sibling file 全体の使用 evidence を集約します。 +別 namespace または別 containing type にある同名 type とは evidence を共有せず、 +containing type の kind と generic arity も logical identity の一部として維持します。family 内で +本当に参照されていない member は actionable candidate のまま残ります。通常の JSON、compact、 +`--by-bucket`、`--actionable` はすべて同じ family-aware classification を使います。 Public API、framework entrypoint、DTO、serialization contract、generated hook、test-only hook、Markdown heading と fenced-code の language marker、reflection、config 経由の使用は false positive になりうるため、 低 confidence bucket に寄せられます。 diff --git a/changelog.d/unreleased/4834.fixed.md b/changelog.d/unreleased/4834.fixed.md new file mode 100644 index 000000000..0e63b5410 --- /dev/null +++ b/changelog.d/unreleased/4834.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 4834 +affected: + - src/CodeIndex/Database/DbSymbolReader.UnusedSymbols.cs + - src/CodeIndex/Database/DbContext.ConnectionFunctions.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs + - USER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Restored cross-file C# partial-family usage detection in `unused` (#4834)** — the #3673 fallback now matches sibling declarations by their fully qualified logical type name and semantic containing-type shape, including nested partial types and generic arity, and limits use evidence to the matched peer declaration's source range. Private fields used from another partial file are no longer reported as actionable, while unrelated same-named types, family-external occurrences, and genuinely unused fields remain separate. + +## 日本語 + +- **`unused` の C# partial family に対する cross-file 使用検出を復旧しました (#4834)** — #3673 で追加した fallback が nested partial type と generic arity を含む sibling declaration を完全修飾 logical type 名および semantic な containing-type shape で照合し、使用 evidence を matched peer declaration の source range 内に限定するようになりました。別 partial file から使われる private field を actionable として誤報せず、無関係な同名 type、family 外 occurrence、本当に未使用の field は分離したまま扱います。 diff --git a/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs b/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs index 0aeb6aaa7..1e4221bdf 100644 --- a/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs +++ b/src/CodeIndex/Database/DbContext.ConnectionFunctions.cs @@ -71,6 +71,15 @@ internal static void RegisterConnectionFunctions(SqliteConnection connection) connection.CreateFunction( "csharp_identifier_occurrence_count", (string? text, string? identifier) => CountCSharpIdentifierOccurrences(text, identifier)); + connection.CreateFunction( + "csharp_identifier_occurrence_count_in_line_range", + (string? text, long? chunkStartLine, long? rangeStartLine, long? rangeEndLine, string? identifier) => + CountCSharpIdentifierOccurrencesInLineRange( + text, + chunkStartLine, + rangeStartLine, + rangeEndLine, + identifier)); connection.CreateFunction( "csharp_reference_type_arity", (string? context, string? identifier, long? columnNumber) => @@ -207,6 +216,64 @@ internal static int CountCSharpIdentifierOccurrences(string? text, string? ident return count; } + internal static int CountCSharpIdentifierOccurrencesInLineRange( + string? text, + long? chunkStartLine, + long? rangeStartLine, + long? rangeEndLine, + string? identifier) + { + if (string.IsNullOrEmpty(text) + || string.IsNullOrEmpty(identifier) + || chunkStartLine is null + || rangeStartLine is null + || rangeEndLine is null + || chunkStartLine <= 0 + || rangeStartLine <= 0 + || rangeEndLine < rangeStartLine) + { + return 0; + } + + var relativeStartLine = Math.Max(0, rangeStartLine.Value - chunkStartLine.Value); + var relativeEndLineExclusive = rangeEndLine.Value - chunkStartLine.Value + 1; + if (relativeEndLineExclusive <= 0 + || relativeStartLine > int.MaxValue + || relativeEndLineExclusive > int.MaxValue) + { + return 0; + } + + var startOffset = FindTextLineStartOffset(text, (int)relativeStartLine); + var endOffset = FindTextLineStartOffset(text, (int)relativeEndLineExclusive); + if (startOffset >= endOffset) + return 0; + + var scopedText = startOffset == 0 && endOffset == text.Length + ? text + : text.Substring(startOffset, endOffset - startOffset); + return CountCSharpIdentifierOccurrences(scopedText, identifier); + } + + private static int FindTextLineStartOffset(string text, int zeroBasedLine) + { + if (zeroBasedLine <= 0) + return 0; + + var line = 0; + for (var i = 0; i < text.Length; i++) + { + if (text[i] != '\n') + continue; + + line++; + if (line == zeroBasedLine) + return i + 1; + } + + return text.Length; + } + private static bool CSharpBaseReferenceMatches( string? baseReference, string? candidateName, diff --git a/src/CodeIndex/Database/DbSymbolReader.UnusedSymbols.cs b/src/CodeIndex/Database/DbSymbolReader.UnusedSymbols.cs index 0188b5548..41bd9d84f 100644 --- a/src/CodeIndex/Database/DbSymbolReader.UnusedSymbols.cs +++ b/src/CodeIndex/Database/DbSymbolReader.UnusedSymbols.cs @@ -35,18 +35,14 @@ private string BuildCSharpPartialContainingTypeUseExclusionSql(string symbolAlia var containerKindSql = GetSymbolColumnSql("container_kind", "''", symbolAlias); var containerNameSql = GetSymbolColumnSql("container_name", "''", symbolAlias); var containerQualifiedNameSql = GetSymbolColumnSql("container_qualified_name", containerNameSql, symbolAlias); - var ownContainerNameSql = GetSymbolColumnSql("container_name", "''", "partial_own_type"); var ownSignatureSql = GetSymbolColumnSql("signature", "''", "partial_own_type"); - var peerContainerNameSql = GetSymbolColumnSql("container_name", "''", "partial_peer_type"); var peerSignatureSql = GetSymbolColumnSql("signature", "''", "partial_peer_type"); - var ownQualifiedNameSql = $@"CASE - WHEN {ownContainerNameSql} <> '' THEN {ownContainerNameSql} || '.' || partial_own_type.name - ELSE partial_own_type.name - END"; - var peerQualifiedNameSql = $@"CASE - WHEN {peerContainerNameSql} <> '' THEN {peerContainerNameSql} || '.' || partial_peer_type.name - ELSE partial_peer_type.name - END"; + var ownQualifiedNameSql = BuildCSharpPartialTypeQualifiedNameSql("partial_own_type"); + var peerQualifiedNameSql = BuildCSharpPartialTypeQualifiedNameSql("partial_peer_type"); + var ownTypeShapeSql = BuildCSharpPartialTypeShapeSql("partial_own_type", "partial_own_ancestor"); + var peerTypeShapeSql = BuildCSharpPartialTypeShapeSql("partial_peer_type", "partial_peer_ancestor"); + var peerTypeStartLineSql = GetSymbolColumnSql("start_line", "partial_peer_type.line", "partial_peer_type"); + var peerTypeEndLineSql = GetSymbolColumnSql("end_line", peerTypeStartLineSql, "partial_peer_type"); return $@" AND NOT ( @@ -58,37 +54,78 @@ AND NOT ( AND EXISTS ( SELECT 1 FROM symbols partial_own_type + JOIN symbols partial_peer_type + ON partial_peer_type.file_id <> partial_own_type.file_id + AND partial_peer_type.kind = partial_own_type.kind + AND partial_peer_type.name = partial_own_type.name + JOIN files partial_peer_file ON partial_peer_file.id = partial_peer_type.file_id + JOIN chunks partial_peer_chunk ON partial_peer_chunk.file_id = partial_peer_type.file_id WHERE partial_own_type.file_id = {symbolAlias}.file_id AND partial_own_type.kind = {containerKindSql} AND partial_own_type.name = {containerNameSql} AND lower({ownSignatureSql}) LIKE '%partial%' + AND lower({peerSignatureSql}) LIKE '%partial%' + AND partial_peer_file.lang = 'csharp' AND ( {containerQualifiedNameSql} = '' OR {containerQualifiedNameSql} = partial_own_type.name OR {containerQualifiedNameSql} = {ownQualifiedNameSql} ) - ) - AND EXISTS ( - SELECT 1 - FROM symbols partial_peer_type - JOIN files partial_peer_file ON partial_peer_file.id = partial_peer_type.file_id - JOIN chunks partial_peer_chunk ON partial_peer_chunk.file_id = partial_peer_type.file_id - WHERE partial_peer_file.lang = 'csharp' - AND partial_peer_type.file_id <> {symbolAlias}.file_id - AND partial_peer_type.kind = {containerKindSql} - AND partial_peer_type.name = {containerNameSql} - AND lower({peerSignatureSql}) LIKE '%partial%' AND ( {containerQualifiedNameSql} = '' OR {containerQualifiedNameSql} = partial_peer_type.name OR {containerQualifiedNameSql} = {peerQualifiedNameSql} ) - AND csharp_identifier_occurrence_count(partial_peer_chunk.content, {symbolAlias}.name) > 0 + AND {ownTypeShapeSql} = {peerTypeShapeSql} + AND partial_peer_chunk.end_line >= {peerTypeStartLineSql} + AND partial_peer_chunk.start_line <= {peerTypeEndLineSql} + AND csharp_identifier_occurrence_count_in_line_range( + partial_peer_chunk.content, + partial_peer_chunk.start_line, + {peerTypeStartLineSql}, + {peerTypeEndLineSql}, + {symbolAlias}.name) > 0 LIMIT 1 ) )"; } + private string BuildCSharpPartialTypeQualifiedNameSql(string typeAlias) + { + var containerNameSql = GetSymbolColumnSql("container_name", "''", typeAlias); + var containerQualifiedNameSql = GetSymbolColumnSql("container_qualified_name", containerNameSql, typeAlias); + return $@"CASE + WHEN {containerQualifiedNameSql} <> '' THEN {containerQualifiedNameSql} || '.' || {typeAlias}.name + ELSE {typeAlias}.name + END"; + } + + private string BuildCSharpPartialTypeShapeSql(string typeAlias, string ancestorAlias) + { + var typeStartLineSql = GetSymbolColumnSql("start_line", $"{typeAlias}.line", typeAlias); + var typeEndLineSql = GetSymbolColumnSql("end_line", typeStartLineSql, typeAlias); + var ancestorStartLineSql = GetSymbolColumnSql("start_line", $"{ancestorAlias}.line", ancestorAlias); + var ancestorEndLineSql = GetSymbolColumnSql("end_line", ancestorStartLineSql, ancestorAlias); + var ancestorSignatureSql = GetSymbolColumnSql("signature", "''", ancestorAlias); + return $@"COALESCE(( + SELECT GROUP_CONCAT( + {ancestorAlias}.kind || ':' || {ancestorAlias}.name || '`' || + COALESCE(csharp_definition_type_arity( + {ancestorSignatureSql}, + {ancestorAlias}.name, + {ancestorAlias}.kind), -1), + '/' ORDER BY + {ancestorStartLineSql}, + {ancestorEndLineSql} DESC, + {ancestorAlias}.id) + FROM symbols {ancestorAlias} + WHERE {ancestorAlias}.file_id = {typeAlias}.file_id + AND {ancestorAlias}.kind IN ('class', 'struct', 'interface', 'record') + AND {ancestorStartLineSql} <= {typeStartLineSql} + AND {ancestorEndLineSql} >= {typeEndLineSql} + ), '')"; + } + private const string UnusedBucketLikelyPrivate = "likely_unused_private"; private const string UnusedBucketMaybeNonPublic = "maybe_unused_nonpublic"; private const string UnusedBucketPublicOrExported = "public_or_exported_no_refs"; @@ -702,18 +739,14 @@ private bool HasCSharpPartialContainingTypeUse(UnusedCandidateSymbol candidate) return false; } - var ownContainerNameSql = GetSymbolColumnSql("container_name", "''", "own_type"); var ownSignatureSql = GetSymbolColumnSql("signature", "''", "own_type"); - var peerContainerNameSql = GetSymbolColumnSql("container_name", "''", "peer_type"); var peerSignatureSql = GetSymbolColumnSql("signature", "''", "peer_type"); - var ownQualifiedNameSql = $@"CASE - WHEN {ownContainerNameSql} <> '' THEN {ownContainerNameSql} || '.' || own_type.name - ELSE own_type.name - END"; - var peerQualifiedNameSql = $@"CASE - WHEN {peerContainerNameSql} <> '' THEN {peerContainerNameSql} || '.' || peer_type.name - ELSE peer_type.name - END"; + var ownQualifiedNameSql = BuildCSharpPartialTypeQualifiedNameSql("own_type"); + var peerQualifiedNameSql = BuildCSharpPartialTypeQualifiedNameSql("peer_type"); + var ownTypeShapeSql = BuildCSharpPartialTypeShapeSql("own_type", "own_ancestor"); + var peerTypeShapeSql = BuildCSharpPartialTypeShapeSql("peer_type", "peer_ancestor"); + var peerTypeStartLineSql = GetSymbolColumnSql("start_line", "peer_type.line", "peer_type"); + var peerTypeEndLineSql = GetSymbolColumnSql("end_line", peerTypeStartLineSql, "peer_type"); using var cmd = _conn.CreateCommand(); cmd.CommandText = $@" @@ -741,7 +774,15 @@ AND lower({peerSignatureSql}) LIKE '%partial%' OR @containerQualifiedName = peer_type.name OR @containerQualifiedName = {peerQualifiedNameSql} ) - AND csharp_identifier_occurrence_count(peer_chunk.content, @symbolName) > 0 + AND {ownTypeShapeSql} = {peerTypeShapeSql} + AND peer_chunk.end_line >= {peerTypeStartLineSql} + AND peer_chunk.start_line <= {peerTypeEndLineSql} + AND csharp_identifier_occurrence_count_in_line_range( + peer_chunk.content, + peer_chunk.start_line, + {peerTypeStartLineSql}, + {peerTypeEndLineSql}, + @symbolName) > 0 LIMIT 1"; SqliteCommandPolicy.Add(cmd, "@fileId", candidate.FileId); SqliteCommandPolicy.Add(cmd, "@containerKind", candidate.ContainerKind); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs index 0fd3edc07..bb35a4698 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs @@ -2330,6 +2330,226 @@ WHERE symbol_name IN ('HandleToolsList', 'ExecuteBatchQuery') } } + [Fact] + public void RunUnused_CSharpPartialFamiliesAggregateNestedFieldUseAcrossOutputModes_Issue4834() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_unused_csharp_partial_fields_4834"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Primary/Writer.Fields.cs", + "csharp", + """ + namespace Demo.Primary; + + public partial class Writer + { + private bool? _hasIssueMetadataColumns; + + private sealed partial class Parser + { + private bool endLineExplicit; + private bool outputFormatExplicit; + private bool ActuallyUnused; + private bool OnlyUsedByOtherFamily; + private bool OnlyUsedByOtherGenericArity; + private bool OnlyUsedOutsidePartialRange; + + [System.Text.Json.Serialization.JsonPropertyName("connectionString")] + public string ConnectionString { get; set; } = ""; + } + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Primary/Writer.Usage.cs", + "csharp", + """ + namespace Demo.Primary; + + public partial class Writer + { + public bool HasIssueMetadataColumns() => _hasIssueMetadataColumns == true; + + private sealed partial class Parser + { + public bool HasExplicitOptions() => endLineExplicit || outputFormatExplicit; + } + } + + public sealed class Noise + { + private bool OnlyUsedOutsidePartialRange; + + public bool ReadOutsidePartialRange() => OnlyUsedOutsidePartialRange; + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Unrelated/Writer.cs", + "csharp", + """ + namespace Demo.Unrelated + { + public partial class Writer + { + private sealed partial class Parser + { + private bool OnlyUsedByOtherFamily; + + public bool HasOtherFamilyOption() => OnlyUsedByOtherFamily; + } + } + } + + namespace Demo.Primary + { + public partial class Writer + { + private sealed partial class Parser + { + private bool OnlyUsedByOtherGenericArity; + + public bool HasOtherGenericArityOption() => OnlyUsedByOtherGenericArity; + } + } + } + """); + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + using var cmd = db.Connection.CreateCommand(); + cmd.CommandText = """ + DELETE FROM symbol_references + WHERE symbol_name IN ( + '_hasIssueMetadataColumns', + 'endLineExplicit', + 'outputFormatExplicit', + 'ActuallyUnused', + 'OnlyUsedByOtherFamily', + 'OnlyUsedByOtherGenericArity', + 'OnlyUsedOutsidePartialRange', + 'ConnectionString' + ) + """; + cmd.ExecuteNonQuery(); + + var writer = new DbWriter(db.Connection); + writer.MarkGraphReady(); + } + + string[] commonArgs = + [ + "--db", dbPath, + "--lang", "csharp", + "--path", "src/Primary/**", + "--limit", "50", + ]; + + var (jsonExitCode, jsonStdout, jsonStderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( + [.. commonArgs, "--json", "--all"], + _jsonOptions)); + using var jsonDocument = ParseJsonOutput(jsonStdout); + var jsonSymbols = jsonDocument.RootElement.GetProperty("symbols").EnumerateArray().ToArray(); + var jsonNames = jsonSymbols + .Select(symbol => symbol.GetProperty("name").GetString()) + .ToHashSet(StringComparer.Ordinal); + + Assert.Equal(CommandExitCodes.Success, jsonExitCode); + Assert.Equal(string.Empty, jsonStderr); + Assert.DoesNotContain("_hasIssueMetadataColumns", jsonNames); + Assert.DoesNotContain("endLineExplicit", jsonNames); + Assert.DoesNotContain("outputFormatExplicit", jsonNames); + Assert.Equal( + "likely_unused_private", + jsonSymbols.Single(symbol => symbol.GetProperty("name").GetString() == "ActuallyUnused") + .GetProperty("unused_bucket").GetString()); + Assert.Equal( + "likely_unused_private", + jsonSymbols.Single(symbol => symbol.GetProperty("name").GetString() == "OnlyUsedByOtherFamily") + .GetProperty("unused_bucket").GetString()); + Assert.Equal( + "likely_unused_private", + jsonSymbols.Single(symbol => symbol.GetProperty("name").GetString() == "OnlyUsedByOtherGenericArity") + .GetProperty("unused_bucket").GetString()); + Assert.Equal( + "likely_unused_private", + jsonSymbols.Single(symbol => symbol.GetProperty("name").GetString() == "OnlyUsedOutsidePartialRange") + .GetProperty("unused_bucket").GetString()); + Assert.Equal( + "reflection_or_config_suspect", + jsonSymbols.Single(symbol => symbol.GetProperty("name").GetString() == "ConnectionString") + .GetProperty("unused_bucket").GetString()); + + var (compactExitCode, compactStdout, compactStderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( + [.. commonArgs, "--compact", "--all"], + _jsonOptions)); + using var compactDocument = ParseJsonOutput(compactStdout); + + Assert.Equal(CommandExitCodes.Success, compactExitCode); + Assert.Equal(string.Empty, compactStderr); + Assert.True(compactDocument.RootElement.GetProperty("compact").GetBoolean()); + Assert.Equal( + 4, + compactDocument.RootElement.GetProperty("returned_bucket_counts") + .GetProperty("likely_unused_private").GetInt32()); + Assert.DoesNotContain("_hasIssueMetadataColumns", compactStdout, StringComparison.Ordinal); + Assert.DoesNotContain("endLineExplicit", compactStdout, StringComparison.Ordinal); + Assert.DoesNotContain("outputFormatExplicit", compactStdout, StringComparison.Ordinal); + Assert.Contains("ActuallyUnused", compactStdout, StringComparison.Ordinal); + Assert.Contains("OnlyUsedByOtherFamily", compactStdout, StringComparison.Ordinal); + Assert.Contains("OnlyUsedByOtherGenericArity", compactStdout, StringComparison.Ordinal); + Assert.Contains("ConnectionString", compactStdout, StringComparison.Ordinal); + + var (bucketExitCode, bucketStdout, bucketStderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( + [.. commonArgs, "--json", "--all", "--by-bucket"], + _jsonOptions)); + using var bucketDocument = ParseJsonOutput(bucketStdout); + var byBucket = bucketDocument.RootElement.GetProperty("by_bucket"); + var likelyUnusedNames = byBucket.GetProperty("likely_unused_private").EnumerateArray() + .Select(symbol => symbol.GetProperty("name").GetString()) + .ToHashSet(StringComparer.Ordinal); + var contractSuspectNames = byBucket.GetProperty("reflection_or_config_suspect").EnumerateArray() + .Select(symbol => symbol.GetProperty("name").GetString()) + .ToHashSet(StringComparer.Ordinal); + + Assert.Equal(CommandExitCodes.Success, bucketExitCode); + Assert.Equal(string.Empty, bucketStderr); + Assert.Contains("ActuallyUnused", likelyUnusedNames); + Assert.Contains("OnlyUsedByOtherFamily", likelyUnusedNames); + Assert.Contains("OnlyUsedByOtherGenericArity", likelyUnusedNames); + Assert.Contains("OnlyUsedOutsidePartialRange", likelyUnusedNames); + Assert.Contains("ConnectionString", contractSuspectNames); + Assert.DoesNotContain("_hasIssueMetadataColumns", likelyUnusedNames); + Assert.DoesNotContain("endLineExplicit", likelyUnusedNames); + Assert.DoesNotContain("outputFormatExplicit", likelyUnusedNames); + + var (actionableExitCode, actionableStdout, actionableStderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( + [.. commonArgs, "--json", "--actionable"], + _jsonOptions)); + using var actionableDocument = ParseJsonOutput(actionableStdout); + var actionableNames = actionableDocument.RootElement.GetProperty("symbols").EnumerateArray() + .Select(symbol => symbol.GetProperty("name").GetString()) + .ToHashSet(StringComparer.Ordinal); + + Assert.Equal(CommandExitCodes.Success, actionableExitCode); + Assert.Equal(string.Empty, actionableStderr); + Assert.Contains("ActuallyUnused", actionableNames); + Assert.Contains("OnlyUsedByOtherFamily", actionableNames); + Assert.Contains("OnlyUsedByOtherGenericArity", actionableNames); + Assert.Contains("OnlyUsedOutsidePartialRange", actionableNames); + Assert.DoesNotContain("ConnectionString", actionableNames); + Assert.DoesNotContain("_hasIssueMetadataColumns", actionableNames); + Assert.DoesNotContain("endLineExplicit", actionableNames); + Assert.DoesNotContain("outputFormatExplicit", actionableNames); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunUnused_CSharpFieldsAndConstantsUseSpecificKinds_Issue3673() {