Skip to main content

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