#![forbid(unsafe_code)]
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const PROTOCOL_VERSION: &str = "0.8.0";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Request {
pub protocol_version: String,
pub license: License,
pub project_root: String,
pub coverage_sources: Vec<CoverageSource>,
pub static_findings: StaticFindings,
#[serde(default)]
pub options: Options,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct License {
pub jwt: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum CoverageSource {
V8 {
path: String,
},
Istanbul {
path: String,
},
V8Dir {
path: String,
},
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StaticFindings {
pub files: Vec<StaticFile>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StaticFile {
pub path: String,
pub functions: Vec<StaticFunction>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StaticFunction {
pub name: String,
pub start_line: u32,
pub end_line: u32,
pub cyclomatic: u32,
pub static_used: bool,
pub test_covered: bool,
#[serde(default)]
pub caller_count: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_count: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<FunctionIdentity>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Options {
#[serde(default)]
pub include_hot_paths: bool,
#[serde(default)]
pub min_invocations_for_hot: Option<u64>,
#[serde(default)]
pub min_observation_volume: Option<u32>,
#[serde(default)]
pub low_traffic_threshold: Option<f64>,
#[serde(default)]
pub trace_count: Option<u64>,
#[serde(default)]
pub period_days: Option<u32>,
#[serde(default)]
pub deployments_seen: Option<u32>,
#[serde(default)]
pub window_seconds: Option<u64>,
#[serde(default)]
pub instances_observed: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Response {
pub protocol_version: String,
pub verdict: ReportVerdict,
pub summary: Summary,
pub findings: Vec<Finding>,
#[serde(default)]
pub hot_paths: Vec<HotPath>,
#[serde(default)]
pub blast_radius: Vec<BlastRadiusEntry>,
#[serde(default)]
pub importance: Vec<ImportanceEntry>,
#[serde(default)]
pub watermark: Option<Watermark>,
#[serde(default)]
pub errors: Vec<DiagnosticMessage>,
#[serde(default)]
pub warnings: Vec<DiagnosticMessage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReportVerdict {
Clean,
HotPathTouched,
ColdCodeDetected,
LicenseExpiredGrace,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Summary {
pub functions_tracked: u64,
pub functions_hit: u64,
pub functions_unhit: u64,
pub functions_untracked: u64,
pub coverage_percent: f64,
pub trace_count: u64,
pub period_days: u32,
pub deployments_seen: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capture_quality: Option<CaptureQuality>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CaptureQuality {
pub window_seconds: u64,
pub instances_observed: u32,
pub lazy_parse_warning: bool,
pub untracked_ratio_percent: f64,
}
impl CaptureQuality {
pub const LAZY_PARSE_THRESHOLD_PERCENT: f64 = 30.0;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Finding {
pub id: String,
pub file: String,
pub function: String,
pub line: u32,
pub verdict: Verdict,
pub invocations: Option<u64>,
pub confidence: Confidence,
pub evidence: Evidence,
#[serde(default)]
pub actions: Vec<Action>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<FunctionIdentity>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
SafeToDelete,
ReviewRequired,
CoverageUnavailable,
LowTraffic,
Active,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Confidence {
VeryHigh,
High,
Medium,
Low,
None,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IdentityResolution {
Resolved,
Fallback,
Unresolved,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FunctionIdentity {
pub file: String,
pub name: String,
pub start_line: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_column: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_line: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_column: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_hash: Option<String>,
pub resolution: IdentityResolution,
pub stable_id: String,
}
impl FunctionIdentity {
#[must_use]
pub fn stable_id_computed(&self) -> String {
function_identity_id(&self.file, &self.name, self.start_line)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Evidence {
pub static_status: String,
pub test_coverage: String,
pub v8_tracking: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub untracked_reason: Option<String>,
pub observation_days: u32,
pub deployments_observed: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct HotPath {
pub id: String,
pub file: String,
pub function: String,
pub line: u32,
#[serde(default)]
pub end_line: u32,
pub invocations: u64,
pub percentile: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<FunctionIdentity>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RiskBand {
Low,
Medium,
High,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct BlastRadiusEntry {
pub id: String,
pub file: String,
pub function: String,
pub line: u32,
pub caller_count: u32,
pub caller_count_weighted_by_traffic: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deploys_touched: Option<u32>,
pub risk_band: RiskBand,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<FunctionIdentity>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ImportanceEntry {
pub id: String,
pub file: String,
pub function: String,
pub line: u32,
pub invocations: u64,
pub cyclomatic: u32,
pub owner_count: u32,
pub importance_score: f64,
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<FunctionIdentity>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Action {
pub kind: String,
pub description: String,
#[serde(default)]
pub auto_fixable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Watermark {
TrialExpired,
LicenseExpiredGrace,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiagnosticMessage {
pub code: String,
pub message: String,
}
#[must_use]
pub fn finding_id(file: &str, function: &str, line: u32) -> String {
format!("fallow:prod:{}", content_hash(file, function, line, "prod"))
}
#[must_use]
pub fn hot_path_id(file: &str, function: &str, line: u32) -> String {
format!("fallow:hot:{}", content_hash(file, function, line, "hot"))
}
#[must_use]
pub fn blast_radius_id(file: &str, function: &str, line: u32) -> String {
format!(
"fallow:blast:{}",
content_hash(file, function, line, "blast")
)
}
#[must_use]
pub fn importance_id(file: &str, function: &str, line: u32) -> String {
format!(
"fallow:importance:{}",
content_hash(file, function, line, "importance")
)
}
#[must_use]
pub fn function_identity_id(file: &str, name: &str, start_line: u32) -> String {
let mut hasher = Sha256::new();
hasher.update(file.as_bytes());
hasher.update(b"\0");
hasher.update(name.as_bytes());
hasher.update(b"\0");
hasher.update(start_line.to_string().as_bytes());
let digest = hasher.finalize();
format!("fallow:fn:{}", hex_prefix(&digest, 8))
}
#[deprecated(
since = "0.8.0",
note = "pre-0.8.0 grace-window recipe; use function_identity_id for new ids"
)]
#[must_use]
pub fn function_identity_id_v1(file: &str, name: &str, start_line: u32) -> String {
let mut hasher = Sha256::new();
hasher.update(file.as_bytes());
hasher.update(name.as_bytes());
hasher.update(start_line.to_string().as_bytes());
hasher.update(b"function");
let digest = hasher.finalize();
format!("fallow:fn:{}", hex_prefix(&digest, 4))
}
#[must_use]
pub fn source_hash_for(body: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(body);
let digest = hasher.finalize();
hex_prefix(&digest, 8)
}
fn content_hash(file: &str, function: &str, line: u32, kind: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(file.as_bytes());
hasher.update(function.as_bytes());
hasher.update(line.to_string().as_bytes());
hasher.update(kind.as_bytes());
let digest = hasher.finalize();
hex_prefix(&digest, 4)
}
fn hex_prefix(digest: &[u8], bytes: usize) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes * 2);
for &byte in digest.iter().take(bytes) {
out.push(char::from(HEX[usize::from(byte >> 4)]));
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
out
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Feature {
ProductionCoverage,
PortfolioDashboard,
McpCloudTools,
CrossRepoAggregation,
#[serde(other)]
Unknown,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_constant_is_v0_8() {
assert!(PROTOCOL_VERSION.starts_with("0.8."));
}
#[test]
fn unknown_report_verdict_round_trips() {
let json = r#""something-new""#;
let verdict: ReportVerdict = serde_json::from_str(json).unwrap();
assert!(matches!(verdict, ReportVerdict::Unknown));
}
#[test]
fn unknown_verdict_round_trips() {
let json = r#""future_state""#;
let verdict: Verdict = serde_json::from_str(json).unwrap();
assert!(matches!(verdict, Verdict::Unknown));
}
#[test]
fn unknown_confidence_round_trips() {
let json = r#""ultra_high""#;
let confidence: Confidence = serde_json::from_str(json).unwrap();
assert!(matches!(confidence, Confidence::Unknown));
}
#[test]
fn unknown_feature_round_trips() {
let json = r#""future_feature""#;
let feature: Feature = serde_json::from_str(json).unwrap();
assert!(matches!(feature, Feature::Unknown));
}
#[test]
fn unknown_watermark_round_trips() {
let json = r#""something-else""#;
let watermark: Watermark = serde_json::from_str(json).unwrap();
assert!(matches!(watermark, Watermark::Unknown));
}
#[test]
fn unknown_risk_band_round_trips() {
let json = r#""critical""#;
let band: RiskBand = serde_json::from_str(json).unwrap();
assert!(matches!(band, RiskBand::Unknown));
}
#[test]
fn unknown_coverage_source_round_trips() {
let json = r#"{"kind":"trace-event","path":"/tmp/x.trace"}"#;
let src: CoverageSource = serde_json::from_str(json).unwrap();
assert!(matches!(src, CoverageSource::Unknown));
}
#[test]
fn coverage_source_kebab_case() {
let json = r#"{"kind":"v8-dir","path":"/tmp/dumps"}"#;
let src: CoverageSource = serde_json::from_str(json).unwrap();
assert!(matches!(src, CoverageSource::V8Dir { .. }));
}
#[test]
fn response_allows_unknown_fields() {
let json = r#"{
"protocol_version": "0.2.0",
"verdict": "clean",
"summary": {
"functions_tracked": 0,
"functions_hit": 0,
"functions_unhit": 0,
"functions_untracked": 0,
"coverage_percent": 0.0,
"trace_count": 0,
"period_days": 0,
"deployments_seen": 0
},
"findings": [],
"future_top_level_field": 42
}"#;
let response: Response = serde_json::from_str(json).unwrap();
assert_eq!(response.protocol_version, "0.2.0");
}
#[test]
fn finding_id_is_deterministic() {
let first = finding_id("src/a.ts", "foo", 42);
let second = finding_id("src/a.ts", "foo", 42);
assert_eq!(first, second);
assert!(first.starts_with("fallow:prod:"));
assert_eq!(first.len(), "fallow:prod:".len() + 8);
}
#[test]
fn capture_quality_round_trips() {
let q = CaptureQuality {
window_seconds: 720,
instances_observed: 1,
lazy_parse_warning: true,
untracked_ratio_percent: 42.5,
};
let json = serde_json::to_string(&q).unwrap();
let parsed: CaptureQuality = serde_json::from_str(&json).unwrap();
assert_eq!(q, parsed);
}
#[test]
fn summary_without_capture_quality_deserializes() {
let json = r#"{
"functions_tracked": 10,
"functions_hit": 5,
"functions_unhit": 5,
"functions_untracked": 0,
"coverage_percent": 50.0,
"trace_count": 100,
"period_days": 1,
"deployments_seen": 1
}"#;
let summary: Summary = serde_json::from_str(json).unwrap();
assert!(summary.capture_quality.is_none());
}
#[test]
fn summary_with_capture_quality_round_trips() {
let summary = Summary {
functions_tracked: 10,
functions_hit: 5,
functions_unhit: 5,
functions_untracked: 3,
coverage_percent: 50.0,
trace_count: 100,
period_days: 1,
deployments_seen: 1,
capture_quality: Some(CaptureQuality {
window_seconds: 720,
instances_observed: 1,
lazy_parse_warning: true,
untracked_ratio_percent: 30.0,
}),
};
let json = serde_json::to_string(&summary).unwrap();
let parsed: Summary = serde_json::from_str(&json).unwrap();
assert_eq!(summary.capture_quality, parsed.capture_quality);
}
#[test]
fn lazy_parse_threshold_is_30_percent() {
assert!((CaptureQuality::LAZY_PARSE_THRESHOLD_PERCENT - 30.0).abs() < f64::EPSILON);
}
#[test]
fn hot_path_id_differs_from_finding_id() {
let f = finding_id("src/a.ts", "foo", 42);
let h = hot_path_id("src/a.ts", "foo", 42);
assert_ne!(f[f.len() - 8..], h[h.len() - 8..]);
}
#[test]
fn finding_id_changes_with_line() {
assert_ne!(
finding_id("src/a.ts", "foo", 10),
finding_id("src/a.ts", "foo", 11),
);
}
#[test]
fn finding_id_changes_with_file() {
assert_ne!(
finding_id("src/a.ts", "foo", 42),
finding_id("src/b.ts", "foo", 42),
);
}
#[test]
fn finding_id_changes_with_function() {
assert_ne!(
finding_id("src/a.ts", "foo", 42),
finding_id("src/a.ts", "bar", 42),
);
}
#[test]
fn finding_id_is_lowercase_hex_ascii() {
let id = finding_id("src/a.ts", "foo", 42);
let hash = &id["fallow:prod:".len()..];
assert!(
hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')),
"expected lowercase hex, got {hash}"
);
}
#[test]
fn evidence_round_trips_with_untracked_reason() {
let evidence = Evidence {
static_status: "used".to_owned(),
test_coverage: "not_covered".to_owned(),
v8_tracking: "untracked".to_owned(),
untracked_reason: Some("lazy_parsed".to_owned()),
observation_days: 30,
deployments_observed: 14,
};
let json = serde_json::to_string(&evidence).unwrap();
assert!(json.contains("\"untracked_reason\":\"lazy_parsed\""));
let back: Evidence = serde_json::from_str(&json).unwrap();
assert_eq!(back.untracked_reason.as_deref(), Some("lazy_parsed"));
}
#[test]
fn static_function_requires_static_used_and_test_covered() {
let json = r#"{"name":"foo","start_line":1,"end_line":2,"cyclomatic":1}"#;
let result: Result<StaticFunction, _> = serde_json::from_str(json);
let err = result
.expect_err("missing static_used / test_covered must fail")
.to_string();
assert!(
err.contains("static_used") || err.contains("test_covered"),
"unexpected error text: {err}"
);
}
#[test]
fn options_defaults_when_fields_omitted() {
let json = "{}";
let options: Options = serde_json::from_str(json).unwrap();
assert!(!options.include_hot_paths);
assert!(options.min_invocations_for_hot.is_none());
assert!(options.min_observation_volume.is_none());
assert!(options.low_traffic_threshold.is_none());
assert!(options.trace_count.is_none());
assert!(options.period_days.is_none());
assert!(options.deployments_seen.is_none());
}
#[test]
fn stable_ids_are_distinct_by_kind() {
let finding = finding_id("src/a.ts", "foo", 42);
let hot = hot_path_id("src/a.ts", "foo", 42);
let blast = blast_radius_id("src/a.ts", "foo", 42);
let importance = importance_id("src/a.ts", "foo", 42);
let function = function_identity_id("src/a.ts", "foo", 42);
assert!(blast.starts_with("fallow:blast:"));
assert!(importance.starts_with("fallow:importance:"));
assert!(function.starts_with("fallow:fn:"));
assert_eq!(blast.len(), "fallow:blast:".len() + 8);
assert_eq!(importance.len(), "fallow:importance:".len() + 8);
assert_eq!(function.len(), "fallow:fn:".len() + 16);
let suffixes = [
&finding[finding.len() - 8..],
&hot[hot.len() - 8..],
&blast[blast.len() - 8..],
&importance[importance.len() - 8..],
&function[function.len() - 8..],
];
for (index, suffix) in suffixes.iter().enumerate() {
assert!(
suffixes.iter().skip(index + 1).all(|other| other != suffix),
"ID suffix collision across finding kinds"
);
}
}
#[test]
fn evidence_omits_untracked_reason_when_none() {
let evidence = Evidence {
static_status: "unused".to_owned(),
test_coverage: "covered".to_owned(),
v8_tracking: "tracked".to_owned(),
untracked_reason: None,
observation_days: 30,
deployments_observed: 14,
};
let json = serde_json::to_string(&evidence).unwrap();
assert!(
!json.contains("untracked_reason"),
"expected untracked_reason omitted, got {json}"
);
}
fn fixture_identity_full() -> FunctionIdentity {
let stable_id = function_identity_id("src/render.tsx", "render", 42);
FunctionIdentity {
file: "src/render.tsx".to_owned(),
name: "render".to_owned(),
start_line: 42,
start_column: Some(5),
end_line: Some(67),
end_column: Some(2),
source_hash: Some(source_hash_for(b"function render() {}")),
resolution: IdentityResolution::Resolved,
stable_id,
}
}
#[test]
fn unknown_identity_resolution_round_trips() {
let json = r#""future_state""#;
let parsed: IdentityResolution = serde_json::from_str(json).unwrap();
assert!(matches!(parsed, IdentityResolution::Unknown));
}
#[test]
fn function_identity_round_trips_with_all_fields_set() {
let identity = fixture_identity_full();
let json = serde_json::to_string(&identity).unwrap();
let parsed: FunctionIdentity = serde_json::from_str(&json).unwrap();
assert_eq!(identity, parsed);
}
#[test]
fn function_identity_omits_columns_when_none() {
let identity = FunctionIdentity {
file: "src/a.ts".to_owned(),
name: "foo".to_owned(),
start_line: 1,
start_column: None,
end_line: None,
end_column: None,
source_hash: None,
resolution: IdentityResolution::Unresolved,
stable_id: function_identity_id("src/a.ts", "foo", 1),
};
let json = serde_json::to_string(&identity).unwrap();
assert!(
!json.contains("start_column"),
"expected start_column omitted, got {json}"
);
assert!(
!json.contains("end_line"),
"expected end_line omitted, got {json}"
);
assert!(
!json.contains("end_column"),
"expected end_column omitted, got {json}"
);
assert!(
!json.contains("source_hash"),
"expected source_hash omitted, got {json}"
);
}
#[test]
fn function_identity_round_trips_with_some_columns() {
let identity = FunctionIdentity {
file: "src/b.ts".to_owned(),
name: "bar".to_owned(),
start_line: 10,
start_column: Some(3),
end_line: None,
end_column: None,
source_hash: None,
resolution: IdentityResolution::Fallback,
stable_id: function_identity_id("src/b.ts", "bar", 10),
};
let json = serde_json::to_string(&identity).unwrap();
assert!(json.contains("\"start_column\":3"));
assert!(!json.contains("end_line"));
assert!(!json.contains("end_column"));
let parsed: FunctionIdentity = serde_json::from_str(&json).unwrap();
assert_eq!(identity, parsed);
}
#[test]
fn function_identity_id_is_deterministic() {
let first = function_identity_id("src/a.ts", "foo", 42);
let second = function_identity_id("src/a.ts", "foo", 42);
assert_eq!(first, second);
}
#[test]
fn function_identity_id_changes_with_file() {
assert_ne!(
function_identity_id("src/a.ts", "foo", 42),
function_identity_id("src/b.ts", "foo", 42),
);
}
#[test]
fn function_identity_id_changes_with_name() {
assert_ne!(
function_identity_id("src/a.ts", "foo", 42),
function_identity_id("src/a.ts", "bar", 42),
);
}
#[test]
fn function_identity_id_changes_with_start_line() {
assert_ne!(
function_identity_id("src/a.ts", "foo", 10),
function_identity_id("src/a.ts", "foo", 11),
);
}
#[test]
fn function_identity_id_unchanged_by_columns() {
let no_columns = FunctionIdentity {
file: "src/a.ts".to_owned(),
name: "foo".to_owned(),
start_line: 42,
start_column: None,
end_line: None,
end_column: None,
source_hash: None,
resolution: IdentityResolution::Unresolved,
stable_id: function_identity_id("src/a.ts", "foo", 42),
};
let with_columns = FunctionIdentity {
file: "src/a.ts".to_owned(),
name: "foo".to_owned(),
start_line: 42,
start_column: Some(5),
end_line: Some(67),
end_column: Some(2),
source_hash: Some(source_hash_for(b"function foo() {}")),
resolution: IdentityResolution::Resolved,
stable_id: function_identity_id("src/a.ts", "foo", 42),
};
assert_eq!(no_columns.stable_id, with_columns.stable_id);
assert_eq!(no_columns.stable_id, no_columns.stable_id_computed());
assert_eq!(with_columns.stable_id, with_columns.stable_id_computed());
}
#[test]
fn function_identity_id_format_is_fallow_fn_16hex() {
let id = function_identity_id("src/a.ts", "foo", 42);
assert!(id.starts_with("fallow:fn:"));
let hash = &id["fallow:fn:".len()..];
assert_eq!(hash.len(), 16, "expected 16 hex chars, got {hash}");
assert!(
hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')),
"expected lowercase hex, got {hash}"
);
}
#[test]
fn function_identity_stable_id_matches_helper() {
let identity = fixture_identity_full();
assert_eq!(identity.stable_id, identity.stable_id_computed());
}
#[test]
fn function_identity_id_anchor_fixture() {
assert_eq!(
function_identity_id("src/render.tsx", "render", 42),
"fallow:fn:cb4482d6aef7c79a",
);
}
#[test]
#[expect(
deprecated,
reason = "grace-window helper is intentionally deprecated; this test pins its legacy 0.7.x output"
)]
fn function_identity_id_v1_preserves_pre_0_8_recipe() {
assert_eq!(
function_identity_id_v1("src/render.tsx", "render", 42),
"fallow:fn:43629542",
);
assert_ne!(
function_identity_id_v1("src/render.tsx", "render", 42),
function_identity_id("src/render.tsx", "render", 42),
);
}
#[test]
fn finding_without_identity_deserializes() {
let json = r#"{
"id": "fallow:prod:deadbeef",
"file": "src/a.ts",
"function": "foo",
"line": 42,
"verdict": "active",
"invocations": 100,
"confidence": "high",
"evidence": {
"static_status": "used",
"test_coverage": "covered",
"v8_tracking": "tracked",
"observation_days": 30,
"deployments_observed": 14
}
}"#;
let finding: Finding = serde_json::from_str(json).unwrap();
assert!(finding.identity.is_none());
assert_eq!(finding.function, "foo");
}
#[test]
fn static_function_without_identity_deserializes() {
let json = r#"{
"name": "foo",
"start_line": 1,
"end_line": 5,
"cyclomatic": 2,
"static_used": true,
"test_covered": false
}"#;
let func: StaticFunction = serde_json::from_str(json).unwrap();
assert!(func.identity.is_none());
}
#[test]
fn hot_path_without_identity_deserializes() {
let json = r#"{
"id": "fallow:hot:deadbeef",
"file": "src/a.ts",
"function": "foo",
"line": 42,
"end_line": 67,
"invocations": 1000,
"percentile": 95
}"#;
let hot: HotPath = serde_json::from_str(json).unwrap();
assert!(hot.identity.is_none());
assert_eq!(hot.function, "foo");
}
#[test]
fn blast_radius_entry_without_identity_deserializes() {
let json = r#"{
"id": "fallow:blast:deadbeef",
"file": "src/a.ts",
"function": "foo",
"line": 42,
"caller_count": 10,
"caller_count_weighted_by_traffic": 5000,
"risk_band": "high"
}"#;
let entry: BlastRadiusEntry = serde_json::from_str(json).unwrap();
assert!(entry.identity.is_none());
assert_eq!(entry.caller_count, 10);
}
#[test]
fn importance_entry_without_identity_deserializes() {
let json = r#"{
"id": "fallow:importance:deadbeef",
"file": "src/a.ts",
"function": "foo",
"line": 42,
"invocations": 5000,
"cyclomatic": 7,
"owner_count": 2,
"importance_score": 87.5,
"reason": "high traffic, complex, narrowly owned"
}"#;
let entry: ImportanceEntry = serde_json::from_str(json).unwrap();
assert!(entry.identity.is_none());
assert!((entry.importance_score - 87.5).abs() < f64::EPSILON);
}
#[test]
fn stable_id_field_required_on_function_identity() {
let json = r#"{
"file": "src/a.ts",
"name": "foo",
"start_line": 42,
"resolution": "resolved"
}"#;
let result: Result<FunctionIdentity, _> = serde_json::from_str(json);
let err = result
.expect_err("missing stable_id must fail deserialization")
.to_string();
assert!(err.contains("stable_id"), "unexpected error text: {err}");
}
#[test]
fn identity_resolution_field_required_on_function_identity() {
let json = r#"{
"file": "src/a.ts",
"name": "foo",
"start_line": 42,
"stable_id": "fallow:fn:0123456789abcdef"
}"#;
let result: Result<FunctionIdentity, _> = serde_json::from_str(json);
let err = result
.expect_err("missing resolution must fail deserialization")
.to_string();
assert!(err.contains("resolution"), "unexpected error text: {err}");
}
#[test]
fn unresolved_identity_with_columns_round_trips() {
let json = r#"{
"file": "src/a.ts",
"name": "foo",
"start_line": 42,
"start_column": 5,
"resolution": "unresolved",
"stable_id": "fallow:fn:0123456789abcdef"
}"#;
let parsed: FunctionIdentity = serde_json::from_str(json).unwrap();
assert!(matches!(parsed.resolution, IdentityResolution::Unresolved));
assert_eq!(parsed.start_column, Some(5));
}
#[test]
fn same_line_functions_distinct_by_identity_via_column_metadata() {
let first = FunctionIdentity {
file: "src/a.ts".to_owned(),
name: "<anonymous>".to_owned(),
start_line: 7,
start_column: Some(12),
end_line: Some(7),
end_column: Some(40),
source_hash: None,
resolution: IdentityResolution::Resolved,
stable_id: function_identity_id("src/a.ts", "<anonymous>", 7),
};
let second = FunctionIdentity {
start_column: Some(50),
end_column: Some(78),
..first.clone()
};
assert_eq!(first.stable_id, second.stable_id);
assert_ne!(first.start_column, second.start_column);
let json_first = serde_json::to_string(&first).unwrap();
let json_second = serde_json::to_string(&second).unwrap();
assert_ne!(json_first, json_second);
assert!(json_first.contains("\"start_column\":12"));
assert!(json_second.contains("\"start_column\":50"));
}
#[test]
fn function_identity_full_json_shape_anchor_fixture() {
let identity = fixture_identity_full();
let json = serde_json::to_string(&identity).unwrap();
assert_eq!(
json,
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"}"#,
);
}
#[test]
fn function_identity_minimal_json_shape_anchor_fixture() {
let identity = FunctionIdentity {
file: "src/minimal.ts".to_owned(),
name: "f".to_owned(),
start_line: 1,
start_column: None,
end_line: None,
end_column: None,
source_hash: None,
resolution: IdentityResolution::Resolved,
stable_id: function_identity_id("src/minimal.ts", "f", 1),
};
let json = serde_json::to_string(&identity).unwrap();
assert_eq!(
json,
r#"{"file":"src/minimal.ts","name":"f","start_line":1,"resolution":"resolved","stable_id":"fallow:fn:c919e9ed9a517375"}"#,
);
}
#[test]
fn identity_resolution_unresolved_shape_fixture() {
let identity = FunctionIdentity {
file: "src/unresolved.ts".to_owned(),
name: "mystery_fn".to_owned(),
start_line: 42,
start_column: None,
end_line: None,
end_column: None,
source_hash: None,
resolution: IdentityResolution::Unresolved,
stable_id: function_identity_id("src/unresolved.ts", "mystery_fn", 42),
};
let json = serde_json::to_string(&identity).unwrap();
assert_eq!(
json,
r#"{"file":"src/unresolved.ts","name":"mystery_fn","start_line":42,"resolution":"unresolved","stable_id":"fallow:fn:b2a29712f84c4a6e"}"#,
);
}
#[test]
fn function_identity_id_unchanged_by_start_column() {
let base = function_identity_id("src/stability.ts", "foo", 10);
let with_start_column = FunctionIdentity {
file: "src/stability.ts".to_owned(),
name: "foo".to_owned(),
start_line: 10,
start_column: Some(7),
end_line: None,
end_column: None,
source_hash: None,
resolution: IdentityResolution::Fallback,
stable_id: function_identity_id("src/stability.ts", "foo", 10),
};
assert_eq!(base, with_start_column.stable_id);
assert_eq!(base, with_start_column.stable_id_computed());
}
#[test]
fn function_identity_id_unchanged_by_end_line() {
let base = function_identity_id("src/stability.ts", "foo", 10);
let with_end_line = FunctionIdentity {
file: "src/stability.ts".to_owned(),
name: "foo".to_owned(),
start_line: 10,
start_column: None,
end_line: Some(99),
end_column: None,
source_hash: None,
resolution: IdentityResolution::Fallback,
stable_id: function_identity_id("src/stability.ts", "foo", 10),
};
assert_eq!(base, with_end_line.stable_id);
assert_eq!(base, with_end_line.stable_id_computed());
}
#[test]
fn function_identity_id_unchanged_by_end_column() {
let base = function_identity_id("src/stability.ts", "foo", 10);
let with_end_column = FunctionIdentity {
file: "src/stability.ts".to_owned(),
name: "foo".to_owned(),
start_line: 10,
start_column: None,
end_line: None,
end_column: Some(42),
source_hash: None,
resolution: IdentityResolution::Fallback,
stable_id: function_identity_id("src/stability.ts", "foo", 10),
};
assert_eq!(base, with_end_column.stable_id);
assert_eq!(base, with_end_column.stable_id_computed());
}
#[test]
fn function_identity_id_unchanged_by_source_hash() {
let base = function_identity_id("src/stability.ts", "foo", 10);
let with_source_hash = FunctionIdentity {
file: "src/stability.ts".to_owned(),
name: "foo".to_owned(),
start_line: 10,
start_column: None,
end_line: None,
end_column: None,
source_hash: Some(source_hash_for(b"function foo() { return 1; }")),
resolution: IdentityResolution::Fallback,
stable_id: function_identity_id("src/stability.ts", "foo", 10),
};
assert_eq!(base, with_source_hash.stable_id);
assert_eq!(base, with_source_hash.stable_id_computed());
}
#[test]
fn source_hash_for_anchor_fixture() {
assert_eq!(
source_hash_for(b"function foo() { return 1; }"),
"74846e29a52fe863",
);
}
#[test]
fn source_hash_for_is_deterministic() {
let first = source_hash_for(b"const greet = (name: string) => `hi, ${name}`;\n");
let second = source_hash_for(b"const greet = (name: string) => `hi, ${name}`;\n");
assert_eq!(first, second);
}
#[test]
fn source_hash_for_differs_on_whitespace_change() {
let tight = source_hash_for(b"function foo(){return 1;}");
let loose = source_hash_for(b"function foo() { return 1; }");
assert_ne!(tight, loose);
}
#[test]
fn source_hash_for_format_is_sixteen_lowercase_hex() {
let hash = source_hash_for(b"function foo() { return 1; }");
assert_eq!(hash.len(), 16, "expected 16 hex chars, got {hash}");
assert!(
hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')),
"expected lowercase hex, got {hash}",
);
}
#[test]
fn source_hash_for_differs_from_sibling_id_helpers() {
let body = b"function foo() {}";
let source = source_hash_for(body);
assert!(!source.contains(':'));
assert_ne!(source, finding_id("src/x.ts", "foo", 1));
assert_ne!(source, hot_path_id("src/x.ts", "foo", 1));
assert_ne!(source, blast_radius_id("src/x.ts", "foo", 1));
assert_ne!(source, importance_id("src/x.ts", "foo", 1));
assert_ne!(source, function_identity_id("src/x.ts", "foo", 1));
}
#[test]
fn source_hash_for_no_fallow_prefix() {
let hash = source_hash_for(b"function foo() { return 1; }");
assert!(
!hash.starts_with("fallow:"),
"source_hash must not carry the fallow: prefix, got {hash}",
);
}
#[test]
fn blast_radius_id_anchor_fixture() {
assert_eq!(
blast_radius_id("src/blast.tsx", "handle", 100),
"fallow:blast:d437d3d3",
);
}
#[test]
fn importance_id_anchor_fixture() {
assert_eq!(
importance_id("src/importance.tsx", "important", 5),
"fallow:importance:38ee86d9",
);
}
}