1use std::collections::BTreeMap;
2use std::path::Path;
3use std::process::ExitCode;
4use std::time::Duration;
5
6use fallow_core::duplicates::DuplicationReport;
7use fallow_core::results::AnalysisResults;
8use fallow_types::envelope::{CheckSummary, ElapsedMs, EntryPoints, SchemaVersion, ToolVersion};
9
10use super::{emit_json, normalize_uri};
11use crate::explain;
12use crate::output_dupes::DupesReportPayload;
13use crate::output_envelope::{
14 CheckGroupedEntry, CheckGroupedOutput, CheckOutput, DupesOutput, GroupByMode, HealthOutput,
15};
16use crate::report::grouping::{OwnershipResolver, ResultGroup};
17
18fn apply_config_fixable_to_duplicate_exports(results: &mut AnalysisResults, config_fixable: bool) {
25 if !config_fixable {
26 return;
27 }
28 for finding in &mut results.duplicate_exports {
29 finding.set_config_fixable(true);
30 }
31}
32
33pub(super) fn print_json(
34 results: &AnalysisResults,
35 root: &Path,
36 elapsed: Duration,
37 explain: bool,
38 regression: Option<&crate::regression::RegressionOutcome>,
39 baseline_matched: Option<(usize, usize)>,
40 config_fixable: bool,
41) -> ExitCode {
42 match build_json_with_config_fixable(results, root, elapsed, config_fixable) {
43 Ok(mut output) => {
44 if let Some(outcome) = regression
45 && let serde_json::Value::Object(ref mut map) = output
46 {
47 map.insert("regression".to_string(), outcome.to_json());
48 }
49 if let Some((entries, matched)) = baseline_matched
50 && let serde_json::Value::Object(ref mut map) = output
51 {
52 map.insert(
53 "baseline".to_string(),
54 serde_json::json!({
55 "entries": entries,
56 "matched": matched,
57 }),
58 );
59 }
60 if explain {
61 insert_meta(&mut output, explain::check_meta());
62 }
63 emit_json(&output, "JSON")
64 }
65 Err(e) => {
66 eprintln!("Error: failed to serialize results: {e}");
67 ExitCode::from(2)
68 }
69 }
70}
71
72#[must_use]
78pub(super) fn print_grouped_json(
79 groups: &[ResultGroup],
80 original: &AnalysisResults,
81 root: &Path,
82 elapsed: Duration,
83 explain: bool,
84 resolver: &OwnershipResolver,
85 config_fixable: bool,
86) -> ExitCode {
87 let entries: Vec<CheckGroupedEntry> = groups
88 .iter()
89 .map(|group| {
90 let mut results = group.results.clone();
91 apply_config_fixable_to_duplicate_exports(&mut results, config_fixable);
92 CheckGroupedEntry {
93 key: group.key.clone(),
94 owners: group.owners.clone(),
95 total_issues: results.total_issues(),
96 results,
97 }
98 })
99 .collect();
100
101 let envelope = CheckGroupedOutput {
102 schema_version: SchemaVersion(SCHEMA_VERSION),
103 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
104 elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
105 grouped_by: group_by_mode_from_label(resolver.mode_label()),
106 total_issues: original.total_issues(),
107 groups: entries,
108 meta: None,
109 };
110
111 let mut output = match serde_json::to_value(&envelope) {
112 Ok(value) => value,
113 Err(e) => {
114 eprintln!("Error: failed to serialize grouped results: {e}");
115 return ExitCode::from(2);
116 }
117 };
118
119 let root_prefix = format!("{}/", root.display());
120 if let Some(arr) = output.get_mut("groups").and_then(|v| v.as_array_mut()) {
127 for entry in arr {
128 strip_root_prefix(entry, &root_prefix);
129 harmonize_multi_kind_suppress_line_actions(entry);
130 }
131 }
132
133 if explain {
134 insert_meta(&mut output, explain::check_meta());
135 }
136
137 emit_json(&output, "JSON")
138}
139
140#[allow(
146 clippy::redundant_pub_crate,
147 reason = "used through report module re-export by combined.rs, audit.rs, flags.rs"
148)]
149pub(crate) const SCHEMA_VERSION: u32 = 6;
150
151#[allow(
160 dead_code,
161 reason = "used by the fallow-cli library target for embedders, but dead in the binary target"
162)]
163pub fn build_json(
164 results: &AnalysisResults,
165 root: &Path,
166 elapsed: Duration,
167) -> Result<serde_json::Value, serde_json::Error> {
168 build_json_with_config_fixable(
169 results,
170 root,
171 elapsed,
172 crate::fix::is_config_fixable(root, None),
173 )
174}
175
176pub fn build_json_with_config_fixable(
181 results: &AnalysisResults,
182 root: &Path,
183 elapsed: Duration,
184 config_fixable: bool,
185) -> Result<serde_json::Value, serde_json::Error> {
186 let mut owned_results = results.clone();
187 apply_config_fixable_to_duplicate_exports(&mut owned_results, config_fixable);
188 let envelope = CheckOutput {
189 schema_version: SchemaVersion(SCHEMA_VERSION),
190 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
191 elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
192 total_issues: owned_results.total_issues(),
193 entry_points: owned_results
194 .entry_point_summary
195 .as_ref()
196 .map(|ep| EntryPoints {
197 total: ep.total,
198 sources: ep
202 .by_source
203 .iter()
204 .map(|(k, v)| (k.replace(' ', "_"), *v))
205 .collect(),
206 }),
207 summary: build_check_summary(&owned_results),
208 results: owned_results,
209 baseline_deltas: None,
210 baseline: None,
211 regression: None,
212 meta: None,
213 workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
214 };
215
216 let mut output = serde_json::to_value(&envelope)?;
217 let root_prefix = format!("{}/", root.display());
218 strip_root_prefix(&mut output, &root_prefix);
222 harmonize_multi_kind_suppress_line_actions(&mut output);
223 Ok(output)
224}
225
226fn build_check_summary(results: &AnalysisResults) -> CheckSummary {
233 CheckSummary {
234 total_issues: results.total_issues(),
235 unused_files: results.unused_files.len(),
236 unused_exports: results.unused_exports.len(),
237 unused_types: results.unused_types.len(),
238 private_type_leaks: results.private_type_leaks.len(),
239 unused_dependencies: results.unused_dependencies.len()
240 + results.unused_dev_dependencies.len()
241 + results.unused_optional_dependencies.len(),
242 unused_enum_members: results.unused_enum_members.len(),
243 unused_class_members: results.unused_class_members.len(),
244 unresolved_imports: results.unresolved_imports.len(),
245 unlisted_dependencies: results.unlisted_dependencies.len(),
246 duplicate_exports: results.duplicate_exports.len(),
247 type_only_dependencies: results.type_only_dependencies.len(),
248 test_only_dependencies: results.test_only_dependencies.len(),
249 circular_dependencies: results.circular_dependencies.len(),
250 re_export_cycles: results.re_export_cycles.len(),
251 boundary_violations: results.boundary_violations.len(),
252 stale_suppressions: results.stale_suppressions.len(),
253 unused_catalog_entries: results.unused_catalog_entries.len(),
254 empty_catalog_groups: results.empty_catalog_groups.len(),
255 unresolved_catalog_references: results.unresolved_catalog_references.len(),
256 unused_dependency_overrides: results.unused_dependency_overrides.len(),
257 misconfigured_dependency_overrides: results.misconfigured_dependency_overrides.len(),
258 }
259}
260
261pub fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
266 match value {
267 serde_json::Value::String(s) => {
268 if let Some(rest) = s.strip_prefix(prefix) {
269 *s = rest.to_string();
270 } else {
271 let normalized = normalize_uri(s);
272 let normalized_prefix = normalize_uri(prefix);
273 if let Some(rest) = normalized.strip_prefix(&normalized_prefix) {
274 *s = rest.to_string();
275 }
276 }
277 }
278 serde_json::Value::Array(arr) => {
279 for item in arr {
280 strip_root_prefix(item, prefix);
281 }
282 }
283 serde_json::Value::Object(map) => {
284 for (_, v) in map.iter_mut() {
285 strip_root_prefix(v, prefix);
286 }
287 }
288 _ => {}
289 }
290}
291
292type SuppressAnchor = (String, u64);
293
294#[allow(
295 clippy::redundant_pub_crate,
296 reason = "used through report module re-export by audit.rs"
297)]
298pub(crate) fn harmonize_multi_kind_suppress_line_actions(output: &mut serde_json::Value) {
299 let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
300 collect_suppress_line_anchors(output, &mut anchors);
301
302 anchors.retain(|_, kinds| {
303 sort_suppression_kinds(kinds);
304 kinds.dedup();
305 kinds.len() > 1
306 });
307 if anchors.is_empty() {
308 return;
309 }
310
311 rewrite_suppress_line_actions(output, &anchors);
312}
313
314fn collect_suppress_line_anchors(
315 value: &serde_json::Value,
316 anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
317) {
318 match value {
319 serde_json::Value::Object(map) => {
320 if let Some(anchor) = suppression_anchor(map)
321 && let Some(actions) = map.get("actions").and_then(serde_json::Value::as_array)
322 {
323 for action in actions {
324 if let Some(comment) = suppress_line_comment(action) {
325 for kind in parse_suppress_line_comment(comment) {
326 let kinds = anchors.entry(anchor.clone()).or_default();
327 if !kinds.iter().any(|existing| existing == &kind) {
328 kinds.push(kind);
329 }
330 }
331 }
332 }
333 }
334
335 for child in map.values() {
336 collect_suppress_line_anchors(child, anchors);
337 }
338 }
339 serde_json::Value::Array(items) => {
340 for item in items {
341 collect_suppress_line_anchors(item, anchors);
342 }
343 }
344 _ => {}
345 }
346}
347
348fn rewrite_suppress_line_actions(
349 value: &mut serde_json::Value,
350 anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
351) {
352 match value {
353 serde_json::Value::Object(map) => {
354 if let Some(anchor) = suppression_anchor(map)
355 && let Some(kinds) = anchors.get(&anchor)
356 {
357 let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
358 if let Some(actions) = map
359 .get_mut("actions")
360 .and_then(serde_json::Value::as_array_mut)
361 {
362 for action in actions {
363 if suppress_line_comment(action).is_some()
364 && let serde_json::Value::Object(action_map) = action
365 {
366 action_map.insert("comment".to_string(), serde_json::json!(comment));
367 }
368 }
369 }
370 }
371
372 for child in map.values_mut() {
373 rewrite_suppress_line_actions(child, anchors);
374 }
375 }
376 serde_json::Value::Array(items) => {
377 for item in items {
378 rewrite_suppress_line_actions(item, anchors);
379 }
380 }
381 _ => {}
382 }
383}
384
385fn suppression_anchor(map: &serde_json::Map<String, serde_json::Value>) -> Option<SuppressAnchor> {
386 let path = map
387 .get("path")
388 .or_else(|| map.get("from_path"))
389 .and_then(serde_json::Value::as_str)?;
390 let line = map.get("line").and_then(serde_json::Value::as_u64)?;
391 Some((path.to_string(), line))
392}
393
394fn suppress_line_comment(action: &serde_json::Value) -> Option<&str> {
395 (action.get("type").and_then(serde_json::Value::as_str) == Some("suppress-line"))
396 .then_some(())
397 .and_then(|()| action.get("comment").and_then(serde_json::Value::as_str))
398}
399
400fn parse_suppress_line_comment(comment: &str) -> Vec<String> {
401 comment
402 .strip_prefix("// fallow-ignore-next-line ")
403 .map(|rest| {
404 rest.split(|c: char| c == ',' || c.is_whitespace())
405 .filter(|token| !token.is_empty())
406 .map(str::to_string)
407 .collect()
408 })
409 .unwrap_or_default()
410}
411
412fn sort_suppression_kinds(kinds: &mut [String]) {
413 kinds.sort_by_key(|kind| suppression_kind_rank(kind));
414}
415
416fn suppression_kind_rank(kind: &str) -> usize {
417 match kind {
418 "unused-file" => 0,
419 "unused-export" => 1,
420 "unused-type" => 2,
421 "private-type-leak" => 3,
422 "unused-enum-member" => 4,
423 "unused-class-member" => 5,
424 "unresolved-import" => 6,
425 "unlisted-dependency" => 7,
426 "duplicate-export" => 8,
427 "circular-dependency" => 9,
428 "re-export-cycle" => 10,
429 "boundary-violation" => 11,
430 "code-duplication" => 12,
431 "complexity" => 13,
432 _ => usize::MAX,
433 }
434}
435
436pub fn build_baseline_deltas_json<'a>(
444 total_delta: i64,
445 per_category: impl Iterator<Item = (&'a str, usize, usize, i64)>,
446) -> serde_json::Value {
447 let mut per_cat = serde_json::Map::new();
448 for (cat, current, baseline, delta) in per_category {
449 per_cat.insert(
450 cat.to_string(),
451 serde_json::json!({
452 "current": current,
453 "baseline": baseline,
454 "delta": delta,
455 }),
456 );
457 }
458 serde_json::json!({
459 "total_delta": total_delta,
460 "per_category": per_cat
461 })
462}
463
464fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
491 if let serde_json::Value::Object(map) = output {
492 map.insert("_meta".to_string(), meta);
493 }
494}
495
496pub fn build_health_json(
504 report: &crate::health_types::HealthReport,
505 root: &Path,
506 elapsed: Duration,
507 explain: bool,
508) -> Result<serde_json::Value, serde_json::Error> {
509 let envelope = HealthOutput {
510 schema_version: SchemaVersion(SCHEMA_VERSION),
511 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
512 elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
513 report: report.clone(),
514 grouped_by: None,
515 groups: None,
516 meta: None,
517 workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
518 };
519 let mut output = serde_json::to_value(&envelope)?;
520 let root_prefix = format!("{}/", root.display());
521 strip_root_prefix(&mut output, &root_prefix);
522 if explain {
523 insert_meta(&mut output, explain::health_meta());
524 }
525 Ok(output)
526}
527
528pub(super) fn print_health_json(
529 report: &crate::health_types::HealthReport,
530 root: &Path,
531 elapsed: Duration,
532 explain: bool,
533) -> ExitCode {
534 match build_health_json(report, root, elapsed, explain) {
535 Ok(output) => emit_json(&output, "JSON"),
536 Err(e) => {
537 eprintln!("Error: failed to serialize health report: {e}");
538 ExitCode::from(2)
539 }
540 }
541}
542
543pub fn build_grouped_health_json(
564 report: &crate::health_types::HealthReport,
565 grouping: &crate::health_types::HealthGrouping,
566 root: &Path,
567 elapsed: Duration,
568 explain: bool,
569) -> Result<serde_json::Value, serde_json::Error> {
570 let root_prefix = format!("{}/", root.display());
571 let envelope = HealthOutput {
579 schema_version: SchemaVersion(SCHEMA_VERSION),
580 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
581 elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
582 report: report.clone(),
583 grouped_by: Some(group_by_mode_from_label(grouping.mode)),
584 groups: None,
588 meta: None,
589 workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
590 };
591 let mut output = serde_json::to_value(&envelope)?;
592 strip_root_prefix(&mut output, &root_prefix);
593
594 let group_values: Vec<serde_json::Value> = grouping
595 .groups
596 .iter()
597 .map(|g| {
598 let mut value = serde_json::to_value(g)?;
599 strip_root_prefix(&mut value, &root_prefix);
600 Ok(value)
601 })
602 .collect::<Result<_, serde_json::Error>>()?;
603
604 if let serde_json::Value::Object(ref mut map) = output {
605 map.insert("groups".to_string(), serde_json::Value::Array(group_values));
606 }
607
608 if explain {
609 insert_meta(&mut output, explain::health_meta());
610 }
611
612 Ok(output)
613}
614
615pub(super) fn print_grouped_health_json(
616 report: &crate::health_types::HealthReport,
617 grouping: &crate::health_types::HealthGrouping,
618 root: &Path,
619 elapsed: Duration,
620 explain: bool,
621) -> ExitCode {
622 match build_grouped_health_json(report, grouping, root, elapsed, explain) {
623 Ok(output) => emit_json(&output, "JSON"),
624 Err(e) => {
625 eprintln!("Error: failed to serialize grouped health report: {e}");
626 ExitCode::from(2)
627 }
628 }
629}
630
631pub fn build_duplication_json(
638 report: &DuplicationReport,
639 root: &Path,
640 elapsed: Duration,
641 explain: bool,
642) -> Result<serde_json::Value, serde_json::Error> {
643 let envelope = DupesOutput {
644 schema_version: SchemaVersion(SCHEMA_VERSION),
645 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
646 elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
647 report: DupesReportPayload::from_report(report),
648 grouped_by: None,
649 total_issues: None,
650 groups: None,
651 meta: None,
652 workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
653 };
654 let mut output = serde_json::to_value(&envelope)?;
655 let root_prefix = format!("{}/", root.display());
656 strip_root_prefix(&mut output, &root_prefix);
657
658 if explain {
659 insert_meta(&mut output, explain::dupes_meta());
660 }
661
662 Ok(output)
663}
664
665pub(super) fn print_duplication_json(
666 report: &DuplicationReport,
667 root: &Path,
668 elapsed: Duration,
669 explain: bool,
670) -> ExitCode {
671 match build_duplication_json(report, root, elapsed, explain) {
672 Ok(output) => emit_json(&output, "JSON"),
673 Err(e) => {
674 eprintln!("Error: failed to serialize duplication report: {e}");
675 ExitCode::from(2)
676 }
677 }
678}
679
680pub fn build_grouped_duplication_json(
701 report: &DuplicationReport,
702 grouping: &super::dupes_grouping::DuplicationGrouping,
703 root: &Path,
704 elapsed: Duration,
705 explain: bool,
706) -> Result<serde_json::Value, serde_json::Error> {
707 let root_prefix = format!("{}/", root.display());
708 let envelope = DupesOutput {
713 schema_version: SchemaVersion(SCHEMA_VERSION),
714 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
715 elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
716 report: DupesReportPayload::from_report(report),
717 grouped_by: Some(group_by_mode_from_label(grouping.mode)),
718 total_issues: Some(report.clone_groups.len()),
719 groups: None,
724 meta: None,
725 workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
726 };
727 let mut output = serde_json::to_value(&envelope)?;
728 strip_root_prefix(&mut output, &root_prefix);
729
730 let group_values: Vec<serde_json::Value> = grouping
731 .groups
732 .iter()
733 .map(|g| {
734 let mut value = serde_json::to_value(g)?;
735 strip_root_prefix(&mut value, &root_prefix);
736 Ok(value)
737 })
738 .collect::<Result<_, serde_json::Error>>()?;
739
740 if let serde_json::Value::Object(ref mut map) = output {
741 map.insert("groups".to_string(), serde_json::Value::Array(group_values));
742 }
743
744 if explain {
745 insert_meta(&mut output, explain::dupes_meta());
746 }
747
748 Ok(output)
749}
750
751fn group_by_mode_from_label(label: &str) -> GroupByMode {
757 match label {
758 "directory" => GroupByMode::Directory,
759 "package" => GroupByMode::Package,
760 "section" => GroupByMode::Section,
761 _ => GroupByMode::Owner,
762 }
763}
764
765pub(super) fn print_grouped_duplication_json(
766 report: &DuplicationReport,
767 grouping: &super::dupes_grouping::DuplicationGrouping,
768 root: &Path,
769 elapsed: Duration,
770 explain: bool,
771) -> ExitCode {
772 match build_grouped_duplication_json(report, grouping, root, elapsed, explain) {
773 Ok(output) => emit_json(&output, "JSON"),
774 Err(e) => {
775 eprintln!("Error: failed to serialize grouped duplication report: {e}");
776 ExitCode::from(2)
777 }
778 }
779}
780
781pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
782 match serde_json::to_string_pretty(value) {
783 Ok(json) => println!("{json}"),
784 Err(e) => {
785 eprintln!("Error: failed to serialize trace output: {e}");
786 #[expect(
787 clippy::exit,
788 reason = "fatal serialization error requires immediate exit"
789 )]
790 std::process::exit(2);
791 }
792 }
793}
794
795#[cfg(test)]
796mod tests {
797 use super::*;
798 use crate::health_types::{
799 RuntimeCoverageAction, RuntimeCoverageConfidence, RuntimeCoverageDataSource,
800 RuntimeCoverageEvidence, RuntimeCoverageFinding, RuntimeCoverageHotPath,
801 RuntimeCoverageMessage, RuntimeCoverageReport, RuntimeCoverageReportVerdict,
802 RuntimeCoverageSchemaVersion, RuntimeCoverageSummary, RuntimeCoverageVerdict,
803 RuntimeCoverageWatermark,
804 };
805 use crate::report::test_helpers::sample_results;
806 use fallow_core::extract::MemberKind;
807 use fallow_core::results::*;
808 use std::path::PathBuf;
809 use std::time::Duration;
810
811 #[test]
812 fn json_output_has_metadata_fields() {
813 let root = PathBuf::from("/project");
814 let results = AnalysisResults::default();
815 let elapsed = Duration::from_millis(123);
816 let output = build_json(&results, &root, elapsed).expect("should serialize");
817
818 assert_eq!(output["schema_version"], 6);
819 assert!(output["version"].is_string());
820 assert_eq!(output["elapsed_ms"], 123);
821 assert_eq!(output["total_issues"], 0);
822 }
823
824 #[test]
825 fn json_output_includes_issue_arrays() {
826 let root = PathBuf::from("/project");
827 let results = sample_results(&root);
828 let elapsed = Duration::from_millis(50);
829 let output = build_json(&results, &root, elapsed).expect("should serialize");
830
831 assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
832 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
833 assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
834 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
835 assert_eq!(
836 output["unused_dev_dependencies"].as_array().unwrap().len(),
837 1
838 );
839 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
840 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
841 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
842 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
843 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
844 assert_eq!(
845 output["type_only_dependencies"].as_array().unwrap().len(),
846 1
847 );
848 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
849 }
850
851 #[test]
852 fn health_json_includes_runtime_coverage_with_relative_paths_and_actions() {
853 let root = PathBuf::from("/project");
854 let report = crate::health_types::HealthReport {
855 runtime_coverage: Some(RuntimeCoverageReport {
856 schema_version: RuntimeCoverageSchemaVersion::V1,
857 verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
858 signals: Vec::new(),
859 summary: RuntimeCoverageSummary {
860 data_source: RuntimeCoverageDataSource::Local,
861 last_received_at: None,
862 functions_tracked: 3,
863 functions_hit: 1,
864 functions_unhit: 1,
865 functions_untracked: 1,
866 coverage_percent: 33.3,
867 trace_count: 2_847_291,
868 period_days: 30,
869 deployments_seen: 14,
870 capture_quality: Some(crate::health_types::RuntimeCoverageCaptureQuality {
871 window_seconds: 720,
872 instances_observed: 1,
873 lazy_parse_warning: true,
874 untracked_ratio_percent: 42.5,
875 }),
876 },
877 findings: vec![RuntimeCoverageFinding {
878 id: "fallow:prod:deadbeef".to_owned(),
879 stable_id: None,
880 path: root.join("src/cold.ts"),
881 function: "coldPath".to_owned(),
882 line: 12,
883 verdict: RuntimeCoverageVerdict::ReviewRequired,
884 invocations: Some(0),
885 confidence: RuntimeCoverageConfidence::Medium,
886 evidence: RuntimeCoverageEvidence {
887 static_status: "used".to_owned(),
888 test_coverage: "not_covered".to_owned(),
889 v8_tracking: "tracked".to_owned(),
890 untracked_reason: None,
891 observation_days: 30,
892 deployments_observed: 14,
893 },
894 actions: vec![RuntimeCoverageAction {
895 kind: "review-deletion".to_owned(),
896 description: "Tracked in runtime coverage with zero invocations."
897 .to_owned(),
898 auto_fixable: false,
899 }],
900 source_hash: None,
901 }],
902 hot_paths: vec![RuntimeCoverageHotPath {
903 id: "fallow:hot:cafebabe".to_owned(),
904 stable_id: None,
905 path: root.join("src/hot.ts"),
906 function: "hotPath".to_owned(),
907 line: 3,
908 end_line: 9,
909 invocations: 250,
910 percentile: 99,
911 actions: vec![],
912 }],
913 blast_radius: vec![],
914 importance: vec![],
915 watermark: Some(RuntimeCoverageWatermark::LicenseExpiredGrace),
916 warnings: vec![RuntimeCoverageMessage {
917 code: "partial-merge".to_owned(),
918 message: "Merged coverage omitted one chunk.".to_owned(),
919 }],
920 }),
921 ..Default::default()
922 };
923
924 let envelope = HealthOutput {
925 schema_version: SchemaVersion(SCHEMA_VERSION),
926 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
927 elapsed_ms: ElapsedMs(7),
928 report,
929 grouped_by: None,
930 groups: None,
931 meta: None,
932 workspace_diagnostics: Vec::new(),
933 };
934 let mut output = serde_json::to_value(&envelope).expect("should serialize health envelope");
935 strip_root_prefix(&mut output, "/project/");
936
937 assert_eq!(
938 output["runtime_coverage"]["verdict"],
939 serde_json::Value::String("cold-code-detected".to_owned())
940 );
941 assert_eq!(
942 output["runtime_coverage"]["schema_version"],
943 serde_json::Value::String("1".to_owned())
944 );
945 assert_eq!(
946 output["runtime_coverage"]["summary"]["functions_tracked"],
947 serde_json::Value::from(3)
948 );
949 assert_eq!(
950 output["runtime_coverage"]["summary"]["coverage_percent"],
951 serde_json::Value::from(33.3)
952 );
953 let finding = &output["runtime_coverage"]["findings"][0];
954 assert_eq!(finding["path"], "src/cold.ts");
955 assert_eq!(finding["verdict"], "review_required");
956 assert_eq!(finding["id"], "fallow:prod:deadbeef");
957 assert_eq!(finding["actions"][0]["type"], "review-deletion");
958 let hot_path = &output["runtime_coverage"]["hot_paths"][0];
959 assert_eq!(hot_path["path"], "src/hot.ts");
960 assert_eq!(hot_path["function"], "hotPath");
961 assert_eq!(hot_path["percentile"], 99);
962 assert_eq!(
963 output["runtime_coverage"]["watermark"],
964 serde_json::Value::String("license-expired-grace".to_owned())
965 );
966 assert_eq!(
967 output["runtime_coverage"]["warnings"][0]["code"],
968 serde_json::Value::String("partial-merge".to_owned())
969 );
970 }
971
972 #[test]
973 fn json_metadata_fields_appear_first() {
974 let root = PathBuf::from("/project");
975 let results = AnalysisResults::default();
976 let elapsed = Duration::from_millis(0);
977 let output = build_json(&results, &root, elapsed).expect("should serialize");
978 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
979 assert_eq!(keys[0], "schema_version");
980 assert_eq!(keys[1], "version");
981 assert_eq!(keys[2], "elapsed_ms");
982 assert_eq!(keys[3], "total_issues");
983 }
984
985 #[test]
986 fn json_total_issues_matches_results() {
987 let root = PathBuf::from("/project");
988 let results = sample_results(&root);
989 let total = results.total_issues();
990 let elapsed = Duration::from_millis(0);
991 let output = build_json(&results, &root, elapsed).expect("should serialize");
992
993 assert_eq!(output["total_issues"], total);
994 }
995
996 #[test]
997 fn json_unused_export_contains_expected_fields() {
998 let root = PathBuf::from("/project");
999 let mut results = AnalysisResults::default();
1000 results
1001 .unused_exports
1002 .push(UnusedExportFinding::with_actions(UnusedExport {
1003 path: root.join("src/utils.ts"),
1004 export_name: "helperFn".to_string(),
1005 is_type_only: false,
1006 line: 10,
1007 col: 4,
1008 span_start: 120,
1009 is_re_export: false,
1010 }));
1011 let elapsed = Duration::from_millis(0);
1012 let output = build_json(&results, &root, elapsed).expect("should serialize");
1013
1014 let export = &output["unused_exports"][0];
1015 assert_eq!(export["export_name"], "helperFn");
1016 assert_eq!(export["line"], 10);
1017 assert_eq!(export["col"], 4);
1018 assert_eq!(export["is_type_only"], false);
1019 assert_eq!(export["span_start"], 120);
1020 assert_eq!(export["is_re_export"], false);
1021 }
1022
1023 #[test]
1024 fn json_serializes_to_valid_json() {
1025 let root = PathBuf::from("/project");
1026 let results = sample_results(&root);
1027 let elapsed = Duration::from_millis(42);
1028 let output = build_json(&results, &root, elapsed).expect("should serialize");
1029
1030 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
1031 let reparsed: serde_json::Value =
1032 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
1033 assert_eq!(reparsed, output);
1034 }
1035
1036 #[test]
1039 fn json_empty_results_produce_valid_structure() {
1040 let root = PathBuf::from("/project");
1041 let results = AnalysisResults::default();
1042 let elapsed = Duration::from_millis(0);
1043 let output = build_json(&results, &root, elapsed).expect("should serialize");
1044
1045 assert_eq!(output["total_issues"], 0);
1046 assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
1047 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
1048 assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
1049 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
1050 assert_eq!(
1051 output["unused_dev_dependencies"].as_array().unwrap().len(),
1052 0
1053 );
1054 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
1055 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
1056 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
1057 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
1058 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
1059 assert_eq!(
1060 output["type_only_dependencies"].as_array().unwrap().len(),
1061 0
1062 );
1063 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
1064 }
1065
1066 #[test]
1067 fn json_empty_results_round_trips_through_string() {
1068 let root = PathBuf::from("/project");
1069 let results = AnalysisResults::default();
1070 let elapsed = Duration::from_millis(0);
1071 let output = build_json(&results, &root, elapsed).expect("should serialize");
1072
1073 let json_str = serde_json::to_string(&output).expect("should stringify");
1074 let reparsed: serde_json::Value =
1075 serde_json::from_str(&json_str).expect("should parse back");
1076 assert_eq!(reparsed["total_issues"], 0);
1077 }
1078
1079 #[test]
1082 fn json_paths_are_relative_to_root() {
1083 let root = PathBuf::from("/project");
1084 let mut results = AnalysisResults::default();
1085 results
1086 .unused_files
1087 .push(UnusedFileFinding::with_actions(UnusedFile {
1088 path: root.join("src/deep/nested/file.ts"),
1089 }));
1090 let elapsed = Duration::from_millis(0);
1091 let output = build_json(&results, &root, elapsed).expect("should serialize");
1092
1093 let path = output["unused_files"][0]["path"].as_str().unwrap();
1094 assert_eq!(path, "src/deep/nested/file.ts");
1095 assert!(!path.starts_with("/project"));
1096 }
1097
1098 #[test]
1099 fn json_strips_root_from_nested_locations() {
1100 let root = PathBuf::from("/project");
1101 let mut results = AnalysisResults::default();
1102 results
1103 .unlisted_dependencies
1104 .push(UnlistedDependencyFinding::with_actions(
1105 UnlistedDependency {
1106 package_name: "chalk".to_string(),
1107 imported_from: vec![ImportSite {
1108 path: root.join("src/cli.ts"),
1109 line: 2,
1110 col: 0,
1111 }],
1112 },
1113 ));
1114 let elapsed = Duration::from_millis(0);
1115 let output = build_json(&results, &root, elapsed).expect("should serialize");
1116
1117 let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
1118 .as_str()
1119 .unwrap();
1120 assert_eq!(site_path, "src/cli.ts");
1121 }
1122
1123 #[test]
1124 fn json_strips_root_from_duplicate_export_locations() {
1125 let root = PathBuf::from("/project");
1126 let mut results = AnalysisResults::default();
1127 results
1128 .duplicate_exports
1129 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1130 export_name: "Config".to_string(),
1131 locations: vec![
1132 DuplicateLocation {
1133 path: root.join("src/config.ts"),
1134 line: 15,
1135 col: 0,
1136 },
1137 DuplicateLocation {
1138 path: root.join("src/types.ts"),
1139 line: 30,
1140 col: 0,
1141 },
1142 ],
1143 }));
1144 let elapsed = Duration::from_millis(0);
1145 let output = build_json(&results, &root, elapsed).expect("should serialize");
1146
1147 let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
1148 .as_str()
1149 .unwrap();
1150 let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
1151 .as_str()
1152 .unwrap();
1153 assert_eq!(loc0, "src/config.ts");
1154 assert_eq!(loc1, "src/types.ts");
1155 }
1156
1157 #[test]
1158 fn json_strips_root_from_circular_dependency_files() {
1159 let root = PathBuf::from("/project");
1160 let mut results = AnalysisResults::default();
1161 results
1162 .circular_dependencies
1163 .push(CircularDependencyFinding::with_actions(
1164 CircularDependency {
1165 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1166 length: 2,
1167 line: 1,
1168 col: 0,
1169 is_cross_package: false,
1170 },
1171 ));
1172 let elapsed = Duration::from_millis(0);
1173 let output = build_json(&results, &root, elapsed).expect("should serialize");
1174
1175 let files = output["circular_dependencies"][0]["files"]
1176 .as_array()
1177 .unwrap();
1178 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
1179 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
1180 }
1181
1182 #[test]
1183 fn json_path_outside_root_not_stripped() {
1184 let root = PathBuf::from("/project");
1185 let mut results = AnalysisResults::default();
1186 results
1187 .unused_files
1188 .push(UnusedFileFinding::with_actions(UnusedFile {
1189 path: PathBuf::from("/other/project/src/file.ts"),
1190 }));
1191 let elapsed = Duration::from_millis(0);
1192 let output = build_json(&results, &root, elapsed).expect("should serialize");
1193
1194 let path = output["unused_files"][0]["path"].as_str().unwrap();
1195 assert!(path.contains("/other/project/"));
1196 }
1197
1198 #[test]
1201 fn json_unused_file_contains_path() {
1202 let root = PathBuf::from("/project");
1203 let mut results = AnalysisResults::default();
1204 results
1205 .unused_files
1206 .push(UnusedFileFinding::with_actions(UnusedFile {
1207 path: root.join("src/orphan.ts"),
1208 }));
1209 let elapsed = Duration::from_millis(0);
1210 let output = build_json(&results, &root, elapsed).expect("should serialize");
1211
1212 let file = &output["unused_files"][0];
1213 assert_eq!(file["path"], "src/orphan.ts");
1214 }
1215
1216 #[test]
1217 fn json_unused_type_contains_expected_fields() {
1218 let root = PathBuf::from("/project");
1219 let mut results = AnalysisResults::default();
1220 results
1221 .unused_types
1222 .push(UnusedTypeFinding::with_actions(UnusedExport {
1223 path: root.join("src/types.ts"),
1224 export_name: "OldInterface".to_string(),
1225 is_type_only: true,
1226 line: 20,
1227 col: 0,
1228 span_start: 300,
1229 is_re_export: false,
1230 }));
1231 let elapsed = Duration::from_millis(0);
1232 let output = build_json(&results, &root, elapsed).expect("should serialize");
1233
1234 let typ = &output["unused_types"][0];
1235 assert_eq!(typ["export_name"], "OldInterface");
1236 assert_eq!(typ["is_type_only"], true);
1237 assert_eq!(typ["line"], 20);
1238 assert_eq!(typ["path"], "src/types.ts");
1239 }
1240
1241 #[test]
1242 fn json_unused_dependency_contains_expected_fields() {
1243 let root = PathBuf::from("/project");
1244 let mut results = AnalysisResults::default();
1245 results
1246 .unused_dependencies
1247 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1248 package_name: "axios".to_string(),
1249 location: DependencyLocation::Dependencies,
1250 path: root.join("package.json"),
1251 line: 10,
1252 used_in_workspaces: Vec::new(),
1253 }));
1254 let elapsed = Duration::from_millis(0);
1255 let output = build_json(&results, &root, elapsed).expect("should serialize");
1256
1257 let dep = &output["unused_dependencies"][0];
1258 assert_eq!(dep["package_name"], "axios");
1259 assert_eq!(dep["line"], 10);
1260 assert!(dep.get("used_in_workspaces").is_none());
1261 }
1262
1263 #[test]
1264 fn json_unused_dependency_includes_cross_workspace_context() {
1265 let root = PathBuf::from("/project");
1266 let mut results = AnalysisResults::default();
1267 results
1268 .unused_dependencies
1269 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1270 package_name: "lodash-es".to_string(),
1271 location: DependencyLocation::Dependencies,
1272 path: root.join("packages/shared/package.json"),
1273 line: 6,
1274 used_in_workspaces: vec![root.join("packages/consumer")],
1275 }));
1276 let elapsed = Duration::from_millis(0);
1277 let output = build_json(&results, &root, elapsed).expect("should serialize");
1278
1279 let dep = &output["unused_dependencies"][0];
1280 assert_eq!(
1281 dep["used_in_workspaces"],
1282 serde_json::json!(["packages/consumer"])
1283 );
1284 }
1285
1286 #[test]
1287 fn json_unused_dev_dependency_contains_expected_fields() {
1288 let root = PathBuf::from("/project");
1289 let mut results = AnalysisResults::default();
1290 results
1291 .unused_dev_dependencies
1292 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1293 package_name: "vitest".to_string(),
1294 location: DependencyLocation::DevDependencies,
1295 path: root.join("package.json"),
1296 line: 15,
1297 used_in_workspaces: Vec::new(),
1298 }));
1299 let elapsed = Duration::from_millis(0);
1300 let output = build_json(&results, &root, elapsed).expect("should serialize");
1301
1302 let dep = &output["unused_dev_dependencies"][0];
1303 assert_eq!(dep["package_name"], "vitest");
1304 }
1305
1306 #[test]
1307 fn json_unused_optional_dependency_contains_expected_fields() {
1308 let root = PathBuf::from("/project");
1309 let mut results = AnalysisResults::default();
1310 results
1311 .unused_optional_dependencies
1312 .push(UnusedOptionalDependencyFinding::with_actions(
1313 UnusedDependency {
1314 package_name: "fsevents".to_string(),
1315 location: DependencyLocation::OptionalDependencies,
1316 path: root.join("package.json"),
1317 line: 12,
1318 used_in_workspaces: Vec::new(),
1319 },
1320 ));
1321 let elapsed = Duration::from_millis(0);
1322 let output = build_json(&results, &root, elapsed).expect("should serialize");
1323
1324 let dep = &output["unused_optional_dependencies"][0];
1325 assert_eq!(dep["package_name"], "fsevents");
1326 assert_eq!(output["total_issues"], 1);
1327 }
1328
1329 #[test]
1330 fn json_unused_enum_member_contains_expected_fields() {
1331 let root = PathBuf::from("/project");
1332 let mut results = AnalysisResults::default();
1333 results
1334 .unused_enum_members
1335 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1336 path: root.join("src/enums.ts"),
1337 parent_name: "Color".to_string(),
1338 member_name: "Purple".to_string(),
1339 kind: MemberKind::EnumMember,
1340 line: 5,
1341 col: 2,
1342 }));
1343 let elapsed = Duration::from_millis(0);
1344 let output = build_json(&results, &root, elapsed).expect("should serialize");
1345
1346 let member = &output["unused_enum_members"][0];
1347 assert_eq!(member["parent_name"], "Color");
1348 assert_eq!(member["member_name"], "Purple");
1349 assert_eq!(member["line"], 5);
1350 assert_eq!(member["path"], "src/enums.ts");
1351 }
1352
1353 #[test]
1354 fn json_unused_class_member_contains_expected_fields() {
1355 let root = PathBuf::from("/project");
1356 let mut results = AnalysisResults::default();
1357 results
1358 .unused_class_members
1359 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
1360 path: root.join("src/api.ts"),
1361 parent_name: "ApiClient".to_string(),
1362 member_name: "deprecatedFetch".to_string(),
1363 kind: MemberKind::ClassMethod,
1364 line: 100,
1365 col: 4,
1366 }));
1367 let elapsed = Duration::from_millis(0);
1368 let output = build_json(&results, &root, elapsed).expect("should serialize");
1369
1370 let member = &output["unused_class_members"][0];
1371 assert_eq!(member["parent_name"], "ApiClient");
1372 assert_eq!(member["member_name"], "deprecatedFetch");
1373 assert_eq!(member["line"], 100);
1374 }
1375
1376 #[test]
1377 fn json_unresolved_import_contains_expected_fields() {
1378 let root = PathBuf::from("/project");
1379 let mut results = AnalysisResults::default();
1380 results
1381 .unresolved_imports
1382 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1383 path: root.join("src/app.ts"),
1384 specifier: "@acme/missing-pkg".to_string(),
1385 line: 7,
1386 col: 0,
1387 specifier_col: 0,
1388 }));
1389 let elapsed = Duration::from_millis(0);
1390 let output = build_json(&results, &root, elapsed).expect("should serialize");
1391
1392 let import = &output["unresolved_imports"][0];
1393 assert_eq!(import["specifier"], "@acme/missing-pkg");
1394 assert_eq!(import["line"], 7);
1395 assert_eq!(import["path"], "src/app.ts");
1396 }
1397
1398 #[test]
1399 fn json_unlisted_dependency_contains_import_sites() {
1400 let root = PathBuf::from("/project");
1401 let mut results = AnalysisResults::default();
1402 results
1403 .unlisted_dependencies
1404 .push(UnlistedDependencyFinding::with_actions(
1405 UnlistedDependency {
1406 package_name: "dotenv".to_string(),
1407 imported_from: vec![
1408 ImportSite {
1409 path: root.join("src/config.ts"),
1410 line: 1,
1411 col: 0,
1412 },
1413 ImportSite {
1414 path: root.join("src/server.ts"),
1415 line: 3,
1416 col: 0,
1417 },
1418 ],
1419 },
1420 ));
1421 let elapsed = Duration::from_millis(0);
1422 let output = build_json(&results, &root, elapsed).expect("should serialize");
1423
1424 let dep = &output["unlisted_dependencies"][0];
1425 assert_eq!(dep["package_name"], "dotenv");
1426 let sites = dep["imported_from"].as_array().unwrap();
1427 assert_eq!(sites.len(), 2);
1428 assert_eq!(sites[0]["path"], "src/config.ts");
1429 assert_eq!(sites[1]["path"], "src/server.ts");
1430 }
1431
1432 #[test]
1433 fn json_duplicate_export_contains_locations() {
1434 let root = PathBuf::from("/project");
1435 let mut results = AnalysisResults::default();
1436 results
1437 .duplicate_exports
1438 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1439 export_name: "Button".to_string(),
1440 locations: vec![
1441 DuplicateLocation {
1442 path: root.join("src/ui.ts"),
1443 line: 10,
1444 col: 0,
1445 },
1446 DuplicateLocation {
1447 path: root.join("src/components.ts"),
1448 line: 25,
1449 col: 0,
1450 },
1451 ],
1452 }));
1453 let elapsed = Duration::from_millis(0);
1454 let output = build_json(&results, &root, elapsed).expect("should serialize");
1455
1456 let dup = &output["duplicate_exports"][0];
1457 assert_eq!(dup["export_name"], "Button");
1458 let locs = dup["locations"].as_array().unwrap();
1459 assert_eq!(locs.len(), 2);
1460 assert_eq!(locs[0]["line"], 10);
1461 assert_eq!(locs[1]["line"], 25);
1462 }
1463
1464 #[test]
1465 fn duplicate_export_add_to_config_is_auto_fixable_when_config_exists() {
1466 let dir = tempfile::tempdir().unwrap();
1467 let root = dir.path();
1468 std::fs::write(root.join(".fallowrc.json"), "{}\n").unwrap();
1469 let mut results = AnalysisResults::default();
1470 results
1471 .duplicate_exports
1472 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1473 export_name: "Button".to_string(),
1474 locations: vec![
1475 DuplicateLocation {
1476 path: root.join("src/ui.ts"),
1477 line: 10,
1478 col: 0,
1479 },
1480 DuplicateLocation {
1481 path: root.join("src/components.ts"),
1482 line: 25,
1483 col: 0,
1484 },
1485 ],
1486 }));
1487
1488 let output = build_json(&results, root, Duration::ZERO).unwrap();
1489 let actions = output["duplicate_exports"][0]["actions"]
1490 .as_array()
1491 .unwrap();
1492 assert_eq!(actions[0]["type"], "add-to-config");
1493 assert_eq!(actions[0]["auto_fixable"], true);
1494 }
1495
1496 #[test]
1497 fn duplicate_export_add_to_config_is_auto_fixable_when_create_fallback_allowed() {
1498 let dir = tempfile::tempdir().unwrap();
1503 let root = dir.path();
1504 let mut results = AnalysisResults::default();
1505 results
1506 .duplicate_exports
1507 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1508 export_name: "Button".to_string(),
1509 locations: vec![
1510 DuplicateLocation {
1511 path: root.join("src/ui.ts"),
1512 line: 10,
1513 col: 0,
1514 },
1515 DuplicateLocation {
1516 path: root.join("src/components.ts"),
1517 line: 25,
1518 col: 0,
1519 },
1520 ],
1521 }));
1522
1523 let output = build_json(&results, root, Duration::ZERO).unwrap();
1524 let actions = output["duplicate_exports"][0]["actions"]
1525 .as_array()
1526 .unwrap();
1527 assert_eq!(actions[0]["type"], "add-to-config");
1528 assert_eq!(actions[0]["auto_fixable"], true);
1529 }
1530
1531 #[test]
1532 fn duplicate_export_add_to_config_is_not_auto_fixable_in_monorepo_subpackage() {
1533 let dir = tempfile::tempdir().unwrap();
1538 let workspace = dir.path();
1539 std::fs::write(
1540 workspace.join("pnpm-workspace.yaml"),
1541 "packages:\n - 'packages/*'\n",
1542 )
1543 .unwrap();
1544 let sub = workspace.join("packages/ui");
1545 std::fs::create_dir_all(&sub).unwrap();
1546 let mut results = AnalysisResults::default();
1547 results
1548 .duplicate_exports
1549 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1550 export_name: "Button".to_string(),
1551 locations: vec![
1552 DuplicateLocation {
1553 path: sub.join("src/ui.ts"),
1554 line: 10,
1555 col: 0,
1556 },
1557 DuplicateLocation {
1558 path: sub.join("src/components.ts"),
1559 line: 25,
1560 col: 0,
1561 },
1562 ],
1563 }));
1564
1565 let output = build_json(&results, &sub, Duration::ZERO).unwrap();
1566 let actions = output["duplicate_exports"][0]["actions"]
1567 .as_array()
1568 .unwrap();
1569 assert_eq!(actions[0]["type"], "add-to-config");
1570 assert_eq!(actions[0]["auto_fixable"], false);
1571 }
1572
1573 #[test]
1574 fn json_type_only_dependency_contains_expected_fields() {
1575 let root = PathBuf::from("/project");
1576 let mut results = AnalysisResults::default();
1577 results
1578 .type_only_dependencies
1579 .push(TypeOnlyDependencyFinding::with_actions(
1580 TypeOnlyDependency {
1581 package_name: "zod".to_string(),
1582 path: root.join("package.json"),
1583 line: 8,
1584 },
1585 ));
1586 let elapsed = Duration::from_millis(0);
1587 let output = build_json(&results, &root, elapsed).expect("should serialize");
1588
1589 let dep = &output["type_only_dependencies"][0];
1590 assert_eq!(dep["package_name"], "zod");
1591 assert_eq!(dep["line"], 8);
1592 }
1593
1594 #[test]
1595 fn json_circular_dependency_contains_expected_fields() {
1596 let root = PathBuf::from("/project");
1597 let mut results = AnalysisResults::default();
1598 results
1599 .circular_dependencies
1600 .push(CircularDependencyFinding::with_actions(
1601 CircularDependency {
1602 files: vec![
1603 root.join("src/a.ts"),
1604 root.join("src/b.ts"),
1605 root.join("src/c.ts"),
1606 ],
1607 length: 3,
1608 line: 5,
1609 col: 0,
1610 is_cross_package: false,
1611 },
1612 ));
1613 let elapsed = Duration::from_millis(0);
1614 let output = build_json(&results, &root, elapsed).expect("should serialize");
1615
1616 let cycle = &output["circular_dependencies"][0];
1617 assert_eq!(cycle["length"], 3);
1618 assert_eq!(cycle["line"], 5);
1619 let files = cycle["files"].as_array().unwrap();
1620 assert_eq!(files.len(), 3);
1621 }
1622
1623 #[test]
1626 fn json_re_export_flagged_correctly() {
1627 let root = PathBuf::from("/project");
1628 let mut results = AnalysisResults::default();
1629 results
1630 .unused_exports
1631 .push(UnusedExportFinding::with_actions(UnusedExport {
1632 path: root.join("src/index.ts"),
1633 export_name: "reExported".to_string(),
1634 is_type_only: false,
1635 line: 1,
1636 col: 0,
1637 span_start: 0,
1638 is_re_export: true,
1639 }));
1640 let elapsed = Duration::from_millis(0);
1641 let output = build_json(&results, &root, elapsed).expect("should serialize");
1642
1643 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
1644 }
1645
1646 #[test]
1649 fn json_schema_version_is_pinned() {
1650 let root = PathBuf::from("/project");
1651 let results = AnalysisResults::default();
1652 let elapsed = Duration::from_millis(0);
1653 let output = build_json(&results, &root, elapsed).expect("should serialize");
1654
1655 assert_eq!(output["schema_version"], SCHEMA_VERSION);
1656 assert_eq!(output["schema_version"], 6);
1657 }
1658
1659 #[test]
1662 fn json_version_matches_cargo_pkg_version() {
1663 let root = PathBuf::from("/project");
1664 let results = AnalysisResults::default();
1665 let elapsed = Duration::from_millis(0);
1666 let output = build_json(&results, &root, elapsed).expect("should serialize");
1667
1668 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1669 }
1670
1671 #[test]
1674 fn json_elapsed_ms_zero_duration() {
1675 let root = PathBuf::from("/project");
1676 let results = AnalysisResults::default();
1677 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1678
1679 assert_eq!(output["elapsed_ms"], 0);
1680 }
1681
1682 #[test]
1683 fn json_elapsed_ms_large_duration() {
1684 let root = PathBuf::from("/project");
1685 let results = AnalysisResults::default();
1686 let elapsed = Duration::from_mins(2);
1687 let output = build_json(&results, &root, elapsed).expect("should serialize");
1688
1689 assert_eq!(output["elapsed_ms"], 120_000);
1690 }
1691
1692 #[test]
1693 fn json_elapsed_ms_sub_millisecond_truncated() {
1694 let root = PathBuf::from("/project");
1695 let results = AnalysisResults::default();
1696 let elapsed = Duration::from_micros(500);
1698 let output = build_json(&results, &root, elapsed).expect("should serialize");
1699
1700 assert_eq!(output["elapsed_ms"], 0);
1701 }
1702
1703 #[test]
1706 fn json_multiple_unused_files() {
1707 let root = PathBuf::from("/project");
1708 let mut results = AnalysisResults::default();
1709 results
1710 .unused_files
1711 .push(UnusedFileFinding::with_actions(UnusedFile {
1712 path: root.join("src/a.ts"),
1713 }));
1714 results
1715 .unused_files
1716 .push(UnusedFileFinding::with_actions(UnusedFile {
1717 path: root.join("src/b.ts"),
1718 }));
1719 results
1720 .unused_files
1721 .push(UnusedFileFinding::with_actions(UnusedFile {
1722 path: root.join("src/c.ts"),
1723 }));
1724 let elapsed = Duration::from_millis(0);
1725 let output = build_json(&results, &root, elapsed).expect("should serialize");
1726
1727 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1728 assert_eq!(output["total_issues"], 3);
1729 }
1730
1731 #[test]
1734 fn strip_root_prefix_on_string_value() {
1735 let mut value = serde_json::json!("/project/src/file.ts");
1736 strip_root_prefix(&mut value, "/project/");
1737 assert_eq!(value, "src/file.ts");
1738 }
1739
1740 #[test]
1741 fn strip_root_prefix_leaves_non_matching_string() {
1742 let mut value = serde_json::json!("/other/src/file.ts");
1743 strip_root_prefix(&mut value, "/project/");
1744 assert_eq!(value, "/other/src/file.ts");
1745 }
1746
1747 #[test]
1748 fn strip_root_prefix_recurses_into_arrays() {
1749 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1750 strip_root_prefix(&mut value, "/project/");
1751 assert_eq!(value[0], "a.ts");
1752 assert_eq!(value[1], "b.ts");
1753 assert_eq!(value[2], "/other/c.ts");
1754 }
1755
1756 #[test]
1757 fn strip_root_prefix_recurses_into_nested_objects() {
1758 let mut value = serde_json::json!({
1759 "outer": {
1760 "path": "/project/src/nested.ts"
1761 }
1762 });
1763 strip_root_prefix(&mut value, "/project/");
1764 assert_eq!(value["outer"]["path"], "src/nested.ts");
1765 }
1766
1767 #[test]
1768 fn strip_root_prefix_leaves_numbers_and_booleans() {
1769 let mut value = serde_json::json!({
1770 "line": 42,
1771 "is_type_only": false,
1772 "path": "/project/src/file.ts"
1773 });
1774 strip_root_prefix(&mut value, "/project/");
1775 assert_eq!(value["line"], 42);
1776 assert_eq!(value["is_type_only"], false);
1777 assert_eq!(value["path"], "src/file.ts");
1778 }
1779
1780 #[test]
1781 fn strip_root_prefix_normalizes_windows_separators() {
1782 let mut value = serde_json::json!(r"/project\src\file.ts");
1783 strip_root_prefix(&mut value, "/project/");
1784 assert_eq!(value, "src/file.ts");
1785 }
1786
1787 #[test]
1788 fn strip_root_prefix_handles_empty_string_after_strip() {
1789 let mut value = serde_json::json!("/project/");
1792 strip_root_prefix(&mut value, "/project/");
1793 assert_eq!(value, "");
1794 }
1795
1796 #[test]
1797 fn strip_root_prefix_deeply_nested_array_of_objects() {
1798 let mut value = serde_json::json!({
1799 "groups": [{
1800 "instances": [{
1801 "file": "/project/src/a.ts"
1802 }, {
1803 "file": "/project/src/b.ts"
1804 }]
1805 }]
1806 });
1807 strip_root_prefix(&mut value, "/project/");
1808 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1809 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1810 }
1811
1812 #[test]
1815 fn json_full_sample_results_total_issues_correct() {
1816 let root = PathBuf::from("/project");
1817 let results = sample_results(&root);
1818 let elapsed = Duration::from_millis(100);
1819 let output = build_json(&results, &root, elapsed).expect("should serialize");
1820
1821 assert_eq!(output["total_issues"], results.total_issues());
1827 }
1828
1829 #[test]
1830 fn json_full_sample_no_absolute_paths_in_output() {
1831 let root = PathBuf::from("/project");
1832 let results = sample_results(&root);
1833 let elapsed = Duration::from_millis(0);
1834 let output = build_json(&results, &root, elapsed).expect("should serialize");
1835
1836 let json_str = serde_json::to_string(&output).expect("should stringify");
1837 assert!(!json_str.contains("/project/src/"));
1839 assert!(!json_str.contains("/project/package.json"));
1840 }
1841
1842 #[test]
1845 fn json_output_is_deterministic() {
1846 let root = PathBuf::from("/project");
1847 let results = sample_results(&root);
1848 let elapsed = Duration::from_millis(50);
1849
1850 let output1 = build_json(&results, &root, elapsed).expect("first build");
1851 let output2 = build_json(&results, &root, elapsed).expect("second build");
1852
1853 assert_eq!(output1, output2);
1854 }
1855
1856 #[test]
1859 fn json_results_fields_do_not_shadow_metadata() {
1860 let root = PathBuf::from("/project");
1863 let results = AnalysisResults::default();
1864 let elapsed = Duration::from_millis(99);
1865 let output = build_json(&results, &root, elapsed).expect("should serialize");
1866
1867 assert_eq!(output["schema_version"], 6);
1869 assert_eq!(output["elapsed_ms"], 99);
1870 }
1871
1872 #[test]
1875 fn json_all_issue_type_arrays_present_in_empty_results() {
1876 let root = PathBuf::from("/project");
1877 let results = AnalysisResults::default();
1878 let elapsed = Duration::from_millis(0);
1879 let output = build_json(&results, &root, elapsed).expect("should serialize");
1880
1881 let expected_arrays = [
1882 "unused_files",
1883 "unused_exports",
1884 "unused_types",
1885 "unused_dependencies",
1886 "unused_dev_dependencies",
1887 "unused_optional_dependencies",
1888 "unused_enum_members",
1889 "unused_class_members",
1890 "unresolved_imports",
1891 "unlisted_dependencies",
1892 "duplicate_exports",
1893 "type_only_dependencies",
1894 "test_only_dependencies",
1895 "circular_dependencies",
1896 ];
1897 for key in &expected_arrays {
1898 assert!(
1899 output[key].is_array(),
1900 "expected '{key}' to be an array in JSON output"
1901 );
1902 }
1903 }
1904
1905 #[test]
1908 fn insert_meta_adds_key_to_object() {
1909 let mut output = serde_json::json!({ "foo": 1 });
1910 let meta = serde_json::json!({ "docs": "https://example.com" });
1911 insert_meta(&mut output, meta.clone());
1912 assert_eq!(output["_meta"], meta);
1913 }
1914
1915 #[test]
1916 fn insert_meta_noop_on_non_object() {
1917 let mut output = serde_json::json!([1, 2, 3]);
1918 let meta = serde_json::json!({ "docs": "https://example.com" });
1919 insert_meta(&mut output, meta);
1920 assert!(output.is_array());
1922 }
1923
1924 #[test]
1925 fn insert_meta_overwrites_existing_meta() {
1926 let mut output = serde_json::json!({ "_meta": "old" });
1927 let meta = serde_json::json!({ "new": true });
1928 insert_meta(&mut output, meta.clone());
1929 assert_eq!(output["_meta"], meta);
1930 }
1931
1932 #[test]
1935 fn strip_root_prefix_null_unchanged() {
1936 let mut value = serde_json::Value::Null;
1937 strip_root_prefix(&mut value, "/project/");
1938 assert!(value.is_null());
1939 }
1940
1941 #[test]
1944 fn strip_root_prefix_empty_string() {
1945 let mut value = serde_json::json!("");
1946 strip_root_prefix(&mut value, "/project/");
1947 assert_eq!(value, "");
1948 }
1949
1950 #[test]
1953 fn strip_root_prefix_mixed_types() {
1954 let mut value = serde_json::json!({
1955 "path": "/project/src/file.ts",
1956 "line": 42,
1957 "flag": true,
1958 "nested": {
1959 "items": ["/project/a.ts", 99, null, "/project/b.ts"],
1960 "deep": { "path": "/project/c.ts" }
1961 }
1962 });
1963 strip_root_prefix(&mut value, "/project/");
1964 assert_eq!(value["path"], "src/file.ts");
1965 assert_eq!(value["line"], 42);
1966 assert_eq!(value["flag"], true);
1967 assert_eq!(value["nested"]["items"][0], "a.ts");
1968 assert_eq!(value["nested"]["items"][1], 99);
1969 assert!(value["nested"]["items"][2].is_null());
1970 assert_eq!(value["nested"]["items"][3], "b.ts");
1971 assert_eq!(value["nested"]["deep"]["path"], "c.ts");
1972 }
1973
1974 #[test]
1977 fn json_check_meta_integrates_correctly() {
1978 let root = PathBuf::from("/project");
1979 let results = AnalysisResults::default();
1980 let elapsed = Duration::from_millis(0);
1981 let mut output = build_json(&results, &root, elapsed).expect("should serialize");
1982 insert_meta(&mut output, crate::explain::check_meta());
1983
1984 assert!(output["_meta"]["docs"].is_string());
1985 assert!(output["_meta"]["rules"].is_object());
1986 }
1987
1988 #[test]
1991 fn json_unused_member_kind_serialized() {
1992 let root = PathBuf::from("/project");
1993 let mut results = AnalysisResults::default();
1994 results
1995 .unused_enum_members
1996 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1997 path: root.join("src/enums.ts"),
1998 parent_name: "Color".to_string(),
1999 member_name: "Red".to_string(),
2000 kind: MemberKind::EnumMember,
2001 line: 3,
2002 col: 2,
2003 }));
2004 results
2005 .unused_class_members
2006 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
2007 path: root.join("src/class.ts"),
2008 parent_name: "Foo".to_string(),
2009 member_name: "bar".to_string(),
2010 kind: MemberKind::ClassMethod,
2011 line: 10,
2012 col: 4,
2013 }));
2014
2015 let elapsed = Duration::from_millis(0);
2016 let output = build_json(&results, &root, elapsed).expect("should serialize");
2017
2018 let enum_member = &output["unused_enum_members"][0];
2019 assert!(enum_member["kind"].is_string());
2020 let class_member = &output["unused_class_members"][0];
2021 assert!(class_member["kind"].is_string());
2022 }
2023
2024 #[test]
2027 fn json_unused_export_has_actions() {
2028 let root = PathBuf::from("/project");
2029 let mut results = AnalysisResults::default();
2030 results
2031 .unused_exports
2032 .push(UnusedExportFinding::with_actions(UnusedExport {
2033 path: root.join("src/utils.ts"),
2034 export_name: "helperFn".to_string(),
2035 is_type_only: false,
2036 line: 10,
2037 col: 4,
2038 span_start: 120,
2039 is_re_export: false,
2040 }));
2041 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2042
2043 let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2044 assert_eq!(actions.len(), 2);
2045
2046 assert_eq!(actions[0]["type"], "remove-export");
2048 assert_eq!(actions[0]["auto_fixable"], true);
2049 assert!(actions[0].get("note").is_none());
2050
2051 assert_eq!(actions[1]["type"], "suppress-line");
2053 assert_eq!(
2054 actions[1]["comment"],
2055 "// fallow-ignore-next-line unused-export"
2056 );
2057 }
2058
2059 #[test]
2060 fn json_same_line_findings_share_multi_kind_suppression_comment() {
2061 let root = PathBuf::from("/project");
2062 let mut results = AnalysisResults::default();
2063 results
2064 .unused_exports
2065 .push(UnusedExportFinding::with_actions(UnusedExport {
2066 path: root.join("src/api.ts"),
2067 export_name: "helperFn".to_string(),
2068 is_type_only: false,
2069 line: 10,
2070 col: 4,
2071 span_start: 120,
2072 is_re_export: false,
2073 }));
2074 results
2075 .unused_types
2076 .push(UnusedTypeFinding::with_actions(UnusedExport {
2077 path: root.join("src/api.ts"),
2078 export_name: "OldType".to_string(),
2079 is_type_only: true,
2080 line: 10,
2081 col: 0,
2082 span_start: 60,
2083 is_re_export: false,
2084 }));
2085 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2086
2087 let export_actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2088 let type_actions = output["unused_types"][0]["actions"].as_array().unwrap();
2089 assert_eq!(
2090 export_actions[1]["comment"],
2091 "// fallow-ignore-next-line unused-export, unused-type"
2092 );
2093 assert_eq!(
2094 type_actions[1]["comment"],
2095 "// fallow-ignore-next-line unused-export, unused-type"
2096 );
2097 }
2098
2099 #[test]
2100 fn audit_like_json_shares_suppression_comment_across_dead_code_and_complexity() {
2101 let mut output = serde_json::json!({
2102 "dead_code": {
2103 "unused_exports": [{
2104 "path": "src/main.ts",
2105 "line": 1,
2106 "actions": [
2107 { "type": "remove-export", "auto_fixable": true },
2108 {
2109 "type": "suppress-line",
2110 "auto_fixable": false,
2111 "comment": "// fallow-ignore-next-line unused-export"
2112 }
2113 ]
2114 }]
2115 },
2116 "complexity": {
2117 "findings": [{
2118 "path": "src/main.ts",
2119 "line": 1,
2120 "actions": [
2121 { "type": "refactor-function", "auto_fixable": false },
2122 {
2123 "type": "suppress-line",
2124 "auto_fixable": false,
2125 "comment": "// fallow-ignore-next-line complexity"
2126 }
2127 ]
2128 }]
2129 }
2130 });
2131
2132 harmonize_multi_kind_suppress_line_actions(&mut output);
2133
2134 assert_eq!(
2135 output["dead_code"]["unused_exports"][0]["actions"][1]["comment"],
2136 "// fallow-ignore-next-line unused-export, complexity"
2137 );
2138 assert_eq!(
2139 output["complexity"]["findings"][0]["actions"][1]["comment"],
2140 "// fallow-ignore-next-line unused-export, complexity"
2141 );
2142 }
2143
2144 #[test]
2145 fn json_unused_file_has_file_suppress_and_note() {
2146 let root = PathBuf::from("/project");
2147 let mut results = AnalysisResults::default();
2148 results
2149 .unused_files
2150 .push(UnusedFileFinding::with_actions(UnusedFile {
2151 path: root.join("src/dead.ts"),
2152 }));
2153 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2154
2155 let actions = output["unused_files"][0]["actions"].as_array().unwrap();
2156 assert_eq!(actions[0]["type"], "delete-file");
2157 assert_eq!(actions[0]["auto_fixable"], false);
2158 assert!(actions[0]["note"].is_string());
2159 assert_eq!(actions[1]["type"], "suppress-file");
2160 assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
2161 }
2162
2163 #[test]
2164 fn json_unused_dependency_has_config_suppress_with_package_name() {
2165 let root = PathBuf::from("/project");
2166 let mut results = AnalysisResults::default();
2167 results
2168 .unused_dependencies
2169 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2170 package_name: "lodash".to_string(),
2171 location: DependencyLocation::Dependencies,
2172 path: root.join("package.json"),
2173 line: 5,
2174 used_in_workspaces: Vec::new(),
2175 }));
2176 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2177
2178 let actions = output["unused_dependencies"][0]["actions"]
2179 .as_array()
2180 .unwrap();
2181 assert_eq!(actions[0]["type"], "remove-dependency");
2182 assert_eq!(actions[0]["auto_fixable"], true);
2183
2184 assert_eq!(actions[1]["type"], "add-to-config");
2186 assert_eq!(actions[1]["config_key"], "ignoreDependencies");
2187 assert_eq!(actions[1]["value"], "lodash");
2188 }
2189
2190 #[test]
2191 fn json_cross_workspace_dependency_is_not_auto_fixable() {
2192 let root = PathBuf::from("/project");
2193 let mut results = AnalysisResults::default();
2194 results
2195 .unused_dependencies
2196 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2197 package_name: "lodash-es".to_string(),
2198 location: DependencyLocation::Dependencies,
2199 path: root.join("packages/shared/package.json"),
2200 line: 5,
2201 used_in_workspaces: vec![root.join("packages/consumer")],
2202 }));
2203 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2204
2205 let actions = output["unused_dependencies"][0]["actions"]
2206 .as_array()
2207 .unwrap();
2208 assert_eq!(actions[0]["type"], "move-dependency");
2209 assert_eq!(actions[0]["auto_fixable"], false);
2210 assert!(
2211 actions[0]["note"]
2212 .as_str()
2213 .unwrap()
2214 .contains("will not remove")
2215 );
2216 assert_eq!(actions[1]["type"], "add-to-config");
2217 }
2218
2219 #[test]
2220 fn json_empty_results_have_no_actions_in_empty_arrays() {
2221 let root = PathBuf::from("/project");
2222 let results = AnalysisResults::default();
2223 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2224
2225 assert!(output["unused_exports"].as_array().unwrap().is_empty());
2227 assert!(output["unused_files"].as_array().unwrap().is_empty());
2228 }
2229
2230 #[test]
2231 fn json_all_issue_types_have_actions() {
2232 let root = PathBuf::from("/project");
2233 let results = sample_results(&root);
2234 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2235
2236 let issue_keys = [
2237 "unused_files",
2238 "unused_exports",
2239 "unused_types",
2240 "unused_dependencies",
2241 "unused_dev_dependencies",
2242 "unused_optional_dependencies",
2243 "unused_enum_members",
2244 "unused_class_members",
2245 "unresolved_imports",
2246 "unlisted_dependencies",
2247 "duplicate_exports",
2248 "type_only_dependencies",
2249 "test_only_dependencies",
2250 "circular_dependencies",
2251 ];
2252
2253 for key in &issue_keys {
2254 let arr = output[key].as_array().unwrap();
2255 if !arr.is_empty() {
2256 let actions = arr[0]["actions"].as_array();
2257 assert!(
2258 actions.is_some() && !actions.unwrap().is_empty(),
2259 "missing actions for {key}"
2260 );
2261 }
2262 }
2263 }
2264
2265 fn build_actions_for_finding_json(
2274 finding_json: serde_json::Value,
2275 opts: crate::health_types::HealthActionOptions,
2276 max_cyclomatic_threshold: u16,
2277 max_cognitive_threshold: u16,
2278 max_crap_threshold: f64,
2279 ) -> Vec<serde_json::Value> {
2280 let mut value = finding_json;
2281 if let Some(map) = value.as_object_mut() {
2286 map.entry("col".to_string())
2287 .or_insert(serde_json::Value::from(0_u32));
2288 map.entry("line_count".to_string())
2289 .or_insert(serde_json::Value::from(0_u32));
2290 map.entry("param_count".to_string())
2291 .or_insert(serde_json::Value::from(0_u8));
2292 map.entry("severity".to_string())
2293 .or_insert(serde_json::Value::String("moderate".to_string()));
2294 }
2295 let violation = synthesize_complexity_violation(&value);
2299 let ctx = crate::health_types::HealthActionContext {
2300 opts,
2301 max_cyclomatic_threshold,
2302 max_cognitive_threshold,
2303 max_crap_threshold,
2304 };
2305 let finding = crate::health_types::HealthFinding::with_actions(violation, &ctx);
2306 let serialized = serde_json::to_value(&finding).expect("serialize HealthFinding");
2307 serialized["actions"]
2308 .as_array()
2309 .cloned()
2310 .unwrap_or_default()
2311 }
2312
2313 fn synthesize_complexity_violation(
2318 value: &serde_json::Value,
2319 ) -> crate::health_types::ComplexityViolation {
2320 use crate::health_types::{
2321 CoverageSource, CoverageTier, ExceededThreshold, FindingSeverity,
2322 };
2323 let exceeded = match value["exceeded"].as_str().unwrap_or("crap") {
2324 "cyclomatic" => ExceededThreshold::Cyclomatic,
2325 "cognitive" => ExceededThreshold::Cognitive,
2326 "both" => ExceededThreshold::Both,
2327 "crap" => ExceededThreshold::Crap,
2328 "cyclomatic_crap" => ExceededThreshold::CyclomaticCrap,
2329 "cognitive_crap" => ExceededThreshold::CognitiveCrap,
2330 "all" => ExceededThreshold::All,
2331 other => panic!("unknown exceeded label: {other}"),
2332 };
2333 let severity = match value["severity"].as_str().unwrap_or("moderate") {
2334 "moderate" => FindingSeverity::Moderate,
2335 "high" => FindingSeverity::High,
2336 "critical" => FindingSeverity::Critical,
2337 other => panic!("unknown severity label: {other}"),
2338 };
2339 let coverage_tier = value
2340 .get("coverage_tier")
2341 .and_then(|v| v.as_str())
2342 .map(|t| match t {
2343 "none" => CoverageTier::None,
2344 "partial" => CoverageTier::Partial,
2345 "high" => CoverageTier::High,
2346 other => panic!("unknown coverage_tier label: {other}"),
2347 });
2348 let coverage_source =
2349 value
2350 .get("coverage_source")
2351 .and_then(|v| v.as_str())
2352 .map(|s| match s {
2353 "istanbul" => CoverageSource::Istanbul,
2354 "estimated" => CoverageSource::Estimated,
2355 "estimated_component_inherited" => CoverageSource::EstimatedComponentInherited,
2356 other => panic!("unknown coverage_source label: {other}"),
2357 });
2358 crate::health_types::ComplexityViolation {
2359 path: std::path::PathBuf::from(value["path"].as_str().unwrap_or("src/x.ts")),
2360 name: value["name"].as_str().unwrap_or("fn").to_string(),
2361 line: u32::try_from(value["line"].as_u64().unwrap_or(0)).unwrap_or(0),
2362 col: u32::try_from(value["col"].as_u64().unwrap_or(0)).unwrap_or(0),
2363 cyclomatic: u16::try_from(value["cyclomatic"].as_u64().unwrap_or(0)).unwrap_or(0),
2364 cognitive: u16::try_from(value["cognitive"].as_u64().unwrap_or(0)).unwrap_or(0),
2365 line_count: u32::try_from(value["line_count"].as_u64().unwrap_or(0)).unwrap_or(0),
2366 param_count: u8::try_from(value["param_count"].as_u64().unwrap_or(0)).unwrap_or(0),
2367 exceeded,
2368 severity,
2369 crap: value.get("crap").and_then(|v| v.as_f64()),
2370 coverage_pct: value.get("coverage_pct").and_then(|v| v.as_f64()),
2371 coverage_tier,
2372 coverage_source,
2373 inherited_from: value
2374 .get("inherited_from")
2375 .and_then(|v| v.as_str())
2376 .map(std::path::PathBuf::from),
2377 component_rollup: value.get("component_rollup").and_then(|v| {
2378 let map = v.as_object()?;
2379 Some(crate::health_types::ComponentRollup {
2380 component: map.get("component")?.as_str()?.to_string(),
2381 class_worst_function: map.get("class_worst_function")?.as_str()?.to_string(),
2382 class_cyclomatic: u16::try_from(map.get("class_cyclomatic")?.as_u64()?).ok()?,
2383 class_cognitive: u16::try_from(map.get("class_cognitive")?.as_u64()?).ok()?,
2384 template_path: std::path::PathBuf::from(map.get("template_path")?.as_str()?),
2385 template_cyclomatic: u16::try_from(map.get("template_cyclomatic")?.as_u64()?)
2386 .ok()?,
2387 template_cognitive: u16::try_from(map.get("template_cognitive")?.as_u64()?)
2388 .ok()?,
2389 })
2390 }),
2391 }
2392 }
2393
2394 #[test]
2395 fn health_finding_has_actions() {
2396 let actions = build_actions_for_finding_json(
2397 serde_json::json!({
2398 "path": "src/utils.ts",
2399 "name": "processData",
2400 "line": 10,
2401 "col": 0,
2402 "cyclomatic": 25,
2403 "cognitive": 30,
2404 "line_count": 150,
2405 "exceeded": "both"
2406 }),
2407 crate::health_types::HealthActionOptions::default(),
2408 20,
2409 15,
2410 30.0,
2411 );
2412
2413 assert_eq!(actions.len(), 2);
2414 assert_eq!(actions[0]["type"], "refactor-function");
2415 assert_eq!(actions[0]["auto_fixable"], false);
2416 assert!(
2417 actions[0]["description"]
2418 .as_str()
2419 .unwrap()
2420 .contains("processData")
2421 );
2422 assert_eq!(actions[1]["type"], "suppress-line");
2423 assert_eq!(
2424 actions[1]["comment"],
2425 "// fallow-ignore-next-line complexity"
2426 );
2427 }
2428
2429 #[test]
2437 fn health_finding_suppress_has_placement() {
2438 let actions = build_actions_for_finding_json(
2439 serde_json::json!({
2440 "path": "src/utils.ts",
2441 "name": "processData",
2442 "line": 10,
2443 "col": 0,
2444 "cyclomatic": 25,
2445 "cognitive": 30,
2446 "line_count": 150,
2447 "exceeded": "both"
2448 }),
2449 crate::health_types::HealthActionOptions::default(),
2450 20,
2451 15,
2452 30.0,
2453 );
2454
2455 assert_eq!(actions[1]["placement"], "above-function-declaration");
2456 }
2457
2458 #[test]
2459 fn html_template_health_finding_uses_html_suppression() {
2460 let actions = build_actions_for_finding_json(
2461 serde_json::json!({
2462 "path": "src/app.component.html",
2463 "name": "<template>",
2464 "line": 1,
2465 "col": 0,
2466 "cyclomatic": 25,
2467 "cognitive": 30,
2468 "line_count": 40,
2469 "exceeded": "both"
2470 }),
2471 crate::health_types::HealthActionOptions::default(),
2472 20,
2473 15,
2474 30.0,
2475 );
2476
2477 let suppress = &actions[1];
2478 assert_eq!(suppress["type"], "suppress-file");
2479 assert_eq!(
2480 suppress["comment"],
2481 "<!-- fallow-ignore-file complexity -->"
2482 );
2483 assert_eq!(suppress["placement"], "top-of-template");
2484 }
2485
2486 #[test]
2487 fn inline_template_health_finding_uses_decorator_suppression() {
2488 let actions = build_actions_for_finding_json(
2489 serde_json::json!({
2490 "path": "src/app.component.ts",
2491 "name": "<template>",
2492 "line": 5,
2493 "col": 0,
2494 "cyclomatic": 25,
2495 "cognitive": 30,
2496 "line_count": 40,
2497 "exceeded": "both"
2498 }),
2499 crate::health_types::HealthActionOptions::default(),
2500 20,
2501 15,
2502 30.0,
2503 );
2504
2505 let refactor = &actions[0];
2506 assert_eq!(refactor["type"], "refactor-function");
2507 assert!(
2508 refactor["description"]
2509 .as_str()
2510 .unwrap()
2511 .contains("template complexity")
2512 );
2513 let suppress = &actions[1];
2514 assert_eq!(suppress["type"], "suppress-line");
2515 assert_eq!(
2516 suppress["description"],
2517 "Suppress with an inline comment above the Angular decorator"
2518 );
2519 assert_eq!(suppress["placement"], "above-angular-decorator");
2520 }
2521
2522 fn crap_only_finding_envelope(
2537 coverage_tier: Option<&str>,
2538 cyclomatic: u16,
2539 max_cyclomatic_threshold: u16,
2540 ) -> serde_json::Value {
2541 crap_only_finding_envelope_with_max_crap(
2542 coverage_tier,
2543 cyclomatic,
2544 12,
2545 max_cyclomatic_threshold,
2546 15,
2547 30.0,
2548 )
2549 }
2550
2551 fn crap_only_finding_envelope_with_cognitive(
2552 coverage_tier: Option<&str>,
2553 cyclomatic: u16,
2554 cognitive: u16,
2555 max_cyclomatic_threshold: u16,
2556 ) -> serde_json::Value {
2557 crap_only_finding_envelope_with_max_crap(
2558 coverage_tier,
2559 cyclomatic,
2560 cognitive,
2561 max_cyclomatic_threshold,
2562 15,
2563 30.0,
2564 )
2565 }
2566
2567 fn crap_only_finding_envelope_with_max_crap(
2575 coverage_tier: Option<&str>,
2576 cyclomatic: u16,
2577 cognitive: u16,
2578 max_cyclomatic_threshold: u16,
2579 max_cognitive_threshold: u16,
2580 max_crap_threshold: f64,
2581 ) -> serde_json::Value {
2582 build_finding_envelope_with_ctx(
2583 coverage_tier,
2584 cyclomatic,
2585 cognitive,
2586 max_cyclomatic_threshold,
2587 max_cognitive_threshold,
2588 max_crap_threshold,
2589 crate::health_types::HealthActionOptions::default(),
2590 )
2591 }
2592
2593 fn build_finding_envelope_with_ctx(
2597 coverage_tier: Option<&str>,
2598 cyclomatic: u16,
2599 cognitive: u16,
2600 max_cyclomatic_threshold: u16,
2601 max_cognitive_threshold: u16,
2602 max_crap_threshold: f64,
2603 action_opts: crate::health_types::HealthActionOptions,
2604 ) -> serde_json::Value {
2605 let tier = coverage_tier.map(|t| match t {
2606 "none" => crate::health_types::CoverageTier::None,
2607 "partial" => crate::health_types::CoverageTier::Partial,
2608 "high" => crate::health_types::CoverageTier::High,
2609 other => panic!("unknown coverage tier label: {other}"),
2610 });
2611 let violation = crate::health_types::ComplexityViolation {
2612 path: std::path::PathBuf::from("src/risk.ts"),
2613 name: "computeScore".to_string(),
2614 line: 12,
2615 col: 0,
2616 cyclomatic,
2617 cognitive,
2618 line_count: 40,
2619 param_count: 0,
2620 exceeded: crate::health_types::ExceededThreshold::Crap,
2621 severity: crate::health_types::FindingSeverity::Moderate,
2622 crap: Some(35.5),
2623 coverage_pct: None,
2624 coverage_tier: tier,
2625 coverage_source: None,
2626 inherited_from: None,
2627 component_rollup: None,
2628 };
2629 let ctx = crate::health_types::HealthActionContext {
2630 opts: action_opts,
2631 max_cyclomatic_threshold,
2632 max_cognitive_threshold,
2633 max_crap_threshold,
2634 };
2635 let finding = crate::health_types::HealthFinding::with_actions(violation, &ctx);
2636 let actions_meta = if action_opts.omit_suppress_line {
2637 Some(serde_json::json!({
2638 "suppression_hints_omitted": true,
2639 "reason": action_opts.omit_reason.unwrap_or("unspecified"),
2640 "scope": "health-findings",
2641 }))
2642 } else {
2643 None
2644 };
2645 let mut envelope = serde_json::json!({
2646 "findings": [serde_json::to_value(&finding).unwrap()],
2647 "summary": {
2648 "max_cyclomatic_threshold": max_cyclomatic_threshold,
2649 "max_cognitive_threshold": max_cognitive_threshold,
2650 "max_crap_threshold": max_crap_threshold,
2651 },
2652 });
2653 if let Some(meta) = actions_meta
2654 && let Some(map) = envelope.as_object_mut()
2655 {
2656 map.insert("actions_meta".to_string(), meta);
2657 }
2658 envelope
2659 }
2660
2661 #[test]
2662 fn crap_only_tier_none_emits_add_tests() {
2663 let output = crap_only_finding_envelope(Some("none"), 6, 20);
2664 let actions = output["findings"][0]["actions"].as_array().unwrap();
2665 assert!(
2666 actions.iter().any(|a| a["type"] == "add-tests"),
2667 "tier=none crap-only must emit add-tests, got {actions:?}"
2668 );
2669 assert!(
2670 !actions.iter().any(|a| a["type"] == "increase-coverage"),
2671 "tier=none must not emit increase-coverage"
2672 );
2673 }
2674
2675 #[test]
2676 fn crap_only_tier_partial_emits_increase_coverage() {
2677 let output = crap_only_finding_envelope(Some("partial"), 6, 20);
2678 let actions = output["findings"][0]["actions"].as_array().unwrap();
2679 assert!(
2680 actions.iter().any(|a| a["type"] == "increase-coverage"),
2681 "tier=partial crap-only must emit increase-coverage, got {actions:?}"
2682 );
2683 assert!(
2684 !actions.iter().any(|a| a["type"] == "add-tests"),
2685 "tier=partial must not emit add-tests"
2686 );
2687 }
2688
2689 #[test]
2690 fn crap_only_tier_high_emits_increase_coverage_when_full_coverage_can_clear_crap() {
2691 let output = crap_only_finding_envelope(Some("high"), 20, 30);
2695 let actions = output["findings"][0]["actions"].as_array().unwrap();
2696 assert!(
2697 actions.iter().any(|a| a["type"] == "increase-coverage"),
2698 "tier=high crap-only must still emit increase-coverage when full coverage can clear CRAP, got {actions:?}"
2699 );
2700 assert!(
2701 !actions.iter().any(|a| a["type"] == "refactor-function"),
2702 "coverage-remediable crap-only findings should not get refactor-function unless near the cyclomatic threshold"
2703 );
2704 assert!(
2705 !actions.iter().any(|a| a["type"] == "add-tests"),
2706 "tier=high must not emit add-tests"
2707 );
2708 }
2709
2710 #[test]
2711 fn crap_only_emits_refactor_when_full_coverage_cannot_clear_crap() {
2712 let output = crap_only_finding_envelope_with_max_crap(Some("high"), 35, 12, 50, 15, 30.0);
2716 let actions = output["findings"][0]["actions"].as_array().unwrap();
2717 assert!(
2718 actions.iter().any(|a| a["type"] == "refactor-function"),
2719 "full-coverage-impossible CRAP-only finding must emit refactor-function, got {actions:?}"
2720 );
2721 assert!(
2722 !actions.iter().any(|a| a["type"] == "increase-coverage"),
2723 "must not emit increase-coverage when even 100% coverage cannot clear CRAP"
2724 );
2725 assert!(
2726 !actions.iter().any(|a| a["type"] == "add-tests"),
2727 "must not emit add-tests when even 100% coverage cannot clear CRAP"
2728 );
2729 }
2730
2731 #[test]
2732 fn crap_only_high_cc_appends_secondary_refactor() {
2733 let output = crap_only_finding_envelope(Some("none"), 16, 20);
2736 let actions = output["findings"][0]["actions"].as_array().unwrap();
2737 assert!(
2738 actions.iter().any(|a| a["type"] == "add-tests"),
2739 "near-threshold crap-only still emits the primary tier action"
2740 );
2741 assert!(
2742 actions.iter().any(|a| a["type"] == "refactor-function"),
2743 "near-threshold crap-only must also emit secondary refactor-function"
2744 );
2745 }
2746
2747 #[test]
2748 fn crap_only_far_below_threshold_no_secondary_refactor() {
2749 let output = crap_only_finding_envelope(Some("none"), 6, 20);
2751 let actions = output["findings"][0]["actions"].as_array().unwrap();
2752 assert!(
2753 !actions.iter().any(|a| a["type"] == "refactor-function"),
2754 "low-CC crap-only should not get a secondary refactor-function"
2755 );
2756 }
2757
2758 #[test]
2759 fn crap_only_near_threshold_low_cognitive_no_secondary_refactor() {
2760 let output = crap_only_finding_envelope_with_cognitive(Some("none"), 17, 2, 20);
2769 let actions = output["findings"][0]["actions"].as_array().unwrap();
2770 assert!(
2771 actions.iter().any(|a| a["type"] == "add-tests"),
2772 "primary tier action still emits"
2773 );
2774 assert!(
2775 !actions.iter().any(|a| a["type"] == "refactor-function"),
2776 "near-threshold CC with cognitive below floor must NOT emit secondary refactor (got {actions:?})"
2777 );
2778 }
2779
2780 #[test]
2781 fn crap_only_near_threshold_high_cognitive_emits_secondary_refactor() {
2782 let output = crap_only_finding_envelope_with_cognitive(Some("none"), 16, 10, 20);
2788 let actions = output["findings"][0]["actions"].as_array().unwrap();
2789 assert!(
2790 actions.iter().any(|a| a["type"] == "add-tests"),
2791 "primary tier action still emits"
2792 );
2793 assert!(
2794 actions.iter().any(|a| a["type"] == "refactor-function"),
2795 "near-threshold CC with cognitive above floor must emit secondary refactor (got {actions:?})"
2796 );
2797 }
2798
2799 #[test]
2800 fn cyclomatic_only_emits_only_refactor_function() {
2801 let actions = build_actions_for_finding_json(
2802 serde_json::json!({
2803 "path": "src/cyclo.ts",
2804 "name": "branchy",
2805 "line": 5,
2806 "col": 0,
2807 "cyclomatic": 25,
2808 "cognitive": 10,
2809 "line_count": 80,
2810 "exceeded": "cyclomatic",
2811 }),
2812 crate::health_types::HealthActionOptions::default(),
2813 20,
2814 15,
2815 30.0,
2816 );
2817 assert!(
2818 actions.iter().any(|a| a["type"] == "refactor-function"),
2819 "non-CRAP findings emit refactor-function"
2820 );
2821 assert!(
2822 !actions.iter().any(|a| a["type"] == "add-tests"),
2823 "non-CRAP findings must not emit add-tests"
2824 );
2825 assert!(
2826 !actions.iter().any(|a| a["type"] == "increase-coverage"),
2827 "non-CRAP findings must not emit increase-coverage"
2828 );
2829 }
2830
2831 #[test]
2834 fn suppress_line_omitted_when_baseline_active() {
2835 let output = build_finding_envelope_with_ctx(
2836 Some("none"),
2837 6,
2838 12,
2839 20,
2840 15,
2841 30.0,
2842 crate::health_types::HealthActionOptions {
2843 omit_suppress_line: true,
2844 omit_reason: Some("baseline-active"),
2845 },
2846 );
2847 let actions = output["findings"][0]["actions"].as_array().unwrap();
2848 assert!(
2849 !actions.iter().any(|a| a["type"] == "suppress-line"),
2850 "baseline-active must not emit suppress-line, got {actions:?}"
2851 );
2852 assert_eq!(
2853 output["actions_meta"]["suppression_hints_omitted"],
2854 serde_json::Value::Bool(true)
2855 );
2856 assert_eq!(output["actions_meta"]["reason"], "baseline-active");
2857 assert_eq!(output["actions_meta"]["scope"], "health-findings");
2858 }
2859
2860 #[test]
2861 fn suppress_line_omitted_when_config_disabled() {
2862 let output = build_finding_envelope_with_ctx(
2863 Some("none"),
2864 6,
2865 12,
2866 20,
2867 15,
2868 30.0,
2869 crate::health_types::HealthActionOptions {
2870 omit_suppress_line: true,
2871 omit_reason: Some("config-disabled"),
2872 },
2873 );
2874 assert_eq!(output["actions_meta"]["reason"], "config-disabled");
2875 }
2876
2877 #[test]
2878 fn suppress_line_emitted_by_default() {
2879 let output = crap_only_finding_envelope(Some("none"), 6, 20);
2880 let actions = output["findings"][0]["actions"].as_array().unwrap();
2881 assert!(
2882 actions.iter().any(|a| a["type"] == "suppress-line"),
2883 "default opts must emit suppress-line"
2884 );
2885 assert!(
2886 output.get("actions_meta").is_none(),
2887 "actions_meta must be absent when no omission occurred"
2888 );
2889 }
2890
2891 #[test]
2898 fn every_emitted_health_action_type_is_in_schema_enum() {
2899 let cases = [
2903 ("crap", Some("none"), 6_u16, 20_u16),
2905 ("crap", Some("partial"), 6, 20),
2906 ("crap", Some("high"), 12, 20),
2907 ("crap", Some("none"), 16, 20), ("cyclomatic", None, 25, 20),
2909 ("cognitive_crap", Some("partial"), 6, 20),
2910 ("all", Some("none"), 25, 20),
2911 ];
2912
2913 let mut emitted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2914 for (exceeded, tier, cc, max) in cases {
2915 let mut finding = serde_json::json!({
2916 "path": "src/x.ts",
2917 "name": "fn",
2918 "line": 1,
2919 "col": 0,
2920 "cyclomatic": cc,
2921 "cognitive": 5,
2922 "line_count": 10,
2923 "exceeded": exceeded,
2924 "crap": 35.0,
2925 });
2926 if let Some(t) = tier {
2927 finding["coverage_tier"] = serde_json::Value::String(t.to_owned());
2928 }
2929 let actions = build_actions_for_finding_json(
2930 finding,
2931 crate::health_types::HealthActionOptions::default(),
2932 max,
2933 15,
2934 30.0,
2935 );
2936 for action in &actions {
2937 if let Some(ty) = action["type"].as_str() {
2938 emitted.insert(ty.to_owned());
2939 }
2940 }
2941 }
2942
2943 let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2949 .join("..")
2950 .join("..")
2951 .join("docs")
2952 .join("output-schema.json");
2953 let raw = std::fs::read_to_string(&schema_path)
2954 .expect("docs/output-schema.json must be readable for the drift-guard test");
2955 let schema: serde_json::Value = serde_json::from_str(&raw).expect("schema parses");
2956 let type_field = &schema["definitions"]["HealthFindingAction"]["properties"]["type"];
2957 let type_def = if let Some(reference) = type_field.get("$ref").and_then(|r| r.as_str()) {
2958 let name = reference
2959 .strip_prefix("#/definitions/")
2960 .expect("HealthFindingAction.type $ref points into #/definitions/");
2961 &schema["definitions"][name]
2962 } else {
2963 type_field
2964 };
2965 let mut enum_values: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2966 if let Some(arr) = type_def.get("enum").and_then(|e| e.as_array()) {
2967 for v in arr {
2968 if let Some(s) = v.as_str() {
2969 enum_values.insert(s.to_owned());
2970 }
2971 }
2972 }
2973 if let Some(arr) = type_def.get("oneOf").and_then(|e| e.as_array()) {
2974 for branch in arr {
2975 if let Some(s) = branch.get("const").and_then(|c| c.as_str()) {
2976 enum_values.insert(s.to_owned());
2977 }
2978 }
2979 }
2980 assert!(
2981 !enum_values.is_empty(),
2982 "could not extract HealthFindingActionType variants from schema (neither `enum` nor `oneOf` with `const` branches)"
2983 );
2984
2985 for ty in &emitted {
2986 assert!(
2987 enum_values.contains(ty),
2988 "build_health_finding_actions emitted action type `{ty}` but \
2989 docs/output-schema.json HealthFindingAction.type enum does \
2990 not list it. Add it to the schema (and any downstream \
2991 typed consumers) when introducing a new action type."
2992 );
2993 }
2994 }
2995
2996 #[test]
3010 fn no_new_post_pass_helpers_in_json_rs() {
3011 const POST_PASS_ALLOW_LIST: &[(&str, &str)] = &[];
3012 let source_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3013 .join("src")
3014 .join("report")
3015 .join("json.rs");
3016 let source = std::fs::read_to_string(&source_path).expect(
3017 "crates/cli/src/report/json.rs must be readable for the post-pass drift-guard test",
3018 );
3019 let mut found: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3020 for line in source.lines() {
3021 if let Some(name) = extract_post_pass_fn_name(line) {
3022 found.insert(name.to_owned());
3023 }
3024 }
3025 let allow: std::collections::BTreeSet<&'static str> =
3026 POST_PASS_ALLOW_LIST.iter().map(|(name, _)| *name).collect();
3027 let unexpected: Vec<&str> = found
3028 .iter()
3029 .filter(|name| !allow.contains(name.as_str()))
3030 .map(String::as_str)
3031 .collect();
3032 let stale: Vec<&str> = allow
3033 .iter()
3034 .filter(|name| !found.contains(**name))
3035 .copied()
3036 .collect();
3037 assert!(
3038 unexpected.is_empty(),
3039 "new post-pass helper(s) defined in crates/cli/src/report/json.rs are not in \
3040 POST_PASS_ALLOW_LIST: {unexpected:?}.\n\
3041 The typed `serde(flatten)` envelope is the source of truth for `actions[]` on \
3042 every finding. If a new post-pass is genuinely needed, file a tracking issue, \
3043 add the entry to POST_PASS_ALLOW_LIST with the issue link as the reason, and \
3044 reference the issue in the PR body. See issue #412 for context."
3045 );
3046 assert!(
3047 stale.is_empty(),
3048 "stale entries in POST_PASS_ALLOW_LIST (function no longer defined in \
3049 crates/cli/src/report/json.rs): {stale:?}.\n\
3050 Remove them in the same commit that retired the function."
3051 );
3052 }
3053
3054 fn extract_post_pass_fn_name(line: &str) -> Option<&str> {
3059 let trimmed = line.trim_start();
3060 if trimmed.starts_with("//") {
3061 return None;
3062 }
3063 let mut rest = trimmed;
3064 if let Some(after) = rest.strip_prefix("pub") {
3065 let after = after.trim_start();
3066 rest = if let Some(after) = after.strip_prefix('(') {
3067 let close = after.find(')')?;
3068 after[close + 1..].trim_start()
3069 } else {
3070 after
3071 };
3072 }
3073 for prefix in ["async ", "const ", "unsafe "] {
3074 if let Some(after) = rest.strip_prefix(prefix) {
3075 rest = after.trim_start();
3076 }
3077 }
3078 let after_fn = rest.strip_prefix("fn ")?;
3079 let name_end = after_fn
3080 .find(|c: char| !c.is_alphanumeric() && c != '_')
3081 .unwrap_or(after_fn.len());
3082 let name = &after_fn[..name_end];
3083 if name.starts_with("inject_") || name.starts_with("augment_") {
3084 Some(name)
3085 } else {
3086 None
3087 }
3088 }
3089}