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