diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 35e68a5c2..dd23da485 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1746,6 +1746,19 @@ language across reference/caller/callee evidence. `graph_language_source`, `graph_language_confidence`, `graph_language_candidates`, and `graph_language_conflict` distinguish authoritative filter/definition decisions from consistent inference and keep mixed-language evidence unresolved. +CLI `inspect --selector 'id:@g:'` validates the emitted index generation, resolves +the persisted ID directly in the active database, and then enters this same candidate-bundle path. +Keep selector parsing in a typed extensible model, +reject non-positive/malformed IDs before opening graph state, and report an absent database-local +ID or a stale/cross-database generation as `E018_QUERY_NOT_FOUND`. The unversioned `id:` form +remains a same-database compatibility input but cannot prove generation provenance. +`identity_scoped` must describe evidence precision, not merely schema +availability: if an inbound reference still has more than one resolution candidate, emit +`identity_scope_reason: ambiguous_reference_candidates` and keep the candidate rows visible while +marking the bundle non-identity-scoped. C# call resolution may narrow ordinary required-parameter +overloads by positional argument count only. Named arguments, optional/default parameters, +`params`, generic method inference, and incomplete syntax remain ambiguous. Extension receiver +adjustment and dynamic receiver types are outside this arity helper. Path/line resolution must select `symbols.id` and enter the same candidate-bundle builder as name resolution; do not hand graph loaders only the display name. Each bounded references, callers, and callees section computes its own stable-order page and authoritative total. @@ -5707,6 +5720,18 @@ reference/caller/callee query は `symbol_reference_candidates` または 言語を推論します。`graph_language_source`、`graph_language_confidence`、 `graph_language_candidates`、`graph_language_conflict` により、filter/definition による authoritative な判定と一貫した推論を区別し、複数言語の evidence は未確定のままにします。 +CLI `inspect --selector 'id:@g:'` は出力時の index generation を検証し、active +database の永続 ID を直接解決してから同じ candidate-bundle 経路へ入ります。selector parser は型付きで拡張可能な model に保ち、正でない +ID や不正形式は graph state を開く前に拒否し、database-local ID が存在しない場合は +もちろん、stale / cross-database generation の場合も `E018_QUERY_NOT_FOUND` を返してください。 +generation なしの `id:` は same-database 互換入力として維持しますが、generation provenance +は証明できません。`identity_scoped` は schema の利用可否だけでなく +evidence の精度を表します。inbound reference に複数の resolution candidate が残る場合は +candidate row を維持しつつ bundle を non-identity-scoped とし、 +`identity_scope_reason: ambiguous_reference_candidates` を出力します。C# call は通常の required +parameter overload を位置引数個数だけで絞り込めます。named argument、optional/default parameter、 +`params`、generic method inference、不完全な構文は曖昧なままにします。extension の receiver +調整と dynamic receiver の型は、この arity helper の対象外です。 path/line resolution は `symbols.id` を select し、name resolution と同じ candidate-bundle builder に入れてください。graph loader に display name だけを渡しては なりません。上限付きの references、callers、callees section は、それぞれ安定順序の page と diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 44da4a07b..777fe5a4f 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -879,6 +879,7 @@ Use the inventory below before adding or moving a test class: - Console-only test classes belong in the dedicated non-parallel console-sensitive collection once every capture/swap window, including writer-disposal checks, is protected by `ConsoleCapture` or `TestConsoleLock.Gate`; they do not also need the SQLite-sensitive collection. - Pure i18n resolution, self-locking JSON-envelope capture, and isolated LSP request/budget fixtures should remain outside the SQLite-sensitive collection; owning a temporary DB is not itself process-global state when the context is disposed before helper cleanup. - Independent C# query regressions for static lambdas, declaration continuations, and named-argument labels use standalone test classes plus `QueryCommandTestSupport`; do not fold them back into the SQLite-pool-sensitive `QueryCommandRunnerTests` partial class. +- Inspect-selector overload regressions also stay standalone with `QueryCommandTestSupport`. Cover emitted-selector round trips, candidate-scoped pagination, missing/malformed/stale/cross-database IDs, distinct required-arity calls, and truthful ambiguity for named/generic/optional/`params` cases in isolated indexed fixtures. - JSON compatibility-alias and versioned-error query regressions also use standalone classes and the same self-locking support so their independent temporary databases can run outside the pool-sensitive collection. - Search fixture classification and count-mode guard-filter regressions are standalone for the same reason; their per-test databases and self-locking console captures do not require SQLite pool serialization. - Status hotspot-readiness regressions are standalone and reuse the shared partial-type database fixture from `QueryCommandTestSupport`; keep the fixture centralized even though the status class runs outside the pool-sensitive collection. @@ -1998,6 +1999,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - console だけを扱う test class は、writer disposal check を含むすべての capture / swap 期間を `ConsoleCapture` または `TestConsoleLock.Gate` で保護したうえで、専用の non-parallel な console-sensitive collection に入れる。SQLite-sensitive collection にも入れる必要はない。 - pure i18n resolution、内部で lock する JSON-envelope capture、独立した LSP request / budget fixture は SQLite-sensitive collection の外に保つ。一時 DB を所有するだけなら、context を helper cleanup 前に dispose している限り process-global state ではない。 - static lambda、宣言 continuation、named-argument label の独立した C# query regression は standalone test class と `QueryCommandTestSupport` を使います。SQLite-pool-sensitive な `QueryCommandRunnerTests` partial class に戻さないでください。 +- inspect selector の overload regression も `QueryCommandTestSupport` を使う standalone class に保ちます。出力 selector の往復、candidate-scoped pagination、存在しない / 不正 / stale / cross-database の ID、required arity が異なる call、named / generic / optional / `params` の正直な曖昧性を、独立した indexed fixture で検証してください。 - JSON compatibility-alias と versioned-error の query regression も standalone class と同じ self-locking support を使い、独立した一時 database を pool-sensitive collection の外で実行してください。 - search fixture classification と count-mode guard-filter の regression も同じ理由で standalone にします。test ごとの database と self-locking console capture は SQLite pool の直列化を必要としません。 - status hotspot-readiness regression は standalone とし、共有 partial-type database fixture を `QueryCommandTestSupport` から再利用します。status class を pool-sensitive collection の外で実行しても fixture は一元化してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 3a808736a..d473915e6 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -338,7 +338,17 @@ traverse a combined identity graph; narrow it with language or path filters. `inspect` and MCP `analyze_symbol` return `candidate_bundles` when a name resolves to indexed definitions. Each bundle is labeled with a stable selector containing the symbol ID, qualified/container name, signature, language, kind, path, and line, and its graph sections -are scoped to that candidate identity. When multiple candidates are returned, the top-level +are scoped to that candidate identity. Reuse an emitted generation-bound ID selector with +`cdidx inspect --selector 'id:@g:'` against the same database to inspect exactly +that physical symbol without repeating name resolution. The legacy `id:` form is accepted for +the active database, but only the emitted generation fingerprint can detect a rebuilt or different +database. Symbol IDs are database-local; a missing, stale, or cross-database selector returns +`E018_QUERY_NOT_FOUND`. `identity_scoped` is true only when every inbound edge belongs uniquely +to the selected identity. `identity_scope_reason: ambiguous_reference_candidates` keeps +optional, `params`, named-argument, generic-inference, and otherwise unresolved overload evidence +explicit instead of presenting duplicated candidate evidence as exact. C# calls with ordinary +required parameters are narrowed only when their positional argument count safely distinguishes +an overload; extension receiver adjustment and dynamic receiver types are not inferred by this narrowing. When multiple candidates are returned, the top-level `references`, `callers`, and `callees` arrays are explicitly labeled `graph_scope: primary_candidate` and mirror only the first prioritized bundle instead of merging unrelated definitions; consume the corresponding bundle for every other candidate. @@ -420,6 +430,10 @@ same section envelopes and accepts their cursors. In path/line mode, `--path` locates the definition but does not restrict inbound references or callers to that file. Inspect graph cursors are accepted only by `inspect`; passing one to another command is a usage error. +Selector-mode pagination uses the same contract: replay `--selector`, the same +filters and page size, and the returned `--cursor`. Do not combine `--selector` +with a symbol query, source coordinate, or `--group-partials`; `--path` by itself +remains an evidence filter. For narrower `inspect` evidence, `--fields ` implies JSON and selects top-level groups such as `definitions`, `file`, `graph`, `references`, `callers`, and `callees`. Collection selectors accept one nested level, for @@ -3996,7 +4010,18 @@ language または path filter で対象を絞り込んでください。 `inspect` と MCP `analyze_symbol` は、名前が index 済み定義へ解決される場合に `candidate_bundles` を返します。各 bundle は symbol ID、qualified/container name、 signature、language、kind、path、line を含む安定 selector で識別され、graph section は -その candidate identity に限定されます。複数 candidate が返る場合、top-level の +その candidate identity に限定されます。同じ database に対して、出力された generation-bound +ID selector を `cdidx inspect --selector 'id:@g:'` で再利用すると、name resolution +を繰り返さず対象の物理 symbol だけを inspect できます。legacy の `id:` 形式も active database +向けに受理しますが、再構築後または別 database であることを検出できるのは出力された generation +fingerprint 付き形式だけです。symbol ID は database-local であり、存在しない、stale、または +cross-database selector は `E018_QUERY_NOT_FOUND` を返します。`identity_scoped` は inbound edge がすべて選択 identity +へ一意に属する場合だけ true になります。optional、`params`、named argument、generic inference +などで overload を確定できない場合は `identity_scope_reason: ambiguous_reference_candidates` を +返し、重複した candidate evidence を exact として扱いません。C# の通常の required parameter +呼び出しは、位置引数の個数で overload を安全に区別できる場合だけ絞り込みます。extension の +receiver 調整と dynamic receiver の型はこの絞り込みでは推論しません。 +複数 candidate が返る場合、top-level の `references`、`callers`、`callees` 配列は `graph_scope: primary_candidate` と 明示され、無関係な定義を結合せず優先順位1位の bundle だけを反映します。それ以外は 対応する bundle を利用してください。`--fields candidates` で bundle を明示的に @@ -4076,6 +4101,10 @@ path/line mode の `--path` は定義の位置を特定しますが、inbound re そのファイルだけに制限しません。MCP `analyze_symbol` も同じ section envelope を公開し、 その cursor を受け付けます。inspect graph cursor は `inspect` だけが受理し、別 command に 渡すと usage error になります。 +selector mode の pagination も同じ contract を使います。`--selector`、同じ filter と page size、 +返された `--cursor` を再指定してください。`--selector` は symbol query、source coordinate、 +`--group-partials` と組み合わせられません。`--path` だけを指定した場合は evidence filter として +引き続き利用できます。 `inspect` の証跡をさらに絞りたい場合、`--fields ` は JSON 出力を暗黙に有効化し、 `definitions`、`file`、`graph`、`references`、`callers`、`callees` などの top-level group を選択します。collection selector は 1 階層の nested field に対応し、 diff --git a/changelog.d/unreleased/5159.fixed.md b/changelog.d/unreleased/5159.fixed.md new file mode 100644 index 000000000..43d18ef28 --- /dev/null +++ b/changelog.d/unreleased/5159.fixed.md @@ -0,0 +1,40 @@ +--- +category: fixed +issues: + - 5159 +affected: + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/InspectGraphCursor.cs + - src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs + - src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs + - src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Database/DbContext.ConnectionFunctionRegistration.cs + - src/CodeIndex/Database/DbContext.SchemaMetadata.cs + - src/CodeIndex/Database/DbReader.FilesStatus.cs + - src/CodeIndex/Database/DbSymbolReader.Analysis.cs + - src/CodeIndex/Database/DbSymbolReader.Hotspots.cs + - src/CodeIndex/Database/DbWriter.References.cs + - src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs + - src/CodeIndex/Models/QueryResults.cs + - src/CodeIndex/Models/SymbolSelector.cs + - tests/CodeIndex.Tests/QueryCommandRunnerIssue5159Tests.cs + - tests/CodeIndex.Tests/DbReaderSymbolIdentityTests.cs + - tests/CodeIndex.Tests/DbReaderSymbolQueryTests.cs + - tests/CodeIndex.Tests/DbReaderImpactTests.cs + - tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Inspect selectors now round-trip exact overload identities (#5159)** — `inspect --selector 'id:@g:'` reuses emitted generation-bound, database-local symbol IDs with candidate-scoped pagination and stable missing/invalid/stale diagnostics. C# required-parameter overloads narrow conservatively by positional argument count, while optional, `params`, named, generic, and unresolved calls remain explicitly non-identity-scoped instead of duplicating evidence under a false exact label. + +## 日本語 + +- **inspect selector で正確な overload identity を再利用できるようになりました (#5159)** — `inspect --selector 'id:@g:'` は出力済みの generation-bound かつ database-local な symbol ID を candidate-scoped pagination と安定した missing / invalid / stale diagnostic 付きで再利用します。C# の required-parameter overload は位置引数個数で保守的に絞り込み、optional、`params`、named、generic、未解決 call は、重複 evidence を誤って exact とせず明示的に non-identity-scoped のままにします。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 9ebb4b078..d4f172b59 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -515,6 +515,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--indexed-only", Description = "Languages: list only languages present in the current index", PrimaryCommands = Set(LanguagesFilterCommands) }, new() { Name = "--capability", ValueDomain = Values(["all", "none", "graph", "references", "symbols", "missing-any", "missing-graph", "missing-references", "missing-symbols", "search-only"]), Description = "Languages: filter by language capability or capability gap", PrimaryCommands = Set(LanguagesFilterCommands) }, new() { Name = "--query", ValuePlaceholder = "", Description = "Literal query", PrimaryCommands = Set(QueryCommands) }, + new() { Name = "--selector", ValuePlaceholder = "", Description = "Inspect one exact symbol identity using a selector emitted by inspect", PrimaryCommands = Set("inspect") }, new() { Name = "--recipe", ValuePlaceholder = "", ValueKind = CliOptionValueKind.FreeText, Description = "Search: run a built-in audit recipe query set, optionally selecting one child query", PrimaryCommands = Set("search") }, new() { Name = "--include-query", ValuePlaceholder = "", Description = "Search recipe: include one child query; repeat or comma-separate values", PrimaryCommands = Set("search") }, new() { Name = "--exclude-query", ValuePlaceholder = "", Description = "Search recipe: exclude one child query; repeat or comma-separate values", PrimaryCommands = Set("search") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 044cdcb31..4d5b16893 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -97,7 +97,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("find", "cdidx find (--path |--all) [--db ] [--json] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--context ] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--line-scan-limit ] [--allow-partial] [--exact] [--regex] [--count]"), ("excerpt", "cdidx excerpt [--line |--start |--start-line ] [--end |--end-line ] [--clamp] [--context |--before |--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--redact-paths|--show-paths] [--no-semantic-tokens] [--max-json-bytes ] [--verbose]"), ("map", "cdidx map [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--cursor ] [--summary-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>] [--max-json-bytes ]"), - ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]"), + ("inspect", "cdidx inspect |--query |-- |--selector [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]"), ("inspect", "cdidx inspect --path --line [--end-line ] [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ]"), ("outline", "cdidx outline [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ] [--cursor ] [--max-json-bytes ] [--sort ] [--kind ] [--outline-fields ]"), ("status", "cdidx status [--db ] [--json] [--format ] [--compact] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--check[=workspace,fold,graph,issues,hotspot,csharp,sql,newer]] [--stale-after ] [--explain ] [--log-path] [--config [--redact-paths|--show-paths]] [--check-updates]"), diff --git a/src/CodeIndex/Cli/InspectGraphCursor.cs b/src/CodeIndex/Cli/InspectGraphCursor.cs index 7def6c6fd..0aac75649 100644 --- a/src/CodeIndex/Cli/InspectGraphCursor.cs +++ b/src/CodeIndex/Cli/InspectGraphCursor.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using CodeIndex.Database; +using CodeIndex.Models; namespace CodeIndex.Cli; @@ -23,7 +24,7 @@ internal static string BuildQueryFingerprint(IEnumerable components) internal static (string Fingerprint, string? StableAt) BuildGenerationFingerprint(DbReader reader) { var generation = reader.GetPaginationGeneration(); - return (BuildValueFingerprint(generation.Identity), generation.StableAt); + return (SymbolSelector.BuildGenerationFingerprint(generation.Identity), generation.StableAt); } internal static string Format( diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs index b1ca96012..cde96e14c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs @@ -50,6 +50,15 @@ private bool TryParseResultOption(string normalizedArg, string currentArg, strin case "--group-partials": groupPartials = true; break; + case "--selector": + if (TryReadStringOptionValue(args, ref i, "--selector", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var selectorValue, out var selectorError)) + { + WarnIfDuplicateSingleValueOption("--selector", selectorValue!); + selector = selectorValue; + } + else + AddParseError(selectorError!); + break; case "--cycles": dependencyCycles = true; break; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs index f066eb377..13b83dffe 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs @@ -35,6 +35,7 @@ private sealed partial class QueryArgumentParser private string? minUnusedConfidence; private string? severity; private string? query; + private string? selector; private bool rawFts; private bool includeBody; private int? bodyStartLine; @@ -432,6 +433,7 @@ private QueryCommandOptions BuildOptions(DbPathResolution dbResolution, string r UnusedActionable = unusedActionable, Severity = severity, Query = query, + Selector = selector, RawFts = rawFts, IncludeBody = includeBody || inspectFieldsIncludeBody, BodyStartLine = bodyStartLine, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs index eead2774d..784c3bdfc 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Inspect.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using CodeIndex.Database; +using CodeIndex.Models; namespace CodeIndex.Cli; @@ -78,6 +79,44 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions CommandErrorWriter.WriteStderr(exactError); return CommandExitCodes.UsageError; } + SymbolSelector? selectedSymbol = null; + if (options.Selector != null) + { + if (!SymbolSelector.TryParse(options.Selector, out var parsedSelector)) + { + WriteUsageError( + $"invalid symbol selector: {ConsoleUi.FormatBoundedValue(options.Selector)}", + GetUsageLineOrThrow("inspect"), + "Pass a selector emitted by inspect in the form `--selector 'id:@g:'`, or a legacy unversioned ID."); + return CommandExitCodes.UsageError; + } + + selectedSymbol = parsedSelector; + if (!string.IsNullOrWhiteSpace(options.Query)) + { + WriteUsageError( + "--selector cannot be combined with a symbol query argument", + GetUsageLineOrThrow("inspect"), + "Remove the positional/--query value and pass only the selector emitted by inspect."); + return CommandExitCodes.UsageError; + } + if (options.StartLine.HasValue || options.EndLine.HasValue) + { + WriteUsageError( + "--selector cannot be combined with a source coordinate", + GetUsageLineOrThrow("inspect"), + "Remove --line/--start-line/--end-line; --path remains available as a graph evidence filter."); + return CommandExitCodes.UsageError; + } + if (options.GroupPartials) + { + WriteUsageError( + "--selector cannot be combined with --group-partials", + GetUsageLineOrThrow("inspect"), + "A selector already identifies one physical symbol; remove --group-partials."); + return CommandExitCodes.UsageError; + } + } var pathLineInspectMode = IsInspectPathLineMode(options); if (pathLineInspectMode && options.GroupPartials) { @@ -87,9 +126,9 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions "Remove --path/--line and inspect a symbol name, or remove --group-partials to keep coordinate-based physical navigation."); return CommandExitCodes.UsageError; } - if (!pathLineInspectMode && TryWriteBlankQueryError(options, "inspect")) + if (selectedSymbol == null && !pathLineInspectMode && TryWriteBlankQueryError(options, "inspect")) return CommandExitCodes.UsageError; - if (!pathLineInspectMode && string.IsNullOrWhiteSpace(options.Query)) + if (selectedSymbol == null && !pathLineInspectMode && string.IsNullOrWhiteSpace(options.Query)) { WriteUsageError( "inspect requires a symbol query argument", @@ -97,7 +136,7 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions "Add the symbol you want to inspect, for example: `cdidx inspect QueryCommandRunner`, or pass `--path --line ` for a source excerpt."); return CommandExitCodes.UsageError; } - if (options.Query != null && IsBareVerbatimQueryToken(options.Query)) + if (selectedSymbol == null && options.Query != null && IsBareVerbatimQueryToken(options.Query)) { WriteUsageError( "inspect requires a symbol query argument", @@ -143,7 +182,7 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions { indexedFile = reader.GetFileByPath(inspectPath); } - else if (options.Query != null) + else if (selectedSymbol == null && options.Query != null) { var resolvedQueryPath = DbPathResolver.ResolveQueryFilePath(options.DbPath, options.Query, options.DbPathExplicit); indexedFile = reader.GetFileByPath(resolvedQueryPath); @@ -186,9 +225,9 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions category: "range"); } - var inspectQuery = fileInspectMode + var inspectQuery = selectedSymbol?.ToString() ?? (fileInspectMode ? $"{inspectPath}:{inspectLine}" - : options.Query!; + : options.Query!); var queryFingerprint = BuildInspectGraphQueryFingerprint( inspectQuery, options, @@ -196,6 +235,20 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions fileInspectMode, inspectLimit); var generation = InspectGraphCursorCodec.BuildGenerationFingerprint(reader); + var currentSelectorGeneration = SymbolSelector.BuildGenerationFingerprint( + reader.GetSymbolSelectorGenerationIdentity()); + if (selectedSymbol is { GenerationFingerprint: { } selectorGeneration } + && !string.Equals(selectorGeneration, currentSelectorGeneration, StringComparison.Ordinal)) + { + return CommandErrorWriter.WriteJsonOrHuman( + options.Json, + jsonOptions, + $"symbol selector is stale or belongs to another index generation: {selectedSymbol}", + CommandExitCodes.NotFound, + "Rerun the originating inspect query against this database and use a selector from its current candidate_bundles.", + errorCode: CommandErrorCodes.QueryNotFound, + category: "not_found"); + } if (graphCursor != null && (!string.Equals(graphCursor.QueryFingerprint, queryFingerprint, StringComparison.Ordinal) || !string.Equals(graphCursor.GenerationFingerprint, generation.Fingerprint, StringComparison.Ordinal))) @@ -241,7 +294,20 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions options.BodyLines, kind: options.Kind, groupPartials: options.GroupPartials, - graphPage: graphPage); + graphPage: graphPage, + selectedSymbolId: selectedSymbol?.SymbolId, + selectedSymbolGenerationFingerprint: selectedSymbol?.GenerationFingerprint); + if (selectedSymbol != null && analysis.Definitions.Count == 0) + { + return CommandErrorWriter.WriteJsonOrHuman( + options.Json, + jsonOptions, + $"symbol selector not found in the active index: {selectedSymbol}", + CommandExitCodes.NotFound, + "Rerun the originating inspect query against this database and use a selector from its current candidate_bundles.", + errorCode: CommandErrorCodes.QueryNotFound, + category: "not_found"); + } if (graphCursor?.CandidateSelector != null && !(analysis.CandidateBundles?.Any(bundle => string.Equals(bundle.Selector.Selector, graphCursor.CandidateSelector, StringComparison.Ordinal)) ?? false)) @@ -335,6 +401,8 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions Console.WriteLine($"Graph Note : {analysis.GraphSupportReason}"); if (analysis.GraphScope != null) Console.WriteLine($"Graph Scope : {analysis.GraphScope}"); + if (analysis.CandidateBundles?.FirstOrDefault() is { } selectedCandidate) + Console.WriteLine($"Identity Scoped : {selectedCandidate.IdentityScoped} ({selectedCandidate.IdentityScopeReason})"); if (analysis.SelectionRequired) Console.WriteLine("Selection Required : true — use a candidate selector/path before trusting graph sections."); if (analysis.UnsupportedSymbolKind != null) @@ -375,7 +443,7 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions { foreach (var bundle in analysis.CandidateBundles) { - var title = $"Candidate {bundle.Selector.Selector} ({bundle.Selector.QualifiedName})"; + var title = $"Candidate {bundle.Selector.Selector} ({bundle.Selector.QualifiedName}; identity_scoped={bundle.IdentityScoped}; {bundle.IdentityScopeReason})"; WriteRepoMapSection(title, [$"{bundle.Definition.Kind,-10} {bundle.Definition.Path}:{bundle.Definition.StartLine}-{bundle.Definition.EndLine}"]); WriteRepoMapSection($"{title} references", bundle.References.Select(item => $"{item.Path}:{item.Line}:{item.Column} {item.Context}")); WriteInspectGraphSectionStatus($"{title} references", bundle.GraphSections.References); @@ -931,7 +999,7 @@ private static bool IsInspectSourceExcerptRequested(QueryCommandOptions options) var definition = analysis.Definitions.FirstOrDefault(); var path = inspectPath - ?? GetSingleSpecificPathPattern(options.PathPatterns) + ?? (options.Selector == null ? GetSingleSpecificPathPattern(options.PathPatterns) : null) ?? definition?.Path ?? analysis.File?.Path; if (path == null) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 4f38c3f0b..74231c430 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -109,6 +109,7 @@ public sealed class QueryCommandOptions public List VisibilityFilters { get; init; } = []; public List ExcludeVisibilityFilters { get; init; } = []; public string? Query { get; init; } + public string? Selector { get; init; } public bool RawFts { get; init; } public bool IncludeBody { get; init; } public int? BodyStartLine { get; init; } diff --git a/src/CodeIndex/Database/DbContext.ConnectionFunctionRegistration.cs b/src/CodeIndex/Database/DbContext.ConnectionFunctionRegistration.cs index d26222494..439028425 100644 --- a/src/CodeIndex/Database/DbContext.ConnectionFunctionRegistration.cs +++ b/src/CodeIndex/Database/DbContext.ConnectionFunctionRegistration.cs @@ -117,6 +117,10 @@ private static void RegisterCSharpReferenceShapeFunctions(SqliteConnection conne "csharp_constructor_parameter_count", (string? signature, string? identifier, string? symbolKind) => CSharpTypeReferenceArity.GetConstructorParameterCount(signature, identifier, symbolKind)); + connection.CreateFunction( + "csharp_callable_parameter_count", + (string? signature, string? identifier, string? symbolKind) => + CSharpTypeReferenceArity.GetUnambiguousCallableParameterCount(signature, identifier, symbolKind)); } private static void RegisterCSharpPartialIdentityFunctions(SqliteConnection connection) @@ -166,6 +170,10 @@ private static void RegisterCSharpFileAndBaseFunctions(SqliteConnection connecti "csharp_invocation_argument_count", (string? context, string? identifier, long? columnNumber) => CSharpTypeReferenceArity.GetInvocationArgumentCount(context, identifier, columnNumber)); + connection.CreateFunction( + "csharp_unambiguous_invocation_argument_count", + (string? context, string? identifier, long? columnNumber) => + CSharpTypeReferenceArity.GetUnambiguousInvocationArgumentCount(context, identifier, columnNumber)); connection.CreateFunction( "csharp_definition_is_value_type", (string? signature, string? symbolKind) => diff --git a/src/CodeIndex/Database/DbContext.SchemaMetadata.cs b/src/CodeIndex/Database/DbContext.SchemaMetadata.cs index 4b73399e7..6f406f758 100644 --- a/src/CodeIndex/Database/DbContext.SchemaMetadata.cs +++ b/src/CodeIndex/Database/DbContext.SchemaMetadata.cs @@ -67,7 +67,8 @@ public static string BuildIncompleteHotspotFamilyMarkerFingerprint(string? finge public const string SqlGraphContractVersionMetaKey = "sql_graph_contract_version"; public const int HdlGraphContractVersion = 1; public const string HdlGraphContractVersionMetaKey = "hdl_graph_contract_version"; - // Version 9 (#5158) invalidates references resolved with physical declaration paths + // Version 10 (#5159) invalidates C# call candidates written before conservative method + // argument-count narrowing. Version 9 (#5158) invalidates references resolved with physical declaration paths // instead of authoritative logical C# partial-family identities. Version 8 (#4914) // invalidates C# candidates whose partial family identity did not // distinguish namespace boundaries from nested-type boundaries. Version 7 (#4914) @@ -75,6 +76,8 @@ public static string BuildIncompleteHotspotFamilyMarkerFingerprint(string? finge // identity. Version 6 (#4850) previously separated constructor // callables from logical partial-type families; version 5 (#4846) made Markdown fragment // resolution document/path-scoped. + // バージョン 10 (#5159) では保守的なメソッド引数個数の絞り込みより前に書かれた C# call + // candidate を無効化する。 // バージョン 9 (#5158) では、正式な C# logical partial-family identity ではなく // physical declaration path で解決した reference を無効化する。バージョン 8 (#4914) では // namespace 境界と nested-type 境界を区別しない partial family @@ -82,7 +85,7 @@ public static string BuildIncompleteHotspotFamilyMarkerFingerprint(string? finge // family が source-file identity を持つ前の C# candidate を無効化する。バージョン 6 (#4850) は constructor callable と logical // partial type family を分離し、バージョン 5 (#4846) は Markdown fragment 解決を // document/path 内に限定した。 - public const int ReferenceIdentityContractVersion = 9; + public const int ReferenceIdentityContractVersion = 10; public const string ReferenceIdentityContractVersionMetaKey = "reference_identity_contract_version"; public static string GetDynamicReferenceGraphContractVersionMetaKey(string lang) => $"dynamic_reference_graph_contract_version_{lang}"; diff --git a/src/CodeIndex/Database/DbReader.FilesStatus.cs b/src/CodeIndex/Database/DbReader.FilesStatus.cs index 978e5cb96..720fc0a44 100644 --- a/src/CodeIndex/Database/DbReader.FilesStatus.cs +++ b/src/CodeIndex/Database/DbReader.FilesStatus.cs @@ -879,6 +879,12 @@ public FreshnessHintResult GetFreshnessHint() return (identity, stableAt); } + internal string GetSymbolSelectorGenerationIdentity() + => string.Create( + CultureInfo.InvariantCulture, + $"{GetPaginationGeneration().Identity}\n" + + $"{TryGetMetaStringInternal(DbContext.IndexedProjectRootMetaKey) ?? "no-indexed-project-root"}"); + internal string GetFoldPaginationGenerationIdentity() { var userVersion = ExecuteScalar("PRAGMA user_version"); diff --git a/src/CodeIndex/Database/DbReader.SymbolSearchListQueryBuilder.cs b/src/CodeIndex/Database/DbReader.SymbolSearchListQueryBuilder.cs index afa956fea..091370b49 100644 --- a/src/CodeIndex/Database/DbReader.SymbolSearchListQueryBuilder.cs +++ b/src/CodeIndex/Database/DbReader.SymbolSearchListQueryBuilder.cs @@ -109,14 +109,21 @@ private SymbolSearchRankingSql BuildSymbolSearchRankingSql( var conservativeSignal = includeRankSignals ? $"(f.lang = 'csharp' AND (s.kind = 'property' OR ({definitionSites}) > 1 OR lower(s.name) IN {GenericSymbolRankNamesSql}))" : "0"; - var csharpPartialIdentitySignal = includeRankSignals && CanUseCSharpIdentityHotspotCounts() + var useCSharpIdentityRank = includeRankSignals && CanUseCSharpIdentityHotspotCounts(); + var csharpPartialIdentitySignal = useCSharpIdentityRank ? $"(f.lang = 'csharp' AND ({logicalPartialKeySql}) LIKE 'family:%')" : "0"; + var fallbackReferenceCount = $"CASE WHEN {conservativeSignal} THEN COALESCE(symbol_file_rank.reference_count, 0) ELSE COALESCE(symbol_rank.reference_count, 0) END"; + var fallbackHotspotScore = $"CASE WHEN {conservativeSignal} THEN COALESCE(symbol_file_rank.hotspot_score, 0.0) ELSE COALESCE(symbol_rank.hotspot_score, 0.0) END"; var referenceCount = includeRankSignals - ? $"CASE WHEN {csharpPartialIdentitySignal} THEN COALESCE(symbol_identity_rank.reference_count, 0) WHEN {conservativeSignal} THEN COALESCE(symbol_file_rank.reference_count, 0) ELSE COALESCE(symbol_rank.reference_count, 0) END" + ? useCSharpIdentityRank + ? $"CASE WHEN {csharpPartialIdentitySignal} THEN COALESCE(symbol_identity_rank.reference_count, 0) ELSE {fallbackReferenceCount} END" + : fallbackReferenceCount : "CAST(0 AS INTEGER)"; var hotspotScore = includeRankSignals - ? $"CASE WHEN {csharpPartialIdentitySignal} THEN COALESCE(symbol_identity_rank.hotspot_score, 0.0) WHEN {conservativeSignal} THEN COALESCE(symbol_file_rank.hotspot_score, 0.0) ELSE COALESCE(symbol_rank.hotspot_score, 0.0) END" + ? useCSharpIdentityRank + ? $"CASE WHEN {csharpPartialIdentitySignal} THEN COALESCE(symbol_identity_rank.hotspot_score, 0.0) ELSE {fallbackHotspotScore} END" + : fallbackHotspotScore : "CAST(0.0 AS REAL)"; var dilution = $"CASE WHEN ({definitionSites}) > 1 THEN CAST(({definitionSites}) * ({definitionSites}) AS REAL) ELSE 1.0 END"; var structuralPenalty = includeRankSignals @@ -136,7 +143,7 @@ private SymbolSearchRankingSql BuildSymbolSearchRankingSql( ELSE 0.0 END)"; return new SymbolSearchRankingSql( - BuildSymbolRankJoin(includeRankSignals, logicalPartialKeySql), + BuildSymbolRankJoin(includeRankSignals, useCSharpIdentityRank, logicalPartialKeySql), genericPenalty, definitionSites, referenceCount, @@ -148,12 +155,15 @@ ELSE 0.0 BuildExactSymbolNameOrderSql()); } - private string BuildSymbolRankJoin(bool includeRankSignals, string logicalPartialKeySql) + private string BuildSymbolRankJoin( + bool includeRankSignals, + bool useCSharpIdentityRank, + string logicalPartialKeySql) { if (!includeRankSignals) return string.Empty; - var identityJoin = CanUseCSharpIdentityHotspotCounts() + var identityJoin = useCSharpIdentityRank ? $@" LEFT JOIN ( SELECT identity_site.lang, diff --git a/src/CodeIndex/Database/DbSymbolReader.Analysis.cs b/src/CodeIndex/Database/DbSymbolReader.Analysis.cs index 2038169e4..9f18e6cf7 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Analysis.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Analysis.cs @@ -1,5 +1,6 @@ using System.Globalization; using CodeIndex.Indexer; +using CodeIndex.Models; using Microsoft.Data.Sqlite; namespace CodeIndex.Database; @@ -187,7 +188,8 @@ private List GetSymbolsAtLine(string path, int line, int limit, st {GetSymbolColumnSql("container_name")} AS container_name, {GetSymbolColumnSql("visibility")} AS visibility, {GetSymbolColumnSql("return_type")} AS return_type, - s.id AS symbol_id + s.id AS symbol_id, + {GetSymbolColumnSql("container_qualified_name")} AS container_qualified_name FROM symbols s JOIN files f ON s.file_id = f.id WHERE f.path = @path @@ -242,16 +244,64 @@ private static SymbolResult ReadSymbolResult(SqliteDataReader reader) Visibility = GetNullableString(reader, 12), ReturnType = GetNullableString(reader, 13), SymbolId = reader.GetInt64(14), + ContainerQualifiedName = GetNullableString(reader, 15), }; + private DefinitionResult? GetDefinitionBySymbolId( + long symbolId, + bool includeBody, + int? bodyStartLine, + int? bodyLineCount, + string? lang, + string? kind) + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = $@" + SELECT f.path, f.lang, s.kind, s.name, s.line, + {GetSymbolColumnSql("start_line", "s.line")} AS start_line, + {GetSymbolColumnSql("end_line", "s.line")} AS end_line, + {GetSymbolColumnSql("body_start_line")} AS body_start_line, + {GetSymbolColumnSql("body_end_line")} AS body_end_line, + {GetSymbolColumnSql("signature")} AS signature, + {GetSymbolColumnSql("container_kind")} AS container_kind, + {GetSymbolColumnSql("container_name")} AS container_name, + {GetSymbolColumnSql("visibility")} AS visibility, + {GetSymbolColumnSql("return_type")} AS return_type, + s.id AS symbol_id, + {GetSymbolColumnSql("container_qualified_name")} AS container_qualified_name + FROM symbols s + JOIN files f ON s.file_id = f.id + WHERE s.id = @symbol_id"; + if (lang != null) + cmd.CommandText += " AND f.lang = @lang"; + if (kind != null) + cmd.CommandText += " AND s.kind = @kind"; + cmd.CommandText += " LIMIT 1"; + SqliteCommandPolicy.Add(cmd, "@symbol_id", symbolId); + if (lang != null) + SqliteCommandPolicy.Add(cmd, "@lang", lang); + if (kind != null) + SqliteCommandPolicy.Add(cmd, "@kind", kind); + using var reader = cmd.ExecuteTrackedReader(); + if (!reader.TrackedRead()) + return null; + + return BuildDefinitionResult( + ReadSymbolResult(reader), + includeBody, + bodyStartLine, + bodyLineCount); + } + /// /// Bundle definition, graph, and local file context for one symbol query. /// 単一シンボルクエリ向けに、定義・グラフ・ローカル文脈をまとめて返す。 /// - public SymbolAnalysisResult AnalyzeSymbol(string query, int limit = 10, string? lang = null, bool includeBody = false, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, int? bodyStartLine = null, int? bodyLineCount = null, string? kind = null, bool groupPartials = false, SymbolGraphPageRequest? graphPage = null) + public SymbolAnalysisResult AnalyzeSymbol(string query, int limit = 10, string? lang = null, bool includeBody = false, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, int? bodyStartLine = null, int? bodyLineCount = null, string? kind = null, bool groupPartials = false, SymbolGraphPageRequest? graphPage = null, long? selectedSymbolId = null, string? selectedSymbolGenerationFingerprint = null) { using var txn = _conn.BeginTransaction(deferred: true); - if (string.IsNullOrWhiteSpace(query) || IsBareVerbatimQueryToken(query)) + if (selectedSymbolId == null + && (string.IsNullOrWhiteSpace(query) || IsBareVerbatimQueryToken(query))) { var workspaceFreshness = GetWorkspaceFreshness(); var emptyResult = new SymbolAnalysisResult @@ -267,9 +317,9 @@ public SymbolAnalysisResult AnalyzeSymbol(string query, int limit = 10, string? } lang = DbReader.NormalizeQueryLanguage(lang); - var normalizedQuery = - NormalizeSymbolSearchQueryForSymbolSearch(query, lang, exact) - ?? query; + var normalizedQuery = selectedSymbolId != null + ? query + : NormalizeSymbolSearchQueryForSymbolSearch(query, lang, exact) ?? query; // Propagate `exact` to every bundled sub-query so the one-round-trip AI workflow // (`inspect` / MCP `analyze_symbol`) keeps the same precision contract as the leaf // commands. Without this, `inspect Run --exact` would still pull RunAsync/RunImpact @@ -285,7 +335,18 @@ public SymbolAnalysisResult AnalyzeSymbol(string query, int limit = 10, string? // definitions / file / freshness / references / callers / callees / nearby symbols // が同じ WAL snapshot を参照するようにする。 var definitionLimit = Math.Min(limit, 5); - var definitions = PrioritizeSourceDefinitions(GetDefinitions(normalizedQuery, definitionLimit, kind: kind, lang, includeBody, pathPatterns, excludePathPatterns, excludeTests, since: null, exact, bodyStartLine: bodyStartLine, bodyLineCount: bodyLineCount, groupPartials: groupPartials)); + var selectedGenerationMatches = selectedSymbolGenerationFingerprint == null + || string.Equals( + selectedSymbolGenerationFingerprint, + SymbolSelector.BuildGenerationFingerprint(GetSymbolSelectorGenerationIdentity()), + StringComparison.Ordinal); + var definitions = selectedSymbolId is long symbolId + ? selectedGenerationMatches + ? GetDefinitionBySymbolId(symbolId, includeBody, bodyStartLine, bodyLineCount, lang, kind) is { } selectedDefinition + ? new List { selectedDefinition } + : [] + : [] + : PrioritizeSourceDefinitions(GetDefinitions(normalizedQuery, definitionLimit, kind: kind, lang, includeBody, pathPatterns, excludePathPatterns, excludeTests, since: null, exact, bodyStartLine: bodyStartLine, bodyLineCount: bodyLineCount, groupPartials: groupPartials)); DefinitionResult? primaryDefinition = definitions .FirstOrDefault(definition => SupportsReferenceLanguage(definition.Lang) && !IsCSharpEnumMemberDefinition(definition)) ?? definitions.FirstOrDefault(definition => SupportsReferenceLanguage(definition.Lang)) @@ -303,7 +364,9 @@ public SymbolAnalysisResult AnalyzeSymbol(string query, int limit = 10, string? var graphLanguageCandidates = new List(); var graphLanguageConflict = false; const bool hasUnsupportedEnumMember = false; - var hasSupportedGraphDefinition = exact + var hasSupportedGraphDefinition = selectedSymbolId != null + ? definitions.Any(definition => SupportsSymbolGraph(definition.Lang, definition.Kind, definition.ContainerKind) == true) + : exact ? HasExactGraphSupportedDefinition(normalizedQuery, lang, pathPatterns, excludePathPatterns, excludeTests) : definitions.Any(definition => SupportsSymbolGraph(definition.Lang, definition.Kind, definition.ContainerKind) == true); var unsupportedSymbolKind = hasUnsupportedEnumMember ? "enum_member" : null; @@ -311,7 +374,7 @@ public SymbolAnalysisResult AnalyzeSymbol(string query, int limit = 10, string? .Select((definition, index) => BuildSymbolCandidateBundle( definition, limit, - includeNameFallback: index == 0, + includeNameFallback: selectedSymbolId == null && index == 0, pathPatterns, excludePathPatterns, excludeTests, @@ -323,20 +386,21 @@ public SymbolAnalysisResult AnalyzeSymbol(string query, int limit = 10, string? var fallbackReferenceOffset = GetGraphSectionOffset(graphPage, "references", candidateSelector: null); var fallbackCallerOffset = GetGraphSectionOffset(graphPage, "callers", candidateSelector: null); var fallbackCalleeOffset = GetGraphSectionOffset(graphPage, "callees", candidateSelector: null); + var allowNameGraphFallback = selectedSymbolId == null && definitions.Count == 0; var references = selectedBundle?.References - ?? (definitions.Count == 0 + ?? (allowNameGraphFallback ? SearchReferences(normalizedQuery, limit, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact, maxLineWidth, offset: fallbackReferenceOffset) : []); var callers = selectedBundle?.Callers - ?? (definitions.Count == 0 + ?? (allowNameGraphFallback ? GetCallers(normalizedQuery, limit, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact, offset: fallbackCallerOffset) : []); var callees = selectedBundle?.Callees - ?? (definitions.Count == 0 + ?? (allowNameGraphFallback ? GetCallees(normalizedQuery, limit, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact, offset: fallbackCalleeOffset) : []); var graphSections = selectedBundle?.GraphSections - ?? (definitions.Count == 0 + ?? (allowNameGraphFallback ? BuildGraphSections( CountSearchReferencesTotal(normalizedQuery, lang, null, pathPatterns, excludePathPatterns, excludeTests, exact).Count, references.Count, @@ -398,10 +462,10 @@ public SymbolAnalysisResult AnalyzeSymbol(string query, int limit = 10, string? excludePathPatterns: excludePathPatterns, excludeTests: excludeTests) : (ExactQuerySignal?)null; - var relaxedSymbols = exact && definitions.Count == 0 && references.Count == 0 && callers.Count == 0 && callees.Count == 0 + var relaxedSymbols = selectedSymbolId == null && exact && definitions.Count == 0 && references.Count == 0 && callers.Count == 0 && callees.Count == 0 ? SearchSymbols(normalizedQuery, Math.Max(limit, 5), kind: null, lang, pathPatterns, excludePathPatterns, excludeTests, since: null, exact: false) : null; - var exactZeroHint = exact && definitions.Count == 0 && references.Count == 0 && callers.Count == 0 && callees.Count == 0 + var exactZeroHint = selectedSymbolId == null && exact && definitions.Count == 0 && references.Count == 0 && callers.Count == 0 && callees.Count == 0 ? ExactZeroHintResult.FromRelaxedMatches( relaxedSymbols!.Count, relaxedSymbols.Select(result => result.Name)) @@ -516,37 +580,40 @@ private SymbolCandidateBundle BuildSymbolCandidateBundle( int maxLineWidth, SymbolGraphPageRequest? graphPage = null) { - var identityScoped = CanScopeCandidateByIdentity(definition); + var identityAvailable = CanScopeCandidateByIdentity(definition); + var hasAmbiguousInboundEvidence = identityAvailable + && HasAmbiguousInboundEvidence(definition.SymbolId!.Value); + var identityScoped = identityAvailable && !hasAmbiguousInboundEvidence; var selector = BuildSymbolCandidateSelector(definition); var referenceOffset = GetGraphSectionOffset(graphPage, "references", selector.Selector); var callerOffset = GetGraphSectionOffset(graphPage, "callers", selector.Selector); var calleeOffset = GetGraphSectionOffset(graphPage, "callees", selector.Selector); - var references = identityScoped + var references = identityAvailable ? SearchReferencesForCandidate(definition, limit, pathPatterns, excludePathPatterns, excludeTests, maxLineWidth, referenceOffset) : includeNameFallback ? SearchReferences(definition.Name, limit, definition.Lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true, maxLineWidth, offset: referenceOffset) : []; - var callers = identityScoped + var callers = identityAvailable ? GetCallersForCandidate(definition, limit, pathPatterns, excludePathPatterns, excludeTests, callerOffset) : includeNameFallback ? GetCallers(definition.Name, limit, definition.Lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true, offset: callerOffset) : []; - var callees = identityScoped + var callees = identityAvailable ? GetCalleesForCandidate(definition, limit, pathPatterns, excludePathPatterns, excludeTests, calleeOffset) : includeNameFallback ? GetCallees(definition.Name, limit, definition.Lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true, offset: calleeOffset) : []; - var referenceTotal = identityScoped + var referenceTotal = identityAvailable ? CountSearchReferencesForCandidate(definition, pathPatterns, excludePathPatterns, excludeTests) : includeNameFallback ? CountSearchReferencesTotal(definition.Name, definition.Lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true).Count : 0; - var callerTotal = identityScoped + var callerTotal = identityAvailable ? CountCallersForCandidate(definition, pathPatterns, excludePathPatterns, excludeTests) : includeNameFallback ? CountCallersTotal(definition.Name, definition.Lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true).Count : 0; - var calleeTotal = identityScoped + var calleeTotal = identityAvailable ? CountCalleesForCandidate(definition, pathPatterns, excludePathPatterns, excludeTests) : includeNameFallback ? CountCalleesTotal(definition.Name, definition.Lang, null, pathPatterns, excludePathPatterns, excludeTests, exact: true).Count @@ -575,6 +642,11 @@ private SymbolCandidateBundle BuildSymbolCandidateBundle( GraphSupported = graphSupported, GraphSupportReason = graphSupportReason, IdentityScoped = identityScoped, + IdentityScopeReason = identityScoped + ? "exact_identity" + : hasAmbiguousInboundEvidence + ? "ambiguous_reference_candidates" + : "identity_contract_unavailable", NearbySymbols = nearbySymbols, References = references, Callers = callers, @@ -636,20 +708,41 @@ private bool CanScopeCandidateByIdentity(DefinitionResult definition) => && _referenceColumns.Contains("source_symbol_id") && HasTable("symbol_reference_candidates"); - private static SymbolCandidateSelector BuildSymbolCandidateSelector(DefinitionResult definition) + private bool HasAmbiguousInboundEvidence(long symbolId) + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = """ + SELECT EXISTS ( + SELECT 1 + FROM symbol_reference_candidates AS candidate + JOIN symbol_references AS reference + ON reference.id = candidate.reference_id + WHERE candidate.symbol_id = @symbol_id + AND reference.resolution_candidate_count > 1 + LIMIT 1 + ) + """; + SqliteCommandPolicy.Add(cmd, "@symbol_id", symbolId); + return Convert.ToInt32(cmd.ExecuteScalar()) != 0; + } + + private SymbolCandidateSelector BuildSymbolCandidateSelector(DefinitionResult definition) { var container = definition.ContainerQualifiedName ?? definition.ContainerName; var qualifiedName = string.IsNullOrWhiteSpace(container) ? definition.Name : $"{container}.{definition.Name}"; + var generationFingerprint = SymbolSelector.BuildGenerationFingerprint( + GetSymbolSelectorGenerationIdentity()); var selector = definition.SymbolId is long symbolId - ? $"id:{symbolId.ToString(CultureInfo.InvariantCulture)}" + ? new SymbolSelector(symbolId, generationFingerprint).ToString() : $"{definition.Lang}:{definition.Path}:{definition.StartLine.ToString(CultureInfo.InvariantCulture)}:{qualifiedName}"; return new SymbolCandidateSelector { Selector = selector, SymbolId = definition.SymbolId, + GenerationFingerprint = definition.SymbolId != null ? generationFingerprint : null, QualifiedName = qualifiedName, Container = container, Signature = definition.Signature, diff --git a/src/CodeIndex/Database/DbSymbolReader.Hotspots.cs b/src/CodeIndex/Database/DbSymbolReader.Hotspots.cs index 5e6160815..fecf1125e 100644 --- a/src/CodeIndex/Database/DbSymbolReader.Hotspots.cs +++ b/src/CodeIndex/Database/DbSymbolReader.Hotspots.cs @@ -11,6 +11,7 @@ public partial class DbReader { private bool CanUseCSharpIdentityHotspotCounts() => HasCurrentReferenceIdentityContractForRead() + && GetHotspotFamilySignal("csharp").Ready && _referenceColumns.Contains("target_symbol_id") && _referenceColumns.Contains("resolution_state") && HasTable("symbol_reference_candidates"); diff --git a/src/CodeIndex/Database/DbWriter.References.cs b/src/CodeIndex/Database/DbWriter.References.cs index 16d9c03a9..3553c3b6b 100644 --- a/src/CodeIndex/Database/DbWriter.References.cs +++ b/src/CodeIndex/Database/DbWriter.References.cs @@ -200,6 +200,7 @@ CREATE TEMP TABLE IF NOT EXISTS csharp_symbol_facts ( symbol_id INTEGER NOT NULL PRIMARY KEY, definition_type_arity INTEGER, constructor_parameter_count INTEGER, + callable_parameter_count INTEGER, is_value_type INTEGER NOT NULL ) WITHOUT ROWID; @@ -267,6 +268,11 @@ THEN csharp_invocation_argument_count( COALESCE(r.context, reference_line.context), r.symbol_name, r.column_number) + WHEN r.reference_kind = 'call' + THEN csharp_unambiguous_invocation_argument_count( + COALESCE(r.context, reference_line.context), + r.symbol_name, + r.column_number) END, CASE WHEN ( @@ -298,7 +304,7 @@ LEFT JOIN reference_lines AS reference_line ON reference_line.id = r.reference_line_id WHERE {scopePredicate} AND ( - r.reference_kind IN ('instantiate', 'type_reference') + r.reference_kind IN ('call', 'instantiate', 'type_reference') OR ( r.reference_kind = 'reference' AND r.target_qualifier LIKE @@ -349,6 +355,7 @@ INSERT INTO temp.csharp_symbol_facts( symbol_id, definition_type_arity, constructor_parameter_count, + callable_parameter_count, is_value_type) SELECT symbol.id, csharp_definition_type_arity( @@ -359,6 +366,10 @@ INSERT INTO temp.csharp_symbol_facts( symbol.signature, symbol.name, symbol.kind), + csharp_callable_parameter_count( + symbol.signature, + symbol.name, + symbol.kind), csharp_definition_is_value_type( symbol.signature, symbol.kind) @@ -398,6 +409,15 @@ FROM temp.csharp_symbol_facts AS symbol_fact ) """; + private static string BuildCSharpCallableParameterCountSql(string symbolAlias) + => $""" + ( + SELECT symbol_fact.callable_parameter_count + FROM temp.csharp_symbol_facts AS symbol_fact + WHERE symbol_fact.symbol_id = {symbolAlias}.id + ) + """; + private static string BuildCSharpIsValueTypeSql(string symbolAlias) => $""" ( @@ -633,11 +653,36 @@ FROM temp.csharp_reference_facts AS reference_fact ) """; + private static string BuildCSharpCallCandidatePredicateSql(string symbolAlias) => $""" + ( + r.reference_kind <> 'call' + OR ( + {symbolAlias}.kind = 'function' + AND {CSharpReferenceArgumentCountSql} IS NOT NULL + AND {BuildCSharpCallableParameterCountSql(symbolAlias)} + = {CSharpReferenceArgumentCountSql} + ) + OR {CSharpReferenceArgumentCountSql} IS NULL + OR {BuildCSharpCallableParameterCountSql(symbolAlias)} IS NULL + ) + """; + private static string CSharpTypeReferenceCandidatePredicateSql => $""" ( source_file.lang <> 'csharp' - OR r.reference_kind NOT IN ('instantiate', 'type_reference') + OR r.reference_kind NOT IN ('call', 'instantiate', 'type_reference') OR CASE + WHEN r.reference_kind = 'call' + AND s.kind = 'function' + AND {CSharpReferenceArgumentCountSql} IS NOT NULL + AND {BuildCSharpCallableParameterCountSql("s")} + = {CSharpReferenceArgumentCountSql} THEN 1 + WHEN r.reference_kind = 'call' + AND ( + {CSharpReferenceArgumentCountSql} IS NULL + OR {BuildCSharpCallableParameterCountSql("s")} IS NULL + ) THEN 1 + WHEN r.reference_kind = 'call' THEN 0 WHEN r.reference_kind = 'instantiate' AND s.name <> r.symbol_name COLLATE BINARY THEN 0 WHEN r.reference_kind = 'instantiate' @@ -1425,6 +1470,7 @@ JOIN files AS target_file WHERE source_file.lang = 'csharp' AND r.target_qualifier IS NULL AND r.reference_kind NOT IN ('instantiate', 'type_reference') + AND {BuildCSharpCallCandidatePredicateSql("target")} AND NOT EXISTS ( SELECT 1 FROM temp.{ReferenceLowerRankCandidateMatchesTable} AS lower_rank_match diff --git a/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs b/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs index 5222f7bfa..4db0a3f0d 100644 --- a/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs +++ b/src/CodeIndex/Indexer/CSharpTypeReferenceArity.cs @@ -65,6 +65,94 @@ internal static bool IsMemberReceiver(string? context, string? symbolName, long? : null; } + internal static int? GetUnambiguousInvocationArgumentCount( + string? context, + string? symbolName, + long? columnNumber) + { + if (string.IsNullOrWhiteSpace(context) || string.IsNullOrWhiteSpace(symbolName)) + return null; + + var occurrence = FindClosestIdentifierOccurrence(context, symbolName, columnNumber); + if (occurrence < 0) + return null; + + var cursor = occurrence + symbolName.Length; + if (!SkipCSharpTrivia(context, ref cursor) + || cursor >= context.Length + || context[cursor] != '(') + { + // Generic method binding needs type inference and deliberately stays unresolved. + return null; + } + + return TryAnalyzeTopLevelParameters( + context, + cursor, + out var count, + out var hasNamedArgument, + out _, + out _, + out var hasAngleBrackets) + && !hasNamedArgument + && !hasAngleBrackets + ? count + : null; + } + + internal static int? GetUnambiguousCallableParameterCount( + string? signature, + string? symbolName, + string? symbolKind) + { + if (!string.Equals(symbolKind, "function", StringComparison.Ordinal) + || string.IsNullOrWhiteSpace(signature) + || string.IsNullOrWhiteSpace(symbolName)) + { + return null; + } + + for (var searchAt = 0; searchAt <= signature.Length - symbolName.Length;) + { + var occurrence = signature.IndexOf(symbolName, searchAt, StringComparison.Ordinal); + if (occurrence < 0) + return null; + searchAt = occurrence + Math.Max(1, symbolName.Length); + if (!IsIdentifierOccurrence(signature, occurrence, symbolName.Length)) + continue; + + // A partial method's defining declaration and implementation may legally + // disagree about where an optional default is written. Treat the physical + // rows as one binding-sensitive family instead of narrowing only one side. + if (ContainsIdentifier(signature, "partial", occurrence)) + return null; + + var cursor = occurrence + symbolName.Length; + if (!SkipCSharpTrivia(signature, ref cursor) + || cursor >= signature.Length + || signature[cursor] != '(') + { + // Generic callables and malformed/truncated signatures remain ambiguous. + continue; + } + + return TryAnalyzeTopLevelParameters( + signature, + cursor, + out var count, + out _, + out var hasOptionalDefault, + out var hasBindingSensitiveModifier, + out _) + && !hasOptionalDefault + && !hasBindingSensitiveModifier + ? count + : null; + } + + return null; + } + internal static int? GetDefinitionArity(string? signature, string? symbolName, string? symbolKind) { if (string.IsNullOrWhiteSpace(symbolName)) @@ -491,18 +579,50 @@ private static bool TryCountTopLevelTypeArguments( } private static bool TryCountTopLevelParameters(string text, int openParenthesis, out int count) + => TryAnalyzeTopLevelParameters( + text, + openParenthesis, + out count, + out _, + out _, + out _, + out _); + + private static bool TryAnalyzeTopLevelParameters( + string text, + int openParenthesis, + out int count, + out bool hasTopLevelColon, + out bool hasTopLevelEquals, + out bool hasBindingSensitiveModifier, + out bool hasAngleBrackets) { count = 0; + hasTopLevelColon = false; + hasTopLevelEquals = false; + hasBindingSensitiveModifier = false; + hasAngleBrackets = false; var parenthesisDepth = 0; var bracketDepth = 0; var braceDepth = 0; var angleDepth = 0; var hasItemContent = false; + var hasTopLevelSeparator = false; for (var i = openParenthesis + 1; i < text.Length; i++) { var c = text[i]; if (c is '"' or '\'') { + // Raw strings can contain unescaped commas and quote characters. The + // lightweight scanner deliberately keeps those calls ambiguous. + if (c == '"' + && i + 2 < text.Length + && text[i + 1] == '"' + && text[i + 2] == '"') + { + return false; + } + i = SkipQuotedLiteral(text, i, c); if (i >= text.Length) return false; @@ -533,6 +653,8 @@ private static bool TryCountTopLevelParameters(string text, int openParenthesis, hasItemContent = true; break; case ')' when bracketDepth == 0 && braceDepth == 0 && angleDepth == 0: + if (hasTopLevelSeparator && !hasItemContent) + return false; count = hasItemContent ? count + 1 : 0; return true; case '[': @@ -552,6 +674,7 @@ private static bool TryCountTopLevelParameters(string text, int openParenthesis, hasItemContent = true; break; case '<': + hasAngleBrackets = true; angleDepth++; hasItemContent = true; break; @@ -567,8 +690,41 @@ private static bool TryCountTopLevelParameters(string text, int openParenthesis, return false; count++; hasItemContent = false; + hasTopLevelSeparator = true; + break; + case ':' when parenthesisDepth == 0 + && bracketDepth == 0 + && braceDepth == 0 + && angleDepth == 0: + hasTopLevelColon = true; + hasItemContent = true; + break; + case '=' when parenthesisDepth == 0 + && bracketDepth == 0 + && braceDepth == 0 + && angleDepth == 0: + hasTopLevelEquals = true; + hasItemContent = true; break; default: + if (parenthesisDepth == 0 + && braceDepth == 0 + && angleDepth == 0 + && IsIdentifierStart(c)) + { + var identifierEnd = i + 1; + while (identifierEnd < text.Length && IsIdentifierPart(text[identifierEnd])) + identifierEnd++; + var identifier = text.AsSpan(i, identifierEnd - i); + hasBindingSensitiveModifier |= bracketDepth == 0 + ? identifier.SequenceEqual("params".AsSpan()) + || identifier.SequenceEqual("this".AsSpan()) + : identifier.SequenceEqual("Optional".AsSpan()) + || identifier.SequenceEqual("OptionalAttribute".AsSpan()) + || identifier.SequenceEqual("DefaultParameterValue".AsSpan()) + || identifier.SequenceEqual("DefaultParameterValueAttribute".AsSpan()); + i = identifierEnd - 1; + } hasItemContent |= !char.IsWhiteSpace(c); break; } @@ -645,6 +801,9 @@ private static bool IsIdentifierOccurrence(string text, int occurrence, int leng private static bool IsIdentifierPart(char c) => char.IsLetterOrDigit(c) || c == '_'; + private static bool IsIdentifierStart(char c) + => char.IsLetter(c) || c == '_'; + private static void SkipWhitespace(string text, ref int cursor) { while (cursor < text.Length && char.IsWhiteSpace(text[cursor])) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index eeef9160d..9a8edc41d 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -901,6 +901,7 @@ private JsonArray BuildCompactCandidateBundles(List? bund ["selector"] = JsonSerializer.SerializeToNode(bundle.Selector, _jsonOptions), ["definition"] = BuildCompactSymbolRow(bundle.Definition), ["identity_scoped"] = bundle.IdentityScoped, + ["identity_scope_reason"] = bundle.IdentityScopeReason, ["graph_supported"] = bundle.GraphSupported, ["graph_support_reason"] = bundle.GraphSupportReason, ["nearby_symbol_count"] = bundle.NearbySymbols.Count, diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index a66fa82ca..5f8e08c11 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -2757,6 +2757,9 @@ public class SymbolCandidateSelector [JsonPropertyName("symbol_id")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public long? SymbolId { get; set; } + [JsonPropertyName("generation_fingerprint")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? GenerationFingerprint { get; set; } [JsonPropertyName("qualified_name")] public string QualifiedName { get; set; } = string.Empty; [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] @@ -2779,6 +2782,8 @@ public class SymbolCandidateBundle public string? GraphSupportReason { get; set; } [JsonPropertyName("identity_scoped")] public bool IdentityScoped { get; set; } + [JsonPropertyName("identity_scope_reason")] + public string IdentityScopeReason { get; set; } = string.Empty; public List NearbySymbols { get; set; } = []; public List References { get; set; } = []; public List Callers { get; set; } = []; diff --git a/src/CodeIndex/Models/SymbolSelector.cs b/src/CodeIndex/Models/SymbolSelector.cs new file mode 100644 index 000000000..7afd5f02a --- /dev/null +++ b/src/CodeIndex/Models/SymbolSelector.cs @@ -0,0 +1,59 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace CodeIndex.Models; + +/// +/// Stable selector for one indexed symbol identity. +/// インデックス済みシンボル identity を 1 件選択する安定 selector。 +/// +public readonly record struct SymbolSelector(long SymbolId, string? GenerationFingerprint = null) +{ + private const string GenerationSeparator = "@g:"; + + public static bool TryParse(string? value, out SymbolSelector selector) + { + selector = default; + if (string.IsNullOrWhiteSpace(value)) + return false; + + var generationSeparatorIndex = value.IndexOf(GenerationSeparator, StringComparison.Ordinal); + var idValue = generationSeparatorIndex >= 0 + ? value.AsSpan(0, generationSeparatorIndex) + : value.AsSpan(); + string? generationFingerprint = null; + if (generationSeparatorIndex >= 0) + { + var generationValue = value.AsSpan(generationSeparatorIndex + GenerationSeparator.Length); + if (generationValue.Length != 16 || !generationValue.ToString().All(Uri.IsHexDigit)) + return false; + generationFingerprint = generationValue.ToString().ToLowerInvariant(); + } + + if (!idValue.StartsWith("id:", StringComparison.Ordinal) + || !long.TryParse( + idValue["id:".Length..], + NumberStyles.None, + CultureInfo.InvariantCulture, + out var symbolId) + || symbolId <= 0) + { + return false; + } + + selector = new SymbolSelector(symbolId, generationFingerprint); + return true; + } + + public static string BuildGenerationFingerprint(string generationIdentity) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(generationIdentity)); + return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant(); + } + + public override string ToString() + => GenerationFingerprint == null + ? $"id:{SymbolId.ToString(CultureInfo.InvariantCulture)}" + : $"id:{SymbolId.ToString(CultureInfo.InvariantCulture)}{GenerationSeparator}{GenerationFingerprint}"; +} diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index c9385595d..d0c29baef 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -436,7 +436,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() AssertSearchUsageFragments(output); Assert.Contains("cdidx definition |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--group-partials] [--since ]", output); Assert.Contains("cdidx references |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--include-qualified-common-calls] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]", output); - Assert.Contains("cdidx inspect |--query |-- [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]", output); + Assert.Contains("cdidx inspect |--query |-- |--selector [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ] [--exact|--exact-name] [--group-partials]", output); Assert.Contains("cdidx inspect --path --line [--end-line ] [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ]", output); Assert.Contains("cdidx outline [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ] [--cursor ] [--max-json-bytes ] [--sort ] [--kind ] [--outline-fields ]", output); Assert.Contains("--snippet-lines ", output); diff --git a/tests/CodeIndex.Tests/DbReaderImpactTests.cs b/tests/CodeIndex.Tests/DbReaderImpactTests.cs index 527522da5..1bcd291ea 100644 --- a/tests/CodeIndex.Tests/DbReaderImpactTests.cs +++ b/tests/CodeIndex.Tests/DbReaderImpactTests.cs @@ -306,7 +306,8 @@ public static void Run(int value) Assert.Equal("Run", overloadCaller.CallerName); Assert.Equal("Run", overloadCaller.CalleeName); Assert.NotNull(overloadCaller.CallerSymbolId); - Assert.Null(overloadCaller.CalleeSymbolId); + Assert.NotNull(overloadCaller.CalleeSymbolId); + Assert.NotEqual(overloadCaller.CallerSymbolId, overloadCaller.CalleeSymbolId); Assert.Equal([new List { "Leaf", "Run", "Run" }], overloadCaller.Paths); Assert.Equal( 3, diff --git a/tests/CodeIndex.Tests/DbReaderSymbolIdentityTests.cs b/tests/CodeIndex.Tests/DbReaderSymbolIdentityTests.cs index acb3faa5b..7ed2163dd 100644 --- a/tests/CodeIndex.Tests/DbReaderSymbolIdentityTests.cs +++ b/tests/CodeIndex.Tests/DbReaderSymbolIdentityTests.cs @@ -210,11 +210,12 @@ public static void InvokePrimary() Assert.Equal(2, primaryReferences.Count); Assert.All(primaryReferences, reference => { - Assert.Equal("resolved_group", reference.ResolutionState); - Assert.Null(reference.TargetSymbolId); + Assert.Equal("resolved", reference.ResolutionState); + Assert.NotNull(reference.TargetSymbolId); Assert.NotNull(reference.TargetSymbolKey); - Assert.Equal(2, reference.ResolutionCandidateCount); + Assert.Equal(1, reference.ResolutionCandidateCount); }); + Assert.Equal(2, primaryReferences.Select(reference => reference.TargetSymbolId).Distinct().Count()); var qualifiedCallers = _reader.GetCallers( "Primary.Open5084", diff --git a/tests/CodeIndex.Tests/DbReaderSymbolQueryTests.cs b/tests/CodeIndex.Tests/DbReaderSymbolQueryTests.cs index 84547558c..f7b7ae29d 100644 --- a/tests/CodeIndex.Tests/DbReaderSymbolQueryTests.cs +++ b/tests/CodeIndex.Tests/DbReaderSymbolQueryTests.cs @@ -603,9 +603,11 @@ public void Call(Api api) includeQualifiedCommonCalls: true); Assert.All(overloadReferences, reference => { - Assert.Equal("ambiguous", reference.ResolutionState); - Assert.Equal(2, reference.ResolutionCandidateCount); + Assert.Equal("resolved", reference.ResolutionState); + Assert.NotNull(reference.TargetSymbolId); + Assert.Equal(1, reference.ResolutionCandidateCount); }); + Assert.Equal(2, overloadReferences.Select(reference => reference.TargetSymbolId).Distinct().Count()); var results = _reader.GetSymbolHotspots( limit: 10, diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs index b12898e8f..7a306e479 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerUpdateTests.cs @@ -575,7 +575,7 @@ public void Run_UpdateMode_NoOpRepairsVersion4MarkdownCandidates_Issue4846() var (initialExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); Assert.Equal(CommandExitCodes.Success, initialExitCode); - Assert.Equal(9, DbContext.ReferenceIdentityContractVersion); + Assert.Equal(10, DbContext.ReferenceIdentityContractVersion); var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); using (var connection = new SqliteConnection($"Data Source={dbPath}")) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5159Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5159Tests.cs new file mode 100644 index 000000000..6b9c51688 --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5159Tests.cs @@ -0,0 +1,384 @@ +using System.Text.Json; +using CodeIndex.Cli; +using CodeIndex.Indexer; +using static CodeIndex.Tests.QueryCommandTestSupport; + +namespace CodeIndex.Tests; + +public sealed class QueryCommandRunnerIssue5159Tests +{ + [Theory] + [InlineData("service.Ping()", "Ping", 9, 0)] + [InlineData("service.Ping(1, Create(2, 3))", "Ping", 9, 2)] + public void UnambiguousInvocationArity_CountsSimplePositionalCalls_Issue5159( + string context, + string name, + long column, + int expected) + { + Assert.Equal( + expected, + CSharpTypeReferenceArity.GetUnambiguousInvocationArgumentCount(context, name, column)); + } + + [Theory] + [InlineData("service.Ping(value: 1)")] + [InlineData("service.Ping(1)")] + [InlineData("service.Ping(condition ? 1 : 2)")] + [InlineData("service.Ping(a < b, c > d)")] + [InlineData("service.Ping(1,)")] + [InlineData("service.Ping(\"\"\"a\" , \"b\"\"\")")] + public void UnambiguousInvocationArity_RejectsBindingSensitiveCalls_Issue5159(string context) + { + Assert.Null(CSharpTypeReferenceArity.GetUnambiguousInvocationArgumentCount( + context, + "Ping", + 9)); + } + + [Theory] + [InlineData("public void Ping()", 0)] + [InlineData("public static void Ping(int value, string text)", 2)] + public void UnambiguousCallableArity_CountsRequiredNonGenericParameters_Issue5159( + string signature, + int expected) + { + Assert.Equal( + expected, + CSharpTypeReferenceArity.GetUnambiguousCallableParameterCount( + signature, + "Ping", + "function")); + } + + [Theory] + [InlineData("public void Ping(int value = 0)")] + [InlineData("public void Ping(params int[] values)")] + [InlineData("public void Ping(T value)")] + [InlineData("public static void Ping(this Service service)")] + [InlineData("public void Ping([Optional] int value)")] + [InlineData("public void Ping([System.Runtime.InteropServices.OptionalAttribute] int value)")] + [InlineData("public void Ping([DefaultParameterValue(0)] int value)")] + [InlineData("partial void Ping(int value)")] + public void UnambiguousCallableArity_RejectsBindingSensitiveDeclarations_Issue5159( + string signature) + { + Assert.Null(CSharpTypeReferenceArity.GetUnambiguousCallableParameterCount( + signature, + "Ping", + "function")); + } + + [Fact] + public void InspectSelector_RoundTripsOverloadIdentityAndKeepsAmbiguityTruthful_Issue5159() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_inspect_selector_issue5159"); + try + { + TestProjectHelper.WriteTextFile( + projectRoot, + "src/Fixture.cs", + """" + using static SelectorFixture.StaticService; + + namespace SelectorFixture; + + [System.AttributeUsage(System.AttributeTargets.Method)] + public sealed class PingAttribute : System.Attribute { } + + public static class StaticService + { + public static void StaticPing() { } + public static void StaticPing(int value) { } + } + + public sealed partial class Service + { + [Ping()] + public void Ping() { } + [Ping()] + public void Ping(int value) { } + public void Optional(int value = 0) { } + public void Optional(string value = "") { } + public void AttributeOptional([System.Runtime.InteropServices.Optional] int value, [System.Runtime.InteropServices.Optional] string text) { } + public void AttributeOptional(object value) { } + public void Incomplete() { } + public void Incomplete(int value) { } + public void RawPing(string value) { } + public void RawPing(string left, string right) { } + partial void PartialPing(int value = 0); + partial void PartialPing(int value) { } + public void RunPartial() { PartialPing(); } + } + + public sealed class Caller + { + public void Run(Service service) + { + service.Ping(); + service.Ping(); + service.Ping(1); + service.Ping(2); + service.Optional(); + service.AttributeOptional(1); + service.Incomplete(1,); + service.RawPing("""a" , "b"""); + StaticPing(); + StaticPing(1); + } + } + """"); + TestProjectHelper.WriteTextFile( + projectRoot, + "docs/Evidence.md", + string.Join('\n', Enumerable.Range(1, 30).Select(line => $"unrelated evidence line {line}"))); + + var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run( + [projectRoot, "--json", "--quiet"], + JsonOptions)); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + + Assert.Equal(CommandExitCodes.Success, indexExitCode); + Assert.Equal(string.Empty, indexStderr); + + var (nameExitCode, nameStdout, nameStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect( + ["Ping", "--db", dbPath, "--json", "--exact-name", "--lang", "csharp", "--limit", "10"], + JsonOptions)); + using var nameDocument = ParseJsonOutput(nameStdout); + var bundles = nameDocument.RootElement.GetProperty("candidate_bundles").EnumerateArray().ToArray(); + var zeroParameterBundle = Assert.Single(bundles.Where(bundle => + bundle.GetProperty("definition").GetProperty("signature").GetString()!.Contains("Ping()", StringComparison.Ordinal))); + var oneParameterBundle = Assert.Single(bundles.Where(bundle => + bundle.GetProperty("definition").GetProperty("signature").GetString()!.Contains("Ping(int value)", StringComparison.Ordinal))); + var zeroSelector = zeroParameterBundle.GetProperty("selector").GetProperty("selector").GetString()!; + var oneSelector = oneParameterBundle.GetProperty("selector").GetProperty("selector").GetString()!; + var zeroSymbolId = zeroParameterBundle.GetProperty("selector").GetProperty("symbol_id").GetInt64(); + var zeroGeneration = zeroParameterBundle.GetProperty("selector").GetProperty("generation_fingerprint").GetString()!; + + Assert.Equal(CommandExitCodes.Success, nameExitCode); + Assert.Equal(string.Empty, nameStderr); + Assert.NotEqual(zeroSelector, oneSelector); + Assert.Equal($"id:{zeroSymbolId}@g:{zeroGeneration}", zeroSelector); + Assert.Matches("^[0-9a-f]{16}$", zeroGeneration); + Assert.True(zeroParameterBundle.GetProperty("identity_scoped").GetBoolean()); + Assert.True(oneParameterBundle.GetProperty("identity_scoped").GetBoolean()); + Assert.Equal("exact_identity", zeroParameterBundle.GetProperty("identity_scope_reason").GetString()); + Assert.All( + zeroParameterBundle.GetProperty("references").EnumerateArray(), + reference => Assert.Contains("Ping()", reference.GetProperty("context").GetString(), StringComparison.Ordinal)); + Assert.All( + oneParameterBundle.GetProperty("references").EnumerateArray(), + reference => Assert.Matches(@"Ping\([12]\)", reference.GetProperty("context").GetString())); + Assert.DoesNotContain( + zeroParameterBundle.GetProperty("references").EnumerateArray(), + reference => reference.GetProperty("context").GetString()!.TrimStart() + .StartsWith("[Ping()]", StringComparison.Ordinal)); + Assert.DoesNotContain( + oneParameterBundle.GetProperty("references").EnumerateArray(), + reference => reference.GetProperty("context").GetString()!.TrimStart() + .StartsWith("[Ping()]", StringComparison.Ordinal)); + + var (staticExitCode, staticStdout, staticStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect( + ["StaticPing", "--db", dbPath, "--json", "--exact-name", "--lang", "csharp", "--limit", "10"], + JsonOptions)); + using var staticDocument = ParseJsonOutput(staticStdout); + var staticBundles = staticDocument.RootElement.GetProperty("candidate_bundles").EnumerateArray().ToArray(); + var staticZeroBundle = Assert.Single(staticBundles.Where(bundle => + bundle.GetProperty("definition").GetProperty("signature").GetString()!.Contains("StaticPing()", StringComparison.Ordinal))); + var staticOneBundle = Assert.Single(staticBundles.Where(bundle => + bundle.GetProperty("definition").GetProperty("signature").GetString()!.Contains("StaticPing(int value)", StringComparison.Ordinal))); + Assert.Equal(CommandExitCodes.Success, staticExitCode); + Assert.Equal(string.Empty, staticStderr); + Assert.True(staticZeroBundle.GetProperty("identity_scoped").GetBoolean()); + Assert.True(staticOneBundle.GetProperty("identity_scoped").GetBoolean()); + Assert.All( + staticZeroBundle.GetProperty("references").EnumerateArray(), + reference => Assert.Contains("StaticPing()", reference.GetProperty("context").GetString(), StringComparison.Ordinal)); + Assert.All( + staticOneBundle.GetProperty("references").EnumerateArray(), + reference => Assert.Contains("StaticPing(1)", reference.GetProperty("context").GetString(), StringComparison.Ordinal)); + + var selectorArgs = new[] + { + "--selector", zeroSelector, + "--db", dbPath, + "--json", + "--lang", "csharp", + "--limit", "1", + }; + var (selectorExitCode, selectorStdout, selectorStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect(selectorArgs, JsonOptions)); + using var selectorDocument = ParseJsonOutput(selectorStdout); + var selected = selectorDocument.RootElement; + var selectedBundle = Assert.Single(selected.GetProperty("candidate_bundles").EnumerateArray()); + var nextCursor = selectedBundle + .GetProperty("graph_sections") + .GetProperty("references") + .GetProperty("next_cursor") + .GetString(); + + Assert.Equal(CommandExitCodes.Success, selectorExitCode); + Assert.Equal(string.Empty, selectorStderr); + Assert.Equal(zeroSelector, selected.GetProperty("query").GetString()); + Assert.Equal("single_candidate", selected.GetProperty("graph_scope").GetString()); + Assert.Equal(zeroSelector, selectedBundle.GetProperty("selector").GetProperty("selector").GetString()); + Assert.Single(selected.GetProperty("definitions").EnumerateArray()); + Assert.Contains("Ping()", selected.GetProperty("definitions")[0].GetProperty("signature").GetString(), StringComparison.Ordinal); + Assert.False(string.IsNullOrWhiteSpace(nextCursor)); + + var (legacyExitCode, legacyStdout, legacyStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect( + ["--selector", $"id:{zeroSymbolId}", "--db", dbPath, "--json"], + JsonOptions)); + using var legacyDocument = ParseJsonOutput(legacyStdout); + Assert.Equal(CommandExitCodes.Success, legacyExitCode); + Assert.Equal(string.Empty, legacyStderr); + Assert.Contains( + "Ping()", + legacyDocument.RootElement.GetProperty("definitions")[0].GetProperty("signature").GetString(), + StringComparison.Ordinal); + + var (continuationExitCode, continuationStdout, continuationStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect(selectorArgs.Concat(["--cursor", nextCursor!]).ToArray(), JsonOptions)); + using var continuationDocument = ParseJsonOutput(continuationStdout); + var continuedReference = Assert.Single( + continuationDocument.RootElement.GetProperty("references").EnumerateArray()); + Assert.Equal(CommandExitCodes.Success, continuationExitCode); + Assert.Equal(string.Empty, continuationStderr); + Assert.Contains("Ping()", continuedReference.GetProperty("context").GetString(), StringComparison.Ordinal); + + var (ambiguousExitCode, ambiguousStdout, ambiguousStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect( + ["Optional", "--db", dbPath, "--json", "--exact-name", "--lang", "csharp", "--limit", "10"], + JsonOptions)); + using var ambiguousDocument = ParseJsonOutput(ambiguousStdout); + var ambiguousBundles = ambiguousDocument.RootElement.GetProperty("candidate_bundles").EnumerateArray().ToArray(); + Assert.Equal(CommandExitCodes.Success, ambiguousExitCode); + Assert.Equal(string.Empty, ambiguousStderr); + Assert.All(ambiguousBundles, bundle => Assert.False(bundle.GetProperty("identity_scoped").GetBoolean())); + Assert.All(ambiguousBundles, bundle => Assert.Equal( + "ambiguous_reference_candidates", + bundle.GetProperty("identity_scope_reason").GetString())); + + foreach (var ambiguousName in new[] { "AttributeOptional", "Incomplete", "PartialPing", "RawPing" }) + { + var (conservativeExitCode, conservativeStdout, conservativeStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect( + [ambiguousName, "--db", dbPath, "--json", "--exact-name", "--lang", "csharp", "--limit", "10"], + JsonOptions)); + using var conservativeDocument = ParseJsonOutput(conservativeStdout); + var conservativeBundles = conservativeDocument.RootElement + .GetProperty("candidate_bundles") + .EnumerateArray() + .ToArray(); + Assert.Equal(CommandExitCodes.Success, conservativeExitCode); + Assert.Equal(string.Empty, conservativeStderr); + Assert.Equal(2, conservativeBundles.Length); + Assert.All(conservativeBundles, bundle => Assert.False(bundle.GetProperty("identity_scoped").GetBoolean())); + Assert.All(conservativeBundles, bundle => Assert.Equal( + "ambiguous_reference_candidates", + bundle.GetProperty("identity_scope_reason").GetString())); + } + + var (excerptExitCode, excerptStdout, excerptStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect( + [ + "--selector", zeroSelector, + "--db", dbPath, + "--json", + "--path", "docs/Evidence.md", + "--context", "1", + "--fields", "definitions,source_excerpt", + ], + JsonOptions)); + using var excerptDocument = ParseJsonOutput(excerptStdout); + var excerpt = excerptDocument.RootElement.GetProperty("source_excerpt"); + Assert.Equal(CommandExitCodes.Success, excerptExitCode); + Assert.Equal(string.Empty, excerptStderr); + Assert.Equal("src/Fixture.cs", excerpt.GetProperty("path").GetString()); + Assert.Contains("Ping()", excerpt.GetProperty("content").GetString(), StringComparison.Ordinal); + + var (languageMismatchExitCode, languageMismatchStdout, languageMismatchStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect( + ["--selector", zeroSelector, "--db", dbPath, "--json", "--lang", "python"], + JsonOptions)); + using var languageMismatchDocument = ParseJsonOutput(languageMismatchStdout); + Assert.Equal(CommandExitCodes.NotFound, languageMismatchExitCode); + Assert.Equal(string.Empty, languageMismatchStderr); + Assert.Equal( + "E018_QUERY_NOT_FOUND", + languageMismatchDocument.RootElement.GetProperty("error_code").GetString()); + + Assert.Contains("--selector", CliFlagSchema.GetAcceptedFlagNamesForCommand("inspect")); + + var (missingExitCode, missingStdout, missingStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect( + ["--selector", "id:9223372036854775807", "--db", dbPath, "--json"], + JsonOptions)); + using var missingDocument = ParseJsonOutput(missingStdout); + Assert.Equal(CommandExitCodes.NotFound, missingExitCode); + Assert.Equal(string.Empty, missingStderr); + Assert.Equal("E018_QUERY_NOT_FOUND", missingDocument.RootElement.GetProperty("error_code").GetString()); + + var (invalidExitCode, _, invalidStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect( + ["--selector", "id:-1", "--db", dbPath], + JsonOptions)); + Assert.Equal(CommandExitCodes.UsageError, invalidExitCode); + Assert.Contains("invalid symbol selector", invalidStderr, StringComparison.Ordinal); + + var otherProjectRoot = TestProjectHelper.CreateTempProject("cdidx_inspect_selector_cross_db_issue5159"); + try + { + TestProjectHelper.WriteTextFile( + otherProjectRoot, + "src/Other.cs", + "namespace OtherFixture; public sealed class Other { public void Ping() { } }"); + var (otherIndexExitCode, _, otherIndexStderr) = CaptureConsole(() => IndexCommandRunner.Run( + [otherProjectRoot, "--json", "--quiet"], + JsonOptions)); + var otherDbPath = Path.Combine(otherProjectRoot, ".cdidx", "codeindex.db"); + Assert.Equal(CommandExitCodes.Success, otherIndexExitCode); + Assert.Equal(string.Empty, otherIndexStderr); + + var (crossDbExitCode, crossDbStdout, crossDbStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect( + ["--selector", zeroSelector, "--db", otherDbPath, "--json"], + JsonOptions)); + using var crossDbDocument = ParseJsonOutput(crossDbStdout); + Assert.Equal(CommandExitCodes.NotFound, crossDbExitCode); + Assert.Equal(string.Empty, crossDbStderr); + Assert.Equal("E018_QUERY_NOT_FOUND", crossDbDocument.RootElement.GetProperty("error_code").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(otherProjectRoot); + } + + TestProjectHelper.WriteTextFile( + projectRoot, + "src/GenerationChange.cs", + "namespace SelectorFixture; public sealed class GenerationChange { }"); + var (updateExitCode, _, updateStderr) = CaptureConsole(() => IndexCommandRunner.Run( + [projectRoot, "--json", "--quiet"], + JsonOptions)); + Assert.Equal(CommandExitCodes.Success, updateExitCode); + Assert.Equal(string.Empty, updateStderr); + + var (staleExitCode, staleStdout, staleStderr) = CaptureConsole(() => + QueryCommandRunner.RunInspect( + ["--selector", zeroSelector, "--db", dbPath, "--json"], + JsonOptions)); + using var staleDocument = ParseJsonOutput(staleStdout); + Assert.Equal(CommandExitCodes.NotFound, staleExitCode); + Assert.Equal(string.Empty, staleStderr); + Assert.Equal("E018_QUERY_NOT_FOUND", staleDocument.RootElement.GetProperty("error_code").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } +}