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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions TESTING_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <n>` 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.
Expand Down Expand Up @@ -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 <n>` が少なくとも要求 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を共有してください。
Expand Down
17 changes: 17 additions & 0 deletions changelog.d/unreleased/4864.fixed.md
Original file line number Diff line number Diff line change
@@ -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 として検索できます。
133 changes: 122 additions & 11 deletions src/CodeIndex/Database/SearchMatchClassifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
Expand Down Expand Up @@ -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<int, string>? lineContext)
{
var trimmed = text.TrimStart();
if (trimmed.StartsWith("///", StringComparison.Ordinal) ||
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<int, string>? 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);
Expand All @@ -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<int, string>? 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(
Expand Down
Loading
Loading