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 watermark: Some(RuntimeCoverageWatermark::LicenseExpiredGrace),
1680 warnings: vec![RuntimeCoverageMessage {
1681 code: "partial-merge".to_owned(),
1682 message: "Merged coverage omitted one chunk.".to_owned(),
1683 }],
1684 }),
1685 ..Default::default()
1686 };
1687
1688 let report_value = serde_json::to_value(&report).expect("should serialize health report");
1689 let mut output = build_json_envelope(report_value, Duration::from_millis(7));
1690 strip_root_prefix(&mut output, "/project/");
1691 inject_runtime_coverage_schema_version(&mut output);
1692 inject_health_actions(&mut output, HealthActionOptions::default());
1693
1694 assert_eq!(
1695 output["runtime_coverage"]["verdict"],
1696 serde_json::Value::String("cold-code-detected".to_owned())
1697 );
1698 assert_eq!(
1699 output["runtime_coverage"]["schema_version"],
1700 serde_json::Value::String("1".to_owned())
1701 );
1702 assert_eq!(
1703 output["runtime_coverage"]["summary"]["functions_tracked"],
1704 serde_json::Value::from(3)
1705 );
1706 assert_eq!(
1707 output["runtime_coverage"]["summary"]["coverage_percent"],
1708 serde_json::Value::from(33.3)
1709 );
1710 let finding = &output["runtime_coverage"]["findings"][0];
1711 assert_eq!(finding["path"], "src/cold.ts");
1712 assert_eq!(finding["verdict"], "review_required");
1713 assert_eq!(finding["id"], "fallow:prod:deadbeef");
1714 assert_eq!(finding["actions"][0]["type"], "review-deletion");
1715 let hot_path = &output["runtime_coverage"]["hot_paths"][0];
1716 assert_eq!(hot_path["path"], "src/hot.ts");
1717 assert_eq!(hot_path["function"], "hotPath");
1718 assert_eq!(hot_path["percentile"], 99);
1719 assert_eq!(
1720 output["runtime_coverage"]["watermark"],
1721 serde_json::Value::String("license-expired-grace".to_owned())
1722 );
1723 assert_eq!(
1724 output["runtime_coverage"]["warnings"][0]["code"],
1725 serde_json::Value::String("partial-merge".to_owned())
1726 );
1727 }
1728
1729 #[test]
1730 fn json_metadata_fields_appear_first() {
1731 let root = PathBuf::from("/project");
1732 let results = AnalysisResults::default();
1733 let elapsed = Duration::from_millis(0);
1734 let output = build_json(&results, &root, elapsed).expect("should serialize");
1735 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1736 assert_eq!(keys[0], "schema_version");
1737 assert_eq!(keys[1], "version");
1738 assert_eq!(keys[2], "elapsed_ms");
1739 assert_eq!(keys[3], "total_issues");
1740 }
1741
1742 #[test]
1743 fn json_total_issues_matches_results() {
1744 let root = PathBuf::from("/project");
1745 let results = sample_results(&root);
1746 let total = results.total_issues();
1747 let elapsed = Duration::from_millis(0);
1748 let output = build_json(&results, &root, elapsed).expect("should serialize");
1749
1750 assert_eq!(output["total_issues"], total);
1751 }
1752
1753 #[test]
1754 fn json_unused_export_contains_expected_fields() {
1755 let root = PathBuf::from("/project");
1756 let mut results = AnalysisResults::default();
1757 results.unused_exports.push(UnusedExport {
1758 path: root.join("src/utils.ts"),
1759 export_name: "helperFn".to_string(),
1760 is_type_only: false,
1761 line: 10,
1762 col: 4,
1763 span_start: 120,
1764 is_re_export: false,
1765 });
1766 let elapsed = Duration::from_millis(0);
1767 let output = build_json(&results, &root, elapsed).expect("should serialize");
1768
1769 let export = &output["unused_exports"][0];
1770 assert_eq!(export["export_name"], "helperFn");
1771 assert_eq!(export["line"], 10);
1772 assert_eq!(export["col"], 4);
1773 assert_eq!(export["is_type_only"], false);
1774 assert_eq!(export["span_start"], 120);
1775 assert_eq!(export["is_re_export"], false);
1776 }
1777
1778 #[test]
1779 fn json_serializes_to_valid_json() {
1780 let root = PathBuf::from("/project");
1781 let results = sample_results(&root);
1782 let elapsed = Duration::from_millis(42);
1783 let output = build_json(&results, &root, elapsed).expect("should serialize");
1784
1785 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
1786 let reparsed: serde_json::Value =
1787 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
1788 assert_eq!(reparsed, output);
1789 }
1790
1791 #[test]
1794 fn json_empty_results_produce_valid_structure() {
1795 let root = PathBuf::from("/project");
1796 let results = AnalysisResults::default();
1797 let elapsed = Duration::from_millis(0);
1798 let output = build_json(&results, &root, elapsed).expect("should serialize");
1799
1800 assert_eq!(output["total_issues"], 0);
1801 assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
1802 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
1803 assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
1804 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
1805 assert_eq!(
1806 output["unused_dev_dependencies"].as_array().unwrap().len(),
1807 0
1808 );
1809 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
1810 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
1811 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
1812 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
1813 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
1814 assert_eq!(
1815 output["type_only_dependencies"].as_array().unwrap().len(),
1816 0
1817 );
1818 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
1819 }
1820
1821 #[test]
1822 fn json_empty_results_round_trips_through_string() {
1823 let root = PathBuf::from("/project");
1824 let results = AnalysisResults::default();
1825 let elapsed = Duration::from_millis(0);
1826 let output = build_json(&results, &root, elapsed).expect("should serialize");
1827
1828 let json_str = serde_json::to_string(&output).expect("should stringify");
1829 let reparsed: serde_json::Value =
1830 serde_json::from_str(&json_str).expect("should parse back");
1831 assert_eq!(reparsed["total_issues"], 0);
1832 }
1833
1834 #[test]
1837 fn json_paths_are_relative_to_root() {
1838 let root = PathBuf::from("/project");
1839 let mut results = AnalysisResults::default();
1840 results.unused_files.push(UnusedFile {
1841 path: root.join("src/deep/nested/file.ts"),
1842 });
1843 let elapsed = Duration::from_millis(0);
1844 let output = build_json(&results, &root, elapsed).expect("should serialize");
1845
1846 let path = output["unused_files"][0]["path"].as_str().unwrap();
1847 assert_eq!(path, "src/deep/nested/file.ts");
1848 assert!(!path.starts_with("/project"));
1849 }
1850
1851 #[test]
1852 fn json_strips_root_from_nested_locations() {
1853 let root = PathBuf::from("/project");
1854 let mut results = AnalysisResults::default();
1855 results.unlisted_dependencies.push(UnlistedDependency {
1856 package_name: "chalk".to_string(),
1857 imported_from: vec![ImportSite {
1858 path: root.join("src/cli.ts"),
1859 line: 2,
1860 col: 0,
1861 }],
1862 });
1863 let elapsed = Duration::from_millis(0);
1864 let output = build_json(&results, &root, elapsed).expect("should serialize");
1865
1866 let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
1867 .as_str()
1868 .unwrap();
1869 assert_eq!(site_path, "src/cli.ts");
1870 }
1871
1872 #[test]
1873 fn json_strips_root_from_duplicate_export_locations() {
1874 let root = PathBuf::from("/project");
1875 let mut results = AnalysisResults::default();
1876 results.duplicate_exports.push(DuplicateExport {
1877 export_name: "Config".to_string(),
1878 locations: vec![
1879 DuplicateLocation {
1880 path: root.join("src/config.ts"),
1881 line: 15,
1882 col: 0,
1883 },
1884 DuplicateLocation {
1885 path: root.join("src/types.ts"),
1886 line: 30,
1887 col: 0,
1888 },
1889 ],
1890 });
1891 let elapsed = Duration::from_millis(0);
1892 let output = build_json(&results, &root, elapsed).expect("should serialize");
1893
1894 let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
1895 .as_str()
1896 .unwrap();
1897 let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
1898 .as_str()
1899 .unwrap();
1900 assert_eq!(loc0, "src/config.ts");
1901 assert_eq!(loc1, "src/types.ts");
1902 }
1903
1904 #[test]
1905 fn json_strips_root_from_circular_dependency_files() {
1906 let root = PathBuf::from("/project");
1907 let mut results = AnalysisResults::default();
1908 results.circular_dependencies.push(CircularDependency {
1909 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1910 length: 2,
1911 line: 1,
1912 col: 0,
1913 is_cross_package: false,
1914 });
1915 let elapsed = Duration::from_millis(0);
1916 let output = build_json(&results, &root, elapsed).expect("should serialize");
1917
1918 let files = output["circular_dependencies"][0]["files"]
1919 .as_array()
1920 .unwrap();
1921 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
1922 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
1923 }
1924
1925 #[test]
1926 fn json_path_outside_root_not_stripped() {
1927 let root = PathBuf::from("/project");
1928 let mut results = AnalysisResults::default();
1929 results.unused_files.push(UnusedFile {
1930 path: PathBuf::from("/other/project/src/file.ts"),
1931 });
1932 let elapsed = Duration::from_millis(0);
1933 let output = build_json(&results, &root, elapsed).expect("should serialize");
1934
1935 let path = output["unused_files"][0]["path"].as_str().unwrap();
1936 assert!(path.contains("/other/project/"));
1937 }
1938
1939 #[test]
1942 fn json_unused_file_contains_path() {
1943 let root = PathBuf::from("/project");
1944 let mut results = AnalysisResults::default();
1945 results.unused_files.push(UnusedFile {
1946 path: root.join("src/orphan.ts"),
1947 });
1948 let elapsed = Duration::from_millis(0);
1949 let output = build_json(&results, &root, elapsed).expect("should serialize");
1950
1951 let file = &output["unused_files"][0];
1952 assert_eq!(file["path"], "src/orphan.ts");
1953 }
1954
1955 #[test]
1956 fn json_unused_type_contains_expected_fields() {
1957 let root = PathBuf::from("/project");
1958 let mut results = AnalysisResults::default();
1959 results.unused_types.push(UnusedExport {
1960 path: root.join("src/types.ts"),
1961 export_name: "OldInterface".to_string(),
1962 is_type_only: true,
1963 line: 20,
1964 col: 0,
1965 span_start: 300,
1966 is_re_export: false,
1967 });
1968 let elapsed = Duration::from_millis(0);
1969 let output = build_json(&results, &root, elapsed).expect("should serialize");
1970
1971 let typ = &output["unused_types"][0];
1972 assert_eq!(typ["export_name"], "OldInterface");
1973 assert_eq!(typ["is_type_only"], true);
1974 assert_eq!(typ["line"], 20);
1975 assert_eq!(typ["path"], "src/types.ts");
1976 }
1977
1978 #[test]
1979 fn json_unused_dependency_contains_expected_fields() {
1980 let root = PathBuf::from("/project");
1981 let mut results = AnalysisResults::default();
1982 results.unused_dependencies.push(UnusedDependency {
1983 package_name: "axios".to_string(),
1984 location: DependencyLocation::Dependencies,
1985 path: root.join("package.json"),
1986 line: 10,
1987 used_in_workspaces: Vec::new(),
1988 });
1989 let elapsed = Duration::from_millis(0);
1990 let output = build_json(&results, &root, elapsed).expect("should serialize");
1991
1992 let dep = &output["unused_dependencies"][0];
1993 assert_eq!(dep["package_name"], "axios");
1994 assert_eq!(dep["line"], 10);
1995 assert!(dep.get("used_in_workspaces").is_none());
1996 }
1997
1998 #[test]
1999 fn json_unused_dependency_includes_cross_workspace_context() {
2000 let root = PathBuf::from("/project");
2001 let mut results = AnalysisResults::default();
2002 results.unused_dependencies.push(UnusedDependency {
2003 package_name: "lodash-es".to_string(),
2004 location: DependencyLocation::Dependencies,
2005 path: root.join("packages/shared/package.json"),
2006 line: 6,
2007 used_in_workspaces: vec![root.join("packages/consumer")],
2008 });
2009 let elapsed = Duration::from_millis(0);
2010 let output = build_json(&results, &root, elapsed).expect("should serialize");
2011
2012 let dep = &output["unused_dependencies"][0];
2013 assert_eq!(
2014 dep["used_in_workspaces"],
2015 serde_json::json!(["packages/consumer"])
2016 );
2017 }
2018
2019 #[test]
2020 fn json_unused_dev_dependency_contains_expected_fields() {
2021 let root = PathBuf::from("/project");
2022 let mut results = AnalysisResults::default();
2023 results.unused_dev_dependencies.push(UnusedDependency {
2024 package_name: "vitest".to_string(),
2025 location: DependencyLocation::DevDependencies,
2026 path: root.join("package.json"),
2027 line: 15,
2028 used_in_workspaces: Vec::new(),
2029 });
2030 let elapsed = Duration::from_millis(0);
2031 let output = build_json(&results, &root, elapsed).expect("should serialize");
2032
2033 let dep = &output["unused_dev_dependencies"][0];
2034 assert_eq!(dep["package_name"], "vitest");
2035 }
2036
2037 #[test]
2038 fn json_unused_optional_dependency_contains_expected_fields() {
2039 let root = PathBuf::from("/project");
2040 let mut results = AnalysisResults::default();
2041 results.unused_optional_dependencies.push(UnusedDependency {
2042 package_name: "fsevents".to_string(),
2043 location: DependencyLocation::OptionalDependencies,
2044 path: root.join("package.json"),
2045 line: 12,
2046 used_in_workspaces: Vec::new(),
2047 });
2048 let elapsed = Duration::from_millis(0);
2049 let output = build_json(&results, &root, elapsed).expect("should serialize");
2050
2051 let dep = &output["unused_optional_dependencies"][0];
2052 assert_eq!(dep["package_name"], "fsevents");
2053 assert_eq!(output["total_issues"], 1);
2054 }
2055
2056 #[test]
2057 fn json_unused_enum_member_contains_expected_fields() {
2058 let root = PathBuf::from("/project");
2059 let mut results = AnalysisResults::default();
2060 results.unused_enum_members.push(UnusedMember {
2061 path: root.join("src/enums.ts"),
2062 parent_name: "Color".to_string(),
2063 member_name: "Purple".to_string(),
2064 kind: MemberKind::EnumMember,
2065 line: 5,
2066 col: 2,
2067 });
2068 let elapsed = Duration::from_millis(0);
2069 let output = build_json(&results, &root, elapsed).expect("should serialize");
2070
2071 let member = &output["unused_enum_members"][0];
2072 assert_eq!(member["parent_name"], "Color");
2073 assert_eq!(member["member_name"], "Purple");
2074 assert_eq!(member["line"], 5);
2075 assert_eq!(member["path"], "src/enums.ts");
2076 }
2077
2078 #[test]
2079 fn json_unused_class_member_contains_expected_fields() {
2080 let root = PathBuf::from("/project");
2081 let mut results = AnalysisResults::default();
2082 results.unused_class_members.push(UnusedMember {
2083 path: root.join("src/api.ts"),
2084 parent_name: "ApiClient".to_string(),
2085 member_name: "deprecatedFetch".to_string(),
2086 kind: MemberKind::ClassMethod,
2087 line: 100,
2088 col: 4,
2089 });
2090 let elapsed = Duration::from_millis(0);
2091 let output = build_json(&results, &root, elapsed).expect("should serialize");
2092
2093 let member = &output["unused_class_members"][0];
2094 assert_eq!(member["parent_name"], "ApiClient");
2095 assert_eq!(member["member_name"], "deprecatedFetch");
2096 assert_eq!(member["line"], 100);
2097 }
2098
2099 #[test]
2100 fn json_unresolved_import_contains_expected_fields() {
2101 let root = PathBuf::from("/project");
2102 let mut results = AnalysisResults::default();
2103 results.unresolved_imports.push(UnresolvedImport {
2104 path: root.join("src/app.ts"),
2105 specifier: "@acme/missing-pkg".to_string(),
2106 line: 7,
2107 col: 0,
2108 specifier_col: 0,
2109 });
2110 let elapsed = Duration::from_millis(0);
2111 let output = build_json(&results, &root, elapsed).expect("should serialize");
2112
2113 let import = &output["unresolved_imports"][0];
2114 assert_eq!(import["specifier"], "@acme/missing-pkg");
2115 assert_eq!(import["line"], 7);
2116 assert_eq!(import["path"], "src/app.ts");
2117 }
2118
2119 #[test]
2120 fn json_unlisted_dependency_contains_import_sites() {
2121 let root = PathBuf::from("/project");
2122 let mut results = AnalysisResults::default();
2123 results.unlisted_dependencies.push(UnlistedDependency {
2124 package_name: "dotenv".to_string(),
2125 imported_from: vec![
2126 ImportSite {
2127 path: root.join("src/config.ts"),
2128 line: 1,
2129 col: 0,
2130 },
2131 ImportSite {
2132 path: root.join("src/server.ts"),
2133 line: 3,
2134 col: 0,
2135 },
2136 ],
2137 });
2138 let elapsed = Duration::from_millis(0);
2139 let output = build_json(&results, &root, elapsed).expect("should serialize");
2140
2141 let dep = &output["unlisted_dependencies"][0];
2142 assert_eq!(dep["package_name"], "dotenv");
2143 let sites = dep["imported_from"].as_array().unwrap();
2144 assert_eq!(sites.len(), 2);
2145 assert_eq!(sites[0]["path"], "src/config.ts");
2146 assert_eq!(sites[1]["path"], "src/server.ts");
2147 }
2148
2149 #[test]
2150 fn json_duplicate_export_contains_locations() {
2151 let root = PathBuf::from("/project");
2152 let mut results = AnalysisResults::default();
2153 results.duplicate_exports.push(DuplicateExport {
2154 export_name: "Button".to_string(),
2155 locations: vec![
2156 DuplicateLocation {
2157 path: root.join("src/ui.ts"),
2158 line: 10,
2159 col: 0,
2160 },
2161 DuplicateLocation {
2162 path: root.join("src/components.ts"),
2163 line: 25,
2164 col: 0,
2165 },
2166 ],
2167 });
2168 let elapsed = Duration::from_millis(0);
2169 let output = build_json(&results, &root, elapsed).expect("should serialize");
2170
2171 let dup = &output["duplicate_exports"][0];
2172 assert_eq!(dup["export_name"], "Button");
2173 let locs = dup["locations"].as_array().unwrap();
2174 assert_eq!(locs.len(), 2);
2175 assert_eq!(locs[0]["line"], 10);
2176 assert_eq!(locs[1]["line"], 25);
2177 }
2178
2179 #[test]
2180 fn json_type_only_dependency_contains_expected_fields() {
2181 let root = PathBuf::from("/project");
2182 let mut results = AnalysisResults::default();
2183 results.type_only_dependencies.push(TypeOnlyDependency {
2184 package_name: "zod".to_string(),
2185 path: root.join("package.json"),
2186 line: 8,
2187 });
2188 let elapsed = Duration::from_millis(0);
2189 let output = build_json(&results, &root, elapsed).expect("should serialize");
2190
2191 let dep = &output["type_only_dependencies"][0];
2192 assert_eq!(dep["package_name"], "zod");
2193 assert_eq!(dep["line"], 8);
2194 }
2195
2196 #[test]
2197 fn json_circular_dependency_contains_expected_fields() {
2198 let root = PathBuf::from("/project");
2199 let mut results = AnalysisResults::default();
2200 results.circular_dependencies.push(CircularDependency {
2201 files: vec![
2202 root.join("src/a.ts"),
2203 root.join("src/b.ts"),
2204 root.join("src/c.ts"),
2205 ],
2206 length: 3,
2207 line: 5,
2208 col: 0,
2209 is_cross_package: false,
2210 });
2211 let elapsed = Duration::from_millis(0);
2212 let output = build_json(&results, &root, elapsed).expect("should serialize");
2213
2214 let cycle = &output["circular_dependencies"][0];
2215 assert_eq!(cycle["length"], 3);
2216 assert_eq!(cycle["line"], 5);
2217 let files = cycle["files"].as_array().unwrap();
2218 assert_eq!(files.len(), 3);
2219 }
2220
2221 #[test]
2224 fn json_re_export_flagged_correctly() {
2225 let root = PathBuf::from("/project");
2226 let mut results = AnalysisResults::default();
2227 results.unused_exports.push(UnusedExport {
2228 path: root.join("src/index.ts"),
2229 export_name: "reExported".to_string(),
2230 is_type_only: false,
2231 line: 1,
2232 col: 0,
2233 span_start: 0,
2234 is_re_export: true,
2235 });
2236 let elapsed = Duration::from_millis(0);
2237 let output = build_json(&results, &root, elapsed).expect("should serialize");
2238
2239 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
2240 }
2241
2242 #[test]
2245 fn json_schema_version_is_4() {
2246 let root = PathBuf::from("/project");
2247 let results = AnalysisResults::default();
2248 let elapsed = Duration::from_millis(0);
2249 let output = build_json(&results, &root, elapsed).expect("should serialize");
2250
2251 assert_eq!(output["schema_version"], SCHEMA_VERSION);
2252 assert_eq!(output["schema_version"], 4);
2253 }
2254
2255 #[test]
2258 fn json_version_matches_cargo_pkg_version() {
2259 let root = PathBuf::from("/project");
2260 let results = AnalysisResults::default();
2261 let elapsed = Duration::from_millis(0);
2262 let output = build_json(&results, &root, elapsed).expect("should serialize");
2263
2264 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
2265 }
2266
2267 #[test]
2270 fn json_elapsed_ms_zero_duration() {
2271 let root = PathBuf::from("/project");
2272 let results = AnalysisResults::default();
2273 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
2274
2275 assert_eq!(output["elapsed_ms"], 0);
2276 }
2277
2278 #[test]
2279 fn json_elapsed_ms_large_duration() {
2280 let root = PathBuf::from("/project");
2281 let results = AnalysisResults::default();
2282 let elapsed = Duration::from_mins(2);
2283 let output = build_json(&results, &root, elapsed).expect("should serialize");
2284
2285 assert_eq!(output["elapsed_ms"], 120_000);
2286 }
2287
2288 #[test]
2289 fn json_elapsed_ms_sub_millisecond_truncated() {
2290 let root = PathBuf::from("/project");
2291 let results = AnalysisResults::default();
2292 let elapsed = Duration::from_micros(500);
2294 let output = build_json(&results, &root, elapsed).expect("should serialize");
2295
2296 assert_eq!(output["elapsed_ms"], 0);
2297 }
2298
2299 #[test]
2302 fn json_multiple_unused_files() {
2303 let root = PathBuf::from("/project");
2304 let mut results = AnalysisResults::default();
2305 results.unused_files.push(UnusedFile {
2306 path: root.join("src/a.ts"),
2307 });
2308 results.unused_files.push(UnusedFile {
2309 path: root.join("src/b.ts"),
2310 });
2311 results.unused_files.push(UnusedFile {
2312 path: root.join("src/c.ts"),
2313 });
2314 let elapsed = Duration::from_millis(0);
2315 let output = build_json(&results, &root, elapsed).expect("should serialize");
2316
2317 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
2318 assert_eq!(output["total_issues"], 3);
2319 }
2320
2321 #[test]
2324 fn strip_root_prefix_on_string_value() {
2325 let mut value = serde_json::json!("/project/src/file.ts");
2326 strip_root_prefix(&mut value, "/project/");
2327 assert_eq!(value, "src/file.ts");
2328 }
2329
2330 #[test]
2331 fn strip_root_prefix_leaves_non_matching_string() {
2332 let mut value = serde_json::json!("/other/src/file.ts");
2333 strip_root_prefix(&mut value, "/project/");
2334 assert_eq!(value, "/other/src/file.ts");
2335 }
2336
2337 #[test]
2338 fn strip_root_prefix_recurses_into_arrays() {
2339 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
2340 strip_root_prefix(&mut value, "/project/");
2341 assert_eq!(value[0], "a.ts");
2342 assert_eq!(value[1], "b.ts");
2343 assert_eq!(value[2], "/other/c.ts");
2344 }
2345
2346 #[test]
2347 fn strip_root_prefix_recurses_into_nested_objects() {
2348 let mut value = serde_json::json!({
2349 "outer": {
2350 "path": "/project/src/nested.ts"
2351 }
2352 });
2353 strip_root_prefix(&mut value, "/project/");
2354 assert_eq!(value["outer"]["path"], "src/nested.ts");
2355 }
2356
2357 #[test]
2358 fn strip_root_prefix_leaves_numbers_and_booleans() {
2359 let mut value = serde_json::json!({
2360 "line": 42,
2361 "is_type_only": false,
2362 "path": "/project/src/file.ts"
2363 });
2364 strip_root_prefix(&mut value, "/project/");
2365 assert_eq!(value["line"], 42);
2366 assert_eq!(value["is_type_only"], false);
2367 assert_eq!(value["path"], "src/file.ts");
2368 }
2369
2370 #[test]
2371 fn strip_root_prefix_normalizes_windows_separators() {
2372 let mut value = serde_json::json!(r"/project\src\file.ts");
2373 strip_root_prefix(&mut value, "/project/");
2374 assert_eq!(value, "src/file.ts");
2375 }
2376
2377 #[test]
2378 fn strip_root_prefix_handles_empty_string_after_strip() {
2379 let mut value = serde_json::json!("/project/");
2382 strip_root_prefix(&mut value, "/project/");
2383 assert_eq!(value, "");
2384 }
2385
2386 #[test]
2387 fn strip_root_prefix_deeply_nested_array_of_objects() {
2388 let mut value = serde_json::json!({
2389 "groups": [{
2390 "instances": [{
2391 "file": "/project/src/a.ts"
2392 }, {
2393 "file": "/project/src/b.ts"
2394 }]
2395 }]
2396 });
2397 strip_root_prefix(&mut value, "/project/");
2398 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
2399 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
2400 }
2401
2402 #[test]
2405 fn json_full_sample_results_total_issues_correct() {
2406 let root = PathBuf::from("/project");
2407 let results = sample_results(&root);
2408 let elapsed = Duration::from_millis(100);
2409 let output = build_json(&results, &root, elapsed).expect("should serialize");
2410
2411 assert_eq!(output["total_issues"], results.total_issues());
2417 }
2418
2419 #[test]
2420 fn json_full_sample_no_absolute_paths_in_output() {
2421 let root = PathBuf::from("/project");
2422 let results = sample_results(&root);
2423 let elapsed = Duration::from_millis(0);
2424 let output = build_json(&results, &root, elapsed).expect("should serialize");
2425
2426 let json_str = serde_json::to_string(&output).expect("should stringify");
2427 assert!(!json_str.contains("/project/src/"));
2429 assert!(!json_str.contains("/project/package.json"));
2430 }
2431
2432 #[test]
2435 fn json_output_is_deterministic() {
2436 let root = PathBuf::from("/project");
2437 let results = sample_results(&root);
2438 let elapsed = Duration::from_millis(50);
2439
2440 let output1 = build_json(&results, &root, elapsed).expect("first build");
2441 let output2 = build_json(&results, &root, elapsed).expect("second build");
2442
2443 assert_eq!(output1, output2);
2444 }
2445
2446 #[test]
2449 fn json_results_fields_do_not_shadow_metadata() {
2450 let root = PathBuf::from("/project");
2453 let results = AnalysisResults::default();
2454 let elapsed = Duration::from_millis(99);
2455 let output = build_json(&results, &root, elapsed).expect("should serialize");
2456
2457 assert_eq!(output["schema_version"], 4);
2459 assert_eq!(output["elapsed_ms"], 99);
2460 }
2461
2462 #[test]
2465 fn json_all_issue_type_arrays_present_in_empty_results() {
2466 let root = PathBuf::from("/project");
2467 let results = AnalysisResults::default();
2468 let elapsed = Duration::from_millis(0);
2469 let output = build_json(&results, &root, elapsed).expect("should serialize");
2470
2471 let expected_arrays = [
2472 "unused_files",
2473 "unused_exports",
2474 "unused_types",
2475 "unused_dependencies",
2476 "unused_dev_dependencies",
2477 "unused_optional_dependencies",
2478 "unused_enum_members",
2479 "unused_class_members",
2480 "unresolved_imports",
2481 "unlisted_dependencies",
2482 "duplicate_exports",
2483 "type_only_dependencies",
2484 "test_only_dependencies",
2485 "circular_dependencies",
2486 ];
2487 for key in &expected_arrays {
2488 assert!(
2489 output[key].is_array(),
2490 "expected '{key}' to be an array in JSON output"
2491 );
2492 }
2493 }
2494
2495 #[test]
2498 fn insert_meta_adds_key_to_object() {
2499 let mut output = serde_json::json!({ "foo": 1 });
2500 let meta = serde_json::json!({ "docs": "https://example.com" });
2501 insert_meta(&mut output, meta.clone());
2502 assert_eq!(output["_meta"], meta);
2503 }
2504
2505 #[test]
2506 fn insert_meta_noop_on_non_object() {
2507 let mut output = serde_json::json!([1, 2, 3]);
2508 let meta = serde_json::json!({ "docs": "https://example.com" });
2509 insert_meta(&mut output, meta);
2510 assert!(output.is_array());
2512 }
2513
2514 #[test]
2515 fn insert_meta_overwrites_existing_meta() {
2516 let mut output = serde_json::json!({ "_meta": "old" });
2517 let meta = serde_json::json!({ "new": true });
2518 insert_meta(&mut output, meta.clone());
2519 assert_eq!(output["_meta"], meta);
2520 }
2521
2522 #[test]
2525 fn build_json_envelope_has_metadata_fields() {
2526 let report = serde_json::json!({ "findings": [] });
2527 let elapsed = Duration::from_millis(42);
2528 let output = build_json_envelope(report, elapsed);
2529
2530 assert_eq!(output["schema_version"], 4);
2531 assert!(output["version"].is_string());
2532 assert_eq!(output["elapsed_ms"], 42);
2533 assert!(output["findings"].is_array());
2534 }
2535
2536 #[test]
2537 fn build_json_envelope_metadata_appears_first() {
2538 let report = serde_json::json!({ "data": "value" });
2539 let output = build_json_envelope(report, Duration::from_millis(10));
2540
2541 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
2542 assert_eq!(keys[0], "schema_version");
2543 assert_eq!(keys[1], "version");
2544 assert_eq!(keys[2], "elapsed_ms");
2545 }
2546
2547 #[test]
2548 fn build_json_envelope_non_object_report() {
2549 let report = serde_json::json!("not an object");
2551 let output = build_json_envelope(report, Duration::from_millis(0));
2552
2553 let obj = output.as_object().unwrap();
2554 assert_eq!(obj.len(), 3);
2555 assert!(obj.contains_key("schema_version"));
2556 assert!(obj.contains_key("version"));
2557 assert!(obj.contains_key("elapsed_ms"));
2558 }
2559
2560 #[test]
2563 fn strip_root_prefix_null_unchanged() {
2564 let mut value = serde_json::Value::Null;
2565 strip_root_prefix(&mut value, "/project/");
2566 assert!(value.is_null());
2567 }
2568
2569 #[test]
2572 fn strip_root_prefix_empty_string() {
2573 let mut value = serde_json::json!("");
2574 strip_root_prefix(&mut value, "/project/");
2575 assert_eq!(value, "");
2576 }
2577
2578 #[test]
2581 fn strip_root_prefix_mixed_types() {
2582 let mut value = serde_json::json!({
2583 "path": "/project/src/file.ts",
2584 "line": 42,
2585 "flag": true,
2586 "nested": {
2587 "items": ["/project/a.ts", 99, null, "/project/b.ts"],
2588 "deep": { "path": "/project/c.ts" }
2589 }
2590 });
2591 strip_root_prefix(&mut value, "/project/");
2592 assert_eq!(value["path"], "src/file.ts");
2593 assert_eq!(value["line"], 42);
2594 assert_eq!(value["flag"], true);
2595 assert_eq!(value["nested"]["items"][0], "a.ts");
2596 assert_eq!(value["nested"]["items"][1], 99);
2597 assert!(value["nested"]["items"][2].is_null());
2598 assert_eq!(value["nested"]["items"][3], "b.ts");
2599 assert_eq!(value["nested"]["deep"]["path"], "c.ts");
2600 }
2601
2602 #[test]
2605 fn json_check_meta_integrates_correctly() {
2606 let root = PathBuf::from("/project");
2607 let results = AnalysisResults::default();
2608 let elapsed = Duration::from_millis(0);
2609 let mut output = build_json(&results, &root, elapsed).expect("should serialize");
2610 insert_meta(&mut output, crate::explain::check_meta());
2611
2612 assert!(output["_meta"]["docs"].is_string());
2613 assert!(output["_meta"]["rules"].is_object());
2614 }
2615
2616 #[test]
2619 fn json_unused_member_kind_serialized() {
2620 let root = PathBuf::from("/project");
2621 let mut results = AnalysisResults::default();
2622 results.unused_enum_members.push(UnusedMember {
2623 path: root.join("src/enums.ts"),
2624 parent_name: "Color".to_string(),
2625 member_name: "Red".to_string(),
2626 kind: MemberKind::EnumMember,
2627 line: 3,
2628 col: 2,
2629 });
2630 results.unused_class_members.push(UnusedMember {
2631 path: root.join("src/class.ts"),
2632 parent_name: "Foo".to_string(),
2633 member_name: "bar".to_string(),
2634 kind: MemberKind::ClassMethod,
2635 line: 10,
2636 col: 4,
2637 });
2638
2639 let elapsed = Duration::from_millis(0);
2640 let output = build_json(&results, &root, elapsed).expect("should serialize");
2641
2642 let enum_member = &output["unused_enum_members"][0];
2643 assert!(enum_member["kind"].is_string());
2644 let class_member = &output["unused_class_members"][0];
2645 assert!(class_member["kind"].is_string());
2646 }
2647
2648 #[test]
2651 fn json_unused_export_has_actions() {
2652 let root = PathBuf::from("/project");
2653 let mut results = AnalysisResults::default();
2654 results.unused_exports.push(UnusedExport {
2655 path: root.join("src/utils.ts"),
2656 export_name: "helperFn".to_string(),
2657 is_type_only: false,
2658 line: 10,
2659 col: 4,
2660 span_start: 120,
2661 is_re_export: false,
2662 });
2663 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2664
2665 let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2666 assert_eq!(actions.len(), 2);
2667
2668 assert_eq!(actions[0]["type"], "remove-export");
2670 assert_eq!(actions[0]["auto_fixable"], true);
2671 assert!(actions[0].get("note").is_none());
2672
2673 assert_eq!(actions[1]["type"], "suppress-line");
2675 assert_eq!(
2676 actions[1]["comment"],
2677 "// fallow-ignore-next-line unused-export"
2678 );
2679 }
2680
2681 #[test]
2682 fn json_unused_file_has_file_suppress_and_note() {
2683 let root = PathBuf::from("/project");
2684 let mut results = AnalysisResults::default();
2685 results.unused_files.push(UnusedFile {
2686 path: root.join("src/dead.ts"),
2687 });
2688 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2689
2690 let actions = output["unused_files"][0]["actions"].as_array().unwrap();
2691 assert_eq!(actions[0]["type"], "delete-file");
2692 assert_eq!(actions[0]["auto_fixable"], false);
2693 assert!(actions[0]["note"].is_string());
2694 assert_eq!(actions[1]["type"], "suppress-file");
2695 assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
2696 }
2697
2698 #[test]
2699 fn json_unused_dependency_has_config_suppress_with_package_name() {
2700 let root = PathBuf::from("/project");
2701 let mut results = AnalysisResults::default();
2702 results.unused_dependencies.push(UnusedDependency {
2703 package_name: "lodash".to_string(),
2704 location: DependencyLocation::Dependencies,
2705 path: root.join("package.json"),
2706 line: 5,
2707 used_in_workspaces: Vec::new(),
2708 });
2709 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2710
2711 let actions = output["unused_dependencies"][0]["actions"]
2712 .as_array()
2713 .unwrap();
2714 assert_eq!(actions[0]["type"], "remove-dependency");
2715 assert_eq!(actions[0]["auto_fixable"], true);
2716
2717 assert_eq!(actions[1]["type"], "add-to-config");
2719 assert_eq!(actions[1]["config_key"], "ignoreDependencies");
2720 assert_eq!(actions[1]["value"], "lodash");
2721 }
2722
2723 #[test]
2724 fn json_cross_workspace_dependency_is_not_auto_fixable() {
2725 let root = PathBuf::from("/project");
2726 let mut results = AnalysisResults::default();
2727 results.unused_dependencies.push(UnusedDependency {
2728 package_name: "lodash-es".to_string(),
2729 location: DependencyLocation::Dependencies,
2730 path: root.join("packages/shared/package.json"),
2731 line: 5,
2732 used_in_workspaces: vec![root.join("packages/consumer")],
2733 });
2734 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2735
2736 let actions = output["unused_dependencies"][0]["actions"]
2737 .as_array()
2738 .unwrap();
2739 assert_eq!(actions[0]["type"], "move-dependency");
2740 assert_eq!(actions[0]["auto_fixable"], false);
2741 assert!(
2742 actions[0]["note"]
2743 .as_str()
2744 .unwrap()
2745 .contains("will not remove")
2746 );
2747 assert_eq!(actions[1]["type"], "add-to-config");
2748 }
2749
2750 #[test]
2751 fn json_empty_results_have_no_actions_in_empty_arrays() {
2752 let root = PathBuf::from("/project");
2753 let results = AnalysisResults::default();
2754 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2755
2756 assert!(output["unused_exports"].as_array().unwrap().is_empty());
2758 assert!(output["unused_files"].as_array().unwrap().is_empty());
2759 }
2760
2761 #[test]
2762 fn json_all_issue_types_have_actions() {
2763 let root = PathBuf::from("/project");
2764 let results = sample_results(&root);
2765 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2766
2767 let issue_keys = [
2768 "unused_files",
2769 "unused_exports",
2770 "unused_types",
2771 "unused_dependencies",
2772 "unused_dev_dependencies",
2773 "unused_optional_dependencies",
2774 "unused_enum_members",
2775 "unused_class_members",
2776 "unresolved_imports",
2777 "unlisted_dependencies",
2778 "duplicate_exports",
2779 "type_only_dependencies",
2780 "test_only_dependencies",
2781 "circular_dependencies",
2782 ];
2783
2784 for key in &issue_keys {
2785 let arr = output[key].as_array().unwrap();
2786 if !arr.is_empty() {
2787 let actions = arr[0]["actions"].as_array();
2788 assert!(
2789 actions.is_some() && !actions.unwrap().is_empty(),
2790 "missing actions for {key}"
2791 );
2792 }
2793 }
2794 }
2795
2796 #[test]
2799 fn health_finding_has_actions() {
2800 let mut output = serde_json::json!({
2801 "findings": [{
2802 "path": "src/utils.ts",
2803 "name": "processData",
2804 "line": 10,
2805 "col": 0,
2806 "cyclomatic": 25,
2807 "cognitive": 30,
2808 "line_count": 150,
2809 "exceeded": "both"
2810 }]
2811 });
2812
2813 inject_health_actions(&mut output, HealthActionOptions::default());
2814
2815 let actions = output["findings"][0]["actions"].as_array().unwrap();
2816 assert_eq!(actions.len(), 2);
2817 assert_eq!(actions[0]["type"], "refactor-function");
2818 assert_eq!(actions[0]["auto_fixable"], false);
2819 assert!(
2820 actions[0]["description"]
2821 .as_str()
2822 .unwrap()
2823 .contains("processData")
2824 );
2825 assert_eq!(actions[1]["type"], "suppress-line");
2826 assert_eq!(
2827 actions[1]["comment"],
2828 "// fallow-ignore-next-line complexity"
2829 );
2830 }
2831
2832 #[test]
2833 fn refactoring_target_has_actions() {
2834 let mut output = serde_json::json!({
2835 "targets": [{
2836 "path": "src/big-module.ts",
2837 "priority": 85.0,
2838 "efficiency": 42.5,
2839 "recommendation": "Split module: 12 exports, 4 unused",
2840 "category": "split_high_impact",
2841 "effort": "medium",
2842 "confidence": "high",
2843 "evidence": { "unused_exports": 4 }
2844 }]
2845 });
2846
2847 inject_health_actions(&mut output, HealthActionOptions::default());
2848
2849 let actions = output["targets"][0]["actions"].as_array().unwrap();
2850 assert_eq!(actions.len(), 2);
2851 assert_eq!(actions[0]["type"], "apply-refactoring");
2852 assert_eq!(
2853 actions[0]["description"],
2854 "Split module: 12 exports, 4 unused"
2855 );
2856 assert_eq!(actions[0]["category"], "split_high_impact");
2857 assert_eq!(actions[1]["type"], "suppress-line");
2859 }
2860
2861 #[test]
2862 fn refactoring_target_without_evidence_has_no_suppress() {
2863 let mut output = serde_json::json!({
2864 "targets": [{
2865 "path": "src/simple.ts",
2866 "priority": 30.0,
2867 "efficiency": 15.0,
2868 "recommendation": "Consider extracting helper functions",
2869 "category": "extract_complex_functions",
2870 "effort": "small",
2871 "confidence": "medium"
2872 }]
2873 });
2874
2875 inject_health_actions(&mut output, HealthActionOptions::default());
2876
2877 let actions = output["targets"][0]["actions"].as_array().unwrap();
2878 assert_eq!(actions.len(), 1);
2879 assert_eq!(actions[0]["type"], "apply-refactoring");
2880 }
2881
2882 #[test]
2883 fn health_empty_findings_no_actions() {
2884 let mut output = serde_json::json!({
2885 "findings": [],
2886 "targets": []
2887 });
2888
2889 inject_health_actions(&mut output, HealthActionOptions::default());
2890
2891 assert!(output["findings"].as_array().unwrap().is_empty());
2892 assert!(output["targets"].as_array().unwrap().is_empty());
2893 }
2894
2895 #[test]
2896 fn hotspot_has_actions() {
2897 let mut output = serde_json::json!({
2898 "hotspots": [{
2899 "path": "src/utils.ts",
2900 "complexity_score": 45.0,
2901 "churn_score": 12,
2902 "hotspot_score": 540.0
2903 }]
2904 });
2905
2906 inject_health_actions(&mut output, HealthActionOptions::default());
2907
2908 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2909 assert_eq!(actions.len(), 2);
2910 assert_eq!(actions[0]["type"], "refactor-file");
2911 assert!(
2912 actions[0]["description"]
2913 .as_str()
2914 .unwrap()
2915 .contains("src/utils.ts")
2916 );
2917 assert_eq!(actions[1]["type"], "add-tests");
2918 }
2919
2920 #[test]
2921 fn hotspot_low_bus_factor_emits_action() {
2922 let mut output = serde_json::json!({
2923 "hotspots": [{
2924 "path": "src/api.ts",
2925 "ownership": {
2926 "bus_factor": 1,
2927 "contributor_count": 1,
2928 "top_contributor": {"identifier": "alice@x", "share": 1.0, "stale_days": 5, "commits": 30},
2929 "unowned": null,
2930 "drift": false,
2931 }
2932 }]
2933 });
2934
2935 inject_health_actions(&mut output, HealthActionOptions::default());
2936
2937 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2938 assert!(
2939 actions
2940 .iter()
2941 .filter_map(|a| a["type"].as_str())
2942 .any(|t| t == "low-bus-factor"),
2943 "low-bus-factor action should be present",
2944 );
2945 let bus = actions
2946 .iter()
2947 .find(|a| a["type"] == "low-bus-factor")
2948 .unwrap();
2949 assert!(bus["description"].as_str().unwrap().contains("alice@x"));
2950 }
2951
2952 #[test]
2953 fn hotspot_unowned_emits_action_with_pattern() {
2954 let mut output = serde_json::json!({
2955 "hotspots": [{
2956 "path": "src/api/users.ts",
2957 "ownership": {
2958 "bus_factor": 2,
2959 "contributor_count": 4,
2960 "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
2961 "unowned": true,
2962 "drift": false,
2963 }
2964 }]
2965 });
2966
2967 inject_health_actions(&mut output, HealthActionOptions::default());
2968
2969 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2970 let unowned = actions
2971 .iter()
2972 .find(|a| a["type"] == "unowned-hotspot")
2973 .expect("unowned-hotspot action should be present");
2974 assert_eq!(unowned["suggested_pattern"], "/src/api/");
2977 assert_eq!(unowned["heuristic"], "directory-deepest");
2978 }
2979
2980 #[test]
2981 fn hotspot_unowned_skipped_when_codeowners_missing() {
2982 let mut output = serde_json::json!({
2983 "hotspots": [{
2984 "path": "src/api.ts",
2985 "ownership": {
2986 "bus_factor": 2,
2987 "contributor_count": 4,
2988 "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
2989 "unowned": null,
2990 "drift": false,
2991 }
2992 }]
2993 });
2994
2995 inject_health_actions(&mut output, HealthActionOptions::default());
2996
2997 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2998 assert!(
2999 !actions.iter().any(|a| a["type"] == "unowned-hotspot"),
3000 "unowned action must not fire when CODEOWNERS file is absent"
3001 );
3002 }
3003
3004 #[test]
3005 fn hotspot_drift_emits_action() {
3006 let mut output = serde_json::json!({
3007 "hotspots": [{
3008 "path": "src/old.ts",
3009 "ownership": {
3010 "bus_factor": 1,
3011 "contributor_count": 2,
3012 "top_contributor": {"identifier": "bob@x", "share": 0.9, "stale_days": 1, "commits": 18},
3013 "unowned": null,
3014 "drift": true,
3015 "drift_reason": "original author alice@x has 5% share",
3016 }
3017 }]
3018 });
3019
3020 inject_health_actions(&mut output, HealthActionOptions::default());
3021
3022 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
3023 let drift = actions
3024 .iter()
3025 .find(|a| a["type"] == "ownership-drift")
3026 .expect("ownership-drift action should be present");
3027 assert!(drift["description"].as_str().unwrap().contains("alice@x"));
3028 }
3029
3030 #[test]
3033 fn codeowners_pattern_uses_deepest_directory() {
3034 assert_eq!(
3037 suggest_codeowners_pattern("src/api/users/handlers.ts"),
3038 "/src/api/users/"
3039 );
3040 }
3041
3042 #[test]
3043 fn codeowners_pattern_for_root_file() {
3044 assert_eq!(suggest_codeowners_pattern("README.md"), "/README.md");
3045 }
3046
3047 #[test]
3048 fn codeowners_pattern_normalizes_backslashes() {
3049 assert_eq!(
3050 suggest_codeowners_pattern("src\\api\\users.ts"),
3051 "/src/api/"
3052 );
3053 }
3054
3055 #[test]
3056 fn codeowners_pattern_two_level_path() {
3057 assert_eq!(suggest_codeowners_pattern("src/foo.ts"), "/src/");
3058 }
3059
3060 #[test]
3061 fn health_finding_suppress_has_placement() {
3062 let mut output = serde_json::json!({
3063 "findings": [{
3064 "path": "src/utils.ts",
3065 "name": "processData",
3066 "line": 10,
3067 "col": 0,
3068 "cyclomatic": 25,
3069 "cognitive": 30,
3070 "line_count": 150,
3071 "exceeded": "both"
3072 }]
3073 });
3074
3075 inject_health_actions(&mut output, HealthActionOptions::default());
3076
3077 let suppress = &output["findings"][0]["actions"][1];
3078 assert_eq!(suppress["placement"], "above-function-declaration");
3079 }
3080
3081 #[test]
3082 fn html_template_health_finding_uses_html_suppression() {
3083 let mut output = serde_json::json!({
3084 "findings": [{
3085 "path": "src/app.component.html",
3086 "name": "<template>",
3087 "line": 1,
3088 "col": 0,
3089 "cyclomatic": 25,
3090 "cognitive": 30,
3091 "line_count": 40,
3092 "exceeded": "both"
3093 }]
3094 });
3095
3096 inject_health_actions(&mut output, HealthActionOptions::default());
3097
3098 let suppress = &output["findings"][0]["actions"][1];
3099 assert_eq!(suppress["type"], "suppress-file");
3100 assert_eq!(
3101 suppress["comment"],
3102 "<!-- fallow-ignore-file complexity -->"
3103 );
3104 assert_eq!(suppress["placement"], "top-of-template");
3105 }
3106
3107 #[test]
3108 fn inline_template_health_finding_uses_decorator_suppression() {
3109 let mut output = serde_json::json!({
3110 "findings": [{
3111 "path": "src/app.component.ts",
3112 "name": "<template>",
3113 "line": 5,
3114 "col": 0,
3115 "cyclomatic": 25,
3116 "cognitive": 30,
3117 "line_count": 40,
3118 "exceeded": "both"
3119 }]
3120 });
3121
3122 inject_health_actions(&mut output, HealthActionOptions::default());
3123
3124 let refactor = &output["findings"][0]["actions"][0];
3125 assert_eq!(refactor["type"], "refactor-function");
3126 assert!(
3127 refactor["description"]
3128 .as_str()
3129 .unwrap()
3130 .contains("template complexity")
3131 );
3132 let suppress = &output["findings"][0]["actions"][1];
3133 assert_eq!(suppress["type"], "suppress-line");
3134 assert_eq!(
3135 suppress["description"],
3136 "Suppress with an inline comment above the Angular decorator"
3137 );
3138 assert_eq!(suppress["placement"], "above-angular-decorator");
3139 }
3140
3141 #[test]
3144 fn clone_family_has_actions() {
3145 let mut output = serde_json::json!({
3146 "clone_families": [{
3147 "files": ["src/a.ts", "src/b.ts"],
3148 "groups": [
3149 { "instances": [{"file": "src/a.ts"}, {"file": "src/b.ts"}], "token_count": 100, "line_count": 20 }
3150 ],
3151 "total_duplicated_lines": 20,
3152 "total_duplicated_tokens": 100,
3153 "suggestions": [
3154 { "kind": "ExtractFunction", "description": "Extract shared validation logic", "estimated_savings": 15 }
3155 ]
3156 }]
3157 });
3158
3159 inject_dupes_actions(&mut output);
3160
3161 let actions = output["clone_families"][0]["actions"].as_array().unwrap();
3162 assert_eq!(actions.len(), 3);
3163 assert_eq!(actions[0]["type"], "extract-shared");
3164 assert_eq!(actions[0]["auto_fixable"], false);
3165 assert!(
3166 actions[0]["description"]
3167 .as_str()
3168 .unwrap()
3169 .contains("20 lines")
3170 );
3171 assert_eq!(actions[1]["type"], "apply-suggestion");
3173 assert!(
3174 actions[1]["description"]
3175 .as_str()
3176 .unwrap()
3177 .contains("validation logic")
3178 );
3179 assert_eq!(actions[2]["type"], "suppress-line");
3181 assert_eq!(
3182 actions[2]["comment"],
3183 "// fallow-ignore-next-line code-duplication"
3184 );
3185 }
3186
3187 #[test]
3188 fn clone_group_has_actions() {
3189 let mut output = serde_json::json!({
3190 "clone_groups": [{
3191 "instances": [
3192 {"file": "src/a.ts", "start_line": 1, "end_line": 10},
3193 {"file": "src/b.ts", "start_line": 5, "end_line": 14}
3194 ],
3195 "token_count": 50,
3196 "line_count": 10
3197 }]
3198 });
3199
3200 inject_dupes_actions(&mut output);
3201
3202 let actions = output["clone_groups"][0]["actions"].as_array().unwrap();
3203 assert_eq!(actions.len(), 2);
3204 assert_eq!(actions[0]["type"], "extract-shared");
3205 assert!(
3206 actions[0]["description"]
3207 .as_str()
3208 .unwrap()
3209 .contains("10 lines")
3210 );
3211 assert!(
3212 actions[0]["description"]
3213 .as_str()
3214 .unwrap()
3215 .contains("2 instances")
3216 );
3217 assert_eq!(actions[1]["type"], "suppress-line");
3218 }
3219
3220 #[test]
3221 fn dupes_empty_results_no_actions() {
3222 let mut output = serde_json::json!({
3223 "clone_families": [],
3224 "clone_groups": []
3225 });
3226
3227 inject_dupes_actions(&mut output);
3228
3229 assert!(output["clone_families"].as_array().unwrap().is_empty());
3230 assert!(output["clone_groups"].as_array().unwrap().is_empty());
3231 }
3232
3233 fn crap_only_finding_envelope(
3242 coverage_tier: Option<&str>,
3243 cyclomatic: u16,
3244 max_cyclomatic_threshold: u16,
3245 ) -> serde_json::Value {
3246 crap_only_finding_envelope_with_max_crap(
3247 coverage_tier,
3248 cyclomatic,
3249 12,
3250 max_cyclomatic_threshold,
3251 15,
3252 30.0,
3253 )
3254 }
3255
3256 fn crap_only_finding_envelope_with_cognitive(
3257 coverage_tier: Option<&str>,
3258 cyclomatic: u16,
3259 cognitive: u16,
3260 max_cyclomatic_threshold: u16,
3261 ) -> serde_json::Value {
3262 crap_only_finding_envelope_with_max_crap(
3263 coverage_tier,
3264 cyclomatic,
3265 cognitive,
3266 max_cyclomatic_threshold,
3267 15,
3268 30.0,
3269 )
3270 }
3271
3272 fn crap_only_finding_envelope_with_max_crap(
3273 coverage_tier: Option<&str>,
3274 cyclomatic: u16,
3275 cognitive: u16,
3276 max_cyclomatic_threshold: u16,
3277 max_cognitive_threshold: u16,
3278 max_crap_threshold: f64,
3279 ) -> serde_json::Value {
3280 let mut finding = serde_json::json!({
3281 "path": "src/risk.ts",
3282 "name": "computeScore",
3283 "line": 12,
3284 "col": 0,
3285 "cyclomatic": cyclomatic,
3286 "cognitive": cognitive,
3287 "line_count": 40,
3288 "exceeded": "crap",
3289 "crap": 35.5,
3290 });
3291 if let Some(tier) = coverage_tier {
3292 finding["coverage_tier"] = serde_json::Value::String(tier.to_owned());
3293 }
3294 serde_json::json!({
3295 "findings": [finding],
3296 "summary": {
3297 "max_cyclomatic_threshold": max_cyclomatic_threshold,
3298 "max_cognitive_threshold": max_cognitive_threshold,
3299 "max_crap_threshold": max_crap_threshold,
3300 },
3301 })
3302 }
3303
3304 #[test]
3305 fn crap_only_tier_none_emits_add_tests() {
3306 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3307 inject_health_actions(&mut output, HealthActionOptions::default());
3308 let actions = output["findings"][0]["actions"].as_array().unwrap();
3309 assert!(
3310 actions.iter().any(|a| a["type"] == "add-tests"),
3311 "tier=none crap-only must emit add-tests, got {actions:?}"
3312 );
3313 assert!(
3314 !actions.iter().any(|a| a["type"] == "increase-coverage"),
3315 "tier=none must not emit increase-coverage"
3316 );
3317 }
3318
3319 #[test]
3320 fn crap_only_tier_partial_emits_increase_coverage() {
3321 let mut output = crap_only_finding_envelope(Some("partial"), 6, 20);
3322 inject_health_actions(&mut output, HealthActionOptions::default());
3323 let actions = output["findings"][0]["actions"].as_array().unwrap();
3324 assert!(
3325 actions.iter().any(|a| a["type"] == "increase-coverage"),
3326 "tier=partial crap-only must emit increase-coverage, got {actions:?}"
3327 );
3328 assert!(
3329 !actions.iter().any(|a| a["type"] == "add-tests"),
3330 "tier=partial must not emit add-tests"
3331 );
3332 }
3333
3334 #[test]
3335 fn crap_only_tier_high_emits_increase_coverage_when_full_coverage_can_clear_crap() {
3336 let mut output = crap_only_finding_envelope(Some("high"), 20, 30);
3340 inject_health_actions(&mut output, HealthActionOptions::default());
3341 let actions = output["findings"][0]["actions"].as_array().unwrap();
3342 assert!(
3343 actions.iter().any(|a| a["type"] == "increase-coverage"),
3344 "tier=high crap-only must still emit increase-coverage when full coverage can clear CRAP, got {actions:?}"
3345 );
3346 assert!(
3347 !actions.iter().any(|a| a["type"] == "refactor-function"),
3348 "coverage-remediable crap-only findings should not get refactor-function unless near the cyclomatic threshold"
3349 );
3350 assert!(
3351 !actions.iter().any(|a| a["type"] == "add-tests"),
3352 "tier=high must not emit add-tests"
3353 );
3354 }
3355
3356 #[test]
3357 fn crap_only_emits_refactor_when_full_coverage_cannot_clear_crap() {
3358 let mut output =
3362 crap_only_finding_envelope_with_max_crap(Some("high"), 35, 12, 50, 15, 30.0);
3363 inject_health_actions(&mut output, HealthActionOptions::default());
3364 let actions = output["findings"][0]["actions"].as_array().unwrap();
3365 assert!(
3366 actions.iter().any(|a| a["type"] == "refactor-function"),
3367 "full-coverage-impossible CRAP-only finding must emit refactor-function, got {actions:?}"
3368 );
3369 assert!(
3370 !actions.iter().any(|a| a["type"] == "increase-coverage"),
3371 "must not emit increase-coverage when even 100% coverage cannot clear CRAP"
3372 );
3373 assert!(
3374 !actions.iter().any(|a| a["type"] == "add-tests"),
3375 "must not emit add-tests when even 100% coverage cannot clear CRAP"
3376 );
3377 }
3378
3379 #[test]
3380 fn crap_only_high_cc_appends_secondary_refactor() {
3381 let mut output = crap_only_finding_envelope(Some("none"), 16, 20);
3384 inject_health_actions(&mut output, HealthActionOptions::default());
3385 let actions = output["findings"][0]["actions"].as_array().unwrap();
3386 assert!(
3387 actions.iter().any(|a| a["type"] == "add-tests"),
3388 "near-threshold crap-only still emits the primary tier action"
3389 );
3390 assert!(
3391 actions.iter().any(|a| a["type"] == "refactor-function"),
3392 "near-threshold crap-only must also emit secondary refactor-function"
3393 );
3394 }
3395
3396 #[test]
3397 fn crap_only_far_below_threshold_no_secondary_refactor() {
3398 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3400 inject_health_actions(&mut output, HealthActionOptions::default());
3401 let actions = output["findings"][0]["actions"].as_array().unwrap();
3402 assert!(
3403 !actions.iter().any(|a| a["type"] == "refactor-function"),
3404 "low-CC crap-only should not get a secondary refactor-function"
3405 );
3406 }
3407
3408 #[test]
3409 fn crap_only_near_threshold_low_cognitive_no_secondary_refactor() {
3410 let mut output = crap_only_finding_envelope_with_cognitive(Some("none"), 17, 2, 20);
3419 inject_health_actions(&mut output, HealthActionOptions::default());
3420 let actions = output["findings"][0]["actions"].as_array().unwrap();
3421 assert!(
3422 actions.iter().any(|a| a["type"] == "add-tests"),
3423 "primary tier action still emits"
3424 );
3425 assert!(
3426 !actions.iter().any(|a| a["type"] == "refactor-function"),
3427 "near-threshold CC with cognitive below floor must NOT emit secondary refactor (got {actions:?})"
3428 );
3429 }
3430
3431 #[test]
3432 fn crap_only_near_threshold_high_cognitive_emits_secondary_refactor() {
3433 let mut output = crap_only_finding_envelope_with_cognitive(Some("none"), 16, 10, 20);
3439 inject_health_actions(&mut output, HealthActionOptions::default());
3440 let actions = output["findings"][0]["actions"].as_array().unwrap();
3441 assert!(
3442 actions.iter().any(|a| a["type"] == "add-tests"),
3443 "primary tier action still emits"
3444 );
3445 assert!(
3446 actions.iter().any(|a| a["type"] == "refactor-function"),
3447 "near-threshold CC with cognitive above floor must emit secondary refactor (got {actions:?})"
3448 );
3449 }
3450
3451 #[test]
3452 fn cyclomatic_only_emits_only_refactor_function() {
3453 let mut output = serde_json::json!({
3454 "findings": [{
3455 "path": "src/cyclo.ts",
3456 "name": "branchy",
3457 "line": 5,
3458 "col": 0,
3459 "cyclomatic": 25,
3460 "cognitive": 10,
3461 "line_count": 80,
3462 "exceeded": "cyclomatic",
3463 }],
3464 "summary": { "max_cyclomatic_threshold": 20 },
3465 });
3466 inject_health_actions(&mut output, HealthActionOptions::default());
3467 let actions = output["findings"][0]["actions"].as_array().unwrap();
3468 assert!(
3469 actions.iter().any(|a| a["type"] == "refactor-function"),
3470 "non-CRAP findings emit refactor-function"
3471 );
3472 assert!(
3473 !actions.iter().any(|a| a["type"] == "add-tests"),
3474 "non-CRAP findings must not emit add-tests"
3475 );
3476 assert!(
3477 !actions.iter().any(|a| a["type"] == "increase-coverage"),
3478 "non-CRAP findings must not emit increase-coverage"
3479 );
3480 }
3481
3482 #[test]
3485 fn suppress_line_omitted_when_baseline_active() {
3486 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3487 inject_health_actions(
3488 &mut output,
3489 HealthActionOptions {
3490 omit_suppress_line: true,
3491 omit_reason: Some("baseline-active"),
3492 },
3493 );
3494 let actions = output["findings"][0]["actions"].as_array().unwrap();
3495 assert!(
3496 !actions.iter().any(|a| a["type"] == "suppress-line"),
3497 "baseline-active must not emit suppress-line, got {actions:?}"
3498 );
3499 assert_eq!(
3500 output["actions_meta"]["suppression_hints_omitted"],
3501 serde_json::Value::Bool(true)
3502 );
3503 assert_eq!(output["actions_meta"]["reason"], "baseline-active");
3504 assert_eq!(output["actions_meta"]["scope"], "health-findings");
3505 }
3506
3507 #[test]
3508 fn suppress_line_omitted_when_config_disabled() {
3509 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3510 inject_health_actions(
3511 &mut output,
3512 HealthActionOptions {
3513 omit_suppress_line: true,
3514 omit_reason: Some("config-disabled"),
3515 },
3516 );
3517 assert_eq!(output["actions_meta"]["reason"], "config-disabled");
3518 }
3519
3520 #[test]
3521 fn suppress_line_emitted_by_default() {
3522 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3523 inject_health_actions(&mut output, HealthActionOptions::default());
3524 let actions = output["findings"][0]["actions"].as_array().unwrap();
3525 assert!(
3526 actions.iter().any(|a| a["type"] == "suppress-line"),
3527 "default opts must emit suppress-line"
3528 );
3529 assert!(
3530 output.get("actions_meta").is_none(),
3531 "actions_meta must be absent when no omission occurred"
3532 );
3533 }
3534
3535 #[test]
3542 fn every_emitted_health_action_type_is_in_schema_enum() {
3543 let cases = [
3547 ("crap", Some("none"), 6_u16, 20_u16),
3549 ("crap", Some("partial"), 6, 20),
3550 ("crap", Some("high"), 12, 20),
3551 ("crap", Some("none"), 16, 20), ("cyclomatic", None, 25, 20),
3553 ("cognitive_crap", Some("partial"), 6, 20),
3554 ("all", Some("none"), 25, 20),
3555 ];
3556
3557 let mut emitted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3558 for (exceeded, tier, cc, max) in cases {
3559 let mut finding = serde_json::json!({
3560 "path": "src/x.ts",
3561 "name": "fn",
3562 "line": 1,
3563 "col": 0,
3564 "cyclomatic": cc,
3565 "cognitive": 5,
3566 "line_count": 10,
3567 "exceeded": exceeded,
3568 "crap": 35.0,
3569 });
3570 if let Some(t) = tier {
3571 finding["coverage_tier"] = serde_json::Value::String(t.to_owned());
3572 }
3573 let mut output = serde_json::json!({
3574 "findings": [finding],
3575 "summary": { "max_cyclomatic_threshold": max },
3576 });
3577 inject_health_actions(&mut output, HealthActionOptions::default());
3578 for action in output["findings"][0]["actions"].as_array().unwrap() {
3579 if let Some(ty) = action["type"].as_str() {
3580 emitted.insert(ty.to_owned());
3581 }
3582 }
3583 }
3584
3585 let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3587 .join("..")
3588 .join("..")
3589 .join("docs")
3590 .join("output-schema.json");
3591 let raw = std::fs::read_to_string(&schema_path)
3592 .expect("docs/output-schema.json must be readable for the drift-guard test");
3593 let schema: serde_json::Value = serde_json::from_str(&raw).expect("schema parses");
3594 let enum_values: std::collections::BTreeSet<String> =
3595 schema["definitions"]["HealthFindingAction"]["properties"]["type"]["enum"]
3596 .as_array()
3597 .expect("HealthFindingAction.type.enum is an array")
3598 .iter()
3599 .filter_map(|v| v.as_str().map(str::to_owned))
3600 .collect();
3601
3602 for ty in &emitted {
3603 assert!(
3604 enum_values.contains(ty),
3605 "build_health_finding_actions emitted action type `{ty}` but \
3606 docs/output-schema.json HealthFindingAction.type enum does \
3607 not list it. Add it to the schema (and any downstream \
3608 typed consumers) when introducing a new action type."
3609 );
3610 }
3611 }
3612}