From c4d4ede1007907b4e102799ae472a8306f3990c8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 24 May 2026 18:42:01 +0900 Subject: [PATCH 1/3] Fix C# lambda capture references (#2061) --- DEVELOPER_GUIDE.md | 6 +- changelog.d/unreleased/2061.fixed.md | 19 +++ src/CodeIndex/Database/DbReader.cs | 8 +- .../Indexer/References/ReferenceExtractor.cs | 123 ++++++++++++++++++ tests/CodeIndex.Tests/DbReaderTests.cs | 22 ++++ .../ReferenceExtractorTests.cs | 47 +++++++ 6 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 changelog.d/unreleased/2061.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 529521b91f..e1c4bce8e7 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -876,13 +876,13 @@ Process exit codes are coarse (`0` success, `1` usage, `2` not-found, `3` db, `4 ## Reference-kind filtering matrix -Different graph entry points walk different `reference_kind` subsets by design. The split mirrors **call graph vs. dependency graph**: `callers`, `callees`, `hotspots`, and `impact`'s BFS layer model the runtime call graph and exclude metadata-only edges (`attribute` / `annotation`); `deps` and `impact`'s heuristic file-level fallback model the compile-time dependency graph and include metadata edges so that `[JsonConverter(typeof(User))]` and `@Inject(User.class)` still surface as real dependencies of `User`. Both directions of `deps` share the same SQL function (`DbReader.GetFileDependencies`), so forward and reverse walks always emit the same kind set. +Different graph entry points walk different `reference_kind` subsets by design. The split mirrors **call graph vs. dependency graph**: `callers`, `callees`, `hotspots`, and `impact`'s BFS layer model the runtime call graph plus closure dependency edges (`capture`) and exclude metadata-only edges (`attribute` / `annotation`); `deps` and `impact`'s heuristic file-level fallback model the compile-time dependency graph and include metadata edges so that `[JsonConverter(typeof(User))]` and `@Inject(User.class)` still surface as real dependencies of `User`. Both directions of `deps` share the same SQL function (`DbReader.GetFileDependencies`), so forward and reverse walks always emit the same kind set. | Entry point | Direction | Reference kinds walked | Backing function | | --- | --- | --- | --- | | `references` (CLI / MCP) | symbol-centric | all `reference_kind` rows; narrowed by `--kind` when provided | `DbReader.GetReferences` | -| `callers` / `callees` (default) | source ↔ container | `('call', 'instantiate', 'subscribe')` (= `CallGraphReferenceKindsSql`); metadata kinds rejected at CLI / MCP `--kind` boundary | `DbReader.GetCallers` / `DbReader.GetCallees` | -| `impact` callers mode | transitive forward (BFS) | `('call', 'instantiate', 'subscribe')` via `GetCallersExact` | `DbReader.GetTransitiveCallers` | +| `callers` / `callees` (default) | source ↔ container | `CallGraphReferenceKindsSql`, including runtime calls/events plus dependency-oriented `capture`, `friend`, `consumes_hook`, and `augmentation`; metadata kinds rejected at CLI / MCP `--kind` boundary | `DbReader.GetCallers` / `DbReader.GetCallees` | +| `impact` callers mode | transitive forward (BFS) | `CallGraphReferenceKindsSql`, including `capture`, via `GetCallersExact` | `DbReader.GetTransitiveCallers` | | `impact` file-hint fallback | reverse (definition file → dependent files) | all kinds; metadata-only rows gated by `IsMetadataTargetUnambiguous` + structured-type evidence | `DbReader.GetFileDependencyHintsToResolvedType` | | `deps` (default = forward) | source file → target file | all kinds; metadata rows require class-like + metadata-eligible targets (`has_metadata_target_kind`) and a unique resolution (`target_ambiguity`) | `DbReader.GetFileDependencies` | | `deps --reverse` | target file → source file | same as forward `deps` (same SQL) | `DbReader.GetFileDependencies` | diff --git a/changelog.d/unreleased/2061.fixed.md b/changelog.d/unreleased/2061.fixed.md new file mode 100644 index 0000000000..d33c881b5d --- /dev/null +++ b/changelog.d/unreleased/2061.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 2061 +affected: + - DEVELOPER_GUIDE.md + - src/CodeIndex/Database/DbReader.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.cs + - tests/CodeIndex.Tests/DbReaderTests.cs + - tests/CodeIndex.Tests/ReferenceExtractorTests.cs +--- + +## English + +- **C# lambda captures now emit capture references (#2061)** — C# lambdas that read an enclosing local variable now add a `capture` reference edge so reference and impact workflows can see closure dependencies. + +## 日本語 + +- **C# lambda のキャプチャが capture 参照を出すようになりました (#2061)** — 外側のローカル変数を読む C# lambda が `capture` 参照エッジを追加するようになり、references / impact 系の workflow でクロージャ依存を確認できます。 diff --git a/src/CodeIndex/Database/DbReader.cs b/src/CodeIndex/Database/DbReader.cs index 4595b4d1ca..aecd83cf62 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -149,15 +149,17 @@ ELSE 0 END"; private const string InvokeReferenceKindsSql = "('call', 'instantiate')"; private const string EventReferenceKindsSql = "('subscribe', 'unsubscribe', 'razor_event_binding')"; - private const string ImpactAnchorReferenceKindsSql = "('call', 'instantiate', 'subscribe', 'unsubscribe', 'razor_event_binding')"; + private const string ImpactAnchorReferenceKindsSql = "('call', 'instantiate', 'subscribe', 'unsubscribe', 'razor_event_binding', 'capture')"; // Reference kinds that participate in the call-graph (callers/callees/hotspots). Metadata // kinds such as `attribute` / `annotation` are excluded so they do not inflate the graph // with non-call edges (issue #293); React `consumes_hook` and C++ `friend` edges are retained - // because users expect them in dependency-oriented graph queries. + // because users expect them in dependency-oriented graph queries. C# closure `capture` edges + // are dependency edges from a lambda body back to an enclosing local and participate in impact. // call-graph (callers/callees/hotspots) に参加する reference kind。`attribute` / `annotation` // のようなメタデータ kind は非呼び出しエッジなのでここから除外する (issue #293)。 // Razor の `razor_event_binding`、React の `consumes_hook`、C++ の `friend` は依存関係 graph query に含める。 - internal const string CallGraphReferenceKindsSql = "('augmentation', 'call', 'instantiate', 'subscribe', 'unsubscribe', 'razor_event_binding', 'friend', 'consumes_hook')"; + // C# closure の `capture` は lambda 本体から外側 local への依存であり、impact に参加する。 + internal const string CallGraphReferenceKindsSql = "('augmentation', 'call', 'instantiate', 'subscribe', 'unsubscribe', 'razor_event_binding', 'friend', 'consumes_hook', 'capture')"; private const string SyntheticTopLevelCallerName = ""; private const string SyntheticTopLevelCallerKind = "function"; diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs index 6e6acdcf73..d236d7aad2 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs @@ -391,6 +391,12 @@ private static bool IsFunctionLikeSymbolKind(string kind) + @"\s*(?:(?:\.|::)\s*" + CSharpIdentifierPattern + @")*)(?:\s*<[^)\];{}]+>)?(?:\s*\[[^\]\n]*\])*"; + private static readonly Regex CSharpLocalDeclarationRegex = new( + $@"(?{CSharpIdentifierPattern})\s*(?=[=;,\)])", + RegexOptions.Compiled); + private static readonly Regex CSharpLambdaRegex = new( + $@"(?\([^)]*\)|{CSharpIdentifierPattern})\s*=>\s*(?.*)$", + RegexOptions.Compiled); // The `(?:\?\.)?` segment captures JavaScript / TypeScript optional chaining calls such as // `callback?.()` and `callback?.()`. Without it the `?.` stops the regex from reaching the // trailing `(`, and the call reference to `callback` is silently dropped. Other supported @@ -1134,6 +1140,9 @@ bool HasActiveSameFileCSharpTypeCandidate(string typeExpression, int lineNumber) } var pendingCSharpMultiLineTypePattern = default(CSharpMultiLineTypePatternState); var pendingCSharpWhereConstraint = language == "csharp" ? new CSharpWhereConstraintState() : null; + var csharpLocalNamesByFunction = language == "csharp" + ? new Dictionary>(StringComparer.Ordinal) + : null; var sqlState = language == "sql" ? SqlReferenceExtractor.CreateState() : null; var csharpInDelimitedDocComment = false; var jvmInDelimitedDocComment = false; @@ -1871,6 +1880,16 @@ bool ShouldSuppressDefinitionCall(string resolvedName, int callIndex) if (language == "csharp") { + EmitCSharpLambdaCaptureReferences( + preparedLine, + references, + seen, + fileId, + context, + lineNumber, + container, + csharpLocalNamesByFunction); + CSharpReferenceExtractor.EmitTypePositionReferences( preparedLine, originalLine, @@ -1898,6 +1917,8 @@ bool ShouldSuppressDefinitionCall(string resolvedName, int callIndex) { CSharpReferenceExtractor.StartWaitingForMultiLineTypePatternHead(ref pendingCSharpMultiLineTypePattern); } + + TrackCSharpLocalDeclarations(preparedLine, container, csharpLocalNamesByFunction); } else if (language == "java") { @@ -3400,6 +3421,108 @@ internal static string BuildReferenceDedupeKey( return $"{fileId}:{languageSegment}:{lineNumber}:{column}:{referenceKind}:{name}"; } + private static void EmitCSharpLambdaCaptureReferences( + string preparedLine, + List references, + HashSet seen, + long fileId, + string context, + int lineNumber, + SymbolRecord? container, + Dictionary>? localNamesByFunction) + { + if (container?.Kind != "function" + || localNamesByFunction == null + || !localNamesByFunction.TryGetValue(container.Name, out var localNames) + || localNames.Count == 0) + { + return; + } + + foreach (Match lambda in CSharpLambdaRegex.Matches(preparedLine)) + { + var body = lambda.Groups["body"].Value; + if (string.IsNullOrWhiteSpace(body)) + continue; + + var parameterNames = CollectCSharpLambdaParameterNames(lambda.Groups["params"].Value); + foreach (var localName in localNames) + { + if (parameterNames.Contains(localName)) + continue; + if (!ContainsCSharpIdentifier(body, localName, out var bodyRelativeIndex)) + continue; + + AddReference( + references, + seen, + fileId, + localName, + lambda.Groups["body"].Index + bodyRelativeIndex, + "capture", + context, + lineNumber, + container, + "csharp"); + } + } + } + + private static HashSet CollectCSharpLambdaParameterNames(string parameterText) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (Match match in Regex.Matches(parameterText, CSharpIdentifierPattern)) + { + var name = NormalizeAtPrefixedIdentifier(match.Value); + if (!IsIgnoredCallName("csharp", name)) + names.Add(name); + } + + return names; + } + + private static bool ContainsCSharpIdentifier(string text, string name, out int index) + { + index = -1; + var normalizedName = NormalizeAtPrefixedIdentifier(name); + foreach (Match match in Regex.Matches(text, CSharpIdentifierPattern)) + { + if (string.Equals(NormalizeAtPrefixedIdentifier(match.Value), normalizedName, StringComparison.Ordinal)) + { + index = match.Index; + return true; + } + } + + return false; + } + + private static void TrackCSharpLocalDeclarations( + string preparedLine, + SymbolRecord? container, + Dictionary>? localNamesByFunction) + { + if (container?.Kind != "function" || localNamesByFunction == null) + return; + if (preparedLine.Contains("=>", StringComparison.Ordinal)) + return; + + foreach (Match match in CSharpLocalDeclarationRegex.Matches(preparedLine)) + { + var name = NormalizeAtPrefixedIdentifier(match.Groups["name"].Value); + if (IsIgnoredCallName("csharp", name)) + continue; + + if (!localNamesByFunction.TryGetValue(container.Name, out var localNames)) + { + localNames = new HashSet(StringComparer.Ordinal); + localNamesByFunction[container.Name] = localNames; + } + + localNames.Add(name); + } + } + private static void MarkMutualRecursionReferences(List references) { var edges = new HashSet<(string Caller, string Callee)>(); diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index 9d3fff128f..0569b491f0 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -7070,6 +7070,28 @@ export function Widget() { && caller.ReferenceKind == "consumes_hook"); } + [Fact] + public void ReferenceKindMatrix_CallersIncludesCSharpLambdaCaptures() + { + InsertIndexedFile("src/CaptureDemo.cs", "csharp", + """ + public class CaptureDemo + { + public void Run() + { + var seed = 1; + System.Func next = () => seed + 1; + } + } + """); + + var callers = _reader.GetCallers("seed", lang: "csharp", exact: true); + + Assert.Contains(callers, caller => + caller.CallerName == "Run" + && caller.ReferenceKind == "capture"); + } + [Fact] public void GetFileDependencies_MatchesCSharpAttributeSuffixConvention() { diff --git a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs index bd96c02ca1..18ffb22501 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs @@ -31648,6 +31648,53 @@ export function after() { Assert.Contains(references, r => r.SymbolName == "./public-api" && r.ReferenceKind == "reference" && r.Line == 16); } + [Fact] + public void Extract_CSharpLambdaCapture_EmitsCaptureReferenceForEnclosingLocal() + { + const string content = """ + class Demo + { + void Run() + { + var seed = 1; + System.Func next = () => seed + 1; + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + var capture = Assert.Single(references.Where(r => + r.SymbolName == "seed" + && r.ReferenceKind == "capture")); + Assert.Equal(6, capture.Line); + Assert.Equal("function", capture.ContainerKind); + Assert.Equal("Run", capture.ContainerName); + } + + [Fact] + public void Extract_CSharpLambdaCapture_DoesNotCaptureLambdaParameterShadow() + { + const string content = """ + class Demo + { + void Run() + { + var seed = 1; + System.Func next = seed => seed + 1; + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + Assert.DoesNotContain(references, r => + r.SymbolName == "seed" + && r.ReferenceKind == "capture"); + } + private static SymbolRecord Container(string name, string kind, int startLine, int endLine) => new() { From 40c71de0117c6c48620957eaad8c61381bcd20ee Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 24 May 2026 18:48:29 +0900 Subject: [PATCH 2/3] Harden C# lambda capture scope tracking (#2061) --- .../Indexer/References/ReferenceExtractor.cs | 10 +++++-- .../ReferenceExtractorTests.cs | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs index d236d7aad2..144c5a6524 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs @@ -3433,7 +3433,7 @@ private static void EmitCSharpLambdaCaptureReferences( { if (container?.Kind != "function" || localNamesByFunction == null - || !localNamesByFunction.TryGetValue(container.Name, out var localNames) + || !localNamesByFunction.TryGetValue(GetCSharpContainerLocalScopeKey(container), out var localNames) || localNames.Count == 0) { return; @@ -3513,16 +3513,20 @@ private static void TrackCSharpLocalDeclarations( if (IsIgnoredCallName("csharp", name)) continue; - if (!localNamesByFunction.TryGetValue(container.Name, out var localNames)) + var scopeKey = GetCSharpContainerLocalScopeKey(container); + if (!localNamesByFunction.TryGetValue(scopeKey, out var localNames)) { localNames = new HashSet(StringComparer.Ordinal); - localNamesByFunction[container.Name] = localNames; + localNamesByFunction[scopeKey] = localNames; } localNames.Add(name); } } + private static string GetCSharpContainerLocalScopeKey(SymbolRecord container) + => $"{container.Kind}:{container.ContainerQualifiedName}:{container.ContainerKind}:{container.ContainerName}:{container.Name}:{container.StartLine}:{container.EndLine}:{container.BodyStartLine}:{container.BodyEndLine}:{container.StartColumn}"; + private static void MarkMutualRecursionReferences(List references) { var edges = new HashSet<(string Caller, string Callee)>(); diff --git a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs index 18ffb22501..0c399bef08 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs @@ -31695,6 +31695,35 @@ void Run() && r.ReferenceKind == "capture"); } + [Fact] + public void Extract_CSharpLambdaCapture_DoesNotShareLocalsAcrossSameNamedMethods() + { + const string content = """ + class First + { + void Run() + { + var seed = 1; + } + } + + class Second + { + void Run() + { + System.Func next = () => seed + 1; + } + } + """; + + var symbols = SymbolExtractor.Extract(1, "csharp", content); + var references = ReferenceExtractor.Extract(1, "csharp", content, symbols); + + Assert.DoesNotContain(references, r => + r.SymbolName == "seed" + && r.ReferenceKind == "capture"); + } + private static SymbolRecord Container(string name, string kind, int startLine, int endLine) => new() { From a9d4236e9cb61941037f6f5172fb877e038344ba Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 24 May 2026 18:51:37 +0900 Subject: [PATCH 3/3] Sync capture graph documentation (#2061) --- DEVELOPER_GUIDE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index e1c4bce8e7..79d1a650d3 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2411,13 +2411,13 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを ## reference_kind フィルタの対応表 -グラフ系エントリポイントは、用途別に意図的に異なる `reference_kind` の部分集合だけを辿る。設計上の分割は **呼び出しグラフ vs 依存グラフ** に対応する: `callers`、`callees`、`hotspots`、`impact` の BFS 層は実行時の呼び出し・グラフ可視な結合をモデル化するため metadata 専用エッジ (`attribute` / `annotation`) を除外し、TypeScript merged interface 用の `augmentation` は dependency edge として含める。`deps` と `impact` の heuristic file-level fallback はコンパイル時の依存グラフをモデル化するため、`[JsonConverter(typeof(User))]` や `@Inject(User.class)` も `User` への本物の依存として metadata エッジを含める。`deps` は forward / reverse とも同じ SQL 関数 (`DbReader.GetFileDependencies`) を共有するため、両方向で常に同じ kind 集合を出す。 +グラフ系エントリポイントは、用途別に意図的に異なる `reference_kind` の部分集合だけを辿る。設計上の分割は **呼び出しグラフ vs 依存グラフ** に対応する: `callers`、`callees`、`hotspots`、`impact` の BFS 層は実行時の呼び出し・グラフ可視な結合とクロージャ依存 (`capture`) をモデル化するため metadata 専用エッジ (`attribute` / `annotation`) を除外し、TypeScript merged interface 用の `augmentation` は dependency edge として含める。`deps` と `impact` の heuristic file-level fallback はコンパイル時の依存グラフをモデル化するため、`[JsonConverter(typeof(User))]` や `@Inject(User.class)` も `User` への本物の依存として metadata エッジを含める。`deps` は forward / reverse とも同じ SQL 関数 (`DbReader.GetFileDependencies`) を共有するため、両方向で常に同じ kind 集合を出す。 | エントリポイント | 方向 | 辿る reference_kind | 実装 | | --- | --- | --- | --- | | `references` (CLI / MCP) | symbol 中心 | すべての `reference_kind` 行 (`--kind` 指定時は絞り込み) | `DbReader.GetReferences` | -| `callers` / `callees` (デフォルト) | source ↔ container | `('augmentation', 'call', 'instantiate', 'subscribe', 'unsubscribe', 'razor_event_binding', 'friend', 'consumes_hook')` (= `CallGraphReferenceKindsSql`)。metadata 種別は CLI / MCP `--kind` 境界で拒否 | `DbReader.GetCallers` / `DbReader.GetCallees` | -| `impact` callers mode | 推移的 forward (BFS) | `GetCallersExact` 経由で `('augmentation', 'call', 'instantiate', 'subscribe', 'unsubscribe', 'razor_event_binding', 'friend', 'consumes_hook')` | `DbReader.GetTransitiveCallers` | +| `callers` / `callees` (デフォルト) | source ↔ container | `CallGraphReferenceKindsSql`。runtime call / event に加えて dependency-oriented な `capture`、`friend`、`consumes_hook`、`augmentation` を含む。metadata 種別は CLI / MCP `--kind` 境界で拒否 | `DbReader.GetCallers` / `DbReader.GetCallees` | +| `impact` callers mode | 推移的 forward (BFS) | `GetCallersExact` 経由で `capture` を含む `CallGraphReferenceKindsSql` | `DbReader.GetTransitiveCallers` | | `impact` file-hint fallback | reverse (定義ファイル → 依存先) | 全 kind。metadata 専用行は `IsMetadataTargetUnambiguous` と structured-type evidence で gating | `DbReader.GetFileDependencyHintsToResolvedType` | | `deps` (デフォルト = forward) | source file → target file | 全 kind。metadata 行は class-like かつ metadata-eligible な target (`has_metadata_target_kind`) と一意解決 (`target_ambiguity`) を要求 | `DbReader.GetFileDependencies` | | `deps --reverse` | target file → source file | forward `deps` と同じ SQL を共有 | `DbReader.GetFileDependencies` |