Skip to main content

fallow_cov_protocol/
lib.rs

1//! Versioned envelope types shared between the public `fallow` CLI and the
2//! closed-source `fallow-cov` production-coverage sidecar.
3//!
4//! The public CLI builds a [`Request`] from its static analysis output, spawns
5//! the sidecar, writes the request to stdin, and reads a [`Response`] from
6//! stdout. Both sides depend on this crate to guarantee contract alignment.
7//!
8//! # Versioning
9//!
10//! The top-level `protocol_version` field is a full semver string. Major
11//! bumps indicate breaking changes; consumers MUST reject mismatched majors.
12//! Minor bumps add optional fields; consumers MUST forward-accept unknown
13//! fields and SHOULD map unknown enum variants to [`Feature::Unknown`],
14//! [`ReportVerdict::Unknown`], or [`Verdict::Unknown`] rather than erroring.
15//!
16//! # 0.2 overview
17//!
18//! This is the first production-shaped contract. The top-level
19//! [`ReportVerdict`] (previously `Verdict`) is unchanged in meaning but was
20//! renamed to avoid colliding with per-finding [`Verdict`]. Each
21//! [`Finding`] and [`HotPath`] now carries a deterministic [`finding_id`] /
22//! [`hot_path_id`] hash, a full [`Evidence`] block, and — for findings — a
23//! per-function verdict and nullable invocation count. [`Confidence`]
24//! gained `VeryHigh` and `None` variants to match the decision table in
25//! `.internal/spec-production-coverage.md`.
26//!
27//! [`StaticFunction::static_used`] and [`StaticFunction::test_covered`] are
28//! intentionally required (no `#[serde(default)]`) — a silent default would
29//! hide every `safe_to_delete` finding, so 0.1-shape requests must fail
30//! deserialization instead of parsing into a wrong answer.
31
32#![forbid(unsafe_code)]
33
34use serde::{Deserialize, Serialize};
35use sha2::{Digest, Sha256};
36
37/// Current protocol version. Bumped per spec rules above.
38pub const PROTOCOL_VERSION: &str = "0.2.0";
39
40// -- Request envelope -------------------------------------------------------
41
42/// Sent by the public CLI to the sidecar via stdin.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Request {
45    /// Semver string of the protocol version this request targets.
46    pub protocol_version: String,
47    pub license: License,
48    /// Absolute path of the project root under analysis.
49    pub project_root: String,
50    pub coverage_sources: Vec<CoverageSource>,
51    pub static_findings: StaticFindings,
52    #[serde(default)]
53    pub options: Options,
54}
55
56/// The license material the sidecar should validate.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct License {
59    /// Full JWT string, already stripped of whitespace.
60    pub jwt: String,
61}
62
63/// A single coverage artifact on disk.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(tag = "kind", rename_all = "kebab-case")]
66pub enum CoverageSource {
67    /// A single V8 `ScriptCoverage` JSON file.
68    V8 { path: String },
69    /// A single Istanbul JSON file.
70    Istanbul { path: String },
71    /// A directory containing multiple V8 dumps to merge in memory.
72    V8Dir { path: String },
73}
74
75/// Static analysis output the public CLI already produced.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct StaticFindings {
78    pub files: Vec<StaticFile>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct StaticFile {
83    pub path: String,
84    pub functions: Vec<StaticFunction>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct StaticFunction {
89    pub name: String,
90    pub start_line: u32,
91    pub end_line: u32,
92    pub cyclomatic: u32,
93    /// Whether this function is statically referenced by the module graph.
94    /// Drives [`Evidence::static_status`] and gates [`Verdict::SafeToDelete`].
95    /// Required: a missing field would silently default to "used" and hide
96    /// every `safe_to_delete` finding.
97    pub static_used: bool,
98    /// Whether this function is covered by the project's test suite.
99    /// Drives [`Evidence::test_coverage`]. Required for the same reason as
100    /// [`StaticFunction::static_used`].
101    pub test_covered: bool,
102}
103
104/// Runtime knobs. All fields are optional so new options can be added without
105/// a breaking change.
106#[derive(Debug, Clone, Default, Serialize, Deserialize)]
107pub struct Options {
108    #[serde(default)]
109    pub include_hot_paths: bool,
110    #[serde(default)]
111    pub min_invocations_for_hot: Option<u64>,
112    /// Minimum total trace volume before `safe_to_delete` / `review_required`
113    /// verdicts are allowed at high/very-high confidence. Below this the
114    /// sidecar caps confidence at [`Confidence::Medium`]. Spec default `5000`.
115    #[serde(default)]
116    pub min_observation_volume: Option<u32>,
117    /// Fraction of total `trace_count` below which an invoked function is
118    /// classified as [`Verdict::LowTraffic`] instead of `active`. Spec default
119    /// `0.001` (0.1%).
120    #[serde(default)]
121    pub low_traffic_threshold: Option<f64>,
122    /// Total number of traces / request-equivalents the coverage dump covers.
123    /// Used as the denominator for the low-traffic ratio and gates the
124    /// minimum-observation-volume cap. When `None` the sidecar falls back to
125    /// the sum of observed invocations in the current request.
126    #[serde(default)]
127    pub trace_count: Option<u64>,
128    /// Number of days of observation the coverage dump represents. Surfaced
129    /// verbatim in [`Summary::period_days`] and [`Evidence::observation_days`].
130    #[serde(default)]
131    pub period_days: Option<u32>,
132    /// Number of distinct production deployments that contributed coverage.
133    /// Surfaced verbatim in [`Summary::deployments_seen`] and
134    /// [`Evidence::deployments_observed`].
135    #[serde(default)]
136    pub deployments_seen: Option<u32>,
137}
138
139// -- Response envelope ------------------------------------------------------
140
141/// Emitted by the sidecar to stdout.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct Response {
144    pub protocol_version: String,
145    pub verdict: ReportVerdict,
146    pub summary: Summary,
147    pub findings: Vec<Finding>,
148    #[serde(default)]
149    pub hot_paths: Vec<HotPath>,
150    #[serde(default)]
151    pub watermark: Option<Watermark>,
152    #[serde(default)]
153    pub errors: Vec<DiagnosticMessage>,
154    #[serde(default)]
155    pub warnings: Vec<DiagnosticMessage>,
156}
157
158/// Top-level report verdict (was `Verdict` in 0.1). Summarises the overall
159/// state of the run; per-finding verdicts live on [`Finding::verdict`].
160/// Unknown variants are forward-mapped to [`ReportVerdict::Unknown`].
161#[derive(Debug, Clone, Serialize, Deserialize)]
162#[serde(rename_all = "kebab-case")]
163pub enum ReportVerdict {
164    Clean,
165    HotPathChangesNeeded,
166    ColdCodeDetected,
167    LicenseExpiredGrace,
168    /// Sentinel for forward-compatibility with newer sidecars.
169    #[serde(other)]
170    Unknown,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct Summary {
175    /// Number of functions the sidecar could observe in the V8 dump.
176    pub functions_tracked: u64,
177    /// Functions that received at least one invocation.
178    pub functions_hit: u64,
179    /// Functions that were tracked but never invoked.
180    pub functions_unhit: u64,
181    /// Functions the sidecar could not track (lazy-parsed, worker thread, etc.).
182    pub functions_untracked: u64,
183    /// Ratio of `functions_hit / functions_tracked`, expressed as percent.
184    pub coverage_percent: f64,
185    /// Total number of observed invocations across all functions in the
186    /// current request. Denominator for low-traffic classification.
187    pub trace_count: u64,
188    /// Days of observation covered by the supplied dump.
189    pub period_days: u32,
190    /// Distinct deployments contributing to the supplied dump.
191    pub deployments_seen: u32,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct Finding {
196    /// Deterministic content hash of shape `fallow:prod:<hash>`. See
197    /// [`finding_id`] for the canonical helper.
198    pub id: String,
199    pub file: String,
200    pub function: String,
201    /// 1-indexed line number the function starts on. Included in the ID hash
202    /// so anonymous functions with identical names but different locations
203    /// get distinct IDs.
204    pub line: u32,
205    /// Per-finding verdict. Describes what the agent should do with this
206    /// specific function.
207    pub verdict: Verdict,
208    /// Raw invocation count from the V8 dump. `None` when the function was
209    /// not tracked (lazy-parsed, worker-thread isolate, etc.).
210    pub invocations: Option<u64>,
211    pub confidence: Confidence,
212    pub evidence: Evidence,
213    #[serde(default)]
214    pub actions: Vec<Action>,
215}
216
217/// Per-finding verdict. Replaces the 0.1 `CallState` enum.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case")]
220pub enum Verdict {
221    /// Statically unused AND never invoked in production with coverage tracked.
222    SafeToDelete,
223    /// Used somewhere statically / by tests / by an untracked call site but
224    /// never invoked in production. Needs a human look.
225    ReviewRequired,
226    /// V8 could not observe the function (lazy-parsed, worker thread,
227    /// dynamic code). Nothing can be said about runtime behaviour.
228    CoverageUnavailable,
229    /// Invoked in production but below the configured low-traffic threshold
230    /// relative to `trace_count`. Effectively dead in the current period.
231    LowTraffic,
232    /// Function was invoked above the low-traffic threshold — not dead.
233    Active,
234    /// Sentinel for forward-compatibility.
235    #[serde(other)]
236    Unknown,
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "snake_case")]
241pub enum Confidence {
242    /// Combined static + runtime signal: statically unused AND tracked AND
243    /// zero invocations. Strongest delete signal the sidecar emits.
244    VeryHigh,
245    High,
246    Medium,
247    Low,
248    /// Explicit absence of confidence (e.g. coverage unavailable).
249    None,
250    #[serde(other)]
251    Unknown,
252}
253
254/// Supporting evidence for a [`Finding`]. Mirrors the rows of the decision
255/// table in `.internal/spec-production-coverage.md` so the CLI can render the
256/// "why" behind each verdict without re-deriving it.
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct Evidence {
259    /// `"unused"` when the CLI marked the function statically unreachable,
260    /// `"used"` otherwise.
261    pub static_status: String,
262    /// `"covered"` or `"not_covered"` by the project's test suite.
263    pub test_coverage: String,
264    /// `"tracked"` when V8 observed the function, `"untracked"` otherwise.
265    pub v8_tracking: String,
266    /// Populated when `v8_tracking == "untracked"`. Values mirror the spec:
267    /// `"lazy_parsed"`, `"worker_thread"`, `"dynamic_eval"`, `"unknown"`.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub untracked_reason: Option<String>,
270    /// Days of observation the decision rests on. Echoes [`Summary::period_days`].
271    pub observation_days: u32,
272    /// Distinct deployments the decision rests on. Echoes [`Summary::deployments_seen`].
273    pub deployments_observed: u32,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct HotPath {
278    /// Deterministic content hash of shape `fallow:hot:<hash>`. See
279    /// [`hot_path_id`] for the canonical helper.
280    pub id: String,
281    pub file: String,
282    pub function: String,
283    pub line: u32,
284    pub invocations: u64,
285    /// Percentile rank of this function's invocation count over the
286    /// invocation distribution of the current response's hot paths. `100`
287    /// means the busiest function, `0` the quietest that still qualified.
288    pub percentile: u8,
289}
290
291/// Machine-readable next-step hint for AI agents.
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct Action {
294    pub kind: String,
295    pub description: String,
296    #[serde(default)]
297    pub auto_fixable: bool,
298}
299
300/// What to render in the human output when the license is in the grace window.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302#[serde(rename_all = "kebab-case")]
303pub enum Watermark {
304    TrialExpired,
305    LicenseExpiredGrace,
306    #[serde(other)]
307    Unknown,
308}
309
310/// Error / warning surfaced by the sidecar.
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct DiagnosticMessage {
313    pub code: String,
314    pub message: String,
315}
316
317// -- Stable ID helpers -----------------------------------------------------
318
319/// Compute the deterministic [`Finding::id`] for a production-coverage finding.
320///
321/// Emits `fallow:prod:<hash>` where `<hash>` is the first 8 hex characters of
322/// `SHA-256(file + function + line + "prod")`. The concatenation is plain,
323/// unseparated UTF-8. The canonical order MUST stay identical across protocol
324/// revisions; changing it breaks ID stability across runs and invalidates any
325/// consumer that persists IDs (CI deduplication, suppression, agent
326/// cross-references).
327#[must_use]
328pub fn finding_id(file: &str, function: &str, line: u32) -> String {
329    format!("fallow:prod:{}", content_hash(file, function, line, "prod"))
330}
331
332/// Compute the deterministic [`HotPath::id`] for a hot-path finding. Uses the
333/// same canonical order as [`finding_id`] with kind `"hot"`, emitting
334/// `fallow:hot:<hash>`.
335#[must_use]
336pub fn hot_path_id(file: &str, function: &str, line: u32) -> String {
337    format!("fallow:hot:{}", content_hash(file, function, line, "hot"))
338}
339
340fn content_hash(file: &str, function: &str, line: u32, kind: &str) -> String {
341    let mut hasher = Sha256::new();
342    hasher.update(file.as_bytes());
343    hasher.update(function.as_bytes());
344    hasher.update(line.to_string().as_bytes());
345    hasher.update(kind.as_bytes());
346    let digest = hasher.finalize();
347    let mut out = String::with_capacity(8);
348    for byte in digest.iter().take(4) {
349        use std::fmt::Write as _;
350        let _ = write!(out, "{byte:02x}");
351    }
352    out
353}
354
355// -- License features -------------------------------------------------------
356
357/// Feature flags present in the license JWT's `features` claim.
358///
359/// Wire format stays a string array (forward-compatible); new variants are
360/// additive in minor protocol bumps.
361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
362#[serde(rename_all = "snake_case")]
363pub enum Feature {
364    ProductionCoverage,
365    // Deferred to later phases:
366    PortfolioDashboard,
367    McpCloudTools,
368    CrossRepoAggregation,
369    #[serde(other)]
370    Unknown,
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn version_constant_is_v0_2() {
379        assert!(PROTOCOL_VERSION.starts_with("0.2."));
380    }
381
382    #[test]
383    fn unknown_report_verdict_round_trips() {
384        let json = r#""something-new""#;
385        let verdict: ReportVerdict = serde_json::from_str(json).unwrap();
386        assert!(matches!(verdict, ReportVerdict::Unknown));
387    }
388
389    #[test]
390    fn unknown_verdict_round_trips() {
391        let json = r#""future_state""#;
392        let verdict: Verdict = serde_json::from_str(json).unwrap();
393        assert!(matches!(verdict, Verdict::Unknown));
394    }
395
396    #[test]
397    fn unknown_confidence_round_trips() {
398        let json = r#""ultra_high""#;
399        let confidence: Confidence = serde_json::from_str(json).unwrap();
400        assert!(matches!(confidence, Confidence::Unknown));
401    }
402
403    #[test]
404    fn unknown_feature_round_trips() {
405        let json = r#""future_feature""#;
406        let feature: Feature = serde_json::from_str(json).unwrap();
407        assert!(matches!(feature, Feature::Unknown));
408    }
409
410    #[test]
411    fn coverage_source_kebab_case() {
412        let json = r#"{"kind":"v8-dir","path":"/tmp/dumps"}"#;
413        let src: CoverageSource = serde_json::from_str(json).unwrap();
414        assert!(matches!(src, CoverageSource::V8Dir { .. }));
415    }
416
417    #[test]
418    fn response_allows_unknown_fields() {
419        let json = r#"{
420            "protocol_version": "0.2.0",
421            "verdict": "clean",
422            "summary": {
423                "functions_tracked": 0,
424                "functions_hit": 0,
425                "functions_unhit": 0,
426                "functions_untracked": 0,
427                "coverage_percent": 0.0,
428                "trace_count": 0,
429                "period_days": 0,
430                "deployments_seen": 0
431            },
432            "findings": [],
433            "future_top_level_field": 42
434        }"#;
435        let response: Response = serde_json::from_str(json).unwrap();
436        assert_eq!(response.protocol_version, "0.2.0");
437    }
438
439    #[test]
440    fn finding_id_is_deterministic() {
441        let first = finding_id("src/a.ts", "foo", 42);
442        let second = finding_id("src/a.ts", "foo", 42);
443        assert_eq!(first, second);
444        assert!(first.starts_with("fallow:prod:"));
445        assert_eq!(first.len(), "fallow:prod:".len() + 8);
446    }
447
448    #[test]
449    fn hot_path_id_differs_from_finding_id() {
450        let f = finding_id("src/a.ts", "foo", 42);
451        let h = hot_path_id("src/a.ts", "foo", 42);
452        assert_ne!(f[f.len() - 8..], h[h.len() - 8..]);
453    }
454
455    #[test]
456    fn finding_id_changes_with_line() {
457        assert_ne!(
458            finding_id("src/a.ts", "foo", 10),
459            finding_id("src/a.ts", "foo", 11),
460        );
461    }
462
463    #[test]
464    fn finding_id_changes_with_file() {
465        assert_ne!(
466            finding_id("src/a.ts", "foo", 42),
467            finding_id("src/b.ts", "foo", 42),
468        );
469    }
470
471    #[test]
472    fn finding_id_changes_with_function() {
473        assert_ne!(
474            finding_id("src/a.ts", "foo", 42),
475            finding_id("src/a.ts", "bar", 42),
476        );
477    }
478
479    #[test]
480    fn evidence_round_trips_with_untracked_reason() {
481        let evidence = Evidence {
482            static_status: "used".to_owned(),
483            test_coverage: "not_covered".to_owned(),
484            v8_tracking: "untracked".to_owned(),
485            untracked_reason: Some("lazy_parsed".to_owned()),
486            observation_days: 30,
487            deployments_observed: 14,
488        };
489        let json = serde_json::to_string(&evidence).unwrap();
490        assert!(json.contains("\"untracked_reason\":\"lazy_parsed\""));
491        let back: Evidence = serde_json::from_str(&json).unwrap();
492        assert_eq!(back.untracked_reason.as_deref(), Some("lazy_parsed"));
493    }
494
495    #[test]
496    fn static_function_requires_static_used_and_test_covered() {
497        // Belt-and-suspenders: a 0.1-shape request (no static_used / test_covered)
498        // must fail deserialization rather than silently defaulting to "used + covered"
499        // which would hide every safe_to_delete finding.
500        let json = r#"{"name":"foo","start_line":1,"end_line":2,"cyclomatic":1}"#;
501        let result: Result<StaticFunction, _> = serde_json::from_str(json);
502        let err = result
503            .expect_err("missing static_used / test_covered must fail")
504            .to_string();
505        assert!(
506            err.contains("static_used") || err.contains("test_covered"),
507            "unexpected error text: {err}"
508        );
509    }
510
511    #[test]
512    fn options_defaults_when_fields_omitted() {
513        let json = "{}";
514        let options: Options = serde_json::from_str(json).unwrap();
515        assert!(!options.include_hot_paths);
516        assert!(options.min_invocations_for_hot.is_none());
517        assert!(options.min_observation_volume.is_none());
518        assert!(options.low_traffic_threshold.is_none());
519        assert!(options.trace_count.is_none());
520        assert!(options.period_days.is_none());
521        assert!(options.deployments_seen.is_none());
522    }
523
524    #[test]
525    fn evidence_omits_untracked_reason_when_none() {
526        let evidence = Evidence {
527            static_status: "unused".to_owned(),
528            test_coverage: "covered".to_owned(),
529            v8_tracking: "tracked".to_owned(),
530            untracked_reason: None,
531            observation_days: 30,
532            deployments_observed: 14,
533        };
534        let json = serde_json::to_string(&evidence).unwrap();
535        assert!(
536            !json.contains("untracked_reason"),
537            "expected untracked_reason omitted, got {json}"
538        );
539    }
540}