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