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 "private_type_leaks": results.private_type_leaks.len(),
201 "unused_dependencies": results.unused_dependencies.len()
202 + results.unused_dev_dependencies.len()
203 + results.unused_optional_dependencies.len(),
204 "unused_enum_members": results.unused_enum_members.len(),
205 "unused_class_members": results.unused_class_members.len(),
206 "unresolved_imports": results.unresolved_imports.len(),
207 "unlisted_dependencies": results.unlisted_dependencies.len(),
208 "duplicate_exports": results.duplicate_exports.len(),
209 "type_only_dependencies": results.type_only_dependencies.len(),
210 "test_only_dependencies": results.test_only_dependencies.len(),
211 "circular_dependencies": results.circular_dependencies.len(),
212 "boundary_violations": results.boundary_violations.len(),
213 "stale_suppressions": results.stale_suppressions.len(),
214 });
215 map.insert("summary".to_string(), summary);
216
217 if let serde_json::Value::Object(results_map) = results_value {
218 for (key, value) in results_map {
219 map.insert(key, value);
220 }
221 }
222
223 let mut output = serde_json::Value::Object(map);
224 let root_prefix = format!("{}/", root.display());
225 strip_root_prefix(&mut output, &root_prefix);
229 inject_actions(&mut output);
230 Ok(output)
231}
232
233pub fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
238 match value {
239 serde_json::Value::String(s) => {
240 if let Some(rest) = s.strip_prefix(prefix) {
241 *s = rest.to_string();
242 } else {
243 let normalized = normalize_uri(s);
244 let normalized_prefix = normalize_uri(prefix);
245 if let Some(rest) = normalized.strip_prefix(&normalized_prefix) {
246 *s = rest.to_string();
247 }
248 }
249 }
250 serde_json::Value::Array(arr) => {
251 for item in arr {
252 strip_root_prefix(item, prefix);
253 }
254 }
255 serde_json::Value::Object(map) => {
256 for (_, v) in map.iter_mut() {
257 strip_root_prefix(v, prefix);
258 }
259 }
260 _ => {}
261 }
262}
263
264enum SuppressKind {
268 InlineComment,
270 FileComment,
272 ConfigIgnoreDep,
274}
275
276struct ActionSpec {
278 fix_type: &'static str,
279 auto_fixable: bool,
280 description: &'static str,
281 note: Option<&'static str>,
282 suppress: SuppressKind,
283 issue_kind: &'static str,
284}
285
286fn actions_for_issue_type(key: &str) -> Option<ActionSpec> {
288 match key {
289 "unused_files" => Some(ActionSpec {
290 fix_type: "delete-file",
291 auto_fixable: false,
292 description: "Delete this file",
293 note: Some(
294 "File deletion may remove runtime functionality not visible to static analysis",
295 ),
296 suppress: SuppressKind::FileComment,
297 issue_kind: "unused-file",
298 }),
299 "unused_exports" => Some(ActionSpec {
300 fix_type: "remove-export",
301 auto_fixable: true,
302 description: "Remove the unused export from the public API",
303 note: None,
304 suppress: SuppressKind::InlineComment,
305 issue_kind: "unused-export",
306 }),
307 "unused_types" => Some(ActionSpec {
308 fix_type: "remove-export",
309 auto_fixable: true,
310 description: "Remove the `export` (or `export type`) keyword from the type declaration",
311 note: None,
312 suppress: SuppressKind::InlineComment,
313 issue_kind: "unused-type",
314 }),
315 "private_type_leaks" => Some(ActionSpec {
316 fix_type: "export-type",
317 auto_fixable: false,
318 description: "Export the referenced private type by name",
319 note: Some("Keep the type exported while it is part of a public signature"),
320 suppress: SuppressKind::InlineComment,
321 issue_kind: "private-type-leak",
322 }),
323 "unused_dependencies" => Some(ActionSpec {
324 fix_type: "remove-dependency",
325 auto_fixable: true,
326 description: "Remove from dependencies in package.json",
327 note: None,
328 suppress: SuppressKind::ConfigIgnoreDep,
329 issue_kind: "unused-dependency",
330 }),
331 "unused_dev_dependencies" => Some(ActionSpec {
332 fix_type: "remove-dependency",
333 auto_fixable: true,
334 description: "Remove from devDependencies in package.json",
335 note: None,
336 suppress: SuppressKind::ConfigIgnoreDep,
337 issue_kind: "unused-dev-dependency",
338 }),
339 "unused_optional_dependencies" => Some(ActionSpec {
340 fix_type: "remove-dependency",
341 auto_fixable: true,
342 description: "Remove from optionalDependencies in package.json",
343 note: None,
344 suppress: SuppressKind::ConfigIgnoreDep,
345 issue_kind: "unused-dependency",
347 }),
348 "unused_enum_members" => Some(ActionSpec {
349 fix_type: "remove-enum-member",
350 auto_fixable: true,
351 description: "Remove this enum member",
352 note: None,
353 suppress: SuppressKind::InlineComment,
354 issue_kind: "unused-enum-member",
355 }),
356 "unused_class_members" => Some(ActionSpec {
357 fix_type: "remove-class-member",
358 auto_fixable: false,
359 description: "Remove this class member",
360 note: Some("Class member may be used via dependency injection or decorators"),
361 suppress: SuppressKind::InlineComment,
362 issue_kind: "unused-class-member",
363 }),
364 "unresolved_imports" => Some(ActionSpec {
365 fix_type: "resolve-import",
366 auto_fixable: false,
367 description: "Fix the import specifier or install the missing module",
368 note: Some("Verify the module path and check tsconfig paths configuration"),
369 suppress: SuppressKind::InlineComment,
370 issue_kind: "unresolved-import",
371 }),
372 "unlisted_dependencies" => Some(ActionSpec {
373 fix_type: "install-dependency",
374 auto_fixable: false,
375 description: "Add this package to dependencies in package.json",
376 note: Some("Verify this package should be a direct dependency before adding"),
377 suppress: SuppressKind::ConfigIgnoreDep,
378 issue_kind: "unlisted-dependency",
379 }),
380 "duplicate_exports" => Some(ActionSpec {
381 fix_type: "remove-duplicate",
382 auto_fixable: false,
383 description: "Keep one canonical export location and remove the others",
384 note: Some("Review all locations to determine which should be the canonical export"),
385 suppress: SuppressKind::InlineComment,
386 issue_kind: "duplicate-export",
387 }),
388 "type_only_dependencies" => Some(ActionSpec {
389 fix_type: "move-to-dev",
390 auto_fixable: false,
391 description: "Move to devDependencies (only type imports are used)",
392 note: Some(
393 "Type imports are erased at runtime so this dependency is not needed in production",
394 ),
395 suppress: SuppressKind::ConfigIgnoreDep,
396 issue_kind: "type-only-dependency",
397 }),
398 "test_only_dependencies" => Some(ActionSpec {
399 fix_type: "move-to-dev",
400 auto_fixable: false,
401 description: "Move to devDependencies (only test files import this)",
402 note: Some(
403 "Only test files import this package so it does not need to be a production dependency",
404 ),
405 suppress: SuppressKind::ConfigIgnoreDep,
406 issue_kind: "test-only-dependency",
407 }),
408 "circular_dependencies" => Some(ActionSpec {
409 fix_type: "refactor-cycle",
410 auto_fixable: false,
411 description: "Extract shared logic into a separate module to break the cycle",
412 note: Some(
413 "Circular imports can cause initialization issues and make code harder to reason about",
414 ),
415 suppress: SuppressKind::InlineComment,
416 issue_kind: "circular-dependency",
417 }),
418 "boundary_violations" => Some(ActionSpec {
419 fix_type: "refactor-boundary",
420 auto_fixable: false,
421 description: "Move the import through an allowed zone or restructure the dependency",
422 note: Some(
423 "This import crosses an architecture boundary that is not permitted by the configured rules",
424 ),
425 suppress: SuppressKind::InlineComment,
426 issue_kind: "boundary-violation",
427 }),
428 _ => None,
429 }
430}
431
432fn build_actions(
434 item: &serde_json::Value,
435 issue_key: &str,
436 spec: &ActionSpec,
437) -> serde_json::Value {
438 let mut actions = Vec::with_capacity(2);
439 let cross_workspace_dependency = is_dependency_issue(issue_key)
440 && item
441 .get("used_in_workspaces")
442 .and_then(serde_json::Value::as_array)
443 .is_some_and(|workspaces| !workspaces.is_empty());
444
445 let mut fix_action = if cross_workspace_dependency {
447 serde_json::json!({
448 "type": "move-dependency",
449 "auto_fixable": false,
450 "description": "Move this dependency to the workspace package.json that imports it",
451 "note": "fallow fix will not remove dependencies that are imported by another workspace",
452 })
453 } else {
454 serde_json::json!({
455 "type": spec.fix_type,
456 "auto_fixable": spec.auto_fixable,
457 "description": spec.description,
458 })
459 };
460 if let Some(note) = spec.note {
461 fix_action["note"] = serde_json::json!(note);
462 }
463 if (issue_key == "unused_exports" || issue_key == "unused_types")
465 && item
466 .get("is_re_export")
467 .and_then(serde_json::Value::as_bool)
468 == Some(true)
469 {
470 fix_action["note"] = serde_json::json!(
471 "This finding originates from a re-export; verify it is not part of your public API before removing"
472 );
473 }
474 actions.push(fix_action);
475
476 match spec.suppress {
478 SuppressKind::InlineComment => {
479 let mut suppress = serde_json::json!({
480 "type": "suppress-line",
481 "auto_fixable": false,
482 "description": "Suppress with an inline comment above the line",
483 "comment": format!("// fallow-ignore-next-line {}", spec.issue_kind),
484 });
485 if issue_key == "duplicate_exports" {
487 suppress["scope"] = serde_json::json!("per-location");
488 }
489 actions.push(suppress);
490 }
491 SuppressKind::FileComment => {
492 actions.push(serde_json::json!({
493 "type": "suppress-file",
494 "auto_fixable": false,
495 "description": "Suppress with a file-level comment at the top of the file",
496 "comment": format!("// fallow-ignore-file {}", spec.issue_kind),
497 }));
498 }
499 SuppressKind::ConfigIgnoreDep => {
500 let pkg = item
502 .get("package_name")
503 .and_then(serde_json::Value::as_str)
504 .unwrap_or("package-name");
505 actions.push(serde_json::json!({
506 "type": "add-to-config",
507 "auto_fixable": false,
508 "description": format!("Add \"{pkg}\" to ignoreDependencies in fallow config"),
509 "config_key": "ignoreDependencies",
510 "value": pkg,
511 }));
512 }
513 }
514
515 serde_json::Value::Array(actions)
516}
517
518fn is_dependency_issue(issue_key: &str) -> bool {
519 matches!(
520 issue_key,
521 "unused_dependencies" | "unused_dev_dependencies" | "unused_optional_dependencies"
522 )
523}
524
525fn inject_actions(output: &mut serde_json::Value) {
530 let Some(map) = output.as_object_mut() else {
531 return;
532 };
533
534 for (key, value) in map.iter_mut() {
535 let Some(spec) = actions_for_issue_type(key) else {
536 continue;
537 };
538 let Some(arr) = value.as_array_mut() else {
539 continue;
540 };
541 for item in arr {
542 let actions = build_actions(item, key, &spec);
543 if let serde_json::Value::Object(obj) = item {
544 obj.insert("actions".to_string(), actions);
545 }
546 }
547 }
548}
549
550pub fn build_baseline_deltas_json<'a>(
558 total_delta: i64,
559 per_category: impl Iterator<Item = (&'a str, usize, usize, i64)>,
560) -> serde_json::Value {
561 let mut per_cat = serde_json::Map::new();
562 for (cat, current, baseline, delta) in per_category {
563 per_cat.insert(
564 cat.to_string(),
565 serde_json::json!({
566 "current": current,
567 "baseline": baseline,
568 "delta": delta,
569 }),
570 );
571 }
572 serde_json::json!({
573 "total_delta": total_delta,
574 "per_category": per_cat
575 })
576}
577
578const SECONDARY_REFACTOR_BAND: u16 = 5;
588
589#[derive(Debug, Clone, Copy, Default)]
604pub struct HealthActionOptions {
605 pub omit_suppress_line: bool,
607 pub omit_reason: Option<&'static str>,
612}
613
614#[allow(
621 clippy::redundant_pub_crate,
622 reason = "pub(crate) needed, used by audit.rs via re-export, but not part of public API"
623)]
624pub(crate) fn inject_health_actions(output: &mut serde_json::Value, opts: HealthActionOptions) {
625 let Some(map) = output.as_object_mut() else {
626 return;
627 };
628
629 let max_cyclomatic_threshold = map
632 .get("summary")
633 .and_then(|s| s.get("max_cyclomatic_threshold"))
634 .and_then(serde_json::Value::as_u64)
635 .and_then(|v| u16::try_from(v).ok())
636 .unwrap_or(20);
637 let max_cognitive_threshold = map
638 .get("summary")
639 .and_then(|s| s.get("max_cognitive_threshold"))
640 .and_then(serde_json::Value::as_u64)
641 .and_then(|v| u16::try_from(v).ok())
642 .unwrap_or(15);
643 let max_crap_threshold = map
644 .get("summary")
645 .and_then(|s| s.get("max_crap_threshold"))
646 .and_then(serde_json::Value::as_f64)
647 .unwrap_or(30.0);
648
649 if let Some(findings) = map.get_mut("findings").and_then(|v| v.as_array_mut()) {
651 for item in findings {
652 let actions = build_health_finding_actions(
653 item,
654 opts,
655 max_cyclomatic_threshold,
656 max_cognitive_threshold,
657 max_crap_threshold,
658 );
659 if let serde_json::Value::Object(obj) = item {
660 obj.insert("actions".to_string(), actions);
661 }
662 }
663 }
664
665 if let Some(targets) = map.get_mut("targets").and_then(|v| v.as_array_mut()) {
667 for item in targets {
668 let actions = build_refactoring_target_actions(item);
669 if let serde_json::Value::Object(obj) = item {
670 obj.insert("actions".to_string(), actions);
671 }
672 }
673 }
674
675 if let Some(hotspots) = map.get_mut("hotspots").and_then(|v| v.as_array_mut()) {
677 for item in hotspots {
678 let actions = build_hotspot_actions(item);
679 if let serde_json::Value::Object(obj) = item {
680 obj.insert("actions".to_string(), actions);
681 }
682 }
683 }
684
685 if let Some(gaps) = map.get_mut("coverage_gaps").and_then(|v| v.as_object_mut()) {
687 if let Some(files) = gaps.get_mut("files").and_then(|v| v.as_array_mut()) {
688 for item in files {
689 let actions = build_untested_file_actions(item);
690 if let serde_json::Value::Object(obj) = item {
691 obj.insert("actions".to_string(), actions);
692 }
693 }
694 }
695 if let Some(exports) = gaps.get_mut("exports").and_then(|v| v.as_array_mut()) {
696 for item in exports {
697 let actions = build_untested_export_actions(item);
698 if let serde_json::Value::Object(obj) = item {
699 obj.insert("actions".to_string(), actions);
700 }
701 }
702 }
703 }
704
705 if opts.omit_suppress_line {
713 let reason = opts.omit_reason.unwrap_or("unspecified");
714 map.insert(
715 "actions_meta".to_string(),
716 serde_json::json!({
717 "suppression_hints_omitted": true,
718 "reason": reason,
719 "scope": "health-findings",
720 }),
721 );
722 }
723}
724
725fn build_health_finding_actions(
749 item: &serde_json::Value,
750 opts: HealthActionOptions,
751 max_cyclomatic_threshold: u16,
752 max_cognitive_threshold: u16,
753 max_crap_threshold: f64,
754) -> serde_json::Value {
755 let name = item
756 .get("name")
757 .and_then(serde_json::Value::as_str)
758 .unwrap_or("function");
759 let path = item
760 .get("path")
761 .and_then(serde_json::Value::as_str)
762 .unwrap_or("");
763 let exceeded = item
764 .get("exceeded")
765 .and_then(serde_json::Value::as_str)
766 .unwrap_or("");
767 let includes_crap = matches!(
768 exceeded,
769 "crap" | "cyclomatic_crap" | "cognitive_crap" | "all"
770 );
771 let crap_only = exceeded == "crap";
772 let tier = item
773 .get("coverage_tier")
774 .and_then(serde_json::Value::as_str);
775 let cyclomatic = item
776 .get("cyclomatic")
777 .and_then(serde_json::Value::as_u64)
778 .and_then(|v| u16::try_from(v).ok())
779 .unwrap_or(0);
780 let cognitive = item
781 .get("cognitive")
782 .and_then(serde_json::Value::as_u64)
783 .and_then(|v| u16::try_from(v).ok())
784 .unwrap_or(0);
785 let full_coverage_can_clear_crap = !includes_crap || f64::from(cyclomatic) < max_crap_threshold;
786
787 let mut actions: Vec<serde_json::Value> = Vec::new();
788
789 if includes_crap {
791 let coverage_action = build_crap_coverage_action(name, tier, full_coverage_can_clear_crap);
792 if let Some(action) = coverage_action {
793 actions.push(action);
794 }
795 }
796
797 let crap_only_needs_complexity_reduction = crap_only && !full_coverage_can_clear_crap;
813 let cognitive_floor = max_cognitive_threshold / 2;
814 let near_cyclomatic_threshold = crap_only
815 && cyclomatic > 0
816 && cyclomatic >= max_cyclomatic_threshold.saturating_sub(SECONDARY_REFACTOR_BAND)
817 && cognitive >= cognitive_floor;
818 let is_template = name == "<template>";
819 if !crap_only || crap_only_needs_complexity_reduction || near_cyclomatic_threshold {
820 let (description, note) = if is_template {
821 (
822 format!(
823 "Refactor `{name}` to reduce template complexity (simplify control flow and bindings)"
824 ),
825 "Consider splitting complex template branches into smaller components or simpler bindings",
826 )
827 } else {
828 (
829 format!(
830 "Refactor `{name}` to reduce complexity (extract helper functions, simplify branching)"
831 ),
832 "Consider splitting into smaller functions with single responsibilities",
833 )
834 };
835 actions.push(serde_json::json!({
836 "type": "refactor-function",
837 "auto_fixable": false,
838 "description": description,
839 "note": note,
840 }));
841 }
842
843 if !opts.omit_suppress_line {
844 if is_template
845 && Path::new(path)
846 .extension()
847 .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
848 {
849 actions.push(serde_json::json!({
850 "type": "suppress-file",
851 "auto_fixable": false,
852 "description": "Suppress with an HTML comment at the top of the template",
853 "comment": "<!-- fallow-ignore-file complexity -->",
854 "placement": "top-of-template",
855 }));
856 } else if is_template {
857 actions.push(serde_json::json!({
858 "type": "suppress-line",
859 "auto_fixable": false,
860 "description": "Suppress with an inline comment above the Angular decorator",
861 "comment": "// fallow-ignore-next-line complexity",
862 "placement": "above-angular-decorator",
863 }));
864 } else {
865 actions.push(serde_json::json!({
866 "type": "suppress-line",
867 "auto_fixable": false,
868 "description": "Suppress with an inline comment above the function declaration",
869 "comment": "// fallow-ignore-next-line complexity",
870 "placement": "above-function-declaration",
871 }));
872 }
873 }
874
875 serde_json::Value::Array(actions)
876}
877
878fn build_crap_coverage_action(
884 name: &str,
885 tier: Option<&str>,
886 full_coverage_can_clear_crap: bool,
887) -> Option<serde_json::Value> {
888 if !full_coverage_can_clear_crap {
889 return None;
890 }
891
892 match tier {
893 Some("partial" | "high") => Some(serde_json::json!({
898 "type": "increase-coverage",
899 "auto_fixable": false,
900 "description": format!("Increase test coverage for `{name}` (file is reachable from existing tests; add targeted assertions for uncovered branches)"),
901 "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",
902 })),
903 _ => Some(serde_json::json!({
905 "type": "add-tests",
906 "auto_fixable": false,
907 "description": format!("Add test coverage for `{name}` to lower its CRAP score (coverage reduces risk even without refactoring)"),
908 "note": "CRAP = CC^2 * (1 - cov/100)^3 + CC; higher coverage is the fastest way to bring CRAP under threshold",
909 })),
910 }
911}
912
913fn build_hotspot_actions(item: &serde_json::Value) -> serde_json::Value {
915 let path = item
916 .get("path")
917 .and_then(serde_json::Value::as_str)
918 .unwrap_or("file");
919
920 let mut actions = vec![
921 serde_json::json!({
922 "type": "refactor-file",
923 "auto_fixable": false,
924 "description": format!("Refactor `{path}`, high complexity combined with frequent changes makes this a maintenance risk"),
925 "note": "Prioritize extracting complex functions, adding tests, or splitting the module",
926 }),
927 serde_json::json!({
928 "type": "add-tests",
929 "auto_fixable": false,
930 "description": format!("Add test coverage for `{path}` to reduce change risk"),
931 "note": "Frequently changed complex files benefit most from comprehensive test coverage",
932 }),
933 ];
934
935 if let Some(ownership) = item.get("ownership") {
936 if ownership
938 .get("bus_factor")
939 .and_then(serde_json::Value::as_u64)
940 == Some(1)
941 {
942 let top = ownership.get("top_contributor");
943 let owner = top
944 .and_then(|t| t.get("identifier"))
945 .and_then(serde_json::Value::as_str)
946 .unwrap_or("the sole contributor");
947 let commits = top
952 .and_then(|t| t.get("commits"))
953 .and_then(serde_json::Value::as_u64)
954 .unwrap_or(0);
955 let suggested: Vec<String> = ownership
961 .get("suggested_reviewers")
962 .and_then(serde_json::Value::as_array)
963 .map(|arr| {
964 arr.iter()
965 .filter_map(|r| {
966 r.get("identifier")
967 .and_then(serde_json::Value::as_str)
968 .map(String::from)
969 })
970 .collect()
971 })
972 .unwrap_or_default();
973 let mut low_bus_action = serde_json::json!({
974 "type": "low-bus-factor",
975 "auto_fixable": false,
976 "description": format!(
977 "{owner} is the sole recent contributor to `{path}`; adding a second reviewer reduces knowledge-loss risk"
978 ),
979 });
980 if !suggested.is_empty() {
981 let list = suggested
982 .iter()
983 .map(|s| format!("@{s}"))
984 .collect::<Vec<_>>()
985 .join(", ");
986 low_bus_action["note"] =
987 serde_json::Value::String(format!("Candidate reviewers: {list}"));
988 } else if commits < 5 {
989 low_bus_action["note"] = serde_json::Value::String(
990 "Single recent contributor on a low-commit file. Consider a pair review for major changes."
991 .to_string(),
992 );
993 }
994 actions.push(low_bus_action);
996 }
997
998 if ownership
1001 .get("unowned")
1002 .and_then(serde_json::Value::as_bool)
1003 == Some(true)
1004 {
1005 actions.push(serde_json::json!({
1006 "type": "unowned-hotspot",
1007 "auto_fixable": false,
1008 "description": format!("Add a CODEOWNERS entry for `{path}`"),
1009 "note": "Frequently-changed files without declared owners create review bottlenecks",
1010 "suggested_pattern": suggest_codeowners_pattern(path),
1011 "heuristic": "directory-deepest",
1012 }));
1013 }
1014
1015 if ownership.get("drift").and_then(serde_json::Value::as_bool) == Some(true) {
1018 let reason = ownership
1019 .get("drift_reason")
1020 .and_then(serde_json::Value::as_str)
1021 .unwrap_or("ownership has shifted from the original author");
1022 actions.push(serde_json::json!({
1023 "type": "ownership-drift",
1024 "auto_fixable": false,
1025 "description": format!("Update CODEOWNERS for `{path}`: {reason}"),
1026 "note": "Drift suggests the declared or original owner is no longer the right reviewer",
1027 }));
1028 }
1029 }
1030
1031 serde_json::Value::Array(actions)
1032}
1033
1034fn suggest_codeowners_pattern(path: &str) -> String {
1047 let normalized = path.replace('\\', "/");
1048 let trimmed = normalized.trim_start_matches('/');
1049 let mut components: Vec<&str> = trimmed.split('/').collect();
1050 components.pop(); if components.is_empty() {
1052 return format!("/{trimmed}");
1053 }
1054 format!("/{}/", components.join("/"))
1055}
1056
1057fn build_refactoring_target_actions(item: &serde_json::Value) -> serde_json::Value {
1059 let recommendation = item
1060 .get("recommendation")
1061 .and_then(serde_json::Value::as_str)
1062 .unwrap_or("Apply the recommended refactoring");
1063
1064 let category = item
1065 .get("category")
1066 .and_then(serde_json::Value::as_str)
1067 .unwrap_or("refactoring");
1068
1069 let mut actions = vec![serde_json::json!({
1070 "type": "apply-refactoring",
1071 "auto_fixable": false,
1072 "description": recommendation,
1073 "category": category,
1074 })];
1075
1076 if item.get("evidence").is_some() {
1078 actions.push(serde_json::json!({
1079 "type": "suppress-line",
1080 "auto_fixable": false,
1081 "description": "Suppress the underlying complexity finding",
1082 "comment": "// fallow-ignore-next-line complexity",
1083 }));
1084 }
1085
1086 serde_json::Value::Array(actions)
1087}
1088
1089fn build_untested_file_actions(item: &serde_json::Value) -> serde_json::Value {
1091 let path = item
1092 .get("path")
1093 .and_then(serde_json::Value::as_str)
1094 .unwrap_or("file");
1095
1096 serde_json::Value::Array(vec![
1097 serde_json::json!({
1098 "type": "add-tests",
1099 "auto_fixable": false,
1100 "description": format!("Add test coverage for `{path}`"),
1101 "note": "No test dependency path reaches this runtime file",
1102 }),
1103 serde_json::json!({
1104 "type": "suppress-file",
1105 "auto_fixable": false,
1106 "description": format!("Suppress coverage gap reporting for `{path}`"),
1107 "comment": "// fallow-ignore-file coverage-gaps",
1108 }),
1109 ])
1110}
1111
1112fn build_untested_export_actions(item: &serde_json::Value) -> serde_json::Value {
1114 let path = item
1115 .get("path")
1116 .and_then(serde_json::Value::as_str)
1117 .unwrap_or("file");
1118 let export_name = item
1119 .get("export_name")
1120 .and_then(serde_json::Value::as_str)
1121 .unwrap_or("export");
1122
1123 serde_json::Value::Array(vec![
1124 serde_json::json!({
1125 "type": "add-test-import",
1126 "auto_fixable": false,
1127 "description": format!("Import and test `{export_name}` from `{path}`"),
1128 "note": "This export is runtime-reachable but no test-reachable module references it",
1129 }),
1130 serde_json::json!({
1131 "type": "suppress-file",
1132 "auto_fixable": false,
1133 "description": format!("Suppress coverage gap reporting for `{path}`"),
1134 "comment": "// fallow-ignore-file coverage-gaps",
1135 }),
1136 ])
1137}
1138
1139#[allow(
1146 clippy::redundant_pub_crate,
1147 reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
1148)]
1149pub(crate) fn inject_dupes_actions(output: &mut serde_json::Value) {
1150 let Some(map) = output.as_object_mut() else {
1151 return;
1152 };
1153
1154 if let Some(families) = map.get_mut("clone_families").and_then(|v| v.as_array_mut()) {
1156 for item in families {
1157 let actions = build_clone_family_actions(item);
1158 if let serde_json::Value::Object(obj) = item {
1159 obj.insert("actions".to_string(), actions);
1160 }
1161 }
1162 }
1163
1164 if let Some(groups) = map.get_mut("clone_groups").and_then(|v| v.as_array_mut()) {
1166 for item in groups {
1167 let actions = build_clone_group_actions(item);
1168 if let serde_json::Value::Object(obj) = item {
1169 obj.insert("actions".to_string(), actions);
1170 }
1171 }
1172 }
1173}
1174
1175fn build_clone_family_actions(item: &serde_json::Value) -> serde_json::Value {
1177 let group_count = item
1178 .get("groups")
1179 .and_then(|v| v.as_array())
1180 .map_or(0, Vec::len);
1181
1182 let total_lines = item
1183 .get("total_duplicated_lines")
1184 .and_then(serde_json::Value::as_u64)
1185 .unwrap_or(0);
1186
1187 let mut actions = vec![serde_json::json!({
1188 "type": "extract-shared",
1189 "auto_fixable": false,
1190 "description": format!(
1191 "Extract {group_count} duplicated code block{} ({total_lines} lines) into a shared module",
1192 if group_count == 1 { "" } else { "s" }
1193 ),
1194 "note": "These clone groups share the same files, indicating a structural relationship — refactor together",
1195 })];
1196
1197 if let Some(suggestions) = item.get("suggestions").and_then(|v| v.as_array()) {
1199 for suggestion in suggestions {
1200 if let Some(desc) = suggestion
1201 .get("description")
1202 .and_then(serde_json::Value::as_str)
1203 {
1204 actions.push(serde_json::json!({
1205 "type": "apply-suggestion",
1206 "auto_fixable": false,
1207 "description": desc,
1208 }));
1209 }
1210 }
1211 }
1212
1213 actions.push(serde_json::json!({
1214 "type": "suppress-line",
1215 "auto_fixable": false,
1216 "description": "Suppress with an inline comment above the duplicated code",
1217 "comment": "// fallow-ignore-next-line code-duplication",
1218 }));
1219
1220 serde_json::Value::Array(actions)
1221}
1222
1223fn build_clone_group_actions(item: &serde_json::Value) -> serde_json::Value {
1225 let instance_count = item
1226 .get("instances")
1227 .and_then(|v| v.as_array())
1228 .map_or(0, Vec::len);
1229
1230 let line_count = item
1231 .get("line_count")
1232 .and_then(serde_json::Value::as_u64)
1233 .unwrap_or(0);
1234
1235 let actions = vec![
1236 serde_json::json!({
1237 "type": "extract-shared",
1238 "auto_fixable": false,
1239 "description": format!(
1240 "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
1241 if instance_count == 1 { "" } else { "s" }
1242 ),
1243 }),
1244 serde_json::json!({
1245 "type": "suppress-line",
1246 "auto_fixable": false,
1247 "description": "Suppress with an inline comment above the duplicated code",
1248 "comment": "// fallow-ignore-next-line code-duplication",
1249 }),
1250 ];
1251
1252 serde_json::Value::Array(actions)
1253}
1254
1255fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
1257 if let serde_json::Value::Object(map) = output {
1258 map.insert("_meta".to_string(), meta);
1259 }
1260}
1261
1262pub fn build_health_json(
1270 report: &crate::health_types::HealthReport,
1271 root: &Path,
1272 elapsed: Duration,
1273 explain: bool,
1274 action_opts: HealthActionOptions,
1275) -> Result<serde_json::Value, serde_json::Error> {
1276 let report_value = serde_json::to_value(report)?;
1277 let mut output = build_json_envelope(report_value, elapsed);
1278 let root_prefix = format!("{}/", root.display());
1279 strip_root_prefix(&mut output, &root_prefix);
1280 inject_health_actions(&mut output, action_opts);
1281 if explain {
1282 insert_meta(&mut output, explain::health_meta());
1283 }
1284 Ok(output)
1285}
1286
1287pub(super) fn print_health_json(
1288 report: &crate::health_types::HealthReport,
1289 root: &Path,
1290 elapsed: Duration,
1291 explain: bool,
1292 action_opts: HealthActionOptions,
1293) -> ExitCode {
1294 match build_health_json(report, root, elapsed, explain, action_opts) {
1295 Ok(output) => emit_json(&output, "JSON"),
1296 Err(e) => {
1297 eprintln!("Error: failed to serialize health report: {e}");
1298 ExitCode::from(2)
1299 }
1300 }
1301}
1302
1303pub fn build_grouped_health_json(
1323 report: &crate::health_types::HealthReport,
1324 grouping: &crate::health_types::HealthGrouping,
1325 root: &Path,
1326 elapsed: Duration,
1327 explain: bool,
1328 action_opts: HealthActionOptions,
1329) -> Result<serde_json::Value, serde_json::Error> {
1330 let root_prefix = format!("{}/", root.display());
1331 let report_value = serde_json::to_value(report)?;
1332 let mut output = build_json_envelope(report_value, elapsed);
1333 strip_root_prefix(&mut output, &root_prefix);
1334 inject_health_actions(&mut output, action_opts);
1335
1336 if let serde_json::Value::Object(ref mut map) = output {
1337 map.insert("grouped_by".to_string(), serde_json::json!(grouping.mode));
1338 }
1339
1340 let group_values: Vec<serde_json::Value> = grouping
1348 .groups
1349 .iter()
1350 .map(|g| {
1351 let mut value = serde_json::to_value(g)?;
1352 strip_root_prefix(&mut value, &root_prefix);
1353 inject_health_actions(&mut value, action_opts);
1354 Ok(value)
1355 })
1356 .collect::<Result<_, serde_json::Error>>()?;
1357
1358 if let serde_json::Value::Object(ref mut map) = output {
1359 map.insert("groups".to_string(), serde_json::Value::Array(group_values));
1360 }
1361
1362 if explain {
1363 insert_meta(&mut output, explain::health_meta());
1364 }
1365
1366 Ok(output)
1367}
1368
1369pub(super) fn print_grouped_health_json(
1370 report: &crate::health_types::HealthReport,
1371 grouping: &crate::health_types::HealthGrouping,
1372 root: &Path,
1373 elapsed: Duration,
1374 explain: bool,
1375 action_opts: HealthActionOptions,
1376) -> ExitCode {
1377 match build_grouped_health_json(report, grouping, root, elapsed, explain, action_opts) {
1378 Ok(output) => emit_json(&output, "JSON"),
1379 Err(e) => {
1380 eprintln!("Error: failed to serialize grouped health report: {e}");
1381 ExitCode::from(2)
1382 }
1383 }
1384}
1385
1386pub fn build_duplication_json(
1393 report: &DuplicationReport,
1394 root: &Path,
1395 elapsed: Duration,
1396 explain: bool,
1397) -> Result<serde_json::Value, serde_json::Error> {
1398 let report_value = serde_json::to_value(report)?;
1399
1400 let mut output = build_json_envelope(report_value, elapsed);
1401 let root_prefix = format!("{}/", root.display());
1402 strip_root_prefix(&mut output, &root_prefix);
1403 inject_dupes_actions(&mut output);
1404
1405 if explain {
1406 insert_meta(&mut output, explain::dupes_meta());
1407 }
1408
1409 Ok(output)
1410}
1411
1412pub(super) fn print_duplication_json(
1413 report: &DuplicationReport,
1414 root: &Path,
1415 elapsed: Duration,
1416 explain: bool,
1417) -> ExitCode {
1418 match build_duplication_json(report, root, elapsed, explain) {
1419 Ok(output) => emit_json(&output, "JSON"),
1420 Err(e) => {
1421 eprintln!("Error: failed to serialize duplication report: {e}");
1422 ExitCode::from(2)
1423 }
1424 }
1425}
1426
1427pub fn build_grouped_duplication_json(
1448 report: &DuplicationReport,
1449 grouping: &super::dupes_grouping::DuplicationGrouping,
1450 root: &Path,
1451 elapsed: Duration,
1452 explain: bool,
1453) -> Result<serde_json::Value, serde_json::Error> {
1454 let report_value = serde_json::to_value(report)?;
1455 let mut output = build_json_envelope(report_value, elapsed);
1456 let root_prefix = format!("{}/", root.display());
1457 strip_root_prefix(&mut output, &root_prefix);
1458 inject_dupes_actions(&mut output);
1459
1460 if let serde_json::Value::Object(ref mut map) = output {
1461 map.insert("grouped_by".to_string(), serde_json::json!(grouping.mode));
1462 map.insert(
1468 "total_issues".to_string(),
1469 serde_json::json!(report.clone_groups.len()),
1470 );
1471 }
1472
1473 let group_values: Vec<serde_json::Value> = grouping
1474 .groups
1475 .iter()
1476 .map(|g| {
1477 let mut value = serde_json::to_value(g)?;
1478 strip_root_prefix(&mut value, &root_prefix);
1479 inject_dupes_actions(&mut value);
1480 Ok(value)
1481 })
1482 .collect::<Result<_, serde_json::Error>>()?;
1483
1484 if let serde_json::Value::Object(ref mut map) = output {
1485 map.insert("groups".to_string(), serde_json::Value::Array(group_values));
1486 }
1487
1488 if explain {
1489 insert_meta(&mut output, explain::dupes_meta());
1490 }
1491
1492 Ok(output)
1493}
1494
1495pub(super) fn print_grouped_duplication_json(
1496 report: &DuplicationReport,
1497 grouping: &super::dupes_grouping::DuplicationGrouping,
1498 root: &Path,
1499 elapsed: Duration,
1500 explain: bool,
1501) -> ExitCode {
1502 match build_grouped_duplication_json(report, grouping, root, elapsed, explain) {
1503 Ok(output) => emit_json(&output, "JSON"),
1504 Err(e) => {
1505 eprintln!("Error: failed to serialize grouped duplication report: {e}");
1506 ExitCode::from(2)
1507 }
1508 }
1509}
1510
1511pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
1512 match serde_json::to_string_pretty(value) {
1513 Ok(json) => println!("{json}"),
1514 Err(e) => {
1515 eprintln!("Error: failed to serialize trace output: {e}");
1516 #[expect(
1517 clippy::exit,
1518 reason = "fatal serialization error requires immediate exit"
1519 )]
1520 std::process::exit(2);
1521 }
1522 }
1523}
1524
1525#[cfg(test)]
1526mod tests {
1527 use super::*;
1528 use crate::health_types::{
1529 RuntimeCoverageAction, RuntimeCoverageConfidence, RuntimeCoverageEvidence,
1530 RuntimeCoverageFinding, RuntimeCoverageHotPath, RuntimeCoverageMessage,
1531 RuntimeCoverageReport, RuntimeCoverageReportVerdict, RuntimeCoverageSummary,
1532 RuntimeCoverageVerdict, RuntimeCoverageWatermark,
1533 };
1534 use crate::report::test_helpers::sample_results;
1535 use fallow_core::extract::MemberKind;
1536 use fallow_core::results::*;
1537 use std::path::PathBuf;
1538 use std::time::Duration;
1539
1540 #[test]
1541 fn json_output_has_metadata_fields() {
1542 let root = PathBuf::from("/project");
1543 let results = AnalysisResults::default();
1544 let elapsed = Duration::from_millis(123);
1545 let output = build_json(&results, &root, elapsed).expect("should serialize");
1546
1547 assert_eq!(output["schema_version"], 4);
1548 assert!(output["version"].is_string());
1549 assert_eq!(output["elapsed_ms"], 123);
1550 assert_eq!(output["total_issues"], 0);
1551 }
1552
1553 #[test]
1554 fn json_output_includes_issue_arrays() {
1555 let root = PathBuf::from("/project");
1556 let results = sample_results(&root);
1557 let elapsed = Duration::from_millis(50);
1558 let output = build_json(&results, &root, elapsed).expect("should serialize");
1559
1560 assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
1561 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
1562 assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
1563 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
1564 assert_eq!(
1565 output["unused_dev_dependencies"].as_array().unwrap().len(),
1566 1
1567 );
1568 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
1569 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
1570 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
1571 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
1572 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
1573 assert_eq!(
1574 output["type_only_dependencies"].as_array().unwrap().len(),
1575 1
1576 );
1577 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
1578 }
1579
1580 #[test]
1581 fn health_json_includes_runtime_coverage_with_relative_paths_and_actions() {
1582 let root = PathBuf::from("/project");
1583 let report = crate::health_types::HealthReport {
1584 runtime_coverage: Some(RuntimeCoverageReport {
1585 verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
1586 summary: RuntimeCoverageSummary {
1587 functions_tracked: 3,
1588 functions_hit: 1,
1589 functions_unhit: 1,
1590 functions_untracked: 1,
1591 coverage_percent: 33.3,
1592 trace_count: 2_847_291,
1593 period_days: 30,
1594 deployments_seen: 14,
1595 capture_quality: Some(crate::health_types::RuntimeCoverageCaptureQuality {
1596 window_seconds: 720,
1597 instances_observed: 1,
1598 lazy_parse_warning: true,
1599 untracked_ratio_percent: 42.5,
1600 }),
1601 },
1602 findings: vec![RuntimeCoverageFinding {
1603 id: "fallow:prod:deadbeef".to_owned(),
1604 path: root.join("src/cold.ts"),
1605 function: "coldPath".to_owned(),
1606 line: 12,
1607 verdict: RuntimeCoverageVerdict::ReviewRequired,
1608 invocations: Some(0),
1609 confidence: RuntimeCoverageConfidence::Medium,
1610 evidence: RuntimeCoverageEvidence {
1611 static_status: "used".to_owned(),
1612 test_coverage: "not_covered".to_owned(),
1613 v8_tracking: "tracked".to_owned(),
1614 untracked_reason: None,
1615 observation_days: 30,
1616 deployments_observed: 14,
1617 },
1618 actions: vec![RuntimeCoverageAction {
1619 kind: "review-deletion".to_owned(),
1620 description: "Tracked in runtime coverage with zero invocations."
1621 .to_owned(),
1622 auto_fixable: false,
1623 }],
1624 }],
1625 hot_paths: vec![RuntimeCoverageHotPath {
1626 id: "fallow:hot:cafebabe".to_owned(),
1627 path: root.join("src/hot.ts"),
1628 function: "hotPath".to_owned(),
1629 line: 3,
1630 invocations: 250,
1631 percentile: 99,
1632 actions: vec![],
1633 }],
1634 watermark: Some(RuntimeCoverageWatermark::LicenseExpiredGrace),
1635 warnings: vec![RuntimeCoverageMessage {
1636 code: "partial-merge".to_owned(),
1637 message: "Merged coverage omitted one chunk.".to_owned(),
1638 }],
1639 }),
1640 ..Default::default()
1641 };
1642
1643 let report_value = serde_json::to_value(&report).expect("should serialize health report");
1644 let mut output = build_json_envelope(report_value, Duration::from_millis(7));
1645 strip_root_prefix(&mut output, "/project/");
1646 inject_health_actions(&mut output, HealthActionOptions::default());
1647
1648 assert_eq!(
1649 output["runtime_coverage"]["verdict"],
1650 serde_json::Value::String("cold-code-detected".to_owned())
1651 );
1652 assert_eq!(
1653 output["runtime_coverage"]["summary"]["functions_tracked"],
1654 serde_json::Value::from(3)
1655 );
1656 assert_eq!(
1657 output["runtime_coverage"]["summary"]["coverage_percent"],
1658 serde_json::Value::from(33.3)
1659 );
1660 let finding = &output["runtime_coverage"]["findings"][0];
1661 assert_eq!(finding["path"], "src/cold.ts");
1662 assert_eq!(finding["verdict"], "review_required");
1663 assert_eq!(finding["id"], "fallow:prod:deadbeef");
1664 assert_eq!(finding["actions"][0]["type"], "review-deletion");
1665 let hot_path = &output["runtime_coverage"]["hot_paths"][0];
1666 assert_eq!(hot_path["path"], "src/hot.ts");
1667 assert_eq!(hot_path["function"], "hotPath");
1668 assert_eq!(hot_path["percentile"], 99);
1669 assert_eq!(
1670 output["runtime_coverage"]["watermark"],
1671 serde_json::Value::String("license-expired-grace".to_owned())
1672 );
1673 assert_eq!(
1674 output["runtime_coverage"]["warnings"][0]["code"],
1675 serde_json::Value::String("partial-merge".to_owned())
1676 );
1677 }
1678
1679 #[test]
1680 fn json_metadata_fields_appear_first() {
1681 let root = PathBuf::from("/project");
1682 let results = AnalysisResults::default();
1683 let elapsed = Duration::from_millis(0);
1684 let output = build_json(&results, &root, elapsed).expect("should serialize");
1685 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1686 assert_eq!(keys[0], "schema_version");
1687 assert_eq!(keys[1], "version");
1688 assert_eq!(keys[2], "elapsed_ms");
1689 assert_eq!(keys[3], "total_issues");
1690 }
1691
1692 #[test]
1693 fn json_total_issues_matches_results() {
1694 let root = PathBuf::from("/project");
1695 let results = sample_results(&root);
1696 let total = results.total_issues();
1697 let elapsed = Duration::from_millis(0);
1698 let output = build_json(&results, &root, elapsed).expect("should serialize");
1699
1700 assert_eq!(output["total_issues"], total);
1701 }
1702
1703 #[test]
1704 fn json_unused_export_contains_expected_fields() {
1705 let root = PathBuf::from("/project");
1706 let mut results = AnalysisResults::default();
1707 results.unused_exports.push(UnusedExport {
1708 path: root.join("src/utils.ts"),
1709 export_name: "helperFn".to_string(),
1710 is_type_only: false,
1711 line: 10,
1712 col: 4,
1713 span_start: 120,
1714 is_re_export: false,
1715 });
1716 let elapsed = Duration::from_millis(0);
1717 let output = build_json(&results, &root, elapsed).expect("should serialize");
1718
1719 let export = &output["unused_exports"][0];
1720 assert_eq!(export["export_name"], "helperFn");
1721 assert_eq!(export["line"], 10);
1722 assert_eq!(export["col"], 4);
1723 assert_eq!(export["is_type_only"], false);
1724 assert_eq!(export["span_start"], 120);
1725 assert_eq!(export["is_re_export"], false);
1726 }
1727
1728 #[test]
1729 fn json_serializes_to_valid_json() {
1730 let root = PathBuf::from("/project");
1731 let results = sample_results(&root);
1732 let elapsed = Duration::from_millis(42);
1733 let output = build_json(&results, &root, elapsed).expect("should serialize");
1734
1735 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
1736 let reparsed: serde_json::Value =
1737 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
1738 assert_eq!(reparsed, output);
1739 }
1740
1741 #[test]
1744 fn json_empty_results_produce_valid_structure() {
1745 let root = PathBuf::from("/project");
1746 let results = AnalysisResults::default();
1747 let elapsed = Duration::from_millis(0);
1748 let output = build_json(&results, &root, elapsed).expect("should serialize");
1749
1750 assert_eq!(output["total_issues"], 0);
1751 assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
1752 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
1753 assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
1754 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
1755 assert_eq!(
1756 output["unused_dev_dependencies"].as_array().unwrap().len(),
1757 0
1758 );
1759 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
1760 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
1761 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
1762 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
1763 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
1764 assert_eq!(
1765 output["type_only_dependencies"].as_array().unwrap().len(),
1766 0
1767 );
1768 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
1769 }
1770
1771 #[test]
1772 fn json_empty_results_round_trips_through_string() {
1773 let root = PathBuf::from("/project");
1774 let results = AnalysisResults::default();
1775 let elapsed = Duration::from_millis(0);
1776 let output = build_json(&results, &root, elapsed).expect("should serialize");
1777
1778 let json_str = serde_json::to_string(&output).expect("should stringify");
1779 let reparsed: serde_json::Value =
1780 serde_json::from_str(&json_str).expect("should parse back");
1781 assert_eq!(reparsed["total_issues"], 0);
1782 }
1783
1784 #[test]
1787 fn json_paths_are_relative_to_root() {
1788 let root = PathBuf::from("/project");
1789 let mut results = AnalysisResults::default();
1790 results.unused_files.push(UnusedFile {
1791 path: root.join("src/deep/nested/file.ts"),
1792 });
1793 let elapsed = Duration::from_millis(0);
1794 let output = build_json(&results, &root, elapsed).expect("should serialize");
1795
1796 let path = output["unused_files"][0]["path"].as_str().unwrap();
1797 assert_eq!(path, "src/deep/nested/file.ts");
1798 assert!(!path.starts_with("/project"));
1799 }
1800
1801 #[test]
1802 fn json_strips_root_from_nested_locations() {
1803 let root = PathBuf::from("/project");
1804 let mut results = AnalysisResults::default();
1805 results.unlisted_dependencies.push(UnlistedDependency {
1806 package_name: "chalk".to_string(),
1807 imported_from: vec![ImportSite {
1808 path: root.join("src/cli.ts"),
1809 line: 2,
1810 col: 0,
1811 }],
1812 });
1813 let elapsed = Duration::from_millis(0);
1814 let output = build_json(&results, &root, elapsed).expect("should serialize");
1815
1816 let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
1817 .as_str()
1818 .unwrap();
1819 assert_eq!(site_path, "src/cli.ts");
1820 }
1821
1822 #[test]
1823 fn json_strips_root_from_duplicate_export_locations() {
1824 let root = PathBuf::from("/project");
1825 let mut results = AnalysisResults::default();
1826 results.duplicate_exports.push(DuplicateExport {
1827 export_name: "Config".to_string(),
1828 locations: vec![
1829 DuplicateLocation {
1830 path: root.join("src/config.ts"),
1831 line: 15,
1832 col: 0,
1833 },
1834 DuplicateLocation {
1835 path: root.join("src/types.ts"),
1836 line: 30,
1837 col: 0,
1838 },
1839 ],
1840 });
1841 let elapsed = Duration::from_millis(0);
1842 let output = build_json(&results, &root, elapsed).expect("should serialize");
1843
1844 let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
1845 .as_str()
1846 .unwrap();
1847 let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
1848 .as_str()
1849 .unwrap();
1850 assert_eq!(loc0, "src/config.ts");
1851 assert_eq!(loc1, "src/types.ts");
1852 }
1853
1854 #[test]
1855 fn json_strips_root_from_circular_dependency_files() {
1856 let root = PathBuf::from("/project");
1857 let mut results = AnalysisResults::default();
1858 results.circular_dependencies.push(CircularDependency {
1859 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1860 length: 2,
1861 line: 1,
1862 col: 0,
1863 is_cross_package: false,
1864 });
1865 let elapsed = Duration::from_millis(0);
1866 let output = build_json(&results, &root, elapsed).expect("should serialize");
1867
1868 let files = output["circular_dependencies"][0]["files"]
1869 .as_array()
1870 .unwrap();
1871 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
1872 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
1873 }
1874
1875 #[test]
1876 fn json_path_outside_root_not_stripped() {
1877 let root = PathBuf::from("/project");
1878 let mut results = AnalysisResults::default();
1879 results.unused_files.push(UnusedFile {
1880 path: PathBuf::from("/other/project/src/file.ts"),
1881 });
1882 let elapsed = Duration::from_millis(0);
1883 let output = build_json(&results, &root, elapsed).expect("should serialize");
1884
1885 let path = output["unused_files"][0]["path"].as_str().unwrap();
1886 assert!(path.contains("/other/project/"));
1887 }
1888
1889 #[test]
1892 fn json_unused_file_contains_path() {
1893 let root = PathBuf::from("/project");
1894 let mut results = AnalysisResults::default();
1895 results.unused_files.push(UnusedFile {
1896 path: root.join("src/orphan.ts"),
1897 });
1898 let elapsed = Duration::from_millis(0);
1899 let output = build_json(&results, &root, elapsed).expect("should serialize");
1900
1901 let file = &output["unused_files"][0];
1902 assert_eq!(file["path"], "src/orphan.ts");
1903 }
1904
1905 #[test]
1906 fn json_unused_type_contains_expected_fields() {
1907 let root = PathBuf::from("/project");
1908 let mut results = AnalysisResults::default();
1909 results.unused_types.push(UnusedExport {
1910 path: root.join("src/types.ts"),
1911 export_name: "OldInterface".to_string(),
1912 is_type_only: true,
1913 line: 20,
1914 col: 0,
1915 span_start: 300,
1916 is_re_export: false,
1917 });
1918 let elapsed = Duration::from_millis(0);
1919 let output = build_json(&results, &root, elapsed).expect("should serialize");
1920
1921 let typ = &output["unused_types"][0];
1922 assert_eq!(typ["export_name"], "OldInterface");
1923 assert_eq!(typ["is_type_only"], true);
1924 assert_eq!(typ["line"], 20);
1925 assert_eq!(typ["path"], "src/types.ts");
1926 }
1927
1928 #[test]
1929 fn json_unused_dependency_contains_expected_fields() {
1930 let root = PathBuf::from("/project");
1931 let mut results = AnalysisResults::default();
1932 results.unused_dependencies.push(UnusedDependency {
1933 package_name: "axios".to_string(),
1934 location: DependencyLocation::Dependencies,
1935 path: root.join("package.json"),
1936 line: 10,
1937 used_in_workspaces: Vec::new(),
1938 });
1939 let elapsed = Duration::from_millis(0);
1940 let output = build_json(&results, &root, elapsed).expect("should serialize");
1941
1942 let dep = &output["unused_dependencies"][0];
1943 assert_eq!(dep["package_name"], "axios");
1944 assert_eq!(dep["line"], 10);
1945 assert!(dep.get("used_in_workspaces").is_none());
1946 }
1947
1948 #[test]
1949 fn json_unused_dependency_includes_cross_workspace_context() {
1950 let root = PathBuf::from("/project");
1951 let mut results = AnalysisResults::default();
1952 results.unused_dependencies.push(UnusedDependency {
1953 package_name: "lodash-es".to_string(),
1954 location: DependencyLocation::Dependencies,
1955 path: root.join("packages/shared/package.json"),
1956 line: 6,
1957 used_in_workspaces: vec![root.join("packages/consumer")],
1958 });
1959 let elapsed = Duration::from_millis(0);
1960 let output = build_json(&results, &root, elapsed).expect("should serialize");
1961
1962 let dep = &output["unused_dependencies"][0];
1963 assert_eq!(
1964 dep["used_in_workspaces"],
1965 serde_json::json!(["packages/consumer"])
1966 );
1967 }
1968
1969 #[test]
1970 fn json_unused_dev_dependency_contains_expected_fields() {
1971 let root = PathBuf::from("/project");
1972 let mut results = AnalysisResults::default();
1973 results.unused_dev_dependencies.push(UnusedDependency {
1974 package_name: "vitest".to_string(),
1975 location: DependencyLocation::DevDependencies,
1976 path: root.join("package.json"),
1977 line: 15,
1978 used_in_workspaces: Vec::new(),
1979 });
1980 let elapsed = Duration::from_millis(0);
1981 let output = build_json(&results, &root, elapsed).expect("should serialize");
1982
1983 let dep = &output["unused_dev_dependencies"][0];
1984 assert_eq!(dep["package_name"], "vitest");
1985 }
1986
1987 #[test]
1988 fn json_unused_optional_dependency_contains_expected_fields() {
1989 let root = PathBuf::from("/project");
1990 let mut results = AnalysisResults::default();
1991 results.unused_optional_dependencies.push(UnusedDependency {
1992 package_name: "fsevents".to_string(),
1993 location: DependencyLocation::OptionalDependencies,
1994 path: root.join("package.json"),
1995 line: 12,
1996 used_in_workspaces: Vec::new(),
1997 });
1998 let elapsed = Duration::from_millis(0);
1999 let output = build_json(&results, &root, elapsed).expect("should serialize");
2000
2001 let dep = &output["unused_optional_dependencies"][0];
2002 assert_eq!(dep["package_name"], "fsevents");
2003 assert_eq!(output["total_issues"], 1);
2004 }
2005
2006 #[test]
2007 fn json_unused_enum_member_contains_expected_fields() {
2008 let root = PathBuf::from("/project");
2009 let mut results = AnalysisResults::default();
2010 results.unused_enum_members.push(UnusedMember {
2011 path: root.join("src/enums.ts"),
2012 parent_name: "Color".to_string(),
2013 member_name: "Purple".to_string(),
2014 kind: MemberKind::EnumMember,
2015 line: 5,
2016 col: 2,
2017 });
2018 let elapsed = Duration::from_millis(0);
2019 let output = build_json(&results, &root, elapsed).expect("should serialize");
2020
2021 let member = &output["unused_enum_members"][0];
2022 assert_eq!(member["parent_name"], "Color");
2023 assert_eq!(member["member_name"], "Purple");
2024 assert_eq!(member["line"], 5);
2025 assert_eq!(member["path"], "src/enums.ts");
2026 }
2027
2028 #[test]
2029 fn json_unused_class_member_contains_expected_fields() {
2030 let root = PathBuf::from("/project");
2031 let mut results = AnalysisResults::default();
2032 results.unused_class_members.push(UnusedMember {
2033 path: root.join("src/api.ts"),
2034 parent_name: "ApiClient".to_string(),
2035 member_name: "deprecatedFetch".to_string(),
2036 kind: MemberKind::ClassMethod,
2037 line: 100,
2038 col: 4,
2039 });
2040 let elapsed = Duration::from_millis(0);
2041 let output = build_json(&results, &root, elapsed).expect("should serialize");
2042
2043 let member = &output["unused_class_members"][0];
2044 assert_eq!(member["parent_name"], "ApiClient");
2045 assert_eq!(member["member_name"], "deprecatedFetch");
2046 assert_eq!(member["line"], 100);
2047 }
2048
2049 #[test]
2050 fn json_unresolved_import_contains_expected_fields() {
2051 let root = PathBuf::from("/project");
2052 let mut results = AnalysisResults::default();
2053 results.unresolved_imports.push(UnresolvedImport {
2054 path: root.join("src/app.ts"),
2055 specifier: "@acme/missing-pkg".to_string(),
2056 line: 7,
2057 col: 0,
2058 specifier_col: 0,
2059 });
2060 let elapsed = Duration::from_millis(0);
2061 let output = build_json(&results, &root, elapsed).expect("should serialize");
2062
2063 let import = &output["unresolved_imports"][0];
2064 assert_eq!(import["specifier"], "@acme/missing-pkg");
2065 assert_eq!(import["line"], 7);
2066 assert_eq!(import["path"], "src/app.ts");
2067 }
2068
2069 #[test]
2070 fn json_unlisted_dependency_contains_import_sites() {
2071 let root = PathBuf::from("/project");
2072 let mut results = AnalysisResults::default();
2073 results.unlisted_dependencies.push(UnlistedDependency {
2074 package_name: "dotenv".to_string(),
2075 imported_from: vec![
2076 ImportSite {
2077 path: root.join("src/config.ts"),
2078 line: 1,
2079 col: 0,
2080 },
2081 ImportSite {
2082 path: root.join("src/server.ts"),
2083 line: 3,
2084 col: 0,
2085 },
2086 ],
2087 });
2088 let elapsed = Duration::from_millis(0);
2089 let output = build_json(&results, &root, elapsed).expect("should serialize");
2090
2091 let dep = &output["unlisted_dependencies"][0];
2092 assert_eq!(dep["package_name"], "dotenv");
2093 let sites = dep["imported_from"].as_array().unwrap();
2094 assert_eq!(sites.len(), 2);
2095 assert_eq!(sites[0]["path"], "src/config.ts");
2096 assert_eq!(sites[1]["path"], "src/server.ts");
2097 }
2098
2099 #[test]
2100 fn json_duplicate_export_contains_locations() {
2101 let root = PathBuf::from("/project");
2102 let mut results = AnalysisResults::default();
2103 results.duplicate_exports.push(DuplicateExport {
2104 export_name: "Button".to_string(),
2105 locations: vec![
2106 DuplicateLocation {
2107 path: root.join("src/ui.ts"),
2108 line: 10,
2109 col: 0,
2110 },
2111 DuplicateLocation {
2112 path: root.join("src/components.ts"),
2113 line: 25,
2114 col: 0,
2115 },
2116 ],
2117 });
2118 let elapsed = Duration::from_millis(0);
2119 let output = build_json(&results, &root, elapsed).expect("should serialize");
2120
2121 let dup = &output["duplicate_exports"][0];
2122 assert_eq!(dup["export_name"], "Button");
2123 let locs = dup["locations"].as_array().unwrap();
2124 assert_eq!(locs.len(), 2);
2125 assert_eq!(locs[0]["line"], 10);
2126 assert_eq!(locs[1]["line"], 25);
2127 }
2128
2129 #[test]
2130 fn json_type_only_dependency_contains_expected_fields() {
2131 let root = PathBuf::from("/project");
2132 let mut results = AnalysisResults::default();
2133 results.type_only_dependencies.push(TypeOnlyDependency {
2134 package_name: "zod".to_string(),
2135 path: root.join("package.json"),
2136 line: 8,
2137 });
2138 let elapsed = Duration::from_millis(0);
2139 let output = build_json(&results, &root, elapsed).expect("should serialize");
2140
2141 let dep = &output["type_only_dependencies"][0];
2142 assert_eq!(dep["package_name"], "zod");
2143 assert_eq!(dep["line"], 8);
2144 }
2145
2146 #[test]
2147 fn json_circular_dependency_contains_expected_fields() {
2148 let root = PathBuf::from("/project");
2149 let mut results = AnalysisResults::default();
2150 results.circular_dependencies.push(CircularDependency {
2151 files: vec![
2152 root.join("src/a.ts"),
2153 root.join("src/b.ts"),
2154 root.join("src/c.ts"),
2155 ],
2156 length: 3,
2157 line: 5,
2158 col: 0,
2159 is_cross_package: false,
2160 });
2161 let elapsed = Duration::from_millis(0);
2162 let output = build_json(&results, &root, elapsed).expect("should serialize");
2163
2164 let cycle = &output["circular_dependencies"][0];
2165 assert_eq!(cycle["length"], 3);
2166 assert_eq!(cycle["line"], 5);
2167 let files = cycle["files"].as_array().unwrap();
2168 assert_eq!(files.len(), 3);
2169 }
2170
2171 #[test]
2174 fn json_re_export_flagged_correctly() {
2175 let root = PathBuf::from("/project");
2176 let mut results = AnalysisResults::default();
2177 results.unused_exports.push(UnusedExport {
2178 path: root.join("src/index.ts"),
2179 export_name: "reExported".to_string(),
2180 is_type_only: false,
2181 line: 1,
2182 col: 0,
2183 span_start: 0,
2184 is_re_export: true,
2185 });
2186 let elapsed = Duration::from_millis(0);
2187 let output = build_json(&results, &root, elapsed).expect("should serialize");
2188
2189 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
2190 }
2191
2192 #[test]
2195 fn json_schema_version_is_4() {
2196 let root = PathBuf::from("/project");
2197 let results = AnalysisResults::default();
2198 let elapsed = Duration::from_millis(0);
2199 let output = build_json(&results, &root, elapsed).expect("should serialize");
2200
2201 assert_eq!(output["schema_version"], SCHEMA_VERSION);
2202 assert_eq!(output["schema_version"], 4);
2203 }
2204
2205 #[test]
2208 fn json_version_matches_cargo_pkg_version() {
2209 let root = PathBuf::from("/project");
2210 let results = AnalysisResults::default();
2211 let elapsed = Duration::from_millis(0);
2212 let output = build_json(&results, &root, elapsed).expect("should serialize");
2213
2214 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
2215 }
2216
2217 #[test]
2220 fn json_elapsed_ms_zero_duration() {
2221 let root = PathBuf::from("/project");
2222 let results = AnalysisResults::default();
2223 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
2224
2225 assert_eq!(output["elapsed_ms"], 0);
2226 }
2227
2228 #[test]
2229 fn json_elapsed_ms_large_duration() {
2230 let root = PathBuf::from("/project");
2231 let results = AnalysisResults::default();
2232 let elapsed = Duration::from_mins(2);
2233 let output = build_json(&results, &root, elapsed).expect("should serialize");
2234
2235 assert_eq!(output["elapsed_ms"], 120_000);
2236 }
2237
2238 #[test]
2239 fn json_elapsed_ms_sub_millisecond_truncated() {
2240 let root = PathBuf::from("/project");
2241 let results = AnalysisResults::default();
2242 let elapsed = Duration::from_micros(500);
2244 let output = build_json(&results, &root, elapsed).expect("should serialize");
2245
2246 assert_eq!(output["elapsed_ms"], 0);
2247 }
2248
2249 #[test]
2252 fn json_multiple_unused_files() {
2253 let root = PathBuf::from("/project");
2254 let mut results = AnalysisResults::default();
2255 results.unused_files.push(UnusedFile {
2256 path: root.join("src/a.ts"),
2257 });
2258 results.unused_files.push(UnusedFile {
2259 path: root.join("src/b.ts"),
2260 });
2261 results.unused_files.push(UnusedFile {
2262 path: root.join("src/c.ts"),
2263 });
2264 let elapsed = Duration::from_millis(0);
2265 let output = build_json(&results, &root, elapsed).expect("should serialize");
2266
2267 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
2268 assert_eq!(output["total_issues"], 3);
2269 }
2270
2271 #[test]
2274 fn strip_root_prefix_on_string_value() {
2275 let mut value = serde_json::json!("/project/src/file.ts");
2276 strip_root_prefix(&mut value, "/project/");
2277 assert_eq!(value, "src/file.ts");
2278 }
2279
2280 #[test]
2281 fn strip_root_prefix_leaves_non_matching_string() {
2282 let mut value = serde_json::json!("/other/src/file.ts");
2283 strip_root_prefix(&mut value, "/project/");
2284 assert_eq!(value, "/other/src/file.ts");
2285 }
2286
2287 #[test]
2288 fn strip_root_prefix_recurses_into_arrays() {
2289 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
2290 strip_root_prefix(&mut value, "/project/");
2291 assert_eq!(value[0], "a.ts");
2292 assert_eq!(value[1], "b.ts");
2293 assert_eq!(value[2], "/other/c.ts");
2294 }
2295
2296 #[test]
2297 fn strip_root_prefix_recurses_into_nested_objects() {
2298 let mut value = serde_json::json!({
2299 "outer": {
2300 "path": "/project/src/nested.ts"
2301 }
2302 });
2303 strip_root_prefix(&mut value, "/project/");
2304 assert_eq!(value["outer"]["path"], "src/nested.ts");
2305 }
2306
2307 #[test]
2308 fn strip_root_prefix_leaves_numbers_and_booleans() {
2309 let mut value = serde_json::json!({
2310 "line": 42,
2311 "is_type_only": false,
2312 "path": "/project/src/file.ts"
2313 });
2314 strip_root_prefix(&mut value, "/project/");
2315 assert_eq!(value["line"], 42);
2316 assert_eq!(value["is_type_only"], false);
2317 assert_eq!(value["path"], "src/file.ts");
2318 }
2319
2320 #[test]
2321 fn strip_root_prefix_normalizes_windows_separators() {
2322 let mut value = serde_json::json!(r"/project\src\file.ts");
2323 strip_root_prefix(&mut value, "/project/");
2324 assert_eq!(value, "src/file.ts");
2325 }
2326
2327 #[test]
2328 fn strip_root_prefix_handles_empty_string_after_strip() {
2329 let mut value = serde_json::json!("/project/");
2332 strip_root_prefix(&mut value, "/project/");
2333 assert_eq!(value, "");
2334 }
2335
2336 #[test]
2337 fn strip_root_prefix_deeply_nested_array_of_objects() {
2338 let mut value = serde_json::json!({
2339 "groups": [{
2340 "instances": [{
2341 "file": "/project/src/a.ts"
2342 }, {
2343 "file": "/project/src/b.ts"
2344 }]
2345 }]
2346 });
2347 strip_root_prefix(&mut value, "/project/");
2348 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
2349 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
2350 }
2351
2352 #[test]
2355 fn json_full_sample_results_total_issues_correct() {
2356 let root = PathBuf::from("/project");
2357 let results = sample_results(&root);
2358 let elapsed = Duration::from_millis(100);
2359 let output = build_json(&results, &root, elapsed).expect("should serialize");
2360
2361 assert_eq!(output["total_issues"], results.total_issues());
2367 }
2368
2369 #[test]
2370 fn json_full_sample_no_absolute_paths_in_output() {
2371 let root = PathBuf::from("/project");
2372 let results = sample_results(&root);
2373 let elapsed = Duration::from_millis(0);
2374 let output = build_json(&results, &root, elapsed).expect("should serialize");
2375
2376 let json_str = serde_json::to_string(&output).expect("should stringify");
2377 assert!(!json_str.contains("/project/src/"));
2379 assert!(!json_str.contains("/project/package.json"));
2380 }
2381
2382 #[test]
2385 fn json_output_is_deterministic() {
2386 let root = PathBuf::from("/project");
2387 let results = sample_results(&root);
2388 let elapsed = Duration::from_millis(50);
2389
2390 let output1 = build_json(&results, &root, elapsed).expect("first build");
2391 let output2 = build_json(&results, &root, elapsed).expect("second build");
2392
2393 assert_eq!(output1, output2);
2394 }
2395
2396 #[test]
2399 fn json_results_fields_do_not_shadow_metadata() {
2400 let root = PathBuf::from("/project");
2403 let results = AnalysisResults::default();
2404 let elapsed = Duration::from_millis(99);
2405 let output = build_json(&results, &root, elapsed).expect("should serialize");
2406
2407 assert_eq!(output["schema_version"], 4);
2409 assert_eq!(output["elapsed_ms"], 99);
2410 }
2411
2412 #[test]
2415 fn json_all_issue_type_arrays_present_in_empty_results() {
2416 let root = PathBuf::from("/project");
2417 let results = AnalysisResults::default();
2418 let elapsed = Duration::from_millis(0);
2419 let output = build_json(&results, &root, elapsed).expect("should serialize");
2420
2421 let expected_arrays = [
2422 "unused_files",
2423 "unused_exports",
2424 "unused_types",
2425 "unused_dependencies",
2426 "unused_dev_dependencies",
2427 "unused_optional_dependencies",
2428 "unused_enum_members",
2429 "unused_class_members",
2430 "unresolved_imports",
2431 "unlisted_dependencies",
2432 "duplicate_exports",
2433 "type_only_dependencies",
2434 "test_only_dependencies",
2435 "circular_dependencies",
2436 ];
2437 for key in &expected_arrays {
2438 assert!(
2439 output[key].is_array(),
2440 "expected '{key}' to be an array in JSON output"
2441 );
2442 }
2443 }
2444
2445 #[test]
2448 fn insert_meta_adds_key_to_object() {
2449 let mut output = serde_json::json!({ "foo": 1 });
2450 let meta = serde_json::json!({ "docs": "https://example.com" });
2451 insert_meta(&mut output, meta.clone());
2452 assert_eq!(output["_meta"], meta);
2453 }
2454
2455 #[test]
2456 fn insert_meta_noop_on_non_object() {
2457 let mut output = serde_json::json!([1, 2, 3]);
2458 let meta = serde_json::json!({ "docs": "https://example.com" });
2459 insert_meta(&mut output, meta);
2460 assert!(output.is_array());
2462 }
2463
2464 #[test]
2465 fn insert_meta_overwrites_existing_meta() {
2466 let mut output = serde_json::json!({ "_meta": "old" });
2467 let meta = serde_json::json!({ "new": true });
2468 insert_meta(&mut output, meta.clone());
2469 assert_eq!(output["_meta"], meta);
2470 }
2471
2472 #[test]
2475 fn build_json_envelope_has_metadata_fields() {
2476 let report = serde_json::json!({ "findings": [] });
2477 let elapsed = Duration::from_millis(42);
2478 let output = build_json_envelope(report, elapsed);
2479
2480 assert_eq!(output["schema_version"], 4);
2481 assert!(output["version"].is_string());
2482 assert_eq!(output["elapsed_ms"], 42);
2483 assert!(output["findings"].is_array());
2484 }
2485
2486 #[test]
2487 fn build_json_envelope_metadata_appears_first() {
2488 let report = serde_json::json!({ "data": "value" });
2489 let output = build_json_envelope(report, Duration::from_millis(10));
2490
2491 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
2492 assert_eq!(keys[0], "schema_version");
2493 assert_eq!(keys[1], "version");
2494 assert_eq!(keys[2], "elapsed_ms");
2495 }
2496
2497 #[test]
2498 fn build_json_envelope_non_object_report() {
2499 let report = serde_json::json!("not an object");
2501 let output = build_json_envelope(report, Duration::from_millis(0));
2502
2503 let obj = output.as_object().unwrap();
2504 assert_eq!(obj.len(), 3);
2505 assert!(obj.contains_key("schema_version"));
2506 assert!(obj.contains_key("version"));
2507 assert!(obj.contains_key("elapsed_ms"));
2508 }
2509
2510 #[test]
2513 fn strip_root_prefix_null_unchanged() {
2514 let mut value = serde_json::Value::Null;
2515 strip_root_prefix(&mut value, "/project/");
2516 assert!(value.is_null());
2517 }
2518
2519 #[test]
2522 fn strip_root_prefix_empty_string() {
2523 let mut value = serde_json::json!("");
2524 strip_root_prefix(&mut value, "/project/");
2525 assert_eq!(value, "");
2526 }
2527
2528 #[test]
2531 fn strip_root_prefix_mixed_types() {
2532 let mut value = serde_json::json!({
2533 "path": "/project/src/file.ts",
2534 "line": 42,
2535 "flag": true,
2536 "nested": {
2537 "items": ["/project/a.ts", 99, null, "/project/b.ts"],
2538 "deep": { "path": "/project/c.ts" }
2539 }
2540 });
2541 strip_root_prefix(&mut value, "/project/");
2542 assert_eq!(value["path"], "src/file.ts");
2543 assert_eq!(value["line"], 42);
2544 assert_eq!(value["flag"], true);
2545 assert_eq!(value["nested"]["items"][0], "a.ts");
2546 assert_eq!(value["nested"]["items"][1], 99);
2547 assert!(value["nested"]["items"][2].is_null());
2548 assert_eq!(value["nested"]["items"][3], "b.ts");
2549 assert_eq!(value["nested"]["deep"]["path"], "c.ts");
2550 }
2551
2552 #[test]
2555 fn json_check_meta_integrates_correctly() {
2556 let root = PathBuf::from("/project");
2557 let results = AnalysisResults::default();
2558 let elapsed = Duration::from_millis(0);
2559 let mut output = build_json(&results, &root, elapsed).expect("should serialize");
2560 insert_meta(&mut output, crate::explain::check_meta());
2561
2562 assert!(output["_meta"]["docs"].is_string());
2563 assert!(output["_meta"]["rules"].is_object());
2564 }
2565
2566 #[test]
2569 fn json_unused_member_kind_serialized() {
2570 let root = PathBuf::from("/project");
2571 let mut results = AnalysisResults::default();
2572 results.unused_enum_members.push(UnusedMember {
2573 path: root.join("src/enums.ts"),
2574 parent_name: "Color".to_string(),
2575 member_name: "Red".to_string(),
2576 kind: MemberKind::EnumMember,
2577 line: 3,
2578 col: 2,
2579 });
2580 results.unused_class_members.push(UnusedMember {
2581 path: root.join("src/class.ts"),
2582 parent_name: "Foo".to_string(),
2583 member_name: "bar".to_string(),
2584 kind: MemberKind::ClassMethod,
2585 line: 10,
2586 col: 4,
2587 });
2588
2589 let elapsed = Duration::from_millis(0);
2590 let output = build_json(&results, &root, elapsed).expect("should serialize");
2591
2592 let enum_member = &output["unused_enum_members"][0];
2593 assert!(enum_member["kind"].is_string());
2594 let class_member = &output["unused_class_members"][0];
2595 assert!(class_member["kind"].is_string());
2596 }
2597
2598 #[test]
2601 fn json_unused_export_has_actions() {
2602 let root = PathBuf::from("/project");
2603 let mut results = AnalysisResults::default();
2604 results.unused_exports.push(UnusedExport {
2605 path: root.join("src/utils.ts"),
2606 export_name: "helperFn".to_string(),
2607 is_type_only: false,
2608 line: 10,
2609 col: 4,
2610 span_start: 120,
2611 is_re_export: false,
2612 });
2613 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2614
2615 let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2616 assert_eq!(actions.len(), 2);
2617
2618 assert_eq!(actions[0]["type"], "remove-export");
2620 assert_eq!(actions[0]["auto_fixable"], true);
2621 assert!(actions[0].get("note").is_none());
2622
2623 assert_eq!(actions[1]["type"], "suppress-line");
2625 assert_eq!(
2626 actions[1]["comment"],
2627 "// fallow-ignore-next-line unused-export"
2628 );
2629 }
2630
2631 #[test]
2632 fn json_unused_file_has_file_suppress_and_note() {
2633 let root = PathBuf::from("/project");
2634 let mut results = AnalysisResults::default();
2635 results.unused_files.push(UnusedFile {
2636 path: root.join("src/dead.ts"),
2637 });
2638 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2639
2640 let actions = output["unused_files"][0]["actions"].as_array().unwrap();
2641 assert_eq!(actions[0]["type"], "delete-file");
2642 assert_eq!(actions[0]["auto_fixable"], false);
2643 assert!(actions[0]["note"].is_string());
2644 assert_eq!(actions[1]["type"], "suppress-file");
2645 assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
2646 }
2647
2648 #[test]
2649 fn json_unused_dependency_has_config_suppress_with_package_name() {
2650 let root = PathBuf::from("/project");
2651 let mut results = AnalysisResults::default();
2652 results.unused_dependencies.push(UnusedDependency {
2653 package_name: "lodash".to_string(),
2654 location: DependencyLocation::Dependencies,
2655 path: root.join("package.json"),
2656 line: 5,
2657 used_in_workspaces: Vec::new(),
2658 });
2659 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2660
2661 let actions = output["unused_dependencies"][0]["actions"]
2662 .as_array()
2663 .unwrap();
2664 assert_eq!(actions[0]["type"], "remove-dependency");
2665 assert_eq!(actions[0]["auto_fixable"], true);
2666
2667 assert_eq!(actions[1]["type"], "add-to-config");
2669 assert_eq!(actions[1]["config_key"], "ignoreDependencies");
2670 assert_eq!(actions[1]["value"], "lodash");
2671 }
2672
2673 #[test]
2674 fn json_cross_workspace_dependency_is_not_auto_fixable() {
2675 let root = PathBuf::from("/project");
2676 let mut results = AnalysisResults::default();
2677 results.unused_dependencies.push(UnusedDependency {
2678 package_name: "lodash-es".to_string(),
2679 location: DependencyLocation::Dependencies,
2680 path: root.join("packages/shared/package.json"),
2681 line: 5,
2682 used_in_workspaces: vec![root.join("packages/consumer")],
2683 });
2684 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2685
2686 let actions = output["unused_dependencies"][0]["actions"]
2687 .as_array()
2688 .unwrap();
2689 assert_eq!(actions[0]["type"], "move-dependency");
2690 assert_eq!(actions[0]["auto_fixable"], false);
2691 assert!(
2692 actions[0]["note"]
2693 .as_str()
2694 .unwrap()
2695 .contains("will not remove")
2696 );
2697 assert_eq!(actions[1]["type"], "add-to-config");
2698 }
2699
2700 #[test]
2701 fn json_empty_results_have_no_actions_in_empty_arrays() {
2702 let root = PathBuf::from("/project");
2703 let results = AnalysisResults::default();
2704 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2705
2706 assert!(output["unused_exports"].as_array().unwrap().is_empty());
2708 assert!(output["unused_files"].as_array().unwrap().is_empty());
2709 }
2710
2711 #[test]
2712 fn json_all_issue_types_have_actions() {
2713 let root = PathBuf::from("/project");
2714 let results = sample_results(&root);
2715 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2716
2717 let issue_keys = [
2718 "unused_files",
2719 "unused_exports",
2720 "unused_types",
2721 "unused_dependencies",
2722 "unused_dev_dependencies",
2723 "unused_optional_dependencies",
2724 "unused_enum_members",
2725 "unused_class_members",
2726 "unresolved_imports",
2727 "unlisted_dependencies",
2728 "duplicate_exports",
2729 "type_only_dependencies",
2730 "test_only_dependencies",
2731 "circular_dependencies",
2732 ];
2733
2734 for key in &issue_keys {
2735 let arr = output[key].as_array().unwrap();
2736 if !arr.is_empty() {
2737 let actions = arr[0]["actions"].as_array();
2738 assert!(
2739 actions.is_some() && !actions.unwrap().is_empty(),
2740 "missing actions for {key}"
2741 );
2742 }
2743 }
2744 }
2745
2746 #[test]
2749 fn health_finding_has_actions() {
2750 let mut output = serde_json::json!({
2751 "findings": [{
2752 "path": "src/utils.ts",
2753 "name": "processData",
2754 "line": 10,
2755 "col": 0,
2756 "cyclomatic": 25,
2757 "cognitive": 30,
2758 "line_count": 150,
2759 "exceeded": "both"
2760 }]
2761 });
2762
2763 inject_health_actions(&mut output, HealthActionOptions::default());
2764
2765 let actions = output["findings"][0]["actions"].as_array().unwrap();
2766 assert_eq!(actions.len(), 2);
2767 assert_eq!(actions[0]["type"], "refactor-function");
2768 assert_eq!(actions[0]["auto_fixable"], false);
2769 assert!(
2770 actions[0]["description"]
2771 .as_str()
2772 .unwrap()
2773 .contains("processData")
2774 );
2775 assert_eq!(actions[1]["type"], "suppress-line");
2776 assert_eq!(
2777 actions[1]["comment"],
2778 "// fallow-ignore-next-line complexity"
2779 );
2780 }
2781
2782 #[test]
2783 fn refactoring_target_has_actions() {
2784 let mut output = serde_json::json!({
2785 "targets": [{
2786 "path": "src/big-module.ts",
2787 "priority": 85.0,
2788 "efficiency": 42.5,
2789 "recommendation": "Split module: 12 exports, 4 unused",
2790 "category": "split_high_impact",
2791 "effort": "medium",
2792 "confidence": "high",
2793 "evidence": { "unused_exports": 4 }
2794 }]
2795 });
2796
2797 inject_health_actions(&mut output, HealthActionOptions::default());
2798
2799 let actions = output["targets"][0]["actions"].as_array().unwrap();
2800 assert_eq!(actions.len(), 2);
2801 assert_eq!(actions[0]["type"], "apply-refactoring");
2802 assert_eq!(
2803 actions[0]["description"],
2804 "Split module: 12 exports, 4 unused"
2805 );
2806 assert_eq!(actions[0]["category"], "split_high_impact");
2807 assert_eq!(actions[1]["type"], "suppress-line");
2809 }
2810
2811 #[test]
2812 fn refactoring_target_without_evidence_has_no_suppress() {
2813 let mut output = serde_json::json!({
2814 "targets": [{
2815 "path": "src/simple.ts",
2816 "priority": 30.0,
2817 "efficiency": 15.0,
2818 "recommendation": "Consider extracting helper functions",
2819 "category": "extract_complex_functions",
2820 "effort": "small",
2821 "confidence": "medium"
2822 }]
2823 });
2824
2825 inject_health_actions(&mut output, HealthActionOptions::default());
2826
2827 let actions = output["targets"][0]["actions"].as_array().unwrap();
2828 assert_eq!(actions.len(), 1);
2829 assert_eq!(actions[0]["type"], "apply-refactoring");
2830 }
2831
2832 #[test]
2833 fn health_empty_findings_no_actions() {
2834 let mut output = serde_json::json!({
2835 "findings": [],
2836 "targets": []
2837 });
2838
2839 inject_health_actions(&mut output, HealthActionOptions::default());
2840
2841 assert!(output["findings"].as_array().unwrap().is_empty());
2842 assert!(output["targets"].as_array().unwrap().is_empty());
2843 }
2844
2845 #[test]
2846 fn hotspot_has_actions() {
2847 let mut output = serde_json::json!({
2848 "hotspots": [{
2849 "path": "src/utils.ts",
2850 "complexity_score": 45.0,
2851 "churn_score": 12,
2852 "hotspot_score": 540.0
2853 }]
2854 });
2855
2856 inject_health_actions(&mut output, HealthActionOptions::default());
2857
2858 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2859 assert_eq!(actions.len(), 2);
2860 assert_eq!(actions[0]["type"], "refactor-file");
2861 assert!(
2862 actions[0]["description"]
2863 .as_str()
2864 .unwrap()
2865 .contains("src/utils.ts")
2866 );
2867 assert_eq!(actions[1]["type"], "add-tests");
2868 }
2869
2870 #[test]
2871 fn hotspot_low_bus_factor_emits_action() {
2872 let mut output = serde_json::json!({
2873 "hotspots": [{
2874 "path": "src/api.ts",
2875 "ownership": {
2876 "bus_factor": 1,
2877 "contributor_count": 1,
2878 "top_contributor": {"identifier": "alice@x", "share": 1.0, "stale_days": 5, "commits": 30},
2879 "unowned": null,
2880 "drift": false,
2881 }
2882 }]
2883 });
2884
2885 inject_health_actions(&mut output, HealthActionOptions::default());
2886
2887 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2888 assert!(
2889 actions
2890 .iter()
2891 .filter_map(|a| a["type"].as_str())
2892 .any(|t| t == "low-bus-factor"),
2893 "low-bus-factor action should be present",
2894 );
2895 let bus = actions
2896 .iter()
2897 .find(|a| a["type"] == "low-bus-factor")
2898 .unwrap();
2899 assert!(bus["description"].as_str().unwrap().contains("alice@x"));
2900 }
2901
2902 #[test]
2903 fn hotspot_unowned_emits_action_with_pattern() {
2904 let mut output = serde_json::json!({
2905 "hotspots": [{
2906 "path": "src/api/users.ts",
2907 "ownership": {
2908 "bus_factor": 2,
2909 "contributor_count": 4,
2910 "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
2911 "unowned": true,
2912 "drift": false,
2913 }
2914 }]
2915 });
2916
2917 inject_health_actions(&mut output, HealthActionOptions::default());
2918
2919 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2920 let unowned = actions
2921 .iter()
2922 .find(|a| a["type"] == "unowned-hotspot")
2923 .expect("unowned-hotspot action should be present");
2924 assert_eq!(unowned["suggested_pattern"], "/src/api/");
2927 assert_eq!(unowned["heuristic"], "directory-deepest");
2928 }
2929
2930 #[test]
2931 fn hotspot_unowned_skipped_when_codeowners_missing() {
2932 let mut output = serde_json::json!({
2933 "hotspots": [{
2934 "path": "src/api.ts",
2935 "ownership": {
2936 "bus_factor": 2,
2937 "contributor_count": 4,
2938 "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
2939 "unowned": null,
2940 "drift": false,
2941 }
2942 }]
2943 });
2944
2945 inject_health_actions(&mut output, HealthActionOptions::default());
2946
2947 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2948 assert!(
2949 !actions.iter().any(|a| a["type"] == "unowned-hotspot"),
2950 "unowned action must not fire when CODEOWNERS file is absent"
2951 );
2952 }
2953
2954 #[test]
2955 fn hotspot_drift_emits_action() {
2956 let mut output = serde_json::json!({
2957 "hotspots": [{
2958 "path": "src/old.ts",
2959 "ownership": {
2960 "bus_factor": 1,
2961 "contributor_count": 2,
2962 "top_contributor": {"identifier": "bob@x", "share": 0.9, "stale_days": 1, "commits": 18},
2963 "unowned": null,
2964 "drift": true,
2965 "drift_reason": "original author alice@x has 5% share",
2966 }
2967 }]
2968 });
2969
2970 inject_health_actions(&mut output, HealthActionOptions::default());
2971
2972 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2973 let drift = actions
2974 .iter()
2975 .find(|a| a["type"] == "ownership-drift")
2976 .expect("ownership-drift action should be present");
2977 assert!(drift["description"].as_str().unwrap().contains("alice@x"));
2978 }
2979
2980 #[test]
2983 fn codeowners_pattern_uses_deepest_directory() {
2984 assert_eq!(
2987 suggest_codeowners_pattern("src/api/users/handlers.ts"),
2988 "/src/api/users/"
2989 );
2990 }
2991
2992 #[test]
2993 fn codeowners_pattern_for_root_file() {
2994 assert_eq!(suggest_codeowners_pattern("README.md"), "/README.md");
2995 }
2996
2997 #[test]
2998 fn codeowners_pattern_normalizes_backslashes() {
2999 assert_eq!(
3000 suggest_codeowners_pattern("src\\api\\users.ts"),
3001 "/src/api/"
3002 );
3003 }
3004
3005 #[test]
3006 fn codeowners_pattern_two_level_path() {
3007 assert_eq!(suggest_codeowners_pattern("src/foo.ts"), "/src/");
3008 }
3009
3010 #[test]
3011 fn health_finding_suppress_has_placement() {
3012 let mut output = serde_json::json!({
3013 "findings": [{
3014 "path": "src/utils.ts",
3015 "name": "processData",
3016 "line": 10,
3017 "col": 0,
3018 "cyclomatic": 25,
3019 "cognitive": 30,
3020 "line_count": 150,
3021 "exceeded": "both"
3022 }]
3023 });
3024
3025 inject_health_actions(&mut output, HealthActionOptions::default());
3026
3027 let suppress = &output["findings"][0]["actions"][1];
3028 assert_eq!(suppress["placement"], "above-function-declaration");
3029 }
3030
3031 #[test]
3032 fn html_template_health_finding_uses_html_suppression() {
3033 let mut output = serde_json::json!({
3034 "findings": [{
3035 "path": "src/app.component.html",
3036 "name": "<template>",
3037 "line": 1,
3038 "col": 0,
3039 "cyclomatic": 25,
3040 "cognitive": 30,
3041 "line_count": 40,
3042 "exceeded": "both"
3043 }]
3044 });
3045
3046 inject_health_actions(&mut output, HealthActionOptions::default());
3047
3048 let suppress = &output["findings"][0]["actions"][1];
3049 assert_eq!(suppress["type"], "suppress-file");
3050 assert_eq!(
3051 suppress["comment"],
3052 "<!-- fallow-ignore-file complexity -->"
3053 );
3054 assert_eq!(suppress["placement"], "top-of-template");
3055 }
3056
3057 #[test]
3058 fn inline_template_health_finding_uses_decorator_suppression() {
3059 let mut output = serde_json::json!({
3060 "findings": [{
3061 "path": "src/app.component.ts",
3062 "name": "<template>",
3063 "line": 5,
3064 "col": 0,
3065 "cyclomatic": 25,
3066 "cognitive": 30,
3067 "line_count": 40,
3068 "exceeded": "both"
3069 }]
3070 });
3071
3072 inject_health_actions(&mut output, HealthActionOptions::default());
3073
3074 let refactor = &output["findings"][0]["actions"][0];
3075 assert_eq!(refactor["type"], "refactor-function");
3076 assert!(
3077 refactor["description"]
3078 .as_str()
3079 .unwrap()
3080 .contains("template complexity")
3081 );
3082 let suppress = &output["findings"][0]["actions"][1];
3083 assert_eq!(suppress["type"], "suppress-line");
3084 assert_eq!(
3085 suppress["description"],
3086 "Suppress with an inline comment above the Angular decorator"
3087 );
3088 assert_eq!(suppress["placement"], "above-angular-decorator");
3089 }
3090
3091 #[test]
3094 fn clone_family_has_actions() {
3095 let mut output = serde_json::json!({
3096 "clone_families": [{
3097 "files": ["src/a.ts", "src/b.ts"],
3098 "groups": [
3099 { "instances": [{"file": "src/a.ts"}, {"file": "src/b.ts"}], "token_count": 100, "line_count": 20 }
3100 ],
3101 "total_duplicated_lines": 20,
3102 "total_duplicated_tokens": 100,
3103 "suggestions": [
3104 { "kind": "ExtractFunction", "description": "Extract shared validation logic", "estimated_savings": 15 }
3105 ]
3106 }]
3107 });
3108
3109 inject_dupes_actions(&mut output);
3110
3111 let actions = output["clone_families"][0]["actions"].as_array().unwrap();
3112 assert_eq!(actions.len(), 3);
3113 assert_eq!(actions[0]["type"], "extract-shared");
3114 assert_eq!(actions[0]["auto_fixable"], false);
3115 assert!(
3116 actions[0]["description"]
3117 .as_str()
3118 .unwrap()
3119 .contains("20 lines")
3120 );
3121 assert_eq!(actions[1]["type"], "apply-suggestion");
3123 assert!(
3124 actions[1]["description"]
3125 .as_str()
3126 .unwrap()
3127 .contains("validation logic")
3128 );
3129 assert_eq!(actions[2]["type"], "suppress-line");
3131 assert_eq!(
3132 actions[2]["comment"],
3133 "// fallow-ignore-next-line code-duplication"
3134 );
3135 }
3136
3137 #[test]
3138 fn clone_group_has_actions() {
3139 let mut output = serde_json::json!({
3140 "clone_groups": [{
3141 "instances": [
3142 {"file": "src/a.ts", "start_line": 1, "end_line": 10},
3143 {"file": "src/b.ts", "start_line": 5, "end_line": 14}
3144 ],
3145 "token_count": 50,
3146 "line_count": 10
3147 }]
3148 });
3149
3150 inject_dupes_actions(&mut output);
3151
3152 let actions = output["clone_groups"][0]["actions"].as_array().unwrap();
3153 assert_eq!(actions.len(), 2);
3154 assert_eq!(actions[0]["type"], "extract-shared");
3155 assert!(
3156 actions[0]["description"]
3157 .as_str()
3158 .unwrap()
3159 .contains("10 lines")
3160 );
3161 assert!(
3162 actions[0]["description"]
3163 .as_str()
3164 .unwrap()
3165 .contains("2 instances")
3166 );
3167 assert_eq!(actions[1]["type"], "suppress-line");
3168 }
3169
3170 #[test]
3171 fn dupes_empty_results_no_actions() {
3172 let mut output = serde_json::json!({
3173 "clone_families": [],
3174 "clone_groups": []
3175 });
3176
3177 inject_dupes_actions(&mut output);
3178
3179 assert!(output["clone_families"].as_array().unwrap().is_empty());
3180 assert!(output["clone_groups"].as_array().unwrap().is_empty());
3181 }
3182
3183 fn crap_only_finding_envelope(
3192 coverage_tier: Option<&str>,
3193 cyclomatic: u16,
3194 max_cyclomatic_threshold: u16,
3195 ) -> serde_json::Value {
3196 crap_only_finding_envelope_with_max_crap(
3197 coverage_tier,
3198 cyclomatic,
3199 12,
3200 max_cyclomatic_threshold,
3201 15,
3202 30.0,
3203 )
3204 }
3205
3206 fn crap_only_finding_envelope_with_cognitive(
3207 coverage_tier: Option<&str>,
3208 cyclomatic: u16,
3209 cognitive: u16,
3210 max_cyclomatic_threshold: u16,
3211 ) -> serde_json::Value {
3212 crap_only_finding_envelope_with_max_crap(
3213 coverage_tier,
3214 cyclomatic,
3215 cognitive,
3216 max_cyclomatic_threshold,
3217 15,
3218 30.0,
3219 )
3220 }
3221
3222 fn crap_only_finding_envelope_with_max_crap(
3223 coverage_tier: Option<&str>,
3224 cyclomatic: u16,
3225 cognitive: u16,
3226 max_cyclomatic_threshold: u16,
3227 max_cognitive_threshold: u16,
3228 max_crap_threshold: f64,
3229 ) -> serde_json::Value {
3230 let mut finding = serde_json::json!({
3231 "path": "src/risk.ts",
3232 "name": "computeScore",
3233 "line": 12,
3234 "col": 0,
3235 "cyclomatic": cyclomatic,
3236 "cognitive": cognitive,
3237 "line_count": 40,
3238 "exceeded": "crap",
3239 "crap": 35.5,
3240 });
3241 if let Some(tier) = coverage_tier {
3242 finding["coverage_tier"] = serde_json::Value::String(tier.to_owned());
3243 }
3244 serde_json::json!({
3245 "findings": [finding],
3246 "summary": {
3247 "max_cyclomatic_threshold": max_cyclomatic_threshold,
3248 "max_cognitive_threshold": max_cognitive_threshold,
3249 "max_crap_threshold": max_crap_threshold,
3250 },
3251 })
3252 }
3253
3254 #[test]
3255 fn crap_only_tier_none_emits_add_tests() {
3256 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3257 inject_health_actions(&mut output, HealthActionOptions::default());
3258 let actions = output["findings"][0]["actions"].as_array().unwrap();
3259 assert!(
3260 actions.iter().any(|a| a["type"] == "add-tests"),
3261 "tier=none crap-only must emit add-tests, got {actions:?}"
3262 );
3263 assert!(
3264 !actions.iter().any(|a| a["type"] == "increase-coverage"),
3265 "tier=none must not emit increase-coverage"
3266 );
3267 }
3268
3269 #[test]
3270 fn crap_only_tier_partial_emits_increase_coverage() {
3271 let mut output = crap_only_finding_envelope(Some("partial"), 6, 20);
3272 inject_health_actions(&mut output, HealthActionOptions::default());
3273 let actions = output["findings"][0]["actions"].as_array().unwrap();
3274 assert!(
3275 actions.iter().any(|a| a["type"] == "increase-coverage"),
3276 "tier=partial crap-only must emit increase-coverage, got {actions:?}"
3277 );
3278 assert!(
3279 !actions.iter().any(|a| a["type"] == "add-tests"),
3280 "tier=partial must not emit add-tests"
3281 );
3282 }
3283
3284 #[test]
3285 fn crap_only_tier_high_emits_increase_coverage_when_full_coverage_can_clear_crap() {
3286 let mut output = crap_only_finding_envelope(Some("high"), 20, 30);
3290 inject_health_actions(&mut output, HealthActionOptions::default());
3291 let actions = output["findings"][0]["actions"].as_array().unwrap();
3292 assert!(
3293 actions.iter().any(|a| a["type"] == "increase-coverage"),
3294 "tier=high crap-only must still emit increase-coverage when full coverage can clear CRAP, got {actions:?}"
3295 );
3296 assert!(
3297 !actions.iter().any(|a| a["type"] == "refactor-function"),
3298 "coverage-remediable crap-only findings should not get refactor-function unless near the cyclomatic threshold"
3299 );
3300 assert!(
3301 !actions.iter().any(|a| a["type"] == "add-tests"),
3302 "tier=high must not emit add-tests"
3303 );
3304 }
3305
3306 #[test]
3307 fn crap_only_emits_refactor_when_full_coverage_cannot_clear_crap() {
3308 let mut output =
3312 crap_only_finding_envelope_with_max_crap(Some("high"), 35, 12, 50, 15, 30.0);
3313 inject_health_actions(&mut output, HealthActionOptions::default());
3314 let actions = output["findings"][0]["actions"].as_array().unwrap();
3315 assert!(
3316 actions.iter().any(|a| a["type"] == "refactor-function"),
3317 "full-coverage-impossible CRAP-only finding must emit refactor-function, got {actions:?}"
3318 );
3319 assert!(
3320 !actions.iter().any(|a| a["type"] == "increase-coverage"),
3321 "must not emit increase-coverage when even 100% coverage cannot clear CRAP"
3322 );
3323 assert!(
3324 !actions.iter().any(|a| a["type"] == "add-tests"),
3325 "must not emit add-tests when even 100% coverage cannot clear CRAP"
3326 );
3327 }
3328
3329 #[test]
3330 fn crap_only_high_cc_appends_secondary_refactor() {
3331 let mut output = crap_only_finding_envelope(Some("none"), 16, 20);
3334 inject_health_actions(&mut output, HealthActionOptions::default());
3335 let actions = output["findings"][0]["actions"].as_array().unwrap();
3336 assert!(
3337 actions.iter().any(|a| a["type"] == "add-tests"),
3338 "near-threshold crap-only still emits the primary tier action"
3339 );
3340 assert!(
3341 actions.iter().any(|a| a["type"] == "refactor-function"),
3342 "near-threshold crap-only must also emit secondary refactor-function"
3343 );
3344 }
3345
3346 #[test]
3347 fn crap_only_far_below_threshold_no_secondary_refactor() {
3348 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3350 inject_health_actions(&mut output, HealthActionOptions::default());
3351 let actions = output["findings"][0]["actions"].as_array().unwrap();
3352 assert!(
3353 !actions.iter().any(|a| a["type"] == "refactor-function"),
3354 "low-CC crap-only should not get a secondary refactor-function"
3355 );
3356 }
3357
3358 #[test]
3359 fn crap_only_near_threshold_low_cognitive_no_secondary_refactor() {
3360 let mut output = crap_only_finding_envelope_with_cognitive(Some("none"), 17, 2, 20);
3369 inject_health_actions(&mut output, HealthActionOptions::default());
3370 let actions = output["findings"][0]["actions"].as_array().unwrap();
3371 assert!(
3372 actions.iter().any(|a| a["type"] == "add-tests"),
3373 "primary tier action still emits"
3374 );
3375 assert!(
3376 !actions.iter().any(|a| a["type"] == "refactor-function"),
3377 "near-threshold CC with cognitive below floor must NOT emit secondary refactor (got {actions:?})"
3378 );
3379 }
3380
3381 #[test]
3382 fn crap_only_near_threshold_high_cognitive_emits_secondary_refactor() {
3383 let mut output = crap_only_finding_envelope_with_cognitive(Some("none"), 16, 10, 20);
3389 inject_health_actions(&mut output, HealthActionOptions::default());
3390 let actions = output["findings"][0]["actions"].as_array().unwrap();
3391 assert!(
3392 actions.iter().any(|a| a["type"] == "add-tests"),
3393 "primary tier action still emits"
3394 );
3395 assert!(
3396 actions.iter().any(|a| a["type"] == "refactor-function"),
3397 "near-threshold CC with cognitive above floor must emit secondary refactor (got {actions:?})"
3398 );
3399 }
3400
3401 #[test]
3402 fn cyclomatic_only_emits_only_refactor_function() {
3403 let mut output = serde_json::json!({
3404 "findings": [{
3405 "path": "src/cyclo.ts",
3406 "name": "branchy",
3407 "line": 5,
3408 "col": 0,
3409 "cyclomatic": 25,
3410 "cognitive": 10,
3411 "line_count": 80,
3412 "exceeded": "cyclomatic",
3413 }],
3414 "summary": { "max_cyclomatic_threshold": 20 },
3415 });
3416 inject_health_actions(&mut output, HealthActionOptions::default());
3417 let actions = output["findings"][0]["actions"].as_array().unwrap();
3418 assert!(
3419 actions.iter().any(|a| a["type"] == "refactor-function"),
3420 "non-CRAP findings emit refactor-function"
3421 );
3422 assert!(
3423 !actions.iter().any(|a| a["type"] == "add-tests"),
3424 "non-CRAP findings must not emit add-tests"
3425 );
3426 assert!(
3427 !actions.iter().any(|a| a["type"] == "increase-coverage"),
3428 "non-CRAP findings must not emit increase-coverage"
3429 );
3430 }
3431
3432 #[test]
3435 fn suppress_line_omitted_when_baseline_active() {
3436 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3437 inject_health_actions(
3438 &mut output,
3439 HealthActionOptions {
3440 omit_suppress_line: true,
3441 omit_reason: Some("baseline-active"),
3442 },
3443 );
3444 let actions = output["findings"][0]["actions"].as_array().unwrap();
3445 assert!(
3446 !actions.iter().any(|a| a["type"] == "suppress-line"),
3447 "baseline-active must not emit suppress-line, got {actions:?}"
3448 );
3449 assert_eq!(
3450 output["actions_meta"]["suppression_hints_omitted"],
3451 serde_json::Value::Bool(true)
3452 );
3453 assert_eq!(output["actions_meta"]["reason"], "baseline-active");
3454 assert_eq!(output["actions_meta"]["scope"], "health-findings");
3455 }
3456
3457 #[test]
3458 fn suppress_line_omitted_when_config_disabled() {
3459 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3460 inject_health_actions(
3461 &mut output,
3462 HealthActionOptions {
3463 omit_suppress_line: true,
3464 omit_reason: Some("config-disabled"),
3465 },
3466 );
3467 assert_eq!(output["actions_meta"]["reason"], "config-disabled");
3468 }
3469
3470 #[test]
3471 fn suppress_line_emitted_by_default() {
3472 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3473 inject_health_actions(&mut output, HealthActionOptions::default());
3474 let actions = output["findings"][0]["actions"].as_array().unwrap();
3475 assert!(
3476 actions.iter().any(|a| a["type"] == "suppress-line"),
3477 "default opts must emit suppress-line"
3478 );
3479 assert!(
3480 output.get("actions_meta").is_none(),
3481 "actions_meta must be absent when no omission occurred"
3482 );
3483 }
3484
3485 #[test]
3492 fn every_emitted_health_action_type_is_in_schema_enum() {
3493 let cases = [
3497 ("crap", Some("none"), 6_u16, 20_u16),
3499 ("crap", Some("partial"), 6, 20),
3500 ("crap", Some("high"), 12, 20),
3501 ("crap", Some("none"), 16, 20), ("cyclomatic", None, 25, 20),
3503 ("cognitive_crap", Some("partial"), 6, 20),
3504 ("all", Some("none"), 25, 20),
3505 ];
3506
3507 let mut emitted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3508 for (exceeded, tier, cc, max) in cases {
3509 let mut finding = serde_json::json!({
3510 "path": "src/x.ts",
3511 "name": "fn",
3512 "line": 1,
3513 "col": 0,
3514 "cyclomatic": cc,
3515 "cognitive": 5,
3516 "line_count": 10,
3517 "exceeded": exceeded,
3518 "crap": 35.0,
3519 });
3520 if let Some(t) = tier {
3521 finding["coverage_tier"] = serde_json::Value::String(t.to_owned());
3522 }
3523 let mut output = serde_json::json!({
3524 "findings": [finding],
3525 "summary": { "max_cyclomatic_threshold": max },
3526 });
3527 inject_health_actions(&mut output, HealthActionOptions::default());
3528 for action in output["findings"][0]["actions"].as_array().unwrap() {
3529 if let Some(ty) = action["type"].as_str() {
3530 emitted.insert(ty.to_owned());
3531 }
3532 }
3533 }
3534
3535 let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3537 .join("..")
3538 .join("..")
3539 .join("docs")
3540 .join("output-schema.json");
3541 let raw = std::fs::read_to_string(&schema_path)
3542 .expect("docs/output-schema.json must be readable for the drift-guard test");
3543 let schema: serde_json::Value = serde_json::from_str(&raw).expect("schema parses");
3544 let enum_values: std::collections::BTreeSet<String> =
3545 schema["definitions"]["HealthFindingAction"]["properties"]["type"]["enum"]
3546 .as_array()
3547 .expect("HealthFindingAction.type.enum is an array")
3548 .iter()
3549 .filter_map(|v| v.as_str().map(str::to_owned))
3550 .collect();
3551
3552 for ty in &emitted {
3553 assert!(
3554 enum_values.contains(ty),
3555 "build_health_finding_actions emitted action type `{ty}` but \
3556 docs/output-schema.json HealthFindingAction.type enum does \
3557 not list it. Add it to the schema (and any downstream \
3558 typed consumers) when introducing a new action type."
3559 );
3560 }
3561 }
3562}