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(
563 report: &crate::health_types::HealthReport,
564 grouping: &crate::health_types::HealthGrouping,
565 root: &Path,
566 elapsed: Duration,
567 explain: bool,
568) -> Result<serde_json::Value, serde_json::Error> {
569 let root_prefix = format!("{}/", root.display());
570 let envelope = HealthOutput {
578 schema_version: SchemaVersion(SCHEMA_VERSION),
579 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
580 elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
581 report: report.clone(),
582 grouped_by: Some(group_by_mode_from_label(grouping.mode)),
583 groups: None,
587 meta: None,
588 workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
589 };
590 let mut output = serde_json::to_value(&envelope)?;
591 strip_root_prefix(&mut output, &root_prefix);
592
593 let group_values: Vec<serde_json::Value> = grouping
594 .groups
595 .iter()
596 .map(|g| {
597 let mut value = serde_json::to_value(g)?;
598 strip_root_prefix(&mut value, &root_prefix);
599 Ok(value)
600 })
601 .collect::<Result<_, serde_json::Error>>()?;
602
603 if let serde_json::Value::Object(ref mut map) = output {
604 map.insert("groups".to_string(), serde_json::Value::Array(group_values));
605 }
606
607 if explain {
608 insert_meta(&mut output, explain::health_meta());
609 }
610
611 Ok(output)
612}
613
614pub(super) fn print_grouped_health_json(
615 report: &crate::health_types::HealthReport,
616 grouping: &crate::health_types::HealthGrouping,
617 root: &Path,
618 elapsed: Duration,
619 explain: bool,
620) -> ExitCode {
621 match build_grouped_health_json(report, grouping, root, elapsed, explain) {
622 Ok(output) => emit_json(&output, "JSON"),
623 Err(e) => {
624 eprintln!("Error: failed to serialize grouped health report: {e}");
625 ExitCode::from(2)
626 }
627 }
628}
629
630pub fn build_duplication_json(
637 report: &DuplicationReport,
638 root: &Path,
639 elapsed: Duration,
640 explain: bool,
641) -> Result<serde_json::Value, serde_json::Error> {
642 let envelope = DupesOutput {
643 schema_version: SchemaVersion(SCHEMA_VERSION),
644 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
645 elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
646 report: DupesReportPayload::from_report(report),
647 grouped_by: None,
648 total_issues: None,
649 groups: None,
650 meta: None,
651 workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
652 };
653 let mut output = serde_json::to_value(&envelope)?;
654 let root_prefix = format!("{}/", root.display());
655 strip_root_prefix(&mut output, &root_prefix);
656
657 if explain {
658 insert_meta(&mut output, explain::dupes_meta());
659 }
660
661 Ok(output)
662}
663
664pub(super) fn print_duplication_json(
665 report: &DuplicationReport,
666 root: &Path,
667 elapsed: Duration,
668 explain: bool,
669) -> ExitCode {
670 match build_duplication_json(report, root, elapsed, explain) {
671 Ok(output) => emit_json(&output, "JSON"),
672 Err(e) => {
673 eprintln!("Error: failed to serialize duplication report: {e}");
674 ExitCode::from(2)
675 }
676 }
677}
678
679pub fn build_grouped_duplication_json(
700 report: &DuplicationReport,
701 grouping: &super::dupes_grouping::DuplicationGrouping,
702 root: &Path,
703 elapsed: Duration,
704 explain: bool,
705) -> Result<serde_json::Value, serde_json::Error> {
706 let root_prefix = format!("{}/", root.display());
707 let envelope = DupesOutput {
712 schema_version: SchemaVersion(SCHEMA_VERSION),
713 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
714 elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
715 report: DupesReportPayload::from_report(report),
716 grouped_by: Some(group_by_mode_from_label(grouping.mode)),
717 total_issues: Some(report.clone_groups.len()),
718 groups: None,
723 meta: None,
724 workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
725 };
726 let mut output = serde_json::to_value(&envelope)?;
727 strip_root_prefix(&mut output, &root_prefix);
728
729 let group_values: Vec<serde_json::Value> = grouping
730 .groups
731 .iter()
732 .map(|g| {
733 let mut value = serde_json::to_value(g)?;
734 strip_root_prefix(&mut value, &root_prefix);
735 Ok(value)
736 })
737 .collect::<Result<_, serde_json::Error>>()?;
738
739 if let serde_json::Value::Object(ref mut map) = output {
740 map.insert("groups".to_string(), serde_json::Value::Array(group_values));
741 }
742
743 if explain {
744 insert_meta(&mut output, explain::dupes_meta());
745 }
746
747 Ok(output)
748}
749
750fn group_by_mode_from_label(label: &str) -> GroupByMode {
756 match label {
757 "directory" => GroupByMode::Directory,
758 "package" => GroupByMode::Package,
759 "section" => GroupByMode::Section,
760 _ => GroupByMode::Owner,
761 }
762}
763
764pub(super) fn print_grouped_duplication_json(
765 report: &DuplicationReport,
766 grouping: &super::dupes_grouping::DuplicationGrouping,
767 root: &Path,
768 elapsed: Duration,
769 explain: bool,
770) -> ExitCode {
771 match build_grouped_duplication_json(report, grouping, root, elapsed, explain) {
772 Ok(output) => emit_json(&output, "JSON"),
773 Err(e) => {
774 eprintln!("Error: failed to serialize grouped duplication report: {e}");
775 ExitCode::from(2)
776 }
777 }
778}
779
780pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
781 match serde_json::to_string_pretty(value) {
782 Ok(json) => println!("{json}"),
783 Err(e) => {
784 eprintln!("Error: failed to serialize trace output: {e}");
785 #[expect(
786 clippy::exit,
787 reason = "fatal serialization error requires immediate exit"
788 )]
789 std::process::exit(2);
790 }
791 }
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797 use crate::health_types::{
798 RuntimeCoverageAction, RuntimeCoverageConfidence, RuntimeCoverageDataSource,
799 RuntimeCoverageEvidence, RuntimeCoverageFinding, RuntimeCoverageHotPath,
800 RuntimeCoverageMessage, RuntimeCoverageReport, RuntimeCoverageReportVerdict,
801 RuntimeCoverageSchemaVersion, RuntimeCoverageSummary, RuntimeCoverageVerdict,
802 RuntimeCoverageWatermark,
803 };
804 use crate::report::test_helpers::sample_results;
805 use fallow_core::extract::MemberKind;
806 use fallow_core::results::*;
807 use std::path::PathBuf;
808 use std::time::Duration;
809
810 #[test]
811 fn json_output_has_metadata_fields() {
812 let root = PathBuf::from("/project");
813 let results = AnalysisResults::default();
814 let elapsed = Duration::from_millis(123);
815 let output = build_json(&results, &root, elapsed).expect("should serialize");
816
817 assert_eq!(output["schema_version"], 6);
818 assert!(output["version"].is_string());
819 assert_eq!(output["elapsed_ms"], 123);
820 assert_eq!(output["total_issues"], 0);
821 }
822
823 #[test]
824 fn json_output_includes_issue_arrays() {
825 let root = PathBuf::from("/project");
826 let results = sample_results(&root);
827 let elapsed = Duration::from_millis(50);
828 let output = build_json(&results, &root, elapsed).expect("should serialize");
829
830 assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
831 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
832 assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
833 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
834 assert_eq!(
835 output["unused_dev_dependencies"].as_array().unwrap().len(),
836 1
837 );
838 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
839 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
840 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
841 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
842 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
843 assert_eq!(
844 output["type_only_dependencies"].as_array().unwrap().len(),
845 1
846 );
847 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
848 }
849
850 #[test]
851 fn health_json_includes_runtime_coverage_with_relative_paths_and_actions() {
852 let root = PathBuf::from("/project");
853 let report = crate::health_types::HealthReport {
854 runtime_coverage: Some(RuntimeCoverageReport {
855 schema_version: RuntimeCoverageSchemaVersion::V1,
856 verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
857 signals: Vec::new(),
858 summary: RuntimeCoverageSummary {
859 data_source: RuntimeCoverageDataSource::Local,
860 last_received_at: None,
861 functions_tracked: 3,
862 functions_hit: 1,
863 functions_unhit: 1,
864 functions_untracked: 1,
865 coverage_percent: 33.3,
866 trace_count: 2_847_291,
867 period_days: 30,
868 deployments_seen: 14,
869 capture_quality: Some(crate::health_types::RuntimeCoverageCaptureQuality {
870 window_seconds: 720,
871 instances_observed: 1,
872 lazy_parse_warning: true,
873 untracked_ratio_percent: 42.5,
874 }),
875 },
876 findings: vec![RuntimeCoverageFinding {
877 id: "fallow:prod:deadbeef".to_owned(),
878 path: root.join("src/cold.ts"),
879 function: "coldPath".to_owned(),
880 line: 12,
881 verdict: RuntimeCoverageVerdict::ReviewRequired,
882 invocations: Some(0),
883 confidence: RuntimeCoverageConfidence::Medium,
884 evidence: RuntimeCoverageEvidence {
885 static_status: "used".to_owned(),
886 test_coverage: "not_covered".to_owned(),
887 v8_tracking: "tracked".to_owned(),
888 untracked_reason: None,
889 observation_days: 30,
890 deployments_observed: 14,
891 },
892 actions: vec![RuntimeCoverageAction {
893 kind: "review-deletion".to_owned(),
894 description: "Tracked in runtime coverage with zero invocations."
895 .to_owned(),
896 auto_fixable: false,
897 }],
898 }],
899 hot_paths: vec![RuntimeCoverageHotPath {
900 id: "fallow:hot:cafebabe".to_owned(),
901 path: root.join("src/hot.ts"),
902 function: "hotPath".to_owned(),
903 line: 3,
904 end_line: 9,
905 invocations: 250,
906 percentile: 99,
907 actions: vec![],
908 }],
909 blast_radius: vec![],
910 importance: vec![],
911 watermark: Some(RuntimeCoverageWatermark::LicenseExpiredGrace),
912 warnings: vec![RuntimeCoverageMessage {
913 code: "partial-merge".to_owned(),
914 message: "Merged coverage omitted one chunk.".to_owned(),
915 }],
916 }),
917 ..Default::default()
918 };
919
920 let envelope = HealthOutput {
921 schema_version: SchemaVersion(SCHEMA_VERSION),
922 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
923 elapsed_ms: ElapsedMs(7),
924 report,
925 grouped_by: None,
926 groups: None,
927 meta: None,
928 workspace_diagnostics: Vec::new(),
929 };
930 let mut output = serde_json::to_value(&envelope).expect("should serialize health envelope");
931 strip_root_prefix(&mut output, "/project/");
932
933 assert_eq!(
934 output["runtime_coverage"]["verdict"],
935 serde_json::Value::String("cold-code-detected".to_owned())
936 );
937 assert_eq!(
938 output["runtime_coverage"]["schema_version"],
939 serde_json::Value::String("1".to_owned())
940 );
941 assert_eq!(
942 output["runtime_coverage"]["summary"]["functions_tracked"],
943 serde_json::Value::from(3)
944 );
945 assert_eq!(
946 output["runtime_coverage"]["summary"]["coverage_percent"],
947 serde_json::Value::from(33.3)
948 );
949 let finding = &output["runtime_coverage"]["findings"][0];
950 assert_eq!(finding["path"], "src/cold.ts");
951 assert_eq!(finding["verdict"], "review_required");
952 assert_eq!(finding["id"], "fallow:prod:deadbeef");
953 assert_eq!(finding["actions"][0]["type"], "review-deletion");
954 let hot_path = &output["runtime_coverage"]["hot_paths"][0];
955 assert_eq!(hot_path["path"], "src/hot.ts");
956 assert_eq!(hot_path["function"], "hotPath");
957 assert_eq!(hot_path["percentile"], 99);
958 assert_eq!(
959 output["runtime_coverage"]["watermark"],
960 serde_json::Value::String("license-expired-grace".to_owned())
961 );
962 assert_eq!(
963 output["runtime_coverage"]["warnings"][0]["code"],
964 serde_json::Value::String("partial-merge".to_owned())
965 );
966 }
967
968 #[test]
969 fn json_metadata_fields_appear_first() {
970 let root = PathBuf::from("/project");
971 let results = AnalysisResults::default();
972 let elapsed = Duration::from_millis(0);
973 let output = build_json(&results, &root, elapsed).expect("should serialize");
974 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
975 assert_eq!(keys[0], "schema_version");
976 assert_eq!(keys[1], "version");
977 assert_eq!(keys[2], "elapsed_ms");
978 assert_eq!(keys[3], "total_issues");
979 }
980
981 #[test]
982 fn json_total_issues_matches_results() {
983 let root = PathBuf::from("/project");
984 let results = sample_results(&root);
985 let total = results.total_issues();
986 let elapsed = Duration::from_millis(0);
987 let output = build_json(&results, &root, elapsed).expect("should serialize");
988
989 assert_eq!(output["total_issues"], total);
990 }
991
992 #[test]
993 fn json_unused_export_contains_expected_fields() {
994 let root = PathBuf::from("/project");
995 let mut results = AnalysisResults::default();
996 results
997 .unused_exports
998 .push(UnusedExportFinding::with_actions(UnusedExport {
999 path: root.join("src/utils.ts"),
1000 export_name: "helperFn".to_string(),
1001 is_type_only: false,
1002 line: 10,
1003 col: 4,
1004 span_start: 120,
1005 is_re_export: false,
1006 }));
1007 let elapsed = Duration::from_millis(0);
1008 let output = build_json(&results, &root, elapsed).expect("should serialize");
1009
1010 let export = &output["unused_exports"][0];
1011 assert_eq!(export["export_name"], "helperFn");
1012 assert_eq!(export["line"], 10);
1013 assert_eq!(export["col"], 4);
1014 assert_eq!(export["is_type_only"], false);
1015 assert_eq!(export["span_start"], 120);
1016 assert_eq!(export["is_re_export"], false);
1017 }
1018
1019 #[test]
1020 fn json_serializes_to_valid_json() {
1021 let root = PathBuf::from("/project");
1022 let results = sample_results(&root);
1023 let elapsed = Duration::from_millis(42);
1024 let output = build_json(&results, &root, elapsed).expect("should serialize");
1025
1026 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
1027 let reparsed: serde_json::Value =
1028 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
1029 assert_eq!(reparsed, output);
1030 }
1031
1032 #[test]
1035 fn json_empty_results_produce_valid_structure() {
1036 let root = PathBuf::from("/project");
1037 let results = AnalysisResults::default();
1038 let elapsed = Duration::from_millis(0);
1039 let output = build_json(&results, &root, elapsed).expect("should serialize");
1040
1041 assert_eq!(output["total_issues"], 0);
1042 assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
1043 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
1044 assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
1045 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
1046 assert_eq!(
1047 output["unused_dev_dependencies"].as_array().unwrap().len(),
1048 0
1049 );
1050 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
1051 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
1052 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
1053 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
1054 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
1055 assert_eq!(
1056 output["type_only_dependencies"].as_array().unwrap().len(),
1057 0
1058 );
1059 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
1060 }
1061
1062 #[test]
1063 fn json_empty_results_round_trips_through_string() {
1064 let root = PathBuf::from("/project");
1065 let results = AnalysisResults::default();
1066 let elapsed = Duration::from_millis(0);
1067 let output = build_json(&results, &root, elapsed).expect("should serialize");
1068
1069 let json_str = serde_json::to_string(&output).expect("should stringify");
1070 let reparsed: serde_json::Value =
1071 serde_json::from_str(&json_str).expect("should parse back");
1072 assert_eq!(reparsed["total_issues"], 0);
1073 }
1074
1075 #[test]
1078 fn json_paths_are_relative_to_root() {
1079 let root = PathBuf::from("/project");
1080 let mut results = AnalysisResults::default();
1081 results
1082 .unused_files
1083 .push(UnusedFileFinding::with_actions(UnusedFile {
1084 path: root.join("src/deep/nested/file.ts"),
1085 }));
1086 let elapsed = Duration::from_millis(0);
1087 let output = build_json(&results, &root, elapsed).expect("should serialize");
1088
1089 let path = output["unused_files"][0]["path"].as_str().unwrap();
1090 assert_eq!(path, "src/deep/nested/file.ts");
1091 assert!(!path.starts_with("/project"));
1092 }
1093
1094 #[test]
1095 fn json_strips_root_from_nested_locations() {
1096 let root = PathBuf::from("/project");
1097 let mut results = AnalysisResults::default();
1098 results
1099 .unlisted_dependencies
1100 .push(UnlistedDependencyFinding::with_actions(
1101 UnlistedDependency {
1102 package_name: "chalk".to_string(),
1103 imported_from: vec![ImportSite {
1104 path: root.join("src/cli.ts"),
1105 line: 2,
1106 col: 0,
1107 }],
1108 },
1109 ));
1110 let elapsed = Duration::from_millis(0);
1111 let output = build_json(&results, &root, elapsed).expect("should serialize");
1112
1113 let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
1114 .as_str()
1115 .unwrap();
1116 assert_eq!(site_path, "src/cli.ts");
1117 }
1118
1119 #[test]
1120 fn json_strips_root_from_duplicate_export_locations() {
1121 let root = PathBuf::from("/project");
1122 let mut results = AnalysisResults::default();
1123 results
1124 .duplicate_exports
1125 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1126 export_name: "Config".to_string(),
1127 locations: vec![
1128 DuplicateLocation {
1129 path: root.join("src/config.ts"),
1130 line: 15,
1131 col: 0,
1132 },
1133 DuplicateLocation {
1134 path: root.join("src/types.ts"),
1135 line: 30,
1136 col: 0,
1137 },
1138 ],
1139 }));
1140 let elapsed = Duration::from_millis(0);
1141 let output = build_json(&results, &root, elapsed).expect("should serialize");
1142
1143 let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
1144 .as_str()
1145 .unwrap();
1146 let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
1147 .as_str()
1148 .unwrap();
1149 assert_eq!(loc0, "src/config.ts");
1150 assert_eq!(loc1, "src/types.ts");
1151 }
1152
1153 #[test]
1154 fn json_strips_root_from_circular_dependency_files() {
1155 let root = PathBuf::from("/project");
1156 let mut results = AnalysisResults::default();
1157 results
1158 .circular_dependencies
1159 .push(CircularDependencyFinding::with_actions(
1160 CircularDependency {
1161 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1162 length: 2,
1163 line: 1,
1164 col: 0,
1165 is_cross_package: false,
1166 },
1167 ));
1168 let elapsed = Duration::from_millis(0);
1169 let output = build_json(&results, &root, elapsed).expect("should serialize");
1170
1171 let files = output["circular_dependencies"][0]["files"]
1172 .as_array()
1173 .unwrap();
1174 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
1175 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
1176 }
1177
1178 #[test]
1179 fn json_path_outside_root_not_stripped() {
1180 let root = PathBuf::from("/project");
1181 let mut results = AnalysisResults::default();
1182 results
1183 .unused_files
1184 .push(UnusedFileFinding::with_actions(UnusedFile {
1185 path: PathBuf::from("/other/project/src/file.ts"),
1186 }));
1187 let elapsed = Duration::from_millis(0);
1188 let output = build_json(&results, &root, elapsed).expect("should serialize");
1189
1190 let path = output["unused_files"][0]["path"].as_str().unwrap();
1191 assert!(path.contains("/other/project/"));
1192 }
1193
1194 #[test]
1197 fn json_unused_file_contains_path() {
1198 let root = PathBuf::from("/project");
1199 let mut results = AnalysisResults::default();
1200 results
1201 .unused_files
1202 .push(UnusedFileFinding::with_actions(UnusedFile {
1203 path: root.join("src/orphan.ts"),
1204 }));
1205 let elapsed = Duration::from_millis(0);
1206 let output = build_json(&results, &root, elapsed).expect("should serialize");
1207
1208 let file = &output["unused_files"][0];
1209 assert_eq!(file["path"], "src/orphan.ts");
1210 }
1211
1212 #[test]
1213 fn json_unused_type_contains_expected_fields() {
1214 let root = PathBuf::from("/project");
1215 let mut results = AnalysisResults::default();
1216 results
1217 .unused_types
1218 .push(UnusedTypeFinding::with_actions(UnusedExport {
1219 path: root.join("src/types.ts"),
1220 export_name: "OldInterface".to_string(),
1221 is_type_only: true,
1222 line: 20,
1223 col: 0,
1224 span_start: 300,
1225 is_re_export: false,
1226 }));
1227 let elapsed = Duration::from_millis(0);
1228 let output = build_json(&results, &root, elapsed).expect("should serialize");
1229
1230 let typ = &output["unused_types"][0];
1231 assert_eq!(typ["export_name"], "OldInterface");
1232 assert_eq!(typ["is_type_only"], true);
1233 assert_eq!(typ["line"], 20);
1234 assert_eq!(typ["path"], "src/types.ts");
1235 }
1236
1237 #[test]
1238 fn json_unused_dependency_contains_expected_fields() {
1239 let root = PathBuf::from("/project");
1240 let mut results = AnalysisResults::default();
1241 results
1242 .unused_dependencies
1243 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1244 package_name: "axios".to_string(),
1245 location: DependencyLocation::Dependencies,
1246 path: root.join("package.json"),
1247 line: 10,
1248 used_in_workspaces: Vec::new(),
1249 }));
1250 let elapsed = Duration::from_millis(0);
1251 let output = build_json(&results, &root, elapsed).expect("should serialize");
1252
1253 let dep = &output["unused_dependencies"][0];
1254 assert_eq!(dep["package_name"], "axios");
1255 assert_eq!(dep["line"], 10);
1256 assert!(dep.get("used_in_workspaces").is_none());
1257 }
1258
1259 #[test]
1260 fn json_unused_dependency_includes_cross_workspace_context() {
1261 let root = PathBuf::from("/project");
1262 let mut results = AnalysisResults::default();
1263 results
1264 .unused_dependencies
1265 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1266 package_name: "lodash-es".to_string(),
1267 location: DependencyLocation::Dependencies,
1268 path: root.join("packages/shared/package.json"),
1269 line: 6,
1270 used_in_workspaces: vec![root.join("packages/consumer")],
1271 }));
1272 let elapsed = Duration::from_millis(0);
1273 let output = build_json(&results, &root, elapsed).expect("should serialize");
1274
1275 let dep = &output["unused_dependencies"][0];
1276 assert_eq!(
1277 dep["used_in_workspaces"],
1278 serde_json::json!(["packages/consumer"])
1279 );
1280 }
1281
1282 #[test]
1283 fn json_unused_dev_dependency_contains_expected_fields() {
1284 let root = PathBuf::from("/project");
1285 let mut results = AnalysisResults::default();
1286 results
1287 .unused_dev_dependencies
1288 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1289 package_name: "vitest".to_string(),
1290 location: DependencyLocation::DevDependencies,
1291 path: root.join("package.json"),
1292 line: 15,
1293 used_in_workspaces: Vec::new(),
1294 }));
1295 let elapsed = Duration::from_millis(0);
1296 let output = build_json(&results, &root, elapsed).expect("should serialize");
1297
1298 let dep = &output["unused_dev_dependencies"][0];
1299 assert_eq!(dep["package_name"], "vitest");
1300 }
1301
1302 #[test]
1303 fn json_unused_optional_dependency_contains_expected_fields() {
1304 let root = PathBuf::from("/project");
1305 let mut results = AnalysisResults::default();
1306 results
1307 .unused_optional_dependencies
1308 .push(UnusedOptionalDependencyFinding::with_actions(
1309 UnusedDependency {
1310 package_name: "fsevents".to_string(),
1311 location: DependencyLocation::OptionalDependencies,
1312 path: root.join("package.json"),
1313 line: 12,
1314 used_in_workspaces: Vec::new(),
1315 },
1316 ));
1317 let elapsed = Duration::from_millis(0);
1318 let output = build_json(&results, &root, elapsed).expect("should serialize");
1319
1320 let dep = &output["unused_optional_dependencies"][0];
1321 assert_eq!(dep["package_name"], "fsevents");
1322 assert_eq!(output["total_issues"], 1);
1323 }
1324
1325 #[test]
1326 fn json_unused_enum_member_contains_expected_fields() {
1327 let root = PathBuf::from("/project");
1328 let mut results = AnalysisResults::default();
1329 results
1330 .unused_enum_members
1331 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1332 path: root.join("src/enums.ts"),
1333 parent_name: "Color".to_string(),
1334 member_name: "Purple".to_string(),
1335 kind: MemberKind::EnumMember,
1336 line: 5,
1337 col: 2,
1338 }));
1339 let elapsed = Duration::from_millis(0);
1340 let output = build_json(&results, &root, elapsed).expect("should serialize");
1341
1342 let member = &output["unused_enum_members"][0];
1343 assert_eq!(member["parent_name"], "Color");
1344 assert_eq!(member["member_name"], "Purple");
1345 assert_eq!(member["line"], 5);
1346 assert_eq!(member["path"], "src/enums.ts");
1347 }
1348
1349 #[test]
1350 fn json_unused_class_member_contains_expected_fields() {
1351 let root = PathBuf::from("/project");
1352 let mut results = AnalysisResults::default();
1353 results
1354 .unused_class_members
1355 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
1356 path: root.join("src/api.ts"),
1357 parent_name: "ApiClient".to_string(),
1358 member_name: "deprecatedFetch".to_string(),
1359 kind: MemberKind::ClassMethod,
1360 line: 100,
1361 col: 4,
1362 }));
1363 let elapsed = Duration::from_millis(0);
1364 let output = build_json(&results, &root, elapsed).expect("should serialize");
1365
1366 let member = &output["unused_class_members"][0];
1367 assert_eq!(member["parent_name"], "ApiClient");
1368 assert_eq!(member["member_name"], "deprecatedFetch");
1369 assert_eq!(member["line"], 100);
1370 }
1371
1372 #[test]
1373 fn json_unresolved_import_contains_expected_fields() {
1374 let root = PathBuf::from("/project");
1375 let mut results = AnalysisResults::default();
1376 results
1377 .unresolved_imports
1378 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1379 path: root.join("src/app.ts"),
1380 specifier: "@acme/missing-pkg".to_string(),
1381 line: 7,
1382 col: 0,
1383 specifier_col: 0,
1384 }));
1385 let elapsed = Duration::from_millis(0);
1386 let output = build_json(&results, &root, elapsed).expect("should serialize");
1387
1388 let import = &output["unresolved_imports"][0];
1389 assert_eq!(import["specifier"], "@acme/missing-pkg");
1390 assert_eq!(import["line"], 7);
1391 assert_eq!(import["path"], "src/app.ts");
1392 }
1393
1394 #[test]
1395 fn json_unlisted_dependency_contains_import_sites() {
1396 let root = PathBuf::from("/project");
1397 let mut results = AnalysisResults::default();
1398 results
1399 .unlisted_dependencies
1400 .push(UnlistedDependencyFinding::with_actions(
1401 UnlistedDependency {
1402 package_name: "dotenv".to_string(),
1403 imported_from: vec![
1404 ImportSite {
1405 path: root.join("src/config.ts"),
1406 line: 1,
1407 col: 0,
1408 },
1409 ImportSite {
1410 path: root.join("src/server.ts"),
1411 line: 3,
1412 col: 0,
1413 },
1414 ],
1415 },
1416 ));
1417 let elapsed = Duration::from_millis(0);
1418 let output = build_json(&results, &root, elapsed).expect("should serialize");
1419
1420 let dep = &output["unlisted_dependencies"][0];
1421 assert_eq!(dep["package_name"], "dotenv");
1422 let sites = dep["imported_from"].as_array().unwrap();
1423 assert_eq!(sites.len(), 2);
1424 assert_eq!(sites[0]["path"], "src/config.ts");
1425 assert_eq!(sites[1]["path"], "src/server.ts");
1426 }
1427
1428 #[test]
1429 fn json_duplicate_export_contains_locations() {
1430 let root = PathBuf::from("/project");
1431 let mut results = AnalysisResults::default();
1432 results
1433 .duplicate_exports
1434 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1435 export_name: "Button".to_string(),
1436 locations: vec![
1437 DuplicateLocation {
1438 path: root.join("src/ui.ts"),
1439 line: 10,
1440 col: 0,
1441 },
1442 DuplicateLocation {
1443 path: root.join("src/components.ts"),
1444 line: 25,
1445 col: 0,
1446 },
1447 ],
1448 }));
1449 let elapsed = Duration::from_millis(0);
1450 let output = build_json(&results, &root, elapsed).expect("should serialize");
1451
1452 let dup = &output["duplicate_exports"][0];
1453 assert_eq!(dup["export_name"], "Button");
1454 let locs = dup["locations"].as_array().unwrap();
1455 assert_eq!(locs.len(), 2);
1456 assert_eq!(locs[0]["line"], 10);
1457 assert_eq!(locs[1]["line"], 25);
1458 }
1459
1460 #[test]
1461 fn duplicate_export_add_to_config_is_auto_fixable_when_config_exists() {
1462 let dir = tempfile::tempdir().unwrap();
1463 let root = dir.path();
1464 std::fs::write(root.join(".fallowrc.json"), "{}\n").unwrap();
1465 let mut results = AnalysisResults::default();
1466 results
1467 .duplicate_exports
1468 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1469 export_name: "Button".to_string(),
1470 locations: vec![
1471 DuplicateLocation {
1472 path: root.join("src/ui.ts"),
1473 line: 10,
1474 col: 0,
1475 },
1476 DuplicateLocation {
1477 path: root.join("src/components.ts"),
1478 line: 25,
1479 col: 0,
1480 },
1481 ],
1482 }));
1483
1484 let output = build_json(&results, root, Duration::ZERO).unwrap();
1485 let actions = output["duplicate_exports"][0]["actions"]
1486 .as_array()
1487 .unwrap();
1488 assert_eq!(actions[0]["type"], "add-to-config");
1489 assert_eq!(actions[0]["auto_fixable"], true);
1490 }
1491
1492 #[test]
1493 fn duplicate_export_add_to_config_is_auto_fixable_when_create_fallback_allowed() {
1494 let dir = tempfile::tempdir().unwrap();
1499 let root = dir.path();
1500 let mut results = AnalysisResults::default();
1501 results
1502 .duplicate_exports
1503 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1504 export_name: "Button".to_string(),
1505 locations: vec![
1506 DuplicateLocation {
1507 path: root.join("src/ui.ts"),
1508 line: 10,
1509 col: 0,
1510 },
1511 DuplicateLocation {
1512 path: root.join("src/components.ts"),
1513 line: 25,
1514 col: 0,
1515 },
1516 ],
1517 }));
1518
1519 let output = build_json(&results, root, Duration::ZERO).unwrap();
1520 let actions = output["duplicate_exports"][0]["actions"]
1521 .as_array()
1522 .unwrap();
1523 assert_eq!(actions[0]["type"], "add-to-config");
1524 assert_eq!(actions[0]["auto_fixable"], true);
1525 }
1526
1527 #[test]
1528 fn duplicate_export_add_to_config_is_not_auto_fixable_in_monorepo_subpackage() {
1529 let dir = tempfile::tempdir().unwrap();
1534 let workspace = dir.path();
1535 std::fs::write(
1536 workspace.join("pnpm-workspace.yaml"),
1537 "packages:\n - 'packages/*'\n",
1538 )
1539 .unwrap();
1540 let sub = workspace.join("packages/ui");
1541 std::fs::create_dir_all(&sub).unwrap();
1542 let mut results = AnalysisResults::default();
1543 results
1544 .duplicate_exports
1545 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1546 export_name: "Button".to_string(),
1547 locations: vec![
1548 DuplicateLocation {
1549 path: sub.join("src/ui.ts"),
1550 line: 10,
1551 col: 0,
1552 },
1553 DuplicateLocation {
1554 path: sub.join("src/components.ts"),
1555 line: 25,
1556 col: 0,
1557 },
1558 ],
1559 }));
1560
1561 let output = build_json(&results, &sub, Duration::ZERO).unwrap();
1562 let actions = output["duplicate_exports"][0]["actions"]
1563 .as_array()
1564 .unwrap();
1565 assert_eq!(actions[0]["type"], "add-to-config");
1566 assert_eq!(actions[0]["auto_fixable"], false);
1567 }
1568
1569 #[test]
1570 fn json_type_only_dependency_contains_expected_fields() {
1571 let root = PathBuf::from("/project");
1572 let mut results = AnalysisResults::default();
1573 results
1574 .type_only_dependencies
1575 .push(TypeOnlyDependencyFinding::with_actions(
1576 TypeOnlyDependency {
1577 package_name: "zod".to_string(),
1578 path: root.join("package.json"),
1579 line: 8,
1580 },
1581 ));
1582 let elapsed = Duration::from_millis(0);
1583 let output = build_json(&results, &root, elapsed).expect("should serialize");
1584
1585 let dep = &output["type_only_dependencies"][0];
1586 assert_eq!(dep["package_name"], "zod");
1587 assert_eq!(dep["line"], 8);
1588 }
1589
1590 #[test]
1591 fn json_circular_dependency_contains_expected_fields() {
1592 let root = PathBuf::from("/project");
1593 let mut results = AnalysisResults::default();
1594 results
1595 .circular_dependencies
1596 .push(CircularDependencyFinding::with_actions(
1597 CircularDependency {
1598 files: vec![
1599 root.join("src/a.ts"),
1600 root.join("src/b.ts"),
1601 root.join("src/c.ts"),
1602 ],
1603 length: 3,
1604 line: 5,
1605 col: 0,
1606 is_cross_package: false,
1607 },
1608 ));
1609 let elapsed = Duration::from_millis(0);
1610 let output = build_json(&results, &root, elapsed).expect("should serialize");
1611
1612 let cycle = &output["circular_dependencies"][0];
1613 assert_eq!(cycle["length"], 3);
1614 assert_eq!(cycle["line"], 5);
1615 let files = cycle["files"].as_array().unwrap();
1616 assert_eq!(files.len(), 3);
1617 }
1618
1619 #[test]
1622 fn json_re_export_flagged_correctly() {
1623 let root = PathBuf::from("/project");
1624 let mut results = AnalysisResults::default();
1625 results
1626 .unused_exports
1627 .push(UnusedExportFinding::with_actions(UnusedExport {
1628 path: root.join("src/index.ts"),
1629 export_name: "reExported".to_string(),
1630 is_type_only: false,
1631 line: 1,
1632 col: 0,
1633 span_start: 0,
1634 is_re_export: true,
1635 }));
1636 let elapsed = Duration::from_millis(0);
1637 let output = build_json(&results, &root, elapsed).expect("should serialize");
1638
1639 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
1640 }
1641
1642 #[test]
1645 fn json_schema_version_is_pinned() {
1646 let root = PathBuf::from("/project");
1647 let results = AnalysisResults::default();
1648 let elapsed = Duration::from_millis(0);
1649 let output = build_json(&results, &root, elapsed).expect("should serialize");
1650
1651 assert_eq!(output["schema_version"], SCHEMA_VERSION);
1652 assert_eq!(output["schema_version"], 6);
1653 }
1654
1655 #[test]
1658 fn json_version_matches_cargo_pkg_version() {
1659 let root = PathBuf::from("/project");
1660 let results = AnalysisResults::default();
1661 let elapsed = Duration::from_millis(0);
1662 let output = build_json(&results, &root, elapsed).expect("should serialize");
1663
1664 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1665 }
1666
1667 #[test]
1670 fn json_elapsed_ms_zero_duration() {
1671 let root = PathBuf::from("/project");
1672 let results = AnalysisResults::default();
1673 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1674
1675 assert_eq!(output["elapsed_ms"], 0);
1676 }
1677
1678 #[test]
1679 fn json_elapsed_ms_large_duration() {
1680 let root = PathBuf::from("/project");
1681 let results = AnalysisResults::default();
1682 let elapsed = Duration::from_mins(2);
1683 let output = build_json(&results, &root, elapsed).expect("should serialize");
1684
1685 assert_eq!(output["elapsed_ms"], 120_000);
1686 }
1687
1688 #[test]
1689 fn json_elapsed_ms_sub_millisecond_truncated() {
1690 let root = PathBuf::from("/project");
1691 let results = AnalysisResults::default();
1692 let elapsed = Duration::from_micros(500);
1694 let output = build_json(&results, &root, elapsed).expect("should serialize");
1695
1696 assert_eq!(output["elapsed_ms"], 0);
1697 }
1698
1699 #[test]
1702 fn json_multiple_unused_files() {
1703 let root = PathBuf::from("/project");
1704 let mut results = AnalysisResults::default();
1705 results
1706 .unused_files
1707 .push(UnusedFileFinding::with_actions(UnusedFile {
1708 path: root.join("src/a.ts"),
1709 }));
1710 results
1711 .unused_files
1712 .push(UnusedFileFinding::with_actions(UnusedFile {
1713 path: root.join("src/b.ts"),
1714 }));
1715 results
1716 .unused_files
1717 .push(UnusedFileFinding::with_actions(UnusedFile {
1718 path: root.join("src/c.ts"),
1719 }));
1720 let elapsed = Duration::from_millis(0);
1721 let output = build_json(&results, &root, elapsed).expect("should serialize");
1722
1723 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1724 assert_eq!(output["total_issues"], 3);
1725 }
1726
1727 #[test]
1730 fn strip_root_prefix_on_string_value() {
1731 let mut value = serde_json::json!("/project/src/file.ts");
1732 strip_root_prefix(&mut value, "/project/");
1733 assert_eq!(value, "src/file.ts");
1734 }
1735
1736 #[test]
1737 fn strip_root_prefix_leaves_non_matching_string() {
1738 let mut value = serde_json::json!("/other/src/file.ts");
1739 strip_root_prefix(&mut value, "/project/");
1740 assert_eq!(value, "/other/src/file.ts");
1741 }
1742
1743 #[test]
1744 fn strip_root_prefix_recurses_into_arrays() {
1745 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1746 strip_root_prefix(&mut value, "/project/");
1747 assert_eq!(value[0], "a.ts");
1748 assert_eq!(value[1], "b.ts");
1749 assert_eq!(value[2], "/other/c.ts");
1750 }
1751
1752 #[test]
1753 fn strip_root_prefix_recurses_into_nested_objects() {
1754 let mut value = serde_json::json!({
1755 "outer": {
1756 "path": "/project/src/nested.ts"
1757 }
1758 });
1759 strip_root_prefix(&mut value, "/project/");
1760 assert_eq!(value["outer"]["path"], "src/nested.ts");
1761 }
1762
1763 #[test]
1764 fn strip_root_prefix_leaves_numbers_and_booleans() {
1765 let mut value = serde_json::json!({
1766 "line": 42,
1767 "is_type_only": false,
1768 "path": "/project/src/file.ts"
1769 });
1770 strip_root_prefix(&mut value, "/project/");
1771 assert_eq!(value["line"], 42);
1772 assert_eq!(value["is_type_only"], false);
1773 assert_eq!(value["path"], "src/file.ts");
1774 }
1775
1776 #[test]
1777 fn strip_root_prefix_normalizes_windows_separators() {
1778 let mut value = serde_json::json!(r"/project\src\file.ts");
1779 strip_root_prefix(&mut value, "/project/");
1780 assert_eq!(value, "src/file.ts");
1781 }
1782
1783 #[test]
1784 fn strip_root_prefix_handles_empty_string_after_strip() {
1785 let mut value = serde_json::json!("/project/");
1788 strip_root_prefix(&mut value, "/project/");
1789 assert_eq!(value, "");
1790 }
1791
1792 #[test]
1793 fn strip_root_prefix_deeply_nested_array_of_objects() {
1794 let mut value = serde_json::json!({
1795 "groups": [{
1796 "instances": [{
1797 "file": "/project/src/a.ts"
1798 }, {
1799 "file": "/project/src/b.ts"
1800 }]
1801 }]
1802 });
1803 strip_root_prefix(&mut value, "/project/");
1804 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1805 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1806 }
1807
1808 #[test]
1811 fn json_full_sample_results_total_issues_correct() {
1812 let root = PathBuf::from("/project");
1813 let results = sample_results(&root);
1814 let elapsed = Duration::from_millis(100);
1815 let output = build_json(&results, &root, elapsed).expect("should serialize");
1816
1817 assert_eq!(output["total_issues"], results.total_issues());
1823 }
1824
1825 #[test]
1826 fn json_full_sample_no_absolute_paths_in_output() {
1827 let root = PathBuf::from("/project");
1828 let results = sample_results(&root);
1829 let elapsed = Duration::from_millis(0);
1830 let output = build_json(&results, &root, elapsed).expect("should serialize");
1831
1832 let json_str = serde_json::to_string(&output).expect("should stringify");
1833 assert!(!json_str.contains("/project/src/"));
1835 assert!(!json_str.contains("/project/package.json"));
1836 }
1837
1838 #[test]
1841 fn json_output_is_deterministic() {
1842 let root = PathBuf::from("/project");
1843 let results = sample_results(&root);
1844 let elapsed = Duration::from_millis(50);
1845
1846 let output1 = build_json(&results, &root, elapsed).expect("first build");
1847 let output2 = build_json(&results, &root, elapsed).expect("second build");
1848
1849 assert_eq!(output1, output2);
1850 }
1851
1852 #[test]
1855 fn json_results_fields_do_not_shadow_metadata() {
1856 let root = PathBuf::from("/project");
1859 let results = AnalysisResults::default();
1860 let elapsed = Duration::from_millis(99);
1861 let output = build_json(&results, &root, elapsed).expect("should serialize");
1862
1863 assert_eq!(output["schema_version"], 6);
1865 assert_eq!(output["elapsed_ms"], 99);
1866 }
1867
1868 #[test]
1871 fn json_all_issue_type_arrays_present_in_empty_results() {
1872 let root = PathBuf::from("/project");
1873 let results = AnalysisResults::default();
1874 let elapsed = Duration::from_millis(0);
1875 let output = build_json(&results, &root, elapsed).expect("should serialize");
1876
1877 let expected_arrays = [
1878 "unused_files",
1879 "unused_exports",
1880 "unused_types",
1881 "unused_dependencies",
1882 "unused_dev_dependencies",
1883 "unused_optional_dependencies",
1884 "unused_enum_members",
1885 "unused_class_members",
1886 "unresolved_imports",
1887 "unlisted_dependencies",
1888 "duplicate_exports",
1889 "type_only_dependencies",
1890 "test_only_dependencies",
1891 "circular_dependencies",
1892 ];
1893 for key in &expected_arrays {
1894 assert!(
1895 output[key].is_array(),
1896 "expected '{key}' to be an array in JSON output"
1897 );
1898 }
1899 }
1900
1901 #[test]
1904 fn insert_meta_adds_key_to_object() {
1905 let mut output = serde_json::json!({ "foo": 1 });
1906 let meta = serde_json::json!({ "docs": "https://example.com" });
1907 insert_meta(&mut output, meta.clone());
1908 assert_eq!(output["_meta"], meta);
1909 }
1910
1911 #[test]
1912 fn insert_meta_noop_on_non_object() {
1913 let mut output = serde_json::json!([1, 2, 3]);
1914 let meta = serde_json::json!({ "docs": "https://example.com" });
1915 insert_meta(&mut output, meta);
1916 assert!(output.is_array());
1918 }
1919
1920 #[test]
1921 fn insert_meta_overwrites_existing_meta() {
1922 let mut output = serde_json::json!({ "_meta": "old" });
1923 let meta = serde_json::json!({ "new": true });
1924 insert_meta(&mut output, meta.clone());
1925 assert_eq!(output["_meta"], meta);
1926 }
1927
1928 #[test]
1931 fn strip_root_prefix_null_unchanged() {
1932 let mut value = serde_json::Value::Null;
1933 strip_root_prefix(&mut value, "/project/");
1934 assert!(value.is_null());
1935 }
1936
1937 #[test]
1940 fn strip_root_prefix_empty_string() {
1941 let mut value = serde_json::json!("");
1942 strip_root_prefix(&mut value, "/project/");
1943 assert_eq!(value, "");
1944 }
1945
1946 #[test]
1949 fn strip_root_prefix_mixed_types() {
1950 let mut value = serde_json::json!({
1951 "path": "/project/src/file.ts",
1952 "line": 42,
1953 "flag": true,
1954 "nested": {
1955 "items": ["/project/a.ts", 99, null, "/project/b.ts"],
1956 "deep": { "path": "/project/c.ts" }
1957 }
1958 });
1959 strip_root_prefix(&mut value, "/project/");
1960 assert_eq!(value["path"], "src/file.ts");
1961 assert_eq!(value["line"], 42);
1962 assert_eq!(value["flag"], true);
1963 assert_eq!(value["nested"]["items"][0], "a.ts");
1964 assert_eq!(value["nested"]["items"][1], 99);
1965 assert!(value["nested"]["items"][2].is_null());
1966 assert_eq!(value["nested"]["items"][3], "b.ts");
1967 assert_eq!(value["nested"]["deep"]["path"], "c.ts");
1968 }
1969
1970 #[test]
1973 fn json_check_meta_integrates_correctly() {
1974 let root = PathBuf::from("/project");
1975 let results = AnalysisResults::default();
1976 let elapsed = Duration::from_millis(0);
1977 let mut output = build_json(&results, &root, elapsed).expect("should serialize");
1978 insert_meta(&mut output, crate::explain::check_meta());
1979
1980 assert!(output["_meta"]["docs"].is_string());
1981 assert!(output["_meta"]["rules"].is_object());
1982 }
1983
1984 #[test]
1987 fn json_unused_member_kind_serialized() {
1988 let root = PathBuf::from("/project");
1989 let mut results = AnalysisResults::default();
1990 results
1991 .unused_enum_members
1992 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1993 path: root.join("src/enums.ts"),
1994 parent_name: "Color".to_string(),
1995 member_name: "Red".to_string(),
1996 kind: MemberKind::EnumMember,
1997 line: 3,
1998 col: 2,
1999 }));
2000 results
2001 .unused_class_members
2002 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
2003 path: root.join("src/class.ts"),
2004 parent_name: "Foo".to_string(),
2005 member_name: "bar".to_string(),
2006 kind: MemberKind::ClassMethod,
2007 line: 10,
2008 col: 4,
2009 }));
2010
2011 let elapsed = Duration::from_millis(0);
2012 let output = build_json(&results, &root, elapsed).expect("should serialize");
2013
2014 let enum_member = &output["unused_enum_members"][0];
2015 assert!(enum_member["kind"].is_string());
2016 let class_member = &output["unused_class_members"][0];
2017 assert!(class_member["kind"].is_string());
2018 }
2019
2020 #[test]
2023 fn json_unused_export_has_actions() {
2024 let root = PathBuf::from("/project");
2025 let mut results = AnalysisResults::default();
2026 results
2027 .unused_exports
2028 .push(UnusedExportFinding::with_actions(UnusedExport {
2029 path: root.join("src/utils.ts"),
2030 export_name: "helperFn".to_string(),
2031 is_type_only: false,
2032 line: 10,
2033 col: 4,
2034 span_start: 120,
2035 is_re_export: false,
2036 }));
2037 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2038
2039 let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2040 assert_eq!(actions.len(), 2);
2041
2042 assert_eq!(actions[0]["type"], "remove-export");
2044 assert_eq!(actions[0]["auto_fixable"], true);
2045 assert!(actions[0].get("note").is_none());
2046
2047 assert_eq!(actions[1]["type"], "suppress-line");
2049 assert_eq!(
2050 actions[1]["comment"],
2051 "// fallow-ignore-next-line unused-export"
2052 );
2053 }
2054
2055 #[test]
2056 fn json_same_line_findings_share_multi_kind_suppression_comment() {
2057 let root = PathBuf::from("/project");
2058 let mut results = AnalysisResults::default();
2059 results
2060 .unused_exports
2061 .push(UnusedExportFinding::with_actions(UnusedExport {
2062 path: root.join("src/api.ts"),
2063 export_name: "helperFn".to_string(),
2064 is_type_only: false,
2065 line: 10,
2066 col: 4,
2067 span_start: 120,
2068 is_re_export: false,
2069 }));
2070 results
2071 .unused_types
2072 .push(UnusedTypeFinding::with_actions(UnusedExport {
2073 path: root.join("src/api.ts"),
2074 export_name: "OldType".to_string(),
2075 is_type_only: true,
2076 line: 10,
2077 col: 0,
2078 span_start: 60,
2079 is_re_export: false,
2080 }));
2081 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2082
2083 let export_actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2084 let type_actions = output["unused_types"][0]["actions"].as_array().unwrap();
2085 assert_eq!(
2086 export_actions[1]["comment"],
2087 "// fallow-ignore-next-line unused-export, unused-type"
2088 );
2089 assert_eq!(
2090 type_actions[1]["comment"],
2091 "// fallow-ignore-next-line unused-export, unused-type"
2092 );
2093 }
2094
2095 #[test]
2096 fn audit_like_json_shares_suppression_comment_across_dead_code_and_complexity() {
2097 let mut output = serde_json::json!({
2098 "dead_code": {
2099 "unused_exports": [{
2100 "path": "src/main.ts",
2101 "line": 1,
2102 "actions": [
2103 { "type": "remove-export", "auto_fixable": true },
2104 {
2105 "type": "suppress-line",
2106 "auto_fixable": false,
2107 "comment": "// fallow-ignore-next-line unused-export"
2108 }
2109 ]
2110 }]
2111 },
2112 "complexity": {
2113 "findings": [{
2114 "path": "src/main.ts",
2115 "line": 1,
2116 "actions": [
2117 { "type": "refactor-function", "auto_fixable": false },
2118 {
2119 "type": "suppress-line",
2120 "auto_fixable": false,
2121 "comment": "// fallow-ignore-next-line complexity"
2122 }
2123 ]
2124 }]
2125 }
2126 });
2127
2128 harmonize_multi_kind_suppress_line_actions(&mut output);
2129
2130 assert_eq!(
2131 output["dead_code"]["unused_exports"][0]["actions"][1]["comment"],
2132 "// fallow-ignore-next-line unused-export, complexity"
2133 );
2134 assert_eq!(
2135 output["complexity"]["findings"][0]["actions"][1]["comment"],
2136 "// fallow-ignore-next-line unused-export, complexity"
2137 );
2138 }
2139
2140 #[test]
2141 fn json_unused_file_has_file_suppress_and_note() {
2142 let root = PathBuf::from("/project");
2143 let mut results = AnalysisResults::default();
2144 results
2145 .unused_files
2146 .push(UnusedFileFinding::with_actions(UnusedFile {
2147 path: root.join("src/dead.ts"),
2148 }));
2149 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2150
2151 let actions = output["unused_files"][0]["actions"].as_array().unwrap();
2152 assert_eq!(actions[0]["type"], "delete-file");
2153 assert_eq!(actions[0]["auto_fixable"], false);
2154 assert!(actions[0]["note"].is_string());
2155 assert_eq!(actions[1]["type"], "suppress-file");
2156 assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
2157 }
2158
2159 #[test]
2160 fn json_unused_dependency_has_config_suppress_with_package_name() {
2161 let root = PathBuf::from("/project");
2162 let mut results = AnalysisResults::default();
2163 results
2164 .unused_dependencies
2165 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2166 package_name: "lodash".to_string(),
2167 location: DependencyLocation::Dependencies,
2168 path: root.join("package.json"),
2169 line: 5,
2170 used_in_workspaces: Vec::new(),
2171 }));
2172 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2173
2174 let actions = output["unused_dependencies"][0]["actions"]
2175 .as_array()
2176 .unwrap();
2177 assert_eq!(actions[0]["type"], "remove-dependency");
2178 assert_eq!(actions[0]["auto_fixable"], true);
2179
2180 assert_eq!(actions[1]["type"], "add-to-config");
2182 assert_eq!(actions[1]["config_key"], "ignoreDependencies");
2183 assert_eq!(actions[1]["value"], "lodash");
2184 }
2185
2186 #[test]
2187 fn json_cross_workspace_dependency_is_not_auto_fixable() {
2188 let root = PathBuf::from("/project");
2189 let mut results = AnalysisResults::default();
2190 results
2191 .unused_dependencies
2192 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2193 package_name: "lodash-es".to_string(),
2194 location: DependencyLocation::Dependencies,
2195 path: root.join("packages/shared/package.json"),
2196 line: 5,
2197 used_in_workspaces: vec![root.join("packages/consumer")],
2198 }));
2199 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2200
2201 let actions = output["unused_dependencies"][0]["actions"]
2202 .as_array()
2203 .unwrap();
2204 assert_eq!(actions[0]["type"], "move-dependency");
2205 assert_eq!(actions[0]["auto_fixable"], false);
2206 assert!(
2207 actions[0]["note"]
2208 .as_str()
2209 .unwrap()
2210 .contains("will not remove")
2211 );
2212 assert_eq!(actions[1]["type"], "add-to-config");
2213 }
2214
2215 #[test]
2216 fn json_empty_results_have_no_actions_in_empty_arrays() {
2217 let root = PathBuf::from("/project");
2218 let results = AnalysisResults::default();
2219 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2220
2221 assert!(output["unused_exports"].as_array().unwrap().is_empty());
2223 assert!(output["unused_files"].as_array().unwrap().is_empty());
2224 }
2225
2226 #[test]
2227 fn json_all_issue_types_have_actions() {
2228 let root = PathBuf::from("/project");
2229 let results = sample_results(&root);
2230 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2231
2232 let issue_keys = [
2233 "unused_files",
2234 "unused_exports",
2235 "unused_types",
2236 "unused_dependencies",
2237 "unused_dev_dependencies",
2238 "unused_optional_dependencies",
2239 "unused_enum_members",
2240 "unused_class_members",
2241 "unresolved_imports",
2242 "unlisted_dependencies",
2243 "duplicate_exports",
2244 "type_only_dependencies",
2245 "test_only_dependencies",
2246 "circular_dependencies",
2247 ];
2248
2249 for key in &issue_keys {
2250 let arr = output[key].as_array().unwrap();
2251 if !arr.is_empty() {
2252 let actions = arr[0]["actions"].as_array();
2253 assert!(
2254 actions.is_some() && !actions.unwrap().is_empty(),
2255 "missing actions for {key}"
2256 );
2257 }
2258 }
2259 }
2260
2261 fn build_actions_for_finding_json(
2270 finding_json: serde_json::Value,
2271 opts: crate::health_types::HealthActionOptions,
2272 max_cyclomatic_threshold: u16,
2273 max_cognitive_threshold: u16,
2274 max_crap_threshold: f64,
2275 ) -> Vec<serde_json::Value> {
2276 let mut value = finding_json;
2277 if let Some(map) = value.as_object_mut() {
2282 map.entry("col".to_string())
2283 .or_insert(serde_json::Value::from(0_u32));
2284 map.entry("line_count".to_string())
2285 .or_insert(serde_json::Value::from(0_u32));
2286 map.entry("param_count".to_string())
2287 .or_insert(serde_json::Value::from(0_u8));
2288 map.entry("severity".to_string())
2289 .or_insert(serde_json::Value::String("moderate".to_string()));
2290 }
2291 let violation = synthesize_complexity_violation(&value);
2295 let ctx = crate::health_types::HealthActionContext {
2296 opts,
2297 max_cyclomatic_threshold,
2298 max_cognitive_threshold,
2299 max_crap_threshold,
2300 };
2301 let finding = crate::health_types::HealthFinding::with_actions(violation, &ctx);
2302 let serialized = serde_json::to_value(&finding).expect("serialize HealthFinding");
2303 serialized["actions"]
2304 .as_array()
2305 .cloned()
2306 .unwrap_or_default()
2307 }
2308
2309 fn synthesize_complexity_violation(
2314 value: &serde_json::Value,
2315 ) -> crate::health_types::ComplexityViolation {
2316 use crate::health_types::{
2317 CoverageSource, CoverageTier, ExceededThreshold, FindingSeverity,
2318 };
2319 let exceeded = match value["exceeded"].as_str().unwrap_or("crap") {
2320 "cyclomatic" => ExceededThreshold::Cyclomatic,
2321 "cognitive" => ExceededThreshold::Cognitive,
2322 "both" => ExceededThreshold::Both,
2323 "crap" => ExceededThreshold::Crap,
2324 "cyclomatic_crap" => ExceededThreshold::CyclomaticCrap,
2325 "cognitive_crap" => ExceededThreshold::CognitiveCrap,
2326 "all" => ExceededThreshold::All,
2327 other => panic!("unknown exceeded label: {other}"),
2328 };
2329 let severity = match value["severity"].as_str().unwrap_or("moderate") {
2330 "moderate" => FindingSeverity::Moderate,
2331 "high" => FindingSeverity::High,
2332 "critical" => FindingSeverity::Critical,
2333 other => panic!("unknown severity label: {other}"),
2334 };
2335 let coverage_tier = value
2336 .get("coverage_tier")
2337 .and_then(|v| v.as_str())
2338 .map(|t| match t {
2339 "none" => CoverageTier::None,
2340 "partial" => CoverageTier::Partial,
2341 "high" => CoverageTier::High,
2342 other => panic!("unknown coverage_tier label: {other}"),
2343 });
2344 let coverage_source =
2345 value
2346 .get("coverage_source")
2347 .and_then(|v| v.as_str())
2348 .map(|s| match s {
2349 "istanbul" => CoverageSource::Istanbul,
2350 "estimated" => CoverageSource::Estimated,
2351 "estimated_component_inherited" => CoverageSource::EstimatedComponentInherited,
2352 other => panic!("unknown coverage_source label: {other}"),
2353 });
2354 crate::health_types::ComplexityViolation {
2355 path: std::path::PathBuf::from(value["path"].as_str().unwrap_or("src/x.ts")),
2356 name: value["name"].as_str().unwrap_or("fn").to_string(),
2357 line: u32::try_from(value["line"].as_u64().unwrap_or(0)).unwrap_or(0),
2358 col: u32::try_from(value["col"].as_u64().unwrap_or(0)).unwrap_or(0),
2359 cyclomatic: u16::try_from(value["cyclomatic"].as_u64().unwrap_or(0)).unwrap_or(0),
2360 cognitive: u16::try_from(value["cognitive"].as_u64().unwrap_or(0)).unwrap_or(0),
2361 line_count: u32::try_from(value["line_count"].as_u64().unwrap_or(0)).unwrap_or(0),
2362 param_count: u8::try_from(value["param_count"].as_u64().unwrap_or(0)).unwrap_or(0),
2363 exceeded,
2364 severity,
2365 crap: value.get("crap").and_then(|v| v.as_f64()),
2366 coverage_pct: value.get("coverage_pct").and_then(|v| v.as_f64()),
2367 coverage_tier,
2368 coverage_source,
2369 inherited_from: value
2370 .get("inherited_from")
2371 .and_then(|v| v.as_str())
2372 .map(std::path::PathBuf::from),
2373 component_rollup: value.get("component_rollup").and_then(|v| {
2374 let map = v.as_object()?;
2375 Some(crate::health_types::ComponentRollup {
2376 component: map.get("component")?.as_str()?.to_string(),
2377 class_worst_function: map.get("class_worst_function")?.as_str()?.to_string(),
2378 class_cyclomatic: u16::try_from(map.get("class_cyclomatic")?.as_u64()?).ok()?,
2379 class_cognitive: u16::try_from(map.get("class_cognitive")?.as_u64()?).ok()?,
2380 template_path: std::path::PathBuf::from(map.get("template_path")?.as_str()?),
2381 template_cyclomatic: u16::try_from(map.get("template_cyclomatic")?.as_u64()?)
2382 .ok()?,
2383 template_cognitive: u16::try_from(map.get("template_cognitive")?.as_u64()?)
2384 .ok()?,
2385 })
2386 }),
2387 }
2388 }
2389
2390 #[test]
2391 fn health_finding_has_actions() {
2392 let actions = build_actions_for_finding_json(
2393 serde_json::json!({
2394 "path": "src/utils.ts",
2395 "name": "processData",
2396 "line": 10,
2397 "col": 0,
2398 "cyclomatic": 25,
2399 "cognitive": 30,
2400 "line_count": 150,
2401 "exceeded": "both"
2402 }),
2403 crate::health_types::HealthActionOptions::default(),
2404 20,
2405 15,
2406 30.0,
2407 );
2408
2409 assert_eq!(actions.len(), 2);
2410 assert_eq!(actions[0]["type"], "refactor-function");
2411 assert_eq!(actions[0]["auto_fixable"], false);
2412 assert!(
2413 actions[0]["description"]
2414 .as_str()
2415 .unwrap()
2416 .contains("processData")
2417 );
2418 assert_eq!(actions[1]["type"], "suppress-line");
2419 assert_eq!(
2420 actions[1]["comment"],
2421 "// fallow-ignore-next-line complexity"
2422 );
2423 }
2424
2425 #[test]
2433 fn health_finding_suppress_has_placement() {
2434 let actions = build_actions_for_finding_json(
2435 serde_json::json!({
2436 "path": "src/utils.ts",
2437 "name": "processData",
2438 "line": 10,
2439 "col": 0,
2440 "cyclomatic": 25,
2441 "cognitive": 30,
2442 "line_count": 150,
2443 "exceeded": "both"
2444 }),
2445 crate::health_types::HealthActionOptions::default(),
2446 20,
2447 15,
2448 30.0,
2449 );
2450
2451 assert_eq!(actions[1]["placement"], "above-function-declaration");
2452 }
2453
2454 #[test]
2455 fn html_template_health_finding_uses_html_suppression() {
2456 let actions = build_actions_for_finding_json(
2457 serde_json::json!({
2458 "path": "src/app.component.html",
2459 "name": "<template>",
2460 "line": 1,
2461 "col": 0,
2462 "cyclomatic": 25,
2463 "cognitive": 30,
2464 "line_count": 40,
2465 "exceeded": "both"
2466 }),
2467 crate::health_types::HealthActionOptions::default(),
2468 20,
2469 15,
2470 30.0,
2471 );
2472
2473 let suppress = &actions[1];
2474 assert_eq!(suppress["type"], "suppress-file");
2475 assert_eq!(
2476 suppress["comment"],
2477 "<!-- fallow-ignore-file complexity -->"
2478 );
2479 assert_eq!(suppress["placement"], "top-of-template");
2480 }
2481
2482 #[test]
2483 fn inline_template_health_finding_uses_decorator_suppression() {
2484 let actions = build_actions_for_finding_json(
2485 serde_json::json!({
2486 "path": "src/app.component.ts",
2487 "name": "<template>",
2488 "line": 5,
2489 "col": 0,
2490 "cyclomatic": 25,
2491 "cognitive": 30,
2492 "line_count": 40,
2493 "exceeded": "both"
2494 }),
2495 crate::health_types::HealthActionOptions::default(),
2496 20,
2497 15,
2498 30.0,
2499 );
2500
2501 let refactor = &actions[0];
2502 assert_eq!(refactor["type"], "refactor-function");
2503 assert!(
2504 refactor["description"]
2505 .as_str()
2506 .unwrap()
2507 .contains("template complexity")
2508 );
2509 let suppress = &actions[1];
2510 assert_eq!(suppress["type"], "suppress-line");
2511 assert_eq!(
2512 suppress["description"],
2513 "Suppress with an inline comment above the Angular decorator"
2514 );
2515 assert_eq!(suppress["placement"], "above-angular-decorator");
2516 }
2517
2518 fn crap_only_finding_envelope(
2533 coverage_tier: Option<&str>,
2534 cyclomatic: u16,
2535 max_cyclomatic_threshold: u16,
2536 ) -> serde_json::Value {
2537 crap_only_finding_envelope_with_max_crap(
2538 coverage_tier,
2539 cyclomatic,
2540 12,
2541 max_cyclomatic_threshold,
2542 15,
2543 30.0,
2544 )
2545 }
2546
2547 fn crap_only_finding_envelope_with_cognitive(
2548 coverage_tier: Option<&str>,
2549 cyclomatic: u16,
2550 cognitive: u16,
2551 max_cyclomatic_threshold: u16,
2552 ) -> serde_json::Value {
2553 crap_only_finding_envelope_with_max_crap(
2554 coverage_tier,
2555 cyclomatic,
2556 cognitive,
2557 max_cyclomatic_threshold,
2558 15,
2559 30.0,
2560 )
2561 }
2562
2563 fn crap_only_finding_envelope_with_max_crap(
2571 coverage_tier: Option<&str>,
2572 cyclomatic: u16,
2573 cognitive: u16,
2574 max_cyclomatic_threshold: u16,
2575 max_cognitive_threshold: u16,
2576 max_crap_threshold: f64,
2577 ) -> serde_json::Value {
2578 build_finding_envelope_with_ctx(
2579 coverage_tier,
2580 cyclomatic,
2581 cognitive,
2582 max_cyclomatic_threshold,
2583 max_cognitive_threshold,
2584 max_crap_threshold,
2585 crate::health_types::HealthActionOptions::default(),
2586 )
2587 }
2588
2589 fn build_finding_envelope_with_ctx(
2593 coverage_tier: Option<&str>,
2594 cyclomatic: u16,
2595 cognitive: u16,
2596 max_cyclomatic_threshold: u16,
2597 max_cognitive_threshold: u16,
2598 max_crap_threshold: f64,
2599 action_opts: crate::health_types::HealthActionOptions,
2600 ) -> serde_json::Value {
2601 let tier = coverage_tier.map(|t| match t {
2602 "none" => crate::health_types::CoverageTier::None,
2603 "partial" => crate::health_types::CoverageTier::Partial,
2604 "high" => crate::health_types::CoverageTier::High,
2605 other => panic!("unknown coverage tier label: {other}"),
2606 });
2607 let violation = crate::health_types::ComplexityViolation {
2608 path: std::path::PathBuf::from("src/risk.ts"),
2609 name: "computeScore".to_string(),
2610 line: 12,
2611 col: 0,
2612 cyclomatic,
2613 cognitive,
2614 line_count: 40,
2615 param_count: 0,
2616 exceeded: crate::health_types::ExceededThreshold::Crap,
2617 severity: crate::health_types::FindingSeverity::Moderate,
2618 crap: Some(35.5),
2619 coverage_pct: None,
2620 coverage_tier: tier,
2621 coverage_source: None,
2622 inherited_from: None,
2623 component_rollup: None,
2624 };
2625 let ctx = crate::health_types::HealthActionContext {
2626 opts: action_opts,
2627 max_cyclomatic_threshold,
2628 max_cognitive_threshold,
2629 max_crap_threshold,
2630 };
2631 let finding = crate::health_types::HealthFinding::with_actions(violation, &ctx);
2632 let actions_meta = if action_opts.omit_suppress_line {
2633 Some(serde_json::json!({
2634 "suppression_hints_omitted": true,
2635 "reason": action_opts.omit_reason.unwrap_or("unspecified"),
2636 "scope": "health-findings",
2637 }))
2638 } else {
2639 None
2640 };
2641 let mut envelope = serde_json::json!({
2642 "findings": [serde_json::to_value(&finding).unwrap()],
2643 "summary": {
2644 "max_cyclomatic_threshold": max_cyclomatic_threshold,
2645 "max_cognitive_threshold": max_cognitive_threshold,
2646 "max_crap_threshold": max_crap_threshold,
2647 },
2648 });
2649 if let Some(meta) = actions_meta
2650 && let Some(map) = envelope.as_object_mut()
2651 {
2652 map.insert("actions_meta".to_string(), meta);
2653 }
2654 envelope
2655 }
2656
2657 #[test]
2658 fn crap_only_tier_none_emits_add_tests() {
2659 let output = crap_only_finding_envelope(Some("none"), 6, 20);
2660 let actions = output["findings"][0]["actions"].as_array().unwrap();
2661 assert!(
2662 actions.iter().any(|a| a["type"] == "add-tests"),
2663 "tier=none crap-only must emit add-tests, got {actions:?}"
2664 );
2665 assert!(
2666 !actions.iter().any(|a| a["type"] == "increase-coverage"),
2667 "tier=none must not emit increase-coverage"
2668 );
2669 }
2670
2671 #[test]
2672 fn crap_only_tier_partial_emits_increase_coverage() {
2673 let output = crap_only_finding_envelope(Some("partial"), 6, 20);
2674 let actions = output["findings"][0]["actions"].as_array().unwrap();
2675 assert!(
2676 actions.iter().any(|a| a["type"] == "increase-coverage"),
2677 "tier=partial crap-only must emit increase-coverage, got {actions:?}"
2678 );
2679 assert!(
2680 !actions.iter().any(|a| a["type"] == "add-tests"),
2681 "tier=partial must not emit add-tests"
2682 );
2683 }
2684
2685 #[test]
2686 fn crap_only_tier_high_emits_increase_coverage_when_full_coverage_can_clear_crap() {
2687 let output = crap_only_finding_envelope(Some("high"), 20, 30);
2691 let actions = output["findings"][0]["actions"].as_array().unwrap();
2692 assert!(
2693 actions.iter().any(|a| a["type"] == "increase-coverage"),
2694 "tier=high crap-only must still emit increase-coverage when full coverage can clear CRAP, got {actions:?}"
2695 );
2696 assert!(
2697 !actions.iter().any(|a| a["type"] == "refactor-function"),
2698 "coverage-remediable crap-only findings should not get refactor-function unless near the cyclomatic threshold"
2699 );
2700 assert!(
2701 !actions.iter().any(|a| a["type"] == "add-tests"),
2702 "tier=high must not emit add-tests"
2703 );
2704 }
2705
2706 #[test]
2707 fn crap_only_emits_refactor_when_full_coverage_cannot_clear_crap() {
2708 let output = crap_only_finding_envelope_with_max_crap(Some("high"), 35, 12, 50, 15, 30.0);
2712 let actions = output["findings"][0]["actions"].as_array().unwrap();
2713 assert!(
2714 actions.iter().any(|a| a["type"] == "refactor-function"),
2715 "full-coverage-impossible CRAP-only finding must emit refactor-function, got {actions:?}"
2716 );
2717 assert!(
2718 !actions.iter().any(|a| a["type"] == "increase-coverage"),
2719 "must not emit increase-coverage when even 100% coverage cannot clear CRAP"
2720 );
2721 assert!(
2722 !actions.iter().any(|a| a["type"] == "add-tests"),
2723 "must not emit add-tests when even 100% coverage cannot clear CRAP"
2724 );
2725 }
2726
2727 #[test]
2728 fn crap_only_high_cc_appends_secondary_refactor() {
2729 let output = crap_only_finding_envelope(Some("none"), 16, 20);
2732 let actions = output["findings"][0]["actions"].as_array().unwrap();
2733 assert!(
2734 actions.iter().any(|a| a["type"] == "add-tests"),
2735 "near-threshold crap-only still emits the primary tier action"
2736 );
2737 assert!(
2738 actions.iter().any(|a| a["type"] == "refactor-function"),
2739 "near-threshold crap-only must also emit secondary refactor-function"
2740 );
2741 }
2742
2743 #[test]
2744 fn crap_only_far_below_threshold_no_secondary_refactor() {
2745 let output = crap_only_finding_envelope(Some("none"), 6, 20);
2747 let actions = output["findings"][0]["actions"].as_array().unwrap();
2748 assert!(
2749 !actions.iter().any(|a| a["type"] == "refactor-function"),
2750 "low-CC crap-only should not get a secondary refactor-function"
2751 );
2752 }
2753
2754 #[test]
2755 fn crap_only_near_threshold_low_cognitive_no_secondary_refactor() {
2756 let output = crap_only_finding_envelope_with_cognitive(Some("none"), 17, 2, 20);
2765 let actions = output["findings"][0]["actions"].as_array().unwrap();
2766 assert!(
2767 actions.iter().any(|a| a["type"] == "add-tests"),
2768 "primary tier action still emits"
2769 );
2770 assert!(
2771 !actions.iter().any(|a| a["type"] == "refactor-function"),
2772 "near-threshold CC with cognitive below floor must NOT emit secondary refactor (got {actions:?})"
2773 );
2774 }
2775
2776 #[test]
2777 fn crap_only_near_threshold_high_cognitive_emits_secondary_refactor() {
2778 let output = crap_only_finding_envelope_with_cognitive(Some("none"), 16, 10, 20);
2784 let actions = output["findings"][0]["actions"].as_array().unwrap();
2785 assert!(
2786 actions.iter().any(|a| a["type"] == "add-tests"),
2787 "primary tier action still emits"
2788 );
2789 assert!(
2790 actions.iter().any(|a| a["type"] == "refactor-function"),
2791 "near-threshold CC with cognitive above floor must emit secondary refactor (got {actions:?})"
2792 );
2793 }
2794
2795 #[test]
2796 fn cyclomatic_only_emits_only_refactor_function() {
2797 let actions = build_actions_for_finding_json(
2798 serde_json::json!({
2799 "path": "src/cyclo.ts",
2800 "name": "branchy",
2801 "line": 5,
2802 "col": 0,
2803 "cyclomatic": 25,
2804 "cognitive": 10,
2805 "line_count": 80,
2806 "exceeded": "cyclomatic",
2807 }),
2808 crate::health_types::HealthActionOptions::default(),
2809 20,
2810 15,
2811 30.0,
2812 );
2813 assert!(
2814 actions.iter().any(|a| a["type"] == "refactor-function"),
2815 "non-CRAP findings emit refactor-function"
2816 );
2817 assert!(
2818 !actions.iter().any(|a| a["type"] == "add-tests"),
2819 "non-CRAP findings must not emit add-tests"
2820 );
2821 assert!(
2822 !actions.iter().any(|a| a["type"] == "increase-coverage"),
2823 "non-CRAP findings must not emit increase-coverage"
2824 );
2825 }
2826
2827 #[test]
2830 fn suppress_line_omitted_when_baseline_active() {
2831 let output = build_finding_envelope_with_ctx(
2832 Some("none"),
2833 6,
2834 12,
2835 20,
2836 15,
2837 30.0,
2838 crate::health_types::HealthActionOptions {
2839 omit_suppress_line: true,
2840 omit_reason: Some("baseline-active"),
2841 },
2842 );
2843 let actions = output["findings"][0]["actions"].as_array().unwrap();
2844 assert!(
2845 !actions.iter().any(|a| a["type"] == "suppress-line"),
2846 "baseline-active must not emit suppress-line, got {actions:?}"
2847 );
2848 assert_eq!(
2849 output["actions_meta"]["suppression_hints_omitted"],
2850 serde_json::Value::Bool(true)
2851 );
2852 assert_eq!(output["actions_meta"]["reason"], "baseline-active");
2853 assert_eq!(output["actions_meta"]["scope"], "health-findings");
2854 }
2855
2856 #[test]
2857 fn suppress_line_omitted_when_config_disabled() {
2858 let output = build_finding_envelope_with_ctx(
2859 Some("none"),
2860 6,
2861 12,
2862 20,
2863 15,
2864 30.0,
2865 crate::health_types::HealthActionOptions {
2866 omit_suppress_line: true,
2867 omit_reason: Some("config-disabled"),
2868 },
2869 );
2870 assert_eq!(output["actions_meta"]["reason"], "config-disabled");
2871 }
2872
2873 #[test]
2874 fn suppress_line_emitted_by_default() {
2875 let output = crap_only_finding_envelope(Some("none"), 6, 20);
2876 let actions = output["findings"][0]["actions"].as_array().unwrap();
2877 assert!(
2878 actions.iter().any(|a| a["type"] == "suppress-line"),
2879 "default opts must emit suppress-line"
2880 );
2881 assert!(
2882 output.get("actions_meta").is_none(),
2883 "actions_meta must be absent when no omission occurred"
2884 );
2885 }
2886
2887 #[test]
2894 fn every_emitted_health_action_type_is_in_schema_enum() {
2895 let cases = [
2899 ("crap", Some("none"), 6_u16, 20_u16),
2901 ("crap", Some("partial"), 6, 20),
2902 ("crap", Some("high"), 12, 20),
2903 ("crap", Some("none"), 16, 20), ("cyclomatic", None, 25, 20),
2905 ("cognitive_crap", Some("partial"), 6, 20),
2906 ("all", Some("none"), 25, 20),
2907 ];
2908
2909 let mut emitted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2910 for (exceeded, tier, cc, max) in cases {
2911 let mut finding = serde_json::json!({
2912 "path": "src/x.ts",
2913 "name": "fn",
2914 "line": 1,
2915 "col": 0,
2916 "cyclomatic": cc,
2917 "cognitive": 5,
2918 "line_count": 10,
2919 "exceeded": exceeded,
2920 "crap": 35.0,
2921 });
2922 if let Some(t) = tier {
2923 finding["coverage_tier"] = serde_json::Value::String(t.to_owned());
2924 }
2925 let actions = build_actions_for_finding_json(
2926 finding,
2927 crate::health_types::HealthActionOptions::default(),
2928 max,
2929 15,
2930 30.0,
2931 );
2932 for action in &actions {
2933 if let Some(ty) = action["type"].as_str() {
2934 emitted.insert(ty.to_owned());
2935 }
2936 }
2937 }
2938
2939 let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2945 .join("..")
2946 .join("..")
2947 .join("docs")
2948 .join("output-schema.json");
2949 let raw = std::fs::read_to_string(&schema_path)
2950 .expect("docs/output-schema.json must be readable for the drift-guard test");
2951 let schema: serde_json::Value = serde_json::from_str(&raw).expect("schema parses");
2952 let type_field = &schema["definitions"]["HealthFindingAction"]["properties"]["type"];
2953 let type_def = if let Some(reference) = type_field.get("$ref").and_then(|r| r.as_str()) {
2954 let name = reference
2955 .strip_prefix("#/definitions/")
2956 .expect("HealthFindingAction.type $ref points into #/definitions/");
2957 &schema["definitions"][name]
2958 } else {
2959 type_field
2960 };
2961 let mut enum_values: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2962 if let Some(arr) = type_def.get("enum").and_then(|e| e.as_array()) {
2963 for v in arr {
2964 if let Some(s) = v.as_str() {
2965 enum_values.insert(s.to_owned());
2966 }
2967 }
2968 }
2969 if let Some(arr) = type_def.get("oneOf").and_then(|e| e.as_array()) {
2970 for branch in arr {
2971 if let Some(s) = branch.get("const").and_then(|c| c.as_str()) {
2972 enum_values.insert(s.to_owned());
2973 }
2974 }
2975 }
2976 assert!(
2977 !enum_values.is_empty(),
2978 "could not extract HealthFindingActionType variants from schema (neither `enum` nor `oneOf` with `const` branches)"
2979 );
2980
2981 for ty in &emitted {
2982 assert!(
2983 enum_values.contains(ty),
2984 "build_health_finding_actions emitted action type `{ty}` but \
2985 docs/output-schema.json HealthFindingAction.type enum does \
2986 not list it. Add it to the schema (and any downstream \
2987 typed consumers) when introducing a new action type."
2988 );
2989 }
2990 }
2991
2992 #[test]
3006 fn no_new_post_pass_helpers_in_json_rs() {
3007 const POST_PASS_ALLOW_LIST: &[(&str, &str)] = &[];
3008 let source_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3009 .join("src")
3010 .join("report")
3011 .join("json.rs");
3012 let source = std::fs::read_to_string(&source_path).expect(
3013 "crates/cli/src/report/json.rs must be readable for the post-pass drift-guard test",
3014 );
3015 let mut found: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3016 for line in source.lines() {
3017 if let Some(name) = extract_post_pass_fn_name(line) {
3018 found.insert(name.to_owned());
3019 }
3020 }
3021 let allow: std::collections::BTreeSet<&'static str> =
3022 POST_PASS_ALLOW_LIST.iter().map(|(name, _)| *name).collect();
3023 let unexpected: Vec<&str> = found
3024 .iter()
3025 .filter(|name| !allow.contains(name.as_str()))
3026 .map(String::as_str)
3027 .collect();
3028 let stale: Vec<&str> = allow
3029 .iter()
3030 .filter(|name| !found.contains(**name))
3031 .copied()
3032 .collect();
3033 assert!(
3034 unexpected.is_empty(),
3035 "new post-pass helper(s) defined in crates/cli/src/report/json.rs are not in \
3036 POST_PASS_ALLOW_LIST: {unexpected:?}.\n\
3037 The typed `serde(flatten)` envelope is the source of truth for `actions[]` on \
3038 every finding. If a new post-pass is genuinely needed, file a tracking issue, \
3039 add the entry to POST_PASS_ALLOW_LIST with the issue link as the reason, and \
3040 reference the issue in the PR body. See issue #412 for context."
3041 );
3042 assert!(
3043 stale.is_empty(),
3044 "stale entries in POST_PASS_ALLOW_LIST (function no longer defined in \
3045 crates/cli/src/report/json.rs): {stale:?}.\n\
3046 Remove them in the same commit that retired the function."
3047 );
3048 }
3049
3050 fn extract_post_pass_fn_name(line: &str) -> Option<&str> {
3055 let trimmed = line.trim_start();
3056 if trimmed.starts_with("//") {
3057 return None;
3058 }
3059 let mut rest = trimmed;
3060 if let Some(after) = rest.strip_prefix("pub") {
3061 let after = after.trim_start();
3062 rest = if let Some(after) = after.strip_prefix('(') {
3063 let close = after.find(')')?;
3064 after[close + 1..].trim_start()
3065 } else {
3066 after
3067 };
3068 }
3069 for prefix in ["async ", "const ", "unsafe "] {
3070 if let Some(after) = rest.strip_prefix(prefix) {
3071 rest = after.trim_start();
3072 }
3073 }
3074 let after_fn = rest.strip_prefix("fn ")?;
3075 let name_end = after_fn
3076 .find(|c: char| !c.is_alphanumeric() && c != '_')
3077 .unwrap_or(after_fn.len());
3078 let name = &after_fn[..name_end];
3079 if name.starts_with("inject_") || name.starts_with("augment_") {
3080 Some(name)
3081 } else {
3082 None
3083 }
3084 }
3085}