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 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}