diff --git a/Darling/Darling.Tests/PlanSync4522Tests.cs b/Darling/Darling.Tests/PlanSync4522Tests.cs
new file mode 100644
index 0000000000..8d3d627787
--- /dev/null
+++ b/Darling/Darling.Tests/PlanSync4522Tests.cs
@@ -0,0 +1,102 @@
+/*
+ * 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.Linq;
+using PerformanceMonitor.PlanAnalysis;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4522 (PerformanceStudio#577) — Rule 5 (Row Estimate Mismatch) checked
+/// node.HasActualStats && node.EstimateRows > 0 without also requiring
+/// node.ActualExecutions > 0. An operator in a branch that never ran (an outer join's
+/// inner side, a conditional CASE branch, an OR branch of a concatenation) returns 0 rows
+/// because it never executed, not because the estimate was wrong. The rule now skips operators
+/// with zero executions, the same way rules 11, 12 and 29 already do, and the now-unreachable
+/// ActualExecutions > 0 ? ActualExecutions : 1 fallback is removed.
+///
+/// The repro XML is a synthetic actual plan (dbo.t) with two Sort operators under a
+/// Concatenation: one ran zero times (a branch that never executed), one ran once and returned
+/// nothing despite a large estimate.
+///
+public sealed class PlanSync4522Tests
+{
+ private const string ReproXmlTemplate = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ private static ParsedPlan ParseAndAnalyze(long firstExecutions, long secondExecutions)
+ {
+ var xml = ReproXmlTemplate
+ .Replace("{{FIRST_EXECUTIONS}}", firstExecutions.ToString())
+ .Replace("{{SECOND_EXECUTIONS}}", secondExecutions.ToString());
+ var plan = ShowPlanParser.Parse(xml);
+ PlanAnalyzer.Analyze(plan);
+ return plan;
+ }
+
+ private static bool HasRowEstimateMismatch(PlanNode node) =>
+ node.Warnings.Any(w => w.WarningType == "Row Estimate Mismatch");
+
+ private static PlanNode FindSort(ParsedPlan plan, int nodeId)
+ {
+ var root = plan.Batches.SelectMany(b => b.Statements).Single().RootNode!;
+ return Flatten(root).Single(n => n.NodeId == nodeId);
+ }
+
+ private static System.Collections.Generic.IEnumerable Flatten(PlanNode node)
+ {
+ yield return node;
+ foreach (var child in node.Children)
+ foreach (var descendant in Flatten(child))
+ yield return descendant;
+ }
+
+ [Fact]
+ public void Rule05_OperatorThatNeverExecuted_IsNotAnEstimateMismatch()
+ {
+ // Node 1 never executed (ActualExecutions=0) despite an estimate of 1000 rows.
+ var plan = ParseAndAnalyze(firstExecutions: 0, secondExecutions: 1);
+ var neverRan = FindSort(plan, nodeId: 1);
+
+ Assert.False(HasRowEstimateMismatch(neverRan));
+ }
+
+ [Fact]
+ public void Rule05_OperatorThatExecutedAndReturnedNothing_StillWarns()
+ {
+ // Node 2 ran once (ActualExecutions=1) and still returned 0 rows against an estimate
+ // of 1000 — a real mismatch, not a branch that never ran.
+ var plan = ParseAndAnalyze(firstExecutions: 0, secondExecutions: 1);
+ var ranButEmpty = FindSort(plan, nodeId: 2);
+
+ Assert.True(HasRowEstimateMismatch(ranButEmpty));
+ }
+}
diff --git a/Darling/Darling.Tests/PlanSync4525Tests.cs b/Darling/Darling.Tests/PlanSync4525Tests.cs
new file mode 100644
index 0000000000..81a311b52e
--- /dev/null
+++ b/Darling/Darling.Tests/PlanSync4525Tests.cs
@@ -0,0 +1,111 @@
+/*
+ * 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.Linq;
+using PerformanceMonitor.PlanAnalysis;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4525 (PerformanceStudio#584) — Rule 23 (Table-Valued Function) fired on every
+/// LogicalOp="Table-valued function" operator, including the engine's own functions:
+/// STRING_SPLIT, OPENJSON, GENERATE_SERIES, and every DMV/DMF. Those run as
+/// the same operator, but their Object element names no database and no schema — a
+/// function the user wrote always has both. now carries the
+/// schema next to (both set from the parsed Object
+/// element), and rule 23 skips an operator with neither.
+///
+/// The repro XML is a synthetic actual plan, one variant with no Object element (as
+/// the engine's own functions appear) and one with a dbo/[db] user function.
+///
+public sealed class PlanSync4525Tests
+{
+ private const string EngineFunctionXml = """
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ private const string UserFunctionXml = """
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ private static ParsedPlan ParseAndAnalyze(string xml)
+ {
+ var plan = ShowPlanParser.Parse(xml);
+ PlanAnalyzer.Analyze(plan);
+ return plan;
+ }
+
+ private static System.Collections.Generic.IEnumerable Flatten(PlanNode node)
+ {
+ yield return node;
+ foreach (var child in node.Children)
+ foreach (var descendant in Flatten(child))
+ yield return descendant;
+ }
+
+ // RootNode is the synthetic statement wrapper (NodeId == -1); the parsed Table-valued
+ // function operator is its child.
+ private static PlanNode FindTvfNode(ParsedPlan plan)
+ {
+ var root = plan.Batches.SelectMany(b => b.Statements).Single().RootNode!;
+ return Flatten(root).Single(n => n.LogicalOp == "Table-valued function");
+ }
+
+ [Fact]
+ public void Rule23_EngineFunctionWithNoDatabaseAndNoSchema_DoesNotWarn()
+ {
+ var plan = ParseAndAnalyze(EngineFunctionXml);
+ var node = FindTvfNode(plan);
+
+ Assert.Null(node.DatabaseName);
+ Assert.DoesNotContain(node.Warnings, w => w.WarningType == "Table-Valued Function");
+ }
+
+ [Fact]
+ public void Rule23_UserFunctionWithDatabaseAndSchema_StillWarns()
+ {
+ var plan = ParseAndAnalyze(UserFunctionXml);
+ var node = FindTvfNode(plan);
+
+ Assert.Equal("db", node.DatabaseName);
+ Assert.Contains(node.Warnings, w => w.WarningType == "Table-Valued Function");
+ }
+
+ // Compile-only on dev: PlanNode.SchemaName is a new member (#4525 / PS#584); the parser
+ // now sets it next to DatabaseName from the same