From 0343abf1d4d2d008b86fc9c5d05aad105714aa4c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 14:16:25 +0900 Subject: [PATCH 1/3] Fix C# named-argument type references (#4833) --- DEVELOPER_GUIDE.md | 4 + TESTING_GUIDE.md | 2 + changelog.d/unreleased/4833.fixed.md | 15 ++ .../ReferenceExtractor.DeclarationTypes.cs | 36 ++++ .../QueryCommandRunnerIssue4833Tests.cs | 157 ++++++++++++++++++ .../ReferenceExtractorCSharpTests.cs | 89 ++++++++++ 6 files changed, 303 insertions(+) create mode 100644 changelog.d/unreleased/4833.fixed.md create mode 100644 tests/CodeIndex.Tests/QueryCommandRunnerIssue4833Tests.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 83968b6a8..8de5ebba6 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1126,6 +1126,8 @@ Reference extraction deduplicates only within the same indexed file and language TypeScript decorators emit `annotation` rows for the decorator name and must not hide the decorated declaration's type-position edges. For example, `constructor(@Inject() svc: Service)` records `Inject` as `annotation` and `Service` as `type_reference`, and `@Input() profile: UserProfile` records both the decorator and field type. +C# named-argument labels such as `overwrite:` are syntax, not type positions, and must not emit `type_reference` rows. The declaration-type scanner suppresses a leading single-colon label inside an argument fragment, including comma-terminated multiline argument lines, while preserving expression references in the argument value. Alias-qualified names (`Alias::Type`), statement and `case` labels, nullable types, and ternary expressions remain distinct colon-bearing constructs and are not suppressed by this rule. + ### GPU and shader reference extraction CUDA, GLSL, HLSL, Metal, and WGSL use a request-scoped, stateless reference @@ -4276,6 +4278,8 @@ authoritative な判定と一貫した推論を区別し、複数言語の evide TypeScript decorator は decorator 名を `annotation` 行として出力し、decorated declaration の型位置エッジを隠してはならない。たとえば `constructor(@Inject() svc: Service)` は `Inject` を `annotation`、`Service` を `type_reference` として記録し、`@Input() profile: UserProfile` も decorator と field type の両方を記録する。 +C# の `overwrite:` のような named-argument label は構文であり、型位置ではないため `type_reference` 行を出力してはならない。declaration-type scanner は argument fragment の先頭にある単一 colon の label を、comma で終わる複数行 argument も含めて抑止し、argument value 内の式参照は維持する。alias-qualified name(`Alias::Type`)、statement / `case` label、nullable type、ternary expression は別の colon 構文として扱い、この規則では抑止しない。 + ### GPU / shader の参照抽出 CUDA、GLSL、HLSL、Metal、WGSL は request ごとの stateless な参照 extractor を使う。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 018c8040d..9e0f81be7 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -92,6 +92,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Use `AssertReferencesContainInContext(...)` when several reference names share the same kind and exact source context; keep direct predicates when context is only one part of a richer edge contract. Use `AssertReferencesDoNotContain(...)` for negative checks over one reference kind; retain direct predicates when the exclusion depends on container, context, line, or other metadata. `ReferenceExtractorTests.ExtractSymbolsAndReferences(...)` owns the common symbol-then-reference extraction setup for tests that need both lists; use it instead of repeating the two extractor calls when the fixture does not need a specialized path or workspace symbol setup, and discard the symbol tuple element with `_` instead of keeping an unused `symbols` local when the test only asserts references. + C# named-argument coverage keeps syntax discrimination in `ReferenceExtractorCSharpTests` and persisted query/dependency behavior in `QueryCommandRunnerIssue4833Tests`; preserve positional and reordered arguments, attributes, constructors, nested and multiline calls, expression-side type references, and negative controls for aliases, labels, nullable types, and ternaries (#4833; regressions #106 and #122). Crystal, Groovy, Tcl, Prolog, and `ambiguous_pl` graph fixtures keep imports, parenthesized calls, conservative same-file command/predicate calls, caller containers, and keyword false-positive controls independently diagnosable; capability tests must keep their advertised symbol/reference/graph flags aligned with those extractor fixtures (#4746). Database status tests must also pin stale and current extractor-version stamps for those languages so pre-graph rows cannot report authoritative graph readiness. HDL graph coverage keeps Verilog, SystemVerilog, and VHDL in one coordinated extractor fixture for syntax-visible hierarchy/package/architecture edges, known same-file signal/type references, scope attribution, declaration/literal false-positive suppression, and comment masking. The paired `QueryCommandRunnerReferencesTests` fixture must persist and query one representative edge per HDL language so extractor-only assertions cannot hide database or CLI graph drift. `IndexCommandRunnerFullScanTests` must also clear the HDL graph contract and persisted edge, then prove that a normal full scan refreshes an unchanged HDL file before restoring readiness. @@ -980,6 +981,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" 複数の reference name が同じ kind と完全一致 source context を共有する場合は `AssertReferencesContainInContext(...)` を使い、context がより詳細な edge contract の一部にすぎない場合は直接 predicate を維持します。 1つの reference kind に対する否定チェックには `AssertReferencesDoNotContain(...)` を使い、container、context、line など他の metadata に依存する除外は直接 predicate を維持します。 `ReferenceExtractorTests.ExtractSymbolsAndReferences(...)` は symbol 抽出から reference 抽出までの共通 setup を所有します。fixture が特殊な path や workspace symbol setup を必要としない場合は 2 つの extractor 呼び出しを繰り返さずこの helper を使い、reference だけを検証するテストでは未使用の `symbols` local を残さず symbol 側を `_` で捨ててください。 + C# named-argument の coverage は構文の判別を `ReferenceExtractorCSharpTests`、永続化後の query / dependency 動作を `QueryCommandRunnerIssue4833Tests` に保持します。positional argument との混在、並べ替え、attribute、constructor、nested / multiline call、value 式側の type reference、および alias、label、nullable type、ternary に対する負例を維持してください(#4833、回帰 #106 / #122)。 Crystal、Groovy、Tcl、Prolog、`ambiguous_pl` の graph fixture では、import、括弧付き call、同一ファイルに限定した保守的な command / predicate call、caller container、keyword の false-positive control を個別に診断可能な状態で維持し、capability test の symbol / reference / graph 広告を extractor fixture と一致させてください(#4746)。 さらに database status test でこれらの言語の古い extractor-version stamp と現行 stamp を固定し、graph 対応前の row が authoritative な graph readiness を報告できないことを検証してください。 HDL graph coverage では Verilog、SystemVerilog、VHDL を1つの連携した extractor fixture にまとめ、構文上確認できる hierarchy/package/architecture edge、同一ファイル内の既知 signal/type reference、scope attribution、declaration/literal の false-positive 抑止、comment masking を検証します。対になる `QueryCommandRunnerReferencesTests` fixture では各 HDL 言語の代表 edge を1件ずつ永続化して query し、extractor-only assertion が database または CLI graph のずれを隠せないようにします。さらに `IndexCommandRunnerFullScanTests` では HDL graph contract と永続化 edge を消去し、通常の full scan が未変更 HDL file を更新してから readiness を復元することを検証します。 diff --git a/changelog.d/unreleased/4833.fixed.md b/changelog.d/unreleased/4833.fixed.md new file mode 100644 index 000000000..515800ca1 --- /dev/null +++ b/changelog.d/unreleased/4833.fixed.md @@ -0,0 +1,15 @@ +--- +category: fixed +issues: + - 4833 +affected: + - src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs +--- + +## English + +- **C# named-argument labels no longer become type references (#4833; regressions #106 and #122)** — labels in positional, reordered, attribute, constructor, nested, and multiline argument lists are suppressed without hiding references from their value expressions or changing other colon-bearing C# constructs. + +## 日本語 + +- **C# の named-argument label を型参照として扱わないようにしました (#4833、回帰 #106 / #122)** — positional argument との混在、並べ替え、attribute、constructor、nested / multiline な引数リストにある label を抑止しつつ、value 式内の参照と C# のその他の colon 構文は維持します。 diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs index 0e3b7491a..2a39e565f 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs @@ -625,6 +625,9 @@ private static bool TryGetParameterTypeRelativeSpan(string parameterFragment, st typeStart = -1; typeLength = 0; + if (language == "csharp" && StartsWithCSharpNamedArgumentLabel(parameterFragment)) + return false; + int end = FindTopLevelAssignmentIndex(parameterFragment); if (end < 0) end = parameterFragment.Length; @@ -677,6 +680,13 @@ private static bool TryGetSimpleDeclarationTypeSpan(string line, string language if (IsDefinitelyNotTypeDeclarationLine(line, language)) return false; + if (language == "csharp" + && line.AsSpan().TrimEnd().EndsWith(",") + && StartsWithCSharpNamedArgumentLabel(line)) + { + return false; + } + int firstParen = FindFirstTopLevelChar(line, '('); int firstTerminator = FindFirstTopLevelChar(line, ';'); int firstBrace = FindFirstTopLevelChar(line, '{'); @@ -723,6 +733,32 @@ private static bool TryGetSimpleDeclarationTypeSpan(string line, string language return true; } + private static bool StartsWithCSharpNamedArgumentLabel(string text) + { + int index = 0; + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + if (index >= text.Length || !IsCSharpIdentifierStart(text[index])) + return false; + + if (text[index] == '@') + { + index++; + if (index >= text.Length || (text[index] != '_' && !char.IsLetter(text[index]))) + return false; + } + + index++; + while (index < text.Length && IsCSharpIdentifierPart(text[index])) + index++; + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + + return index < text.Length + && text[index] == ':' + && (index + 1 >= text.Length || text[index + 1] != ':'); + } + private static bool IsDefinitelyNotTypeDeclarationLine(string line, string language) { var trimmed = line.TrimStart(); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue4833Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue4833Tests.cs new file mode 100644 index 000000000..795641a11 --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue4833Tests.cs @@ -0,0 +1,157 @@ +using CodeIndex.Cli; +using CodeIndex.Indexer; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Tests; + +public partial class QueryCommandRunnerTests +{ + [Fact] + public void CSharpNamedArgumentLabels_DoNotCreateExactReferencesOrDependencies_Issue4833() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_csharp_named_arguments_issue4833"); + try + { + TestProjectHelper.WriteTextFile( + projectRoot, + "src/Types.cs", + """ + namespace Fixture; + + public sealed class ExpressionPayload + { + } + """); + TestProjectHelper.WriteTextFile( + projectRoot, + "src/Impostor.cs", + """ + namespace Fixture; + + public sealed class CollisionHolder + { + public bool overwrite { get; } = true; + } + """); + TestProjectHelper.WriteTextFile( + projectRoot, + "src/Consumer.cs", + """ + using System; + + namespace Fixture; + + public sealed class Consumer + { + public void Run(bool condition) + { + Sink(1, overwrite: true, payload: typeof(ExpressionPayload)); + Sink( + positional: 2, + payload: typeof(ExpressionPayload), + overwrite: condition ? true : false); + } + + private static void Sink(int positional, bool overwrite, Type payload) + { + } + } + """); + + var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run( + [projectRoot, "--json", "--quiet"], + _jsonOptions)); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + + Assert.Equal(CommandExitCodes.Success, indexExitCode); + Assert.Equal(string.Empty, indexStderr); + + var (labelExitCode, labelStdout, labelStderr) = CaptureConsole( + () => QueryCommandRunner.RunReferences( + [ + "overwrite", + "--db", dbPath, + "--json", + "--exact-name", + "--kind", "type_reference", + "--lang", "csharp", + ], + _jsonOptions)); + using var labelDocument = ParseJsonOutput(labelStdout); + + Assert.Equal(CommandExitCodes.Success, labelExitCode); + Assert.Equal(string.Empty, labelStderr); + Assert.Equal(0, labelDocument.RootElement.GetProperty("count").GetInt32()); + Assert.Empty(labelDocument.RootElement.GetProperty("references").EnumerateArray()); + + var (payloadExitCode, payloadStdout, payloadStderr) = CaptureConsole( + () => QueryCommandRunner.RunReferences( + [ + "ExpressionPayload", + "--db", dbPath, + "--json", + "--exact-name", + "--kind", "type_reference", + "--lang", "csharp", + ], + _jsonOptions)); + using var payloadDocument = ParseJsonOutput(payloadStdout); + var payloadReference = payloadDocument.RootElement; + + Assert.Equal(CommandExitCodes.Success, payloadExitCode); + Assert.Equal(string.Empty, payloadStderr); + Assert.Equal("resolved", payloadReference.GetProperty("resolution_state").GetString()); + Assert.Equal("src/Consumer.cs", payloadReference.GetProperty("path").GetString()); + + using (var connection = new SqliteConnection( + new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadOnly, + }.ToString())) + { + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT COUNT(*) + FROM symbols + WHERE name = 'overwrite' + AND kind = 'property' + """; + Assert.Equal(1L, (long)command.ExecuteScalar()!); + + command.CommandText = """ + SELECT COUNT(*) + FROM symbol_references AS reference + JOIN files AS source_file ON source_file.id = reference.file_id + WHERE source_file.path = 'src/Consumer.cs' + AND reference.symbol_name = 'overwrite' + AND reference.reference_kind = 'type_reference' + """; + Assert.Equal(0L, (long)command.ExecuteScalar()!); + } + + var (depsExitCode, depsStdout, depsStderr) = CaptureConsole( + () => QueryCommandRunner.RunDeps( + ["--db", dbPath, "--json", "--lang", "csharp", "--limit", "100"], + _jsonOptions)); + using var depsDocument = ParseJsonOutput(depsStdout); + var dependencyEdges = depsDocument.RootElement.GetProperty("edges").EnumerateArray().ToArray(); + + Assert.Equal(CommandExitCodes.Success, depsExitCode); + Assert.Equal(string.Empty, depsStderr); + Assert.Contains( + dependencyEdges, + edge => edge.GetProperty("source_path").GetString() == "src/Consumer.cs" + && edge.GetProperty("target_path").GetString() == "src/Types.cs"); + Assert.DoesNotContain( + dependencyEdges, + edge => edge.GetProperty("source_path").GetString() == "src/Consumer.cs" + && edge.GetProperty("target_path").GetString() == "src/Impostor.cs"); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } +} diff --git a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs index ccc12c0a5..df2a2c89c 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs @@ -7466,6 +7466,95 @@ private static bool ReferenceMatches( && (column is null || reference.Column == column.Value); } + [Fact] + public void Extract_CsharpNamedArgumentLabels_DoNotBecomeTypeReferences_Issue4833() + { + // Named-argument labels are parameter selectors, not type positions. Keep the + // expressions after each label visible to their dedicated reference scanners, + // including attributes, constructors, nested calls, and multiline argument lists. + // named argument label は parameter selector であり、型位置ではない。attribute、 + // constructor、nested call、multiline 引数でも label 後の式は各参照 scanner に残す。 + const string content = """ + extern alias Alias; + using System; + + public sealed class ProbeAttribute : Attribute + { + public ProbeAttribute(Type payload, bool overwrite = false) {} + } + + public sealed class ExpressionPayload {} + public sealed class ConstructorTarget + { + public ConstructorTarget(bool overwrite, Type payload) {} + } + + public sealed class Demo + { + private static void Invoke(int positional, bool overwrite, Type payload) {} + private static object InvokeAdapter(bool overwrite, Type payload) => new(); + private static void Outer(object inner) {} + + [Probe(typeof(ExpressionPayload), overwrite: true)] + public void Run(bool condition, object value) + { + Invoke(1, overwrite: true, payload: typeof(ExpressionPayload)); + Invoke(payload: typeof(ExpressionPayload), positional: 2, overwrite: false); + _ = new ConstructorTarget(overwrite: true, payload: typeof(ExpressionPayload)); + Outer(inner: InvokeAdapter(overwrite: true, payload: typeof(ExpressionPayload))); + Invoke( + positional: 3, + payload: typeof(ExpressionPayload), + overwrite: condition ? true : false); + + retry: + if (value is ExpressionPayload matched) + goto done; + switch (value) + { + case ExpressionPayload casePayload: + goto done; + } + + ExpressionPayload? nullablePayload = null; + Alias::ExpressionPayload aliasQualified = default!; + if (condition) + goto retry; + + done: + _ = nullablePayload; + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + var namedArgumentLabels = new HashSet( + ["overwrite", "payload", "positional", "inner"], + StringComparer.Ordinal); + + Assert.DoesNotContain( + references, + reference => reference.ReferenceKind == "type_reference" + && namedArgumentLabels.Contains(reference.SymbolName)); + Assert.True( + references.Count(reference => + reference.SymbolName == "ExpressionPayload" + && reference.ReferenceKind == "type_reference") >= 8); + Assert.Contains( + references, + reference => reference.SymbolName == "ConstructorTarget" + && reference.ReferenceKind == "instantiate"); + Assert.Contains( + references, + reference => reference.SymbolName == "InvokeAdapter" + && reference.ReferenceKind == "call"); + Assert.DoesNotContain( + references, + reference => reference.SymbolName == "Alias" + && reference.ReferenceKind == "type_reference"); + } + #if NET8_0 [Fact] #else From b0ead763efc747f012781f23080f42a264cbd031 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 15:00:47 +0900 Subject: [PATCH 2/3] Preserve C# colon-qualified declaration types (#4833) --- DEVELOPER_GUIDE.md | 4 +- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/4833.fixed.md | 4 +- .../ReferenceExtractor.DeclarationTypes.cs | 66 +++++++++++++------ .../QueryCommandRunnerIssue4833Tests.cs | 39 +++++++++++ .../ReferenceExtractorCSharpTests.cs | 22 +++++-- 6 files changed, 108 insertions(+), 31 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 8de5ebba6..9ed4be5d8 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1126,7 +1126,7 @@ Reference extraction deduplicates only within the same indexed file and language TypeScript decorators emit `annotation` rows for the decorator name and must not hide the decorated declaration's type-position edges. For example, `constructor(@Inject() svc: Service)` records `Inject` as `annotation` and `Service` as `type_reference`, and `@Input() profile: UserProfile` records both the decorator and field type. -C# named-argument labels such as `overwrite:` are syntax, not type positions, and must not emit `type_reference` rows. The declaration-type scanner suppresses a leading single-colon label inside an argument fragment, including comma-terminated multiline argument lines, while preserving expression references in the argument value. Alias-qualified names (`Alias::Type`), statement and `case` labels, nullable types, and ternary expressions remain distinct colon-bearing constructs and are not suppressed by this rule. +C# named-argument labels such as `overwrite:` are syntax, not type positions, and must not emit `type_reference` rows. The declaration-type scanner skips a leading single-colon label inside an argument fragment, including comma-terminated multiline argument lines, while preserving expression references and named `out` declaration types in the argument value. Multiline property subpatterns likewise keep the type after their property label. Alias-qualified names (`Alias::Type`), statement and `case` labels, nullable types, and ternary expressions remain distinct colon-bearing constructs. ### GPU and shader reference extraction @@ -4278,7 +4278,7 @@ authoritative な判定と一貫した推論を区別し、複数言語の evide TypeScript decorator は decorator 名を `annotation` 行として出力し、decorated declaration の型位置エッジを隠してはならない。たとえば `constructor(@Inject() svc: Service)` は `Inject` を `annotation`、`Service` を `type_reference` として記録し、`@Input() profile: UserProfile` も decorator と field type の両方を記録する。 -C# の `overwrite:` のような named-argument label は構文であり、型位置ではないため `type_reference` 行を出力してはならない。declaration-type scanner は argument fragment の先頭にある単一 colon の label を、comma で終わる複数行 argument も含めて抑止し、argument value 内の式参照は維持する。alias-qualified name(`Alias::Type`)、statement / `case` label、nullable type、ternary expression は別の colon 構文として扱い、この規則では抑止しない。 +C# の `overwrite:` のような named-argument label は構文であり、型位置ではないため `type_reference` 行を出力してはならない。declaration-type scanner は argument fragment の先頭にある単一 colon の label を、comma で終わる複数行 argument も含めて読み飛ばし、argument value 内の式参照と named `out` declaration の型を維持する。複数行 property subpattern でも property label 後の型を維持する。alias-qualified name(`Alias::Type`)、statement / `case` label、nullable type、ternary expression は別の colon 構文として扱う。 ### GPU / shader の参照抽出 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 9e0f81be7..5ad249717 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -92,7 +92,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Use `AssertReferencesContainInContext(...)` when several reference names share the same kind and exact source context; keep direct predicates when context is only one part of a richer edge contract. Use `AssertReferencesDoNotContain(...)` for negative checks over one reference kind; retain direct predicates when the exclusion depends on container, context, line, or other metadata. `ReferenceExtractorTests.ExtractSymbolsAndReferences(...)` owns the common symbol-then-reference extraction setup for tests that need both lists; use it instead of repeating the two extractor calls when the fixture does not need a specialized path or workspace symbol setup, and discard the symbol tuple element with `_` instead of keeping an unused `symbols` local when the test only asserts references. - C# named-argument coverage keeps syntax discrimination in `ReferenceExtractorCSharpTests` and persisted query/dependency behavior in `QueryCommandRunnerIssue4833Tests`; preserve positional and reordered arguments, attributes, constructors, nested and multiline calls, expression-side type references, and negative controls for aliases, labels, nullable types, and ternaries (#4833; regressions #106 and #122). + C# named-argument coverage keeps syntax discrimination in `ReferenceExtractorCSharpTests` and persisted query/dependency behavior in `QueryCommandRunnerIssue4833Tests`; preserve positional and reordered arguments, attributes, constructors, nested and multiline calls, expression-side and named-`out` declaration type references, property-subpattern types, and negative controls for aliases, labels, nullable types, and ternaries (#4833; regressions #106 and #122). Crystal, Groovy, Tcl, Prolog, and `ambiguous_pl` graph fixtures keep imports, parenthesized calls, conservative same-file command/predicate calls, caller containers, and keyword false-positive controls independently diagnosable; capability tests must keep their advertised symbol/reference/graph flags aligned with those extractor fixtures (#4746). Database status tests must also pin stale and current extractor-version stamps for those languages so pre-graph rows cannot report authoritative graph readiness. HDL graph coverage keeps Verilog, SystemVerilog, and VHDL in one coordinated extractor fixture for syntax-visible hierarchy/package/architecture edges, known same-file signal/type references, scope attribution, declaration/literal false-positive suppression, and comment masking. The paired `QueryCommandRunnerReferencesTests` fixture must persist and query one representative edge per HDL language so extractor-only assertions cannot hide database or CLI graph drift. `IndexCommandRunnerFullScanTests` must also clear the HDL graph contract and persisted edge, then prove that a normal full scan refreshes an unchanged HDL file before restoring readiness. @@ -981,7 +981,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" 複数の reference name が同じ kind と完全一致 source context を共有する場合は `AssertReferencesContainInContext(...)` を使い、context がより詳細な edge contract の一部にすぎない場合は直接 predicate を維持します。 1つの reference kind に対する否定チェックには `AssertReferencesDoNotContain(...)` を使い、container、context、line など他の metadata に依存する除外は直接 predicate を維持します。 `ReferenceExtractorTests.ExtractSymbolsAndReferences(...)` は symbol 抽出から reference 抽出までの共通 setup を所有します。fixture が特殊な path や workspace symbol setup を必要としない場合は 2 つの extractor 呼び出しを繰り返さずこの helper を使い、reference だけを検証するテストでは未使用の `symbols` local を残さず symbol 側を `_` で捨ててください。 - C# named-argument の coverage は構文の判別を `ReferenceExtractorCSharpTests`、永続化後の query / dependency 動作を `QueryCommandRunnerIssue4833Tests` に保持します。positional argument との混在、並べ替え、attribute、constructor、nested / multiline call、value 式側の type reference、および alias、label、nullable type、ternary に対する負例を維持してください(#4833、回帰 #106 / #122)。 + C# named-argument の coverage は構文の判別を `ReferenceExtractorCSharpTests`、永続化後の query / dependency 動作を `QueryCommandRunnerIssue4833Tests` に保持します。positional argument との混在、並べ替え、attribute、constructor、nested / multiline call、value 式側と named `out` declaration の type reference、property subpattern の型、および alias、label、nullable type、ternary に対する負例を維持してください(#4833、回帰 #106 / #122)。 Crystal、Groovy、Tcl、Prolog、`ambiguous_pl` の graph fixture では、import、括弧付き call、同一ファイルに限定した保守的な command / predicate call、caller container、keyword の false-positive control を個別に診断可能な状態で維持し、capability test の symbol / reference / graph 広告を extractor fixture と一致させてください(#4746)。 さらに database status test でこれらの言語の古い extractor-version stamp と現行 stamp を固定し、graph 対応前の row が authoritative な graph readiness を報告できないことを検証してください。 HDL graph coverage では Verilog、SystemVerilog、VHDL を1つの連携した extractor fixture にまとめ、構文上確認できる hierarchy/package/architecture edge、同一ファイル内の既知 signal/type reference、scope attribution、declaration/literal の false-positive 抑止、comment masking を検証します。対になる `QueryCommandRunnerReferencesTests` fixture では各 HDL 言語の代表 edge を1件ずつ永続化して query し、extractor-only assertion が database または CLI graph のずれを隠せないようにします。さらに `IndexCommandRunnerFullScanTests` では HDL graph contract と永続化 edge を消去し、通常の full scan が未変更 HDL file を更新してから readiness を復元することを検証します。 diff --git a/changelog.d/unreleased/4833.fixed.md b/changelog.d/unreleased/4833.fixed.md index 515800ca1..6c121c36d 100644 --- a/changelog.d/unreleased/4833.fixed.md +++ b/changelog.d/unreleased/4833.fixed.md @@ -8,8 +8,8 @@ affected: ## English -- **C# named-argument labels no longer become type references (#4833; regressions #106 and #122)** — labels in positional, reordered, attribute, constructor, nested, and multiline argument lists are suppressed without hiding references from their value expressions or changing other colon-bearing C# constructs. +- **C# named-argument labels no longer become type references (#4833; regressions #106 and #122)** — labels in positional, reordered, attribute, constructor, nested, and multiline argument lists are suppressed without hiding value-expression, named-`out` declaration, or property-subpattern type references, and without changing other colon-bearing C# constructs. ## 日本語 -- **C# の named-argument label を型参照として扱わないようにしました (#4833、回帰 #106 / #122)** — positional argument との混在、並べ替え、attribute、constructor、nested / multiline な引数リストにある label を抑止しつつ、value 式内の参照と C# のその他の colon 構文は維持します。 +- **C# の named-argument label を型参照として扱わないようにしました (#4833、回帰 #106 / #122)** — positional argument との混在、並べ替え、attribute、constructor、nested / multiline な引数リストにある label を抑止しつつ、value 式、named `out` declaration、property subpattern の型参照と C# のその他の colon 構文は維持します。 diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs index 2a39e565f..3afb8c671 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs @@ -625,16 +625,25 @@ private static bool TryGetParameterTypeRelativeSpan(string parameterFragment, st typeStart = -1; typeLength = 0; - if (language == "csharp" && StartsWithCSharpNamedArgumentLabel(parameterFragment)) - return false; - - int end = FindTopLevelAssignmentIndex(parameterFragment); + int candidateOffset = 0; + bool hasLeadingCSharpColonLabel = language == "csharp" + && TryGetCSharpLeadingColonLabelValueStart(parameterFragment, out candidateOffset); + var candidateSource = candidateOffset == 0 + ? parameterFragment + : parameterFragment.Substring(candidateOffset); + + int end = FindTopLevelAssignmentIndex(candidateSource); if (end < 0) - end = parameterFragment.Length; - var candidate = parameterFragment.Substring(0, end); + end = candidateSource.Length; + var candidate = candidateSource.Substring(0, end); var tokens = GetTopLevelTokenSpans(candidate); if (tokens.Count < 2) return false; + if (hasLeadingCSharpColonLabel + && !candidate.AsSpan(tokens[0].Start, tokens[0].Length).Equals("out", StringComparison.Ordinal)) + { + return false; + } int first = 0; while (first < tokens.Count) @@ -652,7 +661,8 @@ private static bool TryGetParameterTypeRelativeSpan(string parameterFragment, st if (first >= tokens.Count - 1) return false; - typeStart = tokens[first].Start; + int relativeTypeStart = tokens[first].Start; + typeStart = candidateOffset + relativeTypeStart; int lastTypeToken = tokens.Count - 2; while (lastTypeToken >= first) { @@ -668,7 +678,7 @@ private static bool TryGetParameterTypeRelativeSpan(string parameterFragment, st if (lastTypeToken < first) return false; - typeLength = tokens[lastTypeToken].Start + tokens[lastTypeToken].Length - typeStart; + typeLength = tokens[lastTypeToken].Start + tokens[lastTypeToken].Length - relativeTypeStart; return true; } @@ -680,12 +690,10 @@ private static bool TryGetSimpleDeclarationTypeSpan(string line, string language if (IsDefinitelyNotTypeDeclarationLine(line, language)) return false; - if (language == "csharp" + int headStart = 0; + bool hasLeadingCSharpColonLabel = language == "csharp" && line.AsSpan().TrimEnd().EndsWith(",") - && StartsWithCSharpNamedArgumentLabel(line)) - { - return false; - } + && TryGetCSharpLeadingColonLabelValueStart(line, out headStart); int firstParen = FindFirstTopLevelChar(line, '('); int firstTerminator = FindFirstTopLevelChar(line, ';'); @@ -702,7 +710,7 @@ private static bool TryGetSimpleDeclarationTypeSpan(string line, string language if (firstParen >= 0 && firstParen < boundary) return false; - var head = line.Substring(0, boundary); + var head = line.Substring(headStart, boundary - headStart); var tokens = GetTopLevelTokenSpans(head); if (tokens.Count < 2) return false; @@ -711,7 +719,10 @@ private static bool TryGetSimpleDeclarationTypeSpan(string line, string language while (first < tokens.Count) { var token = head.Substring(tokens[first].Start, tokens[first].Length); - if (token.StartsWith("[", StringComparison.Ordinal) || token.StartsWith("@", StringComparison.Ordinal) || IsDeclarationModifier(language, token)) + if (token.StartsWith("[", StringComparison.Ordinal) + || token.StartsWith("@", StringComparison.Ordinal) + || IsDeclarationModifier(language, token) + || (hasLeadingCSharpColonLabel && IsParameterModifier(language, token))) { first++; continue; @@ -722,19 +733,23 @@ private static bool TryGetSimpleDeclarationTypeSpan(string line, string language if (first >= tokens.Count - 1) return false; + if (hasLeadingCSharpColonLabel && first != tokens.Count - 2) + return false; var declaredNameToken = head.Substring(tokens[^1].Start, tokens[^1].Length); if (!IsSimpleDeclarationIdentifier(language, declaredNameToken)) return false; - typeStart = tokens[first].Start; + int relativeTypeStart = tokens[first].Start; + typeStart = headStart + relativeTypeStart; int lastTypeToken = tokens.Count - 2; - typeLength = tokens[lastTypeToken].Start + tokens[lastTypeToken].Length - typeStart; + typeLength = tokens[lastTypeToken].Start + tokens[lastTypeToken].Length - relativeTypeStart; return true; } - private static bool StartsWithCSharpNamedArgumentLabel(string text) + private static bool TryGetCSharpLeadingColonLabelValueStart(string text, out int valueStart) { + valueStart = 0; int index = 0; while (index < text.Length && char.IsWhiteSpace(text[index])) index++; @@ -754,9 +769,18 @@ private static bool StartsWithCSharpNamedArgumentLabel(string text) while (index < text.Length && char.IsWhiteSpace(text[index])) index++; - return index < text.Length - && text[index] == ':' - && (index + 1 >= text.Length || text[index + 1] != ':'); + if (index >= text.Length + || text[index] != ':' + || (index + 1 < text.Length && text[index + 1] == ':')) + { + return false; + } + + index++; + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + valueStart = index; + return true; } private static bool IsDefinitelyNotTypeDeclarationLine(string line, string language) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue4833Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue4833Tests.cs index 795641a11..f324df19d 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerIssue4833Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue4833Tests.cs @@ -21,6 +21,11 @@ namespace Fixture; public sealed class ExpressionPayload { } + + public sealed class PatternHolder + { + public object? Value { get; init; } + } """); TestProjectHelper.WriteTextFile( projectRoot, @@ -31,6 +36,7 @@ namespace Fixture; public sealed class CollisionHolder { public bool overwrite { get; } = true; + public object? Value { get; } } """); TestProjectHelper.WriteTextFile( @@ -50,11 +56,24 @@ public void Run(bool condition) positional: 2, payload: typeof(ExpressionPayload), overwrite: condition ? true : false); + SinkOut(payload: out ExpressionPayload declaredPayload); + if (declaredPayload is PatternHolder + { + Value: ExpressionPayload propertyPayload, + }) + { + _ = propertyPayload; + } } private static void Sink(int positional, bool overwrite, Type payload) { } + + private static void SinkOut(out ExpressionPayload payload) + { + payload = new(); + } } """); @@ -129,6 +148,26 @@ FROM symbol_references AS reference AND reference.reference_kind = 'type_reference' """; Assert.Equal(0L, (long)command.ExecuteScalar()!); + + command.CommandText = """ + SELECT COUNT(*) + FROM symbol_references AS reference + JOIN files AS source_file ON source_file.id = reference.file_id + WHERE source_file.path = 'src/Consumer.cs' + AND reference.symbol_name = 'ExpressionPayload' + AND reference.reference_kind = 'type_reference' + """; + Assert.Equal(5L, (long)command.ExecuteScalar()!); + + command.CommandText = """ + SELECT COUNT(*) + FROM symbol_references AS reference + JOIN files AS source_file ON source_file.id = reference.file_id + WHERE source_file.path = 'src/Consumer.cs' + AND reference.symbol_name IN ('payload', 'Value') + AND reference.reference_kind = 'type_reference' + """; + Assert.Equal(0L, (long)command.ExecuteScalar()!); } var (depsExitCode, depsStdout, depsStderr) = CaptureConsole( diff --git a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs index df2a2c89c..bb92ede24 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs @@ -7484,6 +7484,11 @@ public ProbeAttribute(Type payload, bool overwrite = false) {} } public sealed class ExpressionPayload {} + public sealed class PatternHolder + { + public object Value { get; init; } = new(); + } + public sealed class ConstructorTarget { public ConstructorTarget(bool overwrite, Type payload) {} @@ -7493,6 +7498,7 @@ public sealed class Demo { private static void Invoke(int positional, bool overwrite, Type payload) {} private static object InvokeAdapter(bool overwrite, Type payload) => new(); + private static void InvokeOut(out ExpressionPayload payload) => payload = new(); private static void Outer(object inner) {} [Probe(typeof(ExpressionPayload), overwrite: true)] @@ -7502,6 +7508,7 @@ public void Run(bool condition, object value) Invoke(payload: typeof(ExpressionPayload), positional: 2, overwrite: false); _ = new ConstructorTarget(overwrite: true, payload: typeof(ExpressionPayload)); Outer(inner: InvokeAdapter(overwrite: true, payload: typeof(ExpressionPayload))); + InvokeOut(payload: out ExpressionPayload declaredPayload); Invoke( positional: 3, payload: typeof(ExpressionPayload), @@ -7510,6 +7517,13 @@ public void Run(bool condition, object value) retry: if (value is ExpressionPayload matched) goto done; + if (value is PatternHolder + { + Value: ExpressionPayload propertyPayload, + }) + { + _ = propertyPayload; + } switch (value) { case ExpressionPayload casePayload: @@ -7529,18 +7543,18 @@ public void Run(bool condition, object value) var symbols = SymbolExtractor.Extract(1, "csharp", content); var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); - var namedArgumentLabels = new HashSet( - ["overwrite", "payload", "positional", "inner"], + var colonLabels = new HashSet( + ["overwrite", "payload", "positional", "inner", "Value"], StringComparer.Ordinal); Assert.DoesNotContain( references, reference => reference.ReferenceKind == "type_reference" - && namedArgumentLabels.Contains(reference.SymbolName)); + && colonLabels.Contains(reference.SymbolName)); Assert.True( references.Count(reference => reference.SymbolName == "ExpressionPayload" - && reference.ReferenceKind == "type_reference") >= 8); + && reference.ReferenceKind == "type_reference") >= 10); Assert.Contains( references, reference => reference.SymbolName == "ConstructorTarget" From 343c6fc669f8715d8863770bda6061f6a71420ca Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 15:51:50 +0900 Subject: [PATCH 3/3] Preserve typed C# named-argument values (#4833) --- DEVELOPER_GUIDE.md | 4 +- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/4833.fixed.md | 4 +- .../ReferenceExtractor.DeclarationTypes.cs | 220 ++++++++++++++++-- .../QueryCommandRunnerIssue4833Tests.cs | 18 +- .../ReferenceExtractorCSharpTests.cs | 30 ++- 6 files changed, 248 insertions(+), 32 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 9ed4be5d8..ac747283d 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1126,7 +1126,7 @@ Reference extraction deduplicates only within the same indexed file and language TypeScript decorators emit `annotation` rows for the decorator name and must not hide the decorated declaration's type-position edges. For example, `constructor(@Inject() svc: Service)` records `Inject` as `annotation` and `Service` as `type_reference`, and `@Input() profile: UserProfile` records both the decorator and field type. -C# named-argument labels such as `overwrite:` are syntax, not type positions, and must not emit `type_reference` rows. The declaration-type scanner skips a leading single-colon label inside an argument fragment, including comma-terminated multiline argument lines, while preserving expression references and named `out` declaration types in the argument value. Multiline property subpatterns likewise keep the type after their property label. Alias-qualified names (`Alias::Type`), statement and `case` labels, nullable types, and ternary expressions remain distinct colon-bearing constructs. +C# named-argument labels such as `overwrite:` are syntax, not type positions, and must not emit `type_reference` rows. The declaration-type scanner skips a leading single-colon label inside an argument fragment, including comma-terminated multiline argument lines, while preserving expression references, named `out` declaration types, explicitly typed lambda and anonymous-method parameters, and typed LINQ range variables in the argument value. Multiline property subpatterns likewise keep the type after their property label. Alias-qualified names (`Alias::Type`), statement and `case` labels, nullable types, and ternary expressions remain distinct colon-bearing constructs. ### GPU and shader reference extraction @@ -4278,7 +4278,7 @@ authoritative な判定と一貫した推論を区別し、複数言語の evide TypeScript decorator は decorator 名を `annotation` 行として出力し、decorated declaration の型位置エッジを隠してはならない。たとえば `constructor(@Inject() svc: Service)` は `Inject` を `annotation`、`Service` を `type_reference` として記録し、`@Input() profile: UserProfile` も decorator と field type の両方を記録する。 -C# の `overwrite:` のような named-argument label は構文であり、型位置ではないため `type_reference` 行を出力してはならない。declaration-type scanner は argument fragment の先頭にある単一 colon の label を、comma で終わる複数行 argument も含めて読み飛ばし、argument value 内の式参照と named `out` declaration の型を維持する。複数行 property subpattern でも property label 後の型を維持する。alias-qualified name(`Alias::Type`)、statement / `case` label、nullable type、ternary expression は別の colon 構文として扱う。 +C# の `overwrite:` のような named-argument label は構文であり、型位置ではないため `type_reference` 行を出力してはならない。declaration-type scanner は argument fragment の先頭にある単一 colon の label を、comma で終わる複数行 argument も含めて読み飛ばし、argument value 内の式参照、named `out` declaration の型、明示型 lambda / anonymous method の parameter、および型付き LINQ range variable を維持する。複数行 property subpattern でも property label 後の型を維持する。alias-qualified name(`Alias::Type`)、statement / `case` label、nullable type、ternary expression は別の colon 構文として扱う。 ### GPU / shader の参照抽出 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 5ad249717..a39c44e31 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -92,7 +92,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Use `AssertReferencesContainInContext(...)` when several reference names share the same kind and exact source context; keep direct predicates when context is only one part of a richer edge contract. Use `AssertReferencesDoNotContain(...)` for negative checks over one reference kind; retain direct predicates when the exclusion depends on container, context, line, or other metadata. `ReferenceExtractorTests.ExtractSymbolsAndReferences(...)` owns the common symbol-then-reference extraction setup for tests that need both lists; use it instead of repeating the two extractor calls when the fixture does not need a specialized path or workspace symbol setup, and discard the symbol tuple element with `_` instead of keeping an unused `symbols` local when the test only asserts references. - C# named-argument coverage keeps syntax discrimination in `ReferenceExtractorCSharpTests` and persisted query/dependency behavior in `QueryCommandRunnerIssue4833Tests`; preserve positional and reordered arguments, attributes, constructors, nested and multiline calls, expression-side and named-`out` declaration type references, property-subpattern types, and negative controls for aliases, labels, nullable types, and ternaries (#4833; regressions #106 and #122). + C# named-argument coverage keeps syntax discrimination in `ReferenceExtractorCSharpTests` and persisted query/dependency behavior in `QueryCommandRunnerIssue4833Tests`; preserve positional and reordered arguments, attributes, constructors, nested and multiline calls, expression-side, named-`out`, explicitly typed lambda/anonymous-method, and typed LINQ range-variable type references, property-subpattern types, and negative controls for aliases, labels, nullable types, and ternaries (#4833; regressions #106 and #122). Crystal, Groovy, Tcl, Prolog, and `ambiguous_pl` graph fixtures keep imports, parenthesized calls, conservative same-file command/predicate calls, caller containers, and keyword false-positive controls independently diagnosable; capability tests must keep their advertised symbol/reference/graph flags aligned with those extractor fixtures (#4746). Database status tests must also pin stale and current extractor-version stamps for those languages so pre-graph rows cannot report authoritative graph readiness. HDL graph coverage keeps Verilog, SystemVerilog, and VHDL in one coordinated extractor fixture for syntax-visible hierarchy/package/architecture edges, known same-file signal/type references, scope attribution, declaration/literal false-positive suppression, and comment masking. The paired `QueryCommandRunnerReferencesTests` fixture must persist and query one representative edge per HDL language so extractor-only assertions cannot hide database or CLI graph drift. `IndexCommandRunnerFullScanTests` must also clear the HDL graph contract and persisted edge, then prove that a normal full scan refreshes an unchanged HDL file before restoring readiness. @@ -981,7 +981,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" 複数の reference name が同じ kind と完全一致 source context を共有する場合は `AssertReferencesContainInContext(...)` を使い、context がより詳細な edge contract の一部にすぎない場合は直接 predicate を維持します。 1つの reference kind に対する否定チェックには `AssertReferencesDoNotContain(...)` を使い、container、context、line など他の metadata に依存する除外は直接 predicate を維持します。 `ReferenceExtractorTests.ExtractSymbolsAndReferences(...)` は symbol 抽出から reference 抽出までの共通 setup を所有します。fixture が特殊な path や workspace symbol setup を必要としない場合は 2 つの extractor 呼び出しを繰り返さずこの helper を使い、reference だけを検証するテストでは未使用の `symbols` local を残さず symbol 側を `_` で捨ててください。 - C# named-argument の coverage は構文の判別を `ReferenceExtractorCSharpTests`、永続化後の query / dependency 動作を `QueryCommandRunnerIssue4833Tests` に保持します。positional argument との混在、並べ替え、attribute、constructor、nested / multiline call、value 式側と named `out` declaration の type reference、property subpattern の型、および alias、label、nullable type、ternary に対する負例を維持してください(#4833、回帰 #106 / #122)。 + C# named-argument の coverage は構文の判別を `ReferenceExtractorCSharpTests`、永続化後の query / dependency 動作を `QueryCommandRunnerIssue4833Tests` に保持します。positional argument との混在、並べ替え、attribute、constructor、nested / multiline call、value 式側、named `out` declaration、明示型 lambda / anonymous method、および型付き LINQ range variable の type reference、property subpattern の型、ならびに alias、label、nullable type、ternary に対する負例を維持してください(#4833、回帰 #106 / #122)。 Crystal、Groovy、Tcl、Prolog、`ambiguous_pl` の graph fixture では、import、括弧付き call、同一ファイルに限定した保守的な command / predicate call、caller container、keyword の false-positive control を個別に診断可能な状態で維持し、capability test の symbol / reference / graph 広告を extractor fixture と一致させてください(#4746)。 さらに database status test でこれらの言語の古い extractor-version stamp と現行 stamp を固定し、graph 対応前の row が authoritative な graph readiness を報告できないことを検証してください。 HDL graph coverage では Verilog、SystemVerilog、VHDL を1つの連携した extractor fixture にまとめ、構文上確認できる hierarchy/package/architecture edge、同一ファイル内の既知 signal/type reference、scope attribution、declaration/literal の false-positive 抑止、comment masking を検証します。対になる `QueryCommandRunnerReferencesTests` fixture では各 HDL 言語の代表 edge を1件ずつ永続化して query し、extractor-only assertion が database または CLI graph のずれを隠せないようにします。さらに `IndexCommandRunnerFullScanTests` では HDL graph contract と永続化 edge を消去し、通常の full scan が未変更 HDL file を更新してから readiness を復元することを検証します。 diff --git a/changelog.d/unreleased/4833.fixed.md b/changelog.d/unreleased/4833.fixed.md index 6c121c36d..f32d1f6f6 100644 --- a/changelog.d/unreleased/4833.fixed.md +++ b/changelog.d/unreleased/4833.fixed.md @@ -8,8 +8,8 @@ affected: ## English -- **C# named-argument labels no longer become type references (#4833; regressions #106 and #122)** — labels in positional, reordered, attribute, constructor, nested, and multiline argument lists are suppressed without hiding value-expression, named-`out` declaration, or property-subpattern type references, and without changing other colon-bearing C# constructs. +- **C# named-argument labels no longer become type references (#4833; regressions #106 and #122)** — labels in positional, reordered, attribute, constructor, nested, and multiline argument lists are suppressed without hiding value-expression types (including named `out`, explicitly typed anonymous functions, and typed LINQ range variables) or property-subpattern types, and without changing other colon-bearing C# constructs. ## 日本語 -- **C# の named-argument label を型参照として扱わないようにしました (#4833、回帰 #106 / #122)** — positional argument との混在、並べ替え、attribute、constructor、nested / multiline な引数リストにある label を抑止しつつ、value 式、named `out` declaration、property subpattern の型参照と C# のその他の colon 構文は維持します。 +- **C# の named-argument label を型参照として扱わないようにしました (#4833、回帰 #106 / #122)** — positional argument との混在、並べ替え、attribute、constructor、nested / multiline な引数リストにある label を抑止しつつ、value 式(named `out`、明示型 anonymous function、型付き LINQ range variable を含む)と property subpattern の型参照、および C# のその他の colon 構文は維持します。 diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs index 3afb8c671..84249356a 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.DeclarationTypes.cs @@ -18,6 +18,22 @@ internal static void EmitDeclarationTypeReferences( Func resolveContainerForColumn, IReadOnlySet? ignoredSegments = null) { + if (language == "csharp" + && TryGetCSharpLeadingColonLabelValueStart(line, out var namedArgumentValueStart)) + { + EmitCSharpNamedArgumentValueTypeReferences( + line, + namedArgumentValueStart, + lineStartOffset: 0, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + ignoredSegments); + } + if (TryFindCallableParameterList(line, language, out var callableNameStart, out var paramStart, out var paramEnd)) { if (TryGetCallableReturnTypeSpan(line, callableNameStart, language, out var typeStart, out var typeLength)) @@ -308,7 +324,8 @@ private static void EmitParameterTypeReferences( string context, int lineNumber, Func resolveContainerForColumn, - IReadOnlySet? ignoredSegments = null) + IReadOnlySet? ignoredSegments = null, + int lineStartOffset = 0) { if (paramEnd <= paramStart) return; @@ -317,10 +334,27 @@ private static void EmitParameterTypeReferences( foreach (var (segmentStart, segmentLength) in SplitTopLevelCommaSpans(parameterList)) { var fragment = parameterList.Slice(segmentStart, segmentLength).ToString(); + if (language == "csharp" + && TryGetCSharpLeadingColonLabelValueStart(fragment, out var namedArgumentValueStart)) + { + EmitCSharpNamedArgumentValueTypeReferences( + fragment, + namedArgumentValueStart, + lineStartOffset + paramStart + segmentStart, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + ignoredSegments); + continue; + } + if (!TryGetParameterTypeRelativeSpan(fragment, language, out var typeRelativeStart, out var typeRelativeLength)) continue; - int absoluteStart = paramStart + segmentStart + typeRelativeStart; + int absoluteStart = lineStartOffset + paramStart + segmentStart + typeRelativeStart; AddTypeExpressionSegmentsForLanguage( language, references, @@ -625,25 +659,13 @@ private static bool TryGetParameterTypeRelativeSpan(string parameterFragment, st typeStart = -1; typeLength = 0; - int candidateOffset = 0; - bool hasLeadingCSharpColonLabel = language == "csharp" - && TryGetCSharpLeadingColonLabelValueStart(parameterFragment, out candidateOffset); - var candidateSource = candidateOffset == 0 - ? parameterFragment - : parameterFragment.Substring(candidateOffset); - - int end = FindTopLevelAssignmentIndex(candidateSource); + int end = FindTopLevelAssignmentIndex(parameterFragment); if (end < 0) - end = candidateSource.Length; - var candidate = candidateSource.Substring(0, end); + end = parameterFragment.Length; + var candidate = parameterFragment.Substring(0, end); var tokens = GetTopLevelTokenSpans(candidate); if (tokens.Count < 2) return false; - if (hasLeadingCSharpColonLabel - && !candidate.AsSpan(tokens[0].Start, tokens[0].Length).Equals("out", StringComparison.Ordinal)) - { - return false; - } int first = 0; while (first < tokens.Count) @@ -661,8 +683,7 @@ private static bool TryGetParameterTypeRelativeSpan(string parameterFragment, st if (first >= tokens.Count - 1) return false; - int relativeTypeStart = tokens[first].Start; - typeStart = candidateOffset + relativeTypeStart; + typeStart = tokens[first].Start; int lastTypeToken = tokens.Count - 2; while (lastTypeToken >= first) { @@ -678,7 +699,7 @@ private static bool TryGetParameterTypeRelativeSpan(string parameterFragment, st if (lastTypeToken < first) return false; - typeLength = tokens[lastTypeToken].Start + tokens[lastTypeToken].Length - relativeTypeStart; + typeLength = tokens[lastTypeToken].Start + tokens[lastTypeToken].Length - typeStart; return true; } @@ -783,6 +804,165 @@ private static bool TryGetCSharpLeadingColonLabelValueStart(string text, out int return true; } + private static void EmitCSharpNamedArgumentValueTypeReferences( + string text, + int valueStart, + int lineStartOffset, + List references, + ReferenceDedupeSet seen, + long fileId, + string context, + int lineNumber, + Func resolveContainerForColumn, + IReadOnlySet? ignoredSegments) + { + if (valueStart >= text.Length) + return; + + var value = text.Substring(valueStart); + var valueTokens = GetTopLevelTokenSpans(value); + if (valueTokens.Count >= 2 + && value.AsSpan(valueTokens[0].Start, valueTokens[0].Length).Equals("out", StringComparison.Ordinal) + && TryGetParameterTypeRelativeSpan(value, "csharp", out var outTypeStart, out var outTypeLength)) + { + AddTypeExpressionSegmentsForLanguage( + "csharp", + references, + seen, + fileId, + value.Substring(outTypeStart, outTypeLength), + lineStartOffset + valueStart + outTypeStart, + context, + lineNumber, + resolveContainerForColumn(lineStartOffset + valueStart + outTypeStart), + ignoredSegments); + } + + if (TryGetCSharpNamedArgumentAnonymousFunctionParameterList(value, out var paramStart, out var paramEnd)) + { + EmitParameterTypeReferences( + "csharp", + value, + paramStart, + paramEnd, + references, + seen, + fileId, + context, + lineNumber, + resolveContainerForColumn, + ignoredSegments, + lineStartOffset + valueStart); + } + + if (TryGetCSharpNamedArgumentQueryTypeSpan(value, out var queryTypeStart, out var queryTypeLength)) + { + AddTypeExpressionSegmentsForLanguage( + "csharp", + references, + seen, + fileId, + value.Substring(queryTypeStart, queryTypeLength), + lineStartOffset + valueStart + queryTypeStart, + context, + lineNumber, + resolveContainerForColumn(lineStartOffset + valueStart + queryTypeStart), + ignoredSegments); + } + } + + private static bool TryGetCSharpNamedArgumentAnonymousFunctionParameterList( + string value, + out int paramStart, + out int paramEnd) + { + paramStart = -1; + paramEnd = -1; + + int cursor = 0; + while (TrySkipCSharpKeyword(value, ref cursor, "static") + || TrySkipCSharpKeyword(value, ref cursor, "async")) + { + } + + if (TrySkipCSharpKeyword(value, ref cursor, "delegate")) + { + if (cursor >= value.Length || value[cursor] != '(') + return false; + } + else if (cursor >= value.Length || value[cursor] != '(') + { + return false; + } + + int closeParen = FindMatchingChar(value, cursor, '(', ')'); + if (closeParen < 0) + return false; + + int continuation = closeParen + 1; + while (continuation < value.Length && char.IsWhiteSpace(value[continuation])) + continuation++; + if (continuation + 1 >= value.Length + || value[continuation] != '=' + || value[continuation + 1] != '>') + { + var delegateStart = 0; + while (delegateStart < value.Length && char.IsWhiteSpace(value[delegateStart])) + delegateStart++; + while (TrySkipCSharpKeyword(value, ref delegateStart, "static") + || TrySkipCSharpKeyword(value, ref delegateStart, "async")) + { + } + if (!value.AsSpan(delegateStart).StartsWith("delegate", StringComparison.Ordinal)) + return false; + } + + paramStart = cursor + 1; + paramEnd = closeParen; + return true; + } + + private static bool TryGetCSharpNamedArgumentQueryTypeSpan( + string value, + out int typeStart, + out int typeLength) + { + typeStart = -1; + typeLength = 0; + + var tokens = GetTopLevelTokenSpans(value); + if (tokens.Count < 4 + || !value.AsSpan(tokens[0].Start, tokens[0].Length).Equals("from", StringComparison.Ordinal) + || !IsSimpleDeclarationIdentifier( + "csharp", + value.Substring(tokens[2].Start, tokens[2].Length)) + || !value.AsSpan(tokens[3].Start, tokens[3].Length).Equals("in", StringComparison.Ordinal)) + { + return false; + } + + typeStart = tokens[1].Start; + typeLength = tokens[1].Length; + return !value.AsSpan(typeStart, typeLength).Equals("var", StringComparison.Ordinal); + } + + private static bool TrySkipCSharpKeyword(string text, ref int index, string keyword) + { + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + if (!text.AsSpan(index).StartsWith(keyword, StringComparison.Ordinal)) + return false; + + int end = index + keyword.Length; + if (end < text.Length && IsCSharpIdentifierPart(text[end])) + return false; + + index = end; + while (index < text.Length && char.IsWhiteSpace(text[index])) + index++; + return true; + } + private static bool IsDefinitelyNotTypeDeclarationLine(string line, string language) { var trimmed = line.TrimStart(); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue4833Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue4833Tests.cs index f324df19d..29c94e04c 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerIssue4833Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue4833Tests.cs @@ -44,12 +44,14 @@ public sealed class CollisionHolder "src/Consumer.cs", """ using System; + using System.Collections.Generic; + using System.Linq; namespace Fixture; public sealed class Consumer { - public void Run(bool condition) + public void Run(bool condition, IEnumerable source) { Sink(1, overwrite: true, payload: typeof(ExpressionPayload)); Sink( @@ -57,6 +59,12 @@ public void Run(bool condition) payload: typeof(ExpressionPayload), overwrite: condition ? true : false); SinkOut(payload: out ExpressionPayload declaredPayload); + _ = source.Select(selector: (ExpressionPayload item) => item); + _ = source.Select(selector: delegate(ExpressionPayload item) { return item; }); + SinkQuery(query: from ExpressionPayload item in source select item, other: condition); + SinkQuery( + query: from ExpressionPayload item in source select item, + other: condition); if (declaredPayload is PatternHolder { Value: ExpressionPayload propertyPayload, @@ -74,6 +82,10 @@ private static void SinkOut(out ExpressionPayload payload) { payload = new(); } + + private static void SinkQuery(object query, object other) + { + } } """); @@ -157,14 +169,14 @@ FROM symbol_references AS reference AND reference.symbol_name = 'ExpressionPayload' AND reference.reference_kind = 'type_reference' """; - Assert.Equal(5L, (long)command.ExecuteScalar()!); + Assert.Equal(10L, (long)command.ExecuteScalar()!); command.CommandText = """ SELECT COUNT(*) FROM symbol_references AS reference JOIN files AS source_file ON source_file.id = reference.file_id WHERE source_file.path = 'src/Consumer.cs' - AND reference.symbol_name IN ('payload', 'Value') + AND reference.symbol_name IN ('payload', 'selector', 'query', 'other', 'Value') AND reference.reference_kind = 'type_reference' """; Assert.Equal(0L, (long)command.ExecuteScalar()!); diff --git a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs index bb92ede24..ce32a7005 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorCSharpTests.cs @@ -7477,6 +7477,7 @@ public void Extract_CsharpNamedArgumentLabels_DoNotBecomeTypeReferences_Issue483 const string content = """ extern alias Alias; using System; + using System.Collections.Generic; public sealed class ProbeAttribute : Attribute { @@ -7500,15 +7501,23 @@ private static void Invoke(int positional, bool overwrite, Type payload) {} private static object InvokeAdapter(bool overwrite, Type payload) => new(); private static void InvokeOut(out ExpressionPayload payload) => payload = new(); private static void Outer(object inner) {} + private static void Consume(Func transform) {} + private static void ConsumeQuery(object query, object other) {} [Probe(typeof(ExpressionPayload), overwrite: true)] - public void Run(bool condition, object value) + public void Run(bool condition, object value, IEnumerable source) { Invoke(1, overwrite: true, payload: typeof(ExpressionPayload)); Invoke(payload: typeof(ExpressionPayload), positional: 2, overwrite: false); _ = new ConstructorTarget(overwrite: true, payload: typeof(ExpressionPayload)); Outer(inner: InvokeAdapter(overwrite: true, payload: typeof(ExpressionPayload))); InvokeOut(payload: out ExpressionPayload declaredPayload); + Consume(transform: (ExpressionPayload item) => item); + Consume(transform: delegate(ExpressionPayload item) { return item; }); + ConsumeQuery(query: from ExpressionPayload item in source select item, other: value); + ConsumeQuery( + query: from ExpressionPayload item in source select item, + other: value); Invoke( positional: 3, payload: typeof(ExpressionPayload), @@ -7544,7 +7553,7 @@ public void Run(bool condition, object value) var symbols = SymbolExtractor.Extract(1, "csharp", content); var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); var colonLabels = new HashSet( - ["overwrite", "payload", "positional", "inner", "Value"], + ["overwrite", "payload", "positional", "inner", "transform", "query", "other", "Value"], StringComparer.Ordinal); Assert.DoesNotContain( @@ -7554,7 +7563,22 @@ public void Run(bool condition, object value) Assert.True( references.Count(reference => reference.SymbolName == "ExpressionPayload" - && reference.ReferenceKind == "type_reference") >= 10); + && reference.ReferenceKind == "type_reference") >= 17); + var namedValueTypeLines = content + .Split('\n') + .Select((line, index) => (Line: line, Number: index + 1)) + .Where(item => item.Line.Contains("transform:", StringComparison.Ordinal) + || item.Line.Contains("query: from", StringComparison.Ordinal)) + .Select(item => item.Number) + .ToArray(); + Assert.Equal(4, namedValueTypeLines.Length); + Assert.All( + namedValueTypeLines, + expectedLine => Assert.Contains( + references, + reference => reference.SymbolName == "ExpressionPayload" + && reference.ReferenceKind == "type_reference" + && reference.Line == expectedLine)); Assert.Contains( references, reference => reference.SymbolName == "ConstructorTarget"