diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 376f58d6f0..8870f326db 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -880,13 +880,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`), while keeping explicit generic invocation type-argument edges (`generic_type_argument`) such as `Process(value)` because they bind a concrete type into an invoked method; `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`), while keeping explicit generic invocation type-argument edges (`generic_type_argument`) such as `Process(value)` because they bind a concrete type into an invoked method; `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 | `('augmentation', 'call', 'instantiate', 'generic_type_argument', 'subscribe', 'unsubscribe', 'razor_event_binding', 'friend', 'consumes_hook')` (= `CallGraphReferenceKindsSql`); metadata kinds rejected at CLI / MCP `--kind` boundary | `DbReader.GetCallers` / `DbReader.GetCallees` | -| `impact` callers mode | transitive forward (BFS) | `('augmentation', 'call', 'instantiate', 'generic_type_argument', 'subscribe', 'unsubscribe', 'razor_event_binding', 'friend', 'consumes_hook')` via `GetCallersExact` | `DbReader.GetTransitiveCallers` | +| `callers` / `callees` (default) | source ↔ container | `('augmentation', 'call', 'instantiate', 'generic_type_argument', 'subscribe', 'unsubscribe', 'razor_event_binding', 'friend', 'consumes_hook', 'capture')` (= `CallGraphReferenceKindsSql`); metadata kinds rejected at CLI / MCP `--kind` boundary | `DbReader.GetCallers` / `DbReader.GetCallees` | +| `impact` callers mode | transitive forward (BFS) | `('augmentation', 'call', 'instantiate', 'generic_type_argument', 'subscribe', 'unsubscribe', 'razor_event_binding', 'friend', 'consumes_hook', '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` | @@ -2417,13 +2417,13 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを ## reference_kind フィルタの対応表 -グラフ系エントリポイントは、用途別に意図的に異なる `reference_kind` の部分集合だけを辿る。設計上の分割は **呼び出しグラフ vs 依存グラフ** に対応する: `callers`、`callees`、`hotspots`、`impact` の BFS 層は実行時の呼び出し・グラフ可視な結合をモデル化するため metadata 専用エッジ (`attribute` / `annotation`) を除外し、`Process(value)` のように呼び出し済みメソッドへ具体型を束縛する明示的 generic 呼び出し型引数 (`generic_type_argument`) は含める。`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`) を除外し、`Process(value)` のように呼び出し済みメソッドへ具体型を束縛する明示的 generic 呼び出し型引数 (`generic_type_argument`) と 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', 'generic_type_argument', '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', 'generic_type_argument', 'subscribe', 'unsubscribe', 'razor_event_binding', 'friend', 'consumes_hook')` | `DbReader.GetTransitiveCallers` | +| `callers` / `callees` (デフォルト) | source ↔ container | `('augmentation', 'call', 'instantiate', 'generic_type_argument', 'subscribe', 'unsubscribe', 'razor_event_binding', 'friend', 'consumes_hook', 'capture')` (= `CallGraphReferenceKindsSql`)。metadata 種別は CLI / MCP `--kind` 境界で拒否 | `DbReader.GetCallers` / `DbReader.GetCallees` | +| `impact` callers mode | 推移的 forward (BFS) | `GetCallersExact` 経由で `('augmentation', 'call', 'instantiate', 'generic_type_argument', 'subscribe', 'unsubscribe', 'razor_event_binding', 'friend', 'consumes_hook', 'capture')` | `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` | 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 bc6dc97803..9c277c6f3f 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -149,17 +149,21 @@ 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. C# generic type arguments - // are retained only when they are attached to an explicit invocation, so impact can follow - // `Process(x)` without promoting ordinary type annotations to call-graph edges (#2062). + // 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. + // C# generic type arguments are retained only when they are attached to an explicit invocation, + // so impact can follow `Process(x)` without promoting ordinary type annotations to + // call-graph edges (#2062). // 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', 'generic_type_argument', 'subscribe', 'unsubscribe', 'razor_event_binding', 'friend', 'consumes_hook')"; + // C# closure の `capture` は lambda 本体から外側 local への依存であり、impact に参加する。 + // C# generic invocation type arguments are included when tied to an actual call (#2062). + internal const string CallGraphReferenceKindsSql = "('augmentation', 'call', 'instantiate', 'generic_type_argument', '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 409d78a947..96509758c4 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 @@ -1137,6 +1143,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; @@ -1874,6 +1883,16 @@ bool ShouldSuppressDefinitionCall(string resolvedName, int callIndex) if (language == "csharp") { + EmitCSharpLambdaCaptureReferences( + preparedLine, + references, + seen, + fileId, + context, + lineNumber, + container, + csharpLocalNamesByFunction); + CSharpReferenceExtractor.EmitTypePositionReferences( preparedLine, originalLine, @@ -1901,6 +1920,8 @@ bool ShouldSuppressDefinitionCall(string resolvedName, int callIndex) { CSharpReferenceExtractor.StartWaitingForMultiLineTypePatternHead(ref pendingCSharpMultiLineTypePattern); } + + TrackCSharpLocalDeclarations(preparedLine, container, csharpLocalNamesByFunction); } else if (language == "java") { @@ -3415,6 +3436,112 @@ 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(GetCSharpContainerLocalScopeKey(container), 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; + + var scopeKey = GetCSharpContainerLocalScopeKey(container); + if (!localNamesByFunction.TryGetValue(scopeKey, out var localNames)) + { + localNames = new HashSet(StringComparer.Ordinal); + 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/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index 59bce22597..c7dd660e77 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -7314,6 +7314,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 11788198f3..1e1de8d1c4 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs @@ -31972,6 +31972,82 @@ 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"); + } + + [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() {