1use std::collections::BTreeMap;
2use std::path::Path;
3use std::process::ExitCode;
4use std::time::Duration;
5
6use fallow_core::duplicates::DuplicationReport;
7use fallow_core::results::AnalysisResults;
8
9use super::shared::NAMESPACE_BARREL_HINT;
10use super::{emit_json, normalize_uri};
11use crate::explain;
12use crate::report::grouping::{OwnershipResolver, ResultGroup};
13
14const IGNORE_EXPORTS_VALUE_SCHEMA: &str =
18 "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreExports";
19
20const IGNORE_DEPENDENCIES_VALUE_SCHEMA: &str = "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json#/properties/ignoreDependencies/items";
24
25pub(super) fn print_json(
26 results: &AnalysisResults,
27 root: &Path,
28 elapsed: Duration,
29 explain: bool,
30 regression: Option<&crate::regression::RegressionOutcome>,
31 baseline_matched: Option<(usize, usize)>,
32) -> ExitCode {
33 match build_json(results, root, elapsed) {
34 Ok(mut output) => {
35 if let Some(outcome) = regression
36 && let serde_json::Value::Object(ref mut map) = output
37 {
38 map.insert("regression".to_string(), outcome.to_json());
39 }
40 if let Some((entries, matched)) = baseline_matched
41 && let serde_json::Value::Object(ref mut map) = output
42 {
43 map.insert(
44 "baseline".to_string(),
45 serde_json::json!({
46 "entries": entries,
47 "matched": matched,
48 }),
49 );
50 }
51 if explain {
52 insert_meta(&mut output, explain::check_meta());
53 }
54 emit_json(&output, "JSON")
55 }
56 Err(e) => {
57 eprintln!("Error: failed to serialize results: {e}");
58 ExitCode::from(2)
59 }
60 }
61}
62
63#[must_use]
69pub(super) fn print_grouped_json(
70 groups: &[ResultGroup],
71 original: &AnalysisResults,
72 root: &Path,
73 elapsed: Duration,
74 explain: bool,
75 resolver: &OwnershipResolver,
76) -> ExitCode {
77 let root_prefix = format!("{}/", root.display());
78
79 let group_values: Vec<serde_json::Value> = groups
80 .iter()
81 .filter_map(|group| {
82 let mut value = serde_json::to_value(&group.results).ok()?;
83 strip_root_prefix(&mut value, &root_prefix);
84 inject_actions(&mut value);
85 harmonize_multi_kind_suppress_line_actions(&mut value);
86
87 if let serde_json::Value::Object(ref mut map) = value {
88 let mut ordered = serde_json::Map::new();
91 ordered.insert("key".to_string(), serde_json::json!(group.key));
92 if let Some(ref owners) = group.owners {
93 ordered.insert("owners".to_string(), serde_json::json!(owners));
94 }
95 ordered.insert(
96 "total_issues".to_string(),
97 serde_json::json!(group.results.total_issues()),
98 );
99 for (k, v) in map.iter() {
100 ordered.insert(k.clone(), v.clone());
101 }
102 Some(serde_json::Value::Object(ordered))
103 } else {
104 Some(value)
105 }
106 })
107 .collect();
108
109 let mut output = serde_json::json!({
110 "schema_version": SCHEMA_VERSION,
111 "version": env!("CARGO_PKG_VERSION"),
112 "elapsed_ms": elapsed.as_millis() as u64,
113 "grouped_by": resolver.mode_label(),
114 "total_issues": original.total_issues(),
115 "groups": group_values,
116 });
117
118 if explain {
119 insert_meta(&mut output, explain::check_meta());
120 }
121
122 emit_json(&output, "JSON")
123}
124
125const SCHEMA_VERSION: u32 = 6;
131const RUNTIME_COVERAGE_SCHEMA_VERSION: &str = "1";
132
133fn build_json_envelope(report_value: serde_json::Value, elapsed: Duration) -> serde_json::Value {
139 let mut map = serde_json::Map::new();
140 map.insert(
141 "schema_version".to_string(),
142 serde_json::json!(SCHEMA_VERSION),
143 );
144 map.insert(
145 "version".to_string(),
146 serde_json::json!(env!("CARGO_PKG_VERSION")),
147 );
148 map.insert(
149 "elapsed_ms".to_string(),
150 serde_json::json!(elapsed.as_millis()),
151 );
152 if let serde_json::Value::Object(report_map) = report_value {
153 for (key, value) in report_map {
154 map.insert(key, value);
155 }
156 }
157 serde_json::Value::Object(map)
158}
159
160fn inject_runtime_coverage_schema_version(output: &mut serde_json::Value) {
161 let serde_json::Value::Object(map) = output else {
162 return;
163 };
164
165 if let Some(report) = map.get_mut("runtime_coverage") {
166 inject_runtime_coverage_report_schema_version(report);
167 }
168
169 if let Some(serde_json::Value::Array(groups)) = map.get_mut("groups") {
170 for group in groups {
171 if let Some(report) = group
172 .as_object_mut()
173 .and_then(|group| group.get_mut("runtime_coverage"))
174 {
175 inject_runtime_coverage_report_schema_version(report);
176 }
177 }
178 }
179}
180
181fn inject_runtime_coverage_report_schema_version(report: &mut serde_json::Value) {
182 let serde_json::Value::Object(report_map) = report else {
183 return;
184 };
185
186 let mut ordered = serde_json::Map::new();
187 ordered.insert(
188 "schema_version".to_string(),
189 serde_json::json!(RUNTIME_COVERAGE_SCHEMA_VERSION),
190 );
191 for (key, value) in std::mem::take(report_map) {
192 if key != "schema_version" {
193 ordered.insert(key, value);
194 }
195 }
196 *report_map = ordered;
197}
198
199pub fn build_json(
208 results: &AnalysisResults,
209 root: &Path,
210 elapsed: Duration,
211) -> Result<serde_json::Value, serde_json::Error> {
212 let results_value = serde_json::to_value(results)?;
213
214 let mut map = serde_json::Map::new();
215 map.insert(
216 "schema_version".to_string(),
217 serde_json::json!(SCHEMA_VERSION),
218 );
219 map.insert(
220 "version".to_string(),
221 serde_json::json!(env!("CARGO_PKG_VERSION")),
222 );
223 map.insert(
224 "elapsed_ms".to_string(),
225 serde_json::json!(elapsed.as_millis()),
226 );
227 map.insert(
228 "total_issues".to_string(),
229 serde_json::json!(results.total_issues()),
230 );
231
232 if let Some(ref ep) = results.entry_point_summary {
234 let sources: serde_json::Map<String, serde_json::Value> = ep
235 .by_source
236 .iter()
237 .map(|(k, v)| (k.replace(' ', "_"), serde_json::json!(v)))
238 .collect();
239 map.insert(
240 "entry_points".to_string(),
241 serde_json::json!({
242 "total": ep.total,
243 "sources": sources,
244 }),
245 );
246 }
247
248 let summary = serde_json::json!({
250 "total_issues": results.total_issues(),
251 "unused_files": results.unused_files.len(),
252 "unused_exports": results.unused_exports.len(),
253 "unused_types": results.unused_types.len(),
254 "private_type_leaks": results.private_type_leaks.len(),
255 "unused_dependencies": results.unused_dependencies.len()
256 + results.unused_dev_dependencies.len()
257 + results.unused_optional_dependencies.len(),
258 "unused_enum_members": results.unused_enum_members.len(),
259 "unused_class_members": results.unused_class_members.len(),
260 "unresolved_imports": results.unresolved_imports.len(),
261 "unlisted_dependencies": results.unlisted_dependencies.len(),
262 "duplicate_exports": results.duplicate_exports.len(),
263 "type_only_dependencies": results.type_only_dependencies.len(),
264 "test_only_dependencies": results.test_only_dependencies.len(),
265 "circular_dependencies": results.circular_dependencies.len(),
266 "boundary_violations": results.boundary_violations.len(),
267 "stale_suppressions": results.stale_suppressions.len(),
268 "unused_catalog_entries": results.unused_catalog_entries.len(),
269 });
270 map.insert("summary".to_string(), summary);
271
272 if let serde_json::Value::Object(results_map) = results_value {
273 for (key, value) in results_map {
274 map.insert(key, value);
275 }
276 }
277
278 let mut output = serde_json::Value::Object(map);
279 let root_prefix = format!("{}/", root.display());
280 strip_root_prefix(&mut output, &root_prefix);
284 inject_actions(&mut output);
285 harmonize_multi_kind_suppress_line_actions(&mut output);
286 Ok(output)
287}
288
289pub fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
294 match value {
295 serde_json::Value::String(s) => {
296 if let Some(rest) = s.strip_prefix(prefix) {
297 *s = rest.to_string();
298 } else {
299 let normalized = normalize_uri(s);
300 let normalized_prefix = normalize_uri(prefix);
301 if let Some(rest) = normalized.strip_prefix(&normalized_prefix) {
302 *s = rest.to_string();
303 }
304 }
305 }
306 serde_json::Value::Array(arr) => {
307 for item in arr {
308 strip_root_prefix(item, prefix);
309 }
310 }
311 serde_json::Value::Object(map) => {
312 for (_, v) in map.iter_mut() {
313 strip_root_prefix(v, prefix);
314 }
315 }
316 _ => {}
317 }
318}
319
320enum SuppressKind {
324 InlineComment,
326 YamlComment,
328 FileComment,
330 ConfigIgnoreDep,
332}
333
334struct ActionSpec {
336 fix_type: &'static str,
337 auto_fixable: bool,
338 description: &'static str,
339 note: Option<&'static str>,
340 suppress: SuppressKind,
341 issue_kind: &'static str,
342}
343
344#[expect(
346 clippy::too_many_lines,
347 reason = "one match arm per issue type keeps the action spec table flat and grep-friendly"
348)]
349fn actions_for_issue_type(key: &str) -> Option<ActionSpec> {
350 match key {
351 "unused_files" => Some(ActionSpec {
352 fix_type: "delete-file",
353 auto_fixable: false,
354 description: "Delete this file",
355 note: Some(
356 "File deletion may remove runtime functionality not visible to static analysis",
357 ),
358 suppress: SuppressKind::FileComment,
359 issue_kind: "unused-file",
360 }),
361 "unused_exports" => Some(ActionSpec {
362 fix_type: "remove-export",
363 auto_fixable: true,
364 description: "Remove the unused export from the public API",
365 note: None,
366 suppress: SuppressKind::InlineComment,
367 issue_kind: "unused-export",
368 }),
369 "unused_types" => Some(ActionSpec {
370 fix_type: "remove-export",
371 auto_fixable: true,
372 description: "Remove the `export` (or `export type`) keyword from the type declaration",
373 note: None,
374 suppress: SuppressKind::InlineComment,
375 issue_kind: "unused-type",
376 }),
377 "private_type_leaks" => Some(ActionSpec {
378 fix_type: "export-type",
379 auto_fixable: false,
380 description: "Export the referenced private type by name",
381 note: Some("Keep the type exported while it is part of a public signature"),
382 suppress: SuppressKind::InlineComment,
383 issue_kind: "private-type-leak",
384 }),
385 "unused_dependencies" => Some(ActionSpec {
386 fix_type: "remove-dependency",
387 auto_fixable: true,
388 description: "Remove from dependencies in package.json",
389 note: None,
390 suppress: SuppressKind::ConfigIgnoreDep,
391 issue_kind: "unused-dependency",
392 }),
393 "unused_dev_dependencies" => Some(ActionSpec {
394 fix_type: "remove-dependency",
395 auto_fixable: true,
396 description: "Remove from devDependencies in package.json",
397 note: None,
398 suppress: SuppressKind::ConfigIgnoreDep,
399 issue_kind: "unused-dev-dependency",
400 }),
401 "unused_optional_dependencies" => Some(ActionSpec {
402 fix_type: "remove-dependency",
403 auto_fixable: true,
404 description: "Remove from optionalDependencies in package.json",
405 note: None,
406 suppress: SuppressKind::ConfigIgnoreDep,
407 issue_kind: "unused-dependency",
409 }),
410 "unused_enum_members" => Some(ActionSpec {
411 fix_type: "remove-enum-member",
412 auto_fixable: true,
413 description: "Remove this enum member",
414 note: None,
415 suppress: SuppressKind::InlineComment,
416 issue_kind: "unused-enum-member",
417 }),
418 "unused_class_members" => Some(ActionSpec {
419 fix_type: "remove-class-member",
420 auto_fixable: false,
421 description: "Remove this class member",
422 note: Some("Class member may be used via dependency injection or decorators"),
423 suppress: SuppressKind::InlineComment,
424 issue_kind: "unused-class-member",
425 }),
426 "unresolved_imports" => Some(ActionSpec {
427 fix_type: "resolve-import",
428 auto_fixable: false,
429 description: "Fix the import specifier or install the missing module",
430 note: Some("Verify the module path and check tsconfig paths configuration"),
431 suppress: SuppressKind::InlineComment,
432 issue_kind: "unresolved-import",
433 }),
434 "unlisted_dependencies" => Some(ActionSpec {
435 fix_type: "install-dependency",
436 auto_fixable: false,
437 description: "Add this package to dependencies in package.json",
438 note: Some("Verify this package should be a direct dependency before adding"),
439 suppress: SuppressKind::ConfigIgnoreDep,
440 issue_kind: "unlisted-dependency",
441 }),
442 "duplicate_exports" => Some(ActionSpec {
443 fix_type: "remove-duplicate",
444 auto_fixable: false,
445 description: "Keep one canonical export location and remove the others",
446 note: Some(NAMESPACE_BARREL_HINT),
447 suppress: SuppressKind::InlineComment,
448 issue_kind: "duplicate-export",
449 }),
450 "type_only_dependencies" => Some(ActionSpec {
451 fix_type: "move-to-dev",
452 auto_fixable: false,
453 description: "Move to devDependencies (only type imports are used)",
454 note: Some(
455 "Type imports are erased at runtime so this dependency is not needed in production",
456 ),
457 suppress: SuppressKind::ConfigIgnoreDep,
458 issue_kind: "type-only-dependency",
459 }),
460 "test_only_dependencies" => Some(ActionSpec {
461 fix_type: "move-to-dev",
462 auto_fixable: false,
463 description: "Move to devDependencies (only test files import this)",
464 note: Some(
465 "Only test files import this package so it does not need to be a production dependency",
466 ),
467 suppress: SuppressKind::ConfigIgnoreDep,
468 issue_kind: "test-only-dependency",
469 }),
470 "circular_dependencies" => Some(ActionSpec {
471 fix_type: "refactor-cycle",
472 auto_fixable: false,
473 description: "Extract shared logic into a separate module to break the cycle",
474 note: Some(
475 "Circular imports can cause initialization issues and make code harder to reason about",
476 ),
477 suppress: SuppressKind::InlineComment,
478 issue_kind: "circular-dependency",
479 }),
480 "boundary_violations" => Some(ActionSpec {
481 fix_type: "refactor-boundary",
482 auto_fixable: false,
483 description: "Move the import through an allowed zone or restructure the dependency",
484 note: Some(
485 "This import crosses an architecture boundary that is not permitted by the configured rules",
486 ),
487 suppress: SuppressKind::InlineComment,
488 issue_kind: "boundary-violation",
489 }),
490 "unused_catalog_entries" => Some(ActionSpec {
491 fix_type: "remove-catalog-entry",
492 auto_fixable: false,
493 description: "Remove the entry from pnpm-workspace.yaml",
494 note: Some(
495 "If any consumer declares the same package with a hardcoded version, switch the consumer to `catalog:` before removing",
496 ),
497 suppress: SuppressKind::YamlComment,
498 issue_kind: "unused-catalog-entry",
499 }),
500 _ => None,
501 }
502}
503
504fn build_actions(
506 item: &serde_json::Value,
507 issue_key: &str,
508 spec: &ActionSpec,
509) -> serde_json::Value {
510 let mut actions = Vec::with_capacity(2);
511 let cross_workspace_dependency = is_dependency_issue(issue_key)
512 && item
513 .get("used_in_workspaces")
514 .and_then(serde_json::Value::as_array)
515 .is_some_and(|workspaces| !workspaces.is_empty());
516
517 if issue_key == "duplicate_exports"
524 && let Some(value) = build_duplicate_exports_config_value(item)
525 {
526 actions.push(serde_json::json!({
527 "type": "add-to-config",
528 "auto_fixable": false,
529 "description": "Add an ignoreExports rule so these files are excluded from duplicate-export grouping (use when this duplication is an intentional namespace-barrel API).",
530 "config_key": "ignoreExports",
531 "value": value,
532 "value_schema": IGNORE_EXPORTS_VALUE_SCHEMA,
533 }));
534 }
535
536 let mut fix_action = if cross_workspace_dependency {
538 serde_json::json!({
539 "type": "move-dependency",
540 "auto_fixable": false,
541 "description": "Move this dependency to the workspace package.json that imports it",
542 "note": "fallow fix will not remove dependencies that are imported by another workspace",
543 })
544 } else {
545 serde_json::json!({
546 "type": spec.fix_type,
547 "auto_fixable": spec.auto_fixable,
548 "description": spec.description,
549 })
550 };
551 if let Some(note) = spec.note {
552 fix_action["note"] = serde_json::json!(note);
553 }
554 if (issue_key == "unused_exports" || issue_key == "unused_types")
556 && item
557 .get("is_re_export")
558 .and_then(serde_json::Value::as_bool)
559 == Some(true)
560 {
561 fix_action["note"] = serde_json::json!(
562 "This finding originates from a re-export; verify it is not part of your public API before removing"
563 );
564 }
565 actions.push(fix_action);
566
567 match spec.suppress {
569 SuppressKind::InlineComment => {
570 let mut suppress = serde_json::json!({
571 "type": "suppress-line",
572 "auto_fixable": false,
573 "description": "Suppress with an inline comment above the line",
574 "comment": format!("// fallow-ignore-next-line {}", spec.issue_kind),
575 });
576 if issue_key == "duplicate_exports" {
578 suppress["scope"] = serde_json::json!("per-location");
579 }
580 actions.push(suppress);
581 }
582 SuppressKind::YamlComment => {
583 actions.push(serde_json::json!({
584 "type": "suppress-line",
585 "auto_fixable": false,
586 "description": "Suppress with a YAML comment above the line",
587 "comment": format!("# fallow-ignore-next-line {}", spec.issue_kind),
588 }));
589 }
590 SuppressKind::FileComment => {
591 actions.push(serde_json::json!({
592 "type": "suppress-file",
593 "auto_fixable": false,
594 "description": "Suppress with a file-level comment at the top of the file",
595 "comment": format!("// fallow-ignore-file {}", spec.issue_kind),
596 }));
597 }
598 SuppressKind::ConfigIgnoreDep => {
599 let pkg = item
601 .get("package_name")
602 .and_then(serde_json::Value::as_str)
603 .unwrap_or("package-name");
604 actions.push(serde_json::json!({
605 "type": "add-to-config",
606 "auto_fixable": false,
607 "description": format!("Add \"{pkg}\" to ignoreDependencies in fallow config"),
608 "config_key": "ignoreDependencies",
609 "value": pkg,
610 "value_schema": IGNORE_DEPENDENCIES_VALUE_SCHEMA,
611 }));
612 }
613 }
614
615 serde_json::Value::Array(actions)
616}
617
618fn build_duplicate_exports_config_value(item: &serde_json::Value) -> Option<serde_json::Value> {
622 let locations = item.get("locations")?.as_array()?;
623 let mut entries: Vec<serde_json::Value> = Vec::with_capacity(locations.len());
624 let mut seen: rustc_hash::FxHashSet<&str> = rustc_hash::FxHashSet::default();
625 for loc in locations {
626 let Some(path) = loc.get("path").and_then(serde_json::Value::as_str) else {
627 continue;
628 };
629 if !seen.insert(path) {
630 continue;
631 }
632 entries.push(serde_json::json!({
633 "file": path,
634 "exports": ["*"],
635 }));
636 }
637 if entries.is_empty() {
638 return None;
639 }
640 Some(serde_json::Value::Array(entries))
641}
642
643fn is_dependency_issue(issue_key: &str) -> bool {
644 matches!(
645 issue_key,
646 "unused_dependencies" | "unused_dev_dependencies" | "unused_optional_dependencies"
647 )
648}
649
650fn inject_actions(output: &mut serde_json::Value) {
655 let Some(map) = output.as_object_mut() else {
656 return;
657 };
658
659 for (key, value) in map.iter_mut() {
660 let Some(spec) = actions_for_issue_type(key) else {
661 continue;
662 };
663 let Some(arr) = value.as_array_mut() else {
664 continue;
665 };
666 for item in arr {
667 let actions = build_actions(item, key, &spec);
668 if let serde_json::Value::Object(obj) = item {
669 obj.insert("actions".to_string(), actions);
670 }
671 }
672 }
673}
674
675type SuppressAnchor = (String, u64);
676
677#[allow(
678 clippy::redundant_pub_crate,
679 reason = "used through report module re-export by audit.rs"
680)]
681pub(crate) fn harmonize_multi_kind_suppress_line_actions(output: &mut serde_json::Value) {
682 let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
683 collect_suppress_line_anchors(output, &mut anchors);
684
685 anchors.retain(|_, kinds| {
686 sort_suppression_kinds(kinds);
687 kinds.dedup();
688 kinds.len() > 1
689 });
690 if anchors.is_empty() {
691 return;
692 }
693
694 rewrite_suppress_line_actions(output, &anchors);
695}
696
697fn collect_suppress_line_anchors(
698 value: &serde_json::Value,
699 anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
700) {
701 match value {
702 serde_json::Value::Object(map) => {
703 if let Some(anchor) = suppression_anchor(map)
704 && let Some(actions) = map.get("actions").and_then(serde_json::Value::as_array)
705 {
706 for action in actions {
707 if let Some(comment) = suppress_line_comment(action) {
708 for kind in parse_suppress_line_comment(comment) {
709 let kinds = anchors.entry(anchor.clone()).or_default();
710 if !kinds.iter().any(|existing| existing == &kind) {
711 kinds.push(kind);
712 }
713 }
714 }
715 }
716 }
717
718 for child in map.values() {
719 collect_suppress_line_anchors(child, anchors);
720 }
721 }
722 serde_json::Value::Array(items) => {
723 for item in items {
724 collect_suppress_line_anchors(item, anchors);
725 }
726 }
727 _ => {}
728 }
729}
730
731fn rewrite_suppress_line_actions(
732 value: &mut serde_json::Value,
733 anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
734) {
735 match value {
736 serde_json::Value::Object(map) => {
737 if let Some(anchor) = suppression_anchor(map)
738 && let Some(kinds) = anchors.get(&anchor)
739 {
740 let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
741 if let Some(actions) = map
742 .get_mut("actions")
743 .and_then(serde_json::Value::as_array_mut)
744 {
745 for action in actions {
746 if suppress_line_comment(action).is_some()
747 && let serde_json::Value::Object(action_map) = action
748 {
749 action_map.insert("comment".to_string(), serde_json::json!(comment));
750 }
751 }
752 }
753 }
754
755 for child in map.values_mut() {
756 rewrite_suppress_line_actions(child, anchors);
757 }
758 }
759 serde_json::Value::Array(items) => {
760 for item in items {
761 rewrite_suppress_line_actions(item, anchors);
762 }
763 }
764 _ => {}
765 }
766}
767
768fn suppression_anchor(map: &serde_json::Map<String, serde_json::Value>) -> Option<SuppressAnchor> {
769 let path = map
770 .get("path")
771 .or_else(|| map.get("from_path"))
772 .and_then(serde_json::Value::as_str)?;
773 let line = map.get("line").and_then(serde_json::Value::as_u64)?;
774 Some((path.to_string(), line))
775}
776
777fn suppress_line_comment(action: &serde_json::Value) -> Option<&str> {
778 (action.get("type").and_then(serde_json::Value::as_str) == Some("suppress-line"))
779 .then_some(())
780 .and_then(|()| action.get("comment").and_then(serde_json::Value::as_str))
781}
782
783fn parse_suppress_line_comment(comment: &str) -> Vec<String> {
784 comment
785 .strip_prefix("// fallow-ignore-next-line ")
786 .map(|rest| {
787 rest.split(|c: char| c == ',' || c.is_whitespace())
788 .filter(|token| !token.is_empty())
789 .map(str::to_string)
790 .collect()
791 })
792 .unwrap_or_default()
793}
794
795fn sort_suppression_kinds(kinds: &mut [String]) {
796 kinds.sort_by_key(|kind| suppression_kind_rank(kind));
797}
798
799fn suppression_kind_rank(kind: &str) -> usize {
800 match kind {
801 "unused-file" => 0,
802 "unused-export" => 1,
803 "unused-type" => 2,
804 "private-type-leak" => 3,
805 "unused-enum-member" => 4,
806 "unused-class-member" => 5,
807 "unresolved-import" => 6,
808 "unlisted-dependency" => 7,
809 "duplicate-export" => 8,
810 "circular-dependency" => 9,
811 "boundary-violation" => 10,
812 "code-duplication" => 11,
813 "complexity" => 12,
814 _ => usize::MAX,
815 }
816}
817
818pub fn build_baseline_deltas_json<'a>(
826 total_delta: i64,
827 per_category: impl Iterator<Item = (&'a str, usize, usize, i64)>,
828) -> serde_json::Value {
829 let mut per_cat = serde_json::Map::new();
830 for (cat, current, baseline, delta) in per_category {
831 per_cat.insert(
832 cat.to_string(),
833 serde_json::json!({
834 "current": current,
835 "baseline": baseline,
836 "delta": delta,
837 }),
838 );
839 }
840 serde_json::json!({
841 "total_delta": total_delta,
842 "per_category": per_cat
843 })
844}
845
846const SECONDARY_REFACTOR_BAND: u16 = 5;
856
857#[derive(Debug, Clone, Copy, Default)]
872pub struct HealthActionOptions {
873 pub omit_suppress_line: bool,
875 pub omit_reason: Option<&'static str>,
880}
881
882#[allow(
889 clippy::redundant_pub_crate,
890 reason = "pub(crate) needed, used by audit.rs via re-export, but not part of public API"
891)]
892pub(crate) fn inject_health_actions(output: &mut serde_json::Value, opts: HealthActionOptions) {
893 let Some(map) = output.as_object_mut() else {
894 return;
895 };
896
897 let max_cyclomatic_threshold = map
900 .get("summary")
901 .and_then(|s| s.get("max_cyclomatic_threshold"))
902 .and_then(serde_json::Value::as_u64)
903 .and_then(|v| u16::try_from(v).ok())
904 .unwrap_or(20);
905 let max_cognitive_threshold = map
906 .get("summary")
907 .and_then(|s| s.get("max_cognitive_threshold"))
908 .and_then(serde_json::Value::as_u64)
909 .and_then(|v| u16::try_from(v).ok())
910 .unwrap_or(15);
911 let max_crap_threshold = map
912 .get("summary")
913 .and_then(|s| s.get("max_crap_threshold"))
914 .and_then(serde_json::Value::as_f64)
915 .unwrap_or(30.0);
916
917 if let Some(findings) = map.get_mut("findings").and_then(|v| v.as_array_mut()) {
919 for item in findings {
920 let actions = build_health_finding_actions(
921 item,
922 opts,
923 max_cyclomatic_threshold,
924 max_cognitive_threshold,
925 max_crap_threshold,
926 );
927 if let serde_json::Value::Object(obj) = item {
928 obj.insert("actions".to_string(), actions);
929 }
930 }
931 }
932
933 if let Some(targets) = map.get_mut("targets").and_then(|v| v.as_array_mut()) {
935 for item in targets {
936 let actions = build_refactoring_target_actions(item);
937 if let serde_json::Value::Object(obj) = item {
938 obj.insert("actions".to_string(), actions);
939 }
940 }
941 }
942
943 if let Some(hotspots) = map.get_mut("hotspots").and_then(|v| v.as_array_mut()) {
945 for item in hotspots {
946 let actions = build_hotspot_actions(item);
947 if let serde_json::Value::Object(obj) = item {
948 obj.insert("actions".to_string(), actions);
949 }
950 }
951 }
952
953 if let Some(gaps) = map.get_mut("coverage_gaps").and_then(|v| v.as_object_mut()) {
955 if let Some(files) = gaps.get_mut("files").and_then(|v| v.as_array_mut()) {
956 for item in files {
957 let actions = build_untested_file_actions(item);
958 if let serde_json::Value::Object(obj) = item {
959 obj.insert("actions".to_string(), actions);
960 }
961 }
962 }
963 if let Some(exports) = gaps.get_mut("exports").and_then(|v| v.as_array_mut()) {
964 for item in exports {
965 let actions = build_untested_export_actions(item);
966 if let serde_json::Value::Object(obj) = item {
967 obj.insert("actions".to_string(), actions);
968 }
969 }
970 }
971 }
972
973 if opts.omit_suppress_line {
981 let reason = opts.omit_reason.unwrap_or("unspecified");
982 map.insert(
983 "actions_meta".to_string(),
984 serde_json::json!({
985 "suppression_hints_omitted": true,
986 "reason": reason,
987 "scope": "health-findings",
988 }),
989 );
990 }
991}
992
993fn build_health_finding_actions(
1017 item: &serde_json::Value,
1018 opts: HealthActionOptions,
1019 max_cyclomatic_threshold: u16,
1020 max_cognitive_threshold: u16,
1021 max_crap_threshold: f64,
1022) -> serde_json::Value {
1023 let name = item
1024 .get("name")
1025 .and_then(serde_json::Value::as_str)
1026 .unwrap_or("function");
1027 let path = item
1028 .get("path")
1029 .and_then(serde_json::Value::as_str)
1030 .unwrap_or("");
1031 let exceeded = item
1032 .get("exceeded")
1033 .and_then(serde_json::Value::as_str)
1034 .unwrap_or("");
1035 let includes_crap = matches!(
1036 exceeded,
1037 "crap" | "cyclomatic_crap" | "cognitive_crap" | "all"
1038 );
1039 let crap_only = exceeded == "crap";
1040 let tier = item
1041 .get("coverage_tier")
1042 .and_then(serde_json::Value::as_str);
1043 let cyclomatic = item
1044 .get("cyclomatic")
1045 .and_then(serde_json::Value::as_u64)
1046 .and_then(|v| u16::try_from(v).ok())
1047 .unwrap_or(0);
1048 let cognitive = item
1049 .get("cognitive")
1050 .and_then(serde_json::Value::as_u64)
1051 .and_then(|v| u16::try_from(v).ok())
1052 .unwrap_or(0);
1053 let full_coverage_can_clear_crap = !includes_crap || f64::from(cyclomatic) < max_crap_threshold;
1054
1055 let mut actions: Vec<serde_json::Value> = Vec::new();
1056
1057 if includes_crap {
1059 let coverage_action = build_crap_coverage_action(name, tier, full_coverage_can_clear_crap);
1060 if let Some(action) = coverage_action {
1061 actions.push(action);
1062 }
1063 }
1064
1065 let crap_only_needs_complexity_reduction = crap_only && !full_coverage_can_clear_crap;
1081 let cognitive_floor = max_cognitive_threshold / 2;
1082 let near_cyclomatic_threshold = crap_only
1083 && cyclomatic > 0
1084 && cyclomatic >= max_cyclomatic_threshold.saturating_sub(SECONDARY_REFACTOR_BAND)
1085 && cognitive >= cognitive_floor;
1086 let is_template = name == "<template>";
1087 if !crap_only || crap_only_needs_complexity_reduction || near_cyclomatic_threshold {
1088 let (description, note) = if is_template {
1089 (
1090 format!(
1091 "Refactor `{name}` to reduce template complexity (simplify control flow and bindings)"
1092 ),
1093 "Consider splitting complex template branches into smaller components or simpler bindings",
1094 )
1095 } else {
1096 (
1097 format!(
1098 "Refactor `{name}` to reduce complexity (extract helper functions, simplify branching)"
1099 ),
1100 "Consider splitting into smaller functions with single responsibilities",
1101 )
1102 };
1103 actions.push(serde_json::json!({
1104 "type": "refactor-function",
1105 "auto_fixable": false,
1106 "description": description,
1107 "note": note,
1108 }));
1109 }
1110
1111 if !opts.omit_suppress_line {
1112 if is_template
1113 && Path::new(path)
1114 .extension()
1115 .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
1116 {
1117 actions.push(serde_json::json!({
1118 "type": "suppress-file",
1119 "auto_fixable": false,
1120 "description": "Suppress with an HTML comment at the top of the template",
1121 "comment": "<!-- fallow-ignore-file complexity -->",
1122 "placement": "top-of-template",
1123 }));
1124 } else if is_template {
1125 actions.push(serde_json::json!({
1126 "type": "suppress-line",
1127 "auto_fixable": false,
1128 "description": "Suppress with an inline comment above the Angular decorator",
1129 "comment": "// fallow-ignore-next-line complexity",
1130 "placement": "above-angular-decorator",
1131 }));
1132 } else {
1133 actions.push(serde_json::json!({
1134 "type": "suppress-line",
1135 "auto_fixable": false,
1136 "description": "Suppress with an inline comment above the function declaration",
1137 "comment": "// fallow-ignore-next-line complexity",
1138 "placement": "above-function-declaration",
1139 }));
1140 }
1141 }
1142
1143 serde_json::Value::Array(actions)
1144}
1145
1146fn build_crap_coverage_action(
1152 name: &str,
1153 tier: Option<&str>,
1154 full_coverage_can_clear_crap: bool,
1155) -> Option<serde_json::Value> {
1156 if !full_coverage_can_clear_crap {
1157 return None;
1158 }
1159
1160 match tier {
1161 Some("partial" | "high") => Some(serde_json::json!({
1166 "type": "increase-coverage",
1167 "auto_fixable": false,
1168 "description": format!("Increase test coverage for `{name}` (file is reachable from existing tests; add targeted assertions for uncovered branches)"),
1169 "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",
1170 })),
1171 _ => Some(serde_json::json!({
1173 "type": "add-tests",
1174 "auto_fixable": false,
1175 "description": format!("Add test coverage for `{name}` to lower its CRAP score (coverage reduces risk even without refactoring)"),
1176 "note": "CRAP = CC^2 * (1 - cov/100)^3 + CC; higher coverage is the fastest way to bring CRAP under threshold",
1177 })),
1178 }
1179}
1180
1181fn build_hotspot_actions(item: &serde_json::Value) -> serde_json::Value {
1183 let path = item
1184 .get("path")
1185 .and_then(serde_json::Value::as_str)
1186 .unwrap_or("file");
1187
1188 let mut actions = vec![
1189 serde_json::json!({
1190 "type": "refactor-file",
1191 "auto_fixable": false,
1192 "description": format!("Refactor `{path}`, high complexity combined with frequent changes makes this a maintenance risk"),
1193 "note": "Prioritize extracting complex functions, adding tests, or splitting the module",
1194 }),
1195 serde_json::json!({
1196 "type": "add-tests",
1197 "auto_fixable": false,
1198 "description": format!("Add test coverage for `{path}` to reduce change risk"),
1199 "note": "Frequently changed complex files benefit most from comprehensive test coverage",
1200 }),
1201 ];
1202
1203 if let Some(ownership) = item.get("ownership") {
1204 if ownership
1206 .get("bus_factor")
1207 .and_then(serde_json::Value::as_u64)
1208 == Some(1)
1209 {
1210 let top = ownership.get("top_contributor");
1211 let owner = top
1212 .and_then(|t| t.get("identifier"))
1213 .and_then(serde_json::Value::as_str)
1214 .unwrap_or("the sole contributor");
1215 let commits = top
1220 .and_then(|t| t.get("commits"))
1221 .and_then(serde_json::Value::as_u64)
1222 .unwrap_or(0);
1223 let suggested: Vec<String> = ownership
1229 .get("suggested_reviewers")
1230 .and_then(serde_json::Value::as_array)
1231 .map(|arr| {
1232 arr.iter()
1233 .filter_map(|r| {
1234 r.get("identifier")
1235 .and_then(serde_json::Value::as_str)
1236 .map(String::from)
1237 })
1238 .collect()
1239 })
1240 .unwrap_or_default();
1241 let mut low_bus_action = serde_json::json!({
1242 "type": "low-bus-factor",
1243 "auto_fixable": false,
1244 "description": format!(
1245 "{owner} is the sole recent contributor to `{path}`; adding a second reviewer reduces knowledge-loss risk"
1246 ),
1247 });
1248 if !suggested.is_empty() {
1249 let list = suggested
1250 .iter()
1251 .map(|s| format!("@{s}"))
1252 .collect::<Vec<_>>()
1253 .join(", ");
1254 low_bus_action["note"] =
1255 serde_json::Value::String(format!("Candidate reviewers: {list}"));
1256 } else if commits < 5 {
1257 low_bus_action["note"] = serde_json::Value::String(
1258 "Single recent contributor on a low-commit file. Consider a pair review for major changes."
1259 .to_string(),
1260 );
1261 }
1262 actions.push(low_bus_action);
1264 }
1265
1266 if ownership
1269 .get("unowned")
1270 .and_then(serde_json::Value::as_bool)
1271 == Some(true)
1272 {
1273 actions.push(serde_json::json!({
1274 "type": "unowned-hotspot",
1275 "auto_fixable": false,
1276 "description": format!("Add a CODEOWNERS entry for `{path}`"),
1277 "note": "Frequently-changed files without declared owners create review bottlenecks",
1278 "suggested_pattern": suggest_codeowners_pattern(path),
1279 "heuristic": "directory-deepest",
1280 }));
1281 }
1282
1283 if ownership.get("drift").and_then(serde_json::Value::as_bool) == Some(true) {
1286 let reason = ownership
1287 .get("drift_reason")
1288 .and_then(serde_json::Value::as_str)
1289 .unwrap_or("ownership has shifted from the original author");
1290 actions.push(serde_json::json!({
1291 "type": "ownership-drift",
1292 "auto_fixable": false,
1293 "description": format!("Update CODEOWNERS for `{path}`: {reason}"),
1294 "note": "Drift suggests the declared or original owner is no longer the right reviewer",
1295 }));
1296 }
1297 }
1298
1299 serde_json::Value::Array(actions)
1300}
1301
1302fn suggest_codeowners_pattern(path: &str) -> String {
1315 let normalized = path.replace('\\', "/");
1316 let trimmed = normalized.trim_start_matches('/');
1317 let mut components: Vec<&str> = trimmed.split('/').collect();
1318 components.pop(); if components.is_empty() {
1320 return format!("/{trimmed}");
1321 }
1322 format!("/{}/", components.join("/"))
1323}
1324
1325fn build_refactoring_target_actions(item: &serde_json::Value) -> serde_json::Value {
1327 let recommendation = item
1328 .get("recommendation")
1329 .and_then(serde_json::Value::as_str)
1330 .unwrap_or("Apply the recommended refactoring");
1331
1332 let category = item
1333 .get("category")
1334 .and_then(serde_json::Value::as_str)
1335 .unwrap_or("refactoring");
1336
1337 let mut actions = vec![serde_json::json!({
1338 "type": "apply-refactoring",
1339 "auto_fixable": false,
1340 "description": recommendation,
1341 "category": category,
1342 })];
1343
1344 if item.get("evidence").is_some() {
1346 actions.push(serde_json::json!({
1347 "type": "suppress-line",
1348 "auto_fixable": false,
1349 "description": "Suppress the underlying complexity finding",
1350 "comment": "// fallow-ignore-next-line complexity",
1351 }));
1352 }
1353
1354 serde_json::Value::Array(actions)
1355}
1356
1357fn build_untested_file_actions(item: &serde_json::Value) -> serde_json::Value {
1359 let path = item
1360 .get("path")
1361 .and_then(serde_json::Value::as_str)
1362 .unwrap_or("file");
1363
1364 serde_json::Value::Array(vec![
1365 serde_json::json!({
1366 "type": "add-tests",
1367 "auto_fixable": false,
1368 "description": format!("Add test coverage for `{path}`"),
1369 "note": "No test dependency path reaches this runtime file",
1370 }),
1371 serde_json::json!({
1372 "type": "suppress-file",
1373 "auto_fixable": false,
1374 "description": format!("Suppress coverage gap reporting for `{path}`"),
1375 "comment": "// fallow-ignore-file coverage-gaps",
1376 }),
1377 ])
1378}
1379
1380fn build_untested_export_actions(item: &serde_json::Value) -> serde_json::Value {
1382 let path = item
1383 .get("path")
1384 .and_then(serde_json::Value::as_str)
1385 .unwrap_or("file");
1386 let export_name = item
1387 .get("export_name")
1388 .and_then(serde_json::Value::as_str)
1389 .unwrap_or("export");
1390
1391 serde_json::Value::Array(vec![
1392 serde_json::json!({
1393 "type": "add-test-import",
1394 "auto_fixable": false,
1395 "description": format!("Import and test `{export_name}` from `{path}`"),
1396 "note": "This export is runtime-reachable but no test-reachable module references it",
1397 }),
1398 serde_json::json!({
1399 "type": "suppress-file",
1400 "auto_fixable": false,
1401 "description": format!("Suppress coverage gap reporting for `{path}`"),
1402 "comment": "// fallow-ignore-file coverage-gaps",
1403 }),
1404 ])
1405}
1406
1407#[allow(
1414 clippy::redundant_pub_crate,
1415 reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
1416)]
1417pub(crate) fn inject_dupes_actions(output: &mut serde_json::Value) {
1418 let Some(map) = output.as_object_mut() else {
1419 return;
1420 };
1421
1422 if let Some(families) = map.get_mut("clone_families").and_then(|v| v.as_array_mut()) {
1424 for item in families {
1425 let actions = build_clone_family_actions(item);
1426 if let serde_json::Value::Object(obj) = item {
1427 obj.insert("actions".to_string(), actions);
1428 }
1429 }
1430 }
1431
1432 if let Some(groups) = map.get_mut("clone_groups").and_then(|v| v.as_array_mut()) {
1434 for item in groups {
1435 let actions = build_clone_group_actions(item);
1436 if let serde_json::Value::Object(obj) = item {
1437 obj.insert("actions".to_string(), actions);
1438 }
1439 }
1440 }
1441}
1442
1443fn build_clone_family_actions(item: &serde_json::Value) -> serde_json::Value {
1445 let group_count = item
1446 .get("groups")
1447 .and_then(|v| v.as_array())
1448 .map_or(0, Vec::len);
1449
1450 let total_lines = item
1451 .get("total_duplicated_lines")
1452 .and_then(serde_json::Value::as_u64)
1453 .unwrap_or(0);
1454
1455 let mut actions = vec![serde_json::json!({
1456 "type": "extract-shared",
1457 "auto_fixable": false,
1458 "description": format!(
1459 "Extract {group_count} duplicated code block{} ({total_lines} lines) into a shared module",
1460 if group_count == 1 { "" } else { "s" }
1461 ),
1462 "note": "These clone groups share the same files, indicating a structural relationship; refactor together",
1463 })];
1464
1465 if let Some(suggestions) = item.get("suggestions").and_then(|v| v.as_array()) {
1467 for suggestion in suggestions {
1468 if let Some(desc) = suggestion
1469 .get("description")
1470 .and_then(serde_json::Value::as_str)
1471 {
1472 actions.push(serde_json::json!({
1473 "type": "apply-suggestion",
1474 "auto_fixable": false,
1475 "description": desc,
1476 }));
1477 }
1478 }
1479 }
1480
1481 actions.push(serde_json::json!({
1482 "type": "suppress-line",
1483 "auto_fixable": false,
1484 "description": "Suppress with an inline comment above the duplicated code",
1485 "comment": "// fallow-ignore-next-line code-duplication",
1486 }));
1487
1488 serde_json::Value::Array(actions)
1489}
1490
1491fn build_clone_group_actions(item: &serde_json::Value) -> serde_json::Value {
1493 let instance_count = item
1494 .get("instances")
1495 .and_then(|v| v.as_array())
1496 .map_or(0, Vec::len);
1497
1498 let line_count = item
1499 .get("line_count")
1500 .and_then(serde_json::Value::as_u64)
1501 .unwrap_or(0);
1502
1503 let actions = vec![
1504 serde_json::json!({
1505 "type": "extract-shared",
1506 "auto_fixable": false,
1507 "description": format!(
1508 "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
1509 if instance_count == 1 { "" } else { "s" }
1510 ),
1511 }),
1512 serde_json::json!({
1513 "type": "suppress-line",
1514 "auto_fixable": false,
1515 "description": "Suppress with an inline comment above the duplicated code",
1516 "comment": "// fallow-ignore-next-line code-duplication",
1517 }),
1518 ];
1519
1520 serde_json::Value::Array(actions)
1521}
1522
1523fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
1525 if let serde_json::Value::Object(map) = output {
1526 map.insert("_meta".to_string(), meta);
1527 }
1528}
1529
1530pub fn build_health_json(
1538 report: &crate::health_types::HealthReport,
1539 root: &Path,
1540 elapsed: Duration,
1541 explain: bool,
1542 action_opts: HealthActionOptions,
1543) -> Result<serde_json::Value, serde_json::Error> {
1544 let report_value = serde_json::to_value(report)?;
1545 let mut output = build_json_envelope(report_value, elapsed);
1546 let root_prefix = format!("{}/", root.display());
1547 strip_root_prefix(&mut output, &root_prefix);
1548 inject_runtime_coverage_schema_version(&mut output);
1549 inject_health_actions(&mut output, action_opts);
1550 if explain {
1551 insert_meta(&mut output, explain::health_meta());
1552 }
1553 Ok(output)
1554}
1555
1556pub(super) fn print_health_json(
1557 report: &crate::health_types::HealthReport,
1558 root: &Path,
1559 elapsed: Duration,
1560 explain: bool,
1561 action_opts: HealthActionOptions,
1562) -> ExitCode {
1563 match build_health_json(report, root, elapsed, explain, action_opts) {
1564 Ok(output) => emit_json(&output, "JSON"),
1565 Err(e) => {
1566 eprintln!("Error: failed to serialize health report: {e}");
1567 ExitCode::from(2)
1568 }
1569 }
1570}
1571
1572pub fn build_grouped_health_json(
1592 report: &crate::health_types::HealthReport,
1593 grouping: &crate::health_types::HealthGrouping,
1594 root: &Path,
1595 elapsed: Duration,
1596 explain: bool,
1597 action_opts: HealthActionOptions,
1598) -> Result<serde_json::Value, serde_json::Error> {
1599 let root_prefix = format!("{}/", root.display());
1600 let report_value = serde_json::to_value(report)?;
1601 let mut output = build_json_envelope(report_value, elapsed);
1602 strip_root_prefix(&mut output, &root_prefix);
1603 inject_runtime_coverage_schema_version(&mut output);
1604 inject_health_actions(&mut output, action_opts);
1605
1606 if let serde_json::Value::Object(ref mut map) = output {
1607 map.insert("grouped_by".to_string(), serde_json::json!(grouping.mode));
1608 }
1609
1610 let group_values: Vec<serde_json::Value> = grouping
1618 .groups
1619 .iter()
1620 .map(|g| {
1621 let mut value = serde_json::to_value(g)?;
1622 strip_root_prefix(&mut value, &root_prefix);
1623 inject_runtime_coverage_schema_version(&mut value);
1624 inject_health_actions(&mut value, action_opts);
1625 Ok(value)
1626 })
1627 .collect::<Result<_, serde_json::Error>>()?;
1628
1629 if let serde_json::Value::Object(ref mut map) = output {
1630 map.insert("groups".to_string(), serde_json::Value::Array(group_values));
1631 }
1632
1633 if explain {
1634 insert_meta(&mut output, explain::health_meta());
1635 }
1636
1637 Ok(output)
1638}
1639
1640pub(super) fn print_grouped_health_json(
1641 report: &crate::health_types::HealthReport,
1642 grouping: &crate::health_types::HealthGrouping,
1643 root: &Path,
1644 elapsed: Duration,
1645 explain: bool,
1646 action_opts: HealthActionOptions,
1647) -> ExitCode {
1648 match build_grouped_health_json(report, grouping, root, elapsed, explain, action_opts) {
1649 Ok(output) => emit_json(&output, "JSON"),
1650 Err(e) => {
1651 eprintln!("Error: failed to serialize grouped health report: {e}");
1652 ExitCode::from(2)
1653 }
1654 }
1655}
1656
1657pub fn build_duplication_json(
1664 report: &DuplicationReport,
1665 root: &Path,
1666 elapsed: Duration,
1667 explain: bool,
1668) -> Result<serde_json::Value, serde_json::Error> {
1669 let report_value = serde_json::to_value(report)?;
1670
1671 let mut output = build_json_envelope(report_value, elapsed);
1672 let root_prefix = format!("{}/", root.display());
1673 strip_root_prefix(&mut output, &root_prefix);
1674 inject_dupes_actions(&mut output);
1675
1676 if explain {
1677 insert_meta(&mut output, explain::dupes_meta());
1678 }
1679
1680 Ok(output)
1681}
1682
1683pub(super) fn print_duplication_json(
1684 report: &DuplicationReport,
1685 root: &Path,
1686 elapsed: Duration,
1687 explain: bool,
1688) -> ExitCode {
1689 match build_duplication_json(report, root, elapsed, explain) {
1690 Ok(output) => emit_json(&output, "JSON"),
1691 Err(e) => {
1692 eprintln!("Error: failed to serialize duplication report: {e}");
1693 ExitCode::from(2)
1694 }
1695 }
1696}
1697
1698pub fn build_grouped_duplication_json(
1719 report: &DuplicationReport,
1720 grouping: &super::dupes_grouping::DuplicationGrouping,
1721 root: &Path,
1722 elapsed: Duration,
1723 explain: bool,
1724) -> Result<serde_json::Value, serde_json::Error> {
1725 let report_value = serde_json::to_value(report)?;
1726 let mut output = build_json_envelope(report_value, elapsed);
1727 let root_prefix = format!("{}/", root.display());
1728 strip_root_prefix(&mut output, &root_prefix);
1729 inject_dupes_actions(&mut output);
1730
1731 if let serde_json::Value::Object(ref mut map) = output {
1732 map.insert("grouped_by".to_string(), serde_json::json!(grouping.mode));
1733 map.insert(
1739 "total_issues".to_string(),
1740 serde_json::json!(report.clone_groups.len()),
1741 );
1742 }
1743
1744 let group_values: Vec<serde_json::Value> = grouping
1745 .groups
1746 .iter()
1747 .map(|g| {
1748 let mut value = serde_json::to_value(g)?;
1749 strip_root_prefix(&mut value, &root_prefix);
1750 inject_dupes_actions(&mut value);
1751 Ok(value)
1752 })
1753 .collect::<Result<_, serde_json::Error>>()?;
1754
1755 if let serde_json::Value::Object(ref mut map) = output {
1756 map.insert("groups".to_string(), serde_json::Value::Array(group_values));
1757 }
1758
1759 if explain {
1760 insert_meta(&mut output, explain::dupes_meta());
1761 }
1762
1763 Ok(output)
1764}
1765
1766pub(super) fn print_grouped_duplication_json(
1767 report: &DuplicationReport,
1768 grouping: &super::dupes_grouping::DuplicationGrouping,
1769 root: &Path,
1770 elapsed: Duration,
1771 explain: bool,
1772) -> ExitCode {
1773 match build_grouped_duplication_json(report, grouping, root, elapsed, explain) {
1774 Ok(output) => emit_json(&output, "JSON"),
1775 Err(e) => {
1776 eprintln!("Error: failed to serialize grouped duplication report: {e}");
1777 ExitCode::from(2)
1778 }
1779 }
1780}
1781
1782pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
1783 match serde_json::to_string_pretty(value) {
1784 Ok(json) => println!("{json}"),
1785 Err(e) => {
1786 eprintln!("Error: failed to serialize trace output: {e}");
1787 #[expect(
1788 clippy::exit,
1789 reason = "fatal serialization error requires immediate exit"
1790 )]
1791 std::process::exit(2);
1792 }
1793 }
1794}
1795
1796#[cfg(test)]
1797mod tests {
1798 use super::*;
1799 use crate::health_types::{
1800 RuntimeCoverageAction, RuntimeCoverageConfidence, RuntimeCoverageDataSource,
1801 RuntimeCoverageEvidence, RuntimeCoverageFinding, RuntimeCoverageHotPath,
1802 RuntimeCoverageMessage, RuntimeCoverageReport, RuntimeCoverageReportVerdict,
1803 RuntimeCoverageSummary, RuntimeCoverageVerdict, RuntimeCoverageWatermark,
1804 };
1805 use crate::report::test_helpers::sample_results;
1806 use fallow_core::extract::MemberKind;
1807 use fallow_core::results::*;
1808 use std::path::PathBuf;
1809 use std::time::Duration;
1810
1811 #[test]
1812 fn json_output_has_metadata_fields() {
1813 let root = PathBuf::from("/project");
1814 let results = AnalysisResults::default();
1815 let elapsed = Duration::from_millis(123);
1816 let output = build_json(&results, &root, elapsed).expect("should serialize");
1817
1818 assert_eq!(output["schema_version"], 6);
1819 assert!(output["version"].is_string());
1820 assert_eq!(output["elapsed_ms"], 123);
1821 assert_eq!(output["total_issues"], 0);
1822 }
1823
1824 #[test]
1825 fn json_output_includes_issue_arrays() {
1826 let root = PathBuf::from("/project");
1827 let results = sample_results(&root);
1828 let elapsed = Duration::from_millis(50);
1829 let output = build_json(&results, &root, elapsed).expect("should serialize");
1830
1831 assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
1832 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
1833 assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
1834 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
1835 assert_eq!(
1836 output["unused_dev_dependencies"].as_array().unwrap().len(),
1837 1
1838 );
1839 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
1840 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
1841 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
1842 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
1843 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
1844 assert_eq!(
1845 output["type_only_dependencies"].as_array().unwrap().len(),
1846 1
1847 );
1848 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
1849 }
1850
1851 #[test]
1852 fn health_json_includes_runtime_coverage_with_relative_paths_and_actions() {
1853 let root = PathBuf::from("/project");
1854 let report = crate::health_types::HealthReport {
1855 runtime_coverage: Some(RuntimeCoverageReport {
1856 verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
1857 signals: Vec::new(),
1858 summary: RuntimeCoverageSummary {
1859 data_source: RuntimeCoverageDataSource::Local,
1860 last_received_at: None,
1861 functions_tracked: 3,
1862 functions_hit: 1,
1863 functions_unhit: 1,
1864 functions_untracked: 1,
1865 coverage_percent: 33.3,
1866 trace_count: 2_847_291,
1867 period_days: 30,
1868 deployments_seen: 14,
1869 capture_quality: Some(crate::health_types::RuntimeCoverageCaptureQuality {
1870 window_seconds: 720,
1871 instances_observed: 1,
1872 lazy_parse_warning: true,
1873 untracked_ratio_percent: 42.5,
1874 }),
1875 },
1876 findings: vec![RuntimeCoverageFinding {
1877 id: "fallow:prod:deadbeef".to_owned(),
1878 path: root.join("src/cold.ts"),
1879 function: "coldPath".to_owned(),
1880 line: 12,
1881 verdict: RuntimeCoverageVerdict::ReviewRequired,
1882 invocations: Some(0),
1883 confidence: RuntimeCoverageConfidence::Medium,
1884 evidence: RuntimeCoverageEvidence {
1885 static_status: "used".to_owned(),
1886 test_coverage: "not_covered".to_owned(),
1887 v8_tracking: "tracked".to_owned(),
1888 untracked_reason: None,
1889 observation_days: 30,
1890 deployments_observed: 14,
1891 },
1892 actions: vec![RuntimeCoverageAction {
1893 kind: "review-deletion".to_owned(),
1894 description: "Tracked in runtime coverage with zero invocations."
1895 .to_owned(),
1896 auto_fixable: false,
1897 }],
1898 }],
1899 hot_paths: vec![RuntimeCoverageHotPath {
1900 id: "fallow:hot:cafebabe".to_owned(),
1901 path: root.join("src/hot.ts"),
1902 function: "hotPath".to_owned(),
1903 line: 3,
1904 end_line: 9,
1905 invocations: 250,
1906 percentile: 99,
1907 actions: vec![],
1908 }],
1909 blast_radius: vec![],
1910 importance: vec![],
1911 watermark: Some(RuntimeCoverageWatermark::LicenseExpiredGrace),
1912 warnings: vec![RuntimeCoverageMessage {
1913 code: "partial-merge".to_owned(),
1914 message: "Merged coverage omitted one chunk.".to_owned(),
1915 }],
1916 }),
1917 ..Default::default()
1918 };
1919
1920 let report_value = serde_json::to_value(&report).expect("should serialize health report");
1921 let mut output = build_json_envelope(report_value, Duration::from_millis(7));
1922 strip_root_prefix(&mut output, "/project/");
1923 inject_runtime_coverage_schema_version(&mut output);
1924 inject_health_actions(&mut output, HealthActionOptions::default());
1925
1926 assert_eq!(
1927 output["runtime_coverage"]["verdict"],
1928 serde_json::Value::String("cold-code-detected".to_owned())
1929 );
1930 assert_eq!(
1931 output["runtime_coverage"]["schema_version"],
1932 serde_json::Value::String("1".to_owned())
1933 );
1934 assert_eq!(
1935 output["runtime_coverage"]["summary"]["functions_tracked"],
1936 serde_json::Value::from(3)
1937 );
1938 assert_eq!(
1939 output["runtime_coverage"]["summary"]["coverage_percent"],
1940 serde_json::Value::from(33.3)
1941 );
1942 let finding = &output["runtime_coverage"]["findings"][0];
1943 assert_eq!(finding["path"], "src/cold.ts");
1944 assert_eq!(finding["verdict"], "review_required");
1945 assert_eq!(finding["id"], "fallow:prod:deadbeef");
1946 assert_eq!(finding["actions"][0]["type"], "review-deletion");
1947 let hot_path = &output["runtime_coverage"]["hot_paths"][0];
1948 assert_eq!(hot_path["path"], "src/hot.ts");
1949 assert_eq!(hot_path["function"], "hotPath");
1950 assert_eq!(hot_path["percentile"], 99);
1951 assert_eq!(
1952 output["runtime_coverage"]["watermark"],
1953 serde_json::Value::String("license-expired-grace".to_owned())
1954 );
1955 assert_eq!(
1956 output["runtime_coverage"]["warnings"][0]["code"],
1957 serde_json::Value::String("partial-merge".to_owned())
1958 );
1959 }
1960
1961 #[test]
1962 fn json_metadata_fields_appear_first() {
1963 let root = PathBuf::from("/project");
1964 let results = AnalysisResults::default();
1965 let elapsed = Duration::from_millis(0);
1966 let output = build_json(&results, &root, elapsed).expect("should serialize");
1967 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1968 assert_eq!(keys[0], "schema_version");
1969 assert_eq!(keys[1], "version");
1970 assert_eq!(keys[2], "elapsed_ms");
1971 assert_eq!(keys[3], "total_issues");
1972 }
1973
1974 #[test]
1975 fn json_total_issues_matches_results() {
1976 let root = PathBuf::from("/project");
1977 let results = sample_results(&root);
1978 let total = results.total_issues();
1979 let elapsed = Duration::from_millis(0);
1980 let output = build_json(&results, &root, elapsed).expect("should serialize");
1981
1982 assert_eq!(output["total_issues"], total);
1983 }
1984
1985 #[test]
1986 fn json_unused_export_contains_expected_fields() {
1987 let root = PathBuf::from("/project");
1988 let mut results = AnalysisResults::default();
1989 results.unused_exports.push(UnusedExport {
1990 path: root.join("src/utils.ts"),
1991 export_name: "helperFn".to_string(),
1992 is_type_only: false,
1993 line: 10,
1994 col: 4,
1995 span_start: 120,
1996 is_re_export: false,
1997 });
1998 let elapsed = Duration::from_millis(0);
1999 let output = build_json(&results, &root, elapsed).expect("should serialize");
2000
2001 let export = &output["unused_exports"][0];
2002 assert_eq!(export["export_name"], "helperFn");
2003 assert_eq!(export["line"], 10);
2004 assert_eq!(export["col"], 4);
2005 assert_eq!(export["is_type_only"], false);
2006 assert_eq!(export["span_start"], 120);
2007 assert_eq!(export["is_re_export"], false);
2008 }
2009
2010 #[test]
2011 fn json_serializes_to_valid_json() {
2012 let root = PathBuf::from("/project");
2013 let results = sample_results(&root);
2014 let elapsed = Duration::from_millis(42);
2015 let output = build_json(&results, &root, elapsed).expect("should serialize");
2016
2017 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
2018 let reparsed: serde_json::Value =
2019 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
2020 assert_eq!(reparsed, output);
2021 }
2022
2023 #[test]
2026 fn json_empty_results_produce_valid_structure() {
2027 let root = PathBuf::from("/project");
2028 let results = AnalysisResults::default();
2029 let elapsed = Duration::from_millis(0);
2030 let output = build_json(&results, &root, elapsed).expect("should serialize");
2031
2032 assert_eq!(output["total_issues"], 0);
2033 assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
2034 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
2035 assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
2036 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
2037 assert_eq!(
2038 output["unused_dev_dependencies"].as_array().unwrap().len(),
2039 0
2040 );
2041 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
2042 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
2043 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
2044 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
2045 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
2046 assert_eq!(
2047 output["type_only_dependencies"].as_array().unwrap().len(),
2048 0
2049 );
2050 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
2051 }
2052
2053 #[test]
2054 fn json_empty_results_round_trips_through_string() {
2055 let root = PathBuf::from("/project");
2056 let results = AnalysisResults::default();
2057 let elapsed = Duration::from_millis(0);
2058 let output = build_json(&results, &root, elapsed).expect("should serialize");
2059
2060 let json_str = serde_json::to_string(&output).expect("should stringify");
2061 let reparsed: serde_json::Value =
2062 serde_json::from_str(&json_str).expect("should parse back");
2063 assert_eq!(reparsed["total_issues"], 0);
2064 }
2065
2066 #[test]
2069 fn json_paths_are_relative_to_root() {
2070 let root = PathBuf::from("/project");
2071 let mut results = AnalysisResults::default();
2072 results.unused_files.push(UnusedFile {
2073 path: root.join("src/deep/nested/file.ts"),
2074 });
2075 let elapsed = Duration::from_millis(0);
2076 let output = build_json(&results, &root, elapsed).expect("should serialize");
2077
2078 let path = output["unused_files"][0]["path"].as_str().unwrap();
2079 assert_eq!(path, "src/deep/nested/file.ts");
2080 assert!(!path.starts_with("/project"));
2081 }
2082
2083 #[test]
2084 fn json_strips_root_from_nested_locations() {
2085 let root = PathBuf::from("/project");
2086 let mut results = AnalysisResults::default();
2087 results.unlisted_dependencies.push(UnlistedDependency {
2088 package_name: "chalk".to_string(),
2089 imported_from: vec![ImportSite {
2090 path: root.join("src/cli.ts"),
2091 line: 2,
2092 col: 0,
2093 }],
2094 });
2095 let elapsed = Duration::from_millis(0);
2096 let output = build_json(&results, &root, elapsed).expect("should serialize");
2097
2098 let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
2099 .as_str()
2100 .unwrap();
2101 assert_eq!(site_path, "src/cli.ts");
2102 }
2103
2104 #[test]
2105 fn json_strips_root_from_duplicate_export_locations() {
2106 let root = PathBuf::from("/project");
2107 let mut results = AnalysisResults::default();
2108 results.duplicate_exports.push(DuplicateExport {
2109 export_name: "Config".to_string(),
2110 locations: vec![
2111 DuplicateLocation {
2112 path: root.join("src/config.ts"),
2113 line: 15,
2114 col: 0,
2115 },
2116 DuplicateLocation {
2117 path: root.join("src/types.ts"),
2118 line: 30,
2119 col: 0,
2120 },
2121 ],
2122 });
2123 let elapsed = Duration::from_millis(0);
2124 let output = build_json(&results, &root, elapsed).expect("should serialize");
2125
2126 let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
2127 .as_str()
2128 .unwrap();
2129 let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
2130 .as_str()
2131 .unwrap();
2132 assert_eq!(loc0, "src/config.ts");
2133 assert_eq!(loc1, "src/types.ts");
2134 }
2135
2136 #[test]
2137 fn json_strips_root_from_circular_dependency_files() {
2138 let root = PathBuf::from("/project");
2139 let mut results = AnalysisResults::default();
2140 results.circular_dependencies.push(CircularDependency {
2141 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2142 length: 2,
2143 line: 1,
2144 col: 0,
2145 is_cross_package: false,
2146 });
2147 let elapsed = Duration::from_millis(0);
2148 let output = build_json(&results, &root, elapsed).expect("should serialize");
2149
2150 let files = output["circular_dependencies"][0]["files"]
2151 .as_array()
2152 .unwrap();
2153 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
2154 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
2155 }
2156
2157 #[test]
2158 fn json_path_outside_root_not_stripped() {
2159 let root = PathBuf::from("/project");
2160 let mut results = AnalysisResults::default();
2161 results.unused_files.push(UnusedFile {
2162 path: PathBuf::from("/other/project/src/file.ts"),
2163 });
2164 let elapsed = Duration::from_millis(0);
2165 let output = build_json(&results, &root, elapsed).expect("should serialize");
2166
2167 let path = output["unused_files"][0]["path"].as_str().unwrap();
2168 assert!(path.contains("/other/project/"));
2169 }
2170
2171 #[test]
2174 fn json_unused_file_contains_path() {
2175 let root = PathBuf::from("/project");
2176 let mut results = AnalysisResults::default();
2177 results.unused_files.push(UnusedFile {
2178 path: root.join("src/orphan.ts"),
2179 });
2180 let elapsed = Duration::from_millis(0);
2181 let output = build_json(&results, &root, elapsed).expect("should serialize");
2182
2183 let file = &output["unused_files"][0];
2184 assert_eq!(file["path"], "src/orphan.ts");
2185 }
2186
2187 #[test]
2188 fn json_unused_type_contains_expected_fields() {
2189 let root = PathBuf::from("/project");
2190 let mut results = AnalysisResults::default();
2191 results.unused_types.push(UnusedExport {
2192 path: root.join("src/types.ts"),
2193 export_name: "OldInterface".to_string(),
2194 is_type_only: true,
2195 line: 20,
2196 col: 0,
2197 span_start: 300,
2198 is_re_export: false,
2199 });
2200 let elapsed = Duration::from_millis(0);
2201 let output = build_json(&results, &root, elapsed).expect("should serialize");
2202
2203 let typ = &output["unused_types"][0];
2204 assert_eq!(typ["export_name"], "OldInterface");
2205 assert_eq!(typ["is_type_only"], true);
2206 assert_eq!(typ["line"], 20);
2207 assert_eq!(typ["path"], "src/types.ts");
2208 }
2209
2210 #[test]
2211 fn json_unused_dependency_contains_expected_fields() {
2212 let root = PathBuf::from("/project");
2213 let mut results = AnalysisResults::default();
2214 results.unused_dependencies.push(UnusedDependency {
2215 package_name: "axios".to_string(),
2216 location: DependencyLocation::Dependencies,
2217 path: root.join("package.json"),
2218 line: 10,
2219 used_in_workspaces: Vec::new(),
2220 });
2221 let elapsed = Duration::from_millis(0);
2222 let output = build_json(&results, &root, elapsed).expect("should serialize");
2223
2224 let dep = &output["unused_dependencies"][0];
2225 assert_eq!(dep["package_name"], "axios");
2226 assert_eq!(dep["line"], 10);
2227 assert!(dep.get("used_in_workspaces").is_none());
2228 }
2229
2230 #[test]
2231 fn json_unused_dependency_includes_cross_workspace_context() {
2232 let root = PathBuf::from("/project");
2233 let mut results = AnalysisResults::default();
2234 results.unused_dependencies.push(UnusedDependency {
2235 package_name: "lodash-es".to_string(),
2236 location: DependencyLocation::Dependencies,
2237 path: root.join("packages/shared/package.json"),
2238 line: 6,
2239 used_in_workspaces: vec![root.join("packages/consumer")],
2240 });
2241 let elapsed = Duration::from_millis(0);
2242 let output = build_json(&results, &root, elapsed).expect("should serialize");
2243
2244 let dep = &output["unused_dependencies"][0];
2245 assert_eq!(
2246 dep["used_in_workspaces"],
2247 serde_json::json!(["packages/consumer"])
2248 );
2249 }
2250
2251 #[test]
2252 fn json_unused_dev_dependency_contains_expected_fields() {
2253 let root = PathBuf::from("/project");
2254 let mut results = AnalysisResults::default();
2255 results.unused_dev_dependencies.push(UnusedDependency {
2256 package_name: "vitest".to_string(),
2257 location: DependencyLocation::DevDependencies,
2258 path: root.join("package.json"),
2259 line: 15,
2260 used_in_workspaces: Vec::new(),
2261 });
2262 let elapsed = Duration::from_millis(0);
2263 let output = build_json(&results, &root, elapsed).expect("should serialize");
2264
2265 let dep = &output["unused_dev_dependencies"][0];
2266 assert_eq!(dep["package_name"], "vitest");
2267 }
2268
2269 #[test]
2270 fn json_unused_optional_dependency_contains_expected_fields() {
2271 let root = PathBuf::from("/project");
2272 let mut results = AnalysisResults::default();
2273 results.unused_optional_dependencies.push(UnusedDependency {
2274 package_name: "fsevents".to_string(),
2275 location: DependencyLocation::OptionalDependencies,
2276 path: root.join("package.json"),
2277 line: 12,
2278 used_in_workspaces: Vec::new(),
2279 });
2280 let elapsed = Duration::from_millis(0);
2281 let output = build_json(&results, &root, elapsed).expect("should serialize");
2282
2283 let dep = &output["unused_optional_dependencies"][0];
2284 assert_eq!(dep["package_name"], "fsevents");
2285 assert_eq!(output["total_issues"], 1);
2286 }
2287
2288 #[test]
2289 fn json_unused_enum_member_contains_expected_fields() {
2290 let root = PathBuf::from("/project");
2291 let mut results = AnalysisResults::default();
2292 results.unused_enum_members.push(UnusedMember {
2293 path: root.join("src/enums.ts"),
2294 parent_name: "Color".to_string(),
2295 member_name: "Purple".to_string(),
2296 kind: MemberKind::EnumMember,
2297 line: 5,
2298 col: 2,
2299 });
2300 let elapsed = Duration::from_millis(0);
2301 let output = build_json(&results, &root, elapsed).expect("should serialize");
2302
2303 let member = &output["unused_enum_members"][0];
2304 assert_eq!(member["parent_name"], "Color");
2305 assert_eq!(member["member_name"], "Purple");
2306 assert_eq!(member["line"], 5);
2307 assert_eq!(member["path"], "src/enums.ts");
2308 }
2309
2310 #[test]
2311 fn json_unused_class_member_contains_expected_fields() {
2312 let root = PathBuf::from("/project");
2313 let mut results = AnalysisResults::default();
2314 results.unused_class_members.push(UnusedMember {
2315 path: root.join("src/api.ts"),
2316 parent_name: "ApiClient".to_string(),
2317 member_name: "deprecatedFetch".to_string(),
2318 kind: MemberKind::ClassMethod,
2319 line: 100,
2320 col: 4,
2321 });
2322 let elapsed = Duration::from_millis(0);
2323 let output = build_json(&results, &root, elapsed).expect("should serialize");
2324
2325 let member = &output["unused_class_members"][0];
2326 assert_eq!(member["parent_name"], "ApiClient");
2327 assert_eq!(member["member_name"], "deprecatedFetch");
2328 assert_eq!(member["line"], 100);
2329 }
2330
2331 #[test]
2332 fn json_unresolved_import_contains_expected_fields() {
2333 let root = PathBuf::from("/project");
2334 let mut results = AnalysisResults::default();
2335 results.unresolved_imports.push(UnresolvedImport {
2336 path: root.join("src/app.ts"),
2337 specifier: "@acme/missing-pkg".to_string(),
2338 line: 7,
2339 col: 0,
2340 specifier_col: 0,
2341 });
2342 let elapsed = Duration::from_millis(0);
2343 let output = build_json(&results, &root, elapsed).expect("should serialize");
2344
2345 let import = &output["unresolved_imports"][0];
2346 assert_eq!(import["specifier"], "@acme/missing-pkg");
2347 assert_eq!(import["line"], 7);
2348 assert_eq!(import["path"], "src/app.ts");
2349 }
2350
2351 #[test]
2352 fn json_unlisted_dependency_contains_import_sites() {
2353 let root = PathBuf::from("/project");
2354 let mut results = AnalysisResults::default();
2355 results.unlisted_dependencies.push(UnlistedDependency {
2356 package_name: "dotenv".to_string(),
2357 imported_from: vec![
2358 ImportSite {
2359 path: root.join("src/config.ts"),
2360 line: 1,
2361 col: 0,
2362 },
2363 ImportSite {
2364 path: root.join("src/server.ts"),
2365 line: 3,
2366 col: 0,
2367 },
2368 ],
2369 });
2370 let elapsed = Duration::from_millis(0);
2371 let output = build_json(&results, &root, elapsed).expect("should serialize");
2372
2373 let dep = &output["unlisted_dependencies"][0];
2374 assert_eq!(dep["package_name"], "dotenv");
2375 let sites = dep["imported_from"].as_array().unwrap();
2376 assert_eq!(sites.len(), 2);
2377 assert_eq!(sites[0]["path"], "src/config.ts");
2378 assert_eq!(sites[1]["path"], "src/server.ts");
2379 }
2380
2381 #[test]
2382 fn json_duplicate_export_contains_locations() {
2383 let root = PathBuf::from("/project");
2384 let mut results = AnalysisResults::default();
2385 results.duplicate_exports.push(DuplicateExport {
2386 export_name: "Button".to_string(),
2387 locations: vec![
2388 DuplicateLocation {
2389 path: root.join("src/ui.ts"),
2390 line: 10,
2391 col: 0,
2392 },
2393 DuplicateLocation {
2394 path: root.join("src/components.ts"),
2395 line: 25,
2396 col: 0,
2397 },
2398 ],
2399 });
2400 let elapsed = Duration::from_millis(0);
2401 let output = build_json(&results, &root, elapsed).expect("should serialize");
2402
2403 let dup = &output["duplicate_exports"][0];
2404 assert_eq!(dup["export_name"], "Button");
2405 let locs = dup["locations"].as_array().unwrap();
2406 assert_eq!(locs.len(), 2);
2407 assert_eq!(locs[0]["line"], 10);
2408 assert_eq!(locs[1]["line"], 25);
2409 }
2410
2411 #[test]
2412 fn json_type_only_dependency_contains_expected_fields() {
2413 let root = PathBuf::from("/project");
2414 let mut results = AnalysisResults::default();
2415 results.type_only_dependencies.push(TypeOnlyDependency {
2416 package_name: "zod".to_string(),
2417 path: root.join("package.json"),
2418 line: 8,
2419 });
2420 let elapsed = Duration::from_millis(0);
2421 let output = build_json(&results, &root, elapsed).expect("should serialize");
2422
2423 let dep = &output["type_only_dependencies"][0];
2424 assert_eq!(dep["package_name"], "zod");
2425 assert_eq!(dep["line"], 8);
2426 }
2427
2428 #[test]
2429 fn json_circular_dependency_contains_expected_fields() {
2430 let root = PathBuf::from("/project");
2431 let mut results = AnalysisResults::default();
2432 results.circular_dependencies.push(CircularDependency {
2433 files: vec![
2434 root.join("src/a.ts"),
2435 root.join("src/b.ts"),
2436 root.join("src/c.ts"),
2437 ],
2438 length: 3,
2439 line: 5,
2440 col: 0,
2441 is_cross_package: false,
2442 });
2443 let elapsed = Duration::from_millis(0);
2444 let output = build_json(&results, &root, elapsed).expect("should serialize");
2445
2446 let cycle = &output["circular_dependencies"][0];
2447 assert_eq!(cycle["length"], 3);
2448 assert_eq!(cycle["line"], 5);
2449 let files = cycle["files"].as_array().unwrap();
2450 assert_eq!(files.len(), 3);
2451 }
2452
2453 #[test]
2456 fn json_re_export_flagged_correctly() {
2457 let root = PathBuf::from("/project");
2458 let mut results = AnalysisResults::default();
2459 results.unused_exports.push(UnusedExport {
2460 path: root.join("src/index.ts"),
2461 export_name: "reExported".to_string(),
2462 is_type_only: false,
2463 line: 1,
2464 col: 0,
2465 span_start: 0,
2466 is_re_export: true,
2467 });
2468 let elapsed = Duration::from_millis(0);
2469 let output = build_json(&results, &root, elapsed).expect("should serialize");
2470
2471 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
2472 }
2473
2474 #[test]
2477 fn json_schema_version_is_pinned() {
2478 let root = PathBuf::from("/project");
2479 let results = AnalysisResults::default();
2480 let elapsed = Duration::from_millis(0);
2481 let output = build_json(&results, &root, elapsed).expect("should serialize");
2482
2483 assert_eq!(output["schema_version"], SCHEMA_VERSION);
2484 assert_eq!(output["schema_version"], 6);
2485 }
2486
2487 #[test]
2490 fn json_version_matches_cargo_pkg_version() {
2491 let root = PathBuf::from("/project");
2492 let results = AnalysisResults::default();
2493 let elapsed = Duration::from_millis(0);
2494 let output = build_json(&results, &root, elapsed).expect("should serialize");
2495
2496 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
2497 }
2498
2499 #[test]
2502 fn json_elapsed_ms_zero_duration() {
2503 let root = PathBuf::from("/project");
2504 let results = AnalysisResults::default();
2505 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
2506
2507 assert_eq!(output["elapsed_ms"], 0);
2508 }
2509
2510 #[test]
2511 fn json_elapsed_ms_large_duration() {
2512 let root = PathBuf::from("/project");
2513 let results = AnalysisResults::default();
2514 let elapsed = Duration::from_mins(2);
2515 let output = build_json(&results, &root, elapsed).expect("should serialize");
2516
2517 assert_eq!(output["elapsed_ms"], 120_000);
2518 }
2519
2520 #[test]
2521 fn json_elapsed_ms_sub_millisecond_truncated() {
2522 let root = PathBuf::from("/project");
2523 let results = AnalysisResults::default();
2524 let elapsed = Duration::from_micros(500);
2526 let output = build_json(&results, &root, elapsed).expect("should serialize");
2527
2528 assert_eq!(output["elapsed_ms"], 0);
2529 }
2530
2531 #[test]
2534 fn json_multiple_unused_files() {
2535 let root = PathBuf::from("/project");
2536 let mut results = AnalysisResults::default();
2537 results.unused_files.push(UnusedFile {
2538 path: root.join("src/a.ts"),
2539 });
2540 results.unused_files.push(UnusedFile {
2541 path: root.join("src/b.ts"),
2542 });
2543 results.unused_files.push(UnusedFile {
2544 path: root.join("src/c.ts"),
2545 });
2546 let elapsed = Duration::from_millis(0);
2547 let output = build_json(&results, &root, elapsed).expect("should serialize");
2548
2549 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
2550 assert_eq!(output["total_issues"], 3);
2551 }
2552
2553 #[test]
2556 fn strip_root_prefix_on_string_value() {
2557 let mut value = serde_json::json!("/project/src/file.ts");
2558 strip_root_prefix(&mut value, "/project/");
2559 assert_eq!(value, "src/file.ts");
2560 }
2561
2562 #[test]
2563 fn strip_root_prefix_leaves_non_matching_string() {
2564 let mut value = serde_json::json!("/other/src/file.ts");
2565 strip_root_prefix(&mut value, "/project/");
2566 assert_eq!(value, "/other/src/file.ts");
2567 }
2568
2569 #[test]
2570 fn strip_root_prefix_recurses_into_arrays() {
2571 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
2572 strip_root_prefix(&mut value, "/project/");
2573 assert_eq!(value[0], "a.ts");
2574 assert_eq!(value[1], "b.ts");
2575 assert_eq!(value[2], "/other/c.ts");
2576 }
2577
2578 #[test]
2579 fn strip_root_prefix_recurses_into_nested_objects() {
2580 let mut value = serde_json::json!({
2581 "outer": {
2582 "path": "/project/src/nested.ts"
2583 }
2584 });
2585 strip_root_prefix(&mut value, "/project/");
2586 assert_eq!(value["outer"]["path"], "src/nested.ts");
2587 }
2588
2589 #[test]
2590 fn strip_root_prefix_leaves_numbers_and_booleans() {
2591 let mut value = serde_json::json!({
2592 "line": 42,
2593 "is_type_only": false,
2594 "path": "/project/src/file.ts"
2595 });
2596 strip_root_prefix(&mut value, "/project/");
2597 assert_eq!(value["line"], 42);
2598 assert_eq!(value["is_type_only"], false);
2599 assert_eq!(value["path"], "src/file.ts");
2600 }
2601
2602 #[test]
2603 fn strip_root_prefix_normalizes_windows_separators() {
2604 let mut value = serde_json::json!(r"/project\src\file.ts");
2605 strip_root_prefix(&mut value, "/project/");
2606 assert_eq!(value, "src/file.ts");
2607 }
2608
2609 #[test]
2610 fn strip_root_prefix_handles_empty_string_after_strip() {
2611 let mut value = serde_json::json!("/project/");
2614 strip_root_prefix(&mut value, "/project/");
2615 assert_eq!(value, "");
2616 }
2617
2618 #[test]
2619 fn strip_root_prefix_deeply_nested_array_of_objects() {
2620 let mut value = serde_json::json!({
2621 "groups": [{
2622 "instances": [{
2623 "file": "/project/src/a.ts"
2624 }, {
2625 "file": "/project/src/b.ts"
2626 }]
2627 }]
2628 });
2629 strip_root_prefix(&mut value, "/project/");
2630 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
2631 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
2632 }
2633
2634 #[test]
2637 fn json_full_sample_results_total_issues_correct() {
2638 let root = PathBuf::from("/project");
2639 let results = sample_results(&root);
2640 let elapsed = Duration::from_millis(100);
2641 let output = build_json(&results, &root, elapsed).expect("should serialize");
2642
2643 assert_eq!(output["total_issues"], results.total_issues());
2649 }
2650
2651 #[test]
2652 fn json_full_sample_no_absolute_paths_in_output() {
2653 let root = PathBuf::from("/project");
2654 let results = sample_results(&root);
2655 let elapsed = Duration::from_millis(0);
2656 let output = build_json(&results, &root, elapsed).expect("should serialize");
2657
2658 let json_str = serde_json::to_string(&output).expect("should stringify");
2659 assert!(!json_str.contains("/project/src/"));
2661 assert!(!json_str.contains("/project/package.json"));
2662 }
2663
2664 #[test]
2667 fn json_output_is_deterministic() {
2668 let root = PathBuf::from("/project");
2669 let results = sample_results(&root);
2670 let elapsed = Duration::from_millis(50);
2671
2672 let output1 = build_json(&results, &root, elapsed).expect("first build");
2673 let output2 = build_json(&results, &root, elapsed).expect("second build");
2674
2675 assert_eq!(output1, output2);
2676 }
2677
2678 #[test]
2681 fn json_results_fields_do_not_shadow_metadata() {
2682 let root = PathBuf::from("/project");
2685 let results = AnalysisResults::default();
2686 let elapsed = Duration::from_millis(99);
2687 let output = build_json(&results, &root, elapsed).expect("should serialize");
2688
2689 assert_eq!(output["schema_version"], 6);
2691 assert_eq!(output["elapsed_ms"], 99);
2692 }
2693
2694 #[test]
2697 fn json_all_issue_type_arrays_present_in_empty_results() {
2698 let root = PathBuf::from("/project");
2699 let results = AnalysisResults::default();
2700 let elapsed = Duration::from_millis(0);
2701 let output = build_json(&results, &root, elapsed).expect("should serialize");
2702
2703 let expected_arrays = [
2704 "unused_files",
2705 "unused_exports",
2706 "unused_types",
2707 "unused_dependencies",
2708 "unused_dev_dependencies",
2709 "unused_optional_dependencies",
2710 "unused_enum_members",
2711 "unused_class_members",
2712 "unresolved_imports",
2713 "unlisted_dependencies",
2714 "duplicate_exports",
2715 "type_only_dependencies",
2716 "test_only_dependencies",
2717 "circular_dependencies",
2718 ];
2719 for key in &expected_arrays {
2720 assert!(
2721 output[key].is_array(),
2722 "expected '{key}' to be an array in JSON output"
2723 );
2724 }
2725 }
2726
2727 #[test]
2730 fn insert_meta_adds_key_to_object() {
2731 let mut output = serde_json::json!({ "foo": 1 });
2732 let meta = serde_json::json!({ "docs": "https://example.com" });
2733 insert_meta(&mut output, meta.clone());
2734 assert_eq!(output["_meta"], meta);
2735 }
2736
2737 #[test]
2738 fn insert_meta_noop_on_non_object() {
2739 let mut output = serde_json::json!([1, 2, 3]);
2740 let meta = serde_json::json!({ "docs": "https://example.com" });
2741 insert_meta(&mut output, meta);
2742 assert!(output.is_array());
2744 }
2745
2746 #[test]
2747 fn insert_meta_overwrites_existing_meta() {
2748 let mut output = serde_json::json!({ "_meta": "old" });
2749 let meta = serde_json::json!({ "new": true });
2750 insert_meta(&mut output, meta.clone());
2751 assert_eq!(output["_meta"], meta);
2752 }
2753
2754 #[test]
2757 fn build_json_envelope_has_metadata_fields() {
2758 let report = serde_json::json!({ "findings": [] });
2759 let elapsed = Duration::from_millis(42);
2760 let output = build_json_envelope(report, elapsed);
2761
2762 assert_eq!(output["schema_version"], 6);
2763 assert!(output["version"].is_string());
2764 assert_eq!(output["elapsed_ms"], 42);
2765 assert!(output["findings"].is_array());
2766 }
2767
2768 #[test]
2769 fn build_json_envelope_metadata_appears_first() {
2770 let report = serde_json::json!({ "data": "value" });
2771 let output = build_json_envelope(report, Duration::from_millis(10));
2772
2773 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
2774 assert_eq!(keys[0], "schema_version");
2775 assert_eq!(keys[1], "version");
2776 assert_eq!(keys[2], "elapsed_ms");
2777 }
2778
2779 #[test]
2780 fn build_json_envelope_non_object_report() {
2781 let report = serde_json::json!("not an object");
2783 let output = build_json_envelope(report, Duration::from_millis(0));
2784
2785 let obj = output.as_object().unwrap();
2786 assert_eq!(obj.len(), 3);
2787 assert!(obj.contains_key("schema_version"));
2788 assert!(obj.contains_key("version"));
2789 assert!(obj.contains_key("elapsed_ms"));
2790 }
2791
2792 #[test]
2795 fn strip_root_prefix_null_unchanged() {
2796 let mut value = serde_json::Value::Null;
2797 strip_root_prefix(&mut value, "/project/");
2798 assert!(value.is_null());
2799 }
2800
2801 #[test]
2804 fn strip_root_prefix_empty_string() {
2805 let mut value = serde_json::json!("");
2806 strip_root_prefix(&mut value, "/project/");
2807 assert_eq!(value, "");
2808 }
2809
2810 #[test]
2813 fn strip_root_prefix_mixed_types() {
2814 let mut value = serde_json::json!({
2815 "path": "/project/src/file.ts",
2816 "line": 42,
2817 "flag": true,
2818 "nested": {
2819 "items": ["/project/a.ts", 99, null, "/project/b.ts"],
2820 "deep": { "path": "/project/c.ts" }
2821 }
2822 });
2823 strip_root_prefix(&mut value, "/project/");
2824 assert_eq!(value["path"], "src/file.ts");
2825 assert_eq!(value["line"], 42);
2826 assert_eq!(value["flag"], true);
2827 assert_eq!(value["nested"]["items"][0], "a.ts");
2828 assert_eq!(value["nested"]["items"][1], 99);
2829 assert!(value["nested"]["items"][2].is_null());
2830 assert_eq!(value["nested"]["items"][3], "b.ts");
2831 assert_eq!(value["nested"]["deep"]["path"], "c.ts");
2832 }
2833
2834 #[test]
2837 fn json_check_meta_integrates_correctly() {
2838 let root = PathBuf::from("/project");
2839 let results = AnalysisResults::default();
2840 let elapsed = Duration::from_millis(0);
2841 let mut output = build_json(&results, &root, elapsed).expect("should serialize");
2842 insert_meta(&mut output, crate::explain::check_meta());
2843
2844 assert!(output["_meta"]["docs"].is_string());
2845 assert!(output["_meta"]["rules"].is_object());
2846 }
2847
2848 #[test]
2851 fn json_unused_member_kind_serialized() {
2852 let root = PathBuf::from("/project");
2853 let mut results = AnalysisResults::default();
2854 results.unused_enum_members.push(UnusedMember {
2855 path: root.join("src/enums.ts"),
2856 parent_name: "Color".to_string(),
2857 member_name: "Red".to_string(),
2858 kind: MemberKind::EnumMember,
2859 line: 3,
2860 col: 2,
2861 });
2862 results.unused_class_members.push(UnusedMember {
2863 path: root.join("src/class.ts"),
2864 parent_name: "Foo".to_string(),
2865 member_name: "bar".to_string(),
2866 kind: MemberKind::ClassMethod,
2867 line: 10,
2868 col: 4,
2869 });
2870
2871 let elapsed = Duration::from_millis(0);
2872 let output = build_json(&results, &root, elapsed).expect("should serialize");
2873
2874 let enum_member = &output["unused_enum_members"][0];
2875 assert!(enum_member["kind"].is_string());
2876 let class_member = &output["unused_class_members"][0];
2877 assert!(class_member["kind"].is_string());
2878 }
2879
2880 #[test]
2883 fn json_unused_export_has_actions() {
2884 let root = PathBuf::from("/project");
2885 let mut results = AnalysisResults::default();
2886 results.unused_exports.push(UnusedExport {
2887 path: root.join("src/utils.ts"),
2888 export_name: "helperFn".to_string(),
2889 is_type_only: false,
2890 line: 10,
2891 col: 4,
2892 span_start: 120,
2893 is_re_export: false,
2894 });
2895 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2896
2897 let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2898 assert_eq!(actions.len(), 2);
2899
2900 assert_eq!(actions[0]["type"], "remove-export");
2902 assert_eq!(actions[0]["auto_fixable"], true);
2903 assert!(actions[0].get("note").is_none());
2904
2905 assert_eq!(actions[1]["type"], "suppress-line");
2907 assert_eq!(
2908 actions[1]["comment"],
2909 "// fallow-ignore-next-line unused-export"
2910 );
2911 }
2912
2913 #[test]
2914 fn json_same_line_findings_share_multi_kind_suppression_comment() {
2915 let root = PathBuf::from("/project");
2916 let mut results = AnalysisResults::default();
2917 results.unused_exports.push(UnusedExport {
2918 path: root.join("src/api.ts"),
2919 export_name: "helperFn".to_string(),
2920 is_type_only: false,
2921 line: 10,
2922 col: 4,
2923 span_start: 120,
2924 is_re_export: false,
2925 });
2926 results.unused_types.push(UnusedExport {
2927 path: root.join("src/api.ts"),
2928 export_name: "OldType".to_string(),
2929 is_type_only: true,
2930 line: 10,
2931 col: 0,
2932 span_start: 60,
2933 is_re_export: false,
2934 });
2935 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2936
2937 let export_actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2938 let type_actions = output["unused_types"][0]["actions"].as_array().unwrap();
2939 assert_eq!(
2940 export_actions[1]["comment"],
2941 "// fallow-ignore-next-line unused-export, unused-type"
2942 );
2943 assert_eq!(
2944 type_actions[1]["comment"],
2945 "// fallow-ignore-next-line unused-export, unused-type"
2946 );
2947 }
2948
2949 #[test]
2950 fn audit_like_json_shares_suppression_comment_across_dead_code_and_complexity() {
2951 let mut output = serde_json::json!({
2952 "dead_code": {
2953 "unused_exports": [{
2954 "path": "src/main.ts",
2955 "line": 1,
2956 "actions": [
2957 { "type": "remove-export", "auto_fixable": true },
2958 {
2959 "type": "suppress-line",
2960 "auto_fixable": false,
2961 "comment": "// fallow-ignore-next-line unused-export"
2962 }
2963 ]
2964 }]
2965 },
2966 "complexity": {
2967 "findings": [{
2968 "path": "src/main.ts",
2969 "line": 1,
2970 "actions": [
2971 { "type": "refactor-function", "auto_fixable": false },
2972 {
2973 "type": "suppress-line",
2974 "auto_fixable": false,
2975 "comment": "// fallow-ignore-next-line complexity"
2976 }
2977 ]
2978 }]
2979 }
2980 });
2981
2982 harmonize_multi_kind_suppress_line_actions(&mut output);
2983
2984 assert_eq!(
2985 output["dead_code"]["unused_exports"][0]["actions"][1]["comment"],
2986 "// fallow-ignore-next-line unused-export, complexity"
2987 );
2988 assert_eq!(
2989 output["complexity"]["findings"][0]["actions"][1]["comment"],
2990 "// fallow-ignore-next-line unused-export, complexity"
2991 );
2992 }
2993
2994 #[test]
2995 fn json_unused_file_has_file_suppress_and_note() {
2996 let root = PathBuf::from("/project");
2997 let mut results = AnalysisResults::default();
2998 results.unused_files.push(UnusedFile {
2999 path: root.join("src/dead.ts"),
3000 });
3001 let output = build_json(&results, &root, Duration::ZERO).unwrap();
3002
3003 let actions = output["unused_files"][0]["actions"].as_array().unwrap();
3004 assert_eq!(actions[0]["type"], "delete-file");
3005 assert_eq!(actions[0]["auto_fixable"], false);
3006 assert!(actions[0]["note"].is_string());
3007 assert_eq!(actions[1]["type"], "suppress-file");
3008 assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
3009 }
3010
3011 #[test]
3012 fn json_unused_dependency_has_config_suppress_with_package_name() {
3013 let root = PathBuf::from("/project");
3014 let mut results = AnalysisResults::default();
3015 results.unused_dependencies.push(UnusedDependency {
3016 package_name: "lodash".to_string(),
3017 location: DependencyLocation::Dependencies,
3018 path: root.join("package.json"),
3019 line: 5,
3020 used_in_workspaces: Vec::new(),
3021 });
3022 let output = build_json(&results, &root, Duration::ZERO).unwrap();
3023
3024 let actions = output["unused_dependencies"][0]["actions"]
3025 .as_array()
3026 .unwrap();
3027 assert_eq!(actions[0]["type"], "remove-dependency");
3028 assert_eq!(actions[0]["auto_fixable"], true);
3029
3030 assert_eq!(actions[1]["type"], "add-to-config");
3032 assert_eq!(actions[1]["config_key"], "ignoreDependencies");
3033 assert_eq!(actions[1]["value"], "lodash");
3034 }
3035
3036 #[test]
3037 fn json_cross_workspace_dependency_is_not_auto_fixable() {
3038 let root = PathBuf::from("/project");
3039 let mut results = AnalysisResults::default();
3040 results.unused_dependencies.push(UnusedDependency {
3041 package_name: "lodash-es".to_string(),
3042 location: DependencyLocation::Dependencies,
3043 path: root.join("packages/shared/package.json"),
3044 line: 5,
3045 used_in_workspaces: vec![root.join("packages/consumer")],
3046 });
3047 let output = build_json(&results, &root, Duration::ZERO).unwrap();
3048
3049 let actions = output["unused_dependencies"][0]["actions"]
3050 .as_array()
3051 .unwrap();
3052 assert_eq!(actions[0]["type"], "move-dependency");
3053 assert_eq!(actions[0]["auto_fixable"], false);
3054 assert!(
3055 actions[0]["note"]
3056 .as_str()
3057 .unwrap()
3058 .contains("will not remove")
3059 );
3060 assert_eq!(actions[1]["type"], "add-to-config");
3061 }
3062
3063 #[test]
3064 fn json_empty_results_have_no_actions_in_empty_arrays() {
3065 let root = PathBuf::from("/project");
3066 let results = AnalysisResults::default();
3067 let output = build_json(&results, &root, Duration::ZERO).unwrap();
3068
3069 assert!(output["unused_exports"].as_array().unwrap().is_empty());
3071 assert!(output["unused_files"].as_array().unwrap().is_empty());
3072 }
3073
3074 #[test]
3075 fn json_all_issue_types_have_actions() {
3076 let root = PathBuf::from("/project");
3077 let results = sample_results(&root);
3078 let output = build_json(&results, &root, Duration::ZERO).unwrap();
3079
3080 let issue_keys = [
3081 "unused_files",
3082 "unused_exports",
3083 "unused_types",
3084 "unused_dependencies",
3085 "unused_dev_dependencies",
3086 "unused_optional_dependencies",
3087 "unused_enum_members",
3088 "unused_class_members",
3089 "unresolved_imports",
3090 "unlisted_dependencies",
3091 "duplicate_exports",
3092 "type_only_dependencies",
3093 "test_only_dependencies",
3094 "circular_dependencies",
3095 ];
3096
3097 for key in &issue_keys {
3098 let arr = output[key].as_array().unwrap();
3099 if !arr.is_empty() {
3100 let actions = arr[0]["actions"].as_array();
3101 assert!(
3102 actions.is_some() && !actions.unwrap().is_empty(),
3103 "missing actions for {key}"
3104 );
3105 }
3106 }
3107 }
3108
3109 #[test]
3112 fn health_finding_has_actions() {
3113 let mut output = serde_json::json!({
3114 "findings": [{
3115 "path": "src/utils.ts",
3116 "name": "processData",
3117 "line": 10,
3118 "col": 0,
3119 "cyclomatic": 25,
3120 "cognitive": 30,
3121 "line_count": 150,
3122 "exceeded": "both"
3123 }]
3124 });
3125
3126 inject_health_actions(&mut output, HealthActionOptions::default());
3127
3128 let actions = output["findings"][0]["actions"].as_array().unwrap();
3129 assert_eq!(actions.len(), 2);
3130 assert_eq!(actions[0]["type"], "refactor-function");
3131 assert_eq!(actions[0]["auto_fixable"], false);
3132 assert!(
3133 actions[0]["description"]
3134 .as_str()
3135 .unwrap()
3136 .contains("processData")
3137 );
3138 assert_eq!(actions[1]["type"], "suppress-line");
3139 assert_eq!(
3140 actions[1]["comment"],
3141 "// fallow-ignore-next-line complexity"
3142 );
3143 }
3144
3145 #[test]
3146 fn refactoring_target_has_actions() {
3147 let mut output = serde_json::json!({
3148 "targets": [{
3149 "path": "src/big-module.ts",
3150 "priority": 85.0,
3151 "efficiency": 42.5,
3152 "recommendation": "Split module: 12 exports, 4 unused",
3153 "category": "split_high_impact",
3154 "effort": "medium",
3155 "confidence": "high",
3156 "evidence": { "unused_exports": 4 }
3157 }]
3158 });
3159
3160 inject_health_actions(&mut output, HealthActionOptions::default());
3161
3162 let actions = output["targets"][0]["actions"].as_array().unwrap();
3163 assert_eq!(actions.len(), 2);
3164 assert_eq!(actions[0]["type"], "apply-refactoring");
3165 assert_eq!(
3166 actions[0]["description"],
3167 "Split module: 12 exports, 4 unused"
3168 );
3169 assert_eq!(actions[0]["category"], "split_high_impact");
3170 assert_eq!(actions[1]["type"], "suppress-line");
3172 }
3173
3174 #[test]
3175 fn refactoring_target_without_evidence_has_no_suppress() {
3176 let mut output = serde_json::json!({
3177 "targets": [{
3178 "path": "src/simple.ts",
3179 "priority": 30.0,
3180 "efficiency": 15.0,
3181 "recommendation": "Consider extracting helper functions",
3182 "category": "extract_complex_functions",
3183 "effort": "small",
3184 "confidence": "medium"
3185 }]
3186 });
3187
3188 inject_health_actions(&mut output, HealthActionOptions::default());
3189
3190 let actions = output["targets"][0]["actions"].as_array().unwrap();
3191 assert_eq!(actions.len(), 1);
3192 assert_eq!(actions[0]["type"], "apply-refactoring");
3193 }
3194
3195 #[test]
3196 fn health_empty_findings_no_actions() {
3197 let mut output = serde_json::json!({
3198 "findings": [],
3199 "targets": []
3200 });
3201
3202 inject_health_actions(&mut output, HealthActionOptions::default());
3203
3204 assert!(output["findings"].as_array().unwrap().is_empty());
3205 assert!(output["targets"].as_array().unwrap().is_empty());
3206 }
3207
3208 #[test]
3209 fn hotspot_has_actions() {
3210 let mut output = serde_json::json!({
3211 "hotspots": [{
3212 "path": "src/utils.ts",
3213 "complexity_score": 45.0,
3214 "churn_score": 12,
3215 "hotspot_score": 540.0
3216 }]
3217 });
3218
3219 inject_health_actions(&mut output, HealthActionOptions::default());
3220
3221 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
3222 assert_eq!(actions.len(), 2);
3223 assert_eq!(actions[0]["type"], "refactor-file");
3224 assert!(
3225 actions[0]["description"]
3226 .as_str()
3227 .unwrap()
3228 .contains("src/utils.ts")
3229 );
3230 assert_eq!(actions[1]["type"], "add-tests");
3231 }
3232
3233 #[test]
3234 fn hotspot_low_bus_factor_emits_action() {
3235 let mut output = serde_json::json!({
3236 "hotspots": [{
3237 "path": "src/api.ts",
3238 "ownership": {
3239 "bus_factor": 1,
3240 "contributor_count": 1,
3241 "top_contributor": {"identifier": "alice@x", "share": 1.0, "stale_days": 5, "commits": 30},
3242 "unowned": null,
3243 "drift": false,
3244 }
3245 }]
3246 });
3247
3248 inject_health_actions(&mut output, HealthActionOptions::default());
3249
3250 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
3251 assert!(
3252 actions
3253 .iter()
3254 .filter_map(|a| a["type"].as_str())
3255 .any(|t| t == "low-bus-factor"),
3256 "low-bus-factor action should be present",
3257 );
3258 let bus = actions
3259 .iter()
3260 .find(|a| a["type"] == "low-bus-factor")
3261 .unwrap();
3262 assert!(bus["description"].as_str().unwrap().contains("alice@x"));
3263 }
3264
3265 #[test]
3266 fn hotspot_unowned_emits_action_with_pattern() {
3267 let mut output = serde_json::json!({
3268 "hotspots": [{
3269 "path": "src/api/users.ts",
3270 "ownership": {
3271 "bus_factor": 2,
3272 "contributor_count": 4,
3273 "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
3274 "unowned": true,
3275 "drift": false,
3276 }
3277 }]
3278 });
3279
3280 inject_health_actions(&mut output, HealthActionOptions::default());
3281
3282 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
3283 let unowned = actions
3284 .iter()
3285 .find(|a| a["type"] == "unowned-hotspot")
3286 .expect("unowned-hotspot action should be present");
3287 assert_eq!(unowned["suggested_pattern"], "/src/api/");
3290 assert_eq!(unowned["heuristic"], "directory-deepest");
3291 }
3292
3293 #[test]
3294 fn hotspot_unowned_skipped_when_codeowners_missing() {
3295 let mut output = serde_json::json!({
3296 "hotspots": [{
3297 "path": "src/api.ts",
3298 "ownership": {
3299 "bus_factor": 2,
3300 "contributor_count": 4,
3301 "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
3302 "unowned": null,
3303 "drift": false,
3304 }
3305 }]
3306 });
3307
3308 inject_health_actions(&mut output, HealthActionOptions::default());
3309
3310 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
3311 assert!(
3312 !actions.iter().any(|a| a["type"] == "unowned-hotspot"),
3313 "unowned action must not fire when CODEOWNERS file is absent"
3314 );
3315 }
3316
3317 #[test]
3318 fn hotspot_drift_emits_action() {
3319 let mut output = serde_json::json!({
3320 "hotspots": [{
3321 "path": "src/old.ts",
3322 "ownership": {
3323 "bus_factor": 1,
3324 "contributor_count": 2,
3325 "top_contributor": {"identifier": "bob@x", "share": 0.9, "stale_days": 1, "commits": 18},
3326 "unowned": null,
3327 "drift": true,
3328 "drift_reason": "original author alice@x has 5% share",
3329 }
3330 }]
3331 });
3332
3333 inject_health_actions(&mut output, HealthActionOptions::default());
3334
3335 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
3336 let drift = actions
3337 .iter()
3338 .find(|a| a["type"] == "ownership-drift")
3339 .expect("ownership-drift action should be present");
3340 assert!(drift["description"].as_str().unwrap().contains("alice@x"));
3341 }
3342
3343 #[test]
3346 fn codeowners_pattern_uses_deepest_directory() {
3347 assert_eq!(
3350 suggest_codeowners_pattern("src/api/users/handlers.ts"),
3351 "/src/api/users/"
3352 );
3353 }
3354
3355 #[test]
3356 fn codeowners_pattern_for_root_file() {
3357 assert_eq!(suggest_codeowners_pattern("README.md"), "/README.md");
3358 }
3359
3360 #[test]
3361 fn codeowners_pattern_normalizes_backslashes() {
3362 assert_eq!(
3363 suggest_codeowners_pattern("src\\api\\users.ts"),
3364 "/src/api/"
3365 );
3366 }
3367
3368 #[test]
3369 fn codeowners_pattern_two_level_path() {
3370 assert_eq!(suggest_codeowners_pattern("src/foo.ts"), "/src/");
3371 }
3372
3373 #[test]
3374 fn health_finding_suppress_has_placement() {
3375 let mut output = serde_json::json!({
3376 "findings": [{
3377 "path": "src/utils.ts",
3378 "name": "processData",
3379 "line": 10,
3380 "col": 0,
3381 "cyclomatic": 25,
3382 "cognitive": 30,
3383 "line_count": 150,
3384 "exceeded": "both"
3385 }]
3386 });
3387
3388 inject_health_actions(&mut output, HealthActionOptions::default());
3389
3390 let suppress = &output["findings"][0]["actions"][1];
3391 assert_eq!(suppress["placement"], "above-function-declaration");
3392 }
3393
3394 #[test]
3395 fn html_template_health_finding_uses_html_suppression() {
3396 let mut output = serde_json::json!({
3397 "findings": [{
3398 "path": "src/app.component.html",
3399 "name": "<template>",
3400 "line": 1,
3401 "col": 0,
3402 "cyclomatic": 25,
3403 "cognitive": 30,
3404 "line_count": 40,
3405 "exceeded": "both"
3406 }]
3407 });
3408
3409 inject_health_actions(&mut output, HealthActionOptions::default());
3410
3411 let suppress = &output["findings"][0]["actions"][1];
3412 assert_eq!(suppress["type"], "suppress-file");
3413 assert_eq!(
3414 suppress["comment"],
3415 "<!-- fallow-ignore-file complexity -->"
3416 );
3417 assert_eq!(suppress["placement"], "top-of-template");
3418 }
3419
3420 #[test]
3421 fn inline_template_health_finding_uses_decorator_suppression() {
3422 let mut output = serde_json::json!({
3423 "findings": [{
3424 "path": "src/app.component.ts",
3425 "name": "<template>",
3426 "line": 5,
3427 "col": 0,
3428 "cyclomatic": 25,
3429 "cognitive": 30,
3430 "line_count": 40,
3431 "exceeded": "both"
3432 }]
3433 });
3434
3435 inject_health_actions(&mut output, HealthActionOptions::default());
3436
3437 let refactor = &output["findings"][0]["actions"][0];
3438 assert_eq!(refactor["type"], "refactor-function");
3439 assert!(
3440 refactor["description"]
3441 .as_str()
3442 .unwrap()
3443 .contains("template complexity")
3444 );
3445 let suppress = &output["findings"][0]["actions"][1];
3446 assert_eq!(suppress["type"], "suppress-line");
3447 assert_eq!(
3448 suppress["description"],
3449 "Suppress with an inline comment above the Angular decorator"
3450 );
3451 assert_eq!(suppress["placement"], "above-angular-decorator");
3452 }
3453
3454 #[test]
3457 fn clone_family_has_actions() {
3458 let mut output = serde_json::json!({
3459 "clone_families": [{
3460 "files": ["src/a.ts", "src/b.ts"],
3461 "groups": [
3462 { "instances": [{"file": "src/a.ts"}, {"file": "src/b.ts"}], "token_count": 100, "line_count": 20 }
3463 ],
3464 "total_duplicated_lines": 20,
3465 "total_duplicated_tokens": 100,
3466 "suggestions": [
3467 { "kind": "ExtractFunction", "description": "Extract shared validation logic", "estimated_savings": 15 }
3468 ]
3469 }]
3470 });
3471
3472 inject_dupes_actions(&mut output);
3473
3474 let actions = output["clone_families"][0]["actions"].as_array().unwrap();
3475 assert_eq!(actions.len(), 3);
3476 assert_eq!(actions[0]["type"], "extract-shared");
3477 assert_eq!(actions[0]["auto_fixable"], false);
3478 assert!(
3479 actions[0]["description"]
3480 .as_str()
3481 .unwrap()
3482 .contains("20 lines")
3483 );
3484 assert_eq!(actions[1]["type"], "apply-suggestion");
3486 assert!(
3487 actions[1]["description"]
3488 .as_str()
3489 .unwrap()
3490 .contains("validation logic")
3491 );
3492 assert_eq!(actions[2]["type"], "suppress-line");
3494 assert_eq!(
3495 actions[2]["comment"],
3496 "// fallow-ignore-next-line code-duplication"
3497 );
3498 }
3499
3500 #[test]
3501 fn clone_group_has_actions() {
3502 let mut output = serde_json::json!({
3503 "clone_groups": [{
3504 "instances": [
3505 {"file": "src/a.ts", "start_line": 1, "end_line": 10},
3506 {"file": "src/b.ts", "start_line": 5, "end_line": 14}
3507 ],
3508 "token_count": 50,
3509 "line_count": 10
3510 }]
3511 });
3512
3513 inject_dupes_actions(&mut output);
3514
3515 let actions = output["clone_groups"][0]["actions"].as_array().unwrap();
3516 assert_eq!(actions.len(), 2);
3517 assert_eq!(actions[0]["type"], "extract-shared");
3518 assert!(
3519 actions[0]["description"]
3520 .as_str()
3521 .unwrap()
3522 .contains("10 lines")
3523 );
3524 assert!(
3525 actions[0]["description"]
3526 .as_str()
3527 .unwrap()
3528 .contains("2 instances")
3529 );
3530 assert_eq!(actions[1]["type"], "suppress-line");
3531 }
3532
3533 #[test]
3534 fn dupes_empty_results_no_actions() {
3535 let mut output = serde_json::json!({
3536 "clone_families": [],
3537 "clone_groups": []
3538 });
3539
3540 inject_dupes_actions(&mut output);
3541
3542 assert!(output["clone_families"].as_array().unwrap().is_empty());
3543 assert!(output["clone_groups"].as_array().unwrap().is_empty());
3544 }
3545
3546 fn crap_only_finding_envelope(
3555 coverage_tier: Option<&str>,
3556 cyclomatic: u16,
3557 max_cyclomatic_threshold: u16,
3558 ) -> serde_json::Value {
3559 crap_only_finding_envelope_with_max_crap(
3560 coverage_tier,
3561 cyclomatic,
3562 12,
3563 max_cyclomatic_threshold,
3564 15,
3565 30.0,
3566 )
3567 }
3568
3569 fn crap_only_finding_envelope_with_cognitive(
3570 coverage_tier: Option<&str>,
3571 cyclomatic: u16,
3572 cognitive: u16,
3573 max_cyclomatic_threshold: u16,
3574 ) -> serde_json::Value {
3575 crap_only_finding_envelope_with_max_crap(
3576 coverage_tier,
3577 cyclomatic,
3578 cognitive,
3579 max_cyclomatic_threshold,
3580 15,
3581 30.0,
3582 )
3583 }
3584
3585 fn crap_only_finding_envelope_with_max_crap(
3586 coverage_tier: Option<&str>,
3587 cyclomatic: u16,
3588 cognitive: u16,
3589 max_cyclomatic_threshold: u16,
3590 max_cognitive_threshold: u16,
3591 max_crap_threshold: f64,
3592 ) -> serde_json::Value {
3593 let mut finding = serde_json::json!({
3594 "path": "src/risk.ts",
3595 "name": "computeScore",
3596 "line": 12,
3597 "col": 0,
3598 "cyclomatic": cyclomatic,
3599 "cognitive": cognitive,
3600 "line_count": 40,
3601 "exceeded": "crap",
3602 "crap": 35.5,
3603 });
3604 if let Some(tier) = coverage_tier {
3605 finding["coverage_tier"] = serde_json::Value::String(tier.to_owned());
3606 }
3607 serde_json::json!({
3608 "findings": [finding],
3609 "summary": {
3610 "max_cyclomatic_threshold": max_cyclomatic_threshold,
3611 "max_cognitive_threshold": max_cognitive_threshold,
3612 "max_crap_threshold": max_crap_threshold,
3613 },
3614 })
3615 }
3616
3617 #[test]
3618 fn crap_only_tier_none_emits_add_tests() {
3619 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3620 inject_health_actions(&mut output, HealthActionOptions::default());
3621 let actions = output["findings"][0]["actions"].as_array().unwrap();
3622 assert!(
3623 actions.iter().any(|a| a["type"] == "add-tests"),
3624 "tier=none crap-only must emit add-tests, got {actions:?}"
3625 );
3626 assert!(
3627 !actions.iter().any(|a| a["type"] == "increase-coverage"),
3628 "tier=none must not emit increase-coverage"
3629 );
3630 }
3631
3632 #[test]
3633 fn crap_only_tier_partial_emits_increase_coverage() {
3634 let mut output = crap_only_finding_envelope(Some("partial"), 6, 20);
3635 inject_health_actions(&mut output, HealthActionOptions::default());
3636 let actions = output["findings"][0]["actions"].as_array().unwrap();
3637 assert!(
3638 actions.iter().any(|a| a["type"] == "increase-coverage"),
3639 "tier=partial crap-only must emit increase-coverage, got {actions:?}"
3640 );
3641 assert!(
3642 !actions.iter().any(|a| a["type"] == "add-tests"),
3643 "tier=partial must not emit add-tests"
3644 );
3645 }
3646
3647 #[test]
3648 fn crap_only_tier_high_emits_increase_coverage_when_full_coverage_can_clear_crap() {
3649 let mut output = crap_only_finding_envelope(Some("high"), 20, 30);
3653 inject_health_actions(&mut output, HealthActionOptions::default());
3654 let actions = output["findings"][0]["actions"].as_array().unwrap();
3655 assert!(
3656 actions.iter().any(|a| a["type"] == "increase-coverage"),
3657 "tier=high crap-only must still emit increase-coverage when full coverage can clear CRAP, got {actions:?}"
3658 );
3659 assert!(
3660 !actions.iter().any(|a| a["type"] == "refactor-function"),
3661 "coverage-remediable crap-only findings should not get refactor-function unless near the cyclomatic threshold"
3662 );
3663 assert!(
3664 !actions.iter().any(|a| a["type"] == "add-tests"),
3665 "tier=high must not emit add-tests"
3666 );
3667 }
3668
3669 #[test]
3670 fn crap_only_emits_refactor_when_full_coverage_cannot_clear_crap() {
3671 let mut output =
3675 crap_only_finding_envelope_with_max_crap(Some("high"), 35, 12, 50, 15, 30.0);
3676 inject_health_actions(&mut output, HealthActionOptions::default());
3677 let actions = output["findings"][0]["actions"].as_array().unwrap();
3678 assert!(
3679 actions.iter().any(|a| a["type"] == "refactor-function"),
3680 "full-coverage-impossible CRAP-only finding must emit refactor-function, got {actions:?}"
3681 );
3682 assert!(
3683 !actions.iter().any(|a| a["type"] == "increase-coverage"),
3684 "must not emit increase-coverage when even 100% coverage cannot clear CRAP"
3685 );
3686 assert!(
3687 !actions.iter().any(|a| a["type"] == "add-tests"),
3688 "must not emit add-tests when even 100% coverage cannot clear CRAP"
3689 );
3690 }
3691
3692 #[test]
3693 fn crap_only_high_cc_appends_secondary_refactor() {
3694 let mut output = crap_only_finding_envelope(Some("none"), 16, 20);
3697 inject_health_actions(&mut output, HealthActionOptions::default());
3698 let actions = output["findings"][0]["actions"].as_array().unwrap();
3699 assert!(
3700 actions.iter().any(|a| a["type"] == "add-tests"),
3701 "near-threshold crap-only still emits the primary tier action"
3702 );
3703 assert!(
3704 actions.iter().any(|a| a["type"] == "refactor-function"),
3705 "near-threshold crap-only must also emit secondary refactor-function"
3706 );
3707 }
3708
3709 #[test]
3710 fn crap_only_far_below_threshold_no_secondary_refactor() {
3711 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3713 inject_health_actions(&mut output, HealthActionOptions::default());
3714 let actions = output["findings"][0]["actions"].as_array().unwrap();
3715 assert!(
3716 !actions.iter().any(|a| a["type"] == "refactor-function"),
3717 "low-CC crap-only should not get a secondary refactor-function"
3718 );
3719 }
3720
3721 #[test]
3722 fn crap_only_near_threshold_low_cognitive_no_secondary_refactor() {
3723 let mut output = crap_only_finding_envelope_with_cognitive(Some("none"), 17, 2, 20);
3732 inject_health_actions(&mut output, HealthActionOptions::default());
3733 let actions = output["findings"][0]["actions"].as_array().unwrap();
3734 assert!(
3735 actions.iter().any(|a| a["type"] == "add-tests"),
3736 "primary tier action still emits"
3737 );
3738 assert!(
3739 !actions.iter().any(|a| a["type"] == "refactor-function"),
3740 "near-threshold CC with cognitive below floor must NOT emit secondary refactor (got {actions:?})"
3741 );
3742 }
3743
3744 #[test]
3745 fn crap_only_near_threshold_high_cognitive_emits_secondary_refactor() {
3746 let mut output = crap_only_finding_envelope_with_cognitive(Some("none"), 16, 10, 20);
3752 inject_health_actions(&mut output, HealthActionOptions::default());
3753 let actions = output["findings"][0]["actions"].as_array().unwrap();
3754 assert!(
3755 actions.iter().any(|a| a["type"] == "add-tests"),
3756 "primary tier action still emits"
3757 );
3758 assert!(
3759 actions.iter().any(|a| a["type"] == "refactor-function"),
3760 "near-threshold CC with cognitive above floor must emit secondary refactor (got {actions:?})"
3761 );
3762 }
3763
3764 #[test]
3765 fn cyclomatic_only_emits_only_refactor_function() {
3766 let mut output = serde_json::json!({
3767 "findings": [{
3768 "path": "src/cyclo.ts",
3769 "name": "branchy",
3770 "line": 5,
3771 "col": 0,
3772 "cyclomatic": 25,
3773 "cognitive": 10,
3774 "line_count": 80,
3775 "exceeded": "cyclomatic",
3776 }],
3777 "summary": { "max_cyclomatic_threshold": 20 },
3778 });
3779 inject_health_actions(&mut output, HealthActionOptions::default());
3780 let actions = output["findings"][0]["actions"].as_array().unwrap();
3781 assert!(
3782 actions.iter().any(|a| a["type"] == "refactor-function"),
3783 "non-CRAP findings emit refactor-function"
3784 );
3785 assert!(
3786 !actions.iter().any(|a| a["type"] == "add-tests"),
3787 "non-CRAP findings must not emit add-tests"
3788 );
3789 assert!(
3790 !actions.iter().any(|a| a["type"] == "increase-coverage"),
3791 "non-CRAP findings must not emit increase-coverage"
3792 );
3793 }
3794
3795 #[test]
3798 fn suppress_line_omitted_when_baseline_active() {
3799 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3800 inject_health_actions(
3801 &mut output,
3802 HealthActionOptions {
3803 omit_suppress_line: true,
3804 omit_reason: Some("baseline-active"),
3805 },
3806 );
3807 let actions = output["findings"][0]["actions"].as_array().unwrap();
3808 assert!(
3809 !actions.iter().any(|a| a["type"] == "suppress-line"),
3810 "baseline-active must not emit suppress-line, got {actions:?}"
3811 );
3812 assert_eq!(
3813 output["actions_meta"]["suppression_hints_omitted"],
3814 serde_json::Value::Bool(true)
3815 );
3816 assert_eq!(output["actions_meta"]["reason"], "baseline-active");
3817 assert_eq!(output["actions_meta"]["scope"], "health-findings");
3818 }
3819
3820 #[test]
3821 fn suppress_line_omitted_when_config_disabled() {
3822 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3823 inject_health_actions(
3824 &mut output,
3825 HealthActionOptions {
3826 omit_suppress_line: true,
3827 omit_reason: Some("config-disabled"),
3828 },
3829 );
3830 assert_eq!(output["actions_meta"]["reason"], "config-disabled");
3831 }
3832
3833 #[test]
3834 fn suppress_line_emitted_by_default() {
3835 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3836 inject_health_actions(&mut output, HealthActionOptions::default());
3837 let actions = output["findings"][0]["actions"].as_array().unwrap();
3838 assert!(
3839 actions.iter().any(|a| a["type"] == "suppress-line"),
3840 "default opts must emit suppress-line"
3841 );
3842 assert!(
3843 output.get("actions_meta").is_none(),
3844 "actions_meta must be absent when no omission occurred"
3845 );
3846 }
3847
3848 #[test]
3855 fn every_emitted_health_action_type_is_in_schema_enum() {
3856 let cases = [
3860 ("crap", Some("none"), 6_u16, 20_u16),
3862 ("crap", Some("partial"), 6, 20),
3863 ("crap", Some("high"), 12, 20),
3864 ("crap", Some("none"), 16, 20), ("cyclomatic", None, 25, 20),
3866 ("cognitive_crap", Some("partial"), 6, 20),
3867 ("all", Some("none"), 25, 20),
3868 ];
3869
3870 let mut emitted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3871 for (exceeded, tier, cc, max) in cases {
3872 let mut finding = serde_json::json!({
3873 "path": "src/x.ts",
3874 "name": "fn",
3875 "line": 1,
3876 "col": 0,
3877 "cyclomatic": cc,
3878 "cognitive": 5,
3879 "line_count": 10,
3880 "exceeded": exceeded,
3881 "crap": 35.0,
3882 });
3883 if let Some(t) = tier {
3884 finding["coverage_tier"] = serde_json::Value::String(t.to_owned());
3885 }
3886 let mut output = serde_json::json!({
3887 "findings": [finding],
3888 "summary": { "max_cyclomatic_threshold": max },
3889 });
3890 inject_health_actions(&mut output, HealthActionOptions::default());
3891 for action in output["findings"][0]["actions"].as_array().unwrap() {
3892 if let Some(ty) = action["type"].as_str() {
3893 emitted.insert(ty.to_owned());
3894 }
3895 }
3896 }
3897
3898 let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3900 .join("..")
3901 .join("..")
3902 .join("docs")
3903 .join("output-schema.json");
3904 let raw = std::fs::read_to_string(&schema_path)
3905 .expect("docs/output-schema.json must be readable for the drift-guard test");
3906 let schema: serde_json::Value = serde_json::from_str(&raw).expect("schema parses");
3907 let enum_values: std::collections::BTreeSet<String> =
3908 schema["definitions"]["HealthFindingAction"]["properties"]["type"]["enum"]
3909 .as_array()
3910 .expect("HealthFindingAction.type.enum is an array")
3911 .iter()
3912 .filter_map(|v| v.as_str().map(str::to_owned))
3913 .collect();
3914
3915 for ty in &emitted {
3916 assert!(
3917 enum_values.contains(ty),
3918 "build_health_finding_actions emitted action type `{ty}` but \
3919 docs/output-schema.json HealthFindingAction.type enum does \
3920 not list it. Add it to the schema (and any downstream \
3921 typed consumers) when introducing a new action type."
3922 );
3923 }
3924 }
3925}