Skip to main content

animsmith_core/
evaluation.rs

1//! Typed check-evaluation records.
2//!
3//! Selection, configuration, applicability, evaluation coverage, content
4//! findings, and coverage gaps are independent dimensions. The types in this
5//! module are the single execution/result boundary for both CLI and embedded
6//! consumers.
7
8use 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/// One authoritative built-in evidence-code definition.
24///
25/// The `builtin_codes!` rows below own each built-in code's serialized
26/// identity, meaning, and allowed emitters. Both runtime validation and the
27/// output-documentation contract consume these definitions.
28#[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/// Whether a check was selected for this invocation.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum SelectionState {
87    /// Selected explicitly or through the default full catalog.
88    Selected,
89    /// Omitted by an explicit selection.
90    Unselected,
91}
92
93/// Whether configuration enabled the check.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum ConfigurationState {
97    /// The check is enabled.
98    Enabled,
99    /// The check is opt-in without an enabling severity, or
100    /// `severity = "off"` disabled it.
101    Disabled,
102}
103
104/// Whether a check applies to the supplied document and declarations.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum Applicability {
108    /// The check has work for this document/configuration.
109    Applicable,
110    /// The check has no work for this document/configuration.
111    NotApplicable,
112}
113
114/// How much applicable work was evaluated.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum EvaluationState {
118    /// All modelled work completed.
119    Complete,
120    /// Some modelled work completed and some has a typed coverage gap.
121    Partial,
122    /// No applicable work was evaluated.
123    NotEvaluated,
124}
125
126/// Stable machine code for a unit of evaluated or missing work.
127///
128/// Built-in codes are constants. Custom checks may construct namespaced codes
129/// without forcing animsmith's built-in registry to become a closed enum.
130#[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// Private evidence-vocabulary authority for built-in emitters implemented
233// outside core. Owning crates publish their runnable check catalogs.
234#[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    /// Construct a stable custom scope code.
247    ///
248    /// Custom checks should use a namespaced value such as `acme:reference`.
249    pub const fn custom(code: &'static str) -> Self {
250        Self(Cow::Borrowed(code))
251    }
252
253    /// Return the serialized snake-case or namespaced code.
254    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/// A stable identifier for work that completed or could not be evaluated.
266#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
267#[serde(deny_unknown_fields)]
268pub struct EvaluationScope {
269    /// Consumer-neutral work-unit code such as `member_existence`.
270    pub code: EvaluationScopeCode,
271    /// Optional subject within the check, such as a group or clip name.
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub subject: Option<String>,
274}
275
276impl EvaluationScope {
277    /// Construct a whole-check scope.
278    pub fn new(code: EvaluationScopeCode) -> Self {
279        Self {
280            code,
281            subject: None,
282        }
283    }
284
285    /// Attach a subject identifier.
286    pub fn subject(mut self, subject: impl Into<String>) -> Self {
287        self.subject = Some(subject.into());
288        self
289    }
290}
291
292/// Stable machine code for an evaluation-coverage gap.
293///
294/// Built-in codes are constants. Custom checks may construct their own code;
295/// embedders should namespace custom values so they cannot collide with future
296/// built-ins.
297#[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    /// Construct a stable custom code.
345    ///
346    /// Custom checks should use a namespaced value such as
347    /// `acme:reference_unavailable`.
348    pub const fn custom(code: &'static str) -> Self {
349        Self(code)
350    }
351
352    /// Return the serialized snake-case or namespaced code.
353    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/// A typed reason applicable work could not be evaluated.
365#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
366pub struct CoverageGap {
367    /// Stable machine code. Automation must not parse [`CoverageGap::message`].
368    pub code: CoverageGapCode,
369    /// Human-readable display text.
370    pub message: String,
371    /// Optional work scope affected by the gap.
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub scope: Option<EvaluationScope>,
374}
375
376impl CoverageGap {
377    /// Construct a whole-check coverage gap.
378    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    /// Attach the affected work scope.
387    pub fn scope(mut self, scope: EvaluationScope) -> Self {
388        self.scope = Some(scope);
389        self
390    }
391}
392
393/// Output evidence from one selected, enabled, applicable check.
394///
395/// The shared evaluation boundary derives coverage from completed scopes and
396/// gaps and reports malformed evidence through [`EvaluationError`].
397#[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/// Versioned prediction attachment kept private so standalone V1 contracts
406/// retain their existing public API while current lint can carry V2 evidence.
407#[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    /// Collect findings, completed scopes, and coverage gaps through the one
467    /// check-output construction path.
468    ///
469    /// Classification and validation happen when [`evaluate_checks`] projects
470    /// this evidence into a [`CheckEvaluation`].
471    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    /// Attach one engine-prediction record to this check output.
485    ///
486    /// The shared evaluation boundary validates facet/scope/finding
487    /// relationships after it knows the authoritative parent check id.
488    pub fn with_engine_prediction(mut self, prediction: EnginePredictionV1) -> Self {
489        self.engine_prediction = Some(EnginePredictionEvidence::V1(prediction));
490        self
491    }
492
493    /// Attach a bounded-overflow V2 engine-prediction record.
494    pub fn with_engine_prediction_v2(mut self, prediction: EnginePredictionV2) -> Self {
495        self.engine_prediction = Some(EnginePredictionEvidence::V2(prediction));
496        self
497    }
498
499    /// Attach an exact-source V3 engine-prediction record.
500    pub fn with_engine_prediction_v3(mut self, prediction: EnginePredictionV3) -> Self {
501        self.engine_prediction = Some(EnginePredictionEvidence::V3(prediction));
502        self
503    }
504
505    /// Attach a result-bearing V4 engine-prediction record.
506    pub fn with_engine_prediction_v4(mut self, prediction: EnginePredictionV4) -> Self {
507        self.engine_prediction = Some(EnginePredictionEvidence::V4(prediction));
508        self
509    }
510
511    /// Attach a raw-animation-inventory V5 engine-prediction record.
512    pub fn with_engine_prediction_v5(mut self, prediction: EnginePredictionV5) -> Self {
513        self.engine_prediction = Some(EnginePredictionEvidence::V5(prediction));
514        self
515    }
516
517    /// Attach a transform-path-and-intent-bound V6 engine-prediction record.
518    pub fn with_engine_prediction_v6(mut self, prediction: EnginePredictionV6) -> Self {
519        self.engine_prediction = Some(EnginePredictionEvidence::V6(prediction));
520        self
521    }
522
523    /// Content findings emitted by evaluated work.
524    pub fn findings(&self) -> &[Finding] {
525        &self.findings
526    }
527
528    /// Stable identifiers for work that completed.
529    pub fn evaluated_scopes(&self) -> &[EvaluationScope] {
530        &self.evaluated_scopes
531    }
532
533    /// Typed reasons work did not complete.
534    pub fn gaps(&self) -> &[CoverageGap] {
535        &self.gaps
536    }
537
538    /// Engine-prediction facets emitted by this check, when any.
539    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    /// V2 prediction attachment used by the current output-v13 lint path.
554    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    /// V3 prediction attachment used by current exact-source lint output.
569    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    /// V4 result-bearing prediction attachment.
584    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    /// V5 raw-animation-inventory prediction attachment.
599    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    /// V6 transform-path-and-intent-bound prediction attachment.
614    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
646/// Borrowed coverage-gap fields used by the shared producer/readback
647/// evaluation validator.
648pub(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
697/// Validate the sole check-evaluation lifecycle and derive its serialized
698/// coverage state. Both producer construction and output-v11 readback use this
699/// authority.
700pub(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/// Final output-v17 record for one catalog check.
801#[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    /// Construct a selected, enabled, applicable evaluation from validated
812    /// check output.
813    ///
814    /// # Errors
815    ///
816    /// Returns an error for an empty check id, malformed coverage codes,
817    /// built-in evidence emitted by an undeclared check, or when a nested
818    /// finding names a different check.
819    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    /// Stable check id.
978    pub fn check_id(&self) -> &'static str {
979        self.check_id
980    }
981
982    /// Invocation selection state.
983    pub fn selection(&self) -> SelectionState {
984        self.selection
985    }
986
987    /// Configuration activation state.
988    pub fn configuration(&self) -> ConfigurationState {
989        self.configuration
990    }
991
992    /// Applicability to this document/configuration.
993    pub fn applicability(&self) -> Applicability {
994        self.applicability
995    }
996
997    /// Derive evaluation coverage from activation, completed scopes, ordinary
998    /// gaps, and required-unavailable prediction facets.
999    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    /// Content findings emitted by evaluated work.
1015    pub fn findings(&self) -> &[Finding] {
1016        self.output.findings()
1017    }
1018
1019    /// Stable identifiers for work that completed.
1020    pub fn evaluated_scopes(&self) -> &[EvaluationScope] {
1021        self.output.evaluated_scopes()
1022    }
1023
1024    /// Typed reasons work was not evaluated.
1025    pub fn gaps(&self) -> &[CoverageGap] {
1026        self.output.gaps()
1027    }
1028
1029    /// Engine-prediction facets attached to this check, when any.
1030    pub const fn engine_prediction(&self) -> Option<&EnginePredictionV1> {
1031        self.output.engine_prediction()
1032    }
1033
1034    /// V2 engine-prediction attachment used by output-v13 lint reports.
1035    pub const fn engine_prediction_v2(&self) -> Option<&EnginePredictionV2> {
1036        self.output.engine_prediction_v2()
1037    }
1038
1039    /// V3 engine-prediction attachment used by current exact-source lint reports.
1040    pub const fn engine_prediction_v3(&self) -> Option<&EnginePredictionV3> {
1041        self.output.engine_prediction_v3()
1042    }
1043
1044    /// V4 result-bearing engine-prediction attachment.
1045    pub const fn engine_prediction_v4(&self) -> Option<&EnginePredictionV4> {
1046        self.output.engine_prediction_v4()
1047    }
1048
1049    /// V5 raw-animation-inventory prediction attachment.
1050    pub const fn engine_prediction_v5(&self) -> Option<&EnginePredictionV5> {
1051        self.output.engine_prediction_v5()
1052    }
1053
1054    /// V6 transform-path-and-intent-bound engine-prediction attachment.
1055    pub const fn engine_prediction_v6(&self) -> Option<&EnginePredictionV6> {
1056        self.output.engine_prediction_v6()
1057    }
1058
1059    /// Whether this check has unavailable prediction work under either
1060    /// versioned prediction contract.
1061    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/// Catalog-selection policy for [`evaluate_checks`].
1143#[derive(Debug, Clone, Copy)]
1144pub enum CheckSelection<'a> {
1145    /// Select the whole supplied catalog.
1146    All,
1147    /// Select only the named ids.
1148    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/// Invalid catalog or check output supplied to [`evaluate_checks`].
1161#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1162#[non_exhaustive]
1163pub enum EvaluationError {
1164    /// The supplied programmatic configuration contains an invalid value.
1165    #[error("invalid configuration: {0}")]
1166    InvalidConfiguration(#[from] ConfigValidationError),
1167    /// Engine-prediction evidence violates its wire or lifecycle contract.
1168    #[error("invalid engine prediction: {0}")]
1169    InvalidPrediction(#[from] PredictionContractError),
1170    /// A catalog or directly constructed check record used an empty id.
1171    #[error("check id cannot be empty")]
1172    InvalidCheckId(&'static str),
1173    /// Two catalog entries used the same stable check id.
1174    #[error("duplicate check id {0:?}")]
1175    DuplicateCheckId(&'static str),
1176    /// Explicit selection named an id absent from the supplied catalog.
1177    #[error("unknown selected check id {0:?}")]
1178    UnknownSelection(String),
1179    /// A selected, applicable check opted out of `severity = "off"`.
1180    #[error(
1181        "check {check_id:?} cannot be disabled with severity = \"off\" while selected and applicable"
1182    )]
1183    SeverityOffNotAllowed {
1184        /// Stable id of the protected check.
1185        check_id: &'static str,
1186    },
1187    /// A current production rule did not emit exactly its preallocated slots.
1188    #[error(
1189        "check {check_id:?} emitted {emitted} prediction facets after receiving {allocated} allocated slots"
1190    )]
1191    PredictionAllocationMismatch {
1192        /// Stable id of the production rule.
1193        check_id: &'static str,
1194        /// Candidate plus summary slots assigned before evaluation.
1195        allocated: usize,
1196        /// Facets actually returned by the rule.
1197        emitted: usize,
1198    },
1199    /// Check evidence violates the derived coverage-state invariants.
1200    #[error("check {check_id:?} emitted invalid output: {reason}")]
1201    InvalidCheckOutput {
1202        /// Stable id of the check that emitted malformed evidence.
1203        check_id: &'static str,
1204        /// Contract rule violated by the output.
1205        reason: &'static str,
1206    },
1207    /// A nested finding claimed a different check id than its parent record.
1208    #[error("check {check_id:?} emitted a finding for {finding_check_id:?}")]
1209    FindingCheckIdMismatch {
1210        /// Parent check id.
1211        check_id: &'static str,
1212        /// Mismatched nested finding id.
1213        finding_check_id: &'static str,
1214    },
1215    /// A built-in completed or missing-work scope came from an undeclared check.
1216    #[error("check {check_id:?} cannot emit built-in evaluation scope {code}")]
1217    BuiltinEvaluationScopeEmitterMismatch {
1218        /// Stable id of the check that emitted the scope.
1219        check_id: &'static str,
1220        /// Built-in scope code that does not declare this emitter.
1221        code: EvaluationScopeCode,
1222    },
1223    /// A built-in coverage-gap code came from an undeclared check.
1224    #[error("check {check_id:?} cannot emit built-in coverage gap {code}")]
1225    BuiltinCoverageGapEmitterMismatch {
1226        /// Stable id of the check that emitted the gap.
1227        check_id: &'static str,
1228        /// Built-in gap code that does not declare this emitter.
1229        code: CoverageGapCode,
1230    },
1231}
1232
1233/// Apply the shared lint gate policy to validated check records.
1234///
1235/// Allowed check ids suppress content findings only. Required-unavailable
1236/// prediction facets always block, independent of severity or allow policy.
1237pub 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
1250/// Evaluate a full catalog into one record per check.
1251///
1252/// Selection and configuration are recorded independently. A check can be
1253/// disabled by `severity = "off"` or by its opt-in default; an explicit
1254/// `note`, `warn`, or `error` severity enables an opt-in check. Inactive checks
1255/// never evaluate, but their cheap applicability predicate still establishes
1256/// whether declared work exists. Coverage gaps are nonblocking evidence;
1257/// callers own any stricter policy.
1258///
1259/// # Errors
1260///
1261/// Returns an error for invalid directly constructed configuration, empty or
1262/// duplicate catalog ids, unknown explicitly selected ids, malformed coverage
1263/// evidence, built-in evidence emitted by an undeclared check, or a nested
1264/// finding whose id disagrees with its parent check. Configuration validation
1265/// runs before catalog inspection or check evaluation.
1266pub 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
1338/// Current-output evaluation: reserve every V2/V3 prediction facet in catalog
1339/// order before any selected check evaluates. Historical V1 callers retain
1340/// [`evaluate_checks`].
1341pub 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            // This represents actual candidate construction in a production
1543            // rule: omitted capacity must not be evaluated post hoc.
1544            *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        // The first rule demanded 4,096, but its two omitted candidates were
1666        // never constructed; its reserved summary is constructed separately.
1667        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            // Published crates intentionally exclude repository-level docs.
1736            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}