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