diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 1f17e6363..e650298bf 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -201,6 +201,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding `goto --all` coverage shares one 63-definition fixture across the default bare LSP array and the plain `--json` argument path; it must assert complete deterministic output without a limit and retain a separate explicit-limit assertion in the same setup. Cursor-pagination coverage for `outline` and `unused` must replay the emitted opaque token successfully, then prove that changed filters/order and a changed index-generation stamp fail with restart-required diagnostics. Discovery-pagination coverage must page compact search/symbol/file output and the opt-in search-array/languages envelopes, assert authoritative total/omitted/stable-at metadata, reject query or generation cursor mismatches, and prove that a partial `find --all` scan resumes after the last scanned line. + Shared CLI/MCP `languages` coverage must compare catalog rows with the scoped `language_capability_counts` contract, keep `catalog`, `matched_catalog`, and `indexed_workspace` counts distinct through filters and pagination, verify explicit scope/capability labels, and retain zero-index, partial-index, alias, and workspace-plugin catalog cases. Inspect graph-section coverage must compare name and path/line resolution through the same persisted candidate ID, keep ambiguous overload and partial-family bundles isolated, assert independent total/returned/truncated metadata for references, callers, and callees (including empty sections), and replay a query-, page-size-, and generation-bound cursor across the smallest two-row page boundary. Put inbound callers in another file to prove the location path is only a locator, seed equal-rank same-line callees to pin the complete identity tie-breakers, reject page-size changes before candidate lookup, reject inspect cursors in another command, and verify the same envelopes in MCP `analyze_symbol`. Quiet-flag coverage in `ProgramCliTests.cs` reuses one seeded symbols database across text, NDJSON, and JSON-array modes and compares stdout with and without a trailing quiet alias, proving that quiet mode changes only informational stderr. Doctor full-inventory coverage keeps composed filter selection, filtered summary counts, exact UTF-8 byte-budget boundaries, and structured overflow errors together in `ProgramRunnerTests`; license JSON remains a subprocess contract in `ProgramCliTests` so immediate-command dispatch and the published field names are both exercised. @@ -1145,6 +1146,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" `goto --all` の coverage は、既定の bare LSP array と plain `--json` 引数経路で 1 つの 63 定義 fixture を共有し、limit 未指定時の完全かつ決定的な出力と、同じ setup 内での明示 limit の維持を検証してください。 `outline` と `unused` の cursor pagination coverage では、出力された opaque token の再利用成功に加え、filter/order の変更と index-generation stamp の変更が restart-required 診断で失敗することを検証してください。 discovery pagination の coverage では compact search / symbol / file と opt-in の search array / languages envelope をページングし、authoritative な total / omitted / stable-at metadata、query / generation 不一致 cursor の拒否、partial な `find --all` scan が最後に scan した line の次から再開することを検証してください。 + CLI/MCP 共有の `languages` coverage では、catalog 行と scope 付き `language_capability_counts` 契約を比較し、filter と pagination を通して `catalog`、`matched_catalog`、`indexed_workspace` の件数を分離してください。scope/capability label の明示、zero/partial index、alias、workspace plugin による catalog 拡張も維持します。 inspect graph-section coverage では、name と path/line resolution が同じ persisted candidate ID を通ること、曖昧な overload と partial-family bundle が分離されること、references / callers / callees の独立した total / returned / truncated metadata(空 section を含む)を検証してください。inbound caller は別ファイルに置いて location path が locator にすぎないことを証明し、最小の2行 page 境界で query / page size / generation に束縛された cursor を再利用します。同順位かつ同じ行の callee で完全な identity tie-breaker を固定し、candidate lookup より前に page-size 変更を拒否し、別 command では inspect cursor を拒否してください。MCP `analyze_symbol` でも同じ envelope を確認します。 `ProgramCliTests.cs` の quiet flag coverage は1つの seeded symbols database を text、NDJSON、JSON array の各 mode で再利用し、末尾に quiet alias を付けた場合と付けない場合の stdout を比較して、quiet mode が informational stderr だけを変えることを固定します。 doctor full-inventory coverage では、合成 filter の選択、filtered summary 件数、UTF-8 byte budget の exact boundary、structured overflow error を `ProgramRunnerTests` にまとめます。license JSON は `ProgramCliTests` の subprocess contract として、immediate-command dispatch と公開 field 名を同時に検証します。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index a896743b2..702a3780f 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2505,6 +2505,8 @@ The database reflects the working tree at the time of the last index. After swit ## Supported languages +CLI JSON and MCP `languages` responses share one catalog snapshot and expose `language_capability_counts`. Every count carries an explicit `scope`, `capability`, `count`, and `available` field. The `catalog` scope describes the complete runtime catalog, `matched_catalog` describes rows remaining after language/capability/indexed filters but before pagination, and `indexed_workspace` describes catalog languages currently present in the configured database. Capabilities are reported separately for detection, symbol extraction, reference extraction, outline, and graph queries, so catalog size and indexed-language totals are never interchangeable. + All indexed languages are searchable through FTS5. Rows with **Symbols = yes** also support structured queries by function, class, import, or language-specific symbol name. Use `cdidx languages --indexed-only --json` to list only languages present in the current DB; JSON rows expose `symbol_extraction`, `reference_extraction`, `graph_queries`, `capability_gaps`, `unsupported_guidance`, and `indexed_file_count`. Pattern capabilities are split into suffix-only `extensions`, literal `exact_filenames`, and `filename_prefix_patterns` whose `` placeholder requires one or more trailing characters. `legacy_patterns` retains the previous combined list for a deprecation window, while `pattern_provenance` reports each pattern's kind and `built_in`, `plugin_or_pattern`, or `language_map_override` source. The top-level `detection_policy` reports that extensions remain case-insensitive while exact filenames and filename-prefix patterns follow the indexed filesystem's `path_case_sensitive` policy; its `precedence` array records that a trusted language-map suffix override wins before built-in exact-filename, prefix-pattern, and extension rules. `language_map_diagnostics` reports stable `code`, sanitized `config`, `reason`, and `blocks_parent_fallback` fields. If the closest workspace `.cdidx-langmap.yaml` cannot be probed or read, cdidx reports the failure and does not silently inherit a parent workspace map for that subtree. When references or graph queries are unsupported, `unsupported_guidance` explains why empty reference/graph results are not authoritative and lists fallback commands. Add `--language `, `--extension `, or `--alias ` to retrieve one language row by canonical name, recognized suffix extension, or display alias. Add `--capability graph|references|symbols|missing-graph|missing-references|missing-symbols|search-only` to narrow the table to languages that support a structured capability or still have a capability gap. YAML reference extraction recognizes GitHub Actions `uses` targets, `needs` job edges, and unambiguous repository-local script/project paths in `run` steps. JSON reference extraction recognizes conservative repository-local file paths in string values, including paths embedded in command strings, and emits them as `project_reference` edges; URLs, parent-directory paths, and ambiguous bare filenames are ignored. | Language | Extensions | Symbols | @@ -5743,6 +5745,8 @@ indexing はファイル単位の SQLite transaction を commit します。長 ## 対応言語 +CLI JSON と MCP の `languages` response は同じ catalog snapshot を共有し、`language_capability_counts` を返します。各件数には `scope`、`capability`、`count`、`available` が明示されます。`catalog` scope は runtime catalog 全体、`matched_catalog` は言語・capability・indexed filter 適用後かつ pagination 前の行、`indexed_workspace` は設定済み database に現在存在する catalog 言語を表します。detection、symbol extraction、reference extraction、outline、graph query の capability は個別に集計されるため、catalog size と indexed-language total を同じ件数として扱うことはできません。 + 全言語が FTS5 全文検索に対応しています。**シンボル = yes** の行は、関数・クラス・import 名などの構造化検索にも対応します。現在の DB に存在する言語だけを一覧するには `cdidx languages --indexed-only --json` を使います。JSON 行には `symbol_extraction`、`reference_extraction`、`graph_queries`、`capability_gaps`、`unsupported_guidance`、`indexed_file_count` が含まれます。pattern capability は suffix だけを含む `extensions`、literal な `exact_filenames`、1 文字以上の末尾文字列を要求する `` placeholder 付き `filename_prefix_patterns` に分離されます。`legacy_patterns` は deprecation 期間のため従来の combined list を保持し、`pattern_provenance` は各 pattern の kind と `built_in`、`plugin_or_pattern`、`language_map_override` source を示します。top-level の `detection_policy` は、拡張子が引き続き case-insensitive である一方、完全一致ファイル名と filename-prefix pattern が index 対象 filesystem の `path_case_sensitive` ポリシーに従うことを示します。`precedence` 配列は、信頼済み language-map suffix override が built-in の完全一致 filename、prefix pattern、extension rule より先に適用されることを示します。`language_map_diagnostics` は安定した `code`、sanitization 済み `config`、`reason`、`blocks_parent_fallback` を返します。最も近い workspace の `.cdidx-langmap.yaml` を probe または read できない場合、cdidx は失敗を報告し、その subtree で親 workspace map を暗黙に継承しません。参照抽出やグラフクエリが未対応の場合、`unsupported_guidance` は空の参照/グラフ結果を根拠として扱えない理由と代替コマンドを示します。言語名・認識済み suffix extension・表示 alias から 1 行を取得するには `--language `、`--extension `、`--alias ` を追加してください。YAML の参照抽出は GitHub Actions の `uses` target、`needs` job edge、`run` step 内の明確なリポジトリローカル script / project path を認識します。JSON の参照抽出は command 文字列内を含む string value から保守的にリポジトリローカル file path を認識し、`project_reference` edge として記録します。URL、親ディレクトリ path、曖昧な basename だけのファイル名は無視します。 | 言語 | 拡張子 | シンボル | diff --git a/changelog.d/unreleased/4895.fixed.md b/changelog.d/unreleased/4895.fixed.md new file mode 100644 index 000000000..24f665782 --- /dev/null +++ b/changelog.d/unreleased/4895.fixed.md @@ -0,0 +1,25 @@ +--- +category: fixed +issues: + - 4895 +affected: + - src/CodeIndex/Models/LanguageCapabilityCatalog.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - src/CodeIndex/Cli/QueryCommandRunner.Languages.cs + - src/CodeIndex/Mcp/McpToolCatalog.cs + - src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs + - src/CodeIndex/Mcp/McpToolHandlers.Languages.cs + - tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4730Tests.cs + - tests/CodeIndex.Tests/McpServerToolsCallTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerTests.cs + - TESTING_GUIDE.md + - USER_GUIDE.md +--- + +## English + +- **CLI and MCP language counts now share one scoped capability catalog (#4895)** — `languages` responses derive rows, human summaries, and machine-readable counts from the same runtime snapshot, explicitly separating complete catalog, matched catalog, and indexed workspace counts for detection, symbol extraction, reference extraction, outline, and graph queries. + +## 日本語 + +- **CLI と MCP の言語件数が scope 付き capability catalog を共有するようになりました (#4895)** — `languages` response の行、human summary、machine-readable count を同じ runtime snapshot から生成し、detection、symbol extraction、reference extraction、outline、graph query について catalog 全体、matched catalog、indexed workspace の件数を明示的に分離します。 diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index c81bf19c2..087eb5951 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using CodeIndex.Database; using CodeIndex.Indexer.Extensibility; @@ -798,8 +799,10 @@ internal sealed record LanguageEntryJsonResult( [property: JsonPropertyName("legacy_patterns")] List LegacyPatterns, [property: JsonPropertyName("pattern_provenance")] List PatternProvenance, [property: JsonPropertyName("aliases")] List Aliases, + [property: JsonPropertyName("detection")] bool Detection, [property: JsonPropertyName("symbol_extraction")] bool SymbolExtraction, [property: JsonPropertyName("reference_extraction")] bool ReferenceExtraction, + [property: JsonPropertyName("outline")] bool Outline, [property: JsonPropertyName("graph_queries")] bool GraphQueries, [property: JsonPropertyName("capability_gaps")] List CapabilityGaps, [property: JsonPropertyName("unsupported_guidance")] List UnsupportedGuidance, @@ -825,6 +828,7 @@ internal sealed record LanguageMapDiagnosticJsonResult( internal sealed record LanguagesJsonResult( [property: JsonPropertyName("languages")] List Languages, + [property: JsonPropertyName("language_capability_counts")] JsonObject LanguageCapabilityCounts, [property: JsonPropertyName("detection_policy")] LanguageDetectionPolicyJsonResult DetectionPolicy, [property: JsonPropertyName("language_map_diagnostics")] List LanguageMapDiagnostics, [property: JsonPropertyName("reference_extraction_limits")] ReferenceExtractionSafetyLimits ReferenceExtractionLimits, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Languages.cs b/src/CodeIndex/Cli/QueryCommandRunner.Languages.cs index 9e2619d05..b3030f64a 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Languages.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Languages.cs @@ -4,7 +4,6 @@ using System.Text.Json.Nodes; using CodeIndex.Database; using CodeIndex.Indexer; -using CodeIndex.Indexer.Extensibility; using CodeIndex.Models; namespace CodeIndex.Cli; @@ -38,102 +37,25 @@ public static int RunLanguages(string[] cmdArgs, JsonSerializerOptions jsonOptio return WithDb(options, jsonOptions, reader => { var status = reader.GetStatus(includeDatabaseSizeAttribution: false); - var catalog = BuildLanguageCatalog(reader.GetIndexedProjectRoot()); - var indexedLanguageCounts = loadIndexedCounts ? status.Languages : null; + var catalog = LanguageCapabilityCatalog.Build(reader.GetIndexedProjectRoot(), GetLanguageAliases); + var indexedLanguageCounts = status.Languages; return WriteLanguages( SelectLanguages(catalog.Languages, indexedLanguageCounts), - catalog.Languages.Count, + catalog, indexedLanguageCounts, catalog.Diagnostics); }); } - var defaultCatalog = BuildLanguageCatalog(workspaceRoot: null); + var defaultCatalog = LanguageCapabilityCatalog.Build(workspaceRoot: null, GetLanguageAliases); return WriteLanguages( SelectLanguages(defaultCatalog.Languages, indexedLanguageCounts: null), - defaultCatalog.Languages.Count, + defaultCatalog, indexedLanguageCounts: null, defaultCatalog.Diagnostics); - (List> Languages, IReadOnlyList Diagnostics) - BuildLanguageCatalog(string? workspaceRoot) - { - ExtractorPluginRegistry.LoadPatternConfigsForProjectRoot(workspaceRoot); - var languagePatterns = FileIndexer.GetLanguagePatterns(workspaceRoot, out var diagnostics); - var symbolLangs = SymbolExtractor.GetSupportedLanguages(workspaceRoot); - var graphLangs = ReferenceExtractor.GetSupportedLanguages(workspaceRoot); - - // Build a consolidated view: language -> capability flags and gaps. - // 統合ビュー: 言語 -> capability flag と gap。 - var allLangs = new Dictionary(StringComparer.Ordinal); - foreach (var pattern in languagePatterns) - { - var lang = pattern.Language; - if (!allLangs.TryGetValue(lang, out var info)) - { - var hasSymbols = symbolLangs.Contains(lang); - var hasReferences = graphLangs.Contains(lang); - info = new LanguageSupportInfo( - [], - [], - [], - [], - [], - GetLanguageAliases(lang).ToList(), - hasSymbols, - hasReferences, - hasReferences, - LanguageCapabilitySupport.BuildGaps(hasSymbols, hasReferences, hasReferences), - LanguageCapabilitySupport.BuildUnsupportedGuidance(lang, hasSymbols, hasReferences, hasReferences)); - allLangs[lang] = info; - } - - switch (pattern.Kind) - { - case FileIndexer.LanguagePatternKind.Extension: - info.Extensions.Add(pattern.Pattern); - break; - case FileIndexer.LanguagePatternKind.ExactFilename: - info.ExactFilenames.Add(pattern.Pattern); - break; - case FileIndexer.LanguagePatternKind.FilenamePrefixPattern: - info.FilenamePrefixPatterns.Add(pattern.Pattern); - break; - } - if (!info.LegacyPatterns.Contains(pattern.Pattern, StringComparer.Ordinal)) - info.LegacyPatterns.Add(pattern.Pattern); - info.PatternProvenance.Add(new LanguagePatternProvenanceJsonResult( - pattern.Pattern, - GetLanguagePatternKindName(pattern.Kind), - pattern.Source)); - } - - foreach (var lang in FileIndexer.GetContentDetectedLanguageBuckets()) - { - if (allLangs.ContainsKey(lang)) - continue; - - var hasSymbols = symbolLangs.Contains(lang); - var hasReferences = graphLangs.Contains(lang); - allLangs[lang] = new LanguageSupportInfo( - [], - [], - [], - [], - [], - GetLanguageAliases(lang).ToList(), - hasSymbols, - hasReferences, - hasReferences, - LanguageCapabilitySupport.BuildGaps(hasSymbols, hasReferences, hasReferences), - LanguageCapabilitySupport.BuildUnsupportedGuidance(lang, hasSymbols, hasReferences, hasReferences)); - } - - return (allLangs.OrderBy(kv => kv.Key).ToList(), diagnostics); - } - - IEnumerable> SelectLanguages( - IEnumerable> languages, + IEnumerable> SelectLanguages( + IEnumerable> languages, IReadOnlyDictionary? indexedLanguageCounts) { var selected = languages; @@ -145,8 +67,8 @@ IEnumerable> SelectLanguages( } int WriteLanguages( - IEnumerable> languages, - int totalLanguageCount, + IEnumerable> languages, + LanguageCapabilityCatalogSnapshot catalog, IReadOnlyDictionary? indexedLanguageCounts, IReadOnlyList languageMapDiagnostics) { @@ -154,11 +76,15 @@ int WriteLanguages( .Where(kv => options.LanguageCapabilities.All(capability => LanguageMatchesCapability(kv.Value, capability))) .OrderBy(kv => kv.Key, StringComparer.Ordinal) .ToList(); + var scopedCounts = LanguageCapabilityCatalog.Count( + catalog.Languages, + filtered, + indexedLanguageCounts); if (json && (options.SummaryOnly || options.CountOnly || options.OutputFormat == OutputFormatCount)) { var payload = BuildLanguageSummaryPayload( filtered, - totalLanguageCount, + catalog.Languages, indexedLanguageCounts, options, languageMapDiagnostics); @@ -196,19 +122,26 @@ int WriteLanguages( kv.Value.FilenamePrefixPatterns.OrderBy(value => value, StringComparer.Ordinal).ToList(), kv.Value.LegacyPatterns.OrderBy(value => value, StringComparer.Ordinal).ToList(), kv.Value.PatternProvenance - .OrderBy(value => value.Kind, StringComparer.Ordinal) + .OrderBy(value => value.Kind) .ThenBy(value => value.Pattern, StringComparer.Ordinal) + .Select(value => new LanguagePatternProvenanceJsonResult( + value.Pattern, + GetLanguagePatternKindName(value.Kind), + value.Source)) .ToList(), kv.Value.Aliases.OrderBy(a => a).ToList(), + kv.Value.Detection, kv.Value.Symbols, kv.Value.References, + kv.Value.Outline, kv.Value.Graph, - kv.Value.CapabilityGaps, - kv.Value.UnsupportedGuidance, + kv.Value.CapabilityGaps.ToList(), + kv.Value.UnsupportedGuidance.ToList(), GetIndexedLanguageCount(indexedLanguageCounts, kv.Key))).ToList(); Console.WriteLine(SerializeQueryJson( new LanguagesJsonResult( entries, + scopedCounts.ToJson(), BuildLanguageDetectionPolicy(), BuildLanguageMapDiagnostics(languageMapDiagnostics), ReferenceExtractor.GetSafetyLimits()), @@ -264,26 +197,13 @@ int WriteLanguages( Console.WriteLine($" Gaps: {string.Join(", ", info.CapabilityGaps)}"); } } - CommandErrorWriter.WriteStderr($"\n({filtered.Count} languages)"); + CommandErrorWriter.WriteStderr($"\n({scopedCounts.FormatSummary()})"); } return CommandExitCodes.Success; } } - private sealed record LanguageSupportInfo( - List Extensions, - List ExactFilenames, - List FilenamePrefixPatterns, - List LegacyPatterns, - List PatternProvenance, - List Aliases, - bool Symbols, - bool References, - bool Graph, - List CapabilityGaps, - List UnsupportedGuidance); - private static string GetLanguagePatternKindName(FileIndexer.LanguagePatternKind kind) => kind switch { @@ -342,18 +262,18 @@ private static bool ShouldLoadLanguageIndexedCounts(QueryCommandOptions options) return indexedLanguageCounts.TryGetValue(lang, out var count) ? count : 0; } - private static bool LanguageMatchesLookup(string lang, LanguageSupportInfo language, QueryCommandOptions options) + private static bool LanguageMatchesLookup(string lang, LanguageCatalogSupportInfo language, QueryCommandOptions options) => options.LanguageLookups.Any(lookup => string.Equals(DbReader.NormalizeQueryLanguage(lookup), lang, StringComparison.Ordinal)) || options.LanguageExtensionLookups.Any(lookup => LanguageMatchesExtensionLookup(language, lookup)) || options.LanguageAliasLookups.Any(lookup => LanguageMatchesAliasLookup(language, lookup)); - private static bool LanguageMatchesExtensionLookup(LanguageSupportInfo language, string lookup) + private static bool LanguageMatchesExtensionLookup(LanguageCatalogSupportInfo language, string lookup) { var normalized = NormalizeLanguageLookupKey(lookup); return language.Extensions.Any(ext => string.Equals(NormalizeLanguageLookupKey(ext), normalized, StringComparison.Ordinal)); } - private static bool LanguageMatchesAliasLookup(LanguageSupportInfo language, string lookup) + private static bool LanguageMatchesAliasLookup(LanguageCatalogSupportInfo language, string lookup) { var normalized = NormalizeLanguageLookupKey(lookup); return language.Aliases.Any(alias => string.Equals(NormalizeLanguageLookupKey(alias), normalized, StringComparison.Ordinal)); @@ -371,7 +291,7 @@ private static string NormalizeLanguageLookupKey(string value) return builder.ToString(); } - private static bool LanguageMatchesCapability(LanguageSupportInfo language, string capability) + private static bool LanguageMatchesCapability(LanguageCatalogSupportInfo language, string capability) => capability switch { LanguageCapabilityAll => language.Symbols && language.References && language.Graph, @@ -404,19 +324,24 @@ LanguageCapabilityMissingSymbols or } private static JsonObject BuildLanguageSummaryPayload( - IReadOnlyList> languages, - int totalLanguageCount, + IReadOnlyList> languages, + IReadOnlyList> catalogLanguages, IReadOnlyDictionary? indexedLanguageCounts, QueryCommandOptions options, IReadOnlyList languageMapDiagnostics) { + var scopedCounts = LanguageCapabilityCatalog.Count( + catalogLanguages, + languages, + indexedLanguageCounts); var payload = new JsonObject { ["api_version"] = JsonOutputContract.ApiVersion, ["count"] = languages.Count, ["language_count"] = languages.Count, - ["total_language_count"] = totalLanguageCount, + ["total_language_count"] = catalogLanguages.Count, ["capability_counts"] = BuildLanguageCapabilityCounts(languages), + ["language_capability_counts"] = scopedCounts.ToJson(), ["detection_policy"] = BuildLanguageDetectionPolicyNode(), ["language_map_diagnostics"] = BuildLanguageMapDiagnosticNode(languageMapDiagnostics), }; @@ -453,13 +378,13 @@ private static JsonObject BuildLanguageSummaryPayload( return payload; } - private static JsonObject BuildLanguageCapabilityCounts(IReadOnlyList> languages) + private static JsonObject BuildLanguageCapabilityCounts(IReadOnlyList> languages) { - static bool HasAll(LanguageSupportInfo language) + static bool HasAll(LanguageCatalogSupportInfo language) => language.Symbols && language.References && language.Graph; - static bool HasNone(LanguageSupportInfo language) + static bool HasNone(LanguageCatalogSupportInfo language) => !language.Symbols && !language.References && !language.Graph; - static bool IsSymbolOnly(LanguageSupportInfo language) + static bool IsSymbolOnly(LanguageCatalogSupportInfo language) => language.Symbols && !language.References && !language.Graph; return new JsonObject @@ -467,8 +392,10 @@ static bool IsSymbolOnly(LanguageSupportInfo language) ["all"] = languages.Count(kv => HasAll(kv.Value)), ["none"] = languages.Count(kv => HasNone(kv.Value)), ["search_only"] = languages.Count(kv => HasNone(kv.Value)), + ["detection"] = languages.Count(kv => kv.Value.Detection), ["symbols"] = languages.Count(kv => kv.Value.Symbols), ["references"] = languages.Count(kv => kv.Value.References), + ["outline"] = languages.Count(kv => kv.Value.Outline), ["graph"] = languages.Count(kv => kv.Value.Graph), ["symbol_only"] = languages.Count(kv => IsSymbolOnly(kv.Value)), ["missing_any"] = languages.Count(kv => kv.Value.CapabilityGaps.Count > 0), diff --git a/src/CodeIndex/Mcp/McpToolCatalog.cs b/src/CodeIndex/Mcp/McpToolCatalog.cs index 8932ef3a0..6ba156a0b 100644 --- a/src/CodeIndex/Mcp/McpToolCatalog.cs +++ b/src/CodeIndex/Mcp/McpToolCatalog.cs @@ -409,7 +409,7 @@ private static JsonArray CreateToolCatalog() ReadOnlyAnnotations()), CreateToolDefinition( "languages", - "List supported languages with extensions, aliases, capabilities, and unsupported_guidance fallback commands. Use `indexedOnly`, `capability`, `extension`, or `alias` to match CLI language filters and extension lookup. / 対応言語一覧を拡張子・別名・機能・`unsupported_guidance` の代替コマンド付きで返す。`indexedOnly` / `capability` / `extension` / `alias` で CLI の言語フィルタと拡張子 lookup に合わせて絞り込める。", + "List supported languages with extensions, aliases, capabilities, scoped `language_capability_counts`, and unsupported_guidance fallback commands. Counts explicitly separate the catalog, matched catalog, and indexed workspace scopes. Use `indexedOnly`, `capability`, `extension`, or `alias` to match CLI language filters and extension lookup. / 対応言語一覧を拡張子・別名・機能・scope 付き `language_capability_counts`・`unsupported_guidance` の代替コマンド付きで返す。件数は catalog、matched catalog、indexed workspace の scope を明示的に分離する。`indexedOnly` / `capability` / `extension` / `alias` で CLI の言語フィルタと拡張子 lookup に合わせて絞り込める。", new JsonObject { ["type"] = "object", diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs b/src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs index aacf6b1ea..3850f3c42 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Instructions.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; +using CodeIndex.Cli; using CodeIndex.Database; using CodeIndex.Indexer; using CodeIndex.Models; @@ -65,7 +66,17 @@ bool All(params string[] names) if (On(name)) graphEnabled.Add(name); if (graphEnabled.Count > 0) { - var langs = string.Join(", ", ReferenceExtractor.GetSupportedLanguages()); + var languageCatalog = LanguageCapabilityCatalog.Build( + workspaceRoot: null, + QueryCommandRunner.GetLanguageAliases); + var referenceLanguageNames = ReferenceExtractor.GetSupportedLanguages(); + var langs = string.Join( + ", ", + languageCatalog.Languages + .Where(language => language.Value.Graph) + .SelectMany(language => new[] { language.Key } + .Concat(language.Value.Aliases.Where(referenceLanguageNames.Contains))) + .Distinct(StringComparer.Ordinal)); var names = string.Join(", ", graphEnabled); var sentence = $"Graph tools ({names}) only work for supported languages ({langs});"; sentence += On("search") diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Languages.cs b/src/CodeIndex/Mcp/McpToolHandlers.Languages.cs index 652f4acc5..e83403ebd 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Languages.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Languages.cs @@ -1,8 +1,8 @@ using System.Text.Json; using System.Text.Json.Nodes; using CodeIndex.Cli; +using CodeIndex.Database; using CodeIndex.Indexer; -using CodeIndex.Indexer.Extensibility; using CodeIndex.Models; namespace CodeIndex.Mcp; @@ -30,81 +30,17 @@ private JsonNode ExecuteLanguages(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, $"Invalid language capability '{capability}'. Use one of: symbols, graph, references."); } - (Dictionary Languages, int SymbolLanguageCount, int ReferenceLanguageCount, IReadOnlyList Diagnostics) BuildCatalog(string? workspaceRoot) + JsonNode BuildResponse( + IReadOnlyDictionary? indexedLanguageCounts, + string? workspaceRoot) { - ExtractorPluginRegistry.LoadPatternConfigsForProjectRoot(workspaceRoot); - var languagePatterns = FileIndexer.GetLanguagePatterns(workspaceRoot, out var diagnostics); - var symbolLangs = SymbolExtractor.GetSupportedLanguages(workspaceRoot); - var referenceLangs = ReferenceExtractor.GetSupportedLanguages(workspaceRoot); - var languages = new Dictionary(StringComparer.Ordinal); - foreach (var pattern in languagePatterns) - { - var lang = pattern.Language; - if (!languages.TryGetValue(lang, out var info)) - { - var hasSymbols = symbolLangs.Contains(lang); - var hasReferences = referenceLangs.Contains(lang); - info = new McpLanguageSupportInfo( - [], - [], - [], - [], - [], - QueryCommandRunner.GetLanguageAliases(lang).ToList(), - hasSymbols, - hasReferences, - hasReferences, - LanguageCapabilitySupport.BuildGaps(hasSymbols, hasReferences, hasReferences), - LanguageCapabilitySupport.BuildUnsupportedGuidance(lang, hasSymbols, hasReferences, hasReferences)); - languages[lang] = info; - } - - switch (pattern.Kind) - { - case FileIndexer.LanguagePatternKind.Extension: - info.Extensions.Add(pattern.Pattern); - break; - case FileIndexer.LanguagePatternKind.ExactFilename: - info.ExactFilenames.Add(pattern.Pattern); - break; - case FileIndexer.LanguagePatternKind.FilenamePrefixPattern: - info.FilenamePrefixPatterns.Add(pattern.Pattern); - break; - } - if (!info.LegacyPatterns.Contains(pattern.Pattern, StringComparer.Ordinal)) - info.LegacyPatterns.Add(pattern.Pattern); - info.PatternProvenance.Add(pattern); - } - - foreach (var lang in FileIndexer.GetContentDetectedLanguageBuckets()) - { - if (languages.ContainsKey(lang)) - continue; - - var hasSymbols = symbolLangs.Contains(lang); - var hasReferences = referenceLangs.Contains(lang); - languages[lang] = new McpLanguageSupportInfo( - [], - [], - [], - [], - [], - QueryCommandRunner.GetLanguageAliases(lang).ToList(), - hasSymbols, - hasReferences, - hasReferences, - LanguageCapabilitySupport.BuildGaps(hasSymbols, hasReferences, hasReferences), - LanguageCapabilitySupport.BuildUnsupportedGuidance(lang, hasSymbols, hasReferences, hasReferences)); - } - - return (languages, symbolLangs.Count, referenceLangs.Count, diagnostics); - } - - JsonNode BuildResponse(HashSet? indexedLanguages, string? workspaceRoot) - { - var catalog = BuildCatalog(workspaceRoot); + var catalog = LanguageCapabilityCatalog.Build( + workspaceRoot, + QueryCommandRunner.GetLanguageAliases); var sorted = catalog.Languages - .Where(kv => !indexedOnly || indexedLanguages?.Contains(kv.Key) == true) + .Where(kv => !indexedOnly + || (indexedLanguageCounts?.TryGetValue(kv.Key, out var indexedCount) == true + && indexedCount > 0)) .Where(kv => capabilities.All(capability => LanguageMatchesCapability(kv.Value.Symbols, kv.Value.References, kv.Value.Graph, capability))) .Where(kv => normalizedExtension is null || kv.Value.Extensions.Contains(normalizedExtension, StringComparer.OrdinalIgnoreCase)) .Where(kv => aliasFilter is null @@ -162,7 +98,7 @@ JsonNode BuildResponse(HashSet? indexedLanguages, string? workspaceRoot) }); } - languagesArray.Add(new JsonObject + var languageRow = new JsonObject { ["lang"] = lang, ["extensions"] = extArray, @@ -171,17 +107,32 @@ JsonNode BuildResponse(HashSet? indexedLanguages, string? workspaceRoot) ["legacy_patterns"] = legacyPatternArray, ["pattern_provenance"] = provenanceArray, ["aliases"] = new JsonArray(info.Aliases.OrderBy(alias => alias, StringComparer.Ordinal).Select(alias => JsonValue.Create(alias)).ToArray()), + ["detection"] = info.Detection, ["symbol_extraction"] = info.Symbols, ["reference_extraction"] = info.References, + ["outline"] = info.Outline, ["graph_queries"] = info.Graph, ["capability_gaps"] = new JsonArray(info.CapabilityGaps.Select(gap => JsonValue.Create(gap)).ToArray()), ["unsupported_guidance"] = guidanceArray, - }); + }; + if (indexedLanguageCounts is not null) + { + languageRow["indexed_file_count"] = indexedLanguageCounts.TryGetValue(lang, out var indexedFileCount) + ? indexedFileCount + : 0; + } + languagesArray.Add(languageRow); } + var scopedCounts = LanguageCapabilityCatalog.Count( + catalog.Languages, + sorted, + indexedLanguageCounts); var payload = new JsonObject { + ["api_version"] = JsonOutputContract.ApiVersion, ["languages"] = languagesArray, + ["language_capability_counts"] = scopedCounts.ToJson(), ["detection_policy"] = new JsonObject { ["filename_case_policy"] = "filesystem", @@ -227,8 +178,10 @@ JsonNode BuildResponse(HashSet? indexedLanguages, string? workspaceRoot) }; } - var summary = $"{sorted.Count} languages supported. {catalog.SymbolLanguageCount} with symbol extraction, {catalog.ReferenceLanguageCount} with reference extraction, {catalog.ReferenceLanguageCount} with call-graph queries."; - return CreateToolResult(id, summary, payload); + return CreateToolResult( + id, + $"{sorted.Count} languages supported in matched catalog. {scopedCounts.FormatSummary()}.", + payload); } var configuredDatabaseAvailable = _dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase) @@ -240,25 +193,7 @@ JsonNode BuildResponse(HashSet? indexedLanguages, string? workspaceRoot) return WithDbReader(id, args, reader => { var status = reader.GetStatus(includeDatabaseSizeAttribution: false); - var indexedLanguages = indexedOnly - ? new HashSet(status.Languages.Keys, StringComparer.Ordinal) - : null; - return BuildResponse(indexedLanguages, reader.GetIndexedProjectRoot()); + return BuildResponse(status.Languages, reader.GetIndexedProjectRoot()); }); } - - private sealed record McpLanguageSupportInfo( - List Extensions, - List ExactFilenames, - List FilenamePrefixPatterns, - List LegacyPatterns, - List PatternProvenance, - List Aliases, - bool Symbols, - bool References, - bool Graph, - List CapabilityGaps, - List UnsupportedGuidance); - - } diff --git a/src/CodeIndex/Models/LanguageCapabilityCatalog.cs b/src/CodeIndex/Models/LanguageCapabilityCatalog.cs new file mode 100644 index 000000000..66cceffe6 --- /dev/null +++ b/src/CodeIndex/Models/LanguageCapabilityCatalog.cs @@ -0,0 +1,229 @@ +using System.Text.Json.Nodes; +using CodeIndex.Indexer; +using CodeIndex.Indexer.Extensibility; + +namespace CodeIndex.Models; + +internal sealed record LanguageCapabilityCatalogSnapshot( + IReadOnlyList> Languages, + IReadOnlyList Diagnostics); + +internal sealed record LanguageCatalogSupportInfo( + IReadOnlyList Extensions, + IReadOnlyList ExactFilenames, + IReadOnlyList FilenamePrefixPatterns, + IReadOnlyList LegacyPatterns, + IReadOnlyList PatternProvenance, + IReadOnlyList Aliases, + bool Detection, + bool Symbols, + bool References, + bool Outline, + bool Graph, + IReadOnlyList CapabilityGaps, + IReadOnlyList UnsupportedGuidance); + +internal sealed record LanguageCapabilityCountScope( + string Scope, + bool Available, + int? CatalogMembership, + int? Detection, + int? SymbolExtraction, + int? ReferenceExtraction, + int? Outline, + int? GraphQueries) +{ + public JsonObject ToJson() + => new() + { + ["catalog_membership"] = BuildCount("catalog_membership", CatalogMembership), + ["detection"] = BuildCount("detection", Detection), + ["symbol_extraction"] = BuildCount("symbol_extraction", SymbolExtraction), + ["reference_extraction"] = BuildCount("reference_extraction", ReferenceExtraction), + ["outline"] = BuildCount("outline", Outline), + ["graph_queries"] = BuildCount("graph_queries", GraphQueries), + }; + + private JsonObject BuildCount(string capability, int? count) + => new() + { + ["scope"] = Scope, + ["capability"] = capability, + ["count"] = count.HasValue ? JsonValue.Create(count.Value) : null, + ["available"] = Available, + }; +} + +internal sealed record LanguageCapabilityCountSnapshot( + LanguageCapabilityCountScope Catalog, + LanguageCapabilityCountScope MatchedCatalog, + LanguageCapabilityCountScope IndexedWorkspace) +{ + public JsonObject ToJson() + => new() + { + ["catalog"] = Catalog.ToJson(), + ["matched_catalog"] = MatchedCatalog.ToJson(), + ["indexed_workspace"] = IndexedWorkspace.ToJson(), + }; + + public string FormatSummary() + { + var catalogCount = Catalog.CatalogMembership.GetValueOrDefault(); + var matchedCount = MatchedCatalog.CatalogMembership.GetValueOrDefault(); + var indexedSummary = IndexedWorkspace.Available + ? $"{IndexedWorkspace.CatalogMembership.GetValueOrDefault()} indexed workspace languages" + : "indexed workspace language count unavailable"; + return $"{catalogCount} catalog languages; " + + $"{Catalog.SymbolExtraction.GetValueOrDefault()} with symbol extraction; " + + $"{Catalog.ReferenceExtraction.GetValueOrDefault()} with reference extraction; " + + $"{Catalog.Outline.GetValueOrDefault()} with outline support; " + + $"{Catalog.GraphQueries.GetValueOrDefault()} with graph queries; {indexedSummary}; " + + $"{matchedCount} matching catalog languages"; + } +} + +internal static class LanguageCapabilityCatalog +{ + public static LanguageCapabilityCatalogSnapshot Build( + string? workspaceRoot, + Func> getAliases) + { + ExtractorPluginRegistry.LoadPatternConfigsForProjectRoot(workspaceRoot); + var languagePatterns = FileIndexer.GetLanguagePatterns(workspaceRoot, out var diagnostics); + var symbolLanguages = SymbolExtractor.GetSupportedLanguages(workspaceRoot); + var referenceLanguages = ReferenceExtractor.GetSupportedLanguages(workspaceRoot); + var languages = new Dictionary(StringComparer.Ordinal); + + MutableLanguageCatalogSupportInfo CreateSupportInfo(string language) + { + var hasSymbols = symbolLanguages.Contains(language); + var hasReferences = referenceLanguages.Contains(language); + return new MutableLanguageCatalogSupportInfo( + getAliases(language), + Detection: true, + Symbols: hasSymbols, + References: hasReferences, + Outline: hasSymbols, + Graph: hasReferences, + LanguageCapabilitySupport.BuildGaps(hasSymbols, hasReferences, hasReferences), + LanguageCapabilitySupport.BuildUnsupportedGuidance(language, hasSymbols, hasReferences, hasReferences)); + } + + foreach (var pattern in languagePatterns) + { + var language = pattern.Language; + if (!languages.TryGetValue(language, out var info)) + { + info = CreateSupportInfo(language); + languages[language] = info; + } + + switch (pattern.Kind) + { + case FileIndexer.LanguagePatternKind.Extension: + info.Extensions.Add(pattern.Pattern); + break; + case FileIndexer.LanguagePatternKind.ExactFilename: + info.ExactFilenames.Add(pattern.Pattern); + break; + case FileIndexer.LanguagePatternKind.FilenamePrefixPattern: + info.FilenamePrefixPatterns.Add(pattern.Pattern); + break; + } + if (!info.LegacyPatterns.Contains(pattern.Pattern, StringComparer.Ordinal)) + info.LegacyPatterns.Add(pattern.Pattern); + info.PatternProvenance.Add(pattern); + } + + foreach (var language in FileIndexer.GetContentDetectedLanguageBuckets()) + { + if (!languages.ContainsKey(language)) + languages[language] = CreateSupportInfo(language); + } + + return new LanguageCapabilityCatalogSnapshot( + languages + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => new KeyValuePair( + pair.Key, + pair.Value.Freeze())) + .ToList(), + diagnostics); + } + + public static LanguageCapabilityCountSnapshot Count( + IReadOnlyList> catalog, + IReadOnlyList> matchedCatalog, + IReadOnlyDictionary? indexedLanguageCounts) + { + var indexedCatalog = indexedLanguageCounts is null + ? null + : catalog + .Where(pair => indexedLanguageCounts.TryGetValue(pair.Key, out var count) && count > 0) + .ToList(); + return new LanguageCapabilityCountSnapshot( + CountScope("catalog", catalog), + CountScope("matched_catalog", matchedCatalog), + indexedCatalog is null + ? UnavailableScope("indexed_workspace") + : CountScope("indexed_workspace", indexedCatalog)); + } + + private static LanguageCapabilityCountScope CountScope( + string scope, + IReadOnlyList> languages) + => new( + scope, + Available: true, + CatalogMembership: languages.Count, + Detection: languages.Count(pair => pair.Value.Detection), + SymbolExtraction: languages.Count(pair => pair.Value.Symbols), + ReferenceExtraction: languages.Count(pair => pair.Value.References), + Outline: languages.Count(pair => pair.Value.Outline), + GraphQueries: languages.Count(pair => pair.Value.Graph)); + + private static LanguageCapabilityCountScope UnavailableScope(string scope) + => new( + scope, + Available: false, + CatalogMembership: null, + Detection: null, + SymbolExtraction: null, + ReferenceExtraction: null, + Outline: null, + GraphQueries: null); + + private sealed class MutableLanguageCatalogSupportInfo( + IReadOnlyList aliases, + bool Detection, + bool Symbols, + bool References, + bool Outline, + bool Graph, + IReadOnlyList CapabilityGaps, + IReadOnlyList UnsupportedGuidance) + { + public List Extensions { get; } = []; + public List ExactFilenames { get; } = []; + public List FilenamePrefixPatterns { get; } = []; + public List LegacyPatterns { get; } = []; + public List PatternProvenance { get; } = []; + + public LanguageCatalogSupportInfo Freeze() + => new( + Extensions.ToArray(), + ExactFilenames.ToArray(), + FilenamePrefixPatterns.ToArray(), + LegacyPatterns.ToArray(), + PatternProvenance.ToArray(), + aliases.ToArray(), + Detection, + Symbols, + References, + Outline, + Graph, + CapabilityGaps.ToArray(), + UnsupportedGuidance.ToArray()); + } +} diff --git a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4730Tests.cs b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4730Tests.cs index ed8aa40e0..0585149c8 100644 --- a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4730Tests.cs +++ b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4730Tests.cs @@ -237,6 +237,15 @@ public void SymbolsFilesAndLanguages_PageWithGenerationBoundCursors_Issue4730() Assert.False(string.IsNullOrWhiteSpace(languageMetadata.GetProperty("result_stable_at").GetString())); Assert.Single(languageDocument.RootElement.GetProperty("results").EnumerateArray()); Assert.True(languageDocument.RootElement.GetProperty("results")[0].TryGetProperty("exact_filenames", out _)); + var scopedCounts = languageMetadata + .GetProperty("response_context") + .GetProperty("language_capability_counts"); + Assert.Equal( + languageMetadata.GetProperty("total_count").GetInt32(), + scopedCounts.GetProperty("catalog").GetProperty("catalog_membership").GetProperty("count").GetInt32()); + Assert.Equal( + 1, + scopedCounts.GetProperty("indexed_workspace").GetProperty("catalog_membership").GetProperty("count").GetInt32()); var languageCursor = languageMetadata.GetProperty("next_cursor").GetString()!; var firstLanguage = languageDocument.RootElement.GetProperty("results")[0].GetProperty("lang").GetString(); @@ -247,8 +256,12 @@ public void SymbolsFilesAndLanguages_PageWithGenerationBoundCursors_Issue4730() Assert.Equal(string.Empty, nextLanguageStderr); using (var nextLanguageDocument = JsonDocument.Parse(nextLanguageStdout)) { - Assert.Equal(1, nextLanguageDocument.RootElement.GetProperty("metadata").GetProperty("cursor_offset").GetInt32()); + var nextMetadata = nextLanguageDocument.RootElement.GetProperty("metadata"); + Assert.Equal(1, nextMetadata.GetProperty("cursor_offset").GetInt32()); Assert.NotEqual(firstLanguage, nextLanguageDocument.RootElement.GetProperty("results")[0].GetProperty("lang").GetString()); + Assert.Equal( + scopedCounts.GetRawText(), + nextMetadata.GetProperty("response_context").GetProperty("language_capability_counts").GetRawText()); } var (mismatchExitCode, mismatchStdout, mismatchStderr) = CaptureConsole(() => diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index 236264dd8..810e9b8fa 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -6505,6 +6505,10 @@ public void ToolsCall_Languages_DefaultCatalogUsesIndexedWorkspaceSnapshot_Issue entry => entry?["lang"]?.GetValue() == "mcpcatalog")!; Assert.True(language["symbol_extraction"]!.GetValue()); Assert.Contains(".mcpcatalog", language["extensions"]!.AsArray().Select(extension => extension!.GetValue())); + var scopedCounts = response["result"]!["structuredContent"]!["language_capability_counts"]!; + Assert.Equal( + response["result"]!["structuredContent"]!["languages"]!.AsArray().Count, + scopedCounts["catalog"]!["catalog_membership"]!["count"]!.GetValue()); } finally { @@ -6539,6 +6543,66 @@ public void ToolsCall_Languages_FiltersByCliCompatibleMetadata_Issue3540() Assert.Equal(1, structured["extension_lookup"]!["matched"]!.GetValue()); Assert.Equal("csharp", Assert.Single(structured["extension_lookup"]!["languages"]!.AsArray())!.GetValue()); Assert.Equal(1, structured["alias_lookup"]!["matched"]!.GetValue()); + Assert.Equal( + 1, + structured["language_capability_counts"]!["matched_catalog"]!["catalog_membership"]!["count"]!.GetValue()); + } + + [Fact] + public void ToolsCall_Languages_UsesCanonicalScopedCapabilityCounts_Issue4895() + { + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":4895,"method":"tools/call","params":{"name":"languages","arguments":{}}}""")!; + var response = _server.HandleMessage(request)!; + + var result = response["result"]!; + var structured = result["structuredContent"]!; + var languages = structured["languages"]!.AsArray(); + var counts = structured["language_capability_counts"]!; + var expectedCatalog = LanguageCapabilityCatalog.Build( + _projectRoot, + QueryCommandRunner.GetLanguageAliases); + + var catalogMembership = counts["catalog"]!["catalog_membership"]!; + Assert.Equal("catalog", catalogMembership["scope"]!.GetValue()); + Assert.Equal("catalog_membership", catalogMembership["capability"]!.GetValue()); + Assert.True(catalogMembership["available"]!.GetValue()); + Assert.Equal(expectedCatalog.Languages.Count, catalogMembership["count"]!.GetValue()); + Assert.Equal(languages.Count, catalogMembership["count"]!.GetValue()); + + var symbolCount = counts["catalog"]!["symbol_extraction"]!; + Assert.Equal("symbol_extraction", symbolCount["capability"]!.GetValue()); + Assert.Equal( + languages.Count(language => language!["symbol_extraction"]!.GetValue()), + symbolCount["count"]!.GetValue()); + Assert.All(languages, language => + { + Assert.NotNull(language!["detection"]); + Assert.NotNull(language["outline"]); + }); + + var indexedMembership = counts["indexed_workspace"]!["catalog_membership"]!; + Assert.Equal("indexed_workspace", indexedMembership["scope"]!.GetValue()); + Assert.Equal(1, indexedMembership["count"]!.GetValue()); + Assert.Contains( + $"{languages.Count} catalog languages", + result["content"]![0]!["text"]!.GetValue(), + StringComparison.Ordinal); + } + + [Fact] + public void ToolsCall_Languages_EmptyIndexReportsAvailableZeroIndexedScope_Issue4895() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_mcp_languages_empty_counts_4895"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + using var server = new McpServer(dbPath, ConsoleUi.LoadVersion(), dbPathExplicit: true); + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":4895,"method":"tools/call","params":{"name":"languages","arguments":{}}}""")!; + + var response = server.HandleMessage(request)!; + + var indexedMembership = response["result"]!["structuredContent"]! + ["language_capability_counts"]!["indexed_workspace"]!["catalog_membership"]!; + Assert.True(indexedMembership["available"]!.GetValue()); + Assert.Equal(0, indexedMembership["count"]!.GetValue()); } [Fact] diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 94a148eb7..8735037ca 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -2864,6 +2864,13 @@ public void RunLanguages_DefaultDatabaseUsesIndexedWorkspaceExtractorSnapshot_Is Assert.Contains( ".workspacecatalog", workspaceLanguage.GetProperty("extensions").EnumerateArray().Select(extension => extension.GetString())); + var scopedCounts = document.RootElement.GetProperty("language_capability_counts"); + Assert.Equal( + document.RootElement.GetProperty("languages").GetArrayLength(), + scopedCounts.GetProperty("catalog").GetProperty("catalog_membership").GetProperty("count").GetInt32()); + Assert.Equal( + "symbol_extraction", + scopedCounts.GetProperty("catalog").GetProperty("symbol_extraction").GetProperty("capability").GetString()); } finally { @@ -2873,6 +2880,79 @@ public void RunLanguages_DefaultDatabaseUsesIndexedWorkspaceExtractorSnapshot_Is } } + [Fact] + public void RunLanguages_JsonSeparatesCatalogMatchedAndIndexedCapabilityCounts_Issue4895() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_languages_scoped_counts_4895"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.InsertIndexedFile(dbPath, "src/App.cs", "csharp", "class App { }\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "README.md", "markdown", "# App\n"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => + QueryCommandRunner.RunLanguages( + ["--json", "--db", dbPath, "--language", "csharp"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var root = document.RootElement; + var language = Assert.Single(root.GetProperty("languages").EnumerateArray()); + Assert.True(language.GetProperty("detection").GetBoolean()); + Assert.True(language.GetProperty("outline").GetBoolean()); + + var counts = root.GetProperty("language_capability_counts"); + var catalogMembership = counts.GetProperty("catalog").GetProperty("catalog_membership"); + Assert.Equal("catalog", catalogMembership.GetProperty("scope").GetString()); + Assert.Equal("catalog_membership", catalogMembership.GetProperty("capability").GetString()); + Assert.True(catalogMembership.GetProperty("available").GetBoolean()); + Assert.True(catalogMembership.GetProperty("count").GetInt32() > 1); + + var matchedMembership = counts.GetProperty("matched_catalog").GetProperty("catalog_membership"); + Assert.Equal("matched_catalog", matchedMembership.GetProperty("scope").GetString()); + Assert.Equal(1, matchedMembership.GetProperty("count").GetInt32()); + + var indexedMembership = counts.GetProperty("indexed_workspace").GetProperty("catalog_membership"); + Assert.Equal("indexed_workspace", indexedMembership.GetProperty("scope").GetString()); + Assert.Equal(2, indexedMembership.GetProperty("count").GetInt32()); + + foreach (var capability in new[] + { + "detection", + "symbol_extraction", + "reference_extraction", + "outline", + "graph_queries", + }) + { + var count = counts.GetProperty("catalog").GetProperty(capability); + Assert.Equal("catalog", count.GetProperty("scope").GetString()); + Assert.Equal(capability, count.GetProperty("capability").GetString()); + Assert.True(count.GetProperty("available").GetBoolean()); + } + } + + [Fact] + public void RunLanguages_EmptyIndexReportsAvailableZeroIndexedScope_Issue4895() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_languages_empty_counts_4895"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + + var (exitCode, stdout, stderr) = CaptureConsole(() => + QueryCommandRunner.RunLanguages(["--summary-only", "--db", dbPath], _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var indexedMembership = document.RootElement + .GetProperty("language_capability_counts") + .GetProperty("indexed_workspace") + .GetProperty("catalog_membership"); + Assert.Equal("indexed_workspace", indexedMembership.GetProperty("scope").GetString()); + Assert.True(indexedMembership.GetProperty("available").GetBoolean()); + Assert.Equal(0, indexedMembership.GetProperty("count").GetInt32()); + } + [Fact] public void RunStatus_HumanSupportSummaryUsesIndexedWorkspaceSnapshot_Issue4602() {