Skip to main content

fallow_output/
health_runtime_coverage.rs

1use std::fmt;
2use std::path::PathBuf;
3
4use fallow_types::serde_path;
5
6/// Runtime coverage JSON contract version. This is scoped to the
7/// `runtime_coverage` block and is independent of the top-level fallow
8/// JSON `schema_version`.
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
10#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
11pub enum RuntimeCoverageSchemaVersion {
12    /// First release of the runtime coverage block contract.
13    #[default]
14    #[serde(rename = "1")]
15    V1,
16}
17
18/// Top-level verdict for the whole runtime-coverage report. Mirrors
19/// `fallow_cov_protocol::ReportVerdict`. The verdict is the SINGLE most
20/// actionable finding; for the full set of findings see
21/// [`RuntimeCoverageReport::signals`]. The verdict promotes `hot-path-touched`
22/// above `cold-code-detected` in PR-review context (when the CLI was
23/// given a change-scope: `--diff-file` or `--changed-since`) because the
24/// touched-hot-path is event-tied to the current diff and reviewers need
25/// it to be the top-line signal. In standalone analysis (no change
26/// scope), `cold-code-detected` remains primary.
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29#[serde(rename_all = "kebab-case")]
30pub enum RuntimeCoverageReportVerdict {
31    Clean,
32    HotPathTouched,
33    ColdCodeDetected,
34    LicenseExpiredGrace,
35    #[default]
36    Unknown,
37}
38
39/// Discrete signal captured during runtime-coverage post-processing.
40/// `verdict` collapses to one summary value; `signals` enumerates ALL
41/// findings the report carries so JSON consumers, CI dashboards, and
42/// agents can reason about them independently of the headline. Order is
43/// stable: severity-descending so the first entry mirrors a sensible
44/// non-PR-context verdict.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
46#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
47#[serde(rename_all = "kebab-case")]
48pub enum RuntimeCoverageSignal {
49    LicenseExpiredGrace,
50    ColdCodeDetected,
51    HotPathTouched,
52}
53
54impl RuntimeCoverageSignal {
55    #[must_use]
56    pub const fn as_str(self) -> &'static str {
57        match self {
58            Self::LicenseExpiredGrace => "license-expired-grace",
59            Self::ColdCodeDetected => "cold-code-detected",
60            Self::HotPathTouched => "hot-path-touched",
61        }
62    }
63}
64
65impl fmt::Display for RuntimeCoverageSignal {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        f.write_str(self.as_str())
68    }
69}
70
71impl RuntimeCoverageReportVerdict {
72    #[must_use]
73    pub const fn as_str(self) -> &'static str {
74        match self {
75            Self::Clean => "clean",
76            Self::HotPathTouched => "hot-path-touched",
77            Self::ColdCodeDetected => "cold-code-detected",
78            Self::LicenseExpiredGrace => "license-expired-grace",
79            Self::Unknown => "unknown",
80        }
81    }
82}
83
84impl fmt::Display for RuntimeCoverageReportVerdict {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.write_str(self.as_str())
87    }
88}
89
90/// Protocol-level per-function runtime coverage verdict derived from the
91/// decision table in fallow-cov-protocol. The CLI's `runtime_coverage.findings`
92/// array omits `active` entries even though the underlying enum still includes
93/// it.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
95#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
96#[serde(rename_all = "snake_case")]
97pub enum RuntimeCoverageVerdict {
98    SafeToDelete,
99    ReviewRequired,
100    CoverageUnavailable,
101    LowTraffic,
102    Active,
103    Unknown,
104}
105
106impl RuntimeCoverageVerdict {
107    #[must_use]
108    pub const fn as_str(self) -> &'static str {
109        match self {
110            Self::SafeToDelete => "safe_to_delete",
111            Self::ReviewRequired => "review_required",
112            Self::CoverageUnavailable => "coverage_unavailable",
113            Self::LowTraffic => "low_traffic",
114            Self::Active => "active",
115            Self::Unknown => "unknown",
116        }
117    }
118
119    #[must_use]
120    pub const fn human_label(self) -> &'static str {
121        match self {
122            Self::SafeToDelete => "safe to delete",
123            Self::ReviewRequired => "review required",
124            Self::CoverageUnavailable => "coverage unavailable",
125            Self::LowTraffic => "low traffic",
126            Self::Active => "active",
127            Self::Unknown => "unknown",
128        }
129    }
130}
131
132impl fmt::Display for RuntimeCoverageVerdict {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        f.write_str(self.as_str())
135    }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
139#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
140#[serde(rename_all = "snake_case")]
141/// Confidence level for a runtime coverage finding.
142pub enum RuntimeCoverageConfidence {
143    VeryHigh,
144    High,
145    Medium,
146    Low,
147    None,
148    Unknown,
149}
150
151impl RuntimeCoverageConfidence {
152    #[must_use]
153    pub const fn as_str(self) -> &'static str {
154        match self {
155            Self::VeryHigh => "very_high",
156            Self::High => "high",
157            Self::Medium => "medium",
158            Self::Low => "low",
159            Self::None => "none",
160            Self::Unknown => "unknown",
161        }
162    }
163}
164
165impl fmt::Display for RuntimeCoverageConfidence {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        f.write_str(self.as_str())
168    }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
172#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
173#[serde(rename_all = "kebab-case")]
174/// License or trial watermark applied to runtime coverage output.
175pub enum RuntimeCoverageWatermark {
176    TrialExpired,
177    LicenseExpiredGrace,
178    Unknown,
179}
180
181impl RuntimeCoverageWatermark {
182    #[must_use]
183    pub const fn as_str(self) -> &'static str {
184        match self {
185            Self::TrialExpired => "trial-expired",
186            Self::LicenseExpiredGrace => "license-expired-grace",
187            Self::Unknown => "unknown",
188        }
189    }
190}
191
192impl fmt::Display for RuntimeCoverageWatermark {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        f.write_str(self.as_str())
195    }
196}
197
198/// Runtime coverage source used to produce the summary.
199#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
200#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
201#[serde(rename_all = "snake_case")]
202pub enum RuntimeCoverageDataSource {
203    #[default]
204    Local,
205    Cloud,
206}
207
208impl RuntimeCoverageDataSource {
209    #[must_use]
210    pub const fn as_str(self) -> &'static str {
211        match self {
212            Self::Local => "local",
213            Self::Cloud => "cloud",
214        }
215    }
216}
217
218impl fmt::Display for RuntimeCoverageDataSource {
219    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220        f.write_str(self.as_str())
221    }
222}
223
224/// Summary block mirroring `fallow_cov_protocol::Summary` (0.3 shape).
225#[derive(Debug, Clone, Default, serde::Serialize)]
226#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
227pub struct RuntimeCoverageSummary {
228    /// Runtime evidence source used for this report. Local mode reads a
229    /// supplied runtime coverage artifact; cloud mode pulls the latest
230    /// fallow.cloud runtime context after explicit opt-in.
231    pub data_source: RuntimeCoverageDataSource,
232    /// Timestamp of the newest runtime payload included in the report. Null for
233    /// local single-capture artifacts that do not carry cloud receipt metadata.
234    pub last_received_at: Option<String>,
235    /// Number of functions the sidecar could observe in the V8 or Istanbul
236    /// dump.
237    pub functions_tracked: usize,
238    /// Tracked functions that received at least one invocation.
239    pub functions_hit: usize,
240    /// Tracked functions that were never invoked.
241    pub functions_unhit: usize,
242    /// Functions the sidecar could not track (lazy-parsed, worker thread,
243    /// dynamic code, unresolved source map).
244    pub functions_untracked: usize,
245    /// Ratio of functions_hit / functions_tracked, expressed as a percent.
246    pub coverage_percent: f64,
247    /// Total number of observed invocations across all functions. Denominator
248    /// for low-traffic classification.
249    pub trace_count: u64,
250    /// Days of observation covered by the supplied dump (Phase 2 local analysis
251    /// emits 0, set by the beacon/cloud in Phase 3+).
252    pub period_days: u32,
253    /// Distinct deployments contributing to the supplied dump (Phase 2 local
254    /// analysis emits 0).
255    pub deployments_seen: u32,
256    /// Capture-quality telemetry. `None` for protocol-0.2 sidecars; protocol-0.3+
257    /// sidecars always populate it. Fuels the human-output short-window warning
258    /// and the quantified trial CTA, and is passed through to JSON consumers so
259    /// agent pipelines can surface the same signal.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub capture_quality: Option<RuntimeCoverageCaptureQuality>,
262}
263
264/// Quality-of-capture signals emitted by the sidecar so the CLI can explain
265/// short-window captures honestly instead of letting users blame the tool.
266#[derive(Debug, Clone, PartialEq, serde::Serialize)]
267#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
268pub struct RuntimeCoverageCaptureQuality {
269    /// Total observation window in seconds. Finer-grained than period_days
270    /// (which rounds up to whole days).
271    pub window_seconds: u64,
272    /// Number of distinct production instances that contributed to the dump.
273    pub instances_observed: u32,
274    /// True when the untracked-function ratio exceeds the sidecar's lazy-parse
275    /// threshold (30%). Signals that many untracked functions likely reflect
276    /// lazy-parsed code rather than unreachable code.
277    pub lazy_parse_warning: bool,
278    /// functions_untracked / functions_tracked as a percentage, rounded to 2
279    /// decimal places.
280    pub untracked_ratio_percent: f64,
281}
282
283/// Supporting evidence for a finding (mirrors `fallow_cov_protocol::Evidence`).
284#[derive(Debug, Clone, serde::Serialize)]
285#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
286pub struct RuntimeCoverageEvidence {
287    /// `used` when the function is reachable in the module graph, `unused`
288    /// otherwise.
289    pub static_status: String,
290    /// `covered` when the project's test suite hits this function,
291    /// `not_covered` otherwise.
292    pub test_coverage: String,
293    /// `tracked` when V8 observed the function, `untracked` otherwise.
294    pub v8_tracking: String,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    /// Reason the function is untracked. Populated only when v8_tracking is
297    /// `untracked`. Values: `lazy_parsed`, `worker_thread`, `dynamic_eval`,
298    /// `unknown`.
299    pub untracked_reason: Option<String>,
300    /// Days of observation backing this finding.
301    pub observation_days: u32,
302    /// Distinct deployments backing this finding.
303    pub deployments_observed: u32,
304}
305
306#[derive(Debug, Clone, serde::Serialize)]
307#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
308/// Suggested follow-up action for a runtime coverage finding.
309pub struct RuntimeCoverageAction {
310    /// Action identifier, normalized to `type` in JSON output. Known values
311    /// emitted by `fallow coverage analyze`: `delete-cold-code`
312    /// (verdict=safe_to_delete), `review-runtime` (verdict=review_required).
313    /// The sidecar may emit additional protocol-specific identifiers;
314    /// consumers should treat unknown values as forward-compat extensions.
315    #[serde(rename = "type")]
316    pub kind: String,
317    pub description: String,
318    /// Whether fallow can apply this action automatically.
319    pub auto_fixable: bool,
320}
321
322#[derive(Debug, Clone, serde::Serialize)]
323#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
324pub struct RuntimeCoverageMessage {
325    pub code: String,
326    /// Human-readable warning message.
327    pub message: String,
328}
329
330/// Discriminator inputs that PRODUCED a finding's verdict (fallow-rs/fallow-cloud#321),
331/// emitted alongside the verdict so an agent can reproduce it and see the
332/// minimum-observation confidence cap instead of re-deriving them from scratch.
333/// F4: these make the EXISTING Fallow-owned discriminators legible; they are not
334/// a new or external signal and gate nothing. Pairs with `evidence.static_status`
335/// (the static half of the discriminator set).
336#[derive(Debug, Clone, serde::Serialize)]
337#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
338pub struct RuntimeCoverageDiscriminators {
339    /// Three-state runtime tracking: `called` (invocations > 0), `never_called`
340    /// (V8 tracked it, invocations == 0), or `untracked` (V8 never saw it). The
341    /// ONLY signal that can issue a deletion verdict.
342    pub tracking_state: String,
343    /// `invocations / trace_count` for this function; `null` when untracked (no
344    /// invocation count). The per-function value behind the low-traffic split.
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    #[cfg_attr(feature = "schema", schemars(default))]
347    pub invocation_ratio: Option<f64>,
348    /// Active/low_traffic split ratio in effect (CLI default 0.001). A tracked
349    /// function whose `invocation_ratio` is below this reads `low_traffic`, else
350    /// `active`.
351    pub low_traffic_threshold: f64,
352    /// Total observed invocations across all functions (the `invocation_ratio`
353    /// denominator), echoed per finding so the verdict is self-contained.
354    pub trace_count: u64,
355    /// High-confidence verdict floor (CLI default 5000). When `trace_count` is
356    /// below it, confidence is capped regardless of the per-function signal.
357    pub min_observation_volume: u32,
358    /// `trace_count >= min_observation_volume`: whether the dump cleared the
359    /// confidence floor. `false` means this verdict's confidence is capped.
360    pub meets_observation_volume: bool,
361}
362
363#[derive(Debug, Clone, serde::Serialize)]
364#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
365pub struct RuntimeCoverageFinding {
366    /// Per-finding suppression key of the form `fallow:prod:<hash>` (first 8 hex
367    /// of SHA-256(file + function + line + 'prod')). Hashes the current line, so
368    /// it changes when the function moves. Use this to suppress one finding.
369    pub id: String,
370    /// Cross-surface join key of the form `fallow:fn:<hash>`
371    /// (`fallow_cov_protocol::function_identity_id`, hashes file + name +
372    /// start_line). The same function shares ONE value across findings, hot
373    /// paths, blast-radius, and importance entries (the per-finding `id` uses a
374    /// per-surface salt, so it differs by surface), and across V8, Istanbul,
375    /// and oxc producers (columns are excluded from the hash). Like `id`, it
376    /// changes when the function's file, name, or start line changes; it is a
377    /// cross-surface / cross-producer join key, not a line-move-immune one.
378    /// `null` when the producing surface (or an un-migrated cloud) supplied no
379    /// `FunctionIdentity`.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    #[cfg_attr(feature = "schema", schemars(default))]
382    pub stable_id: Option<String>,
383    /// Content digest of the function's full-span source slice
384    /// (`fallow_cov_protocol::source_hash_for`: first 8 bytes of SHA-256 as 16
385    /// lowercase hex). Unlike `stable_id`, this is stable across line moves: a
386    /// moved-but-unedited function keeps the same value, so baselines can
387    /// suppress it after a pure line shift. `null` when the producing surface
388    /// supplied no `source_hash`.
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    #[cfg_attr(feature = "schema", schemars(default))]
391    pub source_hash: Option<String>,
392    /// File path relative to the project root.
393    #[serde(serialize_with = "serde_path::serialize")]
394    pub path: PathBuf,
395    /// Static function name as reported in the merged coverage result.
396    pub function: String,
397    /// 1-indexed line number the function starts on.
398    pub line: u32,
399    pub verdict: RuntimeCoverageVerdict,
400    /// Raw V8 invocation count. `None` when the function was untracked
401    /// (lazy-parsed, worker thread, or dynamic code).
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub invocations: Option<u64>,
404    pub confidence: RuntimeCoverageConfidence,
405    pub evidence: RuntimeCoverageEvidence,
406    #[serde(default, skip_serializing_if = "Vec::is_empty")]
407    #[cfg_attr(feature = "schema", schemars(default))]
408    /// Suggested actions for this finding. Omitted when empty.
409    pub actions: Vec<RuntimeCoverageAction>,
410    /// The discriminator inputs that produced this verdict (#321), emitted so an
411    /// agent can reproduce it and see the confidence cap. `None` for findings
412    /// not built from the merge pipeline (e.g. baseline round-trips). Omitted
413    /// from JSON when absent.
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    #[cfg_attr(feature = "schema", schemars(default))]
416    pub discriminators: Option<RuntimeCoverageDiscriminators>,
417}
418
419#[derive(Debug, Clone, serde::Serialize)]
420#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
421pub struct RuntimeCoverageHotPath {
422    /// Stable content-hash ID of the form `fallow:hot:<hash>`.
423    pub id: String,
424    /// Cross-surface join key (`fallow:fn:<hash>`) for the hot function. Stable
425    /// across line moves; shared with the same function's findings / blast /
426    /// importance entries. `null` when no `FunctionIdentity` was supplied.
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    #[cfg_attr(feature = "schema", schemars(default))]
429    pub stable_id: Option<String>,
430    /// File path relative to the project root.
431    #[serde(serialize_with = "serde_path::serialize")]
432    pub path: PathBuf,
433    /// Function name for the hot path.
434    pub function: String,
435    /// 1-indexed line number the function starts on.
436    pub line: u32,
437    /// 1-indexed line the function ends on (inclusive). Mirrors
438    /// `fallow_cov_protocol::HotPath::end_line` (added in protocol 0.5).
439    /// Older 0.4-shape sidecars omit the field on the wire; serde defaults
440    /// to `0`, which the line-overlap filter MUST treat as a single-line
441    /// range (`line..=line`) rather than a span.
442    pub end_line: u32,
443    /// Observed invocation count for the hot path.
444    pub invocations: u64,
445    /// Percentile rank over this response's hot-path distribution. `100`
446    /// means the busiest, `0` means the quietest function that qualified.
447    pub percentile: u8,
448    #[serde(default, skip_serializing_if = "Vec::is_empty")]
449    #[cfg_attr(feature = "schema", schemars(default))]
450    /// Suggested actions for this hot path (e.g., review-on-change). Omitted
451    /// when empty.
452    pub actions: Vec<RuntimeCoverageAction>,
453}
454
455#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
456#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
457#[serde(rename_all = "snake_case")]
458/// Blast-radius risk band. The current thresholds are high at >=20 static
459/// callers or >=1,000,000 traffic-weighted caller reach; medium at >=5 callers
460/// or >=50,000 weighted reach; low otherwise.
461pub enum RuntimeCoverageRiskBand {
462    Low,
463    Medium,
464    High,
465}
466
467impl RuntimeCoverageRiskBand {
468    #[must_use]
469    pub const fn as_str(self) -> &'static str {
470        match self {
471            Self::Low => "low",
472            Self::Medium => "medium",
473            Self::High => "high",
474        }
475    }
476}
477
478impl fmt::Display for RuntimeCoverageRiskBand {
479    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
480        f.write_str(self.as_str())
481    }
482}
483
484#[derive(Debug, Clone, serde::Serialize)]
485#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
486pub struct RuntimeCoverageBlastRadiusEntry {
487    /// Stable content-hash ID of the form `fallow:blast:<hash>`.
488    pub id: String,
489    /// Cross-surface join key (`fallow:fn:<hash>`) for the function. Stable
490    /// across line moves; shared with the same function's findings / hot-path /
491    /// importance entries. `null` when no `FunctionIdentity` was supplied.
492    #[serde(default, skip_serializing_if = "Option::is_none")]
493    #[cfg_attr(feature = "schema", schemars(default))]
494    pub stable_id: Option<String>,
495    /// File path relative to the project root.
496    #[serde(serialize_with = "serde_path::serialize")]
497    pub file: PathBuf,
498    /// Function name for the blast-radius entry.
499    pub function: String,
500    /// 1-indexed line number the function starts on.
501    pub line: u32,
502    /// Static caller count from the module graph.
503    pub caller_count: u32,
504    /// Caller reach weighted by observed runtime traffic.
505    pub caller_count_weighted_by_traffic: u64,
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    /// Distinct deploy SHAs that touched the function in the observation
508    /// window. Cloud mode only; omitted in local mode.
509    pub deploys_touched: Option<u32>,
510    pub risk_band: RuntimeCoverageRiskBand,
511}
512
513#[derive(Debug, Clone, serde::Serialize)]
514#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
515pub struct RuntimeCoverageImportanceEntry {
516    /// Stable content-hash ID of the form `fallow:importance:<hash>`.
517    pub id: String,
518    /// Cross-surface join key (`fallow:fn:<hash>`) for the function. Stable
519    /// across line moves; shared with the same function's findings / hot-path /
520    /// blast-radius entries. `null` when no `FunctionIdentity` was supplied.
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    #[cfg_attr(feature = "schema", schemars(default))]
523    pub stable_id: Option<String>,
524    /// File path relative to the project root.
525    #[serde(serialize_with = "serde_path::serialize")]
526    pub file: PathBuf,
527    /// Function name for the importance entry.
528    pub function: String,
529    /// 1-indexed line number the function starts on.
530    pub line: u32,
531    /// Observed invocation count for this function.
532    pub invocations: u64,
533    /// Cyclomatic complexity from the static health pipeline.
534    pub cyclomatic: u32,
535    /// Number of CODEOWNERS owners matched for this file. Zero means no owner
536    /// was resolved.
537    pub owner_count: u32,
538    /// 0-100 explainable score from log-scaled traffic, capped complexity
539    /// weight, and ownership-risk weight.
540    pub importance_score: f64,
541    /// Templated one-sentence explanation for the score.
542    pub reason: String,
543}
544
545#[derive(Debug, Clone, Default, serde::Serialize)]
546#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
547/// Runtime coverage findings merged into the health report or emitted by
548/// `fallow coverage analyze`. Present in health output when --runtime-coverage
549/// is used. Shape mirrors the runtime coverage JSON contract; cloud mode
550/// fetches runtime facts explicitly and merges them locally with AST/static
551/// analysis.
552pub struct RuntimeCoverageReport {
553    /// Runtime coverage JSON contract version. This is scoped to the
554    /// `runtime_coverage` block and is independent of the top-level fallow
555    /// JSON `schema_version`.
556    pub schema_version: RuntimeCoverageSchemaVersion,
557    /// Single most actionable runtime-coverage signal under the current
558    /// context. In PR-review context (CLI saw `--diff-file` or
559    /// `--changed-since`) the verdict is `hot-path-touched` whenever a hot
560    /// function was touched, regardless of cold-code findings; in standalone
561    /// analysis `cold-code-detected` remains primary. For the full set of
562    /// findings the report carries, see `signals`.
563    pub verdict: RuntimeCoverageReportVerdict,
564    /// All signals captured by post-processing. Independent of `verdict`,
565    /// which is the single most actionable signal under the current
566    /// context. Empty when the report is `Clean` and not under license
567    /// grace. Order is stable severity-descending.
568    #[serde(default, skip_serializing_if = "Vec::is_empty")]
569    #[cfg_attr(feature = "schema", schemars(default))]
570    pub signals: Vec<RuntimeCoverageSignal>,
571    /// Aggregate tracked / hit / unhit / untracked counts for the analyzed
572    /// runtime coverage input.
573    pub summary: RuntimeCoverageSummary,
574    #[serde(default, skip_serializing_if = "Vec::is_empty")]
575    #[cfg_attr(feature = "schema", schemars(default))]
576    /// Surfaced runtime coverage findings (`safe_to_delete`, `review_required`,
577    /// `low_traffic`, `coverage_unavailable`). Omitted when empty. `active`
578    /// functions stay out of this list so the CLI output remains actionable.
579    pub findings: Vec<RuntimeCoverageFinding>,
580    #[serde(default, skip_serializing_if = "Vec::is_empty")]
581    #[cfg_attr(feature = "schema", schemars(default))]
582    /// Top runtime functions by invocation count. Omitted when empty.
583    pub hot_paths: Vec<RuntimeCoverageHotPath>,
584    /// First-class blast-radius entries for runtime-observed functions. Present
585    /// whenever runtime coverage analysis runs.
586    pub blast_radius: Vec<RuntimeCoverageBlastRadiusEntry>,
587    /// First-class production-importance entries for runtime-observed
588    /// functions. Present whenever runtime coverage analysis runs.
589    pub importance: Vec<RuntimeCoverageImportanceEntry>,
590    #[serde(default, skip_serializing_if = "Option::is_none")]
591    /// License/trial watermark for grace-mode output. Omitted when not
592    /// applicable.
593    pub watermark: Option<RuntimeCoverageWatermark>,
594    #[serde(default, skip_serializing_if = "Vec::is_empty")]
595    #[cfg_attr(feature = "schema", schemars(default))]
596    /// Non-fatal merge or coverage diagnostics. Omitted when empty.
597    pub warnings: Vec<RuntimeCoverageMessage>,
598    /// Whether an autonomous agent may act on this report (fallow-rs/fallow-cloud#316,
599    /// mirrors the cloud runtime-context contract). `false` when the capture
600    /// carries no usable runtime evidence (no tracked functions); then
601    /// `actionability_verdict` is `insufficient_evidence` and
602    /// `actionability_reason` explains. F4: a non-action floor, never a gate on a
603    /// positive verdict.
604    pub actionable: bool,
605    /// Why the report is non-actionable; `null` when `actionable` is true.
606    #[serde(default, skip_serializing_if = "Option::is_none")]
607    #[cfg_attr(feature = "schema", schemars(default))]
608    pub actionability_reason: Option<String>,
609    /// First-class non-action verdict (`insufficient_evidence`) when not
610    /// actionable; `null` otherwise. Mirrors the cloud runtime-context `verdict`;
611    /// named distinctly from the report-context `verdict` above to avoid a
612    /// collision.
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    #[cfg_attr(feature = "schema", schemars(default))]
615    pub actionability_verdict: Option<String>,
616    /// Provenance an agent reads to self-attenuate confidence (fallow-rs/fallow-cloud#319,
617    /// mirrors the cloud runtime-context `provenance`). F4: context only.
618    pub provenance: RuntimeCoverageProvenance,
619}
620
621/// Provenance of a runtime-coverage report (fallow-rs/fallow-cloud#319), mirroring
622/// the cloud runtime-context `provenance` block so the local-capture and cloud
623/// surfaces present one portable shape. F4: provenance is context only; it never
624/// gates a verdict or confidence.
625#[derive(Debug, Clone, serde::Serialize)]
626#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
627pub struct RuntimeCoverageProvenance {
628    /// `local` for a local capture, `cloud` for the cloud read path.
629    pub data_source: RuntimeCoverageDataSource,
630    /// `true` / `false` / `unknown`. Always `unknown` for a local capture: the
631    /// local path has no deployment-origin signal (the cloud may resolve it).
632    pub is_production: String,
633    /// Age in whole days of the most recent evidence; `0` for a fresh local
634    /// capture, `null` when no runtime data is present.
635    #[serde(default, skip_serializing_if = "Option::is_none")]
636    #[cfg_attr(feature = "schema", schemars(default))]
637    pub freshness_days: Option<u32>,
638    /// `functions_untracked / (functions_tracked + functions_untracked)`, in
639    /// `[0, 1]`. High ratios mark a thin / partial capture.
640    pub untracked_ratio: f64,
641    /// Fraction of resolution-attempted functions whose position could not be
642    /// mapped to source, in `[0, 1]`. `0` for a local capture (positions resolve
643    /// natively or via the sidecar).
644    pub unresolved_ratio: f64,
645    /// Whether `freshness_days` exceeds `stale_after_days`.
646    pub stale: bool,
647    /// The documented staleness cutoff (days), echoed so the rule travels in-band.
648    pub stale_after_days: u32,
649}
650
651impl Default for RuntimeCoverageProvenance {
652    fn default() -> Self {
653        Self {
654            data_source: RuntimeCoverageDataSource::Local,
655            is_production: "unknown".to_owned(),
656            freshness_days: Some(0),
657            untracked_ratio: 0.0,
658            unresolved_ratio: 0.0,
659            stale: false,
660            stale_after_days: RUNTIME_STALE_AFTER_DAYS,
661        }
662    }
663}
664
665/// Staleness cutoff in days, mirrored from the cloud runtime-context
666/// (`RUNTIME_STALE_AFTER_DAYS`) so the local and cloud contracts agree.
667pub const RUNTIME_STALE_AFTER_DAYS: u32 = 14;
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    #[test]
674    fn report_verdict_display_matches_kebab_case_serde() {
675        assert_eq!(RuntimeCoverageReportVerdict::Clean.to_string(), "clean");
676        assert_eq!(
677            RuntimeCoverageReportVerdict::HotPathTouched.to_string(),
678            "hot-path-touched",
679        );
680        assert_eq!(
681            RuntimeCoverageReportVerdict::ColdCodeDetected.to_string(),
682            "cold-code-detected",
683        );
684        assert_eq!(
685            RuntimeCoverageReportVerdict::LicenseExpiredGrace.to_string(),
686            "license-expired-grace",
687        );
688        assert_eq!(RuntimeCoverageReportVerdict::Unknown.to_string(), "unknown",);
689    }
690
691    #[test]
692    fn verdict_display_matches_snake_case_serde() {
693        assert_eq!(
694            RuntimeCoverageVerdict::SafeToDelete.to_string(),
695            "safe_to_delete",
696        );
697        assert_eq!(
698            RuntimeCoverageVerdict::ReviewRequired.to_string(),
699            "review_required",
700        );
701        assert_eq!(
702            RuntimeCoverageVerdict::CoverageUnavailable.to_string(),
703            "coverage_unavailable",
704        );
705        assert_eq!(
706            RuntimeCoverageVerdict::LowTraffic.to_string(),
707            "low_traffic",
708        );
709        assert_eq!(RuntimeCoverageVerdict::Active.to_string(), "active");
710    }
711
712    #[test]
713    fn confidence_display_matches_snake_case_serde() {
714        assert_eq!(RuntimeCoverageConfidence::VeryHigh.to_string(), "very_high",);
715        assert_eq!(RuntimeCoverageConfidence::High.to_string(), "high");
716        assert_eq!(RuntimeCoverageConfidence::Medium.to_string(), "medium");
717        assert_eq!(RuntimeCoverageConfidence::Low.to_string(), "low");
718        assert_eq!(RuntimeCoverageConfidence::None.to_string(), "none");
719        assert_eq!(RuntimeCoverageConfidence::Unknown.to_string(), "unknown");
720    }
721
722    #[test]
723    fn watermark_display_matches_kebab_case_serde() {
724        assert_eq!(
725            RuntimeCoverageWatermark::TrialExpired.to_string(),
726            "trial-expired",
727        );
728        assert_eq!(
729            RuntimeCoverageWatermark::LicenseExpiredGrace.to_string(),
730            "license-expired-grace",
731        );
732    }
733
734    #[test]
735    fn action_serializes_kind_as_type() {
736        let action = RuntimeCoverageAction {
737            kind: "review-deletion".to_owned(),
738            description: "Remove the function.".to_owned(),
739            auto_fixable: false,
740        };
741        let value = serde_json::to_value(&action).expect("action should serialize");
742        assert_eq!(value["type"], "review-deletion");
743        assert!(
744            value.get("kind").is_none(),
745            "kind should be renamed to type"
746        );
747    }
748}