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
@@ -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 =>