Skip to main content

nmbrs_runtime/
workload_lint.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Load-time authoring lints for semantic surfaces serde cannot check
5//! (SRD-83 follow-up). Runs once at workload load — before any phase
6//! dispatch, dryrun included — per the dryrun-as-validation-floor
7//! doctrine and "never ignore silently".
8//!
9//! Three checks:
10//!
11//! 1. **Error-router specs parse.** Every `errors:` spec — phase-level
12//!    and op-level — goes through the real [`ErrorRouter`] parser, so a
13//!    bad verb or regex is a load error naming its phase/op instead of
14//!    a first-error-at-runtime surprise. Specs carrying `{param}`
15//!    interpolation are skipped here (they resolve later and are
16//!    parsed again at scope init).
17//! 2. **Error-router catch-all lint.** An error class matching no rule
18//!    falls through to `stop` with only an eprintln; a router without a
19//!    literal `.*` rule therefore has a silent fall-through mode. One
20//!    warning per such spec.
21//! 3. **Metric-family lint.** `metric('family, …', 'stat')` and
22//!    `metric_window(…)` selectors inside `stop_when`, `continue_if`,
23//!    and phase `poll.until` predicates read 0.0 SILENTLY when the
24//!    family never registers — a typo'd family makes a coordination
25//!    gate pass instantly or hang to its timeout. Family tokens are
26//!    checked against the built-in activity instrument namespace plus
27//!    every phase-declared `metrics:` name. Unknown families WARN
28//!    rather than fail: adapter counters and relevancy families are
29//!    registered at runtime and are not statically knowable.
30//!
31//! Hard failures come back as `Err`; warnings come back as strings for
32//! the caller to route through `diag!` (keeps this module pure and
33//! directly testable).
34
35use nmbrs_workload::model::{StopConditionSpec, WorkloadPhase};
36
37/// Built-in activity instrument families, mirroring
38/// `ActivityMetrics::register_on` (activity.rs) — the same namespace the
39/// SRD-83 stop-condition wires draw from. Phase-declared `metrics:`
40/// names are unioned in per workload before matching.
41const ACTIVITY_FAMILIES: [&str; 18] = [
42    "cycles_servicetime",
43    "cycles_waittime",
44    "cycles_responsetime",
45    "result_success",
46    "result_failure",
47    "result_total",
48    "cycles_total",
49    "skips_total",
50    "errors_total",
51    "attempt_total",
52    "attempt_success",
53    "attempt_failure",
54    "stanzas_total",
55    "daemon_cancelled_total",
56    "daemon_errors_total",
57    "result_elements",
58    "result_bytes",
59    "tries",
60];
61
62/// Run every lint over the loaded workload. `Ok(warnings)` — the run
63/// may proceed, with each warning routed to the operator; `Err` — a
64/// spec is malformed and the load fails.
65pub fn lint_workload<'a>(
66    workload_stop_when: &[StopConditionSpec],
67    phases: impl Iterator<Item = (&'a str, &'a WorkloadPhase)> + Clone,
68) -> Result<Vec<String>, String> {
69    let mut warnings = Vec::new();
70
71    // The metric-family allowlist: built-ins + every phase-declared
72    // phase-metric name across the workload (a gate in one phase may
73    // legitimately read a metric another phase declares).
74    let mut known: Vec<String> = ACTIVITY_FAMILIES.iter().map(|s| s.to_string()).collect();
75    for (_, phase) in phases.clone() {
76        known.extend(phase.metrics.keys().cloned());
77    }
78
79    for sc in workload_stop_when {
80        lint_metric_families(&sc.when, &known, "workload `stop_when`", &mut warnings);
81    }
82
83    for (name, phase) in phases {
84        let ctx = format!("phase '{name}'");
85        if let Some(spec) = phase.errors.as_deref() {
86            lint_error_router(spec, &ctx, &mut warnings)?;
87        }
88        for op in &phase.ops {
89            if let Some(serde_json::Value::String(spec)) = op.params.get("errors") {
90                lint_error_router(spec, &format!("{ctx} op '{}'", op.name), &mut warnings)?;
91            }
92        }
93        for sc in &phase.stop_when {
94            lint_metric_families(
95                &sc.when,
96                &known,
97                &format!("{ctx} `stop_when`"),
98                &mut warnings,
99            );
100        }
101        if let Some(gate) = &phase.continue_if {
102            lint_metric_families(
103                &gate.when,
104                &known,
105                &format!("{ctx} `continue_if`"),
106                &mut warnings,
107            );
108        }
109        if let Some(poll) = &phase.poll {
110            lint_metric_families(
111                &poll.until,
112                &known,
113                &format!("{ctx} `poll.until`"),
114                &mut warnings,
115            );
116        }
117    }
118
119    Ok(warnings)
120}
121
122/// Check one `errors:` router spec: it must parse (hard error), and it
123/// should carry a catch-all rule (warning). Interpolated specs are
124/// deferred to runtime resolution.
125fn lint_error_router(spec: &str, ctx: &str, warnings: &mut Vec<String>) -> Result<(), String> {
126    if spec.contains('{') {
127        return Ok(()); // `{param}`-bearing; resolved and parsed later
128    }
129    let router = nmbrs_errorhandler::ErrorRouter::parse(spec)
130        .map_err(|e| format!("{ctx}: invalid `errors:` spec '{spec}': {e}"))?;
131    if !router.has_catch_all() {
132        warnings.push(format!(
133            "{ctx}: `errors: \"{spec}\"` has no catch-all rule — an error \
134             class matching no pattern falls through to `stop`; end the \
135             spec with `.*:<verbs>` to make the default explicit"
136        ));
137    }
138    Ok(())
139}
140
141/// Scan one polydat predicate source for `metric(…)` /
142/// `metric_window(…)` calls with a literal selector, and warn on family
143/// tokens outside the known namespace.
144fn lint_metric_families(src: &str, known: &[String], ctx: &str, warnings: &mut Vec<String>) {
145    for family in metric_families(src) {
146        if family.contains('{') {
147            continue; // interpolated — resolved later
148        }
149        if !known.iter().any(|k| k == &family) {
150            warnings.push(format!(
151                "{ctx}: `metric()` selector names family '{family}', which is \
152                 not a built-in instrument family or a declared phase metric — \
153                 an unregistered family reads 0.0 silently; check the spelling \
154                 (predicate: {src})"
155            ));
156        }
157    }
158}
159
160/// Extract the family token (the first comma-separated field of the
161/// selector literal) from every `metric('…')` / `metric_window('…')`
162/// call in `src`. Non-literal selectors (an expression, not a quoted
163/// string) yield nothing — those are resolved at runtime.
164fn metric_families(src: &str) -> Vec<String> {
165    let bytes = src.as_bytes();
166    let mut out = Vec::new();
167    let mut i = 0;
168    while let Some(rel) = src[i..].find("metric") {
169        let start = i + rel;
170        i = start + "metric".len();
171        // Word boundary on the left: reject e.g. `my_metric(`.
172        if start > 0 {
173            let prev = bytes[start - 1] as char;
174            if prev.is_alphanumeric() || prev == '_' {
175                continue;
176            }
177        }
178        // Optional `_window` suffix, then `(`.
179        let mut j = i;
180        if src[j..].starts_with("_window") {
181            j += "_window".len();
182        }
183        let rest = src[j..].trim_start();
184        let Some(after_paren) = rest.strip_prefix('(') else {
185            continue;
186        };
187        let sel = after_paren.trim_start();
188        let Some(quote) = sel.chars().next().filter(|c| *c == '\'' || *c == '"') else {
189            continue; // non-literal selector
190        };
191        let body = &sel[1..];
192        let Some(end) = body.find(quote) else {
193            continue;
194        };
195        let selector = &body[..end];
196        let family = selector.split(',').next().unwrap_or("").trim().to_string();
197        if !family.is_empty() {
198            out.push(family);
199        }
200    }
201    out
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn extracts_families_from_both_call_forms() {
210        let src = "metric('cycles_total, phase=ingest', 'count') >= 5 \
211                   && metric_window(\"result_failure\", 'rate') < 0.1";
212        assert_eq!(
213            metric_families(src),
214            vec!["cycles_total".to_string(), "result_failure".to_string()]
215        );
216    }
217
218    #[test]
219    fn ignores_non_literal_selectors_and_other_identifiers() {
220        assert!(metric_families("my_metric('x','count') + metric(fam, 'count')").is_empty());
221    }
222
223    #[test]
224    fn unknown_family_warns_known_family_does_not() {
225        let known: Vec<String> = ACTIVITY_FAMILIES.iter().map(|s| s.to_string()).collect();
226        let mut w = Vec::new();
227        lint_metric_families(
228            "metric('cycles_totl, phase=x', 'count') > 0",
229            &known,
230            "t",
231            &mut w,
232        );
233        assert_eq!(w.len(), 1, "typo'd family must warn: {w:?}");
234        w.clear();
235        lint_metric_families(
236            "metric('cycles_total, phase=x', 'count') > 0",
237            &known,
238            "t",
239            &mut w,
240        );
241        assert!(w.is_empty(), "known family must not warn: {w:?}");
242    }
243
244    #[test]
245    fn router_without_catch_all_warns_with_catch_all_does_not() {
246        let mut w = Vec::new();
247        lint_error_router("TimeoutError:retry,warn", "t", &mut w).unwrap();
248        assert_eq!(w.len(), 1, "no catch-all must warn: {w:?}");
249        w.clear();
250        lint_error_router("TimeoutError:retry,warn;.*:counter", "t", &mut w).unwrap();
251        assert!(w.is_empty(), "catch-all present must not warn: {w:?}");
252    }
253
254    #[test]
255    fn bad_router_spec_is_a_load_error_interpolated_is_deferred() {
256        let mut w = Vec::new();
257        let err = lint_error_router(".*:sotp", "phase 'p'", &mut w).unwrap_err();
258        assert!(err.contains("unknown error handler"), "got: {err}");
259        lint_error_router("{overload_policy}", "t", &mut w)
260            .expect("interpolated spec must be deferred, not parsed");
261    }
262}