Skip to main content

trellis_testing/
audit.rs

1use trellis_core::{
2    Graph, NodeId, OutputFrameKind, OutputFrameKindTrace, OutputKey, ResourceCommandKind,
3    ResourceKey, Revision, ScopeId, TransactionId, TransactionResult,
4};
5
6/// Structural context for an audited resource command assertion.
7#[derive(Clone, Debug, Eq, PartialEq)]
8pub struct ResourceAuditContext {
9    /// Resource key for the audited command.
10    pub key: ResourceKey,
11    /// Scope associated with the command.
12    pub scope: ScopeId,
13    /// Transaction that emitted the command.
14    pub transaction_id: TransactionId,
15    /// Revision that emitted the command.
16    pub revision: Revision,
17    /// Command operation without application payload.
18    pub kind: ResourceCommandKind,
19}
20
21/// Structural context for an audited output frame assertion.
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct OutputAuditContext {
24    /// Output key for the audited frame.
25    pub key: OutputKey,
26    /// Scope associated with the frame.
27    pub scope: ScopeId,
28    /// Transaction that emitted the frame.
29    pub transaction_id: TransactionId,
30    /// Revision carried by the frame.
31    pub revision: Revision,
32    /// Frame kind without materialized payload.
33    pub kind: OutputFrameKindTrace,
34}
35
36/// Failure from an explainability assertion over transaction audit data.
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub enum AuditAssertionError {
39    /// A resource command had no graph-visible explanation.
40    MissingResourceCommand {
41        /// Command context.
42        context: ResourceAuditContext,
43    },
44    /// A resource command explanation did not match the emitted command.
45    ResourceCommandMismatch {
46        /// Command context.
47        context: ResourceAuditContext,
48        /// Missing or mismatched field name.
49        field: &'static str,
50    },
51    /// An output frame had no graph-visible explanation.
52    MissingOutputFrame {
53        /// Frame context.
54        context: OutputAuditContext,
55    },
56    /// An output frame explanation did not match the emitted frame.
57    OutputFrameMismatch {
58        /// Frame context.
59        context: OutputAuditContext,
60        /// Missing or mismatched field name.
61        field: &'static str,
62    },
63    /// A requested dependency path was missing.
64    MissingDependencyPath {
65        /// Upstream node.
66        from: NodeId,
67        /// Downstream node.
68        to: NodeId,
69    },
70}
71
72/// Asserts every resource command in a result has matching audit explanation.
73pub fn assert_no_unexplained_plan<C>(
74    graph: &Graph<C>,
75    result: &TransactionResult<C>,
76) -> Result<(), AuditAssertionError> {
77    for command in result.resource_plan.commands() {
78        let context = resource_audit_context(command, result);
79        let key = command.key();
80        let explanation = graph.why_resource_command(key).ok_or_else(|| {
81            AuditAssertionError::MissingResourceCommand {
82                context: context.clone(),
83            }
84        })?;
85        if explanation.scope != command.scope() {
86            return Err(AuditAssertionError::ResourceCommandMismatch {
87                context,
88                field: "scope",
89            });
90        }
91        if explanation.transaction_id != result.transaction_id {
92            return Err(AuditAssertionError::ResourceCommandMismatch {
93                context,
94                field: "transaction_id",
95            });
96        }
97        if explanation.revision != result.revision {
98            return Err(AuditAssertionError::ResourceCommandMismatch {
99                context,
100                field: "revision",
101            });
102        }
103        if explanation.kind != resource_command_kind(command) {
104            return Err(AuditAssertionError::ResourceCommandMismatch {
105                context,
106                field: "kind",
107            });
108        }
109        if !resource_cause_is_explainable(explanation, command, result) {
110            return Err(AuditAssertionError::ResourceCommandMismatch {
111                context,
112                field: "cause",
113            });
114        }
115    }
116    Ok(())
117}
118
119/// Asserts every output frame in a result has matching audit explanation.
120pub fn assert_no_unexplained_output_frame<C>(
121    graph: &Graph<C>,
122    result: &TransactionResult<C>,
123) -> Result<(), AuditAssertionError> {
124    for frame in &result.output_frames {
125        let context = output_audit_context(frame);
126        let explanation = graph.why_output_frame(frame.output_key).ok_or(
127            AuditAssertionError::MissingOutputFrame {
128                context: context.clone(),
129            },
130        )?;
131        if explanation.scope != frame.scope {
132            return Err(AuditAssertionError::OutputFrameMismatch {
133                context,
134                field: "scope",
135            });
136        }
137        if explanation.transaction_id != frame.transaction_id {
138            return Err(AuditAssertionError::OutputFrameMismatch {
139                context,
140                field: "transaction_id",
141            });
142        }
143        if explanation.revision != frame.revision {
144            return Err(AuditAssertionError::OutputFrameMismatch {
145                context,
146                field: "revision",
147            });
148        }
149        if explanation.kind != output_frame_kind(&frame.kind) {
150            return Err(AuditAssertionError::OutputFrameMismatch {
151                context,
152                field: "kind",
153            });
154        }
155        if !output_frame_is_explainable(explanation, result) {
156            return Err(AuditAssertionError::OutputFrameMismatch {
157                context,
158                field: "input_causes",
159            });
160        }
161    }
162    Ok(())
163}
164
165/// Asserts that a deterministic dependency path exists in the graph.
166pub fn assert_dependency_path_exists<C>(
167    graph: &Graph<C>,
168    from: NodeId,
169    to: NodeId,
170) -> Result<(), AuditAssertionError> {
171    graph
172        .dependency_path(from, to)
173        .map(|_| ())
174        .ok_or(AuditAssertionError::MissingDependencyPath { from, to })
175}
176
177fn resource_command_kind<C>(command: &trellis_core::ResourceCommand<C>) -> ResourceCommandKind {
178    match command {
179        trellis_core::ResourceCommand::Open { .. } => ResourceCommandKind::Open,
180        trellis_core::ResourceCommand::Close { .. } => ResourceCommandKind::Close,
181        trellis_core::ResourceCommand::Replace { .. } => ResourceCommandKind::Replace,
182        trellis_core::ResourceCommand::Refresh { .. } => ResourceCommandKind::Refresh,
183    }
184}
185
186fn output_frame_kind(kind: &OutputFrameKind) -> OutputFrameKindTrace {
187    match kind {
188        OutputFrameKind::Baseline(_) => OutputFrameKindTrace::Baseline,
189        OutputFrameKind::Delta(_) => OutputFrameKindTrace::Delta,
190        OutputFrameKind::Clear(reason) => OutputFrameKindTrace::Clear(*reason),
191        OutputFrameKind::Rebaseline(_, reason) => OutputFrameKindTrace::Rebaseline(*reason),
192    }
193}
194
195fn resource_audit_context<C>(
196    command: &trellis_core::ResourceCommand<C>,
197    result: &TransactionResult<C>,
198) -> ResourceAuditContext {
199    ResourceAuditContext {
200        key: command.key().clone(),
201        scope: command.scope(),
202        transaction_id: result.transaction_id,
203        revision: result.revision,
204        kind: resource_command_kind(command),
205    }
206}
207
208fn output_audit_context(frame: &trellis_core::OutputFrame) -> OutputAuditContext {
209    OutputAuditContext {
210        key: frame.output_key,
211        scope: frame.scope,
212        transaction_id: frame.transaction_id,
213        revision: frame.revision,
214        kind: output_frame_kind(&frame.kind),
215    }
216}
217
218fn resource_cause_is_explainable<C>(
219    explanation: &trellis_core::ResourceCommandExplanation,
220    command: &trellis_core::ResourceCommand<C>,
221    result: &TransactionResult<C>,
222) -> bool {
223    match explanation.cause {
224        trellis_core::ResourceCommandCause::Planner { collection } => {
225            explanation.collection_diffs.contains(&collection)
226                && (result.changed_inputs.is_empty()
227                    || (!explanation.input_causes.is_empty()
228                        && !explanation.dependency_paths.is_empty()))
229        }
230        trellis_core::ResourceCommandCause::ScopeClosed { scope } => scope == command.scope(),
231    }
232}
233
234fn output_frame_is_explainable<C>(
235    explanation: &trellis_core::OutputFrameExplanation,
236    result: &TransactionResult<C>,
237) -> bool {
238    if result.changed_inputs.is_empty() {
239        return true;
240    }
241    if matches!(
242        explanation.kind,
243        OutputFrameKindTrace::Baseline
244            | OutputFrameKindTrace::Clear(_)
245            | OutputFrameKindTrace::Rebaseline(_)
246    ) && explanation.changed_dependencies.is_empty()
247    {
248        return true;
249    }
250    !explanation.changed_dependencies.is_empty()
251        && !explanation.input_causes.is_empty()
252        && !explanation.dependency_paths.is_empty()
253}