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