diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 0df1704d6..568ae9e83 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -205,6 +205,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Command-specific output format coverage uses a command/format matrix that checks both parser acceptance and the matching usage line; recognized shared formats without a command implementation need a separate usage-error assertion. Ad-hoc search SARIF completion coverage shares one fixture across complete, 1-of-126 limited, facet-filtered occurrence-expanded limited, bounded guarded, empty, and synthetically merged multi-run documents. Assert source/emitted/omitted counts and source-count authority in SARIF result units, applied limits, conservative truncation, null cursor state, raw-FTS and option-like-query replay commands, guard-preserving replay, and unchanged rule/location/severity fields on every run. Recipe SARIF coverage must assert bounded result counts, `recipe/query` rule identity, source locations, severity mapping, confidence, conservative truncation metadata, and stable `fingerprints.cdidx/v1` values across identical runs. + MCP schema-origin coverage keeps identical audit phrases in `McpToolCatalog.cs` top-level tool descriptions, concatenated description segments, nested schema-property prose, and executable C# in one indexed fixture; assert explicit `schema_description` search metadata and the recipe's JSON, SARIF, and issue-draft outputs so origin filtering cannot drift across projections. Recipe row-selection coverage reuses one multi-file, multi-chunk fixture across aggregate JSON, compact JSON, NDJSON, and issue-draft source metadata. Assert emitted/matched/omitted counts, `selection_reason` / `selection_omitted_count`, first-per-file path uniqueness, selector-preserving replay commands, suppressed raw cursors when a later limit truncates selected rows, and rejection of incoming cursors with either selector. A separate candidate-window fixture must exceed the default low-limit fetch envelope and prove that `--sample ` observes at least its requested candidate target; validate rejected selectors for non-row recipe shapes without opening a database. Unused default-suppression row, JSON count, summary-only, and text count envelopes, including the `--all` count control, share one unused-symbol fixture. Unused default-suppressed and `--all` JSON cursor pagination share one unused-symbol fixture. @@ -1123,6 +1124,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" コマンド別の出力形式 coverage は command / format matrix で parser の受理と対応する usage line の両方を検証してください。共通 parser が認識してもコマンド側に実装がない形式には、別途 usage error の assertion が必要です。 ad-hoc search SARIF の completion coverage は complete、1-of-126 の limited、facet filter 付き occurrence 展開後の limited、bounded guard、empty、合成した multi-run document で1つの fixture を共有します。SARIF result 単位の source / emitted / omitted count と source count の確定性、適用済み limit、保守的な truncation、null cursor state、raw FTS と option のような query の replay command、guard を保持する replay、および各 run で rule / location / severity field が不変であることを検証してください。 Recipe SARIF coverage では、上限付き result count、`recipe/query` rule identity、source location、severity mapping、confidence、保守的な truncation metadata、同一 run 間で安定する `fingerprints.cdidx/v1` を検証してください。 + MCP schema-origin coverage では、同一の audit phrase を `McpToolCatalog.cs` の top-level tool description、連結された description segment、nested schema property の prose、実行可能な C# に置いた1つの indexed fixture を共有し、明示的な `schema_description` 検索 metadata と recipe の JSON、SARIF、issue-draft 出力を検証して、projection 間で origin filter が drift しないようにしてください。 recipe row-selection coverage は aggregate JSON、compact JSON、NDJSON、issue-draft の source metadata で1つの multi-file / multi-chunk fixture を共有します。emitted / matched / omitted count、`selection_reason` / `selection_omitted_count`、first-per-file の path uniqueness、selector を保持する replay command、後続 limit が選択済み row を truncate する場合の raw cursor 抑止、両 selector と受け取った cursor の併用拒否を検証してください。別の candidate-window fixture では既定の low-limit fetch envelope を超え、`--sample ` が少なくとも要求 candidate 数を観測することを証明し、row を持たない recipe shape での selector 拒否は database を開かずに確認してください。 unused default-suppressionのrow、JSON count、summary-only、text count envelopeは、`--all` count controlも含めて1つのunused-symbol fixtureを共有してください。 unusedのdefault-suppressed JSON cursor paginationと`--all` JSON cursor paginationは1つのunused-symbol fixtureを共有してください。 diff --git a/changelog.d/unreleased/4864.fixed.md b/changelog.d/unreleased/4864.fixed.md new file mode 100644 index 000000000..9f74a46c5 --- /dev/null +++ b/changelog.d/unreleased/4864.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 4864 +affected: + - src/CodeIndex/Database/SearchMatchClassifier.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - TESTING_GUIDE.md +--- + +## English + +- **MCP tool-catalog schema prose no longer triggers executable audit recipes (#4864)** — top-level, concatenated, and nested schema descriptions moved into `McpToolCatalog.cs` now retain the `schema_description` origin introduced by #4416, while identical text in executable C# remains searchable audit evidence. + +## 日本語 + +- **MCP tool catalog の schema prose が実行可能コード向け audit recipe を誤検出しなくなりました (#4864)** — `McpToolCatalog.cs` へ移動した top-level、連結済み、nested の schema description でも #4416 で導入した `schema_description` origin を維持し、同一テキストが実行可能な C# にある場合は引き続き audit evidence として検索できます。 diff --git a/src/CodeIndex/Database/SearchMatchClassifier.cs b/src/CodeIndex/Database/SearchMatchClassifier.cs index fa213487f..9b97488b9 100644 --- a/src/CodeIndex/Database/SearchMatchClassifier.cs +++ b/src/CodeIndex/Database/SearchMatchClassifier.cs @@ -108,7 +108,7 @@ private static string ClassifyOrigin( var index = Math.Clamp(column - 1, 0, Math.Max(0, text.Length - 1)); var normalizedLang = lang?.ToLowerInvariant(); if (string.Equals(normalizedLang, "csharp", StringComparison.Ordinal)) - return ClassifyCSharp(path, text, index); + return ClassifyCSharp(path, line, text, index, lineContext); if (string.Equals(normalizedLang, "markdown", StringComparison.Ordinal)) { @@ -136,7 +136,12 @@ private static string ClassifyOrigin( return Code; } - private static string ClassifyCSharp(string path, string text, int index) + private static string ClassifyCSharp( + string path, + int line, + string text, + int index, + IReadOnlyDictionary? lineContext) { var trimmed = text.TrimStart(); if (trimmed.StartsWith("///", StringComparison.Ordinal) || @@ -166,10 +171,10 @@ private static string ClassifyCSharp(string path, string text, int index) { if (index >= contentStart && index <= contentEnd) { + if (LooksLikeSchemaDescription(path, line, text, contentStart, lineContext)) + return SchemaDescription; if (LooksLikeRegexString(text)) return RegexLiteral; - if (LooksLikeSchemaDescription(path, text, contentStart)) - return SchemaDescription; return LooksLikeHelpText(path, text) ? HelpText : StringLiteral; } @@ -265,10 +270,19 @@ private static bool LooksLikeHelpText(string path, string text) text.Contains("--", StringComparison.Ordinal); } - private static bool LooksLikeSchemaDescription(string path, string text, int contentStart) + private static bool LooksLikeSchemaDescription( + string path, + int line, + string text, + int contentStart, + IReadOnlyDictionary? lineContext) { - if (!string.Equals(path.Replace('\\', '/'), "src/CodeIndex/Mcp/McpToolDefinitions.cs", StringComparison.Ordinal)) + var normalizedPath = path.Replace('\\', '/'); + if (normalizedPath is not "src/CodeIndex/Mcp/McpToolDefinitions.cs" + and not "src/CodeIndex/Mcp/McpToolCatalog.cs") + { return false; + } const string descriptionProperty = "[\"description\"]"; var propertyIndex = text.IndexOf(descriptionProperty, StringComparison.Ordinal); @@ -279,14 +293,111 @@ private static bool LooksLikeSchemaDescription(string path, string text, int con return valueQuoteIndex >= 0 && contentStart == valueQuoteIndex + 1; } - const string appendCall = "AppendConstraintDescription("; - var callIndex = text.IndexOf(appendCall, StringComparison.Ordinal); + return IsDescriptionBuilderArgument(line, text, contentStart, lineContext); + } + + private static bool IsDescriptionBuilderArgument( + int line, + string text, + int contentStart, + IReadOnlyDictionary? lineContext) + { + var context = text; + var targetIndex = contentStart; + if (lineContext is not null) + { + var builder = new System.Text.StringBuilder(); + for (var candidateLine = Math.Max(1, line - 64); candidateLine <= line; candidateLine++) + { + var candidateText = candidateLine == line + ? text + : lineContext.TryGetValue(candidateLine, out var value) ? value : string.Empty; + if (candidateLine == line) + targetIndex = builder.Length + contentStart; + builder.AppendLine(candidateText); + } + context = builder.ToString(); + } + + return IsInvocationArgument(context, targetIndex, "CreateToolDefinition(", expectedArgumentIndex: 1) || + IsInvocationArgument(context, targetIndex, "StringOrArraySchema(", expectedArgumentIndex: 0) || + IsInvocationArgument(context, targetIndex, "AppendConstraintDescription(", expectedArgumentIndex: 1); + } + + private static bool IsInvocationArgument( + string text, + int targetIndex, + string invocation, + int expectedArgumentIndex) + { + var callIndex = text.LastIndexOf(invocation, Math.Min(targetIndex, text.Length - 1), StringComparison.Ordinal); if (callIndex < 0) return false; - var commaIndex = text.IndexOf(',', callIndex + appendCall.Length); - var argumentQuoteIndex = commaIndex < 0 ? -1 : text.IndexOf('"', commaIndex + 1); - return argumentQuoteIndex >= 0 && contentStart == argumentQuoteIndex + 1; + var argumentIndex = 0; + var parentheses = 0; + var brackets = 0; + var braces = 0; + for (var i = callIndex + invocation.Length; i < targetIndex && i < text.Length; i++) + { + if (StartsCSharpString(text, i, out var contentStart, out var contentEnd)) + { + if (targetIndex >= contentStart && targetIndex <= contentEnd) + return argumentIndex == expectedArgumentIndex; + i = Math.Max(i, contentEnd + 1); + continue; + } + + if (text[i] == '\'') + { + i = SkipCharacterLiteral(text, i); + continue; + } + + switch (text[i]) + { + case '(': + parentheses++; + break; + case ')': + if (parentheses == 0 && brackets == 0 && braces == 0) + return false; + parentheses = Math.Max(0, parentheses - 1); + break; + case '[': + brackets++; + break; + case ']': + brackets = Math.Max(0, brackets - 1); + break; + case '{': + braces++; + break; + case '}': + braces = Math.Max(0, braces - 1); + break; + case ',' when parentheses == 0 && brackets == 0 && braces == 0: + argumentIndex++; + break; + } + } + + return argumentIndex == expectedArgumentIndex; + } + + private static int SkipCharacterLiteral(string text, int quoteIndex) + { + for (var i = quoteIndex + 1; i < text.Length; i++) + { + if (text[i] == '\\') + { + i++; + continue; + } + if (text[i] == '\'') + return i; + } + return text.Length - 1; } private static bool IsInsideGitHubActionsRunBlock( diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 7e6c1f2d6..57a653ed6 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -24,25 +24,68 @@ public void GetSearchRecipeResultRanking_BypassesContextRankingWhenTotalLimitIsE } [Fact] - public void SearchMatchClassifier_McpSchemaDescriptionHasDedicatedOrigin_Issue4416() + public void SearchMatchClassifier_McpSchemaDescriptionHasDedicatedOrigin_Issues4416_4864() { const string schemaLine = "[\"tokenBoundary\"] = new JsonObject { [\"description\"] = \"Use new HttpClient as an example.\" };"; + const string toolDescriptionLine = "\"Use new HttpClient and review BCL Regex only as top-level schema prose. \""; + const string toolDescriptionContinuationLine = "+ \"A second new HttpClient example stays prose.\","; + const string catalogRuntimeLine = "var label = \"new HttpClient\";"; const string runtimeLine = "var client = new HttpClient();"; - var schema = SearchMatchClassifier.Classify( + string[] schemaPaths = + [ "src/CodeIndex/Mcp/McpToolDefinitions.cs", - "csharp", - 1, - schemaLine, - schemaLine.IndexOf("new HttpClient", StringComparison.Ordinal) + 1, - "new HttpClient".Length); + "src/CodeIndex/Mcp/McpToolCatalog.cs", + ]; + var schemaFacets = schemaPaths + .Select(path => SearchMatchClassifier.Classify( + path, + "csharp", + 1, + schemaLine, + schemaLine.IndexOf("new HttpClient", StringComparison.Ordinal) + 1, + "new HttpClient".Length)) + .ToArray(); var siblingType = SearchMatchClassifier.Classify( - "src/CodeIndex/Mcp/McpToolDefinitions.cs", + "src/CodeIndex/Mcp/McpToolCatalog.cs", "csharp", 1, schemaLine, schemaLine.IndexOf("JsonObject", StringComparison.Ordinal) + 1, "JsonObject".Length); + var toolDescriptionContext = new Dictionary + { + [1] = "CreateToolDefinition(", + [2] = "\"sample\",", + [3] = toolDescriptionLine, + [4] = toolDescriptionContinuationLine, + [5] = "new JsonObject());", + [6] = catalogRuntimeLine, + }; + var toolDescription = SearchMatchClassifier.Classify( + "src/CodeIndex/Mcp/McpToolCatalog.cs", + "csharp", + 3, + toolDescriptionLine, + toolDescriptionLine.IndexOf("new HttpClient", StringComparison.Ordinal) + 1, + "new HttpClient".Length, + lineContext: toolDescriptionContext); + var toolDescriptionContinuation = SearchMatchClassifier.Classify( + "src/CodeIndex/Mcp/McpToolCatalog.cs", + "csharp", + 4, + toolDescriptionContinuationLine, + toolDescriptionContinuationLine.IndexOf("new HttpClient", StringComparison.Ordinal) + 1, + "new HttpClient".Length, + lineContext: toolDescriptionContext); + var catalogRuntime = SearchMatchClassifier.Classify( + "src/CodeIndex/Mcp/McpToolCatalog.cs", + "csharp", + 6, + catalogRuntimeLine, + catalogRuntimeLine.IndexOf("new HttpClient", StringComparison.Ordinal) + 1, + "new HttpClient".Length, + lineContext: toolDescriptionContext); var runtime = SearchMatchClassifier.Classify( "src/CodeIndex/Network/ClientFactory.cs", "csharp", @@ -51,10 +94,13 @@ public void SearchMatchClassifier_McpSchemaDescriptionHasDedicatedOrigin_Issue44 runtimeLine.IndexOf("new HttpClient", StringComparison.Ordinal) + 1, "new HttpClient".Length); - Assert.Equal(SearchMatchClassifier.SchemaDescription, schema.Origin); + Assert.All(schemaFacets, schema => Assert.Equal(SearchMatchClassifier.SchemaDescription, schema.Origin)); + Assert.Equal(SearchMatchClassifier.SchemaDescription, toolDescription.Origin); + Assert.Equal(SearchMatchClassifier.SchemaDescription, toolDescriptionContinuation.Origin); Assert.Equal(SearchMatchClassifier.Code, siblingType.Origin); + Assert.Equal(SearchMatchClassifier.StringLiteral, catalogRuntime.Origin); Assert.Equal(SearchMatchClassifier.Code, runtime.Origin); - Assert.True(SearchMatchClassifier.IsStringLikeOrigin(schema.Origin)); + Assert.All(schemaFacets, schema => Assert.True(SearchMatchClassifier.IsStringLikeOrigin(schema.Origin))); } [Fact] @@ -2053,6 +2099,119 @@ public void RunSearch_SourceOnlyExcludesDocumentationOriginsByDefault_Issue4184( } } + [Fact] + public void RunSearch_McpSchemaDescriptionsStayOutOfExecutableRecipeOutputs_Issue4864() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_mcp_schema_origins_4864"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/CodeIndex/Mcp/McpToolCatalog.cs", + "csharp", + """ + var tool = CreateToolDefinition( + "sample", + "Use new HttpClient and review BCL Regex only as top-level schema prose. " + + "A second new HttpClient example stays prose.", + new JsonObject + { + ["tokenBoundary"] = new JsonObject { ["description"] = "Use new HttpClient only as nested schema prose." } + }); + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/App.cs", + "csharp", + """ + using System.Net.Http; + var client = new HttpClient(); + """); + + var (recipeExitCode, recipeStdout, recipeStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code/http-client-construction", "--db", dbPath, "--json", "--limit", "10"], + _jsonOptions)); + var (schemaExitCode, schemaStdout, schemaStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + [ + "new HttpClient", + "--db", dbPath, + "--exact-substring", + "--origin", "schema_description", + "--json=array", + "--limit", "10", + ], + _jsonOptions)); + var (sarifExitCode, sarifStdout, sarifStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code/http-client-construction", "--db", dbPath, "--format", "sarif", "--limit", "10"], + _jsonOptions)); + var (draftExitCode, draftStdout, draftStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code/http-client-construction", "--db", dbPath, "--format", "issue-drafts", "--limit", "10"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, recipeExitCode); + Assert.Equal(CommandExitCodes.Success, schemaExitCode); + Assert.Equal(CommandExitCodes.Success, sarifExitCode); + Assert.Equal(CommandExitCodes.Success, draftExitCode); + Assert.Equal(string.Empty, recipeStderr); + Assert.Equal(string.Empty, schemaStderr); + Assert.Equal(string.Empty, sarifStderr); + Assert.Equal(string.Empty, draftStderr); + + using var recipeDocument = ParseJsonOutput(recipeStdout); + var recipeRoot = recipeDocument.RootElement; + var recipeMetadata = Assert.Single(recipeRoot.GetProperty("recipe").GetProperty("queries").EnumerateArray()); + var recipeQuery = Assert.Single(recipeRoot.GetProperty("queries").EnumerateArray()); + var recipeResult = Assert.Single(recipeQuery.GetProperty("results").EnumerateArray()); + Assert.Equal(1, recipeRoot.GetProperty("result_count").GetInt32()); + Assert.Equal("src/App.cs", recipeResult.GetProperty("path").GetString()); + Assert.Contains( + SearchMatchClassifier.SchemaDescription, + recipeMetadata.GetProperty("exclude_origins").EnumerateArray().Select(value => value.GetString())); + Assert.Contains( + SearchMatchClassifier.Code, + recipeResult.GetProperty("match_origins").EnumerateArray().Select(value => value.GetString())); + + using var schemaDocument = ParseJsonOutput(schemaStdout); + var schemaResult = Assert.Single(schemaDocument.RootElement.EnumerateArray()); + Assert.Equal("src/CodeIndex/Mcp/McpToolCatalog.cs", schemaResult.GetProperty("path").GetString()); + Assert.Contains( + SearchMatchClassifier.SchemaDescription, + schemaResult.GetProperty("match_origins").EnumerateArray().Select(value => value.GetString())); + var schemaFacets = schemaResult.GetProperty("match_facets").EnumerateArray().ToArray(); + Assert.Equal(3, schemaFacets.Length); + Assert.All( + schemaFacets, + facet => Assert.Equal(SearchMatchClassifier.SchemaDescription, facet.GetProperty("origin").GetString())); + + using var sarifDocument = ParseJsonOutput(sarifStdout); + var sarifResult = Assert.Single( + sarifDocument.RootElement.GetProperty("runs")[0].GetProperty("results").EnumerateArray()); + Assert.Equal( + "src/App.cs", + sarifResult.GetProperty("locations")[0] + .GetProperty("physicalLocation") + .GetProperty("artifactLocation") + .GetProperty("uri") + .GetString()); + Assert.Contains( + SearchMatchClassifier.Code, + sarifResult.GetProperty("properties").GetProperty("match_origins").EnumerateArray().Select(value => value.GetString())); + + using var draftDocument = ParseJsonOutput(draftStdout); + var draftRoot = draftDocument.RootElement; + var draft = Assert.Single(draftRoot.GetProperty("drafts").EnumerateArray()); + var evidence = Assert.Single(draft.GetProperty("evidence").EnumerateArray()); + Assert.Equal(1, draftRoot.GetProperty("result_count").GetInt32()); + Assert.Equal("src/App.cs", evidence.GetProperty("path").GetString()); + Assert.DoesNotContain("McpToolCatalog.cs", draft.GetProperty("body").GetString(), StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_ListRecipesJsonIncludesBuiltInAuditMetadata_Issue3144() {