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