feat(m9/T28b): trigger analysis-kind selector (UI) + --trigger-kind (CLI)

Surfaces the T28a backend "analysisKind" discriminator in both authoring
surfaces: an Advisory|Strict <select> (id="alarm-trigger-kind" /
"script-trigger-kind") added to the Expression fragment of
AlarmTriggerEditor and ScriptTriggerEditor, and a --trigger-kind option
on template alarm/script add+update in the CLI.

Key/value contract: "analysisKind":"Strict" when strict; key omitted for
Advisory — exactly as ValidationService.IsStrictAnalysis reads it.
Selector only shown for Expression triggers; non-Expression triggers do
not emit the key even if IsStrictAnalysisKind is set on the model.

Both projects build 0 warnings; 101 CentralUI Trigger tests + 33 CLI
Template tests pass.
This commit is contained in:
Joseph Doherty
2026-06-18 10:44:57 -04:00
parent f618ac0322
commit dcc6f623e2
8 changed files with 684 additions and 2 deletions
@@ -17,13 +17,33 @@ internal static class AlarmTriggerConfigJson
/// flags, or returns null when none are supplied (so the alarm is created without a
/// trigger config). Unknown/blank trigger types yield null.
/// </summary>
/// <param name="triggerType">The trigger type string (case-insensitive).</param>
/// <param name="attribute">Attribute name (non-Expression trigger types).</param>
/// <param name="matchValue">ValueMatch: value to compare against.</param>
/// <param name="notEquals">ValueMatch: invert the comparison.</param>
/// <param name="min">RangeViolation: minimum allowed value.</param>
/// <param name="max">RangeViolation: maximum allowed value.</param>
/// <param name="thresholdPerSecond">RateOfChange: rate threshold per second.</param>
/// <param name="windowSeconds">RateOfChange: sliding window in seconds.</param>
/// <param name="direction">RateOfChange: direction (rising|falling|either).</param>
/// <param name="loLo">HiLo: low-low setpoint.</param>
/// <param name="lo">HiLo: low setpoint.</param>
/// <param name="hi">HiLo: high setpoint.</param>
/// <param name="hiHi">HiLo: high-high setpoint.</param>
/// <param name="expression">Expression: boolean trigger expression.</param>
/// <param name="analysisKind">
/// M9-T28b: optional analysis kind for Expression triggers ("strict" → emits
/// <c>"analysisKind":"Strict"</c>; null/"advisory"/anything else → Advisory default,
/// key omitted). Ignored for non-Expression trigger types.
/// </param>
internal static string? Build(
string triggerType, string? attribute,
string? matchValue, bool notEquals,
double? min, double? max,
double? thresholdPerSecond, double? windowSeconds, string? direction,
double? loLo, double? lo, double? hi, double? hiHi,
string? expression)
string? expression,
string? analysisKind = null)
{
var type = triggerType?.Trim();
var anyTyped = attribute is not null || matchValue is not null || notEquals
@@ -66,6 +86,11 @@ internal static class AlarmTriggerConfigJson
break;
case "expression":
w.WriteString("expression", expression ?? "");
// M9-T28b: emit "analysisKind":"Strict" only when the caller passes
// --trigger-kind strict (case-insensitive); Advisory (the default) is
// expressed by omitting the key, matching ValidationService.IsStrictAnalysis.
if (string.Equals(analysisKind?.Trim(), "Strict", StringComparison.OrdinalIgnoreCase))
w.WriteString("analysisKind", "Strict");
break;
}
w.WriteEndObject();
@@ -339,6 +339,13 @@ public static class TemplateCommands
var hiOption = new Option<double?>("--hi") { Description = "HiLo: high setpoint" };
var hiHiOption = new Option<double?>("--hihi") { Description = "HiLo: high-high setpoint" };
var expressionOption = new Option<string?>("--expression") { Description = "Expression: boolean trigger expression" };
// M9-T28b: analysis kind for Expression triggers (advisory|strict; default advisory).
// Writes "analysisKind":"Strict" into the trigger config when set to strict.
var triggerKindOption = new Option<string?>("--trigger-kind")
{
Description = "Expression trigger analysis kind: advisory (default) or strict. " +
"Strict escalates a blank expression from a warning to a deploy-blocking error."
};
var addCmd = new Command("add") { Description = "Add an alarm to a template" };
addCmd.Add(templateIdOption);
@@ -361,6 +368,7 @@ public static class TemplateCommands
addCmd.Add(hiOption);
addCmd.Add(hiHiOption);
addCmd.Add(expressionOption);
addCmd.Add(triggerKindOption);
addCmd.SetAction(async (ParseResult result) =>
{
var triggerType = result.GetValue(triggerTypeOption)!;
@@ -372,7 +380,8 @@ public static class TemplateCommands
result.GetValue(minOption), result.GetValue(maxOption),
result.GetValue(thresholdOption), result.GetValue(windowOption), result.GetValue(directionOption),
result.GetValue(loLoOption), result.GetValue(loOption), result.GetValue(hiOption), result.GetValue(hiHiOption),
result.GetValue(expressionOption));
result.GetValue(expressionOption),
result.GetValue(triggerKindOption));
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
@@ -395,6 +404,11 @@ public static class TemplateCommands
var updateTriggerConfigOption = new Option<string?>("--trigger-config") { Description = "Trigger configuration JSON" };
var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" };
updateLockedOption.DefaultValueFactory = _ => false;
// M9-T28b: --trigger-kind for update (same semantics as add)
var updateTriggerKindOption = new Option<string?>("--trigger-kind")
{
Description = "Expression trigger analysis kind: advisory (default) or strict."
};
var updateCmd = new Command("update") { Description = "Update a template alarm" };
updateCmd.Add(updateIdOption);
@@ -404,6 +418,7 @@ public static class TemplateCommands
updateCmd.Add(updateDescOption);
updateCmd.Add(updateTriggerConfigOption);
updateCmd.Add(updateLockedOption);
updateCmd.Add(updateTriggerKindOption);
updateCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
@@ -514,6 +529,13 @@ public static class TemplateCommands
var paramsOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
var returnOption = new Option<string?>("--return-def") { Description = "Return definition JSON" };
// M9-T28b: analysis kind for Expression triggers (advisory|strict; default advisory).
var scriptTriggerKindOption = new Option<string?>("--trigger-kind")
{
Description = "Expression trigger analysis kind: advisory (default) or strict. " +
"Strict escalates a blank expression from a warning to a deploy-blocking error. " +
"Used with --trigger-config or appended to a built config when --trigger-type is Expression."
};
var addCmd = new Command("add") { Description = "Add a script to a template" };
addCmd.Add(templateIdOption);
@@ -524,6 +546,7 @@ public static class TemplateCommands
addCmd.Add(lockedOption);
addCmd.Add(paramsOption);
addCmd.Add(returnOption);
addCmd.Add(scriptTriggerKindOption);
addCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
@@ -550,6 +573,11 @@ public static class TemplateCommands
var updateParamsOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
var updateReturnOption = new Option<string?>("--return-def") { Description = "Return definition JSON" };
// M9-T28b: --trigger-kind for update (same semantics as add)
var updateScriptTriggerKindOption = new Option<string?>("--trigger-kind")
{
Description = "Expression trigger analysis kind: advisory (default) or strict."
};
var updateCmd = new Command("update") { Description = "Update a template script" };
updateCmd.Add(updateIdOption);
@@ -560,6 +588,7 @@ public static class TemplateCommands
updateCmd.Add(updateLockedOption);
updateCmd.Add(updateParamsOption);
updateCmd.Add(updateReturnOption);
updateCmd.Add(updateScriptTriggerKindOption);
updateCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
@@ -100,6 +100,15 @@ internal static class AlarmTriggerConfigCodec
case AlarmTriggerType.Expression:
model.Expression = TryReadString(root, "expression");
// M9-T28b: read optional analysisKind discriminator ("Strict" → strict;
// absent/"Advisory"/anything else → Advisory default, preserving
// today's behavior exactly — matches ValidationService.IsStrictAnalysis).
if (root.TryGetProperty("analysisKind", out var ak)
&& ak.ValueKind == JsonValueKind.String)
{
model.IsStrictAnalysisKind = string.Equals(
ak.GetString(), "Strict", StringComparison.OrdinalIgnoreCase);
}
break;
}
}
@@ -172,6 +181,11 @@ internal static class AlarmTriggerConfigCodec
case AlarmTriggerType.Expression:
w.WriteString("expression", model.Expression ?? "");
// M9-T28b: emit "analysisKind":"Strict" only when explicitly set;
// Advisory is the default so the key is omitted to keep the payload
// minimal and backward-compatible with older ValidationService versions.
if (model.IsStrictAnalysisKind)
w.WriteString("analysisKind", "Strict");
break;
}
@@ -342,4 +356,14 @@ internal sealed class AlarmTriggerModel
/// The boolean C# expression to evaluate for Expression triggers.
/// </summary>
public string? Expression { get; set; }
// M9-T28b: per-trigger analysis kind (Expression only). When true the
// codec serializes "analysisKind":"Strict"; when false (Advisory, the
// default) the key is omitted. Matches ValidationService.IsStrictAnalysis.
/// <summary>
/// When <see langword="true"/>, the trigger config carries
/// <c>"analysisKind":"Strict"</c> so ValidationService escalates the
/// blank-expression advisory to a deploy-blocking error.
/// </summary>
public bool IsStrictAnalysisKind { get; set; }
}
@@ -534,6 +534,8 @@
_thresholdText = FormatNullable(_model.ThresholdPerSecond);
_windowText = FormatNullable(_model.WindowSeconds);
_directionText = _model.Direction;
// M9-T28b: sync the analysis-kind selector from the loaded model.
_analysisKindValue = _model.IsStrictAnalysisKind ? "Strict" : "Advisory";
_loLoText = FormatNullable(_model.LoLo);
_loText = FormatNullable(_model.Lo);
_hiText = FormatNullable(_model.Hi);
@@ -583,6 +585,21 @@
<div class="form-text">
A boolean C# expression — e.g. <code>Attributes["Temperature"] &gt; 80</code>.
</div>
@* M9-T28b: analysis-kind selector — Advisory (default) keeps the blank-expression
finding as a non-blocking warning; Strict escalates it to a deploy-blocking error. *@
<div class="mt-2">
<label for="alarm-trigger-kind" class="form-label small text-uppercase text-muted fw-semibold mb-1">
Analysis kind
</label>
<select id="alarm-trigger-kind"
class="form-select form-select-sm"
style="max-width: 280px;"
@bind="_analysisKindValue"
@bind:after="OnAnalysisKindChanged">
<option value="Advisory">Advisory — blank expression is a warning</option>
<option value="Strict">Strict — blank expression blocks deploy</option>
</select>
</div>
};
private async Task OnExpressionChanged(string value)
@@ -591,6 +608,15 @@
await Emit();
}
// M9-T28b: backing field + handler for the Advisory|Strict selector.
private string _analysisKindValue = "Advisory";
private async Task OnAnalysisKindChanged()
{
_model.IsStrictAnalysisKind = string.Equals(_analysisKindValue, "Strict", StringComparison.OrdinalIgnoreCase);
await Emit();
}
// ── Hint text ──────────────────────────────────────────────────────────
private string BuildHint()
@@ -42,6 +42,16 @@ internal sealed class ScriptTriggerModel
/// <summary>Fire mode (Conditional + Expression). Defaults to <see cref="ScriptTriggerMode.OnTrue"/>.</summary>
public ScriptTriggerMode Mode { get; set; } = ScriptTriggerMode.OnTrue;
// M9-T28b: per-trigger analysis kind (Expression only). When true the
// codec serializes "analysisKind":"Strict"; when false (Advisory, the
// default) the key is omitted. Matches ValidationService.IsStrictAnalysis.
/// <summary>
/// When <see langword="true"/>, the trigger config carries
/// <c>"analysisKind":"Strict"</c> so ValidationService escalates the
/// blank-expression advisory to a deploy-blocking error.
/// </summary>
public bool IsStrictAnalysisKind { get; set; }
}
/// <summary>
@@ -148,6 +158,15 @@ internal static class ScriptTriggerConfigCodec
case ScriptTriggerKind.Expression:
model.Expression = root.TryGetProperty("expression", out var e) ? e.GetString() : null;
model.Mode = ReadMode(root);
// M9-T28b: read optional analysisKind discriminator (matches
// ValidationService.IsStrictAnalysis — "Strict" case-insensitive → strict;
// absent/"Advisory"/anything else → Advisory default).
if (root.TryGetProperty("analysisKind", out var ak)
&& ak.ValueKind == JsonValueKind.String)
{
model.IsStrictAnalysisKind = string.Equals(
ak.GetString(), "Strict", StringComparison.OrdinalIgnoreCase);
}
break;
}
}
@@ -196,6 +215,10 @@ internal static class ScriptTriggerConfigCodec
case ScriptTriggerKind.Expression:
w.WriteString("expression", model.Expression ?? "");
w.WriteString("mode", model.Mode.ToString());
// M9-T28b: emit "analysisKind":"Strict" only when explicitly set;
// Advisory is the default so the key is omitted to stay backward-compatible.
if (model.IsStrictAnalysisKind)
w.WriteString("analysisKind", "Strict");
break;
// Call → empty object.
@@ -136,6 +136,8 @@
_operator = _model.Operator;
_thresholdText = _model.Threshold?.ToString("R", CultureInfo.InvariantCulture);
(_intervalText, _intervalUnit) = SplitInterval(_model.IntervalMs);
// M9-T28b: sync the analysis-kind selector from the loaded model.
_analysisKindValue = _model.IsStrictAnalysisKind ? "Strict" : "Advisory";
}
/// <summary>Chooses the largest whole unit (min/sec/ms) that represents the period exactly.</summary>
@@ -272,6 +274,21 @@
<div class="form-text">
A boolean C# expression — e.g. <code>Attributes["Temperature"] &gt; 80</code>.
</div>
@* M9-T28b: analysis-kind selector — Advisory keeps the blank-expression finding
as a non-blocking warning; Strict escalates it to a deploy-blocking error. *@
<div class="mt-2">
<label for="script-trigger-kind" class="form-label small text-uppercase text-muted fw-semibold mb-1">
Analysis kind
</label>
<select id="script-trigger-kind"
class="form-select form-select-sm"
style="max-width: 280px;"
@bind="_analysisKindValue"
@bind:after="OnAnalysisKindChanged">
<option value="Advisory">Advisory — blank expression is a warning</option>
<option value="Strict">Strict — blank expression blocks deploy</option>
</select>
</div>
};
private async Task OnExpressionChanged(string value)
@@ -280,6 +297,15 @@
await Emit();
}
// M9-T28b: backing field + handler for the Advisory|Strict selector.
private string _analysisKindValue = "Advisory";
private async Task OnAnalysisKindChanged()
{
_model.IsStrictAnalysisKind = string.Equals(_analysisKindValue, "Strict", StringComparison.OrdinalIgnoreCase);
await Emit();
}
// ── Fire mode (Conditional + Expression) ───────────────────────────────
private RenderFragment RenderMode() => __builder =>