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}