Skip to main content

omena_cascade/
frame_footprint.rs

1//! Frame-aware diagnostic recheck contracts for incremental cascade consumers.
2//!
3//! This module exposes the conservative V0 footprint records used to decide
4//! which diagnostics must be rechecked after a bounded module edit.
5
6use std::collections::BTreeSet;
7
8use omena_syntax::ident::AuthoredPropertyTextV0;
9use serde::Serialize;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
12#[serde(rename_all = "camelCase")]
13pub struct DiagnosticFrameFootprintV0 {
14    pub schema_version: &'static str,
15    pub product: &'static str,
16    pub feature_gate: &'static str,
17    pub diagnostic_code: String,
18    pub diagnostic_instance_id: String,
19    pub evidence_module_ids: Vec<String>,
20    pub resolver_evidence: Vec<ResolverEvidenceV0>,
21    pub cascade_evidence: Vec<CascadeEvidenceV0>,
22    pub custom_property_evidence: Vec<CustomPropertyEvidenceV0>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub outcome_conjunction_witness: Option<OutcomeConjunctionWitnessV0>,
25    pub conservative: bool,
26    pub layer_marker: &'static str,
27}
28
29#[derive(Debug, Clone, Serialize)]
30#[serde(rename_all = "camelCase")]
31pub struct CascadeEvidenceV0 {
32    pub selector: String,
33    pub property: AuthoredPropertyTextV0,
34    pub declaration_ids: Vec<String>,
35}
36
37impl PartialEq for CascadeEvidenceV0 {
38    fn eq(&self, other: &Self) -> bool {
39        self.selector == other.selector
40            && self.property.to_property_name().canonical_key()
41                == other.property.to_property_name().canonical_key()
42            && self.declaration_ids == other.declaration_ids
43    }
44}
45
46impl Eq for CascadeEvidenceV0 {}
47
48#[derive(Debug, Clone, Serialize)]
49#[serde(rename_all = "camelCase")]
50pub struct CustomPropertyEvidenceV0 {
51    pub custom_property_name: AuthoredPropertyTextV0,
52    pub dependency_names: Vec<AuthoredPropertyTextV0>,
53}
54
55impl PartialEq for CustomPropertyEvidenceV0 {
56    fn eq(&self, other: &Self) -> bool {
57        self.custom_property_name.to_custom_key() == other.custom_property_name.to_custom_key()
58            && self.dependency_names.len() == other.dependency_names.len()
59            && self
60                .dependency_names
61                .iter()
62                .zip(&other.dependency_names)
63                .all(|(left, right)| left.to_custom_key() == right.to_custom_key())
64    }
65}
66
67impl Eq for CustomPropertyEvidenceV0 {}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
70#[serde(rename_all = "camelCase")]
71pub struct ResolverEvidenceV0 {
72    pub specifier: String,
73    pub resolved_module_id: String,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
77#[serde(rename_all = "camelCase")]
78pub struct OutcomeConjunctionWitnessV0 {
79    pub schema_version: &'static str,
80    pub product: &'static str,
81    pub layer_marker: &'static str,
82    pub feature_gate: &'static str,
83    pub partition_id: String,
84    pub outcome_key_count: usize,
85    pub conservative: bool,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
89#[serde(rename_all = "camelCase")]
90pub struct ModuleFootprintV0 {
91    pub schema_version: &'static str,
92    pub product: &'static str,
93    pub layer_marker: &'static str,
94    pub feature_gate: &'static str,
95    pub module_ids: Vec<String>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
99#[serde(rename_all = "camelCase")]
100pub struct RecheckSelectionV0 {
101    pub schema_version: &'static str,
102    pub product: &'static str,
103    pub layer_marker: &'static str,
104    pub feature_gate: &'static str,
105    pub selected_diagnostic_instance_ids: Vec<String>,
106    pub skipped_diagnostic_instance_ids: Vec<String>,
107    pub conservative: bool,
108}
109
110pub fn derive_frame_for_diagnostic(
111    diagnostic_code: impl Into<String>,
112    diagnostic_instance_id: impl Into<String>,
113    evidence_module_ids: Vec<String>,
114) -> DiagnosticFrameFootprintV0 {
115    let evidence_module_ids = canonicalize_module_ids(evidence_module_ids);
116    DiagnosticFrameFootprintV0 {
117        schema_version: "0",
118        product: "omena-cascade.diagnostic-frame-footprint",
119        feature_gate: "frame-rule",
120        diagnostic_code: diagnostic_code.into(),
121        diagnostic_instance_id: diagnostic_instance_id.into(),
122        resolver_evidence: evidence_module_ids
123            .iter()
124            .map(|module_id| ResolverEvidenceV0 {
125                specifier: module_id.clone(),
126                resolved_module_id: module_id.clone(),
127            })
128            .collect(),
129        cascade_evidence: Vec::new(),
130        custom_property_evidence: Vec::new(),
131        outcome_conjunction_witness: Some(outcome_conjunction_witness(&evidence_module_ids)),
132        evidence_module_ids,
133        conservative: true,
134        layer_marker: "frame-rule",
135    }
136}
137
138pub fn derive_frames_for_diagnostic_set(
139    diagnostics: Vec<(String, String, Vec<String>)>,
140) -> Vec<DiagnosticFrameFootprintV0> {
141    diagnostics
142        .into_iter()
143        .map(|(code, instance_id, module_ids)| {
144            derive_frame_for_diagnostic(code, instance_id, module_ids)
145        })
146        .collect()
147}
148
149pub fn compute_edit_footprint(module_ids: Vec<String>) -> ModuleFootprintV0 {
150    ModuleFootprintV0 {
151        schema_version: "0",
152        product: "omena-cascade.module-footprint",
153        layer_marker: "frame-rule",
154        feature_gate: "frame-rule",
155        module_ids: canonicalize_module_ids(module_ids),
156    }
157}
158
159pub fn select_recheck_set(
160    frames: &[DiagnosticFrameFootprintV0],
161    edit_footprint: &ModuleFootprintV0,
162) -> RecheckSelectionV0 {
163    let edit_modules = edit_footprint
164        .module_ids
165        .iter()
166        .collect::<BTreeSet<&String>>();
167    let mut selected = Vec::new();
168    let mut skipped = Vec::new();
169
170    for frame in frames {
171        if frame
172            .evidence_module_ids
173            .iter()
174            .any(|module_id| edit_modules.contains(module_id))
175        {
176            selected.push(frame.diagnostic_instance_id.clone());
177        } else {
178            skipped.push(frame.diagnostic_instance_id.clone());
179        }
180    }
181
182    RecheckSelectionV0 {
183        schema_version: "0",
184        product: "omena-cascade.recheck-selection",
185        layer_marker: "frame-rule",
186        feature_gate: "frame-rule",
187        selected_diagnostic_instance_ids: selected,
188        skipped_diagnostic_instance_ids: skipped,
189        conservative: true,
190    }
191}
192
193pub fn intersect_frame_with_footprint(
194    frame: &DiagnosticFrameFootprintV0,
195    footprint: &ModuleFootprintV0,
196) -> bool {
197    let module_ids = footprint.module_ids.iter().collect::<BTreeSet<&String>>();
198    frame
199        .evidence_module_ids
200        .iter()
201        .any(|module_id| module_ids.contains(module_id))
202}
203
204pub fn outcome_conjunction_witness(module_ids: &[String]) -> OutcomeConjunctionWitnessV0 {
205    OutcomeConjunctionWitnessV0 {
206        schema_version: "0",
207        product: "omena-cascade.outcome-conjunction-witness",
208        layer_marker: "frame-rule",
209        feature_gate: "frame-rule",
210        partition_id: module_ids.join("+"),
211        outcome_key_count: module_ids.len(),
212        conservative: true,
213    }
214}
215
216pub fn partition_into_outcome_conjunction_classes(
217    frames: &[DiagnosticFrameFootprintV0],
218) -> Vec<OutcomeConjunctionWitnessV0> {
219    frames
220        .iter()
221        .filter_map(|frame| frame.outcome_conjunction_witness.clone())
222        .collect()
223}
224
225fn canonicalize_module_ids(module_ids: Vec<String>) -> Vec<String> {
226    module_ids
227        .into_iter()
228        .collect::<BTreeSet<_>>()
229        .into_iter()
230        .collect()
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn frame_selection_is_sorted_deduped_and_conservative() {
239        let frame = derive_frame_for_diagnostic(
240            "missing-static-class",
241            "d1",
242            vec!["b".into(), "a".into(), "a".into()],
243        );
244        let footprint = compute_edit_footprint(vec!["a".into()]);
245        let selection = select_recheck_set(std::slice::from_ref(&frame), &footprint);
246
247        assert_eq!(frame.evidence_module_ids, vec!["a", "b"]);
248        assert_eq!(frame.feature_gate, "frame-rule");
249        assert_eq!(footprint.feature_gate, "frame-rule");
250        assert_eq!(selection.layer_marker, "frame-rule");
251        assert_eq!(selection.feature_gate, "frame-rule");
252        assert!(
253            frame
254                .outcome_conjunction_witness
255                .as_ref()
256                .is_some_and(|witness| witness.feature_gate == "frame-rule")
257        );
258        assert!(frame.conservative);
259        assert!(intersect_frame_with_footprint(&frame, &footprint));
260        assert_eq!(selection.selected_diagnostic_instance_ids, vec!["d1"]);
261    }
262}