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