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}