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
66 changes: 66 additions & 0 deletions Darling/Darling.Tests/McpToolGuideHeads.AnalyzeProcedurePlan.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using PerformanceMonitor.Common;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// #3898 D3 head pin for <c>analyze_procedure_plan</c> (wave E, lane e2): a twin whose key PARAMETER differs by
/// product (sql_handle on Darling, plan_handle on Lite), so per D6 the head states both in one clause rather than
/// picking a SKU-specific word. Follows the pattern in <see cref="McpToolGuideHeadsHealthParserTests"/>. Lite's
/// twin is <c>Lite.Tests/McpToolGuideHeadsAnalyzeProcedurePlanTests</c>.
/// </summary>
public sealed class McpToolGuideHeadsAnalyzeProcedurePlanTests
{
private const string Tool = "analyze_procedure_plan";

/// <summary>The guardrail facts the head must state: the differing key (D6), the two miss statuses in the
/// order <c>AnalyzeProcedurePlan</c> checks them (not_collected before unavailable — see the tool body), the
/// missing-index guardrails the plan-tool truth pins keep in every plan tool's head (the impact_basis label;
/// corroboration, never a diagnosis; the fixed caveat's regression-risk clause), and the operator cut's basis
/// and counts.</summary>
private static readonly string[] HeadFacts =
[
"by sql_handle (Darling) or plan_handle (Lite)",
"No plan: not_collected if the engine can't collect procedure_stats, else unavailable.",
"labelled impact_basis",
"corroboration for a statement already measured slow, never a diagnosis",
"every row carries the fixed caveat (regression risk for other plans, write cost)",
"top_operators by operators_ranked_by, with operators_returned / total_operators / truncated.",
];

[Fact]
public void Head_CarriesEveryGuardrailFact_AndStaysUnderTheCap()
{
var served = McpToolGuideTests.Served(Tool);
Assert.NotNull(served.Tail);
Assert.EndsWith(McpToolGuide.GuidePointer, served.Served, StringComparison.Ordinal);
Assert.True(served.Served.Length <= 620, $"{Tool}: served head {served.Served.Length} is over the 620 target");
Assert.All(served.ParameterDescriptionLengths, p => Assert.True(p.Length <= 200, $"{Tool}.{p.Parameter}: {p.Length} > 200"));

foreach (var fact in HeadFacts)
{
Assert.Contains(fact, served.Served, StringComparison.Ordinal);
}
}

/// <summary>D9: nothing dropped. Every sentence of the pre-#3898 description, including Darling's
/// product-specific first sentence naming sql_handle, survives verbatim in the tail get_tool_guide serves.</summary>
[Fact]
public void Tail_KeepsEveryOriginalSentence_IncludingTheProductSpecificFirstOne()
{
var served = McpToolGuideTests.Served(Tool);
Assert.Contains("Analyzes a stored execution plan captured from procedure stats by sql_handle.", served.Tail!, StringComparison.Ordinal);
Assert.Contains("Use after get_top_procedures_by_cpu to understand why a procedure is expensive.", served.Tail!, StringComparison.Ordinal);
Assert.Contains("operators_returned / total_operators / truncated", served.Tail!, StringComparison.Ordinal);
Assert.Contains("operators_ranked_by: measured actual_elapsed_ms when the plan has runtime statistics, otherwise the optimizer's cost_percent estimate.", served.Tail!, StringComparison.Ordinal);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
tool analyze_plan_xml 880
param analyze_plan_xml.plan_xml 25

tool analyze_procedure_plan 926
tool analyze_procedure_plan 618
param analyze_procedure_plan.server_name 28
param analyze_procedure_plan.sql_handle 52

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ public static async Task<string> AnalyzeQueryPlan(
}

[McpServerTool(Name = "analyze_procedure_plan"), Description(
"Analyzes a procedure's stored plan by sql_handle (Darling) or plan_handle (Lite). No plan: not_collected if the engine can't collect procedure_stats, else unavailable. " +
"Per statement: warnings; missing_indexes labelled impact_basis, with create_statement — the optimizer's suggested CREATE INDEX for this statement: " +
"corroboration for a statement already measured slow, never a diagnosis; every row carries the fixed caveat (regression risk for other plans, write cost); " +
"parameters; memory_grant; top_operators by operators_ranked_by, with operators_returned / total_operators / truncated. " +
"<<GUIDE>> " +
"Analyzes a stored execution plan captured from procedure stats by sql_handle. " +
"Use after get_top_procedures_by_cpu to understand why a procedure is expensive. " +
"Returns warnings, missing indexes (column lists, the optimizer's statement-scoped impact estimate labelled impact_basis, and create_statement — the optimizer's suggested CREATE INDEX for this statement: corroboration for a statement already measured slow, never a diagnosis, and every row carries the fixed caveat — the estimate is per-statement, an index is a per-table commitment with write cost and regression risk for other plans, so test it), parameters, memory grants, and top_operators — a stated cut of the operators_cap most expensive operators per statement, with operators_returned / total_operators / truncated, ranked by operators_ranked_by: measured actual_elapsed_ms when the plan has runtime statistics, otherwise the optimizer's cost_percent estimate.")]
Expand Down
61 changes: 61 additions & 0 deletions Lite.Tests/McpToolGuideHeads.AnalyzeProcedurePlan.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor Lite.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using PerformanceMonitor.Common;
using Xunit;

namespace Lite.Tests;

/// <summary>
/// #3898 D3 head pin for <c>analyze_procedure_plan</c>'s Lite twin (wave E, lane e2). Darling's twin is
/// <c>Darling.Tests/McpToolGuideHeadsAnalyzeProcedurePlanTests</c>, which also holds the D6 rationale: the key
/// PARAMETER differs by product (sql_handle on Darling, plan_handle on Lite), so the head names both in one
/// clause rather than picking a SKU-specific word.
/// </summary>
public sealed class McpToolGuideHeadsAnalyzeProcedurePlanTests
{
private const string Tool = "analyze_procedure_plan";

private static readonly string[] HeadFacts =
[
"by sql_handle (Darling) or plan_handle (Lite)",
"No plan: not_collected if the engine can't collect procedure_stats, else unavailable.",
"labelled impact_basis",
"corroboration for a statement already measured slow, never a diagnosis",
"every row carries the fixed caveat (regression risk for other plans, write cost)",
"top_operators by operators_ranked_by, with operators_returned / total_operators / truncated.",
];

[Fact]
public void Head_CarriesEveryGuardrailFact_AndStaysUnderTheCap()
{
var served = McpToolGuideTests.Served(Tool);
Assert.NotNull(served.Tail);
Assert.EndsWith(McpToolGuide.GuidePointer, served.Served, StringComparison.Ordinal);
Assert.True(served.Served.Length <= 620, $"{Tool}: served head {served.Served.Length} is over the 620 target");
Assert.All(served.ParameterDescriptionLengths, p => Assert.True(p.Length <= 200, $"{Tool}.{p.Parameter}: {p.Length} > 200"));

foreach (var fact in HeadFacts)
{
Assert.Contains(fact, served.Served, StringComparison.Ordinal);
}
}

/// <summary>D9: nothing dropped. Every sentence of the pre-#3898 description, including Lite's
/// product-specific first sentence naming plan_handle, survives verbatim in the tail get_tool_guide serves.</summary>
[Fact]
public void Tail_KeepsEveryOriginalSentence_IncludingTheProductSpecificFirstOne()
{
var served = McpToolGuideTests.Served(Tool);
Assert.Contains("Analyzes an execution plan from procedure stats by plan_handle.", served.Tail!, StringComparison.Ordinal);
Assert.Contains("Use after get_top_procedures_by_cpu to understand why a procedure is expensive.", served.Tail!, StringComparison.Ordinal);
Assert.Contains("operators_returned / total_operators / truncated", served.Tail!, StringComparison.Ordinal);
Assert.Contains("operators_ranked_by: measured actual_elapsed_ms when the plan has runtime statistics, otherwise the optimizer's cost_percent estimate.", served.Tail!, StringComparison.Ordinal);
}
}
2 changes: 1 addition & 1 deletion Lite.Tests/McpToolsListBudget/McpPlanTools.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
tool analyze_plan_xml 880
param analyze_plan_xml.plan_xml 25

tool analyze_procedure_plan 912
tool analyze_procedure_plan 618
param analyze_procedure_plan.plan_handle 53
param analyze_procedure_plan.server_name 28

Expand Down
5 changes: 5 additions & 0 deletions Lite/Mcp/McpPlanTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ public static async Task<string> AnalyzeQueryPlan(
}

[McpServerTool(Name = "analyze_procedure_plan"), Description(
"Analyzes a procedure's stored plan by sql_handle (Darling) or plan_handle (Lite). No plan: not_collected if the engine can't collect procedure_stats, else unavailable. " +
"Per statement: warnings; missing_indexes labelled impact_basis, with create_statement — the optimizer's suggested CREATE INDEX for this statement: " +
"corroboration for a statement already measured slow, never a diagnosis; every row carries the fixed caveat (regression risk for other plans, write cost); " +
"parameters; memory_grant; top_operators by operators_ranked_by, with operators_returned / total_operators / truncated. " +
"<<GUIDE>> " +
"Analyzes an execution plan from procedure stats by plan_handle. " +
"Use after get_top_procedures_by_cpu to understand why a procedure is expensive. " +
"Returns warnings, missing indexes (column lists, the optimizer's statement-scoped impact estimate labelled impact_basis, and create_statement — the optimizer's suggested CREATE INDEX for this statement: corroboration for a statement already measured slow, never a diagnosis, and every row carries the fixed caveat — the estimate is per-statement, an index is a per-table commitment with write cost and regression risk for other plans, so test it), parameters, memory grants, and top_operators — a stated cut of the operators_cap most expensive operators per statement, with operators_returned / total_operators / truncated, ranked by operators_ranked_by: measured actual_elapsed_ms when the plan has runtime statistics, otherwise the optimizer's cost_percent estimate.")]
Expand Down
Loading