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::{
19 EnginePredictionV1, EnginePredictionV2, EnginePredictionV3, EnginePredictionV4,
20 EnginePredictionV5, EnginePredictionV6, PredictionContractError,
21};
22
23#[derive(Debug, Clone, Copy)]
29struct BuiltinEvidenceCode {
30 code: &'static str,
31 #[cfg_attr(not(test), allow(dead_code))]
32 meaning: &'static str,
33 emitted_by: &'static [&'static str],
34}
35
36macro_rules! builtin_codes {
37 (
38 $kind:ident, $registry:ident, $definitions:ident, $error:ident, $registry_doc:literal;
39 $($name:ident => $value:literal,
40 meaning = $meaning:literal,
41 emitted_by = [$($emitter:literal),+ $(,)?]),+ $(,)?
42 ) => {
43 impl $kind {
44 $(#[doc = $meaning] pub const $name: Self = Self::from_static($value);)+
45 }
46
47 #[doc = $registry_doc]
48 pub const $registry: &[$kind] = &[$($kind::$name),+];
49
50 const $definitions: &[BuiltinEvidenceCode] = &[
51 $(BuiltinEvidenceCode {
52 code: $value,
53 meaning: $meaning,
54 emitted_by: &[$($emitter),+],
55 }),+
56 ];
57
58 impl $kind {
59 #[allow(dead_code)]
60 fn builtin_definition(&self) -> Option<&'static BuiltinEvidenceCode> {
61 $definitions
62 .iter()
63 .find(|definition| definition.code == self.as_str())
64 }
65
66 #[allow(dead_code)]
67 fn validate_emitter(&self, check_id: &'static str) -> Result<(), EvaluationError> {
68 if self
69 .builtin_definition()
70 .is_some_and(|definition| !definition.emitted_by.contains(&check_id))
71 {
72 return Err(EvaluationError::$error {
73 check_id,
74 code: self.clone(),
75 });
76 }
77 Ok(())
78 }
79 }
80 };
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum SelectionState {
87 Selected,
89 Unselected,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum ConfigurationState {
97 Enabled,
99 Disabled,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum Applicability {
108 Applicable,
110 NotApplicable,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum EvaluationState {
118 Complete,
120 Partial,
122 NotEvaluated,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
131#[serde(transparent)]
132pub struct EvaluationScopeCode(Cow<'static, str>);
133
134builtin_codes!(
135 EvaluationScopeCode,
136 BUILTIN_EVALUATION_SCOPE_CODES,
137 BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS,
138 BuiltinEvaluationScopeEmitterMismatch,
139 "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.";
140 FIRST_FRAME_REST_DELTA => "first_frame_rest_delta",
141 meaning = "The named clip's first-frame/rest-pose rotation evidence was evaluated.",
142 emitted_by = ["bind-pose"],
143 LOOP_CLOSURE => "loop_closure",
144 meaning = "One named clip's per-bone model-space pose closure was measured.",
145 emitted_by = ["loop-closure"],
146 DUPLICATE_LOOP_ENDPOINT => "duplicate_loop_endpoint",
147 meaning = "One named clip's authored tracks were analyzed for redundant closing endpoint keys.",
148 emitted_by = ["duplicate-loop-endpoint"],
149 LOOP_SEAM => "loop_seam",
150 meaning = "One named clip's positional loop seam was measured.",
151 emitted_by = ["loop-seam"],
152 LOOP_SEAM_VELOCITY => "loop_seam_velocity",
153 meaning = "One named clip's per-bone model-space seam velocity continuity was measured.",
154 emitted_by = ["loop-seam-vel"],
155 LOOP_SEAM_ROTATION => "loop_seam_rotation",
156 meaning = "One named clip's per-bone model-space angular seam velocity continuity was measured.",
157 emitted_by = ["loop-seam-rot"],
158 FOOT_STANCE => "foot_stance",
159 meaning = "Whole-clip prerequisites for stance analysis were evaluated.",
160 emitted_by = ["foot-slide"],
161 LEFT_FOOT_STANCE => "left_foot_stance",
162 meaning = "The named clip's left foot/toe stance was evaluated.",
163 emitted_by = ["foot-slide"],
164 RIGHT_FOOT_STANCE => "right_foot_stance",
165 meaning = "The named clip's right foot/toe stance was evaluated.",
166 emitted_by = ["foot-slide"],
167 ROOT_MOTION_SPEED => "root_motion_speed",
168 meaning = "One named clip's root-motion speed was measured.",
169 emitted_by = ["root-motion-speed"],
170 MEMBER_EXISTENCE => "member_existence",
171 meaning = "Configured group members were checked for existence.",
172 emitted_by = ["gait-group", "sync-group", "time-complement"],
173 PHASE_MEASUREMENT => "phase_measurement",
174 meaning = "One named clip's gait phase was measured or lacked usable evidence.",
175 emitted_by = ["gait-group", "time-complement"],
176 PHASE_COHERENCE => "phase_coherence",
177 meaning = "One named group's measurable gait phases were compared.",
178 emitted_by = ["gait-group", "time-complement"],
179 SYNC_MEMBER_MEASUREMENT => "sync_member_measurement",
180 meaning = "One named same-time sync-group member's timing evidence was measured.",
181 emitted_by = ["sync-group"],
182 SYNC_COMPATIBILITY => "sync_compatibility",
183 meaning = "One named same-time sync group had compatible member timing evidence compared.",
184 emitted_by = ["sync-group"],
185 TRAVEL_MODE => "travel_mode",
186 meaning = "One named clip's XZ movement-owner declaration was judged.",
187 emitted_by = ["in-place"],
188 FRAME_GRID => "frame_grid",
189 meaning = "The named clip's declared frame grid was evaluated.",
190 emitted_by = ["fps"],
191 REQUIRED_BONE_PRESENCE => "required_bone_presence",
192 meaning = "Configured structural skeleton-bone presence requirements were evaluated.",
193 emitted_by = ["required-bones"],
194 SELECTED_NODE_REST_SCALE => "selected_node_rest_scale",
195 meaning = "One configured source-node selector resolved and its effective rest-world linear scale was evaluated.",
196 emitted_by = ["rest-world-scale"],
197 ANIMATION_ASSET_LABEL => "animation_asset_label",
198 meaning = "One source animation index was projected to the selected engine profile's canonical asset-label selector.",
199 emitted_by = ["engine-addressability"],
200 ANIMATION_ASSET_LABEL_INVENTORY => "animation_asset_label_inventory",
201 meaning = "Complete source-animation inventory required for asset-label prediction was unavailable.",
202 emitted_by = ["engine-addressability"],
203 SCENE_ASSET_LABEL => "scene_asset_label",
204 meaning = "One source scene index was projected to the selected engine profile's canonical asset-label selector.",
205 emitted_by = ["engine-addressability"],
206 DEFAULT_SCENE_ROUTE => "default_scene_route",
207 meaning = "The source default-scene observation was projected to the selected engine profile's route to an existing scene asset.",
208 emitted_by = ["engine-addressability"],
209 SKIN_ASSET_LABEL => "skin_asset_label",
210 meaning = "One source skin index was projected to the selected engine profile's conditional canonical skin asset-label selector.",
211 emitted_by = ["engine-addressability"],
212 INVERSE_BIND_MATRICES_ASSET_LABEL => "inverse_bind_matrices_asset_label",
213 meaning = "One source skin index was projected to the selected engine profile's canonical inverse-bind-matrices asset-label selector.",
214 emitted_by = ["engine-addressability"],
215 NAMED_ADDRESSABILITY_MAP => "named_addressability_map",
216 meaning = "One selected engine profile named-addressability map and its duplicate-name policy were evaluated.",
217 emitted_by = ["engine-addressability"],
218 ANIMATION_TARGET_ID => "animation_target_id",
219 meaning = "One unique source animation target node's exact path and target identifier were evaluated.",
220 emitted_by = ["engine-addressability"],
221 GLTF_ADDRESSABILITY_INVENTORY => "gltf_addressability_inventory",
222 meaning = "Complete raw glTF scene, node, skin, attachment, and path evidence required for rich addressability prediction was unavailable.",
223 emitted_by = ["engine-addressability"],
224 ENGINE_CLIP_BOUNDARY => "engine_clip_boundary",
225 meaning = "One source animation clip's exact end-frame boundary was evaluated.",
226 emitted_by = ["engine-clip-boundary"],
227 ENGINE_CLIP_BOUNDARY_INVENTORY => "engine_clip_boundary_inventory",
228 meaning = "Complete exact source-animation boundary inventory was unavailable.",
229 emitted_by = ["engine-clip-boundary"],
230);
231
232#[cfg(test)]
235const EXTERNAL_BUILTIN_CHECK_IDS: &[&str] = &[
236 "engine-addressability",
237 "engine-clip-boundary",
238 "engine-unit-scale",
239];
240
241impl EvaluationScopeCode {
242 const fn from_static(code: &'static str) -> Self {
243 Self(Cow::Borrowed(code))
244 }
245
246 pub const fn custom(code: &'static str) -> Self {
250 Self(Cow::Borrowed(code))
251 }
252
253 pub fn as_str(&self) -> &str {
255 &self.0
256 }
257}
258
259impl fmt::Display for EvaluationScopeCode {
260 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261 f.write_str(&self.0)
262 }
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
267#[serde(deny_unknown_fields)]
268pub struct EvaluationScope {
269 pub code: EvaluationScopeCode,
271 #[serde(skip_serializing_if = "Option::is_none")]
273 pub subject: Option<String>,
274}
275
276impl EvaluationScope {
277 pub fn new(code: EvaluationScopeCode) -> Self {
279 Self {
280 code,
281 subject: None,
282 }
283 }
284
285 pub fn subject(mut self, subject: impl Into<String>) -> Self {
287 self.subject = Some(subject.into());
288 self
289 }
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
298#[serde(transparent)]
299pub struct CoverageGapCode(&'static str);
300
301builtin_codes!(
302 CoverageGapCode,
303 BUILTIN_COVERAGE_GAP_CODES,
304 BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS,
305 BuiltinCoverageGapEmitterMismatch,
306 "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.";
307 ROLES_UNRESOLVED => "roles_unresolved",
308 meaning = "Required semantic rig roles were not resolved.",
309 emitted_by = ["loop-seam", "root-motion-speed", "in-place", "foot-slide", "gait-group", "time-complement"],
310 MEASUREMENT_UNAVAILABLE => "measurement_unavailable",
311 meaning = "A required numeric measurement could not be produced or did not meet its evidence floor.",
312 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"],
313 SKELETON_UNAVAILABLE => "skeleton_unavailable",
314 meaning = "Required skeleton presence work could not run because the file has no usable skeleton.",
315 emitted_by = ["required-bones"],
316 NODE_SELECTOR_NO_MATCH => "node_selector_no_match",
317 meaning = "A configured source-node selector matched no named source node.",
318 emitted_by = ["rest-world-scale"],
319 NODE_SELECTOR_AMBIGUOUS => "node_selector_ambiguous",
320 meaning = "A configured source-node selector matched more than one named source node.",
321 emitted_by = ["rest-world-scale"],
322 INSUFFICIENT_MEASURABLE_MEMBERS => "insufficient_measurable_members",
323 meaning = "Fewer than two configured group members produced usable comparison evidence.",
324 emitted_by = ["gait-group", "sync-group", "time-complement"],
325 MEMBERS_NOT_EVALUATED => "members_not_evaluated",
326 meaning = "Some configured group members did not produce usable comparison evidence.",
327 emitted_by = ["gait-group", "sync-group", "time-complement"],
328 INVALID_DECLARED_FPS => "invalid_declared_fps",
329 meaning = "A declared frame rate was zero, negative, or non-finite.",
330 emitted_by = ["fps"],
331 SYNC_FRAME_GRID_UNAVAILABLE => "sync_frame_grid_unavailable",
332 meaning = "A same-time sync-group member lacks usable declared frame-grid evidence.",
333 emitted_by = ["sync-group"],
334 INSUFFICIENT_ROTATION_EVIDENCE => "insufficient_rotation_evidence",
335 meaning = "Too few usable rotation tracks existed for a bind-pose comparison.",
336 emitted_by = ["bind-pose"],
337);
338
339impl CoverageGapCode {
340 const fn from_static(code: &'static str) -> Self {
341 Self(code)
342 }
343
344 pub const fn custom(code: &'static str) -> Self {
349 Self(code)
350 }
351
352 pub const fn as_str(self) -> &'static str {
354 self.0
355 }
356}
357
358impl fmt::Display for CoverageGapCode {
359 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
360 f.write_str(self.0)
361 }
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
366pub struct CoverageGap {
367 pub code: CoverageGapCode,
369 pub message: String,
371 #[serde(skip_serializing_if = "Option::is_none")]
373 pub scope: Option<EvaluationScope>,
374}
375
376impl CoverageGap {
377 pub fn new(code: CoverageGapCode, message: impl Into<String>) -> Self {
379 Self {
380 code,
381 message: message.into(),
382 scope: None,
383 }
384 }
385
386 pub fn scope(mut self, scope: EvaluationScope) -> Self {
388 self.scope = Some(scope);
389 self
390 }
391}
392
393#[derive(Debug, Clone)]
398pub struct CheckOutput {
399 findings: Vec<Finding>,
400 evaluated_scopes: Vec<EvaluationScope>,
401 gaps: Vec<CoverageGap>,
402 engine_prediction: Option<EnginePredictionEvidence>,
403}
404
405#[derive(Debug, Clone)]
408enum EnginePredictionEvidence {
409 V1(EnginePredictionV1),
410 V2(EnginePredictionV2),
411 V3(EnginePredictionV3),
412 V4(EnginePredictionV4),
413 V5(EnginePredictionV5),
414 V6(EnginePredictionV6),
415}
416
417impl EnginePredictionEvidence {
418 fn scopes(&self) -> Vec<&EvaluationScope> {
419 match self {
420 Self::V1(prediction) => prediction
421 .facets()
422 .iter()
423 .map(|facet| facet.scope())
424 .collect(),
425 Self::V2(prediction) => prediction
426 .facets()
427 .iter()
428 .map(|facet| facet.scope())
429 .collect(),
430 Self::V3(prediction) => prediction
431 .facets()
432 .iter()
433 .map(|facet| facet.scope())
434 .collect(),
435 Self::V4(prediction) => prediction
436 .facets()
437 .iter()
438 .map(|facet| facet.scope())
439 .collect(),
440 Self::V5(prediction) => prediction
441 .facets()
442 .iter()
443 .map(|facet| facet.scope())
444 .collect(),
445 Self::V6(prediction) => prediction
446 .facets()
447 .iter()
448 .map(|facet| facet.scope())
449 .collect(),
450 }
451 }
452
453 fn scope(&self, index: usize) -> &EvaluationScope {
454 match self {
455 Self::V1(prediction) => prediction.facets()[index].scope(),
456 Self::V2(prediction) => prediction.facets()[index].scope(),
457 Self::V3(prediction) => prediction.facets()[index].scope(),
458 Self::V4(prediction) => prediction.facets()[index].scope(),
459 Self::V5(prediction) => prediction.facets()[index].scope(),
460 Self::V6(prediction) => prediction.facets()[index].scope(),
461 }
462 }
463}
464
465impl CheckOutput {
466 pub fn from_coverage(
472 findings: Vec<Finding>,
473 evaluated_scopes: Vec<EvaluationScope>,
474 gaps: Vec<CoverageGap>,
475 ) -> Self {
476 Self {
477 findings,
478 evaluated_scopes,
479 gaps,
480 engine_prediction: None,
481 }
482 }
483
484 pub fn with_engine_prediction(mut self, prediction: EnginePredictionV1) -> Self {
489 self.engine_prediction = Some(EnginePredictionEvidence::V1(prediction));
490 self
491 }
492
493 pub fn with_engine_prediction_v2(mut self, prediction: EnginePredictionV2) -> Self {
495 self.engine_prediction = Some(EnginePredictionEvidence::V2(prediction));
496 self
497 }
498
499 pub fn with_engine_prediction_v3(mut self, prediction: EnginePredictionV3) -> Self {
501 self.engine_prediction = Some(EnginePredictionEvidence::V3(prediction));
502 self
503 }
504
505 pub fn with_engine_prediction_v4(mut self, prediction: EnginePredictionV4) -> Self {
507 self.engine_prediction = Some(EnginePredictionEvidence::V4(prediction));
508 self
509 }
510
511 pub fn with_engine_prediction_v5(mut self, prediction: EnginePredictionV5) -> Self {
513 self.engine_prediction = Some(EnginePredictionEvidence::V5(prediction));
514 self
515 }
516
517 pub fn with_engine_prediction_v6(mut self, prediction: EnginePredictionV6) -> Self {
519 self.engine_prediction = Some(EnginePredictionEvidence::V6(prediction));
520 self
521 }
522
523 pub fn findings(&self) -> &[Finding] {
525 &self.findings
526 }
527
528 pub fn evaluated_scopes(&self) -> &[EvaluationScope] {
530 &self.evaluated_scopes
531 }
532
533 pub fn gaps(&self) -> &[CoverageGap] {
535 &self.gaps
536 }
537
538 pub const fn engine_prediction(&self) -> Option<&EnginePredictionV1> {
540 match self.engine_prediction.as_ref() {
541 Some(EnginePredictionEvidence::V1(prediction)) => Some(prediction),
542 Some(
543 EnginePredictionEvidence::V2(_)
544 | EnginePredictionEvidence::V3(_)
545 | EnginePredictionEvidence::V4(_)
546 | EnginePredictionEvidence::V5(_)
547 | EnginePredictionEvidence::V6(_),
548 )
549 | None => None,
550 }
551 }
552
553 pub const fn engine_prediction_v2(&self) -> Option<&EnginePredictionV2> {
555 match self.engine_prediction.as_ref() {
556 Some(EnginePredictionEvidence::V2(prediction)) => Some(prediction),
557 Some(
558 EnginePredictionEvidence::V1(_)
559 | EnginePredictionEvidence::V3(_)
560 | EnginePredictionEvidence::V4(_)
561 | EnginePredictionEvidence::V5(_)
562 | EnginePredictionEvidence::V6(_),
563 )
564 | None => None,
565 }
566 }
567
568 pub const fn engine_prediction_v3(&self) -> Option<&EnginePredictionV3> {
570 match self.engine_prediction.as_ref() {
571 Some(EnginePredictionEvidence::V3(prediction)) => Some(prediction),
572 Some(
573 EnginePredictionEvidence::V1(_)
574 | EnginePredictionEvidence::V2(_)
575 | EnginePredictionEvidence::V4(_)
576 | EnginePredictionEvidence::V5(_)
577 | EnginePredictionEvidence::V6(_),
578 )
579 | None => None,
580 }
581 }
582
583 pub const fn engine_prediction_v4(&self) -> Option<&EnginePredictionV4> {
585 match self.engine_prediction.as_ref() {
586 Some(EnginePredictionEvidence::V4(prediction)) => Some(prediction),
587 Some(
588 EnginePredictionEvidence::V1(_)
589 | EnginePredictionEvidence::V2(_)
590 | EnginePredictionEvidence::V3(_)
591 | EnginePredictionEvidence::V5(_)
592 | EnginePredictionEvidence::V6(_),
593 )
594 | None => None,
595 }
596 }
597
598 pub const fn engine_prediction_v5(&self) -> Option<&EnginePredictionV5> {
600 match self.engine_prediction.as_ref() {
601 Some(EnginePredictionEvidence::V5(prediction)) => Some(prediction),
602 Some(
603 EnginePredictionEvidence::V1(_)
604 | EnginePredictionEvidence::V2(_)
605 | EnginePredictionEvidence::V3(_)
606 | EnginePredictionEvidence::V4(_)
607 | EnginePredictionEvidence::V6(_),
608 )
609 | None => None,
610 }
611 }
612
613 pub const fn engine_prediction_v6(&self) -> Option<&EnginePredictionV6> {
615 match self.engine_prediction.as_ref() {
616 Some(EnginePredictionEvidence::V6(prediction)) => Some(prediction),
617 Some(
618 EnginePredictionEvidence::V1(_)
619 | EnginePredictionEvidence::V2(_)
620 | EnginePredictionEvidence::V3(_)
621 | EnginePredictionEvidence::V4(_)
622 | EnginePredictionEvidence::V5(_),
623 )
624 | None => None,
625 }
626 }
627
628 fn has_required_prediction_unavailable(&self) -> bool {
629 self.engine_prediction
630 .as_ref()
631 .is_some_and(|prediction| match prediction {
632 EnginePredictionEvidence::V1(prediction) => prediction.has_required_unavailable(),
633 EnginePredictionEvidence::V2(prediction) => prediction.has_required_unavailable(),
634 EnginePredictionEvidence::V3(prediction) => prediction.has_required_unavailable(),
635 EnginePredictionEvidence::V4(prediction) => prediction.has_required_unavailable(),
636 EnginePredictionEvidence::V5(prediction) => prediction.has_required_unavailable(),
637 EnginePredictionEvidence::V6(prediction) => prediction.has_required_unavailable(),
638 })
639 }
640
641 fn has_missing_work(&self) -> bool {
642 !self.gaps.is_empty() || self.has_required_prediction_unavailable()
643 }
644}
645
646pub(crate) struct CheckEvaluationGapRef<'a> {
649 pub(crate) code: &'a str,
650 pub(crate) scope: Option<&'a EvaluationScope>,
651}
652
653pub(crate) struct CheckEvaluationValidationInput<'a> {
654 pub(crate) check_id: &'a str,
655 pub(crate) selection: SelectionState,
656 pub(crate) configuration: ConfigurationState,
657 pub(crate) applicability: Applicability,
658 pub(crate) finding_check_ids: &'a [&'a str],
659 pub(crate) evaluated_scopes: &'a [EvaluationScope],
660 pub(crate) gaps: &'a [CheckEvaluationGapRef<'a>],
661 pub(crate) prediction_scopes: &'a [&'a EvaluationScope],
662 pub(crate) has_prediction: bool,
663 pub(crate) prediction_has_required_unavailable: bool,
664}
665
666#[derive(Debug, Clone, Copy, PartialEq, Eq)]
667pub(crate) enum CheckEvaluationValidationError {
668 InvalidCheckId,
669 InvalidOutput(&'static str),
670 FindingCheckIdMismatch { finding_index: usize },
671 EvaluatedScopeEmitterMismatch { scope_index: usize },
672 GapScopeEmitterMismatch { gap_index: usize },
673 GapEmitterMismatch { gap_index: usize },
674 PredictionScopeEmitterMismatch { facet_index: usize },
675}
676
677impl CheckEvaluationValidationError {
678 pub(crate) const fn reason(self) -> &'static str {
679 match self {
680 Self::InvalidCheckId => "check id cannot be empty",
681 Self::InvalidOutput(reason) => reason,
682 Self::FindingCheckIdMismatch { .. } => "finding check_id must match its parent check",
683 Self::EvaluatedScopeEmitterMismatch { .. } => {
684 "evaluated scope code is invalid for its parent check"
685 }
686 Self::GapScopeEmitterMismatch { .. } => {
687 "coverage gap scope code is invalid for its parent check"
688 }
689 Self::GapEmitterMismatch { .. } => "coverage gap code is invalid for its parent check",
690 Self::PredictionScopeEmitterMismatch { .. } => {
691 "prediction facet scope code is invalid for its parent check"
692 }
693 }
694 }
695}
696
697pub(crate) fn validate_and_derive_check_evaluation(
701 input: CheckEvaluationValidationInput<'_>,
702) -> Result<EvaluationState, CheckEvaluationValidationError> {
703 let CheckEvaluationValidationInput {
704 check_id,
705 selection,
706 configuration,
707 applicability,
708 finding_check_ids,
709 evaluated_scopes,
710 gaps,
711 prediction_scopes,
712 has_prediction,
713 prediction_has_required_unavailable,
714 } = input;
715 if check_id.is_empty() {
716 return Err(CheckEvaluationValidationError::InvalidCheckId);
717 }
718 if let Some(finding_index) = finding_check_ids
719 .iter()
720 .position(|finding_check_id| *finding_check_id != check_id)
721 {
722 return Err(CheckEvaluationValidationError::FindingCheckIdMismatch { finding_index });
723 }
724 for (scope_index, scope) in evaluated_scopes.iter().enumerate() {
725 if scope.code.as_str().is_empty()
726 || scope
727 .code
728 .builtin_definition()
729 .is_some_and(|definition| !definition.emitted_by.contains(&check_id))
730 {
731 return Err(
732 CheckEvaluationValidationError::EvaluatedScopeEmitterMismatch { scope_index },
733 );
734 }
735 }
736 for (gap_index, gap) in gaps.iter().enumerate() {
737 if gap.code.is_empty()
738 || BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS
739 .iter()
740 .find(|definition| definition.code == gap.code)
741 .is_some_and(|definition| !definition.emitted_by.contains(&check_id))
742 {
743 return Err(CheckEvaluationValidationError::GapEmitterMismatch { gap_index });
744 }
745 if gap.scope.is_some_and(|scope| {
746 scope.code.as_str().is_empty()
747 || scope
748 .code
749 .builtin_definition()
750 .is_some_and(|definition| !definition.emitted_by.contains(&check_id))
751 }) {
752 return Err(CheckEvaluationValidationError::GapScopeEmitterMismatch { gap_index });
753 }
754 }
755 for (facet_index, scope) in prediction_scopes.iter().enumerate() {
756 if scope.code.as_str().is_empty()
757 || scope
758 .code
759 .builtin_definition()
760 .is_some_and(|definition| !definition.emitted_by.contains(&check_id))
761 {
762 return Err(
763 CheckEvaluationValidationError::PredictionScopeEmitterMismatch { facet_index },
764 );
765 }
766 }
767
768 let inactive = selection == SelectionState::Unselected
769 || configuration == ConfigurationState::Disabled
770 || applicability == Applicability::NotApplicable;
771 if inactive {
772 if !finding_check_ids.is_empty()
773 || !evaluated_scopes.is_empty()
774 || !gaps.is_empty()
775 || has_prediction
776 {
777 return Err(CheckEvaluationValidationError::InvalidOutput(
778 "inactive check must have empty output",
779 ));
780 }
781 return Ok(EvaluationState::NotEvaluated);
782 }
783
784 let missing = !gaps.is_empty() || prediction_has_required_unavailable;
785 let derived = if !missing {
786 EvaluationState::Complete
787 } else if evaluated_scopes.is_empty() {
788 EvaluationState::NotEvaluated
789 } else {
790 EvaluationState::Partial
791 };
792 if derived == EvaluationState::NotEvaluated && !finding_check_ids.is_empty() {
793 return Err(CheckEvaluationValidationError::InvalidOutput(
794 "not-evaluated output cannot carry content findings",
795 ));
796 }
797 Ok(derived)
798}
799
800#[derive(Debug, Clone)]
802pub struct CheckEvaluation {
803 check_id: &'static str,
804 selection: SelectionState,
805 configuration: ConfigurationState,
806 applicability: Applicability,
807 output: CheckOutput,
808}
809
810impl CheckEvaluation {
811 pub fn evaluated(check_id: &'static str, output: CheckOutput) -> Result<Self, EvaluationError> {
820 let gap_refs = output
821 .gaps
822 .iter()
823 .map(|gap| CheckEvaluationGapRef {
824 code: gap.code.as_str(),
825 scope: gap.scope.as_ref(),
826 })
827 .collect::<Vec<_>>();
828 let finding_check_ids = output
829 .findings
830 .iter()
831 .map(|finding| finding.check_id)
832 .collect::<Vec<_>>();
833 let prediction_scopes = output
834 .engine_prediction
835 .as_ref()
836 .map_or_else(Vec::new, EnginePredictionEvidence::scopes);
837 validate_and_derive_check_evaluation(CheckEvaluationValidationInput {
838 check_id,
839 selection: SelectionState::Selected,
840 configuration: ConfigurationState::Enabled,
841 applicability: Applicability::Applicable,
842 finding_check_ids: &finding_check_ids,
843 evaluated_scopes: &output.evaluated_scopes,
844 gaps: &gap_refs,
845 prediction_scopes: &prediction_scopes,
846 has_prediction: output.engine_prediction.is_some(),
847 prediction_has_required_unavailable: output.has_missing_work()
848 && output.gaps.is_empty(),
849 })
850 .map_err(|error| match error {
851 CheckEvaluationValidationError::InvalidCheckId => {
852 EvaluationError::InvalidCheckId(check_id)
853 }
854 CheckEvaluationValidationError::InvalidOutput(reason) => {
855 EvaluationError::InvalidCheckOutput { check_id, reason }
856 }
857 CheckEvaluationValidationError::FindingCheckIdMismatch { finding_index } => {
858 EvaluationError::FindingCheckIdMismatch {
859 check_id,
860 finding_check_id: output.findings[finding_index].check_id,
861 }
862 }
863 CheckEvaluationValidationError::EvaluatedScopeEmitterMismatch { scope_index } => {
864 let code = output.evaluated_scopes[scope_index].code.clone();
865 if code.as_str().is_empty() {
866 EvaluationError::InvalidCheckOutput {
867 check_id,
868 reason: "evaluated scope code cannot be empty",
869 }
870 } else {
871 EvaluationError::BuiltinEvaluationScopeEmitterMismatch { check_id, code }
872 }
873 }
874 CheckEvaluationValidationError::GapScopeEmitterMismatch { gap_index } => {
875 let code = output.gaps[gap_index]
876 .scope
877 .as_ref()
878 .expect("the validator reports only present gap scopes")
879 .code
880 .clone();
881 if code.as_str().is_empty() {
882 EvaluationError::InvalidCheckOutput {
883 check_id,
884 reason: "coverage gap scope code cannot be empty",
885 }
886 } else {
887 EvaluationError::BuiltinEvaluationScopeEmitterMismatch { check_id, code }
888 }
889 }
890 CheckEvaluationValidationError::GapEmitterMismatch { gap_index } => {
891 let code = output.gaps[gap_index].code;
892 if code.as_str().is_empty() {
893 EvaluationError::InvalidCheckOutput {
894 check_id,
895 reason: "coverage gap code cannot be empty",
896 }
897 } else {
898 EvaluationError::BuiltinCoverageGapEmitterMismatch { check_id, code }
899 }
900 }
901 CheckEvaluationValidationError::PredictionScopeEmitterMismatch { facet_index } => {
902 let code = output
903 .engine_prediction
904 .as_ref()
905 .expect("the validator reports only present prediction scopes")
906 .scope(facet_index)
907 .code
908 .clone();
909 if code.as_str().is_empty() {
910 EvaluationError::InvalidCheckOutput {
911 check_id,
912 reason: "prediction facet scope code cannot be empty",
913 }
914 } else {
915 EvaluationError::BuiltinEvaluationScopeEmitterMismatch { check_id, code }
916 }
917 }
918 })?;
919 if let Some(prediction) = &output.engine_prediction {
920 match prediction {
921 EnginePredictionEvidence::V1(prediction) => prediction.validate_for_check(
922 check_id,
923 &output.evaluated_scopes,
924 &output.gaps,
925 &output.findings,
926 )?,
927 EnginePredictionEvidence::V2(prediction) => prediction.validate_for_check(
928 check_id,
929 &output.evaluated_scopes,
930 &output.gaps,
931 &output.findings,
932 )?,
933 EnginePredictionEvidence::V3(prediction) => prediction.validate_for_check(
934 check_id,
935 &output.evaluated_scopes,
936 &output.gaps,
937 &output.findings,
938 )?,
939 EnginePredictionEvidence::V4(prediction) => prediction.validate_for_check(
940 check_id,
941 &output.evaluated_scopes,
942 &output.gaps,
943 &output.findings,
944 )?,
945 EnginePredictionEvidence::V5(prediction) => prediction.validate_for_check(
946 check_id,
947 &output.evaluated_scopes,
948 &output.gaps,
949 &output.findings,
950 )?,
951 EnginePredictionEvidence::V6(prediction) => prediction.validate_for_check(
952 check_id,
953 &output.evaluated_scopes,
954 &output.gaps,
955 &output.findings,
956 )?,
957 }
958 } else if output
959 .findings
960 .iter()
961 .any(|finding| finding.prediction_scope.is_some())
962 {
963 return Err(EvaluationError::InvalidCheckOutput {
964 check_id,
965 reason: "finding has prediction_scope without an engine prediction",
966 });
967 }
968 Ok(Self {
969 check_id,
970 selection: SelectionState::Selected,
971 configuration: ConfigurationState::Enabled,
972 applicability: Applicability::Applicable,
973 output,
974 })
975 }
976
977 pub fn check_id(&self) -> &'static str {
979 self.check_id
980 }
981
982 pub fn selection(&self) -> SelectionState {
984 self.selection
985 }
986
987 pub fn configuration(&self) -> ConfigurationState {
989 self.configuration
990 }
991
992 pub fn applicability(&self) -> Applicability {
994 self.applicability
995 }
996
997 pub fn evaluation(&self) -> EvaluationState {
1000 if self.selection == SelectionState::Unselected
1001 || self.configuration == ConfigurationState::Disabled
1002 || self.applicability == Applicability::NotApplicable
1003 {
1004 EvaluationState::NotEvaluated
1005 } else if !self.output.has_missing_work() {
1006 EvaluationState::Complete
1007 } else if self.output.evaluated_scopes.is_empty() {
1008 EvaluationState::NotEvaluated
1009 } else {
1010 EvaluationState::Partial
1011 }
1012 }
1013
1014 pub fn findings(&self) -> &[Finding] {
1016 self.output.findings()
1017 }
1018
1019 pub fn evaluated_scopes(&self) -> &[EvaluationScope] {
1021 self.output.evaluated_scopes()
1022 }
1023
1024 pub fn gaps(&self) -> &[CoverageGap] {
1026 self.output.gaps()
1027 }
1028
1029 pub const fn engine_prediction(&self) -> Option<&EnginePredictionV1> {
1031 self.output.engine_prediction()
1032 }
1033
1034 pub const fn engine_prediction_v2(&self) -> Option<&EnginePredictionV2> {
1036 self.output.engine_prediction_v2()
1037 }
1038
1039 pub const fn engine_prediction_v3(&self) -> Option<&EnginePredictionV3> {
1041 self.output.engine_prediction_v3()
1042 }
1043
1044 pub const fn engine_prediction_v4(&self) -> Option<&EnginePredictionV4> {
1046 self.output.engine_prediction_v4()
1047 }
1048
1049 pub const fn engine_prediction_v5(&self) -> Option<&EnginePredictionV5> {
1051 self.output.engine_prediction_v5()
1052 }
1053
1054 pub const fn engine_prediction_v6(&self) -> Option<&EnginePredictionV6> {
1056 self.output.engine_prediction_v6()
1057 }
1058
1059 pub fn has_required_prediction_unavailable(&self) -> bool {
1062 self.output.has_required_prediction_unavailable()
1063 }
1064
1065 fn inactive(
1066 check_id: &'static str,
1067 selection: SelectionState,
1068 configuration: ConfigurationState,
1069 applicability: Applicability,
1070 ) -> Self {
1071 debug_assert!(
1072 selection == SelectionState::Unselected
1073 || configuration == ConfigurationState::Disabled
1074 || applicability == Applicability::NotApplicable
1075 );
1076 Self {
1077 check_id,
1078 selection,
1079 configuration,
1080 applicability,
1081 output: CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new()),
1082 }
1083 }
1084
1085 fn override_severity(&mut self, severity: SeveritySetting) {
1086 if let Some(severity) = severity.as_severity() {
1087 for finding in &mut self.output.findings {
1088 finding.severity = severity;
1089 }
1090 }
1091 }
1092}
1093
1094impl Serialize for CheckEvaluation {
1095 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1096 where
1097 S: Serializer,
1098 {
1099 let mut fields = 6;
1100 fields += usize::from(!self.output.evaluated_scopes.is_empty());
1101 fields += usize::from(!self.output.gaps.is_empty());
1102 fields += usize::from(self.output.engine_prediction.is_some());
1103 let mut state = serializer.serialize_struct("CheckEvaluation", fields)?;
1104 state.serialize_field("check_id", &self.check_id)?;
1105 state.serialize_field("selection", &self.selection)?;
1106 state.serialize_field("configuration", &self.configuration)?;
1107 state.serialize_field("applicability", &self.applicability)?;
1108 state.serialize_field("evaluation", &self.evaluation())?;
1109 state.serialize_field("findings", &self.output.findings)?;
1110 if !self.output.evaluated_scopes.is_empty() {
1111 state.serialize_field("evaluated_scopes", &self.output.evaluated_scopes)?;
1112 }
1113 if !self.output.gaps.is_empty() {
1114 state.serialize_field("gaps", &self.output.gaps)?;
1115 }
1116 if let Some(prediction) = &self.output.engine_prediction {
1117 match prediction {
1118 EnginePredictionEvidence::V1(prediction) => {
1119 state.serialize_field("prediction", prediction)?;
1120 }
1121 EnginePredictionEvidence::V2(prediction) => {
1122 state.serialize_field("prediction", prediction)?;
1123 }
1124 EnginePredictionEvidence::V3(prediction) => {
1125 state.serialize_field("prediction", prediction)?;
1126 }
1127 EnginePredictionEvidence::V4(prediction) => {
1128 state.serialize_field("prediction", prediction)?;
1129 }
1130 EnginePredictionEvidence::V5(prediction) => {
1131 state.serialize_field("prediction", prediction)?;
1132 }
1133 EnginePredictionEvidence::V6(prediction) => {
1134 state.serialize_field("prediction", prediction)?;
1135 }
1136 }
1137 }
1138 state.end()
1139 }
1140}
1141
1142#[derive(Debug, Clone, Copy)]
1144pub enum CheckSelection<'a> {
1145 All,
1147 Only(&'a BTreeSet<String>),
1149}
1150
1151impl CheckSelection<'_> {
1152 fn contains(self, id: &str) -> bool {
1153 match self {
1154 Self::All => true,
1155 Self::Only(ids) => ids.contains(id),
1156 }
1157 }
1158}
1159
1160#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1162#[non_exhaustive]
1163pub enum EvaluationError {
1164 #[error("invalid configuration: {0}")]
1166 InvalidConfiguration(#[from] ConfigValidationError),
1167 #[error("invalid engine prediction: {0}")]
1169 InvalidPrediction(#[from] PredictionContractError),
1170 #[error("check id cannot be empty")]
1172 InvalidCheckId(&'static str),
1173 #[error("duplicate check id {0:?}")]
1175 DuplicateCheckId(&'static str),
1176 #[error("unknown selected check id {0:?}")]
1178 UnknownSelection(String),
1179 #[error(
1181 "check {check_id:?} cannot be disabled with severity = \"off\" while selected and applicable"
1182 )]
1183 SeverityOffNotAllowed {
1184 check_id: &'static str,
1186 },
1187 #[error(
1189 "check {check_id:?} emitted {emitted} prediction facets after receiving {allocated} allocated slots"
1190 )]
1191 PredictionAllocationMismatch {
1192 check_id: &'static str,
1194 allocated: usize,
1196 emitted: usize,
1198 },
1199 #[error("check {check_id:?} emitted invalid output: {reason}")]
1201 InvalidCheckOutput {
1202 check_id: &'static str,
1204 reason: &'static str,
1206 },
1207 #[error("check {check_id:?} emitted a finding for {finding_check_id:?}")]
1209 FindingCheckIdMismatch {
1210 check_id: &'static str,
1212 finding_check_id: &'static str,
1214 },
1215 #[error("check {check_id:?} cannot emit built-in evaluation scope {code}")]
1217 BuiltinEvaluationScopeEmitterMismatch {
1218 check_id: &'static str,
1220 code: EvaluationScopeCode,
1222 },
1223 #[error("check {check_id:?} cannot emit built-in coverage gap {code}")]
1225 BuiltinCoverageGapEmitterMismatch {
1226 check_id: &'static str,
1228 code: CoverageGapCode,
1230 },
1231}
1232
1233pub fn lint_requires_failure(
1238 checks: &[CheckEvaluation],
1239 fail_at: crate::finding::Severity,
1240 allowed_content_check_ids: &BTreeSet<String>,
1241) -> bool {
1242 checks.iter().any(|check| {
1243 check.has_required_prediction_unavailable()
1244 || check.findings().iter().any(|finding| {
1245 finding.severity >= fail_at && !allowed_content_check_ids.contains(finding.check_id)
1246 })
1247 })
1248}
1249
1250pub fn evaluate_checks(
1267 ctx: &CheckCtx<'_>,
1268 checks: &[Box<dyn Check + '_>],
1269 selection: CheckSelection<'_>,
1270) -> Result<Vec<CheckEvaluation>, EvaluationError> {
1271 ctx.config.validate()?;
1272
1273 let mut catalog_ids = BTreeSet::new();
1274 for check in checks {
1275 if check.id().is_empty() {
1276 return Err(EvaluationError::InvalidCheckId(check.id()));
1277 }
1278 if !catalog_ids.insert(check.id()) {
1279 return Err(EvaluationError::DuplicateCheckId(check.id()));
1280 }
1281 }
1282 if let CheckSelection::Only(selected) = selection
1283 && let Some(unknown) = selected
1284 .iter()
1285 .find(|id| !catalog_ids.contains(id.as_str()))
1286 {
1287 return Err(EvaluationError::UnknownSelection(unknown.clone()));
1288 }
1289
1290 let mut records = Vec::with_capacity(checks.len());
1291 for check in checks {
1292 let selection_state = if selection.contains(check.id()) {
1293 SelectionState::Selected
1294 } else {
1295 SelectionState::Unselected
1296 };
1297 let setting = ctx.config.check_settings(check.id()).severity;
1298 let configuration = match setting {
1299 Some(SeveritySetting::Off) => ConfigurationState::Disabled,
1300 Some(_) => ConfigurationState::Enabled,
1301 None if check.enabled_by_default() => ConfigurationState::Enabled,
1302 None => ConfigurationState::Disabled,
1303 };
1304 let applicability = check.applicability(ctx);
1305
1306 if selection_state == SelectionState::Selected
1307 && setting == Some(SeveritySetting::Off)
1308 && applicability == Applicability::Applicable
1309 && !check.allows_severity_off()
1310 {
1311 return Err(EvaluationError::SeverityOffNotAllowed {
1312 check_id: check.id(),
1313 });
1314 }
1315
1316 if selection_state == SelectionState::Unselected
1317 || configuration == ConfigurationState::Disabled
1318 || applicability == Applicability::NotApplicable
1319 {
1320 records.push(CheckEvaluation::inactive(
1321 check.id(),
1322 selection_state,
1323 configuration,
1324 applicability,
1325 ));
1326 continue;
1327 }
1328
1329 let mut evaluation = CheckEvaluation::evaluated(check.id(), check.evaluate(ctx))?;
1330 if let Some(setting) = setting {
1331 evaluation.override_severity(setting);
1332 }
1333 records.push(evaluation);
1334 }
1335 Ok(records)
1336}
1337
1338pub fn evaluate_checks_v2(
1342 ctx: &CheckCtx<'_>,
1343 checks: &[Box<dyn Check + '_>],
1344 selection: CheckSelection<'_>,
1345) -> Result<Vec<CheckEvaluation>, EvaluationError> {
1346 ctx.config.validate()?;
1347 let mut ids = BTreeSet::new();
1348 for check in checks {
1349 if check.id().is_empty() {
1350 return Err(EvaluationError::InvalidCheckId(check.id()));
1351 }
1352 if !ids.insert(check.id()) {
1353 return Err(EvaluationError::DuplicateCheckId(check.id()));
1354 }
1355 }
1356 if let CheckSelection::Only(selected) = selection
1357 && let Some(unknown) = selected.iter().find(|id| !ids.contains(id.as_str()))
1358 {
1359 return Err(EvaluationError::UnknownSelection(unknown.clone()));
1360 }
1361 let states = checks
1362 .iter()
1363 .map(|check| {
1364 let selected = selection.contains(check.id());
1365 let setting = ctx.config.check_settings(check.id()).severity;
1366 let configuration = match setting {
1367 Some(SeveritySetting::Off) => ConfigurationState::Disabled,
1368 Some(_) => ConfigurationState::Enabled,
1369 None if check.enabled_by_default() => ConfigurationState::Enabled,
1370 None => ConfigurationState::Disabled,
1371 };
1372 let applicability = check.applicability(ctx);
1373 if selected
1374 && setting == Some(SeveritySetting::Off)
1375 && applicability == Applicability::Applicable
1376 && !check.allows_severity_off()
1377 {
1378 return Err(EvaluationError::SeverityOffNotAllowed {
1379 check_id: check.id(),
1380 });
1381 }
1382 Ok((selected, setting, configuration, applicability))
1383 })
1384 .collect::<Result<Vec<_>, EvaluationError>>()?;
1385 let demands = checks
1386 .iter()
1387 .zip(&states)
1388 .filter(|(_, (selected, _, configuration, applicability))| {
1389 *selected
1390 && *configuration == ConfigurationState::Enabled
1391 && *applicability == Applicability::Applicable
1392 })
1393 .map(|(check, _)| {
1394 crate::PredictionRuleDemandV2::new(check.id(), check.prediction_facet_demand_v2(ctx))
1395 })
1396 .collect::<Result<Vec<_>, _>>()?;
1397 let allocations = crate::allocate_prediction_facets_v2(&demands)?;
1398 let mut records = Vec::with_capacity(checks.len());
1399 for (check, (selected, setting, configuration, applicability)) in checks.iter().zip(states) {
1400 if !selected
1401 || configuration == ConfigurationState::Disabled
1402 || applicability == Applicability::NotApplicable
1403 {
1404 records.push(CheckEvaluation::inactive(
1405 check.id(),
1406 if selected {
1407 SelectionState::Selected
1408 } else {
1409 SelectionState::Unselected
1410 },
1411 configuration,
1412 applicability,
1413 ));
1414 continue;
1415 }
1416 let allocation = allocations
1417 .iter()
1418 .find(|allocation| allocation.rule_id() == check.id())
1419 .copied()
1420 .expect("active check was allocated");
1421 let output = check.evaluate_with_prediction_allocation_v2(ctx, allocation);
1422 let emitted = output
1423 .engine_prediction_v2()
1424 .map_or(0, |prediction| prediction.facets().len())
1425 + output
1426 .engine_prediction_v3()
1427 .map_or(0, |prediction| prediction.facets().len())
1428 + output
1429 .engine_prediction_v4()
1430 .map_or(0, |prediction| prediction.facets().len())
1431 + output
1432 .engine_prediction_v5()
1433 .map_or(0, |prediction| prediction.facets().len())
1434 + output
1435 .engine_prediction_v6()
1436 .map_or(0, |prediction| prediction.facets().len());
1437 if emitted != allocation.emitted_slots() {
1438 return Err(EvaluationError::PredictionAllocationMismatch {
1439 check_id: check.id(),
1440 allocated: allocation.emitted_slots(),
1441 emitted,
1442 });
1443 }
1444 let mut evaluation = CheckEvaluation::evaluated(check.id(), output)?;
1445 if let Some(setting) = setting {
1446 evaluation.override_severity(setting);
1447 }
1448 records.push(evaluation);
1449 }
1450 Ok(records)
1451}
1452
1453#[cfg(test)]
1454mod authority_contract {
1455 use std::cell::RefCell;
1456 use std::collections::BTreeSet;
1457 use std::path::{Path, PathBuf};
1458 use std::rc::Rc;
1459
1460 use super::{
1461 BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS, BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS,
1462 BuiltinEvidenceCode, CheckEvaluation, CheckOutput, CoverageGap, CoverageGapCode,
1463 EXTERNAL_BUILTIN_CHECK_IDS, EvaluationError, EvaluationScope, EvaluationScopeCode,
1464 EvaluationState, lint_requires_failure,
1465 };
1466 use crate::InputIdentity;
1467 use crate::finding::{Finding, Severity};
1468 use crate::prediction::{
1469 EnginePredictionBasisV1, EnginePredictionFacetV1, EnginePredictionFacetV2,
1470 EnginePredictionV1, EnginePredictionV2, PredictionBasisReferenceV1,
1471 PredictionProvenanceIdentityV1, PredictionProvenanceIdentityV2, PredictionScalarV1,
1472 PredictionUnavailableReasonV1, PredictionUnavailableReasonV2,
1473 };
1474 use crate::{
1475 Applicability, Check, CheckCtx, CheckSelection, Config, Document, MetricGrids,
1476 PredictionFacetDemandV2, PredictionRuleAllocationV2, ResolvedRoles, evaluate_checks_v2,
1477 };
1478
1479 struct AllocatedSyntheticCheck {
1480 id: &'static str,
1481 demand: usize,
1482 applicable: bool,
1483 allocations: Rc<RefCell<Vec<(String, usize, bool)>>>,
1484 constructed_candidates: Rc<RefCell<usize>>,
1485 }
1486
1487 struct EmptyAllocatedCheck;
1488
1489 impl Check for EmptyAllocatedCheck {
1490 fn id(&self) -> &'static str {
1491 "test:empty-allocated"
1492 }
1493
1494 fn evaluate(&self, _ctx: &CheckCtx<'_>) -> CheckOutput {
1495 panic!("V2 orchestration must call the allocation-aware hook")
1496 }
1497
1498 fn prediction_facet_demand_v2(&self, _ctx: &CheckCtx<'_>) -> PredictionFacetDemandV2 {
1499 PredictionFacetDemandV2::Exact(1)
1500 }
1501
1502 fn evaluate_with_prediction_allocation_v2(
1503 &self,
1504 _ctx: &CheckCtx<'_>,
1505 _allocation: PredictionRuleAllocationV2<'_>,
1506 ) -> CheckOutput {
1507 CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
1508 }
1509 }
1510
1511 impl Check for AllocatedSyntheticCheck {
1512 fn id(&self) -> &'static str {
1513 self.id
1514 }
1515
1516 fn applicability(&self, _ctx: &CheckCtx<'_>) -> Applicability {
1517 if self.applicable {
1518 Applicability::Applicable
1519 } else {
1520 Applicability::NotApplicable
1521 }
1522 }
1523
1524 fn evaluate(&self, _ctx: &CheckCtx<'_>) -> CheckOutput {
1525 panic!("V2 orchestration must call the allocation-aware hook")
1526 }
1527
1528 fn prediction_facet_demand_v2(&self, _ctx: &CheckCtx<'_>) -> PredictionFacetDemandV2 {
1529 PredictionFacetDemandV2::Exact(self.demand)
1530 }
1531
1532 fn evaluate_with_prediction_allocation_v2(
1533 &self,
1534 _ctx: &CheckCtx<'_>,
1535 allocation: PredictionRuleAllocationV2<'_>,
1536 ) -> CheckOutput {
1537 self.allocations.borrow_mut().push((
1538 self.id.to_owned(),
1539 allocation.candidate_capacity(),
1540 allocation.summary_required(),
1541 ));
1542 *self.constructed_candidates.borrow_mut() += allocation.candidate_capacity();
1545 let basis = || {
1546 EnginePredictionBasisV1::new(vec![
1547 PredictionBasisReferenceV1::profile_fact("animation_addressability").unwrap(),
1548 ])
1549 .unwrap()
1550 };
1551 let mut scopes = Vec::with_capacity(allocation.candidate_capacity());
1552 let mut facets = Vec::with_capacity(allocation.emitted_slots());
1553 for index in 0..allocation.candidate_capacity() {
1554 let scope =
1555 EvaluationScope::new(EvaluationScopeCode::custom("test:allocated-candidate"))
1556 .subject(format!("{}:{index}", self.id));
1557 scopes.push(scope.clone());
1558 facets.push(EnginePredictionFacetV2::available(scope, basis()).unwrap());
1559 }
1560 if allocation.summary_required() {
1561 facets.push(
1562 EnginePredictionFacetV2::required_unavailable(
1563 EvaluationScope::new(EvaluationScopeCode::custom(match self.id {
1564 "test:first" => "test:first:facet-budget",
1565 "test:second" => "test:second:facet-budget",
1566 "test:active" => "test:active:facet-budget",
1567 _ => "test:synthetic:facet-budget",
1568 })),
1569 basis(),
1570 vec![PredictionUnavailableReasonV2::FacetBudgetExceeded],
1571 )
1572 .unwrap(),
1573 );
1574 }
1575 if facets.is_empty() {
1576 return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
1577 }
1578 let identity: PredictionProvenanceIdentityV2 = serde_json::from_value(
1579 serde_json::to_value(InputIdentity::from_bytes(b"synthetic-v2-provenance"))
1580 .unwrap(),
1581 )
1582 .unwrap();
1583 CheckOutput::from_coverage(Vec::new(), scopes, Vec::new())
1584 .with_engine_prediction_v2(EnginePredictionV2::new(identity, facets).unwrap())
1585 }
1586 }
1587
1588 fn assert_reference_table(docs: &str, heading: &str, entries: &[BuiltinEvidenceCode]) {
1589 let section = docs
1590 .split_once(heading)
1591 .unwrap_or_else(|| panic!("missing reference heading {heading:?}"))
1592 .1;
1593 let documented = section
1594 .lines()
1595 .skip_while(|line| line.trim().is_empty())
1596 .take_while(|line| !line.trim().is_empty())
1597 .filter(|line| line.starts_with("| `"))
1598 .collect::<Vec<_>>();
1599 let expected = entries
1600 .iter()
1601 .map(|definition| {
1602 let BuiltinEvidenceCode {
1603 code,
1604 meaning,
1605 emitted_by,
1606 } = definition;
1607 assert!(
1608 !meaning.trim().is_empty() && !meaning.contains(['\r', '\n']),
1609 "{code} must have a one-line meaning"
1610 );
1611 let emitters = emitted_by
1612 .iter()
1613 .map(|check_id| format!("`{check_id}`"))
1614 .collect::<Vec<_>>()
1615 .join(", ");
1616 format!("| `{code}` | {meaning} | {emitters} |")
1617 })
1618 .collect::<Vec<_>>();
1619
1620 assert_eq!(documented.len(), expected.len(), "row count for {heading}");
1621 let documented = documented.into_iter().collect::<BTreeSet<_>>();
1622 assert_eq!(
1623 documented.len(),
1624 expected.len(),
1625 "duplicate rows for {heading}"
1626 );
1627 let expected = expected.iter().map(String::as_str).collect::<BTreeSet<_>>();
1628 assert_eq!(documented, expected, "exact rows for {heading}");
1629 }
1630
1631 #[test]
1632 fn v2_orchestration_reserves_catalog_order_before_constructing_candidates() {
1633 let doc = Document::default();
1634 let grids = MetricGrids::new(&doc);
1635 let roles = ResolvedRoles::default();
1636 let config = Config::default();
1637 let ctx = CheckCtx::new(&grids, &roles, &config);
1638 let allocations = Rc::new(RefCell::new(Vec::new()));
1639 let constructed = Rc::new(RefCell::new(0));
1640 let checks: Vec<Box<dyn Check>> = vec![
1641 Box::new(AllocatedSyntheticCheck {
1642 id: "test:first",
1643 demand: 4_096,
1644 applicable: true,
1645 allocations: allocations.clone(),
1646 constructed_candidates: constructed.clone(),
1647 }),
1648 Box::new(AllocatedSyntheticCheck {
1649 id: "test:second",
1650 demand: 1,
1651 applicable: true,
1652 allocations: allocations.clone(),
1653 constructed_candidates: constructed.clone(),
1654 }),
1655 ];
1656
1657 let first = evaluate_checks_v2(&ctx, &checks, CheckSelection::All).unwrap();
1658 assert_eq!(
1659 allocations.borrow().as_slice(),
1660 [
1661 ("test:first".to_owned(), 4_094, true),
1662 ("test:second".to_owned(), 1, false),
1663 ]
1664 );
1665 assert_eq!(*constructed.borrow(), 4_095);
1668 let first_bytes = serde_json::to_vec(&first).unwrap();
1669
1670 allocations.borrow_mut().clear();
1671 *constructed.borrow_mut() = 0;
1672 let second = evaluate_checks_v2(&ctx, &checks, CheckSelection::All).unwrap();
1673 assert_eq!(serde_json::to_vec(&second).unwrap(), first_bytes);
1674 assert_eq!(*constructed.borrow(), 4_095);
1675 }
1676
1677 #[test]
1678 fn v2_orchestration_does_not_reserve_or_evaluate_inactive_rule_demand() {
1679 let doc = Document::default();
1680 let grids = MetricGrids::new(&doc);
1681 let roles = ResolvedRoles::default();
1682 let config = Config::default();
1683 let ctx = CheckCtx::new(&grids, &roles, &config);
1684 let allocations = Rc::new(RefCell::new(Vec::new()));
1685 let constructed = Rc::new(RefCell::new(0));
1686 let checks: Vec<Box<dyn Check>> = vec![
1687 Box::new(AllocatedSyntheticCheck {
1688 id: "test:active",
1689 demand: 4_096,
1690 applicable: true,
1691 allocations: allocations.clone(),
1692 constructed_candidates: constructed.clone(),
1693 }),
1694 Box::new(AllocatedSyntheticCheck {
1695 id: "test:inactive",
1696 demand: 4_096,
1697 applicable: false,
1698 allocations: allocations.clone(),
1699 constructed_candidates: constructed.clone(),
1700 }),
1701 ];
1702
1703 let records = evaluate_checks_v2(&ctx, &checks, CheckSelection::All).unwrap();
1704 assert_eq!(
1705 allocations.borrow().as_slice(),
1706 [("test:active".to_owned(), 4_096, false)]
1707 );
1708 assert_eq!(*constructed.borrow(), 4_096);
1709 assert_eq!(records[1].applicability(), Applicability::NotApplicable);
1710 }
1711
1712 #[test]
1713 fn v2_orchestration_rejects_output_that_does_not_fill_its_allocation() {
1714 let doc = Document::default();
1715 let grids = MetricGrids::new(&doc);
1716 let roles = ResolvedRoles::default();
1717 let config = Config::default();
1718 let ctx = CheckCtx::new(&grids, &roles, &config);
1719 let checks: Vec<Box<dyn Check>> = vec![Box::new(EmptyAllocatedCheck)];
1720
1721 assert!(matches!(
1722 evaluate_checks_v2(&ctx, &checks, CheckSelection::All),
1723 Err(EvaluationError::PredictionAllocationMismatch {
1724 check_id: "test:empty-allocated",
1725 allocated: 1,
1726 emitted: 0,
1727 })
1728 ));
1729 }
1730
1731 #[test]
1732 fn output_docs_match_registered_builtin_evidence_codes_exactly() {
1733 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
1734 let Some(workspace_root) = source_workspace_root(manifest_dir) else {
1735 return;
1737 };
1738 let docs_path = workspace_root.join("docs/output.md");
1739 let docs = std::fs::read_to_string(&docs_path)
1740 .unwrap_or_else(|error| panic!("cannot read {}: {error}", docs_path.display()));
1741 let crlf = docs.lines().collect::<Vec<_>>().join("\r\n");
1742 for line_endings in [docs.as_str(), crlf.as_str()] {
1743 assert_reference_table(
1744 line_endings,
1745 "Built-in gap codes are:",
1746 BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS,
1747 );
1748 assert_reference_table(
1749 line_endings,
1750 "Built-in completed/gap scope codes are:",
1751 BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS,
1752 );
1753 }
1754 }
1755
1756 #[test]
1757 fn builtin_evidence_authority_has_unique_codes_and_known_emitters() {
1758 let catalog_ids = crate::all_checks()
1759 .into_iter()
1760 .map(|check| check.id())
1761 .chain(EXTERNAL_BUILTIN_CHECK_IDS.iter().copied())
1762 .collect::<BTreeSet<_>>();
1763 let authorities = [
1764 ("coverage-gap", BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS),
1765 (
1766 "evaluation-scope",
1767 BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS,
1768 ),
1769 ];
1770
1771 for (kind, definitions) in authorities {
1772 assert!(
1773 definitions
1774 .iter()
1775 .all(|definition| !definition.code.is_empty()),
1776 "{kind} authority codes must be nonempty"
1777 );
1778 let defined_codes = definitions
1779 .iter()
1780 .map(|definition| definition.code)
1781 .collect::<BTreeSet<_>>();
1782 assert_eq!(
1783 defined_codes.len(),
1784 definitions.len(),
1785 "duplicate {kind} authority code"
1786 );
1787 for definition in definitions {
1788 let emitters = definition
1789 .emitted_by
1790 .iter()
1791 .copied()
1792 .collect::<BTreeSet<_>>();
1793 assert_eq!(
1794 emitters.len(),
1795 definition.emitted_by.len(),
1796 "{} has duplicate emitters",
1797 definition.code
1798 );
1799 for emitter in emitters {
1800 assert!(
1801 catalog_ids.contains(emitter),
1802 "{} declares unknown emitter {emitter:?}",
1803 definition.code
1804 );
1805 }
1806 }
1807 }
1808 }
1809
1810 #[test]
1811 fn every_builtin_code_enforces_its_emitter_matrix() {
1812 let catalog_ids = crate::all_checks()
1813 .into_iter()
1814 .map(|check| check.id())
1815 .chain(EXTERNAL_BUILTIN_CHECK_IDS.iter().copied())
1816 .collect::<Vec<_>>();
1817
1818 for definition in BUILTIN_EVALUATION_SCOPE_CODE_DEFINITIONS {
1819 for &check_id in &catalog_ids {
1820 let code = EvaluationScopeCode::custom(definition.code);
1821 let completed = CheckEvaluation::evaluated(
1822 check_id,
1823 CheckOutput::from_coverage(
1824 Vec::new(),
1825 vec![EvaluationScope::new(code.clone())],
1826 Vec::new(),
1827 ),
1828 );
1829 let gap_scope = CheckEvaluation::evaluated(
1830 check_id,
1831 CheckOutput::from_coverage(
1832 Vec::new(),
1833 Vec::new(),
1834 vec![
1835 CoverageGap::new(CoverageGapCode::custom("test:gap"), "gap")
1836 .scope(EvaluationScope::new(code.clone())),
1837 ],
1838 ),
1839 );
1840 if definition.emitted_by.contains(&check_id) {
1841 assert!(
1842 completed.is_ok(),
1843 "{} must allow {check_id:?}",
1844 definition.code
1845 );
1846 assert!(
1847 gap_scope.is_ok(),
1848 "{} must allow {check_id:?}",
1849 definition.code
1850 );
1851 } else {
1852 let expected =
1853 EvaluationError::BuiltinEvaluationScopeEmitterMismatch { check_id, code };
1854 assert_eq!(completed.unwrap_err(), expected);
1855 assert_eq!(gap_scope.unwrap_err(), expected);
1856 }
1857 }
1858 }
1859
1860 for definition in BUILTIN_COVERAGE_GAP_CODE_DEFINITIONS {
1861 for &check_id in &catalog_ids {
1862 let code = CoverageGapCode::custom(definition.code);
1863 let gap = CheckEvaluation::evaluated(
1864 check_id,
1865 CheckOutput::from_coverage(
1866 Vec::new(),
1867 Vec::new(),
1868 vec![CoverageGap::new(code, "test gap")],
1869 ),
1870 );
1871 if definition.emitted_by.contains(&check_id) {
1872 assert!(gap.is_ok(), "{} must allow {check_id:?}", definition.code);
1873 } else {
1874 assert_eq!(
1875 gap.unwrap_err(),
1876 EvaluationError::BuiltinCoverageGapEmitterMismatch { check_id, code }
1877 );
1878 }
1879 }
1880 }
1881 }
1882
1883 #[test]
1884 fn mixed_prediction_facets_use_the_existing_evaluation_and_exit_lifecycle() {
1885 const CHECK_ID: &str = "test:engine-prediction";
1886 let code = EvaluationScopeCode::custom("test:prediction-work");
1887 let available_scope = EvaluationScope::new(code.clone()).subject("available-clip");
1888 let unavailable_scope = EvaluationScope::new(code).subject("unavailable-clip");
1889 let available_basis = EnginePredictionBasisV1::new(vec![
1890 PredictionBasisReferenceV1::project_field(
1891 "project.mode",
1892 PredictionScalarV1::token("generic").unwrap(),
1893 )
1894 .unwrap(),
1895 ])
1896 .unwrap();
1897 let unavailable_basis = EnginePredictionBasisV1::new(Vec::new()).unwrap();
1898 let prediction = EnginePredictionV1::new(
1899 PredictionProvenanceIdentityV1::from_input_identity(InputIdentity::from_bytes(
1900 b"profile",
1901 )),
1902 vec![
1903 EnginePredictionFacetV1::available(available_scope.clone(), available_basis)
1904 .unwrap(),
1905 EnginePredictionFacetV1::required_unavailable(
1906 unavailable_scope,
1907 unavailable_basis,
1908 vec![PredictionUnavailableReasonV1::MeasurementUnavailable],
1909 )
1910 .unwrap(),
1911 ],
1912 )
1913 .unwrap();
1914 let finding = Finding::new(CHECK_ID, Severity::Note, "available subject finding")
1915 .prediction_scope(available_scope.clone());
1916 let check = CheckEvaluation::evaluated(
1917 CHECK_ID,
1918 CheckOutput::from_coverage(vec![finding], vec![available_scope], Vec::new())
1919 .with_engine_prediction(prediction),
1920 )
1921 .unwrap();
1922
1923 assert_eq!(check.evaluation(), EvaluationState::Partial);
1924 assert!(lint_requires_failure(
1925 &[check],
1926 Severity::Error,
1927 &BTreeSet::from([CHECK_ID.to_owned()]),
1928 ));
1929 }
1930
1931 #[test]
1932 fn available_only_prediction_does_not_fail_without_a_threshold_finding() {
1933 const CHECK_ID: &str = "test:available-prediction";
1934 let scope = EvaluationScope::new(EvaluationScopeCode::custom("test:prediction-work"));
1935 let basis = EnginePredictionBasisV1::new(vec![
1936 PredictionBasisReferenceV1::project_field(
1937 "project.mode",
1938 PredictionScalarV1::token("generic").unwrap(),
1939 )
1940 .unwrap(),
1941 ])
1942 .unwrap();
1943 let prediction = EnginePredictionV1::new(
1944 PredictionProvenanceIdentityV1::from_input_identity(InputIdentity::from_bytes(
1945 b"profile",
1946 )),
1947 vec![EnginePredictionFacetV1::available(scope.clone(), basis).unwrap()],
1948 )
1949 .unwrap();
1950 let finding = Finding::new(CHECK_ID, Severity::Note, "nonblocking available finding")
1951 .prediction_scope(scope.clone());
1952 let check = CheckEvaluation::evaluated(
1953 CHECK_ID,
1954 CheckOutput::from_coverage(vec![finding], vec![scope], Vec::new())
1955 .with_engine_prediction(prediction),
1956 )
1957 .unwrap();
1958
1959 assert_eq!(check.evaluation(), EvaluationState::Complete);
1960 assert!(!lint_requires_failure(
1961 &[check],
1962 Severity::Error,
1963 &BTreeSet::new(),
1964 ));
1965 }
1966
1967 #[test]
1968 fn required_unavailable_prediction_fails_despite_allow_and_severity() {
1969 const CHECK_ID: &str = "test:unavailable-prediction";
1970 let scope = EvaluationScope::new(EvaluationScopeCode::custom("test:prediction-work"));
1971 let prediction = EnginePredictionV1::new(
1972 PredictionProvenanceIdentityV1::from_input_identity(InputIdentity::from_bytes(
1973 b"profile",
1974 )),
1975 vec![
1976 EnginePredictionFacetV1::required_unavailable(
1977 scope,
1978 EnginePredictionBasisV1::new(Vec::new()).unwrap(),
1979 vec![PredictionUnavailableReasonV1::MeasurementUnavailable],
1980 )
1981 .unwrap(),
1982 ],
1983 )
1984 .unwrap();
1985 let check = CheckEvaluation::evaluated(
1986 CHECK_ID,
1987 CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
1988 .with_engine_prediction(prediction),
1989 )
1990 .unwrap();
1991
1992 assert_eq!(check.evaluation(), EvaluationState::NotEvaluated);
1993 assert!(lint_requires_failure(
1994 &[check],
1995 Severity::Error,
1996 &BTreeSet::from([CHECK_ID.to_owned()]),
1997 ));
1998 }
1999
2000 #[test]
2001 fn source_workspace_detection_has_a_positive_checkout_control() {
2002 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
2003 let detected = source_workspace_root(manifest_dir);
2004 if manifest_dir.join(".cargo_vcs_info.json").is_file() {
2005 assert!(detected.is_none(), "published packages must skip repo docs");
2006 return;
2007 }
2008
2009 let expected = manifest_dir.join("../..");
2010 if expected.join("docs/output.md").is_file() {
2011 assert_eq!(
2012 detected.as_deref(),
2013 Some(expected.as_path()),
2014 "the exact source checkout must enforce its output docs"
2015 );
2016 }
2017 }
2018
2019 fn source_workspace_root(manifest_dir: &Path) -> Option<PathBuf> {
2020 if manifest_dir.join(".cargo_vcs_info.json").is_file() {
2021 return None;
2022 }
2023 let workspace_root = manifest_dir.join("../..");
2024 let current_manifest = manifest_dir.join("Cargo.toml").canonicalize().ok()?;
2025 let workspace_manifest = workspace_root
2026 .join("crates/animsmith-core/Cargo.toml")
2027 .canonicalize()
2028 .ok()?;
2029 (current_manifest == workspace_manifest).then_some(workspace_root)
2030 }
2031}