From c48afd2cb9f12ddb323207bc7cfa941962b79930 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 01:28:17 +0900 Subject: [PATCH 1/3] Fix impact cycle identity detection (#4847) --- DEVELOPER_GUIDE.md | 2 + TESTING_GUIDE.md | 2 + USER_GUIDE.md | 13 + changelog.d/unreleased/4847.fixed.md | 21 + .../Database/DbReader.GraphQueries.cs | 390 +++++++++++------- src/CodeIndex/Models/QueryResults.cs | 19 + tests/CodeIndex.Tests/DbReaderImpactTests.cs | 159 +++++++ .../QueryCommandRunnerImpactTests.cs | 8 +- 8 files changed, 470 insertions(+), 144 deletions(-) create mode 100644 changelog.d/unreleased/4847.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 2124777b8..362154043 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1827,6 +1827,7 @@ Process exit codes are coarse (`0` success including valid zero-row queries, `1` - **Bundled symbol analysis** — `inspect` and MCP `analyze_symbol` return definition, nearby symbols, references, callers, callees, file metadata, workspace trust metadata, and graph-support metadata in one request so AI clients can answer common symbol questions with fewer round-trips. - **Language-aware reference extraction** — `references`, `callers`, `callees`, and `impact` are backed by an indexed reference table built only for languages where regex-based call/reference extraction is meaningful. Unsupported languages intentionally fall back to text search instead of returning low-confidence pseudo-graph data. When a language is removed from graph support, `PurgeUnsupportedReferences` deletes its stale `symbol_references` rows on the next indexing run, and graph read paths additionally filter by supported languages to prevent stale edges from surviving between index runs. Shell is intentionally excluded because its command-style invocations (`foo arg1 arg2`) cannot be detected by the parenthesized-call regex. **Nested generic call sites**: C#/Java constructor calls like `new Dictionary>()` and C# generic method calls like `Helper.DoWork>()` are recovered by a depth-aware fallback scanner so the outer target still reaches the reference table even though the flat regex fast-path cannot balance `>>`. **JS/TS no-paren constructors**: JavaScript / TypeScript zero-argument constructor calls that legally omit `()` — for example `new Foo;`, `new Date;`, qualified targets like `new Demo.Provider;`, and one-level generic TypeScript forms like `new Box;` — are emitted as `instantiate` edges via a dedicated language-gated path, while next-line `.bar()` / `[0]` continuations are suppressed so a line-ended `new Foo` does not become a phantom standalone instantiation. **Constructor chain calls**: C# `: this(...)` / `: base(...)` initializers and Java `this(...)` / `super(...)` first-statement calls are detected separately from the generic call regex and rewritten so the reference target is the real constructor (enclosing class/record for `this`, the parsed base type from the class signature for `base` / `super`). Cross-line C# initializers are attributed to the owning constructor rather than the enclosing class. Base-type parsing strips generics, record primary-ctor args, `where` constraints, and `global::` / dotted namespace qualifiers; Java `super.method()` stays a normal method call. **Type-position dependency edges**: C#/Java base lists, declaration types, generic constraints, `throws`, `is`/`as`/`instanceof`, and real C# XML-doc `cref` sites are indexed as `type_reference` rows so `references` / `impact` can see compile-time rename dependencies without polluting the default dynamic call graph exposed by `callers` / `callees`. C# XML-doc `cref` extraction accepts declaration-attached XML-doc comments from both `///` lines and delimited `/** ... */` blocks, including declarations that begin later on the same physical line after the closing `*/` only when no unrelated same-line code or declaration intervenes, while ordinary `//` / `////` comments, non-documenting block comments, method-body XML-doc comments that merely precede a later declaration, brace-free field/property initializer continuations, brace-free expression lambdas, intervening top-level executable statements, same-line non-target code after `*/`, other nested executable continuations, and multiline raw/verbatim string content whose line happens to start with `/**` stay excluded. Non-doc code or string content after the closing `*/` on the same physical line is still outside the doc-comment slice. Even though the regex now runs against that narrower slice, the extractor preserves `symbol_references.column` relative to the original physical source line. On the C# read path, `using static` constant-pattern suppression is token-aware around `is` / `case`, reconstructs an anchor-aware indexed multi-line window when the anchor lives on a previous line, and keeps trivia-bearing forms such as `value is/*comment*/Red`, `value is\n Red or Blue`, `value is\n // comment\n Red`, `case\n // comment\n Point:`, long `case` / `or` chains, and `case\tRed:` filtered or rescued correctly. Qualified constant/member patterns stay qualifier-driven on that exact-name read path, so an unrelated same-name type such as `class Red {}` no longer cancels suppression for `case Color.Red or Color.Blue:` just because the leaf name matches. The extractor-side pending type-pattern carry now also survives trivia-only separator lines, standalone continuation-line `not`, and multiline `case` heads/logical continuations, so comment-only or `not`-only continuation lines no longer drop the later type head before the real token arrives. Non-type `case` labels such as `case > 0:` and `case not > 0:` do not arm that pending carry, so the next-line call/identifier token stays out of `type_reference`. Same-name type rescue also honors `file` visibility so file-local types only rescue references from the same physical file; inherited protected/public/internal nested types from real base classes rescue derived-class pattern heads only after the base reference is normalized through active type and namespace aliases, and alias-expanded constructed generic bases are canonicalized again before containing-type lookup so `AliasBase = Probe.Base` resolves the same way as `Probe.Base`; implemented interfaces do not contribute inherited nested-type rescue; and same-file `using Namespace;`, project-wide `global using Namespace;`, and active type aliases all participate in the rescue set. The extractor deliberately leaves ambiguous unqualified `using static` heads such as `value is Red` in the DB, because file-local parsing alone cannot know whether another file in the same namespace declares the real `Red` type; the workspace-aware read path is responsible for suppressing the pure constant-only cases. **SQL qualified-name alignment**: SQL definitions still persist their schema-qualified symbol name (`dbo.fn_X`), but graph/`deps`/unused/hotspot readers now resolve each SQL reference row through its stored source-line context, recorded call column, and enclosing container before they compare it to definitions, so qualified `references` / `callers` / `impact` queries stay schema-scoped even when one line contains multiple qualified calls or the lookup is non-exact. Those readers fall back to the bare leaf only when the source site itself is genuinely unqualified, which keeps `deps`, `unused`, and `hotspots` aligned with qualified SQL calls without regressing bare-call support or double-counting `EXEC dbo.fn_Target; EXEC sales.fn_Target;`. Once a row already has a recorded call column, those downstream readers no longer whole-line-upgrade that row to a later qualified token, so trailing comments, string literals, or a second qualified call cannot steal the earlier unqualified edge. Exact SQL graph/dependency readers also preserve the resolved segment count, so a quoted single identifier containing a dot such as `"sales.fn_Target"` stays distinct from the real qualified name `sales.fn_Target` across exact `references` / `callers` / `impact` and aggregate `deps` / `unused` / `hotspots`. SQL CTE body source rows use the raw `cte_body_reference` kind, so `references --kind cte_body_reference` can distinguish anchor/recursive-member internals from outer-query table references. Qualified SQL `callees` queries also keep leaf fallback disabled unless the caller query itself is unqualified, so `callees sales.Caller` no longer widens to `dbo.Caller`. SQL extractors also accept optional whitespace around qualified-name dots, so definitions/calls such as `[sales] . [fn_Target]` and `[dbo] . [fn_Target]` keep their full qualified identity instead of truncating at the first segment. The same SQL no-parens extractor now preserves ANSI / PostgreSQL double-quoted call targets such as `CALL "sales"."proc_name"` and `EXEC "dbo"."fn_Target"` instead of stripping them as string literals, while true single-quoted SQL string literals remain masked. Definition-oriented readers also canonicalize quoted qualified SQL names (`[dbo].[fn_X]` → `dbo.fn_X`) before matching, and they only fall back to the leaf identifier for unqualified queries so exact qualified lookups do not widen to sibling schemas that merely share the same leaf name. Exact SQL definition matching also preserves segment count, so a quoted single identifier that contains a dot (`"sales.fn_Target"`) does not collide with a real qualified name (`sales.fn_Target`). SQL exact graph leaf fallback also stays on the Unicode folded exact path, and both quoted qualified and unqualified Unicode exact definition lookups now use the folded normalized path, so queries such as `dbo.Äpfel` / `dbo.äpfel` and bare `Äpfel` / `äpfel` keep matching leaf call/reference rows such as `äpfel` plus stored definitions such as `[dbo].[Äpfel]` or `dbo.Äpfel` instead of silently degrading to ASCII-only `NOCASE`. Exact multi-name SQL `symbols --count` lookups also bind the folded leaf parameters on that same `_foldReady` path, so Unicode leaf query sets no longer fail with missing-parameter database errors. - **Transitive impact analysis** — `impact` and MCP `impact_analysis` compute the transitive caller chain of a symbol using BFS. Design constraints refined through adversarial review: caller matching uses case-insensitive exact match (`lower() = lower()`) to avoid both substring expansion and case-sensitivity brittleness; symbol names are pre-resolved through definitions with exact-case preference; the read path filters to graph-supported languages to prevent stale edges from removed languages; the definition set used for heuristic fallback must also respect active `--lang` / `--path` / `--exclude-path` / `--exclude-tests` filters and graph-supported languages so out-of-scope or unsupported duplicates do not suppress in-scope hints; fallback eligibility is keyed off class-like definitions only, so same-name namespace/import siblings do not block a single resolved class / struct / interface target, while pure non-callable `namespace` / `import` queries surface `non_callable_symbol_kind` guidance; heuristic file-level hints still return a successful result and encode their non-authoritative status via `impact_mode`, `heuristic`, `hint_count`, and `truncated`; caller rows include `result_kind: "graph"` and heuristic `file_impacts` rows include `result_kind: "file_heuristic"` so clients can distinguish authoritative hop-depth graph results from boundary fallback hints without inferring from list position or depth values; when `truncated` is `true`, the JSON / MCP payload also exposes `truncated_reason` so callers can distinguish actionable cases from runaway-graph cases — `user_limit` means the caller-supplied `--limit` was reached and raising `--limit` will return more results, while `safety_cap` means an internal per-symbol BFS fetch-iteration cap fired (the graph is likely pathological / cyclic and raising `--limit` alone will not help). `impact` / MCP `impact_analysis` also expose `termination_reason` (`completed`, `max_depth_reached`, `cycle_detected`, `row_limit_truncated`, `safety_cap`, or `cancelled`), `cycle_detected`, and `cycles` so caller cycles are distinguishable from natural traversal completion or limit/depth termination (#1883). `safety_cap` outranks `user_limit` whenever both are encountered, and the heuristic file-level hints path is `user_limit`-only because hint truncation is always driven by the caller's `--limit`. The field is omitted whenever `truncated` is `false`. (#1533) `count` / `file_count` now describe the visible returned set while `confirmed_count` / `confirmed_file_count` preserve symbol-level caller totals for heuristic-success payloads, and `impact --json --count` uses the same `*_count` field names as the full payload; to reduce general-name collisions, a file only qualifies for type fallback if it both references one of the candidate member names and also exposes same-file evidence anchoring the source/target pair — either a `call` / `instantiate` reference to the resolved target name (the call-graph itself authoritatively pins the relationship, so this path runs before the metadata-attribute bypass and does not depend on the looser ambiguity guard) or structured type evidence through indexed symbol metadata such as signatures or return types — rather than raw comment/string text matches. The call/instantiate anchor matches the resolved name exactly with no suffix-strip alias, because callable references already carry the authoritative identifier and applying the C# `[Foo]` → `FooAttribute` alias there would let unrelated `Foo()` method calls falsely anchor `impact FooAttribute` (#1881); the metadata bypass keeps the C# `Attribute` suffix alias because attribute use sites legitimately abbreviate the target name. The signature evidence path is Unicode-aware so fullwidth/accented identifiers are tokenized consistently with exact-name resolution; hint `reference_count` reflects the real number of matching reference rows while the symbol list stays deduplicated; only multiple class-like definitions are treated as fallback ambiguity, even when they share one file; and `PurgeUnsupportedReferences` runs in all three indexing paths (CLI full scan, CLI update mode, MCP index). +- **Impact cycle identity** — On a current reference-identity graph, `impact` carries resolved source/target symbol IDs through every BFS hop and detects cycles only from actual traversed directed edges between those canonical IDs. A repeated or folded display name is never a zero-hop cycle by itself; direct recursion remains a real singleton cycle because its persisted edge has the same source and target ID. Structured caller rows expose `caller_symbol_id` / `callee_symbol_id`, `--with-paths` nodes expose `symbol_id`, and cycle rows expose `member_identities` while retaining the display-only `members` list for compatibility. Legacy graphs without the current identity contract keep the name-keyed compatibility path (#4847). - **Extractor regex backtracking policy** — Built-in symbol and reference extractors must not use unbounded regular expression matching on repository-controlled file content. Backtracking regexes use `BoundedRegex.DefaultMatchTimeout`, while `RegexOptions.NonBacktracking` is allowed for patterns that are compatible with the non-backtracking engine. Patterns that deliberately remain backtracking-only, such as lookaround-heavy or balancing-group extractors, are acceptable only because the shared timeout audit covers them. If a future extractor must use `System.Text.RegularExpressions.Regex` directly, it must pass an explicit timeout and document why `BoundedRegex` or `NonBacktracking` is not suitable. - **Hybrid symbol extraction** — No AST parsers and no heavyweight language-specific dependencies. Most languages still use compiled regex patterns, while JavaScript/TypeScript add a lightweight lexer/state machine for class-body method extraction, private-scope filtering, synthetic class-expression binding detection, and JS/TS-specific range resolution that regex alone could not handle reliably. The trade-off still favors speed and portability over full parser accuracy, but the index stores richer symbol metadata such as definition ranges, optional body ranges, signatures, enclosing symbols, qualified container paths, authoritative family keys, visibility, and return types when the language patterns or JS/TS state machine can infer them. Visual Basic patterns also treat `Namespace ... End Namespace` as a real container and allow implicit-visibility declarations plus leading modifiers (`Shared`, `Overrides`, `Partial`, etc.), so VB projects expose the same top-level orientation and member coverage that other class-based languages already get. Visual Basic container patterns use case-insensitive `VisualBasicEnd` range tracking so cross-file partial families still get stable body ranges and can participate in hotspot-family grouping. **Pattern externalization**: Language patterns are currently defined inline in `SymbolExtractor.cs` using compiled `Regex` objects. This keeps the extraction pipeline self-contained and allows compile-time validation, but means adding a new language requires a code change and rebuild. A future iteration could externalize patterns to JSON/TOML files (loaded at startup), which would lower the barrier for community contributions and enable hot-reload during development. The trade-off is losing compile-time safety and slightly increasing startup cost. If externalized, patterns should include: language name, kind (function/class/import/namespace), regex string, body style (brace/indent/ruby-end/none), and optional capture group names for visibility and return type. - **Nested C# interpolation state** — The C# lexical masker keeps immutable parent frames when an interpolated regular, verbatim, or raw string starts inside another interpolation hole. Closing the nested string restores the complete outer mode, delimiter, dollar-count, and brace-depth state; expression-bodied property calls remain excluded from declaration patterns, and C# extractor contract bumps force existing indexes to refresh affected files. @@ -5022,6 +5023,7 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを exact な SQL の graph/dependency reader は解決済み segment 数も保持するため、`"sales.fn_Target"` のようなドット入り quoted single identifier が、本物の qualified name `sales.fn_Target` と exact `references` / `callers` / `impact` や集計系の `deps` / `unused` / `hotspots` で衝突しない。 - **言語考慮の参照抽出** — `references`、`callers`、`callees` は、正規表現ベースの call/reference 抽出が意味を持つ言語だけに対してインデックス化された参照テーブルで支える。未対応言語では、低信頼な疑似グラフ結果を返す代わりにテキスト検索へ戻る前提で設計する。**nested generic 呼び出し**: `new Dictionary>()` のような C#/Java のコンストラクタ呼び出しと、`Helper.DoWork>()` のような C# generic method call は、平坦な regex fast-path で `>>` を釣り合わせられなくても depth-aware fallback scanner で拾い直し、外側 target を参照テーブルへ残す。**JS/TS の no-paren constructor**: JavaScript / TypeScript の zero-argument constructor call で `()` を合法的に省略できる `new Foo;`、`new Date;`、`new Demo.Provider;`、`new Box;` も、専用の言語別経路で `instantiate` edge として出す。行末 `new Foo` に対する次行 `.bar()` / `[0]` continuation は suppress し、phantom な単独 instantiation にしない。**コンストラクタ連鎖呼び出し**: C# の `: this(...)` / `: base(...)` イニシャライザと、Java のコンストラクタ本体冒頭文 `this(...)` / `super(...)` は、汎用 call regex とは別に検出し、呼び先が実際のコンストラクタとなるように書き換える(`this` は外側の class/record、`base` / `super` は外側クラスのシグネチャから解析した基底型)。C# のクロス行イニシャライザは外側クラスではなく、そのコンストラクタに紐付ける。基底型の解析は generic 引数、record のプライマリコンストラクタ引数、`where` 制約、`global::` やドット付きの namespace 修飾を剥がす。Java の `super.method()` は通常のメソッド呼び出しのまま扱う。**型位置の依存エッジ**: C#/Java の継承リスト、宣言型、generic 制約、`throws`、`is` / `as` / `instanceof`、および C# XML doc の `cref` は `type_reference` 行として索引し、既定の `callers` / `callees` が見せる動的 call graph を汚さずに、`references` / `impact` から compile-time rename 依存を辿れるようにする。C# XML doc の `cref` 抽出は、実際に後続宣言へ結び付く XML-doc comment である `///` 行と delimited `/** ... */` block の両方を対象にしつつ、通常の `//` / `////` コメントや通常の block comment は phantom 依存として扱わない。また、同じ物理行でも closing `*/` より後ろに続く code / string の内容、doc comment と後続宣言の間へ割り込むトップレベル実行文、brace-free field/property initializer continuation、brace-free expression lambda、nested executable continuation、複数行 raw/verbatim string のうち行頭がたまたま `/**` で始まる内容は doc-comment slice の外として扱う。regex 自体は narrowed した doc-comment slice に対して走らせるが、`symbol_references.column` は元の物理ソース行位置に固定したまま保持する。C# の read path では、`using static` による constant-pattern suppress が `is` / `case` の前後の trivia を考慮してトークン単位で判定され、anchor が前行にある場合は anchor-aware な複数行コンテキストをインデックス済み行から再構成するため、`value is/*comment*/Red`、`value is\n Red or Blue`、`value is\n // comment\n Red`、`case\n // comment\n Point:`、長い `case` / `or` 連鎖、`case\tRed:` のような形でも phantom `type_reference` を漏らさない。qualified constant/member pattern は exact-name read path でも qualifier 起点で suppress するため、`case Color.Red or Color.Blue:` に対して無関係な `class Red {}` が suppress を打ち消さない。extractor 側の pending type-pattern carry も trivia-only 区切り行、standalone な continuation-line `not`、複数行 `case` head / logical continuation をまたいで維持されるため、comment-only 行や `not` だけの継続行で後続の本物の type head を落とさない。`case > 0:` や `case not > 0:` のような非型 `case` ラベルではその pending carry を armed にしないため、次行の call/identifier token が `type_reference` に混入しない。同名型の rescue も `file` 可視性を尊重し、file-local な型は同じ物理ファイル内の参照だけを救済する。基底クラスから見える protected/public/internal nested type は、基底型参照を active な型 alias / namespace alias 経由まで正規化し、さらに alias 展開後に constructed generic な基底型を再 canonicalize したうえで derived class の pattern head を救済する一方、implemented interface は inherited nested-type rescue に参加しない。さらに same-file `using Namespace;`、project-wide `global using Namespace;`、型 alias も同じ rescue 集合に入る。一方で extractor は file-local な情報だけでは同一 namespace の別ファイルにある実型を判定できないため、`value is Red` のような曖昧な unqualified `using static` head は DB に残し、pure constant-only case の抑止は workspace-aware な read path 側で行う。**SQL qualified-name alignment**: SQL の graph/dependency reader は、各 reference 行の source-line context、記録済み call 列位置、enclosing container から SQL 参照名を復元して定義と照合するため、qualified な `references` / `callers` / `impact` query は exact / non-exact を問わず sibling schema へ widen しない。source 側が genuinely unqualified な場合にだけ bare leaf fallback を許可するので、qualified call を含む `deps` / `unused` / `hotspots` も schema 単位で整合し、`EXEC dbo.fn_Target; EXEC sales.fn_Target;` のような同一行 multi-call も二重計上しない。列位置が記録されている row は、その列に qualified token が見つからなければ whole-line の別 qualified token へ昇格させないため、行末コメント・文字列リテラル・後続の別 call が先頭の unqualified edge を横取りすることもない。qualified な `callees` query でも caller query 自体が unqualified なとき以外は leaf fallback を無効化したため、`callees sales.Caller` が `dbo.Caller` へ広がらない。SQL extractor は qualified-name の `.` 前後空白も許容し、definition 系 reader は quoted qualified SQL name (`[dbo].[fn_X]` → `dbo.fn_X`) を正規化してから照合する。さらに exact SQL 定義照合は segment 数を保持し、SQL の exact graph leaf fallback は Unicode folded exact path を維持する。SQL CTE 本体内の source 行は raw `cte_body_reference` kind を使うため、`references --kind cte_body_reference` で anchor/recursive member 内部を outer query の table reference と区別できる。そのため、quoted single identifier の衝突や Unicode exact lookup の ASCII-only `NOCASE` 退行も防ぐ。exact な SQL の graph/dependency reader は解決済み segment 数も保持するため、`"sales.fn_Target"` のようなドット入り quoted single identifier が、本物の qualified name `sales.fn_Target` と exact `references` / `callers` / `impact` や集計系の `deps` / `unused` / `hotspots` で衝突しない。 - **推移的 impact analysis** — `impact` と MCP `impact_analysis` は、シンボルの推移的 caller chain を BFS で計算する。caller matching は substring expansion と大小文字差の脆さを避けるため `lower() = lower()` の大小文字非依存 exact match を使い、symbol 名は exact-case を優先して definition から事前解決し、read path は graph-supported language に限定して削除済み言語の stale edge を防ぐ。heuristic fallback が使う definition set も `--lang` / `--path` / `--exclude-path` / `--exclude-tests` と graph-supported language を尊重し、class-like definition だけを fallback 候補にするため、同名 namespace/import sibling は単一の class / struct / interface target を妨げず、純粋な non-callable `namespace` / `import` query は `non_callable_symbol_kind` guidance を返す。heuristic file-level hints は成功応答だが non-authoritative status を `impact_mode`、`heuristic`、`hint_count`、`truncated` で示し、caller rows は `result_kind: "graph"`、heuristic `file_impacts` rows は `result_kind: "file_heuristic"` を持つため、クライアントは list 位置や depth 値から推測せずに authoritative hop-depth graph 結果と境界 fallback hint を区別できる。`truncated` が `true` のときは JSON / MCP payload に `truncated_reason` も出し、`user_limit` は caller 指定の `--limit` 到達、`safety_cap` は内部の per-symbol BFS fetch-iteration cap 到達を意味する。`impact` / MCP `impact_analysis` は `termination_reason`(`completed`、`max_depth_reached`、`cycle_detected`、`row_limit_truncated`、`safety_cap`、`cancelled`)、`cycle_detected`、`cycles` も出すため、caller cycle と通常完了や limit/depth termination を区別できる(#1883)。`safety_cap` は `user_limit` より優先し、heuristic file-level hints path は caller の `--limit` だけで切り詰められるため `user_limit` のみを使う。`truncated` が `false` のとき `truncated_reason` は省略される。(#1533)`count` / `file_count` は返却された可視集合、`confirmed_count` / `confirmed_file_count` は heuristic-success payload の symbol-level caller totals を保持し、`impact --json --count` も full payload と同じ `*_count` field 名を使う。一般名の衝突を減らすため、type fallback では候補 member 名への参照に加え、同一ファイル内に source/target pair を anchor する証拠が必要になる。証拠は解決済み target 名への `call` / `instantiate` reference(この経路は call graph 自体が関係を pin するため metadata-attribute bypass より先に走り、緩い ambiguity guard に依存しない)、または signature / return type など indexed symbol metadata からの structured type evidence に限り、comment/string の raw text match は使わない。call/instantiate anchor は解決済み名を exact に照合し、suffix-strip alias は使わない。callable reference はすでに authoritative identifier を持つため、C# の `[Foo]` → `FooAttribute` alias をここへ適用すると無関係な `Foo()` method call が `impact FooAttribute` を偽 anchor できてしまうためである(#1881)。metadata bypass は attribute use site が正当に target 名を省略するため C# `Attribute` suffix alias を維持する。signature evidence path は Unicode-aware で、hint `reference_count` は実際に一致した reference row 数を表し、symbol list は deduplicate される。fallback ambiguity は同じファイル内であっても複数の class-like definition がある場合だけ扱い、`PurgeUnsupportedReferences` は CLI full scan、CLI update mode、MCP index のすべての indexing path で走る。 +- **impact cycle の identity** — 現行の reference-identity graph では、`impact` は解決済み source/target symbol ID を BFS の全 hop へ引き継ぎ、実際に走査した正規 ID 間の有向辺だけから cycle を判定する。表示名の重複や fold 一致だけではゼロホップ cycle にせず、永続化された辺の source/target ID が同一になる直接再帰は実在する singleton cycle として残す。構造化 caller row は `caller_symbol_id` / `callee_symbol_id`、`--with-paths` の node は `symbol_id`、cycle row は互換用の表示名 `members` に加えて `member_identities` を公開する。現行 identity 契約を持たない legacy graph は名前キーの互換経路を維持する(#4847)。 - **構造化MCPレスポンス** — MCPツール呼び出しは `structuredContent` に型付きJSONを返し、`content` は互換性のため簡潔に保つ。 - **MCP の pre-validation rate limiting と bucket eviction** — direct な `tools/call` はすべて tool 名、enablement、argument の詳細検証前に caller-wide の固定 coarse bucket を 1 つ消費する。canonical な既知 tool 名は secondary `(tool, caller)` bucket も維持し、missing、malformed、empty、oversized、case-variant、unknown な名前は名前由来 bucket を作成しない。unknown な `batch_query` inner-slot 名は caller ごとの 1 つの固定 invalid-slot partition を共有する。`CDIDX_MCP_RATE_LIMIT_BUCKET_IDLE_SECONDS` は既定 900 秒。process-local 上限到達時は期限切れ bucket を直ちに prune する。layered acquisition は両 partition を 1 lock 内で評価し、secondary 拒否前に coarse token を消費した場合は、`retry_after_ms` が必要なすべての token refill と capacity 境界を含む。これにより malformed call の既知名ローテーションによる burst 増幅と未信頼名による cardinality 増加を防ぎ、正規の bucket 作成は通知された時刻に回復でき、過去の caller ID もプロセス寿命いっぱい保持しない(#2824 / #4547)。 - **MCP envelope レスポンス上限** — `CDIDX_MCP_RESPONSE_MAX_BYTES` は既定 10 MiB、最大 64 MiB。invalid 値は既定値へ戻し、最大超過値は stderr 警告付きでクランプするため、operator が誤って JSON-RPC response guard を実質無効化できない。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 00eb30d65..f21a9db14 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -218,6 +218,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Deps JSON and json-graph byte-limit failures share one SQL graph fixture. Deps JSON summary output and json-graph summary rejection share one SQL graph fixture. Dependency-cycle coverage must prove that the graph budget is independent of the display limit, SCC ranking remains stable when the page size grows, opaque cursors return the next ranked component, mismatched cursor filters fail closed, and graph-budget exhaustion marks totals as non-authoritative. + Impact-cycle coverage must distinguish consecutive same-display-name symbols by canonical source/target IDs, verify their structured shortest-path identities, and retain controls for direct singleton recursion and multi-node cycles. Exact-symbol dependency coverage reuses the C# source-candidate fixture to prove that a `--limit 1` query can select a symbol beyond the unfiltered candidate window. The repository-scale guard builds 10,001 candidate symbols, runs on non-Windows .NET 8, and keeps query-only work within 2 seconds and 32 MiB of current-thread allocation; fixture construction stays outside the measured interval. Windows retains the lightweight functional pushdown coverage but omits this scale fixture because its bounded full-suite session already runs close to the one-hour timeout. References stale-SQL-contract count and result envelopes share one downgraded graph fixture. Callers and callees stale-SQL-contract result envelopes share one downgraded graph fixture. @@ -1200,6 +1201,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" generic switch-arm のguardとrelational predecessorも同様に1つのproduction-runtime fixtureを共有し、production `net8.0` targetだけで実行してください。 search language-alias coverage は、各filterが期待結果を1件に分離できる場合、異なる言語fileを1 databaseに置いてalias filterを反復してください。 option風literalのnamed-query escapingは、definition、graph、symbols、files、inspect、impact command全体で1つのindexed Probe fixtureを再利用してください。 + impact cycle の回帰 coverage では、同じ表示名が連続する別 symbol を正規 source/target ID で区別し、構造化 shortest-path identity を検証するとともに、直接 singleton 再帰と複数 node cycle の control を維持してください。 複数 named-query の output coverage は、compact projection、rich JSON 互換性、query ごとの limit / truncation、UTF-8 byte cap に1つの indexed fixture を再利用し、serializer mode を直接比較できるようにしてください。 共通 bounded-response coverage は、definition、find、status、hotspots、references、callers / callees、impact、map 全体で1つの graph-ready databaseを再利用してください。cursor と UTF-8 byte-budget の境界 case は別の最小 multi-row fixture にまとめ、family parity のために indexing setup を重複させないでください。regression coverage では alias と read-only batch dispatch、明示的な definition body projection、inactive な impact collection、authoritative な総件数を持つ map section の row 単位 pagination も確認してください。 adversarial な bounded-response coverage では、parser failure の byte cap、impact definition page の offset、既存 map compact section、map shape control の競合、compact と明示 body の組み合わせ、profile / verbose control record の抽出も固定してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 22f56ac23..01f268ca0 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -736,6 +736,13 @@ annotations, and type-position references are excluded from the symbol-level BFS so metadata cycles do not inflate caller counts; single-type queries may still return heuristic file-level dependency hints. +On a current index, cycle detection follows the resolved source/target symbol IDs +on real directed edges. Two distinct methods with the same display name are not a +cycle, while direct recursion is reported as a singleton cycle. JSON caller rows +include `caller_symbol_id` and `callee_symbol_id`; `path_details` nodes include +`symbol_id`; and each cycle can include `member_identities` alongside the +compatibility `members` display-name list. + ## Performance tuning for large repositories Start by measuring before changing knobs: @@ -3849,6 +3856,12 @@ metadata-only edges は symbol-level BFS から除外されるため、metadata count が膨らむことはありません。ただし single-type query では heuristic file-level dependency hints が返る場合があります。 +current index では、cycle 判定は実在する有向辺の解決済み source/target symbol ID を +辿ります。表示名が同じ別 method は cycle にせず、直接再帰は singleton cycle として +報告します。JSON の caller row は `caller_symbol_id` / `callee_symbol_id`、 +`path_details` node は `symbol_id` を含み、各 cycle は互換用の表示名 `members` に加えて +`member_identities` を含む場合があります。 + ## 大規模リポジトリの performance tuning knob を変える前に、まず測定してください。 diff --git a/changelog.d/unreleased/4847.fixed.md b/changelog.d/unreleased/4847.fixed.md new file mode 100644 index 000000000..308204012 --- /dev/null +++ b/changelog.d/unreleased/4847.fixed.md @@ -0,0 +1,21 @@ +--- +category: fixed +issues: + - 4847 +affected: + - src/CodeIndex/Database/DbReader.GraphQueries.cs + - src/CodeIndex/Models/QueryResults.cs + - tests/CodeIndex.Tests/DbReaderImpactTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerImpactTests.cs + - DEVELOPER_GUIDE.md + - USER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Impact cycle detection now requires a real canonical graph edge (#4847)** — `impact` carries resolved symbol IDs through every traversal hop, so consecutive methods with the same display name no longer create false singleton cycles while direct recursion and real multi-node cycles remain visible. Structured caller, path, and cycle output now includes disambiguating symbol identities. + +## 日本語 + +- **impact の cycle 判定で実在する正規 graph edge を必須にしました (#4847)** — `impact` が解決済み symbol ID を全 traversal hop へ引き継ぐため、表示名が同じ method が連続しても誤った singleton cycle にならず、直接再帰と実在する複数 node cycle は引き続き検出されます。構造化 caller、path、cycle 出力にも識別用 symbol identity を追加しました。 diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index f6ed12466..e870e80e1 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -1067,6 +1067,10 @@ private List GetCallersExactCore(string symbolName, int limit, int var contextSql = ReferenceContextSql("r"); var selfReferenceSql = _referenceColumns.Contains("is_self_reference") ? "r.is_self_reference" : "0"; var mutualRecursionSql = _referenceColumns.Contains("is_mutual_recursion") ? "r.is_mutual_recursion" : "0"; + var sourceSymbolIdSql = _referenceColumns.Contains("source_symbol_id") ? "r.source_symbol_id" : "NULL"; + var targetSymbolIdSql = targetSymbolId != null && HasTable("symbol_reference_candidates") + ? "@targetSymbolId" + : _referenceColumns.Contains("target_symbol_id") ? "r.target_symbol_id" : "NULL"; var supportedLangFilter = BuildGraphSupportedLanguagePredicate(cmd, "f", "callerLang"); @@ -1121,13 +1125,14 @@ AND r.resolution_state IN ('resolved', 'resolved_group') var sql = $@" WITH logical_references AS ( SELECT f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.reference_kind, r.line, + {sourceSymbolIdSql} AS source_symbol_id, + {targetSymbolIdSql} AS target_symbol_id, MAX({selfReferenceSql}) AS is_self_reference, MAX({mutualRecursionSql}) AS is_mutual_recursion FROM symbol_references r JOIN files f ON r.file_id = f.id{referenceLineJoin} WHERE {callerContainerPredicate} AND r.reference_kind IN {CallGraphReferenceKindsSql} - AND {selfReferenceSql} = 0 AND {supportedLangFilter} {targetCondition}"; if (lang != null) @@ -1139,7 +1144,7 @@ AND r.reference_kind IN {CallGraphReferenceKindsSql} sql += BuildCSharpBareMemberGraphReferenceFilter(symbolName, lang, exact: true, contextSql, "f", "r"); AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); sql += @" - GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.reference_kind, r.file_id, r.line, r.column_number + GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.reference_kind, r.file_id, r.line, r.column_number, source_symbol_id, target_symbol_id ) SELECT path, lang, " + BuildCallerKindProjectionSql("r") + @" AS container_kind, CASE WHEN lang = 'solution' AND reference_kind = 'project_reference' THEN path @@ -1147,9 +1152,11 @@ AND r.reference_kind IN {CallGraphReferenceKindsSql} symbol_name, reference_kind, MIN(line) AS first_line, COUNT(*) AS reference_count, MAX(is_self_reference) AS is_self_reference, - MAX(is_mutual_recursion) AS is_mutual_recursion + MAX(is_mutual_recursion) AS is_mutual_recursion, + source_symbol_id, + target_symbol_id FROM logical_references r - GROUP BY path, lang, container_kind, container_name, symbol_name, reference_kind"; + GROUP BY path, lang, container_kind, container_name, symbol_name, reference_kind, source_symbol_id, target_symbol_id"; sql += $" ORDER BY {GetPathBucketOrderSql("r.path")}, reference_count DESC, r.path, COALESCE(r.container_name, ''), COALESCE(r.container_kind, ''), r.symbol_name, reference_kind, first_line LIMIT @limit OFFSET @offset"; cmd.CommandText = sql; @@ -1196,13 +1203,20 @@ FROM logical_references r ReferenceCount = reader.GetInt32(7), HasSelfReference = reader.GetInt32(8) != 0, HasMutualRecursion = reader.GetInt32(9) != 0, + CallerSymbolId = reader.IsDBNull(10) ? null : reader.GetInt64(10), + CalleeSymbolId = reader.IsDBNull(11) ? null : reader.GetInt64(11), }); } return results; } - private static string BuildImpactVisitedKey(CallerResult caller, string callerName) - => $"{caller.Path}:{callerName}:{caller.ReferenceKind}"; + private static string BuildImpactVisitedKey(CallerResult caller, string callerName, bool useCanonicalIdentity) + => useCanonicalIdentity && caller.CallerSymbolId is long callerSymbolId + ? $"id:{callerSymbolId}:{caller.ReferenceKind}" + : $"{caller.Path}:{callerName}:{caller.ReferenceKind}"; + + private static string BuildImpactTraversalNodeKey(long? symbolId, string name) + => symbolId is long canonicalSymbolId ? $"id:{canonicalSymbolId}" : $"name:{name}"; // Per-result cap on the number of distinct shortest paths surfaced by impact --with-paths. // Each call chain row may carry multiple converging paths from the resolved root through @@ -1277,13 +1291,15 @@ private static string BuildImpactVisitedKey(CallerResult caller, string callerNa var resultWindowEnd = checked(resultOffset + limit); var discoveredResultCount = 0; var visited = new HashSet(StringComparer.OrdinalIgnoreCase); - var queue = new Queue<(string Symbol, int Depth)>(); - queue.Enqueue((resolvedName, 0)); + var rootTraversalNodeKey = BuildImpactTraversalNodeKey(identityRootSymbolId, resolvedName); + var queue = new Queue<(string Symbol, long? SymbolId, string NodeKey, int Depth)>(); + queue.Enqueue((resolvedName, identityRootSymbolId, rootTraversalNodeKey, 0)); visited.Add(resolvedName); var truncated = false; var maxDepthReached = false; var cycles = new List(); - var cycleKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + var cycleKeys = new HashSet(StringComparer.Ordinal); + var cycleNodesByKey = new Dictionary(StringComparer.Ordinal); // truncatedReason tracks the *strongest* signal observed: safety_cap wins over // user_limit because it tells callers that raising --limit alone will not help // (the input graph is likely pathological). See Issue #1533. @@ -1296,30 +1312,29 @@ private static string BuildImpactVisitedKey(CallerResult caller, string callerNa var graphStateBudgetHit = false; var boundaryProbeBudgetHit = false; - // Path tracking state is allocated only when callers opt in. We collapse parent edges by - // caller name (rather than path:name) so that diamond chains converging on the same name - // across files share the same node when emitting paths — the issue example asks for - // ["foo", "B", "A"], not file-qualified entries. - // path 追跡は opt-in 時のみ確保する。同名 caller が複数ファイルにあっても経路上は同名 - // ノードとして畳む(issue #1536 の例 ["foo", "B", "A"] と整合)。 - Dictionary> parentsByName = new(StringComparer.OrdinalIgnoreCase); - Dictionary> cycleParentsByName = new(StringComparer.OrdinalIgnoreCase); - Dictionary depthByName = new(StringComparer.OrdinalIgnoreCase) - { - [resolvedName] = 0, + // Traversal state uses canonical symbol IDs when they are available. Display names are + // applied only while materializing output, so consecutive same-name symbols remain + // distinct path nodes (issue #4847) while legacy graphs retain name-keyed behavior. + // traversal state は利用可能なら正規 symbol ID をキーにする。表示名への変換は出力時 + // だけ行い、同名 symbol が連続する経路も別ノードとして保持する (#4847)。 + Dictionary> parentsByNodeKey = new(StringComparer.OrdinalIgnoreCase); + Dictionary> cycleParentsByKey = new(StringComparer.Ordinal); + Dictionary depthByNodeKey = new(StringComparer.OrdinalIgnoreCase) + { + [rootTraversalNodeKey] = 0, }; - var resultIndicesByName = withPaths + var resultIndicesByNodeKey = withPaths ? new Dictionary>(StringComparer.OrdinalIgnoreCase) : null; - var pathNodesByName = withPaths + var pathNodesByKey = withPaths ? new Dictionary(StringComparer.OrdinalIgnoreCase) : null; if (withPaths) - pathNodesByName![resolvedName] = ResolveImpactPathNode(resolvedName, kind: null, lang, referencePath: null, referenceLine: null); + pathNodesByKey![rootTraversalNodeKey] = ResolveImpactPathNode(resolvedName, identityRootSymbolId, kind: null, lang, referencePath: null, referenceLine: null); while (queue.Count > 0 && discoveredResultCount < resultWindowEnd && !graphStateBudgetHit && !boundaryProbeBudgetHit) { - var (currentSymbol, depth) = queue.Dequeue(); + var (currentSymbol, currentSymbolId, currentNodeKey, depth) = queue.Dequeue(); // Fetch callers in pages, filtering out already-visited before counting toward limit. // This prevents diamond graphs from hiding reachable callers behind visited duplicates. @@ -1333,7 +1348,7 @@ private static string BuildImpactVisitedKey(CallerResult caller, string callerNa while (discoveredResultCount < resultWindowEnd && fetchIterations < maxFetchIterations && !graphStateBudgetHit && !boundaryProbeBudgetHit) { fetchIterations++; - var page = depth == 0 && identityRootSymbolId is long targetSymbolId + var page = currentSymbolId is long targetSymbolId ? GetCallersExactForTarget(currentSymbol, targetSymbolId, pageSize, pageOffset, lang, pathPatterns, excludePathPatterns, excludeTests, includeAmbiguousMSource) : GetCallersExact(currentSymbol, pageSize, pageOffset, lang, pathPatterns, excludePathPatterns, excludeTests, includeAmbiguousMSource); @@ -1350,19 +1365,32 @@ private static string BuildImpactVisitedKey(CallerResult caller, string callerNa } var callerName = caller.CallerName ?? SyntheticTopLevelCallerName; - if (IsCycleEdge(callerName, currentSymbol, cycleParentsByName)) - AddImpactCycle(cycles, cycleKeys, BuildCycleMembers(callerName, currentSymbol, cycleParentsByName)); - if (string.Equals(callerName, resolvedName, StringComparison.OrdinalIgnoreCase) - && (rootDefinitionPaths.Count == 0 || rootDefinitionPaths.Contains(caller.Path))) + var callerSymbolId = hasResolvedIdentityGraph ? caller.CallerSymbolId : null; + var calleeSymbolId = hasResolvedIdentityGraph ? caller.CalleeSymbolId ?? currentSymbolId : null; + var callerCycleNode = BuildImpactCycleNode(callerSymbolId, callerName, hasResolvedIdentityGraph); + var currentCycleNode = BuildImpactCycleNode(calleeSymbolId, currentSymbol, hasResolvedIdentityGraph); + if (callerCycleNode is { } callerNode && currentCycleNode is { } currentNode) + { + RegisterImpactCycleNode(cycleNodesByKey, callerNode); + RegisterImpactCycleNode(cycleNodesByKey, currentNode); + if (IsCycleEdge(callerNode.Key, currentNode.Key, cycleParentsByKey)) + AddImpactCycle(cycles, cycleKeys, BuildCycleMembers(callerNode.Key, currentNode.Key, cycleParentsByKey), cycleNodesByKey); + } + if (IsImpactRootCaller(caller, callerName, resolvedName, rootDefinitionPaths, identityRootSymbolId)) continue; - var key = BuildImpactVisitedKey(caller, callerName); - if (!cycleParentsByName.TryGetValue(callerName, out var cycleParentSet)) + var callerNodeKey = BuildImpactTraversalNodeKey(callerSymbolId, callerName); + var key = BuildImpactVisitedKey(caller, callerName, hasResolvedIdentityGraph); + if (callerCycleNode is { } callerCycleParent + && currentCycleNode is { } currentCycleParent) { - cycleParentSet = new HashSet(StringComparer.OrdinalIgnoreCase); - cycleParentsByName[callerName] = cycleParentSet; + if (!cycleParentsByKey.TryGetValue(callerCycleParent.Key, out var cycleParentSet)) + { + cycleParentSet = new HashSet(StringComparer.Ordinal); + cycleParentsByKey[callerCycleParent.Key] = cycleParentSet; + } + cycleParentSet.Add(currentCycleParent.Key); } - cycleParentSet.Add(currentSymbol); - if (ImpactGraphStateEntryCount(parentsByName, cycleParentsByName, depthByName, resultIndicesByName) > graphStateEntryBudget) + if (ImpactGraphStateEntryCount(parentsByNodeKey, cycleParentsByKey, depthByNodeKey, resultIndicesByNodeKey) > graphStateEntryBudget) { graphStateBudgetHit = true; truncated = true; @@ -1378,11 +1406,11 @@ private static string BuildImpactVisitedKey(CallerResult caller, string callerNa // 同 depth で再到達した場合のみ親辺を追加し、別 depth の到達は破棄。 // BFS により最短経路だけが残る。 if (withPaths - && depthByName.TryGetValue(callerName, out var existingDepth) + && depthByNodeKey.TryGetValue(callerNodeKey, out var existingDepth) && existingDepth == depth + 1) { - parentsByName[callerName].Add(currentSymbol); - if (ImpactGraphStateEntryCount(parentsByName, cycleParentsByName, depthByName, resultIndicesByName) > graphStateEntryBudget) + parentsByNodeKey[callerNodeKey].Add(currentNodeKey); + if (ImpactGraphStateEntryCount(parentsByNodeKey, cycleParentsByKey, depthByNodeKey, resultIndicesByNodeKey) > graphStateEntryBudget) { graphStateBudgetHit = true; truncated = true; @@ -1404,6 +1432,8 @@ private static string BuildImpactVisitedKey(CallerResult caller, string callerNa CallerKind = caller.CallerKind, CallerName = caller.CallerName, CalleeName = caller.CalleeName, + CallerSymbolId = callerSymbolId, + CalleeSymbolId = calleeSymbolId, Depth = depth + 1, FirstLine = caller.FirstLine, ReferenceCount = caller.ReferenceCount, @@ -1417,37 +1447,38 @@ private static string BuildImpactVisitedKey(CallerResult caller, string callerNa if (withPaths) { - pathNodesByName!.TryAdd( - callerName, + pathNodesByKey!.TryAdd( + callerNodeKey, ResolveImpactPathNode( callerName, + callerSymbolId, caller.CallerKind, caller.Lang ?? lang, caller.Path, caller.FirstLine)); - if (!depthByName.ContainsKey(callerName)) - depthByName[callerName] = depth + 1; + if (!depthByNodeKey.ContainsKey(callerNodeKey)) + depthByNodeKey[callerNodeKey] = depth + 1; if (includeInPage) { - if (!resultIndicesByName!.TryGetValue(callerName, out var idxList)) + if (!resultIndicesByNodeKey!.TryGetValue(callerNodeKey, out var idxList)) { idxList = new List(); - resultIndicesByName[callerName] = idxList; + resultIndicesByNodeKey[callerNodeKey] = idxList; } idxList.Add(resultIndex); } } - else if (!depthByName.ContainsKey(callerName)) + else if (!depthByNodeKey.ContainsKey(callerNodeKey)) { - depthByName[callerName] = depth + 1; + depthByNodeKey[callerNodeKey] = depth + 1; } - if (!parentsByName.TryGetValue(callerName, out var parentSet)) + if (!parentsByNodeKey.TryGetValue(callerNodeKey, out var parentSet)) { parentSet = new HashSet(StringComparer.OrdinalIgnoreCase); - parentsByName[callerName] = parentSet; + parentsByNodeKey[callerNodeKey] = parentSet; } - parentSet.Add(currentSymbol); - if (ImpactGraphStateEntryCount(parentsByName, cycleParentsByName, depthByName, resultIndicesByName) > graphStateEntryBudget) + parentSet.Add(currentNodeKey); + if (ImpactGraphStateEntryCount(parentsByNodeKey, cycleParentsByKey, depthByNodeKey, resultIndicesByNodeKey) > graphStateEntryBudget) { graphStateBudgetHit = true; truncated = true; @@ -1465,7 +1496,7 @@ private static string BuildImpactVisitedKey(CallerResult caller, string callerNa && caller.CallerName != SyntheticTopLevelCallerName && depth + 1 < maxDepth) { - queue.Enqueue((caller.CallerName, depth + 1)); + queue.Enqueue((caller.CallerName, callerSymbolId, callerNodeKey, depth + 1)); } else if (caller.CallerName != null && caller.CallerName != SyntheticTopLevelCallerName @@ -1473,12 +1504,16 @@ private static string BuildImpactVisitedKey(CallerResult caller, string callerNa { var boundaryInspection = InspectBoundaryCallers( caller.CallerName, + callerSymbolId, resolvedName, rootDefinitionPaths, + identityRootSymbolId, visited, - cycleParentsByName, + cycleParentsByKey, + cycleNodesByKey, cycles, cycleKeys, + hasResolvedIdentityGraph, lang, pathPatterns, excludePathPatterns, @@ -1520,13 +1555,16 @@ private static string BuildImpactVisitedKey(CallerResult caller, string callerNa if (withPaths) { var effectiveCap = maxPathsPerResult > 0 ? maxPathsPerResult : DefaultImpactPathsPerResult; - foreach (var (callerName, indices) in resultIndicesByName!) + foreach (var (callerNodeKey, indices) in resultIndicesByNodeKey!) { - var (paths, more) = EnumerateImpactPaths(callerName, parentsByName, resolvedName, effectiveCap); + var (pathKeys, more) = EnumerateImpactPaths(callerNodeKey, parentsByNodeKey, rootTraversalNodeKey, effectiveCap); + var paths = pathKeys + .Select(path => path.Select(nodeKey => pathNodesByKey![nodeKey].Name).ToList()) + .ToList(); foreach (var idx in indices) { results[idx].Paths = paths; - results[idx].PathDetails = BuildImpactPathDetails(paths, pathNodesByName!, results[idx]); + results[idx].PathDetails = BuildImpactPathDetails(pathKeys, pathNodesByKey!, results[idx]); results[idx].PathsTruncated = more; } } @@ -1553,32 +1591,68 @@ private static int GetImpactGraphStateEntryBudget(int limit) } private static int ImpactGraphStateEntryCount( - Dictionary> parentsByName, - Dictionary> cycleParentsByName, - Dictionary depthByName, - Dictionary>? resultIndicesByName) + Dictionary> parentsByNodeKey, + Dictionary> cycleParentsByNodeKey, + Dictionary depthByNodeKey, + Dictionary>? resultIndicesByNodeKey) { - var count = depthByName.Count + parentsByName.Count + cycleParentsByName.Count + (resultIndicesByName?.Count ?? 0); - foreach (var parents in parentsByName.Values) + var count = depthByNodeKey.Count + parentsByNodeKey.Count + cycleParentsByNodeKey.Count + (resultIndicesByNodeKey?.Count ?? 0); + foreach (var parents in parentsByNodeKey.Values) count += parents.Count; - foreach (var parents in cycleParentsByName.Values) + foreach (var parents in cycleParentsByNodeKey.Values) count += parents.Count; - if (resultIndicesByName != null) - foreach (var indices in resultIndicesByName.Values) + if (resultIndicesByNodeKey != null) + foreach (var indices in resultIndicesByNodeKey.Values) count += indices.Count; return count; } private readonly record struct ImpactBoundaryInspection(bool HasUnvisitedCaller, bool ProbeBudgetHit); + private readonly record struct ImpactCycleNode(string Key, long? SymbolId, string Name); + + private static void RegisterImpactCycleNode( + Dictionary nodesByKey, + ImpactCycleNode node) + => nodesByKey.TryAdd(node.Key, new ImpactCycleMemberResult + { + SymbolId = node.SymbolId, + Name = node.Name, + }); + + private static ImpactCycleNode? BuildImpactCycleNode(long? symbolId, string name, bool hasResolvedIdentityGraph) + { + if (!hasResolvedIdentityGraph) + return new ImpactCycleNode($"name:{NameFold.Fold(name) ?? name}", null, name); + if (symbolId is long canonicalSymbolId) + return new ImpactCycleNode($"id:{canonicalSymbolId}", canonicalSymbolId, name); + return null; + } + + private static bool IsImpactRootCaller( + CallerResult caller, + string callerName, + string resolvedName, + HashSet rootDefinitionPaths, + long? identityRootSymbolId) + { + if (identityRootSymbolId is long rootSymbolId && caller.CallerSymbolId is long callerSymbolId) + return rootSymbolId == callerSymbolId; + return string.Equals(callerName, resolvedName, StringComparison.OrdinalIgnoreCase) + && (rootDefinitionPaths.Count == 0 || rootDefinitionPaths.Contains(caller.Path)); + } private ImpactBoundaryInspection InspectBoundaryCallers( string symbolName, + long? symbolId, string resolvedName, HashSet rootDefinitionPaths, + long? identityRootSymbolId, HashSet visited, - Dictionary> cycleParentsByName, + Dictionary> cycleParentsByKey, + Dictionary cycleNodesByKey, List cycles, HashSet cycleKeys, + bool hasResolvedIdentityGraph, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, @@ -1593,7 +1667,9 @@ private ImpactBoundaryInspection InspectBoundaryCallers( return new ImpactBoundaryInspection(HasUnvisitedCaller: true, ProbeBudgetHit: true); var pageSize = Math.Min(ImpactBoundaryCallerProbePageSize, ImpactBoundaryCallerProbeBudget - probes); - var page = GetCallersExact(symbolName, pageSize, offset, lang, pathPatterns, excludePathPatterns, excludeTests, includeAmbiguousMSource); + var page = symbolId is long targetSymbolId + ? GetCallersExactForTarget(symbolName, targetSymbolId, pageSize, offset, lang, pathPatterns, excludePathPatterns, excludeTests, includeAmbiguousMSource) + : GetCallersExact(symbolName, pageSize, offset, lang, pathPatterns, excludePathPatterns, excludeTests, includeAmbiguousMSource); if (page.Count == 0) return new ImpactBoundaryInspection(HasUnvisitedCaller: false, ProbeBudgetHit: false); probes += page.Count; @@ -1601,21 +1677,33 @@ private ImpactBoundaryInspection InspectBoundaryCallers( foreach (var caller in page) { var callerName = caller.CallerName ?? SyntheticTopLevelCallerName; - if (IsCycleEdge(callerName, symbolName, cycleParentsByName)) - AddImpactCycle(cycles, cycleKeys, BuildCycleMembers(callerName, symbolName, cycleParentsByName)); - var isRoot = string.Equals(callerName, resolvedName, StringComparison.OrdinalIgnoreCase) - && (rootDefinitionPaths.Count == 0 || rootDefinitionPaths.Contains(caller.Path)); + var callerSymbolId = hasResolvedIdentityGraph ? caller.CallerSymbolId : null; + var calleeSymbolId = hasResolvedIdentityGraph ? caller.CalleeSymbolId ?? symbolId : null; + var callerCycleNode = BuildImpactCycleNode(callerSymbolId, callerName, hasResolvedIdentityGraph); + var currentCycleNode = BuildImpactCycleNode(calleeSymbolId, symbolName, hasResolvedIdentityGraph); + if (callerCycleNode is { } callerNode && currentCycleNode is { } currentNode) + { + RegisterImpactCycleNode(cycleNodesByKey, callerNode); + RegisterImpactCycleNode(cycleNodesByKey, currentNode); + if (IsCycleEdge(callerNode.Key, currentNode.Key, cycleParentsByKey)) + AddImpactCycle(cycles, cycleKeys, BuildCycleMembers(callerNode.Key, currentNode.Key, cycleParentsByKey), cycleNodesByKey); + } + var isRoot = IsImpactRootCaller(caller, callerName, resolvedName, rootDefinitionPaths, identityRootSymbolId); if (isRoot) continue; - if (!cycleParentsByName.TryGetValue(callerName, out var cycleParentSet)) + if (callerCycleNode is { } callerCycleParent + && currentCycleNode is { } currentCycleParent) { - cycleParentSet = new HashSet(StringComparer.OrdinalIgnoreCase); - cycleParentsByName[callerName] = cycleParentSet; + if (!cycleParentsByKey.TryGetValue(callerCycleParent.Key, out var cycleParentSet)) + { + cycleParentSet = new HashSet(StringComparer.Ordinal); + cycleParentsByKey[callerCycleParent.Key] = cycleParentSet; + } + cycleParentSet.Add(currentCycleParent.Key); } - cycleParentSet.Add(symbolName); - var key = BuildImpactVisitedKey(caller, callerName); + var key = BuildImpactVisitedKey(caller, callerName, hasResolvedIdentityGraph); if (!visited.Contains(key)) return new ImpactBoundaryInspection(HasUnvisitedCaller: true, ProbeBudgetHit: false); } @@ -1627,31 +1715,31 @@ private ImpactBoundaryInspection InspectBoundaryCallers( } private static bool IsCycleEdge( - string callerName, - string currentSymbol, - Dictionary> parentsByName) + string callerKey, + string currentKey, + Dictionary> parentsByKey) { - if (string.Equals(callerName, currentSymbol, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(callerKey, currentKey, StringComparison.Ordinal)) return true; - return HasAncestor(currentSymbol, callerName, parentsByName); + return HasAncestor(currentKey, callerKey, parentsByKey); } private static bool HasAncestor( string node, string target, - Dictionary> parentsByName) + Dictionary> parentsByKey) { var stack = new Stack(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var seen = new HashSet(StringComparer.Ordinal); stack.Push(node); while (stack.Count > 0) { var current = stack.Pop(); if (!seen.Add(current)) continue; - if (string.Equals(current, target, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(current, target, StringComparison.Ordinal)) return true; - if (!parentsByName.TryGetValue(current, out var parents)) + if (!parentsByKey.TryGetValue(current, out var parents)) continue; foreach (var parent in parents) stack.Push(parent); @@ -1660,48 +1748,48 @@ private static bool HasAncestor( } private static List BuildCycleMembers( - string callerName, - string currentSymbol, - Dictionary> parentsByName) + string callerKey, + string currentKey, + Dictionary> parentsByKey) { - var members = new HashSet(StringComparer.OrdinalIgnoreCase); - if (!TryBuildAncestorPath(currentSymbol, callerName, parentsByName, members)) + var members = new HashSet(StringComparer.Ordinal); + if (!TryBuildAncestorPath(currentKey, callerKey, parentsByKey, members)) { - members.Add(callerName); - members.Add(currentSymbol); + members.Add(callerKey); + members.Add(currentKey); } var result = members.ToList(); - result.Sort(StringComparer.OrdinalIgnoreCase); + result.Sort(StringComparer.Ordinal); return result; } private static bool TryBuildAncestorPath( string node, string target, - Dictionary> parentsByName, + Dictionary> parentsByKey, HashSet members) { - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - return TryBuildAncestorPathCore(node, target, parentsByName, members, seen); + var seen = new HashSet(StringComparer.Ordinal); + return TryBuildAncestorPathCore(node, target, parentsByKey, members, seen); } private static bool TryBuildAncestorPathCore( string node, string target, - Dictionary> parentsByName, + Dictionary> parentsByKey, HashSet members, HashSet seen) { if (!seen.Add(node)) return false; members.Add(node); - if (string.Equals(node, target, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(node, target, StringComparison.Ordinal)) return true; - if (parentsByName.TryGetValue(node, out var parents)) + if (parentsByKey.TryGetValue(node, out var parents)) { foreach (var parent in parents) { - if (TryBuildAncestorPathCore(parent, target, parentsByName, members, seen)) + if (TryBuildAncestorPathCore(parent, target, parentsByKey, members, seen)) return true; } } @@ -1713,49 +1801,61 @@ private static bool TryBuildAncestorPathCore( private static void AddImpactCycle( List cycles, HashSet cycleKeys, - List members) + List memberKeys, + IReadOnlyDictionary nodesByKey) { - if (members.Count == 0) + if (memberKeys.Count == 0) return; - var key = string.Join("\u001F", members); + var key = string.Join("\u001F", memberKeys); if (!cycleKeys.Add(key)) return; - cycles.Add(new ImpactCycleResult { Members = members }); + var identities = memberKeys + .Select(memberKey => nodesByKey[memberKey]) + .OrderBy(node => node.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(node => node.SymbolId) + .Select(node => new ImpactCycleMemberResult + { + SymbolId = node.SymbolId, + Name = node.Name, + }) + .ToList(); + cycles.Add(new ImpactCycleResult + { + Members = identities.Select(identity => identity.Name).ToList(), + MemberIdentities = identities.Any(identity => identity.SymbolId != null) ? identities : null, + }); } private static (List> Paths, bool Truncated) EnumerateImpactPaths( - string callerName, - Dictionary> parentsByName, - string resolvedRoot, + string callerNodeKey, + Dictionary> parentsByNodeKey, + string resolvedRootNodeKey, int maxPathsPerResult) { - // DFS upward from `callerName` to `resolvedRoot` through parent edges. The Stack - // enumerator yields top-first, so a stack of [callerName, ..., resolvedRoot] (pushed - // bottom→top) materializes directly to [resolvedRoot, ..., callerName] without an - // explicit reverse — matching the order in the issue example ["foo", "B", "A"]. - // 親辺を辿って callerName → resolvedRoot を DFS で列挙する。Stack の列挙は top→bottom - // なので、push 順 [callerName, ..., resolvedRoot] がそのまま [resolvedRoot, ..., callerName] - // で取り出せる(issue #1536 の例 ["foo", "B", "A"] に一致)。 + // DFS upward through canonical node keys. The Stack enumerator yields top-first, + // so the materialized key path is already ordered [resolvedRoot, ..., caller]. + // 正規 node key の親辺を DFS で辿る。Stack は top-first で列挙されるため、 + // key path はそのまま [resolvedRoot, ..., caller] 順になる。 var paths = new List>(); var stack = new Stack(); var onStack = new HashSet(StringComparer.OrdinalIgnoreCase); var truncatedRef = new bool[1]; - stack.Push(callerName); - onStack.Add(callerName); - Dfs(callerName); + stack.Push(callerNodeKey); + onStack.Add(callerNodeKey); + Dfs(callerNodeKey); stack.Pop(); - onStack.Remove(callerName); + onStack.Remove(callerNodeKey); return (paths, truncatedRef[0]); void Dfs(string node) { - if (string.Equals(node, resolvedRoot, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(node, resolvedRootNodeKey, StringComparison.OrdinalIgnoreCase)) { paths.Add(stack.ToList()); return; } - if (!parentsByName.TryGetValue(node, out var parents)) + if (!parentsByNodeKey.TryGetValue(node, out var parents)) return; foreach (var p in parents) { @@ -1779,11 +1879,12 @@ void Dfs(string node) } } - private ImpactPathNode ResolveImpactPathNode(string name, string? kind, string? lang, string? referencePath, int? referenceLine) + private ImpactPathNode ResolveImpactPathNode(string name, long? symbolId, string? kind, string? lang, string? referencePath, int? referenceLine) { - var node = TryResolveImpactPathNodeDefinition(name, kind, lang, referencePath) + var node = TryResolveImpactPathNodeDefinition(name, symbolId, kind, lang, referencePath) ?? new ImpactPathNode { + SymbolId = symbolId, Name = name, Kind = kind, Lang = lang, @@ -1793,7 +1894,7 @@ private ImpactPathNode ResolveImpactPathNode(string name, string? kind, string? return node; } - private ImpactPathNode? TryResolveImpactPathNodeDefinition(string name, string? kind, string? lang, string? preferredPath) + private ImpactPathNode? TryResolveImpactPathNodeDefinition(string name, long? symbolId, string? kind, string? lang, string? preferredPath) { if (!_symbolColumns.Contains("name") || !_symbolColumns.Contains("kind")) return null; @@ -1807,7 +1908,8 @@ private ImpactPathNode ResolveImpactPathNode(string name, string? kind, string? : "s.name = @name COLLATE NOCASE"; cmd.CommandText = $@" - SELECT f.path, + SELECT s.id, + f.path, f.lang, s.kind, s.name, @@ -1818,7 +1920,8 @@ private ImpactPathNode ResolveImpactPathNode(string name, string? kind, string? s.file_id FROM symbols s JOIN files f ON s.file_id = f.id - WHERE {namePredicate} + WHERE ((@symbolId IS NOT NULL AND s.id = @symbolId) + OR (@symbolId IS NULL AND {namePredicate})) AND s.kind NOT IN ('import', 'namespace') AND (@kind IS NULL OR s.kind = @kind) AND (@lang IS NULL OR f.lang = @lang) @@ -1827,6 +1930,7 @@ AND s.kind NOT IN ('import', 'namespace') s.line LIMIT 1"; SqliteCommandPolicy.Add(cmd, "@name", name); + SqliteCommandPolicy.AddNullableInt64(cmd, "@symbolId", symbolId); if (_foldReady && _symbolColumns.Contains("name_folded")) SqliteCommandPolicy.Add(cmd, "@nameFolded", NameFold.Fold(name) ?? name); SqliteCommandPolicy.AddNullableText(cmd, "@kind", kind); @@ -1837,18 +1941,20 @@ AND s.kind NOT IN ('import', 'namespace') if (!reader.TrackedRead()) return null; - var definitionPath = reader.GetString(0); - var definitionLang = GetNullableString(reader, 1); - var definitionKind = reader.GetString(2); - var definitionName = reader.GetString(3); - var definitionLine = reader.IsDBNull(4) ? (int?)null : reader.GetInt32(4); - var containerName = GetNullableString(reader, 5); - var containerQualifiedName = GetNullableString(reader, 6); - var familyKey = GetNullableString(reader, 7); - var fileId = reader.GetInt64(8); + var definitionSymbolId = reader.GetInt64(0); + var definitionPath = reader.GetString(1); + var definitionLang = GetNullableString(reader, 2); + var definitionKind = reader.GetString(3); + var definitionName = reader.GetString(4); + var definitionLine = reader.IsDBNull(5) ? (int?)null : reader.GetInt32(5); + var containerName = GetNullableString(reader, 6); + var containerQualifiedName = GetNullableString(reader, 7); + var familyKey = GetNullableString(reader, 8); + var fileId = reader.GetInt64(9); return new ImpactPathNode { + SymbolId = definitionSymbolId, Name = definitionName, Kind = definitionKind, Lang = definitionLang, @@ -1870,21 +1976,20 @@ private static string BuildImpactPathLogicalTargetKey(string? lang, string kind, } private static List> BuildImpactPathDetails( - List> paths, - IReadOnlyDictionary nodesByName, + List> pathKeys, + IReadOnlyDictionary nodesByKey, ImpactResult result) { - var details = new List>(paths.Count); - var resultName = result.CallerName ?? SyntheticTopLevelCallerName; - foreach (var path in paths) + var details = new List>(pathKeys.Count); + foreach (var path in pathKeys) { var detailPath = new List(path.Count); for (var i = 0; i < path.Count; i++) { - var name = path[i]; - var isResultNode = i == path.Count - 1 && string.Equals(name, resultName, StringComparison.OrdinalIgnoreCase); - if (!nodesByName.TryGetValue(name, out var node)) - node = new ImpactPathNode { Name = name }; + var nodeKey = path[i]; + var isResultNode = i == path.Count - 1; + if (!nodesByKey.TryGetValue(nodeKey, out var node)) + node = new ImpactPathNode { Name = nodeKey }; detailPath.Add(isResultNode ? CloneImpactPathNodeForResult(node, result) : CloneImpactPathNode(node)); @@ -1907,6 +2012,7 @@ private static ImpactPathNode CloneImpactPathNodeForResult(ImpactPathNode node, private static ImpactPathNode CloneImpactPathNode(ImpactPathNode node) => new() { + SymbolId = node.SymbolId, Name = node.Name, Kind = node.Kind, Lang = node.Lang, diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index c1e53468b..d12f73fa5 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -676,6 +676,10 @@ public class CallerResult public string? CallerKind { get; set; } public string? CallerName { get; set; } public string CalleeName { get; set; } = string.Empty; + [JsonIgnore] + public long? CallerSymbolId { get; set; } + [JsonIgnore] + public long? CalleeSymbolId { get; set; } // Summary preferred reference_kind for the grouped row. Grouped caller rows can // collapse multiple underlying kinds into one label, so JSON/MCP consumers that // need the full picture should read ReferenceKinds + HasMixedReferenceKinds as @@ -765,6 +769,10 @@ public class ImpactResult public string? CallerKind { get; set; } public string? CallerName { get; set; } public string CalleeName { get; set; } = string.Empty; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? CallerSymbolId { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? CalleeSymbolId { get; set; } public int Depth { get; set; } public int FirstLine { get; set; } public int ReferenceCount { get; set; } @@ -815,6 +823,8 @@ public class ImpactResult public class ImpactPathNode { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? SymbolId { get; set; } public string Name { get; set; } = string.Empty; public string? Kind { get; set; } public string? Lang { get; set; } @@ -925,6 +935,15 @@ public static class ImpactTerminationReasons public class ImpactCycleResult { public List Members { get; set; } = []; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? MemberIdentities { get; set; } +} + +public class ImpactCycleMemberResult +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? SymbolId { get; set; } + public string Name { get; set; } = string.Empty; } public sealed class StatusDatabasePermissionDiagnostic diff --git a/tests/CodeIndex.Tests/DbReaderImpactTests.cs b/tests/CodeIndex.Tests/DbReaderImpactTests.cs index aa2ade755..f02d80abf 100644 --- a/tests/CodeIndex.Tests/DbReaderImpactTests.cs +++ b/tests/CodeIndex.Tests/DbReaderImpactTests.cs @@ -1,5 +1,7 @@ using System.Reflection; using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; using CodeIndex.Database; using CodeIndex.Indexer; using CodeIndex.Models; @@ -138,6 +140,163 @@ public static class ImpactCycle Assert.True(analysis.CycleDetected); var cycle = Assert.Single(analysis.Cycles!); Assert.Equal(new[] { "A", "B", "C" }, cycle.Members); + Assert.Equal(3, cycle.MemberIdentities!.Count); + Assert.All(cycle.MemberIdentities, member => Assert.NotNull(member.SymbolId)); + Assert.Equal(3, cycle.MemberIdentities.Select(member => member.SymbolId).Distinct().Count()); + } + + [Fact] + public void AnalyzeImpact_SameDisplayNameOnRealEdgeDoesNotReportSingletonCycle_Issue4847() + { + // Issue #4847: display names are presentation data. Two consecutive real edges + // through distinct canonical symbols named Run must not become a zero-hop cycle. + // Issue #4847: 表示名は提示用データであり、Run という別々の正規シンボルを + // 連続して辿る実エッジをゼロホップの cycle として扱ってはならない。 + InsertIndexedFile("src/ImpactTarget.cs", "csharp", + """ + namespace ImpactIdentity.Target; + + public static class TargetWorker + { + public static void RunAsync() { } + } + """); + InsertIndexedFile("src/ImpactMiddle.cs", "csharp", + """ + namespace ImpactIdentity.Middle; + + public static class MiddleWorker + { + public static void Run() + { + ImpactIdentity.Target.TargetWorker.RunAsync(); + } + } + """); + InsertIndexedFile("src/ImpactOuter.cs", "csharp", + """ + namespace ImpactIdentity.Outer; + + public static class OuterWorker + { + public static void Run() + { + ImpactIdentity.Middle.MiddleWorker.Run(); + } + } + """); + + var analysis = _reader.AnalyzeImpact( + "ImpactIdentity.Target.TargetWorker.RunAsync", + maxDepth: 2, + limit: 20, + lang: "csharp", + pathPatterns: ["src/Impact*.cs"], + withPaths: true); + + Assert.False(analysis.CycleDetected); + Assert.Null(analysis.Cycles); + Assert.Equal(ImpactTerminationReasons.Completed, analysis.TerminationReason); + Assert.True( + analysis.Callers.Count == 2, + string.Join( + " | ", + analysis.Callers.Select(caller => + $"{caller.Path}:{caller.CallerName}->{caller.CalleeName}:{caller.CallerSymbolId}->{caller.CalleeSymbolId}:depth={caller.Depth}"))); + Assert.All(analysis.Callers, caller => Assert.Equal("Run", caller.CallerName)); + Assert.All(analysis.Callers, caller => Assert.NotNull(caller.CallerSymbolId)); + Assert.Equal(2, analysis.Callers.Select(caller => caller.CallerSymbolId).Distinct().Count()); + var outerEdge = Assert.Single(analysis.Callers.Where(caller => caller.Depth == 2)); + Assert.NotEqual(outerEdge.CallerSymbolId, outerEdge.CalleeSymbolId); + Assert.Equal([new List { "RunAsync", "Run", "Run" }], outerEdge.Paths); + var pathDetails = Assert.Single(outerEdge.PathDetails!); + Assert.Equal(3, pathDetails.Select(node => node.SymbolId).Distinct().Count()); + var structured = JsonNode.Parse(JsonSerializer.Serialize( + analysis, + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }))!; + var structuredOuter = structured["callers"]!.AsArray() + .Single(node => node!["depth"]!.GetValue() == 2)!; + Assert.Equal(outerEdge.CallerSymbolId, structuredOuter["caller_symbol_id"]!.GetValue()); + Assert.Equal(outerEdge.CalleeSymbolId, structuredOuter["callee_symbol_id"]!.GetValue()); + Assert.Equal( + pathDetails[^1].SymbolId, + structuredOuter["path_details"]![0]![2]!["symbol_id"]!.GetValue()); + } + + [Fact] + public void AnalyzeImpact_SameNameOverloadsRemainDistinctBySourceIdentity_Issue4847() + { + InsertIndexedFile("src/ImpactOverloads.cs", "csharp", + """ + public static class ImpactOverloads + { + public static void Anchor() { } + + public static void Run() + { + Anchor(); + } + + public static void Run(int value) + { + Anchor(); + } + } + """); + + var analysis = _reader.AnalyzeImpact( + "ImpactOverloads.Anchor", + maxDepth: 1, + limit: 20, + lang: "csharp", + pathPatterns: ["src/ImpactOverloads.cs"]); + + Assert.False(analysis.CycleDetected); + Assert.Equal(2, analysis.Callers.Count); + Assert.All(analysis.Callers, caller => Assert.Equal("Run", caller.CallerName)); + Assert.All(analysis.Callers, caller => Assert.NotNull(caller.CallerSymbolId)); + Assert.Equal(2, analysis.Callers.Select(caller => caller.CallerSymbolId).Distinct().Count()); + Assert.Single(analysis.Callers.Select(caller => caller.CalleeSymbolId).Distinct()); + Assert.Equal( + analysis.Callers.Select(caller => caller.FirstLine).OrderBy(line => line), + analysis.Callers.Select(caller => caller.FirstLine)); + } + + [Fact] + public void AnalyzeImpact_DirectRecursionReportsRealSingletonCycle_Issue4847() + { + InsertIndexedFile("src/ImpactDirectRecursion.cs", "csharp", + """ + public static class ImpactDirectRecursion + { + public static void Run() + { + Run(); + } + } + """); + + var analysis = _reader.AnalyzeImpact( + "ImpactDirectRecursion.Run", + maxDepth: 2, + limit: 20, + lang: "csharp", + pathPatterns: ["src/ImpactDirectRecursion.cs"]); + + Assert.False(analysis.Truncated); + Assert.True(analysis.CycleDetected); + Assert.Equal(ImpactTerminationReasons.CycleDetected, analysis.TerminationReason); + Assert.Empty(analysis.Callers); + var cycle = Assert.Single(analysis.Cycles!); + Assert.Equal(["Run"], cycle.Members); + var member = Assert.Single(cycle.MemberIdentities!); + Assert.NotNull(member.SymbolId); + var structured = JsonNode.Parse(JsonSerializer.Serialize( + analysis, + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }))!; + Assert.Equal( + member.SymbolId, + structured["cycles"]![0]!["member_identities"]![0]!["symbol_id"]!.GetValue()); } [Fact] diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerImpactTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerImpactTests.cs index 0ece2adb3..3701366bc 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerImpactTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerImpactTests.cs @@ -95,12 +95,16 @@ public void GetTransitiveCallers_MaxDepthBoundaryProbeBudgetTerminatesStably_Iss var inspection = method!.Invoke(reader, [ "Caller0", + null, "Root", new HashSet(StringComparer.OrdinalIgnoreCase), + null, visited, - new Dictionary>(StringComparer.OrdinalIgnoreCase), + new Dictionary>(StringComparer.Ordinal), + new Dictionary(StringComparer.Ordinal), new List(), - new HashSet(StringComparer.OrdinalIgnoreCase), + new HashSet(StringComparer.Ordinal), + false, "csharp", null, null, From 61cf5cae669dcd9fd60f12d8e3628b8598fab6f2 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 02:29:33 +0900 Subject: [PATCH 2/3] Address impact identity review findings (#4847) --- DEVELOPER_GUIDE.md | 4 +- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 17 ++- changelog.d/unreleased/4847.fixed.md | 4 +- .../Database/DbReader.GraphQueries.cs | 132 +++++++++++++----- src/CodeIndex/Models/QueryResults.cs | 6 + tests/CodeIndex.Tests/DbReaderImpactTests.cs | 103 ++++++++++++++ 7 files changed, 223 insertions(+), 47 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 362154043..71c8815eb 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1827,7 +1827,7 @@ Process exit codes are coarse (`0` success including valid zero-row queries, `1` - **Bundled symbol analysis** — `inspect` and MCP `analyze_symbol` return definition, nearby symbols, references, callers, callees, file metadata, workspace trust metadata, and graph-support metadata in one request so AI clients can answer common symbol questions with fewer round-trips. - **Language-aware reference extraction** — `references`, `callers`, `callees`, and `impact` are backed by an indexed reference table built only for languages where regex-based call/reference extraction is meaningful. Unsupported languages intentionally fall back to text search instead of returning low-confidence pseudo-graph data. When a language is removed from graph support, `PurgeUnsupportedReferences` deletes its stale `symbol_references` rows on the next indexing run, and graph read paths additionally filter by supported languages to prevent stale edges from surviving between index runs. Shell is intentionally excluded because its command-style invocations (`foo arg1 arg2`) cannot be detected by the parenthesized-call regex. **Nested generic call sites**: C#/Java constructor calls like `new Dictionary>()` and C# generic method calls like `Helper.DoWork>()` are recovered by a depth-aware fallback scanner so the outer target still reaches the reference table even though the flat regex fast-path cannot balance `>>`. **JS/TS no-paren constructors**: JavaScript / TypeScript zero-argument constructor calls that legally omit `()` — for example `new Foo;`, `new Date;`, qualified targets like `new Demo.Provider;`, and one-level generic TypeScript forms like `new Box;` — are emitted as `instantiate` edges via a dedicated language-gated path, while next-line `.bar()` / `[0]` continuations are suppressed so a line-ended `new Foo` does not become a phantom standalone instantiation. **Constructor chain calls**: C# `: this(...)` / `: base(...)` initializers and Java `this(...)` / `super(...)` first-statement calls are detected separately from the generic call regex and rewritten so the reference target is the real constructor (enclosing class/record for `this`, the parsed base type from the class signature for `base` / `super`). Cross-line C# initializers are attributed to the owning constructor rather than the enclosing class. Base-type parsing strips generics, record primary-ctor args, `where` constraints, and `global::` / dotted namespace qualifiers; Java `super.method()` stays a normal method call. **Type-position dependency edges**: C#/Java base lists, declaration types, generic constraints, `throws`, `is`/`as`/`instanceof`, and real C# XML-doc `cref` sites are indexed as `type_reference` rows so `references` / `impact` can see compile-time rename dependencies without polluting the default dynamic call graph exposed by `callers` / `callees`. C# XML-doc `cref` extraction accepts declaration-attached XML-doc comments from both `///` lines and delimited `/** ... */` blocks, including declarations that begin later on the same physical line after the closing `*/` only when no unrelated same-line code or declaration intervenes, while ordinary `//` / `////` comments, non-documenting block comments, method-body XML-doc comments that merely precede a later declaration, brace-free field/property initializer continuations, brace-free expression lambdas, intervening top-level executable statements, same-line non-target code after `*/`, other nested executable continuations, and multiline raw/verbatim string content whose line happens to start with `/**` stay excluded. Non-doc code or string content after the closing `*/` on the same physical line is still outside the doc-comment slice. Even though the regex now runs against that narrower slice, the extractor preserves `symbol_references.column` relative to the original physical source line. On the C# read path, `using static` constant-pattern suppression is token-aware around `is` / `case`, reconstructs an anchor-aware indexed multi-line window when the anchor lives on a previous line, and keeps trivia-bearing forms such as `value is/*comment*/Red`, `value is\n Red or Blue`, `value is\n // comment\n Red`, `case\n // comment\n Point:`, long `case` / `or` chains, and `case\tRed:` filtered or rescued correctly. Qualified constant/member patterns stay qualifier-driven on that exact-name read path, so an unrelated same-name type such as `class Red {}` no longer cancels suppression for `case Color.Red or Color.Blue:` just because the leaf name matches. The extractor-side pending type-pattern carry now also survives trivia-only separator lines, standalone continuation-line `not`, and multiline `case` heads/logical continuations, so comment-only or `not`-only continuation lines no longer drop the later type head before the real token arrives. Non-type `case` labels such as `case > 0:` and `case not > 0:` do not arm that pending carry, so the next-line call/identifier token stays out of `type_reference`. Same-name type rescue also honors `file` visibility so file-local types only rescue references from the same physical file; inherited protected/public/internal nested types from real base classes rescue derived-class pattern heads only after the base reference is normalized through active type and namespace aliases, and alias-expanded constructed generic bases are canonicalized again before containing-type lookup so `AliasBase = Probe.Base` resolves the same way as `Probe.Base`; implemented interfaces do not contribute inherited nested-type rescue; and same-file `using Namespace;`, project-wide `global using Namespace;`, and active type aliases all participate in the rescue set. The extractor deliberately leaves ambiguous unqualified `using static` heads such as `value is Red` in the DB, because file-local parsing alone cannot know whether another file in the same namespace declares the real `Red` type; the workspace-aware read path is responsible for suppressing the pure constant-only cases. **SQL qualified-name alignment**: SQL definitions still persist their schema-qualified symbol name (`dbo.fn_X`), but graph/`deps`/unused/hotspot readers now resolve each SQL reference row through its stored source-line context, recorded call column, and enclosing container before they compare it to definitions, so qualified `references` / `callers` / `impact` queries stay schema-scoped even when one line contains multiple qualified calls or the lookup is non-exact. Those readers fall back to the bare leaf only when the source site itself is genuinely unqualified, which keeps `deps`, `unused`, and `hotspots` aligned with qualified SQL calls without regressing bare-call support or double-counting `EXEC dbo.fn_Target; EXEC sales.fn_Target;`. Once a row already has a recorded call column, those downstream readers no longer whole-line-upgrade that row to a later qualified token, so trailing comments, string literals, or a second qualified call cannot steal the earlier unqualified edge. Exact SQL graph/dependency readers also preserve the resolved segment count, so a quoted single identifier containing a dot such as `"sales.fn_Target"` stays distinct from the real qualified name `sales.fn_Target` across exact `references` / `callers` / `impact` and aggregate `deps` / `unused` / `hotspots`. SQL CTE body source rows use the raw `cte_body_reference` kind, so `references --kind cte_body_reference` can distinguish anchor/recursive-member internals from outer-query table references. Qualified SQL `callees` queries also keep leaf fallback disabled unless the caller query itself is unqualified, so `callees sales.Caller` no longer widens to `dbo.Caller`. SQL extractors also accept optional whitespace around qualified-name dots, so definitions/calls such as `[sales] . [fn_Target]` and `[dbo] . [fn_Target]` keep their full qualified identity instead of truncating at the first segment. The same SQL no-parens extractor now preserves ANSI / PostgreSQL double-quoted call targets such as `CALL "sales"."proc_name"` and `EXEC "dbo"."fn_Target"` instead of stripping them as string literals, while true single-quoted SQL string literals remain masked. Definition-oriented readers also canonicalize quoted qualified SQL names (`[dbo].[fn_X]` → `dbo.fn_X`) before matching, and they only fall back to the leaf identifier for unqualified queries so exact qualified lookups do not widen to sibling schemas that merely share the same leaf name. Exact SQL definition matching also preserves segment count, so a quoted single identifier that contains a dot (`"sales.fn_Target"`) does not collide with a real qualified name (`sales.fn_Target`). SQL exact graph leaf fallback also stays on the Unicode folded exact path, and both quoted qualified and unqualified Unicode exact definition lookups now use the folded normalized path, so queries such as `dbo.Äpfel` / `dbo.äpfel` and bare `Äpfel` / `äpfel` keep matching leaf call/reference rows such as `äpfel` plus stored definitions such as `[dbo].[Äpfel]` or `dbo.Äpfel` instead of silently degrading to ASCII-only `NOCASE`. Exact multi-name SQL `symbols --count` lookups also bind the folded leaf parameters on that same `_foldReady` path, so Unicode leaf query sets no longer fail with missing-parameter database errors. - **Transitive impact analysis** — `impact` and MCP `impact_analysis` compute the transitive caller chain of a symbol using BFS. Design constraints refined through adversarial review: caller matching uses case-insensitive exact match (`lower() = lower()`) to avoid both substring expansion and case-sensitivity brittleness; symbol names are pre-resolved through definitions with exact-case preference; the read path filters to graph-supported languages to prevent stale edges from removed languages; the definition set used for heuristic fallback must also respect active `--lang` / `--path` / `--exclude-path` / `--exclude-tests` filters and graph-supported languages so out-of-scope or unsupported duplicates do not suppress in-scope hints; fallback eligibility is keyed off class-like definitions only, so same-name namespace/import siblings do not block a single resolved class / struct / interface target, while pure non-callable `namespace` / `import` queries surface `non_callable_symbol_kind` guidance; heuristic file-level hints still return a successful result and encode their non-authoritative status via `impact_mode`, `heuristic`, `hint_count`, and `truncated`; caller rows include `result_kind: "graph"` and heuristic `file_impacts` rows include `result_kind: "file_heuristic"` so clients can distinguish authoritative hop-depth graph results from boundary fallback hints without inferring from list position or depth values; when `truncated` is `true`, the JSON / MCP payload also exposes `truncated_reason` so callers can distinguish actionable cases from runaway-graph cases — `user_limit` means the caller-supplied `--limit` was reached and raising `--limit` will return more results, while `safety_cap` means an internal per-symbol BFS fetch-iteration cap fired (the graph is likely pathological / cyclic and raising `--limit` alone will not help). `impact` / MCP `impact_analysis` also expose `termination_reason` (`completed`, `max_depth_reached`, `cycle_detected`, `row_limit_truncated`, `safety_cap`, or `cancelled`), `cycle_detected`, and `cycles` so caller cycles are distinguishable from natural traversal completion or limit/depth termination (#1883). `safety_cap` outranks `user_limit` whenever both are encountered, and the heuristic file-level hints path is `user_limit`-only because hint truncation is always driven by the caller's `--limit`. The field is omitted whenever `truncated` is `false`. (#1533) `count` / `file_count` now describe the visible returned set while `confirmed_count` / `confirmed_file_count` preserve symbol-level caller totals for heuristic-success payloads, and `impact --json --count` uses the same `*_count` field names as the full payload; to reduce general-name collisions, a file only qualifies for type fallback if it both references one of the candidate member names and also exposes same-file evidence anchoring the source/target pair — either a `call` / `instantiate` reference to the resolved target name (the call-graph itself authoritatively pins the relationship, so this path runs before the metadata-attribute bypass and does not depend on the looser ambiguity guard) or structured type evidence through indexed symbol metadata such as signatures or return types — rather than raw comment/string text matches. The call/instantiate anchor matches the resolved name exactly with no suffix-strip alias, because callable references already carry the authoritative identifier and applying the C# `[Foo]` → `FooAttribute` alias there would let unrelated `Foo()` method calls falsely anchor `impact FooAttribute` (#1881); the metadata bypass keeps the C# `Attribute` suffix alias because attribute use sites legitimately abbreviate the target name. The signature evidence path is Unicode-aware so fullwidth/accented identifiers are tokenized consistently with exact-name resolution; hint `reference_count` reflects the real number of matching reference rows while the symbol list stays deduplicated; only multiple class-like definitions are treated as fallback ambiguity, even when they share one file; and `PurgeUnsupportedReferences` runs in all three indexing paths (CLI full scan, CLI update mode, MCP index). -- **Impact cycle identity** — On a current reference-identity graph, `impact` carries resolved source/target symbol IDs through every BFS hop and detects cycles only from actual traversed directed edges between those canonical IDs. A repeated or folded display name is never a zero-hop cycle by itself; direct recursion remains a real singleton cycle because its persisted edge has the same source and target ID. Structured caller rows expose `caller_symbol_id` / `callee_symbol_id`, `--with-paths` nodes expose `symbol_id`, and cycle rows expose `member_identities` while retaining the display-only `members` list for compatibility. Legacy graphs without the current identity contract keep the name-keyed compatibility path (#4847). +- **Impact cycle identity** — On a current reference-identity graph, `impact` carries resolved source/target symbol IDs through every BFS hop and detects cycles only from actual traversed directed edges between those canonical IDs. A repeated or folded display name is never a zero-hop cycle by itself; direct recursion remains a real singleton cycle because its persisted edge has the same source and target ID. Unresolved or ambiguous name-matched edges remain in conservative traversal output but cannot enter the canonical cycle graph, and mixed same-name target identities aggregate into one caller row without inventing a callee ID. Structured caller rows expose `caller_symbol_id` / a uniquely resolved `callee_symbol_id`, `--with-paths` nodes expose `symbol_id` only for unique identities, and cycle rows expose `member_identities` while retaining the display-only `members` list for compatibility. Legacy graphs without the current identity contract keep the name-keyed compatibility path (#4847). - **Extractor regex backtracking policy** — Built-in symbol and reference extractors must not use unbounded regular expression matching on repository-controlled file content. Backtracking regexes use `BoundedRegex.DefaultMatchTimeout`, while `RegexOptions.NonBacktracking` is allowed for patterns that are compatible with the non-backtracking engine. Patterns that deliberately remain backtracking-only, such as lookaround-heavy or balancing-group extractors, are acceptable only because the shared timeout audit covers them. If a future extractor must use `System.Text.RegularExpressions.Regex` directly, it must pass an explicit timeout and document why `BoundedRegex` or `NonBacktracking` is not suitable. - **Hybrid symbol extraction** — No AST parsers and no heavyweight language-specific dependencies. Most languages still use compiled regex patterns, while JavaScript/TypeScript add a lightweight lexer/state machine for class-body method extraction, private-scope filtering, synthetic class-expression binding detection, and JS/TS-specific range resolution that regex alone could not handle reliably. The trade-off still favors speed and portability over full parser accuracy, but the index stores richer symbol metadata such as definition ranges, optional body ranges, signatures, enclosing symbols, qualified container paths, authoritative family keys, visibility, and return types when the language patterns or JS/TS state machine can infer them. Visual Basic patterns also treat `Namespace ... End Namespace` as a real container and allow implicit-visibility declarations plus leading modifiers (`Shared`, `Overrides`, `Partial`, etc.), so VB projects expose the same top-level orientation and member coverage that other class-based languages already get. Visual Basic container patterns use case-insensitive `VisualBasicEnd` range tracking so cross-file partial families still get stable body ranges and can participate in hotspot-family grouping. **Pattern externalization**: Language patterns are currently defined inline in `SymbolExtractor.cs` using compiled `Regex` objects. This keeps the extraction pipeline self-contained and allows compile-time validation, but means adding a new language requires a code change and rebuild. A future iteration could externalize patterns to JSON/TOML files (loaded at startup), which would lower the barrier for community contributions and enable hot-reload during development. The trade-off is losing compile-time safety and slightly increasing startup cost. If externalized, patterns should include: language name, kind (function/class/import/namespace), regex string, body style (brace/indent/ruby-end/none), and optional capture group names for visibility and return type. - **Nested C# interpolation state** — The C# lexical masker keeps immutable parent frames when an interpolated regular, verbatim, or raw string starts inside another interpolation hole. Closing the nested string restores the complete outer mode, delimiter, dollar-count, and brace-depth state; expression-bodied property calls remain excluded from declaration patterns, and C# extractor contract bumps force existing indexes to refresh affected files. @@ -5023,7 +5023,7 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを exact な SQL の graph/dependency reader は解決済み segment 数も保持するため、`"sales.fn_Target"` のようなドット入り quoted single identifier が、本物の qualified name `sales.fn_Target` と exact `references` / `callers` / `impact` や集計系の `deps` / `unused` / `hotspots` で衝突しない。 - **言語考慮の参照抽出** — `references`、`callers`、`callees` は、正規表現ベースの call/reference 抽出が意味を持つ言語だけに対してインデックス化された参照テーブルで支える。未対応言語では、低信頼な疑似グラフ結果を返す代わりにテキスト検索へ戻る前提で設計する。**nested generic 呼び出し**: `new Dictionary>()` のような C#/Java のコンストラクタ呼び出しと、`Helper.DoWork>()` のような C# generic method call は、平坦な regex fast-path で `>>` を釣り合わせられなくても depth-aware fallback scanner で拾い直し、外側 target を参照テーブルへ残す。**JS/TS の no-paren constructor**: JavaScript / TypeScript の zero-argument constructor call で `()` を合法的に省略できる `new Foo;`、`new Date;`、`new Demo.Provider;`、`new Box;` も、専用の言語別経路で `instantiate` edge として出す。行末 `new Foo` に対する次行 `.bar()` / `[0]` continuation は suppress し、phantom な単独 instantiation にしない。**コンストラクタ連鎖呼び出し**: C# の `: this(...)` / `: base(...)` イニシャライザと、Java のコンストラクタ本体冒頭文 `this(...)` / `super(...)` は、汎用 call regex とは別に検出し、呼び先が実際のコンストラクタとなるように書き換える(`this` は外側の class/record、`base` / `super` は外側クラスのシグネチャから解析した基底型)。C# のクロス行イニシャライザは外側クラスではなく、そのコンストラクタに紐付ける。基底型の解析は generic 引数、record のプライマリコンストラクタ引数、`where` 制約、`global::` やドット付きの namespace 修飾を剥がす。Java の `super.method()` は通常のメソッド呼び出しのまま扱う。**型位置の依存エッジ**: C#/Java の継承リスト、宣言型、generic 制約、`throws`、`is` / `as` / `instanceof`、および C# XML doc の `cref` は `type_reference` 行として索引し、既定の `callers` / `callees` が見せる動的 call graph を汚さずに、`references` / `impact` から compile-time rename 依存を辿れるようにする。C# XML doc の `cref` 抽出は、実際に後続宣言へ結び付く XML-doc comment である `///` 行と delimited `/** ... */` block の両方を対象にしつつ、通常の `//` / `////` コメントや通常の block comment は phantom 依存として扱わない。また、同じ物理行でも closing `*/` より後ろに続く code / string の内容、doc comment と後続宣言の間へ割り込むトップレベル実行文、brace-free field/property initializer continuation、brace-free expression lambda、nested executable continuation、複数行 raw/verbatim string のうち行頭がたまたま `/**` で始まる内容は doc-comment slice の外として扱う。regex 自体は narrowed した doc-comment slice に対して走らせるが、`symbol_references.column` は元の物理ソース行位置に固定したまま保持する。C# の read path では、`using static` による constant-pattern suppress が `is` / `case` の前後の trivia を考慮してトークン単位で判定され、anchor が前行にある場合は anchor-aware な複数行コンテキストをインデックス済み行から再構成するため、`value is/*comment*/Red`、`value is\n Red or Blue`、`value is\n // comment\n Red`、`case\n // comment\n Point:`、長い `case` / `or` 連鎖、`case\tRed:` のような形でも phantom `type_reference` を漏らさない。qualified constant/member pattern は exact-name read path でも qualifier 起点で suppress するため、`case Color.Red or Color.Blue:` に対して無関係な `class Red {}` が suppress を打ち消さない。extractor 側の pending type-pattern carry も trivia-only 区切り行、standalone な continuation-line `not`、複数行 `case` head / logical continuation をまたいで維持されるため、comment-only 行や `not` だけの継続行で後続の本物の type head を落とさない。`case > 0:` や `case not > 0:` のような非型 `case` ラベルではその pending carry を armed にしないため、次行の call/identifier token が `type_reference` に混入しない。同名型の rescue も `file` 可視性を尊重し、file-local な型は同じ物理ファイル内の参照だけを救済する。基底クラスから見える protected/public/internal nested type は、基底型参照を active な型 alias / namespace alias 経由まで正規化し、さらに alias 展開後に constructed generic な基底型を再 canonicalize したうえで derived class の pattern head を救済する一方、implemented interface は inherited nested-type rescue に参加しない。さらに same-file `using Namespace;`、project-wide `global using Namespace;`、型 alias も同じ rescue 集合に入る。一方で extractor は file-local な情報だけでは同一 namespace の別ファイルにある実型を判定できないため、`value is Red` のような曖昧な unqualified `using static` head は DB に残し、pure constant-only case の抑止は workspace-aware な read path 側で行う。**SQL qualified-name alignment**: SQL の graph/dependency reader は、各 reference 行の source-line context、記録済み call 列位置、enclosing container から SQL 参照名を復元して定義と照合するため、qualified な `references` / `callers` / `impact` query は exact / non-exact を問わず sibling schema へ widen しない。source 側が genuinely unqualified な場合にだけ bare leaf fallback を許可するので、qualified call を含む `deps` / `unused` / `hotspots` も schema 単位で整合し、`EXEC dbo.fn_Target; EXEC sales.fn_Target;` のような同一行 multi-call も二重計上しない。列位置が記録されている row は、その列に qualified token が見つからなければ whole-line の別 qualified token へ昇格させないため、行末コメント・文字列リテラル・後続の別 call が先頭の unqualified edge を横取りすることもない。qualified な `callees` query でも caller query 自体が unqualified なとき以外は leaf fallback を無効化したため、`callees sales.Caller` が `dbo.Caller` へ広がらない。SQL extractor は qualified-name の `.` 前後空白も許容し、definition 系 reader は quoted qualified SQL name (`[dbo].[fn_X]` → `dbo.fn_X`) を正規化してから照合する。さらに exact SQL 定義照合は segment 数を保持し、SQL の exact graph leaf fallback は Unicode folded exact path を維持する。SQL CTE 本体内の source 行は raw `cte_body_reference` kind を使うため、`references --kind cte_body_reference` で anchor/recursive member 内部を outer query の table reference と区別できる。そのため、quoted single identifier の衝突や Unicode exact lookup の ASCII-only `NOCASE` 退行も防ぐ。exact な SQL の graph/dependency reader は解決済み segment 数も保持するため、`"sales.fn_Target"` のようなドット入り quoted single identifier が、本物の qualified name `sales.fn_Target` と exact `references` / `callers` / `impact` や集計系の `deps` / `unused` / `hotspots` で衝突しない。 - **推移的 impact analysis** — `impact` と MCP `impact_analysis` は、シンボルの推移的 caller chain を BFS で計算する。caller matching は substring expansion と大小文字差の脆さを避けるため `lower() = lower()` の大小文字非依存 exact match を使い、symbol 名は exact-case を優先して definition から事前解決し、read path は graph-supported language に限定して削除済み言語の stale edge を防ぐ。heuristic fallback が使う definition set も `--lang` / `--path` / `--exclude-path` / `--exclude-tests` と graph-supported language を尊重し、class-like definition だけを fallback 候補にするため、同名 namespace/import sibling は単一の class / struct / interface target を妨げず、純粋な non-callable `namespace` / `import` query は `non_callable_symbol_kind` guidance を返す。heuristic file-level hints は成功応答だが non-authoritative status を `impact_mode`、`heuristic`、`hint_count`、`truncated` で示し、caller rows は `result_kind: "graph"`、heuristic `file_impacts` rows は `result_kind: "file_heuristic"` を持つため、クライアントは list 位置や depth 値から推測せずに authoritative hop-depth graph 結果と境界 fallback hint を区別できる。`truncated` が `true` のときは JSON / MCP payload に `truncated_reason` も出し、`user_limit` は caller 指定の `--limit` 到達、`safety_cap` は内部の per-symbol BFS fetch-iteration cap 到達を意味する。`impact` / MCP `impact_analysis` は `termination_reason`(`completed`、`max_depth_reached`、`cycle_detected`、`row_limit_truncated`、`safety_cap`、`cancelled`)、`cycle_detected`、`cycles` も出すため、caller cycle と通常完了や limit/depth termination を区別できる(#1883)。`safety_cap` は `user_limit` より優先し、heuristic file-level hints path は caller の `--limit` だけで切り詰められるため `user_limit` のみを使う。`truncated` が `false` のとき `truncated_reason` は省略される。(#1533)`count` / `file_count` は返却された可視集合、`confirmed_count` / `confirmed_file_count` は heuristic-success payload の symbol-level caller totals を保持し、`impact --json --count` も full payload と同じ `*_count` field 名を使う。一般名の衝突を減らすため、type fallback では候補 member 名への参照に加え、同一ファイル内に source/target pair を anchor する証拠が必要になる。証拠は解決済み target 名への `call` / `instantiate` reference(この経路は call graph 自体が関係を pin するため metadata-attribute bypass より先に走り、緩い ambiguity guard に依存しない)、または signature / return type など indexed symbol metadata からの structured type evidence に限り、comment/string の raw text match は使わない。call/instantiate anchor は解決済み名を exact に照合し、suffix-strip alias は使わない。callable reference はすでに authoritative identifier を持つため、C# の `[Foo]` → `FooAttribute` alias をここへ適用すると無関係な `Foo()` method call が `impact FooAttribute` を偽 anchor できてしまうためである(#1881)。metadata bypass は attribute use site が正当に target 名を省略するため C# `Attribute` suffix alias を維持する。signature evidence path は Unicode-aware で、hint `reference_count` は実際に一致した reference row 数を表し、symbol list は deduplicate される。fallback ambiguity は同じファイル内であっても複数の class-like definition がある場合だけ扱い、`PurgeUnsupportedReferences` は CLI full scan、CLI update mode、MCP index のすべての indexing path で走る。 -- **impact cycle の identity** — 現行の reference-identity graph では、`impact` は解決済み source/target symbol ID を BFS の全 hop へ引き継ぎ、実際に走査した正規 ID 間の有向辺だけから cycle を判定する。表示名の重複や fold 一致だけではゼロホップ cycle にせず、永続化された辺の source/target ID が同一になる直接再帰は実在する singleton cycle として残す。構造化 caller row は `caller_symbol_id` / `callee_symbol_id`、`--with-paths` の node は `symbol_id`、cycle row は互換用の表示名 `members` に加えて `member_identities` を公開する。現行 identity 契約を持たない legacy graph は名前キーの互換経路を維持する(#4847)。 +- **impact cycle の identity** — 現行の reference-identity graph では、`impact` は解決済み source/target symbol ID を BFS の全 hop へ引き継ぎ、実際に走査した正規 ID 間の有向辺だけから cycle を判定する。表示名の重複や fold 一致だけではゼロホップ cycle にせず、永続化された辺の source/target ID が同一になる直接再帰は実在する singleton cycle として残す。未解決または曖昧な名前一致 edge は保守的な traversal 出力には残すが正規 cycle graph には入れず、同じ caller から複数の同名 target identity への参照は callee ID を捏造せず 1 caller row に集約する。構造化 caller row は `caller_symbol_id` / 一意に解決できた `callee_symbol_id`、`--with-paths` の node は一意な identity の場合だけ `symbol_id`、cycle row は互換用の表示名 `members` に加えて `member_identities` を公開する。現行 identity 契約を持たない legacy graph は名前キーの互換経路を維持する(#4847)。 - **構造化MCPレスポンス** — MCPツール呼び出しは `structuredContent` に型付きJSONを返し、`content` は互換性のため簡潔に保つ。 - **MCP の pre-validation rate limiting と bucket eviction** — direct な `tools/call` はすべて tool 名、enablement、argument の詳細検証前に caller-wide の固定 coarse bucket を 1 つ消費する。canonical な既知 tool 名は secondary `(tool, caller)` bucket も維持し、missing、malformed、empty、oversized、case-variant、unknown な名前は名前由来 bucket を作成しない。unknown な `batch_query` inner-slot 名は caller ごとの 1 つの固定 invalid-slot partition を共有する。`CDIDX_MCP_RATE_LIMIT_BUCKET_IDLE_SECONDS` は既定 900 秒。process-local 上限到達時は期限切れ bucket を直ちに prune する。layered acquisition は両 partition を 1 lock 内で評価し、secondary 拒否前に coarse token を消費した場合は、`retry_after_ms` が必要なすべての token refill と capacity 境界を含む。これにより malformed call の既知名ローテーションによる burst 増幅と未信頼名による cardinality 増加を防ぎ、正規の bucket 作成は通知された時刻に回復でき、過去の caller ID もプロセス寿命いっぱい保持しない(#2824 / #4547)。 - **MCP envelope レスポンス上限** — `CDIDX_MCP_RESPONSE_MAX_BYTES` は既定 10 MiB、最大 64 MiB。invalid 値は既定値へ戻し、最大超過値は stderr 警告付きでクランプするため、operator が誤って JSON-RPC response guard を実質無効化できない。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index f21a9db14..13d5ec874 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -218,7 +218,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Deps JSON and json-graph byte-limit failures share one SQL graph fixture. Deps JSON summary output and json-graph summary rejection share one SQL graph fixture. Dependency-cycle coverage must prove that the graph budget is independent of the display limit, SCC ranking remains stable when the page size grows, opaque cursors return the next ranked component, mismatched cursor filters fail closed, and graph-budget exhaustion marks totals as non-authoritative. - Impact-cycle coverage must distinguish consecutive same-display-name symbols by canonical source/target IDs, verify their structured shortest-path identities, and retain controls for direct singleton recursion and multi-node cycles. + Impact-cycle coverage must distinguish consecutive same-display-name symbols by canonical source/target IDs, verify their structured shortest-path identities, keep unresolved upstream callers out of only the canonical cycle graph, avoid guessed IDs for ambiguous path roots, aggregate mixed target identities without undercounting, and retain controls for direct singleton recursion and multi-node cycles. Exact-symbol dependency coverage reuses the C# source-candidate fixture to prove that a `--limit 1` query can select a symbol beyond the unfiltered candidate window. The repository-scale guard builds 10,001 candidate symbols, runs on non-Windows .NET 8, and keeps query-only work within 2 seconds and 32 MiB of current-thread allocation; fixture construction stays outside the measured interval. Windows retains the lightweight functional pushdown coverage but omits this scale fixture because its bounded full-suite session already runs close to the one-hour timeout. References stale-SQL-contract count and result envelopes share one downgraded graph fixture. Callers and callees stale-SQL-contract result envelopes share one downgraded graph fixture. @@ -1201,7 +1201,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" generic switch-arm のguardとrelational predecessorも同様に1つのproduction-runtime fixtureを共有し、production `net8.0` targetだけで実行してください。 search language-alias coverage は、各filterが期待結果を1件に分離できる場合、異なる言語fileを1 databaseに置いてalias filterを反復してください。 option風literalのnamed-query escapingは、definition、graph、symbols、files、inspect、impact command全体で1つのindexed Probe fixtureを再利用してください。 - impact cycle の回帰 coverage では、同じ表示名が連続する別 symbol を正規 source/target ID で区別し、構造化 shortest-path identity を検証するとともに、直接 singleton 再帰と複数 node cycle の control を維持してください。 + impact cycle の回帰 coverage では、同じ表示名が連続する別 symbol を正規 source/target ID で区別し、構造化 shortest-path identity を検証し、未解決の上流 caller を正規 cycle graph からだけ除外し、曖昧な path root に推測 ID を付けず、複数 target identity を過少計上せず集約するとともに、直接 singleton 再帰と複数 node cycle の control を維持してください。 複数 named-query の output coverage は、compact projection、rich JSON 互換性、query ごとの limit / truncation、UTF-8 byte cap に1つの indexed fixture を再利用し、serializer mode を直接比較できるようにしてください。 共通 bounded-response coverage は、definition、find、status、hotspots、references、callers / callees、impact、map 全体で1つの graph-ready databaseを再利用してください。cursor と UTF-8 byte-budget の境界 case は別の最小 multi-row fixture にまとめ、family parity のために indexing setup を重複させないでください。regression coverage では alias と read-only batch dispatch、明示的な definition body projection、inactive な impact collection、authoritative な総件数を持つ map section の row 単位 pagination も確認してください。 adversarial な bounded-response coverage では、parser failure の byte cap、impact definition page の offset、既存 map compact section、map shape control の競合、compact と明示 body の組み合わせ、profile / verbose control record の抽出も固定してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 01f268ca0..7fa2f5c15 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -739,9 +739,11 @@ return heuristic file-level dependency hints. On a current index, cycle detection follows the resolved source/target symbol IDs on real directed edges. Two distinct methods with the same display name are not a cycle, while direct recursion is reported as a singleton cycle. JSON caller rows -include `caller_symbol_id` and `callee_symbol_id`; `path_details` nodes include -`symbol_id`; and each cycle can include `member_identities` alongside the -compatibility `members` display-name list. +include `caller_symbol_id` and, when the callee is uniquely resolved, +`callee_symbol_id`; uniquely resolved `path_details` nodes include `symbol_id`; +and each cycle can include `member_identities` alongside the compatibility +`members` display-name list. Unresolved or ambiguous caller edges remain visible +in traversal results but do not contribute canonical cycle edges. ## Performance tuning for large repositories @@ -3858,9 +3860,12 @@ dependency hints が返る場合があります。 current index では、cycle 判定は実在する有向辺の解決済み source/target symbol ID を 辿ります。表示名が同じ別 method は cycle にせず、直接再帰は singleton cycle として -報告します。JSON の caller row は `caller_symbol_id` / `callee_symbol_id`、 -`path_details` node は `symbol_id` を含み、各 cycle は互換用の表示名 `members` に加えて -`member_identities` を含む場合があります。 +報告します。JSON の caller row は `caller_symbol_id` と、callee を一意に解決できる場合は +`callee_symbol_id` を含み、`path_details` node は node を一意に解決できる場合に +`symbol_id` を含み、 +各 cycle は互換用の表示名 `members` に加えて `member_identities` を含む場合があります。 +未解決または曖昧な caller edge は traversal 結果には残りますが、正規 cycle edge には +使用しません。 ## 大規模リポジトリの performance tuning diff --git a/changelog.d/unreleased/4847.fixed.md b/changelog.d/unreleased/4847.fixed.md index 308204012..1caa5e717 100644 --- a/changelog.d/unreleased/4847.fixed.md +++ b/changelog.d/unreleased/4847.fixed.md @@ -14,8 +14,8 @@ affected: ## English -- **Impact cycle detection now requires a real canonical graph edge (#4847)** — `impact` carries resolved symbol IDs through every traversal hop, so consecutive methods with the same display name no longer create false singleton cycles while direct recursion and real multi-node cycles remain visible. Structured caller, path, and cycle output now includes disambiguating symbol identities. +- **Impact cycle detection now requires a real canonical graph edge (#4847)** — `impact` carries resolved symbol IDs through every traversal hop, so consecutive methods with the same display name no longer create false singleton cycles while direct recursion and real multi-node cycles remain visible. Unresolved callers remain in conservative traversal output without entering the canonical cycle graph, mixed target identities retain their aggregate counts, and structured caller, path, and cycle output includes only identities that can be resolved without guessing. ## 日本語 -- **impact の cycle 判定で実在する正規 graph edge を必須にしました (#4847)** — `impact` が解決済み symbol ID を全 traversal hop へ引き継ぐため、表示名が同じ method が連続しても誤った singleton cycle にならず、直接再帰と実在する複数 node cycle は引き続き検出されます。構造化 caller、path、cycle 出力にも識別用 symbol identity を追加しました。 +- **impact の cycle 判定で実在する正規 graph edge を必須にしました (#4847)** — `impact` が解決済み symbol ID を全 traversal hop へ引き継ぐため、表示名が同じ method が連続しても誤った singleton cycle にならず、直接再帰と実在する複数 node cycle は引き続き検出されます。未解決 caller は保守的な traversal 出力に残したまま正規 cycle graph から除外し、複数 target identity の集約 count を維持し、構造化 caller、path、cycle 出力には推測せず解決できた identity だけを追加しました。 diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index e870e80e1..d03f7e8f2 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -1068,8 +1068,22 @@ private List GetCallersExactCore(string symbolName, int limit, int var selfReferenceSql = _referenceColumns.Contains("is_self_reference") ? "r.is_self_reference" : "0"; var mutualRecursionSql = _referenceColumns.Contains("is_mutual_recursion") ? "r.is_mutual_recursion" : "0"; var sourceSymbolIdSql = _referenceColumns.Contains("source_symbol_id") ? "r.source_symbol_id" : "NULL"; - var targetSymbolIdSql = targetSymbolId != null && HasTable("symbol_reference_candidates") - ? "@targetSymbolId" + var hasIdentityTargetScope = targetSymbolId != null + && _referenceColumns.Contains("target_symbol_id") + && _referenceColumns.Contains("resolution_state") + && HasTable("symbol_reference_candidates"); + var targetSymbolIdSql = hasIdentityTargetScope + ? @"CASE + WHEN r.resolution_state IN ('resolved', 'resolved_group') + AND EXISTS ( + SELECT 1 + FROM symbol_reference_candidates projected_identity_candidate + WHERE projected_identity_candidate.reference_id = r.id + AND projected_identity_candidate.symbol_id = @targetSymbolId + ) + THEN @targetSymbolId + ELSE r.target_symbol_id + END" : _referenceColumns.Contains("target_symbol_id") ? "r.target_symbol_id" : "NULL"; var supportedLangFilter = BuildGraphSupportedLanguagePredicate(cmd, "f", "callerLang"); @@ -1103,7 +1117,12 @@ private List GetCallersExactCore(string symbolName, int limit, int AND (r.symbol_name = @symbolName COLLATE NOCASE OR (f.lang = 'sql' AND r.symbol_name = sql_leaf_name(@symbolName) COLLATE NOCASE)" + polymorphicNameCondition + " OR (f.lang = 'solution' AND r.reference_kind = 'project_reference' AND r.container_name = @symbolName COLLATE NOCASE))" : @" AND (((f.lang = 'sql') AND sql_context_has_name_at(" + contextSql + @", @symbolName, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name = @symbolName COLLATE NOCASE) OR " + BuildCSharpQualifiedContextFallbackSql(BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: false)) + " OR " + BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false) + polymorphicNameCondition + " OR (f.lang = 'solution' AND r.reference_kind = 'project_reference' AND r.container_name = @symbolName COLLATE NOCASE))"; - var targetCondition = targetSymbolId != null && HasTable("symbol_reference_candidates") + // Resolved rows must match the requested canonical target. Unresolved/ambiguous rows + // retain the historical name-based conservative traversal, but their null target IDs + // keep them out of canonical cycle detection. + // resolved 行は要求された正規 target と一致させる。unresolved/ambiguous 行は従来の + // 名前ベース保守的 traversal に残すが、null target ID により正規 cycle 判定から除外する。 + var targetCondition = hasIdentityTargetScope ? @" AND ( EXISTS ( @@ -1112,6 +1131,10 @@ FROM symbol_reference_candidates identity_candidate WHERE identity_candidate.reference_id = r.id AND identity_candidate.symbol_id = @targetSymbolId AND r.resolution_state IN ('resolved', 'resolved_group') + ) + OR ( + COALESCE(r.resolution_state, 'unresolved') NOT IN ('resolved', 'resolved_group') + " + nameCondition + @" )" + polymorphicNameCondition + @" )" : nameCondition; @@ -1154,10 +1177,15 @@ AND r.reference_kind IN {CallGraphReferenceKindsSql} MAX(is_self_reference) AS is_self_reference, MAX(is_mutual_recursion) AS is_mutual_recursion, source_symbol_id, - target_symbol_id + CASE + WHEN COUNT(DISTINCT COALESCE(target_symbol_id, -1)) = 1 + THEN MIN(target_symbol_id) + ELSE NULL + END AS target_symbol_id, + GROUP_CONCAT(DISTINCT target_symbol_id) AS target_symbol_ids FROM logical_references r - GROUP BY path, lang, container_kind, container_name, symbol_name, reference_kind, source_symbol_id, target_symbol_id"; - sql += $" ORDER BY {GetPathBucketOrderSql("r.path")}, reference_count DESC, r.path, COALESCE(r.container_name, ''), COALESCE(r.container_kind, ''), r.symbol_name, reference_kind, first_line LIMIT @limit OFFSET @offset"; + GROUP BY path, lang, container_kind, container_name, symbol_name, reference_kind, source_symbol_id"; + sql += $" ORDER BY {GetPathBucketOrderSql("r.path")}, reference_count DESC, r.path, COALESCE(r.container_name, ''), COALESCE(r.container_kind, ''), r.symbol_name, reference_kind, first_line, COALESCE(source_symbol_id, -1) LIMIT @limit OFFSET @offset"; cmd.CommandText = sql; SqliteCommandPolicy.Add(cmd, "@symbolName", symbolName); @@ -1205,6 +1233,13 @@ FROM logical_references r HasMutualRecursion = reader.GetInt32(9) != 0, CallerSymbolId = reader.IsDBNull(10) ? null : reader.GetInt64(10), CalleeSymbolId = reader.IsDBNull(11) ? null : reader.GetInt64(11), + CalleeSymbolIds = reader.IsDBNull(12) + ? Array.Empty() + : reader.GetString(12) + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(long.Parse) + .Order() + .ToArray(), }); } return results; @@ -1366,29 +1401,27 @@ private static string BuildImpactTraversalNodeKey(long? symbolId, string name) var callerName = caller.CallerName ?? SyntheticTopLevelCallerName; var callerSymbolId = hasResolvedIdentityGraph ? caller.CallerSymbolId : null; - var calleeSymbolId = hasResolvedIdentityGraph ? caller.CalleeSymbolId ?? currentSymbolId : null; - var callerCycleNode = BuildImpactCycleNode(callerSymbolId, callerName, hasResolvedIdentityGraph); - var currentCycleNode = BuildImpactCycleNode(calleeSymbolId, currentSymbol, hasResolvedIdentityGraph); - if (callerCycleNode is { } callerNode && currentCycleNode is { } currentNode) + var calleeSymbolId = hasResolvedIdentityGraph ? caller.CalleeSymbolId : null; + var cycleEdges = BuildImpactCycleEdges(caller, callerName, currentSymbol, hasResolvedIdentityGraph); + foreach (var cycleEdge in cycleEdges) { - RegisterImpactCycleNode(cycleNodesByKey, callerNode); - RegisterImpactCycleNode(cycleNodesByKey, currentNode); - if (IsCycleEdge(callerNode.Key, currentNode.Key, cycleParentsByKey)) - AddImpactCycle(cycles, cycleKeys, BuildCycleMembers(callerNode.Key, currentNode.Key, cycleParentsByKey), cycleNodesByKey); + RegisterImpactCycleNode(cycleNodesByKey, cycleEdge.Caller); + RegisterImpactCycleNode(cycleNodesByKey, cycleEdge.Callee); + if (IsCycleEdge(cycleEdge.Caller.Key, cycleEdge.Callee.Key, cycleParentsByKey)) + AddImpactCycle(cycles, cycleKeys, BuildCycleMembers(cycleEdge.Caller.Key, cycleEdge.Callee.Key, cycleParentsByKey), cycleNodesByKey); } if (IsImpactRootCaller(caller, callerName, resolvedName, rootDefinitionPaths, identityRootSymbolId)) continue; var callerNodeKey = BuildImpactTraversalNodeKey(callerSymbolId, callerName); var key = BuildImpactVisitedKey(caller, callerName, hasResolvedIdentityGraph); - if (callerCycleNode is { } callerCycleParent - && currentCycleNode is { } currentCycleParent) + foreach (var cycleEdge in cycleEdges) { - if (!cycleParentsByKey.TryGetValue(callerCycleParent.Key, out var cycleParentSet)) + if (!cycleParentsByKey.TryGetValue(cycleEdge.Caller.Key, out var cycleParentSet)) { cycleParentSet = new HashSet(StringComparer.Ordinal); - cycleParentsByKey[callerCycleParent.Key] = cycleParentSet; + cycleParentsByKey[cycleEdge.Caller.Key] = cycleParentSet; } - cycleParentSet.Add(currentCycleParent.Key); + cycleParentSet.Add(cycleEdge.Callee.Key); } if (ImpactGraphStateEntryCount(parentsByNodeKey, cycleParentsByKey, depthByNodeKey, resultIndicesByNodeKey) > graphStateEntryBudget) { @@ -1609,6 +1642,7 @@ private static int ImpactGraphStateEntryCount( private readonly record struct ImpactBoundaryInspection(bool HasUnvisitedCaller, bool ProbeBudgetHit); private readonly record struct ImpactCycleNode(string Key, long? SymbolId, string Name); + private readonly record struct ImpactCycleEdge(ImpactCycleNode Caller, ImpactCycleNode Callee); private static void RegisterImpactCycleNode( Dictionary nodesByKey, @@ -1628,6 +1662,36 @@ private static void RegisterImpactCycleNode( return null; } + private static List BuildImpactCycleEdges( + CallerResult caller, + string callerName, + string calleeName, + bool hasResolvedIdentityGraph) + { + var callerNode = BuildImpactCycleNode(caller.CallerSymbolId, callerName, hasResolvedIdentityGraph); + if (callerNode is not { } canonicalCaller) + return []; + + if (!hasResolvedIdentityGraph) + { + var legacyCallee = BuildImpactCycleNode(symbolId: null, calleeName, hasResolvedIdentityGraph: false)!.Value; + return [new ImpactCycleEdge(canonicalCaller, legacyCallee)]; + } + + var calleeSymbolIds = caller.CalleeSymbolIds.Count > 0 + ? caller.CalleeSymbolIds + : caller.CalleeSymbolId is long calleeSymbolId + ? [calleeSymbolId] + : Array.Empty(); + return calleeSymbolIds + .Distinct() + .Order() + .Select(calleeSymbolId => new ImpactCycleEdge( + canonicalCaller, + new ImpactCycleNode($"id:{calleeSymbolId}", calleeSymbolId, calleeName))) + .ToList(); + } + private static bool IsImpactRootCaller( CallerResult caller, string callerName, @@ -1677,30 +1741,26 @@ private ImpactBoundaryInspection InspectBoundaryCallers( foreach (var caller in page) { var callerName = caller.CallerName ?? SyntheticTopLevelCallerName; - var callerSymbolId = hasResolvedIdentityGraph ? caller.CallerSymbolId : null; - var calleeSymbolId = hasResolvedIdentityGraph ? caller.CalleeSymbolId ?? symbolId : null; - var callerCycleNode = BuildImpactCycleNode(callerSymbolId, callerName, hasResolvedIdentityGraph); - var currentCycleNode = BuildImpactCycleNode(calleeSymbolId, symbolName, hasResolvedIdentityGraph); - if (callerCycleNode is { } callerNode && currentCycleNode is { } currentNode) + var cycleEdges = BuildImpactCycleEdges(caller, callerName, symbolName, hasResolvedIdentityGraph); + foreach (var cycleEdge in cycleEdges) { - RegisterImpactCycleNode(cycleNodesByKey, callerNode); - RegisterImpactCycleNode(cycleNodesByKey, currentNode); - if (IsCycleEdge(callerNode.Key, currentNode.Key, cycleParentsByKey)) - AddImpactCycle(cycles, cycleKeys, BuildCycleMembers(callerNode.Key, currentNode.Key, cycleParentsByKey), cycleNodesByKey); + RegisterImpactCycleNode(cycleNodesByKey, cycleEdge.Caller); + RegisterImpactCycleNode(cycleNodesByKey, cycleEdge.Callee); + if (IsCycleEdge(cycleEdge.Caller.Key, cycleEdge.Callee.Key, cycleParentsByKey)) + AddImpactCycle(cycles, cycleKeys, BuildCycleMembers(cycleEdge.Caller.Key, cycleEdge.Callee.Key, cycleParentsByKey), cycleNodesByKey); } var isRoot = IsImpactRootCaller(caller, callerName, resolvedName, rootDefinitionPaths, identityRootSymbolId); if (isRoot) continue; - if (callerCycleNode is { } callerCycleParent - && currentCycleNode is { } currentCycleParent) + foreach (var cycleEdge in cycleEdges) { - if (!cycleParentsByKey.TryGetValue(callerCycleParent.Key, out var cycleParentSet)) + if (!cycleParentsByKey.TryGetValue(cycleEdge.Caller.Key, out var cycleParentSet)) { cycleParentSet = new HashSet(StringComparer.Ordinal); - cycleParentsByKey[callerCycleParent.Key] = cycleParentSet; + cycleParentsByKey[cycleEdge.Caller.Key] = cycleParentSet; } - cycleParentSet.Add(currentCycleParent.Key); + cycleParentSet.Add(cycleEdge.Callee.Key); } var key = BuildImpactVisitedKey(caller, callerName, hasResolvedIdentityGraph); @@ -1917,7 +1977,8 @@ private ImpactPathNode ResolveImpactPathNode(string name, long? symbolId, string {containerNameSql} AS container_name, {containerQualifiedNameSql} AS container_qualified_name, {familyKeySql} AS family_key, - s.file_id + s.file_id, + COUNT(*) OVER () AS matching_definition_count FROM symbols s JOIN files f ON s.file_id = f.id WHERE ((@symbolId IS NOT NULL AND s.id = @symbolId) @@ -1951,10 +2012,11 @@ AND s.kind NOT IN ('import', 'namespace') var containerQualifiedName = GetNullableString(reader, 7); var familyKey = GetNullableString(reader, 8); var fileId = reader.GetInt64(9); + var matchingDefinitionCount = reader.GetInt64(10); return new ImpactPathNode { - SymbolId = definitionSymbolId, + SymbolId = symbolId ?? (matchingDefinitionCount == 1 ? definitionSymbolId : null), Name = definitionName, Kind = definitionKind, Lang = definitionLang, diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index d12f73fa5..f5e60b198 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -680,6 +680,12 @@ public class CallerResult public long? CallerSymbolId { get; set; } [JsonIgnore] public long? CalleeSymbolId { get; set; } + // Canonical cycle analysis keeps every resolved target behind an aggregated caller row; + // CalleeSymbolId remains the output-safe scalar and is null when those targets differ. + // 集約 caller 行の全 resolved target は cycle 判定用に保持し、target が異なる場合は + // output 用 scalar の CalleeSymbolId を null にする。 + [JsonIgnore] + public IReadOnlyList CalleeSymbolIds { get; set; } = Array.Empty(); // Summary preferred reference_kind for the grouped row. Grouped caller rows can // collapse multiple underlying kinds into one label, so JSON/MCP consumers that // need the full picture should read ReferenceKinds + HasMixedReferenceKinds as diff --git a/tests/CodeIndex.Tests/DbReaderImpactTests.cs b/tests/CodeIndex.Tests/DbReaderImpactTests.cs index f02d80abf..10cfc5084 100644 --- a/tests/CodeIndex.Tests/DbReaderImpactTests.cs +++ b/tests/CodeIndex.Tests/DbReaderImpactTests.cs @@ -262,6 +262,109 @@ public static void Run(int value) analysis.Callers.Select(caller => caller.FirstLine)); } + [Fact] + public void AnalyzeImpact_UnresolvedUpstreamCallerRemainsVisibleButCannotCreateCanonicalCycle_Issue4847() + { + InsertIndexedFile("src/ImpactUnresolvedHop.cs", "csharp", + """ + namespace ImpactIdentity.Unresolved; + + public static class Root + { + public static void Leaf() { } + } + + public static class Middle + { + public static void Mid() + { + Root.Leaf(); + } + } + + public static class Outer + { + public static void Top(dynamic receiver) + { + receiver.Mid(); + } + } + """); + + var analysis = _reader.AnalyzeImpact( + "ImpactIdentity.Unresolved.Root.Leaf", + maxDepth: 2, + limit: 20, + lang: "csharp", + pathPatterns: ["src/ImpactUnresolvedHop.cs"], + withPaths: true); + + Assert.False(analysis.CycleDetected); + Assert.Equal(ImpactTerminationReasons.Completed, analysis.TerminationReason); + Assert.Equal(2, analysis.Callers.Count); + var unresolvedCaller = Assert.Single(analysis.Callers.Where(caller => caller.Depth == 2)); + Assert.Equal("Top", unresolvedCaller.CallerName); + Assert.NotNull(unresolvedCaller.CallerSymbolId); + Assert.Null(unresolvedCaller.CalleeSymbolId); + Assert.Equal([new List { "Leaf", "Mid", "Top" }], unresolvedCaller.Paths); + Assert.All(Assert.Single(unresolvedCaller.PathDetails!), node => Assert.NotNull(node.SymbolId)); + } + + [Fact] + public void AnalyzeImpact_MixedTargetIdentitiesAggregateCallerWithoutGuessingPathRootIdentity_Issue4847() + { + InsertIndexedFile("src/ImpactMixedTargets.cs", "csharp", + """ + namespace ImpactIdentity.Mixed; + + public static class First + { + public static void Target() { } + } + + public static class Second + { + public static void Target() { } + } + + public static class Source + { + public static void Caller() + { + First.Target(); + Second.Target(); + } + } + """); + + var analysis = _reader.AnalyzeImpact( + "Target", + maxDepth: 1, + limit: 1, + lang: "csharp", + pathPatterns: ["src/ImpactMixedTargets.cs"], + withPaths: true); + + Assert.False(analysis.Truncated); + Assert.False(analysis.CycleDetected); + var caller = Assert.Single(analysis.Callers); + Assert.Equal("Caller", caller.CallerName); + Assert.Equal(2, caller.ReferenceCount); + Assert.Equal(2, caller.ReferenceKindCounts["call"]); + Assert.NotNull(caller.CallerSymbolId); + Assert.Null(caller.CalleeSymbolId); + var pathDetails = Assert.Single(caller.PathDetails!); + Assert.Null(pathDetails[0].SymbolId); + Assert.Equal(caller.CallerSymbolId, pathDetails[1].SymbolId); + + var structured = JsonNode.Parse(JsonSerializer.Serialize( + analysis, + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }))!; + var structuredCaller = structured["callers"]![0]!; + Assert.Null(structuredCaller["callee_symbol_id"]); + Assert.Null(structuredCaller["path_details"]![0]![0]!["symbol_id"]); + } + [Fact] public void AnalyzeImpact_DirectRecursionReportsRealSingletonCycle_Issue4847() { From f3e4e175d1c2a2f968e0eb13409b77be27fb3912 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 03:08:02 +0900 Subject: [PATCH 3/3] Keep overload groups out of impact cycles (#4847) --- DEVELOPER_GUIDE.md | 4 +- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 9 ++-- changelog.d/unreleased/4847.fixed.md | 4 +- .../Database/DbReader.GraphQueries.cs | 16 +++--- tests/CodeIndex.Tests/DbReaderImpactTests.cs | 54 +++++++++++++++++++ 6 files changed, 74 insertions(+), 17 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 71c8815eb..d1a4c16ff 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1827,7 +1827,7 @@ Process exit codes are coarse (`0` success including valid zero-row queries, `1` - **Bundled symbol analysis** — `inspect` and MCP `analyze_symbol` return definition, nearby symbols, references, callers, callees, file metadata, workspace trust metadata, and graph-support metadata in one request so AI clients can answer common symbol questions with fewer round-trips. - **Language-aware reference extraction** — `references`, `callers`, `callees`, and `impact` are backed by an indexed reference table built only for languages where regex-based call/reference extraction is meaningful. Unsupported languages intentionally fall back to text search instead of returning low-confidence pseudo-graph data. When a language is removed from graph support, `PurgeUnsupportedReferences` deletes its stale `symbol_references` rows on the next indexing run, and graph read paths additionally filter by supported languages to prevent stale edges from surviving between index runs. Shell is intentionally excluded because its command-style invocations (`foo arg1 arg2`) cannot be detected by the parenthesized-call regex. **Nested generic call sites**: C#/Java constructor calls like `new Dictionary>()` and C# generic method calls like `Helper.DoWork>()` are recovered by a depth-aware fallback scanner so the outer target still reaches the reference table even though the flat regex fast-path cannot balance `>>`. **JS/TS no-paren constructors**: JavaScript / TypeScript zero-argument constructor calls that legally omit `()` — for example `new Foo;`, `new Date;`, qualified targets like `new Demo.Provider;`, and one-level generic TypeScript forms like `new Box;` — are emitted as `instantiate` edges via a dedicated language-gated path, while next-line `.bar()` / `[0]` continuations are suppressed so a line-ended `new Foo` does not become a phantom standalone instantiation. **Constructor chain calls**: C# `: this(...)` / `: base(...)` initializers and Java `this(...)` / `super(...)` first-statement calls are detected separately from the generic call regex and rewritten so the reference target is the real constructor (enclosing class/record for `this`, the parsed base type from the class signature for `base` / `super`). Cross-line C# initializers are attributed to the owning constructor rather than the enclosing class. Base-type parsing strips generics, record primary-ctor args, `where` constraints, and `global::` / dotted namespace qualifiers; Java `super.method()` stays a normal method call. **Type-position dependency edges**: C#/Java base lists, declaration types, generic constraints, `throws`, `is`/`as`/`instanceof`, and real C# XML-doc `cref` sites are indexed as `type_reference` rows so `references` / `impact` can see compile-time rename dependencies without polluting the default dynamic call graph exposed by `callers` / `callees`. C# XML-doc `cref` extraction accepts declaration-attached XML-doc comments from both `///` lines and delimited `/** ... */` blocks, including declarations that begin later on the same physical line after the closing `*/` only when no unrelated same-line code or declaration intervenes, while ordinary `//` / `////` comments, non-documenting block comments, method-body XML-doc comments that merely precede a later declaration, brace-free field/property initializer continuations, brace-free expression lambdas, intervening top-level executable statements, same-line non-target code after `*/`, other nested executable continuations, and multiline raw/verbatim string content whose line happens to start with `/**` stay excluded. Non-doc code or string content after the closing `*/` on the same physical line is still outside the doc-comment slice. Even though the regex now runs against that narrower slice, the extractor preserves `symbol_references.column` relative to the original physical source line. On the C# read path, `using static` constant-pattern suppression is token-aware around `is` / `case`, reconstructs an anchor-aware indexed multi-line window when the anchor lives on a previous line, and keeps trivia-bearing forms such as `value is/*comment*/Red`, `value is\n Red or Blue`, `value is\n // comment\n Red`, `case\n // comment\n Point:`, long `case` / `or` chains, and `case\tRed:` filtered or rescued correctly. Qualified constant/member patterns stay qualifier-driven on that exact-name read path, so an unrelated same-name type such as `class Red {}` no longer cancels suppression for `case Color.Red or Color.Blue:` just because the leaf name matches. The extractor-side pending type-pattern carry now also survives trivia-only separator lines, standalone continuation-line `not`, and multiline `case` heads/logical continuations, so comment-only or `not`-only continuation lines no longer drop the later type head before the real token arrives. Non-type `case` labels such as `case > 0:` and `case not > 0:` do not arm that pending carry, so the next-line call/identifier token stays out of `type_reference`. Same-name type rescue also honors `file` visibility so file-local types only rescue references from the same physical file; inherited protected/public/internal nested types from real base classes rescue derived-class pattern heads only after the base reference is normalized through active type and namespace aliases, and alias-expanded constructed generic bases are canonicalized again before containing-type lookup so `AliasBase = Probe.Base` resolves the same way as `Probe.Base`; implemented interfaces do not contribute inherited nested-type rescue; and same-file `using Namespace;`, project-wide `global using Namespace;`, and active type aliases all participate in the rescue set. The extractor deliberately leaves ambiguous unqualified `using static` heads such as `value is Red` in the DB, because file-local parsing alone cannot know whether another file in the same namespace declares the real `Red` type; the workspace-aware read path is responsible for suppressing the pure constant-only cases. **SQL qualified-name alignment**: SQL definitions still persist their schema-qualified symbol name (`dbo.fn_X`), but graph/`deps`/unused/hotspot readers now resolve each SQL reference row through its stored source-line context, recorded call column, and enclosing container before they compare it to definitions, so qualified `references` / `callers` / `impact` queries stay schema-scoped even when one line contains multiple qualified calls or the lookup is non-exact. Those readers fall back to the bare leaf only when the source site itself is genuinely unqualified, which keeps `deps`, `unused`, and `hotspots` aligned with qualified SQL calls without regressing bare-call support or double-counting `EXEC dbo.fn_Target; EXEC sales.fn_Target;`. Once a row already has a recorded call column, those downstream readers no longer whole-line-upgrade that row to a later qualified token, so trailing comments, string literals, or a second qualified call cannot steal the earlier unqualified edge. Exact SQL graph/dependency readers also preserve the resolved segment count, so a quoted single identifier containing a dot such as `"sales.fn_Target"` stays distinct from the real qualified name `sales.fn_Target` across exact `references` / `callers` / `impact` and aggregate `deps` / `unused` / `hotspots`. SQL CTE body source rows use the raw `cte_body_reference` kind, so `references --kind cte_body_reference` can distinguish anchor/recursive-member internals from outer-query table references. Qualified SQL `callees` queries also keep leaf fallback disabled unless the caller query itself is unqualified, so `callees sales.Caller` no longer widens to `dbo.Caller`. SQL extractors also accept optional whitespace around qualified-name dots, so definitions/calls such as `[sales] . [fn_Target]` and `[dbo] . [fn_Target]` keep their full qualified identity instead of truncating at the first segment. The same SQL no-parens extractor now preserves ANSI / PostgreSQL double-quoted call targets such as `CALL "sales"."proc_name"` and `EXEC "dbo"."fn_Target"` instead of stripping them as string literals, while true single-quoted SQL string literals remain masked. Definition-oriented readers also canonicalize quoted qualified SQL names (`[dbo].[fn_X]` → `dbo.fn_X`) before matching, and they only fall back to the leaf identifier for unqualified queries so exact qualified lookups do not widen to sibling schemas that merely share the same leaf name. Exact SQL definition matching also preserves segment count, so a quoted single identifier that contains a dot (`"sales.fn_Target"`) does not collide with a real qualified name (`sales.fn_Target`). SQL exact graph leaf fallback also stays on the Unicode folded exact path, and both quoted qualified and unqualified Unicode exact definition lookups now use the folded normalized path, so queries such as `dbo.Äpfel` / `dbo.äpfel` and bare `Äpfel` / `äpfel` keep matching leaf call/reference rows such as `äpfel` plus stored definitions such as `[dbo].[Äpfel]` or `dbo.Äpfel` instead of silently degrading to ASCII-only `NOCASE`. Exact multi-name SQL `symbols --count` lookups also bind the folded leaf parameters on that same `_foldReady` path, so Unicode leaf query sets no longer fail with missing-parameter database errors. - **Transitive impact analysis** — `impact` and MCP `impact_analysis` compute the transitive caller chain of a symbol using BFS. Design constraints refined through adversarial review: caller matching uses case-insensitive exact match (`lower() = lower()`) to avoid both substring expansion and case-sensitivity brittleness; symbol names are pre-resolved through definitions with exact-case preference; the read path filters to graph-supported languages to prevent stale edges from removed languages; the definition set used for heuristic fallback must also respect active `--lang` / `--path` / `--exclude-path` / `--exclude-tests` filters and graph-supported languages so out-of-scope or unsupported duplicates do not suppress in-scope hints; fallback eligibility is keyed off class-like definitions only, so same-name namespace/import siblings do not block a single resolved class / struct / interface target, while pure non-callable `namespace` / `import` queries surface `non_callable_symbol_kind` guidance; heuristic file-level hints still return a successful result and encode their non-authoritative status via `impact_mode`, `heuristic`, `hint_count`, and `truncated`; caller rows include `result_kind: "graph"` and heuristic `file_impacts` rows include `result_kind: "file_heuristic"` so clients can distinguish authoritative hop-depth graph results from boundary fallback hints without inferring from list position or depth values; when `truncated` is `true`, the JSON / MCP payload also exposes `truncated_reason` so callers can distinguish actionable cases from runaway-graph cases — `user_limit` means the caller-supplied `--limit` was reached and raising `--limit` will return more results, while `safety_cap` means an internal per-symbol BFS fetch-iteration cap fired (the graph is likely pathological / cyclic and raising `--limit` alone will not help). `impact` / MCP `impact_analysis` also expose `termination_reason` (`completed`, `max_depth_reached`, `cycle_detected`, `row_limit_truncated`, `safety_cap`, or `cancelled`), `cycle_detected`, and `cycles` so caller cycles are distinguishable from natural traversal completion or limit/depth termination (#1883). `safety_cap` outranks `user_limit` whenever both are encountered, and the heuristic file-level hints path is `user_limit`-only because hint truncation is always driven by the caller's `--limit`. The field is omitted whenever `truncated` is `false`. (#1533) `count` / `file_count` now describe the visible returned set while `confirmed_count` / `confirmed_file_count` preserve symbol-level caller totals for heuristic-success payloads, and `impact --json --count` uses the same `*_count` field names as the full payload; to reduce general-name collisions, a file only qualifies for type fallback if it both references one of the candidate member names and also exposes same-file evidence anchoring the source/target pair — either a `call` / `instantiate` reference to the resolved target name (the call-graph itself authoritatively pins the relationship, so this path runs before the metadata-attribute bypass and does not depend on the looser ambiguity guard) or structured type evidence through indexed symbol metadata such as signatures or return types — rather than raw comment/string text matches. The call/instantiate anchor matches the resolved name exactly with no suffix-strip alias, because callable references already carry the authoritative identifier and applying the C# `[Foo]` → `FooAttribute` alias there would let unrelated `Foo()` method calls falsely anchor `impact FooAttribute` (#1881); the metadata bypass keeps the C# `Attribute` suffix alias because attribute use sites legitimately abbreviate the target name. The signature evidence path is Unicode-aware so fullwidth/accented identifiers are tokenized consistently with exact-name resolution; hint `reference_count` reflects the real number of matching reference rows while the symbol list stays deduplicated; only multiple class-like definitions are treated as fallback ambiguity, even when they share one file; and `PurgeUnsupportedReferences` runs in all three indexing paths (CLI full scan, CLI update mode, MCP index). -- **Impact cycle identity** — On a current reference-identity graph, `impact` carries resolved source/target symbol IDs through every BFS hop and detects cycles only from actual traversed directed edges between those canonical IDs. A repeated or folded display name is never a zero-hop cycle by itself; direct recursion remains a real singleton cycle because its persisted edge has the same source and target ID. Unresolved or ambiguous name-matched edges remain in conservative traversal output but cannot enter the canonical cycle graph, and mixed same-name target identities aggregate into one caller row without inventing a callee ID. Structured caller rows expose `caller_symbol_id` / a uniquely resolved `callee_symbol_id`, `--with-paths` nodes expose `symbol_id` only for unique identities, and cycle rows expose `member_identities` while retaining the display-only `members` list for compatibility. Legacy graphs without the current identity contract keep the name-keyed compatibility path (#4847). +- **Impact cycle identity** — On a current reference-identity graph, `impact` carries resolved source/target symbol IDs through every BFS hop and detects cycles only from actual traversed directed edges between those canonical IDs. A repeated or folded display name is never a zero-hop cycle by itself; direct recursion remains a real singleton cycle because its persisted edge has the same source and target ID. Unresolved or ambiguous name-matched edges and non-unique `resolved_group` overload candidates remain in conservative traversal output but cannot enter the canonical cycle graph, and mixed same-name target identities aggregate into one caller row without inventing a callee ID. Structured caller rows expose `caller_symbol_id` / a uniquely resolved `callee_symbol_id`, `--with-paths` nodes expose `symbol_id` only for unique identities, and cycle rows expose `member_identities` while retaining the display-only `members` list for compatibility. Legacy graphs without the current identity contract keep the name-keyed compatibility path (#4847). - **Extractor regex backtracking policy** — Built-in symbol and reference extractors must not use unbounded regular expression matching on repository-controlled file content. Backtracking regexes use `BoundedRegex.DefaultMatchTimeout`, while `RegexOptions.NonBacktracking` is allowed for patterns that are compatible with the non-backtracking engine. Patterns that deliberately remain backtracking-only, such as lookaround-heavy or balancing-group extractors, are acceptable only because the shared timeout audit covers them. If a future extractor must use `System.Text.RegularExpressions.Regex` directly, it must pass an explicit timeout and document why `BoundedRegex` or `NonBacktracking` is not suitable. - **Hybrid symbol extraction** — No AST parsers and no heavyweight language-specific dependencies. Most languages still use compiled regex patterns, while JavaScript/TypeScript add a lightweight lexer/state machine for class-body method extraction, private-scope filtering, synthetic class-expression binding detection, and JS/TS-specific range resolution that regex alone could not handle reliably. The trade-off still favors speed and portability over full parser accuracy, but the index stores richer symbol metadata such as definition ranges, optional body ranges, signatures, enclosing symbols, qualified container paths, authoritative family keys, visibility, and return types when the language patterns or JS/TS state machine can infer them. Visual Basic patterns also treat `Namespace ... End Namespace` as a real container and allow implicit-visibility declarations plus leading modifiers (`Shared`, `Overrides`, `Partial`, etc.), so VB projects expose the same top-level orientation and member coverage that other class-based languages already get. Visual Basic container patterns use case-insensitive `VisualBasicEnd` range tracking so cross-file partial families still get stable body ranges and can participate in hotspot-family grouping. **Pattern externalization**: Language patterns are currently defined inline in `SymbolExtractor.cs` using compiled `Regex` objects. This keeps the extraction pipeline self-contained and allows compile-time validation, but means adding a new language requires a code change and rebuild. A future iteration could externalize patterns to JSON/TOML files (loaded at startup), which would lower the barrier for community contributions and enable hot-reload during development. The trade-off is losing compile-time safety and slightly increasing startup cost. If externalized, patterns should include: language name, kind (function/class/import/namespace), regex string, body style (brace/indent/ruby-end/none), and optional capture group names for visibility and return type. - **Nested C# interpolation state** — The C# lexical masker keeps immutable parent frames when an interpolated regular, verbatim, or raw string starts inside another interpolation hole. Closing the nested string restores the complete outer mode, delimiter, dollar-count, and brace-depth state; expression-bodied property calls remain excluded from declaration patterns, and C# extractor contract bumps force existing indexes to refresh affected files. @@ -5023,7 +5023,7 @@ USER_GUIDEの[終了コード](USER_GUIDE.md#終了コード)セクションを exact な SQL の graph/dependency reader は解決済み segment 数も保持するため、`"sales.fn_Target"` のようなドット入り quoted single identifier が、本物の qualified name `sales.fn_Target` と exact `references` / `callers` / `impact` や集計系の `deps` / `unused` / `hotspots` で衝突しない。 - **言語考慮の参照抽出** — `references`、`callers`、`callees` は、正規表現ベースの call/reference 抽出が意味を持つ言語だけに対してインデックス化された参照テーブルで支える。未対応言語では、低信頼な疑似グラフ結果を返す代わりにテキスト検索へ戻る前提で設計する。**nested generic 呼び出し**: `new Dictionary>()` のような C#/Java のコンストラクタ呼び出しと、`Helper.DoWork>()` のような C# generic method call は、平坦な regex fast-path で `>>` を釣り合わせられなくても depth-aware fallback scanner で拾い直し、外側 target を参照テーブルへ残す。**JS/TS の no-paren constructor**: JavaScript / TypeScript の zero-argument constructor call で `()` を合法的に省略できる `new Foo;`、`new Date;`、`new Demo.Provider;`、`new Box;` も、専用の言語別経路で `instantiate` edge として出す。行末 `new Foo` に対する次行 `.bar()` / `[0]` continuation は suppress し、phantom な単独 instantiation にしない。**コンストラクタ連鎖呼び出し**: C# の `: this(...)` / `: base(...)` イニシャライザと、Java のコンストラクタ本体冒頭文 `this(...)` / `super(...)` は、汎用 call regex とは別に検出し、呼び先が実際のコンストラクタとなるように書き換える(`this` は外側の class/record、`base` / `super` は外側クラスのシグネチャから解析した基底型)。C# のクロス行イニシャライザは外側クラスではなく、そのコンストラクタに紐付ける。基底型の解析は generic 引数、record のプライマリコンストラクタ引数、`where` 制約、`global::` やドット付きの namespace 修飾を剥がす。Java の `super.method()` は通常のメソッド呼び出しのまま扱う。**型位置の依存エッジ**: C#/Java の継承リスト、宣言型、generic 制約、`throws`、`is` / `as` / `instanceof`、および C# XML doc の `cref` は `type_reference` 行として索引し、既定の `callers` / `callees` が見せる動的 call graph を汚さずに、`references` / `impact` から compile-time rename 依存を辿れるようにする。C# XML doc の `cref` 抽出は、実際に後続宣言へ結び付く XML-doc comment である `///` 行と delimited `/** ... */` block の両方を対象にしつつ、通常の `//` / `////` コメントや通常の block comment は phantom 依存として扱わない。また、同じ物理行でも closing `*/` より後ろに続く code / string の内容、doc comment と後続宣言の間へ割り込むトップレベル実行文、brace-free field/property initializer continuation、brace-free expression lambda、nested executable continuation、複数行 raw/verbatim string のうち行頭がたまたま `/**` で始まる内容は doc-comment slice の外として扱う。regex 自体は narrowed した doc-comment slice に対して走らせるが、`symbol_references.column` は元の物理ソース行位置に固定したまま保持する。C# の read path では、`using static` による constant-pattern suppress が `is` / `case` の前後の trivia を考慮してトークン単位で判定され、anchor が前行にある場合は anchor-aware な複数行コンテキストをインデックス済み行から再構成するため、`value is/*comment*/Red`、`value is\n Red or Blue`、`value is\n // comment\n Red`、`case\n // comment\n Point:`、長い `case` / `or` 連鎖、`case\tRed:` のような形でも phantom `type_reference` を漏らさない。qualified constant/member pattern は exact-name read path でも qualifier 起点で suppress するため、`case Color.Red or Color.Blue:` に対して無関係な `class Red {}` が suppress を打ち消さない。extractor 側の pending type-pattern carry も trivia-only 区切り行、standalone な continuation-line `not`、複数行 `case` head / logical continuation をまたいで維持されるため、comment-only 行や `not` だけの継続行で後続の本物の type head を落とさない。`case > 0:` や `case not > 0:` のような非型 `case` ラベルではその pending carry を armed にしないため、次行の call/identifier token が `type_reference` に混入しない。同名型の rescue も `file` 可視性を尊重し、file-local な型は同じ物理ファイル内の参照だけを救済する。基底クラスから見える protected/public/internal nested type は、基底型参照を active な型 alias / namespace alias 経由まで正規化し、さらに alias 展開後に constructed generic な基底型を再 canonicalize したうえで derived class の pattern head を救済する一方、implemented interface は inherited nested-type rescue に参加しない。さらに same-file `using Namespace;`、project-wide `global using Namespace;`、型 alias も同じ rescue 集合に入る。一方で extractor は file-local な情報だけでは同一 namespace の別ファイルにある実型を判定できないため、`value is Red` のような曖昧な unqualified `using static` head は DB に残し、pure constant-only case の抑止は workspace-aware な read path 側で行う。**SQL qualified-name alignment**: SQL の graph/dependency reader は、各 reference 行の source-line context、記録済み call 列位置、enclosing container から SQL 参照名を復元して定義と照合するため、qualified な `references` / `callers` / `impact` query は exact / non-exact を問わず sibling schema へ widen しない。source 側が genuinely unqualified な場合にだけ bare leaf fallback を許可するので、qualified call を含む `deps` / `unused` / `hotspots` も schema 単位で整合し、`EXEC dbo.fn_Target; EXEC sales.fn_Target;` のような同一行 multi-call も二重計上しない。列位置が記録されている row は、その列に qualified token が見つからなければ whole-line の別 qualified token へ昇格させないため、行末コメント・文字列リテラル・後続の別 call が先頭の unqualified edge を横取りすることもない。qualified な `callees` query でも caller query 自体が unqualified なとき以外は leaf fallback を無効化したため、`callees sales.Caller` が `dbo.Caller` へ広がらない。SQL extractor は qualified-name の `.` 前後空白も許容し、definition 系 reader は quoted qualified SQL name (`[dbo].[fn_X]` → `dbo.fn_X`) を正規化してから照合する。さらに exact SQL 定義照合は segment 数を保持し、SQL の exact graph leaf fallback は Unicode folded exact path を維持する。SQL CTE 本体内の source 行は raw `cte_body_reference` kind を使うため、`references --kind cte_body_reference` で anchor/recursive member 内部を outer query の table reference と区別できる。そのため、quoted single identifier の衝突や Unicode exact lookup の ASCII-only `NOCASE` 退行も防ぐ。exact な SQL の graph/dependency reader は解決済み segment 数も保持するため、`"sales.fn_Target"` のようなドット入り quoted single identifier が、本物の qualified name `sales.fn_Target` と exact `references` / `callers` / `impact` や集計系の `deps` / `unused` / `hotspots` で衝突しない。 - **推移的 impact analysis** — `impact` と MCP `impact_analysis` は、シンボルの推移的 caller chain を BFS で計算する。caller matching は substring expansion と大小文字差の脆さを避けるため `lower() = lower()` の大小文字非依存 exact match を使い、symbol 名は exact-case を優先して definition から事前解決し、read path は graph-supported language に限定して削除済み言語の stale edge を防ぐ。heuristic fallback が使う definition set も `--lang` / `--path` / `--exclude-path` / `--exclude-tests` と graph-supported language を尊重し、class-like definition だけを fallback 候補にするため、同名 namespace/import sibling は単一の class / struct / interface target を妨げず、純粋な non-callable `namespace` / `import` query は `non_callable_symbol_kind` guidance を返す。heuristic file-level hints は成功応答だが non-authoritative status を `impact_mode`、`heuristic`、`hint_count`、`truncated` で示し、caller rows は `result_kind: "graph"`、heuristic `file_impacts` rows は `result_kind: "file_heuristic"` を持つため、クライアントは list 位置や depth 値から推測せずに authoritative hop-depth graph 結果と境界 fallback hint を区別できる。`truncated` が `true` のときは JSON / MCP payload に `truncated_reason` も出し、`user_limit` は caller 指定の `--limit` 到達、`safety_cap` は内部の per-symbol BFS fetch-iteration cap 到達を意味する。`impact` / MCP `impact_analysis` は `termination_reason`(`completed`、`max_depth_reached`、`cycle_detected`、`row_limit_truncated`、`safety_cap`、`cancelled`)、`cycle_detected`、`cycles` も出すため、caller cycle と通常完了や limit/depth termination を区別できる(#1883)。`safety_cap` は `user_limit` より優先し、heuristic file-level hints path は caller の `--limit` だけで切り詰められるため `user_limit` のみを使う。`truncated` が `false` のとき `truncated_reason` は省略される。(#1533)`count` / `file_count` は返却された可視集合、`confirmed_count` / `confirmed_file_count` は heuristic-success payload の symbol-level caller totals を保持し、`impact --json --count` も full payload と同じ `*_count` field 名を使う。一般名の衝突を減らすため、type fallback では候補 member 名への参照に加え、同一ファイル内に source/target pair を anchor する証拠が必要になる。証拠は解決済み target 名への `call` / `instantiate` reference(この経路は call graph 自体が関係を pin するため metadata-attribute bypass より先に走り、緩い ambiguity guard に依存しない)、または signature / return type など indexed symbol metadata からの structured type evidence に限り、comment/string の raw text match は使わない。call/instantiate anchor は解決済み名を exact に照合し、suffix-strip alias は使わない。callable reference はすでに authoritative identifier を持つため、C# の `[Foo]` → `FooAttribute` alias をここへ適用すると無関係な `Foo()` method call が `impact FooAttribute` を偽 anchor できてしまうためである(#1881)。metadata bypass は attribute use site が正当に target 名を省略するため C# `Attribute` suffix alias を維持する。signature evidence path は Unicode-aware で、hint `reference_count` は実際に一致した reference row 数を表し、symbol list は deduplicate される。fallback ambiguity は同じファイル内であっても複数の class-like definition がある場合だけ扱い、`PurgeUnsupportedReferences` は CLI full scan、CLI update mode、MCP index のすべての indexing path で走る。 -- **impact cycle の identity** — 現行の reference-identity graph では、`impact` は解決済み source/target symbol ID を BFS の全 hop へ引き継ぎ、実際に走査した正規 ID 間の有向辺だけから cycle を判定する。表示名の重複や fold 一致だけではゼロホップ cycle にせず、永続化された辺の source/target ID が同一になる直接再帰は実在する singleton cycle として残す。未解決または曖昧な名前一致 edge は保守的な traversal 出力には残すが正規 cycle graph には入れず、同じ caller から複数の同名 target identity への参照は callee ID を捏造せず 1 caller row に集約する。構造化 caller row は `caller_symbol_id` / 一意に解決できた `callee_symbol_id`、`--with-paths` の node は一意な identity の場合だけ `symbol_id`、cycle row は互換用の表示名 `members` に加えて `member_identities` を公開する。現行 identity 契約を持たない legacy graph は名前キーの互換経路を維持する(#4847)。 +- **impact cycle の identity** — 現行の reference-identity graph では、`impact` は解決済み source/target symbol ID を BFS の全 hop へ引き継ぎ、実際に走査した正規 ID 間の有向辺だけから cycle を判定する。表示名の重複や fold 一致だけではゼロホップ cycle にせず、永続化された辺の source/target ID が同一になる直接再帰は実在する singleton cycle として残す。未解決または曖昧な名前一致 edge と、一意でない `resolved_group` の overload 候補は保守的な traversal 出力には残すが正規 cycle graph には入れず、同じ caller から複数の同名 target identity への参照は callee ID を捏造せず 1 caller row に集約する。構造化 caller row は `caller_symbol_id` / 一意に解決できた `callee_symbol_id`、`--with-paths` の node は一意な identity の場合だけ `symbol_id`、cycle row は互換用の表示名 `members` に加えて `member_identities` を公開する。現行 identity 契約を持たない legacy graph は名前キーの互換経路を維持する(#4847)。 - **構造化MCPレスポンス** — MCPツール呼び出しは `structuredContent` に型付きJSONを返し、`content` は互換性のため簡潔に保つ。 - **MCP の pre-validation rate limiting と bucket eviction** — direct な `tools/call` はすべて tool 名、enablement、argument の詳細検証前に caller-wide の固定 coarse bucket を 1 つ消費する。canonical な既知 tool 名は secondary `(tool, caller)` bucket も維持し、missing、malformed、empty、oversized、case-variant、unknown な名前は名前由来 bucket を作成しない。unknown な `batch_query` inner-slot 名は caller ごとの 1 つの固定 invalid-slot partition を共有する。`CDIDX_MCP_RATE_LIMIT_BUCKET_IDLE_SECONDS` は既定 900 秒。process-local 上限到達時は期限切れ bucket を直ちに prune する。layered acquisition は両 partition を 1 lock 内で評価し、secondary 拒否前に coarse token を消費した場合は、`retry_after_ms` が必要なすべての token refill と capacity 境界を含む。これにより malformed call の既知名ローテーションによる burst 増幅と未信頼名による cardinality 増加を防ぎ、正規の bucket 作成は通知された時刻に回復でき、過去の caller ID もプロセス寿命いっぱい保持しない(#2824 / #4547)。 - **MCP envelope レスポンス上限** — `CDIDX_MCP_RESPONSE_MAX_BYTES` は既定 10 MiB、最大 64 MiB。invalid 値は既定値へ戻し、最大超過値は stderr 警告付きでクランプするため、operator が誤って JSON-RPC response guard を実質無効化できない。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 13d5ec874..bc6d937de 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -218,7 +218,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Deps JSON and json-graph byte-limit failures share one SQL graph fixture. Deps JSON summary output and json-graph summary rejection share one SQL graph fixture. Dependency-cycle coverage must prove that the graph budget is independent of the display limit, SCC ranking remains stable when the page size grows, opaque cursors return the next ranked component, mismatched cursor filters fail closed, and graph-budget exhaustion marks totals as non-authoritative. - Impact-cycle coverage must distinguish consecutive same-display-name symbols by canonical source/target IDs, verify their structured shortest-path identities, keep unresolved upstream callers out of only the canonical cycle graph, avoid guessed IDs for ambiguous path roots, aggregate mixed target identities without undercounting, and retain controls for direct singleton recursion and multi-node cycles. + Impact-cycle coverage must distinguish consecutive same-display-name symbols by canonical source/target IDs, verify their structured shortest-path identities, keep unresolved upstream callers and non-unique resolved overload groups out of only the canonical cycle graph, avoid guessed IDs for ambiguous path roots, aggregate mixed target identities without undercounting, and retain controls for direct singleton recursion and multi-node cycles. Exact-symbol dependency coverage reuses the C# source-candidate fixture to prove that a `--limit 1` query can select a symbol beyond the unfiltered candidate window. The repository-scale guard builds 10,001 candidate symbols, runs on non-Windows .NET 8, and keeps query-only work within 2 seconds and 32 MiB of current-thread allocation; fixture construction stays outside the measured interval. Windows retains the lightweight functional pushdown coverage but omits this scale fixture because its bounded full-suite session already runs close to the one-hour timeout. References stale-SQL-contract count and result envelopes share one downgraded graph fixture. Callers and callees stale-SQL-contract result envelopes share one downgraded graph fixture. @@ -1201,7 +1201,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" generic switch-arm のguardとrelational predecessorも同様に1つのproduction-runtime fixtureを共有し、production `net8.0` targetだけで実行してください。 search language-alias coverage は、各filterが期待結果を1件に分離できる場合、異なる言語fileを1 databaseに置いてalias filterを反復してください。 option風literalのnamed-query escapingは、definition、graph、symbols、files、inspect、impact command全体で1つのindexed Probe fixtureを再利用してください。 - impact cycle の回帰 coverage では、同じ表示名が連続する別 symbol を正規 source/target ID で区別し、構造化 shortest-path identity を検証し、未解決の上流 caller を正規 cycle graph からだけ除外し、曖昧な path root に推測 ID を付けず、複数 target identity を過少計上せず集約するとともに、直接 singleton 再帰と複数 node cycle の control を維持してください。 + impact cycle の回帰 coverage では、同じ表示名が連続する別 symbol を正規 source/target ID で区別し、構造化 shortest-path identity を検証し、未解決の上流 caller と一意でない resolved overload group を正規 cycle graph からだけ除外し、曖昧な path root に推測 ID を付けず、複数 target identity を過少計上せず集約するとともに、直接 singleton 再帰と複数 node cycle の control を維持してください。 複数 named-query の output coverage は、compact projection、rich JSON 互換性、query ごとの limit / truncation、UTF-8 byte cap に1つの indexed fixture を再利用し、serializer mode を直接比較できるようにしてください。 共通 bounded-response coverage は、definition、find、status、hotspots、references、callers / callees、impact、map 全体で1つの graph-ready databaseを再利用してください。cursor と UTF-8 byte-budget の境界 case は別の最小 multi-row fixture にまとめ、family parity のために indexing setup を重複させないでください。regression coverage では alias と read-only batch dispatch、明示的な definition body projection、inactive な impact collection、authoritative な総件数を持つ map section の row 単位 pagination も確認してください。 adversarial な bounded-response coverage では、parser failure の byte cap、impact definition page の offset、既存 map compact section、map shape control の競合、compact と明示 body の組み合わせ、profile / verbose control record の抽出も固定してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 7fa2f5c15..80c2fad85 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -742,8 +742,9 @@ cycle, while direct recursion is reported as a singleton cycle. JSON caller rows include `caller_symbol_id` and, when the callee is uniquely resolved, `callee_symbol_id`; uniquely resolved `path_details` nodes include `symbol_id`; and each cycle can include `member_identities` alongside the compatibility -`members` display-name list. Unresolved or ambiguous caller edges remain visible -in traversal results but do not contribute canonical cycle edges. +`members` display-name list. Unresolved or ambiguous caller edges, including +non-unique overload resolution groups, remain visible in traversal results but do +not contribute canonical cycle edges. ## Performance tuning for large repositories @@ -3864,8 +3865,8 @@ current index では、cycle 判定は実在する有向辺の解決済み sourc `callee_symbol_id` を含み、`path_details` node は node を一意に解決できる場合に `symbol_id` を含み、 各 cycle は互換用の表示名 `members` に加えて `member_identities` を含む場合があります。 -未解決または曖昧な caller edge は traversal 結果には残りますが、正規 cycle edge には -使用しません。 +一意でない overload の resolution group を含む未解決または曖昧な caller edge は +traversal 結果には残りますが、正規 cycle edge には使用しません。 ## 大規模リポジトリの performance tuning diff --git a/changelog.d/unreleased/4847.fixed.md b/changelog.d/unreleased/4847.fixed.md index 1caa5e717..4c7ebd66f 100644 --- a/changelog.d/unreleased/4847.fixed.md +++ b/changelog.d/unreleased/4847.fixed.md @@ -14,8 +14,8 @@ affected: ## English -- **Impact cycle detection now requires a real canonical graph edge (#4847)** — `impact` carries resolved symbol IDs through every traversal hop, so consecutive methods with the same display name no longer create false singleton cycles while direct recursion and real multi-node cycles remain visible. Unresolved callers remain in conservative traversal output without entering the canonical cycle graph, mixed target identities retain their aggregate counts, and structured caller, path, and cycle output includes only identities that can be resolved without guessing. +- **Impact cycle detection now requires a real canonical graph edge (#4847)** — `impact` carries resolved symbol IDs through every traversal hop, so consecutive methods with the same display name no longer create false singleton cycles while direct recursion and real multi-node cycles remain visible. Unresolved callers and non-unique overload resolution groups remain in conservative traversal output without entering the canonical cycle graph, mixed target identities retain their aggregate counts, and structured caller, path, and cycle output includes only identities that can be resolved without guessing. ## 日本語 -- **impact の cycle 判定で実在する正規 graph edge を必須にしました (#4847)** — `impact` が解決済み symbol ID を全 traversal hop へ引き継ぐため、表示名が同じ method が連続しても誤った singleton cycle にならず、直接再帰と実在する複数 node cycle は引き続き検出されます。未解決 caller は保守的な traversal 出力に残したまま正規 cycle graph から除外し、複数 target identity の集約 count を維持し、構造化 caller、path、cycle 出力には推測せず解決できた identity だけを追加しました。 +- **impact の cycle 判定で実在する正規 graph edge を必須にしました (#4847)** — `impact` が解決済み symbol ID を全 traversal hop へ引き継ぐため、表示名が同じ method が連続しても誤った singleton cycle にならず、直接再帰と実在する複数 node cycle は引き続き検出されます。未解決 caller と一意でない overload resolution group は保守的な traversal 出力に残したまま正規 cycle graph から除外し、複数 target identity の集約 count を維持し、構造化 caller、path、cycle 出力には推測せず解決できた identity だけを追加しました。 diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index d03f7e8f2..e4e15b860 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -1074,7 +1074,7 @@ private List GetCallersExactCore(string symbolName, int limit, int && HasTable("symbol_reference_candidates"); var targetSymbolIdSql = hasIdentityTargetScope ? @"CASE - WHEN r.resolution_state IN ('resolved', 'resolved_group') + WHEN r.resolution_state = 'resolved' AND EXISTS ( SELECT 1 FROM symbol_reference_candidates projected_identity_candidate @@ -1082,7 +1082,7 @@ FROM symbol_reference_candidates projected_identity_candidate AND projected_identity_candidate.symbol_id = @targetSymbolId ) THEN @targetSymbolId - ELSE r.target_symbol_id + ELSE NULL END" : _referenceColumns.Contains("target_symbol_id") ? "r.target_symbol_id" : "NULL"; @@ -1117,11 +1117,13 @@ ELSE r.target_symbol_id AND (r.symbol_name = @symbolName COLLATE NOCASE OR (f.lang = 'sql' AND r.symbol_name = sql_leaf_name(@symbolName) COLLATE NOCASE)" + polymorphicNameCondition + " OR (f.lang = 'solution' AND r.reference_kind = 'project_reference' AND r.container_name = @symbolName COLLATE NOCASE))" : @" AND (((f.lang = 'sql') AND sql_context_has_name_at(" + contextSql + @", @symbolName, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name = @symbolName COLLATE NOCASE) OR " + BuildCSharpQualifiedContextFallbackSql(BuildQualifiedContextMatchSql(contextSql, "r.column_number", folded: false, like: false)) + " OR " + BuildQualifiedLeafFallbackSql("r.symbol_name", "r.symbol_name_folded", folded: false) + polymorphicNameCondition + " OR (f.lang = 'solution' AND r.reference_kind = 'project_reference' AND r.container_name = @symbolName COLLATE NOCASE))"; - // Resolved rows must match the requested canonical target. Unresolved/ambiguous rows - // retain the historical name-based conservative traversal, but their null target IDs - // keep them out of canonical cycle detection. - // resolved 行は要求された正規 target と一致させる。unresolved/ambiguous 行は従来の - // 名前ベース保守的 traversal に残すが、null target ID により正規 cycle 判定から除外する。 + // Resolved rows must match the requested canonical target. Resolution groups can match + // a candidate for conservative traversal, but only uniquely resolved rows project that + // candidate as an actual cycle edge. Unresolved/ambiguous rows retain the historical + // name-based traversal and likewise keep null target IDs. + // resolved 行は要求された正規 target と一致させる。resolution group は保守的 traversal + // の候補にはできるが、一意に resolved した行だけが実際の cycle edge として候補 ID を + // 公開する。unresolved/ambiguous 行も従来の名前ベース探索に残し、target ID は null にする。 var targetCondition = hasIdentityTargetScope ? @" AND ( diff --git a/tests/CodeIndex.Tests/DbReaderImpactTests.cs b/tests/CodeIndex.Tests/DbReaderImpactTests.cs index 10cfc5084..32312379d 100644 --- a/tests/CodeIndex.Tests/DbReaderImpactTests.cs +++ b/tests/CodeIndex.Tests/DbReaderImpactTests.cs @@ -262,6 +262,60 @@ public static void Run(int value) analysis.Callers.Select(caller => caller.FirstLine)); } + [Fact] + public void AnalyzeImpact_ResolvedGroupOverloadChainDoesNotReportSingletonCycle_Issue4847() + { + // A resolved_group candidate is a possible overload target, not proof of an edge to + // every candidate. Revisiting the caller overload must therefore stay out of the + // canonical cycle graph while conservative traversal remains available. + // resolved_group の候補は overload の可能性であり、全候補への実辺を証明しない。 + // caller overload への再到達は保守的に探索しても、正規 cycle graph には入れない。 + InsertIndexedFile("src/ImpactOverloadChain.cs", "csharp", + """ + public static class ImpactOverloadChain + { + public static void Leaf() { } + + public static void Run() + { + Leaf(); + } + + public static void Run(int value) + { + Run(); + } + } + """); + + var analysis = _reader.AnalyzeImpact( + "ImpactOverloadChain.Leaf", + maxDepth: 4, + limit: 20, + lang: "csharp", + pathPatterns: ["src/ImpactOverloadChain.cs"], + withPaths: true); + + Assert.False(analysis.Truncated); + Assert.False(analysis.CycleDetected); + Assert.Null(analysis.Cycles); + Assert.Equal(ImpactTerminationReasons.Completed, analysis.TerminationReason); + Assert.Equal(2, analysis.Callers.Count); + + var overloadCaller = Assert.Single(analysis.Callers.Where(caller => caller.Depth == 2)); + Assert.Equal("Run", overloadCaller.CallerName); + Assert.Equal("Run", overloadCaller.CalleeName); + Assert.NotNull(overloadCaller.CallerSymbolId); + Assert.Null(overloadCaller.CalleeSymbolId); + Assert.Equal([new List { "Leaf", "Run", "Run" }], overloadCaller.Paths); + Assert.Equal( + 3, + Assert.Single(overloadCaller.PathDetails!) + .Select(node => node.SymbolId) + .Distinct() + .Count()); + } + [Fact] public void AnalyzeImpact_UnresolvedUpstreamCallerRemainsVisibleButCannotCreateCanonicalCycle_Issue4847() {