From 65082fb0ff34522df0ad349fb4fdb743e5a4267f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 27 Aug 2026 16:35:56 +0900 Subject: [PATCH 1/3] Fix multiline C# test attributes (#5192) --- DEVELOPER_GUIDE.md | 14 + TESTING_GUIDE.md | 2 + changelog.d/unreleased/5192.fixed.md | 25 ++ .../Symbols/SymbolExtractor.CSharpScanner.cs | 7 +- .../Symbols/SymbolExtractor.Contracts.cs | 10 +- .../SymbolExtractor.DeclarationMetadata.cs | 266 +++++++++++++++--- .../SymbolExtractor.ExtractionPhases.cs | 6 +- .../SymbolExtractor.PatternEmission.cs | 5 +- .../SymbolExtractor.PatternEmissionFlow.cs | 18 +- .../IndexCommandRunnerFullScanTests.cs | 4 +- .../QueryCommandRunnerIssue5192Tests.cs | 126 +++++++++ .../SymbolExtractorCSharpTests.cs | 136 +++++++++ 12 files changed, 573 insertions(+), 46 deletions(-) create mode 100644 changelog.d/unreleased/5192.fixed.md create mode 100644 tests/CodeIndex.Tests/QueryCommandRunnerIssue5192Tests.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index cc646086f..b6d07cf64 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1289,6 +1289,13 @@ the public arrays at runtime. | `volume` | Dockerfile `VOLUME` paths | Container storage search symbol | | `workdir` | Dockerfile `WORKDIR` paths | Container filesystem search symbol | +For C#, `test.method` ownership is determined from the complete leading attribute +block rather than from the immediately preceding physical line. The bounded scanner +reuses the C# lexical masking pass, so multiline attribute arguments and consecutive +attribute lists are supported while brackets and test-like names in comments or +normal, verbatim, raw, interpolated, and character literals are ignored. Explicit +`return:`, `assembly:`, and `module:` targets do not classify a method. + `SymbolKindCatalog.CompatibilityKindFamilies` maps both `typealias` and `type_parameter` to the broad `type` family for consumers that only understand the older coarse taxonomy. The persisted `kind` remains semantic, and `--kind` @@ -5353,6 +5360,13 @@ test も更新してください。公開 array を実行時に変更しては | `volume` | Dockerfile `VOLUME` path | container storage search symbol | | `workdir` | Dockerfile `WORKDIR` path | container filesystem search symbol | +C# の `test.method` 所有権は、直前の物理行だけではなく、先頭に連続する attribute +block 全体から判定します。上限付き scanner は C# の字句 masking pass を再利用するため、 +複数行の attribute 引数と連続する attribute list を扱いつつ、comment、通常文字列、 +verbatim 文字列、raw 文字列、interpolated 文字列、文字 literal 内の bracket や test 風の +名前を無視します。明示的な `return:`、`assembly:`、`module:` target は method を +`test.method` に分類しません。 + 古い粗い taxonomy だけを理解する consumer 向けに、 `SymbolKindCatalog.CompatibilityKindFamilies` は `typealias` と `type_parameter` の両方を広い `type` family へ mapping します。永続化される diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 75be8b255..0bdf9b5bf 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -114,6 +114,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result Extractor coverage is split by language or feature area with partial test classes, while shared helpers remain on the root `SymbolExtractorTests` / `ReferenceExtractorTests` parts. YAML sequence-hierarchy fixtures must keep sequence items path-only, count empty/scalar/alias items in stable indexes, preserve anchor and block-scalar boundaries, assert logical qualified paths separately from nearest emitted symbol parents, and prove a normal full scan refreshes unchanged YAML rows stamped with the prior extractor contract. C# declaration-boundary regressions should pair a direct extractor fixture with a real-index `symbols --exact-name` query. Keep invocation and parameter continuations beside valid multi-line methods, constructors, delegates, and local functions so both false-positive rejection and declaration ranges remain observable. Scope-continuation fixtures must also place unmatched delimiters in character/string literals and comments before later methods, then assert that the later methods remain definitions rather than unresolved declaration-shaped calls. + C# test-attribute ownership coverage pairs `SymbolExtractorCSharpTests` with `QueryCommandRunnerIssue5192Tests`. Keep multiline and same-line attribute lists, comments, qualified xUnit names, MSTest/NUnit forms, literal-contained brackets, ignored targets, member boundaries, and fail-closed budgets in the direct fixture; the persisted fixture must retain `test.method` through outline, symbols, inspect, unused, and `--exclude-tests` behavior (#5192). C# explicit-interface identity coverage pairs extractor assertions for methods, properties, events, and indexers with persisted exact qualified/unqualified queries, fold rewrite validation, inspect/outline checks, and LSP definition/reference scoping. Include multiple and inherited interfaces, generic arity, a same-named public member, and a qualified return type that must not be mistaken for an explicit-interface qualifier. C# callable-containment fixtures should cover block-bodied test methods, local and nested local functions, named lambdas, expression-bodied members, and nested types together, asserting both symbol parents and call-reference containers. Local-function identity coverage additionally keeps pre/post-declaration calls, nested and sibling blocks, overloads, parameter/local delegate shadowing, method groups, identical enclosing names in unrelated types, incomplete ranges, persisted graph/query/inspect/impact behavior, and LSP definition/reference parity in one coordinated contract (#5188, extending #4840). C# switch-expression returned-lambda coverage pairs a direct `ReferenceExtractorCSharpTests` regression with an `IndexCommandRunnerFullScanTests` `--rebuild` graph fixture. Keep expression/block, parenthesized, multiline, nested-switch, async, qualified/unqualified, and non-lambda arms together; assert raw call rows, enclosing containers, exact locations, callers/callees, complete graph readiness, and negative positional-pattern/enum controls including guarded, nested, and contextual-keyword-designation patterns with a following-line arrow (#5085). @@ -1251,6 +1252,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" extractor のカバレッジは言語または機能領域ごとの partial test class に分割し、共有 helper は root 側の `SymbolExtractorTests` / `ReferenceExtractorTests` に残します。 YAML sequence hierarchy の fixture では、sequence item を path 専用のまま保ち、empty / scalar / alias item を安定した index に数え、anchor と block scalar の境界を維持し、論理 qualified path と最寄りの出力済み symbol parent を分けて検証し、旧 extractor contract が stamp された未変更 YAML row を通常の full scan が更新することも確認してください。 C# の declaration boundary に関する regression では、extractor を直接呼ぶ fixture と、実 index に対する `symbols --exact-name` query を組み合わせてください。呼び出し・parameter の continuation と、正当な複数行 method、constructor、delegate、local function を同居させ、false positive の拒否と宣言 range の両方を観測可能にします。スコープ継続用のテストでは、後続メソッドより前の文字・文字列リテラルとコメントに対応しない区切り記号も置き、後続メソッドが未解決の宣言形式呼び出しではなく定義のままになることも検証してください。 + C# の test attribute 所有権 coverage は `SymbolExtractorCSharpTests` と `QueryCommandRunnerIssue5192Tests` を組み合わせます。直接 fixture では複数行 / 同一行の attribute list、comment、修飾 xUnit 名、MSTest / NUnit 形式、literal 内の bracket、無視する target、member boundary、fail-closed budget を維持し、永続化 fixture では outline、symbols、inspect、unused、`--exclude-tests` の各動作で `test.method` が保たれることを確認してください(#5192)。 C# の明示的 interface identity coverage では、method、property、event、indexer の extractor assertion と、永続化後の修飾 / 非修飾の完全一致 query、fold rewrite 検証、inspect / outline、LSP の definition / reference scope を組み合わせます。複数および継承 interface、generic arity、同名 public member に加え、明示的 interface qualifier と誤認してはならない修飾 return type を含めてください。 C# generic outline 表示の coverage は、generic / 非 generic overload、extension / local / explicit-interface method、nested generic の tuple / nullable / array parameter type、constraint、overload を区別する modifier、修飾型 / 具体型名との衝突、escaped / Unicode identifier、attribute / 既定値 literal 内の区切り文字を、抽出と reader setup を共有する1つの `DbReaderTests` fixture に維持します。line 移動を検証する対の fixture は先頭改行以外を同一にし、canonical name / path が不変であることを assertion に含め、旧 index の `Name@line` fallback 用に不完全 signature を手動永続化した row も1件残してください(#4916)。 C# の位置 record に対する検索帰属 coverage は、extractor の座標 assertion、1つの indexed reader fixture、1つの CLI fixture を組み合わせます。record keyword と型名、attribute と component 型、base 引数、body member、同一行の通常 property、複数行 `record struct` component、正規化された verbatim identifier の列、言語未指定の exact grouping、multi-hit の focus 選択、focus の行・列出力、symbol grouping を共通契約に維持します。component 列を持たない旧 symbol では行ベースの fallback を維持してください。C# の座標維持のために comment padding を変更する場合、Java/Kotlin の record component comment が既存 signature を維持することも assertion してください(#5095)。 diff --git a/changelog.d/unreleased/5192.fixed.md b/changelog.d/unreleased/5192.fixed.md new file mode 100644 index 000000000..c99e8e8b3 --- /dev/null +++ b/changelog.d/unreleased/5192.fixed.md @@ -0,0 +1,25 @@ +--- +category: fixed +issues: + - 5192 +affected: + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.DeclarationMetadata.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmission.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmissionFlow.cs + - tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerIssue5192Tests.cs + - tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **C# test methods keep their taxonomy across multiline attribute blocks (#5192)** — `Fact`, `Theory`, MSTest, and NUnit markers now remain attached through multiline arguments, consecutive attribute lists, and intervening comments, while literals, ignored attribute targets, member boundaries, and bounded fail-closed inputs cannot spoof or leak `test.method` classification. The C# extractor contract advances so unchanged files in existing indexes are re-extracted. + +## 日本語 + +- **複数行の attribute block でも C# test method の taxonomy を維持するようになりました (#5192)** — `Fact`、`Theory`、MSTest、NUnit の marker は、複数行引数、連続する attribute list、途中の comment を越えて正しい method に関連付けられ、literal、無視対象の attribute target、member boundary、上限を超えて fail-closed となる入力が `test.method` 分類を偽装または後続宣言へ漏出することを防ぎます。C# extractor contract も更新し、既存 index の未変更ファイルを再抽出します。 diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs index 51f1dd590..c9fc33717 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs @@ -3916,12 +3916,15 @@ private static string[] BuildCSharpMatchLines( bool applyCSharpRegexProbeOptimizations, CSharpRegexProbeCounts? csharpRegexProbeCounts, out int[]?[] collapsedToRaw, - out string[] scopeLines) + out string[] scopeLines, + out bool[] testMethodAttributedDeclarationLines) { var matchLines = new string[rawLines.Length]; collapsedToRaw = new int[]?[rawLines.Length]; scopeLines = new string[rawLines.Length]; + testMethodAttributedDeclarationLines = new bool[rawLines.Length]; var csharpLexState = new CSharpLexState(); + var testAttributeScanner = new CSharpTestAttributePrefixScanner(); var inLeadingAttributeBlock = false; var attributeBracketDepth = 0; var attributeParenDepth = 0; @@ -3937,6 +3940,8 @@ private static string[] BuildCSharpMatchLines( // masking が必要であり、汎用 structural masking だけでは全 C# literal を扱えない。 scopeLines[lineIndex] = BlankCSharpStringDelimitersForCrossLineScan( lexedLine.SanitizedLine); + testMethodAttributedDeclarationLines[lineIndex] = + testAttributeScanner.ScanLine(scopeLines[lineIndex]); matchLines[lineIndex] = CollapseCSharpGenericTypeWhitespace( StripLeadingCSharpAttributeLists( lexedLine.SanitizedLine, diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs index 096140ae6..a75697495 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs @@ -6,11 +6,11 @@ public static partial class SymbolExtractor public const int ExpandedLanguageContractVersion = 2; public const int YamlContractVersion = 3; public const int PythonContractVersion = 2; - // Version 15 (#5188) re-extracts C# calls so local-function lexical targets and - // value-shadowing evidence are persisted for existing indexes. - // バージョン 15 (#5188) は既存 index の C# call を再抽出し、local function の - // 字句 target と value shadowing evidence を永続化する。 - public const int CSharpContractVersion = 15; + // Version 16 (#5192) re-extracts C# symbols so test-method taxonomy from + // multiline attribute blocks is persisted for existing indexes. + // バージョン 16 (#5192) は既存 index の C# symbol を再抽出し、複数行の + // attribute block から得た test method taxonomy を永続化する。 + public const int CSharpContractVersion = 16; public const int DockerfileContractVersion = 2; public const int MakefileContractVersion = 2; public const int StyleAndXamlContractVersion = 2; diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.DeclarationMetadata.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.DeclarationMetadata.cs index 1c4e891a8..692b1389d 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.DeclarationMetadata.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.DeclarationMetadata.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.RegularExpressions; using CodeIndex.Models; @@ -119,58 +120,255 @@ void MaskToEnd(int start) return chars is null ? line : new string(chars); } - private static bool IsCSharpTestMethod(string[] lines, int declarationLineIndex) + private const int CSharpTestAttributeMaxLines = 128; + private const int CSharpTestAttributeMaxCharacters = 32 * 1024; + private const int CSharpTestAttributeMaxItems = 64; + private const int CSharpTestAttributeMaxNameCharacters = 512; + + private static bool IsCSharpTestMethod( + bool[] attributedDeclarationLines, + int declarationLineIndex, + bool isFunction) + { + return isFunction + && declarationLineIndex >= 0 + && declarationLineIndex < attributedDeclarationLines.Length + && attributedDeclarationLines[declarationLineIndex]; + } + + private static void ConsumeCSharpTestAttributePrefix( + bool[] attributedDeclarationLines, + int declarationLineIndex) { - var scannedAttributeLine = false; - for (var lineIndex = declarationLineIndex; lineIndex >= 0; lineIndex--) + if (declarationLineIndex >= 0 + && declarationLineIndex < attributedDeclarationLines.Length + && attributedDeclarationLines[declarationLineIndex]) { - var trimmed = lines[lineIndex].TrimStart(); - if (trimmed.Length == 0) - return false; + // An attribute prefix belongs to the first emitted declaration after it. + // Delaying consumption until emission avoids losing ownership to an + // overlapping pattern candidate that was accepted but deduplicated. + attributedDeclarationLines[declarationLineIndex] = false; + } + } + + private sealed class CSharpTestAttributePrefixScanner + { + private readonly StringBuilder _attributeName = new(64); + private bool _inAttributeSection; + private bool _pendingAttributePrefix; + private bool _blockHasTestAttribute; + private bool _sectionHasTestAttribute; + private bool _sectionTargetIgnored; + private bool _itemNameFinalized; + private bool _budgetExceeded; + private int _bracketDepth; + private int _parenthesisDepth; + private int _blockLineCount; + private int _blockCharacterCount; + private int _blockItemCount; + + public bool ScanLine(string sanitizedLine) + { + var firstNonWhitespace = 0; + while (firstNonWhitespace < sanitizedLine.Length + && char.IsWhiteSpace(sanitizedLine[firstNonWhitespace])) + { + firstNonWhitespace++; + } - if (!trimmed.StartsWith('[')) + if (!_inAttributeSection && !_pendingAttributePrefix) { - if (lineIndex == declarationLineIndex && !scannedAttributeLine) + if (firstNonWhitespace >= sanitizedLine.Length + || sanitizedLine[firstNonWhitespace] != '[') + { + return false; + } + + ResetBlock(); + } + + CountLine(); + var cursor = 0; + while (cursor < sanitizedLine.Length) + { + var ch = sanitizedLine[cursor]; + if (!_inAttributeSection) + { + if (char.IsWhiteSpace(ch)) + { + CountCharacter(); + cursor++; + continue; + } + + if (ch == '[') + { + StartSection(); + CountCharacter(); + cursor++; + continue; + } + + var isAttributedDeclaration = !_budgetExceeded + && _pendingAttributePrefix + && _blockHasTestAttribute; + ResetBlock(); + return isAttributedDeclaration; + } + + CountCharacter(); + cursor++; + + if (ch == '[') + { + _bracketDepth++; + continue; + } + + if (ch == ']') + { + _bracketDepth--; + if (_bracketDepth == 0) + { + CompleteItem(); + if (!_sectionTargetIgnored) + _blockHasTestAttribute |= _sectionHasTestAttribute; + + _inAttributeSection = false; + _pendingAttributePrefix = true; + _parenthesisDepth = 0; + } + + continue; + } + + if (_bracketDepth != 1) continue; - return false; + if (ch == '(') + { + if (_parenthesisDepth == 0) + FinalizeItemName(); + _parenthesisDepth++; + continue; + } + + if (ch == ')' && _parenthesisDepth > 0) + { + _parenthesisDepth--; + continue; + } + + if (_parenthesisDepth != 0) + continue; + + if (ch == ',') + { + CompleteItem(); + continue; + } + + if (ch == ':' && IsTargetSpecifier(_attributeName, out var ignoreTarget)) + { + _sectionTargetIgnored = ignoreTarget; + _attributeName.Clear(); + _itemNameFinalized = false; + continue; + } + + if (!char.IsWhiteSpace(ch) && !_itemNameFinalized) + { + if (_attributeName.Length >= CSharpTestAttributeMaxNameCharacters) + { + _budgetExceeded = true; + } + else + { + _attributeName.Append(ch); + } + } } - scannedAttributeLine = true; - if (CSharpLineHasTestMethodAttribute(trimmed)) - return true; + return false; + } - var remainderIndex = trimmed.LastIndexOf(']'); - if (remainderIndex < 0) - return false; + private void StartSection() + { + _inAttributeSection = true; + _sectionHasTestAttribute = false; + _sectionTargetIgnored = false; + _itemNameFinalized = false; + _bracketDepth = 1; + _parenthesisDepth = 0; + _attributeName.Clear(); + } - var remainder = trimmed[(remainderIndex + 1)..].TrimStart(); - if (remainder.Length > 0) - return false; + private void CompleteItem() + { + FinalizeItemName(); + _blockItemCount++; + if (_blockItemCount > CSharpTestAttributeMaxItems) + _budgetExceeded = true; + + _attributeName.Clear(); + _itemNameFinalized = false; } - return false; - } + private void FinalizeItemName() + { + if (_itemNameFinalized) + return; - private static bool CSharpLineHasTestMethodAttribute(string trimmedLine) - { - var cursor = 0; - while (cursor < trimmedLine.Length && trimmedLine[cursor] == '[') + if (!_budgetExceeded + && _attributeName.Length > 0 + && CSharpTestMethodAttributeRegex.IsMatch(_attributeName.ToString())) + { + _sectionHasTestAttribute = true; + } + + _itemNameFinalized = true; + } + + private static bool IsTargetSpecifier(StringBuilder value, out bool ignoreTarget) { - var closeIndex = trimmedLine.IndexOf(']', cursor + 1); - if (closeIndex < 0) - return false; + var target = value.ToString(); + var isTarget = target is + "assembly" or "event" or "field" or "method" or "module" or + "param" or "property" or "return" or "type" or "typevar"; + ignoreTarget = target is "assembly" or "module" or "return"; + return isTarget; + } - var content = trimmedLine[(cursor + 1)..closeIndex]; - if (CSharpTestMethodAttributeRegex.IsMatch(content)) - return true; + private void CountLine() + { + _blockLineCount++; + if (_blockLineCount > CSharpTestAttributeMaxLines) + _budgetExceeded = true; + } - cursor = closeIndex + 1; - while (cursor < trimmedLine.Length && char.IsWhiteSpace(trimmedLine[cursor])) - cursor++; + private void CountCharacter() + { + _blockCharacterCount++; + if (_blockCharacterCount > CSharpTestAttributeMaxCharacters) + _budgetExceeded = true; } - return false; + private void ResetBlock() + { + _inAttributeSection = false; + _pendingAttributePrefix = false; + _blockHasTestAttribute = false; + _sectionHasTestAttribute = false; + _sectionTargetIgnored = false; + _itemNameFinalized = false; + _budgetExceeded = false; + _bracketDepth = 0; + _parenthesisDepth = 0; + _blockLineCount = 0; + _blockCharacterCount = 0; + _blockItemCount = 0; + _attributeName.Clear(); + } } private static void AddCppFriendDeclarationSymbol( diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs index 932b948f9..941b47d9a 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractionPhases.cs @@ -80,6 +80,7 @@ public PatternScanInputs( int[]?[] csharpMatchColumnToRaw = null!; string[]? csharpScopeLines = null; + bool[]? csharpTestMethodAttributedDeclarationLines = null; CSharpMatchLines = lang == "csharp" ? BuildCSharpMatchLines( lines, @@ -89,10 +90,12 @@ public PatternScanInputs( applyCSharpRegexProbeOptimizations, csharpRegexProbeCounts, out csharpMatchColumnToRaw, - out csharpScopeLines) + out csharpScopeLines, + out csharpTestMethodAttributedDeclarationLines) : null; CSharpMatchColumnToRaw = csharpMatchColumnToRaw; CSharpScopeLines = csharpScopeLines; + CSharpTestMethodAttributedDeclarationLines = csharpTestMethodAttributedDeclarationLines; GetCSharpLineStartStates = lang == "csharp" ? BuildCSharpLineStartStates : null; @@ -120,6 +123,7 @@ public PatternScanInputs( public int[]?[] CSharpMatchColumnToRaw { get; } public string[]? CSharpMatchLines { get; } public string[]? CSharpScopeLines { get; } + public bool[]? CSharpTestMethodAttributedDeclarationLines { get; } public Func? GetCSharpLineStartStates { get; } public Func? GetPrivateScopeColumns { get; } public Func? GetCSharpSwitchExpressionLines { get; } diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmission.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmission.cs index 42d84c96e..2c3110ad8 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmission.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmission.cs @@ -34,7 +34,8 @@ private readonly record struct PatternSymbolEmissionContext( List Symbols, SymbolExtractionState ExtractionState, HashSet? CssSeenSymbols, - HashSet? DockerfileStageNames); + HashSet? DockerfileStageNames, + bool IsCSharpTestMethod); private static string EmitPatternSymbols(PatternSymbolEmissionContext context) { @@ -55,7 +56,7 @@ private static string EmitPatternSymbols(PatternSymbolEmissionContext context) if (context.Language == "csharp" && context.Pattern.Kind == "function" - && IsCSharpTestMethod(context.Lines, context.LineIndex)) + && context.IsCSharpTestMethod) { kind = "test.method"; } diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmissionFlow.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmissionFlow.cs index 21262f117..31657c086 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmissionFlow.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmissionFlow.cs @@ -40,10 +40,17 @@ private static PatternScanResult EmitAcceptedPatternSymbol( var extractionState = extraction.ExtractionState; var cssSeenSymbols = extraction.CssSeenSymbols; var dockerfileStageNames = extraction.DockerfileStageNames; + var isCSharpTestMethod = lang == "csharp" + && scanInputs.CSharpTestMethodAttributedDeclarationLines is { } attributedDeclarationLines + && IsCSharpTestMethod( + attributedDeclarationLines, + i, + pattern.Kind == "function"); ref var scanState = ref extraction.ScanState; ref var pendingRecordPrimaryComponents = ref extraction.PendingRecordPrimaryComponents; ref var recordPrimaryComponentParentIndex = ref extraction.RecordPrimaryComponentParentIndex; emittedKind = kind; + var symbolCountBeforeEmission = symbols.Count; kind = EmitPatternSymbols( new PatternSymbolEmissionContext( fileId, @@ -68,7 +75,16 @@ private static PatternScanResult EmitAcceptedPatternSymbol( symbols, extractionState, cssSeenSymbols, - dockerfileStageNames)); + dockerfileStageNames, + isCSharpTestMethod)); + if (lang == "csharp" + && symbols.Count > symbolCountBeforeEmission + && scanInputs.CSharpTestMethodAttributedDeclarationLines is { } emittedAttributeLines) + { + ConsumeCSharpTestAttributePrefix( + emittedAttributeLines, + i); + } if (lang == "css" && pattern.Kind == "namespace" diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs index 50b60126b..016ad644d 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs @@ -6283,7 +6283,7 @@ FROM symbol_references SymbolExtractor.CSharpContractVersion.ToString( System.Globalization.CultureInfo.InvariantCulture), versionCmd.ExecuteScalar() as string); - Assert.Equal(15, SymbolExtractor.CSharpContractVersion); + Assert.Equal(16, SymbolExtractor.CSharpContractVersion); } finally { @@ -6549,7 +6549,7 @@ ORDER BY symbol_name SymbolExtractor.CSharpContractVersion.ToString( System.Globalization.CultureInfo.InvariantCulture), versionCmd.ExecuteScalar() as string); - Assert.Equal(15, SymbolExtractor.CSharpContractVersion); + Assert.Equal(16, SymbolExtractor.CSharpContractVersion); } finally { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5192Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5192Tests.cs new file mode 100644 index 000000000..b362338ff --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5192Tests.cs @@ -0,0 +1,126 @@ +using CodeIndex.Cli; +using CodeIndex.Database; +using static CodeIndex.Tests.QueryCommandTestSupport; + +namespace CodeIndex.Tests; + +public partial class QueryCommandRunnerTests +{ + [Fact] + public void CSharpMultilineTestAttributes_PersistAcrossSymbolCommandsAndTestFilters_Issue5192() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_csharp_multiline_test_attributes_5192"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "tests/CalculatorTests.cs", + "csharp", + """ + namespace Demo; + + public class CalculatorTests + { + [Theory] + [InlineData( + 1, + "closing ] and fake [Fact]")] + private void MultilineTheory(int value, string text) { } + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Calculator.cs", + "csharp", + """ + namespace Demo; + + public class Calculator + { + private void HelperMethod() { } + } + """); + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + db.InitializeSchema(); + new DbWriter(db.Connection).MarkGraphReady(); + } + + var (outlineExitCode, outlineStdout, outlineStderr) = CaptureConsole(() => + QueryCommandRunner.RunOutline( + ["tests/CalculatorTests.cs", "--db", dbPath, "--json"], + _jsonOptions)); + using var outlineDocument = ParseJsonOutput(outlineStdout); + var outlineTestMethod = Assert.Single( + outlineDocument.RootElement + .GetProperty("symbols") + .EnumerateArray() + .Where(symbol => symbol.GetProperty("name").GetString() == "MultilineTheory")); + + Assert.Equal(CommandExitCodes.Success, outlineExitCode); + Assert.Equal(string.Empty, outlineStderr); + Assert.Equal("test.method", outlineTestMethod.GetProperty("kind").GetString()); + + var (symbolsExitCode, symbolsStdout, symbolsStderr) = CaptureConsole(() => + QueryCommandRunner.RunSymbols( + ["MultilineTheory", "--db", dbPath, "--json", "--exact-name", "--kind", "test.method"], + _jsonOptions)); + var symbolRows = ParseJsonLines(symbolsStdout) + .Select(document => document.RootElement) + .ToArray(); + + Assert.Equal(CommandExitCodes.Success, symbolsExitCode); + Assert.Equal(string.Empty, symbolsStderr); + Assert.Equal("test.method", Assert.Single(symbolRows).GetProperty("kind").GetString()); + + var (inspectExitCode, inspectStdout, inspectStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect( + ["MultilineTheory", "--db", dbPath, "--json", "--exact"], + _jsonOptions)); + using var inspectDocument = ParseJsonOutput(inspectStdout); + var inspectedDefinition = Assert.Single( + inspectDocument.RootElement.GetProperty("definitions").EnumerateArray()); + + Assert.Equal(CommandExitCodes.Success, inspectExitCode); + Assert.Equal(string.Empty, inspectStderr); + Assert.Equal("test.method", inspectedDefinition.GetProperty("kind").GetString()); + + var (unusedExitCode, unusedStdout, unusedStderr) = CaptureConsole(() => + QueryCommandRunner.RunUnused( + ["--db", dbPath, "--json", "--all", "--lang", "csharp", "--limit", "50"], + _jsonOptions)); + using var unusedDocument = ParseJsonOutput(unusedStdout); + var unusedNames = unusedDocument.RootElement + .GetProperty("symbols") + .EnumerateArray() + .Select(symbol => symbol.GetProperty("name").GetString()) + .ToArray(); + + Assert.Equal(CommandExitCodes.Success, unusedExitCode); + Assert.Equal(string.Empty, unusedStderr); + Assert.Contains("MultilineTheory", unusedNames); + Assert.Contains("HelperMethod", unusedNames); + + var (excludedExitCode, excludedStdout, excludedStderr) = CaptureConsole(() => + QueryCommandRunner.RunUnused( + ["--db", dbPath, "--json", "--all", "--lang", "csharp", "--exclude-tests", "--limit", "50"], + _jsonOptions)); + using var excludedDocument = ParseJsonOutput(excludedStdout); + var excludedNames = excludedDocument.RootElement + .GetProperty("symbols") + .EnumerateArray() + .Select(symbol => symbol.GetProperty("name").GetString()) + .ToArray(); + + Assert.Equal(CommandExitCodes.Success, excludedExitCode); + Assert.Equal(string.Empty, excludedStderr); + Assert.DoesNotContain("MultilineTheory", excludedNames); + Assert.Contains("HelperMethod", excludedNames); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } +} diff --git a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs index 6cf9ae9d7..0b51ac353 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs @@ -524,6 +524,142 @@ public void HelperMethod() { } Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "HelperMethod"); } + [Fact] + public void Extract_CSharp_ClassifiesMultilineTestAttributeOwnership_Issue5192() + { + var content = """" + [assembly: Xunit.Theory] + [module: Xunit.Fact] + + namespace Demo.Tests; + + public class MultilineAttributeTests + { + [Theory] + [InlineData( + 1, + 2, + "closing ] and fake [Fact]", + @"verbatim ] and fake [Theory]", + """raw ] and fake [Test]""", + $"interpolated ] {1 + 2} and fake [TestMethod]", + ']')] + public void MultilineInlineData(int left, int right, string normal, string verbatim, string raw, string interpolated, char bracket) { } + + [Xunit.TheoryAttribute, + InlineData(3)] + public void QualifiedTheoryInMultiAttributeSection(int value) { } + + [Fact] + // ownership survives a line comment + [Trait( + "category", + "fast")] + /* ownership also survives a block comment */ + public void CommentsAndMultipleAttributes() { } + + [TestMethod] + public void MsTestMethod() { } + + [DataTestMethod] + public void MsDataTestMethod() { } + + [NUnit.Framework.Test] + public void NUnitTestMethod() { } + + [NUnit.Framework.TestCase( + 4)] + public void NUnitTestCaseMethod(int value) { } + + [NUnit.Framework.TestCaseSource( + nameof(Cases))] + public void NUnitTestCaseSourceMethod(int value) { } + + [Fact] public void SameLineFact() { } + + [method: Fact] + public void ExplicitMethodTargetFact() { } + + [Fact] public void FirstMemberBeforeBoundary() { } + public void FollowingMemberBoundary() { } + + [Obsolete( + "Theory ] [Fact]")] + public void NonTestMultilineAttribute() { } + + [Obsolete("Fact")] + public void LiteralNameDoesNotSpoof() { } + + [return: Xunit.Theory] + public int ReturnTargetDoesNotClassify() => 0; + + [Fact] public int AttributedProperty { get; } + public void MemberAfterAttributedProperty() { } + + public static int[] Cases => [4]; + } + """"; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var expectedTestMethods = new[] + { + "MultilineInlineData", + "QualifiedTheoryInMultiAttributeSection", + "CommentsAndMultipleAttributes", + "MsTestMethod", + "MsDataTestMethod", + "NUnitTestMethod", + "NUnitTestCaseMethod", + "NUnitTestCaseSourceMethod", + "SameLineFact", + "ExplicitMethodTargetFact", + "FirstMemberBeforeBoundary", + }; + var expectedNonTestMethods = new[] + { + "FollowingMemberBoundary", + "NonTestMultilineAttribute", + "LiteralNameDoesNotSpoof", + "ReturnTargetDoesNotClassify", + "MemberAfterAttributedProperty", + }; + + Assert.All( + expectedTestMethods, + name => Assert.Contains(symbols, symbol => symbol.Kind == "test.method" && symbol.Name == name)); + Assert.All( + expectedNonTestMethods, + name => Assert.Contains(symbols, symbol => symbol.Kind == "function" && symbol.Name == name)); + Assert.DoesNotContain( + symbols, + symbol => expectedNonTestMethods.Contains(symbol.Name, StringComparer.Ordinal) + && symbol.Kind == "test.method"); + } + + [Fact] + public void Extract_CSharp_TestAttributeBudgetsFailClosed_Issue5192() + { + var tooManyLines = "public class LineBudget\n{\n [Fact]\n" + + string.Concat(Enumerable.Repeat(" // padding\n", 128)) + + " public void BeyondLineBudget() { }\n}"; + var tooManyItems = "public class ItemBudget\n{\n [" + + string.Join(", ", Enumerable.Repeat("Obsolete", 64)) + + ", Fact]\n public void BeyondItemBudget() { }\n}"; + var tooLongName = "public class NameBudget\n{\n [" + + new string('A', 512) + + "Fact]\n public void BeyondNameBudget() { }\n}"; + + Assert.Contains( + SymbolExtractor.Extract(1, "csharp", tooManyLines), + symbol => symbol.Kind == "function" && symbol.Name == "BeyondLineBudget"); + Assert.Contains( + SymbolExtractor.Extract(1, "csharp", tooManyItems), + symbol => symbol.Kind == "function" && symbol.Name == "BeyondItemBudget"); + Assert.Contains( + SymbolExtractor.Extract(1, "csharp", tooLongName), + symbol => symbol.Kind == "function" && symbol.Name == "BeyondNameBudget"); + } + [Fact] public void Extract_CSharp_NormalizesUnicodeEscapedIdentifierNames() { From c4d73d0e74822a8fab2d4e35084defd5b6311fbf Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 27 Aug 2026 17:07:15 +0900 Subject: [PATCH 2/3] Harden C# test attribute ownership (#5192) --- DEVELOPER_GUIDE.md | 9 +++- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/5192.fixed.md | 4 +- .../SymbolExtractor.DeclarationMetadata.cs | 31 ++++++++++++- .../SymbolExtractor.PatternEmission.cs | 2 +- .../SymbolExtractor.PatternEmissionFlow.cs | 3 +- .../SymbolExtractorCSharpTests.cs | 45 ++++++++++++++++++- 7 files changed, 87 insertions(+), 11 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 1167fd0e5..be57a821f 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1294,7 +1294,10 @@ block rather than from the immediately preceding physical line. The bounded scan reuses the C# lexical masking pass, so multiline attribute arguments and consecutive attribute lists are supported while brackets and test-like names in comments or normal, verbatim, raw, interpolated, and character literals are ignored. Explicit -`return:`, `assembly:`, and `module:` targets do not classify a method. +`return:`, `assembly:`, and `module:` targets do not classify a method. A declaration- +context gate rejects bracket-led collection expressions, and only shaped method +declarations with return types can receive the test taxonomy; fields and constructors +consume their attribute ownership without being reclassified. `SymbolKindCatalog.CompatibilityKindFamilies` maps both `typealias` and `type_parameter` to the broad `type` family for consumers that only understand @@ -5375,7 +5378,9 @@ block 全体から判定します。上限付き scanner は C# の字句 maskin 複数行の attribute 引数と連続する attribute list を扱いつつ、comment、通常文字列、 verbatim 文字列、raw 文字列、interpolated 文字列、文字 literal 内の bracket や test 風の 名前を無視します。明示的な `return:`、`assembly:`、`module:` target は method を -`test.method` に分類しません。 +`test.method` に分類しません。declaration context gate は行頭 bracket の collection +expression を拒否し、return type を持つ shaped method declaration だけが test taxonomy +を受け取ります。field と constructor は attribute 所有権を消費しますが、再分類しません。 古い粗い taxonomy だけを理解する consumer 向けに、 `SymbolKindCatalog.CompatibilityKindFamilies` は `typealias` と diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 1f910434c..498871285 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -114,7 +114,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result Extractor coverage is split by language or feature area with partial test classes, while shared helpers remain on the root `SymbolExtractorTests` / `ReferenceExtractorTests` parts. YAML sequence-hierarchy fixtures must keep sequence items path-only, count empty/scalar/alias items in stable indexes, preserve anchor and block-scalar boundaries, assert logical qualified paths separately from nearest emitted symbol parents, and prove a normal full scan refreshes unchanged YAML rows stamped with the prior extractor contract. C# declaration-boundary regressions should pair a direct extractor fixture with a real-index `symbols --exact-name` query. Keep invocation and parameter continuations beside valid multi-line methods, constructors, delegates, and local functions so both false-positive rejection and declaration ranges remain observable. Scope-continuation fixtures must also place unmatched delimiters in character/string literals and comments before later methods, then assert that the later methods remain definitions rather than unresolved declaration-shaped calls. - C# test-attribute ownership coverage pairs `SymbolExtractorCSharpTests` with `QueryCommandRunnerIssue5192Tests`. Keep multiline and same-line attribute lists, comments, qualified xUnit names, MSTest/NUnit forms, literal-contained brackets, ignored targets, member boundaries, and fail-closed budgets in the direct fixture; the persisted fixture must retain `test.method` through outline, symbols, inspect, unused, and `--exclude-tests` behavior (#5192). + C# test-attribute ownership coverage pairs `SymbolExtractorCSharpTests` with `QueryCommandRunnerIssue5192Tests`. Keep multiline and same-line attribute lists, comments, qualified xUnit names, MSTest/NUnit forms, literal-contained brackets, collection expressions, attributed fields/constructors, ignored targets, member boundaries, and fail-closed budgets in the direct fixture; the persisted fixture must retain `test.method` through outline, symbols, inspect, unused, and `--exclude-tests` behavior (#5192, regression of #1961). C# explicit-interface identity coverage pairs extractor assertions for methods, properties, events, and indexers with persisted exact qualified/unqualified queries, fold rewrite validation, inspect/outline checks, and LSP definition/reference scoping. Include multiple and inherited interfaces, generic arity, a same-named public member, and a qualified return type that must not be mistaken for an explicit-interface qualifier. C# callable-containment fixtures should cover block-bodied test methods, local and nested local functions, named lambdas, expression-bodied members, and nested types together, asserting both symbol parents and call-reference containers. Local-function identity coverage additionally keeps pre/post-declaration calls, nested and sibling blocks, overloads, parameter/local delegate shadowing, method groups, identical enclosing names in unrelated types, incomplete ranges, persisted graph/query/inspect/impact behavior, and LSP definition/reference parity in one coordinated contract (#5188, extending #4840). C# switch-expression returned-lambda coverage pairs a direct `ReferenceExtractorCSharpTests` regression with an `IndexCommandRunnerFullScanTests` `--rebuild` graph fixture. Keep expression/block, parenthesized, multiline, nested-switch, async, qualified/unqualified, and non-lambda arms together; assert raw call rows, enclosing containers, exact locations, callers/callees, complete graph readiness, and negative positional-pattern/enum controls including guarded, nested, and contextual-keyword-designation patterns with a following-line arrow (#5085). @@ -1253,7 +1253,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" extractor のカバレッジは言語または機能領域ごとの partial test class に分割し、共有 helper は root 側の `SymbolExtractorTests` / `ReferenceExtractorTests` に残します。 YAML sequence hierarchy の fixture では、sequence item を path 専用のまま保ち、empty / scalar / alias item を安定した index に数え、anchor と block scalar の境界を維持し、論理 qualified path と最寄りの出力済み symbol parent を分けて検証し、旧 extractor contract が stamp された未変更 YAML row を通常の full scan が更新することも確認してください。 C# の declaration boundary に関する regression では、extractor を直接呼ぶ fixture と、実 index に対する `symbols --exact-name` query を組み合わせてください。呼び出し・parameter の continuation と、正当な複数行 method、constructor、delegate、local function を同居させ、false positive の拒否と宣言 range の両方を観測可能にします。スコープ継続用のテストでは、後続メソッドより前の文字・文字列リテラルとコメントに対応しない区切り記号も置き、後続メソッドが未解決の宣言形式呼び出しではなく定義のままになることも検証してください。 - C# の test attribute 所有権 coverage は `SymbolExtractorCSharpTests` と `QueryCommandRunnerIssue5192Tests` を組み合わせます。直接 fixture では複数行 / 同一行の attribute list、comment、修飾 xUnit 名、MSTest / NUnit 形式、literal 内の bracket、無視する target、member boundary、fail-closed budget を維持し、永続化 fixture では outline、symbols、inspect、unused、`--exclude-tests` の各動作で `test.method` が保たれることを確認してください(#5192)。 + C# の test attribute 所有権 coverage は `SymbolExtractorCSharpTests` と `QueryCommandRunnerIssue5192Tests` を組み合わせます。直接 fixture では複数行 / 同一行の attribute list、comment、修飾 xUnit 名、MSTest / NUnit 形式、literal 内の bracket、collection expression、attribute 付き field / constructor、無視する target、member boundary、fail-closed budget を維持し、永続化 fixture では outline、symbols、inspect、unused、`--exclude-tests` の各動作で `test.method` が保たれることを確認してください(#5192、#1961 の回帰)。 C# の明示的 interface identity coverage では、method、property、event、indexer の extractor assertion と、永続化後の修飾 / 非修飾の完全一致 query、fold rewrite 検証、inspect / outline、LSP の definition / reference scope を組み合わせます。複数および継承 interface、generic arity、同名 public member に加え、明示的 interface qualifier と誤認してはならない修飾 return type を含めてください。 C# generic outline 表示の coverage は、generic / 非 generic overload、extension / local / explicit-interface method、nested generic の tuple / nullable / array parameter type、constraint、overload を区別する modifier、修飾型 / 具体型名との衝突、escaped / Unicode identifier、attribute / 既定値 literal 内の区切り文字を、抽出と reader setup を共有する1つの `DbReaderTests` fixture に維持します。line 移動を検証する対の fixture は先頭改行以外を同一にし、canonical name / path が不変であることを assertion に含め、旧 index の `Name@line` fallback 用に不完全 signature を手動永続化した row も1件残してください(#4916)。 C# の位置 record に対する検索帰属 coverage は、extractor の座標 assertion、1つの indexed reader fixture、1つの CLI fixture を組み合わせます。record keyword と型名、attribute と component 型、base 引数、body member、同一行の通常 property、複数行 `record struct` component、正規化された verbatim identifier の列、言語未指定の exact grouping、multi-hit の focus 選択、focus の行・列出力、symbol grouping を共通契約に維持します。component 列を持たない旧 symbol では行ベースの fallback を維持してください。C# の座標維持のために comment padding を変更する場合、Java/Kotlin の record component comment が既存 signature を維持することも assertion してください(#5095)。 diff --git a/changelog.d/unreleased/5192.fixed.md b/changelog.d/unreleased/5192.fixed.md index c99e8e8b3..84b07888b 100644 --- a/changelog.d/unreleased/5192.fixed.md +++ b/changelog.d/unreleased/5192.fixed.md @@ -18,8 +18,8 @@ affected: ## English -- **C# test methods keep their taxonomy across multiline attribute blocks (#5192)** — `Fact`, `Theory`, MSTest, and NUnit markers now remain attached through multiline arguments, consecutive attribute lists, and intervening comments, while literals, ignored attribute targets, member boundaries, and bounded fail-closed inputs cannot spoof or leak `test.method` classification. The C# extractor contract advances so unchanged files in existing indexes are re-extracted. +- **C# test methods keep their taxonomy across multiline attribute blocks (#5192, regression of #1961)** — `Fact`, `Theory`, MSTest, and NUnit markers now remain attached through multiline arguments, consecutive attribute lists, and intervening comments, while literals, collection expressions, non-method declarations, ignored attribute targets, member boundaries, and bounded fail-closed inputs cannot spoof or leak `test.method` classification. The C# extractor contract advances so unchanged files in existing indexes are re-extracted. ## 日本語 -- **複数行の attribute block でも C# test method の taxonomy を維持するようになりました (#5192)** — `Fact`、`Theory`、MSTest、NUnit の marker は、複数行引数、連続する attribute list、途中の comment を越えて正しい method に関連付けられ、literal、無視対象の attribute target、member boundary、上限を超えて fail-closed となる入力が `test.method` 分類を偽装または後続宣言へ漏出することを防ぎます。C# extractor contract も更新し、既存 index の未変更ファイルを再抽出します。 +- **複数行の attribute block でも C# test method の taxonomy を維持するようになりました (#5192、#1961 の回帰)** — `Fact`、`Theory`、MSTest、NUnit の marker は、複数行引数、連続する attribute list、途中の comment を越えて正しい method に関連付けられ、literal、collection expression、method 以外の宣言、無視対象の attribute target、member boundary、上限を超えて fail-closed となる入力が `test.method` 分類を偽装または後続宣言へ漏出することを防ぎます。C# extractor contract も更新し、既存 index の未変更ファイルを再抽出します。 diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.DeclarationMetadata.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.DeclarationMetadata.cs index 692b1389d..7dd130a6c 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.DeclarationMetadata.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.DeclarationMetadata.cs @@ -161,6 +161,7 @@ private sealed class CSharpTestAttributePrefixScanner private bool _sectionTargetIgnored; private bool _itemNameFinalized; private bool _budgetExceeded; + private bool _canStartAttributePrefix = true; private int _bracketDepth; private int _parenthesisDepth; private int _blockLineCount; @@ -178,12 +179,19 @@ public bool ScanLine(string sanitizedLine) if (!_inAttributeSection && !_pendingAttributePrefix) { - if (firstNonWhitespace >= sanitizedLine.Length - || sanitizedLine[firstNonWhitespace] != '[') + if (firstNonWhitespace >= sanitizedLine.Length) { return false; } + if (sanitizedLine[firstNonWhitespace] != '[' + || !_canStartAttributePrefix) + { + _canStartAttributePrefix = + CanStartCSharpAttributePrefixAfter(sanitizedLine); + return false; + } + ResetBlock(); } @@ -212,6 +220,8 @@ public bool ScanLine(string sanitizedLine) var isAttributedDeclaration = !_budgetExceeded && _pendingAttributePrefix && _blockHasTestAttribute; + _canStartAttributePrefix = + CanStartCSharpAttributePrefixAfter(sanitizedLine); ResetBlock(); return isAttributedDeclaration; } @@ -292,6 +302,23 @@ public bool ScanLine(string sanitizedLine) return false; } + private static bool CanStartCSharpAttributePrefixAfter(string sanitizedLine) + { + var trimmed = sanitizedLine.AsSpan().Trim(); + if (trimmed.Length == 0) + return false; + + // A declaration attribute may begin at the file start or after a completed + // declaration/statement/body. A bracket-led line following an expression + // continuation (`=>`, `=`, `return`, an argument list, and so on) is instead + // a collection expression and must not create attribute ownership. + // declaration attribute は file 先頭または完了した宣言・statement・body の後に + // 開始できる。expression continuation 後の行頭 bracket は collection expression + // なので、attribute 所有権を作らない。 + return trimmed[0] == '#' + || trimmed[^1] is ';' or '{' or '}'; + } + private void StartSection() { _inAttributeSection = true; diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmission.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmission.cs index 2c3110ad8..cebbc85b0 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmission.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmission.cs @@ -55,7 +55,7 @@ private static string EmitPatternSymbols(PatternSymbolEmissionContext context) return kind; if (context.Language == "csharp" - && context.Pattern.Kind == "function" + && context.Kind == "function" && context.IsCSharpTestMethod) { kind = "test.method"; diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmissionFlow.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmissionFlow.cs index 31657c086..980c752b6 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmissionFlow.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmissionFlow.cs @@ -45,7 +45,8 @@ private static PatternScanResult EmitAcceptedPatternSymbol( && IsCSharpTestMethod( attributedDeclarationLines, i, - pattern.Kind == "function"); + shapedSymbol.Kind == "function" + && shapedSymbol.RawReturnType != null); ref var scanState = ref extraction.ScanState; ref var pendingRecordPrimaryComponents = ref extraction.PendingRecordPrimaryComponents; ref var recordPrimaryComponentParentIndex = ref extraction.RecordPrimaryComponentParentIndex; diff --git a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs index 0b51ac353..b34252eb6 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs @@ -525,7 +525,7 @@ public void HelperMethod() { } } [Fact] - public void Extract_CSharp_ClassifiesMultilineTestAttributeOwnership_Issue5192() + public void Extract_CSharp_ClassifiesMultilineTestAttributeOwnership_Issue5192_Regression1961() { var content = """" [assembly: Xunit.Theory] @@ -533,6 +533,12 @@ public void Extract_CSharp_ClassifiesMultilineTestAttributeOwnership_Issue5192() namespace Demo.Tests; + [System.AttributeUsage(System.AttributeTargets.Field)] + public sealed class FieldFactAttribute : System.Attribute { } + + [System.AttributeUsage(System.AttributeTargets.Constructor)] + public sealed class ConstructorFactAttribute : System.Attribute { } + public class MultilineAttributeTests { [Theory] @@ -544,6 +550,14 @@ public class MultilineAttributeTests """raw ] and fake [Test]""", $"interpolated ] {1 + 2} and fake [TestMethod]", ']')] + [InlineData( + 3, + 4, + "second multiline data", + @"second verbatim value", + """second raw value""", + $"second interpolated {3 + 4}", + '[')] public void MultilineInlineData(int left, int right, string normal, string verbatim, string raw, string interpolated, char bracket) { } [Xunit.TheoryAttribute, @@ -596,6 +610,27 @@ public void LiteralNameDoesNotSpoof() { } [Fact] public int AttributedProperty { get; } public void MemberAfterAttributedProperty() { } + [FieldFact] + [System.Obsolete( + "const field marker")] + public const int AttributedConstField = 1; + + [FieldFact] + [System.Obsolete( + "readonly field marker")] + public static readonly int AttributedReadonlyField = 2; + + [ConstructorFact] + [System.Obsolete( + "constructor marker")] + public MultilineAttributeTests() { } + + public int[] CollectionExpression => + [ + Fact + ]; public void CollectionExpressionDoesNotSpoofTest() { } + + public static int Fact => 1; public static int[] Cases => [4]; } """"; @@ -622,6 +657,8 @@ public void MemberAfterAttributedProperty() { } "LiteralNameDoesNotSpoof", "ReturnTargetDoesNotClassify", "MemberAfterAttributedProperty", + "MultilineAttributeTests", + "CollectionExpressionDoesNotSpoofTest", }; Assert.All( @@ -634,6 +671,12 @@ public void MemberAfterAttributedProperty() { } symbols, symbol => expectedNonTestMethods.Contains(symbol.Name, StringComparer.Ordinal) && symbol.Kind == "test.method"); + Assert.Contains( + symbols, + symbol => symbol.Kind == "field" && symbol.Name == "AttributedConstField"); + Assert.Contains( + symbols, + symbol => symbol.Kind == "field" && symbol.Name == "AttributedReadonlyField"); } [Fact] From 78c15317146b51dde92bb62f5b3969940bc7777a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 27 Aug 2026 17:38:41 +0900 Subject: [PATCH 3/3] Fix adversarial C# attribute cases (#5192) --- DEVELOPER_GUIDE.md | 26 ++-- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/5192.fixed.md | 4 +- .../Symbols/SymbolExtractor.CSharpScanner.cs | 3 +- .../SymbolExtractor.DeclarationMetadata.cs | 116 +++++++++++++++--- .../SymbolExtractor.PatternEmissionFlow.cs | 3 +- .../QueryCommandRunnerIssue5192Tests.cs | 18 +++ .../SymbolExtractorCSharpTests.cs | 42 +++++++ 8 files changed, 184 insertions(+), 32 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index be57a821f..389f38926 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1292,12 +1292,14 @@ the public arrays at runtime. For C#, `test.method` ownership is determined from the complete leading attribute block rather than from the immediately preceding physical line. The bounded scanner reuses the C# lexical masking pass, so multiline attribute arguments and consecutive -attribute lists are supported while brackets and test-like names in comments or -normal, verbatim, raw, interpolated, and character literals are ignored. Explicit +attribute lists are supported while generic attribute type arguments stay within their +own item and brackets or test-like names in comments and normal, verbatim, raw, +interpolated, and character literals are ignored. Explicit `return:`, `assembly:`, and `module:` targets do not classify a method. A declaration- -context gate rejects bracket-led collection expressions, and only shaped method -declarations with return types can receive the test taxonomy; fields and constructors -consume their attribute ownership without being reclassified. +context gate tracks expression-initializer braces and rejects bracket-led collection +expressions. Only shaped ordinary method declarations with return types can receive the +test taxonomy; fields, indexers, and constructors (including implicit-return partial +constructors) consume their attribute ownership without being reclassified. `SymbolKindCatalog.CompatibilityKindFamilies` maps both `typealias` and `type_parameter` to the broad `type` family for consumers that only understand @@ -5375,12 +5377,14 @@ test も更新してください。公開 array を実行時に変更しては C# の `test.method` 所有権は、直前の物理行だけではなく、先頭に連続する attribute block 全体から判定します。上限付き scanner は C# の字句 masking pass を再利用するため、 -複数行の attribute 引数と連続する attribute list を扱いつつ、comment、通常文字列、 -verbatim 文字列、raw 文字列、interpolated 文字列、文字 literal 内の bracket や test 風の -名前を無視します。明示的な `return:`、`assembly:`、`module:` target は method を -`test.method` に分類しません。declaration context gate は行頭 bracket の collection -expression を拒否し、return type を持つ shaped method declaration だけが test taxonomy -を受け取ります。field と constructor は attribute 所有権を消費しますが、再分類しません。 +複数行の attribute 引数と連続する attribute list を扱い、generic attribute の型引数を +同じ item 内に保ちつつ、comment、通常文字列、verbatim 文字列、raw 文字列、interpolated +文字列、文字 literal 内の bracket や test 風の名前を無視します。明示的な `return:`、 +`assembly:`、`module:` target は method を `test.method` に分類しません。declaration +context gate は expression initializer の brace を追跡して行頭 bracket の collection +expression を拒否します。return type を持つ shaped な通常 method declaration だけが test +taxonomy を受け取り、field、indexer、constructor(暗黙 return の partial constructor を含む) +は attribute 所有権を消費しますが、再分類しません。 古い粗い taxonomy だけを理解する consumer 向けに、 `SymbolKindCatalog.CompatibilityKindFamilies` は `typealias` と diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 498871285..5bf45e37e 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -114,7 +114,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result Extractor coverage is split by language or feature area with partial test classes, while shared helpers remain on the root `SymbolExtractorTests` / `ReferenceExtractorTests` parts. YAML sequence-hierarchy fixtures must keep sequence items path-only, count empty/scalar/alias items in stable indexes, preserve anchor and block-scalar boundaries, assert logical qualified paths separately from nearest emitted symbol parents, and prove a normal full scan refreshes unchanged YAML rows stamped with the prior extractor contract. C# declaration-boundary regressions should pair a direct extractor fixture with a real-index `symbols --exact-name` query. Keep invocation and parameter continuations beside valid multi-line methods, constructors, delegates, and local functions so both false-positive rejection and declaration ranges remain observable. Scope-continuation fixtures must also place unmatched delimiters in character/string literals and comments before later methods, then assert that the later methods remain definitions rather than unresolved declaration-shaped calls. - C# test-attribute ownership coverage pairs `SymbolExtractorCSharpTests` with `QueryCommandRunnerIssue5192Tests`. Keep multiline and same-line attribute lists, comments, qualified xUnit names, MSTest/NUnit forms, literal-contained brackets, collection expressions, attributed fields/constructors, ignored targets, member boundaries, and fail-closed budgets in the direct fixture; the persisted fixture must retain `test.method` through outline, symbols, inspect, unused, and `--exclude-tests` behavior (#5192, regression of #1961). + C# test-attribute ownership coverage pairs `SymbolExtractorCSharpTests` with `QueryCommandRunnerIssue5192Tests`. Keep multiline and same-line attribute lists, comments, qualified xUnit names, MSTest/NUnit forms, generic attribute type arguments containing test-like names, literal-contained brackets, direct and initializer-brace collection expressions, attributed fields/indexers/ordinary and partial constructors, ignored targets, member boundaries, and fail-closed budgets in the direct fixture; the persisted fixture must retain `test.method` through outline, symbols, inspect, unused, and `--exclude-tests` behavior while non-test methods remain visible (#5192, regression of #1961). C# explicit-interface identity coverage pairs extractor assertions for methods, properties, events, and indexers with persisted exact qualified/unqualified queries, fold rewrite validation, inspect/outline checks, and LSP definition/reference scoping. Include multiple and inherited interfaces, generic arity, a same-named public member, and a qualified return type that must not be mistaken for an explicit-interface qualifier. C# callable-containment fixtures should cover block-bodied test methods, local and nested local functions, named lambdas, expression-bodied members, and nested types together, asserting both symbol parents and call-reference containers. Local-function identity coverage additionally keeps pre/post-declaration calls, nested and sibling blocks, overloads, parameter/local delegate shadowing, method groups, identical enclosing names in unrelated types, incomplete ranges, persisted graph/query/inspect/impact behavior, and LSP definition/reference parity in one coordinated contract (#5188, extending #4840). C# switch-expression returned-lambda coverage pairs a direct `ReferenceExtractorCSharpTests` regression with an `IndexCommandRunnerFullScanTests` `--rebuild` graph fixture. Keep expression/block, parenthesized, multiline, nested-switch, async, qualified/unqualified, and non-lambda arms together; assert raw call rows, enclosing containers, exact locations, callers/callees, complete graph readiness, and negative positional-pattern/enum controls including guarded, nested, and contextual-keyword-designation patterns with a following-line arrow (#5085). @@ -1253,7 +1253,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" extractor のカバレッジは言語または機能領域ごとの partial test class に分割し、共有 helper は root 側の `SymbolExtractorTests` / `ReferenceExtractorTests` に残します。 YAML sequence hierarchy の fixture では、sequence item を path 専用のまま保ち、empty / scalar / alias item を安定した index に数え、anchor と block scalar の境界を維持し、論理 qualified path と最寄りの出力済み symbol parent を分けて検証し、旧 extractor contract が stamp された未変更 YAML row を通常の full scan が更新することも確認してください。 C# の declaration boundary に関する regression では、extractor を直接呼ぶ fixture と、実 index に対する `symbols --exact-name` query を組み合わせてください。呼び出し・parameter の continuation と、正当な複数行 method、constructor、delegate、local function を同居させ、false positive の拒否と宣言 range の両方を観測可能にします。スコープ継続用のテストでは、後続メソッドより前の文字・文字列リテラルとコメントに対応しない区切り記号も置き、後続メソッドが未解決の宣言形式呼び出しではなく定義のままになることも検証してください。 - C# の test attribute 所有権 coverage は `SymbolExtractorCSharpTests` と `QueryCommandRunnerIssue5192Tests` を組み合わせます。直接 fixture では複数行 / 同一行の attribute list、comment、修飾 xUnit 名、MSTest / NUnit 形式、literal 内の bracket、collection expression、attribute 付き field / constructor、無視する target、member boundary、fail-closed budget を維持し、永続化 fixture では outline、symbols、inspect、unused、`--exclude-tests` の各動作で `test.method` が保たれることを確認してください(#5192、#1961 の回帰)。 + C# の test attribute 所有権 coverage は `SymbolExtractorCSharpTests` と `QueryCommandRunnerIssue5192Tests` を組み合わせます。直接 fixture では複数行 / 同一行の attribute list、comment、修飾 xUnit 名、MSTest / NUnit 形式、test 風の名前を含む generic attribute 型引数、literal 内の bracket、直接および initializer brace 内の collection expression、attribute 付き field / indexer / 通常 constructor / partial constructor、無視する target、member boundary、fail-closed budget を維持し、永続化 fixture では outline、symbols、inspect、unused、`--exclude-tests` の各動作で `test.method` が保たれつつ非 test method が表示されることを確認してください(#5192、#1961 の回帰)。 C# の明示的 interface identity coverage では、method、property、event、indexer の extractor assertion と、永続化後の修飾 / 非修飾の完全一致 query、fold rewrite 検証、inspect / outline、LSP の definition / reference scope を組み合わせます。複数および継承 interface、generic arity、同名 public member に加え、明示的 interface qualifier と誤認してはならない修飾 return type を含めてください。 C# generic outline 表示の coverage は、generic / 非 generic overload、extension / local / explicit-interface method、nested generic の tuple / nullable / array parameter type、constraint、overload を区別する modifier、修飾型 / 具体型名との衝突、escaped / Unicode identifier、attribute / 既定値 literal 内の区切り文字を、抽出と reader setup を共有する1つの `DbReaderTests` fixture に維持します。line 移動を検証する対の fixture は先頭改行以外を同一にし、canonical name / path が不変であることを assertion に含め、旧 index の `Name@line` fallback 用に不完全 signature を手動永続化した row も1件残してください(#4916)。 C# の位置 record に対する検索帰属 coverage は、extractor の座標 assertion、1つの indexed reader fixture、1つの CLI fixture を組み合わせます。record keyword と型名、attribute と component 型、base 引数、body member、同一行の通常 property、複数行 `record struct` component、正規化された verbatim identifier の列、言語未指定の exact grouping、multi-hit の focus 選択、focus の行・列出力、symbol grouping を共通契約に維持します。component 列を持たない旧 symbol では行ベースの fallback を維持してください。C# の座標維持のために comment padding を変更する場合、Java/Kotlin の record component comment が既存 signature を維持することも assertion してください(#5095)。 diff --git a/changelog.d/unreleased/5192.fixed.md b/changelog.d/unreleased/5192.fixed.md index 84b07888b..84ec39ba8 100644 --- a/changelog.d/unreleased/5192.fixed.md +++ b/changelog.d/unreleased/5192.fixed.md @@ -18,8 +18,8 @@ affected: ## English -- **C# test methods keep their taxonomy across multiline attribute blocks (#5192, regression of #1961)** — `Fact`, `Theory`, MSTest, and NUnit markers now remain attached through multiline arguments, consecutive attribute lists, and intervening comments, while literals, collection expressions, non-method declarations, ignored attribute targets, member boundaries, and bounded fail-closed inputs cannot spoof or leak `test.method` classification. The C# extractor contract advances so unchanged files in existing indexes are re-extracted. +- **C# test methods keep their taxonomy across multiline attribute blocks (#5192, regression of #1961)** — `Fact`, `Theory`, MSTest, and NUnit markers now remain attached through multiline arguments, consecutive attribute lists, and intervening comments, while literals, generic attribute type arguments, direct or initializer-brace collection expressions, fields, indexers, constructors, ignored attribute targets, member boundaries, and bounded fail-closed inputs cannot spoof or leak `test.method` classification. The C# extractor contract advances so unchanged files in existing indexes are re-extracted. ## 日本語 -- **複数行の attribute block でも C# test method の taxonomy を維持するようになりました (#5192、#1961 の回帰)** — `Fact`、`Theory`、MSTest、NUnit の marker は、複数行引数、連続する attribute list、途中の comment を越えて正しい method に関連付けられ、literal、collection expression、method 以外の宣言、無視対象の attribute target、member boundary、上限を超えて fail-closed となる入力が `test.method` 分類を偽装または後続宣言へ漏出することを防ぎます。C# extractor contract も更新し、既存 index の未変更ファイルを再抽出します。 +- **複数行の attribute block でも C# test method の taxonomy を維持するようになりました (#5192、#1961 の回帰)** — `Fact`、`Theory`、MSTest、NUnit の marker は、複数行引数、連続する attribute list、途中の comment を越えて正しい method に関連付けられ、literal、generic attribute 型引数、直接または initializer brace 内の collection expression、field、indexer、constructor、無視対象の attribute target、member boundary、上限を超えて fail-closed となる入力が `test.method` 分類を偽装または後続宣言へ漏出することを防ぎます。C# extractor contract も更新し、既存 index の未変更ファイルを再抽出します。 diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs index c9fc33717..ede8fcc40 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs @@ -811,12 +811,13 @@ private static void NormalizeCSharpImplicitPartialConstructorReturnTypes(List= 0 && declarationLineIndex < attributedDeclarationLines.Length && attributedDeclarationLines[declarationLineIndex]; @@ -164,6 +164,11 @@ private sealed class CSharpTestAttributePrefixScanner private bool _canStartAttributePrefix = true; private int _bracketDepth; private int _parenthesisDepth; + private int _genericArgumentDepth; + private bool _inExpressionInitializer; + private int _expressionInitializerBraceDepth; + private int _codeParenthesisDepth; + private int _codeBracketDepth; private int _blockLineCount; private int _blockCharacterCount; private int _blockItemCount; @@ -187,8 +192,7 @@ public bool ScanLine(string sanitizedLine) if (sanitizedLine[firstNonWhitespace] != '[' || !_canStartAttributePrefix) { - _canStartAttributePrefix = - CanStartCSharpAttributePrefixAfter(sanitizedLine); + UpdateCSharpAttributePrefixContext(sanitizedLine); return false; } @@ -220,8 +224,7 @@ public bool ScanLine(string sanitizedLine) var isAttributedDeclaration = !_budgetExceeded && _pendingAttributePrefix && _blockHasTestAttribute; - _canStartAttributePrefix = - CanStartCSharpAttributePrefixAfter(sanitizedLine); + UpdateCSharpAttributePrefixContext(sanitizedLine.AsSpan(cursor)); ResetBlock(); return isAttributedDeclaration; } @@ -247,6 +250,7 @@ public bool ScanLine(string sanitizedLine) _inAttributeSection = false; _pendingAttributePrefix = true; _parenthesisDepth = 0; + _genericArgumentDepth = 0; } continue; @@ -272,6 +276,23 @@ public bool ScanLine(string sanitizedLine) if (_parenthesisDepth != 0) continue; + if (ch == '<') + { + if (_genericArgumentDepth == 0) + FinalizeItemName(); + _genericArgumentDepth++; + continue; + } + + if (ch == '>' && _genericArgumentDepth > 0) + { + _genericArgumentDepth--; + continue; + } + + if (_genericArgumentDepth != 0) + continue; + if (ch == ',') { CompleteItem(); @@ -302,21 +323,84 @@ public bool ScanLine(string sanitizedLine) return false; } - private static bool CanStartCSharpAttributePrefixAfter(string sanitizedLine) + private void UpdateCSharpAttributePrefixContext(ReadOnlySpan sanitizedLine) { - var trimmed = sanitizedLine.AsSpan().Trim(); + var trimmed = sanitizedLine.Trim(); if (trimmed.Length == 0) - return false; + return; + + for (var cursor = 0; cursor < sanitizedLine.Length; cursor++) + { + var ch = sanitizedLine[cursor]; + if (ch == '(') + { + _codeParenthesisDepth++; + continue; + } + + if (ch == ')' && _codeParenthesisDepth > 0) + { + _codeParenthesisDepth--; + continue; + } + + if (ch == '[') + { + _codeBracketDepth++; + continue; + } + + if (ch == ']' && _codeBracketDepth > 0) + { + _codeBracketDepth--; + continue; + } + + if (!_inExpressionInitializer + && ch == '=' + && _codeParenthesisDepth == 0 + && _codeBracketDepth == 0 + && IsCSharpAssignmentOperator(sanitizedLine, cursor)) + { + _inExpressionInitializer = true; + continue; + } + + if (!_inExpressionInitializer) + continue; + + if (ch == '{') + { + _expressionInitializerBraceDepth++; + } + else if (ch == '}' && _expressionInitializerBraceDepth > 0) + { + _expressionInitializerBraceDepth--; + } + else if (ch == ';' && _expressionInitializerBraceDepth == 0) + { + _inExpressionInitializer = false; + } + } // A declaration attribute may begin at the file start or after a completed // declaration/statement/body. A bracket-led line following an expression - // continuation (`=>`, `=`, `return`, an argument list, and so on) is instead - // a collection expression and must not create attribute ownership. + // continuation (`=>`, `=`, `return`, an argument list, and so on), including + // an initializer brace, is instead a collection expression and must not create + // attribute ownership. // declaration attribute は file 先頭または完了した宣言・statement・body の後に - // 開始できる。expression continuation 後の行頭 bracket は collection expression - // なので、attribute 所有権を作らない。 - return trimmed[0] == '#' - || trimmed[^1] is ';' or '{' or '}'; + // 開始できる。initializer brace を含む expression continuation 後の行頭 bracket は + // collection expression なので、attribute 所有権を作らない。 + _canStartAttributePrefix = !_inExpressionInitializer + && (trimmed[0] == '#' + || trimmed[^1] is ';' or '{' or '}'); + } + + private static bool IsCSharpAssignmentOperator(ReadOnlySpan line, int index) + { + var previous = index > 0 ? line[index - 1] : '\0'; + var next = index + 1 < line.Length ? line[index + 1] : '\0'; + return next != '=' && previous is not ('=' or '!' or '<' or '>'); } private void StartSection() @@ -327,6 +411,7 @@ private void StartSection() _itemNameFinalized = false; _bracketDepth = 1; _parenthesisDepth = 0; + _genericArgumentDepth = 0; _attributeName.Clear(); } @@ -391,6 +476,7 @@ private void ResetBlock() _budgetExceeded = false; _bracketDepth = 0; _parenthesisDepth = 0; + _genericArgumentDepth = 0; _blockLineCount = 0; _blockCharacterCount = 0; _blockItemCount = 0; diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmissionFlow.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmissionFlow.cs index 980c752b6..870459241 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmissionFlow.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternEmissionFlow.cs @@ -46,7 +46,8 @@ private static PatternScanResult EmitAcceptedPatternSymbol( attributedDeclarationLines, i, shapedSymbol.Kind == "function" - && shapedSymbol.RawReturnType != null); + && shapedSymbol.RawReturnType != null + && match.Groups["name"].Value != "this"); ref var scanState = ref extraction.ScanState; ref var pendingRecordPrimaryComponents = ref extraction.PendingRecordPrimaryComponents; ref var recordPrimaryComponentParentIndex = ref extraction.RecordPrimaryComponentParentIndex; diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5192Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5192Tests.cs index b362338ff..adeaa20ce 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5192Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5192Tests.cs @@ -36,9 +36,25 @@ private void MultilineTheory(int value, string text) { } """ namespace Demo; + public sealed class Fact { } + public sealed class MetadataAttribute : System.Attribute { } + public class Calculator { private void HelperMethod() { } + + [Metadata< + string, + Fact>] + private void GenericAttributeHelper() { } + + private static int FactValue => 1; + private int[][] NestedCollectionInitializer = + { + [ + FactValue + ] + }; private void InitializerFollowingHelper() { } } """); using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) @@ -117,6 +133,8 @@ private void HelperMethod() { } Assert.Equal(string.Empty, excludedStderr); Assert.DoesNotContain("MultilineTheory", excludedNames); Assert.Contains("HelperMethod", excludedNames); + Assert.Contains("GenericAttributeHelper", excludedNames); + Assert.Contains("InitializerFollowingHelper", excludedNames); } finally { diff --git a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs index b34252eb6..a9659dd5a 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs @@ -539,6 +539,17 @@ public sealed class FieldFactAttribute : System.Attribute { } [System.AttributeUsage(System.AttributeTargets.Constructor)] public sealed class ConstructorFactAttribute : System.Attribute { } + [System.AttributeUsage(System.AttributeTargets.Property)] + public sealed class IndexerFactAttribute : System.Attribute + { + public IndexerFactAttribute(string marker) { } + } + + [System.AttributeUsage(System.AttributeTargets.Method)] + public sealed class MetadataAttribute : System.Attribute { } + + public sealed class Fact { } + public class MultilineAttributeTests { [Theory] @@ -625,11 +636,32 @@ public void MemberAfterAttributedProperty() { } "constructor marker")] public MultilineAttributeTests() { } + [ConstructorFact] + [System.Obsolete( + "partial constructor marker")] + partial MultilineAttributeTests(); + + [IndexerFact( + "indexer marker")] + public int this[int index] => index; + + [Metadata< + string, + Fact>] + public void GenericAttributeTypeArgumentDoesNotSpoofTest() { } + public int[] CollectionExpression => [ Fact ]; public void CollectionExpressionDoesNotSpoofTest() { } + public int[][] NestedCollectionInitializer = + { + [ + Fact + ] + }; public void NestedCollectionInitializerDoesNotSpoofTest() { } + public static int Fact => 1; public static int[] Cases => [4]; } @@ -659,6 +691,8 @@ public MultilineAttributeTests() { } "MemberAfterAttributedProperty", "MultilineAttributeTests", "CollectionExpressionDoesNotSpoofTest", + "GenericAttributeTypeArgumentDoesNotSpoofTest", + "NestedCollectionInitializerDoesNotSpoofTest", }; Assert.All( @@ -677,6 +711,14 @@ public MultilineAttributeTests() { } Assert.Contains( symbols, symbol => symbol.Kind == "field" && symbol.Name == "AttributedReadonlyField"); + Assert.Contains( + symbols, + symbol => symbol.Kind == "function" + && symbol.Signature?.Contains("this[int index]", StringComparison.Ordinal) == true); + Assert.DoesNotContain( + symbols, + symbol => symbol.Kind == "test.method" + && symbol.Signature?.Contains("this[int index]", StringComparison.Ordinal) == true); } [Fact]