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