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