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