fallow_cov_protocol/lib.rs
1//! Versioned envelope types shared between the public `fallow` CLI and the
2//! closed-source `fallow-cov` production-coverage sidecar.
3//!
4//! The public CLI builds a [`Request`] from its static analysis output, spawns
5//! the sidecar, writes the request to stdin, and reads a [`Response`] from
6//! stdout. Both sides depend on this crate to guarantee contract alignment.
7//!
8//! # Versioning
9//!
10//! The top-level `protocol_version` field is a full semver string. Major
11//! bumps indicate breaking changes; consumers MUST reject mismatched majors.
12//! Minor bumps add optional fields; consumers MUST forward-accept unknown
13//! fields and SHOULD map unknown enum variants to [`Feature::Unknown`],
14//! [`ReportVerdict::Unknown`], or [`Verdict::Unknown`] rather than erroring.
15//!
16//! # 0.2 overview
17//!
18//! This is the first production-shaped contract. The top-level
19//! [`ReportVerdict`] (previously `Verdict`) is unchanged in meaning but was
20//! renamed to avoid colliding with per-finding [`Verdict`]. Each
21//! [`Finding`] and [`HotPath`] now carries a deterministic [`finding_id`] /
22//! [`hot_path_id`] hash, a full [`Evidence`] block, and — for findings — a
23//! per-function verdict and nullable invocation count. [`Confidence`]
24//! gained `VeryHigh` and `None` variants to match the decision table in
25//! `.internal/spec-production-coverage.md`.
26//!
27//! [`StaticFunction::static_used`] and [`StaticFunction::test_covered`] are
28//! intentionally required (no `#[serde(default)]`) — a silent default would
29//! hide every `safe_to_delete` finding, so 0.1-shape requests must fail
30//! deserialization instead of parsing into a wrong answer.
31//!
32//! # 0.5 changes
33//!
34//! - [`HotPath`] gained an `end_line` field so consumers can match a hot
35//! path against a PR diff at line granularity, not just file granularity.
36//! The field is `#[serde(default)]` for forward-tolerance with 0.4-shape
37//! sidecars; readers MUST treat a `0` value as a single-line range
38//! (`line..=line`).
39//! - `ReportVerdict::HotPathChangesNeeded` was renamed to
40//! [`ReportVerdict::HotPathTouched`]. The wire string changes from
41//! `hot-path-changes-needed` to `hot-path-touched`. The verdict reads as
42//! a state observation rather than an action item; it is informational.
43//!
44//! # 0.6 changes
45//!
46//! - New [`FunctionIdentity`] type and optional `identity` field threaded
47//! through [`StaticFunction`], [`Finding`], [`HotPath`], [`BlastRadiusEntry`],
48//! and [`ImportanceEntry`]. Becomes the canonical join key between the
49//! CLI's static function inventory, V8 / Istanbul runtime coverage, test
50//! coverage from `oxc-coverage-instrument`, source-map remapped findings,
51//! and `fallow-cloud` aggregation when present. Older 0.5-shape envelopes
52//! continue to deserialize with `identity: None`; consumers SHOULD prefer
53//! `identity.stable_id` as the join key when present and fall back to the
54//! legacy `file` + `function` + `line` triple otherwise.
55//! - New [`function_identity_id`] helper emitting `fallow:fn:<8 hex>` (this
56//! 0.6 recipe was reconciled to a NUL-delimited 16-hex digest in 0.8.0;
57//! see `# 0.8 changes`). The helper hashes only `file + name +
58//! start_line + "function"` (NOT
59//! columns) so producers with different positional fidelity (V8 byte
60//! offsets vs Istanbul UTF-16 columns vs oxc spans) agree on the join
61//! key for the same function. Columns survive on the wire as descriptive
62//! metadata for same-line disambiguation in display.
63//! - New [`IdentityResolution`] enum with `Resolved` / `Fallback` /
64//! `Unresolved` / `Unknown` variants. Lets cloud aggregation record per
65//! function whether the identity came from a source-map lookup, a
66//! line-only fallback, or remains unresolved.
67//! - [`StaticFunction`], [`Finding`], [`HotPath`], [`BlastRadiusEntry`],
68//! and [`ImportanceEntry`] are now `#[non_exhaustive]`. This is a
69//! one-time source-side break for downstream Rust consumers that
70//! constructed these via struct literals (the wire shape is unchanged
71//! and forward-compatible). Future field additions become pure additive
72//! changes; the CHANGELOG calls out the migration path.
73//!
74//! # 0.7 changes
75//!
76//! - [`FunctionIdentity::source_hash`] format is now pinned: the first 8
77//! bytes of `SHA-256(<canonical body bytes>)` rendered as 16 lowercase
78//! hex characters. Compute via the new [`source_hash_for`] helper.
79//! Producers that cannot canonicalize the bytes the same way as their
80//! siblings MUST omit the field rather than emit a divergent format.
81//! Closes the cross-producer non-comparability gap that the 0.6.0
82//! "producer-defined, opaque string" wording allowed.
83//! - New [`source_hash_for`] helper. Reuses the existing `sha2`
84//! dependency. No new transitive deps. Anchor fixture
85//! (`source_hash_for_anchor_fixture` in the test module) pins a known
86//! input to a known output so producers can self-test.
87//! - Tightened rustdoc on [`FunctionIdentity::stable_id_computed`]
88//! documenting the method as a diagnostic helper, NOT a validation
89//! gate. Consumers MUST NOT reject payloads whose `stable_id` differs
90//! from the value returned by the helper.
91//! - Byte-level JSON-shape anchor fixtures added for [`FunctionIdentity`]
92//! (full + minimal) plus anchor fixtures for [`blast_radius_id`] and
93//! [`importance_id`] parallel to the existing
94//! [`function_identity_id`] fixture.
95//! - [`RiskBand`] and [`CoverageSource`] gain `Unknown` sentinel variants
96//! with `#[serde(other)]`. Future producers MAY add new variants as
97//! additive minor bumps; consumers map unseen variants to `Unknown`
98//! rather than failing deserialization.
99//! - 0.7.2 (docs only, no wire change): corrected the rustdoc and
100//! CHANGELOG claim that [`FunctionIdentity::stable_id`] is "stable
101//! across line moves". It hashes `start_line`, so a moved function
102//! gets a new `stable_id`; it is the cross-surface + cross-producer
103//! join key, not a line-move-immune one. Line-move-tolerant matching
104//! belongs on the content-based [`FunctionIdentity::source_hash`].
105//!
106//! # 0.8 changes
107//!
108//! - **BREAKING: [`function_identity_id`] recipe reconciled.** The
109//! `stable_id` preimage is now NUL-delimited (`file \0 name \0
110//! start_line`) and truncated to 16 hex chars (64 bits) instead of the
111//! 0.7.x unseparated `file + name + start_line + "function"` truncated
112//! to 8 hex. This matches the cloud aggregation store's recipe (authored
113//! NUL-delimited + 64-bit first) and keeps the column exclusion. Every
114//! `fallow:fn:` value changes; persisted ids (CI dedup, suppression
115//! files, cloud `function_hits`) reset and must be regenerated.
116//! [`function_identity_id_v1`] retains the 0.7.x recipe for the upgrade
117//! grace window: consumers recompute both and match either until the
118//! next re-baseline.
119
120#![forbid(unsafe_code)]
121
122use serde::{Deserialize, Serialize};
123use sha2::{Digest, Sha256};
124
125/// Current protocol version. Bumped per the semver rules above.
126pub const PROTOCOL_VERSION: &str = "0.8.0";
127
128// -- Request envelope -------------------------------------------------------
129
130/// Sent by the public CLI to the sidecar via stdin.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct Request {
133 /// Semver string of the protocol version this request targets.
134 pub protocol_version: String,
135 /// License material the sidecar validates before running coverage analysis.
136 pub license: License,
137 /// Absolute path of the project root under analysis.
138 pub project_root: String,
139 /// One or more coverage artifacts the sidecar should ingest.
140 pub coverage_sources: Vec<CoverageSource>,
141 /// Static analysis output the public CLI already produced for this run.
142 pub static_findings: StaticFindings,
143 /// Optional runtime knobs; all fields default to forward-compatible values.
144 #[serde(default)]
145 pub options: Options,
146}
147
148/// The license material the sidecar should validate.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct License {
151 /// Full JWT string, already stripped of whitespace.
152 pub jwt: String,
153}
154
155/// A single coverage artifact on disk.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157#[serde(tag = "kind", rename_all = "kebab-case")]
158pub enum CoverageSource {
159 /// A single V8 `ScriptCoverage` JSON file.
160 V8 {
161 /// Absolute path to the V8 coverage JSON file.
162 path: String,
163 },
164 /// A single Istanbul JSON file.
165 Istanbul {
166 /// Absolute path to the Istanbul coverage JSON file.
167 path: String,
168 },
169 /// A directory containing multiple V8 dumps to merge in memory.
170 V8Dir {
171 /// Absolute path to the directory containing V8 dump files.
172 path: String,
173 },
174 /// Sentinel for forward-compatibility with newer producers that add
175 /// coverage source kinds (e.g. `IstanbulDir`, `TraceEvent`,
176 /// `RuntimeBeacon`) the current consumer has not seen yet. Sidecars
177 /// receiving an unknown `kind` map the entry here rather than
178 /// failing deserialization; the payload fields associated with the
179 /// unknown kind are intentionally discarded because the consumer
180 /// would not know how to interpret them. Added in protocol 0.7.0.
181 #[serde(other)]
182 Unknown,
183}
184
185/// Static analysis output the public CLI already produced.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct StaticFindings {
188 /// One entry per source file the CLI analyzed.
189 pub files: Vec<StaticFile>,
190}
191
192/// Static analysis results for a single source file.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct StaticFile {
195 /// Path to the source file, relative to [`Request::project_root`].
196 pub path: String,
197 /// Functions the CLI discovered in this file.
198 pub functions: Vec<StaticFunction>,
199}
200
201/// Static analysis results for a single function within a [`StaticFile`].
202///
203/// Marked `#[non_exhaustive]` in 0.6.0: downstream Rust consumers must
204/// stop using struct-literal construction at the type's boundary
205/// (destructure-with-`..` for reads still works). No `Default` impl
206/// ships on this type. See CHANGELOG for the migration note. The wire
207/// shape is unchanged.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[non_exhaustive]
210pub struct StaticFunction {
211 /// Function identifier as reported by the static analyzer. May be an
212 /// anonymous placeholder (e.g. `"<anonymous>"`) when the source has no
213 /// name at the definition site.
214 pub name: String,
215 /// 1-indexed line where the function body starts.
216 pub start_line: u32,
217 /// 1-indexed line where the function body ends (inclusive).
218 pub end_line: u32,
219 /// Cyclomatic complexity of the function, as computed by the CLI.
220 pub cyclomatic: u32,
221 /// Whether this function is statically referenced by the module graph.
222 /// Drives [`Evidence::static_status`] and gates [`Verdict::SafeToDelete`].
223 /// Required: a missing field would silently default to "used" and hide
224 /// every `safe_to_delete` finding.
225 pub static_used: bool,
226 /// Whether this function is covered by the project's test suite.
227 /// Drives [`Evidence::test_coverage`]. Required for the same reason as
228 /// [`StaticFunction::static_used`].
229 pub test_covered: bool,
230 /// Static caller count supplied by the CLI's module graph. Added in 0.4.0
231 /// for first-class blast-radius output; defaults to zero for older CLIs.
232 #[serde(default)]
233 pub caller_count: u32,
234 /// CODEOWNERS owner count for the containing file. `None` means no
235 /// CODEOWNERS data was available; `Some(0)` means CODEOWNERS exists but
236 /// no rule matched this file. Added in 0.4.0 for importance scoring.
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub owner_count: Option<u32>,
239 /// Canonical function identity introduced in 0.6.0. When present,
240 /// consumers SHOULD prefer [`FunctionIdentity::stable_id`] as the
241 /// cross-surface join key over the legacy `(file, name, start_line)`
242 /// triple. Optional for forward-compat with 0.5-shape CLIs.
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub identity: Option<FunctionIdentity>,
245}
246
247/// Runtime knobs. All fields are optional so new options can be added without
248/// a breaking change.
249#[derive(Debug, Clone, Default, Serialize, Deserialize)]
250pub struct Options {
251 /// When true the sidecar computes and returns [`Response::hot_paths`].
252 /// When false, hot-path computation is skipped entirely.
253 #[serde(default)]
254 pub include_hot_paths: bool,
255 /// Minimum invocation count a function must have to qualify as a hot path.
256 /// `None` defers to the sidecar's spec default.
257 #[serde(default)]
258 pub min_invocations_for_hot: Option<u64>,
259 /// Minimum total trace volume before `safe_to_delete` / `review_required`
260 /// verdicts are allowed at high/very-high confidence. Below this the
261 /// sidecar caps confidence at [`Confidence::Medium`]. Spec default `5000`.
262 #[serde(default)]
263 pub min_observation_volume: Option<u32>,
264 /// Fraction of total `trace_count` below which an invoked function is
265 /// classified as [`Verdict::LowTraffic`] instead of `active`. Spec default
266 /// `0.001` (0.1%).
267 #[serde(default)]
268 pub low_traffic_threshold: Option<f64>,
269 /// Total number of traces / request-equivalents the coverage dump covers.
270 /// Used as the denominator for the low-traffic ratio and gates the
271 /// minimum-observation-volume cap. When `None` the sidecar falls back to
272 /// the sum of observed invocations in the current request.
273 #[serde(default)]
274 pub trace_count: Option<u64>,
275 /// Number of days of observation the coverage dump represents. Surfaced
276 /// verbatim in [`Summary::period_days`] and [`Evidence::observation_days`].
277 #[serde(default)]
278 pub period_days: Option<u32>,
279 /// Number of distinct production deployments that contributed coverage.
280 /// Surfaced verbatim in [`Summary::deployments_seen`] and
281 /// [`Evidence::deployments_observed`].
282 #[serde(default)]
283 pub deployments_seen: Option<u32>,
284 /// Total observation window in seconds. Finer-grained than
285 /// [`Self::period_days`]; used to populate
286 /// [`CaptureQuality::window_seconds`]. When `None` the sidecar falls back
287 /// to `period_days * 86_400`. Added in protocol 0.3.0.
288 #[serde(default)]
289 pub window_seconds: Option<u64>,
290 /// Number of distinct production instances that contributed coverage.
291 /// Used to populate [`CaptureQuality::instances_observed`]. When `None`
292 /// the sidecar falls back to [`Self::deployments_seen`]. Added in
293 /// protocol 0.3.0.
294 #[serde(default)]
295 pub instances_observed: Option<u32>,
296}
297
298// -- Response envelope ------------------------------------------------------
299
300/// Emitted by the sidecar to stdout.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct Response {
303 /// Semver string of the protocol version the sidecar produced.
304 pub protocol_version: String,
305 /// Top-level report verdict summarizing the overall state of the run.
306 pub verdict: ReportVerdict,
307 /// Aggregate statistics across the whole analysis.
308 pub summary: Summary,
309 /// Per-function findings, one entry per observed or tracked function.
310 pub findings: Vec<Finding>,
311 /// Hot-path findings, populated only when [`Options::include_hot_paths`]
312 /// was set on the request. Defaults to empty.
313 #[serde(default)]
314 pub hot_paths: Vec<HotPath>,
315 /// First-class blast-radius findings. Added in protocol 0.4.0.
316 #[serde(default)]
317 pub blast_radius: Vec<BlastRadiusEntry>,
318 /// First-class runtime importance findings. Added in protocol 0.4.0.
319 #[serde(default)]
320 pub importance: Vec<ImportanceEntry>,
321 /// Grace-period watermark the CLI should render in human output, if any.
322 #[serde(default)]
323 pub watermark: Option<Watermark>,
324 /// Non-fatal errors the sidecar emitted while processing the request.
325 #[serde(default)]
326 pub errors: Vec<DiagnosticMessage>,
327 /// Warnings the sidecar emitted while processing the request.
328 #[serde(default)]
329 pub warnings: Vec<DiagnosticMessage>,
330}
331
332/// Top-level report verdict for a coverage analysis run.
333///
334/// Was `Verdict` in 0.1. Summarises the overall state of the run;
335/// per-finding verdicts live on [`Finding::verdict`]. Unknown variants
336/// are forward-mapped to [`ReportVerdict::Unknown`].
337#[derive(Debug, Clone, Serialize, Deserialize)]
338#[serde(rename_all = "kebab-case")]
339pub enum ReportVerdict {
340 /// No action required — production coverage confirms the codebase.
341 Clean,
342 /// At least one function in the change set is on a hot path. Reviewers
343 /// should pay extra attention to runtime-critical code touched by this
344 /// PR. Note: the verdict is informational; matching is line-overlap
345 /// against the diff when one is supplied, falling back to file-touch
346 /// when only filenames are available.
347 HotPathTouched,
348 /// At least one finding indicates cold code that should be removed or
349 /// reviewed.
350 ColdCodeDetected,
351 /// The license JWT has expired but the sidecar is still operating inside
352 /// the configured grace window. Output is advisory.
353 LicenseExpiredGrace,
354 /// Sentinel for forward-compatibility with newer sidecars.
355 #[serde(other)]
356 Unknown,
357}
358
359/// Aggregate statistics describing the observed coverage dump.
360#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct Summary {
362 /// Number of functions the sidecar could observe in the V8 dump.
363 pub functions_tracked: u64,
364 /// Functions that received at least one invocation.
365 pub functions_hit: u64,
366 /// Functions that were tracked but never invoked.
367 pub functions_unhit: u64,
368 /// Functions the sidecar could not track (lazy-parsed, worker thread, etc.).
369 pub functions_untracked: u64,
370 /// Ratio of `functions_hit / functions_tracked`, expressed as percent.
371 pub coverage_percent: f64,
372 /// Total number of observed invocations across all functions in the
373 /// current request. Denominator for low-traffic classification.
374 pub trace_count: u64,
375 /// Days of observation covered by the supplied dump.
376 pub period_days: u32,
377 /// Distinct deployments contributing to the supplied dump.
378 pub deployments_seen: u32,
379 /// Quality of the capture window. Populated by the sidecar so the CLI
380 /// can render a "short window" warning alongside low-confidence verdicts,
381 /// and so the upgrade prompt can quantify the delta cloud mode would
382 /// provide. Optional for forward compatibility with 0.2.x sidecars;
383 /// 0.3.x always sets it. Added in protocol 0.3.0 per ADR 009 step 6b.
384 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub capture_quality: Option<CaptureQuality>,
386}
387
388/// Capture-quality telemetry surfaced alongside the aggregate summary.
389///
390/// First-touch local-mode captures (`fallow health --production-coverage-dir`)
391/// tend to produce short windows (minutes to an hour) against a single
392/// instance. Lazy-parsed scripts do not appear in V8 dumps unless they
393/// actually executed during the capture window, which a first-time user
394/// will read as "the tool is broken" rather than "the capture window is
395/// too short." This struct gives the CLI enough information to explain the
396/// state honestly and to quantify what continuous cloud monitoring would add.
397///
398/// Added in protocol 0.3.0 per ADR 009 step 6b, deliverable 2 of 3.
399#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
400pub struct CaptureQuality {
401 /// Total observation window in seconds. Finer-grained than
402 /// [`Summary::period_days`], which rounds up to whole days. A 12-minute
403 /// local capture reports `window_seconds: 720` and `period_days: 1`.
404 pub window_seconds: u64,
405 /// Number of distinct production instances that contributed to the
406 /// dump. Matches [`Summary::deployments_seen`] in the typical case but
407 /// is emitted separately so future captures can distinguish "one
408 /// deployment seen across many instances" from "many deployments".
409 pub instances_observed: u32,
410 /// True when the untracked-function ratio exceeds
411 /// [`Self::LAZY_PARSE_THRESHOLD_PERCENT`]. Signals that the CLI should
412 /// render a "short window" warning: many functions appearing as
413 /// untracked most likely reflect lazy-parsed code rather than
414 /// unreachable code, and the capture window is not long enough to
415 /// distinguish the two.
416 pub lazy_parse_warning: bool,
417 /// `functions_untracked / functions_tracked` as a percentage. Rounded
418 /// to two decimal places for JSON reproducibility. Provided so the CLI
419 /// can render the exact ratio that triggered the warning.
420 pub untracked_ratio_percent: f64,
421}
422
423impl CaptureQuality {
424 /// Threshold above which [`Self::lazy_parse_warning`] fires. Chosen so
425 /// a short window (minutes) against a typical Node app trips the
426 /// warning, while a multi-day continuous capture does not.
427 pub const LAZY_PARSE_THRESHOLD_PERCENT: f64 = 30.0;
428}
429
430/// A per-function finding combining static analysis and runtime coverage.
431///
432/// Marked `#[non_exhaustive]` in 0.6.0: downstream Rust consumers must
433/// stop using struct-literal construction. The wire shape is unchanged.
434#[derive(Debug, Clone, Serialize, Deserialize)]
435#[non_exhaustive]
436pub struct Finding {
437 /// Deterministic content hash of shape `fallow:prod:<hash>`. See
438 /// [`finding_id`] for the canonical helper. Continues to ship through
439 /// 0.6 alongside [`Finding::identity`].
440 ///
441 /// **Which key for which job.** Three identifiers travel with a
442 /// function and serve different axes; do not conflate them:
443 ///
444 /// - `Finding::id` is the **per-finding suppression key**. It hashes
445 /// `file + function + line + "prod"`, so it changes when the
446 /// function moves. Agents writing suppression files / baselines /
447 /// CI dedup key on this to suppress THIS specific finding, not
448 /// every finding on the function.
449 /// - [`FunctionIdentity::stable_id`] is the **cross-surface +
450 /// cross-producer join key**. The same function gets ONE value
451 /// across findings, hot paths, blast-radius entries, and importance
452 /// entries, and across V8 / Istanbul / oxc producers (columns are
453 /// excluded from the hash, so producers of differing positional
454 /// fidelity still agree). Cloud aggregation and any "show me this
455 /// function's history" join uses it. It hashes `start_line`, so,
456 /// exactly like `Finding::id`, it is NOT immune to line moves: a
457 /// function that moves to a new line gets a new `stable_id`.
458 /// - [`FunctionIdentity::source_hash`] is the **content tiebreaker**.
459 /// It hashes the canonical body bytes and excludes position, so it
460 /// is the only one of the three that survives a line move with an
461 /// unchanged body. Optional and absent unless a producer computes
462 /// it; consumers that want line-move-tolerant matching layer it on
463 /// top of `stable_id` when present.
464 ///
465 /// New agent suppression formats SHOULD write `identity.stable_id`
466 /// when present (so one entry correlates the function across every
467 /// surface, not just this one finding) AND retain `Finding::id` for
468 /// backwards-compatibility with 0.5-era baselines. Readers MUST
469 /// accept both forms during the grace window.
470 pub id: String,
471 /// Path to the source file, relative to [`Request::project_root`].
472 pub file: String,
473 /// Function name as reported by the static analyzer. Matches
474 /// [`StaticFunction::name`] and [`FunctionIdentity::name`].
475 pub function: String,
476 /// 1-indexed line number the function starts on. Included in the ID hash
477 /// so anonymous functions with identical names but different locations
478 /// get distinct IDs.
479 pub line: u32,
480 /// Per-finding verdict. Describes what the agent should do with this
481 /// specific function.
482 pub verdict: Verdict,
483 /// Raw invocation count from the V8 dump. `None` when the function was
484 /// not tracked (lazy-parsed, worker-thread isolate, etc.).
485 pub invocations: Option<u64>,
486 /// Confidence the sidecar has in this finding's [`Finding::verdict`].
487 pub confidence: Confidence,
488 /// Evidence rows the sidecar used to arrive at the finding.
489 pub evidence: Evidence,
490 /// Machine-readable next-step hints for AI agents.
491 #[serde(default)]
492 pub actions: Vec<Action>,
493 /// Canonical function identity introduced in 0.6.0. Optional for
494 /// forward-compat with 0.5-shape sidecars. See [`FunctionIdentity`]
495 /// for the canonical join semantics.
496 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub identity: Option<FunctionIdentity>,
498}
499
500/// Per-finding verdict. Replaces the 0.1 `CallState` enum.
501#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
502#[serde(rename_all = "snake_case")]
503pub enum Verdict {
504 /// Statically unused AND never invoked in production with coverage tracked.
505 SafeToDelete,
506 /// Used somewhere statically / by tests / by an untracked call site but
507 /// never invoked in production. Needs a human look.
508 ReviewRequired,
509 /// V8 could not observe the function (lazy-parsed, worker thread,
510 /// dynamic code). Nothing can be said about runtime behaviour.
511 CoverageUnavailable,
512 /// Invoked in production but below the configured low-traffic threshold
513 /// relative to `trace_count`. Effectively dead in the current period.
514 LowTraffic,
515 /// Function was invoked above the low-traffic threshold — not dead.
516 Active,
517 /// Sentinel for forward-compatibility.
518 #[serde(other)]
519 Unknown,
520}
521
522/// Confidence the sidecar attaches to a [`Finding::verdict`].
523#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
524#[serde(rename_all = "snake_case")]
525pub enum Confidence {
526 /// Combined static + runtime signal: statically unused AND tracked AND
527 /// zero invocations. Strongest delete signal the sidecar emits.
528 VeryHigh,
529 /// Strong signal — one of static or runtime is dispositive, the other
530 /// agrees.
531 High,
532 /// Signals agree but observation volume or coverage fidelity tempers the
533 /// call.
534 Medium,
535 /// Weak signal — a single data point suggests the verdict but other
536 /// evidence is missing or ambiguous.
537 Low,
538 /// Explicit absence of confidence (e.g. coverage unavailable).
539 None,
540 /// Sentinel for forward-compatibility.
541 #[serde(other)]
542 Unknown,
543}
544
545/// How a [`FunctionIdentity`] was produced by the upstream coverage
546/// pipeline.
547///
548/// Lets `fallow-cloud` aggregation and the CLI distinguish "this identity
549/// was resolved through a source map" from "this is a best-effort
550/// line-only fallback" without inspecting the column / span fields
551/// directly. Added in protocol 0.6.0.
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
553#[serde(rename_all = "snake_case")]
554pub enum IdentityResolution {
555 /// Identity was produced from a fully-resolved source location, e.g.
556 /// a source-map lookup succeeded for a bundled position, or a direct
557 /// AST traversal yielded byte-accurate columns.
558 Resolved,
559 /// Identity was constructed via a best-effort fallback after a more
560 /// precise resolution failed (missing source map, stale offsets, etc).
561 /// [`FunctionIdentity::stable_id`] is bit-identical to what a
562 /// [`IdentityResolution::Resolved`] producer would emit for the same
563 /// function (the hash inputs are `file` / `name` / `start_line`
564 /// only, none of which the fallback path loses); the confidence
565 /// delta is about the column / span metadata, not the join key
566 /// itself. Consumers that weight join confidence on this variant
567 /// SHOULD apply the weight to display / disambiguation logic
568 /// (column accuracy, source-map traceability), not to the join.
569 Fallback,
570 /// Identity could not be resolved beyond `file`, `name`, and
571 /// `start_line`; columns and `source_hash` are SHOULD-be-absent.
572 /// Consumers SHOULD ignore [`FunctionIdentity::start_column`],
573 /// [`FunctionIdentity::end_column`], and
574 /// [`FunctionIdentity::source_hash`] when `resolution ==
575 /// Unresolved`, even if a non-conforming producer populated them.
576 /// The protocol intentionally documents rather than enforces this
577 /// (a serde-time check would force every consumer to validate);
578 /// `unresolved_identity_with_columns_round_trips` locks the
579 /// document-but-tolerate stance.
580 Unresolved,
581 /// Sentinel for forward-compatibility with newer pipelines.
582 #[serde(other)]
583 Unknown,
584}
585
586/// Canonical, versioned identity for a function.
587///
588/// Becomes the cross-surface join key between the OSS CLI's static
589/// function inventory, V8 / Istanbul runtime coverage, test coverage
590/// from `oxc-coverage-instrument`, source-map remapped findings, and
591/// `fallow-cloud` aggregation when present.
592///
593/// # Name aliasing
594///
595/// The `name` field carries the same value as [`StaticFunction::name`]
596/// and [`Finding::function`]. The three spellings exist for backwards
597/// compatibility with 0.5-and-earlier envelopes: [`Finding::function`]
598/// and the legacy `file` / `line` fields are preserved verbatim so
599/// display surfaces (CLI human output, SARIF, GitHub annotations) keep
600/// working unchanged. New code should read [`FunctionIdentity::name`]
601/// when the field is present.
602///
603/// # Column semantics (load-bearing)
604///
605/// [`FunctionIdentity::start_column`] and [`FunctionIdentity::end_column`]
606/// are **1-indexed UTF-16 column offsets, anchored at the function-body
607/// start** (matching Istanbul `fnMap[i].loc.start`, NOT `fnMap[i].decl.start`).
608/// Producers MUST normalize their native semantics to this anchor:
609///
610/// - **Istanbul producers** read `fnMap[i].loc.start.column` (already
611/// UTF-16, 0-indexed) and add 1.
612/// - **V8 producers** (`fallow-v8-coverage`, `oxc_coverage_v8`) map the
613/// function's `startOffset` byte offset to a UTF-16 column via the
614/// script text, then add 1.
615/// - **AST-based producers** (oxc spans) convert the `Span::start`
616/// byte offset to UTF-16 column, then add 1.
617///
618/// Pick **one** anchor and stick to it: producers picking different
619/// anchors for the same function would silently produce different
620/// `(start_line, start_column)` pairs for display, but they MUST still
621/// produce the same [`FunctionIdentity::stable_id`] because columns are
622/// intentionally NOT hashed (see below).
623///
624/// # Hash exclusion of columns
625///
626/// [`function_identity_id`] hashes only the NUL-delimited `file`, `name`,
627/// and `start_line` (see its rustdoc for the exact 0.8.0 recipe). Columns,
628/// end positions, and `source_hash` are descriptive metadata for display
629/// and same-line disambiguation, but are NOT part of
630/// the hash. Rationale: V8 runtime dumps frequently lack column info,
631/// while Istanbul fnMap and oxc spans always have it. If columns were
632/// hashed, the same function observed by two producers with different
633/// fidelity would produce two different `stable_id` values and the
634/// cross-surface join would silently break.
635///
636/// Same-line functions remain distinguishable via the column metadata on
637/// the struct itself, just not via the `stable_id`. Cloud aggregation
638/// that needs to disambiguate same-line functions during display can use
639/// `(start_line, start_column)` as a secondary key once the stable join
640/// has happened.
641///
642/// # Resolution confidence
643///
644/// [`FunctionIdentity::resolution`] is required (not `Option`) so cloud
645/// aggregation can record how each identity was produced. See
646/// [`IdentityResolution`] for the variants.
647///
648/// Added in protocol 0.6.0.
649#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
650pub struct FunctionIdentity {
651 /// Path to the source file, relative to [`Request::project_root`].
652 /// Matches the legacy `file` field on [`Finding`], [`HotPath`],
653 /// [`BlastRadiusEntry`], and [`ImportanceEntry`].
654 pub file: String,
655 /// Function name as reported by the producing pipeline. Matches
656 /// [`StaticFunction::name`] and [`Finding::function`].
657 pub name: String,
658 /// 1-indexed line where the function body starts. Matches
659 /// [`StaticFunction::start_line`] and the legacy `line` field on
660 /// findings / hot paths / blast-radius / importance entries.
661 pub start_line: u32,
662 /// 1-indexed UTF-16 column of the first character of the function
663 /// body (inclusive). Anchored at the function-body opening
664 /// (Istanbul `loc.start`, V8 mapped from byte offset via script
665 /// text, oxc `Span::start` mapped to UTF-16). Istanbul's
666 /// `loc.start.column` is 0-indexed inclusive, so producers MUST
667 /// add 1 when reading from Istanbul fnMap. V8 producers whose
668 /// `Coverage.takePreciseCoverage()` offsets originated from a
669 /// disk-loaded script source MUST decode the script through UTF-8
670 /// before counting UTF-16 code units; offsets from inline-string
671 /// scripts already speak UTF-16. Optional: older V8 dumps and
672 /// Istanbul artifacts without column data omit this field.
673 /// Descriptive metadata only; NOT part of
674 /// [`FunctionIdentity::stable_id`].
675 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub start_column: Option<u32>,
677 /// 1-indexed line where the function body ends (inclusive). Optional.
678 /// Mirrors [`StaticFunction::end_line`].
679 #[serde(default, skip_serializing_if = "Option::is_none")]
680 pub end_line: Option<u32>,
681 /// 1-indexed UTF-16 column of the last character of the function
682 /// body (inclusive). Same indexing and anchor conventions as
683 /// [`FunctionIdentity::start_column`]. Note: Istanbul's
684 /// `loc.end.column` is 0-indexed AND exclusive (the column AFTER
685 /// the last character), so the mapping from Istanbul to this field
686 /// is identity (`protocol_end_column = istanbul_end_column`): the
687 /// off-by-one between "0-indexed exclusive" and "1-indexed
688 /// inclusive" cancels. V8 and oxc producers MUST convert their
689 /// byte-offset / span-end to the same 1-indexed-inclusive
690 /// convention. Optional. Descriptive metadata only; NOT part of
691 /// [`FunctionIdentity::stable_id`].
692 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub end_column: Option<u32>,
694 /// Optional cross-producer tiebreaker for moved or renamed functions
695 /// whose positions changed but whose source body is byte-identical.
696 ///
697 /// Format (pinned in protocol 0.7.0, MUST hold across producers): the
698 /// first 8 bytes of `SHA-256(<canonical body bytes>)` rendered as 16
699 /// lowercase hex characters. Compute via [`source_hash_for`] so every
700 /// producer agrees on the value.
701 ///
702 /// Canonical body bytes (also pinned): the bytes the producing
703 /// compiler or parser sees for the function, including the signature
704 /// line and the closing brace, with NO whitespace normalization. Two
705 /// producers observing the same function in the same file MUST hand
706 /// the same byte slice to [`source_hash_for`].
707 ///
708 /// Producers that cannot compute this format MUST omit the field
709 /// rather than emit a divergent string. Consumers MAY use a present
710 /// value as a cross-producer comparability signal; an absent value
711 /// carries no information.
712 ///
713 /// NOT part of [`FunctionIdentity::stable_id`].
714 #[serde(default, skip_serializing_if = "Option::is_none")]
715 pub source_hash: Option<String>,
716 /// How this identity was produced. See [`IdentityResolution`].
717 /// Required: a missing field would silently default to one of the
718 /// variants and hide the resolution-confidence signal cloud
719 /// aggregation needs.
720 pub resolution: IdentityResolution,
721 /// Deterministic cross-surface join key of shape `fallow:fn:<16 hex>`.
722 /// Producers MUST compute this via [`function_identity_id`] so the
723 /// CLI, sidecar, and cloud agree on the value for the same function.
724 /// See the struct-level docs for the hash-input rationale.
725 pub stable_id: String,
726}
727
728impl FunctionIdentity {
729 /// Recompute the canonical [`FunctionIdentity::stable_id`] from
730 /// `file`, `name`, and `start_line`. Diagnostic helper only: useful
731 /// for logging or test assertions that a producer-supplied
732 /// `stable_id` was computed via the canonical helper, and for
733 /// `debug_assert!(self.stable_id == self.stable_id_computed())` in
734 /// producer test suites.
735 ///
736 /// NOT a validation gate. Consumers MUST NOT reject payloads whose
737 /// `stable_id` differs from the value returned here. A future
738 /// protocol major that evolves the hash inputs would otherwise turn
739 /// every such consumer into a hard-fail on upgrade, defeating the
740 /// cross-surface join the value exists to provide.
741 #[must_use]
742 pub fn stable_id_computed(&self) -> String {
743 function_identity_id(&self.file, &self.name, self.start_line)
744 }
745}
746
747/// Supporting evidence for a [`Finding`]. Mirrors the rows of the decision
748/// table in `.internal/spec-production-coverage.md` so the CLI can render the
749/// "why" behind each verdict without re-deriving it.
750#[derive(Debug, Clone, Serialize, Deserialize)]
751pub struct Evidence {
752 /// `"unused"` when the CLI marked the function statically unreachable,
753 /// `"used"` otherwise.
754 pub static_status: String,
755 /// `"covered"` or `"not_covered"` by the project's test suite.
756 pub test_coverage: String,
757 /// `"tracked"` when V8 observed the function, `"untracked"` otherwise.
758 pub v8_tracking: String,
759 /// Populated when `v8_tracking == "untracked"`. Values mirror the spec:
760 /// `"lazy_parsed"`, `"worker_thread"`, `"dynamic_eval"`, `"unknown"`.
761 #[serde(default, skip_serializing_if = "Option::is_none")]
762 pub untracked_reason: Option<String>,
763 /// Days of observation the decision rests on. Echoes [`Summary::period_days`].
764 pub observation_days: u32,
765 /// Distinct deployments the decision rests on. Echoes [`Summary::deployments_seen`].
766 pub deployments_observed: u32,
767}
768
769/// A function the sidecar identified as a hot path in the current dump.
770///
771/// Marked `#[non_exhaustive]` in 0.6.0: downstream Rust consumers must
772/// stop using struct-literal construction. The wire shape is unchanged.
773#[derive(Debug, Clone, Serialize, Deserialize)]
774#[non_exhaustive]
775pub struct HotPath {
776 /// Deterministic content hash of shape `fallow:hot:<hash>`. See
777 /// [`hot_path_id`] for the canonical helper. Continues to ship through
778 /// 0.6 alongside [`HotPath::identity`].
779 pub id: String,
780 /// Path to the source file, relative to [`Request::project_root`].
781 pub file: String,
782 /// Function name as reported by the static analyzer.
783 pub function: String,
784 /// 1-indexed line the function starts on.
785 pub line: u32,
786 /// 1-indexed line the function ends on (inclusive). Mirrors
787 /// [`StaticFunction::end_line`] from the request envelope so consumers
788 /// can match a hot path against a PR diff at line granularity, not just
789 /// file granularity. Older 0.4-shape sidecars omit this field; readers
790 /// that receive `0` MUST treat the hot path as a single-line range
791 /// (`line..=line`) rather than a span.
792 #[serde(default)]
793 pub end_line: u32,
794 /// Raw invocation count from the V8 dump.
795 pub invocations: u64,
796 /// Percentile rank of this function's invocation count over the
797 /// invocation distribution of the current response's hot paths. `100`
798 /// means the busiest function, `0` the quietest that still qualified.
799 pub percentile: u8,
800 /// Canonical function identity introduced in 0.6.0. Optional for
801 /// forward-compat with 0.5-shape sidecars.
802 #[serde(default, skip_serializing_if = "Option::is_none")]
803 pub identity: Option<FunctionIdentity>,
804}
805
806/// Risk band for a blast-radius entry.
807#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
808#[serde(rename_all = "snake_case")]
809pub enum RiskBand {
810 /// Low caller fan-in / traffic-weighted reach.
811 Low,
812 /// Moderate caller fan-in / traffic-weighted reach.
813 Medium,
814 /// High caller fan-in / traffic-weighted reach.
815 High,
816 /// Sentinel for forward-compatibility with newer producers that add
817 /// risk bands (e.g. `Critical`, `Negligible`) the current consumer
818 /// has not seen yet. Older consumers map the unknown variant here
819 /// rather than failing deserialization. Added in protocol 0.7.0.
820 #[serde(other)]
821 Unknown,
822}
823
824/// A function with meaningful static or traffic-weighted blast radius.
825///
826/// Marked `#[non_exhaustive]` in 0.6.0: downstream Rust consumers must
827/// stop using struct-literal construction. The wire shape is unchanged.
828#[derive(Debug, Clone, Serialize, Deserialize)]
829#[non_exhaustive]
830pub struct BlastRadiusEntry {
831 /// Deterministic content hash of shape `fallow:blast:<hash>`.
832 /// Continues to ship through 0.6 alongside [`BlastRadiusEntry::identity`].
833 pub id: String,
834 /// Path to the source file, relative to [`Request::project_root`].
835 pub file: String,
836 /// Function name as reported by the static analyzer.
837 pub function: String,
838 /// 1-indexed line the function starts on.
839 pub line: u32,
840 /// Static caller count supplied by the CLI module graph.
841 pub caller_count: u32,
842 /// Caller count weighted by observed traffic. Local mode uses the
843 /// sidecar's current best-effort traffic proxy; cloud mode may replace
844 /// this with summed caller invocations.
845 pub caller_count_weighted_by_traffic: u64,
846 /// Distinct git SHAs that touched this function in the observation window.
847 /// Cloud-only; omitted for local coverage artifacts.
848 #[serde(default, skip_serializing_if = "Option::is_none")]
849 pub deploys_touched: Option<u32>,
850 /// Deterministic low / medium / high band.
851 pub risk_band: RiskBand,
852 /// Canonical function identity introduced in 0.6.0. Optional for
853 /// forward-compat with 0.5-shape sidecars.
854 #[serde(default, skip_serializing_if = "Option::is_none")]
855 pub identity: Option<FunctionIdentity>,
856}
857
858/// A function ranked by runtime traffic, complexity, and ownership risk.
859///
860/// Marked `#[non_exhaustive]` in 0.6.0: downstream Rust consumers must
861/// stop using struct-literal construction. The wire shape is unchanged.
862#[derive(Debug, Clone, Serialize, Deserialize)]
863#[non_exhaustive]
864pub struct ImportanceEntry {
865 /// Deterministic content hash of shape `fallow:importance:<hash>`.
866 /// Continues to ship through 0.6 alongside [`ImportanceEntry::identity`].
867 pub id: String,
868 /// Path to the source file, relative to [`Request::project_root`].
869 pub file: String,
870 /// Function name as reported by the static analyzer.
871 pub function: String,
872 /// 1-indexed line the function starts on.
873 pub line: u32,
874 /// Raw invocation count used for the traffic component.
875 pub invocations: u64,
876 /// Cyclomatic complexity supplied by the CLI health pipeline.
877 pub cyclomatic: u32,
878 /// Number of CODEOWNERS owners; `0` means ownership is absent or unowned.
879 pub owner_count: u32,
880 /// 0-100 importance score. The formula is intentionally simple and
881 /// documented by the sidecar implementation so it can be tuned later.
882 pub importance_score: f64,
883 /// Templated one-sentence explanation, not free-form model text.
884 pub reason: String,
885 /// Canonical function identity introduced in 0.6.0. Optional for
886 /// forward-compat with 0.5-shape sidecars.
887 #[serde(default, skip_serializing_if = "Option::is_none")]
888 pub identity: Option<FunctionIdentity>,
889}
890
891/// Machine-readable next-step hint for AI agents.
892#[derive(Debug, Clone, Serialize, Deserialize)]
893pub struct Action {
894 /// Short identifier for the action kind (e.g. `"delete"`, `"inline"`,
895 /// `"review"`). Free-form on the wire to keep forward compatibility.
896 pub kind: String,
897 /// Human-readable one-liner describing the suggested action.
898 pub description: String,
899 /// Whether the CLI can apply this action non-interactively.
900 #[serde(default)]
901 pub auto_fixable: bool,
902}
903
904/// What to render in the human output when the license is in the grace window.
905#[derive(Debug, Clone, Serialize, Deserialize)]
906#[serde(rename_all = "kebab-case")]
907pub enum Watermark {
908 /// The trial period has ended.
909 TrialExpired,
910 /// A paid license has expired but the sidecar is still inside the grace
911 /// window.
912 LicenseExpiredGrace,
913 /// Sentinel for forward-compatibility.
914 #[serde(other)]
915 Unknown,
916}
917
918/// Error / warning surfaced by the sidecar.
919#[derive(Debug, Clone, Serialize, Deserialize)]
920pub struct DiagnosticMessage {
921 /// Stable machine-readable diagnostic code (e.g. `"COV_DUMP_PARSE"`).
922 pub code: String,
923 /// Human-readable description of the diagnostic.
924 pub message: String,
925}
926
927// -- Stable ID helpers -----------------------------------------------------
928
929/// Compute the deterministic [`Finding::id`] for a production-coverage finding.
930///
931/// Emits `fallow:prod:<hash>` where `<hash>` is the first 8 hex characters of
932/// `SHA-256(file + function + line + "prod")`. The concatenation is plain,
933/// unseparated UTF-8. The canonical order MUST stay identical across protocol
934/// revisions; changing it breaks ID stability across runs and invalidates any
935/// consumer that persists IDs (CI deduplication, suppression, agent
936/// cross-references).
937#[must_use]
938pub fn finding_id(file: &str, function: &str, line: u32) -> String {
939 format!("fallow:prod:{}", content_hash(file, function, line, "prod"))
940}
941
942/// Compute the deterministic [`HotPath::id`] for a hot-path finding. Uses the
943/// same canonical order as [`finding_id`] with kind `"hot"`, emitting
944/// `fallow:hot:<hash>`.
945#[must_use]
946pub fn hot_path_id(file: &str, function: &str, line: u32) -> String {
947 format!("fallow:hot:{}", content_hash(file, function, line, "hot"))
948}
949
950/// Compute the deterministic [`BlastRadiusEntry::id`] for a blast-radius entry.
951#[must_use]
952pub fn blast_radius_id(file: &str, function: &str, line: u32) -> String {
953 format!(
954 "fallow:blast:{}",
955 content_hash(file, function, line, "blast")
956 )
957}
958
959/// Compute the deterministic [`ImportanceEntry::id`] for an importance entry.
960#[must_use]
961pub fn importance_id(file: &str, function: &str, line: u32) -> String {
962 format!(
963 "fallow:importance:{}",
964 content_hash(file, function, line, "importance")
965 )
966}
967
968/// Compute the deterministic [`FunctionIdentity::stable_id`] for a function.
969///
970/// Emits `fallow:fn:<hash>` where `<hash>` is the first 16 hex characters
971/// (first 8 bytes) of `SHA-256(file \0 name \0 start_line)`. The preimage
972/// is NUL-delimited: the three fields joined by single `0x00` bytes, with
973/// `start_line` rendered as its decimal ASCII string. There is no trailing
974/// salt; the `fallow:fn:` prefix namespaces the value against the
975/// per-surface helpers.
976///
977/// # Why columns are NOT in the hash
978///
979/// The canonical hash inputs intentionally exclude column / span / source
980/// hash metadata. Two producers observing the same function with
981/// different positional fidelity (V8 dumps that lack columns vs Istanbul
982/// fnMap that has them, vs oxc spans that have byte-accurate positions)
983/// MUST produce the same `stable_id` so the cross-surface join holds.
984/// Columns survive on the wire (see [`FunctionIdentity::start_column`])
985/// for display and same-line disambiguation, but are NOT part of the
986/// hash.
987///
988/// # Why NUL-delimited and 16 hex (0.8.0)
989///
990/// The 0.8.0 recipe reconciles this helper with the cloud aggregation
991/// store, which was authored NUL-delimited and 64-bit first. NUL
992/// delimiters prevent preimage ambiguity (unseparated `file + name` lets
993/// `("ab","c")` collide with `("a","bc")`); producers MUST reject NUL
994/// bytes in `file` / `name` so a delimiter cannot be smuggled into the
995/// input. The 16-hex (64-bit) truncation keeps the birthday-collision
996/// bound safe for large monorepos aggregated over time, where 8 hex
997/// (32-bit) becomes risky past ~65k functions in one partition.
998///
999/// # Why there is no `kind` parameter
1000///
1001/// Unlike [`finding_id`] / [`hot_path_id`] / [`blast_radius_id`] /
1002/// [`importance_id`], which are per-surface stable IDs, this helper
1003/// produces ONE canonical ID per function across every surface the
1004/// function appears on (findings, hot paths, blast radius, importance,
1005/// static inventory). That is the whole point of the cross-surface join.
1006///
1007/// The canonical preimage, delimiter, and truncation are part of the wire
1008/// contract. Changing any of them breaks ID stability across runs and
1009/// invalidates any consumer that persists IDs (CI deduplication,
1010/// suppression files, agent cross-references) and is therefore always a
1011/// major bump. See [`function_identity_id_v1`] for the pre-0.8.0 recipe
1012/// kept for the upgrade grace window.
1013///
1014/// Added in protocol 0.6.0; recipe reconciled in 0.8.0.
1015#[must_use]
1016pub fn function_identity_id(file: &str, name: &str, start_line: u32) -> String {
1017 let mut hasher = Sha256::new();
1018 hasher.update(file.as_bytes());
1019 hasher.update(b"\0");
1020 hasher.update(name.as_bytes());
1021 hasher.update(b"\0");
1022 hasher.update(start_line.to_string().as_bytes());
1023 let digest = hasher.finalize();
1024 format!("fallow:fn:{}", hex_prefix(&digest, 8))
1025}
1026
1027/// Compute the pre-0.8.0 [`FunctionIdentity::stable_id`] recipe.
1028///
1029/// Retained ONLY for the 0.8.0 upgrade grace window. A consumer holding
1030/// baselines / suppression files written before 0.8.0 recomputes BOTH this
1031/// and [`function_identity_id`] from `file` + `name` + `start_line` and
1032/// suppresses on either match, so a finding stays suppressed until its
1033/// baseline is regenerated. Producers MUST NOT emit this value on the wire.
1034///
1035/// The 0.7.x recipe was `SHA-256(file + name + start_line + "function")`,
1036/// plain unseparated UTF-8, truncated to the first 8 hex characters. The
1037/// 0.8.0 recipe (NUL-delimited, 16 hex) never collides with it.
1038#[deprecated(
1039 since = "0.8.0",
1040 note = "pre-0.8.0 grace-window recipe; use function_identity_id for new ids"
1041)]
1042#[must_use]
1043pub fn function_identity_id_v1(file: &str, name: &str, start_line: u32) -> String {
1044 let mut hasher = Sha256::new();
1045 hasher.update(file.as_bytes());
1046 hasher.update(name.as_bytes());
1047 hasher.update(start_line.to_string().as_bytes());
1048 hasher.update(b"function");
1049 let digest = hasher.finalize();
1050 format!("fallow:fn:{}", hex_prefix(&digest, 4))
1051}
1052
1053/// Compute the canonical [`FunctionIdentity::source_hash`] for the given
1054/// canonical body bytes.
1055///
1056/// Emits 16 lowercase hex characters: the first 8 bytes of `SHA-256(body)`.
1057/// No `fallow:` prefix because the value is a content tiebreaker, not a
1058/// qualified ID; see [`FunctionIdentity::source_hash`] for the field
1059/// rustdoc and the canonicalization rule (signature line plus body plus
1060/// closing brace, no whitespace normalization).
1061///
1062/// Cross-producer comparability is the whole point: V8, Istanbul, oxc,
1063/// and beacon producers that all derive the same canonical body for the
1064/// same function MUST produce the same string from this helper. Producers
1065/// that cannot canonicalize the bytes the same way as their siblings MUST
1066/// omit [`FunctionIdentity::source_hash`] rather than emit a divergent
1067/// format.
1068///
1069/// Truncation (first 8 SHA-256 bytes to 16 hex chars) and lowercase hex
1070/// encoding are part of the wire contract. Changing either invalidates
1071/// every previously persisted `source_hash` value and is therefore always
1072/// a major bump.
1073///
1074/// Added in protocol 0.7.0.
1075#[must_use]
1076pub fn source_hash_for(body: &[u8]) -> String {
1077 let mut hasher = Sha256::new();
1078 hasher.update(body);
1079 let digest = hasher.finalize();
1080 hex_prefix(&digest, 8)
1081}
1082
1083/// Canonical content hash shared by the stable ID helpers. The input order
1084/// (file, function, line, kind) and truncation (first 4 SHA-256 bytes to 8
1085/// hex chars) are part of the wire contract; see [`finding_id`] for the
1086/// rationale.
1087fn content_hash(file: &str, function: &str, line: u32, kind: &str) -> String {
1088 let mut hasher = Sha256::new();
1089 hasher.update(file.as_bytes());
1090 hasher.update(function.as_bytes());
1091 hasher.update(line.to_string().as_bytes());
1092 hasher.update(kind.as_bytes());
1093 let digest = hasher.finalize();
1094 hex_prefix(&digest, 4)
1095}
1096
1097/// Encode the first `bytes` bytes of `digest` as lowercase hex, returning
1098/// a `2 * bytes`-character string. Kept as a single helper so every
1099/// truncation length used by the wire contract is auditable from one
1100/// place. Total by construction: `HEX` is ASCII and `char::from(u8)` is
1101/// infallible, so the helper never panics. If `bytes > digest.len()` the
1102/// iterator silently caps at `digest.len()`; the SHA-256 callers all
1103/// satisfy `bytes <= 32`.
1104fn hex_prefix(digest: &[u8], bytes: usize) -> String {
1105 const HEX: &[u8; 16] = b"0123456789abcdef";
1106 let mut out = String::with_capacity(bytes * 2);
1107 for &byte in digest.iter().take(bytes) {
1108 out.push(char::from(HEX[usize::from(byte >> 4)]));
1109 out.push(char::from(HEX[usize::from(byte & 0x0f)]));
1110 }
1111 out
1112}
1113
1114// -- License features -------------------------------------------------------
1115
1116/// Feature flags present in the license JWT's `features` claim.
1117///
1118/// Wire format stays a string array (forward-compatible); new variants are
1119/// additive in minor protocol bumps.
1120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1121#[serde(rename_all = "snake_case")]
1122pub enum Feature {
1123 /// Production coverage intelligence (the primary sidecar feature).
1124 ProductionCoverage,
1125 /// Portfolio dashboard for cross-project rollups. Deferred.
1126 PortfolioDashboard,
1127 /// MCP cloud tools integration. Deferred.
1128 McpCloudTools,
1129 /// Cross-repo aggregation and deduplication. Deferred.
1130 CrossRepoAggregation,
1131 /// Sentinel for forward-compatibility.
1132 #[serde(other)]
1133 Unknown,
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138 use super::*;
1139
1140 #[test]
1141 fn version_constant_is_v0_8() {
1142 assert!(PROTOCOL_VERSION.starts_with("0.8."));
1143 }
1144
1145 #[test]
1146 fn unknown_report_verdict_round_trips() {
1147 let json = r#""something-new""#;
1148 let verdict: ReportVerdict = serde_json::from_str(json).unwrap();
1149 assert!(matches!(verdict, ReportVerdict::Unknown));
1150 }
1151
1152 #[test]
1153 fn unknown_verdict_round_trips() {
1154 let json = r#""future_state""#;
1155 let verdict: Verdict = serde_json::from_str(json).unwrap();
1156 assert!(matches!(verdict, Verdict::Unknown));
1157 }
1158
1159 #[test]
1160 fn unknown_confidence_round_trips() {
1161 let json = r#""ultra_high""#;
1162 let confidence: Confidence = serde_json::from_str(json).unwrap();
1163 assert!(matches!(confidence, Confidence::Unknown));
1164 }
1165
1166 #[test]
1167 fn unknown_feature_round_trips() {
1168 let json = r#""future_feature""#;
1169 let feature: Feature = serde_json::from_str(json).unwrap();
1170 assert!(matches!(feature, Feature::Unknown));
1171 }
1172
1173 #[test]
1174 fn unknown_watermark_round_trips() {
1175 let json = r#""something-else""#;
1176 let watermark: Watermark = serde_json::from_str(json).unwrap();
1177 assert!(matches!(watermark, Watermark::Unknown));
1178 }
1179
1180 #[test]
1181 fn unknown_risk_band_round_trips() {
1182 // Forward-compat sentinel added in protocol 0.7.0. Future
1183 // producers MAY add risk bands beyond Low / Medium / High; older
1184 // consumers MUST map them to Unknown rather than failing
1185 // deserialization. Adding a new variant is a soft minor bump
1186 // only because this sentinel is present.
1187 let json = r#""critical""#;
1188 let band: RiskBand = serde_json::from_str(json).unwrap();
1189 assert!(matches!(band, RiskBand::Unknown));
1190 }
1191
1192 #[test]
1193 fn unknown_coverage_source_round_trips() {
1194 // Forward-compat sentinel added in protocol 0.7.0. Future
1195 // producers MAY add coverage source kinds beyond v8 / istanbul /
1196 // v8-dir (e.g., istanbul-dir, trace-event, runtime-beacon);
1197 // older sidecars MUST map them to Unknown rather than failing
1198 // deserialization. The payload fields associated with the
1199 // unknown kind are intentionally discarded because the consumer
1200 // would not know how to interpret them.
1201 let json = r#"{"kind":"trace-event","path":"/tmp/x.trace"}"#;
1202 let src: CoverageSource = serde_json::from_str(json).unwrap();
1203 assert!(matches!(src, CoverageSource::Unknown));
1204 }
1205
1206 #[test]
1207 fn coverage_source_kebab_case() {
1208 let json = r#"{"kind":"v8-dir","path":"/tmp/dumps"}"#;
1209 let src: CoverageSource = serde_json::from_str(json).unwrap();
1210 assert!(matches!(src, CoverageSource::V8Dir { .. }));
1211 }
1212
1213 #[test]
1214 fn response_allows_unknown_fields() {
1215 let json = r#"{
1216 "protocol_version": "0.2.0",
1217 "verdict": "clean",
1218 "summary": {
1219 "functions_tracked": 0,
1220 "functions_hit": 0,
1221 "functions_unhit": 0,
1222 "functions_untracked": 0,
1223 "coverage_percent": 0.0,
1224 "trace_count": 0,
1225 "period_days": 0,
1226 "deployments_seen": 0
1227 },
1228 "findings": [],
1229 "future_top_level_field": 42
1230 }"#;
1231 let response: Response = serde_json::from_str(json).unwrap();
1232 assert_eq!(response.protocol_version, "0.2.0");
1233 }
1234
1235 #[test]
1236 fn finding_id_is_deterministic() {
1237 let first = finding_id("src/a.ts", "foo", 42);
1238 let second = finding_id("src/a.ts", "foo", 42);
1239 assert_eq!(first, second);
1240 assert!(first.starts_with("fallow:prod:"));
1241 assert_eq!(first.len(), "fallow:prod:".len() + 8);
1242 }
1243
1244 #[test]
1245 fn capture_quality_round_trips() {
1246 let q = CaptureQuality {
1247 window_seconds: 720,
1248 instances_observed: 1,
1249 lazy_parse_warning: true,
1250 untracked_ratio_percent: 42.5,
1251 };
1252 let json = serde_json::to_string(&q).unwrap();
1253 let parsed: CaptureQuality = serde_json::from_str(&json).unwrap();
1254 assert_eq!(q, parsed);
1255 }
1256
1257 #[test]
1258 fn summary_without_capture_quality_deserializes() {
1259 // 0.2.x sidecars produced this shape; 0.3.x deserialization must
1260 // still accept it so a mixed rollout (newer CLI, older sidecar)
1261 // does not hard-fail.
1262 let json = r#"{
1263 "functions_tracked": 10,
1264 "functions_hit": 5,
1265 "functions_unhit": 5,
1266 "functions_untracked": 0,
1267 "coverage_percent": 50.0,
1268 "trace_count": 100,
1269 "period_days": 1,
1270 "deployments_seen": 1
1271 }"#;
1272 let summary: Summary = serde_json::from_str(json).unwrap();
1273 assert!(summary.capture_quality.is_none());
1274 }
1275
1276 #[test]
1277 fn summary_with_capture_quality_round_trips() {
1278 let summary = Summary {
1279 functions_tracked: 10,
1280 functions_hit: 5,
1281 functions_unhit: 5,
1282 functions_untracked: 3,
1283 coverage_percent: 50.0,
1284 trace_count: 100,
1285 period_days: 1,
1286 deployments_seen: 1,
1287 capture_quality: Some(CaptureQuality {
1288 window_seconds: 720,
1289 instances_observed: 1,
1290 lazy_parse_warning: true,
1291 untracked_ratio_percent: 30.0,
1292 }),
1293 };
1294 let json = serde_json::to_string(&summary).unwrap();
1295 let parsed: Summary = serde_json::from_str(&json).unwrap();
1296 assert_eq!(summary.capture_quality, parsed.capture_quality);
1297 }
1298
1299 #[test]
1300 fn lazy_parse_threshold_is_30_percent() {
1301 // Anchored so a bump forces a deliberate decision and a CHANGELOG
1302 // entry rather than a silent tweak.
1303 assert!((CaptureQuality::LAZY_PARSE_THRESHOLD_PERCENT - 30.0).abs() < f64::EPSILON);
1304 }
1305
1306 #[test]
1307 fn hot_path_id_differs_from_finding_id() {
1308 let f = finding_id("src/a.ts", "foo", 42);
1309 let h = hot_path_id("src/a.ts", "foo", 42);
1310 assert_ne!(f[f.len() - 8..], h[h.len() - 8..]);
1311 }
1312
1313 #[test]
1314 fn finding_id_changes_with_line() {
1315 assert_ne!(
1316 finding_id("src/a.ts", "foo", 10),
1317 finding_id("src/a.ts", "foo", 11),
1318 );
1319 }
1320
1321 #[test]
1322 fn finding_id_changes_with_file() {
1323 assert_ne!(
1324 finding_id("src/a.ts", "foo", 42),
1325 finding_id("src/b.ts", "foo", 42),
1326 );
1327 }
1328
1329 #[test]
1330 fn finding_id_changes_with_function() {
1331 assert_ne!(
1332 finding_id("src/a.ts", "foo", 42),
1333 finding_id("src/a.ts", "bar", 42),
1334 );
1335 }
1336
1337 #[test]
1338 fn finding_id_is_lowercase_hex_ascii() {
1339 // Canonical form is lowercase hex — downstream dedup keys on string
1340 // equality, so an accidental uppercase switch would break persisted IDs.
1341 let id = finding_id("src/a.ts", "foo", 42);
1342 let hash = &id["fallow:prod:".len()..];
1343 assert!(
1344 hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')),
1345 "expected lowercase hex, got {hash}"
1346 );
1347 }
1348
1349 #[test]
1350 fn evidence_round_trips_with_untracked_reason() {
1351 let evidence = Evidence {
1352 static_status: "used".to_owned(),
1353 test_coverage: "not_covered".to_owned(),
1354 v8_tracking: "untracked".to_owned(),
1355 untracked_reason: Some("lazy_parsed".to_owned()),
1356 observation_days: 30,
1357 deployments_observed: 14,
1358 };
1359 let json = serde_json::to_string(&evidence).unwrap();
1360 assert!(json.contains("\"untracked_reason\":\"lazy_parsed\""));
1361 let back: Evidence = serde_json::from_str(&json).unwrap();
1362 assert_eq!(back.untracked_reason.as_deref(), Some("lazy_parsed"));
1363 }
1364
1365 #[test]
1366 fn static_function_requires_static_used_and_test_covered() {
1367 // Belt-and-suspenders: a 0.1-shape request (no static_used / test_covered)
1368 // must fail deserialization rather than silently defaulting to "used + covered"
1369 // which would hide every safe_to_delete finding.
1370 let json = r#"{"name":"foo","start_line":1,"end_line":2,"cyclomatic":1}"#;
1371 let result: Result<StaticFunction, _> = serde_json::from_str(json);
1372 let err = result
1373 .expect_err("missing static_used / test_covered must fail")
1374 .to_string();
1375 assert!(
1376 err.contains("static_used") || err.contains("test_covered"),
1377 "unexpected error text: {err}"
1378 );
1379 }
1380
1381 #[test]
1382 fn options_defaults_when_fields_omitted() {
1383 let json = "{}";
1384 let options: Options = serde_json::from_str(json).unwrap();
1385 assert!(!options.include_hot_paths);
1386 assert!(options.min_invocations_for_hot.is_none());
1387 assert!(options.min_observation_volume.is_none());
1388 assert!(options.low_traffic_threshold.is_none());
1389 assert!(options.trace_count.is_none());
1390 assert!(options.period_days.is_none());
1391 assert!(options.deployments_seen.is_none());
1392 }
1393
1394 #[test]
1395 fn stable_ids_are_distinct_by_kind() {
1396 let finding = finding_id("src/a.ts", "foo", 42);
1397 let hot = hot_path_id("src/a.ts", "foo", 42);
1398 let blast = blast_radius_id("src/a.ts", "foo", 42);
1399 let importance = importance_id("src/a.ts", "foo", 42);
1400 let function = function_identity_id("src/a.ts", "foo", 42);
1401 assert!(blast.starts_with("fallow:blast:"));
1402 assert!(importance.starts_with("fallow:importance:"));
1403 assert!(function.starts_with("fallow:fn:"));
1404 assert_eq!(blast.len(), "fallow:blast:".len() + 8);
1405 assert_eq!(importance.len(), "fallow:importance:".len() + 8);
1406 assert_eq!(function.len(), "fallow:fn:".len() + 16);
1407 let suffixes = [
1408 &finding[finding.len() - 8..],
1409 &hot[hot.len() - 8..],
1410 &blast[blast.len() - 8..],
1411 &importance[importance.len() - 8..],
1412 &function[function.len() - 8..],
1413 ];
1414 for (index, suffix) in suffixes.iter().enumerate() {
1415 assert!(
1416 suffixes.iter().skip(index + 1).all(|other| other != suffix),
1417 "ID suffix collision across finding kinds"
1418 );
1419 }
1420 }
1421
1422 #[test]
1423 fn evidence_omits_untracked_reason_when_none() {
1424 let evidence = Evidence {
1425 static_status: "unused".to_owned(),
1426 test_coverage: "covered".to_owned(),
1427 v8_tracking: "tracked".to_owned(),
1428 untracked_reason: None,
1429 observation_days: 30,
1430 deployments_observed: 14,
1431 };
1432 let json = serde_json::to_string(&evidence).unwrap();
1433 assert!(
1434 !json.contains("untracked_reason"),
1435 "expected untracked_reason omitted, got {json}"
1436 );
1437 }
1438
1439 // -- FunctionIdentity v2 (protocol 0.6.0) -----------------------------
1440
1441 fn fixture_identity_full() -> FunctionIdentity {
1442 let stable_id = function_identity_id("src/render.tsx", "render", 42);
1443 FunctionIdentity {
1444 file: "src/render.tsx".to_owned(),
1445 name: "render".to_owned(),
1446 start_line: 42,
1447 start_column: Some(5),
1448 end_line: Some(67),
1449 end_column: Some(2),
1450 source_hash: Some(source_hash_for(b"function render() {}")),
1451 resolution: IdentityResolution::Resolved,
1452 stable_id,
1453 }
1454 }
1455
1456 #[test]
1457 fn unknown_identity_resolution_round_trips() {
1458 let json = r#""future_state""#;
1459 let parsed: IdentityResolution = serde_json::from_str(json).unwrap();
1460 assert!(matches!(parsed, IdentityResolution::Unknown));
1461 }
1462
1463 #[test]
1464 fn function_identity_round_trips_with_all_fields_set() {
1465 let identity = fixture_identity_full();
1466 let json = serde_json::to_string(&identity).unwrap();
1467 let parsed: FunctionIdentity = serde_json::from_str(&json).unwrap();
1468 assert_eq!(identity, parsed);
1469 }
1470
1471 #[test]
1472 fn function_identity_omits_columns_when_none() {
1473 let identity = FunctionIdentity {
1474 file: "src/a.ts".to_owned(),
1475 name: "foo".to_owned(),
1476 start_line: 1,
1477 start_column: None,
1478 end_line: None,
1479 end_column: None,
1480 source_hash: None,
1481 resolution: IdentityResolution::Unresolved,
1482 stable_id: function_identity_id("src/a.ts", "foo", 1),
1483 };
1484 let json = serde_json::to_string(&identity).unwrap();
1485 assert!(
1486 !json.contains("start_column"),
1487 "expected start_column omitted, got {json}"
1488 );
1489 assert!(
1490 !json.contains("end_line"),
1491 "expected end_line omitted, got {json}"
1492 );
1493 assert!(
1494 !json.contains("end_column"),
1495 "expected end_column omitted, got {json}"
1496 );
1497 assert!(
1498 !json.contains("source_hash"),
1499 "expected source_hash omitted, got {json}"
1500 );
1501 }
1502
1503 #[test]
1504 fn function_identity_round_trips_with_some_columns() {
1505 let identity = FunctionIdentity {
1506 file: "src/b.ts".to_owned(),
1507 name: "bar".to_owned(),
1508 start_line: 10,
1509 start_column: Some(3),
1510 end_line: None,
1511 end_column: None,
1512 source_hash: None,
1513 resolution: IdentityResolution::Fallback,
1514 stable_id: function_identity_id("src/b.ts", "bar", 10),
1515 };
1516 let json = serde_json::to_string(&identity).unwrap();
1517 assert!(json.contains("\"start_column\":3"));
1518 assert!(!json.contains("end_line"));
1519 assert!(!json.contains("end_column"));
1520 let parsed: FunctionIdentity = serde_json::from_str(&json).unwrap();
1521 assert_eq!(identity, parsed);
1522 }
1523
1524 #[test]
1525 fn function_identity_id_is_deterministic() {
1526 let first = function_identity_id("src/a.ts", "foo", 42);
1527 let second = function_identity_id("src/a.ts", "foo", 42);
1528 assert_eq!(first, second);
1529 }
1530
1531 #[test]
1532 fn function_identity_id_changes_with_file() {
1533 assert_ne!(
1534 function_identity_id("src/a.ts", "foo", 42),
1535 function_identity_id("src/b.ts", "foo", 42),
1536 );
1537 }
1538
1539 #[test]
1540 fn function_identity_id_changes_with_name() {
1541 assert_ne!(
1542 function_identity_id("src/a.ts", "foo", 42),
1543 function_identity_id("src/a.ts", "bar", 42),
1544 );
1545 }
1546
1547 #[test]
1548 fn function_identity_id_changes_with_start_line() {
1549 assert_ne!(
1550 function_identity_id("src/a.ts", "foo", 10),
1551 function_identity_id("src/a.ts", "foo", 11),
1552 );
1553 }
1554
1555 #[test]
1556 fn function_identity_id_unchanged_by_columns() {
1557 // Cross-producer agreement test (BLOCK fix from panel review):
1558 // V8 producers without column info MUST produce the same
1559 // stable_id as Istanbul producers with column info, otherwise the
1560 // cross-surface join silently breaks.
1561 let no_columns = FunctionIdentity {
1562 file: "src/a.ts".to_owned(),
1563 name: "foo".to_owned(),
1564 start_line: 42,
1565 start_column: None,
1566 end_line: None,
1567 end_column: None,
1568 source_hash: None,
1569 resolution: IdentityResolution::Unresolved,
1570 stable_id: function_identity_id("src/a.ts", "foo", 42),
1571 };
1572 let with_columns = FunctionIdentity {
1573 file: "src/a.ts".to_owned(),
1574 name: "foo".to_owned(),
1575 start_line: 42,
1576 start_column: Some(5),
1577 end_line: Some(67),
1578 end_column: Some(2),
1579 source_hash: Some(source_hash_for(b"function foo() {}")),
1580 resolution: IdentityResolution::Resolved,
1581 stable_id: function_identity_id("src/a.ts", "foo", 42),
1582 };
1583 assert_eq!(no_columns.stable_id, with_columns.stable_id);
1584 assert_eq!(no_columns.stable_id, no_columns.stable_id_computed());
1585 assert_eq!(with_columns.stable_id, with_columns.stable_id_computed());
1586 }
1587
1588 #[test]
1589 fn function_identity_id_format_is_fallow_fn_16hex() {
1590 let id = function_identity_id("src/a.ts", "foo", 42);
1591 assert!(id.starts_with("fallow:fn:"));
1592 let hash = &id["fallow:fn:".len()..];
1593 assert_eq!(hash.len(), 16, "expected 16 hex chars, got {hash}");
1594 assert!(
1595 hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')),
1596 "expected lowercase hex, got {hash}"
1597 );
1598 }
1599
1600 #[test]
1601 fn function_identity_stable_id_matches_helper() {
1602 let identity = fixture_identity_full();
1603 assert_eq!(identity.stable_id, identity.stable_id_computed());
1604 }
1605
1606 #[test]
1607 fn function_identity_id_anchor_fixture() {
1608 // Conformance fixture: producers (fallow CLI, fallow-cov sidecar,
1609 // browser/node beacons) MUST run this exact input through their
1610 // own pipelines and obtain the same string. Divergence here means
1611 // the cross-surface join would silently break in production.
1612 assert_eq!(
1613 function_identity_id("src/render.tsx", "render", 42),
1614 "fallow:fn:cb4482d6aef7c79a",
1615 );
1616 }
1617
1618 #[test]
1619 #[expect(
1620 deprecated,
1621 reason = "grace-window helper is intentionally deprecated; this test pins its legacy 0.7.x output"
1622 )]
1623 fn function_identity_id_v1_preserves_pre_0_8_recipe() {
1624 // Grace window: the v1 helper MUST keep emitting the 0.7.x value so
1625 // consumers can match baselines written before the 0.8.0 recipe
1626 // change, and it MUST differ from the current recipe.
1627 assert_eq!(
1628 function_identity_id_v1("src/render.tsx", "render", 42),
1629 "fallow:fn:43629542",
1630 );
1631 assert_ne!(
1632 function_identity_id_v1("src/render.tsx", "render", 42),
1633 function_identity_id("src/render.tsx", "render", 42),
1634 );
1635 }
1636
1637 #[test]
1638 fn finding_without_identity_deserializes() {
1639 // 0.5-shape Finding (no identity field) must continue to parse
1640 // with identity: None for forward-compat with older sidecars.
1641 let json = r#"{
1642 "id": "fallow:prod:deadbeef",
1643 "file": "src/a.ts",
1644 "function": "foo",
1645 "line": 42,
1646 "verdict": "active",
1647 "invocations": 100,
1648 "confidence": "high",
1649 "evidence": {
1650 "static_status": "used",
1651 "test_coverage": "covered",
1652 "v8_tracking": "tracked",
1653 "observation_days": 30,
1654 "deployments_observed": 14
1655 }
1656 }"#;
1657 let finding: Finding = serde_json::from_str(json).unwrap();
1658 assert!(finding.identity.is_none());
1659 assert_eq!(finding.function, "foo");
1660 }
1661
1662 #[test]
1663 fn static_function_without_identity_deserializes() {
1664 // 0.5-shape StaticFunction (no identity field) must parse with
1665 // identity: None for forward-compat with older CLIs.
1666 let json = r#"{
1667 "name": "foo",
1668 "start_line": 1,
1669 "end_line": 5,
1670 "cyclomatic": 2,
1671 "static_used": true,
1672 "test_covered": false
1673 }"#;
1674 let func: StaticFunction = serde_json::from_str(json).unwrap();
1675 assert!(func.identity.is_none());
1676 }
1677
1678 #[test]
1679 fn hot_path_without_identity_deserializes() {
1680 // 0.5-shape HotPath (no identity field) must parse with
1681 // identity: None for forward-compat with older sidecars.
1682 let json = r#"{
1683 "id": "fallow:hot:deadbeef",
1684 "file": "src/a.ts",
1685 "function": "foo",
1686 "line": 42,
1687 "end_line": 67,
1688 "invocations": 1000,
1689 "percentile": 95
1690 }"#;
1691 let hot: HotPath = serde_json::from_str(json).unwrap();
1692 assert!(hot.identity.is_none());
1693 assert_eq!(hot.function, "foo");
1694 }
1695
1696 #[test]
1697 fn blast_radius_entry_without_identity_deserializes() {
1698 let json = r#"{
1699 "id": "fallow:blast:deadbeef",
1700 "file": "src/a.ts",
1701 "function": "foo",
1702 "line": 42,
1703 "caller_count": 10,
1704 "caller_count_weighted_by_traffic": 5000,
1705 "risk_band": "high"
1706 }"#;
1707 let entry: BlastRadiusEntry = serde_json::from_str(json).unwrap();
1708 assert!(entry.identity.is_none());
1709 assert_eq!(entry.caller_count, 10);
1710 }
1711
1712 #[test]
1713 fn importance_entry_without_identity_deserializes() {
1714 let json = r#"{
1715 "id": "fallow:importance:deadbeef",
1716 "file": "src/a.ts",
1717 "function": "foo",
1718 "line": 42,
1719 "invocations": 5000,
1720 "cyclomatic": 7,
1721 "owner_count": 2,
1722 "importance_score": 87.5,
1723 "reason": "high traffic, complex, narrowly owned"
1724 }"#;
1725 let entry: ImportanceEntry = serde_json::from_str(json).unwrap();
1726 assert!(entry.identity.is_none());
1727 assert!((entry.importance_score - 87.5).abs() < f64::EPSILON);
1728 }
1729
1730 #[test]
1731 fn stable_id_field_required_on_function_identity() {
1732 // stable_id is the canonical cross-surface join key; a missing
1733 // field would silently default to an empty string and every
1734 // downstream dedup keyed on stable_id would collapse to one
1735 // bucket. Locks the explicit non-default contract.
1736 let json = r#"{
1737 "file": "src/a.ts",
1738 "name": "foo",
1739 "start_line": 42,
1740 "resolution": "resolved"
1741 }"#;
1742 let result: Result<FunctionIdentity, _> = serde_json::from_str(json);
1743 let err = result
1744 .expect_err("missing stable_id must fail deserialization")
1745 .to_string();
1746 assert!(err.contains("stable_id"), "unexpected error text: {err}");
1747 }
1748
1749 #[test]
1750 fn identity_resolution_field_required_on_function_identity() {
1751 // resolution carries the source-map / fallback confidence signal
1752 // cloud aggregation relies on; a missing field would silently
1753 // default and hide the difference between Resolved and Unresolved.
1754 let json = r#"{
1755 "file": "src/a.ts",
1756 "name": "foo",
1757 "start_line": 42,
1758 "stable_id": "fallow:fn:0123456789abcdef"
1759 }"#;
1760 let result: Result<FunctionIdentity, _> = serde_json::from_str(json);
1761 let err = result
1762 .expect_err("missing resolution must fail deserialization")
1763 .to_string();
1764 assert!(err.contains("resolution"), "unexpected error text: {err}");
1765 }
1766
1767 #[test]
1768 fn unresolved_identity_with_columns_round_trips() {
1769 // Locks the "document, don't enforce" stance: the protocol's
1770 // rustdoc on IdentityResolution::Unresolved says columns should
1771 // be absent, but serde does not reject a non-conforming
1772 // producer that emits them anyway. Cloud / agent consumers
1773 // SHOULD ignore the columns when resolution == Unresolved.
1774 // A serde-time rejection would force every consumer to validate
1775 // and would not actually fix the producer; we tolerate and
1776 // document instead.
1777 let json = r#"{
1778 "file": "src/a.ts",
1779 "name": "foo",
1780 "start_line": 42,
1781 "start_column": 5,
1782 "resolution": "unresolved",
1783 "stable_id": "fallow:fn:0123456789abcdef"
1784 }"#;
1785 let parsed: FunctionIdentity = serde_json::from_str(json).unwrap();
1786 assert!(matches!(parsed.resolution, IdentityResolution::Unresolved));
1787 assert_eq!(parsed.start_column, Some(5));
1788 }
1789
1790 #[test]
1791 fn same_line_functions_distinct_by_identity_via_column_metadata() {
1792 // Two anonymous callbacks on the same line of the same file with
1793 // the same name collide on stable_id (intentional: cross-producer
1794 // join). Display surfaces disambiguate via the column metadata
1795 // which survives on the wire even though it does not enter the
1796 // hash. This is the explicit panel-review BLOCK fix: columns
1797 // ride along for display, NOT for hashing.
1798 let first = FunctionIdentity {
1799 file: "src/a.ts".to_owned(),
1800 name: "<anonymous>".to_owned(),
1801 start_line: 7,
1802 start_column: Some(12),
1803 end_line: Some(7),
1804 end_column: Some(40),
1805 source_hash: None,
1806 resolution: IdentityResolution::Resolved,
1807 stable_id: function_identity_id("src/a.ts", "<anonymous>", 7),
1808 };
1809 let second = FunctionIdentity {
1810 start_column: Some(50),
1811 end_column: Some(78),
1812 ..first.clone()
1813 };
1814 assert_eq!(first.stable_id, second.stable_id);
1815 assert_ne!(first.start_column, second.start_column);
1816 // Column metadata survives serde so display can disambiguate.
1817 let json_first = serde_json::to_string(&first).unwrap();
1818 let json_second = serde_json::to_string(&second).unwrap();
1819 assert_ne!(json_first, json_second);
1820 assert!(json_first.contains("\"start_column\":12"));
1821 assert!(json_second.contains("\"start_column\":50"));
1822 }
1823
1824 #[test]
1825 fn function_identity_full_json_shape_anchor_fixture() {
1826 // Byte-equal wire-shape pin (panel item 2). Catches silent
1827 // field-reorder regressions and skip_serializing_if drift on the
1828 // every-Option-Some path that the omits-when-none test cannot
1829 // catch in isolation. Producers and JSON-diff tooling consume this
1830 // exact byte sequence; changing the literal is a wire-shape break.
1831 let identity = fixture_identity_full();
1832 let json = serde_json::to_string(&identity).unwrap();
1833 assert_eq!(
1834 json,
1835 r#"{"file":"src/render.tsx","name":"render","start_line":42,"start_column":5,"end_line":67,"end_column":2,"source_hash":"e25ba02c5e53651f","resolution":"resolved","stable_id":"fallow:fn:cb4482d6aef7c79a"}"#,
1836 );
1837 }
1838
1839 #[test]
1840 fn function_identity_minimal_json_shape_anchor_fixture() {
1841 // Byte-equal wire-shape pin for the minimum required surface
1842 // (panel item 2 companion). The four skip_serializing_if Options
1843 // are absent. Pairs with the full-shape fixture above so a future
1844 // PR cannot regress either the Some path or the None path without
1845 // visibly editing a literal here.
1846 let identity = FunctionIdentity {
1847 file: "src/minimal.ts".to_owned(),
1848 name: "f".to_owned(),
1849 start_line: 1,
1850 start_column: None,
1851 end_line: None,
1852 end_column: None,
1853 source_hash: None,
1854 resolution: IdentityResolution::Resolved,
1855 stable_id: function_identity_id("src/minimal.ts", "f", 1),
1856 };
1857 let json = serde_json::to_string(&identity).unwrap();
1858 assert_eq!(
1859 json,
1860 r#"{"file":"src/minimal.ts","name":"f","start_line":1,"resolution":"resolved","stable_id":"fallow:fn:c919e9ed9a517375"}"#,
1861 );
1862 }
1863
1864 #[test]
1865 fn identity_resolution_unresolved_shape_fixture() {
1866 // Failed-join consumer fixture (panel cross-cutting item from
1867 // Diego and Aria). Documents the on-wire shape an MCP agent or
1868 // cloud aggregator sees when a producer could not resolve the
1869 // identity beyond file / name / start_line: columns and
1870 // source_hash MUST be absent, resolution MUST serialize as
1871 // "unresolved". The protocol documents this stance but does not
1872 // enforce it via serde; see IdentityResolution::Unresolved
1873 // rustdoc and unresolved_identity_with_columns_round_trips.
1874 let identity = FunctionIdentity {
1875 file: "src/unresolved.ts".to_owned(),
1876 name: "mystery_fn".to_owned(),
1877 start_line: 42,
1878 start_column: None,
1879 end_line: None,
1880 end_column: None,
1881 source_hash: None,
1882 resolution: IdentityResolution::Unresolved,
1883 stable_id: function_identity_id("src/unresolved.ts", "mystery_fn", 42),
1884 };
1885 let json = serde_json::to_string(&identity).unwrap();
1886 assert_eq!(
1887 json,
1888 r#"{"file":"src/unresolved.ts","name":"mystery_fn","start_line":42,"resolution":"unresolved","stable_id":"fallow:fn:b2a29712f84c4a6e"}"#,
1889 );
1890 }
1891
1892 #[test]
1893 fn function_identity_id_unchanged_by_start_column() {
1894 // Per-field stability assertion (panel item 5). The struct-level
1895 // function_identity_id_unchanged_by_columns test bundles all four
1896 // metadata fields; the per-field cases catch a future regression
1897 // where the helper accidentally starts hashing one specific
1898 // metadata field but not the others.
1899 let base = function_identity_id("src/stability.ts", "foo", 10);
1900 let with_start_column = FunctionIdentity {
1901 file: "src/stability.ts".to_owned(),
1902 name: "foo".to_owned(),
1903 start_line: 10,
1904 start_column: Some(7),
1905 end_line: None,
1906 end_column: None,
1907 source_hash: None,
1908 resolution: IdentityResolution::Fallback,
1909 stable_id: function_identity_id("src/stability.ts", "foo", 10),
1910 };
1911 assert_eq!(base, with_start_column.stable_id);
1912 assert_eq!(base, with_start_column.stable_id_computed());
1913 }
1914
1915 #[test]
1916 fn function_identity_id_unchanged_by_end_line() {
1917 let base = function_identity_id("src/stability.ts", "foo", 10);
1918 let with_end_line = FunctionIdentity {
1919 file: "src/stability.ts".to_owned(),
1920 name: "foo".to_owned(),
1921 start_line: 10,
1922 start_column: None,
1923 end_line: Some(99),
1924 end_column: None,
1925 source_hash: None,
1926 resolution: IdentityResolution::Fallback,
1927 stable_id: function_identity_id("src/stability.ts", "foo", 10),
1928 };
1929 assert_eq!(base, with_end_line.stable_id);
1930 assert_eq!(base, with_end_line.stable_id_computed());
1931 }
1932
1933 #[test]
1934 fn function_identity_id_unchanged_by_end_column() {
1935 let base = function_identity_id("src/stability.ts", "foo", 10);
1936 let with_end_column = FunctionIdentity {
1937 file: "src/stability.ts".to_owned(),
1938 name: "foo".to_owned(),
1939 start_line: 10,
1940 start_column: None,
1941 end_line: None,
1942 end_column: Some(42),
1943 source_hash: None,
1944 resolution: IdentityResolution::Fallback,
1945 stable_id: function_identity_id("src/stability.ts", "foo", 10),
1946 };
1947 assert_eq!(base, with_end_column.stable_id);
1948 assert_eq!(base, with_end_column.stable_id_computed());
1949 }
1950
1951 #[test]
1952 fn function_identity_id_unchanged_by_source_hash() {
1953 let base = function_identity_id("src/stability.ts", "foo", 10);
1954 let with_source_hash = FunctionIdentity {
1955 file: "src/stability.ts".to_owned(),
1956 name: "foo".to_owned(),
1957 start_line: 10,
1958 start_column: None,
1959 end_line: None,
1960 end_column: None,
1961 source_hash: Some(source_hash_for(b"function foo() { return 1; }")),
1962 resolution: IdentityResolution::Fallback,
1963 stable_id: function_identity_id("src/stability.ts", "foo", 10),
1964 };
1965 assert_eq!(base, with_source_hash.stable_id);
1966 assert_eq!(base, with_source_hash.stable_id_computed());
1967 }
1968
1969 #[test]
1970 fn source_hash_for_anchor_fixture() {
1971 // Conformance fixture for the pinned source_hash format added in
1972 // protocol 0.7.0. Producers (fallow CLI, fallow-cov sidecar,
1973 // browser / node beacons, Istanbul ingester) MUST run this exact
1974 // byte sequence through their own pipelines and obtain the same
1975 // 16-hex string. Divergence here means the cross-producer
1976 // tiebreaker would silently break in production.
1977 assert_eq!(
1978 source_hash_for(b"function foo() { return 1; }"),
1979 "74846e29a52fe863",
1980 );
1981 }
1982
1983 #[test]
1984 fn source_hash_for_is_deterministic() {
1985 let first = source_hash_for(b"const greet = (name: string) => `hi, ${name}`;\n");
1986 let second = source_hash_for(b"const greet = (name: string) => `hi, ${name}`;\n");
1987 assert_eq!(first, second);
1988 }
1989
1990 #[test]
1991 fn source_hash_for_differs_on_whitespace_change() {
1992 // The canonicalization rule says no whitespace normalization, so
1993 // two byte slices that differ ONLY by whitespace must produce
1994 // different hashes. Locks the no-normalization stance against any
1995 // future producer that quietly trims or collapses whitespace.
1996 let tight = source_hash_for(b"function foo(){return 1;}");
1997 let loose = source_hash_for(b"function foo() { return 1; }");
1998 assert_ne!(tight, loose);
1999 }
2000
2001 #[test]
2002 fn source_hash_for_format_is_sixteen_lowercase_hex() {
2003 let hash = source_hash_for(b"function foo() { return 1; }");
2004 assert_eq!(hash.len(), 16, "expected 16 hex chars, got {hash}");
2005 assert!(
2006 hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')),
2007 "expected lowercase hex, got {hash}",
2008 );
2009 }
2010
2011 #[test]
2012 fn source_hash_for_differs_from_sibling_id_helpers() {
2013 // Distinctness check parallel to the kind-salt assertions on
2014 // finding_id / hot_path_id / blast_radius_id / importance_id /
2015 // function_identity_id: source_hash_for hashes a different input
2016 // shape (body bytes, not file + name + line + kind salt) so its
2017 // output MUST NOT collide with any sibling ID helper's output for
2018 // any input. Locks the structural difference even though length
2019 // (16 vs 8 hex) and the absent `fallow:` prefix already make the
2020 // strings unambiguous.
2021 let body = b"function foo() {}";
2022 let source = source_hash_for(body);
2023 // Sibling helpers prefix `fallow:<kind>:`; source_hash carries no
2024 // prefix. Distinctness by construction.
2025 assert!(!source.contains(':'));
2026 assert_ne!(source, finding_id("src/x.ts", "foo", 1));
2027 assert_ne!(source, hot_path_id("src/x.ts", "foo", 1));
2028 assert_ne!(source, blast_radius_id("src/x.ts", "foo", 1));
2029 assert_ne!(source, importance_id("src/x.ts", "foo", 1));
2030 assert_ne!(source, function_identity_id("src/x.ts", "foo", 1));
2031 }
2032
2033 #[test]
2034 fn source_hash_for_no_fallow_prefix() {
2035 // source_hash is a content tiebreaker, not a qualified ID. The
2036 // "fallow:" prefix used by finding_id / hot_path_id / function_identity_id
2037 // exists to namespace cross-surface joins; source_hash is consumed
2038 // raw and MUST NOT carry the prefix.
2039 let hash = source_hash_for(b"function foo() { return 1; }");
2040 assert!(
2041 !hash.starts_with("fallow:"),
2042 "source_hash must not carry the fallow: prefix, got {hash}",
2043 );
2044 }
2045
2046 #[test]
2047 fn blast_radius_id_anchor_fixture() {
2048 // Conformance fixture parallel to function_identity_id_anchor_fixture.
2049 // Locks the canonical hash inputs + truncation for blast_radius_id
2050 // so producers can self-test agreement with the protocol.
2051 assert_eq!(
2052 blast_radius_id("src/blast.tsx", "handle", 100),
2053 "fallow:blast:d437d3d3",
2054 );
2055 }
2056
2057 #[test]
2058 fn importance_id_anchor_fixture() {
2059 // Conformance fixture parallel to function_identity_id_anchor_fixture.
2060 assert_eq!(
2061 importance_id("src/importance.tsx", "important", 5),
2062 "fallow:importance:38ee86d9",
2063 );
2064 }
2065}