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 the semver rules above.
38pub const PROTOCOL_VERSION: &str = "0.4.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 /// License material the sidecar validates before running coverage analysis.
48 pub license: License,
49 /// Absolute path of the project root under analysis.
50 pub project_root: String,
51 /// One or more coverage artifacts the sidecar should ingest.
52 pub coverage_sources: Vec<CoverageSource>,
53 /// Static analysis output the public CLI already produced for this run.
54 pub static_findings: StaticFindings,
55 /// Optional runtime knobs; all fields default to forward-compatible values.
56 #[serde(default)]
57 pub options: Options,
58}
59
60/// The license material the sidecar should validate.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct License {
63 /// Full JWT string, already stripped of whitespace.
64 pub jwt: String,
65}
66
67/// A single coverage artifact on disk.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(tag = "kind", rename_all = "kebab-case")]
70pub enum CoverageSource {
71 /// A single V8 `ScriptCoverage` JSON file.
72 V8 {
73 /// Absolute path to the V8 coverage JSON file.
74 path: String,
75 },
76 /// A single Istanbul JSON file.
77 Istanbul {
78 /// Absolute path to the Istanbul coverage JSON file.
79 path: String,
80 },
81 /// A directory containing multiple V8 dumps to merge in memory.
82 V8Dir {
83 /// Absolute path to the directory containing V8 dump files.
84 path: String,
85 },
86}
87
88/// Static analysis output the public CLI already produced.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct StaticFindings {
91 /// One entry per source file the CLI analyzed.
92 pub files: Vec<StaticFile>,
93}
94
95/// Static analysis results for a single source file.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct StaticFile {
98 /// Path to the source file, relative to [`Request::project_root`].
99 pub path: String,
100 /// Functions the CLI discovered in this file.
101 pub functions: Vec<StaticFunction>,
102}
103
104/// Static analysis results for a single function within a [`StaticFile`].
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct StaticFunction {
107 /// Function identifier as reported by the static analyzer. May be an
108 /// anonymous placeholder (e.g. `"<anonymous>"`) when the source has no
109 /// name at the definition site.
110 pub name: String,
111 /// 1-indexed line where the function body starts.
112 pub start_line: u32,
113 /// 1-indexed line where the function body ends (inclusive).
114 pub end_line: u32,
115 /// Cyclomatic complexity of the function, as computed by the CLI.
116 pub cyclomatic: u32,
117 /// Whether this function is statically referenced by the module graph.
118 /// Drives [`Evidence::static_status`] and gates [`Verdict::SafeToDelete`].
119 /// Required: a missing field would silently default to "used" and hide
120 /// every `safe_to_delete` finding.
121 pub static_used: bool,
122 /// Whether this function is covered by the project's test suite.
123 /// Drives [`Evidence::test_coverage`]. Required for the same reason as
124 /// [`StaticFunction::static_used`].
125 pub test_covered: bool,
126 /// Static caller count supplied by the CLI's module graph. Added in 0.4.0
127 /// for first-class blast-radius output; defaults to zero for older CLIs.
128 #[serde(default)]
129 pub caller_count: u32,
130 /// CODEOWNERS owner count for the containing file. `None` means no
131 /// CODEOWNERS data was available; `Some(0)` means CODEOWNERS exists but
132 /// no rule matched this file. Added in 0.4.0 for importance scoring.
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub owner_count: Option<u32>,
135}
136
137/// Runtime knobs. All fields are optional so new options can be added without
138/// a breaking change.
139#[derive(Debug, Clone, Default, Serialize, Deserialize)]
140pub struct Options {
141 /// When true the sidecar computes and returns [`Response::hot_paths`].
142 /// When false, hot-path computation is skipped entirely.
143 #[serde(default)]
144 pub include_hot_paths: bool,
145 /// Minimum invocation count a function must have to qualify as a hot path.
146 /// `None` defers to the sidecar's spec default.
147 #[serde(default)]
148 pub min_invocations_for_hot: Option<u64>,
149 /// Minimum total trace volume before `safe_to_delete` / `review_required`
150 /// verdicts are allowed at high/very-high confidence. Below this the
151 /// sidecar caps confidence at [`Confidence::Medium`]. Spec default `5000`.
152 #[serde(default)]
153 pub min_observation_volume: Option<u32>,
154 /// Fraction of total `trace_count` below which an invoked function is
155 /// classified as [`Verdict::LowTraffic`] instead of `active`. Spec default
156 /// `0.001` (0.1%).
157 #[serde(default)]
158 pub low_traffic_threshold: Option<f64>,
159 /// Total number of traces / request-equivalents the coverage dump covers.
160 /// Used as the denominator for the low-traffic ratio and gates the
161 /// minimum-observation-volume cap. When `None` the sidecar falls back to
162 /// the sum of observed invocations in the current request.
163 #[serde(default)]
164 pub trace_count: Option<u64>,
165 /// Number of days of observation the coverage dump represents. Surfaced
166 /// verbatim in [`Summary::period_days`] and [`Evidence::observation_days`].
167 #[serde(default)]
168 pub period_days: Option<u32>,
169 /// Number of distinct production deployments that contributed coverage.
170 /// Surfaced verbatim in [`Summary::deployments_seen`] and
171 /// [`Evidence::deployments_observed`].
172 #[serde(default)]
173 pub deployments_seen: Option<u32>,
174 /// Total observation window in seconds. Finer-grained than
175 /// [`Self::period_days`]; used to populate
176 /// [`CaptureQuality::window_seconds`]. When `None` the sidecar falls back
177 /// to `period_days * 86_400`. Added in protocol 0.3.0.
178 #[serde(default)]
179 pub window_seconds: Option<u64>,
180 /// Number of distinct production instances that contributed coverage.
181 /// Used to populate [`CaptureQuality::instances_observed`]. When `None`
182 /// the sidecar falls back to [`Self::deployments_seen`]. Added in
183 /// protocol 0.3.0.
184 #[serde(default)]
185 pub instances_observed: Option<u32>,
186}
187
188// -- Response envelope ------------------------------------------------------
189
190/// Emitted by the sidecar to stdout.
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct Response {
193 /// Semver string of the protocol version the sidecar produced.
194 pub protocol_version: String,
195 /// Top-level report verdict summarizing the overall state of the run.
196 pub verdict: ReportVerdict,
197 /// Aggregate statistics across the whole analysis.
198 pub summary: Summary,
199 /// Per-function findings, one entry per observed or tracked function.
200 pub findings: Vec<Finding>,
201 /// Hot-path findings, populated only when [`Options::include_hot_paths`]
202 /// was set on the request. Defaults to empty.
203 #[serde(default)]
204 pub hot_paths: Vec<HotPath>,
205 /// First-class blast-radius findings. Added in protocol 0.4.0.
206 #[serde(default)]
207 pub blast_radius: Vec<BlastRadiusEntry>,
208 /// First-class runtime importance findings. Added in protocol 0.4.0.
209 #[serde(default)]
210 pub importance: Vec<ImportanceEntry>,
211 /// Grace-period watermark the CLI should render in human output, if any.
212 #[serde(default)]
213 pub watermark: Option<Watermark>,
214 /// Non-fatal errors the sidecar emitted while processing the request.
215 #[serde(default)]
216 pub errors: Vec<DiagnosticMessage>,
217 /// Warnings the sidecar emitted while processing the request.
218 #[serde(default)]
219 pub warnings: Vec<DiagnosticMessage>,
220}
221
222/// Top-level report verdict (was `Verdict` in 0.1). Summarises the overall
223/// state of the run; per-finding verdicts live on [`Finding::verdict`].
224/// Unknown variants are forward-mapped to [`ReportVerdict::Unknown`].
225#[derive(Debug, Clone, Serialize, Deserialize)]
226#[serde(rename_all = "kebab-case")]
227pub enum ReportVerdict {
228 /// No action required — production coverage confirms the codebase.
229 Clean,
230 /// One or more hot paths need attention (regression / drift).
231 HotPathChangesNeeded,
232 /// At least one finding indicates cold code that should be removed or
233 /// reviewed.
234 ColdCodeDetected,
235 /// The license JWT has expired but the sidecar is still operating inside
236 /// the configured grace window. Output is advisory.
237 LicenseExpiredGrace,
238 /// Sentinel for forward-compatibility with newer sidecars.
239 #[serde(other)]
240 Unknown,
241}
242
243/// Aggregate statistics describing the observed coverage dump.
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct Summary {
246 /// Number of functions the sidecar could observe in the V8 dump.
247 pub functions_tracked: u64,
248 /// Functions that received at least one invocation.
249 pub functions_hit: u64,
250 /// Functions that were tracked but never invoked.
251 pub functions_unhit: u64,
252 /// Functions the sidecar could not track (lazy-parsed, worker thread, etc.).
253 pub functions_untracked: u64,
254 /// Ratio of `functions_hit / functions_tracked`, expressed as percent.
255 pub coverage_percent: f64,
256 /// Total number of observed invocations across all functions in the
257 /// current request. Denominator for low-traffic classification.
258 pub trace_count: u64,
259 /// Days of observation covered by the supplied dump.
260 pub period_days: u32,
261 /// Distinct deployments contributing to the supplied dump.
262 pub deployments_seen: u32,
263 /// Quality of the capture window. Populated by the sidecar so the CLI
264 /// can render a "short window" warning alongside low-confidence verdicts,
265 /// and so the upgrade prompt can quantify the delta cloud mode would
266 /// provide. Optional for forward compatibility with 0.2.x sidecars;
267 /// 0.3.x always sets it. Added in protocol 0.3.0 per ADR 009 step 6b.
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub capture_quality: Option<CaptureQuality>,
270}
271
272/// Capture-quality telemetry surfaced alongside the aggregate summary.
273///
274/// First-touch local-mode captures (`fallow health --production-coverage-dir`)
275/// tend to produce short windows (minutes to an hour) against a single
276/// instance. Lazy-parsed scripts do not appear in V8 dumps unless they
277/// actually executed during the capture window, which a first-time user
278/// will read as "the tool is broken" rather than "the capture window is
279/// too short." This struct gives the CLI enough information to explain the
280/// state honestly and to quantify what continuous cloud monitoring would add.
281///
282/// Added in protocol 0.3.0 per ADR 009 step 6b, deliverable 2 of 3.
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
284pub struct CaptureQuality {
285 /// Total observation window in seconds. Finer-grained than
286 /// [`Summary::period_days`], which rounds up to whole days. A 12-minute
287 /// local capture reports `window_seconds: 720` and `period_days: 1`.
288 pub window_seconds: u64,
289 /// Number of distinct production instances that contributed to the
290 /// dump. Matches [`Summary::deployments_seen`] in the typical case but
291 /// is emitted separately so future captures can distinguish "one
292 /// deployment seen across many instances" from "many deployments".
293 pub instances_observed: u32,
294 /// True when the untracked-function ratio exceeds
295 /// [`Self::LAZY_PARSE_THRESHOLD_PERCENT`]. Signals that the CLI should
296 /// render a "short window" warning: many functions appearing as
297 /// untracked most likely reflect lazy-parsed code rather than
298 /// unreachable code, and the capture window is not long enough to
299 /// distinguish the two.
300 pub lazy_parse_warning: bool,
301 /// `functions_untracked / functions_tracked` as a percentage. Rounded
302 /// to two decimal places for JSON reproducibility. Provided so the CLI
303 /// can render the exact ratio that triggered the warning.
304 pub untracked_ratio_percent: f64,
305}
306
307impl CaptureQuality {
308 /// Threshold above which [`Self::lazy_parse_warning`] fires. Chosen so
309 /// a short window (minutes) against a typical Node app trips the
310 /// warning, while a multi-day continuous capture does not.
311 pub const LAZY_PARSE_THRESHOLD_PERCENT: f64 = 30.0;
312}
313
314/// A per-function finding combining static analysis and runtime coverage.
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct Finding {
317 /// Deterministic content hash of shape `fallow:prod:<hash>`. See
318 /// [`finding_id`] for the canonical helper.
319 pub id: String,
320 /// Path to the source file, relative to [`Request::project_root`].
321 pub file: String,
322 /// Function name as reported by the static analyzer. Matches
323 /// [`StaticFunction::name`].
324 pub function: String,
325 /// 1-indexed line number the function starts on. Included in the ID hash
326 /// so anonymous functions with identical names but different locations
327 /// get distinct IDs.
328 pub line: u32,
329 /// Per-finding verdict. Describes what the agent should do with this
330 /// specific function.
331 pub verdict: Verdict,
332 /// Raw invocation count from the V8 dump. `None` when the function was
333 /// not tracked (lazy-parsed, worker-thread isolate, etc.).
334 pub invocations: Option<u64>,
335 /// Confidence the sidecar has in this finding's [`Finding::verdict`].
336 pub confidence: Confidence,
337 /// Evidence rows the sidecar used to arrive at the finding.
338 pub evidence: Evidence,
339 /// Machine-readable next-step hints for AI agents.
340 #[serde(default)]
341 pub actions: Vec<Action>,
342}
343
344/// Per-finding verdict. Replaces the 0.1 `CallState` enum.
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(rename_all = "snake_case")]
347pub enum Verdict {
348 /// Statically unused AND never invoked in production with coverage tracked.
349 SafeToDelete,
350 /// Used somewhere statically / by tests / by an untracked call site but
351 /// never invoked in production. Needs a human look.
352 ReviewRequired,
353 /// V8 could not observe the function (lazy-parsed, worker thread,
354 /// dynamic code). Nothing can be said about runtime behaviour.
355 CoverageUnavailable,
356 /// Invoked in production but below the configured low-traffic threshold
357 /// relative to `trace_count`. Effectively dead in the current period.
358 LowTraffic,
359 /// Function was invoked above the low-traffic threshold — not dead.
360 Active,
361 /// Sentinel for forward-compatibility.
362 #[serde(other)]
363 Unknown,
364}
365
366/// Confidence the sidecar attaches to a [`Finding::verdict`].
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
368#[serde(rename_all = "snake_case")]
369pub enum Confidence {
370 /// Combined static + runtime signal: statically unused AND tracked AND
371 /// zero invocations. Strongest delete signal the sidecar emits.
372 VeryHigh,
373 /// Strong signal — one of static or runtime is dispositive, the other
374 /// agrees.
375 High,
376 /// Signals agree but observation volume or coverage fidelity tempers the
377 /// call.
378 Medium,
379 /// Weak signal — a single data point suggests the verdict but other
380 /// evidence is missing or ambiguous.
381 Low,
382 /// Explicit absence of confidence (e.g. coverage unavailable).
383 None,
384 /// Sentinel for forward-compatibility.
385 #[serde(other)]
386 Unknown,
387}
388
389/// Supporting evidence for a [`Finding`]. Mirrors the rows of the decision
390/// table in `.internal/spec-production-coverage.md` so the CLI can render the
391/// "why" behind each verdict without re-deriving it.
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct Evidence {
394 /// `"unused"` when the CLI marked the function statically unreachable,
395 /// `"used"` otherwise.
396 pub static_status: String,
397 /// `"covered"` or `"not_covered"` by the project's test suite.
398 pub test_coverage: String,
399 /// `"tracked"` when V8 observed the function, `"untracked"` otherwise.
400 pub v8_tracking: String,
401 /// Populated when `v8_tracking == "untracked"`. Values mirror the spec:
402 /// `"lazy_parsed"`, `"worker_thread"`, `"dynamic_eval"`, `"unknown"`.
403 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub untracked_reason: Option<String>,
405 /// Days of observation the decision rests on. Echoes [`Summary::period_days`].
406 pub observation_days: u32,
407 /// Distinct deployments the decision rests on. Echoes [`Summary::deployments_seen`].
408 pub deployments_observed: u32,
409}
410
411/// A function the sidecar identified as a hot path in the current dump.
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct HotPath {
414 /// Deterministic content hash of shape `fallow:hot:<hash>`. See
415 /// [`hot_path_id`] for the canonical helper.
416 pub id: String,
417 /// Path to the source file, relative to [`Request::project_root`].
418 pub file: String,
419 /// Function name as reported by the static analyzer.
420 pub function: String,
421 /// 1-indexed line the function starts on.
422 pub line: u32,
423 /// Raw invocation count from the V8 dump.
424 pub invocations: u64,
425 /// Percentile rank of this function's invocation count over the
426 /// invocation distribution of the current response's hot paths. `100`
427 /// means the busiest function, `0` the quietest that still qualified.
428 pub percentile: u8,
429}
430
431/// Risk band for a blast-radius entry.
432#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(rename_all = "snake_case")]
434pub enum RiskBand {
435 /// Low caller fan-in / traffic-weighted reach.
436 Low,
437 /// Moderate caller fan-in / traffic-weighted reach.
438 Medium,
439 /// High caller fan-in / traffic-weighted reach.
440 High,
441}
442
443/// A function with meaningful static or traffic-weighted blast radius.
444#[derive(Debug, Clone, Serialize, Deserialize)]
445pub struct BlastRadiusEntry {
446 /// Deterministic content hash of shape `fallow:blast:<hash>`.
447 pub id: String,
448 /// Path to the source file, relative to [`Request::project_root`].
449 pub file: String,
450 /// Function name as reported by the static analyzer.
451 pub function: String,
452 /// 1-indexed line the function starts on.
453 pub line: u32,
454 /// Static caller count supplied by the CLI module graph.
455 pub caller_count: u32,
456 /// Caller count weighted by observed traffic. Local mode uses the
457 /// sidecar's current best-effort traffic proxy; cloud mode may replace
458 /// this with summed caller invocations.
459 pub caller_count_weighted_by_traffic: u64,
460 /// Distinct git SHAs that touched this function in the observation window.
461 /// Cloud-only; omitted for local coverage artifacts.
462 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub deploys_touched: Option<u32>,
464 /// Deterministic low / medium / high band.
465 pub risk_band: RiskBand,
466}
467
468/// A function ranked by runtime traffic, complexity, and ownership risk.
469#[derive(Debug, Clone, Serialize, Deserialize)]
470pub struct ImportanceEntry {
471 /// Deterministic content hash of shape `fallow:importance:<hash>`.
472 pub id: String,
473 /// Path to the source file, relative to [`Request::project_root`].
474 pub file: String,
475 /// Function name as reported by the static analyzer.
476 pub function: String,
477 /// 1-indexed line the function starts on.
478 pub line: u32,
479 /// Raw invocation count used for the traffic component.
480 pub invocations: u64,
481 /// Cyclomatic complexity supplied by the CLI health pipeline.
482 pub cyclomatic: u32,
483 /// Number of CODEOWNERS owners; `0` means ownership is absent or unowned.
484 pub owner_count: u32,
485 /// 0-100 importance score. The formula is intentionally simple and
486 /// documented by the sidecar implementation so it can be tuned later.
487 pub importance_score: f64,
488 /// Templated one-sentence explanation, not free-form model text.
489 pub reason: String,
490}
491
492/// Machine-readable next-step hint for AI agents.
493#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct Action {
495 /// Short identifier for the action kind (e.g. `"delete"`, `"inline"`,
496 /// `"review"`). Free-form on the wire to keep forward compatibility.
497 pub kind: String,
498 /// Human-readable one-liner describing the suggested action.
499 pub description: String,
500 /// Whether the CLI can apply this action non-interactively.
501 #[serde(default)]
502 pub auto_fixable: bool,
503}
504
505/// What to render in the human output when the license is in the grace window.
506#[derive(Debug, Clone, Serialize, Deserialize)]
507#[serde(rename_all = "kebab-case")]
508pub enum Watermark {
509 /// The trial period has ended.
510 TrialExpired,
511 /// A paid license has expired but the sidecar is still inside the grace
512 /// window.
513 LicenseExpiredGrace,
514 /// Sentinel for forward-compatibility.
515 #[serde(other)]
516 Unknown,
517}
518
519/// Error / warning surfaced by the sidecar.
520#[derive(Debug, Clone, Serialize, Deserialize)]
521pub struct DiagnosticMessage {
522 /// Stable machine-readable diagnostic code (e.g. `"COV_DUMP_PARSE"`).
523 pub code: String,
524 /// Human-readable description of the diagnostic.
525 pub message: String,
526}
527
528// -- Stable ID helpers -----------------------------------------------------
529
530/// Compute the deterministic [`Finding::id`] for a production-coverage finding.
531///
532/// Emits `fallow:prod:<hash>` where `<hash>` is the first 8 hex characters of
533/// `SHA-256(file + function + line + "prod")`. The concatenation is plain,
534/// unseparated UTF-8. The canonical order MUST stay identical across protocol
535/// revisions; changing it breaks ID stability across runs and invalidates any
536/// consumer that persists IDs (CI deduplication, suppression, agent
537/// cross-references).
538#[must_use]
539pub fn finding_id(file: &str, function: &str, line: u32) -> String {
540 format!("fallow:prod:{}", content_hash(file, function, line, "prod"))
541}
542
543/// Compute the deterministic [`HotPath::id`] for a hot-path finding. Uses the
544/// same canonical order as [`finding_id`] with kind `"hot"`, emitting
545/// `fallow:hot:<hash>`.
546#[must_use]
547pub fn hot_path_id(file: &str, function: &str, line: u32) -> String {
548 format!("fallow:hot:{}", content_hash(file, function, line, "hot"))
549}
550
551/// Compute the deterministic [`BlastRadiusEntry::id`] for a blast-radius entry.
552#[must_use]
553pub fn blast_radius_id(file: &str, function: &str, line: u32) -> String {
554 format!(
555 "fallow:blast:{}",
556 content_hash(file, function, line, "blast")
557 )
558}
559
560/// Compute the deterministic [`ImportanceEntry::id`] for an importance entry.
561#[must_use]
562pub fn importance_id(file: &str, function: &str, line: u32) -> String {
563 format!(
564 "fallow:importance:{}",
565 content_hash(file, function, line, "importance")
566 )
567}
568
569/// Canonical content hash shared by the stable ID helpers. The input order
570/// (file, function, line, kind) and truncation (first 4 SHA-256 bytes → 8 hex
571/// chars) are part of the wire contract; see [`finding_id`] for the rationale.
572fn content_hash(file: &str, function: &str, line: u32, kind: &str) -> String {
573 let mut hasher = Sha256::new();
574 hasher.update(file.as_bytes());
575 hasher.update(function.as_bytes());
576 hasher.update(line.to_string().as_bytes());
577 hasher.update(kind.as_bytes());
578 let digest = hasher.finalize();
579 hex_prefix(&digest)
580}
581
582/// Encode the first four bytes of `digest` as lowercase hex — exactly eight
583/// characters. Kept separate so the truncation length is easy to audit. Total
584/// by construction: `HEX` is ASCII and `char::from(u8)` is infallible, so the
585/// helper never panics.
586fn hex_prefix(digest: &[u8]) -> String {
587 const HEX: &[u8; 16] = b"0123456789abcdef";
588 let mut out = String::with_capacity(8);
589 for &byte in digest.iter().take(4) {
590 out.push(char::from(HEX[usize::from(byte >> 4)]));
591 out.push(char::from(HEX[usize::from(byte & 0x0f)]));
592 }
593 out
594}
595
596// -- License features -------------------------------------------------------
597
598/// Feature flags present in the license JWT's `features` claim.
599///
600/// Wire format stays a string array (forward-compatible); new variants are
601/// additive in minor protocol bumps.
602#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
603#[serde(rename_all = "snake_case")]
604pub enum Feature {
605 /// Production coverage intelligence (the primary sidecar feature).
606 ProductionCoverage,
607 /// Portfolio dashboard for cross-project rollups. Deferred.
608 PortfolioDashboard,
609 /// MCP cloud tools integration. Deferred.
610 McpCloudTools,
611 /// Cross-repo aggregation and deduplication. Deferred.
612 CrossRepoAggregation,
613 /// Sentinel for forward-compatibility.
614 #[serde(other)]
615 Unknown,
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621
622 #[test]
623 fn version_constant_is_v0_4() {
624 assert!(PROTOCOL_VERSION.starts_with("0.4."));
625 }
626
627 #[test]
628 fn unknown_report_verdict_round_trips() {
629 let json = r#""something-new""#;
630 let verdict: ReportVerdict = serde_json::from_str(json).unwrap();
631 assert!(matches!(verdict, ReportVerdict::Unknown));
632 }
633
634 #[test]
635 fn unknown_verdict_round_trips() {
636 let json = r#""future_state""#;
637 let verdict: Verdict = serde_json::from_str(json).unwrap();
638 assert!(matches!(verdict, Verdict::Unknown));
639 }
640
641 #[test]
642 fn unknown_confidence_round_trips() {
643 let json = r#""ultra_high""#;
644 let confidence: Confidence = serde_json::from_str(json).unwrap();
645 assert!(matches!(confidence, Confidence::Unknown));
646 }
647
648 #[test]
649 fn unknown_feature_round_trips() {
650 let json = r#""future_feature""#;
651 let feature: Feature = serde_json::from_str(json).unwrap();
652 assert!(matches!(feature, Feature::Unknown));
653 }
654
655 #[test]
656 fn unknown_watermark_round_trips() {
657 let json = r#""something-else""#;
658 let watermark: Watermark = serde_json::from_str(json).unwrap();
659 assert!(matches!(watermark, Watermark::Unknown));
660 }
661
662 #[test]
663 fn coverage_source_kebab_case() {
664 let json = r#"{"kind":"v8-dir","path":"/tmp/dumps"}"#;
665 let src: CoverageSource = serde_json::from_str(json).unwrap();
666 assert!(matches!(src, CoverageSource::V8Dir { .. }));
667 }
668
669 #[test]
670 fn response_allows_unknown_fields() {
671 let json = r#"{
672 "protocol_version": "0.2.0",
673 "verdict": "clean",
674 "summary": {
675 "functions_tracked": 0,
676 "functions_hit": 0,
677 "functions_unhit": 0,
678 "functions_untracked": 0,
679 "coverage_percent": 0.0,
680 "trace_count": 0,
681 "period_days": 0,
682 "deployments_seen": 0
683 },
684 "findings": [],
685 "future_top_level_field": 42
686 }"#;
687 let response: Response = serde_json::from_str(json).unwrap();
688 assert_eq!(response.protocol_version, "0.2.0");
689 }
690
691 #[test]
692 fn finding_id_is_deterministic() {
693 let first = finding_id("src/a.ts", "foo", 42);
694 let second = finding_id("src/a.ts", "foo", 42);
695 assert_eq!(first, second);
696 assert!(first.starts_with("fallow:prod:"));
697 assert_eq!(first.len(), "fallow:prod:".len() + 8);
698 }
699
700 #[test]
701 fn capture_quality_round_trips() {
702 let q = CaptureQuality {
703 window_seconds: 720,
704 instances_observed: 1,
705 lazy_parse_warning: true,
706 untracked_ratio_percent: 42.5,
707 };
708 let json = serde_json::to_string(&q).unwrap();
709 let parsed: CaptureQuality = serde_json::from_str(&json).unwrap();
710 assert_eq!(q, parsed);
711 }
712
713 #[test]
714 fn summary_without_capture_quality_deserializes() {
715 // 0.2.x sidecars produced this shape; 0.3.x deserialization must
716 // still accept it so a mixed rollout (newer CLI, older sidecar)
717 // does not hard-fail.
718 let json = r#"{
719 "functions_tracked": 10,
720 "functions_hit": 5,
721 "functions_unhit": 5,
722 "functions_untracked": 0,
723 "coverage_percent": 50.0,
724 "trace_count": 100,
725 "period_days": 1,
726 "deployments_seen": 1
727 }"#;
728 let summary: Summary = serde_json::from_str(json).unwrap();
729 assert!(summary.capture_quality.is_none());
730 }
731
732 #[test]
733 fn summary_with_capture_quality_round_trips() {
734 let summary = Summary {
735 functions_tracked: 10,
736 functions_hit: 5,
737 functions_unhit: 5,
738 functions_untracked: 3,
739 coverage_percent: 50.0,
740 trace_count: 100,
741 period_days: 1,
742 deployments_seen: 1,
743 capture_quality: Some(CaptureQuality {
744 window_seconds: 720,
745 instances_observed: 1,
746 lazy_parse_warning: true,
747 untracked_ratio_percent: 30.0,
748 }),
749 };
750 let json = serde_json::to_string(&summary).unwrap();
751 let parsed: Summary = serde_json::from_str(&json).unwrap();
752 assert_eq!(summary.capture_quality, parsed.capture_quality);
753 }
754
755 #[test]
756 fn lazy_parse_threshold_is_30_percent() {
757 // Anchored so a bump forces a deliberate decision and a CHANGELOG
758 // entry rather than a silent tweak.
759 assert!((CaptureQuality::LAZY_PARSE_THRESHOLD_PERCENT - 30.0).abs() < f64::EPSILON);
760 }
761
762 #[test]
763 fn hot_path_id_differs_from_finding_id() {
764 let f = finding_id("src/a.ts", "foo", 42);
765 let h = hot_path_id("src/a.ts", "foo", 42);
766 assert_ne!(f[f.len() - 8..], h[h.len() - 8..]);
767 }
768
769 #[test]
770 fn finding_id_changes_with_line() {
771 assert_ne!(
772 finding_id("src/a.ts", "foo", 10),
773 finding_id("src/a.ts", "foo", 11),
774 );
775 }
776
777 #[test]
778 fn finding_id_changes_with_file() {
779 assert_ne!(
780 finding_id("src/a.ts", "foo", 42),
781 finding_id("src/b.ts", "foo", 42),
782 );
783 }
784
785 #[test]
786 fn finding_id_changes_with_function() {
787 assert_ne!(
788 finding_id("src/a.ts", "foo", 42),
789 finding_id("src/a.ts", "bar", 42),
790 );
791 }
792
793 #[test]
794 fn finding_id_is_lowercase_hex_ascii() {
795 // Canonical form is lowercase hex — downstream dedup keys on string
796 // equality, so an accidental uppercase switch would break persisted IDs.
797 let id = finding_id("src/a.ts", "foo", 42);
798 let hash = &id["fallow:prod:".len()..];
799 assert!(
800 hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')),
801 "expected lowercase hex, got {hash}"
802 );
803 }
804
805 #[test]
806 fn evidence_round_trips_with_untracked_reason() {
807 let evidence = Evidence {
808 static_status: "used".to_owned(),
809 test_coverage: "not_covered".to_owned(),
810 v8_tracking: "untracked".to_owned(),
811 untracked_reason: Some("lazy_parsed".to_owned()),
812 observation_days: 30,
813 deployments_observed: 14,
814 };
815 let json = serde_json::to_string(&evidence).unwrap();
816 assert!(json.contains("\"untracked_reason\":\"lazy_parsed\""));
817 let back: Evidence = serde_json::from_str(&json).unwrap();
818 assert_eq!(back.untracked_reason.as_deref(), Some("lazy_parsed"));
819 }
820
821 #[test]
822 fn static_function_requires_static_used_and_test_covered() {
823 // Belt-and-suspenders: a 0.1-shape request (no static_used / test_covered)
824 // must fail deserialization rather than silently defaulting to "used + covered"
825 // which would hide every safe_to_delete finding.
826 let json = r#"{"name":"foo","start_line":1,"end_line":2,"cyclomatic":1}"#;
827 let result: Result<StaticFunction, _> = serde_json::from_str(json);
828 let err = result
829 .expect_err("missing static_used / test_covered must fail")
830 .to_string();
831 assert!(
832 err.contains("static_used") || err.contains("test_covered"),
833 "unexpected error text: {err}"
834 );
835 }
836
837 #[test]
838 fn options_defaults_when_fields_omitted() {
839 let json = "{}";
840 let options: Options = serde_json::from_str(json).unwrap();
841 assert!(!options.include_hot_paths);
842 assert!(options.min_invocations_for_hot.is_none());
843 assert!(options.min_observation_volume.is_none());
844 assert!(options.low_traffic_threshold.is_none());
845 assert!(options.trace_count.is_none());
846 assert!(options.period_days.is_none());
847 assert!(options.deployments_seen.is_none());
848 }
849
850 #[test]
851 fn stable_ids_are_distinct_by_kind() {
852 let finding = finding_id("src/a.ts", "foo", 42);
853 let hot = hot_path_id("src/a.ts", "foo", 42);
854 let blast = blast_radius_id("src/a.ts", "foo", 42);
855 let importance = importance_id("src/a.ts", "foo", 42);
856 assert!(blast.starts_with("fallow:blast:"));
857 assert!(importance.starts_with("fallow:importance:"));
858 assert_eq!(blast.len(), "fallow:blast:".len() + 8);
859 assert_eq!(importance.len(), "fallow:importance:".len() + 8);
860 let suffixes = [
861 &finding[finding.len() - 8..],
862 &hot[hot.len() - 8..],
863 &blast[blast.len() - 8..],
864 &importance[importance.len() - 8..],
865 ];
866 for (index, suffix) in suffixes.iter().enumerate() {
867 assert!(
868 suffixes.iter().skip(index + 1).all(|other| other != suffix),
869 "ID suffix collision across finding kinds"
870 );
871 }
872 }
873
874 #[test]
875 fn evidence_omits_untracked_reason_when_none() {
876 let evidence = Evidence {
877 static_status: "unused".to_owned(),
878 test_coverage: "covered".to_owned(),
879 v8_tracking: "tracked".to_owned(),
880 untracked_reason: None,
881 observation_days: 30,
882 deployments_observed: 14,
883 };
884 let json = serde_json::to_string(&evidence).unwrap();
885 assert!(
886 !json.contains("untracked_reason"),
887 "expected untracked_reason omitted, got {json}"
888 );
889 }
890}