1use std::path::Path;
2use std::process::ExitCode;
3use std::time::Duration;
4
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::AnalysisResults;
7
8use super::{emit_json, normalize_uri};
9use crate::explain;
10use crate::report::grouping::{OwnershipResolver, ResultGroup};
11
12pub(super) fn print_json(
13 results: &AnalysisResults,
14 root: &Path,
15 elapsed: Duration,
16 explain: bool,
17 regression: Option<&crate::regression::RegressionOutcome>,
18 baseline_matched: Option<(usize, usize)>,
19) -> ExitCode {
20 match build_json(results, root, elapsed) {
21 Ok(mut output) => {
22 if let Some(outcome) = regression
23 && let serde_json::Value::Object(ref mut map) = output
24 {
25 map.insert("regression".to_string(), outcome.to_json());
26 }
27 if let Some((entries, matched)) = baseline_matched
28 && let serde_json::Value::Object(ref mut map) = output
29 {
30 map.insert(
31 "baseline".to_string(),
32 serde_json::json!({
33 "entries": entries,
34 "matched": matched,
35 }),
36 );
37 }
38 if explain {
39 insert_meta(&mut output, explain::check_meta());
40 }
41 emit_json(&output, "JSON")
42 }
43 Err(e) => {
44 eprintln!("Error: failed to serialize results: {e}");
45 ExitCode::from(2)
46 }
47 }
48}
49
50#[must_use]
56pub(super) fn print_grouped_json(
57 groups: &[ResultGroup],
58 original: &AnalysisResults,
59 root: &Path,
60 elapsed: Duration,
61 explain: bool,
62 resolver: &OwnershipResolver,
63) -> ExitCode {
64 let root_prefix = format!("{}/", root.display());
65
66 let group_values: Vec<serde_json::Value> = groups
67 .iter()
68 .filter_map(|group| {
69 let mut value = serde_json::to_value(&group.results).ok()?;
70 strip_root_prefix(&mut value, &root_prefix);
71 inject_actions(&mut value);
72
73 if let serde_json::Value::Object(ref mut map) = value {
74 let mut ordered = serde_json::Map::new();
76 ordered.insert("key".to_string(), serde_json::json!(group.key));
77 ordered.insert(
78 "total_issues".to_string(),
79 serde_json::json!(group.results.total_issues()),
80 );
81 for (k, v) in map.iter() {
82 ordered.insert(k.clone(), v.clone());
83 }
84 Some(serde_json::Value::Object(ordered))
85 } else {
86 Some(value)
87 }
88 })
89 .collect();
90
91 let mut output = serde_json::json!({
92 "schema_version": SCHEMA_VERSION,
93 "version": env!("CARGO_PKG_VERSION"),
94 "elapsed_ms": elapsed.as_millis() as u64,
95 "grouped_by": resolver.mode_label(),
96 "total_issues": original.total_issues(),
97 "groups": group_values,
98 });
99
100 if explain {
101 insert_meta(&mut output, explain::check_meta());
102 }
103
104 emit_json(&output, "JSON")
105}
106
107const SCHEMA_VERSION: u32 = 4;
113
114fn build_json_envelope(report_value: serde_json::Value, elapsed: Duration) -> serde_json::Value {
120 let mut map = serde_json::Map::new();
121 map.insert(
122 "schema_version".to_string(),
123 serde_json::json!(SCHEMA_VERSION),
124 );
125 map.insert(
126 "version".to_string(),
127 serde_json::json!(env!("CARGO_PKG_VERSION")),
128 );
129 map.insert(
130 "elapsed_ms".to_string(),
131 serde_json::json!(elapsed.as_millis()),
132 );
133 if let serde_json::Value::Object(report_map) = report_value {
134 for (key, value) in report_map {
135 map.insert(key, value);
136 }
137 }
138 serde_json::Value::Object(map)
139}
140
141pub fn build_json(
150 results: &AnalysisResults,
151 root: &Path,
152 elapsed: Duration,
153) -> Result<serde_json::Value, serde_json::Error> {
154 let results_value = serde_json::to_value(results)?;
155
156 let mut map = serde_json::Map::new();
157 map.insert(
158 "schema_version".to_string(),
159 serde_json::json!(SCHEMA_VERSION),
160 );
161 map.insert(
162 "version".to_string(),
163 serde_json::json!(env!("CARGO_PKG_VERSION")),
164 );
165 map.insert(
166 "elapsed_ms".to_string(),
167 serde_json::json!(elapsed.as_millis()),
168 );
169 map.insert(
170 "total_issues".to_string(),
171 serde_json::json!(results.total_issues()),
172 );
173
174 if let Some(ref ep) = results.entry_point_summary {
176 let sources: serde_json::Map<String, serde_json::Value> = ep
177 .by_source
178 .iter()
179 .map(|(k, v)| (k.replace(' ', "_"), serde_json::json!(v)))
180 .collect();
181 map.insert(
182 "entry_points".to_string(),
183 serde_json::json!({
184 "total": ep.total,
185 "sources": sources,
186 }),
187 );
188 }
189
190 let summary = serde_json::json!({
192 "total_issues": results.total_issues(),
193 "unused_files": results.unused_files.len(),
194 "unused_exports": results.unused_exports.len(),
195 "unused_types": results.unused_types.len(),
196 "unused_dependencies": results.unused_dependencies.len()
197 + results.unused_dev_dependencies.len()
198 + results.unused_optional_dependencies.len(),
199 "unused_enum_members": results.unused_enum_members.len(),
200 "unused_class_members": results.unused_class_members.len(),
201 "unresolved_imports": results.unresolved_imports.len(),
202 "unlisted_dependencies": results.unlisted_dependencies.len(),
203 "duplicate_exports": results.duplicate_exports.len(),
204 "type_only_dependencies": results.type_only_dependencies.len(),
205 "test_only_dependencies": results.test_only_dependencies.len(),
206 "circular_dependencies": results.circular_dependencies.len(),
207 "boundary_violations": results.boundary_violations.len(),
208 "stale_suppressions": results.stale_suppressions.len(),
209 });
210 map.insert("summary".to_string(), summary);
211
212 if let serde_json::Value::Object(results_map) = results_value {
213 for (key, value) in results_map {
214 map.insert(key, value);
215 }
216 }
217
218 let mut output = serde_json::Value::Object(map);
219 let root_prefix = format!("{}/", root.display());
220 strip_root_prefix(&mut output, &root_prefix);
224 inject_actions(&mut output);
225 Ok(output)
226}
227
228pub fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
233 match value {
234 serde_json::Value::String(s) => {
235 if let Some(rest) = s.strip_prefix(prefix) {
236 *s = rest.to_string();
237 } else {
238 let normalized = normalize_uri(s);
239 let normalized_prefix = normalize_uri(prefix);
240 if let Some(rest) = normalized.strip_prefix(&normalized_prefix) {
241 *s = rest.to_string();
242 }
243 }
244 }
245 serde_json::Value::Array(arr) => {
246 for item in arr {
247 strip_root_prefix(item, prefix);
248 }
249 }
250 serde_json::Value::Object(map) => {
251 for (_, v) in map.iter_mut() {
252 strip_root_prefix(v, prefix);
253 }
254 }
255 _ => {}
256 }
257}
258
259enum SuppressKind {
263 InlineComment,
265 FileComment,
267 ConfigIgnoreDep,
269}
270
271struct ActionSpec {
273 fix_type: &'static str,
274 auto_fixable: bool,
275 description: &'static str,
276 note: Option<&'static str>,
277 suppress: SuppressKind,
278 issue_kind: &'static str,
279}
280
281fn actions_for_issue_type(key: &str) -> Option<ActionSpec> {
283 match key {
284 "unused_files" => Some(ActionSpec {
285 fix_type: "delete-file",
286 auto_fixable: false,
287 description: "Delete this file",
288 note: Some(
289 "File deletion may remove runtime functionality not visible to static analysis",
290 ),
291 suppress: SuppressKind::FileComment,
292 issue_kind: "unused-file",
293 }),
294 "unused_exports" => Some(ActionSpec {
295 fix_type: "remove-export",
296 auto_fixable: true,
297 description: "Remove the `export` keyword from the declaration",
298 note: None,
299 suppress: SuppressKind::InlineComment,
300 issue_kind: "unused-export",
301 }),
302 "unused_types" => Some(ActionSpec {
303 fix_type: "remove-export",
304 auto_fixable: true,
305 description: "Remove the `export` (or `export type`) keyword from the type declaration",
306 note: None,
307 suppress: SuppressKind::InlineComment,
308 issue_kind: "unused-type",
309 }),
310 "unused_dependencies" => Some(ActionSpec {
311 fix_type: "remove-dependency",
312 auto_fixable: true,
313 description: "Remove from dependencies in package.json",
314 note: None,
315 suppress: SuppressKind::ConfigIgnoreDep,
316 issue_kind: "unused-dependency",
317 }),
318 "unused_dev_dependencies" => Some(ActionSpec {
319 fix_type: "remove-dependency",
320 auto_fixable: true,
321 description: "Remove from devDependencies in package.json",
322 note: None,
323 suppress: SuppressKind::ConfigIgnoreDep,
324 issue_kind: "unused-dev-dependency",
325 }),
326 "unused_optional_dependencies" => Some(ActionSpec {
327 fix_type: "remove-dependency",
328 auto_fixable: true,
329 description: "Remove from optionalDependencies in package.json",
330 note: None,
331 suppress: SuppressKind::ConfigIgnoreDep,
332 issue_kind: "unused-dependency",
334 }),
335 "unused_enum_members" => Some(ActionSpec {
336 fix_type: "remove-enum-member",
337 auto_fixable: true,
338 description: "Remove this enum member",
339 note: None,
340 suppress: SuppressKind::InlineComment,
341 issue_kind: "unused-enum-member",
342 }),
343 "unused_class_members" => Some(ActionSpec {
344 fix_type: "remove-class-member",
345 auto_fixable: false,
346 description: "Remove this class member",
347 note: Some("Class member may be used via dependency injection or decorators"),
348 suppress: SuppressKind::InlineComment,
349 issue_kind: "unused-class-member",
350 }),
351 "unresolved_imports" => Some(ActionSpec {
352 fix_type: "resolve-import",
353 auto_fixable: false,
354 description: "Fix the import specifier or install the missing module",
355 note: Some("Verify the module path and check tsconfig paths configuration"),
356 suppress: SuppressKind::InlineComment,
357 issue_kind: "unresolved-import",
358 }),
359 "unlisted_dependencies" => Some(ActionSpec {
360 fix_type: "install-dependency",
361 auto_fixable: false,
362 description: "Add this package to dependencies in package.json",
363 note: Some("Verify this package should be a direct dependency before adding"),
364 suppress: SuppressKind::ConfigIgnoreDep,
365 issue_kind: "unlisted-dependency",
366 }),
367 "duplicate_exports" => Some(ActionSpec {
368 fix_type: "remove-duplicate",
369 auto_fixable: false,
370 description: "Keep one canonical export location and remove the others",
371 note: Some("Review all locations to determine which should be the canonical export"),
372 suppress: SuppressKind::InlineComment,
373 issue_kind: "duplicate-export",
374 }),
375 "type_only_dependencies" => Some(ActionSpec {
376 fix_type: "move-to-dev",
377 auto_fixable: false,
378 description: "Move to devDependencies (only type imports are used)",
379 note: Some(
380 "Type imports are erased at runtime so this dependency is not needed in production",
381 ),
382 suppress: SuppressKind::ConfigIgnoreDep,
383 issue_kind: "type-only-dependency",
384 }),
385 "test_only_dependencies" => Some(ActionSpec {
386 fix_type: "move-to-dev",
387 auto_fixable: false,
388 description: "Move to devDependencies (only test files import this)",
389 note: Some(
390 "Only test files import this package so it does not need to be a production dependency",
391 ),
392 suppress: SuppressKind::ConfigIgnoreDep,
393 issue_kind: "test-only-dependency",
394 }),
395 "circular_dependencies" => Some(ActionSpec {
396 fix_type: "refactor-cycle",
397 auto_fixable: false,
398 description: "Extract shared logic into a separate module to break the cycle",
399 note: Some(
400 "Circular imports can cause initialization issues and make code harder to reason about",
401 ),
402 suppress: SuppressKind::InlineComment,
403 issue_kind: "circular-dependency",
404 }),
405 "boundary_violations" => Some(ActionSpec {
406 fix_type: "refactor-boundary",
407 auto_fixable: false,
408 description: "Move the import through an allowed zone or restructure the dependency",
409 note: Some(
410 "This import crosses an architecture boundary that is not permitted by the configured rules",
411 ),
412 suppress: SuppressKind::InlineComment,
413 issue_kind: "boundary-violation",
414 }),
415 _ => None,
416 }
417}
418
419fn build_actions(
421 item: &serde_json::Value,
422 issue_key: &str,
423 spec: &ActionSpec,
424) -> serde_json::Value {
425 let mut actions = Vec::with_capacity(2);
426
427 let mut fix_action = serde_json::json!({
429 "type": spec.fix_type,
430 "auto_fixable": spec.auto_fixable,
431 "description": spec.description,
432 });
433 if let Some(note) = spec.note {
434 fix_action["note"] = serde_json::json!(note);
435 }
436 if (issue_key == "unused_exports" || issue_key == "unused_types")
438 && item
439 .get("is_re_export")
440 .and_then(serde_json::Value::as_bool)
441 == Some(true)
442 {
443 fix_action["note"] = serde_json::json!(
444 "This finding originates from a re-export; verify it is not part of your public API before removing"
445 );
446 }
447 actions.push(fix_action);
448
449 match spec.suppress {
451 SuppressKind::InlineComment => {
452 let mut suppress = serde_json::json!({
453 "type": "suppress-line",
454 "auto_fixable": false,
455 "description": "Suppress with an inline comment above the line",
456 "comment": format!("// fallow-ignore-next-line {}", spec.issue_kind),
457 });
458 if issue_key == "duplicate_exports" {
460 suppress["scope"] = serde_json::json!("per-location");
461 }
462 actions.push(suppress);
463 }
464 SuppressKind::FileComment => {
465 actions.push(serde_json::json!({
466 "type": "suppress-file",
467 "auto_fixable": false,
468 "description": "Suppress with a file-level comment at the top of the file",
469 "comment": format!("// fallow-ignore-file {}", spec.issue_kind),
470 }));
471 }
472 SuppressKind::ConfigIgnoreDep => {
473 let pkg = item
475 .get("package_name")
476 .and_then(serde_json::Value::as_str)
477 .unwrap_or("package-name");
478 actions.push(serde_json::json!({
479 "type": "add-to-config",
480 "auto_fixable": false,
481 "description": format!("Add \"{pkg}\" to ignoreDependencies in fallow config"),
482 "config_key": "ignoreDependencies",
483 "value": pkg,
484 }));
485 }
486 }
487
488 serde_json::Value::Array(actions)
489}
490
491fn inject_actions(output: &mut serde_json::Value) {
496 let Some(map) = output.as_object_mut() else {
497 return;
498 };
499
500 for (key, value) in map.iter_mut() {
501 let Some(spec) = actions_for_issue_type(key) else {
502 continue;
503 };
504 let Some(arr) = value.as_array_mut() else {
505 continue;
506 };
507 for item in arr {
508 let actions = build_actions(item, key, &spec);
509 if let serde_json::Value::Object(obj) = item {
510 obj.insert("actions".to_string(), actions);
511 }
512 }
513 }
514}
515
516pub fn build_baseline_deltas_json<'a>(
524 total_delta: i64,
525 per_category: impl Iterator<Item = (&'a str, usize, usize, i64)>,
526) -> serde_json::Value {
527 let mut per_cat = serde_json::Map::new();
528 for (cat, current, baseline, delta) in per_category {
529 per_cat.insert(
530 cat.to_string(),
531 serde_json::json!({
532 "current": current,
533 "baseline": baseline,
534 "delta": delta,
535 }),
536 );
537 }
538 serde_json::json!({
539 "total_delta": total_delta,
540 "per_category": per_cat
541 })
542}
543
544#[allow(
549 clippy::redundant_pub_crate,
550 reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
551)]
552pub(crate) fn inject_health_actions(output: &mut serde_json::Value) {
553 let Some(map) = output.as_object_mut() else {
554 return;
555 };
556
557 if let Some(findings) = map.get_mut("findings").and_then(|v| v.as_array_mut()) {
559 for item in findings {
560 let actions = build_health_finding_actions(item);
561 if let serde_json::Value::Object(obj) = item {
562 obj.insert("actions".to_string(), actions);
563 }
564 }
565 }
566
567 if let Some(targets) = map.get_mut("targets").and_then(|v| v.as_array_mut()) {
569 for item in targets {
570 let actions = build_refactoring_target_actions(item);
571 if let serde_json::Value::Object(obj) = item {
572 obj.insert("actions".to_string(), actions);
573 }
574 }
575 }
576
577 if let Some(hotspots) = map.get_mut("hotspots").and_then(|v| v.as_array_mut()) {
579 for item in hotspots {
580 let actions = build_hotspot_actions(item);
581 if let serde_json::Value::Object(obj) = item {
582 obj.insert("actions".to_string(), actions);
583 }
584 }
585 }
586
587 if let Some(gaps) = map.get_mut("coverage_gaps").and_then(|v| v.as_object_mut()) {
589 if let Some(files) = gaps.get_mut("files").and_then(|v| v.as_array_mut()) {
590 for item in files {
591 let actions = build_untested_file_actions(item);
592 if let serde_json::Value::Object(obj) = item {
593 obj.insert("actions".to_string(), actions);
594 }
595 }
596 }
597 if let Some(exports) = gaps.get_mut("exports").and_then(|v| v.as_array_mut()) {
598 for item in exports {
599 let actions = build_untested_export_actions(item);
600 if let serde_json::Value::Object(obj) = item {
601 obj.insert("actions".to_string(), actions);
602 }
603 }
604 }
605 }
606
607 }
612
613fn build_health_finding_actions(item: &serde_json::Value) -> serde_json::Value {
615 let name = item
616 .get("name")
617 .and_then(serde_json::Value::as_str)
618 .unwrap_or("function");
619
620 let mut actions = vec![serde_json::json!({
621 "type": "refactor-function",
622 "auto_fixable": false,
623 "description": format!("Refactor `{name}` to reduce complexity (extract helper functions, simplify branching)"),
624 "note": "Consider splitting into smaller functions with single responsibilities",
625 })];
626
627 actions.push(serde_json::json!({
628 "type": "suppress-line",
629 "auto_fixable": false,
630 "description": "Suppress with an inline comment above the function declaration",
631 "comment": "// fallow-ignore-next-line complexity",
632 "placement": "above-function-declaration",
633 }));
634
635 serde_json::Value::Array(actions)
636}
637
638fn build_hotspot_actions(item: &serde_json::Value) -> serde_json::Value {
640 let path = item
641 .get("path")
642 .and_then(serde_json::Value::as_str)
643 .unwrap_or("file");
644
645 let mut actions = vec![
646 serde_json::json!({
647 "type": "refactor-file",
648 "auto_fixable": false,
649 "description": format!("Refactor `{path}`, high complexity combined with frequent changes makes this a maintenance risk"),
650 "note": "Prioritize extracting complex functions, adding tests, or splitting the module",
651 }),
652 serde_json::json!({
653 "type": "add-tests",
654 "auto_fixable": false,
655 "description": format!("Add test coverage for `{path}` to reduce change risk"),
656 "note": "Frequently changed complex files benefit most from comprehensive test coverage",
657 }),
658 ];
659
660 if let Some(ownership) = item.get("ownership") {
661 if ownership
663 .get("bus_factor")
664 .and_then(serde_json::Value::as_u64)
665 == Some(1)
666 {
667 let top = ownership.get("top_contributor");
668 let owner = top
669 .and_then(|t| t.get("identifier"))
670 .and_then(serde_json::Value::as_str)
671 .unwrap_or("the sole contributor");
672 let commits = top
677 .and_then(|t| t.get("commits"))
678 .and_then(serde_json::Value::as_u64)
679 .unwrap_or(0);
680 let suggested: Vec<String> = ownership
686 .get("suggested_reviewers")
687 .and_then(serde_json::Value::as_array)
688 .map(|arr| {
689 arr.iter()
690 .filter_map(|r| {
691 r.get("identifier")
692 .and_then(serde_json::Value::as_str)
693 .map(String::from)
694 })
695 .collect()
696 })
697 .unwrap_or_default();
698 let mut low_bus_action = serde_json::json!({
699 "type": "low-bus-factor",
700 "auto_fixable": false,
701 "description": format!(
702 "{owner} is the sole recent contributor to `{path}`; adding a second reviewer reduces knowledge-loss risk"
703 ),
704 });
705 if !suggested.is_empty() {
706 let list = suggested
707 .iter()
708 .map(|s| format!("@{s}"))
709 .collect::<Vec<_>>()
710 .join(", ");
711 low_bus_action["note"] =
712 serde_json::Value::String(format!("Candidate reviewers: {list}"));
713 } else if commits < 5 {
714 low_bus_action["note"] = serde_json::Value::String(
715 "Single recent contributor on a low-commit file. Consider a pair review for major changes."
716 .to_string(),
717 );
718 }
719 actions.push(low_bus_action);
721 }
722
723 if ownership
726 .get("unowned")
727 .and_then(serde_json::Value::as_bool)
728 == Some(true)
729 {
730 actions.push(serde_json::json!({
731 "type": "unowned-hotspot",
732 "auto_fixable": false,
733 "description": format!("Add a CODEOWNERS entry for `{path}`"),
734 "note": "Frequently-changed files without declared owners create review bottlenecks",
735 "suggested_pattern": suggest_codeowners_pattern(path),
736 "heuristic": "directory-deepest",
737 }));
738 }
739
740 if ownership.get("drift").and_then(serde_json::Value::as_bool) == Some(true) {
743 let reason = ownership
744 .get("drift_reason")
745 .and_then(serde_json::Value::as_str)
746 .unwrap_or("ownership has shifted from the original author");
747 actions.push(serde_json::json!({
748 "type": "ownership-drift",
749 "auto_fixable": false,
750 "description": format!("Update CODEOWNERS for `{path}`: {reason}"),
751 "note": "Drift suggests the declared or original owner is no longer the right reviewer",
752 }));
753 }
754 }
755
756 serde_json::Value::Array(actions)
757}
758
759fn suggest_codeowners_pattern(path: &str) -> String {
772 let normalized = path.replace('\\', "/");
773 let trimmed = normalized.trim_start_matches('/');
774 let mut components: Vec<&str> = trimmed.split('/').collect();
775 components.pop(); if components.is_empty() {
777 return format!("/{trimmed}");
778 }
779 format!("/{}/", components.join("/"))
780}
781
782fn build_refactoring_target_actions(item: &serde_json::Value) -> serde_json::Value {
784 let recommendation = item
785 .get("recommendation")
786 .and_then(serde_json::Value::as_str)
787 .unwrap_or("Apply the recommended refactoring");
788
789 let category = item
790 .get("category")
791 .and_then(serde_json::Value::as_str)
792 .unwrap_or("refactoring");
793
794 let mut actions = vec![serde_json::json!({
795 "type": "apply-refactoring",
796 "auto_fixable": false,
797 "description": recommendation,
798 "category": category,
799 })];
800
801 if item.get("evidence").is_some() {
803 actions.push(serde_json::json!({
804 "type": "suppress-line",
805 "auto_fixable": false,
806 "description": "Suppress the underlying complexity finding",
807 "comment": "// fallow-ignore-next-line complexity",
808 }));
809 }
810
811 serde_json::Value::Array(actions)
812}
813
814fn build_untested_file_actions(item: &serde_json::Value) -> serde_json::Value {
816 let path = item
817 .get("path")
818 .and_then(serde_json::Value::as_str)
819 .unwrap_or("file");
820
821 serde_json::Value::Array(vec![
822 serde_json::json!({
823 "type": "add-tests",
824 "auto_fixable": false,
825 "description": format!("Add test coverage for `{path}`"),
826 "note": "No test dependency path reaches this runtime file",
827 }),
828 serde_json::json!({
829 "type": "suppress-file",
830 "auto_fixable": false,
831 "description": format!("Suppress coverage gap reporting for `{path}`"),
832 "comment": "// fallow-ignore-file coverage-gaps",
833 }),
834 ])
835}
836
837fn build_untested_export_actions(item: &serde_json::Value) -> serde_json::Value {
839 let path = item
840 .get("path")
841 .and_then(serde_json::Value::as_str)
842 .unwrap_or("file");
843 let export_name = item
844 .get("export_name")
845 .and_then(serde_json::Value::as_str)
846 .unwrap_or("export");
847
848 serde_json::Value::Array(vec![
849 serde_json::json!({
850 "type": "add-test-import",
851 "auto_fixable": false,
852 "description": format!("Import and test `{export_name}` from `{path}`"),
853 "note": "This export is runtime-reachable but no test-reachable module references it",
854 }),
855 serde_json::json!({
856 "type": "suppress-file",
857 "auto_fixable": false,
858 "description": format!("Suppress coverage gap reporting for `{path}`"),
859 "comment": "// fallow-ignore-file coverage-gaps",
860 }),
861 ])
862}
863
864#[allow(
871 clippy::redundant_pub_crate,
872 reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
873)]
874pub(crate) fn inject_dupes_actions(output: &mut serde_json::Value) {
875 let Some(map) = output.as_object_mut() else {
876 return;
877 };
878
879 if let Some(families) = map.get_mut("clone_families").and_then(|v| v.as_array_mut()) {
881 for item in families {
882 let actions = build_clone_family_actions(item);
883 if let serde_json::Value::Object(obj) = item {
884 obj.insert("actions".to_string(), actions);
885 }
886 }
887 }
888
889 if let Some(groups) = map.get_mut("clone_groups").and_then(|v| v.as_array_mut()) {
891 for item in groups {
892 let actions = build_clone_group_actions(item);
893 if let serde_json::Value::Object(obj) = item {
894 obj.insert("actions".to_string(), actions);
895 }
896 }
897 }
898}
899
900fn build_clone_family_actions(item: &serde_json::Value) -> serde_json::Value {
902 let group_count = item
903 .get("groups")
904 .and_then(|v| v.as_array())
905 .map_or(0, Vec::len);
906
907 let total_lines = item
908 .get("total_duplicated_lines")
909 .and_then(serde_json::Value::as_u64)
910 .unwrap_or(0);
911
912 let mut actions = vec![serde_json::json!({
913 "type": "extract-shared",
914 "auto_fixable": false,
915 "description": format!(
916 "Extract {group_count} duplicated code block{} ({total_lines} lines) into a shared module",
917 if group_count == 1 { "" } else { "s" }
918 ),
919 "note": "These clone groups share the same files, indicating a structural relationship — refactor together",
920 })];
921
922 if let Some(suggestions) = item.get("suggestions").and_then(|v| v.as_array()) {
924 for suggestion in suggestions {
925 if let Some(desc) = suggestion
926 .get("description")
927 .and_then(serde_json::Value::as_str)
928 {
929 actions.push(serde_json::json!({
930 "type": "apply-suggestion",
931 "auto_fixable": false,
932 "description": desc,
933 }));
934 }
935 }
936 }
937
938 actions.push(serde_json::json!({
939 "type": "suppress-line",
940 "auto_fixable": false,
941 "description": "Suppress with an inline comment above the duplicated code",
942 "comment": "// fallow-ignore-next-line code-duplication",
943 }));
944
945 serde_json::Value::Array(actions)
946}
947
948fn build_clone_group_actions(item: &serde_json::Value) -> serde_json::Value {
950 let instance_count = item
951 .get("instances")
952 .and_then(|v| v.as_array())
953 .map_or(0, Vec::len);
954
955 let line_count = item
956 .get("line_count")
957 .and_then(serde_json::Value::as_u64)
958 .unwrap_or(0);
959
960 let actions = vec![
961 serde_json::json!({
962 "type": "extract-shared",
963 "auto_fixable": false,
964 "description": format!(
965 "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
966 if instance_count == 1 { "" } else { "s" }
967 ),
968 }),
969 serde_json::json!({
970 "type": "suppress-line",
971 "auto_fixable": false,
972 "description": "Suppress with an inline comment above the duplicated code",
973 "comment": "// fallow-ignore-next-line code-duplication",
974 }),
975 ];
976
977 serde_json::Value::Array(actions)
978}
979
980fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
982 if let serde_json::Value::Object(map) = output {
983 map.insert("_meta".to_string(), meta);
984 }
985}
986
987pub fn build_health_json(
995 report: &crate::health_types::HealthReport,
996 root: &Path,
997 elapsed: Duration,
998 explain: bool,
999) -> Result<serde_json::Value, serde_json::Error> {
1000 let report_value = serde_json::to_value(report)?;
1001 let mut output = build_json_envelope(report_value, elapsed);
1002 let root_prefix = format!("{}/", root.display());
1003 strip_root_prefix(&mut output, &root_prefix);
1004 inject_health_actions(&mut output);
1005 if explain {
1006 insert_meta(&mut output, explain::health_meta());
1007 }
1008 Ok(output)
1009}
1010
1011pub(super) fn print_health_json(
1012 report: &crate::health_types::HealthReport,
1013 root: &Path,
1014 elapsed: Duration,
1015 explain: bool,
1016) -> ExitCode {
1017 match build_health_json(report, root, elapsed, explain) {
1018 Ok(output) => emit_json(&output, "JSON"),
1019 Err(e) => {
1020 eprintln!("Error: failed to serialize health report: {e}");
1021 ExitCode::from(2)
1022 }
1023 }
1024}
1025
1026pub(super) fn print_duplication_json(
1027 report: &DuplicationReport,
1028 root: &Path,
1029 elapsed: Duration,
1030 explain: bool,
1031) -> ExitCode {
1032 let report_value = match serde_json::to_value(report) {
1033 Ok(v) => v,
1034 Err(e) => {
1035 eprintln!("Error: failed to serialize duplication report: {e}");
1036 return ExitCode::from(2);
1037 }
1038 };
1039
1040 let mut output = build_json_envelope(report_value, elapsed);
1041 let root_prefix = format!("{}/", root.display());
1042 strip_root_prefix(&mut output, &root_prefix);
1043 inject_dupes_actions(&mut output);
1044
1045 if explain {
1046 insert_meta(&mut output, explain::dupes_meta());
1047 }
1048
1049 emit_json(&output, "JSON")
1050}
1051
1052pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
1053 match serde_json::to_string_pretty(value) {
1054 Ok(json) => println!("{json}"),
1055 Err(e) => {
1056 eprintln!("Error: failed to serialize trace output: {e}");
1057 #[expect(
1058 clippy::exit,
1059 reason = "fatal serialization error requires immediate exit"
1060 )]
1061 std::process::exit(2);
1062 }
1063 }
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068 use super::*;
1069 use crate::health_types::{
1070 ProductionCoverageAction, ProductionCoverageConfidence, ProductionCoverageEvidence,
1071 ProductionCoverageFinding, ProductionCoverageHotPath, ProductionCoverageMessage,
1072 ProductionCoverageReport, ProductionCoverageReportVerdict, ProductionCoverageSummary,
1073 ProductionCoverageVerdict, ProductionCoverageWatermark,
1074 };
1075 use crate::report::test_helpers::sample_results;
1076 use fallow_core::extract::MemberKind;
1077 use fallow_core::results::*;
1078 use std::path::PathBuf;
1079 use std::time::Duration;
1080
1081 #[test]
1082 fn json_output_has_metadata_fields() {
1083 let root = PathBuf::from("/project");
1084 let results = AnalysisResults::default();
1085 let elapsed = Duration::from_millis(123);
1086 let output = build_json(&results, &root, elapsed).expect("should serialize");
1087
1088 assert_eq!(output["schema_version"], 4);
1089 assert!(output["version"].is_string());
1090 assert_eq!(output["elapsed_ms"], 123);
1091 assert_eq!(output["total_issues"], 0);
1092 }
1093
1094 #[test]
1095 fn json_output_includes_issue_arrays() {
1096 let root = PathBuf::from("/project");
1097 let results = sample_results(&root);
1098 let elapsed = Duration::from_millis(50);
1099 let output = build_json(&results, &root, elapsed).expect("should serialize");
1100
1101 assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
1102 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
1103 assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
1104 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
1105 assert_eq!(
1106 output["unused_dev_dependencies"].as_array().unwrap().len(),
1107 1
1108 );
1109 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
1110 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
1111 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
1112 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
1113 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
1114 assert_eq!(
1115 output["type_only_dependencies"].as_array().unwrap().len(),
1116 1
1117 );
1118 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
1119 }
1120
1121 #[test]
1122 fn health_json_includes_production_coverage_with_relative_paths_and_actions() {
1123 let root = PathBuf::from("/project");
1124 let report = crate::health_types::HealthReport {
1125 production_coverage: Some(ProductionCoverageReport {
1126 verdict: ProductionCoverageReportVerdict::ColdCodeDetected,
1127 summary: ProductionCoverageSummary {
1128 functions_tracked: 3,
1129 functions_hit: 1,
1130 functions_unhit: 1,
1131 functions_untracked: 1,
1132 coverage_percent: 33.3,
1133 trace_count: 2_847_291,
1134 period_days: 30,
1135 deployments_seen: 14,
1136 },
1137 findings: vec![ProductionCoverageFinding {
1138 id: "fallow:prod:deadbeef".to_owned(),
1139 path: root.join("src/cold.ts"),
1140 function: "coldPath".to_owned(),
1141 line: 12,
1142 verdict: ProductionCoverageVerdict::ReviewRequired,
1143 invocations: Some(0),
1144 confidence: ProductionCoverageConfidence::Medium,
1145 evidence: ProductionCoverageEvidence {
1146 static_status: "used".to_owned(),
1147 test_coverage: "not_covered".to_owned(),
1148 v8_tracking: "tracked".to_owned(),
1149 untracked_reason: None,
1150 observation_days: 30,
1151 deployments_observed: 14,
1152 },
1153 actions: vec![ProductionCoverageAction {
1154 kind: "review-deletion".to_owned(),
1155 description: "Tracked in production coverage with zero invocations."
1156 .to_owned(),
1157 auto_fixable: false,
1158 }],
1159 }],
1160 hot_paths: vec![ProductionCoverageHotPath {
1161 id: "fallow:hot:cafebabe".to_owned(),
1162 path: root.join("src/hot.ts"),
1163 function: "hotPath".to_owned(),
1164 line: 3,
1165 invocations: 250,
1166 percentile: 99,
1167 actions: vec![],
1168 }],
1169 watermark: Some(ProductionCoverageWatermark::LicenseExpiredGrace),
1170 warnings: vec![ProductionCoverageMessage {
1171 code: "partial-merge".to_owned(),
1172 message: "Merged coverage omitted one chunk.".to_owned(),
1173 }],
1174 }),
1175 ..Default::default()
1176 };
1177
1178 let report_value = serde_json::to_value(&report).expect("should serialize health report");
1179 let mut output = build_json_envelope(report_value, Duration::from_millis(7));
1180 strip_root_prefix(&mut output, "/project/");
1181 inject_health_actions(&mut output);
1182
1183 assert_eq!(
1184 output["production_coverage"]["verdict"],
1185 serde_json::Value::String("cold-code-detected".to_owned())
1186 );
1187 assert_eq!(
1188 output["production_coverage"]["summary"]["functions_tracked"],
1189 serde_json::Value::from(3)
1190 );
1191 assert_eq!(
1192 output["production_coverage"]["summary"]["coverage_percent"],
1193 serde_json::Value::from(33.3)
1194 );
1195 let finding = &output["production_coverage"]["findings"][0];
1196 assert_eq!(finding["path"], "src/cold.ts");
1197 assert_eq!(finding["verdict"], "review_required");
1198 assert_eq!(finding["id"], "fallow:prod:deadbeef");
1199 assert_eq!(finding["actions"][0]["type"], "review-deletion");
1200 let hot_path = &output["production_coverage"]["hot_paths"][0];
1201 assert_eq!(hot_path["path"], "src/hot.ts");
1202 assert_eq!(hot_path["function"], "hotPath");
1203 assert_eq!(hot_path["percentile"], 99);
1204 assert_eq!(
1205 output["production_coverage"]["watermark"],
1206 serde_json::Value::String("license-expired-grace".to_owned())
1207 );
1208 assert_eq!(
1209 output["production_coverage"]["warnings"][0]["code"],
1210 serde_json::Value::String("partial-merge".to_owned())
1211 );
1212 }
1213
1214 #[test]
1215 fn json_metadata_fields_appear_first() {
1216 let root = PathBuf::from("/project");
1217 let results = AnalysisResults::default();
1218 let elapsed = Duration::from_millis(0);
1219 let output = build_json(&results, &root, elapsed).expect("should serialize");
1220 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1221 assert_eq!(keys[0], "schema_version");
1222 assert_eq!(keys[1], "version");
1223 assert_eq!(keys[2], "elapsed_ms");
1224 assert_eq!(keys[3], "total_issues");
1225 }
1226
1227 #[test]
1228 fn json_total_issues_matches_results() {
1229 let root = PathBuf::from("/project");
1230 let results = sample_results(&root);
1231 let total = results.total_issues();
1232 let elapsed = Duration::from_millis(0);
1233 let output = build_json(&results, &root, elapsed).expect("should serialize");
1234
1235 assert_eq!(output["total_issues"], total);
1236 }
1237
1238 #[test]
1239 fn json_unused_export_contains_expected_fields() {
1240 let root = PathBuf::from("/project");
1241 let mut results = AnalysisResults::default();
1242 results.unused_exports.push(UnusedExport {
1243 path: root.join("src/utils.ts"),
1244 export_name: "helperFn".to_string(),
1245 is_type_only: false,
1246 line: 10,
1247 col: 4,
1248 span_start: 120,
1249 is_re_export: false,
1250 });
1251 let elapsed = Duration::from_millis(0);
1252 let output = build_json(&results, &root, elapsed).expect("should serialize");
1253
1254 let export = &output["unused_exports"][0];
1255 assert_eq!(export["export_name"], "helperFn");
1256 assert_eq!(export["line"], 10);
1257 assert_eq!(export["col"], 4);
1258 assert_eq!(export["is_type_only"], false);
1259 assert_eq!(export["span_start"], 120);
1260 assert_eq!(export["is_re_export"], false);
1261 }
1262
1263 #[test]
1264 fn json_serializes_to_valid_json() {
1265 let root = PathBuf::from("/project");
1266 let results = sample_results(&root);
1267 let elapsed = Duration::from_millis(42);
1268 let output = build_json(&results, &root, elapsed).expect("should serialize");
1269
1270 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
1271 let reparsed: serde_json::Value =
1272 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
1273 assert_eq!(reparsed, output);
1274 }
1275
1276 #[test]
1279 fn json_empty_results_produce_valid_structure() {
1280 let root = PathBuf::from("/project");
1281 let results = AnalysisResults::default();
1282 let elapsed = Duration::from_millis(0);
1283 let output = build_json(&results, &root, elapsed).expect("should serialize");
1284
1285 assert_eq!(output["total_issues"], 0);
1286 assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
1287 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
1288 assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
1289 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
1290 assert_eq!(
1291 output["unused_dev_dependencies"].as_array().unwrap().len(),
1292 0
1293 );
1294 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
1295 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
1296 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
1297 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
1298 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
1299 assert_eq!(
1300 output["type_only_dependencies"].as_array().unwrap().len(),
1301 0
1302 );
1303 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
1304 }
1305
1306 #[test]
1307 fn json_empty_results_round_trips_through_string() {
1308 let root = PathBuf::from("/project");
1309 let results = AnalysisResults::default();
1310 let elapsed = Duration::from_millis(0);
1311 let output = build_json(&results, &root, elapsed).expect("should serialize");
1312
1313 let json_str = serde_json::to_string(&output).expect("should stringify");
1314 let reparsed: serde_json::Value =
1315 serde_json::from_str(&json_str).expect("should parse back");
1316 assert_eq!(reparsed["total_issues"], 0);
1317 }
1318
1319 #[test]
1322 fn json_paths_are_relative_to_root() {
1323 let root = PathBuf::from("/project");
1324 let mut results = AnalysisResults::default();
1325 results.unused_files.push(UnusedFile {
1326 path: root.join("src/deep/nested/file.ts"),
1327 });
1328 let elapsed = Duration::from_millis(0);
1329 let output = build_json(&results, &root, elapsed).expect("should serialize");
1330
1331 let path = output["unused_files"][0]["path"].as_str().unwrap();
1332 assert_eq!(path, "src/deep/nested/file.ts");
1333 assert!(!path.starts_with("/project"));
1334 }
1335
1336 #[test]
1337 fn json_strips_root_from_nested_locations() {
1338 let root = PathBuf::from("/project");
1339 let mut results = AnalysisResults::default();
1340 results.unlisted_dependencies.push(UnlistedDependency {
1341 package_name: "chalk".to_string(),
1342 imported_from: vec![ImportSite {
1343 path: root.join("src/cli.ts"),
1344 line: 2,
1345 col: 0,
1346 }],
1347 });
1348 let elapsed = Duration::from_millis(0);
1349 let output = build_json(&results, &root, elapsed).expect("should serialize");
1350
1351 let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
1352 .as_str()
1353 .unwrap();
1354 assert_eq!(site_path, "src/cli.ts");
1355 }
1356
1357 #[test]
1358 fn json_strips_root_from_duplicate_export_locations() {
1359 let root = PathBuf::from("/project");
1360 let mut results = AnalysisResults::default();
1361 results.duplicate_exports.push(DuplicateExport {
1362 export_name: "Config".to_string(),
1363 locations: vec![
1364 DuplicateLocation {
1365 path: root.join("src/config.ts"),
1366 line: 15,
1367 col: 0,
1368 },
1369 DuplicateLocation {
1370 path: root.join("src/types.ts"),
1371 line: 30,
1372 col: 0,
1373 },
1374 ],
1375 });
1376 let elapsed = Duration::from_millis(0);
1377 let output = build_json(&results, &root, elapsed).expect("should serialize");
1378
1379 let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
1380 .as_str()
1381 .unwrap();
1382 let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
1383 .as_str()
1384 .unwrap();
1385 assert_eq!(loc0, "src/config.ts");
1386 assert_eq!(loc1, "src/types.ts");
1387 }
1388
1389 #[test]
1390 fn json_strips_root_from_circular_dependency_files() {
1391 let root = PathBuf::from("/project");
1392 let mut results = AnalysisResults::default();
1393 results.circular_dependencies.push(CircularDependency {
1394 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1395 length: 2,
1396 line: 1,
1397 col: 0,
1398 is_cross_package: false,
1399 });
1400 let elapsed = Duration::from_millis(0);
1401 let output = build_json(&results, &root, elapsed).expect("should serialize");
1402
1403 let files = output["circular_dependencies"][0]["files"]
1404 .as_array()
1405 .unwrap();
1406 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
1407 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
1408 }
1409
1410 #[test]
1411 fn json_path_outside_root_not_stripped() {
1412 let root = PathBuf::from("/project");
1413 let mut results = AnalysisResults::default();
1414 results.unused_files.push(UnusedFile {
1415 path: PathBuf::from("/other/project/src/file.ts"),
1416 });
1417 let elapsed = Duration::from_millis(0);
1418 let output = build_json(&results, &root, elapsed).expect("should serialize");
1419
1420 let path = output["unused_files"][0]["path"].as_str().unwrap();
1421 assert!(path.contains("/other/project/"));
1422 }
1423
1424 #[test]
1427 fn json_unused_file_contains_path() {
1428 let root = PathBuf::from("/project");
1429 let mut results = AnalysisResults::default();
1430 results.unused_files.push(UnusedFile {
1431 path: root.join("src/orphan.ts"),
1432 });
1433 let elapsed = Duration::from_millis(0);
1434 let output = build_json(&results, &root, elapsed).expect("should serialize");
1435
1436 let file = &output["unused_files"][0];
1437 assert_eq!(file["path"], "src/orphan.ts");
1438 }
1439
1440 #[test]
1441 fn json_unused_type_contains_expected_fields() {
1442 let root = PathBuf::from("/project");
1443 let mut results = AnalysisResults::default();
1444 results.unused_types.push(UnusedExport {
1445 path: root.join("src/types.ts"),
1446 export_name: "OldInterface".to_string(),
1447 is_type_only: true,
1448 line: 20,
1449 col: 0,
1450 span_start: 300,
1451 is_re_export: false,
1452 });
1453 let elapsed = Duration::from_millis(0);
1454 let output = build_json(&results, &root, elapsed).expect("should serialize");
1455
1456 let typ = &output["unused_types"][0];
1457 assert_eq!(typ["export_name"], "OldInterface");
1458 assert_eq!(typ["is_type_only"], true);
1459 assert_eq!(typ["line"], 20);
1460 assert_eq!(typ["path"], "src/types.ts");
1461 }
1462
1463 #[test]
1464 fn json_unused_dependency_contains_expected_fields() {
1465 let root = PathBuf::from("/project");
1466 let mut results = AnalysisResults::default();
1467 results.unused_dependencies.push(UnusedDependency {
1468 package_name: "axios".to_string(),
1469 location: DependencyLocation::Dependencies,
1470 path: root.join("package.json"),
1471 line: 10,
1472 });
1473 let elapsed = Duration::from_millis(0);
1474 let output = build_json(&results, &root, elapsed).expect("should serialize");
1475
1476 let dep = &output["unused_dependencies"][0];
1477 assert_eq!(dep["package_name"], "axios");
1478 assert_eq!(dep["line"], 10);
1479 }
1480
1481 #[test]
1482 fn json_unused_dev_dependency_contains_expected_fields() {
1483 let root = PathBuf::from("/project");
1484 let mut results = AnalysisResults::default();
1485 results.unused_dev_dependencies.push(UnusedDependency {
1486 package_name: "vitest".to_string(),
1487 location: DependencyLocation::DevDependencies,
1488 path: root.join("package.json"),
1489 line: 15,
1490 });
1491 let elapsed = Duration::from_millis(0);
1492 let output = build_json(&results, &root, elapsed).expect("should serialize");
1493
1494 let dep = &output["unused_dev_dependencies"][0];
1495 assert_eq!(dep["package_name"], "vitest");
1496 }
1497
1498 #[test]
1499 fn json_unused_optional_dependency_contains_expected_fields() {
1500 let root = PathBuf::from("/project");
1501 let mut results = AnalysisResults::default();
1502 results.unused_optional_dependencies.push(UnusedDependency {
1503 package_name: "fsevents".to_string(),
1504 location: DependencyLocation::OptionalDependencies,
1505 path: root.join("package.json"),
1506 line: 12,
1507 });
1508 let elapsed = Duration::from_millis(0);
1509 let output = build_json(&results, &root, elapsed).expect("should serialize");
1510
1511 let dep = &output["unused_optional_dependencies"][0];
1512 assert_eq!(dep["package_name"], "fsevents");
1513 assert_eq!(output["total_issues"], 1);
1514 }
1515
1516 #[test]
1517 fn json_unused_enum_member_contains_expected_fields() {
1518 let root = PathBuf::from("/project");
1519 let mut results = AnalysisResults::default();
1520 results.unused_enum_members.push(UnusedMember {
1521 path: root.join("src/enums.ts"),
1522 parent_name: "Color".to_string(),
1523 member_name: "Purple".to_string(),
1524 kind: MemberKind::EnumMember,
1525 line: 5,
1526 col: 2,
1527 });
1528 let elapsed = Duration::from_millis(0);
1529 let output = build_json(&results, &root, elapsed).expect("should serialize");
1530
1531 let member = &output["unused_enum_members"][0];
1532 assert_eq!(member["parent_name"], "Color");
1533 assert_eq!(member["member_name"], "Purple");
1534 assert_eq!(member["line"], 5);
1535 assert_eq!(member["path"], "src/enums.ts");
1536 }
1537
1538 #[test]
1539 fn json_unused_class_member_contains_expected_fields() {
1540 let root = PathBuf::from("/project");
1541 let mut results = AnalysisResults::default();
1542 results.unused_class_members.push(UnusedMember {
1543 path: root.join("src/api.ts"),
1544 parent_name: "ApiClient".to_string(),
1545 member_name: "deprecatedFetch".to_string(),
1546 kind: MemberKind::ClassMethod,
1547 line: 100,
1548 col: 4,
1549 });
1550 let elapsed = Duration::from_millis(0);
1551 let output = build_json(&results, &root, elapsed).expect("should serialize");
1552
1553 let member = &output["unused_class_members"][0];
1554 assert_eq!(member["parent_name"], "ApiClient");
1555 assert_eq!(member["member_name"], "deprecatedFetch");
1556 assert_eq!(member["line"], 100);
1557 }
1558
1559 #[test]
1560 fn json_unresolved_import_contains_expected_fields() {
1561 let root = PathBuf::from("/project");
1562 let mut results = AnalysisResults::default();
1563 results.unresolved_imports.push(UnresolvedImport {
1564 path: root.join("src/app.ts"),
1565 specifier: "@acme/missing-pkg".to_string(),
1566 line: 7,
1567 col: 0,
1568 specifier_col: 0,
1569 });
1570 let elapsed = Duration::from_millis(0);
1571 let output = build_json(&results, &root, elapsed).expect("should serialize");
1572
1573 let import = &output["unresolved_imports"][0];
1574 assert_eq!(import["specifier"], "@acme/missing-pkg");
1575 assert_eq!(import["line"], 7);
1576 assert_eq!(import["path"], "src/app.ts");
1577 }
1578
1579 #[test]
1580 fn json_unlisted_dependency_contains_import_sites() {
1581 let root = PathBuf::from("/project");
1582 let mut results = AnalysisResults::default();
1583 results.unlisted_dependencies.push(UnlistedDependency {
1584 package_name: "dotenv".to_string(),
1585 imported_from: vec![
1586 ImportSite {
1587 path: root.join("src/config.ts"),
1588 line: 1,
1589 col: 0,
1590 },
1591 ImportSite {
1592 path: root.join("src/server.ts"),
1593 line: 3,
1594 col: 0,
1595 },
1596 ],
1597 });
1598 let elapsed = Duration::from_millis(0);
1599 let output = build_json(&results, &root, elapsed).expect("should serialize");
1600
1601 let dep = &output["unlisted_dependencies"][0];
1602 assert_eq!(dep["package_name"], "dotenv");
1603 let sites = dep["imported_from"].as_array().unwrap();
1604 assert_eq!(sites.len(), 2);
1605 assert_eq!(sites[0]["path"], "src/config.ts");
1606 assert_eq!(sites[1]["path"], "src/server.ts");
1607 }
1608
1609 #[test]
1610 fn json_duplicate_export_contains_locations() {
1611 let root = PathBuf::from("/project");
1612 let mut results = AnalysisResults::default();
1613 results.duplicate_exports.push(DuplicateExport {
1614 export_name: "Button".to_string(),
1615 locations: vec![
1616 DuplicateLocation {
1617 path: root.join("src/ui.ts"),
1618 line: 10,
1619 col: 0,
1620 },
1621 DuplicateLocation {
1622 path: root.join("src/components.ts"),
1623 line: 25,
1624 col: 0,
1625 },
1626 ],
1627 });
1628 let elapsed = Duration::from_millis(0);
1629 let output = build_json(&results, &root, elapsed).expect("should serialize");
1630
1631 let dup = &output["duplicate_exports"][0];
1632 assert_eq!(dup["export_name"], "Button");
1633 let locs = dup["locations"].as_array().unwrap();
1634 assert_eq!(locs.len(), 2);
1635 assert_eq!(locs[0]["line"], 10);
1636 assert_eq!(locs[1]["line"], 25);
1637 }
1638
1639 #[test]
1640 fn json_type_only_dependency_contains_expected_fields() {
1641 let root = PathBuf::from("/project");
1642 let mut results = AnalysisResults::default();
1643 results.type_only_dependencies.push(TypeOnlyDependency {
1644 package_name: "zod".to_string(),
1645 path: root.join("package.json"),
1646 line: 8,
1647 });
1648 let elapsed = Duration::from_millis(0);
1649 let output = build_json(&results, &root, elapsed).expect("should serialize");
1650
1651 let dep = &output["type_only_dependencies"][0];
1652 assert_eq!(dep["package_name"], "zod");
1653 assert_eq!(dep["line"], 8);
1654 }
1655
1656 #[test]
1657 fn json_circular_dependency_contains_expected_fields() {
1658 let root = PathBuf::from("/project");
1659 let mut results = AnalysisResults::default();
1660 results.circular_dependencies.push(CircularDependency {
1661 files: vec![
1662 root.join("src/a.ts"),
1663 root.join("src/b.ts"),
1664 root.join("src/c.ts"),
1665 ],
1666 length: 3,
1667 line: 5,
1668 col: 0,
1669 is_cross_package: false,
1670 });
1671 let elapsed = Duration::from_millis(0);
1672 let output = build_json(&results, &root, elapsed).expect("should serialize");
1673
1674 let cycle = &output["circular_dependencies"][0];
1675 assert_eq!(cycle["length"], 3);
1676 assert_eq!(cycle["line"], 5);
1677 let files = cycle["files"].as_array().unwrap();
1678 assert_eq!(files.len(), 3);
1679 }
1680
1681 #[test]
1684 fn json_re_export_flagged_correctly() {
1685 let root = PathBuf::from("/project");
1686 let mut results = AnalysisResults::default();
1687 results.unused_exports.push(UnusedExport {
1688 path: root.join("src/index.ts"),
1689 export_name: "reExported".to_string(),
1690 is_type_only: false,
1691 line: 1,
1692 col: 0,
1693 span_start: 0,
1694 is_re_export: true,
1695 });
1696 let elapsed = Duration::from_millis(0);
1697 let output = build_json(&results, &root, elapsed).expect("should serialize");
1698
1699 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
1700 }
1701
1702 #[test]
1705 fn json_schema_version_is_4() {
1706 let root = PathBuf::from("/project");
1707 let results = AnalysisResults::default();
1708 let elapsed = Duration::from_millis(0);
1709 let output = build_json(&results, &root, elapsed).expect("should serialize");
1710
1711 assert_eq!(output["schema_version"], SCHEMA_VERSION);
1712 assert_eq!(output["schema_version"], 4);
1713 }
1714
1715 #[test]
1718 fn json_version_matches_cargo_pkg_version() {
1719 let root = PathBuf::from("/project");
1720 let results = AnalysisResults::default();
1721 let elapsed = Duration::from_millis(0);
1722 let output = build_json(&results, &root, elapsed).expect("should serialize");
1723
1724 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1725 }
1726
1727 #[test]
1730 fn json_elapsed_ms_zero_duration() {
1731 let root = PathBuf::from("/project");
1732 let results = AnalysisResults::default();
1733 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1734
1735 assert_eq!(output["elapsed_ms"], 0);
1736 }
1737
1738 #[test]
1739 fn json_elapsed_ms_large_duration() {
1740 let root = PathBuf::from("/project");
1741 let results = AnalysisResults::default();
1742 let elapsed = Duration::from_mins(2);
1743 let output = build_json(&results, &root, elapsed).expect("should serialize");
1744
1745 assert_eq!(output["elapsed_ms"], 120_000);
1746 }
1747
1748 #[test]
1749 fn json_elapsed_ms_sub_millisecond_truncated() {
1750 let root = PathBuf::from("/project");
1751 let results = AnalysisResults::default();
1752 let elapsed = Duration::from_micros(500);
1754 let output = build_json(&results, &root, elapsed).expect("should serialize");
1755
1756 assert_eq!(output["elapsed_ms"], 0);
1757 }
1758
1759 #[test]
1762 fn json_multiple_unused_files() {
1763 let root = PathBuf::from("/project");
1764 let mut results = AnalysisResults::default();
1765 results.unused_files.push(UnusedFile {
1766 path: root.join("src/a.ts"),
1767 });
1768 results.unused_files.push(UnusedFile {
1769 path: root.join("src/b.ts"),
1770 });
1771 results.unused_files.push(UnusedFile {
1772 path: root.join("src/c.ts"),
1773 });
1774 let elapsed = Duration::from_millis(0);
1775 let output = build_json(&results, &root, elapsed).expect("should serialize");
1776
1777 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1778 assert_eq!(output["total_issues"], 3);
1779 }
1780
1781 #[test]
1784 fn strip_root_prefix_on_string_value() {
1785 let mut value = serde_json::json!("/project/src/file.ts");
1786 strip_root_prefix(&mut value, "/project/");
1787 assert_eq!(value, "src/file.ts");
1788 }
1789
1790 #[test]
1791 fn strip_root_prefix_leaves_non_matching_string() {
1792 let mut value = serde_json::json!("/other/src/file.ts");
1793 strip_root_prefix(&mut value, "/project/");
1794 assert_eq!(value, "/other/src/file.ts");
1795 }
1796
1797 #[test]
1798 fn strip_root_prefix_recurses_into_arrays() {
1799 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1800 strip_root_prefix(&mut value, "/project/");
1801 assert_eq!(value[0], "a.ts");
1802 assert_eq!(value[1], "b.ts");
1803 assert_eq!(value[2], "/other/c.ts");
1804 }
1805
1806 #[test]
1807 fn strip_root_prefix_recurses_into_nested_objects() {
1808 let mut value = serde_json::json!({
1809 "outer": {
1810 "path": "/project/src/nested.ts"
1811 }
1812 });
1813 strip_root_prefix(&mut value, "/project/");
1814 assert_eq!(value["outer"]["path"], "src/nested.ts");
1815 }
1816
1817 #[test]
1818 fn strip_root_prefix_leaves_numbers_and_booleans() {
1819 let mut value = serde_json::json!({
1820 "line": 42,
1821 "is_type_only": false,
1822 "path": "/project/src/file.ts"
1823 });
1824 strip_root_prefix(&mut value, "/project/");
1825 assert_eq!(value["line"], 42);
1826 assert_eq!(value["is_type_only"], false);
1827 assert_eq!(value["path"], "src/file.ts");
1828 }
1829
1830 #[test]
1831 fn strip_root_prefix_normalizes_windows_separators() {
1832 let mut value = serde_json::json!(r"/project\src\file.ts");
1833 strip_root_prefix(&mut value, "/project/");
1834 assert_eq!(value, "src/file.ts");
1835 }
1836
1837 #[test]
1838 fn strip_root_prefix_handles_empty_string_after_strip() {
1839 let mut value = serde_json::json!("/project/");
1842 strip_root_prefix(&mut value, "/project/");
1843 assert_eq!(value, "");
1844 }
1845
1846 #[test]
1847 fn strip_root_prefix_deeply_nested_array_of_objects() {
1848 let mut value = serde_json::json!({
1849 "groups": [{
1850 "instances": [{
1851 "file": "/project/src/a.ts"
1852 }, {
1853 "file": "/project/src/b.ts"
1854 }]
1855 }]
1856 });
1857 strip_root_prefix(&mut value, "/project/");
1858 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1859 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1860 }
1861
1862 #[test]
1865 fn json_full_sample_results_total_issues_correct() {
1866 let root = PathBuf::from("/project");
1867 let results = sample_results(&root);
1868 let elapsed = Duration::from_millis(100);
1869 let output = build_json(&results, &root, elapsed).expect("should serialize");
1870
1871 assert_eq!(output["total_issues"], results.total_issues());
1877 }
1878
1879 #[test]
1880 fn json_full_sample_no_absolute_paths_in_output() {
1881 let root = PathBuf::from("/project");
1882 let results = sample_results(&root);
1883 let elapsed = Duration::from_millis(0);
1884 let output = build_json(&results, &root, elapsed).expect("should serialize");
1885
1886 let json_str = serde_json::to_string(&output).expect("should stringify");
1887 assert!(!json_str.contains("/project/src/"));
1889 assert!(!json_str.contains("/project/package.json"));
1890 }
1891
1892 #[test]
1895 fn json_output_is_deterministic() {
1896 let root = PathBuf::from("/project");
1897 let results = sample_results(&root);
1898 let elapsed = Duration::from_millis(50);
1899
1900 let output1 = build_json(&results, &root, elapsed).expect("first build");
1901 let output2 = build_json(&results, &root, elapsed).expect("second build");
1902
1903 assert_eq!(output1, output2);
1904 }
1905
1906 #[test]
1909 fn json_results_fields_do_not_shadow_metadata() {
1910 let root = PathBuf::from("/project");
1913 let results = AnalysisResults::default();
1914 let elapsed = Duration::from_millis(99);
1915 let output = build_json(&results, &root, elapsed).expect("should serialize");
1916
1917 assert_eq!(output["schema_version"], 4);
1919 assert_eq!(output["elapsed_ms"], 99);
1920 }
1921
1922 #[test]
1925 fn json_all_issue_type_arrays_present_in_empty_results() {
1926 let root = PathBuf::from("/project");
1927 let results = AnalysisResults::default();
1928 let elapsed = Duration::from_millis(0);
1929 let output = build_json(&results, &root, elapsed).expect("should serialize");
1930
1931 let expected_arrays = [
1932 "unused_files",
1933 "unused_exports",
1934 "unused_types",
1935 "unused_dependencies",
1936 "unused_dev_dependencies",
1937 "unused_optional_dependencies",
1938 "unused_enum_members",
1939 "unused_class_members",
1940 "unresolved_imports",
1941 "unlisted_dependencies",
1942 "duplicate_exports",
1943 "type_only_dependencies",
1944 "test_only_dependencies",
1945 "circular_dependencies",
1946 ];
1947 for key in &expected_arrays {
1948 assert!(
1949 output[key].is_array(),
1950 "expected '{key}' to be an array in JSON output"
1951 );
1952 }
1953 }
1954
1955 #[test]
1958 fn insert_meta_adds_key_to_object() {
1959 let mut output = serde_json::json!({ "foo": 1 });
1960 let meta = serde_json::json!({ "docs": "https://example.com" });
1961 insert_meta(&mut output, meta.clone());
1962 assert_eq!(output["_meta"], meta);
1963 }
1964
1965 #[test]
1966 fn insert_meta_noop_on_non_object() {
1967 let mut output = serde_json::json!([1, 2, 3]);
1968 let meta = serde_json::json!({ "docs": "https://example.com" });
1969 insert_meta(&mut output, meta);
1970 assert!(output.is_array());
1972 }
1973
1974 #[test]
1975 fn insert_meta_overwrites_existing_meta() {
1976 let mut output = serde_json::json!({ "_meta": "old" });
1977 let meta = serde_json::json!({ "new": true });
1978 insert_meta(&mut output, meta.clone());
1979 assert_eq!(output["_meta"], meta);
1980 }
1981
1982 #[test]
1985 fn build_json_envelope_has_metadata_fields() {
1986 let report = serde_json::json!({ "findings": [] });
1987 let elapsed = Duration::from_millis(42);
1988 let output = build_json_envelope(report, elapsed);
1989
1990 assert_eq!(output["schema_version"], 4);
1991 assert!(output["version"].is_string());
1992 assert_eq!(output["elapsed_ms"], 42);
1993 assert!(output["findings"].is_array());
1994 }
1995
1996 #[test]
1997 fn build_json_envelope_metadata_appears_first() {
1998 let report = serde_json::json!({ "data": "value" });
1999 let output = build_json_envelope(report, Duration::from_millis(10));
2000
2001 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
2002 assert_eq!(keys[0], "schema_version");
2003 assert_eq!(keys[1], "version");
2004 assert_eq!(keys[2], "elapsed_ms");
2005 }
2006
2007 #[test]
2008 fn build_json_envelope_non_object_report() {
2009 let report = serde_json::json!("not an object");
2011 let output = build_json_envelope(report, Duration::from_millis(0));
2012
2013 let obj = output.as_object().unwrap();
2014 assert_eq!(obj.len(), 3);
2015 assert!(obj.contains_key("schema_version"));
2016 assert!(obj.contains_key("version"));
2017 assert!(obj.contains_key("elapsed_ms"));
2018 }
2019
2020 #[test]
2023 fn strip_root_prefix_null_unchanged() {
2024 let mut value = serde_json::Value::Null;
2025 strip_root_prefix(&mut value, "/project/");
2026 assert!(value.is_null());
2027 }
2028
2029 #[test]
2032 fn strip_root_prefix_empty_string() {
2033 let mut value = serde_json::json!("");
2034 strip_root_prefix(&mut value, "/project/");
2035 assert_eq!(value, "");
2036 }
2037
2038 #[test]
2041 fn strip_root_prefix_mixed_types() {
2042 let mut value = serde_json::json!({
2043 "path": "/project/src/file.ts",
2044 "line": 42,
2045 "flag": true,
2046 "nested": {
2047 "items": ["/project/a.ts", 99, null, "/project/b.ts"],
2048 "deep": { "path": "/project/c.ts" }
2049 }
2050 });
2051 strip_root_prefix(&mut value, "/project/");
2052 assert_eq!(value["path"], "src/file.ts");
2053 assert_eq!(value["line"], 42);
2054 assert_eq!(value["flag"], true);
2055 assert_eq!(value["nested"]["items"][0], "a.ts");
2056 assert_eq!(value["nested"]["items"][1], 99);
2057 assert!(value["nested"]["items"][2].is_null());
2058 assert_eq!(value["nested"]["items"][3], "b.ts");
2059 assert_eq!(value["nested"]["deep"]["path"], "c.ts");
2060 }
2061
2062 #[test]
2065 fn json_check_meta_integrates_correctly() {
2066 let root = PathBuf::from("/project");
2067 let results = AnalysisResults::default();
2068 let elapsed = Duration::from_millis(0);
2069 let mut output = build_json(&results, &root, elapsed).expect("should serialize");
2070 insert_meta(&mut output, crate::explain::check_meta());
2071
2072 assert!(output["_meta"]["docs"].is_string());
2073 assert!(output["_meta"]["rules"].is_object());
2074 }
2075
2076 #[test]
2079 fn json_unused_member_kind_serialized() {
2080 let root = PathBuf::from("/project");
2081 let mut results = AnalysisResults::default();
2082 results.unused_enum_members.push(UnusedMember {
2083 path: root.join("src/enums.ts"),
2084 parent_name: "Color".to_string(),
2085 member_name: "Red".to_string(),
2086 kind: MemberKind::EnumMember,
2087 line: 3,
2088 col: 2,
2089 });
2090 results.unused_class_members.push(UnusedMember {
2091 path: root.join("src/class.ts"),
2092 parent_name: "Foo".to_string(),
2093 member_name: "bar".to_string(),
2094 kind: MemberKind::ClassMethod,
2095 line: 10,
2096 col: 4,
2097 });
2098
2099 let elapsed = Duration::from_millis(0);
2100 let output = build_json(&results, &root, elapsed).expect("should serialize");
2101
2102 let enum_member = &output["unused_enum_members"][0];
2103 assert!(enum_member["kind"].is_string());
2104 let class_member = &output["unused_class_members"][0];
2105 assert!(class_member["kind"].is_string());
2106 }
2107
2108 #[test]
2111 fn json_unused_export_has_actions() {
2112 let root = PathBuf::from("/project");
2113 let mut results = AnalysisResults::default();
2114 results.unused_exports.push(UnusedExport {
2115 path: root.join("src/utils.ts"),
2116 export_name: "helperFn".to_string(),
2117 is_type_only: false,
2118 line: 10,
2119 col: 4,
2120 span_start: 120,
2121 is_re_export: false,
2122 });
2123 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2124
2125 let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2126 assert_eq!(actions.len(), 2);
2127
2128 assert_eq!(actions[0]["type"], "remove-export");
2130 assert_eq!(actions[0]["auto_fixable"], true);
2131 assert!(actions[0].get("note").is_none());
2132
2133 assert_eq!(actions[1]["type"], "suppress-line");
2135 assert_eq!(
2136 actions[1]["comment"],
2137 "// fallow-ignore-next-line unused-export"
2138 );
2139 }
2140
2141 #[test]
2142 fn json_unused_file_has_file_suppress_and_note() {
2143 let root = PathBuf::from("/project");
2144 let mut results = AnalysisResults::default();
2145 results.unused_files.push(UnusedFile {
2146 path: root.join("src/dead.ts"),
2147 });
2148 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2149
2150 let actions = output["unused_files"][0]["actions"].as_array().unwrap();
2151 assert_eq!(actions[0]["type"], "delete-file");
2152 assert_eq!(actions[0]["auto_fixable"], false);
2153 assert!(actions[0]["note"].is_string());
2154 assert_eq!(actions[1]["type"], "suppress-file");
2155 assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
2156 }
2157
2158 #[test]
2159 fn json_unused_dependency_has_config_suppress_with_package_name() {
2160 let root = PathBuf::from("/project");
2161 let mut results = AnalysisResults::default();
2162 results.unused_dependencies.push(UnusedDependency {
2163 package_name: "lodash".to_string(),
2164 location: DependencyLocation::Dependencies,
2165 path: root.join("package.json"),
2166 line: 5,
2167 });
2168 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2169
2170 let actions = output["unused_dependencies"][0]["actions"]
2171 .as_array()
2172 .unwrap();
2173 assert_eq!(actions[0]["type"], "remove-dependency");
2174 assert_eq!(actions[0]["auto_fixable"], true);
2175
2176 assert_eq!(actions[1]["type"], "add-to-config");
2178 assert_eq!(actions[1]["config_key"], "ignoreDependencies");
2179 assert_eq!(actions[1]["value"], "lodash");
2180 }
2181
2182 #[test]
2183 fn json_empty_results_have_no_actions_in_empty_arrays() {
2184 let root = PathBuf::from("/project");
2185 let results = AnalysisResults::default();
2186 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2187
2188 assert!(output["unused_exports"].as_array().unwrap().is_empty());
2190 assert!(output["unused_files"].as_array().unwrap().is_empty());
2191 }
2192
2193 #[test]
2194 fn json_all_issue_types_have_actions() {
2195 let root = PathBuf::from("/project");
2196 let results = sample_results(&root);
2197 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2198
2199 let issue_keys = [
2200 "unused_files",
2201 "unused_exports",
2202 "unused_types",
2203 "unused_dependencies",
2204 "unused_dev_dependencies",
2205 "unused_optional_dependencies",
2206 "unused_enum_members",
2207 "unused_class_members",
2208 "unresolved_imports",
2209 "unlisted_dependencies",
2210 "duplicate_exports",
2211 "type_only_dependencies",
2212 "test_only_dependencies",
2213 "circular_dependencies",
2214 ];
2215
2216 for key in &issue_keys {
2217 let arr = output[key].as_array().unwrap();
2218 if !arr.is_empty() {
2219 let actions = arr[0]["actions"].as_array();
2220 assert!(
2221 actions.is_some() && !actions.unwrap().is_empty(),
2222 "missing actions for {key}"
2223 );
2224 }
2225 }
2226 }
2227
2228 #[test]
2231 fn health_finding_has_actions() {
2232 let mut output = serde_json::json!({
2233 "findings": [{
2234 "path": "src/utils.ts",
2235 "name": "processData",
2236 "line": 10,
2237 "col": 0,
2238 "cyclomatic": 25,
2239 "cognitive": 30,
2240 "line_count": 150,
2241 "exceeded": "both"
2242 }]
2243 });
2244
2245 inject_health_actions(&mut output);
2246
2247 let actions = output["findings"][0]["actions"].as_array().unwrap();
2248 assert_eq!(actions.len(), 2);
2249 assert_eq!(actions[0]["type"], "refactor-function");
2250 assert_eq!(actions[0]["auto_fixable"], false);
2251 assert!(
2252 actions[0]["description"]
2253 .as_str()
2254 .unwrap()
2255 .contains("processData")
2256 );
2257 assert_eq!(actions[1]["type"], "suppress-line");
2258 assert_eq!(
2259 actions[1]["comment"],
2260 "// fallow-ignore-next-line complexity"
2261 );
2262 }
2263
2264 #[test]
2265 fn refactoring_target_has_actions() {
2266 let mut output = serde_json::json!({
2267 "targets": [{
2268 "path": "src/big-module.ts",
2269 "priority": 85.0,
2270 "efficiency": 42.5,
2271 "recommendation": "Split module: 12 exports, 4 unused",
2272 "category": "split_high_impact",
2273 "effort": "medium",
2274 "confidence": "high",
2275 "evidence": { "unused_exports": 4 }
2276 }]
2277 });
2278
2279 inject_health_actions(&mut output);
2280
2281 let actions = output["targets"][0]["actions"].as_array().unwrap();
2282 assert_eq!(actions.len(), 2);
2283 assert_eq!(actions[0]["type"], "apply-refactoring");
2284 assert_eq!(
2285 actions[0]["description"],
2286 "Split module: 12 exports, 4 unused"
2287 );
2288 assert_eq!(actions[0]["category"], "split_high_impact");
2289 assert_eq!(actions[1]["type"], "suppress-line");
2291 }
2292
2293 #[test]
2294 fn refactoring_target_without_evidence_has_no_suppress() {
2295 let mut output = serde_json::json!({
2296 "targets": [{
2297 "path": "src/simple.ts",
2298 "priority": 30.0,
2299 "efficiency": 15.0,
2300 "recommendation": "Consider extracting helper functions",
2301 "category": "extract_complex_functions",
2302 "effort": "small",
2303 "confidence": "medium"
2304 }]
2305 });
2306
2307 inject_health_actions(&mut output);
2308
2309 let actions = output["targets"][0]["actions"].as_array().unwrap();
2310 assert_eq!(actions.len(), 1);
2311 assert_eq!(actions[0]["type"], "apply-refactoring");
2312 }
2313
2314 #[test]
2315 fn health_empty_findings_no_actions() {
2316 let mut output = serde_json::json!({
2317 "findings": [],
2318 "targets": []
2319 });
2320
2321 inject_health_actions(&mut output);
2322
2323 assert!(output["findings"].as_array().unwrap().is_empty());
2324 assert!(output["targets"].as_array().unwrap().is_empty());
2325 }
2326
2327 #[test]
2328 fn hotspot_has_actions() {
2329 let mut output = serde_json::json!({
2330 "hotspots": [{
2331 "path": "src/utils.ts",
2332 "complexity_score": 45.0,
2333 "churn_score": 12,
2334 "hotspot_score": 540.0
2335 }]
2336 });
2337
2338 inject_health_actions(&mut output);
2339
2340 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2341 assert_eq!(actions.len(), 2);
2342 assert_eq!(actions[0]["type"], "refactor-file");
2343 assert!(
2344 actions[0]["description"]
2345 .as_str()
2346 .unwrap()
2347 .contains("src/utils.ts")
2348 );
2349 assert_eq!(actions[1]["type"], "add-tests");
2350 }
2351
2352 #[test]
2353 fn hotspot_low_bus_factor_emits_action() {
2354 let mut output = serde_json::json!({
2355 "hotspots": [{
2356 "path": "src/api.ts",
2357 "ownership": {
2358 "bus_factor": 1,
2359 "contributor_count": 1,
2360 "top_contributor": {"identifier": "alice@x", "share": 1.0, "stale_days": 5, "commits": 30},
2361 "unowned": null,
2362 "drift": false,
2363 }
2364 }]
2365 });
2366
2367 inject_health_actions(&mut output);
2368
2369 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2370 assert!(
2371 actions
2372 .iter()
2373 .filter_map(|a| a["type"].as_str())
2374 .any(|t| t == "low-bus-factor"),
2375 "low-bus-factor action should be present",
2376 );
2377 let bus = actions
2378 .iter()
2379 .find(|a| a["type"] == "low-bus-factor")
2380 .unwrap();
2381 assert!(bus["description"].as_str().unwrap().contains("alice@x"));
2382 }
2383
2384 #[test]
2385 fn hotspot_unowned_emits_action_with_pattern() {
2386 let mut output = serde_json::json!({
2387 "hotspots": [{
2388 "path": "src/api/users.ts",
2389 "ownership": {
2390 "bus_factor": 2,
2391 "contributor_count": 4,
2392 "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
2393 "unowned": true,
2394 "drift": false,
2395 }
2396 }]
2397 });
2398
2399 inject_health_actions(&mut output);
2400
2401 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2402 let unowned = actions
2403 .iter()
2404 .find(|a| a["type"] == "unowned-hotspot")
2405 .expect("unowned-hotspot action should be present");
2406 assert_eq!(unowned["suggested_pattern"], "/src/api/");
2409 assert_eq!(unowned["heuristic"], "directory-deepest");
2410 }
2411
2412 #[test]
2413 fn hotspot_unowned_skipped_when_codeowners_missing() {
2414 let mut output = serde_json::json!({
2415 "hotspots": [{
2416 "path": "src/api.ts",
2417 "ownership": {
2418 "bus_factor": 2,
2419 "contributor_count": 4,
2420 "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
2421 "unowned": null,
2422 "drift": false,
2423 }
2424 }]
2425 });
2426
2427 inject_health_actions(&mut output);
2428
2429 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2430 assert!(
2431 !actions.iter().any(|a| a["type"] == "unowned-hotspot"),
2432 "unowned action must not fire when CODEOWNERS file is absent"
2433 );
2434 }
2435
2436 #[test]
2437 fn hotspot_drift_emits_action() {
2438 let mut output = serde_json::json!({
2439 "hotspots": [{
2440 "path": "src/old.ts",
2441 "ownership": {
2442 "bus_factor": 1,
2443 "contributor_count": 2,
2444 "top_contributor": {"identifier": "bob@x", "share": 0.9, "stale_days": 1, "commits": 18},
2445 "unowned": null,
2446 "drift": true,
2447 "drift_reason": "original author alice@x has 5% share",
2448 }
2449 }]
2450 });
2451
2452 inject_health_actions(&mut output);
2453
2454 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2455 let drift = actions
2456 .iter()
2457 .find(|a| a["type"] == "ownership-drift")
2458 .expect("ownership-drift action should be present");
2459 assert!(drift["description"].as_str().unwrap().contains("alice@x"));
2460 }
2461
2462 #[test]
2465 fn codeowners_pattern_uses_deepest_directory() {
2466 assert_eq!(
2469 suggest_codeowners_pattern("src/api/users/handlers.ts"),
2470 "/src/api/users/"
2471 );
2472 }
2473
2474 #[test]
2475 fn codeowners_pattern_for_root_file() {
2476 assert_eq!(suggest_codeowners_pattern("README.md"), "/README.md");
2477 }
2478
2479 #[test]
2480 fn codeowners_pattern_normalizes_backslashes() {
2481 assert_eq!(
2482 suggest_codeowners_pattern("src\\api\\users.ts"),
2483 "/src/api/"
2484 );
2485 }
2486
2487 #[test]
2488 fn codeowners_pattern_two_level_path() {
2489 assert_eq!(suggest_codeowners_pattern("src/foo.ts"), "/src/");
2490 }
2491
2492 #[test]
2493 fn health_finding_suppress_has_placement() {
2494 let mut output = serde_json::json!({
2495 "findings": [{
2496 "path": "src/utils.ts",
2497 "name": "processData",
2498 "line": 10,
2499 "col": 0,
2500 "cyclomatic": 25,
2501 "cognitive": 30,
2502 "line_count": 150,
2503 "exceeded": "both"
2504 }]
2505 });
2506
2507 inject_health_actions(&mut output);
2508
2509 let suppress = &output["findings"][0]["actions"][1];
2510 assert_eq!(suppress["placement"], "above-function-declaration");
2511 }
2512
2513 #[test]
2516 fn clone_family_has_actions() {
2517 let mut output = serde_json::json!({
2518 "clone_families": [{
2519 "files": ["src/a.ts", "src/b.ts"],
2520 "groups": [
2521 { "instances": [{"file": "src/a.ts"}, {"file": "src/b.ts"}], "token_count": 100, "line_count": 20 }
2522 ],
2523 "total_duplicated_lines": 20,
2524 "total_duplicated_tokens": 100,
2525 "suggestions": [
2526 { "kind": "ExtractFunction", "description": "Extract shared validation logic", "estimated_savings": 15 }
2527 ]
2528 }]
2529 });
2530
2531 inject_dupes_actions(&mut output);
2532
2533 let actions = output["clone_families"][0]["actions"].as_array().unwrap();
2534 assert_eq!(actions.len(), 3);
2535 assert_eq!(actions[0]["type"], "extract-shared");
2536 assert_eq!(actions[0]["auto_fixable"], false);
2537 assert!(
2538 actions[0]["description"]
2539 .as_str()
2540 .unwrap()
2541 .contains("20 lines")
2542 );
2543 assert_eq!(actions[1]["type"], "apply-suggestion");
2545 assert!(
2546 actions[1]["description"]
2547 .as_str()
2548 .unwrap()
2549 .contains("validation logic")
2550 );
2551 assert_eq!(actions[2]["type"], "suppress-line");
2553 assert_eq!(
2554 actions[2]["comment"],
2555 "// fallow-ignore-next-line code-duplication"
2556 );
2557 }
2558
2559 #[test]
2560 fn clone_group_has_actions() {
2561 let mut output = serde_json::json!({
2562 "clone_groups": [{
2563 "instances": [
2564 {"file": "src/a.ts", "start_line": 1, "end_line": 10},
2565 {"file": "src/b.ts", "start_line": 5, "end_line": 14}
2566 ],
2567 "token_count": 50,
2568 "line_count": 10
2569 }]
2570 });
2571
2572 inject_dupes_actions(&mut output);
2573
2574 let actions = output["clone_groups"][0]["actions"].as_array().unwrap();
2575 assert_eq!(actions.len(), 2);
2576 assert_eq!(actions[0]["type"], "extract-shared");
2577 assert!(
2578 actions[0]["description"]
2579 .as_str()
2580 .unwrap()
2581 .contains("10 lines")
2582 );
2583 assert!(
2584 actions[0]["description"]
2585 .as_str()
2586 .unwrap()
2587 .contains("2 instances")
2588 );
2589 assert_eq!(actions[1]["type"], "suppress-line");
2590 }
2591
2592 #[test]
2593 fn dupes_empty_results_no_actions() {
2594 let mut output = serde_json::json!({
2595 "clone_families": [],
2596 "clone_groups": []
2597 });
2598
2599 inject_dupes_actions(&mut output);
2600
2601 assert!(output["clone_families"].as_array().unwrap().is_empty());
2602 assert!(output["clone_groups"].as_array().unwrap().is_empty());
2603 }
2604}