From 3d108437473b05f1f49b63330e96ca25fbfc29a1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 04:54:20 +0900 Subject: [PATCH 1/3] Normalize C# field initializer symbols (#4865) --- DEVELOPER_GUIDE.md | 4 +- TESTING_GUIDE.md | 2 + changelog.d/unreleased/4865.fixed.md | 19 ++ ...ferenceExtractor.CoreCallClassification.cs | 2 +- .../ReferenceExtractor.CoreExtraction.cs | 2 +- .../ReferenceExtractor.CoreLookups.cs | 16 +- .../Symbols/SymbolExtractor.CSharpScanner.cs | 10 +- .../Symbols/SymbolExtractor.ExtractCore.cs | 168 ++++++++++++++++-- .../Symbols/SymbolExtractor.LexicalScopes.cs | 6 +- .../SymbolExtractor.PatternSignatures.cs | 14 ++ .../Symbols/SymbolExtractor.Patterns.cs | 15 +- .../QueryCommandRunnerInspectTests.cs | 2 +- .../QueryCommandRunnerSymbolTests.cs | 10 +- .../SymbolExtractorCSharpTests.cs | 152 ++++++++-------- tests/CodeIndex.Tests/SymbolExtractorTests.cs | 114 +++++++++--- 15 files changed, 395 insertions(+), 141 deletions(-) create mode 100644 changelog.d/unreleased/4865.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 7ce6df3c3..c3e4d746b 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -696,7 +696,7 @@ Do not add mutable static caches, shared `StringBuilder` instances, reused `Matc | `environment` | Dockerfile `ENV` variable names | Variable/search symbol; participates in Dockerfile variable references | | `event` | Event declarations | Search/filter symbol | | `expose` | Dockerfile `EXPOSE` ports | Container runtime search symbol | -| `field` | Field declarations where distinct from properties; C# const and static readonly fields share the tuple-aware type grammar used by ordinary fields | Search/filter symbol | +| `field` | Field declarations where distinct from properties; C# plain, const, and static readonly fields share the tuple-aware type grammar, and oversized initializer signatures retain declaration metadata while replacing top-level initializer bodies with deterministic markers | Search/filter symbol | | `file_module` | File-scoped module/package declarations | Namespace-like context symbol | | `function` | Functions, methods, constructors, delegates, tasks, and callable bindings that do not have a narrower kind | Primary callable definition; participates in callers/callees through reference rows | | `generator` | JavaScript/TypeScript generator declarations | Callable definition; participates in callers/callees through reference rows | @@ -3924,7 +3924,7 @@ filter、downstream JSON consumer が同じ値を理解できるようにして | `environment` | Dockerfile `ENV` variable name | variable/search symbol。Dockerfile variable reference に参加 | | `event` | event declaration | Search/filter symbol | | `expose` | Dockerfile `EXPOSE` port | container runtime search symbol | -| `field` | property と区別される field declaration。C# の const / static readonly field は通常 field と tuple-aware な型文法を共有する | Search/filter symbol | +| `field` | property と区別される field declaration。C# の通常 / const / static readonly field は tuple-aware な型文法を共有し、巨大 initializer の signature は declaration metadata を維持しつつ top-level initializer 本体を決定的 marker に置換する | Search/filter symbol | | `file_module` | file-scoped module / package declaration | Namespace-like context symbol | | `function` | 関数、method、constructor、delegate、task、およびより狭い kind がない callable binding | Primary callable definition。reference row 経由で callers/callees に参加 | | `generator` | JavaScript / TypeScript generator declaration | Callable definition。reference row 経由で callers/callees に参加 | diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 1650ee7f4..8edb9ff45 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -72,6 +72,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `ChunkSplitterTests.cs`, `SymbolExtractorTests.cs`, `ReferenceExtractorTests.cs`, `SearchSnippetFormatterTests.cs`, `DbPathResolverTests.cs`, `ExcerptRecoveryCommandFormatterTests.cs`, `ConsoleUiTests.cs` Pure or mostly pure behavior tests with in-memory inputs. + C# field coverage keeps collection expressions, constants, multiple declarators, target-typed `new`, arrays, real properties, oversized multiline initializer summarization, and qualified field-receiver references in coordinated fixtures so public kind, signature, and reference-identity contracts cannot drift apart. `DbPathResolverPureTests` keeps only path, injected-input, and URI validation cases that neither read process-global state nor open SQLite, allowing them to run outside the `SQLite pool sensitive` collection. Keep environment/current-directory resolution, real database and metadata probes, pool resets, and static test seams in `DbPathResolverTests`. Search snippet origin-priority coverage keeps PascalCase, snake_case, and phrase queries in coordinated mixed comment/string/code fixtures so identifier focus, same-line code-column clamping, over-1-MiB valid chunks, final-window dropped counts, filtered-origin refocusing, and the phrase control share one contract. Recovery-command coverage asserts structured argv, current `dotnet`/apphost prefix preservation, replay of option-like paths, and display-only escaping for both POSIX sh and PowerShell, including spaces, quotes, dollar signs, and shell metacharacters. @@ -984,6 +985,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `ChunkSplitterTests.cs`、`SymbolExtractorTests.cs`、`ReferenceExtractorTests.cs`、`SearchSnippetFormatterTests.cs`、`DbPathResolverTests.cs`、`ExcerptRecoveryCommandFormatterTests.cs`、`ConsoleUiTests.cs` インメモリ入力中心の、純粋またはほぼ純粋な振る舞いのテスト。 + C# field coverage は collection expression、constant、multiple declarator、target-typed `new`、array、実 property、巨大 multiline initializer の要約、qualified field receiver reference を連携 fixture にまとめ、公開 kind、signature、reference identity の契約が別々にずれないようにする。 `DbPathResolverPureTests` には process-global state を読まず SQLite も開かない path、注入済み入力、URI validation の case だけを置き、`SQLite pool sensitive` collection の外で実行できるようにします。環境変数 / current directory の解決、実 database / metadata probe、pool reset、static test seam は `DbPathResolverTests` に残してください。 search snippet の origin 優先順位 coverage は PascalCase、snake_case、phrase query を連携した comment / string / code 混在 fixture にまとめ、identifier focus、同一行の code 列への clamping、1 MiB を超える有効 chunk、最終 window の dropped count、filter 後 origin への再 focus、phrase の control を一つの contract として検証します。 recovery command の coverage では構造化 argv、現在の `dotnet` / apphost prefix の維持、option と紛らわしい path の再実行、POSIX sh と PowerShell 双方の表示専用 escaping を、空白、quote、dollar sign、shell metacharacter を含めて検証します。 diff --git a/changelog.d/unreleased/4865.fixed.md b/changelog.d/unreleased/4865.fixed.md new file mode 100644 index 000000000..ff24d4c0c --- /dev/null +++ b/changelog.d/unreleased/4865.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 4865 +affected: + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternSignatures.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs + - tests/CodeIndex.Tests/SymbolExtractorTests.cs + - tests/CodeIndex.Tests/ReferenceExtractorTests.cs +--- + +## English + +- **C# fields now keep field identity and bounded signatures with modern initializers (#4865)** — plain fields initialized with collection expressions, target-typed `new`, arrays, or other values are emitted as `field` symbols, while oversized initializer bodies are replaced with deterministic markers that preserve declaration metadata. + +## 日本語 + +- **C# field が modern initializer 使用時も field identity と bounded signature を維持するようになりました (#4865)** — collection expression、target-typed `new`、array などで初期化した通常 field を `field` symbol として発行し、巨大 initializer 本体は declaration metadata を維持する決定的 marker に置換します。 diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs index ad7efaee3..d9db1f9ae 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreCallClassification.cs @@ -124,7 +124,7 @@ private static bool TryAddCoreCallLikeReference( out var receiverNames) && (receiverNames.InstanceNames.Contains(normalizedName) || receiverNames.StaticNames.Contains(normalizedName)) - && call.Lookups.HasCSharpProperty( + && call.Lookups.HasCSharpFieldOrPropertyMember( containingType, normalizedName)) { diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs index 6f5881cf8..edf3bfed8 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreExtraction.cs @@ -394,7 +394,7 @@ private static void RewriteCSharpPropertyReceiverReferences( reference.Line); var containingType = GetContainingTypeQualifiedName(owner); if (containingType == null - || !lookups.HasCSharpProperty( + || !lookups.HasCSharpFieldOrPropertyMember( containingType, reference.SymbolName)) { diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs index 1c4c86117..f97afcce8 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs @@ -33,8 +33,8 @@ private sealed class CoreExtractionLookups private HashSet? pythonClassNames; private bool pythonClassNamesResolved; private PythonImportBindingResolver.ImportedTypeCallLookup? pythonImportedTypeCallLookup; - private HashSet<(string Container, string Name)>? csharpProperties; - private bool csharpPropertiesResolved; + private HashSet<(string Container, string Name)>? csharpFieldOrPropertyMembers; + private bool csharpFieldOrPropertyMembersResolved; private Dictionary>? csharpContainerCandidatesByName; private List<(int StartLine, int StartColumn, int EndLine, int EndColumn, SymbolRecord Container, SymbolRecord Owner)>? recordPrimaryCtorRanges; private bool recordPrimaryCtorRangesResolved; @@ -90,22 +90,22 @@ internal bool HasSameFilePythonClass(string candidate, string leaf) internal PythonImportBindingResolver.ImportedTypeCallLookup GetPythonImportedTypeCallLookup() => pythonImportedTypeCallLookup ??= PythonImportBindingResolver.BuildImportedTypeCallLookup(symbols); - internal bool HasCSharpProperty(string containingType, string propertyName) + internal bool HasCSharpFieldOrPropertyMember(string containingType, string memberName) { - if (!csharpPropertiesResolved) + if (!csharpFieldOrPropertyMembersResolved) { foreach (var symbol in symbols) { - if (symbol.Kind == "property" + if (symbol.Kind is "field" or "property" && symbol.ContainerQualifiedName != null) { - (csharpProperties ??= []).Add((symbol.ContainerQualifiedName, symbol.Name)); + (csharpFieldOrPropertyMembers ??= []).Add((symbol.ContainerQualifiedName, symbol.Name)); } } - csharpPropertiesResolved = true; + csharpFieldOrPropertyMembersResolved = true; } - return csharpProperties?.Contains((containingType, propertyName)) == true; + return csharpFieldOrPropertyMembers?.Contains((containingType, memberName)) == true; } internal SymbolRecord? FindCSharpContainerCandidate(string? containerName, int lineNumber) diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs index 0ee5bbdd8..d64fdfb16 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs @@ -820,8 +820,9 @@ private static int FindNextBraceStatementStart(string line, int startIndex) return -1; } - // For C# plain fields (kind `property`, BodyStyle.None), find the end of the - // field's declaration statement on the same (merged) match line so the + // For C# plain fields (internally tagged as `property`, BodyStyle.None, and + // normalized to public `field`), find the end of the field's declaration + // statement on the same (merged) match line so the // signature can be clamped to the full declaration text and the same-line // pattern scanner can resume after the terminating `;`. Walks with paren / // bracket / brace depth tracking so `{` / `}` inside an initializer @@ -831,8 +832,9 @@ private static int FindNextBraceStatementStart(string line, int startIndex) // so signature and advance both stop before the wrapper terminator. Input // is expected to be the structurally-masked match line so string-literal // `{` / `;` cannot poison the depth tracker. - // C# 通常フィールド(kind `property`、BodyStyle.None)向けに、結合済みマッチ行での - // 宣言文の終端位置を返す。signature を `;` まで含む完全な宣言文字列に揃え、かつ + // C# 通常フィールド(内部タグは `property`、BodyStyle.None、公開時に `field` へ + // 正規化)向けに、結合済みマッチ行での宣言文の終端位置を返す。signature を `;` + // まで含む完全な宣言文字列に揃え、かつ // 同一行のパターンスキャンを `;` の次から再開できるようにするために使う。paren / // bracket / brace の深さを追うので、初期化子(コレクション / オブジェクト初期化子や // ラムダ本体)内の `{` / `}` で判定が途切れない。深さ 0 で出現する `}`(囲む型本体の diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs index cf545e537..0c9bd329a 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs @@ -341,16 +341,18 @@ private static List ExtractCore( continue; } - // Gate the C# plain-field pattern (kind `property`, BodyStyle.None) to - // lines that sit directly inside a type body. Without this gate, local + // Gate the C# plain-field pattern (internally tagged as `property`, + // BodyStyle.None, then normalized to the public `field` kind) to lines + // that sit directly inside a type body. Without this gate, local // variable declarations inside method / property / accessor / lambda // bodies match the same shape and leak into `symbols`, `definition`, - // `outline`, `inspect`, and `unused` as phantom property symbols. + // `outline`, `inspect`, and `unused` as phantom field symbols. // Closes #298 follow-up (codex review blocker). - // C# の通常フィールド用パターン(kind `property` かつ BodyStyle.None)は - // 型本体(class / struct / interface / record / enum の直下)でしか - // 許可しない。このゲートを入れないと、メソッド・プロパティ・アクセサ・ - // ラムダの内部にあるローカル変数宣言が同じ形でマッチしてしまい、 + // C# の通常フィールド用パターン(内部タグは `property`、BodyStyle.None、 + // 公開時に `field` へ正規化)は型本体(class / struct / interface / + // record / enum の直下)でしか許可しない。このゲートを入れないと、 + // メソッド・プロパティ・アクセサ・ラムダの内部にあるローカル変数宣言が + // 同じ形でマッチしてしまい、 // `symbols` / `definition` / `outline` / `inspect` / `unused` に // 擬似シンボルが混入する。Closes #298 の codex レビュー blocker 対応。 if (ShouldSkipCssNestedSelectorCandidate(lang, pattern, patternMatchLine, getCssQualifiedRuleAncestors, i)) @@ -894,8 +896,9 @@ private static List ExtractCore( ref recordPrimaryComponentParentIndex, symbols); - // C# plain-field (kind `property`, BodyStyle.None) matches need their own - // advance path. The generic `sameLineEndColumn`-based advance below resolves + // C# plain-field matches (internally tagged as `property`, BodyStyle.None, + // then normalized to public `field`) need their own advance path. The + // generic `sameLineEndColumn`-based advance below resolves // to -1 for BodyStyle.None and would set `stopAfterFirstPatternMatch`, which // prevents structural siblings on the same line (e.g. the enclosing // `public class C` in `public class C { public int X; }`) from being @@ -903,7 +906,8 @@ private static List ExtractCore( // and continue the same-pattern scan so multiple same-line fields are // still collected, and skip the stop flag so later patterns can still run. // Closes #400. - // C# 通常フィールド(kind `property`、BodyStyle.None)は専用の前進経路を使う。 + // C# 通常フィールド(内部タグは `property`、BodyStyle.None、公開時に + // `field` へ正規化)は専用の前進経路を使う。 // 既定の `sameLineEndColumn` ベースの前進は BodyStyle.None では -1 に落ち、 // `stopAfterFirstPatternMatch` を立ててしまうため、同一行に存在する構造宣言 // (例: `public class C { public int X; }` の外側 class)を後続パターンで @@ -1621,12 +1625,148 @@ private static string BoundCSharpFieldInitializerSignature(string signature) // Field signatures are metadata, not bodies. Large object/collection initializers can // otherwise consume an entire CLI, JSON, MCP, or LSP response budget after multiline - // signatures are collapsed to one line. Keep a deterministic declaration prefix and an - // explicit terminator while bounding the value persisted in the symbol database. #4445 + // signatures are collapsed to one line. Replace each top-level initializer with a + // deterministic marker so modifiers, type, and every declarator remain readable without + // persisting an arbitrary initializer prefix. Keep the legacy hard limit as a final guard + // for pathologically large declarator lists. #4445, #4865 // field signature は body ではなくメタデータである。複数行を1行へ畳み込んだ巨大な // object/collection initializer が CLI / JSON / MCP / LSP の応答予算を使い切らないよう、 - // DB に保存する値を制限しつつ宣言prefixと明示的な終端を維持する。#4445 - return string.Concat(signature.AsSpan(0, CSharpFieldInitializerSignatureLimit - 2), "…;"); + // top-level initializer を決定的 marker に置換し、任意の initializer prefix を保存せずに + // modifier / type / 全 declarator を読める形で維持する。異常に長い declarator list には + // 従来の hard limit を最終ガードとして残す。#4445, #4865 + var sanitized = LexCSharpLine(signature, new CSharpLexState()).SanitizedLine; + var summarized = new StringBuilder(Math.Min(signature.Length, CSharpFieldInitializerSignatureLimit)); + var copyStart = 0; + var searchStart = 0; + + while (TryFindCSharpTopLevelInitializerAssignment(sanitized, searchStart, out var assignmentColumn)) + { + summarized.Append(signature.AsSpan(copyStart, assignmentColumn - copyStart).TrimEnd()); + summarized.Append(" = …"); + + var delimiterColumn = FindCSharpTopLevelInitializerDelimiter(sanitized, assignmentColumn + 1); + if (delimiterColumn >= signature.Length) + { + summarized.Append(';'); + copyStart = signature.Length; + break; + } + + summarized.Append(signature[delimiterColumn]); + copyStart = delimiterColumn + 1; + searchStart = copyStart; + } + + if (copyStart < signature.Length) + summarized.Append(signature.AsSpan(copyStart)); + + var result = summarized.ToString().Trim(); + return result.Length <= CSharpFieldInitializerSignatureLimit + ? result + : string.Concat(result.AsSpan(0, CSharpFieldInitializerSignatureLimit - 2), "…;"); + } + + private static bool TryFindCSharpTopLevelInitializerAssignment( + string sanitized, + int startColumn, + out int assignmentColumn) + { + var parenDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + + for (var column = startColumn; column < sanitized.Length; column++) + { + var ch = sanitized[column]; + switch (ch) + { + case '(': + parenDepth++; + continue; + case ')' when parenDepth > 0: + parenDepth--; + continue; + case '[': + bracketDepth++; + continue; + case ']' when bracketDepth > 0: + bracketDepth--; + continue; + case '{': + braceDepth++; + continue; + case '}' when braceDepth > 0: + braceDepth--; + continue; + case '<' when TryMatchCSharpGenericBracket(sanitized, column, out var genericEnd): + column = genericEnd; + continue; + } + + if (ch != '=' || parenDepth != 0 || bracketDepth != 0 || braceDepth != 0) + continue; + + var previous = column > 0 ? sanitized[column - 1] : '\0'; + var next = column + 1 < sanitized.Length ? sanitized[column + 1] : '\0'; + if (next is '=' or '>' + || previous is '=' or '!' or '<' or '>' or '+' or '-' or '*' or '/' or '%' + or '&' or '|' or '^' or '?') + { + continue; + } + + assignmentColumn = column; + return true; + } + + assignmentColumn = -1; + return false; + } + + private static int FindCSharpTopLevelInitializerDelimiter(string sanitized, int startColumn) + { + var parenDepth = 0; + var bracketDepth = 0; + var braceDepth = 0; + + for (var column = startColumn; column < sanitized.Length; column++) + { + var ch = sanitized[column]; + switch (ch) + { + case '(': + parenDepth++; + continue; + case ')' when parenDepth > 0: + parenDepth--; + continue; + case '[': + bracketDepth++; + continue; + case ']' when bracketDepth > 0: + bracketDepth--; + continue; + case '{': + braceDepth++; + continue; + case '}' when braceDepth > 0: + braceDepth--; + continue; + case '<' when TryMatchCSharpGenericBracket(sanitized, column, out var genericEnd): + column = genericEnd; + continue; + } + + if (parenDepth == 0 + && bracketDepth == 0 + && braceDepth == 0 + && ch is ',' or ';') + { + return column; + } + } + + return sanitized.Length; } private static void AddScriptScopeSymbol(long fileId, string[] lines, List symbols) diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.LexicalScopes.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.LexicalScopes.cs index d444b259c..00d0d755f 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.LexicalScopes.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.LexicalScopes.cs @@ -170,7 +170,7 @@ private static bool TrySkipCSharpBracePropertyCandidate( // Mark every line that sits directly inside a C# type body (class / struct / // interface / record / enum). Used to gate the plain-field pattern so that // local variable declarations inside a method, property accessor, lambda, or - // other non-type body are not misclassified as kind `property`. The scan uses + // other non-type body are not misclassified as the public `field` kind. The scan uses // `structuralLines` (strings / chars / comments already masked), so it is not // fooled by braces or type-declaration-looking text inside literals. Only // brace-delimited types push a type-body frame — `new { ... }`, collection @@ -180,7 +180,7 @@ private static bool TrySkipCSharpBracePropertyCandidate( // C# の「現在この行は型本体(class / struct / interface / record / enum)の // 直下にあるか」を行単位で事前計算する。新しい通常フィールド抽出パターンが // メソッド本体・プロパティアクセサ・ラムダなど「非型本体」に含まれる - // ローカル変数宣言を kind `property` として誤抽出しないよう、このフラグで + // ローカル変数宣言を公開 kind `field` として誤抽出しないよう、このフラグで // ゲートする。走査は既に文字列・文字・コメントを空白化した // `structuralLines` を使うため、リテラル内の `{` や `class` 相当の文字列に // 騙されない。`new { ... }` や collection initializer、ラムダ本体の `{` は @@ -189,7 +189,7 @@ private static bool TrySkipCSharpBracePropertyCandidate( // Marks `{` that opens a class-like body where C# plain fields are legal. // `enum` is intentionally excluded: enum bodies contain enum members (not // fields), and the field regex would otherwise match enum member shapes like - // `[Obsolete] A = (int)B,` as phantom `property` symbols. The column-aware + // `[Obsolete] A = (int)B,` as phantom `field` symbols. The column-aware // scope gate relies on this distinction to reject field candidates inside // enum bodies while still accepting legitimate fields inside class / struct // / interface / record bodies. Closes #400. diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternSignatures.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternSignatures.cs index c7b5bc81b..41d73d124 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternSignatures.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternSignatures.cs @@ -212,6 +212,20 @@ private static PatternSignatureResult BuildPatternSignature( if (lang == "python" && pattern.Kind is "function" or "class") signature = BuildPythonLogicalHeaderSignature(lines, lineIndex, absoluteStartColumn); + if (lang == "csharp" + && pattern.Kind == "property" + && pattern.BodyStyle == BodyStyle.None) + { + // The plain-field matcher historically reused the internal `property` tag so it + // could share the property/field scanning pipeline. Persist the public taxonomy + // value here, after the field-only terminator pattern has already distinguished + // declarations from accessor and expression-bodied properties. #4865 + // plain-field matcher は property / field の走査経路を共有するため内部的に + // `property` tag を再利用してきた。field 専用終端 pattern が accessor / + // expression-bodied property を区別した後、公開 taxonomy の `field` に正規化する。 + kind = "field"; + } + if (kind == "function" && lang == "csharp" && pattern.BodyStyle == BodyStyle.None diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Patterns.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Patterns.cs index 55ecd9e83..f43add8a6 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Patterns.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Patterns.cs @@ -938,11 +938,12 @@ private enum JavaScriptTypeScriptFunctionHeaderConsumeResult // (member hiding) may appear anywhere in the modifier sequence. Visibility is also // accepted anywhere, not just at the front, so legacy orderings like // `readonly public static` / `static public readonly` still classify as fields - // instead of falling through to the plain-field (kind `property`) row. Closes #355. + // instead of falling through to the internally `property`-tagged plain-field row, + // whose public output is normalized to `field`. Closes #355. // static/readonly の順序は自由で、`new`(メンバー隠蔽)も任意位置に置ける。visibility も // 先頭以外の位置に現れることを許容し、`readonly public static` や `static public readonly` - // のような旧来の並びでも kind `field` で取り扱う。通常フィールド(kind `property`)の - // 正規表現に流れ落ちないようにする。Closes #355. + // のような旧来の並びでも kind `field` で取り扱う。公開時に `field` へ正規化される + // 内部 `property` タグの通常フィールド正規表現に流れ落ちないようにする。Closes #355. // Share CSharpTypePattern with const and plain fields so tuple, nullable-tuple, and // generic-over-tuple types retain stable field kind and complete return-type metadata. // const / 通常フィールドと CSharpTypePattern を共有し、tuple / nullable tuple / @@ -955,8 +956,9 @@ private enum JavaScriptTypeScriptFunctionHeaderConsumeResult + $@"(?:(?{CSharpVisibilityPattern})\s+|(?:new|static|readonly)\s+)+" + $@"(?{CSharpTypePattern})\s+(?{CSharpIdentifierPattern})\s*[=;]", RegexOptions.Compiled), BodyStyle.None, "visibility", "returnType"), - // Plain field (instance, readonly, volatile, plain static, etc.) — kind `property`. - // Must come AFTER the `const` and `static readonly` patterns (which take priority + // Plain field (instance, readonly, volatile, plain static, etc.). It keeps the + // internal `property` tag used by scanner gating and normalizes public output to + // `field`. Must come AFTER the `const` and `static readonly` patterns (which take priority // with kind `function`), and BEFORE the structural declaration patterns. // The terminator `=(?![=>])` or `;` distinguishes fields from methods (which end // with `(`), property accessors (which end with `{`), expression-bodied members @@ -965,7 +967,8 @@ private enum JavaScriptTypeScriptFunctionHeaderConsumeResult // regex engine cannot backtrack past an unconsumed `public static event …` // declaration and match it as a field whose returnType is `public static event …`. // Closes #298. - // 通常フィールド(instance / readonly / volatile / 通常 static など) — kind は `property`。 + // 通常フィールド(instance / readonly / volatile / 通常 static など)。スキャナーの + // ゲート判定に使う内部 `property` タグを維持し、公開時に `field` へ正規化する。 // `const` / `static readonly` パターン(kind `function`)より後、型宣言パターンより前に置く。 // 終端を `=(?![=>])` または `;` にすることで、メソッド(`(`)、プロパティアクセサ(`{`)、 // 式本体メンバー(`=>`)、比較演算子オーバーロード(`==`)を除外する。 diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index 5311219d1..776fb4e09 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -3978,7 +3978,7 @@ public bool Match(object value) => value is symbol.GetProperty("kind").GetString() == "function" && symbol.GetProperty("name").GetString() == "Match")); var x = Assert.Single(symbols.Where(symbol => - symbol.GetProperty("kind").GetString() == "property" + symbol.GetProperty("kind").GetString() == "field" && symbol.GetProperty("name").GetString() == "X")); Assert.Equal(CommandExitCodes.Success, exitCode); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs index ffad6fc9f..8ad341392 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs @@ -3760,28 +3760,28 @@ private Dictionary """); var (mapExitCode, mapStdout, _) = CaptureConsole(() => QueryCommandRunner.RunSymbols( - ["--db", dbPath, "--json", "--lang", "csharp", "--kind", "property", "--name", "_map", "--exact-name"], + ["--db", dbPath, "--json", "--lang", "csharp", "--kind", "field", "--name", "_map", "--exact-name"], _jsonOptions)); using var mapDocument = ParseJsonOutput(mapStdout); Assert.Equal(CommandExitCodes.Success, mapExitCode); Assert.Equal("_map", mapDocument.RootElement.GetProperty("name").GetString()); var (xExitCode, xStdout, _) = CaptureConsole(() => QueryCommandRunner.RunSymbols( - ["--db", dbPath, "--json", "--lang", "csharp", "--kind", "property", "--name", "_x", "--exact-name"], + ["--db", dbPath, "--json", "--lang", "csharp", "--kind", "field", "--name", "_x", "--exact-name"], _jsonOptions)); using var xDocument = ParseJsonOutput(xStdout); Assert.Equal(CommandExitCodes.Success, xExitCode); Assert.Equal("_x", xDocument.RootElement.GetProperty("name").GetString()); var (yExitCode, yStdout, _) = CaptureConsole(() => QueryCommandRunner.RunSymbols( - ["--db", dbPath, "--json", "--lang", "csharp", "--kind", "property", "--name", "_y", "--exact-name"], + ["--db", dbPath, "--json", "--lang", "csharp", "--kind", "field", "--name", "_y", "--exact-name"], _jsonOptions)); using var yDocument = ParseJsonOutput(yStdout); Assert.Equal(CommandExitCodes.Success, yExitCode); Assert.Equal("_y", yDocument.RootElement.GetProperty("name").GetString()); var (callbackExitCode, callbackStdout, _) = CaptureConsole(() => QueryCommandRunner.RunSymbols( - ["--db", dbPath, "--json", "--lang", "csharp", "--kind", "property", "--name", "Callback", "--exact-name"], + ["--db", dbPath, "--json", "--lang", "csharp", "--kind", "field", "--name", "Callback", "--exact-name"], _jsonOptions)); using var callbackDocument = ParseJsonOutput(callbackStdout); Assert.Equal(CommandExitCodes.Success, callbackExitCode); @@ -5745,7 +5745,7 @@ public bool Match(object value) => value is Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Equal(string.Empty, stderr); - Assert.Equal("property", x.GetProperty("kind").GetString()); + Assert.Equal("field", x.GetProperty("kind").GetString()); Assert.Equal("X", x.GetProperty("name").GetString()); Assert.Equal("Uses", x.GetProperty("container_name").GetString()); Assert.Equal("public int X;", x.GetProperty("signature").GetString()); diff --git a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs index 72952edc0..150ecaa6c 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorCSharpTests.cs @@ -613,9 +613,6 @@ public string BuildCompletion(string[] items) => { "SymbolNameExactIndexAvailable", "MultilineWithSibling", - "Factory", - "PreservedContinuationSibling", - "PreservedLambdaSibling", "InlineAvailability", }) { @@ -624,6 +621,13 @@ public string BuildCompletion(string[] items) => Assert.Equal("FixtureHost", property.ContainerName); } + foreach (var name in new[] { "Factory", "PreservedContinuationSibling", "PreservedLambdaSibling" }) + { + var field = Assert.Single(symbols.Where(symbol => symbol.Kind == "field" && symbol.Name == name)); + Assert.Equal("class", field.ContainerKind); + Assert.Equal("FixtureHost", field.ContainerName); + } + foreach (var name in new[] { "BuildSql", "BuildCompletion", "HasSymbolIndex", "BeforeSameLine", "SameLineSibling", "AfterTemplates" }) { var method = Assert.Single(symbols.Where(symbol => symbol.Kind == "function" && symbol.Name == name)); @@ -2793,9 +2797,9 @@ public interface IActiveWorkspaceLoader Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "Runner"); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "Run"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "_name"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_name"); foreach (var parameterName in new[] { "appVersion", "jsonOptions", "cancellationToken", "activeWorkspaceLoader" }) - Assert.DoesNotContain(symbols, s => s.Kind == "property" && s.Name == parameterName); + Assert.DoesNotContain(symbols, s => s.Kind is "field" or "property" && s.Name == parameterName); } [Fact] @@ -2911,7 +2915,7 @@ public readonly struct SpacedStruct // Real members are still captured with the correct kinds. / 本物のメンバーが正しい kind で拾えていること。 Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "R4"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "F4"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "F4"); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "M3"); Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "P5"); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "M4"); @@ -3266,8 +3270,9 @@ public void Extract_CSharp_DetectsConstAndStaticReadonly() Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "Version" && s.ReturnType == "string"); Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "MaxRetries" && s.ReturnType == "int"); Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "Map"); - // Regular mutable fields are now extracted as `property` / 通常のフィールドも `property` として抽出される - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "MutableField" && s.ReturnType == "string"); + // Regular mutable fields use the public `field` taxonomy as well. + // 通常の mutable field も公開 taxonomy の `field` として抽出される。 + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "MutableField" && s.ReturnType == "string"); } [Fact] @@ -3275,11 +3280,11 @@ public void Extract_CSharp_StaticReadonlyField_FreeModifierOrder() { // Closes #355: C# allows modifiers to appear in any order, so `readonly static`, // `readonly new static`, and `new readonly static` must all be captured as the - // kind `field` result (static readonly field), not fall through to the plain-field - // (kind `property`) row. + // kind `field` result (static readonly field), not fall through to the internally + // property-tagged plain-field row. // Closes #355: C# の修飾子は任意順で書けるため、`readonly static` / // `readonly new static` / `new readonly static` も kind `field`(static readonly - // フィールド)として取り扱い、通常フィールド(kind `property`)に流れ落ちないこと。 + // フィールド)として取り扱い、内部 property タグの通常フィールド行に流れ落ちないこと。 var content = """ public class Svc { @@ -3337,7 +3342,7 @@ public void DeconstructLocals() Assert.Equal("field", pairByName.Kind); Assert.Equal("Dictionary", pairByName.ReturnType); Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "ConstPair" && s.ReturnType == "(int Left, int Right)"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "PlainPair" && s.ReturnType == "(int Left, int Right)"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "PlainPair" && s.ReturnType == "(int Left, int Right)"); Assert.DoesNotContain(symbols, s => s.Name is "left" or "right" or "nestedLeft" or "nestedRight"); } @@ -3561,14 +3566,14 @@ public class Cfg [Fact] public void Extract_CSharp_PlainField_FreeModifierOrder() { - // Closes #355: plain fields (kind `property`) and multi-line field headers must also + // Closes #355: plain fields (kind `field`) and multi-line field headers must also // accept visibility anywhere in the modifier sequence. Previously `static public int X;` // captured as a field with empty `visibility` (single-line plain-field regex was // visibility-first), and multi-line declarations whose header line starts with a // non-visibility modifier were dropped entirely because // `CSharpPropertyHeaderPrefixRegex` (the merger trigger) was also visibility-first and // did not accept `const`. - // Closes #355: 通常フィールド(kind `property`)と複数行フィールドヘッダも、修飾子列の + // Closes #355: 通常フィールド(kind `field`)と複数行フィールドヘッダも、修飾子列の // 任意位置で visibility を受け付けなければならない。以前は `static public int X;` が // visibility 空のまま captured され(単一行 plain-field 正規表現が visibility-first)、 // 非 visibility 修飾子から始まる複数行宣言は結合トリガの `CSharpPropertyHeaderPrefixRegex` @@ -3588,12 +3593,12 @@ static public Dictionary """; var symbols = SymbolExtractor.Extract(1, "csharp", content); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "X" && s.Visibility == "public" && s.ReturnType == "int"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "Y" && s.Visibility == "public" && s.ReturnType == "int"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "X" && s.Visibility == "public" && s.ReturnType == "int"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "Y" && s.Visibility == "public" && s.ReturnType == "int"); // `new public static` is promoted to kind `function` via the static readonly / const row set. // `new public static` は static readonly / const 系の行で kind `function` に昇格する。 Assert.Contains(symbols, s => s.Name == "Z" && s.Visibility == "public" && s.ReturnType == "int"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "Map" && s.Visibility == "public" && s.ReturnType != null && s.ReturnType.Contains("Dictionary")); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "Map" && s.Visibility == "public" && s.ReturnType != null && s.ReturnType.Contains("Dictionary")); Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "C" && s.Visibility == "public" && s.ReturnType == "int"); } @@ -5719,20 +5724,21 @@ internal enum ColorMode [Fact] public void Extract_CSharp_DetectsPlainFieldDeclarations() { - // Plain fields are now captured as kind `property` so definition/symbols/outline/ + // Plain fields are captured as kind `field` so definition/symbols/outline/ // hotspots/unused can see the full member surface of a class. See issue #298. - // 通常フィールドも kind `property` として抽出される(issue #298)。これにより + // 通常フィールドも kind `field` として抽出される(issues #298 / #4865)。これにより // definition/symbols/outline/hotspots/unused がクラスの全メンバー形を見える。 var content = "public class Config\n{\n public string Name;\n private int _count;\n public readonly string Id = \"x\";\n protected List Items = new();\n internal volatile bool IsReady;\n public static int GlobalCount;\n}"; var symbols = SymbolExtractor.Extract(1, "csharp", content); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "Name" && s.ReturnType == "string" && s.Visibility == "public"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "_count" && s.ReturnType == "int" && s.Visibility == "private"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "Id" && s.ReturnType == "string" && s.Visibility == "public"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "Items" && s.Visibility == "protected"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "IsReady" && s.ReturnType == "bool" && s.Visibility == "internal"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "GlobalCount" && s.ReturnType == "int" && s.Visibility == "public"); - // const / static readonly keep kind `function` / const と static readonly は引き続き kind `function` + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "Name" && s.ReturnType == "string" && s.Visibility == "public"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_count" && s.ReturnType == "int" && s.Visibility == "private"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "Id" && s.ReturnType == "string" && s.Visibility == "public"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "Items" && s.Visibility == "protected"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "IsReady" && s.ReturnType == "bool" && s.Visibility == "internal"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "GlobalCount" && s.ReturnType == "int" && s.Visibility == "public"); + // No field should fall through to the callable row. + // field が callable row に流れ落ちないこと。 Assert.DoesNotContain(symbols, s => s.Name == "Name" && s.Kind == "function"); Assert.DoesNotContain(symbols, s => s.Name == "_count" && s.Kind == "function"); Assert.DoesNotContain(symbols, s => s.Name == "Id" && s.Kind == "function"); @@ -5741,12 +5747,12 @@ public void Extract_CSharp_DetectsPlainFieldDeclarations() [Fact] public void Extract_CSharp_PlainFieldPatternDoesNotLeakLocalVariables() { - // Plain fields are captured as kind `property`, but local variable declarations + // Plain fields are captured as kind `field`, but local variable declarations // inside method / property accessor / constructor / lambda bodies share the same // shape as fields. Without a scope gate, names like `local`, `numbers`, `tmp` // would leak into `symbols`, `definition`, `outline`, `inspect`, and `unused`. // Closes #298 follow-up (codex review blocker). - // 通常フィールドは kind `property` として抽出されるが、メソッド・アクセサ・ + // 通常フィールドは kind `field` として抽出されるが、メソッド・アクセサ・ // コンストラクタ・ラムダの内部にあるローカル変数宣言はフィールドと同じ形を持つ。 // スコープ判定を入れないと `local`、`numbers`、`tmp` などが // `symbols` / `definition` / `outline` / `inspect` / `unused` に混入する。 @@ -5793,9 +5799,9 @@ public void Extract_CSharp_PlainFieldPatternDoesNotLeakLocalVariables() "}"); var symbols = SymbolExtractor.Extract(1, "csharp", content); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "Field"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "Items"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "Lambda"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "Field"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "Items"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "Lambda"); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "Worker"); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "Run"); Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "Value"); @@ -5898,11 +5904,11 @@ public void Extract_CSharp_DetectsMultiLineFieldDeclaration() { // Plain field whose type occupies one line and whose name / initializer spill // onto the next line (`private Dictionary\n _map = new();`) must - // still be captured as a single `property` symbol. The multi-line property match + // still be captured as a single `field` symbol. The multi-line property match // builder combines the header and continuation lines before handing them to the // field regex. Closes #298 follow-up (codex adversarial review). // 型が 1 行目、名前と初期化式が次行へ回る通常フィールド - // (`private Dictionary\n _map = new();`)も、1 件の `property` + // (`private Dictionary\n _map = new();`)も、1 件の `field` // シンボルとして抽出する。multi-line property match builder がヘッダ行と // 継続行を結合してから field regex に渡す。Closes #298 follow-up。 var content = string.Join( @@ -5916,7 +5922,7 @@ public void Extract_CSharp_DetectsMultiLineFieldDeclaration() "}"); var symbols = SymbolExtractor.Extract(1, "csharp", content); - Assert.Contains(symbols, s => s.Kind == "property" + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_map" && s.Visibility == "private" && s.ReturnType == "Dictionary"); @@ -5945,11 +5951,11 @@ public void Extract_CSharp_DetectsMultiLineFieldWithVolatileAndUnsafeModifiers() "}"); var symbols = SymbolExtractor.Extract(1, "csharp", content); - Assert.Contains(symbols, s => s.Kind == "property" + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_map" && s.Visibility == "private" && s.ReturnType == "Dictionary"); - Assert.Contains(symbols, s => s.Kind == "property" + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "Callback" && s.Visibility == "public"); } @@ -5981,11 +5987,11 @@ public void Extract_CSharp_DetectsMultiLineFieldWithParenthesizedInitializer() "}"); var symbols = SymbolExtractor.Extract(1, "csharp", content); - Assert.Contains(symbols, s => s.Kind == "property" + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_value" && s.Visibility == "private" && s.ReturnType == "Lazy"); - Assert.Contains(symbols, s => s.Kind == "property" + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_plain" && s.Visibility == "private" && s.ReturnType == "Lazy"); @@ -6013,12 +6019,12 @@ public void Extract_CSharp_DeclaratorListSurvivesComparisonInitializer() "}"); var symbols = SymbolExtractor.Extract(1, "csharp", content); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "_a" && s.ReturnType == "int" && s.Visibility == "private"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "_b" && s.ReturnType == "int" && s.Visibility == "private"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "_c" && s.ReturnType == "int" && s.Visibility == "private"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "_d" && s.ReturnType == "int" && s.Visibility == "private"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "_e" && s.ReturnType == "int" && s.Visibility == "private"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "_f" && s.ReturnType == "int" && s.Visibility == "private"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_a" && s.ReturnType == "int" && s.Visibility == "private"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_b" && s.ReturnType == "int" && s.Visibility == "private"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_c" && s.ReturnType == "int" && s.Visibility == "private"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_d" && s.ReturnType == "int" && s.Visibility == "private"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_e" && s.ReturnType == "int" && s.Visibility == "private"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_f" && s.ReturnType == "int" && s.Visibility == "private"); } [Fact] @@ -6058,15 +6064,15 @@ public void Extract_CSharp_DetectsMultiLineFieldWithObjectInitializer() "}"); var symbols = SymbolExtractor.Extract(1, "csharp", content); - Assert.Contains(symbols, s => s.Kind == "property" + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_map" && s.Visibility == "private" && s.ReturnType == "Dictionary"); - Assert.Contains(symbols, s => s.Kind == "property" + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_list" && s.Visibility == "private" && s.ReturnType == "List"); - Assert.Contains(symbols, s => s.Kind == "property" + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_typed" && s.Visibility == "private" && s.ReturnType == "Dictionary"); @@ -6160,14 +6166,12 @@ public void Extract_CSharp_DetectsLongObjectInitializerBeyondLookaheadBudget() ]); var symbols = SymbolExtractor.Extract(1, "csharp", content); - var map = Assert.Single(symbols.Where(s => s.Kind == "property" + var map = Assert.Single(symbols.Where(s => s.Kind == "field" && s.Name == "_map" && s.Visibility == "private" && s.ReturnType == "Dictionary")); var signature = Assert.IsType(map.Signature); - Assert.Contains("_map = new()", signature); - Assert.Equal(1024, signature.Length); - Assert.EndsWith("…;", signature, StringComparison.Ordinal); + Assert.Equal("private Dictionary _map = …;", signature); Assert.DoesNotContain("k120", signature, StringComparison.Ordinal); } @@ -6193,9 +6197,7 @@ public void Extract_CSharp_BoundsStaticReadonlyCollectionInitializerSignature_Is var values = Assert.Single(symbols.Where(s => s.Kind == "field" && s.Name == "Values")); var signature = Assert.IsType(values.Signature); - Assert.StartsWith("private static readonly string[] Values =", signature, StringComparison.Ordinal); - Assert.Equal(1024, signature.Length); - Assert.EndsWith("…;", signature, StringComparison.Ordinal); + Assert.Equal("private static readonly string[] Values = …;", signature); Assert.DoesNotContain("value-120", signature, StringComparison.Ordinal); } @@ -6223,7 +6225,7 @@ public void Extract_CSharp_SameLineNestedClassAttachesFieldToInnerType() var inner = Assert.Single(symbols.Where(s => s.Kind == "class" && s.Name == "Inner")); Assert.Equal("class", inner.ContainerKind); Assert.Equal("Outer", inner.ContainerName); - var x = Assert.Single(symbols.Where(s => s.Kind == "property" && s.Name == "X")); + var x = Assert.Single(symbols.Where(s => s.Kind == "field" && s.Name == "X")); Assert.Equal("class", x.ContainerKind); Assert.Equal("Inner", x.ContainerName); Assert.Equal("public int X;", x.Signature); @@ -6233,12 +6235,12 @@ public void Extract_CSharp_SameLineNestedClassAttachesFieldToInnerType() public void Extract_CSharp_SameLineCompactEnumMembersDoNotLeakAsFields() { // `public enum Mode { [Obsolete] A = (int)B, ... }` must produce enum-member - // symbols only and must NOT emit phantom `property` symbols for `[Obsolete] A =`. + // symbols only and must NOT emit phantom `field` symbols for `[Obsolete] A =`. // The column-aware scope gate distinguishes class-like bodies (where fields are // legal) from enum bodies (where members are not fields), so the plain-field // regex is rejected inside enum bodies. Closes #400. // `public enum Mode { [Obsolete] A = (int)B, ... }` は enum member のみを生成し、 - // `[Obsolete] A =` を property として拾ってはならない。列意識スコープゲートが + // `[Obsolete] A =` を field として拾ってはならない。列意識スコープゲートが // class-like body(field が正当)と enum body(member は field ではない)を // 区別するため、enum body 内では plain-field regex がリジェクトされる。 // Closes #400. @@ -6252,7 +6254,7 @@ public void Extract_CSharp_SameLineCompactEnumMembersDoNotLeakAsFields() Assert.Contains(symbols, s => s.Kind == "enum" && s.Name == "Mode"); foreach (var name in new[] { "A", "B", "C" }) { - Assert.DoesNotContain(symbols, s => s.Kind == "property" && s.Name == name); + Assert.DoesNotContain(symbols, s => s.Kind == "field" && s.Name == name); } } @@ -6271,8 +6273,8 @@ public void Extract_CSharp_SameLineFieldWithInitializerFollowedByAnotherField() var symbols = SymbolExtractor.Extract(1, "csharp", content); Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "Holder"); - var a = Assert.Single(symbols, s => s.Kind == "property" && s.Name == "A"); - var b = Assert.Single(symbols, s => s.Kind == "property" && s.Name == "B"); + var a = Assert.Single(symbols, s => s.Kind == "field" && s.Name == "A"); + var b = Assert.Single(symbols, s => s.Kind == "field" && s.Name == "B"); Assert.Equal("public int A = 1;", a.Signature); Assert.Equal("public int B;", b.Signature); } @@ -6281,7 +6283,7 @@ public void Extract_CSharp_SameLineFieldWithInitializerFollowedByAnotherField() public void Extract_CSharp_SameLineDeclaratorListFollowedByAnotherField() { // `public class Holder { public int A, B; public int C; }` must capture - // three property rows (A, B via declarator list, plus C from the second + // three field rows (A, B via declarator list, plus C from the second // same-line field statement). The prior fix broke out of the same-line // scan after declarator expansion and silently dropped `C`. Closes #400. // `public class Holder { public int A, B; public int C; }` では declarator @@ -6292,9 +6294,9 @@ public void Extract_CSharp_SameLineDeclaratorListFollowedByAnotherField() var symbols = SymbolExtractor.Extract(1, "csharp", content); Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "Holder"); - Assert.Single(symbols, s => s.Kind == "property" && s.Name == "A"); - Assert.Single(symbols, s => s.Kind == "property" && s.Name == "B"); - var c = Assert.Single(symbols, s => s.Kind == "property" && s.Name == "C"); + Assert.Single(symbols, s => s.Kind == "field" && s.Name == "A"); + Assert.Single(symbols, s => s.Kind == "field" && s.Name == "B"); + var c = Assert.Single(symbols, s => s.Kind == "field" && s.Name == "C"); Assert.Equal("public int C;", c.Signature); } @@ -6319,7 +6321,7 @@ public void Extract_CSharp_PlainFieldWithInitializerKeepsFullSignature() "}"); var symbols = SymbolExtractor.Extract(1, "csharp", content); - var field = Assert.Single(symbols, s => s.Kind == "property" && s.Name == "_x"); + var field = Assert.Single(symbols, s => s.Kind == "field" && s.Name == "_x"); Assert.Equal("private int _x = 42;", field.Signature); } @@ -6339,7 +6341,7 @@ public void Extract_CSharp_SameLineGenericClassHeaderStillCapturesInnerField() var content = "public class C{int X;}\n"; var symbols = SymbolExtractor.Extract(1, "csharp", content); - var field = Assert.Single(symbols, s => s.Kind == "property" && s.Name == "X"); + var field = Assert.Single(symbols, s => s.Kind == "field" && s.Name == "X"); Assert.Equal("int X;", field.Signature); Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "C"); } @@ -6361,10 +6363,10 @@ public void Extract_CSharp_SameLineGenericFieldSignatureKeepsTerminator() var content = "public class C { public Dictionary Map = new(); public int B; }\n"; var symbols = SymbolExtractor.Extract(1, "csharp", content); - var map = Assert.Single(symbols, s => s.Kind == "property" && s.Name == "Map"); + var map = Assert.Single(symbols, s => s.Kind == "field" && s.Name == "Map"); Assert.Equal("public Dictionary Map = new();", map.Signature); - var b = Assert.Single(symbols, s => s.Kind == "property" && s.Name == "B"); + var b = Assert.Single(symbols, s => s.Kind == "field" && s.Name == "B"); Assert.Equal("public int B;", b.Signature); } @@ -6398,8 +6400,8 @@ public void Extract_CSharp_MultiLineFieldFollowedBySameLineFieldDoesNotCrash() var symbols = SymbolExtractor.Extract(1, "csharp", content); Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "C"); - Assert.Single(symbols, s => s.Kind == "property" && s.Name == "Map"); - var b = Assert.Single(symbols, s => s.Kind == "property" && s.Name == "B"); + Assert.Single(symbols, s => s.Kind == "field" && s.Name == "Map"); + var b = Assert.Single(symbols, s => s.Kind == "field" && s.Name == "B"); Assert.Equal("public int B;", b.Signature); } @@ -6425,10 +6427,10 @@ public void Extract_CSharp_DetectsFunctionPointerField() "}"); var symbols = SymbolExtractor.Extract(1, "csharp", content); - Assert.Contains(symbols, s => s.Kind == "property" + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "Callback" && s.Visibility == "public"); - Assert.Contains(symbols, s => s.Kind == "property" + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_op" && s.Visibility == "private"); } @@ -6452,10 +6454,10 @@ public void Extract_CSharp_DelegateTypeDeclarationIsNotField() var symbols = SymbolExtractor.Extract(1, "csharp", content); // Accept either a dedicated delegate / function classification, but never - // classify the statement as a plain `property` field. - // delegate / function としての抽出は許容するが、`property` field にだけは + // classify the statement as a plain `field`. + // delegate / function としての抽出は許容するが、plain `field` にだけは // 分類しないことを確認する。 - Assert.DoesNotContain(symbols, s => s.Kind == "property" && s.Name == "Callback"); + Assert.DoesNotContain(symbols, s => s.Kind == "field" && s.Name == "Callback"); } [Fact] diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index 7dd72699b..d2c92f563 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -8557,11 +8557,11 @@ public void Extract_CSharp_MultiLineFieldIgnoresBraceInsideStringLiteral() "}"); var symbols = SymbolExtractor.Extract(1, "csharp", content); - Assert.Contains(symbols, s => s.Kind == "property" + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_open" && s.Visibility == "private" && s.ReturnType == "string"); - Assert.Contains(symbols, s => s.Kind == "property" + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_pair" && s.Visibility == "private" && s.ReturnType == "string"); @@ -8570,13 +8570,13 @@ public void Extract_CSharp_MultiLineFieldIgnoresBraceInsideStringLiteral() [Fact] public void Extract_CSharp_DetectsDeclaratorListFields() { - // `private int _x, _y;` must emit one `property` symbol per declarator. The + // `private int _x, _y;` must emit one `field` symbol per declarator. The // field regex greedily swallows earlier declarators into `returnType`, so the // post-match expander walks the top-level commas in `returnType` and the tail // after the match to recover every declarator name. Closes #298 follow-up // (codex adversarial review). // `private int _x, _y;` のような declarator list は declarator ごとに 1 件の - // `property` シンボルを発行する。field regex は前段の declarator を + // `field` シンボルを発行する。field regex は前段の declarator を // returnType に飲み込むため、post-match 展開で returnType のトップレベル `,` // とマッチ後テールを走査し、すべての declarator 名を復元する。 var content = string.Join( @@ -8590,20 +8590,92 @@ public void Extract_CSharp_DetectsDeclaratorListFields() "}"); var symbols = SymbolExtractor.Extract(1, "csharp", content); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "_x" && s.ReturnType == "int" && s.Visibility == "private"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "_y" && s.ReturnType == "int" && s.Visibility == "private"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "First" && s.ReturnType == "string" && s.Visibility == "public"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "Second" && s.ReturnType == "string" && s.Visibility == "public"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "Third" && s.ReturnType == "string" && s.Visibility == "public"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "_a" && s.ReturnType == "int" && s.Visibility == "private"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "_b" && s.ReturnType == "int" && s.Visibility == "private"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "_c" && s.ReturnType == "int" && s.Visibility == "private"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_x" && s.ReturnType == "int" && s.Visibility == "private"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_y" && s.ReturnType == "int" && s.Visibility == "private"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "First" && s.ReturnType == "string" && s.Visibility == "public"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "Second" && s.ReturnType == "string" && s.Visibility == "public"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "Third" && s.ReturnType == "string" && s.Visibility == "public"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_a" && s.ReturnType == "int" && s.Visibility == "private"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_b" && s.ReturnType == "int" && s.Visibility == "private"); + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "_c" && s.ReturnType == "int" && s.Visibility == "private"); // The bogus `int _x,` or `int _a = 1,` returnType from a single-symbol emit must // not leak into the index. 単一シンボル発行で紛れ込む `int _x,` 等の returnType は // インデックスに漏らさない。 Assert.DoesNotContain(symbols, s => s.ReturnType != null && s.ReturnType.Contains(',')); } + [Fact] + public void Extract_CSharp_NormalizesFieldKindsAndLargeInitializerSignatures() + { + var largeInitializer = string.Join( + ",\n", + Enumerable.Range(0, 160).Select(index => $" new Person({index})")); + var content = $$""" + namespace Demo; + + public sealed record Person(int Id); + + public class FieldCases + { + private readonly List _people = []; + private const string Label = "small"; + private int _first = 1, _second; + private Person _targetTyped = new(1); + private int[] _array = new[] { 1, 2, 3 }; + private static readonly List BuiltInRecipes = + [ + {{largeInitializer}} + ]; + + public int Count { get; init; } + public int Computed => _people.Count; + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + + var people = Assert.Single(symbols.Where(symbol => symbol.Name == "_people")); + Assert.Equal("field", people.Kind); + Assert.Equal("List", people.ReturnType); + Assert.Equal("private", people.Visibility); + Assert.Equal(7, people.StartLine); + Assert.Equal(7, people.EndLine); + Assert.Equal("private readonly List _people = [];", people.Signature); + + var label = Assert.Single(symbols.Where(symbol => symbol.Name == "Label")); + Assert.Equal("field", label.Kind); + Assert.Equal("private const string Label = \"small\";", label.Signature); + + var first = Assert.Single(symbols.Where(symbol => symbol.Name == "_first")); + var second = Assert.Single(symbols.Where(symbol => symbol.Name == "_second")); + Assert.Equal("field", first.Kind); + Assert.Equal("field", second.Kind); + Assert.Equal("int", first.ReturnType); + Assert.Equal("int", second.ReturnType); + Assert.Equal("private int _first = 1, _second;", first.Signature); + Assert.Equal(first.Signature, second.Signature); + + Assert.Contains(symbols, symbol => symbol.Kind == "field" + && symbol.Name == "_targetTyped" + && symbol.ReturnType == "Person" + && symbol.Signature == "private Person _targetTyped = new(1);"); + Assert.Contains(symbols, symbol => symbol.Kind == "field" + && symbol.Name == "_array" + && symbol.ReturnType == "int[]" + && symbol.Signature == "private int[] _array = new[] { 1, 2, 3 };"); + + var builtInRecipes = Assert.Single(symbols.Where(symbol => symbol.Name == "BuiltInRecipes")); + Assert.Equal("field", builtInRecipes.Kind); + Assert.Equal("List", builtInRecipes.ReturnType); + Assert.Equal("private static readonly List BuiltInRecipes = …;", builtInRecipes.Signature); + Assert.NotNull(builtInRecipes.Signature); + Assert.True(builtInRecipes.Signature.Length < 100); + + Assert.Contains(symbols, symbol => symbol.Kind == "property" && symbol.Name == "Count"); + Assert.Contains(symbols, symbol => symbol.Kind == "property" && symbol.Name == "Computed"); + Assert.DoesNotContain(symbols, symbol => symbol.Kind == "property" && symbol.Name == "_people"); + } + [Fact] public void Extract_CSharp_SameLineDeclaratorListsStillExpandAfterEarlierSiblings() { @@ -8649,12 +8721,12 @@ public void Extract_CSharp_SameLineDeclaratorListsStillExpandAfterEarlierSibling Assert.Contains(symbols, s => s.Kind == @case.SiblingKind && s.Name == @case.SiblingName && s.ContainerKind == "class" && s.ContainerName == "C"); - var a = Assert.Single(symbols.Where(s => s.Kind == "property" && s.Name == "A")); + var a = Assert.Single(symbols.Where(s => s.Kind == "field" && s.Name == "A")); Assert.Equal("class", a.ContainerKind); Assert.Equal("C", a.ContainerName); Assert.Equal(@case.Signature, a.Signature); - var b = Assert.Single(symbols.Where(s => s.Kind == "property" && s.Name == "B")); + var b = Assert.Single(symbols.Where(s => s.Kind == "field" && s.Name == "B")); Assert.Equal("class", b.ContainerKind); Assert.Equal("C", b.ContainerName); Assert.Equal(@case.Signature, b.Signature); @@ -8737,7 +8809,7 @@ public void Extract_CSharp_SameLineClassBodyFieldIsCapturedAndLocalIsRejected() // 見ていたため、同一行の class body は「型本体の中ではない」と誤判定され X が // 取りこぼされた。逆に class 内の `public void M() { int local = 1; }` では、 // `int local` の列が method body の中(class body ではない)であることを列意識 - // ゲートが認識するため、擬似的な `property local` を生成してはならない。 + // ゲートが認識するため、擬似的な `field local` を生成してはならない。 // Closes #400. var content = string.Join( "\n", @@ -8752,13 +8824,13 @@ public void Extract_CSharp_SameLineClassBodyFieldIsCapturedAndLocalIsRejected() var symbols = SymbolExtractor.Extract(1, "csharp", content); Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "C"); - Assert.Contains(symbols, s => s.Kind == "property" && s.Name == "X" + Assert.Contains(symbols, s => s.Kind == "field" && s.Name == "X" && s.ContainerKind == "class" && s.ContainerName == "C" && s.Signature == "public int X;"); Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "D"); Assert.Contains(symbols, s => s.Kind == "function" && s.Name == "M" && s.ContainerKind == "class" && s.ContainerName == "D"); - Assert.DoesNotContain(symbols, s => s.Kind == "property" && s.Name == "local"); + Assert.DoesNotContain(symbols, s => s.Kind == "field" && s.Name == "local"); } [Fact] @@ -8987,7 +9059,7 @@ public bool Match(object value) => value is Assert.Equal(11, match.BodyEndLine); Assert.Equal("public bool Match(object value) => value is Red;", match.Signature); - var x = Assert.Single(symbols.Where(s => s.Kind == "property" && s.Name == "X")); + var x = Assert.Single(symbols.Where(s => s.Kind == "field" && s.Name == "X")); Assert.Equal(11, x.StartLine); Assert.Equal(11, x.EndLine); Assert.Equal("Uses", x.ContainerName); @@ -10464,11 +10536,11 @@ public void Extract_CSharp_ClosingBraceLineKeepsInnerMemberAfterInnerMethodClose public void Extract_CSharp_SameLineMultipleFieldsAreAllCaptured() { // `public class Multi { public int A; public int B; public int C; }` must - // produce three `property` symbols (A, B, C) plus the outer `Multi` class, with + // produce three `field` symbols (A, B, C) plus the outer `Multi` class, with // clean signatures that stop at the field terminator rather than trailing into // the enclosing `} }`. Closes #400. // `public class Multi { public int A; public int B; public int C; }` は外側 class - // Multi と A / B / C の 3 つの property を生成し、signature は末尾の `} }` を + // Multi と A / B / C の 3 つの field を生成し、signature は末尾の `} }` を // 含まずにフィールド終端(`;`)で切り詰められていなければならない。Closes #400. var content = string.Join( "\n", @@ -10480,7 +10552,7 @@ public void Extract_CSharp_SameLineMultipleFieldsAreAllCaptured() Assert.Contains(symbols, s => s.Kind == "class" && s.Name == "Multi"); foreach (var name in new[] { "A", "B", "C" }) { - var field = Assert.Single(symbols.Where(s => s.Kind == "property" && s.Name == name)); + var field = Assert.Single(symbols.Where(s => s.Kind == "field" && s.Name == name)); Assert.Equal("class", field.ContainerKind); Assert.Equal("Multi", field.ContainerName); Assert.Equal($"public int {name};", field.Signature); From 3a4e953e505252970c0273eb4be68549c5f409ba Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 07:37:59 +0900 Subject: [PATCH 2/3] Address C# field contract review findings (#4865) --- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/4865.fixed.md | 9 ++- .../CSharpReferenceExtractor.Support.cs | 2 +- .../Symbols/SymbolExtractor.CSharpScanner.cs | 25 ++++-- .../Symbols/SymbolExtractor.Contracts.cs | 2 +- .../SymbolExtractor.PatternSignatures.cs | 19 +++-- .../Lsp/LspServer.NavigationFeatures.cs | 1 + src/CodeIndex/Lsp/LspServer.Protocol.cs | 1 + .../IndexCommandRunnerFullScanTests.cs | 76 +++++++++++++++++++ tests/CodeIndex.Tests/LspServerTests.cs | 50 ++++++++++++ .../ReferenceExtractorCSharpTests.cs | 36 +++++++++ tests/CodeIndex.Tests/SymbolExtractorTests.cs | 26 +++++++ 12 files changed, 235 insertions(+), 16 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 8edb9ff45..e36327081 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -72,7 +72,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `ChunkSplitterTests.cs`, `SymbolExtractorTests.cs`, `ReferenceExtractorTests.cs`, `SearchSnippetFormatterTests.cs`, `DbPathResolverTests.cs`, `ExcerptRecoveryCommandFormatterTests.cs`, `ConsoleUiTests.cs` Pure or mostly pure behavior tests with in-memory inputs. - C# field coverage keeps collection expressions, constants, multiple declarators, target-typed `new`, arrays, real properties, oversized multiline initializer summarization, and qualified field-receiver references in coordinated fixtures so public kind, signature, and reference-identity contracts cannot drift apart. + C# field coverage keeps collection expressions, constants, multiple declarators, target-typed `new`, arrays, real properties, oversized multiline initializer summarization, qualified field-receiver references, extractor-contract reindexing, and LSP field-kind projection in coordinated fixtures so public kind, signature, and reference-identity contracts cannot drift apart. `DbPathResolverPureTests` keeps only path, injected-input, and URI validation cases that neither read process-global state nor open SQLite, allowing them to run outside the `SQLite pool sensitive` collection. Keep environment/current-directory resolution, real database and metadata probes, pool resets, and static test seams in `DbPathResolverTests`. Search snippet origin-priority coverage keeps PascalCase, snake_case, and phrase queries in coordinated mixed comment/string/code fixtures so identifier focus, same-line code-column clamping, over-1-MiB valid chunks, final-window dropped counts, filtered-origin refocusing, and the phrase control share one contract. Recovery-command coverage asserts structured argv, current `dotnet`/apphost prefix preservation, replay of option-like paths, and display-only escaping for both POSIX sh and PowerShell, including spaces, quotes, dollar signs, and shell metacharacters. @@ -985,7 +985,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `ChunkSplitterTests.cs`、`SymbolExtractorTests.cs`、`ReferenceExtractorTests.cs`、`SearchSnippetFormatterTests.cs`、`DbPathResolverTests.cs`、`ExcerptRecoveryCommandFormatterTests.cs`、`ConsoleUiTests.cs` インメモリ入力中心の、純粋またはほぼ純粋な振る舞いのテスト。 - C# field coverage は collection expression、constant、multiple declarator、target-typed `new`、array、実 property、巨大 multiline initializer の要約、qualified field receiver reference を連携 fixture にまとめ、公開 kind、signature、reference identity の契約が別々にずれないようにする。 + C# field coverage は collection expression、constant、multiple declarator、target-typed `new`、array、実 property、巨大 multiline initializer の要約、qualified field receiver reference、extractor contract による再 index、LSP field kind への投影を連携 fixture にまとめ、公開 kind、signature、reference identity の契約が別々にずれないようにする。 `DbPathResolverPureTests` には process-global state を読まず SQLite も開かない path、注入済み入力、URI validation の case だけを置き、`SQLite pool sensitive` collection の外で実行できるようにします。環境変数 / current directory の解決、実 database / metadata probe、pool reset、static test seam は `DbPathResolverTests` に残してください。 search snippet の origin 優先順位 coverage は PascalCase、snake_case、phrase query を連携した comment / string / code 混在 fixture にまとめ、identifier focus、同一行の code 列への clamping、1 MiB を超える有効 chunk、最終 window の dropped count、filter 後 origin への再 focus、phrase の control を一つの contract として検証します。 recovery command の coverage では構造化 argv、現在の `dotnet` / apphost prefix の維持、option と紛らわしい path の再実行、POSIX sh と PowerShell 双方の表示専用 escaping を、空白、quote、dollar sign、shell metacharacter を含めて検証します。 diff --git a/changelog.d/unreleased/4865.fixed.md b/changelog.d/unreleased/4865.fixed.md index ff24d4c0c..868f18556 100644 --- a/changelog.d/unreleased/4865.fixed.md +++ b/changelog.d/unreleased/4865.fixed.md @@ -3,11 +3,18 @@ category: fixed issues: - 4865 affected: + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs - src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternSignatures.cs - src/CodeIndex/Indexer/Symbols/SymbolExtractor.ExtractCore.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs - src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs + - src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs + - src/CodeIndex/Lsp/LspServer.Protocol.cs + - src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs - tests/CodeIndex.Tests/SymbolExtractorTests.cs - - tests/CodeIndex.Tests/ReferenceExtractorTests.cs + - tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs + - tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs + - tests/CodeIndex.Tests/LspServerTests.cs --- ## English diff --git a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs index 24eefd9dd..bfb9bfa98 100644 --- a/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs +++ b/src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs @@ -497,7 +497,7 @@ private static void AddCSharpContainingTypeValueReceiverName( ref Dictionary? lookup, SymbolRecord symbol) { - if (symbol.Kind != "property" || string.IsNullOrWhiteSpace(symbol.Name)) + if (symbol.Kind is not ("field" or "property") || string.IsNullOrWhiteSpace(symbol.Name)) return; var containingType = GetContainingTypeQualifiedName(symbol); diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs index d64fdfb16..ec04364db 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs @@ -2854,6 +2854,22 @@ private static string BuildCSharpTypeHeaderSignature( int startColumn, int lastLineIndex, int? lastLineExclusiveEndColumn) + { + var sanitized = BuildSanitizedCSharpMultilineSignature( + lines, + startLineIndex, + startColumn, + lastLineIndex, + lastLineExclusiveEndColumn); + return NormalizeCSharpConstraintGenericWhitespace(sanitized); + } + + private static string BuildSanitizedCSharpMultilineSignature( + string[] lines, + int startLineIndex, + int startColumn, + int lastLineIndex, + int? lastLineExclusiveEndColumn) { // Assemble the raw slice preserving '\n' between physical lines so multi-line raw // and verbatim string literals keep their newlines and leading indentation. The @@ -2890,8 +2906,7 @@ private static string BuildCSharpTypeHeaderSignature( rawSlice.Append(line, from, to - from); } - var sanitized = SanitizeCSharpTypeHeaderSlice(rawSlice.ToString()).Trim(); - return NormalizeCSharpConstraintGenericWhitespace(sanitized); + return SanitizeCSharpMultilineSignatureSlice(rawSlice.ToString()).Trim(); } private static string NormalizeCSharpConstraintGenericWhitespace(string signature) @@ -3002,7 +3017,7 @@ private struct CSharpHeaderFrame public bool EscapeNext; // String / Char: true if a preceding backslash awaits its escaped char. } - // Sanitize a C# type header slice: strip `//` line comments and `/* ... */` block + // Sanitize a multiline C# declaration slice: strip `//` line comments and `/* ... */` block // comments, collapse runs of Code-mode whitespace (including '\n' between lines) to a // single space, preserve all String / Verbatim / Raw / Char literal contents verbatim // (including literal whitespace runs, line breaks inside raw / verbatim strings, and @@ -3011,13 +3026,13 @@ private struct CSharpHeaderFrame // whitespace inside holes is collapsed while literal content outside holes is not. // Closes #382. // - // C# 型ヘッダスライスのサニタイザ: `//` 行コメントと `/* ... */` ブロックコメントを + // C# 複数行宣言スライスのサニタイザ: `//` 行コメントと `/* ... */` ブロックコメントを // 除去し、Code モードの空白列(行間の `\n` も含む)を 1 つのスペースに畳み、String / // Verbatim / Raw / Char リテラルの中身(リテラル内の空白、raw / verbatim の行末改行、 // エスケープ列)は verbatim に残し、補間ホール(`$"{expr}"`、`$@"{expr}"`、raw // `$"""{expr}"""` / `$$"""{{expr}}"""`)内部は Code モードとして分類してホール内の // 空白だけを畳み、ホール外のリテラル内容は畳まないようにする。Closes #382. - private static string SanitizeCSharpTypeHeaderSlice(string input) + private static string SanitizeCSharpMultilineSignatureSlice(string input) { if (string.IsNullOrEmpty(input)) return string.Empty; diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs index 7d54d2e00..43c4cada5 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.Contracts.cs @@ -5,7 +5,7 @@ public static partial class SymbolExtractor public const int DefaultContractVersion = 1; public const int ExpandedLanguageContractVersion = 2; public const int PythonContractVersion = 2; - public const int CSharpContractVersion = 7; + public const int CSharpContractVersion = 8; public const int DockerfileContractVersion = 2; public const int MakefileContractVersion = 2; public const int StyleAndXamlContractVersion = 2; diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternSignatures.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternSignatures.cs index 41d73d124..be82b849c 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternSignatures.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternSignatures.cs @@ -370,12 +370,19 @@ private static bool TryBuildCSharpMultilinePatternSignature( out var fieldLastLineExclusiveEndColumn) && fieldLastLineIndex > lineIndex) { - signature = BuildCSharpMultilineSignature( - lines, - lineIndex, - gateRawStartColumn, - fieldLastLineIndex, - fieldLastLineExclusiveEndColumn); + signature = pattern.Kind == "property" + ? BuildSanitizedCSharpMultilineSignature( + lines, + lineIndex, + gateRawStartColumn, + fieldLastLineIndex, + fieldLastLineExclusiveEndColumn) + : BuildCSharpMultilineSignature( + lines, + lineIndex, + gateRawStartColumn, + fieldLastLineIndex, + fieldLastLineExclusiveEndColumn); return true; } diff --git a/src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs b/src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs index c9738fad1..8b447a2f5 100644 --- a/src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs +++ b/src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs @@ -355,6 +355,7 @@ private static string FormatSymbolDetail(SymbolResult symbol) "class" => 7, "function" or "test.method" => 3, "property" => 10, + "field" => 5, "enum" => 13, "interface" => 8, "namespace" => 9, diff --git a/src/CodeIndex/Lsp/LspServer.Protocol.cs b/src/CodeIndex/Lsp/LspServer.Protocol.cs index c3be3320d..5268ecc04 100644 --- a/src/CodeIndex/Lsp/LspServer.Protocol.cs +++ b/src/CodeIndex/Lsp/LspServer.Protocol.cs @@ -134,6 +134,7 @@ private JsonObject ToDocumentSymbolInformation( "class" => 5, "function" or "test.method" => 12, "property" => 7, + "field" => 8, "enum" => 10, "interface" => 11, "namespace" => 3, diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs index c5b5beb0c..64717b40c 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs @@ -4218,6 +4218,82 @@ def target(): } } + [Fact] + public void Run_FullScan_ReindexesNormalizedCSharpFieldsWhenExtractorContractChanges_Issue4865() + { + var projectRoot = CreateTempProject(); + try + { + var entries = string.Join( + ",\n", + Enumerable.Range(0, 160).Select(index => $" new Person({index})")); + File.WriteAllText( + Path.Combine(projectRoot, "app.cs"), + $$""" + public sealed record Person(int Id); + + public sealed class App + { + private static readonly List BuiltInRecipes = + [ + {{entries}} + ]; + } + """); + File.WriteAllText(Path.Combine(projectRoot, "lib.py"), "def untouched():\n return 1\n"); + + var initialExitCode = IndexCommandRunner.Run([projectRoot, "--json"], _jsonOptions); + Assert.Equal(CommandExitCodes.Success, initialExitCode); + + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + using (var conn = OpenNonPoolingConnection(dbPath)) + { + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE symbols + SET kind = 'property', + signature = 'private static readonly List BuiltInRecipes = stale;' + WHERE name = 'BuiltInRecipes'; + UPDATE codeindex_meta + SET value = '7' + WHERE key = 'symbol_extractor_version_csharp'; + """; + cmd.ExecuteNonQuery(); + } + + var (exitCode, json) = RunAndCaptureJson([projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("success", json.GetProperty("status").GetString()); + Assert.True(json.GetProperty("summary").GetProperty("files_skipped").GetInt32() > 0); + + using var verify = OpenNonPoolingConnection(dbPath); + verify.Open(); + + using var fieldCmd = verify.CreateCommand(); + fieldCmd.CommandText = "SELECT kind, signature FROM symbols WHERE name = 'BuiltInRecipes'"; + using (var fieldReader = fieldCmd.ExecuteReader()) + { + Assert.True(fieldReader.Read()); + Assert.Equal("field", fieldReader.GetString(0)); + Assert.Equal( + "private static readonly List BuiltInRecipes = …;", + fieldReader.GetString(1)); + } + + using var versionCmd = verify.CreateCommand(); + versionCmd.CommandText = "SELECT value FROM codeindex_meta WHERE key = 'symbol_extractor_version_csharp'"; + Assert.Equal( + SymbolExtractor.CSharpContractVersion.ToString(CultureInfo.InvariantCulture), + versionCmd.ExecuteScalar() as string); + } + finally + { + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_FullScan_RebuildRestampsExtractorVersionForZeroSymbolLanguage() { diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 14c627f9e..0cfa57107 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -2472,6 +2472,56 @@ public void HandleMessage_DocumentSymbol_ReturnsIndexedSymbols() } } + [Fact] + public void HandleMessage_NormalizedCSharpField_UsesLspFieldKinds_Issue4865() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_csharp_field_kind"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "app.cs"); + var source = """ + public class App + { + public int CountField = 1; + public int Read() => CountField; + } + """; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, "app.cs", "csharp", source); + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + + var documentResponse = HandleInitializedMessage( + server, + CreateTextDocumentRequest("textDocument/documentSymbol", sourcePath, 48650)); + Assert.NotNull(documentResponse); + var roots = documentResponse!["result"]!.AsArray(); + var app = Assert.Single(roots.Where(symbol => symbol?["name"]?.GetValue() == "App")); + var field = Assert.Single(app!["children"]!.AsArray().Where( + symbol => symbol?["name"]?.GetValue() == "CountField")); + Assert.Equal(8, field!["kind"]!.GetValue()); + + var countFieldCharacter = CharacterOf(source, 3, "CountField"); + var completionResponse = HandleInitializedMessage( + server, + CreatePositionRequest( + "textDocument/completion", + sourcePath, + 48651, + 3, + countFieldCharacter + "CountF".Length)); + Assert.NotNull(completionResponse); + var completionField = Assert.Single(completionResponse!["result"]!["items"]!.AsArray().Where( + item => item?["label"]?.GetValue() == "CountField")); + Assert.Equal(5, completionField!["kind"]!.GetValue()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_DocumentSymbol_DoesNotNestSameRangeTopLevelSymbols_Issue3537() { diff --git a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs index 2be72e61c..fe8fed2c9 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs @@ -1811,6 +1811,42 @@ public static int ReadStatic() Assert.Equal("ReadInstanceFromStatic", readyRef.ContainerName); } + [Fact] + public void Extract_CsharpQualifiedEnumMemberAccess_WithFieldReceiver_DoesNotEmitEnumCall_Issue4865() + { + const string content = """ + namespace Demo; + + public enum Status + { + Ready + } + + public sealed class Holder + { + public int Ready; + } + + public sealed class UsesField + { + public Holder Status = new(); + + public int ReadField() + { + return Status.Ready; + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + Assert.Contains(symbols, symbol => symbol.Kind == "field" && symbol.Name == "Status"); + Assert.DoesNotContain(references, reference => + reference.SymbolName == "Ready" + && reference.ReferenceKind == "call"); + } + [Fact] public void Extract_CsharpQualifiedEnumMemberAccess_WithIndentedShadowing_RespectsLexicalContainers() { diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index d2c92f563..0dd6a8487 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -8676,6 +8676,32 @@ public class FieldCases Assert.DoesNotContain(symbols, symbol => symbol.Kind == "property" && symbol.Name == "_people"); } + [Fact] + public void Extract_CSharp_LargeMultilineFieldSignature_StripsLineCommentsBeforeSummarizing_Issue4865() + { + var largeInitializer = string.Join( + ",\n", + Enumerable.Range(0, 160).Select(index => $" \"value-{index:D3}\"")); + var content = $$""" + public sealed class CommentedFields + { + private string A = "a", // keep the following declarator visible + B = + string.Concat( + {{largeInitializer}}, + "end"); + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + + var field = Assert.Single(symbols.Where(symbol => symbol.Name == "A")); + Assert.Equal("field", field.Kind); + Assert.Equal("private string A = …, B = …;", field.Signature); + Assert.DoesNotContain("keep the following", field.Signature, StringComparison.Ordinal); + Assert.DoesNotContain("value-000", field.Signature, StringComparison.Ordinal); + } + [Fact] public void Extract_CSharp_SameLineDeclaratorListsStillExpandAfterEarlierSiblings() { From 5a6769f717e22aece6208ae3db33ca288aa7a08b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 09:19:00 +0900 Subject: [PATCH 3/3] Resolve C# field review findings (#4865) --- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/4865.fixed.md | 3 + src/CodeIndex/Database/DbSearchReader.cs | 1 + src/CodeIndex/Database/DbWriter.References.cs | 8 +-- .../SymbolExtractor.PatternSignatures.cs | 2 +- .../IndexCommandRunnerFullScanTests.cs | 66 +++++++++++++++++++ .../QueryCommandRunnerSearchTests.cs | 47 +++++++++++++ tests/CodeIndex.Tests/SymbolExtractorTests.cs | 20 +++--- 8 files changed, 135 insertions(+), 16 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index e36327081..512e28081 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -72,7 +72,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `ChunkSplitterTests.cs`, `SymbolExtractorTests.cs`, `ReferenceExtractorTests.cs`, `SearchSnippetFormatterTests.cs`, `DbPathResolverTests.cs`, `ExcerptRecoveryCommandFormatterTests.cs`, `ConsoleUiTests.cs` Pure or mostly pure behavior tests with in-memory inputs. - C# field coverage keeps collection expressions, constants, multiple declarators, target-typed `new`, arrays, real properties, oversized multiline initializer summarization, qualified field-receiver references, extractor-contract reindexing, and LSP field-kind projection in coordinated fixtures so public kind, signature, and reference-identity contracts cannot drift apart. + C# field coverage keeps collection expressions, constants, multiple declarators, target-typed `new`, arrays, real properties, oversized multiline initializer summarization, inherited and qualified field-receiver persistence, extractor-contract reindexing, search declaration metadata, and LSP field-kind projection in coordinated fixtures so public kind, signature, and reference-identity contracts cannot drift apart. `DbPathResolverPureTests` keeps only path, injected-input, and URI validation cases that neither read process-global state nor open SQLite, allowing them to run outside the `SQLite pool sensitive` collection. Keep environment/current-directory resolution, real database and metadata probes, pool resets, and static test seams in `DbPathResolverTests`. Search snippet origin-priority coverage keeps PascalCase, snake_case, and phrase queries in coordinated mixed comment/string/code fixtures so identifier focus, same-line code-column clamping, over-1-MiB valid chunks, final-window dropped counts, filtered-origin refocusing, and the phrase control share one contract. Recovery-command coverage asserts structured argv, current `dotnet`/apphost prefix preservation, replay of option-like paths, and display-only escaping for both POSIX sh and PowerShell, including spaces, quotes, dollar signs, and shell metacharacters. @@ -985,7 +985,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `ChunkSplitterTests.cs`、`SymbolExtractorTests.cs`、`ReferenceExtractorTests.cs`、`SearchSnippetFormatterTests.cs`、`DbPathResolverTests.cs`、`ExcerptRecoveryCommandFormatterTests.cs`、`ConsoleUiTests.cs` インメモリ入力中心の、純粋またはほぼ純粋な振る舞いのテスト。 - C# field coverage は collection expression、constant、multiple declarator、target-typed `new`、array、実 property、巨大 multiline initializer の要約、qualified field receiver reference、extractor contract による再 index、LSP field kind への投影を連携 fixture にまとめ、公開 kind、signature、reference identity の契約が別々にずれないようにする。 + C# field coverage は collection expression、constant、multiple declarator、target-typed `new`、array、実 property、巨大 multiline initializer の要約、継承 / qualified field receiver の永続化、extractor contract による再 index、search declaration metadata、LSP field kind への投影を連携 fixture にまとめ、公開 kind、signature、reference identity の契約が別々にずれないようにする。 `DbPathResolverPureTests` には process-global state を読まず SQLite も開かない path、注入済み入力、URI validation の case だけを置き、`SQLite pool sensitive` collection の外で実行できるようにします。環境変数 / current directory の解決、実 database / metadata probe、pool reset、static test seam は `DbPathResolverTests` に残してください。 search snippet の origin 優先順位 coverage は PascalCase、snake_case、phrase query を連携した comment / string / code 混在 fixture にまとめ、identifier focus、同一行の code 列への clamping、1 MiB を超える有効 chunk、最終 window の dropped count、filter 後 origin への再 focus、phrase の control を一つの contract として検証します。 recovery command の coverage では構造化 argv、現在の `dotnet` / apphost prefix の維持、option と紛らわしい path の再実行、POSIX sh と PowerShell 双方の表示専用 escaping を、空白、quote、dollar sign、shell metacharacter を含めて検証します。 diff --git a/changelog.d/unreleased/4865.fixed.md b/changelog.d/unreleased/4865.fixed.md index 868f18556..59ada0878 100644 --- a/changelog.d/unreleased/4865.fixed.md +++ b/changelog.d/unreleased/4865.fixed.md @@ -9,11 +9,14 @@ affected: - src/CodeIndex/Indexer/Symbols/SymbolExtractor.CSharpScanner.cs - src/CodeIndex/Indexer/References/ReferenceExtractor.CoreLookups.cs - src/CodeIndex/Indexer/References/Languages/CSharpReferenceExtractor.Support.cs + - src/CodeIndex/Database/DbWriter.References.cs + - src/CodeIndex/Database/DbSearchReader.cs - src/CodeIndex/Lsp/LspServer.Protocol.cs - src/CodeIndex/Lsp/LspServer.NavigationFeatures.cs - tests/CodeIndex.Tests/SymbolExtractorTests.cs - tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs - tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs - tests/CodeIndex.Tests/LspServerTests.cs --- diff --git a/src/CodeIndex/Database/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index 3bd69e38b..f99e5eaaf 100644 --- a/src/CodeIndex/Database/DbSearchReader.cs +++ b/src/CodeIndex/Database/DbSearchReader.cs @@ -802,6 +802,7 @@ PARTITION BY requested.request_index CASE s.kind WHEN 'function' THEN 0 WHEN 'test.method' THEN 0 + WHEN 'field' THEN 1 WHEN 'property' THEN 1 WHEN 'class' THEN 2 WHEN 'interface' THEN 2 diff --git a/src/CodeIndex/Database/DbWriter.References.cs b/src/CodeIndex/Database/DbWriter.References.cs index c26de8588..ee980579c 100644 --- a/src/CodeIndex/Database/DbWriter.References.cs +++ b/src/CodeIndex/Database/DbWriter.References.cs @@ -554,7 +554,7 @@ JOIN symbols AS target WHERE source.id = r.source_symbol_id AND source_file.lang = 'csharp' AND target_file.lang = 'csharp' - AND target.kind = 'property' + AND target.kind IN ('field', 'property') AND target.container_qualified_name IN ( SELECT source.container_qualified_name UNION @@ -581,7 +581,7 @@ JOIN symbols AS target WHERE source.id = r.source_symbol_id AND source_file.lang = 'csharp' AND target_file.lang = 'csharp' - AND target.kind = 'property' + AND target.kind IN ('field', 'property') AND target.container_qualified_name IN ( SELECT source.container_qualified_name UNION @@ -631,7 +631,7 @@ JOIN symbols AS target WHERE source.id = r.source_symbol_id AND source_file.lang = 'csharp' AND target_file.lang = 'csharp' - AND target.kind = 'property' + AND target.kind IN ('field', 'property') AND target.container_qualified_name IN ( SELECT source.container_qualified_name UNION @@ -732,7 +732,7 @@ FROM symbol_references AS r AND r.target_qualifier = char(31) || 'property_receiver:' || s.container_qualified_name COLLATE BINARY - AND s.kind = 'property'; + AND s.kind IN ('field', 'property'); INSERT INTO symbol_reference_candidates(reference_id, symbol_id, scope_rank) SELECT r.id, s.id, 0 diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternSignatures.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternSignatures.cs index be82b849c..f878a8625 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternSignatures.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.PatternSignatures.cs @@ -370,7 +370,7 @@ private static bool TryBuildCSharpMultilinePatternSignature( out var fieldLastLineExclusiveEndColumn) && fieldLastLineIndex > lineIndex) { - signature = pattern.Kind == "property" + signature = pattern.Kind is "property" or "function" ? BuildSanitizedCSharpMultilineSignature( lines, lineIndex, diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs index 64717b40c..5c6d80758 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs @@ -4294,6 +4294,72 @@ UPDATE codeindex_meta } } + [Fact] + public void Run_FullScan_ResolvesInheritedCSharpFieldReceiversAsFields_Issue4865() + { + var projectRoot = CreateTempProject(); + try + { + File.WriteAllText( + Path.Combine(projectRoot, "base.cs"), + """ + namespace Demo; + + public enum Status { Ready } + public sealed class Holder { public int Ready; } + + public abstract class Base + { + protected Holder Status = new(); + } + """); + File.WriteAllText( + Path.Combine(projectRoot, "derived.cs"), + """ + namespace Demo; + + public sealed class Derived : Base + { + public int Read() => Status.Ready; + } + """); + + var exitCode = IndexCommandRunner.Run([projectRoot, "--json"], _jsonOptions); + + Assert.Equal(CommandExitCodes.Success, exitCode); + + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + using var verify = OpenNonPoolingConnection(dbPath); + verify.Open(); + + using var referenceCmd = verify.CreateCommand(); + referenceCmd.CommandText = """ + SELECT r.reference_kind, + r.resolution_state, + target.kind, + target.name, + target.container_qualified_name + FROM symbol_references AS r + JOIN files AS source_file ON source_file.id = r.file_id + LEFT JOIN symbols AS target ON target.id = r.target_symbol_id + WHERE source_file.path = 'derived.cs' + AND r.symbol_name = 'Status' + """; + using var referenceReader = referenceCmd.ExecuteReader(); + Assert.True(referenceReader.Read()); + Assert.Equal("reference", referenceReader.GetString(0)); + Assert.Equal("resolved", referenceReader.GetString(1)); + Assert.Equal("field", referenceReader.GetString(2)); + Assert.Equal("Status", referenceReader.GetString(3)); + Assert.Equal("Demo.Base", referenceReader.GetString(4)); + Assert.False(referenceReader.Read()); + } + finally + { + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_FullScan_RebuildRestampsExtractorVersionForZeroSymbolLanguage() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 80477fa6b..267f3e366 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -13774,6 +13774,53 @@ void Run() } } + [Fact] + public void RunSearch_FieldDeclarationsKeepFieldEnclosingMetadataAndDeclarationKind_Issue4865() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_field_declaration_4865"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Fields.cs", + "csharp", + """ + public sealed class Fields + { + private readonly List Issue4865Field = []; + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + [ + "Issue4865Field", + "--db", + dbPath, + "--exact", + "--result-kind", + "declaration", + "--json=array", + ], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var row = Assert.Single(document.RootElement.EnumerateArray()); + Assert.Equal("src/Fields.cs", row.GetProperty("path").GetString()); + Assert.Equal("Issue4865Field", row.GetProperty("enclosing_symbol_name").GetString()); + Assert.Equal("field", row.GetProperty("enclosing_symbol_kind").GetString()); + Assert.Contains( + "declaration", + row.GetProperty("result_kinds").EnumerateArray().Select(value => value.GetString())); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_OriginAliasAndExcludeOriginFilterExactSubstring_Issue3680() { diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index 0dd6a8487..6711655a0 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -8676,20 +8676,22 @@ public class FieldCases Assert.DoesNotContain(symbols, symbol => symbol.Kind == "property" && symbol.Name == "_people"); } - [Fact] - public void Extract_CSharp_LargeMultilineFieldSignature_StripsLineCommentsBeforeSummarizing_Issue4865() + [Theory] + [InlineData("private string")] + [InlineData("private static readonly string")] + [InlineData("private const string")] + public void Extract_CSharp_LargeMultilineFieldSignatures_StripLineCommentsBeforeSummarizing_Issue4865( + string declarationPrefix) { var largeInitializer = string.Join( - ",\n", - Enumerable.Range(0, 160).Select(index => $" \"value-{index:D3}\"")); + " +\n", + Enumerable.Range(0, 160).Select(index => $" \"value-{index:D3}\"")); var content = $$""" public sealed class CommentedFields { - private string A = "a", // keep the following declarator visible + {{declarationPrefix}} A = "a", // keep the following declarator visible B = - string.Concat( - {{largeInitializer}}, - "end"); + {{largeInitializer}}; } """; @@ -8697,7 +8699,7 @@ public sealed class CommentedFields var field = Assert.Single(symbols.Where(symbol => symbol.Name == "A")); Assert.Equal("field", field.Kind); - Assert.Equal("private string A = …, B = …;", field.Signature); + Assert.Equal($"{declarationPrefix} A = …, B = …;", field.Signature); Assert.DoesNotContain("keep the following", field.Signature, StringComparison.Ordinal); Assert.DoesNotContain("value-000", field.Signature, StringComparison.Ordinal); }