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}