1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
//! Structured lint findings. The structured fields (not just a message
//! string) are what make `diff`, the JSON schema, and the HTML report
//! cheap downstream.
use serde::Serialize;
use std::collections::BTreeMap;
use std::fmt;
use crate::evaluation::EvaluationScope;
/// Severity of a lint finding.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
/// Informational diagnostic that does not fail a gate.
Note,
/// Warning-level finding; the CLI treats warnings as a clean exit
/// unless configured to deny warnings.
Warning,
/// Error-level finding; the CLI exits with a content-failure status
/// when any error is present.
Error,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Severity::Note => "note",
Severity::Warning => "warning",
Severity::Error => "error",
})
}
}
/// A measured or expected quantity attached to a finding.
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
#[non_exhaustive]
pub enum Value {
/// Numeric measured or expected value.
Number(f64),
/// Textual measured or expected value.
Text(String),
}
/// One configured member's machine-readable evidence attached to a group
/// finding.
///
/// The member order is the configuration order, while the measurement map is
/// key-sorted for stable JSON. Values are deliberately scalar so consumers do
/// not need to parse presentation text to recover a group comparison table.
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct MemberMeasurement {
/// Configured member name, including a name absent from the input file.
pub member: String,
/// Named scalar measurements for this member. Non-finite numeric values
/// are excluded by [`Self::measurement`] and [`Finding::members`].
pub measurements: BTreeMap<String, Value>,
}
impl MemberMeasurement {
/// Construct an empty evidence row for `member`.
pub fn new(member: impl Into<String>) -> Self {
Self {
member: member.into(),
measurements: BTreeMap::new(),
}
}
/// Attach a finite numeric or textual measurement.
pub fn measurement(mut self, name: impl Into<String>, value: impl Into<Value>) -> Self {
let value = value.into();
if value.is_finite() {
self.measurements.insert(name.into(), value);
}
self
}
}
impl fmt::Display for Value {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Value::Number(n) => write!(f, "{n:.4}"),
Value::Text(s) => f.write_str(s),
}
}
}
/// A structured lint result emitted by a [`crate::Check`].
///
/// The JSON shape is part of animsmith's automation contract. The Rust
/// struct is marked `non_exhaustive` so new optional context fields can
/// be added before 1.0 without forcing downstream construction through
/// struct literals.
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct Finding {
/// Stable check id such as `"loop-seam"`.
pub check_id: &'static str,
/// Effective severity after any per-check override.
pub severity: Severity,
/// Clip associated with the finding, when the finding is clip-local.
#[serde(skip_serializing_if = "Option::is_none")]
pub clip: Option<String>,
/// Bone associated with the finding, when applicable.
#[serde(skip_serializing_if = "Option::is_none")]
pub bone: Option<String>,
/// Stable source-node path associated with the finding, when applicable.
/// Path components carry source indices so repeated display names remain
/// distinguishable.
#[serde(skip_serializing_if = "Option::is_none")]
pub node: Option<String>,
/// Exact prediction facet that produced this finding, when the parent
/// check carries an engine-prediction attachment.
#[serde(skip_serializing_if = "Option::is_none")]
pub prediction_scope: Option<EvaluationScope>,
/// Finite time in seconds associated with the finding, when applicable.
/// Non-finite values are omitted from serialized output.
#[serde(skip_serializing_if = "non_finite_time_or_none")]
pub time_s: Option<f32>,
/// Measured value that triggered the finding. Non-finite numeric values
/// are omitted from serialized output.
#[serde(skip_serializing_if = "non_finite_value_or_none")]
pub measured: Option<Value>,
/// Expected value or threshold for the finding. Non-finite numeric values
/// are omitted from serialized output.
#[serde(skip_serializing_if = "non_finite_value_or_none")]
pub expected: Option<Value>,
/// Per-member evidence for a group-level finding, in configured order.
#[serde(skip_serializing_if = "Option::is_none")]
pub members: Option<Vec<MemberMeasurement>>,
/// Human-readable explanation.
pub message: String,
}
impl Finding {
/// Construct a finding with no optional context fields set.
pub fn new(check_id: &'static str, severity: Severity, message: impl Into<String>) -> Self {
Self {
check_id,
severity,
clip: None,
bone: None,
node: None,
prediction_scope: None,
time_s: None,
measured: None,
expected: None,
members: None,
message: message.into(),
}
}
/// Attach a clip name.
pub fn clip(mut self, clip: impl Into<String>) -> Self {
self.clip = Some(clip.into());
self
}
/// Attach a bone name.
pub fn bone(mut self, bone: impl Into<String>) -> Self {
self.bone = Some(bone.into());
self
}
/// Attach a stable source-node path.
pub fn node(mut self, node: impl Into<String>) -> Self {
self.node = Some(node.into());
self
}
/// Bind this finding to one available engine-prediction facet.
///
/// The shared check-evaluation boundary verifies that the scope identifies
/// exactly one available facet on the parent check. It rejects a scope that
/// is missing or required-unavailable.
pub fn prediction_scope(mut self, scope: EvaluationScope) -> Self {
self.prediction_scope = Some(scope);
self
}
/// Attach a clip time in seconds.
pub fn time(mut self, t: f32) -> Self {
self.time_s = t.is_finite().then_some(t);
self
}
/// Attach a measured value.
pub fn measured(mut self, v: impl Into<Value>) -> Self {
let value = v.into();
self.measured = value.is_finite().then_some(value);
self
}
/// Attach an expected value or threshold.
pub fn expected(mut self, v: impl Into<Value>) -> Self {
let value = v.into();
self.expected = value.is_finite().then_some(value);
self
}
/// Attach a configured-order group member table.
///
/// Any non-finite numeric values supplied through a directly constructed
/// row are removed before serialization, matching scalar finding values.
pub fn members(mut self, mut members: Vec<MemberMeasurement>) -> Self {
for member in &mut members {
member.measurements.retain(|_, value| value.is_finite());
}
self.members = Some(members);
self
}
}
impl Value {
fn is_finite(&self) -> bool {
match self {
Self::Number(number) => number.is_finite(),
Self::Text(_) => true,
}
}
}
fn non_finite_time_or_none(value: &Option<f32>) -> bool {
value.is_none_or(|time| !time.is_finite())
}
fn non_finite_value_or_none(value: &Option<Value>) -> bool {
value.as_ref().is_none_or(|value| !value.is_finite())
}
impl From<f64> for Value {
fn from(n: f64) -> Self {
Value::Number(n)
}
}
impl From<f32> for Value {
fn from(n: f32) -> Self {
Value::Number(n as f64)
}
}
impl From<&str> for Value {
fn from(s: &str) -> Self {
Value::Text(s.to_owned())
}
}
impl From<String> for Value {
fn from(s: String) -> Self {
Value::Text(s)
}
}