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