1use std::borrow::Cow;
9use std::collections::BTreeSet;
10use std::fmt;
11
12use serde::ser::SerializeStruct;
13use serde::{Deserialize, Serialize, Serializer};
14
15use crate::check::{Check, CheckCtx};
16use crate::config::{ConfigValidationError, SeveritySetting};
17use crate::finding::Finding;
18use crate::prediction::{EnginePredictionV1, PredictionContractError};
19
20#[derive(Debug, Clone, Copy)]
26struct BuiltinEvidenceCode {
27 code: &'static str,
28 #[cfg_attr(not(test), allow(dead_code))]
29 meaning: &'static str,
30 emitted_by: &'static [&'static str],
31}
32
33macro_rules! builtin_codes {
34 (
35 $kind:ident, $registry:ident, $definitions:ident, $error:ident, $registry_doc:literal;
36 $($name:ident => $value:literal,
37 meaning = $meaning:literal,
38 emitted_by = [$($emitter:literal),+ $(,)?]),+ $(,)?
39 ) => {
40 impl $kind {
41 $(#[doc = $meaning] pub const $name: Self = Self::from_static($value);)+
42 }
43
44 #[doc = $registry_doc]
45 pub const $registry: &[$kind] = &[$($kind::$name),+];
46
47 const $definitions: &[BuiltinEvidenceCode] = &[
48 $(BuiltinEvidenceCode {
49 code: $value,
50 meaning: $meaning,
51 emitted_by: &[$($emitter),+],
52 }),+
53 ];
54
55 impl $kind {
56 #[allow(dead_code)]
57 fn builtin_definition(&self) -> Option<&'static BuiltinEvidenceCode> {
58 $definitions
59 .iter()
60 .find(|definition| definition.code == self.as_str())
61 }
62
63 #[allow(dead_code)]
64 fn validate_emitter(&self, check_id: &'static str) -> Result<(), EvaluationError> {
65 if self
66 .builtin_definition()
67 .is_some_and(|definition| !definition.emitted_by.contains(&check_id))
68 {
69 return Err(EvaluationError::$error {
70 check_id,
71 code: self.clone(),
72 });
73 }
74 Ok(())
75 }
76 }
77 };
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum SelectionState {
84 Selected,
86 Unselected,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum ConfigurationState {
94 Enabled,
96 Disabled,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum Applicability {
105 Applicable,
107 NotApplicable,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum EvaluationState {
115 Complete,
117 Partial,
119 NotEvaluated,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
128#[serde(transparent)]
129pub struct EvaluationScopeCode(Cow<'static, str>);
130
131builtin_codes!(
132 EvaluationScopeCode,
133 BUILTIN_EVALUATION_SCOPE_CODES,
134 BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS,
135 BuiltinEvaluationScopeEmitterMismatch,
136 "Complete built-in evaluation-scope vocabulary for consumers that need to inspect or allow-list animsmith's catalog codes. Custom checks may use additional namespaced codes.";
137 FIRST_FRAME_REST_DELTA => "first_frame_rest_delta",
138 meaning = "The named clip's first-frame/rest-pose rotation evidence was evaluated.",
139 emitted_by = ["bind-pose"],
140 LOOP_CLOSURE => "loop_closure",
141 meaning = "One named clip's per-bone model-space pose closure was measured.",
142 emitted_by = ["loop-closure"],
143 DUPLICATE_LOOP_ENDPOINT => "duplicate_loop_endpoint",
144 meaning = "One named clip's authored tracks were analyzed for redundant closing endpoint keys.",
145 emitted_by = ["duplicate-loop-endpoint"],
146 LOOP_SEAM => "loop_seam",
147 meaning = "One named clip's positional loop seam was measured.",
148 emitted_by = ["loop-seam"],
149 LOOP_SEAM_VELOCITY => "loop_seam_velocity",
150 meaning = "One named clip's per-bone model-space seam velocity continuity was measured.",
151 emitted_by = ["loop-seam-vel"],
152 LOOP_SEAM_ROTATION => "loop_seam_rotation",
153 meaning = "One named clip's per-bone model-space angular seam velocity continuity was measured.",
154 emitted_by = ["loop-seam-rot"],
155 FOOT_STANCE => "foot_stance",
156 meaning = "Whole-clip prerequisites for stance analysis were evaluated.",
157 emitted_by = ["foot-slide"],
158 LEFT_FOOT_STANCE => "left_foot_stance",
159 meaning = "The named clip's left foot/toe stance was evaluated.",
160 emitted_by = ["foot-slide"],
161 RIGHT_FOOT_STANCE => "right_foot_stance",
162 meaning = "The named clip's right foot/toe stance was evaluated.",
163 emitted_by = ["foot-slide"],
164 ROOT_MOTION_SPEED => "root_motion_speed",
165 meaning = "One named clip's root-motion speed was measured.",
166 emitted_by = ["root-motion-speed"],
167 MEMBER_EXISTENCE => "member_existence",
168 meaning = "Configured group members were checked for existence.",
169 emitted_by = ["gait-group", "sync-group", "time-complement"],
170 PHASE_MEASUREMENT => "phase_measurement",
171 meaning = "One named clip's gait phase was measured or lacked usable evidence.",
172 emitted_by = ["gait-group", "time-complement"],
173 PHASE_COHERENCE => "phase_coherence",
174 meaning = "One named group's measurable gait phases were compared.",
175 emitted_by = ["gait-group", "time-complement"],
176 SYNC_MEMBER_MEASUREMENT => "sync_member_measurement",
177 meaning = "One named same-time sync-group member's timing evidence was measured.",
178 emitted_by = ["sync-group"],
179 SYNC_COMPATIBILITY => "sync_compatibility",
180 meaning = "One named same-time sync group had compatible member timing evidence compared.",
181 emitted_by = ["sync-group"],
182 TRAVEL_MODE => "travel_mode",
183 meaning = "One named clip's XZ movement-owner declaration was judged.",
184 emitted_by = ["in-place"],
185 FRAME_GRID => "frame_grid",
186 meaning = "The named clip's declared frame grid was evaluated.",
187 emitted_by = ["fps"],
188 REQUIRED_BONE_PRESENCE => "required_bone_presence",
189 meaning = "Configured structural skeleton-bone presence requirements were evaluated.",
190 emitted_by = ["required-bones"],
191 SELECTED_NODE_REST_SCALE => "selected_node_rest_scale",
192 meaning = "One configured source-node selector resolved and its effective rest-world linear scale was evaluated.",
193 emitted_by = ["rest-world-scale"],
194 ANIMATION_ASSET_LABEL => "animation_asset_label",
195 meaning = "One source animation index was projected to the selected engine profile's canonical asset-label selector.",
196 emitted_by = ["engine-addressability"],
197 ANIMATION_ASSET_LABEL_INVENTORY => "animation_asset_label_inventory",
198 meaning = "Complete source-animation inventory required for asset-label prediction was unavailable.",
199 emitted_by = ["engine-addressability"],
200);
201
202#[cfg(test)]
205const EXTERNAL_BUILTIN_CHECK_IDS: &[&str] = &["engine-addressability"];
206
207impl EvaluationScopeCode {
208 const fn from_static(code: &'static str) -> Self {
209 Self(Cow::Borrowed(code))
210 }
211
212 pub const fn custom(code: &'static str) -> Self {
216 Self(Cow::Borrowed(code))
217 }
218
219 pub fn as_str(&self) -> &str {
221 &self.0
222 }
223}
224
225impl fmt::Display for EvaluationScopeCode {
226 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227 f.write_str(&self.0)
228 }
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
233#[serde(deny_unknown_fields)]
234pub struct EvaluationScope {
235 pub code: EvaluationScopeCode,
237 #[serde(skip_serializing_if = "Option::is_none")]
239 pub subject: Option<String>,
240}
241
242impl EvaluationScope {
243 pub fn new(code: EvaluationScopeCode) -> Self {
245 Self {
246 code,
247 subject: None,
248 }
249 }
250
251 pub fn subject(mut self, subject: impl Into<String>) -> Self {
253 self.subject = Some(subject.into());
254 self
255 }
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
264#[serde(transparent)]
265pub struct CoverageGapCode(&'static str);
266
267builtin_codes!(
268 CoverageGapCode,
269 BUILTIN_COVERAGE_GAP_CODES,
270 BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS,
271 BuiltinCoverageGapEmitterMismatch,
272 "Complete built-in coverage-gap vocabulary for consumers that need to inspect or allow-list animsmith's catalog codes. Custom checks may use additional namespaced codes.";
273 ROLES_UNRESOLVED => "roles_unresolved",
274 meaning = "Required semantic rig roles were not resolved.",
275 emitted_by = ["loop-seam", "root-motion-speed", "in-place", "foot-slide", "gait-group", "time-complement"],
276 MEASUREMENT_UNAVAILABLE => "measurement_unavailable",
277 meaning = "A required numeric measurement could not be produced or did not meet its evidence floor.",
278 emitted_by = ["loop-closure", "duplicate-loop-endpoint", "loop-seam", "loop-seam-vel", "loop-seam-rot", "root-motion-speed", "in-place", "foot-slide", "gait-group", "sync-group", "time-complement", "rest-world-scale"],
279 SKELETON_UNAVAILABLE => "skeleton_unavailable",
280 meaning = "Required skeleton presence work could not run because the file has no usable skeleton.",
281 emitted_by = ["required-bones"],
282 NODE_SELECTOR_NO_MATCH => "node_selector_no_match",
283 meaning = "A configured source-node selector matched no named source node.",
284 emitted_by = ["rest-world-scale"],
285 NODE_SELECTOR_AMBIGUOUS => "node_selector_ambiguous",
286 meaning = "A configured source-node selector matched more than one named source node.",
287 emitted_by = ["rest-world-scale"],
288 INSUFFICIENT_MEASURABLE_MEMBERS => "insufficient_measurable_members",
289 meaning = "Fewer than two configured group members produced usable comparison evidence.",
290 emitted_by = ["gait-group", "sync-group", "time-complement"],
291 MEMBERS_NOT_EVALUATED => "members_not_evaluated",
292 meaning = "Some configured group members did not produce usable comparison evidence.",
293 emitted_by = ["gait-group", "sync-group", "time-complement"],
294 INVALID_DECLARED_FPS => "invalid_declared_fps",
295 meaning = "A declared frame rate was zero, negative, or non-finite.",
296 emitted_by = ["fps"],
297 SYNC_FRAME_GRID_UNAVAILABLE => "sync_frame_grid_unavailable",
298 meaning = "A same-time sync-group member lacks usable declared frame-grid evidence.",
299 emitted_by = ["sync-group"],
300 INSUFFICIENT_ROTATION_EVIDENCE => "insufficient_rotation_evidence",
301 meaning = "Too few usable rotation tracks existed for a bind-pose comparison.",
302 emitted_by = ["bind-pose"],
303);
304
305impl CoverageGapCode {
306 const fn from_static(code: &'static str) -> Self {
307 Self(code)
308 }
309
310 pub const fn custom(code: &'static str) -> Self {
315 Self(code)
316 }
317
318 pub const fn as_str(self) -> &'static str {
320 self.0
321 }
322}
323
324impl fmt::Display for CoverageGapCode {
325 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326 f.write_str(self.0)
327 }
328}
329
330#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
332pub struct CoverageGap {
333 pub code: CoverageGapCode,
335 pub message: String,
337 #[serde(skip_serializing_if = "Option::is_none")]
339 pub scope: Option<EvaluationScope>,
340}
341
342impl CoverageGap {
343 pub fn new(code: CoverageGapCode, message: impl Into<String>) -> Self {
345 Self {
346 code,
347 message: message.into(),
348 scope: None,
349 }
350 }
351
352 pub fn scope(mut self, scope: EvaluationScope) -> Self {
354 self.scope = Some(scope);
355 self
356 }
357}
358
359#[derive(Debug, Clone)]
364pub struct CheckOutput {
365 findings: Vec<Finding>,
366 evaluated_scopes: Vec<EvaluationScope>,
367 gaps: Vec<CoverageGap>,
368 engine_prediction: Option<EnginePredictionV1>,
369}
370
371impl CheckOutput {
372 pub fn from_coverage(
378 findings: Vec<Finding>,
379 evaluated_scopes: Vec<EvaluationScope>,
380 gaps: Vec<CoverageGap>,
381 ) -> Self {
382 Self {
383 findings,
384 evaluated_scopes,
385 gaps,
386 engine_prediction: None,
387 }
388 }
389
390 pub fn with_engine_prediction(mut self, prediction: EnginePredictionV1) -> Self {
395 self.engine_prediction = Some(prediction);
396 self
397 }
398
399 pub fn findings(&self) -> &[Finding] {
401 &self.findings
402 }
403
404 pub fn evaluated_scopes(&self) -> &[EvaluationScope] {
406 &self.evaluated_scopes
407 }
408
409 pub fn gaps(&self) -> &[CoverageGap] {
411 &self.gaps
412 }
413
414 pub const fn engine_prediction(&self) -> Option<&EnginePredictionV1> {
416 self.engine_prediction.as_ref()
417 }
418
419 fn has_missing_work(&self) -> bool {
420 !self.gaps.is_empty()
421 || self
422 .engine_prediction
423 .as_ref()
424 .is_some_and(EnginePredictionV1::has_required_unavailable)
425 }
426}
427
428pub(crate) struct CheckEvaluationGapRef<'a> {
431 pub(crate) code: &'a str,
432 pub(crate) scope: Option<&'a EvaluationScope>,
433}
434
435pub(crate) struct CheckEvaluationValidationInput<'a> {
436 pub(crate) check_id: &'a str,
437 pub(crate) selection: SelectionState,
438 pub(crate) configuration: ConfigurationState,
439 pub(crate) applicability: Applicability,
440 pub(crate) finding_check_ids: &'a [&'a str],
441 pub(crate) evaluated_scopes: &'a [EvaluationScope],
442 pub(crate) gaps: &'a [CheckEvaluationGapRef<'a>],
443 pub(crate) prediction_scopes: &'a [&'a EvaluationScope],
444 pub(crate) has_prediction: bool,
445 pub(crate) prediction_has_required_unavailable: bool,
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449pub(crate) enum CheckEvaluationValidationError {
450 InvalidCheckId,
451 InvalidOutput(&'static str),
452 FindingCheckIdMismatch { finding_index: usize },
453 EvaluatedScopeEmitterMismatch { scope_index: usize },
454 GapScopeEmitterMismatch { gap_index: usize },
455 GapEmitterMismatch { gap_index: usize },
456 PredictionScopeEmitterMismatch { facet_index: usize },
457}
458
459impl CheckEvaluationValidationError {
460 pub(crate) const fn reason(self) -> &'static str {
461 match self {
462 Self::InvalidCheckId => "check id cannot be empty",
463 Self::InvalidOutput(reason) => reason,
464 Self::FindingCheckIdMismatch { .. } => "finding check_id must match its parent check",
465 Self::EvaluatedScopeEmitterMismatch { .. } => {
466 "evaluated scope code is invalid for its parent check"
467 }
468 Self::GapScopeEmitterMismatch { .. } => {
469 "coverage gap scope code is invalid for its parent check"
470 }
471 Self::GapEmitterMismatch { .. } => "coverage gap code is invalid for its parent check",
472 Self::PredictionScopeEmitterMismatch { .. } => {
473 "prediction facet scope code is invalid for its parent check"
474 }
475 }
476 }
477}
478
479pub(crate) fn validate_and_derive_check_evaluation(
483 input: CheckEvaluationValidationInput<'_>,
484) -> Result<EvaluationState, CheckEvaluationValidationError> {
485 let CheckEvaluationValidationInput {
486 check_id,
487 selection,
488 configuration,
489 applicability,
490 finding_check_ids,
491 evaluated_scopes,
492 gaps,
493 prediction_scopes,
494 has_prediction,
495 prediction_has_required_unavailable,
496 } = input;
497 if check_id.is_empty() {
498 return Err(CheckEvaluationValidationError::InvalidCheckId);
499 }
500 if let Some(finding_index) = finding_check_ids
501 .iter()
502 .position(|finding_check_id| *finding_check_id != check_id)
503 {
504 return Err(CheckEvaluationValidationError::FindingCheckIdMismatch { finding_index });
505 }
506 for (scope_index, scope) in evaluated_scopes.iter().enumerate() {
507 if scope.code.as_str().is_empty()
508 || scope
509 .code
510 .builtin_definition()
511 .is_some_and(|definition| !definition.emitted_by.contains(&check_id))
512 {
513 return Err(
514 CheckEvaluationValidationError::EvaluatedScopeEmitterMismatch { scope_index },
515 );
516 }
517 }
518 for (gap_index, gap) in gaps.iter().enumerate() {
519 if gap.code.is_empty()
520 || BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS
521 .iter()
522 .find(|definition| definition.code == gap.code)
523 .is_some_and(|definition| !definition.emitted_by.contains(&check_id))
524 {
525 return Err(CheckEvaluationValidationError::GapEmitterMismatch { gap_index });
526 }
527 if gap.scope.is_some_and(|scope| {
528 scope.code.as_str().is_empty()
529 || scope
530 .code
531 .builtin_definition()
532 .is_some_and(|definition| !definition.emitted_by.contains(&check_id))
533 }) {
534 return Err(CheckEvaluationValidationError::GapScopeEmitterMismatch { gap_index });
535 }
536 }
537 for (facet_index, scope) in prediction_scopes.iter().enumerate() {
538 if scope.code.as_str().is_empty()
539 || scope
540 .code
541 .builtin_definition()
542 .is_some_and(|definition| !definition.emitted_by.contains(&check_id))
543 {
544 return Err(
545 CheckEvaluationValidationError::PredictionScopeEmitterMismatch { facet_index },
546 );
547 }
548 }
549
550 let inactive = selection == SelectionState::Unselected
551 || configuration == ConfigurationState::Disabled
552 || applicability == Applicability::NotApplicable;
553 if inactive {
554 if !finding_check_ids.is_empty()
555 || !evaluated_scopes.is_empty()
556 || !gaps.is_empty()
557 || has_prediction
558 {
559 return Err(CheckEvaluationValidationError::InvalidOutput(
560 "inactive check must have empty output",
561 ));
562 }
563 return Ok(EvaluationState::NotEvaluated);
564 }
565
566 let missing = !gaps.is_empty() || prediction_has_required_unavailable;
567 let derived = if !missing {
568 EvaluationState::Complete
569 } else if evaluated_scopes.is_empty() {
570 EvaluationState::NotEvaluated
571 } else {
572 EvaluationState::Partial
573 };
574 if derived == EvaluationState::NotEvaluated && !finding_check_ids.is_empty() {
575 return Err(CheckEvaluationValidationError::InvalidOutput(
576 "not-evaluated output cannot carry content findings",
577 ));
578 }
579 Ok(derived)
580}
581
582#[derive(Debug, Clone)]
584pub struct CheckEvaluation {
585 check_id: &'static str,
586 selection: SelectionState,
587 configuration: ConfigurationState,
588 applicability: Applicability,
589 output: CheckOutput,
590}
591
592impl CheckEvaluation {
593 pub fn evaluated(check_id: &'static str, output: CheckOutput) -> Result<Self, EvaluationError> {
602 let gap_refs = output
603 .gaps
604 .iter()
605 .map(|gap| CheckEvaluationGapRef {
606 code: gap.code.as_str(),
607 scope: gap.scope.as_ref(),
608 })
609 .collect::<Vec<_>>();
610 let finding_check_ids = output
611 .findings
612 .iter()
613 .map(|finding| finding.check_id)
614 .collect::<Vec<_>>();
615 let prediction_scopes = output
616 .engine_prediction
617 .as_ref()
618 .into_iter()
619 .flat_map(EnginePredictionV1::facets)
620 .map(|facet| facet.scope())
621 .collect::<Vec<_>>();
622 validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
623 check_id,
624 selection: SelectionState::Selected,
625 configuration: ConfigurationState::Enabled,
626 applicability: Applicability::Applicable,
627 finding_check_ids: &finding_check_ids,
628 evaluated_scopes: &output.evaluated_scopes,
629 gaps: &gap_refs,
630 prediction_scopes: &prediction_scopes,
631 has_prediction: output.engine_prediction.is_some(),
632 prediction_has_required_unavailable: output.has_missing_work()
633 && output.gaps.is_empty(),
634 })
635 .map_err(|error| match error {
636 CheckEvaluationValidationError::InvalidCheckId => {
637 EvaluationError::InvalidCheckId(check_id)
638 }
639 CheckEvaluationValidationError::InvalidOutput(reason) => {
640 EvaluationError::InvalidCheckOutput { check_id, reason }
641 }
642 CheckEvaluationValidationError::FindingCheckIdMismatch { finding_index } => {
643 EvaluationError::FindingCheckIdMismatch {
644 check_id,
645 finding_check_id: output.findings[finding_index].check_id,
646 }
647 }
648 CheckEvaluationValidationError::EvaluatedScopeEmitterMismatch { scope_index } => {
649 let code = output.evaluated_scopes[scope_index].code.clone();
650 if code.as_str().is_empty() {
651 EvaluationError::InvalidCheckOutput {
652 check_id,
653 reason: "evaluated scope code cannot be empty",
654 }
655 } else {
656 EvaluationError::BuiltinEvaluationScopeEmitterMismatch { check_id, code }
657 }
658 }
659 CheckEvaluationValidationError::GapScopeEmitterMismatch { gap_index } => {
660 let code = output.gaps[gap_index]
661 .scope
662 .as_ref()
663 .expect("the validator reports only present gap scopes")
664 .code
665 .clone();
666 if code.as_str().is_empty() {
667 EvaluationError::InvalidCheckOutput {
668 check_id,
669 reason: "coverage gap scope code cannot be empty",
670 }
671 } else {
672 EvaluationError::BuiltinEvaluationScopeEmitterMismatch { check_id, code }
673 }
674 }
675 CheckEvaluationValidationError::GapEmitterMismatch { gap_index } => {
676 let code = output.gaps[gap_index].code;
677 if code.as_str().is_empty() {
678 EvaluationError::InvalidCheckOutput {
679 check_id,
680 reason: "coverage gap code cannot be empty",
681 }
682 } else {
683 EvaluationError::BuiltinCoverageGapEmitterMismatch { check_id, code }
684 }
685 }
686 CheckEvaluationValidationError::PredictionScopeEmitterMismatch { facet_index } => {
687 let code = output
688 .engine_prediction
689 .as_ref()
690 .expect("the validator reports only present prediction scopes")
691 .facets()[facet_index]
692 .scope()
693 .code
694 .clone();
695 if code.as_str().is_empty() {
696 EvaluationError::InvalidCheckOutput {
697 check_id,
698 reason: "prediction facet scope code cannot be empty",
699 }
700 } else {
701 EvaluationError::BuiltinEvaluationScopeEmitterMismatch { check_id, code }
702 }
703 }
704 })?;
705 if let Some(prediction) = &output.engine_prediction {
706 prediction.validate_for_check(
707 check_id,
708 &output.evaluated_scopes,
709 &output.gaps,
710 &output.findings,
711 )?;
712 } else if output
713 .findings
714 .iter()
715 .any(|finding| finding.prediction_scope.is_some())
716 {
717 return Err(EvaluationError::InvalidCheckOutput {
718 check_id,
719 reason: "finding has prediction_scope without an engine prediction",
720 });
721 }
722 Ok(Self {
723 check_id,
724 selection: SelectionState::Selected,
725 configuration: ConfigurationState::Enabled,
726 applicability: Applicability::Applicable,
727 output,
728 })
729 }
730
731 pub fn check_id(&self) -> &'static str {
733 self.check_id
734 }
735
736 pub fn selection(&self) -> SelectionState {
738 self.selection
739 }
740
741 pub fn configuration(&self) -> ConfigurationState {
743 self.configuration
744 }
745
746 pub fn applicability(&self) -> Applicability {
748 self.applicability
749 }
750
751 pub fn evaluation(&self) -> EvaluationState {
754 if self.selection == SelectionState::Unselected
755 || self.configuration == ConfigurationState::Disabled
756 || self.applicability == Applicability::NotApplicable
757 {
758 EvaluationState::NotEvaluated
759 } else if !self.output.has_missing_work() {
760 EvaluationState::Complete
761 } else if self.output.evaluated_scopes.is_empty() {
762 EvaluationState::NotEvaluated
763 } else {
764 EvaluationState::Partial
765 }
766 }
767
768 pub fn findings(&self) -> &[Finding] {
770 self.output.findings()
771 }
772
773 pub fn evaluated_scopes(&self) -> &[EvaluationScope] {
775 self.output.evaluated_scopes()
776 }
777
778 pub fn gaps(&self) -> &[CoverageGap] {
780 self.output.gaps()
781 }
782
783 pub const fn engine_prediction(&self) -> Option<&EnginePredictionV1> {
785 self.output.engine_prediction()
786 }
787
788 fn inactive(
789 check_id: &'static str,
790 selection: SelectionState,
791 configuration: ConfigurationState,
792 applicability: Applicability,
793 ) -> Self {
794 debug_assert!(
795 selection == SelectionState::Unselected
796 || configuration == ConfigurationState::Disabled
797 || applicability == Applicability::NotApplicable
798 );
799 Self {
800 check_id,
801 selection,
802 configuration,
803 applicability,
804 output: CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new()),
805 }
806 }
807
808 fn override_severity(&mut self, severity: SeveritySetting) {
809 if let Some(severity) = severity.as_severity() {
810 for finding in &mut self.output.findings {
811 finding.severity = severity;
812 }
813 }
814 }
815}
816
817impl Serialize for CheckEvaluation {
818 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
819 where
820 S: Serializer,
821 {
822 let mut fields = 6;
823 fields += usize::from(!self.output.evaluated_scopes.is_empty());
824 fields += usize::from(!self.output.gaps.is_empty());
825 fields += usize::from(self.output.engine_prediction.is_some());
826 let mut state = serializer.serialize_struct("CheckEvaluation", fields)?;
827 state.serialize_field("check_id", &self.check_id)?;
828 state.serialize_field("selection", &self.selection)?;
829 state.serialize_field("configuration", &self.configuration)?;
830 state.serialize_field("applicability", &self.applicability)?;
831 state.serialize_field("evaluation", &self.evaluation())?;
832 state.serialize_field("findings", &self.output.findings)?;
833 if !self.output.evaluated_scopes.is_empty() {
834 state.serialize_field("evaluated_scopes", &self.output.evaluated_scopes)?;
835 }
836 if !self.output.gaps.is_empty() {
837 state.serialize_field("gaps", &self.output.gaps)?;
838 }
839 if let Some(prediction) = &self.output.engine_prediction {
840 state.serialize_field("prediction", prediction)?;
841 }
842 state.end()
843 }
844}
845
846#[derive(Debug, Clone, Copy)]
848pub enum CheckSelection<'a> {
849 All,
851 Only(&'a BTreeSet<String>),
853}
854
855impl CheckSelection<'_> {
856 fn contains(self, id: &str) -> bool {
857 match self {
858 Self::All => true,
859 Self::Only(ids) => ids.contains(id),
860 }
861 }
862}
863
864#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
866#[non_exhaustive]
867pub enum EvaluationError {
868 #[error("invalid configuration: {0}")]
870 InvalidConfiguration(#[from] ConfigValidationError),
871 #[error("invalid engine prediction: {0}")]
873 InvalidPrediction(#[from] PredictionContractError),
874 #[error("check id cannot be empty")]
876 InvalidCheckId(&'static str),
877 #[error("duplicate check id {0:?}")]
879 DuplicateCheckId(&'static str),
880 #[error("unknown selected check id {0:?}")]
882 UnknownSelection(String),
883 #[error("check {check_id:?} emitted invalid output: {reason}")]
885 InvalidCheckOutput {
886 check_id: &'static str,
888 reason: &'static str,
890 },
891 #[error("check {check_id:?} emitted a finding for {finding_check_id:?}")]
893 FindingCheckIdMismatch {
894 check_id: &'static str,
896 finding_check_id: &'static str,
898 },
899 #[error("check {check_id:?} cannot emit built-in evaluation scope {code}")]
901 BuiltinEvaluationScopeEmitterMismatch {
902 check_id: &'static str,
904 code: EvaluationScopeCode,
906 },
907 #[error("check {check_id:?} cannot emit built-in coverage gap {code}")]
909 BuiltinCoverageGapEmitterMismatch {
910 check_id: &'static str,
912 code: CoverageGapCode,
914 },
915}
916
917pub fn lint_requires_failure(
922 checks: &[CheckEvaluation],
923 fail_at: crate::finding::Severity,
924 allowed_content_check_ids: &BTreeSet<String>,
925) -> bool {
926 checks.iter().any(|check| {
927 check
928 .engine_prediction()
929 .is_some_and(EnginePredictionV1::has_required_unavailable)
930 || check.findings().iter().any(|finding| {
931 finding.severity >= fail_at && !allowed_content_check_ids.contains(finding.check_id)
932 })
933 })
934}
935
936pub fn evaluate_checks(
953 ctx: &CheckCtx<'_>,
954 checks: &[Box<dyn Check + '_>],
955 selection: CheckSelection<'_>,
956) -> Result<Vec<CheckEvaluation>, EvaluationError> {
957 ctx.config.validate()?;
958
959 let mut catalog_ids = BTreeSet::new();
960 for check in checks {
961 if check.id().is_empty() {
962 return Err(EvaluationError::InvalidCheckId(check.id()));
963 }
964 if !catalog_ids.insert(check.id()) {
965 return Err(EvaluationError::DuplicateCheckId(check.id()));
966 }
967 }
968 if let CheckSelection::Only(selected) = selection
969 && let Some(unknown) = selected
970 .iter()
971 .find(|id| !catalog_ids.contains(id.as_str()))
972 {
973 return Err(EvaluationError::UnknownSelection(unknown.clone()));
974 }
975
976 let mut records = Vec::with_capacity(checks.len());
977 for check in checks {
978 let selection_state = if selection.contains(check.id()) {
979 SelectionState::Selected
980 } else {
981 SelectionState::Unselected
982 };
983 let setting = ctx.config.check_settings(check.id()).severity;
984 let configuration = match setting {
985 Some(SeveritySetting::Off) => ConfigurationState::Disabled,
986 Some(_) => ConfigurationState::Enabled,
987 None if check.enabled_by_default() => ConfigurationState::Enabled,
988 None => ConfigurationState::Disabled,
989 };
990 let applicability = check.applicability(ctx);
991
992 if selection_state == SelectionState::Unselected
993 || configuration == ConfigurationState::Disabled
994 || applicability == Applicability::NotApplicable
995 {
996 records.push(CheckEvaluation::inactive(
997 check.id(),
998 selection_state,
999 configuration,
1000 applicability,
1001 ));
1002 continue;
1003 }
1004
1005 let mut evaluation = CheckEvaluation::evaluated(check.id(), check.evaluate(ctx))?;
1006 if let Some(setting) = setting {
1007 evaluation.override_severity(setting);
1008 }
1009 records.push(evaluation);
1010 }
1011 Ok(records)
1012}
1013
1014#[cfg(test)]
1015mod authority_contract {
1016 use std::collections::BTreeSet;
1017 use std::path::{Path, PathBuf};
1018
1019 use super::{
1020 BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS, BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS,
1021 BuiltinEvidenceCode, CheckEvaluation, CheckOutput, CoverageGap, CoverageGapCode,
1022 EXTERNAL_BUILTIN_CHECK_IDS, EvaluationError, EvaluationScope, EvaluationScopeCode,
1023 EvaluationState, lint_requires_failure,
1024 };
1025 use crate::InputIdentity;
1026 use crate::finding::{Finding, Severity};
1027 use crate::prediction::{
1028 EnginePredictionBasisV1, EnginePredictionFacetV1, EnginePredictionV1,
1029 PredictionBasisReferenceV1, PredictionProvenanceIdentityV1, PredictionScalarV1,
1030 PredictionUnavailableReasonV1,
1031 };
1032
1033 fn assert_reference_table(docs: &str, heading: &str, entries: &[BuiltinEvidenceCode]) {
1034 let section = docs
1035 .split_once(heading)
1036 .unwrap_or_else(|| panic!("missing reference heading {heading:?}"))
1037 .1;
1038 let documented = section
1039 .lines()
1040 .skip_while(|line| line.trim().is_empty())
1041 .take_while(|line| !line.trim().is_empty())
1042 .filter(|line| line.starts_with("| `"))
1043 .collect::<Vec<_>>();
1044 let expected = entries
1045 .iter()
1046 .map(|definition| {
1047 let BuiltinEvidenceCode {
1048 code,
1049 meaning,
1050 emitted_by,
1051 } = definition;
1052 assert!(
1053 !meaning.trim().is_empty() && !meaning.contains(['\r', '\n']),
1054 "{code} must have a one-line meaning"
1055 );
1056 let emitters = emitted_by
1057 .iter()
1058 .map(|check_id| format!("`{check_id}`"))
1059 .collect::<Vec<_>>()
1060 .join(", ");
1061 format!("| `{code}` | {meaning} | {emitters} |")
1062 })
1063 .collect::<Vec<_>>();
1064
1065 assert_eq!(documented.len(), expected.len(), "row count for {heading}");
1066 let documented = documented.into_iter().collect::<BTreeSet<_>>();
1067 assert_eq!(
1068 documented.len(),
1069 expected.len(),
1070 "duplicate rows for {heading}"
1071 );
1072 let expected = expected.iter().map(String::as_str).collect::<BTreeSet<_>>();
1073 assert_eq!(documented, expected, "exact rows for {heading}");
1074 }
1075
1076 #[test]
1077 fn output_docs_match_registered_builtin_evidence_codes_exactly() {
1078 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
1079 let Some(workspace_root) = source_workspace_root(manifest_dir) else {
1080 return;
1082 };
1083 let docs_path = workspace_root.join("docs/output.md");
1084 let docs = std::fs::read_to_string(&docs_path)
1085 .unwrap_or_else(|error| panic!("cannot read {}: {error}", docs_path.display()));
1086 let crlf = docs.lines().collect::<Vec<_>>().join("\r\n");
1087 for line_endings in [docs.as_str(), crlf.as_str()] {
1088 assert_reference_table(
1089 line_endings,
1090 "Built-in gap codes are:",
1091 BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS,
1092 );
1093 assert_reference_table(
1094 line_endings,
1095 "Built-in completed/gap scope codes are:",
1096 BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS,
1097 );
1098 }
1099 }
1100
1101 #[test]
1102 fn builtin_evidence_authority_has_unique_codes_and_known_emitters() {
1103 let catalog_ids = crate::all_checks()
1104 .into_iter()
1105 .map(|check| check.id())
1106 .chain(EXTERNAL_BUILTIN_CHECK_IDS.iter().copied())
1107 .collect::<BTreeSet<_>>();
1108 let authorities = [
1109 ("coverage-gap", BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS),
1110 (
1111 "evaluation-scope",
1112 BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS,
1113 ),
1114 ];
1115
1116 for (kind, definitions) in authorities {
1117 assert!(
1118 definitions
1119 .iter()
1120 .all(|definition| !definition.code.is_empty()),
1121 "{kind} authority codes must be nonempty"
1122 );
1123 let defined_codes = definitions
1124 .iter()
1125 .map(|definition| definition.code)
1126 .collect::<BTreeSet<_>>();
1127 assert_eq!(
1128 defined_codes.len(),
1129 definitions.len(),
1130 "duplicate {kind} authority code"
1131 );
1132 for definition in definitions {
1133 let emitters = definition
1134 .emitted_by
1135 .iter()
1136 .copied()
1137 .collect::<BTreeSet<_>>();
1138 assert_eq!(
1139 emitters.len(),
1140 definition.emitted_by.len(),
1141 "{} has duplicate emitters",
1142 definition.code
1143 );
1144 for emitter in emitters {
1145 assert!(
1146 catalog_ids.contains(emitter),
1147 "{} declares unknown emitter {emitter:?}",
1148 definition.code
1149 );
1150 }
1151 }
1152 }
1153 }
1154
1155 #[test]
1156 fn every_builtin_code_enforces_its_emitter_matrix() {
1157 let catalog_ids = crate::all_checks()
1158 .into_iter()
1159 .map(|check| check.id())
1160 .chain(EXTERNAL_BUILTIN_CHECK_IDS.iter().copied())
1161 .collect::<Vec<_>>();
1162
1163 for definition in BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS {
1164 for &check_id in &catalog_ids {
1165 let code = EvaluationScopeCode::custom(definition.code);
1166 let completed = CheckEvaluation::evaluated(
1167 check_id,
1168 CheckOutput::from_coverage(
1169 Vec::new(),
1170 vec![EvaluationScope::new(code.clone())],
1171 Vec::new(),
1172 ),
1173 );
1174 let gap_scope = CheckEvaluation::evaluated(
1175 check_id,
1176 CheckOutput::from_coverage(
1177 Vec::new(),
1178 Vec::new(),
1179 vec![
1180 CoverageGap::new(CoverageGapCode::custom("test:gap"), "gap")
1181 .scope(EvaluationScope::new(code.clone())),
1182 ],
1183 ),
1184 );
1185 if definition.emitted_by.contains(&check_id) {
1186 assert!(
1187 completed.is_ok(),
1188 "{} must allow {check_id:?}",
1189 definition.code
1190 );
1191 assert!(
1192 gap_scope.is_ok(),
1193 "{} must allow {check_id:?}",
1194 definition.code
1195 );
1196 } else {
1197 let expected =
1198 EvaluationError::BuiltinEvaluationScopeEmitterMismatch { check_id, code };
1199 assert_eq!(completed.unwrap_err(), expected);
1200 assert_eq!(gap_scope.unwrap_err(), expected);
1201 }
1202 }
1203 }
1204
1205 for definition in BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS {
1206 for &check_id in &catalog_ids {
1207 let code = CoverageGapCode::custom(definition.code);
1208 let gap = CheckEvaluation::evaluated(
1209 check_id,
1210 CheckOutput::from_coverage(
1211 Vec::new(),
1212 Vec::new(),
1213 vec![CoverageGap::new(code, "test gap")],
1214 ),
1215 );
1216 if definition.emitted_by.contains(&check_id) {
1217 assert!(gap.is_ok(), "{} must allow {check_id:?}", definition.code);
1218 } else {
1219 assert_eq!(
1220 gap.unwrap_err(),
1221 EvaluationError::BuiltinCoverageGapEmitterMismatch { check_id, code }
1222 );
1223 }
1224 }
1225 }
1226 }
1227
1228 #[test]
1229 fn mixed_prediction_facets_use_the_existing_evaluation_and_exit_lifecycle() {
1230 const CHECK_ID: &str = "test:engine-prediction";
1231 let code = EvaluationScopeCode::custom("test:prediction-work");
1232 let available_scope = EvaluationScope::new(code.clone()).subject("available-clip");
1233 let unavailable_scope = EvaluationScope::new(code).subject("unavailable-clip");
1234 let available_basis = EnginePredictionBasisV1::new(vec![
1235 PredictionBasisReferenceV1::project_field(
1236 "project.mode",
1237 PredictionScalarV1::token("generic").unwrap(),
1238 )
1239 .unwrap(),
1240 ])
1241 .unwrap();
1242 let unavailable_basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
1243 let prediction = EnginePredictionV1::new(
1244 PredictionProvenanceIdentityV1::from_input_identity(InputIdentity::from_bytes(
1245 b"profile",
1246 )),
1247 vec![
1248 EnginePredictionFacetV1::available(available_scope.clone(), available_basis)
1249 .unwrap(),
1250 EnginePredictionFacetV1::required_unavailable(
1251 unavailable_scope,
1252 unavailable_basis,
1253 vec![PredictionUnavailableReasonV1::MeasurementUnavailable],
1254 )
1255 .unwrap(),
1256 ],
1257 )
1258 .unwrap();
1259 let finding = Finding::new(CHECK_ID, Severity::Note, "available subject finding")
1260 .prediction_scope(available_scope.clone());
1261 let check = CheckEvaluation::evaluated(
1262 CHECK_ID,
1263 CheckOutput::from_coverage(vec![finding], vec![available_scope], Vec::new())
1264 .with_engine_prediction(prediction),
1265 )
1266 .unwrap();
1267
1268 assert_eq!(check.evaluation(), EvaluationState::Partial);
1269 assert!(lint_requires_failure(
1270 &[check],
1271 Severity::Error,
1272 &BTreeSet::from([CHECK_ID.to_owned()]),
1273 ));
1274 }
1275
1276 #[test]
1277 fn available_only_prediction_does_not_fail_without_a_threshold_finding() {
1278 const CHECK_ID: &str = "test:available-prediction";
1279 let scope = EvaluationScope::new(EvaluationScopeCode::custom("test:prediction-work"));
1280 let basis = EnginePredictionBasisV1::new(vec![
1281 PredictionBasisReferenceV1::project_field(
1282 "project.mode",
1283 PredictionScalarV1::token("generic").unwrap(),
1284 )
1285 .unwrap(),
1286 ])
1287 .unwrap();
1288 let prediction = EnginePredictionV1::new(
1289 PredictionProvenanceIdentityV1::from_input_identity(InputIdentity::from_bytes(
1290 b"profile",
1291 )),
1292 vec![EnginePredictionFacetV1::available(scope.clone(), basis).unwrap()],
1293 )
1294 .unwrap();
1295 let finding = Finding::new(CHECK_ID, Severity::Note, "nonblocking available finding")
1296 .prediction_scope(scope.clone());
1297 let check = CheckEvaluation::evaluated(
1298 CHECK_ID,
1299 CheckOutput::from_coverage(vec![finding], vec![scope], Vec::new())
1300 .with_engine_prediction(prediction),
1301 )
1302 .unwrap();
1303
1304 assert_eq!(check.evaluation(), EvaluationState::Complete);
1305 assert!(!lint_requires_failure(
1306 &[check],
1307 Severity::Error,
1308 &BTreeSet::new(),
1309 ));
1310 }
1311
1312 #[test]
1313 fn required_unavailable_prediction_fails_despite_allow_and_severity() {
1314 const CHECK_ID: &str = "test:unavailable-prediction";
1315 let scope = EvaluationScope::new(EvaluationScopeCode::custom("test:prediction-work"));
1316 let prediction = EnginePredictionV1::new(
1317 PredictionProvenanceIdentityV1::from_input_identity(InputIdentity::from_bytes(
1318 b"profile",
1319 )),
1320 vec![
1321 EnginePredictionFacetV1::required_unavailable(
1322 scope,
1323 EnginePredictionBasisV1::new(Vec::new()).unwrap(),
1324 vec![PredictionUnavailableReasonV1::MeasurementUnavailable],
1325 )
1326 .unwrap(),
1327 ],
1328 )
1329 .unwrap();
1330 let check = CheckEvaluation::evaluated(
1331 CHECK_ID,
1332 CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
1333 .with_engine_prediction(prediction),
1334 )
1335 .unwrap();
1336
1337 assert_eq!(check.evaluation(), EvaluationState::NotEvaluated);
1338 assert!(lint_requires_failure(
1339 &[check],
1340 Severity::Error,
1341 &BTreeSet::from([CHECK_ID.to_owned()]),
1342 ));
1343 }
1344
1345 #[test]
1346 fn source_workspace_detection_has_a_positive_checkout_control() {
1347 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
1348 let detected = source_workspace_root(manifest_dir);
1349 if manifest_dir.join(".cargo_vcs_info.json").is_file() {
1350 assert!(detected.is_none(), "published packages must skip repo docs");
1351 return;
1352 }
1353
1354 let expected = manifest_dir.join("../..");
1355 if expected.join("docs/output.md").is_file() {
1356 assert_eq!(
1357 detected.as_deref(),
1358 Some(expected.as_path()),
1359 "the exact source checkout must enforce its output docs"
1360 );
1361 }
1362 }
1363
1364 fn source_workspace_root(manifest_dir: &Path) -> Option<PathBuf> {
1365 if manifest_dir.join(".cargo_vcs_info.json").is_file() {
1366 return None;
1367 }
1368 let workspace_root = manifest_dir.join("../..");
1369 let current_manifest = manifest_dir.join("Cargo.toml").canonicalize().ok()?;
1370 let workspace_manifest = workspace_root
1371 .join("crates/animsmith-core/Cargo.toml")
1372 .canonicalize()
1373 .ok()?;
1374 (current_manifest == workspace_manifest).then_some(workspace_root)
1375 }
1376}