From bda6bd0e8a46dbbf2256b7b44c9f6df318f854f2 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:34:39 +0900 Subject: [PATCH] Fix unused limit handling for #2811 --- changelog.d/unreleased/2811.fixed.md | 17 + src/CodeIndex/Database/DbContext.cs | 2 +- src/CodeIndex/Database/DbSymbolReader.cs | 383 +++++++++++++++++++++++ tests/CodeIndex.Tests/DbReaderTests.cs | 87 +++++ 4 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/2811.fixed.md diff --git a/changelog.d/unreleased/2811.fixed.md b/changelog.d/unreleased/2811.fixed.md new file mode 100644 index 0000000000..2485ff3e38 --- /dev/null +++ b/changelog.d/unreleased/2811.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 2811 +affected: + - src/CodeIndex/Database/DbSymbolReader.cs + - src/CodeIndex/Database/DbContext.cs + - tests/CodeIndex.Tests/DbReaderTests.cs +--- + +## English + +- **`unused` now avoids long silent scans for non-SQL scopes (#2811)** — `unused --path ... --exclude-tests --json --limit ...` now uses a bounded non-SQL fast path instead of repeatedly running expensive correlated reference scans, so small limits return promptly on large C# scopes. + +## 日本語 + +- **非 SQL スコープで `unused` が長時間無音で走り続けないようになりました (#2811)** — `unused --path ... --exclude-tests --json --limit ...` は高コストな相関参照スキャンを繰り返さず、非 SQL 用の bounded fast path を使うようになったため、大きな C# スコープでも小さな limit がすばやく返ります。 diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index 97f0e80806..382768613f 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -853,7 +853,7 @@ internal static void RegisterConnectionFunctions(SqliteConnection connection) SqlNameResolver.AllowLeafFallbackAtColumn(symbolName, context, containerName, ToNullableInt(columnNumber)) ? 1 : 0); } - private static int CountCSharpIdentifierOccurrences(string? text, string? identifier) + internal static int CountCSharpIdentifierOccurrences(string? text, string? identifier) { if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(identifier)) return 0; diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index c529ab3442..035f418833 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -71,6 +71,28 @@ public partial class DbReader private const int UnusedPublicCandidateBudget = 2048; private const string SymbolLanguageFileIdFilter = " AND s.file_id IN (SELECT id FROM files WHERE lang = @lang)"; + private sealed class UnusedCandidateSymbol + { + public long FileId { get; init; } + public string Path { get; init; } = string.Empty; + public string? Lang { get; init; } + public string Kind { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public int Line { get; init; } + public int StartLine { get; init; } + public int EndLine { get; init; } + public string? Signature { get; init; } + public string? Visibility { get; init; } + public string? ReturnType { get; init; } + public string? ContainerKind { get; init; } + public string? ContainerName { get; init; } + public bool IsPublicOrExported { get; init; } + public bool IsReflectionOrConfigSuspect { get; init; } + public int ProvisionalBucketOrder { get; init; } + } + + private readonly record struct UnusedCandidateChunk(int StartLine, int EndLine, string Content); + private sealed class NormalizedSymbolSearchQueryList : List { public NormalizedSymbolSearchQueryList(IEnumerable queries) @@ -2611,6 +2633,9 @@ public List GetUnusedSymbols(int limit, string? kind, string // (unsupported languages have no references indexed, so all symbols appear unused) // グラフ対応言語に制限して偽陽性を防ぐ // (未対応言語は参照がインデックスされないため全シンボルが未使用に見える) + if (!ScopeMayIncludeSqlSymbols(kind, lang, pathPatterns, excludePathPatterns, excludeTests)) + return GetUnusedSymbolsWithoutSqlResolver(limit, kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); + var targetCount = Math.Max(limit, 1); var privateLike = FetchUnusedCandidates(targetCount, 0, 0, kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); var maybeNonPublic = FetchUnusedCandidates(targetCount, 1, 0, kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); @@ -2652,6 +2677,324 @@ public List GetUnusedSymbols(int limit, string? kind, string return DiversifyUnusedResults(merged, limit); } + private List GetUnusedSymbolsWithoutSqlResolver(int limit, string? kind, string? lang, + IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) + { + var referencedNames = LoadReferencedSymbolNames(); + var targetCount = Math.Max(limit, 1); + var publicFetchBudget = Math.Max( + targetCount, + Math.Max( + Math.Min( + Math.Max(targetCount * UnusedPublicOverfetchMultiplier, UnusedPublicOverfetchMinimum), + UnusedPublicOverfetchMaximum), + UnusedPublicCandidateBudget)); + var batchSize = Math.Min( + Math.Max(targetCount * UnusedPublicOverfetchMultiplier, UnusedPublicOverfetchMinimum), + UnusedPublicOverfetchMaximum); + var publicOrExported = new List(targetCount); + var chunksByFileId = new Dictionary>(); + var privateLike = CollectUnusedCandidateBucket(targetCount, batchSize, 0, referencedNames, chunksByFileId, + kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); + var maybeNonPublic = CollectUnusedCandidateBucket(targetCount, batchSize, 1, referencedNames, chunksByFileId, + kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); + var reflectionOrConfig = CollectUnusedCandidateBucket(targetCount, batchSize, 3, referencedNames, chunksByFileId, + kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); + CollectPublicUnusedCandidateBucket(targetCount, batchSize, publicFetchBudget, referencedNames, chunksByFileId, + publicOrExported, reflectionOrConfig, kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); + + var merged = new List(privateLike.Count + maybeNonPublic.Count + publicOrExported.Count + reflectionOrConfig.Count); + merged.AddRange(privateLike); + merged.AddRange(maybeNonPublic); + merged.AddRange(publicOrExported); + merged.AddRange(reflectionOrConfig); + return DiversifyUnusedResults(merged, limit); + } + + private List CollectUnusedCandidateBucket(int targetCount, int batchSize, int provisionalBucketOrder, + HashSet referencedNames, Dictionary> chunksByFileId, string? kind, string? lang, + IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, + IReadOnlyList? visibilityFilters, IReadOnlyList? excludeVisibilityFilters) + { + var results = new List(targetCount); + var offset = 0; + while (results.Count < targetCount) + { + var batch = FetchUnusedCandidateSymbols(batchSize, offset, provisionalBucketOrder, kind, lang, + pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters).ToList(); + if (batch.Count == 0) + break; + + offset += batch.Count; + foreach (var candidate in batch) + { + if (referencedNames.Contains(candidate.Name)) + continue; + if (HasSameFilePrivateUse(candidate, chunksByFileId)) + continue; + + results.Add(CreateUnusedSymbolResult(candidate)); + if (results.Count >= targetCount) + break; + } + + if (batch.Count < batchSize) + break; + } + + return results; + } + + private void CollectPublicUnusedCandidateBucket(int targetCount, int batchSize, int candidateBudget, + HashSet referencedNames, Dictionary> chunksByFileId, + List publicOrExported, List reflectionOrConfig, string? kind, string? lang, + IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, + IReadOnlyList? visibilityFilters, IReadOnlyList? excludeVisibilityFilters) + { + var offset = 0; + var candidatesFetched = 0; + while ((publicOrExported.Count < targetCount || reflectionOrConfig.Count < targetCount) + && candidatesFetched < candidateBudget) + { + var batch = FetchUnusedCandidateSymbols(batchSize, offset, 2, kind, lang, + pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters).ToList(); + if (batch.Count == 0) + break; + + offset += batch.Count; + foreach (var candidate in batch) + { + if (referencedNames.Contains(candidate.Name)) + continue; + if (HasSameFilePrivateUse(candidate, chunksByFileId)) + continue; + + candidatesFetched++; + var result = CreateUnusedSymbolResult(candidate); + if (result.UnusedBucket == UnusedBucketReflectionOrConfig) + { + if (reflectionOrConfig.Count < targetCount) + reflectionOrConfig.Add(result); + } + else if (publicOrExported.Count < targetCount) + { + publicOrExported.Add(result); + } + + if ((publicOrExported.Count >= targetCount && reflectionOrConfig.Count >= targetCount) + || candidatesFetched >= candidateBudget) + break; + } + + if (batch.Count < batchSize) + break; + } + } + + private bool HasSameFilePrivateUse(UnusedCandidateSymbol candidate, Dictionary> chunksByFileId) + { + if (!string.Equals(candidate.Lang, "csharp", StringComparison.Ordinal) + || !IsPrivateLikeVisibility(candidate.Visibility) + || candidate.Name.Length == 0 + || !_hasChunksTable + || !HasTable("chunks")) + return false; + + foreach (var chunk in GetUnusedCandidateChunks(candidate.FileId, chunksByFileId)) + { + var occurrenceCount = DbContext.CountCSharpIdentifierOccurrences(chunk.Content, candidate.Name); + if (occurrenceCount <= 0) + continue; + + if (chunk.EndLine < candidate.StartLine + || chunk.StartLine > candidate.EndLine + || occurrenceCount > 1) + return true; + } + + return false; + } + + private List GetUnusedCandidateChunks(long fileId, Dictionary> chunksByFileId) + { + if (chunksByFileId.TryGetValue(fileId, out var cached)) + return cached; + + using var cmd = _conn.CreateCommand(); + cmd.CommandText = """ + SELECT start_line, end_line, content + FROM chunks + WHERE file_id = @fileId + ORDER BY start_line, chunk_index + """; + cmd.Parameters.AddWithValue("@fileId", fileId); + + var chunks = new List(); + using var reader = cmd.ExecuteTrackedReader(); + while (reader.TrackedRead()) + { + chunks.Add(new UnusedCandidateChunk( + GetInt32OrFallback(reader, 0, 1), + GetInt32OrFallback(reader, 1, 0), + GetNullableString(reader, 2) ?? string.Empty)); + } + + chunksByFileId[fileId] = chunks; + return chunks; + } + + private HashSet LoadReferencedSymbolNames() + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = """ + SELECT DISTINCT symbol_name + FROM symbol_references + WHERE symbol_name IS NOT NULL + AND symbol_name <> '' + """; + + var names = new HashSet(StringComparer.Ordinal); + using var reader = cmd.ExecuteTrackedReader(); + while (reader.TrackedRead()) + names.Add(reader.GetString(0)); + return names; + } + + private IEnumerable FetchUnusedCandidateSymbols(int fetchLimit, int offset, int provisionalBucketOrder, string? kind, string? lang, + IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) + { + var graphLangs = ReferenceExtractor.GetSupportedLanguages() + .Where(value => !IsSqlLanguage(value)) + .ToList(); + var visibilitySql = $"lower({GetSymbolColumnSql("visibility", "''")})"; + var signatureSql = $"lower({GetSymbolColumnSql("signature", "''")})"; + const string pathSql = "lower(f.path)"; + var isPublicOrExportedSql = $"{visibilitySql} IN ('public', 'open', 'pub', 'export')"; + var hasConfigContextSql = $@"( + {pathSql} LIKE 'config/%' + OR {pathSql} LIKE '%/config/%' + OR {pathSql} LIKE 'settings/%' + OR {pathSql} LIKE '%/settings/%' + OR {pathSql} LIKE 'options/%' + OR {pathSql} LIKE '%/options/%' + OR {signatureSql} LIKE '%iconfiguration%' + OR {signatureSql} LIKE '%configurationsection%' + OR {signatureSql} LIKE '%ioptions%' + OR {signatureSql} LIKE '%options<%' + )"; + var isReflectionOrConfigSuspectSql = $@"( + {isPublicOrExportedSql} + AND s.kind = 'property' + AND {hasConfigContextSql} + )"; + var provisionalBucketOrderSql = $@" + CASE + WHEN {isReflectionOrConfigSuspectSql} THEN 3 + WHEN {isPublicOrExportedSql} THEN 2 + WHEN {visibilitySql} IN ('private', 'fileprivate') THEN 0 + ELSE 1 + END"; + + var sql = $@" + SELECT s.file_id, f.path, f.lang, s.kind, s.name, s.line, + {GetSymbolColumnSql("start_line", "s.line")} AS start_line, + {GetSymbolColumnSql("end_line", "s.line")} AS end_line, + {GetSymbolColumnSql("signature")} AS signature, + {GetSymbolColumnSql("visibility")} AS visibility, + {GetSymbolColumnSql("return_type")} AS return_type, + {GetSymbolColumnSql("container_kind")} AS container_kind, + {GetSymbolColumnSql("container_name")} AS container_name, + CASE WHEN {isPublicOrExportedSql} THEN 1 ELSE 0 END AS is_public_or_exported, + CASE WHEN {isReflectionOrConfigSuspectSql} THEN 1 ELSE 0 END AS is_reflection_or_config_suspect, + {provisionalBucketOrderSql} AS provisional_bucket_order + FROM symbols s + JOIN files f ON s.file_id = f.id + WHERE s.kind NOT IN ('import', 'namespace')"; + sql += $"\n AND {BuildAmbiguousCSharpEnumMemberExclusionSql("s", "f", pathPatterns, excludePathPatterns, excludeTests)}"; + + if (lang != null) + sql += SymbolLanguageFileIdFilter; + else + sql += $" AND f.lang IN ({string.Join(",", graphLangs.Select((_, i) => $"@gl{i}"))})"; + + if (kind != null) + sql += " AND s.kind = @kind"; + + sql += " AND (" + provisionalBucketOrderSql + ") = @provisionalBucketOrder"; + AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); + AppendVisibilityFilters(ref sql, visibilityFilters, excludeVisibilityFilters); + sql += " ORDER BY f.path, s.line, s.name"; + sql += " LIMIT @limit OFFSET @offset"; + + using var cmd = _conn.CreateCommand(); + cmd.CommandText = sql; + if (lang != null) + cmd.Parameters.AddWithValue("@lang", lang); + else + { + for (int i = 0; i < graphLangs.Count; i++) + cmd.Parameters.AddWithValue($"@gl{i}", graphLangs[i]); + } + if (kind != null) + cmd.Parameters.AddWithValue("@kind", kind); + cmd.Parameters.AddWithValue("@provisionalBucketOrder", provisionalBucketOrder); + cmd.Parameters.AddWithValue("@limit", fetchLimit); + cmd.Parameters.AddWithValue("@offset", offset); + AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); + AddVisibilityFilterParameters(cmd, visibilityFilters, excludeVisibilityFilters); + + using var reader = cmd.ExecuteTrackedReader(); + while (reader.TrackedRead()) + { + yield return new UnusedCandidateSymbol + { + FileId = reader.GetInt64(0), + Path = reader.GetString(1), + Lang = GetNullableString(reader, 2), + Kind = reader.GetString(3), + Name = reader.GetString(4), + Line = reader.GetInt32(5), + StartLine = GetInt32OrFallback(reader, 6, 5), + EndLine = GetInt32OrFallback(reader, 7, 5), + Signature = GetNullableString(reader, 8), + Visibility = GetNullableString(reader, 9), + ReturnType = GetNullableString(reader, 10), + ContainerKind = GetNullableString(reader, 11), + ContainerName = GetNullableString(reader, 12), + IsPublicOrExported = reader.GetInt32(13) != 0, + IsReflectionOrConfigSuspect = reader.GetInt32(14) != 0, + ProvisionalBucketOrder = reader.GetInt32(15), + }; + } + } + + private UnusedSymbolResult CreateUnusedSymbolResult(UnusedCandidateSymbol candidate) + { + var isReflectionOrConfigSuspect = candidate.IsReflectionOrConfigSuspect; + if (!isReflectionOrConfigSuspect && candidate.IsPublicOrExported) + isReflectionOrConfigSuspect = HasReflectionAttributeContext(candidate.Kind, candidate.Path, candidate.StartLine); + + var classification = ClassifyUnusedSymbol(candidate.IsPublicOrExported, isReflectionOrConfigSuspect, candidate.Visibility); + return new UnusedSymbolResult + { + Path = candidate.Path, + Lang = candidate.Lang, + Kind = candidate.Kind, + Name = candidate.Name, + Line = candidate.Line, + StartLine = candidate.StartLine, + EndLine = candidate.EndLine, + Signature = candidate.Signature, + Visibility = candidate.Visibility, + ReturnType = candidate.ReturnType, + ContainerKind = candidate.ContainerKind, + ContainerName = candidate.ContainerName, + UnusedBucket = classification.Bucket, + UnusedConfidence = classification.Confidence, + UnusedReason = classification.Reason, + }; + } + private List FetchUnusedCandidates(int fetchLimit, int provisionalBucketOrder, int offset, string? kind, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) { @@ -2847,6 +3190,8 @@ public QueryCountResult CountUnusedSymbols(string? kind, string? lang, IReadOnly return new QueryCountResult(0, 0); if (lang != null && !ReferenceExtractor.SupportsLanguage(lang)) return new QueryCountResult(0, 0); + if (!ScopeMayIncludeSqlSymbols(kind, lang, pathPatterns, excludePathPatterns, excludeTests)) + return CountUnusedSymbolsWithoutSqlResolver(kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); var graphLangs = ReferenceExtractor.GetSupportedLanguages(); using var cmd = _conn.CreateCommand(); @@ -2922,6 +3267,44 @@ AND sql_reference_matches_target_at(sr.symbol_name, " + contextSql + @", sr.cont reader.FieldCount > 2 && !reader.IsDBNull(2) && Convert.ToInt32(reader.GetValue(2)) != 0); } + private QueryCountResult CountUnusedSymbolsWithoutSqlResolver(string? kind, string? lang, + IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) + { + var referencedNames = LoadReferencedSymbolNames(); + var count = 0; + var paths = new HashSet(StringComparer.Ordinal); + var chunksByFileId = new Dictionary>(); + const int batchSize = UnusedPublicOverfetchMaximum; + for (var bucket = 0; bucket <= 3; bucket++) + { + var offset = 0; + while (true) + { + var batch = FetchUnusedCandidateSymbols(batchSize, offset, bucket, kind, lang, + pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters).ToList(); + if (batch.Count == 0) + break; + + offset += batch.Count; + foreach (var candidate in batch) + { + if (referencedNames.Contains(candidate.Name)) + continue; + if (HasSameFilePrivateUse(candidate, chunksByFileId)) + continue; + + count++; + paths.Add(candidate.Path); + } + + if (batch.Count < batchSize) + break; + } + } + + return new QueryCountResult(count, paths.Count); + } + public bool ScopeMayIncludeSqlSymbols(string? kind, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests) { if (lang != null && !IsSqlLanguage(lang)) diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index b10c135795..62170567ff 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -13794,6 +13794,93 @@ public void GetUnusedSymbols_ClassifiesConfidenceBucketsAndSortsPrivateFirst() Assert.Equal("public_or_exported_no_refs", Assert.Single(unused, symbol => symbol.Name == "UseIOptions").UnusedBucket); } + [Fact] + public void GetUnusedSymbols_NonSqlScope_FiltersReferencedPrivateSymbolsBeforeLimit() + { + const string path = "src/fast_unused_limit_fixture.cs"; + var fileId = _writer.UpsertFile(new FileRecord + { + Path = path, + Lang = "csharp", + Size = 4096, + Lines = 80, + Modified = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), + }); + + var content = new StringBuilder(); + var symbols = new List(); + var references = new List(); + for (var i = 0; i < 64; i++) + { + var name = $"UsedBeforeLimit{i:D2}"; + var line = i + 1; + content.AppendLine($" private void {name}() {{ }}"); + symbols.Add(new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = name, + Line = line, + StartLine = line, + EndLine = line, + Signature = $"private void {name}() {{ }}", + Visibility = "private", + ContainerKind = "class", + ContainerName = "FastUnusedLimitFixture", + }); + references.Add(new ReferenceRecord + { + FileId = fileId, + SymbolName = name, + ReferenceKind = "call", + Line = line, + Column = 17, + Context = $"{name}();", + ContainerKind = "class", + ContainerName = "FastUnusedLimitFixture", + }); + } + + content.AppendLine(" private void HiddenUnusedAfterReferencedPrefix() { }"); + symbols.Add(new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = "HiddenUnusedAfterReferencedPrefix", + Line = 65, + StartLine = 65, + EndLine = 65, + Signature = "private void HiddenUnusedAfterReferencedPrefix() { }", + Visibility = "private", + ContainerKind = "class", + ContainerName = "FastUnusedLimitFixture", + }); + + _writer.InsertChunks([ + new ChunkRecord + { + FileId = fileId, + ChunkIndex = 0, + StartLine = 1, + EndLine = 65, + Content = content.ToString(), + } + ]); + _writer.InsertSymbols(symbols); + _writer.InsertReferences(references); + + var unused = _reader.GetUnusedSymbols(limit: 1, kind: null, lang: "csharp", + pathPatterns: ["fast_unused_limit_fixture.cs"], excludePathPatterns: null, excludeTests: false); + var count = _reader.CountUnusedSymbols(kind: null, lang: "csharp", + pathPatterns: ["fast_unused_limit_fixture.cs"], excludePathPatterns: null, excludeTests: false); + + var result = Assert.Single(unused); + Assert.Equal("HiddenUnusedAfterReferencedPrefix", result.Name); + Assert.Equal("likely_unused_private", result.UnusedBucket); + Assert.Equal(1, count.Count); + Assert.Equal(1, count.FileCount); + } + [Fact] public void GetUnusedSymbols_PlainCliOptionsProperties_StayInPublicBucket() {