use std::path::Path;
use std::process::ExitCode;
use fallow_config::{FallowConfig, OutputFormat};
use fallow_output::{GroupByMode, PrDecisionConclusion};
use crate::report::ci::pr_comment::Provider;
use crate::report::github_annotations::{self, EnvelopeKind};
use crate::report::github_summary;
use crate::telemetry;
pub fn run_report(
from: &Path,
output: OutputFormat,
root: &Path,
config_path: Option<&Path>,
) -> ExitCode {
if let Some(path) = config_path
&& let Err(error) = FallowConfig::load(path)
{
return crate::emit_known_failure(
&format!("failed to load report config {}: {error}", path.display()),
2,
output,
telemetry::FailureReason::Validation,
);
}
let target = match output {
OutputFormat::GithubAnnotations => ReportTarget::GithubAnnotations,
OutputFormat::GithubSummary => ReportTarget::GithubSummary,
OutputFormat::CodeClimate => ReportTarget::CodeClimate,
OutputFormat::Sarif => ReportTarget::Sarif,
OutputFormat::PrCommentGithub => ReportTarget::PrComment(Provider::Github),
OutputFormat::PrCommentGitlab => ReportTarget::PrComment(Provider::Gitlab),
OutputFormat::ReviewGithub => ReportTarget::Review(Provider::Github),
OutputFormat::ReviewGitlab => ReportTarget::Review(Provider::Gitlab),
_ => {
return crate::emit_known_failure(
"fallow report supports --format github-annotations, github-summary, codeclimate, sarif, pr-comment-github, pr-comment-gitlab, review-github, or review-gitlab only",
2,
output,
telemetry::FailureReason::UnsupportedFormat,
);
}
};
let envelope = match load_envelope(from, output) {
Ok(envelope) => envelope,
Err(code) => return code,
};
let saved = match prepare_saved_envelope(envelope, output) {
Ok(saved) => saved,
Err(code) => return code,
};
let kind = match envelope_kind(&saved.envelope, from, output) {
Ok(kind) => kind,
Err(code) => return code,
};
if let Err(error) = validate_report_target(target, kind) {
return crate::emit_known_failure(
&error,
2,
output,
telemetry::FailureReason::UnsupportedFormat,
);
}
if let Err(error) = validate_saved_report_envelope(kind, &saved.envelope) {
return crate::emit_known_failure(&error, 2, output, telemetry::FailureReason::Validation);
}
let resolver = match saved_group_resolver(saved.grouped_by, root, config_path, output) {
Ok(resolver) => resolver,
Err(code) => return code,
};
if !matches!(
target,
ReportTarget::GithubAnnotations | ReportTarget::GithubSummary
) {
crate::report::sarif::note_saved_severity_fallback(
kind,
&saved.envelope,
root,
config_path,
);
}
match target {
ReportTarget::GithubAnnotations => {
github_annotations::print_annotations(kind, &saved.envelope, root)
}
ReportTarget::GithubSummary => github_summary::print_summary(kind, &saved.envelope, root),
ReportTarget::CodeClimate => {
crate::report::codeclimate::print_envelope_codeclimate_with_config(
kind,
&saved.envelope,
root,
config_path,
resolver.as_ref(),
)
}
ReportTarget::Sarif => crate::report::sarif::print_envelope_sarif_with_config(
kind,
&saved.envelope,
root,
config_path,
resolver.as_ref(),
),
ReportTarget::PrComment(provider) | ReportTarget::Review(provider) => {
render_saved_ci_target(
target,
provider,
kind,
&saved.envelope,
root,
config_path,
resolver.as_ref(),
output,
)
}
}
}
fn validate_report_target(target: ReportTarget, kind: EnvelopeKind) -> Result<(), String> {
if matches!(target, ReportTarget::CodeClimate) && kind == EnvelopeKind::Security {
return Err(
"fallow security supports --format human, json, sarif, github-annotations, or github-summary only."
.to_owned(),
);
}
if matches!(target, ReportTarget::PrComment(_) | ReportTarget::Review(_))
&& matches!(kind, EnvelopeKind::Security | EnvelopeKind::Fix)
{
return Err(format!(
"saved {} envelopes do not support --format {}",
command_label(kind),
report_target_label(target)
));
}
Ok(())
}
fn validate_saved_report_envelope(
kind: EnvelopeKind,
envelope: &serde_json::Value,
) -> Result<(), String> {
match kind {
EnvelopeKind::Security => fallow_output::validate_saved_security_envelope(envelope),
EnvelopeKind::Fix => Ok(()),
_ => crate::report::codeclimate::validate_saved_schema(kind, envelope),
}
}
#[expect(
clippy::too_many_arguments,
reason = "saved CI dispatch carries the parsed envelope context without rebuilding it"
)]
fn render_saved_ci_target(
target: ReportTarget,
provider: Provider,
kind: EnvelopeKind,
envelope: &serde_json::Value,
root: &Path,
config_path: Option<&Path>,
resolver: Option<&crate::report::OwnershipResolver>,
output: OutputFormat,
) -> ExitCode {
let issues = match crate::report::codeclimate::envelope_codeclimate_issues_with_config(
kind,
envelope,
root,
config_path,
resolver,
) {
Ok(issues) => issues,
Err(error) => {
return crate::emit_known_failure(
&error,
2,
output,
telemetry::FailureReason::Validation,
);
}
};
let (conclusion, status_message) = match saved_ci_conclusion(kind, envelope) {
Ok(value) => value,
Err(error) => {
return crate::emit_known_failure(
&error,
2,
output,
telemetry::FailureReason::Validation,
);
}
};
let command = command_label(kind);
let status_message = saved_status_message(
envelope,
status_message,
resolver.map(crate::report::OwnershipResolver::mode_label),
);
let status_message = status_message.as_deref();
match target {
ReportTarget::PrComment(_) => {
crate::report::ci::pr_comment::print_pr_comment_from_codeclimate_issues(
command,
provider,
&issues,
conclusion,
crate::report::ci::pr_comment::PrCommentStatus {
message: status_message,
gates: &crate::report::gate_outcome_text::gate_rows(envelope),
},
)
}
ReportTarget::Review(_) => match conclusion {
Some(conclusion) => {
crate::report::ci::review::print_review_envelope_from_codeclimate_issues_with_conclusion(
command,
provider,
&issues,
conclusion,
status_message,
)
}
None => crate::report::ci::review::print_review_envelope_from_codeclimate_issues(
command,
provider,
&issues,
status_message,
),
},
_ => unreachable!("saved CI target dispatch only accepts comment and review targets"),
}
}
pub fn envelope_status_note(
kind: EnvelopeKind,
envelope: &serde_json::Value,
grouping_dropped: Option<&str>,
) -> Result<Option<String>, String> {
let (_, existing) = saved_ci_conclusion(kind, envelope)?;
Ok(saved_status_message(envelope, existing, grouping_dropped))
}
fn saved_status_message(
envelope: &serde_json::Value,
existing: Option<&'static str>,
grouping_dropped: Option<&str>,
) -> Option<String> {
crate::report::join_status_clauses(&[
existing,
crate::report::baseline_advisory_text::advisory_line(envelope).as_deref(),
crate::report::gate_outcome_text::summary_line(envelope).as_deref(),
crate::report::request_outcome_text::summary_line_for_saved_render(envelope).as_deref(),
grouping_dropped
.map(crate::report::grouping_note::dropped_grouping_clause)
.as_deref(),
])
}
#[cfg(test)]
mod status_note_tests {
use fallow_output::{
BaselineScopeReasons, BaselineStaleness, BaselineStalenessAdvisory, GateName, GateOutcome,
GateOutcomes, GateStatus,
};
fn rotted_baseline() -> BaselineStaleness {
BaselineStaleness {
baseline_entries: 8,
matched_entries: 0,
stale_entries: 8,
current_findings: 6,
change_scoped: false,
stale: true,
warning: BaselineStalenessAdvisory::ZeroOverlap,
gate_trips: true,
moved_entries: 0,
unrecognised_format: false,
saved_by: None,
scope_reasons: BaselineScopeReasons::empty(),
}
}
fn gates() -> GateOutcomes {
let mut gates = GateOutcomes::new();
gates.insert(
GateName::Regression,
GateOutcome::measured(GateStatus::Fail, true, 5.0, 0.0),
);
gates
}
fn requests() -> fallow_output::RequestOutcomes {
let mut requests = fallow_output::RequestOutcomes::new();
requests.insert(
fallow_output::RequestName::ChangedSince,
fallow_output::RequestOutcome::not_applied(
fallow_output::RequestName::ChangedSince,
"origin/main",
"invalid-ref",
"--changed-since 'origin/main' was ignored.",
),
);
requests.insert(
fallow_output::RequestName::DiffFilter,
fallow_output::RequestOutcome::applied(
fallow_output::RequestName::DiffFilter,
"--diff-file pr.diff",
),
);
requests
}
#[test]
fn the_live_and_saved_notes_agree() {
let gates = gates();
let envelope = serde_json::json!({ "gate_outcomes": gates });
assert_eq!(
super::saved_status_message(&envelope, None, None),
crate::report::ci_status_note(None, None, Some(&gates), None, None),
);
assert_eq!(
super::saved_status_message(&envelope, Some("Note."), None),
crate::report::ci_status_note(Some("Note."), None, Some(&gates), None, None),
);
}
#[test]
fn the_live_and_saved_notes_agree_on_every_clause() {
let staleness = rotted_baseline();
let mut gates = GateOutcomes::new();
gates.insert(
GateName::StaleBaseline,
GateOutcome::new(GateStatus::Fail, true),
);
let envelope = serde_json::json!({
"baseline_staleness": staleness,
"gate_outcomes": gates
});
let advisory =
crate::report::baseline_advisory_text::advisory_line_for_staleness(Some(&staleness));
let saved = super::saved_status_message(&envelope, Some("Note."), None)
.expect("three clauses are present");
assert_eq!(
Some(saved.clone()),
crate::report::ci_status_note(
Some("Note."),
advisory.as_deref(),
Some(&gates),
None,
None
),
);
assert!(
saved.starts_with("Note. **Baseline matched nothing.**"),
"{saved}"
);
assert!(
saved.ends_with("Gate outcomes: failed stale-baseline."),
"{saved}"
);
}
#[test]
fn the_live_and_saved_grouping_notes_agree() {
let envelope = serde_json::json!({ "kind": "health" });
let saved = super::saved_status_message(&envelope, None, Some("owner"))
.expect("a grouped render has something to say");
assert_eq!(
Some(saved.clone()),
crate::report::ci_status_note(None, None, None, None, Some("owner")),
);
assert!(
saved.contains("--group-by owner was requested"),
"the requested mode reaches the rendered body: {saved}"
);
assert!(super::saved_status_message(&envelope, None, None).is_none());
}
#[test]
fn the_live_and_saved_request_notes_agree() {
let requests = requests();
let envelope = serde_json::json!({ "request_outcomes": requests });
assert_eq!(
super::saved_status_message(&envelope, None, None),
crate::report::ci_status_note(None, None, None, Some(&requests), None),
);
let gates = gates();
let both = serde_json::json!({
"gate_outcomes": gates,
"request_outcomes": requests,
});
let saved = super::saved_status_message(&both, Some("Note."), Some("package"))
.expect("a note for a run with something to say");
assert_eq!(
Some(saved.clone()),
crate::report::ci_status_note(
Some("Note."),
None,
Some(&gates),
Some(&requests),
Some("package")
),
);
assert!(
saved.starts_with("Note. Gate outcomes:"),
"the type-aware message keeps its place ahead of the verdicts: {saved}"
);
assert!(
saved.contains("Request outcomes: not applied changed-since (invalid-ref)"),
"an unapplied request reaches the rendered body: {saved}"
);
assert!(
saved.ends_with("run --format json for the grouped envelope."),
"the grouping clause comes last: {saved}"
);
}
#[test]
fn the_live_and_saved_notes_agree_on_all_five_clauses() {
let staleness = rotted_baseline();
let mut gates = GateOutcomes::new();
gates.insert(
GateName::StaleBaseline,
GateOutcome::new(GateStatus::Fail, true),
);
let requests = requests();
let envelope = serde_json::json!({
"baseline_staleness": staleness,
"gate_outcomes": gates,
"request_outcomes": requests,
});
let advisory =
crate::report::baseline_advisory_text::advisory_line_for_staleness(Some(&staleness));
let saved = super::saved_status_message(&envelope, Some("Note."), Some("package"))
.expect("every clause is present");
assert_eq!(
Some(saved.clone()),
crate::report::ci_status_note(
Some("Note."),
advisory.as_deref(),
Some(&gates),
Some(&requests),
Some("package")
),
);
let baseline = saved
.find("**Baseline matched nothing.**")
.expect("the baseline advisory");
let gate = saved.find("Gate outcomes:").expect("the gate inventory");
let request = saved
.find("Request outcomes:")
.expect("the request inventory");
let grouping = saved.find("Grouping: --group-by").expect("the grouping");
assert!(saved.starts_with("Note. "), "{saved}");
assert!(
baseline < gate && gate < request && request < grouping,
"{saved}"
);
}
#[test]
fn an_envelope_with_no_verdict_carries_no_note() {
let envelope = serde_json::json!({ "kind": "dead-code" });
assert!(super::saved_status_message(&envelope, None, None).is_none());
assert!(crate::report::ci_status_note(None, None, None, None, None).is_none());
}
}
fn saved_ci_conclusion(
kind: EnvelopeKind,
envelope: &serde_json::Value,
) -> Result<(Option<PrDecisionConclusion>, Option<&'static str>), String> {
let type_aware = crate::report::ci::saved_type_aware_metadata(envelope)?;
if type_aware
.iter()
.any(|meta| crate::report::ci::required_type_aware_incomplete(Some(meta)))
{
return Ok((
Some(PrDecisionConclusion::Failure),
Some(crate::report::ci::TYPE_AWARE_INCOMPLETE_MESSAGE),
));
}
if kind != EnvelopeKind::Audit {
return Ok((None, None));
}
let verdict = envelope
.get("verdict")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "saved audit envelope is missing its `verdict`".to_owned())?;
let conclusion = match verdict {
"pass" => PrDecisionConclusion::Success,
"warn" => PrDecisionConclusion::Neutral,
"fail" => PrDecisionConclusion::Failure,
other => {
return Err(format!(
"saved audit envelope has unsupported verdict `{other}`"
));
}
};
Ok((Some(conclusion), None))
}
const fn command_label(kind: EnvelopeKind) -> &'static str {
match kind {
EnvelopeKind::DeadCode => "dead-code",
EnvelopeKind::Dupes => "dupes",
EnvelopeKind::Health => "health",
EnvelopeKind::Audit => "audit",
EnvelopeKind::Combined => "combined",
EnvelopeKind::Security => "security",
EnvelopeKind::Fix => "fix",
}
}
const fn report_target_label(target: ReportTarget) -> &'static str {
match target {
ReportTarget::PrComment(Provider::Github) => "pr-comment-github",
ReportTarget::PrComment(Provider::Gitlab) => "pr-comment-gitlab",
ReportTarget::Review(Provider::Github) => "review-github",
ReportTarget::Review(Provider::Gitlab) => "review-gitlab",
ReportTarget::GithubAnnotations => "github-annotations",
ReportTarget::GithubSummary => "github-summary",
ReportTarget::CodeClimate => "codeclimate",
ReportTarget::Sarif => "sarif",
}
}
#[derive(Debug)]
struct SavedEnvelope {
envelope: serde_json::Value,
grouped_by: Option<GroupByMode>,
}
pub const NORMALIZED_GROUPED_DEAD_CODE_MARKER: &str = "_fallow_report_normalized_grouped_dead_code";
fn prepare_saved_envelope(
envelope: serde_json::Value,
output: OutputFormat,
) -> Result<SavedEnvelope, ExitCode> {
normalize_saved_envelope(envelope).map_err(|error| {
crate::emit_known_failure(&error, 2, output, telemetry::FailureReason::Validation)
})
}
fn normalize_saved_envelope(mut envelope: serde_json::Value) -> Result<SavedEnvelope, String> {
if let Some(root) = envelope.as_object_mut() {
root.remove(NORMALIZED_GROUPED_DEAD_CODE_MARKER);
}
let grouped_by = envelope
.get("grouped_by")
.and_then(serde_json::Value::as_str)
.and_then(parse_group_by_mode);
if envelope.get("kind").and_then(serde_json::Value::as_str) != Some("dead-code-grouped") {
return Ok(SavedEnvelope {
envelope,
grouped_by,
});
}
let Some(root) = envelope.as_object_mut() else {
return Err("saved grouped dead-code envelope must be an object".to_owned());
};
let grouped_by = grouped_by.ok_or_else(|| {
"saved grouped dead-code envelope has an unsupported or missing `grouped_by`".to_owned()
})?;
let groups_value = root.remove("groups").ok_or_else(|| {
"saved grouped dead-code envelope is missing required field `groups`".to_owned()
})?;
let groups = groups_value.as_array().ok_or_else(|| {
"saved grouped dead-code envelope field `groups` must be an array".to_owned()
})?;
let current_schema = root
.get("schema_version")
.and_then(serde_json::Value::as_u64)
== Some(u64::from(fallow_output::CHECK_SCHEMA_VERSION));
if current_schema {
validate_current_grouped_dead_code(root, groups)?;
}
root.remove("grouped_by");
root.insert(
"kind".to_string(),
serde_json::Value::String("dead-code".to_string()),
);
root.insert(
NORMALIZED_GROUPED_DEAD_CODE_MARKER.to_string(),
serde_json::Value::Bool(true),
);
seed_required_categories(root);
for group in groups {
let Some(group) = group.as_object() else {
continue;
};
for (key, value) in group {
if matches!(key.as_str(), "key" | "owners" | "total_issues") {
continue;
}
let Some(items) = value.as_array() else {
continue;
};
let target = root
.entry(key.clone())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
if let Some(target) = target.as_array_mut() {
target.extend(items.iter().cloned());
}
}
}
Ok(SavedEnvelope {
envelope,
grouped_by: Some(grouped_by),
})
}
fn seed_required_categories(root: &mut serde_json::Map<String, serde_json::Value>) {
let Ok(serde_json::Value::Object(empty)) =
serde_json::to_value(fallow_types::results::AnalysisResults::default())
else {
return;
};
for (key, value) in empty {
if value.is_array() {
root.entry(key).or_insert(value);
}
}
}
fn validate_current_grouped_dead_code(
root: &serde_json::Map<String, serde_json::Value>,
groups: &[serde_json::Value],
) -> Result<(), String> {
let root_total = root
.get("total_issues")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| {
"saved grouped dead-code envelope is missing a non-negative integer `total_issues`"
.to_owned()
})?;
let mut grouped_total = 0_u64;
for (index, group) in groups.iter().enumerate() {
let Some(group_object) = group.as_object() else {
return Err(format!(
"saved grouped dead-code envelope group {index} must be an object"
));
};
if !group_object
.get("key")
.is_some_and(serde_json::Value::is_string)
{
return Err(format!(
"saved grouped dead-code envelope group {index} is missing a string `key`"
));
}
let declared_total = group_object
.get("total_issues")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| {
format!(
"saved grouped dead-code envelope group {index} is missing a non-negative integer `total_issues`"
)
})?;
let results =
serde_json::from_value::<fallow_types::results::AnalysisResults>(group.clone())
.map_err(|error| {
format!(
"saved grouped dead-code envelope group {index} is incompatible with this Fallow version: {error}"
)
})?;
let actual_total = u64::try_from(results.total_issues()).unwrap_or(u64::MAX);
if declared_total != actual_total {
return Err(format!(
"saved grouped dead-code envelope group {index} declares {declared_total} findings but contains {actual_total}"
));
}
grouped_total = grouped_total.saturating_add(actual_total);
}
if root_total != grouped_total {
return Err(format!(
"saved grouped dead-code envelope declares {root_total} findings but its groups contain {grouped_total}"
));
}
Ok(())
}
fn parse_group_by_mode(value: &str) -> Option<GroupByMode> {
match value {
"owner" => Some(GroupByMode::Owner),
"directory" => Some(GroupByMode::Directory),
"package" => Some(GroupByMode::Package),
"section" => Some(GroupByMode::Section),
_ => None,
}
}
fn saved_group_resolver(
grouped_by: Option<GroupByMode>,
root: &Path,
config_path: Option<&Path>,
output: OutputFormat,
) -> Result<Option<crate::report::OwnershipResolver>, ExitCode> {
let codeowners = config_path
.and_then(|path| FallowConfig::load(path).ok())
.and_then(|config| config.codeowners)
.or_else(|| {
FallowConfig::find_and_load(root)
.ok()
.flatten()
.and_then(|(config, _)| config.codeowners)
});
crate::runtime_support::build_ownership_resolver_for_mode(
grouped_by,
root,
codeowners.as_deref(),
output,
)
}
#[derive(Clone, Copy)]
enum ReportTarget {
GithubAnnotations,
GithubSummary,
CodeClimate,
Sarif,
PrComment(Provider),
Review(Provider),
}
fn load_envelope(from: &Path, output: OutputFormat) -> Result<serde_json::Value, ExitCode> {
let source = std::fs::read_to_string(from).map_err(|err| {
crate::emit_known_failure(
&format!("failed to read {}: {err}", from.display()),
2,
output,
telemetry::FailureReason::Validation,
)
})?;
serde_json::from_str(&source).map_err(|err| {
crate::emit_known_failure(
&format!(
"{} is not valid JSON ({err}); generate it with `fallow ... --format json`",
from.display()
),
2,
output,
telemetry::FailureReason::Validation,
)
})
}
fn envelope_kind(
envelope: &serde_json::Value,
from: &Path,
output: OutputFormat,
) -> Result<EnvelopeKind, ExitCode> {
let Some(kind) = envelope.get("kind").and_then(serde_json::Value::as_str) else {
if is_fix_envelope(envelope) {
return Ok(EnvelopeKind::Fix);
}
return Err(crate::emit_known_failure(
&format!(
"{} is not a fallow results envelope (missing top-level `kind`); \
generate it with `fallow ... --format json`",
from.display()
),
2,
output,
telemetry::FailureReason::Validation,
));
};
parse_envelope_kind(kind).ok_or_else(|| {
crate::emit_known_failure(
&format!(
"unsupported envelope kind `{kind}` in {}; fallow report renders dead-code, \
dupes, health, audit, security, and combined envelopes",
from.display()
),
2,
output,
telemetry::FailureReason::Validation,
)
})
}
fn parse_envelope_kind(kind: &str) -> Option<EnvelopeKind> {
match kind {
"dead-code" => Some(EnvelopeKind::DeadCode),
"dupes" => Some(EnvelopeKind::Dupes),
"health" => Some(EnvelopeKind::Health),
"audit" => Some(EnvelopeKind::Audit),
"security" => Some(EnvelopeKind::Security),
"combined" => Some(EnvelopeKind::Combined),
_ => None,
}
}
fn is_fix_envelope(envelope: &serde_json::Value) -> bool {
envelope
.get("fixes")
.is_some_and(serde_json::Value::is_array)
&& envelope
.get("total_fixed")
.is_some_and(serde_json::Value::is_number)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_envelope_kind_covers_supported_kinds() {
assert_eq!(
parse_envelope_kind("dead-code"),
Some(EnvelopeKind::DeadCode)
);
assert_eq!(parse_envelope_kind("dupes"), Some(EnvelopeKind::Dupes));
assert_eq!(parse_envelope_kind("health"), Some(EnvelopeKind::Health));
assert_eq!(parse_envelope_kind("audit"), Some(EnvelopeKind::Audit));
assert_eq!(
parse_envelope_kind("security"),
Some(EnvelopeKind::Security)
);
assert_eq!(
parse_envelope_kind("combined"),
Some(EnvelopeKind::Combined)
);
}
#[test]
fn parse_envelope_kind_rejects_unknown_and_grouped_kinds() {
assert_eq!(parse_envelope_kind("dead-code-grouped"), None);
assert_eq!(parse_envelope_kind("feature-flags"), None);
assert_eq!(parse_envelope_kind(""), None);
}
#[test]
fn saved_audit_verdict_maps_to_ci_conclusion() {
for (verdict, expected) in [
("pass", PrDecisionConclusion::Success),
("warn", PrDecisionConclusion::Neutral),
("fail", PrDecisionConclusion::Failure),
] {
let envelope = serde_json::json!({ "verdict": verdict });
let (conclusion, status) =
saved_ci_conclusion(EnvelopeKind::Audit, &envelope).expect("audit verdict");
assert_eq!(conclusion, Some(expected));
assert_eq!(status, None);
}
}
#[test]
fn saved_required_incomplete_type_aware_result_fails_closed() {
let identity = fallow_types::semantic::SemanticAnalysisIdentity {
completeness: fallow_types::semantic::SemanticCompleteness::Partial,
..fallow_types::semantic::SemanticAnalysisIdentity::default()
};
let meta = fallow_types::envelope::TypeAwareMeta {
identity: Some(identity),
required_completeness: Some(
fallow_types::semantic::SemanticCompletenessRequirement::Complete,
),
..fallow_types::envelope::TypeAwareMeta::default()
};
let envelope = serde_json::json!({
"verdict": "pass",
"_meta": { "type_aware": meta }
});
let (conclusion, status) =
saved_ci_conclusion(EnvelopeKind::Audit, &envelope).expect("type-aware gate");
assert_eq!(conclusion, Some(PrDecisionConclusion::Failure));
assert_eq!(
status,
Some(crate::report::ci::TYPE_AWARE_INCOMPLETE_MESSAGE)
);
}
#[test]
fn saved_required_type_aware_result_without_identity_fails_closed() {
let meta = fallow_types::envelope::TypeAwareMeta {
required_completeness: Some(
fallow_types::semantic::SemanticCompletenessRequirement::Complete,
),
..fallow_types::envelope::TypeAwareMeta::default()
};
let envelope = serde_json::json!({
"_meta": { "type_aware": meta }
});
let (conclusion, status) =
saved_ci_conclusion(EnvelopeKind::DeadCode, &envelope).expect("missing identity gate");
assert_eq!(conclusion, Some(PrDecisionConclusion::Failure));
assert_eq!(
status,
Some(crate::report::ci::TYPE_AWARE_INCOMPLETE_MESSAGE)
);
}
#[test]
fn malformed_saved_type_aware_metadata_is_rejected() {
let mut meta = serde_json::to_value(fallow_types::envelope::TypeAwareMeta::default())
.expect("serialize type-aware metadata");
meta["queries"] = serde_json::json!("not-an-array");
let envelope = serde_json::json!({
"_meta": { "type_aware": meta }
});
let error = saved_ci_conclusion(EnvelopeKind::DeadCode, &envelope)
.expect_err("malformed type-aware metadata must fail closed");
assert!(error.contains("saved type-aware metadata at `/_meta/type_aware`"));
assert!(error.contains("invalid type"));
}
#[test]
fn grouped_dead_code_is_flattened_for_saved_renderers() {
let normalized = normalize_saved_envelope(serde_json::json!({
"kind": "dead-code-grouped",
"grouped_by": "owner",
"total_issues": 1,
"groups": [{
"key": "@team",
"owners": ["@team"],
"total_issues": 1,
"unused_files": [{"path": "src/dead.ts", "actions": []}]
}]
}))
.expect("valid grouped envelope");
assert_eq!(normalized.grouped_by, Some(GroupByMode::Owner));
assert_eq!(normalized.envelope["kind"], "dead-code");
assert_eq!(
normalized.envelope["unused_files"][0]["path"],
"src/dead.ts"
);
assert!(normalized.envelope.get("groups").is_none());
assert!(normalized.envelope.get("grouped_by").is_none());
assert_eq!(
normalized.envelope[NORMALIZED_GROUPED_DEAD_CODE_MARKER],
true
);
}
#[test]
fn malformed_current_grouped_dead_code_fails_before_flattening() {
let error = normalize_saved_envelope(serde_json::json!({
"kind": "dead-code-grouped",
"schema_version": fallow_output::CHECK_SCHEMA_VERSION,
"version": env!("CARGO_PKG_VERSION"),
"elapsed_ms": 0,
"grouped_by": "owner",
"total_issues": 1,
"groups": [{
"key": "@team",
"total_issues": 1,
"unused_files": [{"path": "src/dead.ts", "actions": []}],
"unused_exports": "invalid"
}]
}))
.expect_err("malformed current group must fail closed");
assert!(error.contains("group 0 is incompatible with this Fallow version"));
}
#[test]
fn is_fix_envelope_detects_kindless_fix_document() {
let fix = serde_json::json!({
"dry_run": false,
"total_fixed": 3,
"skipped": 0,
"fixes": [{ "type": "remove_export", "applied": true }],
});
assert!(is_fix_envelope(&fix));
}
#[test]
fn is_fix_envelope_rejects_other_kindless_documents() {
assert!(!is_fix_envelope(&serde_json::json!({
"total_issues": 4,
"unused_files": [{ "path": "src/a.ts" }],
})));
assert!(!is_fix_envelope(&serde_json::json!({ "fixes": [] })));
assert!(!is_fix_envelope(&serde_json::json!({ "total_fixed": 0 })));
assert!(!is_fix_envelope(&serde_json::json!({
"fixes": "nope",
"total_fixed": 0,
})));
}
}