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
548const SECONDARY_REFACTOR_BAND: u16 = 5;
558
559#[derive(Debug, Clone, Copy, Default)]
574pub struct HealthActionOptions {
575 pub omit_suppress_line: bool,
577 pub omit_reason: Option<&'static str>,
582}
583
584#[allow(
591 clippy::redundant_pub_crate,
592 reason = "pub(crate) needed, used by audit.rs via re-export, but not part of public API"
593)]
594pub(crate) fn inject_health_actions(output: &mut serde_json::Value, opts: HealthActionOptions) {
595 let Some(map) = output.as_object_mut() else {
596 return;
597 };
598
599 let max_cyclomatic_threshold = map
602 .get("summary")
603 .and_then(|s| s.get("max_cyclomatic_threshold"))
604 .and_then(serde_json::Value::as_u64)
605 .and_then(|v| u16::try_from(v).ok())
606 .unwrap_or(20);
607 let max_cognitive_threshold = map
608 .get("summary")
609 .and_then(|s| s.get("max_cognitive_threshold"))
610 .and_then(serde_json::Value::as_u64)
611 .and_then(|v| u16::try_from(v).ok())
612 .unwrap_or(15);
613 let max_crap_threshold = map
614 .get("summary")
615 .and_then(|s| s.get("max_crap_threshold"))
616 .and_then(serde_json::Value::as_f64)
617 .unwrap_or(30.0);
618
619 if let Some(findings) = map.get_mut("findings").and_then(|v| v.as_array_mut()) {
621 for item in findings {
622 let actions = build_health_finding_actions(
623 item,
624 opts,
625 max_cyclomatic_threshold,
626 max_cognitive_threshold,
627 max_crap_threshold,
628 );
629 if let serde_json::Value::Object(obj) = item {
630 obj.insert("actions".to_string(), actions);
631 }
632 }
633 }
634
635 if let Some(targets) = map.get_mut("targets").and_then(|v| v.as_array_mut()) {
637 for item in targets {
638 let actions = build_refactoring_target_actions(item);
639 if let serde_json::Value::Object(obj) = item {
640 obj.insert("actions".to_string(), actions);
641 }
642 }
643 }
644
645 if let Some(hotspots) = map.get_mut("hotspots").and_then(|v| v.as_array_mut()) {
647 for item in hotspots {
648 let actions = build_hotspot_actions(item);
649 if let serde_json::Value::Object(obj) = item {
650 obj.insert("actions".to_string(), actions);
651 }
652 }
653 }
654
655 if let Some(gaps) = map.get_mut("coverage_gaps").and_then(|v| v.as_object_mut()) {
657 if let Some(files) = gaps.get_mut("files").and_then(|v| v.as_array_mut()) {
658 for item in files {
659 let actions = build_untested_file_actions(item);
660 if let serde_json::Value::Object(obj) = item {
661 obj.insert("actions".to_string(), actions);
662 }
663 }
664 }
665 if let Some(exports) = gaps.get_mut("exports").and_then(|v| v.as_array_mut()) {
666 for item in exports {
667 let actions = build_untested_export_actions(item);
668 if let serde_json::Value::Object(obj) = item {
669 obj.insert("actions".to_string(), actions);
670 }
671 }
672 }
673 }
674
675 if opts.omit_suppress_line {
683 let reason = opts.omit_reason.unwrap_or("unspecified");
684 map.insert(
685 "actions_meta".to_string(),
686 serde_json::json!({
687 "suppression_hints_omitted": true,
688 "reason": reason,
689 "scope": "health-findings",
690 }),
691 );
692 }
693}
694
695fn build_health_finding_actions(
719 item: &serde_json::Value,
720 opts: HealthActionOptions,
721 max_cyclomatic_threshold: u16,
722 max_cognitive_threshold: u16,
723 max_crap_threshold: f64,
724) -> serde_json::Value {
725 let name = item
726 .get("name")
727 .and_then(serde_json::Value::as_str)
728 .unwrap_or("function");
729 let path = item
730 .get("path")
731 .and_then(serde_json::Value::as_str)
732 .unwrap_or("");
733 let exceeded = item
734 .get("exceeded")
735 .and_then(serde_json::Value::as_str)
736 .unwrap_or("");
737 let includes_crap = matches!(
738 exceeded,
739 "crap" | "cyclomatic_crap" | "cognitive_crap" | "all"
740 );
741 let crap_only = exceeded == "crap";
742 let tier = item
743 .get("coverage_tier")
744 .and_then(serde_json::Value::as_str);
745 let cyclomatic = item
746 .get("cyclomatic")
747 .and_then(serde_json::Value::as_u64)
748 .and_then(|v| u16::try_from(v).ok())
749 .unwrap_or(0);
750 let cognitive = item
751 .get("cognitive")
752 .and_then(serde_json::Value::as_u64)
753 .and_then(|v| u16::try_from(v).ok())
754 .unwrap_or(0);
755 let full_coverage_can_clear_crap = !includes_crap || f64::from(cyclomatic) < max_crap_threshold;
756
757 let mut actions: Vec<serde_json::Value> = Vec::new();
758
759 if includes_crap {
761 let coverage_action = build_crap_coverage_action(name, tier, full_coverage_can_clear_crap);
762 if let Some(action) = coverage_action {
763 actions.push(action);
764 }
765 }
766
767 let crap_only_needs_complexity_reduction = crap_only && !full_coverage_can_clear_crap;
783 let cognitive_floor = max_cognitive_threshold / 2;
784 let near_cyclomatic_threshold = crap_only
785 && cyclomatic > 0
786 && cyclomatic >= max_cyclomatic_threshold.saturating_sub(SECONDARY_REFACTOR_BAND)
787 && cognitive >= cognitive_floor;
788 let is_template = name == "<template>";
789 if !crap_only || crap_only_needs_complexity_reduction || near_cyclomatic_threshold {
790 let (description, note) = if is_template {
791 (
792 format!(
793 "Refactor `{name}` to reduce template complexity (simplify control flow and bindings)"
794 ),
795 "Consider splitting complex template branches into smaller components or simpler bindings",
796 )
797 } else {
798 (
799 format!(
800 "Refactor `{name}` to reduce complexity (extract helper functions, simplify branching)"
801 ),
802 "Consider splitting into smaller functions with single responsibilities",
803 )
804 };
805 actions.push(serde_json::json!({
806 "type": "refactor-function",
807 "auto_fixable": false,
808 "description": description,
809 "note": note,
810 }));
811 }
812
813 if !opts.omit_suppress_line {
814 if is_template
815 && Path::new(path)
816 .extension()
817 .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
818 {
819 actions.push(serde_json::json!({
820 "type": "suppress-file",
821 "auto_fixable": false,
822 "description": "Suppress with an HTML comment at the top of the template",
823 "comment": "<!-- fallow-ignore-file complexity -->",
824 "placement": "top-of-template",
825 }));
826 } else if is_template {
827 actions.push(serde_json::json!({
828 "type": "suppress-line",
829 "auto_fixable": false,
830 "description": "Suppress with an inline comment above the Angular decorator",
831 "comment": "// fallow-ignore-next-line complexity",
832 "placement": "above-angular-decorator",
833 }));
834 } else {
835 actions.push(serde_json::json!({
836 "type": "suppress-line",
837 "auto_fixable": false,
838 "description": "Suppress with an inline comment above the function declaration",
839 "comment": "// fallow-ignore-next-line complexity",
840 "placement": "above-function-declaration",
841 }));
842 }
843 }
844
845 serde_json::Value::Array(actions)
846}
847
848fn build_crap_coverage_action(
854 name: &str,
855 tier: Option<&str>,
856 full_coverage_can_clear_crap: bool,
857) -> Option<serde_json::Value> {
858 if !full_coverage_can_clear_crap {
859 return None;
860 }
861
862 match tier {
863 Some("partial" | "high") => Some(serde_json::json!({
868 "type": "increase-coverage",
869 "auto_fixable": false,
870 "description": format!("Increase test coverage for `{name}` (file is reachable from existing tests; add targeted assertions for uncovered branches)"),
871 "note": "CRAP = CC^2 * (1 - cov/100)^3 + CC; targeted branch coverage is more efficient than scaffolding new test files when the file already has coverage",
872 })),
873 _ => Some(serde_json::json!({
875 "type": "add-tests",
876 "auto_fixable": false,
877 "description": format!("Add test coverage for `{name}` to lower its CRAP score (coverage reduces risk even without refactoring)"),
878 "note": "CRAP = CC^2 * (1 - cov/100)^3 + CC; higher coverage is the fastest way to bring CRAP under threshold",
879 })),
880 }
881}
882
883fn build_hotspot_actions(item: &serde_json::Value) -> serde_json::Value {
885 let path = item
886 .get("path")
887 .and_then(serde_json::Value::as_str)
888 .unwrap_or("file");
889
890 let mut actions = vec![
891 serde_json::json!({
892 "type": "refactor-file",
893 "auto_fixable": false,
894 "description": format!("Refactor `{path}`, high complexity combined with frequent changes makes this a maintenance risk"),
895 "note": "Prioritize extracting complex functions, adding tests, or splitting the module",
896 }),
897 serde_json::json!({
898 "type": "add-tests",
899 "auto_fixable": false,
900 "description": format!("Add test coverage for `{path}` to reduce change risk"),
901 "note": "Frequently changed complex files benefit most from comprehensive test coverage",
902 }),
903 ];
904
905 if let Some(ownership) = item.get("ownership") {
906 if ownership
908 .get("bus_factor")
909 .and_then(serde_json::Value::as_u64)
910 == Some(1)
911 {
912 let top = ownership.get("top_contributor");
913 let owner = top
914 .and_then(|t| t.get("identifier"))
915 .and_then(serde_json::Value::as_str)
916 .unwrap_or("the sole contributor");
917 let commits = top
922 .and_then(|t| t.get("commits"))
923 .and_then(serde_json::Value::as_u64)
924 .unwrap_or(0);
925 let suggested: Vec<String> = ownership
931 .get("suggested_reviewers")
932 .and_then(serde_json::Value::as_array)
933 .map(|arr| {
934 arr.iter()
935 .filter_map(|r| {
936 r.get("identifier")
937 .and_then(serde_json::Value::as_str)
938 .map(String::from)
939 })
940 .collect()
941 })
942 .unwrap_or_default();
943 let mut low_bus_action = serde_json::json!({
944 "type": "low-bus-factor",
945 "auto_fixable": false,
946 "description": format!(
947 "{owner} is the sole recent contributor to `{path}`; adding a second reviewer reduces knowledge-loss risk"
948 ),
949 });
950 if !suggested.is_empty() {
951 let list = suggested
952 .iter()
953 .map(|s| format!("@{s}"))
954 .collect::<Vec<_>>()
955 .join(", ");
956 low_bus_action["note"] =
957 serde_json::Value::String(format!("Candidate reviewers: {list}"));
958 } else if commits < 5 {
959 low_bus_action["note"] = serde_json::Value::String(
960 "Single recent contributor on a low-commit file. Consider a pair review for major changes."
961 .to_string(),
962 );
963 }
964 actions.push(low_bus_action);
966 }
967
968 if ownership
971 .get("unowned")
972 .and_then(serde_json::Value::as_bool)
973 == Some(true)
974 {
975 actions.push(serde_json::json!({
976 "type": "unowned-hotspot",
977 "auto_fixable": false,
978 "description": format!("Add a CODEOWNERS entry for `{path}`"),
979 "note": "Frequently-changed files without declared owners create review bottlenecks",
980 "suggested_pattern": suggest_codeowners_pattern(path),
981 "heuristic": "directory-deepest",
982 }));
983 }
984
985 if ownership.get("drift").and_then(serde_json::Value::as_bool) == Some(true) {
988 let reason = ownership
989 .get("drift_reason")
990 .and_then(serde_json::Value::as_str)
991 .unwrap_or("ownership has shifted from the original author");
992 actions.push(serde_json::json!({
993 "type": "ownership-drift",
994 "auto_fixable": false,
995 "description": format!("Update CODEOWNERS for `{path}`: {reason}"),
996 "note": "Drift suggests the declared or original owner is no longer the right reviewer",
997 }));
998 }
999 }
1000
1001 serde_json::Value::Array(actions)
1002}
1003
1004fn suggest_codeowners_pattern(path: &str) -> String {
1017 let normalized = path.replace('\\', "/");
1018 let trimmed = normalized.trim_start_matches('/');
1019 let mut components: Vec<&str> = trimmed.split('/').collect();
1020 components.pop(); if components.is_empty() {
1022 return format!("/{trimmed}");
1023 }
1024 format!("/{}/", components.join("/"))
1025}
1026
1027fn build_refactoring_target_actions(item: &serde_json::Value) -> serde_json::Value {
1029 let recommendation = item
1030 .get("recommendation")
1031 .and_then(serde_json::Value::as_str)
1032 .unwrap_or("Apply the recommended refactoring");
1033
1034 let category = item
1035 .get("category")
1036 .and_then(serde_json::Value::as_str)
1037 .unwrap_or("refactoring");
1038
1039 let mut actions = vec![serde_json::json!({
1040 "type": "apply-refactoring",
1041 "auto_fixable": false,
1042 "description": recommendation,
1043 "category": category,
1044 })];
1045
1046 if item.get("evidence").is_some() {
1048 actions.push(serde_json::json!({
1049 "type": "suppress-line",
1050 "auto_fixable": false,
1051 "description": "Suppress the underlying complexity finding",
1052 "comment": "// fallow-ignore-next-line complexity",
1053 }));
1054 }
1055
1056 serde_json::Value::Array(actions)
1057}
1058
1059fn build_untested_file_actions(item: &serde_json::Value) -> serde_json::Value {
1061 let path = item
1062 .get("path")
1063 .and_then(serde_json::Value::as_str)
1064 .unwrap_or("file");
1065
1066 serde_json::Value::Array(vec![
1067 serde_json::json!({
1068 "type": "add-tests",
1069 "auto_fixable": false,
1070 "description": format!("Add test coverage for `{path}`"),
1071 "note": "No test dependency path reaches this runtime file",
1072 }),
1073 serde_json::json!({
1074 "type": "suppress-file",
1075 "auto_fixable": false,
1076 "description": format!("Suppress coverage gap reporting for `{path}`"),
1077 "comment": "// fallow-ignore-file coverage-gaps",
1078 }),
1079 ])
1080}
1081
1082fn build_untested_export_actions(item: &serde_json::Value) -> serde_json::Value {
1084 let path = item
1085 .get("path")
1086 .and_then(serde_json::Value::as_str)
1087 .unwrap_or("file");
1088 let export_name = item
1089 .get("export_name")
1090 .and_then(serde_json::Value::as_str)
1091 .unwrap_or("export");
1092
1093 serde_json::Value::Array(vec![
1094 serde_json::json!({
1095 "type": "add-test-import",
1096 "auto_fixable": false,
1097 "description": format!("Import and test `{export_name}` from `{path}`"),
1098 "note": "This export is runtime-reachable but no test-reachable module references it",
1099 }),
1100 serde_json::json!({
1101 "type": "suppress-file",
1102 "auto_fixable": false,
1103 "description": format!("Suppress coverage gap reporting for `{path}`"),
1104 "comment": "// fallow-ignore-file coverage-gaps",
1105 }),
1106 ])
1107}
1108
1109#[allow(
1116 clippy::redundant_pub_crate,
1117 reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
1118)]
1119pub(crate) fn inject_dupes_actions(output: &mut serde_json::Value) {
1120 let Some(map) = output.as_object_mut() else {
1121 return;
1122 };
1123
1124 if let Some(families) = map.get_mut("clone_families").and_then(|v| v.as_array_mut()) {
1126 for item in families {
1127 let actions = build_clone_family_actions(item);
1128 if let serde_json::Value::Object(obj) = item {
1129 obj.insert("actions".to_string(), actions);
1130 }
1131 }
1132 }
1133
1134 if let Some(groups) = map.get_mut("clone_groups").and_then(|v| v.as_array_mut()) {
1136 for item in groups {
1137 let actions = build_clone_group_actions(item);
1138 if let serde_json::Value::Object(obj) = item {
1139 obj.insert("actions".to_string(), actions);
1140 }
1141 }
1142 }
1143}
1144
1145fn build_clone_family_actions(item: &serde_json::Value) -> serde_json::Value {
1147 let group_count = item
1148 .get("groups")
1149 .and_then(|v| v.as_array())
1150 .map_or(0, Vec::len);
1151
1152 let total_lines = item
1153 .get("total_duplicated_lines")
1154 .and_then(serde_json::Value::as_u64)
1155 .unwrap_or(0);
1156
1157 let mut actions = vec![serde_json::json!({
1158 "type": "extract-shared",
1159 "auto_fixable": false,
1160 "description": format!(
1161 "Extract {group_count} duplicated code block{} ({total_lines} lines) into a shared module",
1162 if group_count == 1 { "" } else { "s" }
1163 ),
1164 "note": "These clone groups share the same files, indicating a structural relationship — refactor together",
1165 })];
1166
1167 if let Some(suggestions) = item.get("suggestions").and_then(|v| v.as_array()) {
1169 for suggestion in suggestions {
1170 if let Some(desc) = suggestion
1171 .get("description")
1172 .and_then(serde_json::Value::as_str)
1173 {
1174 actions.push(serde_json::json!({
1175 "type": "apply-suggestion",
1176 "auto_fixable": false,
1177 "description": desc,
1178 }));
1179 }
1180 }
1181 }
1182
1183 actions.push(serde_json::json!({
1184 "type": "suppress-line",
1185 "auto_fixable": false,
1186 "description": "Suppress with an inline comment above the duplicated code",
1187 "comment": "// fallow-ignore-next-line code-duplication",
1188 }));
1189
1190 serde_json::Value::Array(actions)
1191}
1192
1193fn build_clone_group_actions(item: &serde_json::Value) -> serde_json::Value {
1195 let instance_count = item
1196 .get("instances")
1197 .and_then(|v| v.as_array())
1198 .map_or(0, Vec::len);
1199
1200 let line_count = item
1201 .get("line_count")
1202 .and_then(serde_json::Value::as_u64)
1203 .unwrap_or(0);
1204
1205 let actions = vec![
1206 serde_json::json!({
1207 "type": "extract-shared",
1208 "auto_fixable": false,
1209 "description": format!(
1210 "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
1211 if instance_count == 1 { "" } else { "s" }
1212 ),
1213 }),
1214 serde_json::json!({
1215 "type": "suppress-line",
1216 "auto_fixable": false,
1217 "description": "Suppress with an inline comment above the duplicated code",
1218 "comment": "// fallow-ignore-next-line code-duplication",
1219 }),
1220 ];
1221
1222 serde_json::Value::Array(actions)
1223}
1224
1225fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
1227 if let serde_json::Value::Object(map) = output {
1228 map.insert("_meta".to_string(), meta);
1229 }
1230}
1231
1232pub fn build_health_json(
1240 report: &crate::health_types::HealthReport,
1241 root: &Path,
1242 elapsed: Duration,
1243 explain: bool,
1244 action_opts: HealthActionOptions,
1245) -> Result<serde_json::Value, serde_json::Error> {
1246 let report_value = serde_json::to_value(report)?;
1247 let mut output = build_json_envelope(report_value, elapsed);
1248 let root_prefix = format!("{}/", root.display());
1249 strip_root_prefix(&mut output, &root_prefix);
1250 inject_health_actions(&mut output, action_opts);
1251 if explain {
1252 insert_meta(&mut output, explain::health_meta());
1253 }
1254 Ok(output)
1255}
1256
1257pub(super) fn print_health_json(
1258 report: &crate::health_types::HealthReport,
1259 root: &Path,
1260 elapsed: Duration,
1261 explain: bool,
1262 action_opts: HealthActionOptions,
1263) -> ExitCode {
1264 match build_health_json(report, root, elapsed, explain, action_opts) {
1265 Ok(output) => emit_json(&output, "JSON"),
1266 Err(e) => {
1267 eprintln!("Error: failed to serialize health report: {e}");
1268 ExitCode::from(2)
1269 }
1270 }
1271}
1272
1273pub fn build_grouped_health_json(
1293 report: &crate::health_types::HealthReport,
1294 grouping: &crate::health_types::HealthGrouping,
1295 root: &Path,
1296 elapsed: Duration,
1297 explain: bool,
1298 action_opts: HealthActionOptions,
1299) -> Result<serde_json::Value, serde_json::Error> {
1300 let root_prefix = format!("{}/", root.display());
1301 let report_value = serde_json::to_value(report)?;
1302 let mut output = build_json_envelope(report_value, elapsed);
1303 strip_root_prefix(&mut output, &root_prefix);
1304 inject_health_actions(&mut output, action_opts);
1305
1306 if let serde_json::Value::Object(ref mut map) = output {
1307 map.insert("grouped_by".to_string(), serde_json::json!(grouping.mode));
1308 }
1309
1310 let group_values: Vec<serde_json::Value> = grouping
1318 .groups
1319 .iter()
1320 .map(|g| {
1321 let mut value = serde_json::to_value(g)?;
1322 strip_root_prefix(&mut value, &root_prefix);
1323 inject_health_actions(&mut value, action_opts);
1324 Ok(value)
1325 })
1326 .collect::<Result<_, serde_json::Error>>()?;
1327
1328 if let serde_json::Value::Object(ref mut map) = output {
1329 map.insert("groups".to_string(), serde_json::Value::Array(group_values));
1330 }
1331
1332 if explain {
1333 insert_meta(&mut output, explain::health_meta());
1334 }
1335
1336 Ok(output)
1337}
1338
1339pub(super) fn print_grouped_health_json(
1340 report: &crate::health_types::HealthReport,
1341 grouping: &crate::health_types::HealthGrouping,
1342 root: &Path,
1343 elapsed: Duration,
1344 explain: bool,
1345 action_opts: HealthActionOptions,
1346) -> ExitCode {
1347 match build_grouped_health_json(report, grouping, root, elapsed, explain, action_opts) {
1348 Ok(output) => emit_json(&output, "JSON"),
1349 Err(e) => {
1350 eprintln!("Error: failed to serialize grouped health report: {e}");
1351 ExitCode::from(2)
1352 }
1353 }
1354}
1355
1356pub fn build_duplication_json(
1363 report: &DuplicationReport,
1364 root: &Path,
1365 elapsed: Duration,
1366 explain: bool,
1367) -> Result<serde_json::Value, serde_json::Error> {
1368 let report_value = serde_json::to_value(report)?;
1369
1370 let mut output = build_json_envelope(report_value, elapsed);
1371 let root_prefix = format!("{}/", root.display());
1372 strip_root_prefix(&mut output, &root_prefix);
1373 inject_dupes_actions(&mut output);
1374
1375 if explain {
1376 insert_meta(&mut output, explain::dupes_meta());
1377 }
1378
1379 Ok(output)
1380}
1381
1382pub(super) fn print_duplication_json(
1383 report: &DuplicationReport,
1384 root: &Path,
1385 elapsed: Duration,
1386 explain: bool,
1387) -> ExitCode {
1388 match build_duplication_json(report, root, elapsed, explain) {
1389 Ok(output) => emit_json(&output, "JSON"),
1390 Err(e) => {
1391 eprintln!("Error: failed to serialize duplication report: {e}");
1392 ExitCode::from(2)
1393 }
1394 }
1395}
1396
1397pub fn build_grouped_duplication_json(
1418 report: &DuplicationReport,
1419 grouping: &super::dupes_grouping::DuplicationGrouping,
1420 root: &Path,
1421 elapsed: Duration,
1422 explain: bool,
1423) -> Result<serde_json::Value, serde_json::Error> {
1424 let report_value = serde_json::to_value(report)?;
1425 let mut output = build_json_envelope(report_value, elapsed);
1426 let root_prefix = format!("{}/", root.display());
1427 strip_root_prefix(&mut output, &root_prefix);
1428 inject_dupes_actions(&mut output);
1429
1430 if let serde_json::Value::Object(ref mut map) = output {
1431 map.insert("grouped_by".to_string(), serde_json::json!(grouping.mode));
1432 map.insert(
1438 "total_issues".to_string(),
1439 serde_json::json!(report.clone_groups.len()),
1440 );
1441 }
1442
1443 let group_values: Vec<serde_json::Value> = grouping
1444 .groups
1445 .iter()
1446 .map(|g| {
1447 let mut value = serde_json::to_value(g)?;
1448 strip_root_prefix(&mut value, &root_prefix);
1449 inject_dupes_actions(&mut value);
1450 Ok(value)
1451 })
1452 .collect::<Result<_, serde_json::Error>>()?;
1453
1454 if let serde_json::Value::Object(ref mut map) = output {
1455 map.insert("groups".to_string(), serde_json::Value::Array(group_values));
1456 }
1457
1458 if explain {
1459 insert_meta(&mut output, explain::dupes_meta());
1460 }
1461
1462 Ok(output)
1463}
1464
1465pub(super) fn print_grouped_duplication_json(
1466 report: &DuplicationReport,
1467 grouping: &super::dupes_grouping::DuplicationGrouping,
1468 root: &Path,
1469 elapsed: Duration,
1470 explain: bool,
1471) -> ExitCode {
1472 match build_grouped_duplication_json(report, grouping, root, elapsed, explain) {
1473 Ok(output) => emit_json(&output, "JSON"),
1474 Err(e) => {
1475 eprintln!("Error: failed to serialize grouped duplication report: {e}");
1476 ExitCode::from(2)
1477 }
1478 }
1479}
1480
1481pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
1482 match serde_json::to_string_pretty(value) {
1483 Ok(json) => println!("{json}"),
1484 Err(e) => {
1485 eprintln!("Error: failed to serialize trace output: {e}");
1486 #[expect(
1487 clippy::exit,
1488 reason = "fatal serialization error requires immediate exit"
1489 )]
1490 std::process::exit(2);
1491 }
1492 }
1493}
1494
1495#[cfg(test)]
1496mod tests {
1497 use super::*;
1498 use crate::health_types::{
1499 RuntimeCoverageAction, RuntimeCoverageConfidence, RuntimeCoverageEvidence,
1500 RuntimeCoverageFinding, RuntimeCoverageHotPath, RuntimeCoverageMessage,
1501 RuntimeCoverageReport, RuntimeCoverageReportVerdict, RuntimeCoverageSummary,
1502 RuntimeCoverageVerdict, RuntimeCoverageWatermark,
1503 };
1504 use crate::report::test_helpers::sample_results;
1505 use fallow_core::extract::MemberKind;
1506 use fallow_core::results::*;
1507 use std::path::PathBuf;
1508 use std::time::Duration;
1509
1510 #[test]
1511 fn json_output_has_metadata_fields() {
1512 let root = PathBuf::from("/project");
1513 let results = AnalysisResults::default();
1514 let elapsed = Duration::from_millis(123);
1515 let output = build_json(&results, &root, elapsed).expect("should serialize");
1516
1517 assert_eq!(output["schema_version"], 4);
1518 assert!(output["version"].is_string());
1519 assert_eq!(output["elapsed_ms"], 123);
1520 assert_eq!(output["total_issues"], 0);
1521 }
1522
1523 #[test]
1524 fn json_output_includes_issue_arrays() {
1525 let root = PathBuf::from("/project");
1526 let results = sample_results(&root);
1527 let elapsed = Duration::from_millis(50);
1528 let output = build_json(&results, &root, elapsed).expect("should serialize");
1529
1530 assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
1531 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
1532 assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
1533 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
1534 assert_eq!(
1535 output["unused_dev_dependencies"].as_array().unwrap().len(),
1536 1
1537 );
1538 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
1539 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
1540 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
1541 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
1542 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
1543 assert_eq!(
1544 output["type_only_dependencies"].as_array().unwrap().len(),
1545 1
1546 );
1547 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
1548 }
1549
1550 #[test]
1551 fn health_json_includes_runtime_coverage_with_relative_paths_and_actions() {
1552 let root = PathBuf::from("/project");
1553 let report = crate::health_types::HealthReport {
1554 runtime_coverage: Some(RuntimeCoverageReport {
1555 verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
1556 summary: RuntimeCoverageSummary {
1557 functions_tracked: 3,
1558 functions_hit: 1,
1559 functions_unhit: 1,
1560 functions_untracked: 1,
1561 coverage_percent: 33.3,
1562 trace_count: 2_847_291,
1563 period_days: 30,
1564 deployments_seen: 14,
1565 capture_quality: Some(crate::health_types::RuntimeCoverageCaptureQuality {
1566 window_seconds: 720,
1567 instances_observed: 1,
1568 lazy_parse_warning: true,
1569 untracked_ratio_percent: 42.5,
1570 }),
1571 },
1572 findings: vec![RuntimeCoverageFinding {
1573 id: "fallow:prod:deadbeef".to_owned(),
1574 path: root.join("src/cold.ts"),
1575 function: "coldPath".to_owned(),
1576 line: 12,
1577 verdict: RuntimeCoverageVerdict::ReviewRequired,
1578 invocations: Some(0),
1579 confidence: RuntimeCoverageConfidence::Medium,
1580 evidence: RuntimeCoverageEvidence {
1581 static_status: "used".to_owned(),
1582 test_coverage: "not_covered".to_owned(),
1583 v8_tracking: "tracked".to_owned(),
1584 untracked_reason: None,
1585 observation_days: 30,
1586 deployments_observed: 14,
1587 },
1588 actions: vec![RuntimeCoverageAction {
1589 kind: "review-deletion".to_owned(),
1590 description: "Tracked in runtime coverage with zero invocations."
1591 .to_owned(),
1592 auto_fixable: false,
1593 }],
1594 }],
1595 hot_paths: vec![RuntimeCoverageHotPath {
1596 id: "fallow:hot:cafebabe".to_owned(),
1597 path: root.join("src/hot.ts"),
1598 function: "hotPath".to_owned(),
1599 line: 3,
1600 invocations: 250,
1601 percentile: 99,
1602 actions: vec![],
1603 }],
1604 watermark: Some(RuntimeCoverageWatermark::LicenseExpiredGrace),
1605 warnings: vec![RuntimeCoverageMessage {
1606 code: "partial-merge".to_owned(),
1607 message: "Merged coverage omitted one chunk.".to_owned(),
1608 }],
1609 }),
1610 ..Default::default()
1611 };
1612
1613 let report_value = serde_json::to_value(&report).expect("should serialize health report");
1614 let mut output = build_json_envelope(report_value, Duration::from_millis(7));
1615 strip_root_prefix(&mut output, "/project/");
1616 inject_health_actions(&mut output, HealthActionOptions::default());
1617
1618 assert_eq!(
1619 output["runtime_coverage"]["verdict"],
1620 serde_json::Value::String("cold-code-detected".to_owned())
1621 );
1622 assert_eq!(
1623 output["runtime_coverage"]["summary"]["functions_tracked"],
1624 serde_json::Value::from(3)
1625 );
1626 assert_eq!(
1627 output["runtime_coverage"]["summary"]["coverage_percent"],
1628 serde_json::Value::from(33.3)
1629 );
1630 let finding = &output["runtime_coverage"]["findings"][0];
1631 assert_eq!(finding["path"], "src/cold.ts");
1632 assert_eq!(finding["verdict"], "review_required");
1633 assert_eq!(finding["id"], "fallow:prod:deadbeef");
1634 assert_eq!(finding["actions"][0]["type"], "review-deletion");
1635 let hot_path = &output["runtime_coverage"]["hot_paths"][0];
1636 assert_eq!(hot_path["path"], "src/hot.ts");
1637 assert_eq!(hot_path["function"], "hotPath");
1638 assert_eq!(hot_path["percentile"], 99);
1639 assert_eq!(
1640 output["runtime_coverage"]["watermark"],
1641 serde_json::Value::String("license-expired-grace".to_owned())
1642 );
1643 assert_eq!(
1644 output["runtime_coverage"]["warnings"][0]["code"],
1645 serde_json::Value::String("partial-merge".to_owned())
1646 );
1647 }
1648
1649 #[test]
1650 fn json_metadata_fields_appear_first() {
1651 let root = PathBuf::from("/project");
1652 let results = AnalysisResults::default();
1653 let elapsed = Duration::from_millis(0);
1654 let output = build_json(&results, &root, elapsed).expect("should serialize");
1655 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1656 assert_eq!(keys[0], "schema_version");
1657 assert_eq!(keys[1], "version");
1658 assert_eq!(keys[2], "elapsed_ms");
1659 assert_eq!(keys[3], "total_issues");
1660 }
1661
1662 #[test]
1663 fn json_total_issues_matches_results() {
1664 let root = PathBuf::from("/project");
1665 let results = sample_results(&root);
1666 let total = results.total_issues();
1667 let elapsed = Duration::from_millis(0);
1668 let output = build_json(&results, &root, elapsed).expect("should serialize");
1669
1670 assert_eq!(output["total_issues"], total);
1671 }
1672
1673 #[test]
1674 fn json_unused_export_contains_expected_fields() {
1675 let root = PathBuf::from("/project");
1676 let mut results = AnalysisResults::default();
1677 results.unused_exports.push(UnusedExport {
1678 path: root.join("src/utils.ts"),
1679 export_name: "helperFn".to_string(),
1680 is_type_only: false,
1681 line: 10,
1682 col: 4,
1683 span_start: 120,
1684 is_re_export: false,
1685 });
1686 let elapsed = Duration::from_millis(0);
1687 let output = build_json(&results, &root, elapsed).expect("should serialize");
1688
1689 let export = &output["unused_exports"][0];
1690 assert_eq!(export["export_name"], "helperFn");
1691 assert_eq!(export["line"], 10);
1692 assert_eq!(export["col"], 4);
1693 assert_eq!(export["is_type_only"], false);
1694 assert_eq!(export["span_start"], 120);
1695 assert_eq!(export["is_re_export"], false);
1696 }
1697
1698 #[test]
1699 fn json_serializes_to_valid_json() {
1700 let root = PathBuf::from("/project");
1701 let results = sample_results(&root);
1702 let elapsed = Duration::from_millis(42);
1703 let output = build_json(&results, &root, elapsed).expect("should serialize");
1704
1705 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
1706 let reparsed: serde_json::Value =
1707 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
1708 assert_eq!(reparsed, output);
1709 }
1710
1711 #[test]
1714 fn json_empty_results_produce_valid_structure() {
1715 let root = PathBuf::from("/project");
1716 let results = AnalysisResults::default();
1717 let elapsed = Duration::from_millis(0);
1718 let output = build_json(&results, &root, elapsed).expect("should serialize");
1719
1720 assert_eq!(output["total_issues"], 0);
1721 assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
1722 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
1723 assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
1724 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
1725 assert_eq!(
1726 output["unused_dev_dependencies"].as_array().unwrap().len(),
1727 0
1728 );
1729 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
1730 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
1731 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
1732 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
1733 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
1734 assert_eq!(
1735 output["type_only_dependencies"].as_array().unwrap().len(),
1736 0
1737 );
1738 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
1739 }
1740
1741 #[test]
1742 fn json_empty_results_round_trips_through_string() {
1743 let root = PathBuf::from("/project");
1744 let results = AnalysisResults::default();
1745 let elapsed = Duration::from_millis(0);
1746 let output = build_json(&results, &root, elapsed).expect("should serialize");
1747
1748 let json_str = serde_json::to_string(&output).expect("should stringify");
1749 let reparsed: serde_json::Value =
1750 serde_json::from_str(&json_str).expect("should parse back");
1751 assert_eq!(reparsed["total_issues"], 0);
1752 }
1753
1754 #[test]
1757 fn json_paths_are_relative_to_root() {
1758 let root = PathBuf::from("/project");
1759 let mut results = AnalysisResults::default();
1760 results.unused_files.push(UnusedFile {
1761 path: root.join("src/deep/nested/file.ts"),
1762 });
1763 let elapsed = Duration::from_millis(0);
1764 let output = build_json(&results, &root, elapsed).expect("should serialize");
1765
1766 let path = output["unused_files"][0]["path"].as_str().unwrap();
1767 assert_eq!(path, "src/deep/nested/file.ts");
1768 assert!(!path.starts_with("/project"));
1769 }
1770
1771 #[test]
1772 fn json_strips_root_from_nested_locations() {
1773 let root = PathBuf::from("/project");
1774 let mut results = AnalysisResults::default();
1775 results.unlisted_dependencies.push(UnlistedDependency {
1776 package_name: "chalk".to_string(),
1777 imported_from: vec![ImportSite {
1778 path: root.join("src/cli.ts"),
1779 line: 2,
1780 col: 0,
1781 }],
1782 });
1783 let elapsed = Duration::from_millis(0);
1784 let output = build_json(&results, &root, elapsed).expect("should serialize");
1785
1786 let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
1787 .as_str()
1788 .unwrap();
1789 assert_eq!(site_path, "src/cli.ts");
1790 }
1791
1792 #[test]
1793 fn json_strips_root_from_duplicate_export_locations() {
1794 let root = PathBuf::from("/project");
1795 let mut results = AnalysisResults::default();
1796 results.duplicate_exports.push(DuplicateExport {
1797 export_name: "Config".to_string(),
1798 locations: vec![
1799 DuplicateLocation {
1800 path: root.join("src/config.ts"),
1801 line: 15,
1802 col: 0,
1803 },
1804 DuplicateLocation {
1805 path: root.join("src/types.ts"),
1806 line: 30,
1807 col: 0,
1808 },
1809 ],
1810 });
1811 let elapsed = Duration::from_millis(0);
1812 let output = build_json(&results, &root, elapsed).expect("should serialize");
1813
1814 let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
1815 .as_str()
1816 .unwrap();
1817 let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
1818 .as_str()
1819 .unwrap();
1820 assert_eq!(loc0, "src/config.ts");
1821 assert_eq!(loc1, "src/types.ts");
1822 }
1823
1824 #[test]
1825 fn json_strips_root_from_circular_dependency_files() {
1826 let root = PathBuf::from("/project");
1827 let mut results = AnalysisResults::default();
1828 results.circular_dependencies.push(CircularDependency {
1829 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1830 length: 2,
1831 line: 1,
1832 col: 0,
1833 is_cross_package: false,
1834 });
1835 let elapsed = Duration::from_millis(0);
1836 let output = build_json(&results, &root, elapsed).expect("should serialize");
1837
1838 let files = output["circular_dependencies"][0]["files"]
1839 .as_array()
1840 .unwrap();
1841 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
1842 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
1843 }
1844
1845 #[test]
1846 fn json_path_outside_root_not_stripped() {
1847 let root = PathBuf::from("/project");
1848 let mut results = AnalysisResults::default();
1849 results.unused_files.push(UnusedFile {
1850 path: PathBuf::from("/other/project/src/file.ts"),
1851 });
1852 let elapsed = Duration::from_millis(0);
1853 let output = build_json(&results, &root, elapsed).expect("should serialize");
1854
1855 let path = output["unused_files"][0]["path"].as_str().unwrap();
1856 assert!(path.contains("/other/project/"));
1857 }
1858
1859 #[test]
1862 fn json_unused_file_contains_path() {
1863 let root = PathBuf::from("/project");
1864 let mut results = AnalysisResults::default();
1865 results.unused_files.push(UnusedFile {
1866 path: root.join("src/orphan.ts"),
1867 });
1868 let elapsed = Duration::from_millis(0);
1869 let output = build_json(&results, &root, elapsed).expect("should serialize");
1870
1871 let file = &output["unused_files"][0];
1872 assert_eq!(file["path"], "src/orphan.ts");
1873 }
1874
1875 #[test]
1876 fn json_unused_type_contains_expected_fields() {
1877 let root = PathBuf::from("/project");
1878 let mut results = AnalysisResults::default();
1879 results.unused_types.push(UnusedExport {
1880 path: root.join("src/types.ts"),
1881 export_name: "OldInterface".to_string(),
1882 is_type_only: true,
1883 line: 20,
1884 col: 0,
1885 span_start: 300,
1886 is_re_export: false,
1887 });
1888 let elapsed = Duration::from_millis(0);
1889 let output = build_json(&results, &root, elapsed).expect("should serialize");
1890
1891 let typ = &output["unused_types"][0];
1892 assert_eq!(typ["export_name"], "OldInterface");
1893 assert_eq!(typ["is_type_only"], true);
1894 assert_eq!(typ["line"], 20);
1895 assert_eq!(typ["path"], "src/types.ts");
1896 }
1897
1898 #[test]
1899 fn json_unused_dependency_contains_expected_fields() {
1900 let root = PathBuf::from("/project");
1901 let mut results = AnalysisResults::default();
1902 results.unused_dependencies.push(UnusedDependency {
1903 package_name: "axios".to_string(),
1904 location: DependencyLocation::Dependencies,
1905 path: root.join("package.json"),
1906 line: 10,
1907 });
1908 let elapsed = Duration::from_millis(0);
1909 let output = build_json(&results, &root, elapsed).expect("should serialize");
1910
1911 let dep = &output["unused_dependencies"][0];
1912 assert_eq!(dep["package_name"], "axios");
1913 assert_eq!(dep["line"], 10);
1914 }
1915
1916 #[test]
1917 fn json_unused_dev_dependency_contains_expected_fields() {
1918 let root = PathBuf::from("/project");
1919 let mut results = AnalysisResults::default();
1920 results.unused_dev_dependencies.push(UnusedDependency {
1921 package_name: "vitest".to_string(),
1922 location: DependencyLocation::DevDependencies,
1923 path: root.join("package.json"),
1924 line: 15,
1925 });
1926 let elapsed = Duration::from_millis(0);
1927 let output = build_json(&results, &root, elapsed).expect("should serialize");
1928
1929 let dep = &output["unused_dev_dependencies"][0];
1930 assert_eq!(dep["package_name"], "vitest");
1931 }
1932
1933 #[test]
1934 fn json_unused_optional_dependency_contains_expected_fields() {
1935 let root = PathBuf::from("/project");
1936 let mut results = AnalysisResults::default();
1937 results.unused_optional_dependencies.push(UnusedDependency {
1938 package_name: "fsevents".to_string(),
1939 location: DependencyLocation::OptionalDependencies,
1940 path: root.join("package.json"),
1941 line: 12,
1942 });
1943 let elapsed = Duration::from_millis(0);
1944 let output = build_json(&results, &root, elapsed).expect("should serialize");
1945
1946 let dep = &output["unused_optional_dependencies"][0];
1947 assert_eq!(dep["package_name"], "fsevents");
1948 assert_eq!(output["total_issues"], 1);
1949 }
1950
1951 #[test]
1952 fn json_unused_enum_member_contains_expected_fields() {
1953 let root = PathBuf::from("/project");
1954 let mut results = AnalysisResults::default();
1955 results.unused_enum_members.push(UnusedMember {
1956 path: root.join("src/enums.ts"),
1957 parent_name: "Color".to_string(),
1958 member_name: "Purple".to_string(),
1959 kind: MemberKind::EnumMember,
1960 line: 5,
1961 col: 2,
1962 });
1963 let elapsed = Duration::from_millis(0);
1964 let output = build_json(&results, &root, elapsed).expect("should serialize");
1965
1966 let member = &output["unused_enum_members"][0];
1967 assert_eq!(member["parent_name"], "Color");
1968 assert_eq!(member["member_name"], "Purple");
1969 assert_eq!(member["line"], 5);
1970 assert_eq!(member["path"], "src/enums.ts");
1971 }
1972
1973 #[test]
1974 fn json_unused_class_member_contains_expected_fields() {
1975 let root = PathBuf::from("/project");
1976 let mut results = AnalysisResults::default();
1977 results.unused_class_members.push(UnusedMember {
1978 path: root.join("src/api.ts"),
1979 parent_name: "ApiClient".to_string(),
1980 member_name: "deprecatedFetch".to_string(),
1981 kind: MemberKind::ClassMethod,
1982 line: 100,
1983 col: 4,
1984 });
1985 let elapsed = Duration::from_millis(0);
1986 let output = build_json(&results, &root, elapsed).expect("should serialize");
1987
1988 let member = &output["unused_class_members"][0];
1989 assert_eq!(member["parent_name"], "ApiClient");
1990 assert_eq!(member["member_name"], "deprecatedFetch");
1991 assert_eq!(member["line"], 100);
1992 }
1993
1994 #[test]
1995 fn json_unresolved_import_contains_expected_fields() {
1996 let root = PathBuf::from("/project");
1997 let mut results = AnalysisResults::default();
1998 results.unresolved_imports.push(UnresolvedImport {
1999 path: root.join("src/app.ts"),
2000 specifier: "@acme/missing-pkg".to_string(),
2001 line: 7,
2002 col: 0,
2003 specifier_col: 0,
2004 });
2005 let elapsed = Duration::from_millis(0);
2006 let output = build_json(&results, &root, elapsed).expect("should serialize");
2007
2008 let import = &output["unresolved_imports"][0];
2009 assert_eq!(import["specifier"], "@acme/missing-pkg");
2010 assert_eq!(import["line"], 7);
2011 assert_eq!(import["path"], "src/app.ts");
2012 }
2013
2014 #[test]
2015 fn json_unlisted_dependency_contains_import_sites() {
2016 let root = PathBuf::from("/project");
2017 let mut results = AnalysisResults::default();
2018 results.unlisted_dependencies.push(UnlistedDependency {
2019 package_name: "dotenv".to_string(),
2020 imported_from: vec![
2021 ImportSite {
2022 path: root.join("src/config.ts"),
2023 line: 1,
2024 col: 0,
2025 },
2026 ImportSite {
2027 path: root.join("src/server.ts"),
2028 line: 3,
2029 col: 0,
2030 },
2031 ],
2032 });
2033 let elapsed = Duration::from_millis(0);
2034 let output = build_json(&results, &root, elapsed).expect("should serialize");
2035
2036 let dep = &output["unlisted_dependencies"][0];
2037 assert_eq!(dep["package_name"], "dotenv");
2038 let sites = dep["imported_from"].as_array().unwrap();
2039 assert_eq!(sites.len(), 2);
2040 assert_eq!(sites[0]["path"], "src/config.ts");
2041 assert_eq!(sites[1]["path"], "src/server.ts");
2042 }
2043
2044 #[test]
2045 fn json_duplicate_export_contains_locations() {
2046 let root = PathBuf::from("/project");
2047 let mut results = AnalysisResults::default();
2048 results.duplicate_exports.push(DuplicateExport {
2049 export_name: "Button".to_string(),
2050 locations: vec![
2051 DuplicateLocation {
2052 path: root.join("src/ui.ts"),
2053 line: 10,
2054 col: 0,
2055 },
2056 DuplicateLocation {
2057 path: root.join("src/components.ts"),
2058 line: 25,
2059 col: 0,
2060 },
2061 ],
2062 });
2063 let elapsed = Duration::from_millis(0);
2064 let output = build_json(&results, &root, elapsed).expect("should serialize");
2065
2066 let dup = &output["duplicate_exports"][0];
2067 assert_eq!(dup["export_name"], "Button");
2068 let locs = dup["locations"].as_array().unwrap();
2069 assert_eq!(locs.len(), 2);
2070 assert_eq!(locs[0]["line"], 10);
2071 assert_eq!(locs[1]["line"], 25);
2072 }
2073
2074 #[test]
2075 fn json_type_only_dependency_contains_expected_fields() {
2076 let root = PathBuf::from("/project");
2077 let mut results = AnalysisResults::default();
2078 results.type_only_dependencies.push(TypeOnlyDependency {
2079 package_name: "zod".to_string(),
2080 path: root.join("package.json"),
2081 line: 8,
2082 });
2083 let elapsed = Duration::from_millis(0);
2084 let output = build_json(&results, &root, elapsed).expect("should serialize");
2085
2086 let dep = &output["type_only_dependencies"][0];
2087 assert_eq!(dep["package_name"], "zod");
2088 assert_eq!(dep["line"], 8);
2089 }
2090
2091 #[test]
2092 fn json_circular_dependency_contains_expected_fields() {
2093 let root = PathBuf::from("/project");
2094 let mut results = AnalysisResults::default();
2095 results.circular_dependencies.push(CircularDependency {
2096 files: vec![
2097 root.join("src/a.ts"),
2098 root.join("src/b.ts"),
2099 root.join("src/c.ts"),
2100 ],
2101 length: 3,
2102 line: 5,
2103 col: 0,
2104 is_cross_package: false,
2105 });
2106 let elapsed = Duration::from_millis(0);
2107 let output = build_json(&results, &root, elapsed).expect("should serialize");
2108
2109 let cycle = &output["circular_dependencies"][0];
2110 assert_eq!(cycle["length"], 3);
2111 assert_eq!(cycle["line"], 5);
2112 let files = cycle["files"].as_array().unwrap();
2113 assert_eq!(files.len(), 3);
2114 }
2115
2116 #[test]
2119 fn json_re_export_flagged_correctly() {
2120 let root = PathBuf::from("/project");
2121 let mut results = AnalysisResults::default();
2122 results.unused_exports.push(UnusedExport {
2123 path: root.join("src/index.ts"),
2124 export_name: "reExported".to_string(),
2125 is_type_only: false,
2126 line: 1,
2127 col: 0,
2128 span_start: 0,
2129 is_re_export: true,
2130 });
2131 let elapsed = Duration::from_millis(0);
2132 let output = build_json(&results, &root, elapsed).expect("should serialize");
2133
2134 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
2135 }
2136
2137 #[test]
2140 fn json_schema_version_is_4() {
2141 let root = PathBuf::from("/project");
2142 let results = AnalysisResults::default();
2143 let elapsed = Duration::from_millis(0);
2144 let output = build_json(&results, &root, elapsed).expect("should serialize");
2145
2146 assert_eq!(output["schema_version"], SCHEMA_VERSION);
2147 assert_eq!(output["schema_version"], 4);
2148 }
2149
2150 #[test]
2153 fn json_version_matches_cargo_pkg_version() {
2154 let root = PathBuf::from("/project");
2155 let results = AnalysisResults::default();
2156 let elapsed = Duration::from_millis(0);
2157 let output = build_json(&results, &root, elapsed).expect("should serialize");
2158
2159 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
2160 }
2161
2162 #[test]
2165 fn json_elapsed_ms_zero_duration() {
2166 let root = PathBuf::from("/project");
2167 let results = AnalysisResults::default();
2168 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
2169
2170 assert_eq!(output["elapsed_ms"], 0);
2171 }
2172
2173 #[test]
2174 fn json_elapsed_ms_large_duration() {
2175 let root = PathBuf::from("/project");
2176 let results = AnalysisResults::default();
2177 let elapsed = Duration::from_mins(2);
2178 let output = build_json(&results, &root, elapsed).expect("should serialize");
2179
2180 assert_eq!(output["elapsed_ms"], 120_000);
2181 }
2182
2183 #[test]
2184 fn json_elapsed_ms_sub_millisecond_truncated() {
2185 let root = PathBuf::from("/project");
2186 let results = AnalysisResults::default();
2187 let elapsed = Duration::from_micros(500);
2189 let output = build_json(&results, &root, elapsed).expect("should serialize");
2190
2191 assert_eq!(output["elapsed_ms"], 0);
2192 }
2193
2194 #[test]
2197 fn json_multiple_unused_files() {
2198 let root = PathBuf::from("/project");
2199 let mut results = AnalysisResults::default();
2200 results.unused_files.push(UnusedFile {
2201 path: root.join("src/a.ts"),
2202 });
2203 results.unused_files.push(UnusedFile {
2204 path: root.join("src/b.ts"),
2205 });
2206 results.unused_files.push(UnusedFile {
2207 path: root.join("src/c.ts"),
2208 });
2209 let elapsed = Duration::from_millis(0);
2210 let output = build_json(&results, &root, elapsed).expect("should serialize");
2211
2212 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
2213 assert_eq!(output["total_issues"], 3);
2214 }
2215
2216 #[test]
2219 fn strip_root_prefix_on_string_value() {
2220 let mut value = serde_json::json!("/project/src/file.ts");
2221 strip_root_prefix(&mut value, "/project/");
2222 assert_eq!(value, "src/file.ts");
2223 }
2224
2225 #[test]
2226 fn strip_root_prefix_leaves_non_matching_string() {
2227 let mut value = serde_json::json!("/other/src/file.ts");
2228 strip_root_prefix(&mut value, "/project/");
2229 assert_eq!(value, "/other/src/file.ts");
2230 }
2231
2232 #[test]
2233 fn strip_root_prefix_recurses_into_arrays() {
2234 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
2235 strip_root_prefix(&mut value, "/project/");
2236 assert_eq!(value[0], "a.ts");
2237 assert_eq!(value[1], "b.ts");
2238 assert_eq!(value[2], "/other/c.ts");
2239 }
2240
2241 #[test]
2242 fn strip_root_prefix_recurses_into_nested_objects() {
2243 let mut value = serde_json::json!({
2244 "outer": {
2245 "path": "/project/src/nested.ts"
2246 }
2247 });
2248 strip_root_prefix(&mut value, "/project/");
2249 assert_eq!(value["outer"]["path"], "src/nested.ts");
2250 }
2251
2252 #[test]
2253 fn strip_root_prefix_leaves_numbers_and_booleans() {
2254 let mut value = serde_json::json!({
2255 "line": 42,
2256 "is_type_only": false,
2257 "path": "/project/src/file.ts"
2258 });
2259 strip_root_prefix(&mut value, "/project/");
2260 assert_eq!(value["line"], 42);
2261 assert_eq!(value["is_type_only"], false);
2262 assert_eq!(value["path"], "src/file.ts");
2263 }
2264
2265 #[test]
2266 fn strip_root_prefix_normalizes_windows_separators() {
2267 let mut value = serde_json::json!(r"/project\src\file.ts");
2268 strip_root_prefix(&mut value, "/project/");
2269 assert_eq!(value, "src/file.ts");
2270 }
2271
2272 #[test]
2273 fn strip_root_prefix_handles_empty_string_after_strip() {
2274 let mut value = serde_json::json!("/project/");
2277 strip_root_prefix(&mut value, "/project/");
2278 assert_eq!(value, "");
2279 }
2280
2281 #[test]
2282 fn strip_root_prefix_deeply_nested_array_of_objects() {
2283 let mut value = serde_json::json!({
2284 "groups": [{
2285 "instances": [{
2286 "file": "/project/src/a.ts"
2287 }, {
2288 "file": "/project/src/b.ts"
2289 }]
2290 }]
2291 });
2292 strip_root_prefix(&mut value, "/project/");
2293 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
2294 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
2295 }
2296
2297 #[test]
2300 fn json_full_sample_results_total_issues_correct() {
2301 let root = PathBuf::from("/project");
2302 let results = sample_results(&root);
2303 let elapsed = Duration::from_millis(100);
2304 let output = build_json(&results, &root, elapsed).expect("should serialize");
2305
2306 assert_eq!(output["total_issues"], results.total_issues());
2312 }
2313
2314 #[test]
2315 fn json_full_sample_no_absolute_paths_in_output() {
2316 let root = PathBuf::from("/project");
2317 let results = sample_results(&root);
2318 let elapsed = Duration::from_millis(0);
2319 let output = build_json(&results, &root, elapsed).expect("should serialize");
2320
2321 let json_str = serde_json::to_string(&output).expect("should stringify");
2322 assert!(!json_str.contains("/project/src/"));
2324 assert!(!json_str.contains("/project/package.json"));
2325 }
2326
2327 #[test]
2330 fn json_output_is_deterministic() {
2331 let root = PathBuf::from("/project");
2332 let results = sample_results(&root);
2333 let elapsed = Duration::from_millis(50);
2334
2335 let output1 = build_json(&results, &root, elapsed).expect("first build");
2336 let output2 = build_json(&results, &root, elapsed).expect("second build");
2337
2338 assert_eq!(output1, output2);
2339 }
2340
2341 #[test]
2344 fn json_results_fields_do_not_shadow_metadata() {
2345 let root = PathBuf::from("/project");
2348 let results = AnalysisResults::default();
2349 let elapsed = Duration::from_millis(99);
2350 let output = build_json(&results, &root, elapsed).expect("should serialize");
2351
2352 assert_eq!(output["schema_version"], 4);
2354 assert_eq!(output["elapsed_ms"], 99);
2355 }
2356
2357 #[test]
2360 fn json_all_issue_type_arrays_present_in_empty_results() {
2361 let root = PathBuf::from("/project");
2362 let results = AnalysisResults::default();
2363 let elapsed = Duration::from_millis(0);
2364 let output = build_json(&results, &root, elapsed).expect("should serialize");
2365
2366 let expected_arrays = [
2367 "unused_files",
2368 "unused_exports",
2369 "unused_types",
2370 "unused_dependencies",
2371 "unused_dev_dependencies",
2372 "unused_optional_dependencies",
2373 "unused_enum_members",
2374 "unused_class_members",
2375 "unresolved_imports",
2376 "unlisted_dependencies",
2377 "duplicate_exports",
2378 "type_only_dependencies",
2379 "test_only_dependencies",
2380 "circular_dependencies",
2381 ];
2382 for key in &expected_arrays {
2383 assert!(
2384 output[key].is_array(),
2385 "expected '{key}' to be an array in JSON output"
2386 );
2387 }
2388 }
2389
2390 #[test]
2393 fn insert_meta_adds_key_to_object() {
2394 let mut output = serde_json::json!({ "foo": 1 });
2395 let meta = serde_json::json!({ "docs": "https://example.com" });
2396 insert_meta(&mut output, meta.clone());
2397 assert_eq!(output["_meta"], meta);
2398 }
2399
2400 #[test]
2401 fn insert_meta_noop_on_non_object() {
2402 let mut output = serde_json::json!([1, 2, 3]);
2403 let meta = serde_json::json!({ "docs": "https://example.com" });
2404 insert_meta(&mut output, meta);
2405 assert!(output.is_array());
2407 }
2408
2409 #[test]
2410 fn insert_meta_overwrites_existing_meta() {
2411 let mut output = serde_json::json!({ "_meta": "old" });
2412 let meta = serde_json::json!({ "new": true });
2413 insert_meta(&mut output, meta.clone());
2414 assert_eq!(output["_meta"], meta);
2415 }
2416
2417 #[test]
2420 fn build_json_envelope_has_metadata_fields() {
2421 let report = serde_json::json!({ "findings": [] });
2422 let elapsed = Duration::from_millis(42);
2423 let output = build_json_envelope(report, elapsed);
2424
2425 assert_eq!(output["schema_version"], 4);
2426 assert!(output["version"].is_string());
2427 assert_eq!(output["elapsed_ms"], 42);
2428 assert!(output["findings"].is_array());
2429 }
2430
2431 #[test]
2432 fn build_json_envelope_metadata_appears_first() {
2433 let report = serde_json::json!({ "data": "value" });
2434 let output = build_json_envelope(report, Duration::from_millis(10));
2435
2436 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
2437 assert_eq!(keys[0], "schema_version");
2438 assert_eq!(keys[1], "version");
2439 assert_eq!(keys[2], "elapsed_ms");
2440 }
2441
2442 #[test]
2443 fn build_json_envelope_non_object_report() {
2444 let report = serde_json::json!("not an object");
2446 let output = build_json_envelope(report, Duration::from_millis(0));
2447
2448 let obj = output.as_object().unwrap();
2449 assert_eq!(obj.len(), 3);
2450 assert!(obj.contains_key("schema_version"));
2451 assert!(obj.contains_key("version"));
2452 assert!(obj.contains_key("elapsed_ms"));
2453 }
2454
2455 #[test]
2458 fn strip_root_prefix_null_unchanged() {
2459 let mut value = serde_json::Value::Null;
2460 strip_root_prefix(&mut value, "/project/");
2461 assert!(value.is_null());
2462 }
2463
2464 #[test]
2467 fn strip_root_prefix_empty_string() {
2468 let mut value = serde_json::json!("");
2469 strip_root_prefix(&mut value, "/project/");
2470 assert_eq!(value, "");
2471 }
2472
2473 #[test]
2476 fn strip_root_prefix_mixed_types() {
2477 let mut value = serde_json::json!({
2478 "path": "/project/src/file.ts",
2479 "line": 42,
2480 "flag": true,
2481 "nested": {
2482 "items": ["/project/a.ts", 99, null, "/project/b.ts"],
2483 "deep": { "path": "/project/c.ts" }
2484 }
2485 });
2486 strip_root_prefix(&mut value, "/project/");
2487 assert_eq!(value["path"], "src/file.ts");
2488 assert_eq!(value["line"], 42);
2489 assert_eq!(value["flag"], true);
2490 assert_eq!(value["nested"]["items"][0], "a.ts");
2491 assert_eq!(value["nested"]["items"][1], 99);
2492 assert!(value["nested"]["items"][2].is_null());
2493 assert_eq!(value["nested"]["items"][3], "b.ts");
2494 assert_eq!(value["nested"]["deep"]["path"], "c.ts");
2495 }
2496
2497 #[test]
2500 fn json_check_meta_integrates_correctly() {
2501 let root = PathBuf::from("/project");
2502 let results = AnalysisResults::default();
2503 let elapsed = Duration::from_millis(0);
2504 let mut output = build_json(&results, &root, elapsed).expect("should serialize");
2505 insert_meta(&mut output, crate::explain::check_meta());
2506
2507 assert!(output["_meta"]["docs"].is_string());
2508 assert!(output["_meta"]["rules"].is_object());
2509 }
2510
2511 #[test]
2514 fn json_unused_member_kind_serialized() {
2515 let root = PathBuf::from("/project");
2516 let mut results = AnalysisResults::default();
2517 results.unused_enum_members.push(UnusedMember {
2518 path: root.join("src/enums.ts"),
2519 parent_name: "Color".to_string(),
2520 member_name: "Red".to_string(),
2521 kind: MemberKind::EnumMember,
2522 line: 3,
2523 col: 2,
2524 });
2525 results.unused_class_members.push(UnusedMember {
2526 path: root.join("src/class.ts"),
2527 parent_name: "Foo".to_string(),
2528 member_name: "bar".to_string(),
2529 kind: MemberKind::ClassMethod,
2530 line: 10,
2531 col: 4,
2532 });
2533
2534 let elapsed = Duration::from_millis(0);
2535 let output = build_json(&results, &root, elapsed).expect("should serialize");
2536
2537 let enum_member = &output["unused_enum_members"][0];
2538 assert!(enum_member["kind"].is_string());
2539 let class_member = &output["unused_class_members"][0];
2540 assert!(class_member["kind"].is_string());
2541 }
2542
2543 #[test]
2546 fn json_unused_export_has_actions() {
2547 let root = PathBuf::from("/project");
2548 let mut results = AnalysisResults::default();
2549 results.unused_exports.push(UnusedExport {
2550 path: root.join("src/utils.ts"),
2551 export_name: "helperFn".to_string(),
2552 is_type_only: false,
2553 line: 10,
2554 col: 4,
2555 span_start: 120,
2556 is_re_export: false,
2557 });
2558 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2559
2560 let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2561 assert_eq!(actions.len(), 2);
2562
2563 assert_eq!(actions[0]["type"], "remove-export");
2565 assert_eq!(actions[0]["auto_fixable"], true);
2566 assert!(actions[0].get("note").is_none());
2567
2568 assert_eq!(actions[1]["type"], "suppress-line");
2570 assert_eq!(
2571 actions[1]["comment"],
2572 "// fallow-ignore-next-line unused-export"
2573 );
2574 }
2575
2576 #[test]
2577 fn json_unused_file_has_file_suppress_and_note() {
2578 let root = PathBuf::from("/project");
2579 let mut results = AnalysisResults::default();
2580 results.unused_files.push(UnusedFile {
2581 path: root.join("src/dead.ts"),
2582 });
2583 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2584
2585 let actions = output["unused_files"][0]["actions"].as_array().unwrap();
2586 assert_eq!(actions[0]["type"], "delete-file");
2587 assert_eq!(actions[0]["auto_fixable"], false);
2588 assert!(actions[0]["note"].is_string());
2589 assert_eq!(actions[1]["type"], "suppress-file");
2590 assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
2591 }
2592
2593 #[test]
2594 fn json_unused_dependency_has_config_suppress_with_package_name() {
2595 let root = PathBuf::from("/project");
2596 let mut results = AnalysisResults::default();
2597 results.unused_dependencies.push(UnusedDependency {
2598 package_name: "lodash".to_string(),
2599 location: DependencyLocation::Dependencies,
2600 path: root.join("package.json"),
2601 line: 5,
2602 });
2603 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2604
2605 let actions = output["unused_dependencies"][0]["actions"]
2606 .as_array()
2607 .unwrap();
2608 assert_eq!(actions[0]["type"], "remove-dependency");
2609 assert_eq!(actions[0]["auto_fixable"], true);
2610
2611 assert_eq!(actions[1]["type"], "add-to-config");
2613 assert_eq!(actions[1]["config_key"], "ignoreDependencies");
2614 assert_eq!(actions[1]["value"], "lodash");
2615 }
2616
2617 #[test]
2618 fn json_empty_results_have_no_actions_in_empty_arrays() {
2619 let root = PathBuf::from("/project");
2620 let results = AnalysisResults::default();
2621 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2622
2623 assert!(output["unused_exports"].as_array().unwrap().is_empty());
2625 assert!(output["unused_files"].as_array().unwrap().is_empty());
2626 }
2627
2628 #[test]
2629 fn json_all_issue_types_have_actions() {
2630 let root = PathBuf::from("/project");
2631 let results = sample_results(&root);
2632 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2633
2634 let issue_keys = [
2635 "unused_files",
2636 "unused_exports",
2637 "unused_types",
2638 "unused_dependencies",
2639 "unused_dev_dependencies",
2640 "unused_optional_dependencies",
2641 "unused_enum_members",
2642 "unused_class_members",
2643 "unresolved_imports",
2644 "unlisted_dependencies",
2645 "duplicate_exports",
2646 "type_only_dependencies",
2647 "test_only_dependencies",
2648 "circular_dependencies",
2649 ];
2650
2651 for key in &issue_keys {
2652 let arr = output[key].as_array().unwrap();
2653 if !arr.is_empty() {
2654 let actions = arr[0]["actions"].as_array();
2655 assert!(
2656 actions.is_some() && !actions.unwrap().is_empty(),
2657 "missing actions for {key}"
2658 );
2659 }
2660 }
2661 }
2662
2663 #[test]
2666 fn health_finding_has_actions() {
2667 let mut output = serde_json::json!({
2668 "findings": [{
2669 "path": "src/utils.ts",
2670 "name": "processData",
2671 "line": 10,
2672 "col": 0,
2673 "cyclomatic": 25,
2674 "cognitive": 30,
2675 "line_count": 150,
2676 "exceeded": "both"
2677 }]
2678 });
2679
2680 inject_health_actions(&mut output, HealthActionOptions::default());
2681
2682 let actions = output["findings"][0]["actions"].as_array().unwrap();
2683 assert_eq!(actions.len(), 2);
2684 assert_eq!(actions[0]["type"], "refactor-function");
2685 assert_eq!(actions[0]["auto_fixable"], false);
2686 assert!(
2687 actions[0]["description"]
2688 .as_str()
2689 .unwrap()
2690 .contains("processData")
2691 );
2692 assert_eq!(actions[1]["type"], "suppress-line");
2693 assert_eq!(
2694 actions[1]["comment"],
2695 "// fallow-ignore-next-line complexity"
2696 );
2697 }
2698
2699 #[test]
2700 fn refactoring_target_has_actions() {
2701 let mut output = serde_json::json!({
2702 "targets": [{
2703 "path": "src/big-module.ts",
2704 "priority": 85.0,
2705 "efficiency": 42.5,
2706 "recommendation": "Split module: 12 exports, 4 unused",
2707 "category": "split_high_impact",
2708 "effort": "medium",
2709 "confidence": "high",
2710 "evidence": { "unused_exports": 4 }
2711 }]
2712 });
2713
2714 inject_health_actions(&mut output, HealthActionOptions::default());
2715
2716 let actions = output["targets"][0]["actions"].as_array().unwrap();
2717 assert_eq!(actions.len(), 2);
2718 assert_eq!(actions[0]["type"], "apply-refactoring");
2719 assert_eq!(
2720 actions[0]["description"],
2721 "Split module: 12 exports, 4 unused"
2722 );
2723 assert_eq!(actions[0]["category"], "split_high_impact");
2724 assert_eq!(actions[1]["type"], "suppress-line");
2726 }
2727
2728 #[test]
2729 fn refactoring_target_without_evidence_has_no_suppress() {
2730 let mut output = serde_json::json!({
2731 "targets": [{
2732 "path": "src/simple.ts",
2733 "priority": 30.0,
2734 "efficiency": 15.0,
2735 "recommendation": "Consider extracting helper functions",
2736 "category": "extract_complex_functions",
2737 "effort": "small",
2738 "confidence": "medium"
2739 }]
2740 });
2741
2742 inject_health_actions(&mut output, HealthActionOptions::default());
2743
2744 let actions = output["targets"][0]["actions"].as_array().unwrap();
2745 assert_eq!(actions.len(), 1);
2746 assert_eq!(actions[0]["type"], "apply-refactoring");
2747 }
2748
2749 #[test]
2750 fn health_empty_findings_no_actions() {
2751 let mut output = serde_json::json!({
2752 "findings": [],
2753 "targets": []
2754 });
2755
2756 inject_health_actions(&mut output, HealthActionOptions::default());
2757
2758 assert!(output["findings"].as_array().unwrap().is_empty());
2759 assert!(output["targets"].as_array().unwrap().is_empty());
2760 }
2761
2762 #[test]
2763 fn hotspot_has_actions() {
2764 let mut output = serde_json::json!({
2765 "hotspots": [{
2766 "path": "src/utils.ts",
2767 "complexity_score": 45.0,
2768 "churn_score": 12,
2769 "hotspot_score": 540.0
2770 }]
2771 });
2772
2773 inject_health_actions(&mut output, HealthActionOptions::default());
2774
2775 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2776 assert_eq!(actions.len(), 2);
2777 assert_eq!(actions[0]["type"], "refactor-file");
2778 assert!(
2779 actions[0]["description"]
2780 .as_str()
2781 .unwrap()
2782 .contains("src/utils.ts")
2783 );
2784 assert_eq!(actions[1]["type"], "add-tests");
2785 }
2786
2787 #[test]
2788 fn hotspot_low_bus_factor_emits_action() {
2789 let mut output = serde_json::json!({
2790 "hotspots": [{
2791 "path": "src/api.ts",
2792 "ownership": {
2793 "bus_factor": 1,
2794 "contributor_count": 1,
2795 "top_contributor": {"identifier": "alice@x", "share": 1.0, "stale_days": 5, "commits": 30},
2796 "unowned": null,
2797 "drift": false,
2798 }
2799 }]
2800 });
2801
2802 inject_health_actions(&mut output, HealthActionOptions::default());
2803
2804 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2805 assert!(
2806 actions
2807 .iter()
2808 .filter_map(|a| a["type"].as_str())
2809 .any(|t| t == "low-bus-factor"),
2810 "low-bus-factor action should be present",
2811 );
2812 let bus = actions
2813 .iter()
2814 .find(|a| a["type"] == "low-bus-factor")
2815 .unwrap();
2816 assert!(bus["description"].as_str().unwrap().contains("alice@x"));
2817 }
2818
2819 #[test]
2820 fn hotspot_unowned_emits_action_with_pattern() {
2821 let mut output = serde_json::json!({
2822 "hotspots": [{
2823 "path": "src/api/users.ts",
2824 "ownership": {
2825 "bus_factor": 2,
2826 "contributor_count": 4,
2827 "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
2828 "unowned": true,
2829 "drift": false,
2830 }
2831 }]
2832 });
2833
2834 inject_health_actions(&mut output, HealthActionOptions::default());
2835
2836 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2837 let unowned = actions
2838 .iter()
2839 .find(|a| a["type"] == "unowned-hotspot")
2840 .expect("unowned-hotspot action should be present");
2841 assert_eq!(unowned["suggested_pattern"], "/src/api/");
2844 assert_eq!(unowned["heuristic"], "directory-deepest");
2845 }
2846
2847 #[test]
2848 fn hotspot_unowned_skipped_when_codeowners_missing() {
2849 let mut output = serde_json::json!({
2850 "hotspots": [{
2851 "path": "src/api.ts",
2852 "ownership": {
2853 "bus_factor": 2,
2854 "contributor_count": 4,
2855 "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
2856 "unowned": null,
2857 "drift": false,
2858 }
2859 }]
2860 });
2861
2862 inject_health_actions(&mut output, HealthActionOptions::default());
2863
2864 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2865 assert!(
2866 !actions.iter().any(|a| a["type"] == "unowned-hotspot"),
2867 "unowned action must not fire when CODEOWNERS file is absent"
2868 );
2869 }
2870
2871 #[test]
2872 fn hotspot_drift_emits_action() {
2873 let mut output = serde_json::json!({
2874 "hotspots": [{
2875 "path": "src/old.ts",
2876 "ownership": {
2877 "bus_factor": 1,
2878 "contributor_count": 2,
2879 "top_contributor": {"identifier": "bob@x", "share": 0.9, "stale_days": 1, "commits": 18},
2880 "unowned": null,
2881 "drift": true,
2882 "drift_reason": "original author alice@x has 5% share",
2883 }
2884 }]
2885 });
2886
2887 inject_health_actions(&mut output, HealthActionOptions::default());
2888
2889 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2890 let drift = actions
2891 .iter()
2892 .find(|a| a["type"] == "ownership-drift")
2893 .expect("ownership-drift action should be present");
2894 assert!(drift["description"].as_str().unwrap().contains("alice@x"));
2895 }
2896
2897 #[test]
2900 fn codeowners_pattern_uses_deepest_directory() {
2901 assert_eq!(
2904 suggest_codeowners_pattern("src/api/users/handlers.ts"),
2905 "/src/api/users/"
2906 );
2907 }
2908
2909 #[test]
2910 fn codeowners_pattern_for_root_file() {
2911 assert_eq!(suggest_codeowners_pattern("README.md"), "/README.md");
2912 }
2913
2914 #[test]
2915 fn codeowners_pattern_normalizes_backslashes() {
2916 assert_eq!(
2917 suggest_codeowners_pattern("src\\api\\users.ts"),
2918 "/src/api/"
2919 );
2920 }
2921
2922 #[test]
2923 fn codeowners_pattern_two_level_path() {
2924 assert_eq!(suggest_codeowners_pattern("src/foo.ts"), "/src/");
2925 }
2926
2927 #[test]
2928 fn health_finding_suppress_has_placement() {
2929 let mut output = serde_json::json!({
2930 "findings": [{
2931 "path": "src/utils.ts",
2932 "name": "processData",
2933 "line": 10,
2934 "col": 0,
2935 "cyclomatic": 25,
2936 "cognitive": 30,
2937 "line_count": 150,
2938 "exceeded": "both"
2939 }]
2940 });
2941
2942 inject_health_actions(&mut output, HealthActionOptions::default());
2943
2944 let suppress = &output["findings"][0]["actions"][1];
2945 assert_eq!(suppress["placement"], "above-function-declaration");
2946 }
2947
2948 #[test]
2949 fn html_template_health_finding_uses_html_suppression() {
2950 let mut output = serde_json::json!({
2951 "findings": [{
2952 "path": "src/app.component.html",
2953 "name": "<template>",
2954 "line": 1,
2955 "col": 0,
2956 "cyclomatic": 25,
2957 "cognitive": 30,
2958 "line_count": 40,
2959 "exceeded": "both"
2960 }]
2961 });
2962
2963 inject_health_actions(&mut output, HealthActionOptions::default());
2964
2965 let suppress = &output["findings"][0]["actions"][1];
2966 assert_eq!(suppress["type"], "suppress-file");
2967 assert_eq!(
2968 suppress["comment"],
2969 "<!-- fallow-ignore-file complexity -->"
2970 );
2971 assert_eq!(suppress["placement"], "top-of-template");
2972 }
2973
2974 #[test]
2975 fn inline_template_health_finding_uses_decorator_suppression() {
2976 let mut output = serde_json::json!({
2977 "findings": [{
2978 "path": "src/app.component.ts",
2979 "name": "<template>",
2980 "line": 5,
2981 "col": 0,
2982 "cyclomatic": 25,
2983 "cognitive": 30,
2984 "line_count": 40,
2985 "exceeded": "both"
2986 }]
2987 });
2988
2989 inject_health_actions(&mut output, HealthActionOptions::default());
2990
2991 let refactor = &output["findings"][0]["actions"][0];
2992 assert_eq!(refactor["type"], "refactor-function");
2993 assert!(
2994 refactor["description"]
2995 .as_str()
2996 .unwrap()
2997 .contains("template complexity")
2998 );
2999 let suppress = &output["findings"][0]["actions"][1];
3000 assert_eq!(suppress["type"], "suppress-line");
3001 assert_eq!(
3002 suppress["description"],
3003 "Suppress with an inline comment above the Angular decorator"
3004 );
3005 assert_eq!(suppress["placement"], "above-angular-decorator");
3006 }
3007
3008 #[test]
3011 fn clone_family_has_actions() {
3012 let mut output = serde_json::json!({
3013 "clone_families": [{
3014 "files": ["src/a.ts", "src/b.ts"],
3015 "groups": [
3016 { "instances": [{"file": "src/a.ts"}, {"file": "src/b.ts"}], "token_count": 100, "line_count": 20 }
3017 ],
3018 "total_duplicated_lines": 20,
3019 "total_duplicated_tokens": 100,
3020 "suggestions": [
3021 { "kind": "ExtractFunction", "description": "Extract shared validation logic", "estimated_savings": 15 }
3022 ]
3023 }]
3024 });
3025
3026 inject_dupes_actions(&mut output);
3027
3028 let actions = output["clone_families"][0]["actions"].as_array().unwrap();
3029 assert_eq!(actions.len(), 3);
3030 assert_eq!(actions[0]["type"], "extract-shared");
3031 assert_eq!(actions[0]["auto_fixable"], false);
3032 assert!(
3033 actions[0]["description"]
3034 .as_str()
3035 .unwrap()
3036 .contains("20 lines")
3037 );
3038 assert_eq!(actions[1]["type"], "apply-suggestion");
3040 assert!(
3041 actions[1]["description"]
3042 .as_str()
3043 .unwrap()
3044 .contains("validation logic")
3045 );
3046 assert_eq!(actions[2]["type"], "suppress-line");
3048 assert_eq!(
3049 actions[2]["comment"],
3050 "// fallow-ignore-next-line code-duplication"
3051 );
3052 }
3053
3054 #[test]
3055 fn clone_group_has_actions() {
3056 let mut output = serde_json::json!({
3057 "clone_groups": [{
3058 "instances": [
3059 {"file": "src/a.ts", "start_line": 1, "end_line": 10},
3060 {"file": "src/b.ts", "start_line": 5, "end_line": 14}
3061 ],
3062 "token_count": 50,
3063 "line_count": 10
3064 }]
3065 });
3066
3067 inject_dupes_actions(&mut output);
3068
3069 let actions = output["clone_groups"][0]["actions"].as_array().unwrap();
3070 assert_eq!(actions.len(), 2);
3071 assert_eq!(actions[0]["type"], "extract-shared");
3072 assert!(
3073 actions[0]["description"]
3074 .as_str()
3075 .unwrap()
3076 .contains("10 lines")
3077 );
3078 assert!(
3079 actions[0]["description"]
3080 .as_str()
3081 .unwrap()
3082 .contains("2 instances")
3083 );
3084 assert_eq!(actions[1]["type"], "suppress-line");
3085 }
3086
3087 #[test]
3088 fn dupes_empty_results_no_actions() {
3089 let mut output = serde_json::json!({
3090 "clone_families": [],
3091 "clone_groups": []
3092 });
3093
3094 inject_dupes_actions(&mut output);
3095
3096 assert!(output["clone_families"].as_array().unwrap().is_empty());
3097 assert!(output["clone_groups"].as_array().unwrap().is_empty());
3098 }
3099
3100 fn crap_only_finding_envelope(
3109 coverage_tier: Option<&str>,
3110 cyclomatic: u16,
3111 max_cyclomatic_threshold: u16,
3112 ) -> serde_json::Value {
3113 crap_only_finding_envelope_with_max_crap(
3114 coverage_tier,
3115 cyclomatic,
3116 12,
3117 max_cyclomatic_threshold,
3118 15,
3119 30.0,
3120 )
3121 }
3122
3123 fn crap_only_finding_envelope_with_cognitive(
3124 coverage_tier: Option<&str>,
3125 cyclomatic: u16,
3126 cognitive: u16,
3127 max_cyclomatic_threshold: u16,
3128 ) -> serde_json::Value {
3129 crap_only_finding_envelope_with_max_crap(
3130 coverage_tier,
3131 cyclomatic,
3132 cognitive,
3133 max_cyclomatic_threshold,
3134 15,
3135 30.0,
3136 )
3137 }
3138
3139 fn crap_only_finding_envelope_with_max_crap(
3140 coverage_tier: Option<&str>,
3141 cyclomatic: u16,
3142 cognitive: u16,
3143 max_cyclomatic_threshold: u16,
3144 max_cognitive_threshold: u16,
3145 max_crap_threshold: f64,
3146 ) -> serde_json::Value {
3147 let mut finding = serde_json::json!({
3148 "path": "src/risk.ts",
3149 "name": "computeScore",
3150 "line": 12,
3151 "col": 0,
3152 "cyclomatic": cyclomatic,
3153 "cognitive": cognitive,
3154 "line_count": 40,
3155 "exceeded": "crap",
3156 "crap": 35.5,
3157 });
3158 if let Some(tier) = coverage_tier {
3159 finding["coverage_tier"] = serde_json::Value::String(tier.to_owned());
3160 }
3161 serde_json::json!({
3162 "findings": [finding],
3163 "summary": {
3164 "max_cyclomatic_threshold": max_cyclomatic_threshold,
3165 "max_cognitive_threshold": max_cognitive_threshold,
3166 "max_crap_threshold": max_crap_threshold,
3167 },
3168 })
3169 }
3170
3171 #[test]
3172 fn crap_only_tier_none_emits_add_tests() {
3173 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3174 inject_health_actions(&mut output, HealthActionOptions::default());
3175 let actions = output["findings"][0]["actions"].as_array().unwrap();
3176 assert!(
3177 actions.iter().any(|a| a["type"] == "add-tests"),
3178 "tier=none crap-only must emit add-tests, got {actions:?}"
3179 );
3180 assert!(
3181 !actions.iter().any(|a| a["type"] == "increase-coverage"),
3182 "tier=none must not emit increase-coverage"
3183 );
3184 }
3185
3186 #[test]
3187 fn crap_only_tier_partial_emits_increase_coverage() {
3188 let mut output = crap_only_finding_envelope(Some("partial"), 6, 20);
3189 inject_health_actions(&mut output, HealthActionOptions::default());
3190 let actions = output["findings"][0]["actions"].as_array().unwrap();
3191 assert!(
3192 actions.iter().any(|a| a["type"] == "increase-coverage"),
3193 "tier=partial crap-only must emit increase-coverage, got {actions:?}"
3194 );
3195 assert!(
3196 !actions.iter().any(|a| a["type"] == "add-tests"),
3197 "tier=partial must not emit add-tests"
3198 );
3199 }
3200
3201 #[test]
3202 fn crap_only_tier_high_emits_increase_coverage_when_full_coverage_can_clear_crap() {
3203 let mut output = crap_only_finding_envelope(Some("high"), 20, 30);
3207 inject_health_actions(&mut output, HealthActionOptions::default());
3208 let actions = output["findings"][0]["actions"].as_array().unwrap();
3209 assert!(
3210 actions.iter().any(|a| a["type"] == "increase-coverage"),
3211 "tier=high crap-only must still emit increase-coverage when full coverage can clear CRAP, got {actions:?}"
3212 );
3213 assert!(
3214 !actions.iter().any(|a| a["type"] == "refactor-function"),
3215 "coverage-remediable crap-only findings should not get refactor-function unless near the cyclomatic threshold"
3216 );
3217 assert!(
3218 !actions.iter().any(|a| a["type"] == "add-tests"),
3219 "tier=high must not emit add-tests"
3220 );
3221 }
3222
3223 #[test]
3224 fn crap_only_emits_refactor_when_full_coverage_cannot_clear_crap() {
3225 let mut output =
3229 crap_only_finding_envelope_with_max_crap(Some("high"), 35, 12, 50, 15, 30.0);
3230 inject_health_actions(&mut output, HealthActionOptions::default());
3231 let actions = output["findings"][0]["actions"].as_array().unwrap();
3232 assert!(
3233 actions.iter().any(|a| a["type"] == "refactor-function"),
3234 "full-coverage-impossible CRAP-only finding must emit refactor-function, got {actions:?}"
3235 );
3236 assert!(
3237 !actions.iter().any(|a| a["type"] == "increase-coverage"),
3238 "must not emit increase-coverage when even 100% coverage cannot clear CRAP"
3239 );
3240 assert!(
3241 !actions.iter().any(|a| a["type"] == "add-tests"),
3242 "must not emit add-tests when even 100% coverage cannot clear CRAP"
3243 );
3244 }
3245
3246 #[test]
3247 fn crap_only_high_cc_appends_secondary_refactor() {
3248 let mut output = crap_only_finding_envelope(Some("none"), 16, 20);
3251 inject_health_actions(&mut output, HealthActionOptions::default());
3252 let actions = output["findings"][0]["actions"].as_array().unwrap();
3253 assert!(
3254 actions.iter().any(|a| a["type"] == "add-tests"),
3255 "near-threshold crap-only still emits the primary tier action"
3256 );
3257 assert!(
3258 actions.iter().any(|a| a["type"] == "refactor-function"),
3259 "near-threshold crap-only must also emit secondary refactor-function"
3260 );
3261 }
3262
3263 #[test]
3264 fn crap_only_far_below_threshold_no_secondary_refactor() {
3265 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3267 inject_health_actions(&mut output, HealthActionOptions::default());
3268 let actions = output["findings"][0]["actions"].as_array().unwrap();
3269 assert!(
3270 !actions.iter().any(|a| a["type"] == "refactor-function"),
3271 "low-CC crap-only should not get a secondary refactor-function"
3272 );
3273 }
3274
3275 #[test]
3276 fn crap_only_near_threshold_low_cognitive_no_secondary_refactor() {
3277 let mut output = crap_only_finding_envelope_with_cognitive(Some("none"), 17, 2, 20);
3286 inject_health_actions(&mut output, HealthActionOptions::default());
3287 let actions = output["findings"][0]["actions"].as_array().unwrap();
3288 assert!(
3289 actions.iter().any(|a| a["type"] == "add-tests"),
3290 "primary tier action still emits"
3291 );
3292 assert!(
3293 !actions.iter().any(|a| a["type"] == "refactor-function"),
3294 "near-threshold CC with cognitive below floor must NOT emit secondary refactor (got {actions:?})"
3295 );
3296 }
3297
3298 #[test]
3299 fn crap_only_near_threshold_high_cognitive_emits_secondary_refactor() {
3300 let mut output = crap_only_finding_envelope_with_cognitive(Some("none"), 16, 10, 20);
3306 inject_health_actions(&mut output, HealthActionOptions::default());
3307 let actions = output["findings"][0]["actions"].as_array().unwrap();
3308 assert!(
3309 actions.iter().any(|a| a["type"] == "add-tests"),
3310 "primary tier action still emits"
3311 );
3312 assert!(
3313 actions.iter().any(|a| a["type"] == "refactor-function"),
3314 "near-threshold CC with cognitive above floor must emit secondary refactor (got {actions:?})"
3315 );
3316 }
3317
3318 #[test]
3319 fn cyclomatic_only_emits_only_refactor_function() {
3320 let mut output = serde_json::json!({
3321 "findings": [{
3322 "path": "src/cyclo.ts",
3323 "name": "branchy",
3324 "line": 5,
3325 "col": 0,
3326 "cyclomatic": 25,
3327 "cognitive": 10,
3328 "line_count": 80,
3329 "exceeded": "cyclomatic",
3330 }],
3331 "summary": { "max_cyclomatic_threshold": 20 },
3332 });
3333 inject_health_actions(&mut output, HealthActionOptions::default());
3334 let actions = output["findings"][0]["actions"].as_array().unwrap();
3335 assert!(
3336 actions.iter().any(|a| a["type"] == "refactor-function"),
3337 "non-CRAP findings emit refactor-function"
3338 );
3339 assert!(
3340 !actions.iter().any(|a| a["type"] == "add-tests"),
3341 "non-CRAP findings must not emit add-tests"
3342 );
3343 assert!(
3344 !actions.iter().any(|a| a["type"] == "increase-coverage"),
3345 "non-CRAP findings must not emit increase-coverage"
3346 );
3347 }
3348
3349 #[test]
3352 fn suppress_line_omitted_when_baseline_active() {
3353 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3354 inject_health_actions(
3355 &mut output,
3356 HealthActionOptions {
3357 omit_suppress_line: true,
3358 omit_reason: Some("baseline-active"),
3359 },
3360 );
3361 let actions = output["findings"][0]["actions"].as_array().unwrap();
3362 assert!(
3363 !actions.iter().any(|a| a["type"] == "suppress-line"),
3364 "baseline-active must not emit suppress-line, got {actions:?}"
3365 );
3366 assert_eq!(
3367 output["actions_meta"]["suppression_hints_omitted"],
3368 serde_json::Value::Bool(true)
3369 );
3370 assert_eq!(output["actions_meta"]["reason"], "baseline-active");
3371 assert_eq!(output["actions_meta"]["scope"], "health-findings");
3372 }
3373
3374 #[test]
3375 fn suppress_line_omitted_when_config_disabled() {
3376 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3377 inject_health_actions(
3378 &mut output,
3379 HealthActionOptions {
3380 omit_suppress_line: true,
3381 omit_reason: Some("config-disabled"),
3382 },
3383 );
3384 assert_eq!(output["actions_meta"]["reason"], "config-disabled");
3385 }
3386
3387 #[test]
3388 fn suppress_line_emitted_by_default() {
3389 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3390 inject_health_actions(&mut output, HealthActionOptions::default());
3391 let actions = output["findings"][0]["actions"].as_array().unwrap();
3392 assert!(
3393 actions.iter().any(|a| a["type"] == "suppress-line"),
3394 "default opts must emit suppress-line"
3395 );
3396 assert!(
3397 output.get("actions_meta").is_none(),
3398 "actions_meta must be absent when no omission occurred"
3399 );
3400 }
3401
3402 #[test]
3409 fn every_emitted_health_action_type_is_in_schema_enum() {
3410 let cases = [
3414 ("crap", Some("none"), 6_u16, 20_u16),
3416 ("crap", Some("partial"), 6, 20),
3417 ("crap", Some("high"), 12, 20),
3418 ("crap", Some("none"), 16, 20), ("cyclomatic", None, 25, 20),
3420 ("cognitive_crap", Some("partial"), 6, 20),
3421 ("all", Some("none"), 25, 20),
3422 ];
3423
3424 let mut emitted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3425 for (exceeded, tier, cc, max) in cases {
3426 let mut finding = serde_json::json!({
3427 "path": "src/x.ts",
3428 "name": "fn",
3429 "line": 1,
3430 "col": 0,
3431 "cyclomatic": cc,
3432 "cognitive": 5,
3433 "line_count": 10,
3434 "exceeded": exceeded,
3435 "crap": 35.0,
3436 });
3437 if let Some(t) = tier {
3438 finding["coverage_tier"] = serde_json::Value::String(t.to_owned());
3439 }
3440 let mut output = serde_json::json!({
3441 "findings": [finding],
3442 "summary": { "max_cyclomatic_threshold": max },
3443 });
3444 inject_health_actions(&mut output, HealthActionOptions::default());
3445 for action in output["findings"][0]["actions"].as_array().unwrap() {
3446 if let Some(ty) = action["type"].as_str() {
3447 emitted.insert(ty.to_owned());
3448 }
3449 }
3450 }
3451
3452 let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3454 .join("..")
3455 .join("..")
3456 .join("docs")
3457 .join("output-schema.json");
3458 let raw = std::fs::read_to_string(&schema_path)
3459 .expect("docs/output-schema.json must be readable for the drift-guard test");
3460 let schema: serde_json::Value = serde_json::from_str(&raw).expect("schema parses");
3461 let enum_values: std::collections::BTreeSet<String> =
3462 schema["definitions"]["HealthFindingAction"]["properties"]["type"]["enum"]
3463 .as_array()
3464 .expect("HealthFindingAction.type.enum is an array")
3465 .iter()
3466 .filter_map(|v| v.as_str().map(str::to_owned))
3467 .collect();
3468
3469 for ty in &emitted {
3470 assert!(
3471 enum_values.contains(ty),
3472 "build_health_finding_actions emitted action type `{ty}` but \
3473 docs/output-schema.json HealthFindingAction.type enum does \
3474 not list it. Add it to the schema (and any downstream \
3475 typed consumers) when introducing a new action type."
3476 );
3477 }
3478 }
3479}