From 7d3c9437e09c0ab91ba6e3871a3b927e6f69bc5c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 15 Jun 2026 09:02:42 +0900 Subject: [PATCH 1/2] Fix C# constructor alias reference search (#3391) --- changelog.d/unreleased/3391.fixed.md | 22 + src/CodeIndex/Cli/ConsoleUi.cs | 2 +- src/CodeIndex/Cli/QueryCommandRunner.cs | 2 +- .../CSharpReferenceExtractor.Support.cs | 129 +++- .../References/ReferenceExtractor.Core.cs | 616 +++++++++++++++++- .../ReferenceExtractor.TypeReferences.cs | 6 + .../Indexer/References/ReferenceExtractor.cs | 6 +- src/CodeIndex/Mcp/McpToolDefinitions.cs | 4 +- src/CodeIndex/Models/SymbolKindCatalog.cs | 1 + tests/CodeIndex.Tests/ConsoleUiTests.cs | 2 +- tests/CodeIndex.Tests/DatabaseTests.cs | 1 + .../ReferenceExtractorTests.cs | 345 ++++++++++ 12 files changed, 1114 insertions(+), 22 deletions(-) create mode 100644 changelog.d/unreleased/3391.fixed.md diff --git a/changelog.d/unreleased/3391.fixed.md b/changelog.d/unreleased/3391.fixed.md new file mode 100644 index 0000000000..d5acfa7d7c --- /dev/null +++ b/changelog.d/unreleased/3391.fixed.md @@ -0,0 +1,22 @@ +--- +category: fixed +issues: + - 3391 +affected: + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.TypeReferences.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Models/SymbolKindCatalog.cs +--- + +## English + +- **C# constructor reference search now resolves using-alias targets (#3391)** — `references BoundedRegex --kind instantiate` can now distinguish `using Regex = CodeIndex.Indexer.BoundedRegex; new Regex(...)` from direct `System.Text.RegularExpressions.Regex` construction, and `references Regex --kind bcl_regex_without_timeout --lang csharp` reports direct BCL Regex construction without a timeout argument. + +## 日本語 + +- **C# constructor reference search が using alias の参照先を解決するようになりました (#3391)** — `references BoundedRegex --kind instantiate` で、`using Regex = CodeIndex.Indexer.BoundedRegex; new Regex(...)` と直接の `System.Text.RegularExpressions.Regex` 生成を区別できるようになり、`references Regex --kind bcl_regex_without_timeout --lang csharp` で timeout 引数なしの直接 BCL Regex 生成を報告します。 diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index a76101a56c..bd788d408f 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -1061,7 +1061,7 @@ private static void PrintFlagReference(Action WriteHelpLine) Console.WriteLine(" Uses NFKC + Unicode CaseFold when ready."); Console.WriteLine(" Legacy/stale-fold DBs fall back to ASCII NOCASE;"); Console.WriteLine(" run `cdidx backfill-fold` or check fold_ready."); - WriteHelpLine(" --kind definition/symbols/hotspots/unused: symbol kind; references: reference kind (call/instantiate/subscribe/attribute/annotation); callers/callees: call-graph kinds only (call/instantiate/subscribe — metadata kinds rejected, use references instead); validate: issue kind"); + WriteHelpLine(" --kind definition/symbols/hotspots/unused: symbol kind; references: reference kind (call/instantiate/subscribe/attribute/annotation/bcl_regex_without_timeout); callers/callees: call-graph kinds only (call/instantiate/subscribe — metadata kinds rejected, use references instead); validate: issue kind"); Console.WriteLine(" --severity validate only: filter issues by severity: info, warning, error"); Console.WriteLine(" --visibility Filter symbols/definitions/unused/hotspots by visibility: public, protected, internal, private"); WriteHelpLine(" --exclude-visibility Exclude symbols/definitions/unused/hotspots by visibility"); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index e7a09183bc..0bb63c1f63 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -11140,7 +11140,7 @@ private static void WriteSymbolExtractionCapabilityHint(string? lang, DbReader r // compile-time な `type_reference` エッジを含む。C++ の `friend` 宣言も extractor が出す // dependency edge として受け付け、graph query にも参加させる。 private static readonly string[] AllValidReferenceKinds = - ["annotation", "attribute", "augmentation", "call", "consumes_hook", "friend", "import", "instantiate", "razor_event_binding", "subscribe", "type_reference", "unsubscribe"]; + ["annotation", "attribute", "augmentation", "bcl_regex_without_timeout", "call", "consumes_hook", "friend", "import", "instantiate", "razor_event_binding", "subscribe", "type_reference", "unsubscribe"]; // Reference kinds that `callers` / `callees` can legitimately return. Metadata kinds // (`attribute` / `annotation`) and type-position edges (`type_reference`) are structurally // not call-graph edges, so those queries are rejected at the CLI / MCP boundary. C++ `friend` diff --git a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs index d2190da02c..de16872e94 100644 --- a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs +++ b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs @@ -13,12 +13,18 @@ private sealed record CSharpNamespaceScope(string QualifiedName, int ScopeStartL private sealed record CSharpUsingNamespaceScope(string TargetQualifiedName, int Line, int ScopeStartLine, int ScopeEndLine); private sealed record CSharpContainingTypeScope(string QualifiedName, int ScopeStartLine, int ScopeEndLine); internal sealed record CSharpUsingAliasRecord(string AliasName, string TargetQualifiedName, int Line, int ScopeStartLine, int ScopeEndLine, bool TargetsType); + internal sealed record CSharpUsingNamespaceRecord(string TargetQualifiedName, int Line, int ScopeStartLine, int ScopeEndLine); internal sealed record CSharpUsingStaticRecord(string TargetQualifiedName, int Line, int ScopeStartLine, int ScopeEndLine); private sealed record CSharpCastTypeShape(IReadOnlyList IdentifierSegments, string? SimpleQualifiedName, bool HasTypeOnlySyntax, bool AllIdentifiersTypeLike); internal sealed record CSharpContainingTypeValueReceiverNames(HashSet InstanceNames, HashSet StaticNames); internal sealed record CSharpFunctionValueReceiverNameRecord(string Name, int ScopeStartLine, int ScopeStartColumn, int ScopeEndLine, int ScopeEndColumn); - private static List BuildCSharpUsingAliases(string language, IReadOnlyList symbols, IReadOnlySet csharpKnownTypeNames) + private static List BuildCSharpUsingAliases( + string language, + IReadOnlyList symbols, + IReadOnlySet csharpKnownTypeNames, + IReadOnlyList? lines = null, + IReadOnlyList? aliasScanLines = null) { var aliases = new List(); if (language != "csharp") @@ -41,9 +47,112 @@ private static List BuildCSharpUsingAliases(string langu if (!match.Success) continue; - var alias = NormalizeCSharpIdentifier(match.Groups["alias"].Value); - var target = TryNormalizeCSharpQualifiedName(match.Groups["target"].Value); - if (string.IsNullOrWhiteSpace(alias) || string.IsNullOrWhiteSpace(target)) + AddCSharpUsingAliasRecord(aliases, namespaceScopes, symbol.Line, match, csharpKnownTypeNames); + } + + if (lines != null) + { + for (var i = 0; i < lines.Count; i++) + { + var scanLine = aliasScanLines != null && i < aliasScanLines.Count + ? aliasScanLines[i] + : lines[i]; + if (!CSharpUsingAliasRegex.IsMatch(scanLine)) + continue; + + var match = CSharpUsingAliasRegex.Match(lines[i]); + if (!match.Success) + continue; + + var lineNumber = i + 1; + if (aliases.Any(existing => existing.Line == lineNumber + && string.Equals(existing.AliasName, NormalizeCSharpIdentifier(match.Groups["alias"].Value), StringComparison.Ordinal))) + { + continue; + } + + AddCSharpUsingAliasRecord(aliases, namespaceScopes, lineNumber, match, csharpKnownTypeNames); + } + } + + aliases.Sort(static (left, right) => left.Line.CompareTo(right.Line)); + return aliases; + } + + private static void AddCSharpUsingAliasRecord( + List aliases, + IReadOnlyList<(int StartLine, int EndLine)> namespaceScopes, + int lineNumber, + Match match, + IReadOnlySet csharpKnownTypeNames) + { + var alias = NormalizeCSharpIdentifier(match.Groups["alias"].Value); + var target = TryNormalizeCSharpQualifiedName(match.Groups["target"].Value) + ?? NormalizeCSharpUsingAliasRawTarget(match.Groups["target"].Value); + if (string.IsNullOrWhiteSpace(alias) || string.IsNullOrWhiteSpace(target)) + return; + + var scopeStartLine = 1; + var scopeEndLine = int.MaxValue; + var scopeWidth = int.MaxValue; + foreach (var (startLine, endLine) in namespaceScopes) + { + if (lineNumber < startLine || lineNumber > endLine) + continue; + + var width = endLine - startLine; + if (width > scopeWidth) + continue; + + scopeStartLine = startLine; + scopeEndLine = endLine; + scopeWidth = width; + } + + aliases.Add(new CSharpUsingAliasRecord( + alias, + target, + lineNumber, + scopeStartLine, + scopeEndLine, + IsCSharpUsingAliasTypeTarget(target, csharpKnownTypeNames))); + } + + private static string NormalizeCSharpUsingAliasRawTarget(string target) + { + var trimmed = target.Trim(); + var genericStart = trimmed.IndexOf('<'); + if (genericStart >= 0) + trimmed = trimmed[..genericStart].TrimEnd(); + return trimmed; + } + + private static List BuildCSharpUsingNamespaces(string language, IReadOnlyList symbols) + { + var imports = new List(); + if (language != "csharp") + return imports; + + var namespaceScopes = symbols + .Where(symbol => symbol.Kind == "namespace") + .Select(symbol => ( + StartLine: symbol.BodyStartLine ?? symbol.StartLine, + EndLine: symbol.BodyEndLine ?? symbol.EndLine)) + .Where(scope => scope.StartLine > 0 && scope.EndLine >= scope.StartLine) + .ToList(); + + foreach (var symbol in symbols) + { + if (symbol.Kind != "import" || string.IsNullOrWhiteSpace(symbol.Signature)) + continue; + + var match = CSharpUsingNamespaceRegex.Match(symbol.Signature!); + if (!match.Success) + continue; + + var target = TryNormalizeCSharpQualifiedName(match.Groups["target"].Value) + ?? NormalizeCSharpUsingAliasRawTarget(match.Groups["target"].Value); + if (string.IsNullOrWhiteSpace(target)) continue; var scopeStartLine = 1; @@ -63,17 +172,11 @@ private static List BuildCSharpUsingAliases(string langu scopeWidth = width; } - aliases.Add(new CSharpUsingAliasRecord( - alias, - target, - symbol.Line, - scopeStartLine, - scopeEndLine, - IsCSharpUsingAliasTypeTarget(target, csharpKnownTypeNames))); + imports.Add(new CSharpUsingNamespaceRecord(target, symbol.Line, scopeStartLine, scopeEndLine)); } - aliases.Sort(static (left, right) => left.Line.CompareTo(right.Line)); - return aliases; + imports.Sort(static (left, right) => left.Line.CompareTo(right.Line)); + return imports; } private static List BuildCSharpUsingStatics(string language, IReadOnlyList symbols) diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs index e5b1146e30..3bcce34f29 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.Core.cs @@ -134,7 +134,8 @@ internal static List ExtractCore(ReferenceExtractionContext req var dockerfileVariableNames = DockerfileReferenceExtractor.BuildVariableNames(language, symbols); var shellCallableNames = ShellReferenceExtractor.BuildCallableNames(language, symbols); var shellGlobalAliasNames = ShellReferenceExtractor.BuildGlobalAliasNames(language, symbols); - var csharpUsingAliases = BuildCSharpUsingAliases(language, symbols, csharpKnownTypeNames); + var csharpUsingAliases = BuildCSharpUsingAliases(language, symbols, csharpKnownTypeNames, lines, structuralLines); + var csharpUsingNamespaces = BuildCSharpUsingNamespaces(language, symbols); var csharpUsingStatics = BuildCSharpUsingStatics(language, symbols); var csharpValueReceiverNames = BuildCSharpValueReceiverNamesByContainingType(language, symbols); var csharpFunctionValueReceiverNames = BuildCSharpValueReceiverNamesByFunctionStartLine( @@ -172,6 +173,617 @@ bool HasActiveSameFileCSharpTypeCandidate(string typeExpression, int lineNumber) && string.Equals(alias.AliasName, shortName, StringComparison.Ordinal)); } + string ResolveCSharpUsingAliasReferenceName(string referenceName, int lineNumber) + { + if (language != "csharp") + return referenceName; + + for (var aliasIndex = csharpUsingAliases.Count - 1; aliasIndex >= 0; aliasIndex--) + { + var alias = csharpUsingAliases[aliasIndex]; + if (alias.Line > lineNumber + || lineNumber < alias.ScopeStartLine + || lineNumber > alias.ScopeEndLine + || !string.Equals(alias.AliasName, referenceName, StringComparison.Ordinal)) + { + continue; + } + + var targetName = GetLastQualifiedSegment(TrimLeadingCSharpGlobalQualifier(alias.TargetQualifiedName)); + return string.IsNullOrWhiteSpace(targetName) ? referenceName : targetName; + } + + return referenceName; + } + + void ApplyCSharpUsingAliasReferenceNames(List references) + { + if (language != "csharp") + return; + + foreach (var reference in references) + { + if (reference.ReferenceKind is not ("instantiate" or "attribute")) + continue; + if (reference.Line <= 0 || reference.Line > lines.Length || reference.Column <= 0) + continue; + if (!IsUnqualifiedCSharpTokenAtColumn(reference.Line, reference.Column, reference.SymbolName)) + continue; + + var resolvedName = ResolveCSharpUsingAliasReferenceName(reference.SymbolName, reference.Line); + if (string.Equals(resolvedName, reference.SymbolName, StringComparison.Ordinal)) + continue; + + reference.SymbolName = resolvedName; + reference.IsSelfReference = IsSameReferenceName(reference.ContainerName, resolvedName); + } + + var deduped = new HashSet(StringComparer.Ordinal); + for (var index = 0; index < references.Count;) + { + var reference = references[index]; + var key = BuildReferenceDedupeKey( + reference.FileId, + language, + reference.Line, + reference.Column, + reference.ReferenceKind, + reference.SymbolName, + new SymbolRecord + { + Kind = reference.ContainerKind ?? string.Empty, + Name = reference.ContainerName ?? string.Empty, + }); + if (deduped.Add(key)) + { + index++; + continue; + } + + references.RemoveAt(index); + } + } + + bool IsUnqualifiedCSharpTokenAtColumn(int lineNumber, int column, string symbolName) + { + if (lineNumber <= 0 + || lineNumber > lines.Length + || column <= 0 + || string.IsNullOrWhiteSpace(symbolName)) + return false; + + var line = lines[lineNumber - 1]; + var tokenStart = column - 1; + if (tokenStart >= line.Length) + return false; + + var tokenNameStart = tokenStart; + if (line[tokenNameStart] == '@') + tokenNameStart++; + + if (tokenNameStart + symbolName.Length > line.Length) + return false; + if (!line.AsSpan(tokenNameStart, symbolName.Length).Equals(symbolName, StringComparison.Ordinal)) + return false; + + var previousIndex = tokenStart - 1; + var nextIndex = tokenNameStart + symbolName.Length; + var hasQualifiedPrefix = HasCSharpQualifiedSeparatorBeforeToken(line, tokenStart) + || (previousIndex >= 0 && IsCSharpIdentifierPart(line[previousIndex])); + var hasIdentifierSuffix = nextIndex < line.Length && IsCSharpIdentifierPart(line[nextIndex]); + return !hasQualifiedPrefix && !hasIdentifierSuffix; + } + + bool HasActiveCSharpUsingNamespace(string targetQualifiedName, int lineNumber) + { + var normalizedTarget = NormalizeCSharpBclRegexQualifiedName(targetQualifiedName); + return csharpUsingNamespaces.Any(import => + import.Line <= lineNumber + && lineNumber >= import.ScopeStartLine + && lineNumber <= import.ScopeEndLine + && string.Equals(NormalizeCSharpBclRegexQualifiedName(import.TargetQualifiedName), normalizedTarget, StringComparison.Ordinal)); + } + + CSharpUsingAliasRecord? FindActiveCSharpUsingAlias(string aliasName, int lineNumber) + { + for (var aliasIndex = csharpUsingAliases.Count - 1; aliasIndex >= 0; aliasIndex--) + { + var alias = csharpUsingAliases[aliasIndex]; + if (alias.Line > lineNumber + || lineNumber < alias.ScopeStartLine + || lineNumber > alias.ScopeEndLine + || !string.Equals(alias.AliasName, aliasName, StringComparison.Ordinal)) + { + continue; + } + + return alias; + } + + return null; + } + + void EmitCSharpBclRegexWithoutTimeoutReferences(List references, HashSet seen) + { + if (language != "csharp") + return; + + foreach (var reference in references.ToArray()) + { + if (reference.ReferenceKind != "instantiate" + || !string.Equals(reference.SymbolName, "Regex", StringComparison.Ordinal) + || reference.Line <= 0 + || reference.Line > lines.Length + || reference.Column <= 0 + || !IsCSharpBclRegexInstantiateReference(reference) + || !IsCSharpRegexConstructorWithoutTimeout(reference.Line, reference.Column, reference.SymbolName)) + { + continue; + } + + var container = new SymbolRecord + { + Kind = reference.ContainerKind ?? string.Empty, + Name = reference.ContainerName ?? string.Empty, + }; + var dedupeKey = BuildReferenceDedupeKey( + reference.FileId, + language, + reference.Line, + reference.Column, + "bcl_regex_without_timeout", + reference.SymbolName, + container); + if (!seen.Add(dedupeKey)) + continue; + + references.Add(new ReferenceRecord + { + FileId = reference.FileId, + SymbolName = reference.SymbolName, + ReferenceKind = "bcl_regex_without_timeout", + Line = reference.Line, + Column = reference.Column, + Context = reference.Context, + ContainerKind = reference.ContainerKind, + ContainerName = reference.ContainerName, + IsSelfReference = reference.IsSelfReference, + }); + } + } + + bool IsCSharpBclRegexInstantiateReference(ReferenceRecord reference) + { + var line = lines[reference.Line - 1]; + if (!TryGetCSharpIdentifierAtColumn(line, reference.Column, out _, out _, out var tokenName)) + return false; + + if (TryGetCSharpQualifiedPrefixAtColumn(line, reference.Column, tokenName, out var prefix) + && string.Equals(NormalizeCSharpBclRegexQualifiedName($"{prefix}.{tokenName}"), "System.Text.RegularExpressions.Regex", StringComparison.Ordinal)) + { + return true; + } + + var alias = FindActiveCSharpUsingAlias(tokenName, reference.Line); + if (alias != null) + { + return string.Equals( + NormalizeCSharpBclRegexQualifiedName(alias.TargetQualifiedName), + "System.Text.RegularExpressions.Regex", + StringComparison.Ordinal); + } + + return string.Equals(tokenName, "Regex", StringComparison.Ordinal) + && !HasActiveSameFileCSharpTypeCandidate(tokenName, reference.Line) + && HasActiveCSharpUsingNamespace("System.Text.RegularExpressions", reference.Line); + } + + bool IsCSharpRegexConstructorWithoutTimeout(int lineNumber, int column, string symbolName) + { + var line = lines[lineNumber - 1]; + _ = symbolName; + if (!TryGetCSharpIdentifierAtColumn(line, column, out _, out var tokenNameStart, out var tokenName)) + return false; + + var cursor = tokenNameStart + tokenName.Length; + while (cursor < line.Length && char.IsWhiteSpace(line[cursor])) + cursor++; + if (cursor >= line.Length || line[cursor] != '(') + return false; + + if (!TryCollectCSharpInvocationArguments(lines, lineNumber - 1, cursor, out var args)) + return false; + + var argCount = CountTopLevelCSharpArguments(args.AsSpan(), out var hasNamedMatchTimeout); + return argCount is 1 or 2 && !hasNamedMatchTimeout; + } + + static bool HasCSharpQualifiedSeparatorBeforeToken(string line, int tokenStart) + { + var probe = tokenStart - 1; + while (probe >= 0 && char.IsWhiteSpace(line[probe])) + probe--; + + if (probe < 0) + return false; + if (line[probe] == '.') + return true; + return line[probe] == ':' && probe >= 1 && line[probe - 1] == ':'; + } + + static bool TryGetCSharpTokenBoundsAtColumn(string line, int column, string symbolName, out int tokenStart, out int tokenNameStart) + { + if (!TryGetCSharpIdentifierAtColumn(line, column, out tokenStart, out tokenNameStart, out var tokenName) + || string.IsNullOrWhiteSpace(symbolName)) + { + return false; + } + + return string.Equals(tokenName, symbolName, StringComparison.Ordinal); + } + + static bool TryGetCSharpIdentifierAtColumn(string line, int column, out int tokenStart, out int tokenNameStart, out string tokenName) + { + tokenStart = column - 1; + tokenNameStart = tokenStart; + tokenName = string.Empty; + if (tokenStart < 0 || tokenStart >= line.Length) + return false; + + if (line[tokenNameStart] == '@') + tokenNameStart++; + + if (tokenNameStart >= line.Length || !IsCSharpIdentifierPart(line[tokenNameStart])) + return false; + + var tokenEnd = tokenNameStart + 1; + while (tokenEnd < line.Length && IsCSharpIdentifierPart(line[tokenEnd])) + tokenEnd++; + + tokenName = NormalizeCSharpIdentifier(line[tokenStart..tokenEnd]); + return !string.IsNullOrWhiteSpace(tokenName); + } + + static bool TryGetCSharpQualifiedPrefixAtColumn(string line, int column, string symbolName, out string prefix) + { + prefix = string.Empty; + if (!TryGetCSharpTokenBoundsAtColumn(line, column, symbolName, out var tokenStart, out _) + || !HasCSharpQualifiedSeparatorBeforeToken(line, tokenStart)) + { + return false; + } + + var cursor = tokenStart - 1; + while (cursor >= 0 && char.IsWhiteSpace(line[cursor])) + cursor--; + if (cursor >= 0 && line[cursor] == '.') + cursor--; + else if (cursor >= 1 && line[cursor] == ':' && line[cursor - 1] == ':') + cursor -= 2; + else + return false; + + var segments = new List(); + while (cursor >= 0) + { + while (cursor >= 0 && char.IsWhiteSpace(line[cursor])) + cursor--; + + var segmentEnd = cursor; + while (cursor >= 0 && (IsCSharpIdentifierPart(line[cursor]) || line[cursor] == '@')) + cursor--; + + var segmentStart = cursor + 1; + if (segmentStart > segmentEnd) + return false; + + segments.Add(NormalizeCSharpIdentifier(line[segmentStart..(segmentEnd + 1)])); + while (cursor >= 0 && char.IsWhiteSpace(line[cursor])) + cursor--; + + if (cursor >= 0 && line[cursor] == '.') + { + cursor--; + continue; + } + + if (cursor >= 1 && line[cursor] == ':' && line[cursor - 1] == ':') + { + cursor -= 2; + continue; + } + + break; + } + + if (segments.Count == 0) + return false; + + segments.Reverse(); + prefix = string.Join('.', segments); + return true; + } + + static bool TryCollectCSharpInvocationArguments(string[] sourceLines, int lineIndex, int openParen, out string args) + { + const int MaxInvocationLines = 32; + var builder = new StringBuilder(); + var depth = 0; + var started = false; + var lineLimit = Math.Min(sourceLines.Length, lineIndex + MaxInvocationLines); + + for (var currentLineIndex = lineIndex; currentLineIndex < lineLimit; currentLineIndex++) + { + var line = sourceLines[currentLineIndex]; + for (var i = currentLineIndex == lineIndex ? openParen : 0; i < line.Length;) + { + var skippedIndex = i; + if (TrySkipCSharpStringOrCharLiteral(line.AsSpan(), ref skippedIndex)) + { + if (started && depth > 0) + builder.Append(line.AsSpan(i, skippedIndex - i)); + i = skippedIndex; + continue; + } + + skippedIndex = i; + if (TrySkipCSharpComment(line.AsSpan(), ref skippedIndex)) + { + if (started && depth > 0) + builder.Append(' '); + i = skippedIndex; + continue; + } + + var ch = line[i++]; + if (ch == '(') + { + if (started && depth > 0) + builder.Append(ch); + depth++; + started = true; + continue; + } + + if (ch == ')' && started) + { + depth--; + if (depth == 0) + { + args = builder.ToString(); + return true; + } + + builder.Append(ch); + continue; + } + + if (started && depth > 0) + builder.Append(ch); + } + + if (started && depth > 0) + builder.Append('\n'); + } + + args = string.Empty; + return false; + } + + static int CountTopLevelCSharpArguments(ReadOnlySpan args, out bool hasNamedMatchTimeout) + { + hasNamedMatchTimeout = false; + var count = 0; + var tokenStart = 0; + var parenDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + + for (var i = 0; i <= args.Length; i++) + { + var atEnd = i == args.Length; + if (!atEnd) + { + if (TrySkipCSharpStringOrCharLiteral(args, ref i) + || TrySkipCSharpComment(args, ref i)) + { + i--; + continue; + } + + var ch = args[i]; + if (ch == '(') + parenDepth++; + else if (ch == ')' && parenDepth > 0) + parenDepth--; + else if (ch == '[') + bracketDepth++; + else if (ch == ']' && bracketDepth > 0) + bracketDepth--; + else if (ch == '{') + braceDepth++; + else if (ch == '}' && braceDepth > 0) + braceDepth--; + + if (ch != ',' || parenDepth != 0 || bracketDepth != 0 || braceDepth != 0) + continue; + } + + var segment = args[tokenStart..i].Trim(); + if (!segment.IsEmpty) + { + count++; + if (CSharpArgumentHasNamedMatchTimeout(segment)) + hasNamedMatchTimeout = true; + } + + tokenStart = i + 1; + } + + return count; + } + + static bool TrySkipCSharpComment(ReadOnlySpan text, ref int index) + { + if (index + 1 >= text.Length || text[index] != '/') + return false; + + if (text[index + 1] == '/') + { + index = text.Length; + return true; + } + + if (text[index + 1] != '*') + return false; + + index += 2; + while (index + 1 < text.Length) + { + if (text[index] == '*' && text[index + 1] == '/') + { + index += 2; + return true; + } + + index++; + } + + index = text.Length; + return true; + } + + static bool TrySkipCSharpStringOrCharLiteral(ReadOnlySpan text, ref int index) + { + var cursor = index; + var verbatim = false; + + if (cursor < text.Length && text[cursor] == '@') + { + verbatim = true; + cursor++; + while (cursor < text.Length && text[cursor] == '$') + cursor++; + } + else + { + while (cursor < text.Length && text[cursor] == '$') + cursor++; + if (cursor < text.Length && text[cursor] == '@') + { + verbatim = true; + cursor++; + } + } + + if (cursor >= text.Length || (text[cursor] != '"' && text[cursor] != '\'')) + return false; + + var quote = text[cursor]; + if (quote == '\'') + { + index = cursor + 1; + while (index < text.Length) + { + if (text[index] == '\\') + { + index += 2; + continue; + } + + if (text[index++] == '\'') + return true; + } + + return true; + } + + var quoteCount = 0; + while (cursor + quoteCount < text.Length && text[cursor + quoteCount] == '"') + quoteCount++; + + if (!verbatim && quoteCount >= 3) + { + index = cursor + quoteCount; + while (index + quoteCount <= text.Length) + { + var matched = true; + for (var offset = 0; offset < quoteCount; offset++) + { + if (text[index + offset] != '"') + { + matched = false; + break; + } + } + + if (matched) + { + index += quoteCount; + return true; + } + + index++; + } + + index = text.Length; + return true; + } + + index = cursor + 1; + while (index < text.Length) + { + if (!verbatim && text[index] == '\\') + { + index += 2; + continue; + } + + if (text[index] == '"') + { + if (verbatim && index + 1 < text.Length && text[index + 1] == '"') + { + index += 2; + continue; + } + + index++; + return true; + } + + index++; + } + + return true; + } + + static bool CSharpArgumentHasNamedMatchTimeout(ReadOnlySpan argument) + { + const string MatchTimeoutName = "matchTimeout"; + var cursor = 0; + while (cursor < argument.Length && char.IsWhiteSpace(argument[cursor])) + cursor++; + if (cursor + MatchTimeoutName.Length > argument.Length + || !argument[cursor..(cursor + MatchTimeoutName.Length)].Equals(MatchTimeoutName, StringComparison.Ordinal)) + { + return false; + } + + cursor += MatchTimeoutName.Length; + while (cursor < argument.Length && char.IsWhiteSpace(argument[cursor])) + cursor++; + return cursor < argument.Length && argument[cursor] == ':'; + } + + static string NormalizeCSharpBclRegexQualifiedName(string value) + { + var normalized = NormalizeCSharpAliasTargetForTypeLookup(value); + normalized = TrimLeadingCSharpGlobalQualifier(normalized); + if (normalized.StartsWith("global.", StringComparison.Ordinal)) + normalized = normalized["global.".Length..]; + return normalized; + } + var references = new List(); var seen = new HashSet(StringComparer.Ordinal); if (language == "csharp") @@ -2368,6 +2980,8 @@ void AddGradleDslReference(string name, int callIndex) fileId); } + ApplyCSharpUsingAliasReferenceNames(references); + EmitCSharpBclRegexWithoutTimeoutReferences(references, seen); MarkMutualRecursionReferences(references); return references; } diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeReferences.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeReferences.cs index 8fee38f424..be97efc74e 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeReferences.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.TypeReferences.cs @@ -3544,6 +3544,12 @@ private static bool IsConstructorCallName(string language, string preparedLine, if (separator == null) break; + if (separator != '\\') + { + while (probe >= 0 && char.IsWhiteSpace(preparedLine[probe])) + probe--; + } + var segmentEnd = probe; while (probe >= 0 && IsIdentifierChar(preparedLine[probe])) probe--; diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs index 927fe31986..9a2ad52639 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs @@ -466,12 +466,12 @@ private static bool IsFunctionLikeSymbolKind(string kind) private static readonly Regex CSharpUsingAliasRegex = new( @"^\s*(?:global\s+)?using\s+(?!static\b)(?@?[A-Za-z_]\w*)\s*=\s*(?[^;]+)", RegexOptions.Compiled); + private static readonly Regex CSharpUsingNamespaceRegex = new( + @"^\s*(?:global\s+)?using\s+(?!static\b)(?[^;=]+?)\s*;?\s*$", + RegexOptions.Compiled); private static readonly Regex CSharpUsingStaticRegex = new( @"^\s*(?:global\s+)?using\s+static\s+(?[^;]+)", RegexOptions.Compiled); - private static readonly Regex CSharpUsingNamespaceRegex = new( - @"^\s*(?:global\s+)?using\s+(?!static\b)(?[^;=]+)", - RegexOptions.Compiled); private static readonly Regex CSharpLocalValueNameRegex = new( @"(?:^\s*|[;{}]\s*)(?:(?:(?:await\s+)?using\s+var)|var|(?:(?:const\s+)?[A-Za-z_]\w*(?:\s*::\s*|\s*\.\s*)*[A-Za-z_]\w*(?:\s*<[^>\n]+>)?(?:\s*\?)?(?:\s*\[\s*\])*))\s+(?@?[A-Za-z_]\w*)\s*(?==|;|,)", RegexOptions.Compiled); diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index ad80849cda..2105d47983 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -92,14 +92,14 @@ private JsonNode HandleToolsList(JsonNode? id) ReadOnlyAnnotations()), CreateToolDefinition( "references", - "Use this when you need usage sites, examples, tests, metadata references, or type-position references for a symbol. Prefer it after `definition`; common next step is `excerpt` on representative rows or `callers`/`callees` for runtime impact. Search indexed symbol references such as call sites. Non-empty responses include `next_step_suggestion`; empty responses include `recovery_hint`. Pass `lsp_compatible:true` to add `uri` and LSP `range` fields to each result. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. When `kind` is omitted, all indexed reference kinds including metadata uses (`attribute` / `annotation`) and compile-time type-position references (`type_reference`) stay visible, and identical constructor `call` + `instantiate` rows at one physical site are collapsed. Pass `kind: \"type_reference\"` to enumerate declaration types, generic constraints, `is`/`as`/`instanceof`, and XML-doc `cref` targets. Examples: `references {\"query\":\"Run\"}`; `references {\"query\":\"Service\",\"kind\":\"type_reference\",\"lang\":\"csharp\"}`. / シンボルの利用箇所、例、テスト、metadata 参照、型位置参照を調べるときに使う。`definition` の後に優先し、次は代表行の `excerpt` または実行時影響の `callers` / `callees` を使う。例: `references {\"query\":\"Run\"}`; `references {\"query\":\"Service\",\"kind\":\"type_reference\",\"lang\":\"csharp\"}`。", + "Use this when you need usage sites, examples, tests, metadata references, or type-position references for a symbol. Prefer it after `definition`; common next step is `excerpt` on representative rows or `callers`/`callees` for runtime impact. Search indexed symbol references such as call sites. Non-empty responses include `next_step_suggestion`; empty responses include `recovery_hint`. Pass `lsp_compatible:true` to add `uri` and LSP `range` fields to each result. For exact matches, use `exactName`; `exact` is the legacy alias documented in USER_GUIDE.md's flag compatibility table. When `kind` is omitted, all indexed reference kinds including metadata uses (`attribute` / `annotation`), C# BCL Regex timeout audit rows (`bcl_regex_without_timeout`), and compile-time type-position references (`type_reference`) stay visible, and identical constructor `call` + `instantiate` rows at one physical site are collapsed. Pass `kind: \"type_reference\"` to enumerate declaration types, generic constraints, `is`/`as`/`instanceof`, and XML-doc `cref` targets. Pass `kind: \"bcl_regex_without_timeout\"` with query `Regex` to audit direct System.Text.RegularExpressions.Regex construction without a timeout argument. Examples: `references {\"query\":\"Run\"}`; `references {\"query\":\"Service\",\"kind\":\"type_reference\",\"lang\":\"csharp\"}`. / シンボルの利用箇所、例、テスト、metadata 参照、型位置参照を調べるときに使う。`definition` の後に優先し、次は代表行の `excerpt` または実行時影響の `callers` / `callees` を使う。`kind: \"bcl_regex_without_timeout\"` と query `Regex` で timeout 引数なしの直接 `System.Text.RegularExpressions.Regex` 生成を監査できる。例: `references {\"query\":\"Run\"}`; `references {\"query\":\"Service\",\"kind\":\"type_reference\",\"lang\":\"csharp\"}`。", new JsonObject { ["type"] = "object", ["properties"] = new JsonObject { ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Referenced symbol name pattern to search for" }, - ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by reference kind (call, instantiate, subscribe, friend, attribute, annotation, type_reference)" }, + ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by reference kind (call, instantiate, subscribe, friend, attribute, annotation, type_reference, bcl_regex_without_timeout)" }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language" }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated`, `more_available`, and `next_offset` when more rows exist.", ["default"] = QueryCommandRunner.DefaultQueryLimit }, ["offset"] = new JsonObject { ["type"] = "integer", ["description"] = "Zero-based result offset for pagination; use `next_offset` from a truncated response.", ["default"] = 0, ["minimum"] = 0 }, diff --git a/src/CodeIndex/Models/SymbolKindCatalog.cs b/src/CodeIndex/Models/SymbolKindCatalog.cs index 01388896b5..23a43afada 100644 --- a/src/CodeIndex/Models/SymbolKindCatalog.cs +++ b/src/CodeIndex/Models/SymbolKindCatalog.cs @@ -79,6 +79,7 @@ public static class SymbolKindCatalog "annotation", "attribute", "augmentation", + "bcl_regex_without_timeout", "call", "capture", "column_reference", diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 9e50f7720f..45b1297cd5 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -136,7 +136,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains(" Uses NFKC + Unicode CaseFold when ready.", output); Assert.Contains(" Legacy/stale-fold DBs fall back to ASCII NOCASE;", output); Assert.Contains(" run `cdidx backfill-fold` or check fold_ready.", output); - Assert.Contains("--kind definition/symbols/hotspots/unused: symbol kind; references: reference kind (call/instantiate/subscribe/attribute/annotation); callers/callees: call-graph kinds only (call/instantiate/subscribe — metadata kinds rejected, use references instead); validate: issue kind", output); + Assert.Contains("--kind definition/symbols/hotspots/unused: symbol kind; references: reference kind (call/instantiate/subscribe/attribute/annotation/bcl_regex_without_timeout); callers/callees: call-graph kinds only (call/instantiate/subscribe — metadata kinds rejected, use references instead); validate: issue kind", output); Assert.Contains("--severity validate only: filter issues by severity: info, warning, error", output); Assert.Contains("--count Count only; search/definition/references/callers/callees/symbols/files/find/unused/hotspots ignore --limit, impact still uses visible page counts", output); Assert.Contains("--no-dedup search only: return every raw overlapping chunk hit (debug/density)", output); diff --git a/tests/CodeIndex.Tests/DatabaseTests.cs b/tests/CodeIndex.Tests/DatabaseTests.cs index 2366f0f086..073567bb1d 100644 --- a/tests/CodeIndex.Tests/DatabaseTests.cs +++ b/tests/CodeIndex.Tests/DatabaseTests.cs @@ -339,6 +339,7 @@ public void InsertSymbols_UnknownKind_ThrowsBeforePersisting() [Theory] [InlineData("annotation")] + [InlineData("bcl_regex_without_timeout")] [InlineData("column_reference")] [InlineData("const_generic_reference")] [InlineData("cte_body_reference")] diff --git a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs index 39b68c0849..93f78b4278 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs @@ -3791,6 +3791,351 @@ public void Execute() Assert.DoesNotContain(references, reference => reference.SymbolName == "Bar" && reference.ReferenceKind == "call"); } + [Fact] + public void Extract_CSharpConstructorAlias_UsesAliasTargetName() + { + const string content = """ + using Regex = CodeIndex.Indexer.BoundedRegex; + + public class Worker + { + public void Execute() + { + _ = new Regex("safe"); + _ = new System.Text.RegularExpressions.Regex("bcl"); + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + Assert.Contains(references, reference => + reference.SymbolName == "BoundedRegex" + && reference.ReferenceKind == "instantiate" + && reference.Context.Contains("new Regex", StringComparison.Ordinal)); + Assert.DoesNotContain(references, reference => + reference.SymbolName == "Regex" + && reference.ReferenceKind == "instantiate" + && reference.Context.Contains("new Regex", StringComparison.Ordinal)); + Assert.Contains(references, reference => + reference.SymbolName == "Regex" + && reference.ReferenceKind == "instantiate" + && reference.Context.Contains("System.Text.RegularExpressions.Regex", StringComparison.Ordinal)); + } + + [Fact] + public void Extract_CSharpConstructorAlias_DoesNotRewriteQualifiedCallWhenAliasNameAppearsElsewhereOnLine() + { + const string content = """ + using Regex = CodeIndex.Indexer.BoundedRegex; + + public class Worker + { + public void Execute() + { + _ = new Regex("alias"); + var note = "Regex"; _ = new System.Text.RegularExpressions.Regex("bcl"); + _ = new System.Text.RegularExpressions.Regex("comment"); // Regex + _ = new System.Text.RegularExpressions . Regex("spaced"); + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + Assert.Contains(references, reference => + reference.SymbolName == "BoundedRegex" + && reference.ReferenceKind == "instantiate" + && reference.Context.Contains("\"alias\"", StringComparison.Ordinal)); + Assert.Contains(references, reference => + reference.SymbolName == "Regex" + && reference.ReferenceKind == "instantiate" + && reference.Context.Contains("\"bcl\"", StringComparison.Ordinal)); + Assert.Contains(references, reference => + reference.SymbolName == "Regex" + && reference.ReferenceKind == "instantiate" + && reference.Context.Contains("\"comment\"", StringComparison.Ordinal)); + Assert.Contains(references, reference => + reference.SymbolName == "Regex" + && reference.ReferenceKind == "instantiate" + && reference.Context.Contains("\"spaced\"", StringComparison.Ordinal)); + Assert.DoesNotContain(references, reference => + reference.SymbolName == "BoundedRegex" + && reference.ReferenceKind == "instantiate" + && (reference.Context.Contains("\"bcl\"", StringComparison.Ordinal) + || reference.Context.Contains("\"comment\"", StringComparison.Ordinal) + || reference.Context.Contains("\"spaced\"", StringComparison.Ordinal))); + } + + [Fact] + public void Extract_CSharpBclRegexWithoutTimeout_EmitsAuditReferenceKind() + { + const string content = """ + using System; + using System.Text.RegularExpressions; + using AliasRegex = CodeIndex.Indexer.BoundedRegex; + using BclRegex = System.Text.RegularExpressions.Regex; + + public class Worker + { + public void Execute() + { + _ = new AliasRegex("alias"); + _ = new BclRegex("alias bcl"); + _ = new System.Text.RegularExpressions.Regex("bcl"); + _ = new System.Text.RegularExpressions . Regex("spaced"); + _ = new global::System.Text.RegularExpressions.Regex("safe", RegexOptions.None, TimeSpan.FromSeconds(1)); + _ = new Regex("using namespace"); + _ = new Regex("named timeout", RegexOptions.None, matchTimeout: TimeSpan.FromSeconds(1)); + _ = new System.Text.RegularExpressions.Regex( // multi bcl + "multi bcl"); + _ = new Regex( // multi safe + "multi safe", + RegexOptions.None, + TimeSpan.FromSeconds(1)); + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + Assert.Contains(references, reference => + reference.SymbolName == "Regex" + && reference.ReferenceKind == "bcl_regex_without_timeout" + && reference.Context.Contains("\"alias bcl\"", StringComparison.Ordinal)); + Assert.Contains(references, reference => + reference.SymbolName == "Regex" + && reference.ReferenceKind == "bcl_regex_without_timeout" + && reference.Context.Contains("\"bcl\"", StringComparison.Ordinal)); + Assert.Contains(references, reference => + reference.SymbolName == "Regex" + && reference.ReferenceKind == "bcl_regex_without_timeout" + && reference.Context.Contains("\"spaced\"", StringComparison.Ordinal)); + Assert.Contains(references, reference => + reference.SymbolName == "Regex" + && reference.ReferenceKind == "bcl_regex_without_timeout" + && reference.Context.Contains("\"using namespace\"", StringComparison.Ordinal)); + Assert.Contains(references, reference => + reference.SymbolName == "Regex" + && reference.ReferenceKind == "bcl_regex_without_timeout" + && reference.Context.Contains("multi bcl", StringComparison.Ordinal)); + Assert.DoesNotContain(references, reference => + reference.ReferenceKind == "bcl_regex_without_timeout" + && (reference.Context.Contains("\"alias\"", StringComparison.Ordinal) + || reference.Context.Contains("\"safe\"", StringComparison.Ordinal) + || reference.Context.Contains("\"named timeout\"", StringComparison.Ordinal) + || reference.Context.Contains("multi safe", StringComparison.Ordinal))); + } + + [Fact] + public void Extract_CSharpBclRegexWithoutTimeout_DoesNotFlagShadowedNamespaceImport() + { + const string aliasContent = """ + using System.Text.RegularExpressions; + using Regex = MyCompany.Text.Regex; + + public class Worker + { + public void Execute() + { + _ = new Regex("alias shadow"); + } + } + """; + const string localTypeContent = """ + using System.Text.RegularExpressions; + + public class Regex + { + public Regex(string pattern) { } + } + + public class Worker + { + public void Execute() + { + _ = new Regex("local shadow"); + } + } + """; + + AssertNoAuditReference(aliasContent, "alias shadow"); + AssertNoAuditReference(localTypeContent, "local shadow"); + + static void AssertNoAuditReference(string content, string marker) + { + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + Assert.DoesNotContain(references, reference => + reference.ReferenceKind == "bcl_regex_without_timeout" + && reference.Context.Contains(marker, StringComparison.Ordinal)); + } + } + + [Fact] + public void Extract_CSharpConstructorAlias_UsesNearestScopedAlias() + { + const string content = """ + using Regex = External.OuterRegex; + + namespace Scoped + { + using Regex = CodeIndex.Indexer.BoundedRegex; + + public class Worker + { + public void Execute() + { + _ = new Regex("scoped"); + } + } + } + + namespace Sibling + { + public class Worker + { + public void Execute() + { + _ = new Regex("sibling"); + } + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + Assert.Contains(references, reference => + reference.SymbolName == "BoundedRegex" + && reference.ReferenceKind == "instantiate" + && reference.Context.Contains("\"scoped\"", StringComparison.Ordinal)); + Assert.DoesNotContain(references, reference => + reference.SymbolName == "OuterRegex" + && reference.ReferenceKind == "instantiate" + && reference.Context.Contains("\"scoped\"", StringComparison.Ordinal)); + Assert.Contains(references, reference => + reference.SymbolName == "OuterRegex" + && reference.ReferenceKind == "instantiate" + && reference.Context.Contains("\"sibling\"", StringComparison.Ordinal)); + Assert.DoesNotContain(references, reference => + reference.SymbolName == "BoundedRegex" + && reference.ReferenceKind == "instantiate" + && reference.Context.Contains("\"sibling\"", StringComparison.Ordinal)); + } + + [Fact] + public void Extract_CSharpConstructorAlias_DoesNotLeakAliasFromSiblingNamespace() + { + const string content = """ + namespace Scoped + { + using Regex = CodeIndex.Indexer.BoundedRegex; + + public class Worker + { + public void Execute() + { + _ = new Regex("scoped"); + } + } + } + + namespace Sibling + { + public class Worker + { + public void Execute() + { + _ = new Regex("sibling"); + } + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + Assert.Contains(references, reference => + reference.SymbolName == "BoundedRegex" + && reference.ReferenceKind == "instantiate" + && reference.Context.Contains("\"scoped\"", StringComparison.Ordinal)); + Assert.Contains(references, reference => + reference.SymbolName == "Regex" + && reference.ReferenceKind == "instantiate" + && reference.Context.Contains("\"sibling\"", StringComparison.Ordinal)); + Assert.DoesNotContain(references, reference => + reference.SymbolName == "BoundedRegex" + && reference.ReferenceKind == "instantiate" + && reference.Context.Contains("\"sibling\"", StringComparison.Ordinal)); + } + + [Fact] + public void Extract_CSharpConstructorAlias_IgnoresAliasTextInsideBlockComment() + { + const string content = """ + /* + using Regex = CodeIndex.Indexer.BoundedRegex; + */ + + public class Worker + { + public void Execute() + { + _ = new Regex("plain"); + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + Assert.Contains(references, reference => + reference.SymbolName == "Regex" + && reference.ReferenceKind == "instantiate" + && reference.Context.Contains("\"plain\"", StringComparison.Ordinal)); + Assert.DoesNotContain(references, reference => + reference.SymbolName == "BoundedRegex" + && reference.ReferenceKind == "instantiate" + && reference.Context.Contains("\"plain\"", StringComparison.Ordinal)); + } + + [Fact] + public void Extract_CSharpUsingAlias_DoesNotRewritePlainCall() + { + const string content = """ + using Regex = CodeIndex.Indexer.BoundedRegex; + + public class Worker + { + public void Execute() + { + Regex("plain call"); + } + + private void Regex(string value) + { + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + Assert.Contains(references, reference => + reference.SymbolName == "Regex" + && reference.ReferenceKind == "call" + && reference.Context.Contains("\"plain call\"", StringComparison.Ordinal)); + Assert.DoesNotContain(references, reference => + reference.SymbolName == "BoundedRegex" + && reference.ReferenceKind == "call" + && reference.Context.Contains("\"plain call\"", StringComparison.Ordinal)); + } + From 03a14f8f3ad19b794f5d4eaf2ecb3d477d6cecdf Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 15 Jun 2026 09:04:18 +0900 Subject: [PATCH 2/2] Recognize serialization contract annotations in unused analysis (#3396) --- changelog.d/unreleased/3396.fixed.md | 15 ++ src/CodeIndex/Database/DbSymbolReader.cs | 41 +++++- tests/CodeIndex.Tests/DbReaderTests.cs | 167 +++++++++++++++++++++++ 3 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3396.fixed.md diff --git a/changelog.d/unreleased/3396.fixed.md b/changelog.d/unreleased/3396.fixed.md new file mode 100644 index 0000000000..8c905a7656 --- /dev/null +++ b/changelog.d/unreleased/3396.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 3396 +affected: + - src/CodeIndex/Database/DbSymbolReader.cs +--- + +## English + +- **Unused-symbol analysis now recognizes more serialization and reflection contract annotations (#3396)** — public C# members, including properties, DTO fields, constructors, and methods annotated with System.Text.Json contract attributes or trimming/reflection preservation attributes, are classified under `reflection_or_config_suspect` instead of the general public no-reference bucket. + +## 日本語 + +- **unused-symbol analysis が serialization / reflection contract annotation をより多く認識するようになりました (#3396)** — System.Text.Json の contract 属性や trimming / reflection preservation 属性が付いた C# public member(property、DTO field、constructor、method を含む)を、一般の public no-reference bucket ではなく `reflection_or_config_suspect` に分類するようになりました。 diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index 0babfb3396..8df7027027 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -24,6 +24,12 @@ public partial class DbReader "jsonpropertyname", "jsonproperty", "jsoninclude", + "jsonextensiondata", + "jsonconverter", + "jsonrequired", + "jsonpropertyorder", + "jsonnumberhandling", + "jsonobjectcreationhandling", "datamember", "bsonelement", "bsonid", @@ -37,13 +43,25 @@ public partial class DbReader "parameter", "inject", "bindnever", + "dynamicallyaccessedmembers", + "dynamicdependency", + "preserve", + "usedimplicitly", + "publicapi", }; private static readonly HashSet ReflectionTypeAttributeNames = new(StringComparer.Ordinal) { "serializable", "jsonserializable", + "jsonsourcegenerationoptions", + "jsonconverter", + "jsonderivedtype", + "jsonpolymorphic", "datacontract", "xmlroot", + "xmltype", + "xmlinclude", + "knowntype", "protocontract", "messagepackobject", "table", @@ -51,6 +69,24 @@ public partial class DbReader "owned", "keyless", "attributeusage", + "dynamicallyaccessedmembers", + "dynamicdependency", + "preserve", + "usedimplicitly", + "publicapi", + }; + private static readonly HashSet ReflectionFunctionAttributeNames = new(StringComparer.Ordinal) + { + "jsonconstructor", + "onserializing", + "onserialized", + "ondeserializing", + "ondeserialized", + "dynamicdependency", + "dynamicallyaccessedmembers", + "preserve", + "usedimplicitly", + "publicapi", }; private static readonly HashSet ReflectionIgnoreAttributeNames = new(StringComparer.Ordinal) { @@ -4157,12 +4193,15 @@ private bool HasReflectionAttributeContext(string kind, string path, int startLi private static HashSet? GetReflectionAttributeNamesForKind(string kind) { - if (kind == "property") + if (kind is "property" or "field") return ReflectionPropertyAttributeNames; if (kind is "class" or "struct" or "interface" or "enum") return ReflectionTypeAttributeNames; + if (kind == "function") + return ReflectionFunctionAttributeNames; + return null; } diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index d157d8bd6e..0ace55d68e 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -15419,6 +15419,173 @@ public class Target Assert.Equal("reflection_or_config_suspect", Assert.Single(unused, symbol => symbol.Name == "IgnoredValue").UnusedBucket); } + [Fact] + public void GetUnusedSymbols_SerializationAndReflectionContractAttributes_AreClassifiedAsSuspect() + { + var fileId = _writer.UpsertFile(new FileRecord + { + Path = "src/serialization_reflection_contract_fixture.cs", + Lang = "csharp", + Size = 940, + Lines = 26, + Modified = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), + }); + _writer.InsertChunks( + [ + new ChunkRecord + { + FileId = fileId, + ChunkIndex = 0, + StartLine = 1, + EndLine = 26, + Content = """ + using System; + using System.Collections.Generic; + using System.Diagnostics.CodeAnalysis; + using System.Text.Json.Serialization; + + public class ContractDto + { + [JsonExtensionData] + public Dictionary ExtensionData { get; set; } = new(); + + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + public Type? ReflectedType { get; set; } + + [JsonInclude] + public string? IncludedField; + + public string? PlainName { get; set; } + + [JsonConstructor] + public ContractDto(string name) { } + + [DynamicDependency(nameof(PlainMethod))] + public void PreservedMethod() { } + + public void PlainMethod() { } + } + """, + } + ]); + _writer.InsertSymbols( + [ + new SymbolRecord + { + FileId = fileId, + Kind = "class", + Name = "ContractDto", + Line = 6, + StartLine = 6, + EndLine = 26, + Signature = "public class ContractDto", + Visibility = "public", + }, + new SymbolRecord + { + FileId = fileId, + Kind = "property", + Name = "ExtensionData", + Line = 9, + StartLine = 9, + EndLine = 9, + Signature = "public Dictionary ExtensionData { get; set; } = new();", + Visibility = "public", + ContainerKind = "class", + ContainerName = "ContractDto", + }, + new SymbolRecord + { + FileId = fileId, + Kind = "property", + Name = "ReflectedType", + Line = 12, + StartLine = 12, + EndLine = 12, + Signature = "public Type? ReflectedType { get; set; }", + Visibility = "public", + ContainerKind = "class", + ContainerName = "ContractDto", + }, + new SymbolRecord + { + FileId = fileId, + Kind = "property", + Name = "PlainName", + Line = 17, + StartLine = 17, + EndLine = 17, + Signature = "public string? PlainName { get; set; }", + Visibility = "public", + ContainerKind = "class", + ContainerName = "ContractDto", + }, + new SymbolRecord + { + FileId = fileId, + Kind = "field", + Name = "IncludedField", + Line = 15, + StartLine = 15, + EndLine = 15, + Signature = "public string? IncludedField;", + Visibility = "public", + ContainerKind = "class", + ContainerName = "ContractDto", + }, + new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = "ContractDto", + Line = 20, + StartLine = 20, + EndLine = 20, + Signature = "public ContractDto(string name) { }", + Visibility = "public", + ContainerKind = "class", + ContainerName = "ContractDto", + }, + new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = "PreservedMethod", + Line = 23, + StartLine = 23, + EndLine = 23, + Signature = "public void PreservedMethod() { }", + Visibility = "public", + ContainerKind = "class", + ContainerName = "ContractDto", + }, + new SymbolRecord + { + FileId = fileId, + Kind = "function", + Name = "PlainMethod", + Line = 25, + StartLine = 25, + EndLine = 25, + Signature = "public void PlainMethod() { }", + Visibility = "public", + ContainerKind = "class", + ContainerName = "ContractDto", + }, + ]); + + var unused = _reader.GetUnusedSymbols(limit: 10, kind: null, lang: "csharp", + pathPatterns: ["serialization_reflection_contract_fixture.cs"], excludePathPatterns: null, excludeTests: false); + + Assert.Equal("reflection_or_config_suspect", Assert.Single(unused, symbol => symbol.Name == "ExtensionData").UnusedBucket); + Assert.Equal("reflection_or_config_suspect", Assert.Single(unused, symbol => symbol.Name == "ReflectedType").UnusedBucket); + Assert.Equal("reflection_or_config_suspect", Assert.Single(unused, symbol => symbol.Name == "IncludedField").UnusedBucket); + Assert.Equal("public_or_exported_no_refs", Assert.Single(unused, symbol => symbol.Name == "PlainName").UnusedBucket); + Assert.Equal("reflection_or_config_suspect", Assert.Single(unused, symbol => symbol.Name == "ContractDto" && symbol.Kind == "function").UnusedBucket); + Assert.Equal("reflection_or_config_suspect", Assert.Single(unused, symbol => symbol.Name == "PreservedMethod").UnusedBucket); + Assert.Equal("public_or_exported_no_refs", Assert.Single(unused, symbol => symbol.Name == "PlainMethod").UnusedBucket); + } + [Fact] public void GetUnusedSymbols_MultilineReflectionAttribute_IsClassifiedAsSuspect() {