Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<IFoo>(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<IFoo>(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` |
Expand Down Expand Up @@ -2417,13 +2417,13 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを

## reference_kind フィルタの対応表

グラフ系エントリポイントは、用途別に意図的に異なる `reference_kind` の部分集合だけを辿る。設計上の分割は **呼び出しグラフ vs 依存グラフ** に対応する: `callers`、`callees`、`hotspots`、`impact` の BFS 層は実行時の呼び出し・グラフ可視な結合をモデル化するため metadata 専用エッジ (`attribute` / `annotation`) を除外し、`Process<IFoo>(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<IFoo>(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` |
Expand Down
19 changes: 19 additions & 0 deletions changelog.d/unreleased/2061.fixed.md
Original file line number Diff line number Diff line change
@@ -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 でクロージャ依存を確認できます。
14 changes: 9 additions & 5 deletions src/CodeIndex/Database/DbReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IFoo>(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<IFoo>(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 = "<top-level>";
private const string SyntheticTopLevelCallerKind = "function";

Expand Down
127 changes: 127 additions & 0 deletions src/CodeIndex/Indexer/References/ReferenceExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,12 @@ private static bool IsFunctionLikeSymbolKind(string kind)
+ @"\s*(?:(?:\.|::)\s*"
+ CSharpIdentifierPattern
+ @")*)(?:\s*<[^)\];{}]+>)?(?:\s*\[[^\]\n]*\])*";
private static readonly Regex CSharpLocalDeclarationRegex = new(
$@"(?<![\w@])(?:var|{CSharpTypeExpressionPattern})\s+(?<name>{CSharpIdentifierPattern})\s*(?=[=;,\)])",
RegexOptions.Compiled);
private static readonly Regex CSharpLambdaRegex = new(
$@"(?<params>\([^)]*\)|{CSharpIdentifierPattern})\s*=>\s*(?<body>.*)$",
RegexOptions.Compiled);
// The `(?:\?\.)?` segment captures JavaScript / TypeScript optional chaining calls such as
// `callback?.()` and `callback?.<T>()`. Without it the `?.` stops the regex from reaching the
// trailing `(`, and the call reference to `callback` is silently dropped. Other supported
Expand Down Expand Up @@ -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<string, HashSet<string>>(StringComparer.Ordinal)
: null;
var sqlState = language == "sql" ? SqlReferenceExtractor.CreateState() : null;
var csharpInDelimitedDocComment = false;
var jvmInDelimitedDocComment = false;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1901,6 +1920,8 @@ bool ShouldSuppressDefinitionCall(string resolvedName, int callIndex)
{
CSharpReferenceExtractor.StartWaitingForMultiLineTypePatternHead(ref pendingCSharpMultiLineTypePattern);
}

TrackCSharpLocalDeclarations(preparedLine, container, csharpLocalNamesByFunction);
}
else if (language == "java")
{
Expand Down Expand Up @@ -3415,6 +3436,112 @@ internal static string BuildReferenceDedupeKey(
return $"{fileId}:{languageSegment}:{lineNumber}:{column}:{referenceKind}:{name}";
}

private static void EmitCSharpLambdaCaptureReferences(
string preparedLine,
List<ReferenceRecord> references,
HashSet<string> seen,
long fileId,
string context,
int lineNumber,
SymbolRecord? container,
Dictionary<string, HashSet<string>>? 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<string> CollectCSharpLambdaParameterNames(string parameterText)
{
var names = new HashSet<string>(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<string, HashSet<string>>? 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<string>(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<ReferenceRecord> references)
{
var edges = new HashSet<(string Caller, string Callee)>();
Expand Down
22 changes: 22 additions & 0 deletions tests/CodeIndex.Tests/DbReaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> 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()
{
Expand Down
Loading
Loading