use crate::report::sink::outln;
use std::path::Path;
use std::process::ExitCode;
use std::time::Duration;
use fallow_api::{
CheckJsonExtraOutputs, CheckJsonOutputInput, CheckJsonPayloadInput, DupesReportPayload,
DuplicationGrouping, DuplicationJsonOutputInput, GroupedCheckJsonOutputInput,
GroupedDuplicationJsonOutputInput,
};
use fallow_types::duplicates::DuplicationReport;
#[cfg(test)]
use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
use fallow_types::results::AnalysisResults;
#[cfg(test)]
use fallow_output::strip_root_prefix;
use fallow_types::envelope::{
BaselineCategoryDelta, BaselineDeltas, BaselineMatch, RegressionResult, RegressionStatus,
RegressionToleranceKind,
};
use super::emit_report_json;
use crate::report::grouping::{OwnershipResolver, ResultGroup};
use fallow_config::WorkspaceDiagnostic;
use fallow_output::GroupByMode;
pub(super) struct PrintJsonInput<'a> {
pub(super) results: &'a AnalysisResults,
pub(super) root: &'a Path,
pub(super) elapsed: Duration,
pub(super) explain: bool,
pub(super) type_aware: Option<&'a fallow_types::envelope::TypeAwareMeta>,
pub(super) regression: Option<&'a crate::regression::RegressionOutcome>,
pub(super) baseline_matched: Option<(usize, usize)>,
pub(super) config_fixable: bool,
pub(super) baseline_staleness: Option<fallow_output::BaselineStaleness>,
pub(super) gate_outcomes: Option<fallow_output::GateOutcomes>,
pub(super) workspace_diagnostics: &'a [WorkspaceDiagnostic],
pub(super) json_style: crate::json_style::JsonStyle,
}
pub(super) fn print_json(input: &PrintJsonInput<'_>) -> ExitCode {
match render_json(input) {
Ok(output) => {
outln!("{output}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Error: failed to serialize results: {e}");
ExitCode::from(2)
}
}
}
pub(super) fn render_json(input: &PrintJsonInput<'_>) -> Result<String, serde_json::Error> {
let output = api_check_json_document_with_config_fixable_meta_and_extras(
input.results,
input.root,
input.elapsed,
input.config_fixable,
check_output_meta(input.explain, input.type_aware),
check_json_extras_with_gates(
input.regression,
None,
input.baseline_matched,
input.baseline_staleness,
input.gate_outcomes.clone(),
),
input.workspace_diagnostics,
)?;
input.json_style.serialize(&output)
}
#[must_use]
pub(super) struct PrintGroupedJsonInput<'a> {
pub(super) groups: &'a [ResultGroup],
pub(super) original: &'a AnalysisResults,
pub(super) root: &'a Path,
pub(super) elapsed: Duration,
pub(super) explain: bool,
pub(super) type_aware: Option<&'a fallow_types::envelope::TypeAwareMeta>,
pub(super) resolver: &'a OwnershipResolver,
pub(super) config_fixable: bool,
pub(super) baseline_staleness: Option<fallow_output::BaselineStaleness>,
pub(super) gate_outcomes: Option<fallow_output::GateOutcomes>,
pub(super) workspace_diagnostics: &'a [WorkspaceDiagnostic],
pub(super) json_style: crate::json_style::JsonStyle,
}
pub(super) fn print_grouped_json(input: &PrintGroupedJsonInput<'_>) -> ExitCode {
let output = match fallow_api::serialize_grouped_check_json(GroupedCheckJsonOutputInput {
gate_outcomes: input.gate_outcomes.clone(),
groups: input.groups,
original: input.original,
root: input.root,
elapsed: input.elapsed,
grouped_by: group_by_mode_from_label(input.resolver.mode_label()),
config_fixable: input.config_fixable,
baseline_staleness: input.baseline_staleness,
meta: check_output_meta(input.explain, input.type_aware),
workspace_diagnostics: input.workspace_diagnostics.to_vec(),
next_steps: crate::report::suggestions::build_dead_code_next_steps(
input.original,
input.root,
crate::report::suggestions::setup_pointer_applicable(input.root),
crate::report::suggestions::due_impact_digest(input.root),
),
envelope_mode: crate::output_runtime::current_root_envelope_mode(),
telemetry_analysis_run_id: crate::output_runtime::telemetry_analysis_run_id().as_deref(),
}) {
Ok(value) => value,
Err(e) => {
eprintln!("Error: failed to serialize grouped results: {e}");
return ExitCode::from(2);
}
};
emit_report_json(&output, "JSON", input.json_style)
}
fn check_output_meta(
explain: bool,
type_aware: Option<&fallow_types::envelope::TypeAwareMeta>,
) -> Option<fallow_types::envelope::Meta> {
if !explain && type_aware.is_none() {
return None;
}
let mut meta = if explain {
fallow_output::check_meta()
} else {
fallow_types::envelope::Meta::default()
};
if explain && type_aware.is_some() {
add_type_aware_explanations(&mut meta);
}
meta.type_aware = type_aware.cloned();
Some(meta)
}
fn add_type_aware_explanations(meta: &mut fallow_types::envelope::Meta) {
add_type_aware_field_definitions(meta);
add_type_aware_metric_definitions(meta);
}
fn add_type_aware_field_definitions(meta: &mut fallow_types::envelope::Meta) {
meta.field_definitions.extend([
(
"type_aware.executed".to_owned(),
"Whether the semantic companion executed at least one query.".to_owned(),
),
(
"type_aware.protocol_version".to_owned(),
"Version of Fallow's backend-neutral type-aware sidecar protocol.".to_owned(),
),
(
"type_aware.backend".to_owned(),
"Semantic backend capability identifier.".to_owned(),
),
(
"type_aware.sidecar_version".to_owned(),
"Version of the sidecar package that executed the query; omitted when no semantic query was needed.".to_owned(),
),
(
"type_aware.backend_version".to_owned(),
"Compiler or semantic engine version that executed the query; omitted when no semantic query was needed.".to_owned(),
),
(
"type_aware.selected_tsconfigs".to_owned(),
"Sorted project configs selected for candidate files. The <inferred> marker denotes a TypeScript inferred project.".to_owned(),
),
(
"type_aware.warnings".to_owned(),
"Bounded semantic warnings. Affected candidates remain findings.".to_owned(),
),
(
"type_aware.projects".to_owned(),
"Per-project status, selection source, diagnostic count, and candidate outcomes.".to_owned(),
),
(
"type_aware.candidate_decisions".to_owned(),
"Per-candidate Fallow decision with bounded checker evidence, contract relations, owning projects, and guarded-edit eligibility.".to_owned(),
),
(
"type_aware.phase_timings_ms".to_owned(),
"Semantic project setup, diagnostics, and batched symbol-scan durations in milliseconds.".to_owned(),
),
(
"type_aware.abstention_reasons".to_owned(),
"Stable reason-code counts for candidates retained without semantic scanning.".to_owned(),
),
]);
}
fn add_type_aware_metric_definitions(meta: &mut fallow_types::envelope::Meta) {
meta.metrics.extend([
(
"type_aware.protocol_version".to_owned(),
type_aware_metric(
"Type-aware Protocol Version",
"Version of Fallow's backend-neutral semantic sidecar protocol.",
"[1, infinity)",
"must match the protocol version supported by this Fallow build",
),
),
(
"type_aware.candidate_count".to_owned(),
type_aware_metric(
"Type-aware Candidate Count",
"Dead-code candidates sent to the semantic sidecar after normal Fallow filtering, including class members, exports, and types.",
"[0, infinity)",
"context only",
),
),
(
"type_aware.confirmed_used_count".to_owned(),
type_aware_metric(
"Type-aware Confirmed Used Count",
"Candidates whose exact declaration has a checker-resolved static reference, including imports, re-exports, type references, and property or element access, and was removed from the findings.",
"[0, candidate_count]",
"higher means semantic analysis removed more syntactic false positives",
),
),
(
"type_aware.contract_preserved_count".to_owned(),
type_aware_metric(
"Type-aware Contract Preserved Count",
"Candidates removed from findings because their declarations satisfy an interface, abstract member, or override contract.",
"[0, candidate_count]",
"higher means semantic contract evidence removed more syntactic false positives",
),
),
(
"type_aware.no_static_references_count".to_owned(),
type_aware_metric(
"Type-aware No Static References Count",
"Candidates retained after complete analysis found no checker-resolved static references.",
"[0, candidate_count]",
"these remain findings; only an eligible class method can receive a guarded fix",
),
),
(
"type_aware.fix_eligible_count".to_owned(),
type_aware_metric(
"Type-aware Fix Eligible Count",
"Class-method candidates whose complete closed-world evidence and declaration guard permit a guarded fix.",
"[0, type_aware.no_static_references_count]",
"higher means more retained findings can be removed safely by fallow fix",
),
),
(
"type_aware.unresolved_count".to_owned(),
type_aware_metric(
"Type-aware Unresolved Count",
"Candidates not positively confirmed as used and therefore retained as findings.",
"[0, candidate_count]",
"lower means more candidates were resolved exactly",
),
),
(
"type_aware.abstained_count".to_owned(),
type_aware_metric(
"Type-aware Abstained Count",
"Candidates retained without semantic scanning because project selection or compiler state was unsafe.",
"[0, candidate_count]",
"zero means every candidate was assigned to a structurally safe TypeScript project",
),
),
(
"type_aware.warning_count".to_owned(),
type_aware_metric(
"Type-aware Warning Count",
"Bounded semantic warnings returned by the sidecar.",
"[0, 20]",
"zero means the selected projects produced no semantic warnings",
),
),
(
"type_aware.elapsed_ms".to_owned(),
type_aware_metric(
"Type-aware Elapsed Time",
"Semantic pass duration reported by the sidecar in milliseconds.",
"[0, infinity)",
"lower is faster",
),
),
]);
add_type_aware_phase_metric_definitions(meta);
add_type_aware_project_metric_definitions(meta);
add_type_aware_abstention_metric_definitions(meta);
}
fn add_type_aware_phase_metric_definitions(meta: &mut fallow_types::envelope::Meta) {
meta.metrics.extend([
(
"type_aware.phase_timings_ms.project_setup".to_owned(),
type_aware_metric(
"Type-aware Project Setup Time",
"Milliseconds spent constructing TypeScript projects and selecting snapshots.",
"[0, elapsed_ms]",
"lower is faster",
),
),
(
"type_aware.phase_timings_ms.diagnostics".to_owned(),
type_aware_metric(
"Type-aware Diagnostics Time",
"Milliseconds spent collecting structural diagnostics before scanning.",
"[0, elapsed_ms]",
"lower is faster",
),
),
(
"type_aware.phase_timings_ms.symbol_scan".to_owned(),
type_aware_metric(
"Type-aware Symbol Scan Time",
"Milliseconds spent on batched symbol lookup and exact declaration matching.",
"[0, elapsed_ms]",
"lower is faster",
),
),
]);
}
fn add_type_aware_project_metric_definitions(meta: &mut fallow_types::envelope::Meta) {
meta.metrics.extend([
(
"type_aware.projects[].candidate_count".to_owned(),
type_aware_metric(
"Project Candidate Count",
"Candidates assigned to one selected TypeScript project.",
"[0, type_aware.candidate_count]",
"context only",
),
),
(
"type_aware.projects[].confirmed_used_count".to_owned(),
type_aware_metric(
"Project Confirmed Used Count",
"Project candidates confirmed as used and removed from findings.",
"[0, project candidate_count]",
"higher means the project supplied more exact semantic proof",
),
),
(
"type_aware.projects[].contract_preserved_count".to_owned(),
type_aware_metric(
"Project Contract Preserved Count",
"Project candidates removed because they satisfy an inherited or implemented contract.",
"[0, project candidate_count]",
"higher means this project supplied more exact contract evidence",
),
),
(
"type_aware.projects[].no_static_references_count".to_owned(),
type_aware_metric(
"Project No Static References Count",
"Project candidates retained after complete analysis found no static references.",
"[0, project candidate_count]",
"these remain findings unless a guarded class-member fix is eligible",
),
),
(
"type_aware.projects[].fix_eligible_count".to_owned(),
type_aware_metric(
"Project Fix Eligible Count",
"Project class-method candidates eligible for a guarded semantic fix.",
"[0, project no_static_references_count]",
"higher means more retained findings have complete deletion evidence",
),
),
(
"type_aware.projects[].unresolved_count".to_owned(),
type_aware_metric(
"Project Unresolved Count",
"Project candidates scanned without an exact confirmed use.",
"[0, project candidate_count]",
"these candidates remain findings",
),
),
(
"type_aware.projects[].abstained_count".to_owned(),
type_aware_metric(
"Project Abstained Count",
"Project candidates retained without scanning because the project was unsafe.",
"[0, project candidate_count]",
"zero means this project was refined",
),
),
(
"type_aware.projects[].blocking_diagnostic_count".to_owned(),
type_aware_metric(
"Project Blocking Diagnostic Count",
"Structural diagnostics that prevented exact semantic scanning for the project.",
"[0, infinity)",
"nonzero means the project abstained",
),
),
(
"type_aware.projects[].source_file_count".to_owned(),
type_aware_metric(
"Project Source File Count",
"Source files loaded into the TypeScript program for this project.",
"[1, infinity)",
"context for project cost and scope",
),
),
]);
}
fn add_type_aware_abstention_metric_definitions(meta: &mut fallow_types::envelope::Meta) {
meta.metrics.extend([
(
"type_aware.abstention_reasons.no_project".to_owned(),
type_aware_metric(
"No-project Abstention Count",
"Candidates not contained by a selected TypeScript project.",
"[0, type_aware.abstained_count]",
"lower means project selection covered more candidates",
),
),
(
"type_aware.abstention_reasons.ambiguous_project".to_owned(),
type_aware_metric(
"Ambiguous-project Abstention Count",
"Candidates contained by multiple explicit TypeScript projects.",
"[0, type_aware.abstained_count]",
"zero means explicit project selection was unambiguous",
),
),
(
"type_aware.abstention_reasons.blocking_diagnostics".to_owned(),
type_aware_metric(
"Blocking-diagnostics Abstention Count",
"Candidates retained because structural TypeScript diagnostics blocked scanning.",
"[0, type_aware.abstained_count]",
"lower means more candidate projects were structurally safe",
),
),
(
"type_aware.abstention_reasons.svelte_virtual_module_exports".to_owned(),
type_aware_metric(
"Svelte Virtual-module Export Abstention Count",
"Candidates retained because TypeScript-Go could not resolve named Svelte virtual-module exports.",
"[0, type_aware.unresolved_count]",
"zero means no candidate depended on unavailable Svelte virtual-module exports",
),
),
(
"type_aware.abstention_reasons.unknown_symbol".to_owned(),
type_aware_metric(
"Unknown-symbol Abstention Count",
"Candidates whose exact declaration identity could not be resolved.",
"[0, type_aware.abstained_count]",
"lower means syntactic and semantic declaration identities agree more often",
),
),
(
"type_aware.abstention_reasons.unsupported_syntax".to_owned(),
type_aware_metric(
"Unsupported-syntax Abstention Count",
"Candidates using declaration syntax unsupported by the semantic backend.",
"[0, type_aware.abstained_count]",
"lower means the semantic backend covers more candidate syntax",
),
),
(
"type_aware.abstention_reasons.capacity".to_owned(),
type_aware_metric(
"Capacity Abstention Count",
"Candidates retained because the bounded semantic request reached capacity.",
"[0, type_aware.abstained_count]",
"zero means every requested candidate fit in the semantic batch",
),
),
]);
}
fn type_aware_metric(
name: &str,
description: &str,
range: &str,
interpretation: &str,
) -> fallow_types::envelope::MetaMetric {
fallow_types::envelope::MetaMetric {
name: Some(name.to_owned()),
description: Some(description.to_owned()),
range: Some(range.to_owned()),
interpretation: Some(interpretation.to_owned()),
}
}
#[cfg(test)]
fn api_check_json_document(
results: &AnalysisResults,
root: &Path,
elapsed: Duration,
) -> Result<serde_json::Value, serde_json::Error> {
api_check_json_document_with_config_fixable(
results,
root,
elapsed,
crate::fix::is_config_fixable(root, None),
)
}
#[cfg(test)]
fn api_check_json_document_with_config_fixable(
results: &AnalysisResults,
root: &Path,
elapsed: Duration,
config_fixable: bool,
) -> Result<serde_json::Value, serde_json::Error> {
api_check_json_document_with_config_fixable_and_meta(
results,
root,
elapsed,
config_fixable,
None,
)
}
#[cfg(test)]
fn api_check_json_document_with_config_fixable_and_meta(
results: &AnalysisResults,
root: &Path,
elapsed: Duration,
config_fixable: bool,
meta: Option<fallow_types::envelope::Meta>,
) -> Result<serde_json::Value, serde_json::Error> {
api_check_json_document_with_config_fixable_meta_and_extras(
results,
root,
elapsed,
config_fixable,
meta,
CheckJsonExtraOutputs::default(),
&[],
)
}
#[expect(
clippy::too_many_arguments,
reason = "check output keeps envelope metadata and run-owned diagnostics explicit"
)]
pub(super) fn api_check_json_document_with_config_fixable_meta_and_extras(
results: &AnalysisResults,
root: &Path,
elapsed: Duration,
config_fixable: bool,
meta: Option<fallow_types::envelope::Meta>,
extras: CheckJsonExtraOutputs,
workspace_diagnostics: &[WorkspaceDiagnostic],
) -> Result<serde_json::Value, serde_json::Error> {
fallow_api::serialize_check_json(CheckJsonOutputInput {
results,
root,
elapsed,
config_fixable,
meta,
extras,
workspace_diagnostics: workspace_diagnostics.to_vec(),
next_steps: crate::report::suggestions::build_dead_code_next_steps(
results,
root,
crate::report::suggestions::setup_pointer_applicable(root),
crate::report::suggestions::due_impact_digest(root),
),
envelope_mode: crate::output_runtime::current_root_envelope_mode(),
telemetry_analysis_run_id: crate::output_runtime::telemetry_analysis_run_id().as_deref(),
})
}
pub fn api_check_json_payload_with_config_fixable(
results: &AnalysisResults,
root: &Path,
elapsed: Duration,
config_fixable: bool,
analysis_diagnostics: &[WorkspaceDiagnostic],
) -> Result<serde_json::Value, serde_json::Error> {
fallow_api::serialize_check_json_payload(CheckJsonPayloadInput {
results,
root,
elapsed,
config_fixable,
extras: CheckJsonExtraOutputs::default(),
workspace_diagnostics: analysis_diagnostics.to_vec(),
})
}
pub fn check_json_extras(
regression: Option<&crate::regression::RegressionOutcome>,
baseline_deltas: Option<BaselineDeltas>,
baseline_matched: Option<(usize, usize)>,
baseline_staleness: Option<fallow_output::BaselineStaleness>,
) -> CheckJsonExtraOutputs {
check_json_extras_with_gates(
regression,
baseline_deltas,
baseline_matched,
baseline_staleness,
None,
)
}
pub fn check_json_extras_with_gates(
regression: Option<&crate::regression::RegressionOutcome>,
baseline_deltas: Option<BaselineDeltas>,
baseline_matched: Option<(usize, usize)>,
baseline_staleness: Option<fallow_output::BaselineStaleness>,
gate_outcomes: Option<fallow_output::GateOutcomes>,
) -> CheckJsonExtraOutputs {
CheckJsonExtraOutputs {
gate_outcomes,
regression: regression.map(regression_output),
baseline_deltas,
baseline: baseline_matched.map(|(entries, matched)| BaselineMatch { entries, matched }),
baseline_staleness,
}
}
#[must_use]
pub fn regression_output(outcome: &crate::regression::RegressionOutcome) -> RegressionResult {
match outcome {
crate::regression::RegressionOutcome::Pass {
baseline_total,
current_total,
} => {
let baseline_total = *baseline_total as i64;
let current_total = *current_total as i64;
RegressionResult {
status: RegressionStatus::Pass,
baseline_total: Some(baseline_total),
current_total: Some(current_total),
delta: Some(current_total - baseline_total),
tolerance: None,
tolerance_kind: None,
exceeded: false,
reason: None,
}
}
crate::regression::RegressionOutcome::Exceeded {
baseline_total,
current_total,
tolerance,
..
} => {
let baseline_total = *baseline_total as i64;
let current_total = *current_total as i64;
let (tolerance, tolerance_kind) = match tolerance {
crate::regression::Tolerance::Percentage(percent) => {
(*percent, RegressionToleranceKind::Percentage)
}
crate::regression::Tolerance::Absolute(count) => {
(*count as f64, RegressionToleranceKind::Absolute)
}
};
RegressionResult {
status: RegressionStatus::Exceeded,
baseline_total: Some(baseline_total),
current_total: Some(current_total),
delta: Some(current_total - baseline_total),
tolerance: Some(tolerance),
tolerance_kind: Some(tolerance_kind),
exceeded: true,
reason: None,
}
}
crate::regression::RegressionOutcome::Skipped { reason } => RegressionResult {
status: RegressionStatus::Skipped,
baseline_total: None,
current_total: None,
delta: None,
tolerance: None,
tolerance_kind: None,
exceeded: false,
reason: Some((*reason).to_string()),
},
}
}
pub fn build_baseline_deltas_output<'a>(
total_delta: i64,
per_category: impl Iterator<Item = (&'a str, usize, usize, i64)>,
) -> BaselineDeltas {
BaselineDeltas {
total_delta,
per_category: per_category
.map(|(category, current, baseline, delta)| {
(
category.to_string(),
BaselineCategoryDelta {
current,
baseline,
delta,
},
)
})
.collect(),
}
}
#[cfg(test)]
fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
if let serde_json::Value::Object(map) = output {
let telemetry = map
.get("_meta")
.and_then(|existing| existing.get("telemetry"))
.cloned();
let mut meta = meta;
if let (Some(telemetry), Some(meta_map)) = (telemetry, meta.as_object_mut()) {
meta_map.insert("telemetry".to_string(), telemetry);
}
map.insert("_meta".to_string(), meta);
}
}
#[expect(
clippy::too_many_arguments,
reason = "health output keeps render options, diagnostics and gate verdicts explicit"
)]
pub(super) fn api_health_json_document(
report: &fallow_output::HealthReport,
root: &Path,
elapsed: Duration,
explain: bool,
type_aware: Option<&fallow_types::envelope::TypeAwareMeta>,
workspace_diagnostics: &[WorkspaceDiagnostic],
gate_outcomes: Option<fallow_output::GateOutcomes>,
) -> Result<serde_json::Value, serde_json::Error> {
let output = fallow_api::serialize_health_report_json(fallow_api::HealthJsonReportInput {
gate_outcomes,
report: report.clone(),
root,
elapsed,
explain,
type_aware: type_aware.cloned(),
grouped_by: None,
groups: None,
workspace_diagnostics: workspace_diagnostics.to_vec(),
next_steps: fallow_output::build_health_next_steps(
crate::report::suggestions::health_next_steps_input(
report,
root,
crate::report::suggestions::setup_pointer_applicable(root),
crate::report::suggestions::due_impact_digest(root),
),
),
envelope_mode: crate::output_runtime::current_root_envelope_mode(),
telemetry_analysis_run_id: crate::output_runtime::telemetry_analysis_run_id().as_deref(),
})?;
Ok(output)
}
#[expect(
clippy::too_many_arguments,
reason = "grouped health output keeps render options and run-owned diagnostics explicit"
)]
fn api_grouped_health_json_document(
report: &fallow_output::HealthReport,
grouping: &fallow_output::HealthGrouping,
root: &Path,
elapsed: Duration,
explain: bool,
type_aware: Option<&fallow_types::envelope::TypeAwareMeta>,
workspace_diagnostics: &[WorkspaceDiagnostic],
gate_outcomes: Option<fallow_output::GateOutcomes>,
) -> Result<serde_json::Value, serde_json::Error> {
fallow_api::serialize_health_report_json(fallow_api::HealthJsonReportInput {
gate_outcomes,
report: report.clone(),
root,
elapsed,
explain,
type_aware: type_aware.cloned(),
grouped_by: Some(group_by_mode_from_label(grouping.mode)),
groups: Some(grouping.groups.clone()),
workspace_diagnostics: workspace_diagnostics.to_vec(),
next_steps: fallow_output::build_health_next_steps(
crate::report::suggestions::health_next_steps_input(
report,
root,
crate::report::suggestions::setup_pointer_applicable(root),
crate::report::suggestions::due_impact_digest(root),
),
),
envelope_mode: crate::output_runtime::current_root_envelope_mode(),
telemetry_analysis_run_id: crate::output_runtime::telemetry_analysis_run_id().as_deref(),
})
}
#[expect(
clippy::too_many_arguments,
reason = "health output keeps render options and run-owned diagnostics explicit"
)]
pub(super) fn print_health_json(
report: &fallow_output::HealthReport,
root: &Path,
elapsed: Duration,
explain: bool,
type_aware: Option<&fallow_types::envelope::TypeAwareMeta>,
workspace_diagnostics: &[WorkspaceDiagnostic],
json_style: crate::json_style::JsonStyle,
gate_outcomes: Option<fallow_output::GateOutcomes>,
) -> ExitCode {
match api_health_json_document(
report,
root,
elapsed,
explain,
type_aware,
workspace_diagnostics,
gate_outcomes,
) {
Ok(output) => emit_report_json(&output, "JSON", json_style),
Err(e) => {
eprintln!("Error: failed to serialize health report: {e}");
ExitCode::from(2)
}
}
}
#[expect(
clippy::too_many_arguments,
reason = "grouped health output keeps render options and semantic provenance explicit"
)]
pub(super) fn print_grouped_health_json(
report: &fallow_output::HealthReport,
grouping: &fallow_output::HealthGrouping,
root: &Path,
elapsed: Duration,
explain: bool,
type_aware: Option<&fallow_types::envelope::TypeAwareMeta>,
workspace_diagnostics: &[WorkspaceDiagnostic],
json_style: crate::json_style::JsonStyle,
gate_outcomes: Option<fallow_output::GateOutcomes>,
) -> ExitCode {
match api_grouped_health_json_document(
report,
grouping,
root,
elapsed,
explain,
type_aware,
workspace_diagnostics,
gate_outcomes,
) {
Ok(output) => emit_report_json(&output, "JSON", json_style),
Err(e) => {
eprintln!("Error: failed to serialize grouped health report: {e}");
ExitCode::from(2)
}
}
}
#[derive(Debug, Clone)]
pub(super) struct DuplicationJsonRender {
pub(super) baseline_staleness: Option<fallow_output::BaselineStaleness>,
pub(super) gate_outcomes: Option<fallow_output::GateOutcomes>,
pub(super) explain: bool,
pub(super) include_fragments: bool,
}
pub(super) fn api_duplication_json_document(
report: &DuplicationReport,
root: &Path,
elapsed: Duration,
render: &DuplicationJsonRender,
workspace_diagnostics: &[WorkspaceDiagnostic],
) -> Result<serde_json::Value, serde_json::Error> {
let payload = DupesReportPayload::from_report(report);
let next_steps = crate::report::suggestions::build_dupes_next_steps(
&payload,
root,
crate::report::suggestions::setup_pointer_applicable(root),
crate::report::suggestions::due_impact_digest(root),
);
fallow_api::serialize_duplication_json(DuplicationJsonOutputInput {
gate_outcomes: render.gate_outcomes.clone(),
report,
root,
elapsed,
include_fragments: render.include_fragments,
meta: render.explain.then(fallow_output::dupes_meta),
workspace_diagnostics: workspace_diagnostics.to_vec(),
next_steps,
baseline_staleness: render.baseline_staleness,
envelope_mode: crate::output_runtime::current_root_envelope_mode(),
telemetry_analysis_run_id: crate::output_runtime::telemetry_analysis_run_id().as_deref(),
})
}
pub(super) fn print_duplication_json(
report: &DuplicationReport,
root: &Path,
elapsed: Duration,
render: &DuplicationJsonRender,
workspace_diagnostics: &[WorkspaceDiagnostic],
json_style: crate::json_style::JsonStyle,
) -> ExitCode {
match api_duplication_json_document(report, root, elapsed, render, workspace_diagnostics) {
Ok(output) => emit_report_json(&output, "JSON", json_style),
Err(e) => {
eprintln!("Error: failed to serialize duplication report: {e}");
ExitCode::from(2)
}
}
}
fn api_grouped_duplication_json_document(
report: &DuplicationReport,
grouping: &DuplicationGrouping,
root: &Path,
elapsed: Duration,
render: &DuplicationJsonRender,
workspace_diagnostics: &[WorkspaceDiagnostic],
) -> Result<serde_json::Value, serde_json::Error> {
let payload = DupesReportPayload::from_report(report);
let next_steps = crate::report::suggestions::build_dupes_next_steps(
&payload,
root,
crate::report::suggestions::setup_pointer_applicable(root),
crate::report::suggestions::due_impact_digest(root),
);
fallow_api::serialize_grouped_duplication_json(GroupedDuplicationJsonOutputInput {
gate_outcomes: render.gate_outcomes.clone(),
report,
grouping,
root,
elapsed,
include_fragments: render.include_fragments,
meta: render.explain.then(fallow_output::dupes_meta),
workspace_diagnostics: workspace_diagnostics.to_vec(),
next_steps,
baseline_staleness: render.baseline_staleness,
envelope_mode: crate::output_runtime::current_root_envelope_mode(),
telemetry_analysis_run_id: crate::output_runtime::telemetry_analysis_run_id().as_deref(),
})
}
fn group_by_mode_from_label(label: &str) -> GroupByMode {
match label {
"directory" => GroupByMode::Directory,
"package" => GroupByMode::Package,
"section" => GroupByMode::Section,
_ => GroupByMode::Owner,
}
}
#[expect(
clippy::too_many_arguments,
reason = "grouped duplication output keeps render options and run-owned diagnostics explicit"
)]
pub(super) fn print_grouped_duplication_json(
report: &DuplicationReport,
grouping: &DuplicationGrouping,
root: &Path,
elapsed: Duration,
render: &DuplicationJsonRender,
workspace_diagnostics: &[WorkspaceDiagnostic],
json_style: crate::json_style::JsonStyle,
) -> ExitCode {
match api_grouped_duplication_json_document(
report,
grouping,
root,
elapsed,
render,
workspace_diagnostics,
) {
Ok(output) => emit_report_json(&output, "JSON", json_style),
Err(e) => {
eprintln!("Error: failed to serialize grouped duplication report: {e}");
ExitCode::from(2)
}
}
}
pub(super) fn print_trace_json<T: serde::Serialize>(
value: &T,
json_style: crate::json_style::JsonStyle,
) {
let value = match fallow_output::serialize_trace_json_output(
value,
crate::output_runtime::current_root_envelope_mode(),
crate::output_runtime::telemetry_analysis_run_id().as_deref(),
) {
Ok(value) => value,
Err(e) => {
eprintln!("Error: failed to build trace output: {e}");
#[expect(
clippy::exit,
reason = "fatal serialization error requires immediate exit"
)]
std::process::exit(2);
}
};
match json_style.serialize(&value) {
Ok(json) => outln!("{json}"),
Err(e) => {
eprintln!("Error: failed to serialize trace output: {e}");
#[expect(
clippy::exit,
reason = "fatal serialization error requires immediate exit"
)]
std::process::exit(2);
}
}
}
pub(super) fn print_semantic_trace_json<T: serde::Serialize>(
value: &T,
explain: bool,
json_style: crate::json_style::JsonStyle,
) {
let mut value = match fallow_output::serialize_trace_json_output(
value,
crate::output_runtime::current_root_envelope_mode(),
crate::output_runtime::telemetry_analysis_run_id().as_deref(),
) {
Ok(value) => value,
Err(e) => {
eprintln!("Error: failed to build semantic trace output: {e}");
#[expect(
clippy::exit,
reason = "fatal serialization error requires immediate exit"
)]
std::process::exit(2);
}
};
attach_semantic_explain_meta(&mut value, explain);
match json_style.serialize(&value) {
Ok(json) => outln!("{json}"),
Err(e) => {
eprintln!("Error: failed to serialize semantic trace output: {e}");
#[expect(
clippy::exit,
reason = "fatal serialization error requires immediate exit"
)]
std::process::exit(2);
}
}
}
pub(super) fn print_semantic_impact_json<T: serde::Serialize>(
value: &T,
explain: bool,
json_style: crate::json_style::JsonStyle,
) {
let mut value = match fallow_output::serialize_named_json_output(
value,
"impact",
crate::output_runtime::current_root_envelope_mode(),
) {
Ok(value) => value,
Err(e) => {
eprintln!("Error: failed to build impact output: {e}");
#[expect(
clippy::exit,
reason = "fatal serialization error requires immediate exit"
)]
std::process::exit(2);
}
};
attach_semantic_explain_meta(&mut value, explain);
match json_style.serialize(&value) {
Ok(json) => outln!("{json}"),
Err(e) => {
eprintln!("Error: failed to serialize impact output: {e}");
#[expect(
clippy::exit,
reason = "fatal serialization error requires immediate exit"
)]
std::process::exit(2);
}
}
}
fn attach_semantic_explain_meta(value: &mut serde_json::Value, explain: bool) {
if !explain {
return;
}
let Some(root) = value.as_object_mut() else {
return;
};
let meta = root
.entry("_meta")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
let Some(meta) = meta.as_object_mut() else {
return;
};
meta.entry("docs").or_insert_with(|| {
serde_json::Value::String("https://docs.fallow.tools/features/type-aware".to_string())
});
meta.entry("field_definitions").or_insert_with(|| {
serde_json::json!({
"semantic": "Authoritative checker-backed evidence. When present, trust semantic.identity, semantic.status, and semantic.references over the syntactic root trace fields. The proof covers only the lane named by semantic.target.namespace, so when the root trace lists a reference the proof does not, the root evidence is wider rather than stale.",
"semantic.identity": "Compatibility identity for the selected effective TypeScript projects and semantic capabilities.",
"semantic.status": "Whether checker-backed evidence is complete, partial, or unavailable.",
"semantic.references": "Bounded exact TypeScript checker references for the requested module export or class member.",
"identity": "Compatibility identity for this exact-symbol semantic impact query.",
"status": "Whether exact-symbol impact evidence is complete, partial, or unavailable.",
"direct_consumers": "Files that import or re-export this exact module export.",
"affected_files": "Transitively affected project files derived from the exact consumer graph.",
"targeted_tests": "Test entry points with a proven dependency path to the exact symbol."
})
});
meta.entry("metrics").or_insert_with(|| {
serde_json::json!({
"semantic.total_reference_count": {
"name": "Exact reference count",
"description": "Checker-resolved references before evidence bounding.",
"range": "[0, infinity)",
"interpretation": "Zero is meaningful only when semantic.status is complete."
},
"total_direct_consumer_count": {
"name": "Direct consumer count",
"description": "Exact module consumers before evidence bounding.",
"range": "[0, infinity)",
"interpretation": "Higher means a wider immediate change surface."
},
"total_affected_file_count": {
"name": "Affected file count",
"description": "Transitive exact-symbol impact files before evidence bounding.",
"range": "[0, infinity)",
"interpretation": "Higher means a wider downstream change surface."
},
"total_targeted_test_count": {
"name": "Targeted test count",
"description": "Tests with a proven dependency path to the exact symbol.",
"range": "[0, infinity)",
"interpretation": "Use these tests first, then retain the normal project verification gates."
}
})
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::test_helpers::sample_results;
use fallow_output::{
RuntimeCoverageAction, RuntimeCoverageConfidence, RuntimeCoverageDataSource,
RuntimeCoverageEvidence, RuntimeCoverageFinding, RuntimeCoverageHotPath,
RuntimeCoverageMessage, RuntimeCoverageReport, RuntimeCoverageReportVerdict,
RuntimeCoverageSchemaVersion, RuntimeCoverageSummary, RuntimeCoverageVerdict,
RuntimeCoverageWatermark,
};
use fallow_types::extract::MemberKind;
use fallow_types::output_dead_code::*;
use fallow_types::results::*;
use std::path::PathBuf;
use std::time::Duration;
#[test]
fn typed_regression_output_matches_legacy_json_shape() {
let outcome = crate::regression::RegressionOutcome::Exceeded {
baseline_total: 10,
current_total: 13,
tolerance: crate::regression::Tolerance::Absolute(2),
type_deltas: Vec::new(),
};
assert_eq!(
serde_json::to_value(regression_output(&outcome)).expect("regression serializes"),
outcome.to_json()
);
}
#[test]
fn typed_baseline_deltas_output_matches_legacy_json_shape() {
let typed = build_baseline_deltas_output(
2,
[("unused_exports", 4_usize, 2_usize, 2_i64)].into_iter(),
);
assert_eq!(
serde_json::to_value(typed).expect("baseline deltas serialize"),
serde_json::json!({
"total_delta": 2,
"per_category": {
"unused_exports": {
"current": 4,
"baseline": 2,
"delta": 2
}
}
})
);
}
#[test]
fn json_output_has_metadata_fields() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
let elapsed = Duration::from_millis(123);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
assert_eq!(output["kind"], "dead-code");
assert_eq!(output["schema_version"], 9);
assert!(output["version"].is_string());
assert_eq!(output["elapsed_ms"], 123);
assert_eq!(output["total_issues"], 0);
}
#[test]
fn json_output_includes_issue_arrays() {
let root = PathBuf::from("/project");
let results = sample_results(&root);
let elapsed = Duration::from_millis(50);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
assert_eq!(
output["unused_dev_dependencies"].as_array().unwrap().len(),
1
);
assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
assert_eq!(
output["type_only_dependencies"].as_array().unwrap().len(),
1
);
assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
}
#[test]
#[expect(
clippy::too_many_lines,
reason = "test fixture; linear setup/assert, length is not a maintainability concern"
)]
fn health_json_includes_runtime_coverage_with_relative_paths_and_actions() {
let root = PathBuf::from("/project");
let report = fallow_output::HealthReport {
runtime_coverage: Some(RuntimeCoverageReport {
schema_version: RuntimeCoverageSchemaVersion::V1,
verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
signals: Vec::new(),
summary: RuntimeCoverageSummary {
data_source: RuntimeCoverageDataSource::Local,
last_received_at: None,
functions_tracked: 3,
functions_hit: 1,
functions_unhit: 1,
functions_untracked: 1,
coverage_percent: 33.3,
trace_count: 2_847_291,
period_days: 30,
deployments_seen: 14,
capture_quality: Some(fallow_output::RuntimeCoverageCaptureQuality {
window_seconds: 720,
instances_observed: 1,
lazy_parse_warning: true,
untracked_ratio_percent: 42.5,
}),
},
findings: vec![RuntimeCoverageFinding {
id: "fallow:prod:deadbeef".to_owned(),
stable_id: None,
path: root.join("src/cold.ts"),
function: "coldPath".to_owned(),
line: 12,
verdict: RuntimeCoverageVerdict::ReviewRequired,
invocations: Some(0),
confidence: RuntimeCoverageConfidence::Medium,
evidence: RuntimeCoverageEvidence {
static_status: "used".to_owned(),
test_coverage: "not_covered".to_owned(),
test_only_reference: None,
v8_tracking: "tracked".to_owned(),
untracked_reason: None,
observation_days: 30,
deployments_observed: 14,
},
actions: vec![RuntimeCoverageAction {
kind: "review-deletion".to_owned(),
description: "Tracked in runtime coverage with zero invocations."
.to_owned(),
auto_fixable: false,
}],
source_hash: None,
discriminators: None,
}],
hot_paths: vec![RuntimeCoverageHotPath {
id: "fallow:hot:cafebabe".to_owned(),
stable_id: None,
path: root.join("src/hot.ts"),
function: "hotPath".to_owned(),
line: 3,
end_line: 9,
invocations: 250,
percentile: 99,
actions: vec![],
}],
blast_radius: vec![],
importance: vec![],
watermark: Some(RuntimeCoverageWatermark::LicenseExpiredGrace),
warnings: vec![RuntimeCoverageMessage {
code: "partial-merge".to_owned(),
message: "Merged coverage omitted one chunk.".to_owned(),
}],
actionable: true,
actionability_reason: None,
actionability_verdict: None,
provenance: fallow_output::RuntimeCoverageProvenance::default(),
}),
..Default::default()
};
let envelope: fallow_output::HealthOutput<
fallow_output::HealthReport,
fallow_output::HealthGroup,
> = fallow_output::HealthOutput {
gate_outcomes: None,
schema_version: SchemaVersion(fallow_output::HEALTH_SCHEMA_VERSION),
version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
elapsed_ms: ElapsedMs(7),
report,
grouped_by: None,
groups: None,
meta: None,
workspace_diagnostics: Vec::new(),
next_steps: Vec::new(),
};
let mut output = serde_json::to_value(&envelope).expect("should serialize health envelope");
strip_root_prefix(&mut output, "/project/");
assert_eq!(
output["runtime_coverage"]["verdict"],
serde_json::Value::String("cold-code-detected".to_owned())
);
assert_eq!(
output["runtime_coverage"]["schema_version"],
serde_json::Value::String("1".to_owned())
);
assert_eq!(
output["runtime_coverage"]["summary"]["functions_tracked"],
serde_json::Value::from(3)
);
assert_eq!(
output["runtime_coverage"]["summary"]["coverage_percent"],
serde_json::Value::from(33.3)
);
let finding = &output["runtime_coverage"]["findings"][0];
assert_eq!(finding["path"], "src/cold.ts");
assert_eq!(finding["verdict"], "review_required");
assert_eq!(finding["id"], "fallow:prod:deadbeef");
assert_eq!(finding["actions"][0]["type"], "review-deletion");
let hot_path = &output["runtime_coverage"]["hot_paths"][0];
assert_eq!(hot_path["path"], "src/hot.ts");
assert_eq!(hot_path["function"], "hotPath");
assert_eq!(hot_path["percentile"], 99);
assert_eq!(
output["runtime_coverage"]["watermark"],
serde_json::Value::String("license-expired-grace".to_owned())
);
assert_eq!(
output["runtime_coverage"]["warnings"][0]["code"],
serde_json::Value::String("partial-merge".to_owned())
);
}
#[test]
fn grouped_health_json_uses_api_contract_for_group_paths() {
let root = PathBuf::from("/project");
let grouping = fallow_output::HealthGrouping {
mode: "package",
groups: vec![fallow_output::HealthGroup {
key: "app".to_string(),
owners: None,
files_analyzed: 1,
functions_above_threshold: 0,
coverage_source_consistency: None,
vital_signs: None,
health_score: None,
findings: Vec::new(),
file_scores: Vec::new(),
hotspots: Vec::new(),
large_functions: vec![fallow_output::LargeFunctionEntry {
path: root.join("src/large.ts"),
name: "large".to_string(),
line: 12,
line_count: 80,
}],
targets: Vec::new(),
actions_meta: None,
}],
};
let output = api_grouped_health_json_document(
&fallow_output::HealthReport::default(),
&grouping,
&root,
Duration::ZERO,
false,
None,
&[],
None,
)
.expect("grouped health JSON should serialize");
assert_eq!(output["kind"], "health");
assert_eq!(output["grouped_by"], "package");
assert_eq!(
output["groups"][0]["large_functions"][0]["path"],
"src/large.ts"
);
}
#[test]
fn json_metadata_fields_appear_first() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
assert_eq!(keys[0], "kind");
assert_eq!(keys[1], "schema_version");
assert_eq!(keys[2], "version");
assert_eq!(keys[3], "elapsed_ms");
assert_eq!(keys[4], "total_issues");
}
#[test]
fn json_total_issues_matches_results() {
let root = PathBuf::from("/project");
let results = sample_results(&root);
let total = results.total_issues();
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
assert_eq!(output["total_issues"], total);
}
#[test]
fn json_unused_export_contains_expected_fields() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_exports
.push(UnusedExportFinding::with_actions(UnusedExport {
path: root.join("src/utils.ts"),
export_name: "helperFn".to_string(),
is_type_only: false,
line: 10,
col: 4,
span_start: 120,
is_re_export: false,
}));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let export = &output["unused_exports"][0];
assert_eq!(export["export_name"], "helperFn");
assert_eq!(export["line"], 10);
assert_eq!(export["col"], 4);
assert_eq!(export["is_type_only"], false);
assert_eq!(export["span_start"], 120);
assert_eq!(export["is_re_export"], false);
}
#[test]
fn json_serializes_to_valid_json() {
let root = PathBuf::from("/project");
let results = sample_results(&root);
let elapsed = Duration::from_millis(42);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
let reparsed: serde_json::Value =
serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
assert_eq!(reparsed, output);
}
#[test]
fn json_empty_results_produce_valid_structure() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
assert_eq!(output["total_issues"], 0);
assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
assert_eq!(
output["unused_dev_dependencies"].as_array().unwrap().len(),
0
);
assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
assert_eq!(
output["type_only_dependencies"].as_array().unwrap().len(),
0
);
assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
}
#[test]
fn json_empty_results_round_trips_through_string() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let json_str = serde_json::to_string(&output).expect("should stringify");
let reparsed: serde_json::Value =
serde_json::from_str(&json_str).expect("should parse back");
assert_eq!(reparsed["total_issues"], 0);
}
#[test]
fn json_paths_are_relative_to_root() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_files
.push(UnusedFileFinding::with_actions(UnusedFile {
path: root.join("src/deep/nested/file.ts"),
}));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let path = output["unused_files"][0]["path"].as_str().unwrap();
assert_eq!(path, "src/deep/nested/file.ts");
assert!(!path.starts_with("/project"));
}
#[test]
fn json_strips_root_from_nested_locations() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unlisted_dependencies
.push(UnlistedDependencyFinding::with_actions(
UnlistedDependency {
package_name: "chalk".to_string(),
imported_from: vec![ImportSite {
path: root.join("src/cli.ts"),
line: 2,
col: 0,
}],
},
));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
.as_str()
.unwrap();
assert_eq!(site_path, "src/cli.ts");
}
#[test]
fn json_strips_root_from_duplicate_export_locations() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.duplicate_exports
.push(DuplicateExportFinding::with_actions(DuplicateExport {
export_name: "Config".to_string(),
locations: vec![
DuplicateLocation {
path: root.join("src/config.ts"),
line: 15,
col: 0,
},
DuplicateLocation {
path: root.join("src/types.ts"),
line: 30,
col: 0,
},
],
}));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
.as_str()
.unwrap();
let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
.as_str()
.unwrap();
assert_eq!(loc0, "src/config.ts");
assert_eq!(loc1, "src/types.ts");
}
#[test]
fn json_strips_root_from_circular_dependency_files() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.circular_dependencies
.push(CircularDependencyFinding::with_actions(
CircularDependency {
files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
length: 2,
line: 1,
col: 0,
edges: Vec::new(),
is_cross_package: false,
},
));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let files = output["circular_dependencies"][0]["files"]
.as_array()
.unwrap();
assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
}
#[test]
fn json_path_outside_root_not_stripped() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_files
.push(UnusedFileFinding::with_actions(UnusedFile {
path: PathBuf::from("/other/project/src/file.ts"),
}));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let path = output["unused_files"][0]["path"].as_str().unwrap();
assert!(path.contains("/other/project/"));
}
#[test]
fn json_unused_file_contains_path() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_files
.push(UnusedFileFinding::with_actions(UnusedFile {
path: root.join("src/orphan.ts"),
}));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let file = &output["unused_files"][0];
assert_eq!(file["path"], "src/orphan.ts");
}
#[test]
fn json_unused_type_contains_expected_fields() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_types
.push(UnusedTypeFinding::with_actions(UnusedExport {
path: root.join("src/types.ts"),
export_name: "OldInterface".to_string(),
is_type_only: true,
line: 20,
col: 0,
span_start: 300,
is_re_export: false,
}));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let typ = &output["unused_types"][0];
assert_eq!(typ["export_name"], "OldInterface");
assert_eq!(typ["is_type_only"], true);
assert_eq!(typ["line"], 20);
assert_eq!(typ["path"], "src/types.ts");
}
#[test]
fn json_unused_dependency_contains_expected_fields() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_dependencies
.push(UnusedDependencyFinding::with_actions(UnusedDependency {
package_name: "axios".to_string(),
location: DependencyLocation::Dependencies,
path: root.join("package.json"),
line: 10,
used_in_workspaces: Vec::new(),
}));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let dep = &output["unused_dependencies"][0];
assert_eq!(dep["package_name"], "axios");
assert_eq!(dep["line"], 10);
assert!(dep.get("used_in_workspaces").is_none());
}
#[test]
fn json_unused_dependency_includes_cross_workspace_context() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_dependencies
.push(UnusedDependencyFinding::with_actions(UnusedDependency {
package_name: "lodash-es".to_string(),
location: DependencyLocation::Dependencies,
path: root.join("packages/shared/package.json"),
line: 6,
used_in_workspaces: vec![root.join("packages/consumer")],
}));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let dep = &output["unused_dependencies"][0];
assert_eq!(
dep["used_in_workspaces"],
serde_json::json!(["packages/consumer"])
);
}
#[test]
fn json_unused_dev_dependency_contains_expected_fields() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_dev_dependencies
.push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
package_name: "vitest".to_string(),
location: DependencyLocation::DevDependencies,
path: root.join("package.json"),
line: 15,
used_in_workspaces: Vec::new(),
}));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let dep = &output["unused_dev_dependencies"][0];
assert_eq!(dep["package_name"], "vitest");
}
#[test]
fn json_unused_optional_dependency_contains_expected_fields() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_optional_dependencies
.push(UnusedOptionalDependencyFinding::with_actions(
UnusedDependency {
package_name: "fsevents".to_string(),
location: DependencyLocation::OptionalDependencies,
path: root.join("package.json"),
line: 12,
used_in_workspaces: Vec::new(),
},
));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let dep = &output["unused_optional_dependencies"][0];
assert_eq!(dep["package_name"], "fsevents");
assert_eq!(output["total_issues"], 1);
}
#[test]
fn json_unused_enum_member_contains_expected_fields() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_enum_members
.push(UnusedEnumMemberFinding::with_actions(UnusedMember {
path: root.join("src/enums.ts"),
parent_name: "Color".to_string(),
member_name: "Purple".to_string(),
kind: MemberKind::EnumMember,
line: 5,
col: 2,
}));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let member = &output["unused_enum_members"][0];
assert_eq!(member["parent_name"], "Color");
assert_eq!(member["member_name"], "Purple");
assert_eq!(member["line"], 5);
assert_eq!(member["path"], "src/enums.ts");
}
#[test]
fn json_unused_class_member_contains_expected_fields() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_class_members
.push(UnusedClassMemberFinding::with_actions(UnusedMember {
path: root.join("src/api.ts"),
parent_name: "ApiClient".to_string(),
member_name: "deprecatedFetch".to_string(),
kind: MemberKind::ClassMethod,
line: 100,
col: 4,
}));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let member = &output["unused_class_members"][0];
assert_eq!(member["parent_name"], "ApiClient");
assert_eq!(member["member_name"], "deprecatedFetch");
assert_eq!(member["line"], 100);
}
#[test]
fn json_unresolved_import_contains_expected_fields() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unresolved_imports
.push(UnresolvedImportFinding::with_actions(UnresolvedImport {
path: root.join("src/app.ts"),
specifier: "@acme/missing-pkg".to_string(),
line: 7,
col: 0,
specifier_col: 0,
}));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let import = &output["unresolved_imports"][0];
assert_eq!(import["specifier"], "@acme/missing-pkg");
assert_eq!(import["line"], 7);
assert_eq!(import["path"], "src/app.ts");
}
#[test]
fn json_unlisted_dependency_contains_import_sites() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unlisted_dependencies
.push(UnlistedDependencyFinding::with_actions(
UnlistedDependency {
package_name: "dotenv".to_string(),
imported_from: vec![
ImportSite {
path: root.join("src/config.ts"),
line: 1,
col: 0,
},
ImportSite {
path: root.join("src/server.ts"),
line: 3,
col: 0,
},
],
},
));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let dep = &output["unlisted_dependencies"][0];
assert_eq!(dep["package_name"], "dotenv");
let sites = dep["imported_from"].as_array().unwrap();
assert_eq!(sites.len(), 2);
assert_eq!(sites[0]["path"], "src/config.ts");
assert_eq!(sites[1]["path"], "src/server.ts");
}
#[test]
fn json_duplicate_export_contains_locations() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.duplicate_exports
.push(DuplicateExportFinding::with_actions(DuplicateExport {
export_name: "Button".to_string(),
locations: vec![
DuplicateLocation {
path: root.join("src/ui.ts"),
line: 10,
col: 0,
},
DuplicateLocation {
path: root.join("src/components.ts"),
line: 25,
col: 0,
},
],
}));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let dup = &output["duplicate_exports"][0];
assert_eq!(dup["export_name"], "Button");
let locs = dup["locations"].as_array().unwrap();
assert_eq!(locs.len(), 2);
assert_eq!(locs[0]["line"], 10);
assert_eq!(locs[1]["line"], 25);
}
#[test]
fn duplicate_export_add_to_config_is_auto_fixable_when_config_exists() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join(".fallowrc.json"), "{}\n").unwrap();
let mut results = AnalysisResults::default();
results
.duplicate_exports
.push(DuplicateExportFinding::with_actions(DuplicateExport {
export_name: "Button".to_string(),
locations: vec![
DuplicateLocation {
path: root.join("src/ui.ts"),
line: 10,
col: 0,
},
DuplicateLocation {
path: root.join("src/components.ts"),
line: 25,
col: 0,
},
],
}));
let output = api_check_json_document(&results, root, Duration::ZERO).unwrap();
let actions = output["duplicate_exports"][0]["actions"]
.as_array()
.unwrap();
assert_eq!(actions[0]["type"], "add-to-config");
assert_eq!(actions[0]["auto_fixable"], true);
}
#[test]
fn duplicate_export_add_to_config_is_auto_fixable_when_create_fallback_allowed() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut results = AnalysisResults::default();
results
.duplicate_exports
.push(DuplicateExportFinding::with_actions(DuplicateExport {
export_name: "Button".to_string(),
locations: vec![
DuplicateLocation {
path: root.join("src/ui.ts"),
line: 10,
col: 0,
},
DuplicateLocation {
path: root.join("src/components.ts"),
line: 25,
col: 0,
},
],
}));
let output = api_check_json_document(&results, root, Duration::ZERO).unwrap();
let actions = output["duplicate_exports"][0]["actions"]
.as_array()
.unwrap();
assert_eq!(actions[0]["type"], "add-to-config");
assert_eq!(actions[0]["auto_fixable"], true);
}
#[test]
fn duplicate_export_add_to_config_is_not_auto_fixable_in_monorepo_subpackage() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path();
std::fs::write(
workspace.join("pnpm-workspace.yaml"),
"packages:\n - 'packages/*'\n",
)
.unwrap();
let sub = workspace.join("packages/ui");
std::fs::create_dir_all(&sub).unwrap();
let mut results = AnalysisResults::default();
results
.duplicate_exports
.push(DuplicateExportFinding::with_actions(DuplicateExport {
export_name: "Button".to_string(),
locations: vec![
DuplicateLocation {
path: sub.join("src/ui.ts"),
line: 10,
col: 0,
},
DuplicateLocation {
path: sub.join("src/components.ts"),
line: 25,
col: 0,
},
],
}));
let output = api_check_json_document(&results, &sub, Duration::ZERO).unwrap();
let actions = output["duplicate_exports"][0]["actions"]
.as_array()
.unwrap();
assert_eq!(actions[0]["type"], "add-to-config");
assert_eq!(actions[0]["auto_fixable"], false);
}
#[test]
fn json_type_only_dependency_contains_expected_fields() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.type_only_dependencies
.push(TypeOnlyDependencyFinding::with_actions(
TypeOnlyDependency {
package_name: "zod".to_string(),
path: root.join("package.json"),
line: 8,
},
));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let dep = &output["type_only_dependencies"][0];
assert_eq!(dep["package_name"], "zod");
assert_eq!(dep["line"], 8);
}
#[test]
fn json_circular_dependency_contains_expected_fields() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.circular_dependencies
.push(CircularDependencyFinding::with_actions(
CircularDependency {
files: vec![
root.join("src/a.ts"),
root.join("src/b.ts"),
root.join("src/c.ts"),
],
length: 3,
line: 5,
col: 0,
edges: Vec::new(),
is_cross_package: false,
},
));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let cycle = &output["circular_dependencies"][0];
assert_eq!(cycle["length"], 3);
assert_eq!(cycle["line"], 5);
let files = cycle["files"].as_array().unwrap();
assert_eq!(files.len(), 3);
}
#[test]
fn json_re_export_flagged_correctly() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_exports
.push(UnusedExportFinding::with_actions(UnusedExport {
path: root.join("src/index.ts"),
export_name: "reExported".to_string(),
is_type_only: false,
line: 1,
col: 0,
span_start: 0,
is_re_export: true,
}));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
assert_eq!(output["unused_exports"][0]["is_re_export"], true);
}
#[test]
fn json_schema_version_is_pinned() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
assert_eq!(
output["schema_version"],
fallow_output::CHECK_SCHEMA_VERSION
);
assert_eq!(output["schema_version"], 9);
}
#[test]
fn json_version_matches_cargo_pkg_version() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
}
#[test]
fn json_elapsed_ms_zero_duration() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
let output =
api_check_json_document(&results, &root, Duration::ZERO).expect("should serialize");
assert_eq!(output["elapsed_ms"], 0);
}
#[test]
fn json_elapsed_ms_large_duration() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
let elapsed = Duration::from_mins(2);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
assert_eq!(output["elapsed_ms"], 120_000);
}
#[test]
fn json_elapsed_ms_sub_millisecond_truncated() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
let elapsed = Duration::from_micros(500);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
assert_eq!(output["elapsed_ms"], 0);
}
#[test]
fn json_multiple_unused_files() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_files
.push(UnusedFileFinding::with_actions(UnusedFile {
path: root.join("src/a.ts"),
}));
results
.unused_files
.push(UnusedFileFinding::with_actions(UnusedFile {
path: root.join("src/b.ts"),
}));
results
.unused_files
.push(UnusedFileFinding::with_actions(UnusedFile {
path: root.join("src/c.ts"),
}));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
assert_eq!(output["total_issues"], 3);
}
#[test]
fn strip_root_prefix_on_string_value() {
let mut value = serde_json::json!("/project/src/file.ts");
strip_root_prefix(&mut value, "/project/");
assert_eq!(value, "src/file.ts");
}
#[test]
fn strip_root_prefix_leaves_non_matching_string() {
let mut value = serde_json::json!("/other/src/file.ts");
strip_root_prefix(&mut value, "/project/");
assert_eq!(value, "/other/src/file.ts");
}
#[test]
fn strip_root_prefix_recurses_into_arrays() {
let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
strip_root_prefix(&mut value, "/project/");
assert_eq!(value[0], "a.ts");
assert_eq!(value[1], "b.ts");
assert_eq!(value[2], "/other/c.ts");
}
#[test]
fn strip_root_prefix_recurses_into_nested_objects() {
let mut value = serde_json::json!({
"outer": {
"path": "/project/src/nested.ts"
}
});
strip_root_prefix(&mut value, "/project/");
assert_eq!(value["outer"]["path"], "src/nested.ts");
}
#[test]
fn strip_root_prefix_leaves_numbers_and_booleans() {
let mut value = serde_json::json!({
"line": 42,
"is_type_only": false,
"path": "/project/src/file.ts"
});
strip_root_prefix(&mut value, "/project/");
assert_eq!(value["line"], 42);
assert_eq!(value["is_type_only"], false);
assert_eq!(value["path"], "src/file.ts");
}
#[test]
fn strip_root_prefix_normalizes_windows_separators() {
let mut value = serde_json::json!(r"/project\src\file.ts");
strip_root_prefix(&mut value, "/project/");
assert_eq!(value, "src/file.ts");
}
#[test]
fn strip_root_prefix_rewrites_embedded_path_strings() {
let mut value =
serde_json::json!("Add \"/project/src/file.ts\" to boundaries.coverage.allowUnmatched");
strip_root_prefix(&mut value, "/project/");
assert_eq!(
value,
"Add \"src/file.ts\" to boundaries.coverage.allowUnmatched"
);
}
#[test]
fn strip_root_prefix_handles_empty_string_after_strip() {
let mut value = serde_json::json!("/project/");
strip_root_prefix(&mut value, "/project/");
assert_eq!(value, "");
}
#[test]
fn strip_root_prefix_deeply_nested_array_of_objects() {
let mut value = serde_json::json!({
"groups": [{
"instances": [{
"file": "/project/src/a.ts"
}, {
"file": "/project/src/b.ts"
}]
}]
});
strip_root_prefix(&mut value, "/project/");
assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
}
#[test]
fn json_full_sample_results_total_issues_correct() {
let root = PathBuf::from("/project");
let results = sample_results(&root);
let elapsed = Duration::from_millis(100);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
assert_eq!(output["total_issues"], results.total_issues());
}
#[test]
fn json_full_sample_no_absolute_paths_in_output() {
let root = PathBuf::from("/project");
let results = sample_results(&root);
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let json_str = serde_json::to_string(&output).expect("should stringify");
assert!(!json_str.contains("/project/src/"));
assert!(!json_str.contains("/project/package.json"));
}
#[test]
fn json_output_is_deterministic() {
let root = PathBuf::from("/project");
let results = sample_results(&root);
let elapsed = Duration::from_millis(50);
let output1 = api_check_json_document(&results, &root, elapsed).expect("first build");
let output2 = api_check_json_document(&results, &root, elapsed).expect("second build");
assert_eq!(output1, output2);
}
#[test]
fn json_results_fields_do_not_shadow_metadata() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
let elapsed = Duration::from_millis(99);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
assert_eq!(output["kind"], "dead-code");
assert_eq!(output["schema_version"], 9);
assert_eq!(output["elapsed_ms"], 99);
}
#[test]
fn type_aware_metadata_is_emitted_without_explain() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
let type_aware = fallow_types::envelope::TypeAwareMeta {
executed: true,
identity: None,
required_completeness: None,
queries: Vec::new(),
candidate_decisions: Vec::new(),
symbol_traces: Vec::new(),
api_surface: None,
symbol_impacts: Vec::new(),
type_coupling: None,
protocol_version: 2,
sidecar_version: Some("0.1.0".to_string()),
backend: "typescript-go".to_string(),
backend_version: Some("7.0.2".to_string()),
selected_tsconfigs: vec!["tsconfig.json".to_string()],
candidate_count: 2,
confirmed_used_count: 1,
contract_preserved_count: 0,
no_static_references_count: 0,
fix_eligible_count: 0,
unresolved_count: 1,
abstained_count: 0,
abstention_reasons: fallow_types::envelope::TypeAwareAbstentionCounts::default(),
projects: Vec::new(),
warning_count: 1,
warnings: vec!["unresolved finding kept".to_owned()],
elapsed_ms: 12,
phase_timings_ms: fallow_types::envelope::TypeAwarePhaseTimings::default(),
};
let output = api_check_json_document_with_config_fixable_meta_and_extras(
&results,
&root,
Duration::default(),
false,
check_output_meta(false, Some(&type_aware)),
CheckJsonExtraOutputs::default(),
&[],
)
.expect("type-aware metadata should serialize");
assert_eq!(output["_meta"]["type_aware"]["backend"], "typescript-go");
assert_eq!(
output["_meta"]["type_aware"]["warnings"][0],
"unresolved finding kept"
);
assert!(output["_meta"].get("rules").is_none());
}
#[test]
fn type_aware_explain_defines_semantic_metrics() {
let type_aware = fallow_types::envelope::TypeAwareMeta::default();
let meta = check_output_meta(true, Some(&type_aware)).expect("metadata");
assert!(
meta.field_definitions
.contains_key("type_aware.protocol_version")
);
assert!(meta.metrics.contains_key("type_aware.protocol_version"));
for metric in [
"type_aware.candidate_count",
"type_aware.elapsed_ms",
"type_aware.phase_timings_ms.project_setup",
"type_aware.phase_timings_ms.diagnostics",
"type_aware.phase_timings_ms.symbol_scan",
"type_aware.projects[].candidate_count",
"type_aware.projects[].confirmed_used_count",
"type_aware.projects[].contract_preserved_count",
"type_aware.projects[].no_static_references_count",
"type_aware.projects[].fix_eligible_count",
"type_aware.projects[].unresolved_count",
"type_aware.projects[].abstained_count",
"type_aware.projects[].blocking_diagnostic_count",
"type_aware.projects[].source_file_count",
"type_aware.abstention_reasons.no_project",
"type_aware.abstention_reasons.ambiguous_project",
"type_aware.abstention_reasons.blocking_diagnostics",
"type_aware.abstention_reasons.svelte_virtual_module_exports",
"type_aware.abstention_reasons.unknown_symbol",
"type_aware.abstention_reasons.unsupported_syntax",
"type_aware.abstention_reasons.capacity",
] {
assert!(meta.metrics.contains_key(metric), "missing metric {metric}");
}
let candidates = meta.metrics["type_aware.candidate_count"]
.description
.as_deref()
.expect("candidate description");
assert!(candidates.contains("class members"));
assert!(candidates.contains("exports"));
assert!(candidates.contains("types"));
let confirmed = meta.metrics["type_aware.confirmed_used_count"]
.description
.as_deref()
.expect("confirmed-used description");
assert!(confirmed.contains("imports"));
assert!(confirmed.contains("re-exports"));
assert!(confirmed.contains("property or element access"));
}
#[test]
fn focused_semantic_explain_marks_authoritative_fields() {
let mut output = serde_json::json!({
"kind": "trace",
"semantic": {
"identity": {"completeness": "complete"},
"status": "complete",
"references": []
},
"_meta": {
"telemetry": {"analysis_run_id": "run-1"}
}
});
attach_semantic_explain_meta(&mut output, true);
assert_eq!(output["_meta"]["telemetry"]["analysis_run_id"], "run-1");
let semantic_definition = output["_meta"]["field_definitions"]["semantic"]
.as_str()
.expect("semantic field definition");
assert!(semantic_definition.contains("trust semantic.identity"));
assert!(
semantic_definition.contains("semantic.target.namespace")
&& semantic_definition.contains("wider"),
"the definition scopes the proof to its lane: {semantic_definition}"
);
assert!(output["_meta"]["metrics"]["semantic.total_reference_count"].is_object());
}
#[test]
fn json_all_issue_type_arrays_present_in_empty_results() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let expected_arrays = [
"unused_files",
"unused_exports",
"unused_types",
"unused_dependencies",
"unused_dev_dependencies",
"unused_optional_dependencies",
"unused_enum_members",
"unused_class_members",
"unresolved_imports",
"unlisted_dependencies",
"duplicate_exports",
"type_only_dependencies",
"test_only_dependencies",
"dev_dependencies_in_production",
"circular_dependencies",
];
for key in &expected_arrays {
assert!(
output[key].is_array(),
"expected '{key}' to be an array in JSON output"
);
}
}
#[test]
fn insert_meta_adds_key_to_object() {
let mut output = serde_json::json!({ "foo": 1 });
let meta = serde_json::json!({ "docs": "https://example.com" });
insert_meta(&mut output, meta.clone());
assert_eq!(output["_meta"], meta);
}
#[test]
fn insert_meta_noop_on_non_object() {
let mut output = serde_json::json!([1, 2, 3]);
let meta = serde_json::json!({ "docs": "https://example.com" });
insert_meta(&mut output, meta);
assert!(output.is_array());
}
#[test]
fn insert_meta_overwrites_existing_meta() {
let mut output = serde_json::json!({ "_meta": "old" });
let meta = serde_json::json!({ "new": true });
insert_meta(&mut output, meta.clone());
assert_eq!(output["_meta"], meta);
}
#[test]
fn insert_meta_preserves_existing_telemetry_meta() {
let mut output = serde_json::json!({
"_meta": {
"telemetry": {
"analysis_run_id": "run_test123"
}
}
});
insert_meta(
&mut output,
serde_json::json!({ "docs": "https://example.com" }),
);
assert_eq!(
output["_meta"]["docs"].as_str(),
Some("https://example.com")
);
assert_eq!(
output["_meta"]["telemetry"]["analysis_run_id"].as_str(),
Some("run_test123")
);
}
#[test]
fn strip_root_prefix_null_unchanged() {
let mut value = serde_json::Value::Null;
strip_root_prefix(&mut value, "/project/");
assert!(value.is_null());
}
#[test]
fn strip_root_prefix_empty_string() {
let mut value = serde_json::json!("");
strip_root_prefix(&mut value, "/project/");
assert_eq!(value, "");
}
#[test]
fn strip_root_prefix_mixed_types() {
let mut value = serde_json::json!({
"path": "/project/src/file.ts",
"line": 42,
"flag": true,
"nested": {
"items": ["/project/a.ts", 99, null, "/project/b.ts"],
"deep": { "path": "/project/c.ts" }
}
});
strip_root_prefix(&mut value, "/project/");
assert_eq!(value["path"], "src/file.ts");
assert_eq!(value["line"], 42);
assert_eq!(value["flag"], true);
assert_eq!(value["nested"]["items"][0], "a.ts");
assert_eq!(value["nested"]["items"][1], 99);
assert!(value["nested"]["items"][2].is_null());
assert_eq!(value["nested"]["items"][3], "b.ts");
assert_eq!(value["nested"]["deep"]["path"], "c.ts");
}
#[test]
fn json_check_meta_integrates_correctly() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
let elapsed = Duration::from_millis(0);
let mut output =
api_check_json_document(&results, &root, elapsed).expect("should serialize");
insert_meta(
&mut output,
serde_json::to_value(fallow_output::check_meta()).unwrap(),
);
assert!(output["_meta"]["docs"].is_string());
assert!(output["_meta"]["rules"].is_object());
}
#[test]
fn json_unused_member_kind_serialized() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_enum_members
.push(UnusedEnumMemberFinding::with_actions(UnusedMember {
path: root.join("src/enums.ts"),
parent_name: "Color".to_string(),
member_name: "Red".to_string(),
kind: MemberKind::EnumMember,
line: 3,
col: 2,
}));
results
.unused_class_members
.push(UnusedClassMemberFinding::with_actions(UnusedMember {
path: root.join("src/class.ts"),
parent_name: "Foo".to_string(),
member_name: "bar".to_string(),
kind: MemberKind::ClassMethod,
line: 10,
col: 4,
}));
let elapsed = Duration::from_millis(0);
let output = api_check_json_document(&results, &root, elapsed).expect("should serialize");
let enum_member = &output["unused_enum_members"][0];
assert!(enum_member["kind"].is_string());
let class_member = &output["unused_class_members"][0];
assert!(class_member["kind"].is_string());
}
#[test]
fn json_unused_export_has_actions() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_exports
.push(UnusedExportFinding::with_actions(UnusedExport {
path: root.join("src/utils.ts"),
export_name: "helperFn".to_string(),
is_type_only: false,
line: 10,
col: 4,
span_start: 120,
is_re_export: false,
}));
let output = api_check_json_document(&results, &root, Duration::ZERO).unwrap();
let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
assert_eq!(actions.len(), 2);
assert_eq!(actions[0]["type"], "remove-export");
assert_eq!(actions[0]["auto_fixable"], true);
assert!(actions[0].get("note").is_none());
assert_eq!(actions[1]["type"], "suppress-line");
assert_eq!(
actions[1]["comment"],
"// fallow-ignore-next-line unused-export"
);
}
#[test]
fn json_boundary_coverage_action_descriptions_use_relative_paths() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.boundary_coverage_violations
.push(BoundaryCoverageViolationFinding::with_actions(
BoundaryCoverageViolation {
path: root.join("src/middleware/error.ts"),
line: 1,
col: 0,
},
));
let output = api_check_json_document(&results, &root, Duration::ZERO).unwrap();
let action = &output["boundary_coverage_violations"][0]["actions"][1];
assert_eq!(
output["boundary_coverage_violations"][0]["path"],
"src/middleware/error.ts"
);
assert_eq!(action["value"], "src/middleware/error.ts");
assert_eq!(
action["description"],
"Add \"src/middleware/error.ts\" to boundaries.coverage.allowUnmatched in fallow config"
);
}
#[test]
fn json_same_line_findings_share_multi_kind_suppression_comment() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_exports
.push(UnusedExportFinding::with_actions(UnusedExport {
path: root.join("src/api.ts"),
export_name: "helperFn".to_string(),
is_type_only: false,
line: 10,
col: 4,
span_start: 120,
is_re_export: false,
}));
results
.unused_types
.push(UnusedTypeFinding::with_actions(UnusedExport {
path: root.join("src/api.ts"),
export_name: "OldType".to_string(),
is_type_only: true,
line: 10,
col: 0,
span_start: 60,
is_re_export: false,
}));
let output = api_check_json_document(&results, &root, Duration::ZERO).unwrap();
let export_actions = output["unused_exports"][0]["actions"].as_array().unwrap();
let type_actions = output["unused_types"][0]["actions"].as_array().unwrap();
assert_eq!(
export_actions[1]["comment"],
"// fallow-ignore-next-line unused-export, unused-type"
);
assert_eq!(
type_actions[1]["comment"],
"// fallow-ignore-next-line unused-export, unused-type"
);
}
#[test]
fn json_unused_file_has_file_suppress_and_note() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_files
.push(UnusedFileFinding::with_actions(UnusedFile {
path: root.join("src/dead.ts"),
}));
let output = api_check_json_document(&results, &root, Duration::ZERO).unwrap();
let actions = output["unused_files"][0]["actions"].as_array().unwrap();
assert_eq!(actions[0]["type"], "delete-file");
assert_eq!(actions[0]["auto_fixable"], false);
assert!(actions[0]["note"].is_string());
assert_eq!(actions[1]["type"], "suppress-file");
assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
}
#[test]
fn json_unused_dependency_has_config_suppress_with_package_name() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_dependencies
.push(UnusedDependencyFinding::with_actions(UnusedDependency {
package_name: "lodash".to_string(),
location: DependencyLocation::Dependencies,
path: root.join("package.json"),
line: 5,
used_in_workspaces: Vec::new(),
}));
let output = api_check_json_document(&results, &root, Duration::ZERO).unwrap();
let actions = output["unused_dependencies"][0]["actions"]
.as_array()
.unwrap();
assert_eq!(actions[0]["type"], "remove-dependency");
assert_eq!(actions[0]["auto_fixable"], true);
assert_eq!(actions[1]["type"], "add-to-config");
assert_eq!(actions[1]["config_key"], "ignoreDependencies");
assert_eq!(actions[1]["value"], "lodash");
}
#[test]
fn json_cross_workspace_dependency_is_not_auto_fixable() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_dependencies
.push(UnusedDependencyFinding::with_actions(UnusedDependency {
package_name: "lodash-es".to_string(),
location: DependencyLocation::Dependencies,
path: root.join("packages/shared/package.json"),
line: 5,
used_in_workspaces: vec![root.join("packages/consumer")],
}));
let output = api_check_json_document(&results, &root, Duration::ZERO).unwrap();
let actions = output["unused_dependencies"][0]["actions"]
.as_array()
.unwrap();
assert_eq!(actions[0]["type"], "move-dependency");
assert_eq!(actions[0]["auto_fixable"], false);
assert!(
actions[0]["note"]
.as_str()
.unwrap()
.contains("will not remove")
);
assert_eq!(actions[1]["type"], "add-to-config");
}
#[test]
fn json_empty_results_have_no_actions_in_empty_arrays() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
let output = api_check_json_document(&results, &root, Duration::ZERO).unwrap();
assert!(output["unused_exports"].as_array().unwrap().is_empty());
assert!(output["unused_files"].as_array().unwrap().is_empty());
}
#[test]
fn json_all_issue_types_have_actions() {
let root = PathBuf::from("/project");
let results = sample_results(&root);
let output = api_check_json_document(&results, &root, Duration::ZERO).unwrap();
let issue_keys = [
"unused_files",
"unused_exports",
"unused_types",
"unused_dependencies",
"unused_dev_dependencies",
"unused_optional_dependencies",
"unused_enum_members",
"unused_class_members",
"unresolved_imports",
"unlisted_dependencies",
"duplicate_exports",
"type_only_dependencies",
"test_only_dependencies",
"dev_dependencies_in_production",
"circular_dependencies",
];
for key in &issue_keys {
let arr = output[key].as_array().unwrap();
if !arr.is_empty() {
let actions = arr[0]["actions"].as_array();
assert!(
actions.is_some() && !actions.unwrap().is_empty(),
"missing actions for {key}"
);
}
}
}
fn build_actions_for_finding_json(
finding_json: serde_json::Value,
opts: fallow_output::HealthActionOptions,
max_cyclomatic_threshold: u16,
max_cognitive_threshold: u16,
max_crap_threshold: f64,
) -> Vec<serde_json::Value> {
let mut value = finding_json;
if let Some(map) = value.as_object_mut() {
map.entry("col".to_string())
.or_insert(serde_json::Value::from(0_u32));
map.entry("line_count".to_string())
.or_insert(serde_json::Value::from(0_u32));
map.entry("param_count".to_string())
.or_insert(serde_json::Value::from(0_u8));
map.entry("severity".to_string())
.or_insert(serde_json::Value::String("moderate".to_string()));
}
let violation = synthesize_complexity_violation(&value);
let ctx = fallow_output::HealthActionContext {
opts,
max_cyclomatic_threshold,
max_cognitive_threshold,
max_crap_threshold,
crap_refactor_band: 5,
};
let finding = fallow_output::HealthFinding::with_actions(violation, &ctx);
let serialized = serde_json::to_value(&finding).expect("serialize HealthFinding");
serialized["actions"]
.as_array()
.cloned()
.unwrap_or_default()
}
fn synthesize_complexity_violation(
value: &serde_json::Value,
) -> fallow_output::ComplexityViolation {
use fallow_output::{CoverageSource, CoverageTier, ExceededThreshold, FindingSeverity};
let exceeded = match value["exceeded"].as_str().unwrap_or("crap") {
"cyclomatic" => ExceededThreshold::Cyclomatic,
"cognitive" => ExceededThreshold::Cognitive,
"both" => ExceededThreshold::Both,
"crap" => ExceededThreshold::Crap,
"cyclomatic_crap" => ExceededThreshold::CyclomaticCrap,
"cognitive_crap" => ExceededThreshold::CognitiveCrap,
"all" => ExceededThreshold::All,
other => panic!("unknown exceeded label: {other}"),
};
let severity = match value["severity"].as_str().unwrap_or("moderate") {
"moderate" => FindingSeverity::Moderate,
"high" => FindingSeverity::High,
"critical" => FindingSeverity::Critical,
other => panic!("unknown severity label: {other}"),
};
let coverage_tier = value
.get("coverage_tier")
.and_then(|v| v.as_str())
.map(|t| match t {
"none" => CoverageTier::None,
"partial" => CoverageTier::Partial,
"high" => CoverageTier::High,
other => panic!("unknown coverage_tier label: {other}"),
});
let coverage_source =
value
.get("coverage_source")
.and_then(|v| v.as_str())
.map(|s| match s {
"istanbul" => CoverageSource::Istanbul,
"estimated" => CoverageSource::Estimated,
"estimated_component_inherited" => CoverageSource::EstimatedComponentInherited,
other => panic!("unknown coverage_source label: {other}"),
});
fallow_output::ComplexityViolation {
path: std::path::PathBuf::from(value["path"].as_str().unwrap_or("src/x.ts")),
name: value["name"].as_str().unwrap_or("fn").to_string(),
line: u32::try_from(value["line"].as_u64().unwrap_or(0)).unwrap_or(0),
col: u32::try_from(value["col"].as_u64().unwrap_or(0)).unwrap_or(0),
cyclomatic: u16::try_from(value["cyclomatic"].as_u64().unwrap_or(0)).unwrap_or(0),
cognitive: u16::try_from(value["cognitive"].as_u64().unwrap_or(0)).unwrap_or(0),
line_count: u32::try_from(value["line_count"].as_u64().unwrap_or(0)).unwrap_or(0),
param_count: u8::try_from(value["param_count"].as_u64().unwrap_or(0)).unwrap_or(0),
react_hook_count: u16::try_from(value["react_hook_count"].as_u64().unwrap_or(0))
.unwrap_or(0),
react_jsx_max_depth: u16::try_from(value["react_jsx_max_depth"].as_u64().unwrap_or(0))
.unwrap_or(0),
react_prop_count: u16::try_from(value["react_prop_count"].as_u64().unwrap_or(0))
.unwrap_or(0),
react_hook_profile: value.get("react_hook_profile").map(|p| {
let read_u16 = |key: &str| {
u16::try_from(p.get(key).and_then(serde_json::Value::as_u64).unwrap_or(0))
.unwrap_or(0)
};
fallow_output::ReactHookProfile {
state: read_u16("state"),
effect: read_u16("effect"),
memo: read_u16("memo"),
callback: read_u16("callback"),
custom: read_u16("custom"),
max_effect_dep_arity: p
.get("max_effect_dep_arity")
.and_then(serde_json::Value::as_u64)
.and_then(|v| u32::try_from(v).ok()),
}
}),
exceeded,
severity,
crap: value.get("crap").and_then(|v| v.as_f64()),
coverage_pct: value.get("coverage_pct").and_then(|v| v.as_f64()),
coverage_tier,
coverage_source,
inherited_from: value
.get("inherited_from")
.and_then(|v| v.as_str())
.map(std::path::PathBuf::from),
component_rollup: value.get("component_rollup").and_then(|v| {
let map = v.as_object()?;
Some(fallow_output::ComponentRollup {
component: map.get("component")?.as_str()?.to_string(),
class_worst_function: map.get("class_worst_function")?.as_str()?.to_string(),
class_cyclomatic: u16::try_from(map.get("class_cyclomatic")?.as_u64()?).ok()?,
class_cognitive: u16::try_from(map.get("class_cognitive")?.as_u64()?).ok()?,
template_path: std::path::PathBuf::from(map.get("template_path")?.as_str()?),
template_cyclomatic: u16::try_from(map.get("template_cyclomatic")?.as_u64()?)
.ok()?,
template_cognitive: u16::try_from(map.get("template_cognitive")?.as_u64()?)
.ok()?,
})
}),
contributions: Vec::new(),
effective_thresholds: None,
threshold_source: None,
}
}
#[test]
fn health_finding_has_actions() {
let actions = build_actions_for_finding_json(
serde_json::json!({
"path": "src/utils.ts",
"name": "processData",
"line": 10,
"col": 0,
"cyclomatic": 25,
"cognitive": 30,
"line_count": 150,
"exceeded": "both"
}),
fallow_output::HealthActionOptions::default(),
20,
15,
30.0,
);
assert_eq!(actions.len(), 2);
assert_eq!(actions[0]["type"], "refactor-function");
assert_eq!(actions[0]["auto_fixable"], false);
assert!(
actions[0]["description"]
.as_str()
.unwrap()
.contains("processData")
);
assert_eq!(actions[1]["type"], "suppress-line");
assert_eq!(
actions[1]["comment"],
"// fallow-ignore-next-line complexity"
);
}
#[test]
fn health_finding_suppress_has_placement() {
let actions = build_actions_for_finding_json(
serde_json::json!({
"path": "src/utils.ts",
"name": "processData",
"line": 10,
"col": 0,
"cyclomatic": 25,
"cognitive": 30,
"line_count": 150,
"exceeded": "both"
}),
fallow_output::HealthActionOptions::default(),
20,
15,
30.0,
);
assert_eq!(actions[1]["placement"], "above-function-declaration");
}
#[test]
fn html_template_health_finding_uses_html_suppression() {
let actions = build_actions_for_finding_json(
serde_json::json!({
"path": "src/app.component.html",
"name": "<template>",
"line": 1,
"col": 0,
"cyclomatic": 25,
"cognitive": 30,
"line_count": 40,
"exceeded": "both"
}),
fallow_output::HealthActionOptions::default(),
20,
15,
30.0,
);
let suppress = &actions[1];
assert_eq!(suppress["type"], "suppress-file");
assert_eq!(
suppress["comment"],
"<!-- fallow-ignore-file complexity -->"
);
assert_eq!(suppress["placement"], "top-of-template");
}
#[test]
fn inline_template_health_finding_uses_decorator_suppression() {
let actions = build_actions_for_finding_json(
serde_json::json!({
"path": "src/app.component.ts",
"name": "<template>",
"line": 5,
"col": 0,
"cyclomatic": 25,
"cognitive": 30,
"line_count": 40,
"exceeded": "both"
}),
fallow_output::HealthActionOptions::default(),
20,
15,
30.0,
);
let refactor = &actions[0];
assert_eq!(refactor["type"], "refactor-function");
assert!(
refactor["description"]
.as_str()
.unwrap()
.contains("template complexity")
);
let suppress = &actions[1];
assert_eq!(suppress["type"], "suppress-line");
assert_eq!(
suppress["description"],
"Suppress with an inline comment above the Angular decorator"
);
assert_eq!(suppress["placement"], "above-angular-decorator");
}
fn crap_only_finding_envelope(
coverage_tier: Option<&str>,
cyclomatic: u16,
max_cyclomatic_threshold: u16,
) -> serde_json::Value {
crap_only_finding_envelope_with_max_crap(
coverage_tier,
cyclomatic,
12,
max_cyclomatic_threshold,
15,
30.0,
)
}
fn crap_only_finding_envelope_with_cognitive(
coverage_tier: Option<&str>,
cyclomatic: u16,
cognitive: u16,
max_cyclomatic_threshold: u16,
) -> serde_json::Value {
crap_only_finding_envelope_with_max_crap(
coverage_tier,
cyclomatic,
cognitive,
max_cyclomatic_threshold,
15,
30.0,
)
}
fn crap_only_finding_envelope_with_max_crap(
coverage_tier: Option<&str>,
cyclomatic: u16,
cognitive: u16,
max_cyclomatic_threshold: u16,
max_cognitive_threshold: u16,
max_crap_threshold: f64,
) -> serde_json::Value {
build_finding_envelope_with_ctx(
coverage_tier,
cyclomatic,
cognitive,
max_cyclomatic_threshold,
max_cognitive_threshold,
max_crap_threshold,
fallow_output::HealthActionOptions::default(),
)
}
#[expect(
clippy::too_many_arguments,
reason = "test scaffold; positional envelope builder over independent metric/threshold knobs, bundling adds churn with no production value"
)]
fn build_finding_envelope_with_ctx(
coverage_tier: Option<&str>,
cyclomatic: u16,
cognitive: u16,
max_cyclomatic_threshold: u16,
max_cognitive_threshold: u16,
max_crap_threshold: f64,
action_opts: fallow_output::HealthActionOptions,
) -> serde_json::Value {
let tier = coverage_tier.map(|t| match t {
"none" => fallow_output::CoverageTier::None,
"partial" => fallow_output::CoverageTier::Partial,
"high" => fallow_output::CoverageTier::High,
other => panic!("unknown coverage tier label: {other}"),
});
let violation = fallow_output::ComplexityViolation {
path: std::path::PathBuf::from("src/risk.ts"),
name: "computeScore".to_string(),
line: 12,
col: 0,
cyclomatic,
cognitive,
line_count: 40,
param_count: 0,
react_hook_count: 0,
react_jsx_max_depth: 0,
react_prop_count: 0,
react_hook_profile: None,
exceeded: fallow_output::ExceededThreshold::Crap,
severity: fallow_output::FindingSeverity::Moderate,
crap: Some(35.5),
coverage_pct: None,
coverage_tier: tier,
coverage_source: None,
inherited_from: None,
component_rollup: None,
contributions: Vec::new(),
effective_thresholds: None,
threshold_source: None,
};
let ctx = fallow_output::HealthActionContext {
opts: action_opts,
max_cyclomatic_threshold,
max_cognitive_threshold,
max_crap_threshold,
crap_refactor_band: 5,
};
let finding = fallow_output::HealthFinding::with_actions(violation, &ctx);
let actions_meta = if action_opts.omit_suppress_line {
Some(serde_json::json!({
"suppression_hints_omitted": true,
"reason": action_opts.omit_reason.unwrap_or("unspecified"),
"scope": "health-findings",
}))
} else {
None
};
let mut envelope = serde_json::json!({
"findings": [serde_json::to_value(&finding).unwrap()],
"summary": {
"max_cyclomatic_threshold": max_cyclomatic_threshold,
"max_cognitive_threshold": max_cognitive_threshold,
"max_crap_threshold": max_crap_threshold,
},
});
if let Some(meta) = actions_meta
&& let Some(map) = envelope.as_object_mut()
{
map.insert("actions_meta".to_string(), meta);
}
envelope
}
#[test]
fn crap_only_tier_none_emits_add_tests() {
let output = crap_only_finding_envelope(Some("none"), 6, 20);
let actions = output["findings"][0]["actions"].as_array().unwrap();
assert!(
actions.iter().any(|a| a["type"] == "add-tests"),
"tier=none crap-only must emit add-tests, got {actions:?}"
);
assert!(
!actions.iter().any(|a| a["type"] == "increase-coverage"),
"tier=none must not emit increase-coverage"
);
}
#[test]
fn crap_only_tier_partial_emits_increase_coverage() {
let output = crap_only_finding_envelope(Some("partial"), 6, 20);
let actions = output["findings"][0]["actions"].as_array().unwrap();
assert!(
actions.iter().any(|a| a["type"] == "increase-coverage"),
"tier=partial crap-only must emit increase-coverage, got {actions:?}"
);
assert!(
!actions.iter().any(|a| a["type"] == "add-tests"),
"tier=partial must not emit add-tests"
);
}
#[test]
fn crap_only_tier_high_emits_increase_coverage_when_full_coverage_can_clear_crap() {
let output = crap_only_finding_envelope(Some("high"), 20, 30);
let actions = output["findings"][0]["actions"].as_array().unwrap();
assert!(
actions.iter().any(|a| a["type"] == "increase-coverage"),
"tier=high crap-only must still emit increase-coverage when full coverage can clear CRAP, got {actions:?}"
);
assert!(
!actions.iter().any(|a| a["type"] == "refactor-function"),
"coverage-remediable crap-only findings should not get refactor-function unless near the cyclomatic threshold"
);
assert!(
!actions.iter().any(|a| a["type"] == "add-tests"),
"tier=high must not emit add-tests"
);
}
#[test]
fn crap_only_emits_refactor_when_full_coverage_cannot_clear_crap() {
let output = crap_only_finding_envelope_with_max_crap(Some("high"), 35, 12, 50, 15, 30.0);
let actions = output["findings"][0]["actions"].as_array().unwrap();
assert!(
actions.iter().any(|a| a["type"] == "refactor-function"),
"full-coverage-impossible CRAP-only finding must emit refactor-function, got {actions:?}"
);
assert!(
!actions.iter().any(|a| a["type"] == "increase-coverage"),
"must not emit increase-coverage when even 100% coverage cannot clear CRAP"
);
assert!(
!actions.iter().any(|a| a["type"] == "add-tests"),
"must not emit add-tests when even 100% coverage cannot clear CRAP"
);
}
#[test]
fn crap_only_high_cc_appends_secondary_refactor() {
let output = crap_only_finding_envelope(Some("none"), 16, 20);
let actions = output["findings"][0]["actions"].as_array().unwrap();
assert!(
actions.iter().any(|a| a["type"] == "add-tests"),
"near-threshold crap-only still emits the primary tier action"
);
assert!(
actions.iter().any(|a| a["type"] == "refactor-function"),
"near-threshold crap-only must also emit secondary refactor-function"
);
}
#[test]
fn crap_only_far_below_threshold_no_secondary_refactor() {
let output = crap_only_finding_envelope(Some("none"), 6, 20);
let actions = output["findings"][0]["actions"].as_array().unwrap();
assert!(
!actions.iter().any(|a| a["type"] == "refactor-function"),
"low-CC crap-only should not get a secondary refactor-function"
);
}
#[test]
fn crap_only_near_threshold_low_cognitive_no_secondary_refactor() {
let output = crap_only_finding_envelope_with_cognitive(Some("none"), 17, 2, 20);
let actions = output["findings"][0]["actions"].as_array().unwrap();
assert!(
actions.iter().any(|a| a["type"] == "add-tests"),
"primary tier action still emits"
);
assert!(
!actions.iter().any(|a| a["type"] == "refactor-function"),
"near-threshold CC with cognitive below floor must NOT emit secondary refactor (got {actions:?})"
);
}
#[test]
fn crap_only_near_threshold_high_cognitive_emits_secondary_refactor() {
let output = crap_only_finding_envelope_with_cognitive(Some("none"), 16, 10, 20);
let actions = output["findings"][0]["actions"].as_array().unwrap();
assert!(
actions.iter().any(|a| a["type"] == "add-tests"),
"primary tier action still emits"
);
assert!(
actions.iter().any(|a| a["type"] == "refactor-function"),
"near-threshold CC with cognitive above floor must emit secondary refactor (got {actions:?})"
);
}
#[test]
fn crap_only_secondary_refactor_respects_configured_band() {
let violation = fallow_output::ComplexityViolation {
path: std::path::PathBuf::from("src/risk.ts"),
name: "computeScore".to_string(),
line: 12,
col: 0,
cyclomatic: 14,
cognitive: 10,
line_count: 40,
param_count: 0,
react_hook_count: 0,
react_jsx_max_depth: 0,
react_prop_count: 0,
react_hook_profile: None,
exceeded: fallow_output::ExceededThreshold::Crap,
severity: fallow_output::FindingSeverity::Moderate,
crap: Some(35.5),
coverage_pct: None,
coverage_tier: Some(fallow_output::CoverageTier::None),
coverage_source: None,
inherited_from: None,
component_rollup: None,
contributions: Vec::new(),
effective_thresholds: None,
threshold_source: None,
};
let narrow_ctx = fallow_output::HealthActionContext {
opts: fallow_output::HealthActionOptions::default(),
max_cyclomatic_threshold: 20,
max_cognitive_threshold: 15,
max_crap_threshold: 30.0,
crap_refactor_band: 5,
};
let wide_ctx = fallow_output::HealthActionContext {
crap_refactor_band: 6,
..narrow_ctx
};
let narrow_actions = fallow_output::build_health_finding_actions(&violation, &narrow_ctx);
let wide_actions = fallow_output::build_health_finding_actions(&violation, &wide_ctx);
assert!(
!narrow_actions.iter().any(|a| {
matches!(
a.kind,
fallow_types::output_health::HealthFindingActionType::RefactorFunction
)
}),
"default band should not refactor a CRAP-only finding 6 below max cyclomatic"
);
assert!(
wide_actions.iter().any(|a| {
matches!(
a.kind,
fallow_types::output_health::HealthFindingActionType::RefactorFunction
)
}),
"configured wider band should emit the secondary refactor action"
);
}
#[test]
fn cyclomatic_only_emits_only_refactor_function() {
let actions = build_actions_for_finding_json(
serde_json::json!({
"path": "src/cyclo.ts",
"name": "branchy",
"line": 5,
"col": 0,
"cyclomatic": 25,
"cognitive": 10,
"line_count": 80,
"exceeded": "cyclomatic",
}),
fallow_output::HealthActionOptions::default(),
20,
15,
30.0,
);
assert!(
actions.iter().any(|a| a["type"] == "refactor-function"),
"non-CRAP findings emit refactor-function"
);
assert!(
!actions.iter().any(|a| a["type"] == "add-tests"),
"non-CRAP findings must not emit add-tests"
);
assert!(
!actions.iter().any(|a| a["type"] == "increase-coverage"),
"non-CRAP findings must not emit increase-coverage"
);
}
#[test]
fn suppress_line_omitted_when_baseline_active() {
let output = build_finding_envelope_with_ctx(
Some("none"),
6,
12,
20,
15,
30.0,
fallow_output::HealthActionOptions {
omit_suppress_line: true,
omit_reason: Some("baseline-active"),
},
);
let actions = output["findings"][0]["actions"].as_array().unwrap();
assert!(
!actions.iter().any(|a| a["type"] == "suppress-line"),
"baseline-active must not emit suppress-line, got {actions:?}"
);
assert_eq!(
output["actions_meta"]["suppression_hints_omitted"],
serde_json::Value::Bool(true)
);
assert_eq!(output["actions_meta"]["reason"], "baseline-active");
assert_eq!(output["actions_meta"]["scope"], "health-findings");
}
#[test]
fn suppress_line_omitted_when_config_disabled() {
let output = build_finding_envelope_with_ctx(
Some("none"),
6,
12,
20,
15,
30.0,
fallow_output::HealthActionOptions {
omit_suppress_line: true,
omit_reason: Some("config-disabled"),
},
);
assert_eq!(output["actions_meta"]["reason"], "config-disabled");
}
#[test]
fn suppress_line_emitted_by_default() {
let output = crap_only_finding_envelope(Some("none"), 6, 20);
let actions = output["findings"][0]["actions"].as_array().unwrap();
assert!(
actions.iter().any(|a| a["type"] == "suppress-line"),
"default opts must emit suppress-line"
);
assert!(
output.get("actions_meta").is_none(),
"actions_meta must be absent when no omission occurred"
);
}
#[test]
fn every_emitted_health_action_type_is_in_schema_enum() {
let cases = [
("crap", Some("none"), 6_u16, 20_u16),
("crap", Some("partial"), 6, 20),
("crap", Some("high"), 12, 20),
("crap", Some("none"), 16, 20), ("cyclomatic", None, 25, 20),
("cognitive_crap", Some("partial"), 6, 20),
("all", Some("none"), 25, 20),
];
let mut emitted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for (exceeded, tier, cc, max) in cases {
let mut finding = serde_json::json!({
"path": "src/x.ts",
"name": "fn",
"line": 1,
"col": 0,
"cyclomatic": cc,
"cognitive": 5,
"line_count": 10,
"exceeded": exceeded,
"crap": 35.0,
});
if let Some(t) = tier {
finding["coverage_tier"] = serde_json::Value::String(t.to_owned());
}
let actions = build_actions_for_finding_json(
finding,
fallow_output::HealthActionOptions::default(),
max,
15,
30.0,
);
for action in &actions {
if let Some(ty) = action["type"].as_str() {
emitted.insert(ty.to_owned());
}
}
}
let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("docs")
.join("output-schema.json");
let raw = std::fs::read_to_string(&schema_path)
.expect("docs/output-schema.json must be readable for the drift-guard test");
let schema: serde_json::Value = serde_json::from_str(&raw).expect("schema parses");
let type_field = &schema["definitions"]["HealthFindingAction"]["properties"]["type"];
let type_def = if let Some(reference) = type_field.get("$ref").and_then(|r| r.as_str()) {
let name = reference
.strip_prefix("#/definitions/")
.expect("HealthFindingAction.type $ref points into #/definitions/");
&schema["definitions"][name]
} else {
type_field
};
let mut enum_values: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
if let Some(arr) = type_def.get("enum").and_then(|e| e.as_array()) {
for v in arr {
if let Some(s) = v.as_str() {
enum_values.insert(s.to_owned());
}
}
}
if let Some(arr) = type_def.get("oneOf").and_then(|e| e.as_array()) {
for branch in arr {
if let Some(s) = branch.get("const").and_then(|c| c.as_str()) {
enum_values.insert(s.to_owned());
}
}
}
assert!(
!enum_values.is_empty(),
"could not extract HealthFindingActionType variants from schema (neither `enum` nor `oneOf` with `const` branches)"
);
for ty in &emitted {
assert!(
enum_values.contains(ty),
"build_health_finding_actions emitted action type `{ty}` but \
docs/output-schema.json HealthFindingAction.type enum does \
not list it. Add it to the schema (and any downstream \
typed consumers) when introducing a new action type."
);
}
}
#[test]
fn no_new_post_pass_helpers_in_json_rs() {
const POST_PASS_ALLOW_LIST: &[(&str, &str)] = &[];
let source_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join("report")
.join("json.rs");
let source = std::fs::read_to_string(&source_path).expect(
"crates/cli/src/report/json.rs must be readable for the post-pass drift-guard test",
);
let mut found: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for line in source.lines() {
if let Some(name) = extract_post_pass_fn_name(line) {
found.insert(name.to_owned());
}
}
let allow: std::collections::BTreeSet<&'static str> =
POST_PASS_ALLOW_LIST.iter().map(|(name, _)| *name).collect();
let unexpected: Vec<&str> = found
.iter()
.filter(|name| !allow.contains(name.as_str()))
.map(String::as_str)
.collect();
let stale: Vec<&str> = allow
.iter()
.filter(|name| !found.contains(**name))
.copied()
.collect();
assert!(
unexpected.is_empty(),
"new post-pass helper(s) defined in crates/cli/src/report/json.rs are not in \
POST_PASS_ALLOW_LIST: {unexpected:?}.\n\
The typed `serde(flatten)` envelope is the source of truth for `actions[]` on \
every finding. If a new post-pass is genuinely needed, file a tracking issue, \
add the entry to POST_PASS_ALLOW_LIST with the issue link as the reason, and \
reference the issue in the PR body. See issue #412 for context."
);
assert!(
stale.is_empty(),
"stale entries in POST_PASS_ALLOW_LIST (function no longer defined in \
crates/cli/src/report/json.rs): {stale:?}.\n\
Remove them in the same commit that retired the function."
);
}
fn extract_post_pass_fn_name(line: &str) -> Option<&str> {
let trimmed = line.trim_start();
if trimmed.starts_with("//") {
return None;
}
let mut rest = trimmed;
if let Some(after) = rest.strip_prefix("pub") {
let after = after.trim_start();
rest = if let Some(after) = after.strip_prefix('(') {
let close = after.find(')')?;
after[close + 1..].trim_start()
} else {
after
};
}
for prefix in ["async ", "const ", "unsafe "] {
if let Some(after) = rest.strip_prefix(prefix) {
rest = after.trim_start();
}
}
let after_fn = rest.strip_prefix("fn ")?;
let name_end = after_fn
.find(|c: char| !c.is_alphanumeric() && c != '_')
.unwrap_or(after_fn.len());
let name = &after_fn[..name_end];
if name.starts_with("inject_") || name.starts_with("augment_") {
Some(name)
} else {
None
}
}
}