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, serde::Deserialize)]
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, serde::Deserialize)]
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, serde::Deserialize)]
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, serde::Deserialize)]
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, serde::Deserialize)]
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, serde::Deserialize)]
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, serde::Deserialize)]
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, serde::Deserialize)]
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, serde::Deserialize)]
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, serde::Deserialize)]
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, serde::Deserialize)]
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,
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, serde::Deserialize)]
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, serde::Deserialize)]
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, serde::Deserialize)]
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    /// Per-call cost inputs and the speed-work score for this hot function.
504    /// Omitted when the hot path has no `stable_id` or no static counterpart
505    /// in this checkout.
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    #[cfg_attr(feature = "schema", schemars(default))]
508    pub optimization_target: Option<RuntimeCoverageOptimizationTarget>,
509}
510
511/// Speed-work inputs for one hot function: how often it runs and how much
512/// work each call does. `importance` ranks the risk of a change; this block
513/// ranks where speed work gives the largest gain.
514#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
515#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
516pub struct RuntimeCoverageOptimizationTarget {
517    /// `invocations` multiplied by the per-call cost that `cost_basis` names.
518    /// Uncapped integer. Compare it only between hot paths with the same
519    /// `cost_basis`: sort by `cost_basis` first, then by `cost_score`
520    /// descending. On the `cognitive` basis the per-call cost is at least 1.
521    pub cost_score: u64,
522    /// Which per-call cost the score uses.
523    pub cost_basis: RuntimeCoverageCostBasis,
524    /// Static cognitive complexity of the function. A static proxy for the
525    /// work per call, not a measurement.
526    pub cognitive: u16,
527    /// Static cyclomatic complexity of the function.
528    pub cyclomatic: u16,
529    /// Number of lines in the function body.
530    pub line_count: u32,
531    /// Peak executions of one block inside the function per call, from V8
532    /// block coverage. `1.0` means no block ran more than once per call. A
533    /// loop body that runs 3 times per call gives `3.0`. Calls to other
534    /// functions do not change the value.
535    /// Omitted when the coverage input has no block counts for the function.
536    #[serde(default, skip_serializing_if = "Option::is_none")]
537    #[cfg_attr(feature = "schema", schemars(default))]
538    pub inner_iterations_per_call: Option<f64>,
539}
540
541/// The per-call cost that `optimization_target.cost_score` multiplies with
542/// `invocations`.
543#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
544#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
545#[serde(rename_all = "snake_case")]
546pub enum RuntimeCoverageCostBasis {
547    /// Measured: peak block executions per call from V8 block coverage.
548    InnerIterations,
549    /// Static proxy: cognitive complexity (minimum 1), used when the function
550    /// has no usable block counts.
551    Cognitive,
552}
553
554impl RuntimeCoverageCostBasis {
555    /// Snake-case wire value of the cost basis.
556    #[must_use]
557    pub const fn as_str(self) -> &'static str {
558        match self {
559            Self::InnerIterations => "inner_iterations",
560            Self::Cognitive => "cognitive",
561        }
562    }
563}
564
565#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
566#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
567#[serde(rename_all = "snake_case")]
568/// Blast-radius risk band. The current thresholds are high at >=20 static
569/// callers or >=1,000,000 traffic-weighted caller reach; medium at >=5 callers
570/// or >=50,000 weighted reach; low otherwise.
571pub enum RuntimeCoverageRiskBand {
572    /// Small caller footprint.
573    Low,
574    /// Moderate caller footprint.
575    Medium,
576    /// Wide caller footprint; changes ripple far.
577    High,
578}
579
580impl RuntimeCoverageRiskBand {
581    /// Snake-case wire value of the risk band.
582    #[must_use]
583    pub const fn as_str(self) -> &'static str {
584        match self {
585            Self::Low => "low",
586            Self::Medium => "medium",
587            Self::High => "high",
588        }
589    }
590}
591
592impl fmt::Display for RuntimeCoverageRiskBand {
593    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
594        f.write_str(self.as_str())
595    }
596}
597
598/// One blast-radius entry in `runtime_coverage.blast_radius`: how far a
599/// change to the function would ripple.
600#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
601#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
602pub struct RuntimeCoverageBlastRadiusEntry {
603    /// Stable content-hash ID of the form `fallow:blast:<hash>`.
604    pub id: String,
605    /// Cross-surface join key (`fallow:fn:<hash>`) for the function. Stable
606    /// across line moves; shared with the same function's findings / hot-path /
607    /// importance entries. `null` when no `FunctionIdentity` was supplied.
608    #[serde(default, skip_serializing_if = "Option::is_none")]
609    #[cfg_attr(feature = "schema", schemars(default))]
610    pub stable_id: Option<String>,
611    /// File path relative to the project root.
612    #[serde(serialize_with = "serde_path::serialize")]
613    pub file: PathBuf,
614    /// Function name for the blast-radius entry.
615    pub function: String,
616    /// 1-indexed line number the function starts on.
617    pub line: u32,
618    /// Static caller count from the module graph.
619    pub caller_count: u32,
620    /// Caller reach weighted by observed runtime traffic.
621    pub caller_count_weighted_by_traffic: u64,
622    #[serde(default, skip_serializing_if = "Option::is_none")]
623    /// Distinct deploy SHAs that touched the function in the observation
624    /// window. Cloud mode only; omitted in local mode.
625    pub deploys_touched: Option<u32>,
626    /// Risk band derived from caller counts and traffic-weighted reach.
627    pub risk_band: RuntimeCoverageRiskBand,
628}
629
630/// One production-importance entry in `runtime_coverage.importance`, scoring
631/// how much a function matters in production.
632#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
633#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
634pub struct RuntimeCoverageImportanceEntry {
635    /// Stable content-hash ID of the form `fallow:importance:<hash>`.
636    pub id: String,
637    /// Cross-surface join key (`fallow:fn:<hash>`) for the function. Stable
638    /// across line moves; shared with the same function's findings / hot-path /
639    /// blast-radius entries. `null` when no `FunctionIdentity` was supplied.
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    #[cfg_attr(feature = "schema", schemars(default))]
642    pub stable_id: Option<String>,
643    /// File path relative to the project root.
644    #[serde(serialize_with = "serde_path::serialize")]
645    pub file: PathBuf,
646    /// Function name for the importance entry.
647    pub function: String,
648    /// 1-indexed line number the function starts on.
649    pub line: u32,
650    /// Observed invocation count for this function.
651    pub invocations: u64,
652    /// Cyclomatic complexity from the static health pipeline.
653    pub cyclomatic: u32,
654    /// Number of CODEOWNERS owners matched for this file. Zero means no owner
655    /// was resolved.
656    pub owner_count: u32,
657    /// 0-100 explainable score from log-scaled traffic, capped complexity
658    /// weight, and ownership-risk weight.
659    pub importance_score: f64,
660    /// Templated one-sentence explanation for the score.
661    pub reason: String,
662}
663
664#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
665#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
666/// Runtime coverage findings merged into the health report or emitted by
667/// `fallow coverage analyze`. Present in health output when --runtime-coverage
668/// is used. Shape mirrors the runtime coverage JSON contract; cloud mode
669/// fetches runtime facts explicitly and merges them locally with AST/static
670/// analysis.
671pub struct RuntimeCoverageReport {
672    /// Runtime coverage JSON contract version. This is scoped to the
673    /// `runtime_coverage` block and is independent of the top-level fallow
674    /// JSON `schema_version`.
675    pub schema_version: RuntimeCoverageSchemaVersion,
676    /// Single most actionable runtime-coverage signal under the current
677    /// context. In PR-review context (CLI saw `--diff-file` or
678    /// `--changed-since`) the verdict is `hot-path-touched` whenever a hot
679    /// function was touched, regardless of cold-code findings; in standalone
680    /// analysis `cold-code-detected` remains primary. For the full set of
681    /// findings the report carries, see `signals`.
682    pub verdict: RuntimeCoverageReportVerdict,
683    /// All signals captured by post-processing. Independent of `verdict`,
684    /// which is the single most actionable signal under the current
685    /// context. Empty when the report is `Clean` and not under license
686    /// grace. Order is stable severity-descending.
687    #[serde(default, skip_serializing_if = "Vec::is_empty")]
688    #[cfg_attr(feature = "schema", schemars(default))]
689    pub signals: Vec<RuntimeCoverageSignal>,
690    /// Aggregate tracked / hit / unhit / untracked counts for the analyzed
691    /// runtime coverage input.
692    pub summary: RuntimeCoverageSummary,
693    #[serde(default, skip_serializing_if = "Vec::is_empty")]
694    #[cfg_attr(feature = "schema", schemars(default))]
695    /// Surfaced runtime coverage findings (`safe_to_delete`, `review_required`,
696    /// `low_traffic`, `coverage_unavailable`). Omitted when empty. `active`
697    /// functions stay out of this list so the CLI output remains actionable.
698    pub findings: Vec<RuntimeCoverageFinding>,
699    #[serde(default, skip_serializing_if = "Vec::is_empty")]
700    #[cfg_attr(feature = "schema", schemars(default))]
701    /// Top runtime functions by invocation count. Omitted when empty.
702    pub hot_paths: Vec<RuntimeCoverageHotPath>,
703    /// First-class blast-radius entries for runtime-observed functions. Present
704    /// whenever runtime coverage analysis runs.
705    pub blast_radius: Vec<RuntimeCoverageBlastRadiusEntry>,
706    /// First-class production-importance entries for runtime-observed
707    /// functions. Present whenever runtime coverage analysis runs.
708    pub importance: Vec<RuntimeCoverageImportanceEntry>,
709    #[serde(default, skip_serializing_if = "Option::is_none")]
710    /// License/trial watermark for grace-mode output. Omitted when not
711    /// applicable.
712    pub watermark: Option<RuntimeCoverageWatermark>,
713    #[serde(default, skip_serializing_if = "Vec::is_empty")]
714    #[cfg_attr(feature = "schema", schemars(default))]
715    /// Non-fatal merge or coverage diagnostics. Omitted when empty.
716    pub warnings: Vec<RuntimeCoverageMessage>,
717    /// Whether an autonomous agent may act on this report (mirrors
718    /// the cloud runtime-context contract). `false` when the capture
719    /// carries no usable runtime evidence (no tracked functions); then
720    /// `actionability_verdict` is `insufficient_evidence` and
721    /// `actionability_reason` explains. F4: a non-action floor, never a gate on a
722    /// positive verdict.
723    pub actionable: bool,
724    /// Why the report is non-actionable; `null` when `actionable` is true.
725    #[serde(default, skip_serializing_if = "Option::is_none")]
726    #[cfg_attr(feature = "schema", schemars(default))]
727    pub actionability_reason: Option<String>,
728    /// First-class non-action verdict (`insufficient_evidence`) when not
729    /// actionable; `null` otherwise. Mirrors the cloud runtime-context `verdict`;
730    /// named distinctly from the report-context `verdict` above to avoid a
731    /// collision.
732    #[serde(default, skip_serializing_if = "Option::is_none")]
733    #[cfg_attr(feature = "schema", schemars(default))]
734    pub actionability_verdict: Option<String>,
735    /// Provenance an agent reads to self-attenuate confidence (mirrors
736    /// the cloud runtime-context `provenance`). F4: context only.
737    pub provenance: RuntimeCoverageProvenance,
738}
739
740/// Warning code for hot paths that have no `optimization_target`: the hot
741/// path has no `stable_id`, or no static function matches its `stable_id`.
742pub const OPTIMIZATION_TARGET_UNMATCHED_WARNING: &str = "optimization_target_unmatched";
743
744impl RuntimeCoverageReport {
745    /// Set the `optimization_target_unmatched` warning from the hot paths that
746    /// this report shows. Call it after the last filter that removes hot
747    /// paths, so that the count agrees with the output.
748    pub fn set_optimization_target_warning(&mut self) {
749        self.warnings
750            .retain(|warning| warning.code != OPTIMIZATION_TARGET_UNMATCHED_WARNING);
751        let unmatched = self
752            .hot_paths
753            .iter()
754            .filter(|hot_path| hot_path.optimization_target.is_none())
755            .count();
756        if unmatched == 0 {
757            return;
758        }
759        self.warnings.push(RuntimeCoverageMessage {
760            code: OPTIMIZATION_TARGET_UNMATCHED_WARNING.to_owned(),
761            message: format!(
762                "Optimization targets are missing for {unmatched} of {total} hot paths. Each of these hot paths has no stable_id, or no static function in this checkout matches its stable_id.",
763                total = self.hot_paths.len(),
764            ),
765        });
766    }
767
768    /// Count the unmatched hot paths again after a filter removed hot paths.
769    /// Does nothing when the report has no `optimization_target_unmatched`
770    /// warning, because only the local merge computes optimization targets.
771    pub fn refresh_optimization_target_warning(&mut self) {
772        if self
773            .warnings
774            .iter()
775            .any(|warning| warning.code == OPTIMIZATION_TARGET_UNMATCHED_WARNING)
776        {
777            self.set_optimization_target_warning();
778        }
779    }
780}
781
782/// Provenance of a runtime-coverage report, mirroring
783/// the cloud runtime-context `provenance` block so the local-capture and cloud
784/// surfaces present one portable shape. F4: provenance is context only; it never
785/// gates a verdict or confidence.
786#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
787#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
788pub struct RuntimeCoverageProvenance {
789    /// `local` for a local capture, `cloud` for the cloud read path.
790    pub data_source: RuntimeCoverageDataSource,
791    /// `true` / `false` / `unknown`. Always `unknown` for a local capture: the
792    /// local path has no deployment-origin signal (the cloud may resolve it).
793    pub is_production: String,
794    /// Age in whole days of the most recent evidence; `0` for a fresh local
795    /// capture, `null` when no runtime data is present.
796    #[serde(default, skip_serializing_if = "Option::is_none")]
797    #[cfg_attr(feature = "schema", schemars(default))]
798    pub freshness_days: Option<u32>,
799    /// `functions_untracked / (functions_tracked + functions_untracked)`, in
800    /// `[0, 1]`. High ratios mark a thin / partial capture.
801    pub untracked_ratio: f64,
802    /// Fraction of resolution-attempted functions whose position could not be
803    /// mapped to source, in `[0, 1]`. `0` for a local capture (positions resolve
804    /// natively or via the sidecar).
805    pub unresolved_ratio: f64,
806    /// Whether `freshness_days` exceeds `stale_after_days`.
807    pub stale: bool,
808    /// The documented staleness cutoff (days), echoed so the rule travels in-band.
809    pub stale_after_days: u32,
810}
811
812impl Default for RuntimeCoverageProvenance {
813    fn default() -> Self {
814        Self {
815            data_source: RuntimeCoverageDataSource::Local,
816            is_production: "unknown".to_owned(),
817            freshness_days: Some(0),
818            untracked_ratio: 0.0,
819            unresolved_ratio: 0.0,
820            stale: false,
821            stale_after_days: RUNTIME_STALE_AFTER_DAYS,
822        }
823    }
824}
825
826/// Staleness cutoff in days, mirrored from the cloud runtime-context
827/// (`RUNTIME_STALE_AFTER_DAYS`) so the local and cloud contracts agree.
828pub const RUNTIME_STALE_AFTER_DAYS: u32 = 14;
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833
834    #[test]
835    fn report_verdict_display_matches_kebab_case_serde() {
836        assert_eq!(RuntimeCoverageReportVerdict::Clean.to_string(), "clean");
837        assert_eq!(
838            RuntimeCoverageReportVerdict::HotPathTouched.to_string(),
839            "hot-path-touched",
840        );
841        assert_eq!(
842            RuntimeCoverageReportVerdict::ColdCodeDetected.to_string(),
843            "cold-code-detected",
844        );
845        assert_eq!(
846            RuntimeCoverageReportVerdict::LicenseExpiredGrace.to_string(),
847            "license-expired-grace",
848        );
849        assert_eq!(RuntimeCoverageReportVerdict::Unknown.to_string(), "unknown",);
850    }
851
852    #[test]
853    fn verdict_display_matches_snake_case_serde() {
854        assert_eq!(
855            RuntimeCoverageVerdict::SafeToDelete.to_string(),
856            "safe_to_delete",
857        );
858        assert_eq!(
859            RuntimeCoverageVerdict::ReviewRequired.to_string(),
860            "review_required",
861        );
862        assert_eq!(
863            RuntimeCoverageVerdict::CoverageUnavailable.to_string(),
864            "coverage_unavailable",
865        );
866        assert_eq!(
867            RuntimeCoverageVerdict::LowTraffic.to_string(),
868            "low_traffic",
869        );
870        assert_eq!(RuntimeCoverageVerdict::Active.to_string(), "active");
871    }
872
873    #[test]
874    fn confidence_display_matches_snake_case_serde() {
875        assert_eq!(RuntimeCoverageConfidence::VeryHigh.to_string(), "very_high",);
876        assert_eq!(RuntimeCoverageConfidence::High.to_string(), "high");
877        assert_eq!(RuntimeCoverageConfidence::Medium.to_string(), "medium");
878        assert_eq!(RuntimeCoverageConfidence::Low.to_string(), "low");
879        assert_eq!(RuntimeCoverageConfidence::None.to_string(), "none");
880        assert_eq!(RuntimeCoverageConfidence::Unknown.to_string(), "unknown");
881    }
882
883    #[test]
884    fn watermark_display_matches_kebab_case_serde() {
885        assert_eq!(
886            RuntimeCoverageWatermark::TrialExpired.to_string(),
887            "trial-expired",
888        );
889        assert_eq!(
890            RuntimeCoverageWatermark::LicenseExpiredGrace.to_string(),
891            "license-expired-grace",
892        );
893    }
894
895    #[test]
896    fn action_serializes_kind_as_type() {
897        let action = RuntimeCoverageAction {
898            kind: "review-deletion".to_owned(),
899            description: "Remove the function.".to_owned(),
900            auto_fixable: false,
901        };
902        let value = serde_json::to_value(&action).expect("action should serialize");
903        assert_eq!(value["type"], "review-deletion");
904        assert!(
905            value.get("kind").is_none(),
906            "kind should be renamed to type"
907        );
908    }
909
910    fn hot_path(matched: bool) -> RuntimeCoverageHotPath {
911        RuntimeCoverageHotPath {
912            id: "fallow:hot:test".to_owned(),
913            stable_id: None,
914            path: PathBuf::from("src/app.js"),
915            function: "resolve".to_owned(),
916            line: 1,
917            end_line: 7,
918            invocations: 600,
919            percentile: 100,
920            actions: Vec::new(),
921            optimization_target: matched.then_some(RuntimeCoverageOptimizationTarget {
922                cost_score: 600,
923                cost_basis: RuntimeCoverageCostBasis::Cognitive,
924                cognitive: 1,
925                cyclomatic: 1,
926                line_count: 7,
927                inner_iterations_per_call: None,
928            }),
929        }
930    }
931
932    fn unmatched_messages(report: &RuntimeCoverageReport) -> Vec<&str> {
933        report
934            .warnings
935            .iter()
936            .filter(|warning| warning.code == OPTIMIZATION_TARGET_UNMATCHED_WARNING)
937            .map(|warning| warning.message.as_str())
938            .collect()
939    }
940
941    #[test]
942    fn unmatched_warning_counts_the_hot_paths_left_after_filters() {
943        let mut report = RuntimeCoverageReport {
944            hot_paths: vec![hot_path(false), hot_path(true), hot_path(false)],
945            ..RuntimeCoverageReport::default()
946        };
947        report.set_optimization_target_warning();
948        assert_eq!(
949            unmatched_messages(&report),
950            [
951                "Optimization targets are missing for 2 of 3 hot paths. Each of these hot paths has no stable_id, or no static function in this checkout matches its stable_id."
952            ]
953        );
954
955        report.hot_paths.truncate(2);
956        report.refresh_optimization_target_warning();
957        assert_eq!(
958            unmatched_messages(&report),
959            [
960                "Optimization targets are missing for 1 of 2 hot paths. Each of these hot paths has no stable_id, or no static function in this checkout matches its stable_id."
961            ]
962        );
963
964        report.hot_paths.clear();
965        report.refresh_optimization_target_warning();
966        assert!(unmatched_messages(&report).is_empty());
967    }
968
969    #[test]
970    fn refresh_does_not_add_the_unmatched_warning() {
971        let mut report = RuntimeCoverageReport {
972            hot_paths: vec![hot_path(false)],
973            ..RuntimeCoverageReport::default()
974        };
975        report.refresh_optimization_target_warning();
976        assert!(report.warnings.is_empty());
977    }
978}