Skip to main content

supercov_engine/
frontend_protocol.rs

1//! Validation boundary between language-specific producers and shared Rust analysis.
2//!
3//! Frontends contribute facts, never verdicts. This module enforces the frozen
4//! per-run declaration against the normalized manifest/evidence request before
5//! the language-neutral analyzer sees it.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use supercov_contracts::{
10    AttributionPrecision, FrontendDeclarationError, FrontendRunDeclaration,
11    FrontendRunnerDeclaration, validate_frontend_run_declaration,
12};
13
14use crate::coverage_report::{
15    CoverageReport, CoverageReportRequest, RawTestResult, ReportError, analyze_coverage_results,
16};
17
18#[derive(Debug)]
19pub enum FrontendProtocolError {
20    Declaration(FrontendDeclarationError),
21    InvalidManifestLimitation,
22    DuplicateManifestLimitation(String),
23    StructuralLimitationMismatch {
24        declared: Vec<String>,
25        manifest: Vec<String>,
26    },
27    UndeclaredRunner(String),
28    UnobservedRunner(String),
29    MissingExactIdentity {
30        runner: String,
31        axis: &'static str,
32    },
33    ScopeRunMismatch {
34        expected: String,
35        actual: String,
36    },
37    RetryMismatch {
38        runner: String,
39        result: usize,
40        scope: usize,
41    },
42    InvalidPhaseKind(String),
43    DuplicatePhase(String),
44    UnknownPhaseReference(String),
45    CyclicPhaseReference(String),
46    Analysis(ReportError),
47}
48
49impl std::fmt::Display for FrontendProtocolError {
50    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            Self::Declaration(error) => write!(formatter, "{error}"),
53            Self::InvalidManifestLimitation => {
54                write!(
55                    formatter,
56                    "frontend manifest contains a limitation without an ID"
57                )
58            }
59            Self::DuplicateManifestLimitation(id) => {
60                write!(formatter, "duplicate frontend manifest limitation: {id}")
61            }
62            Self::StructuralLimitationMismatch { declared, manifest } => write!(
63                formatter,
64                "frontend structural limitation references differ: declared={declared:?} manifest={manifest:?}"
65            ),
66            Self::UndeclaredRunner(runner) => {
67                write!(
68                    formatter,
69                    "frontend evidence uses undeclared runner: {runner}"
70                )
71            }
72            Self::UnobservedRunner(runner) => {
73                write!(
74                    formatter,
75                    "frontend declares an unobserved runner: {runner}"
76                )
77            }
78            Self::MissingExactIdentity { runner, axis } => {
79                write!(
80                    formatter,
81                    "frontend runner {runner} is missing exact {axis} identity"
82                )
83            }
84            Self::ScopeRunMismatch { expected, actual } => write!(
85                formatter,
86                "frontend evidence run identity differs: expected={expected} actual={actual}"
87            ),
88            Self::RetryMismatch {
89                runner,
90                result,
91                scope,
92            } => write!(
93                formatter,
94                "frontend runner {runner} retry identity differs: result={result} scope={scope}"
95            ),
96            Self::InvalidPhaseKind(kind) => {
97                write!(formatter, "unsupported frontend phase kind: {kind}")
98            }
99            Self::DuplicatePhase(id) => write!(formatter, "duplicate frontend phase ID: {id}"),
100            Self::UnknownPhaseReference(id) => {
101                write!(formatter, "unknown frontend phase reference: {id}")
102            }
103            Self::CyclicPhaseReference(id) => {
104                write!(formatter, "cyclic frontend phase causality at: {id}")
105            }
106            Self::Analysis(error) => write!(formatter, "{error:?}"),
107        }
108    }
109}
110
111impl std::error::Error for FrontendProtocolError {}
112
113impl From<FrontendDeclarationError> for FrontendProtocolError {
114    fn from(error: FrontendDeclarationError) -> Self {
115        Self::Declaration(error)
116    }
117}
118
119fn manifest_limitation_ids(
120    request: &CoverageReportRequest,
121) -> Result<BTreeSet<String>, FrontendProtocolError> {
122    let mut ids = BTreeSet::new();
123    for limitation in &request.manifest.limitations {
124        let id = limitation
125            .get("id")
126            .and_then(serde_json::Value::as_str)
127            .filter(|id| !id.is_empty())
128            .ok_or(FrontendProtocolError::InvalidManifestLimitation)?;
129        if !ids.insert(id.to_owned()) {
130            return Err(FrontendProtocolError::DuplicateManifestLimitation(
131                id.to_owned(),
132            ));
133        }
134    }
135    Ok(ids)
136}
137
138fn present(value: &str) -> bool {
139    !value.trim().is_empty() && !value.chars().any(char::is_control)
140}
141
142fn require_exact_identities(
143    runner: &FrontendRunnerDeclaration,
144    raw: &RawTestResult,
145    run_id: &str,
146    global_phase_ids: &mut BTreeSet<String>,
147) -> Result<(), FrontendProtocolError> {
148    let missing = |axis| FrontendProtocolError::MissingExactIdentity {
149        runner: runner.runner.clone(),
150        axis,
151    };
152    if runner.attribution.test == AttributionPrecision::Exact
153        && !raw.test_id.as_deref().is_some_and(present)
154    {
155        return Err(missing("test"));
156    }
157    if let Some(scope) = &raw.scope {
158        if scope.run_id != run_id {
159            return Err(FrontendProtocolError::ScopeRunMismatch {
160                expected: run_id.to_owned(),
161                actual: scope.run_id.clone(),
162            });
163        }
164        if runner.attribution.worker == AttributionPrecision::Exact && !present(&scope.worker_id) {
165            return Err(missing("worker"));
166        }
167        if runner.attribution.test == AttributionPrecision::Exact
168            && (!present(&scope.test_id) || raw.test_id.as_deref() != Some(scope.test_id.as_str()))
169        {
170            return Err(missing("test"));
171        }
172        if runner.attribution.retry == AttributionPrecision::Exact {
173            let result_retry = raw.retry.ok_or_else(|| missing("retry"))?;
174            if result_retry != scope.retry {
175                return Err(FrontendProtocolError::RetryMismatch {
176                    runner: runner.runner.clone(),
177                    result: result_retry,
178                    scope: scope.retry,
179                });
180            }
181        }
182    } else if runner.attribution.worker == AttributionPrecision::Exact {
183        return Err(missing("worker"));
184    } else if runner.attribution.retry == AttributionPrecision::Exact && raw.retry.is_none() {
185        return Err(missing("retry"));
186    }
187
188    let mut phase_ids = BTreeSet::new();
189    for phase in &raw.phases {
190        if !matches!(
191            phase.kind.as_str(),
192            "setup" | "test" | "action" | "assertion" | "teardown" | "background"
193        ) {
194            return Err(FrontendProtocolError::InvalidPhaseKind(phase.kind.clone()));
195        }
196        if runner.attribution.phase == AttributionPrecision::Exact && !present(&phase.id) {
197            return Err(missing("phase"));
198        }
199        if !phase_ids.insert(phase.id.clone()) {
200            return Err(FrontendProtocolError::DuplicatePhase(phase.id.clone()));
201        }
202        if !global_phase_ids.insert(phase.id.clone()) {
203            return Err(FrontendProtocolError::DuplicatePhase(phase.id.clone()));
204        }
205    }
206    let phase_reference = |id: &str| {
207        if present(id) && phase_ids.contains(id) {
208            Ok(())
209        } else {
210            Err(FrontendProtocolError::UnknownPhaseReference(id.to_owned()))
211        }
212    };
213    for phase in &raw.phases {
214        if let Some(cause) = &phase.caused_by_phase_id {
215            phase_reference(cause)?;
216        }
217    }
218    let causes = raw
219        .phases
220        .iter()
221        .filter_map(|phase| {
222            phase
223                .caused_by_phase_id
224                .as_ref()
225                .map(|cause| (phase.id.as_str(), cause.as_str()))
226        })
227        .collect::<BTreeMap<_, _>>();
228    for start in causes.keys() {
229        let mut visited = BTreeSet::new();
230        let mut current = *start;
231        while let Some(next) = causes.get(current) {
232            if !visited.insert(current) {
233                return Err(FrontendProtocolError::CyclicPhaseReference(
234                    (*start).to_owned(),
235                ));
236            }
237            current = next;
238        }
239    }
240    for snapshot in raw.runtime.iter().chain(&raw.browser) {
241        for event in &snapshot.events {
242            if let Some(phase) = &event.phase_id {
243                phase_reference(phase)?;
244            }
245        }
246    }
247    for record in &raw.server {
248        if let Some(phase) = &record.phase_id {
249            phase_reference(phase)?;
250        }
251    }
252    Ok(())
253}
254
255pub fn validate_frontend_report_request(
256    declaration: &FrontendRunDeclaration,
257    request: &CoverageReportRequest,
258) -> Result<(), FrontendProtocolError> {
259    validate_frontend_run_declaration(declaration)?;
260    let manifest = manifest_limitation_ids(request)?;
261    let declared = declaration
262        .structural_limitations
263        .iter()
264        .cloned()
265        .collect::<BTreeSet<_>>();
266    if declared != manifest {
267        return Err(FrontendProtocolError::StructuralLimitationMismatch {
268            declared: declared.into_iter().collect(),
269            manifest: manifest.into_iter().collect(),
270        });
271    }
272
273    let runners = declaration
274        .runners
275        .iter()
276        .map(|runner| (runner.runner.as_str(), runner))
277        .collect::<BTreeMap<_, _>>();
278    let mut observed = BTreeSet::new();
279    let mut phase_ids = BTreeSet::new();
280    for raw in &request.raw_results {
281        let name = raw.provenance.runner.as_str();
282        let runner = runners
283            .get(name)
284            .ok_or_else(|| FrontendProtocolError::UndeclaredRunner(name.to_owned()))?;
285        observed.insert(name);
286        require_exact_identities(runner, raw, &request.run_id, &mut phase_ids)?;
287    }
288    for runner in runners.keys() {
289        if !observed.contains(runner) {
290            return Err(FrontendProtocolError::UnobservedRunner(
291                (*runner).to_owned(),
292            ));
293        }
294    }
295    Ok(())
296}
297
298pub fn analyze_frontend_results(
299    declaration: &FrontendRunDeclaration,
300    request: &CoverageReportRequest,
301) -> Result<CoverageReport, FrontendProtocolError> {
302    validate_frontend_report_request(declaration, request)?;
303    analyze_coverage_results(request).map_err(FrontendProtocolError::Analysis)
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::{
310        coverage_analysis::PointKind,
311        coverage_report::{
312            CoverageManifest, CoveragePhase, ExecutionScope, ExitCodeInput, PointMeta,
313            RuntimeEvent, RuntimeSnapshot, TestProvenance,
314        },
315    };
316    use supercov_contracts::{
317        ExecutionModel, FrontendAttribution, FrontendLimitation, FrontendLimitationScope,
318        FrontendRunnerDeclaration, LANGUAGE_FRONTEND_PROTOCOL_VERSION, StructuralSource,
319    };
320
321    fn declaration() -> FrontendRunDeclaration {
322        FrontendRunDeclaration {
323            protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
324            frontend_id: "fixture".into(),
325            frontend_version: "fixture-v1".into(),
326            language: "fixture".into(),
327            structural_source: StructuralSource::NativeImport,
328            runners: vec![FrontendRunnerDeclaration {
329                runner: "fixture-runner".into(),
330                execution_model: ExecutionModel::SerialInProcess,
331                attribution: FrontendAttribution {
332                    run: AttributionPrecision::Exact,
333                    worker: AttributionPrecision::Exact,
334                    test: AttributionPrecision::Exact,
335                    retry: AttributionPrecision::Exact,
336                    phase: AttributionPrecision::Exact,
337                    action: AttributionPrecision::Unavailable,
338                    assertion: AttributionPrecision::Exact,
339                },
340                limitations: vec![FrontendLimitation {
341                    id: "no-action-hook".into(),
342                    scopes: vec![FrontendLimitationScope::Action],
343                    reason: "The fixture runner has no action lifecycle".into(),
344                }],
345            }],
346            structural_limitations: vec!["dynamic-fixture".into()],
347        }
348    }
349
350    fn request() -> CoverageReportRequest {
351        CoverageReportRequest {
352            run_id: "run".into(),
353            manifest: CoverageManifest {
354                decisions: vec![],
355                points: vec![PointMeta {
356                    id: "point".into(),
357                    kind: PointKind::Statement,
358                    file: "src/example.py".into(),
359                    line: 1,
360                    column: 1,
361                    source: "work()".into(),
362                    label: None,
363                }],
364                branches: vec![],
365                limitations: vec![serde_json::json!({
366                    "id": "dynamic-fixture",
367                    "kind": "dynamic-code",
368                    "file": "src/example.py",
369                    "line": 2,
370                    "column": 1,
371                    "source": "eval(source)",
372                    "reason": "Runtime source has no stable denominator"
373                })],
374                scope: None,
375            },
376            raw_results: vec![RawTestResult {
377                test_id: Some("test".into()),
378                scope: Some(ExecutionScope {
379                    version: 1,
380                    run_id: "run".into(),
381                    worker_id: "worker".into(),
382                    test_id: "test".into(),
383                    test_key: "test".into(),
384                    retry: 0,
385                    attempt_id: "attempt".into(),
386                }),
387                test: "test".into(),
388                test_file: Some("tests/test_example.py".into()),
389                title: Some("test".into()),
390                retry: Some(0),
391                status: Some("passed".into()),
392                expected_status: Some("passed".into()),
393                flaky: false,
394                provenance: TestProvenance {
395                    runner: "fixture-runner".into(),
396                    kind: "integration".into(),
397                    project: None,
398                    source: "explicit".into(),
399                },
400                role: "test".into(),
401                phases: vec![CoveragePhase {
402                    id: "assertion".into(),
403                    kind: "assertion".into(),
404                    operation: "assert result".into(),
405                    source: Some("tests/test_example.py:1".into()),
406                    caused_by_phase_id: None,
407                    started_at_ms: 1,
408                    ended_at_ms: Some(2),
409                    status: Some("passed".into()),
410                    error: None,
411                }],
412                runtime: vec![RuntimeSnapshot {
413                    decisions: vec![],
414                    hits: vec!["point".into()],
415                    events: vec![RuntimeEvent {
416                        event_type: "hit".into(),
417                        id: "point".into(),
418                        vector: None,
419                        timestamp_ms: 1,
420                        phase_id: Some("assertion".into()),
421                        environment: "fixture".into(),
422                    }],
423                }],
424                browser: vec![],
425                server: vec![],
426            }],
427            generated_at: "2026-08-25T00:00:00.000Z".into(),
428            coverage_model: None,
429            integrity: None,
430            test_exit_code: ExitCodeInput::Present(Some(0)),
431        }
432    }
433
434    #[test]
435    fn validates_a_declared_frontend_before_shared_analysis() {
436        let report = analyze_frontend_results(&declaration(), &request()).unwrap();
437        assert!(report.execution.unwrap().valid);
438        assert_eq!(report.view.summary.statements.covered, 1);
439        assert!(!report.view.summary.coverage_complete);
440    }
441
442    #[test]
443    fn rejects_hidden_limitations_undeclared_runners_and_missing_exact_scope() {
444        let mut hidden = declaration();
445        hidden.structural_limitations.clear();
446        assert!(matches!(
447            validate_frontend_report_request(&hidden, &request()),
448            Err(FrontendProtocolError::StructuralLimitationMismatch { .. })
449        ));
450
451        let mut undeclared = request();
452        undeclared.raw_results[0].provenance.runner = "other".into();
453        assert!(matches!(
454            validate_frontend_report_request(&declaration(), &undeclared),
455            Err(FrontendProtocolError::UndeclaredRunner(runner)) if runner == "other"
456        ));
457
458        let mut missing_scope = request();
459        missing_scope.raw_results[0].scope = None;
460        assert!(matches!(
461            validate_frontend_report_request(&declaration(), &missing_scope),
462            Err(FrontendProtocolError::MissingExactIdentity { axis: "worker", .. })
463        ));
464
465        let mut unknown_phase = request();
466        unknown_phase.raw_results[0].runtime[0].events[0].phase_id = Some("other".into());
467        assert!(matches!(
468            validate_frontend_report_request(&declaration(), &unknown_phase),
469            Err(FrontendProtocolError::UnknownPhaseReference(id)) if id == "other"
470        ));
471
472        let mut cyclic_phase = request();
473        cyclic_phase.raw_results[0].phases[0].caused_by_phase_id = Some("assertion".into());
474        assert!(matches!(
475            validate_frontend_report_request(&declaration(), &cyclic_phase),
476            Err(FrontendProtocolError::CyclicPhaseReference(id)) if id == "assertion"
477        ));
478    }
479}