1use std::path::Path;
2use std::process::ExitCode;
3use std::time::Duration;
4
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::AnalysisResults;
7
8use super::{emit_json, normalize_uri};
9use crate::explain;
10use crate::report::grouping::{OwnershipResolver, ResultGroup};
11
12pub(super) fn print_json(
13 results: &AnalysisResults,
14 root: &Path,
15 elapsed: Duration,
16 explain: bool,
17 regression: Option<&crate::regression::RegressionOutcome>,
18 baseline_matched: Option<(usize, usize)>,
19) -> ExitCode {
20 match build_json(results, root, elapsed) {
21 Ok(mut output) => {
22 if let Some(outcome) = regression
23 && let serde_json::Value::Object(ref mut map) = output
24 {
25 map.insert("regression".to_string(), outcome.to_json());
26 }
27 if let Some((entries, matched)) = baseline_matched
28 && let serde_json::Value::Object(ref mut map) = output
29 {
30 map.insert(
31 "baseline".to_string(),
32 serde_json::json!({
33 "entries": entries,
34 "matched": matched,
35 }),
36 );
37 }
38 if explain {
39 insert_meta(&mut output, explain::check_meta());
40 }
41 emit_json(&output, "JSON")
42 }
43 Err(e) => {
44 eprintln!("Error: failed to serialize results: {e}");
45 ExitCode::from(2)
46 }
47 }
48}
49
50#[must_use]
56pub(super) fn print_grouped_json(
57 groups: &[ResultGroup],
58 original: &AnalysisResults,
59 root: &Path,
60 elapsed: Duration,
61 explain: bool,
62 resolver: &OwnershipResolver,
63) -> ExitCode {
64 let root_prefix = format!("{}/", root.display());
65
66 let group_values: Vec<serde_json::Value> = groups
67 .iter()
68 .filter_map(|group| {
69 let mut value = serde_json::to_value(&group.results).ok()?;
70 strip_root_prefix(&mut value, &root_prefix);
71 inject_actions(&mut value);
72
73 if let serde_json::Value::Object(ref mut map) = value {
74 let mut ordered = serde_json::Map::new();
77 ordered.insert("key".to_string(), serde_json::json!(group.key));
78 if let Some(ref owners) = group.owners {
79 ordered.insert("owners".to_string(), serde_json::json!(owners));
80 }
81 ordered.insert(
82 "total_issues".to_string(),
83 serde_json::json!(group.results.total_issues()),
84 );
85 for (k, v) in map.iter() {
86 ordered.insert(k.clone(), v.clone());
87 }
88 Some(serde_json::Value::Object(ordered))
89 } else {
90 Some(value)
91 }
92 })
93 .collect();
94
95 let mut output = serde_json::json!({
96 "schema_version": SCHEMA_VERSION,
97 "version": env!("CARGO_PKG_VERSION"),
98 "elapsed_ms": elapsed.as_millis() as u64,
99 "grouped_by": resolver.mode_label(),
100 "total_issues": original.total_issues(),
101 "groups": group_values,
102 });
103
104 if explain {
105 insert_meta(&mut output, explain::check_meta());
106 }
107
108 emit_json(&output, "JSON")
109}
110
111const SCHEMA_VERSION: u32 = 4;
117
118fn build_json_envelope(report_value: serde_json::Value, elapsed: Duration) -> serde_json::Value {
124 let mut map = serde_json::Map::new();
125 map.insert(
126 "schema_version".to_string(),
127 serde_json::json!(SCHEMA_VERSION),
128 );
129 map.insert(
130 "version".to_string(),
131 serde_json::json!(env!("CARGO_PKG_VERSION")),
132 );
133 map.insert(
134 "elapsed_ms".to_string(),
135 serde_json::json!(elapsed.as_millis()),
136 );
137 if let serde_json::Value::Object(report_map) = report_value {
138 for (key, value) in report_map {
139 map.insert(key, value);
140 }
141 }
142 serde_json::Value::Object(map)
143}
144
145pub fn build_json(
154 results: &AnalysisResults,
155 root: &Path,
156 elapsed: Duration,
157) -> Result<serde_json::Value, serde_json::Error> {
158 let results_value = serde_json::to_value(results)?;
159
160 let mut map = serde_json::Map::new();
161 map.insert(
162 "schema_version".to_string(),
163 serde_json::json!(SCHEMA_VERSION),
164 );
165 map.insert(
166 "version".to_string(),
167 serde_json::json!(env!("CARGO_PKG_VERSION")),
168 );
169 map.insert(
170 "elapsed_ms".to_string(),
171 serde_json::json!(elapsed.as_millis()),
172 );
173 map.insert(
174 "total_issues".to_string(),
175 serde_json::json!(results.total_issues()),
176 );
177
178 if let Some(ref ep) = results.entry_point_summary {
180 let sources: serde_json::Map<String, serde_json::Value> = ep
181 .by_source
182 .iter()
183 .map(|(k, v)| (k.replace(' ', "_"), serde_json::json!(v)))
184 .collect();
185 map.insert(
186 "entry_points".to_string(),
187 serde_json::json!({
188 "total": ep.total,
189 "sources": sources,
190 }),
191 );
192 }
193
194 let summary = serde_json::json!({
196 "total_issues": results.total_issues(),
197 "unused_files": results.unused_files.len(),
198 "unused_exports": results.unused_exports.len(),
199 "unused_types": results.unused_types.len(),
200 "unused_dependencies": results.unused_dependencies.len()
201 + results.unused_dev_dependencies.len()
202 + results.unused_optional_dependencies.len(),
203 "unused_enum_members": results.unused_enum_members.len(),
204 "unused_class_members": results.unused_class_members.len(),
205 "unresolved_imports": results.unresolved_imports.len(),
206 "unlisted_dependencies": results.unlisted_dependencies.len(),
207 "duplicate_exports": results.duplicate_exports.len(),
208 "type_only_dependencies": results.type_only_dependencies.len(),
209 "test_only_dependencies": results.test_only_dependencies.len(),
210 "circular_dependencies": results.circular_dependencies.len(),
211 "boundary_violations": results.boundary_violations.len(),
212 "stale_suppressions": results.stale_suppressions.len(),
213 });
214 map.insert("summary".to_string(), summary);
215
216 if let serde_json::Value::Object(results_map) = results_value {
217 for (key, value) in results_map {
218 map.insert(key, value);
219 }
220 }
221
222 let mut output = serde_json::Value::Object(map);
223 let root_prefix = format!("{}/", root.display());
224 strip_root_prefix(&mut output, &root_prefix);
228 inject_actions(&mut output);
229 Ok(output)
230}
231
232pub fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
237 match value {
238 serde_json::Value::String(s) => {
239 if let Some(rest) = s.strip_prefix(prefix) {
240 *s = rest.to_string();
241 } else {
242 let normalized = normalize_uri(s);
243 let normalized_prefix = normalize_uri(prefix);
244 if let Some(rest) = normalized.strip_prefix(&normalized_prefix) {
245 *s = rest.to_string();
246 }
247 }
248 }
249 serde_json::Value::Array(arr) => {
250 for item in arr {
251 strip_root_prefix(item, prefix);
252 }
253 }
254 serde_json::Value::Object(map) => {
255 for (_, v) in map.iter_mut() {
256 strip_root_prefix(v, prefix);
257 }
258 }
259 _ => {}
260 }
261}
262
263enum SuppressKind {
267 InlineComment,
269 FileComment,
271 ConfigIgnoreDep,
273}
274
275struct ActionSpec {
277 fix_type: &'static str,
278 auto_fixable: bool,
279 description: &'static str,
280 note: Option<&'static str>,
281 suppress: SuppressKind,
282 issue_kind: &'static str,
283}
284
285fn actions_for_issue_type(key: &str) -> Option<ActionSpec> {
287 match key {
288 "unused_files" => Some(ActionSpec {
289 fix_type: "delete-file",
290 auto_fixable: false,
291 description: "Delete this file",
292 note: Some(
293 "File deletion may remove runtime functionality not visible to static analysis",
294 ),
295 suppress: SuppressKind::FileComment,
296 issue_kind: "unused-file",
297 }),
298 "unused_exports" => Some(ActionSpec {
299 fix_type: "remove-export",
300 auto_fixable: true,
301 description: "Remove the `export` keyword from the declaration",
302 note: None,
303 suppress: SuppressKind::InlineComment,
304 issue_kind: "unused-export",
305 }),
306 "unused_types" => Some(ActionSpec {
307 fix_type: "remove-export",
308 auto_fixable: true,
309 description: "Remove the `export` (or `export type`) keyword from the type declaration",
310 note: None,
311 suppress: SuppressKind::InlineComment,
312 issue_kind: "unused-type",
313 }),
314 "unused_dependencies" => Some(ActionSpec {
315 fix_type: "remove-dependency",
316 auto_fixable: true,
317 description: "Remove from dependencies in package.json",
318 note: None,
319 suppress: SuppressKind::ConfigIgnoreDep,
320 issue_kind: "unused-dependency",
321 }),
322 "unused_dev_dependencies" => Some(ActionSpec {
323 fix_type: "remove-dependency",
324 auto_fixable: true,
325 description: "Remove from devDependencies in package.json",
326 note: None,
327 suppress: SuppressKind::ConfigIgnoreDep,
328 issue_kind: "unused-dev-dependency",
329 }),
330 "unused_optional_dependencies" => Some(ActionSpec {
331 fix_type: "remove-dependency",
332 auto_fixable: true,
333 description: "Remove from optionalDependencies in package.json",
334 note: None,
335 suppress: SuppressKind::ConfigIgnoreDep,
336 issue_kind: "unused-dependency",
338 }),
339 "unused_enum_members" => Some(ActionSpec {
340 fix_type: "remove-enum-member",
341 auto_fixable: true,
342 description: "Remove this enum member",
343 note: None,
344 suppress: SuppressKind::InlineComment,
345 issue_kind: "unused-enum-member",
346 }),
347 "unused_class_members" => Some(ActionSpec {
348 fix_type: "remove-class-member",
349 auto_fixable: false,
350 description: "Remove this class member",
351 note: Some("Class member may be used via dependency injection or decorators"),
352 suppress: SuppressKind::InlineComment,
353 issue_kind: "unused-class-member",
354 }),
355 "unresolved_imports" => Some(ActionSpec {
356 fix_type: "resolve-import",
357 auto_fixable: false,
358 description: "Fix the import specifier or install the missing module",
359 note: Some("Verify the module path and check tsconfig paths configuration"),
360 suppress: SuppressKind::InlineComment,
361 issue_kind: "unresolved-import",
362 }),
363 "unlisted_dependencies" => Some(ActionSpec {
364 fix_type: "install-dependency",
365 auto_fixable: false,
366 description: "Add this package to dependencies in package.json",
367 note: Some("Verify this package should be a direct dependency before adding"),
368 suppress: SuppressKind::ConfigIgnoreDep,
369 issue_kind: "unlisted-dependency",
370 }),
371 "duplicate_exports" => Some(ActionSpec {
372 fix_type: "remove-duplicate",
373 auto_fixable: false,
374 description: "Keep one canonical export location and remove the others",
375 note: Some("Review all locations to determine which should be the canonical export"),
376 suppress: SuppressKind::InlineComment,
377 issue_kind: "duplicate-export",
378 }),
379 "type_only_dependencies" => Some(ActionSpec {
380 fix_type: "move-to-dev",
381 auto_fixable: false,
382 description: "Move to devDependencies (only type imports are used)",
383 note: Some(
384 "Type imports are erased at runtime so this dependency is not needed in production",
385 ),
386 suppress: SuppressKind::ConfigIgnoreDep,
387 issue_kind: "type-only-dependency",
388 }),
389 "test_only_dependencies" => Some(ActionSpec {
390 fix_type: "move-to-dev",
391 auto_fixable: false,
392 description: "Move to devDependencies (only test files import this)",
393 note: Some(
394 "Only test files import this package so it does not need to be a production dependency",
395 ),
396 suppress: SuppressKind::ConfigIgnoreDep,
397 issue_kind: "test-only-dependency",
398 }),
399 "circular_dependencies" => Some(ActionSpec {
400 fix_type: "refactor-cycle",
401 auto_fixable: false,
402 description: "Extract shared logic into a separate module to break the cycle",
403 note: Some(
404 "Circular imports can cause initialization issues and make code harder to reason about",
405 ),
406 suppress: SuppressKind::InlineComment,
407 issue_kind: "circular-dependency",
408 }),
409 "boundary_violations" => Some(ActionSpec {
410 fix_type: "refactor-boundary",
411 auto_fixable: false,
412 description: "Move the import through an allowed zone or restructure the dependency",
413 note: Some(
414 "This import crosses an architecture boundary that is not permitted by the configured rules",
415 ),
416 suppress: SuppressKind::InlineComment,
417 issue_kind: "boundary-violation",
418 }),
419 _ => None,
420 }
421}
422
423fn build_actions(
425 item: &serde_json::Value,
426 issue_key: &str,
427 spec: &ActionSpec,
428) -> serde_json::Value {
429 let mut actions = Vec::with_capacity(2);
430
431 let mut fix_action = serde_json::json!({
433 "type": spec.fix_type,
434 "auto_fixable": spec.auto_fixable,
435 "description": spec.description,
436 });
437 if let Some(note) = spec.note {
438 fix_action["note"] = serde_json::json!(note);
439 }
440 if (issue_key == "unused_exports" || issue_key == "unused_types")
442 && item
443 .get("is_re_export")
444 .and_then(serde_json::Value::as_bool)
445 == Some(true)
446 {
447 fix_action["note"] = serde_json::json!(
448 "This finding originates from a re-export; verify it is not part of your public API before removing"
449 );
450 }
451 actions.push(fix_action);
452
453 match spec.suppress {
455 SuppressKind::InlineComment => {
456 let mut suppress = serde_json::json!({
457 "type": "suppress-line",
458 "auto_fixable": false,
459 "description": "Suppress with an inline comment above the line",
460 "comment": format!("// fallow-ignore-next-line {}", spec.issue_kind),
461 });
462 if issue_key == "duplicate_exports" {
464 suppress["scope"] = serde_json::json!("per-location");
465 }
466 actions.push(suppress);
467 }
468 SuppressKind::FileComment => {
469 actions.push(serde_json::json!({
470 "type": "suppress-file",
471 "auto_fixable": false,
472 "description": "Suppress with a file-level comment at the top of the file",
473 "comment": format!("// fallow-ignore-file {}", spec.issue_kind),
474 }));
475 }
476 SuppressKind::ConfigIgnoreDep => {
477 let pkg = item
479 .get("package_name")
480 .and_then(serde_json::Value::as_str)
481 .unwrap_or("package-name");
482 actions.push(serde_json::json!({
483 "type": "add-to-config",
484 "auto_fixable": false,
485 "description": format!("Add \"{pkg}\" to ignoreDependencies in fallow config"),
486 "config_key": "ignoreDependencies",
487 "value": pkg,
488 }));
489 }
490 }
491
492 serde_json::Value::Array(actions)
493}
494
495fn inject_actions(output: &mut serde_json::Value) {
500 let Some(map) = output.as_object_mut() else {
501 return;
502 };
503
504 for (key, value) in map.iter_mut() {
505 let Some(spec) = actions_for_issue_type(key) else {
506 continue;
507 };
508 let Some(arr) = value.as_array_mut() else {
509 continue;
510 };
511 for item in arr {
512 let actions = build_actions(item, key, &spec);
513 if let serde_json::Value::Object(obj) = item {
514 obj.insert("actions".to_string(), actions);
515 }
516 }
517 }
518}
519
520pub fn build_baseline_deltas_json<'a>(
528 total_delta: i64,
529 per_category: impl Iterator<Item = (&'a str, usize, usize, i64)>,
530) -> serde_json::Value {
531 let mut per_cat = serde_json::Map::new();
532 for (cat, current, baseline, delta) in per_category {
533 per_cat.insert(
534 cat.to_string(),
535 serde_json::json!({
536 "current": current,
537 "baseline": baseline,
538 "delta": delta,
539 }),
540 );
541 }
542 serde_json::json!({
543 "total_delta": total_delta,
544 "per_category": per_cat
545 })
546}
547
548const SECONDARY_REFACTOR_BAND: u16 = 5;
558
559#[derive(Debug, Clone, Copy, Default)]
574pub struct HealthActionOptions {
575 pub omit_suppress_line: bool,
577 pub omit_reason: Option<&'static str>,
582}
583
584#[allow(
591 clippy::redundant_pub_crate,
592 reason = "pub(crate) needed, used by audit.rs via re-export, but not part of public API"
593)]
594pub(crate) fn inject_health_actions(output: &mut serde_json::Value, opts: HealthActionOptions) {
595 let Some(map) = output.as_object_mut() else {
596 return;
597 };
598
599 let max_cyclomatic_threshold = map
602 .get("summary")
603 .and_then(|s| s.get("max_cyclomatic_threshold"))
604 .and_then(serde_json::Value::as_u64)
605 .and_then(|v| u16::try_from(v).ok())
606 .unwrap_or(20);
607 let max_cognitive_threshold = map
608 .get("summary")
609 .and_then(|s| s.get("max_cognitive_threshold"))
610 .and_then(serde_json::Value::as_u64)
611 .and_then(|v| u16::try_from(v).ok())
612 .unwrap_or(15);
613 let max_crap_threshold = map
614 .get("summary")
615 .and_then(|s| s.get("max_crap_threshold"))
616 .and_then(serde_json::Value::as_f64)
617 .unwrap_or(30.0);
618
619 if let Some(findings) = map.get_mut("findings").and_then(|v| v.as_array_mut()) {
621 for item in findings {
622 let actions = build_health_finding_actions(
623 item,
624 opts,
625 max_cyclomatic_threshold,
626 max_cognitive_threshold,
627 max_crap_threshold,
628 );
629 if let serde_json::Value::Object(obj) = item {
630 obj.insert("actions".to_string(), actions);
631 }
632 }
633 }
634
635 if let Some(targets) = map.get_mut("targets").and_then(|v| v.as_array_mut()) {
637 for item in targets {
638 let actions = build_refactoring_target_actions(item);
639 if let serde_json::Value::Object(obj) = item {
640 obj.insert("actions".to_string(), actions);
641 }
642 }
643 }
644
645 if let Some(hotspots) = map.get_mut("hotspots").and_then(|v| v.as_array_mut()) {
647 for item in hotspots {
648 let actions = build_hotspot_actions(item);
649 if let serde_json::Value::Object(obj) = item {
650 obj.insert("actions".to_string(), actions);
651 }
652 }
653 }
654
655 if let Some(gaps) = map.get_mut("coverage_gaps").and_then(|v| v.as_object_mut()) {
657 if let Some(files) = gaps.get_mut("files").and_then(|v| v.as_array_mut()) {
658 for item in files {
659 let actions = build_untested_file_actions(item);
660 if let serde_json::Value::Object(obj) = item {
661 obj.insert("actions".to_string(), actions);
662 }
663 }
664 }
665 if let Some(exports) = gaps.get_mut("exports").and_then(|v| v.as_array_mut()) {
666 for item in exports {
667 let actions = build_untested_export_actions(item);
668 if let serde_json::Value::Object(obj) = item {
669 obj.insert("actions".to_string(), actions);
670 }
671 }
672 }
673 }
674
675 if opts.omit_suppress_line {
683 let reason = opts.omit_reason.unwrap_or("unspecified");
684 map.insert(
685 "actions_meta".to_string(),
686 serde_json::json!({
687 "suppression_hints_omitted": true,
688 "reason": reason,
689 "scope": "health-findings",
690 }),
691 );
692 }
693}
694
695fn build_health_finding_actions(
719 item: &serde_json::Value,
720 opts: HealthActionOptions,
721 max_cyclomatic_threshold: u16,
722 max_cognitive_threshold: u16,
723 max_crap_threshold: f64,
724) -> serde_json::Value {
725 let name = item
726 .get("name")
727 .and_then(serde_json::Value::as_str)
728 .unwrap_or("function");
729 let path = item
730 .get("path")
731 .and_then(serde_json::Value::as_str)
732 .unwrap_or("");
733 let exceeded = item
734 .get("exceeded")
735 .and_then(serde_json::Value::as_str)
736 .unwrap_or("");
737 let includes_crap = matches!(
738 exceeded,
739 "crap" | "cyclomatic_crap" | "cognitive_crap" | "all"
740 );
741 let crap_only = exceeded == "crap";
742 let tier = item
743 .get("coverage_tier")
744 .and_then(serde_json::Value::as_str);
745 let cyclomatic = item
746 .get("cyclomatic")
747 .and_then(serde_json::Value::as_u64)
748 .and_then(|v| u16::try_from(v).ok())
749 .unwrap_or(0);
750 let cognitive = item
751 .get("cognitive")
752 .and_then(serde_json::Value::as_u64)
753 .and_then(|v| u16::try_from(v).ok())
754 .unwrap_or(0);
755 let full_coverage_can_clear_crap = !includes_crap || f64::from(cyclomatic) < max_crap_threshold;
756
757 let mut actions: Vec<serde_json::Value> = Vec::new();
758
759 if includes_crap {
761 let coverage_action = build_crap_coverage_action(name, tier, full_coverage_can_clear_crap);
762 if let Some(action) = coverage_action {
763 actions.push(action);
764 }
765 }
766
767 let crap_only_needs_complexity_reduction = crap_only && !full_coverage_can_clear_crap;
783 let cognitive_floor = max_cognitive_threshold / 2;
784 let near_cyclomatic_threshold = crap_only
785 && cyclomatic > 0
786 && cyclomatic >= max_cyclomatic_threshold.saturating_sub(SECONDARY_REFACTOR_BAND)
787 && cognitive >= cognitive_floor;
788 if !crap_only || crap_only_needs_complexity_reduction || near_cyclomatic_threshold {
789 actions.push(serde_json::json!({
790 "type": "refactor-function",
791 "auto_fixable": false,
792 "description": format!("Refactor `{name}` to reduce complexity (extract helper functions, simplify branching)"),
793 "note": "Consider splitting into smaller functions with single responsibilities",
794 }));
795 }
796
797 if !opts.omit_suppress_line {
798 if name == "<template>"
799 && Path::new(path)
800 .extension()
801 .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
802 {
803 actions.push(serde_json::json!({
804 "type": "suppress-file",
805 "auto_fixable": false,
806 "description": "Suppress with an HTML comment at the top of the template",
807 "comment": "<!-- fallow-ignore-file complexity -->",
808 "placement": "top-of-template",
809 }));
810 } else {
811 actions.push(serde_json::json!({
812 "type": "suppress-line",
813 "auto_fixable": false,
814 "description": "Suppress with an inline comment above the function declaration",
815 "comment": "// fallow-ignore-next-line complexity",
816 "placement": "above-function-declaration",
817 }));
818 }
819 }
820
821 serde_json::Value::Array(actions)
822}
823
824fn build_crap_coverage_action(
830 name: &str,
831 tier: Option<&str>,
832 full_coverage_can_clear_crap: bool,
833) -> Option<serde_json::Value> {
834 if !full_coverage_can_clear_crap {
835 return None;
836 }
837
838 match tier {
839 Some("partial" | "high") => Some(serde_json::json!({
844 "type": "increase-coverage",
845 "auto_fixable": false,
846 "description": format!("Increase test coverage for `{name}` (file is reachable from existing tests; add targeted assertions for uncovered branches)"),
847 "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",
848 })),
849 _ => Some(serde_json::json!({
851 "type": "add-tests",
852 "auto_fixable": false,
853 "description": format!("Add test coverage for `{name}` to lower its CRAP score (coverage reduces risk even without refactoring)"),
854 "note": "CRAP = CC^2 * (1 - cov/100)^3 + CC; higher coverage is the fastest way to bring CRAP under threshold",
855 })),
856 }
857}
858
859fn build_hotspot_actions(item: &serde_json::Value) -> serde_json::Value {
861 let path = item
862 .get("path")
863 .and_then(serde_json::Value::as_str)
864 .unwrap_or("file");
865
866 let mut actions = vec![
867 serde_json::json!({
868 "type": "refactor-file",
869 "auto_fixable": false,
870 "description": format!("Refactor `{path}`, high complexity combined with frequent changes makes this a maintenance risk"),
871 "note": "Prioritize extracting complex functions, adding tests, or splitting the module",
872 }),
873 serde_json::json!({
874 "type": "add-tests",
875 "auto_fixable": false,
876 "description": format!("Add test coverage for `{path}` to reduce change risk"),
877 "note": "Frequently changed complex files benefit most from comprehensive test coverage",
878 }),
879 ];
880
881 if let Some(ownership) = item.get("ownership") {
882 if ownership
884 .get("bus_factor")
885 .and_then(serde_json::Value::as_u64)
886 == Some(1)
887 {
888 let top = ownership.get("top_contributor");
889 let owner = top
890 .and_then(|t| t.get("identifier"))
891 .and_then(serde_json::Value::as_str)
892 .unwrap_or("the sole contributor");
893 let commits = top
898 .and_then(|t| t.get("commits"))
899 .and_then(serde_json::Value::as_u64)
900 .unwrap_or(0);
901 let suggested: Vec<String> = ownership
907 .get("suggested_reviewers")
908 .and_then(serde_json::Value::as_array)
909 .map(|arr| {
910 arr.iter()
911 .filter_map(|r| {
912 r.get("identifier")
913 .and_then(serde_json::Value::as_str)
914 .map(String::from)
915 })
916 .collect()
917 })
918 .unwrap_or_default();
919 let mut low_bus_action = serde_json::json!({
920 "type": "low-bus-factor",
921 "auto_fixable": false,
922 "description": format!(
923 "{owner} is the sole recent contributor to `{path}`; adding a second reviewer reduces knowledge-loss risk"
924 ),
925 });
926 if !suggested.is_empty() {
927 let list = suggested
928 .iter()
929 .map(|s| format!("@{s}"))
930 .collect::<Vec<_>>()
931 .join(", ");
932 low_bus_action["note"] =
933 serde_json::Value::String(format!("Candidate reviewers: {list}"));
934 } else if commits < 5 {
935 low_bus_action["note"] = serde_json::Value::String(
936 "Single recent contributor on a low-commit file. Consider a pair review for major changes."
937 .to_string(),
938 );
939 }
940 actions.push(low_bus_action);
942 }
943
944 if ownership
947 .get("unowned")
948 .and_then(serde_json::Value::as_bool)
949 == Some(true)
950 {
951 actions.push(serde_json::json!({
952 "type": "unowned-hotspot",
953 "auto_fixable": false,
954 "description": format!("Add a CODEOWNERS entry for `{path}`"),
955 "note": "Frequently-changed files without declared owners create review bottlenecks",
956 "suggested_pattern": suggest_codeowners_pattern(path),
957 "heuristic": "directory-deepest",
958 }));
959 }
960
961 if ownership.get("drift").and_then(serde_json::Value::as_bool) == Some(true) {
964 let reason = ownership
965 .get("drift_reason")
966 .and_then(serde_json::Value::as_str)
967 .unwrap_or("ownership has shifted from the original author");
968 actions.push(serde_json::json!({
969 "type": "ownership-drift",
970 "auto_fixable": false,
971 "description": format!("Update CODEOWNERS for `{path}`: {reason}"),
972 "note": "Drift suggests the declared or original owner is no longer the right reviewer",
973 }));
974 }
975 }
976
977 serde_json::Value::Array(actions)
978}
979
980fn suggest_codeowners_pattern(path: &str) -> String {
993 let normalized = path.replace('\\', "/");
994 let trimmed = normalized.trim_start_matches('/');
995 let mut components: Vec<&str> = trimmed.split('/').collect();
996 components.pop(); if components.is_empty() {
998 return format!("/{trimmed}");
999 }
1000 format!("/{}/", components.join("/"))
1001}
1002
1003fn build_refactoring_target_actions(item: &serde_json::Value) -> serde_json::Value {
1005 let recommendation = item
1006 .get("recommendation")
1007 .and_then(serde_json::Value::as_str)
1008 .unwrap_or("Apply the recommended refactoring");
1009
1010 let category = item
1011 .get("category")
1012 .and_then(serde_json::Value::as_str)
1013 .unwrap_or("refactoring");
1014
1015 let mut actions = vec![serde_json::json!({
1016 "type": "apply-refactoring",
1017 "auto_fixable": false,
1018 "description": recommendation,
1019 "category": category,
1020 })];
1021
1022 if item.get("evidence").is_some() {
1024 actions.push(serde_json::json!({
1025 "type": "suppress-line",
1026 "auto_fixable": false,
1027 "description": "Suppress the underlying complexity finding",
1028 "comment": "// fallow-ignore-next-line complexity",
1029 }));
1030 }
1031
1032 serde_json::Value::Array(actions)
1033}
1034
1035fn build_untested_file_actions(item: &serde_json::Value) -> serde_json::Value {
1037 let path = item
1038 .get("path")
1039 .and_then(serde_json::Value::as_str)
1040 .unwrap_or("file");
1041
1042 serde_json::Value::Array(vec![
1043 serde_json::json!({
1044 "type": "add-tests",
1045 "auto_fixable": false,
1046 "description": format!("Add test coverage for `{path}`"),
1047 "note": "No test dependency path reaches this runtime file",
1048 }),
1049 serde_json::json!({
1050 "type": "suppress-file",
1051 "auto_fixable": false,
1052 "description": format!("Suppress coverage gap reporting for `{path}`"),
1053 "comment": "// fallow-ignore-file coverage-gaps",
1054 }),
1055 ])
1056}
1057
1058fn build_untested_export_actions(item: &serde_json::Value) -> serde_json::Value {
1060 let path = item
1061 .get("path")
1062 .and_then(serde_json::Value::as_str)
1063 .unwrap_or("file");
1064 let export_name = item
1065 .get("export_name")
1066 .and_then(serde_json::Value::as_str)
1067 .unwrap_or("export");
1068
1069 serde_json::Value::Array(vec![
1070 serde_json::json!({
1071 "type": "add-test-import",
1072 "auto_fixable": false,
1073 "description": format!("Import and test `{export_name}` from `{path}`"),
1074 "note": "This export is runtime-reachable but no test-reachable module references it",
1075 }),
1076 serde_json::json!({
1077 "type": "suppress-file",
1078 "auto_fixable": false,
1079 "description": format!("Suppress coverage gap reporting for `{path}`"),
1080 "comment": "// fallow-ignore-file coverage-gaps",
1081 }),
1082 ])
1083}
1084
1085#[allow(
1092 clippy::redundant_pub_crate,
1093 reason = "pub(crate) needed — used by audit.rs via re-export, but not part of public API"
1094)]
1095pub(crate) fn inject_dupes_actions(output: &mut serde_json::Value) {
1096 let Some(map) = output.as_object_mut() else {
1097 return;
1098 };
1099
1100 if let Some(families) = map.get_mut("clone_families").and_then(|v| v.as_array_mut()) {
1102 for item in families {
1103 let actions = build_clone_family_actions(item);
1104 if let serde_json::Value::Object(obj) = item {
1105 obj.insert("actions".to_string(), actions);
1106 }
1107 }
1108 }
1109
1110 if let Some(groups) = map.get_mut("clone_groups").and_then(|v| v.as_array_mut()) {
1112 for item in groups {
1113 let actions = build_clone_group_actions(item);
1114 if let serde_json::Value::Object(obj) = item {
1115 obj.insert("actions".to_string(), actions);
1116 }
1117 }
1118 }
1119}
1120
1121fn build_clone_family_actions(item: &serde_json::Value) -> serde_json::Value {
1123 let group_count = item
1124 .get("groups")
1125 .and_then(|v| v.as_array())
1126 .map_or(0, Vec::len);
1127
1128 let total_lines = item
1129 .get("total_duplicated_lines")
1130 .and_then(serde_json::Value::as_u64)
1131 .unwrap_or(0);
1132
1133 let mut actions = vec![serde_json::json!({
1134 "type": "extract-shared",
1135 "auto_fixable": false,
1136 "description": format!(
1137 "Extract {group_count} duplicated code block{} ({total_lines} lines) into a shared module",
1138 if group_count == 1 { "" } else { "s" }
1139 ),
1140 "note": "These clone groups share the same files, indicating a structural relationship — refactor together",
1141 })];
1142
1143 if let Some(suggestions) = item.get("suggestions").and_then(|v| v.as_array()) {
1145 for suggestion in suggestions {
1146 if let Some(desc) = suggestion
1147 .get("description")
1148 .and_then(serde_json::Value::as_str)
1149 {
1150 actions.push(serde_json::json!({
1151 "type": "apply-suggestion",
1152 "auto_fixable": false,
1153 "description": desc,
1154 }));
1155 }
1156 }
1157 }
1158
1159 actions.push(serde_json::json!({
1160 "type": "suppress-line",
1161 "auto_fixable": false,
1162 "description": "Suppress with an inline comment above the duplicated code",
1163 "comment": "// fallow-ignore-next-line code-duplication",
1164 }));
1165
1166 serde_json::Value::Array(actions)
1167}
1168
1169fn build_clone_group_actions(item: &serde_json::Value) -> serde_json::Value {
1171 let instance_count = item
1172 .get("instances")
1173 .and_then(|v| v.as_array())
1174 .map_or(0, Vec::len);
1175
1176 let line_count = item
1177 .get("line_count")
1178 .and_then(serde_json::Value::as_u64)
1179 .unwrap_or(0);
1180
1181 let actions = vec![
1182 serde_json::json!({
1183 "type": "extract-shared",
1184 "auto_fixable": false,
1185 "description": format!(
1186 "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
1187 if instance_count == 1 { "" } else { "s" }
1188 ),
1189 }),
1190 serde_json::json!({
1191 "type": "suppress-line",
1192 "auto_fixable": false,
1193 "description": "Suppress with an inline comment above the duplicated code",
1194 "comment": "// fallow-ignore-next-line code-duplication",
1195 }),
1196 ];
1197
1198 serde_json::Value::Array(actions)
1199}
1200
1201fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
1203 if let serde_json::Value::Object(map) = output {
1204 map.insert("_meta".to_string(), meta);
1205 }
1206}
1207
1208pub fn build_health_json(
1216 report: &crate::health_types::HealthReport,
1217 root: &Path,
1218 elapsed: Duration,
1219 explain: bool,
1220 action_opts: HealthActionOptions,
1221) -> Result<serde_json::Value, serde_json::Error> {
1222 let report_value = serde_json::to_value(report)?;
1223 let mut output = build_json_envelope(report_value, elapsed);
1224 let root_prefix = format!("{}/", root.display());
1225 strip_root_prefix(&mut output, &root_prefix);
1226 inject_health_actions(&mut output, action_opts);
1227 if explain {
1228 insert_meta(&mut output, explain::health_meta());
1229 }
1230 Ok(output)
1231}
1232
1233pub(super) fn print_health_json(
1234 report: &crate::health_types::HealthReport,
1235 root: &Path,
1236 elapsed: Duration,
1237 explain: bool,
1238 action_opts: HealthActionOptions,
1239) -> ExitCode {
1240 match build_health_json(report, root, elapsed, explain, action_opts) {
1241 Ok(output) => emit_json(&output, "JSON"),
1242 Err(e) => {
1243 eprintln!("Error: failed to serialize health report: {e}");
1244 ExitCode::from(2)
1245 }
1246 }
1247}
1248
1249pub fn build_grouped_health_json(
1269 report: &crate::health_types::HealthReport,
1270 grouping: &crate::health_types::HealthGrouping,
1271 root: &Path,
1272 elapsed: Duration,
1273 explain: bool,
1274 action_opts: HealthActionOptions,
1275) -> Result<serde_json::Value, serde_json::Error> {
1276 let root_prefix = format!("{}/", root.display());
1277 let report_value = serde_json::to_value(report)?;
1278 let mut output = build_json_envelope(report_value, elapsed);
1279 strip_root_prefix(&mut output, &root_prefix);
1280 inject_health_actions(&mut output, action_opts);
1281
1282 if let serde_json::Value::Object(ref mut map) = output {
1283 map.insert("grouped_by".to_string(), serde_json::json!(grouping.mode));
1284 }
1285
1286 let group_values: Vec<serde_json::Value> = grouping
1294 .groups
1295 .iter()
1296 .map(|g| {
1297 let mut value = serde_json::to_value(g)?;
1298 strip_root_prefix(&mut value, &root_prefix);
1299 inject_health_actions(&mut value, action_opts);
1300 Ok(value)
1301 })
1302 .collect::<Result<_, serde_json::Error>>()?;
1303
1304 if let serde_json::Value::Object(ref mut map) = output {
1305 map.insert("groups".to_string(), serde_json::Value::Array(group_values));
1306 }
1307
1308 if explain {
1309 insert_meta(&mut output, explain::health_meta());
1310 }
1311
1312 Ok(output)
1313}
1314
1315pub(super) fn print_grouped_health_json(
1316 report: &crate::health_types::HealthReport,
1317 grouping: &crate::health_types::HealthGrouping,
1318 root: &Path,
1319 elapsed: Duration,
1320 explain: bool,
1321 action_opts: HealthActionOptions,
1322) -> ExitCode {
1323 match build_grouped_health_json(report, grouping, root, elapsed, explain, action_opts) {
1324 Ok(output) => emit_json(&output, "JSON"),
1325 Err(e) => {
1326 eprintln!("Error: failed to serialize grouped health report: {e}");
1327 ExitCode::from(2)
1328 }
1329 }
1330}
1331
1332pub fn build_duplication_json(
1339 report: &DuplicationReport,
1340 root: &Path,
1341 elapsed: Duration,
1342 explain: bool,
1343) -> Result<serde_json::Value, serde_json::Error> {
1344 let report_value = serde_json::to_value(report)?;
1345
1346 let mut output = build_json_envelope(report_value, elapsed);
1347 let root_prefix = format!("{}/", root.display());
1348 strip_root_prefix(&mut output, &root_prefix);
1349 inject_dupes_actions(&mut output);
1350
1351 if explain {
1352 insert_meta(&mut output, explain::dupes_meta());
1353 }
1354
1355 Ok(output)
1356}
1357
1358pub(super) fn print_duplication_json(
1359 report: &DuplicationReport,
1360 root: &Path,
1361 elapsed: Duration,
1362 explain: bool,
1363) -> ExitCode {
1364 match build_duplication_json(report, root, elapsed, explain) {
1365 Ok(output) => emit_json(&output, "JSON"),
1366 Err(e) => {
1367 eprintln!("Error: failed to serialize duplication report: {e}");
1368 ExitCode::from(2)
1369 }
1370 }
1371}
1372
1373pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
1374 match serde_json::to_string_pretty(value) {
1375 Ok(json) => println!("{json}"),
1376 Err(e) => {
1377 eprintln!("Error: failed to serialize trace output: {e}");
1378 #[expect(
1379 clippy::exit,
1380 reason = "fatal serialization error requires immediate exit"
1381 )]
1382 std::process::exit(2);
1383 }
1384 }
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389 use super::*;
1390 use crate::health_types::{
1391 RuntimeCoverageAction, RuntimeCoverageConfidence, RuntimeCoverageEvidence,
1392 RuntimeCoverageFinding, RuntimeCoverageHotPath, RuntimeCoverageMessage,
1393 RuntimeCoverageReport, RuntimeCoverageReportVerdict, RuntimeCoverageSummary,
1394 RuntimeCoverageVerdict, RuntimeCoverageWatermark,
1395 };
1396 use crate::report::test_helpers::sample_results;
1397 use fallow_core::extract::MemberKind;
1398 use fallow_core::results::*;
1399 use std::path::PathBuf;
1400 use std::time::Duration;
1401
1402 #[test]
1403 fn json_output_has_metadata_fields() {
1404 let root = PathBuf::from("/project");
1405 let results = AnalysisResults::default();
1406 let elapsed = Duration::from_millis(123);
1407 let output = build_json(&results, &root, elapsed).expect("should serialize");
1408
1409 assert_eq!(output["schema_version"], 4);
1410 assert!(output["version"].is_string());
1411 assert_eq!(output["elapsed_ms"], 123);
1412 assert_eq!(output["total_issues"], 0);
1413 }
1414
1415 #[test]
1416 fn json_output_includes_issue_arrays() {
1417 let root = PathBuf::from("/project");
1418 let results = sample_results(&root);
1419 let elapsed = Duration::from_millis(50);
1420 let output = build_json(&results, &root, elapsed).expect("should serialize");
1421
1422 assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
1423 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
1424 assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
1425 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
1426 assert_eq!(
1427 output["unused_dev_dependencies"].as_array().unwrap().len(),
1428 1
1429 );
1430 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
1431 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
1432 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
1433 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
1434 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
1435 assert_eq!(
1436 output["type_only_dependencies"].as_array().unwrap().len(),
1437 1
1438 );
1439 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
1440 }
1441
1442 #[test]
1443 fn health_json_includes_runtime_coverage_with_relative_paths_and_actions() {
1444 let root = PathBuf::from("/project");
1445 let report = crate::health_types::HealthReport {
1446 runtime_coverage: Some(RuntimeCoverageReport {
1447 verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
1448 summary: RuntimeCoverageSummary {
1449 functions_tracked: 3,
1450 functions_hit: 1,
1451 functions_unhit: 1,
1452 functions_untracked: 1,
1453 coverage_percent: 33.3,
1454 trace_count: 2_847_291,
1455 period_days: 30,
1456 deployments_seen: 14,
1457 capture_quality: Some(crate::health_types::RuntimeCoverageCaptureQuality {
1458 window_seconds: 720,
1459 instances_observed: 1,
1460 lazy_parse_warning: true,
1461 untracked_ratio_percent: 42.5,
1462 }),
1463 },
1464 findings: vec![RuntimeCoverageFinding {
1465 id: "fallow:prod:deadbeef".to_owned(),
1466 path: root.join("src/cold.ts"),
1467 function: "coldPath".to_owned(),
1468 line: 12,
1469 verdict: RuntimeCoverageVerdict::ReviewRequired,
1470 invocations: Some(0),
1471 confidence: RuntimeCoverageConfidence::Medium,
1472 evidence: RuntimeCoverageEvidence {
1473 static_status: "used".to_owned(),
1474 test_coverage: "not_covered".to_owned(),
1475 v8_tracking: "tracked".to_owned(),
1476 untracked_reason: None,
1477 observation_days: 30,
1478 deployments_observed: 14,
1479 },
1480 actions: vec![RuntimeCoverageAction {
1481 kind: "review-deletion".to_owned(),
1482 description: "Tracked in runtime coverage with zero invocations."
1483 .to_owned(),
1484 auto_fixable: false,
1485 }],
1486 }],
1487 hot_paths: vec![RuntimeCoverageHotPath {
1488 id: "fallow:hot:cafebabe".to_owned(),
1489 path: root.join("src/hot.ts"),
1490 function: "hotPath".to_owned(),
1491 line: 3,
1492 invocations: 250,
1493 percentile: 99,
1494 actions: vec![],
1495 }],
1496 watermark: Some(RuntimeCoverageWatermark::LicenseExpiredGrace),
1497 warnings: vec![RuntimeCoverageMessage {
1498 code: "partial-merge".to_owned(),
1499 message: "Merged coverage omitted one chunk.".to_owned(),
1500 }],
1501 }),
1502 ..Default::default()
1503 };
1504
1505 let report_value = serde_json::to_value(&report).expect("should serialize health report");
1506 let mut output = build_json_envelope(report_value, Duration::from_millis(7));
1507 strip_root_prefix(&mut output, "/project/");
1508 inject_health_actions(&mut output, HealthActionOptions::default());
1509
1510 assert_eq!(
1511 output["runtime_coverage"]["verdict"],
1512 serde_json::Value::String("cold-code-detected".to_owned())
1513 );
1514 assert_eq!(
1515 output["runtime_coverage"]["summary"]["functions_tracked"],
1516 serde_json::Value::from(3)
1517 );
1518 assert_eq!(
1519 output["runtime_coverage"]["summary"]["coverage_percent"],
1520 serde_json::Value::from(33.3)
1521 );
1522 let finding = &output["runtime_coverage"]["findings"][0];
1523 assert_eq!(finding["path"], "src/cold.ts");
1524 assert_eq!(finding["verdict"], "review_required");
1525 assert_eq!(finding["id"], "fallow:prod:deadbeef");
1526 assert_eq!(finding["actions"][0]["type"], "review-deletion");
1527 let hot_path = &output["runtime_coverage"]["hot_paths"][0];
1528 assert_eq!(hot_path["path"], "src/hot.ts");
1529 assert_eq!(hot_path["function"], "hotPath");
1530 assert_eq!(hot_path["percentile"], 99);
1531 assert_eq!(
1532 output["runtime_coverage"]["watermark"],
1533 serde_json::Value::String("license-expired-grace".to_owned())
1534 );
1535 assert_eq!(
1536 output["runtime_coverage"]["warnings"][0]["code"],
1537 serde_json::Value::String("partial-merge".to_owned())
1538 );
1539 }
1540
1541 #[test]
1542 fn json_metadata_fields_appear_first() {
1543 let root = PathBuf::from("/project");
1544 let results = AnalysisResults::default();
1545 let elapsed = Duration::from_millis(0);
1546 let output = build_json(&results, &root, elapsed).expect("should serialize");
1547 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1548 assert_eq!(keys[0], "schema_version");
1549 assert_eq!(keys[1], "version");
1550 assert_eq!(keys[2], "elapsed_ms");
1551 assert_eq!(keys[3], "total_issues");
1552 }
1553
1554 #[test]
1555 fn json_total_issues_matches_results() {
1556 let root = PathBuf::from("/project");
1557 let results = sample_results(&root);
1558 let total = results.total_issues();
1559 let elapsed = Duration::from_millis(0);
1560 let output = build_json(&results, &root, elapsed).expect("should serialize");
1561
1562 assert_eq!(output["total_issues"], total);
1563 }
1564
1565 #[test]
1566 fn json_unused_export_contains_expected_fields() {
1567 let root = PathBuf::from("/project");
1568 let mut results = AnalysisResults::default();
1569 results.unused_exports.push(UnusedExport {
1570 path: root.join("src/utils.ts"),
1571 export_name: "helperFn".to_string(),
1572 is_type_only: false,
1573 line: 10,
1574 col: 4,
1575 span_start: 120,
1576 is_re_export: false,
1577 });
1578 let elapsed = Duration::from_millis(0);
1579 let output = build_json(&results, &root, elapsed).expect("should serialize");
1580
1581 let export = &output["unused_exports"][0];
1582 assert_eq!(export["export_name"], "helperFn");
1583 assert_eq!(export["line"], 10);
1584 assert_eq!(export["col"], 4);
1585 assert_eq!(export["is_type_only"], false);
1586 assert_eq!(export["span_start"], 120);
1587 assert_eq!(export["is_re_export"], false);
1588 }
1589
1590 #[test]
1591 fn json_serializes_to_valid_json() {
1592 let root = PathBuf::from("/project");
1593 let results = sample_results(&root);
1594 let elapsed = Duration::from_millis(42);
1595 let output = build_json(&results, &root, elapsed).expect("should serialize");
1596
1597 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
1598 let reparsed: serde_json::Value =
1599 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
1600 assert_eq!(reparsed, output);
1601 }
1602
1603 #[test]
1606 fn json_empty_results_produce_valid_structure() {
1607 let root = PathBuf::from("/project");
1608 let results = AnalysisResults::default();
1609 let elapsed = Duration::from_millis(0);
1610 let output = build_json(&results, &root, elapsed).expect("should serialize");
1611
1612 assert_eq!(output["total_issues"], 0);
1613 assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
1614 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
1615 assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
1616 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
1617 assert_eq!(
1618 output["unused_dev_dependencies"].as_array().unwrap().len(),
1619 0
1620 );
1621 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
1622 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
1623 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
1624 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
1625 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
1626 assert_eq!(
1627 output["type_only_dependencies"].as_array().unwrap().len(),
1628 0
1629 );
1630 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
1631 }
1632
1633 #[test]
1634 fn json_empty_results_round_trips_through_string() {
1635 let root = PathBuf::from("/project");
1636 let results = AnalysisResults::default();
1637 let elapsed = Duration::from_millis(0);
1638 let output = build_json(&results, &root, elapsed).expect("should serialize");
1639
1640 let json_str = serde_json::to_string(&output).expect("should stringify");
1641 let reparsed: serde_json::Value =
1642 serde_json::from_str(&json_str).expect("should parse back");
1643 assert_eq!(reparsed["total_issues"], 0);
1644 }
1645
1646 #[test]
1649 fn json_paths_are_relative_to_root() {
1650 let root = PathBuf::from("/project");
1651 let mut results = AnalysisResults::default();
1652 results.unused_files.push(UnusedFile {
1653 path: root.join("src/deep/nested/file.ts"),
1654 });
1655 let elapsed = Duration::from_millis(0);
1656 let output = build_json(&results, &root, elapsed).expect("should serialize");
1657
1658 let path = output["unused_files"][0]["path"].as_str().unwrap();
1659 assert_eq!(path, "src/deep/nested/file.ts");
1660 assert!(!path.starts_with("/project"));
1661 }
1662
1663 #[test]
1664 fn json_strips_root_from_nested_locations() {
1665 let root = PathBuf::from("/project");
1666 let mut results = AnalysisResults::default();
1667 results.unlisted_dependencies.push(UnlistedDependency {
1668 package_name: "chalk".to_string(),
1669 imported_from: vec![ImportSite {
1670 path: root.join("src/cli.ts"),
1671 line: 2,
1672 col: 0,
1673 }],
1674 });
1675 let elapsed = Duration::from_millis(0);
1676 let output = build_json(&results, &root, elapsed).expect("should serialize");
1677
1678 let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
1679 .as_str()
1680 .unwrap();
1681 assert_eq!(site_path, "src/cli.ts");
1682 }
1683
1684 #[test]
1685 fn json_strips_root_from_duplicate_export_locations() {
1686 let root = PathBuf::from("/project");
1687 let mut results = AnalysisResults::default();
1688 results.duplicate_exports.push(DuplicateExport {
1689 export_name: "Config".to_string(),
1690 locations: vec![
1691 DuplicateLocation {
1692 path: root.join("src/config.ts"),
1693 line: 15,
1694 col: 0,
1695 },
1696 DuplicateLocation {
1697 path: root.join("src/types.ts"),
1698 line: 30,
1699 col: 0,
1700 },
1701 ],
1702 });
1703 let elapsed = Duration::from_millis(0);
1704 let output = build_json(&results, &root, elapsed).expect("should serialize");
1705
1706 let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
1707 .as_str()
1708 .unwrap();
1709 let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
1710 .as_str()
1711 .unwrap();
1712 assert_eq!(loc0, "src/config.ts");
1713 assert_eq!(loc1, "src/types.ts");
1714 }
1715
1716 #[test]
1717 fn json_strips_root_from_circular_dependency_files() {
1718 let root = PathBuf::from("/project");
1719 let mut results = AnalysisResults::default();
1720 results.circular_dependencies.push(CircularDependency {
1721 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1722 length: 2,
1723 line: 1,
1724 col: 0,
1725 is_cross_package: false,
1726 });
1727 let elapsed = Duration::from_millis(0);
1728 let output = build_json(&results, &root, elapsed).expect("should serialize");
1729
1730 let files = output["circular_dependencies"][0]["files"]
1731 .as_array()
1732 .unwrap();
1733 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
1734 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
1735 }
1736
1737 #[test]
1738 fn json_path_outside_root_not_stripped() {
1739 let root = PathBuf::from("/project");
1740 let mut results = AnalysisResults::default();
1741 results.unused_files.push(UnusedFile {
1742 path: PathBuf::from("/other/project/src/file.ts"),
1743 });
1744 let elapsed = Duration::from_millis(0);
1745 let output = build_json(&results, &root, elapsed).expect("should serialize");
1746
1747 let path = output["unused_files"][0]["path"].as_str().unwrap();
1748 assert!(path.contains("/other/project/"));
1749 }
1750
1751 #[test]
1754 fn json_unused_file_contains_path() {
1755 let root = PathBuf::from("/project");
1756 let mut results = AnalysisResults::default();
1757 results.unused_files.push(UnusedFile {
1758 path: root.join("src/orphan.ts"),
1759 });
1760 let elapsed = Duration::from_millis(0);
1761 let output = build_json(&results, &root, elapsed).expect("should serialize");
1762
1763 let file = &output["unused_files"][0];
1764 assert_eq!(file["path"], "src/orphan.ts");
1765 }
1766
1767 #[test]
1768 fn json_unused_type_contains_expected_fields() {
1769 let root = PathBuf::from("/project");
1770 let mut results = AnalysisResults::default();
1771 results.unused_types.push(UnusedExport {
1772 path: root.join("src/types.ts"),
1773 export_name: "OldInterface".to_string(),
1774 is_type_only: true,
1775 line: 20,
1776 col: 0,
1777 span_start: 300,
1778 is_re_export: false,
1779 });
1780 let elapsed = Duration::from_millis(0);
1781 let output = build_json(&results, &root, elapsed).expect("should serialize");
1782
1783 let typ = &output["unused_types"][0];
1784 assert_eq!(typ["export_name"], "OldInterface");
1785 assert_eq!(typ["is_type_only"], true);
1786 assert_eq!(typ["line"], 20);
1787 assert_eq!(typ["path"], "src/types.ts");
1788 }
1789
1790 #[test]
1791 fn json_unused_dependency_contains_expected_fields() {
1792 let root = PathBuf::from("/project");
1793 let mut results = AnalysisResults::default();
1794 results.unused_dependencies.push(UnusedDependency {
1795 package_name: "axios".to_string(),
1796 location: DependencyLocation::Dependencies,
1797 path: root.join("package.json"),
1798 line: 10,
1799 });
1800 let elapsed = Duration::from_millis(0);
1801 let output = build_json(&results, &root, elapsed).expect("should serialize");
1802
1803 let dep = &output["unused_dependencies"][0];
1804 assert_eq!(dep["package_name"], "axios");
1805 assert_eq!(dep["line"], 10);
1806 }
1807
1808 #[test]
1809 fn json_unused_dev_dependency_contains_expected_fields() {
1810 let root = PathBuf::from("/project");
1811 let mut results = AnalysisResults::default();
1812 results.unused_dev_dependencies.push(UnusedDependency {
1813 package_name: "vitest".to_string(),
1814 location: DependencyLocation::DevDependencies,
1815 path: root.join("package.json"),
1816 line: 15,
1817 });
1818 let elapsed = Duration::from_millis(0);
1819 let output = build_json(&results, &root, elapsed).expect("should serialize");
1820
1821 let dep = &output["unused_dev_dependencies"][0];
1822 assert_eq!(dep["package_name"], "vitest");
1823 }
1824
1825 #[test]
1826 fn json_unused_optional_dependency_contains_expected_fields() {
1827 let root = PathBuf::from("/project");
1828 let mut results = AnalysisResults::default();
1829 results.unused_optional_dependencies.push(UnusedDependency {
1830 package_name: "fsevents".to_string(),
1831 location: DependencyLocation::OptionalDependencies,
1832 path: root.join("package.json"),
1833 line: 12,
1834 });
1835 let elapsed = Duration::from_millis(0);
1836 let output = build_json(&results, &root, elapsed).expect("should serialize");
1837
1838 let dep = &output["unused_optional_dependencies"][0];
1839 assert_eq!(dep["package_name"], "fsevents");
1840 assert_eq!(output["total_issues"], 1);
1841 }
1842
1843 #[test]
1844 fn json_unused_enum_member_contains_expected_fields() {
1845 let root = PathBuf::from("/project");
1846 let mut results = AnalysisResults::default();
1847 results.unused_enum_members.push(UnusedMember {
1848 path: root.join("src/enums.ts"),
1849 parent_name: "Color".to_string(),
1850 member_name: "Purple".to_string(),
1851 kind: MemberKind::EnumMember,
1852 line: 5,
1853 col: 2,
1854 });
1855 let elapsed = Duration::from_millis(0);
1856 let output = build_json(&results, &root, elapsed).expect("should serialize");
1857
1858 let member = &output["unused_enum_members"][0];
1859 assert_eq!(member["parent_name"], "Color");
1860 assert_eq!(member["member_name"], "Purple");
1861 assert_eq!(member["line"], 5);
1862 assert_eq!(member["path"], "src/enums.ts");
1863 }
1864
1865 #[test]
1866 fn json_unused_class_member_contains_expected_fields() {
1867 let root = PathBuf::from("/project");
1868 let mut results = AnalysisResults::default();
1869 results.unused_class_members.push(UnusedMember {
1870 path: root.join("src/api.ts"),
1871 parent_name: "ApiClient".to_string(),
1872 member_name: "deprecatedFetch".to_string(),
1873 kind: MemberKind::ClassMethod,
1874 line: 100,
1875 col: 4,
1876 });
1877 let elapsed = Duration::from_millis(0);
1878 let output = build_json(&results, &root, elapsed).expect("should serialize");
1879
1880 let member = &output["unused_class_members"][0];
1881 assert_eq!(member["parent_name"], "ApiClient");
1882 assert_eq!(member["member_name"], "deprecatedFetch");
1883 assert_eq!(member["line"], 100);
1884 }
1885
1886 #[test]
1887 fn json_unresolved_import_contains_expected_fields() {
1888 let root = PathBuf::from("/project");
1889 let mut results = AnalysisResults::default();
1890 results.unresolved_imports.push(UnresolvedImport {
1891 path: root.join("src/app.ts"),
1892 specifier: "@acme/missing-pkg".to_string(),
1893 line: 7,
1894 col: 0,
1895 specifier_col: 0,
1896 });
1897 let elapsed = Duration::from_millis(0);
1898 let output = build_json(&results, &root, elapsed).expect("should serialize");
1899
1900 let import = &output["unresolved_imports"][0];
1901 assert_eq!(import["specifier"], "@acme/missing-pkg");
1902 assert_eq!(import["line"], 7);
1903 assert_eq!(import["path"], "src/app.ts");
1904 }
1905
1906 #[test]
1907 fn json_unlisted_dependency_contains_import_sites() {
1908 let root = PathBuf::from("/project");
1909 let mut results = AnalysisResults::default();
1910 results.unlisted_dependencies.push(UnlistedDependency {
1911 package_name: "dotenv".to_string(),
1912 imported_from: vec![
1913 ImportSite {
1914 path: root.join("src/config.ts"),
1915 line: 1,
1916 col: 0,
1917 },
1918 ImportSite {
1919 path: root.join("src/server.ts"),
1920 line: 3,
1921 col: 0,
1922 },
1923 ],
1924 });
1925 let elapsed = Duration::from_millis(0);
1926 let output = build_json(&results, &root, elapsed).expect("should serialize");
1927
1928 let dep = &output["unlisted_dependencies"][0];
1929 assert_eq!(dep["package_name"], "dotenv");
1930 let sites = dep["imported_from"].as_array().unwrap();
1931 assert_eq!(sites.len(), 2);
1932 assert_eq!(sites[0]["path"], "src/config.ts");
1933 assert_eq!(sites[1]["path"], "src/server.ts");
1934 }
1935
1936 #[test]
1937 fn json_duplicate_export_contains_locations() {
1938 let root = PathBuf::from("/project");
1939 let mut results = AnalysisResults::default();
1940 results.duplicate_exports.push(DuplicateExport {
1941 export_name: "Button".to_string(),
1942 locations: vec![
1943 DuplicateLocation {
1944 path: root.join("src/ui.ts"),
1945 line: 10,
1946 col: 0,
1947 },
1948 DuplicateLocation {
1949 path: root.join("src/components.ts"),
1950 line: 25,
1951 col: 0,
1952 },
1953 ],
1954 });
1955 let elapsed = Duration::from_millis(0);
1956 let output = build_json(&results, &root, elapsed).expect("should serialize");
1957
1958 let dup = &output["duplicate_exports"][0];
1959 assert_eq!(dup["export_name"], "Button");
1960 let locs = dup["locations"].as_array().unwrap();
1961 assert_eq!(locs.len(), 2);
1962 assert_eq!(locs[0]["line"], 10);
1963 assert_eq!(locs[1]["line"], 25);
1964 }
1965
1966 #[test]
1967 fn json_type_only_dependency_contains_expected_fields() {
1968 let root = PathBuf::from("/project");
1969 let mut results = AnalysisResults::default();
1970 results.type_only_dependencies.push(TypeOnlyDependency {
1971 package_name: "zod".to_string(),
1972 path: root.join("package.json"),
1973 line: 8,
1974 });
1975 let elapsed = Duration::from_millis(0);
1976 let output = build_json(&results, &root, elapsed).expect("should serialize");
1977
1978 let dep = &output["type_only_dependencies"][0];
1979 assert_eq!(dep["package_name"], "zod");
1980 assert_eq!(dep["line"], 8);
1981 }
1982
1983 #[test]
1984 fn json_circular_dependency_contains_expected_fields() {
1985 let root = PathBuf::from("/project");
1986 let mut results = AnalysisResults::default();
1987 results.circular_dependencies.push(CircularDependency {
1988 files: vec![
1989 root.join("src/a.ts"),
1990 root.join("src/b.ts"),
1991 root.join("src/c.ts"),
1992 ],
1993 length: 3,
1994 line: 5,
1995 col: 0,
1996 is_cross_package: false,
1997 });
1998 let elapsed = Duration::from_millis(0);
1999 let output = build_json(&results, &root, elapsed).expect("should serialize");
2000
2001 let cycle = &output["circular_dependencies"][0];
2002 assert_eq!(cycle["length"], 3);
2003 assert_eq!(cycle["line"], 5);
2004 let files = cycle["files"].as_array().unwrap();
2005 assert_eq!(files.len(), 3);
2006 }
2007
2008 #[test]
2011 fn json_re_export_flagged_correctly() {
2012 let root = PathBuf::from("/project");
2013 let mut results = AnalysisResults::default();
2014 results.unused_exports.push(UnusedExport {
2015 path: root.join("src/index.ts"),
2016 export_name: "reExported".to_string(),
2017 is_type_only: false,
2018 line: 1,
2019 col: 0,
2020 span_start: 0,
2021 is_re_export: true,
2022 });
2023 let elapsed = Duration::from_millis(0);
2024 let output = build_json(&results, &root, elapsed).expect("should serialize");
2025
2026 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
2027 }
2028
2029 #[test]
2032 fn json_schema_version_is_4() {
2033 let root = PathBuf::from("/project");
2034 let results = AnalysisResults::default();
2035 let elapsed = Duration::from_millis(0);
2036 let output = build_json(&results, &root, elapsed).expect("should serialize");
2037
2038 assert_eq!(output["schema_version"], SCHEMA_VERSION);
2039 assert_eq!(output["schema_version"], 4);
2040 }
2041
2042 #[test]
2045 fn json_version_matches_cargo_pkg_version() {
2046 let root = PathBuf::from("/project");
2047 let results = AnalysisResults::default();
2048 let elapsed = Duration::from_millis(0);
2049 let output = build_json(&results, &root, elapsed).expect("should serialize");
2050
2051 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
2052 }
2053
2054 #[test]
2057 fn json_elapsed_ms_zero_duration() {
2058 let root = PathBuf::from("/project");
2059 let results = AnalysisResults::default();
2060 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
2061
2062 assert_eq!(output["elapsed_ms"], 0);
2063 }
2064
2065 #[test]
2066 fn json_elapsed_ms_large_duration() {
2067 let root = PathBuf::from("/project");
2068 let results = AnalysisResults::default();
2069 let elapsed = Duration::from_mins(2);
2070 let output = build_json(&results, &root, elapsed).expect("should serialize");
2071
2072 assert_eq!(output["elapsed_ms"], 120_000);
2073 }
2074
2075 #[test]
2076 fn json_elapsed_ms_sub_millisecond_truncated() {
2077 let root = PathBuf::from("/project");
2078 let results = AnalysisResults::default();
2079 let elapsed = Duration::from_micros(500);
2081 let output = build_json(&results, &root, elapsed).expect("should serialize");
2082
2083 assert_eq!(output["elapsed_ms"], 0);
2084 }
2085
2086 #[test]
2089 fn json_multiple_unused_files() {
2090 let root = PathBuf::from("/project");
2091 let mut results = AnalysisResults::default();
2092 results.unused_files.push(UnusedFile {
2093 path: root.join("src/a.ts"),
2094 });
2095 results.unused_files.push(UnusedFile {
2096 path: root.join("src/b.ts"),
2097 });
2098 results.unused_files.push(UnusedFile {
2099 path: root.join("src/c.ts"),
2100 });
2101 let elapsed = Duration::from_millis(0);
2102 let output = build_json(&results, &root, elapsed).expect("should serialize");
2103
2104 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
2105 assert_eq!(output["total_issues"], 3);
2106 }
2107
2108 #[test]
2111 fn strip_root_prefix_on_string_value() {
2112 let mut value = serde_json::json!("/project/src/file.ts");
2113 strip_root_prefix(&mut value, "/project/");
2114 assert_eq!(value, "src/file.ts");
2115 }
2116
2117 #[test]
2118 fn strip_root_prefix_leaves_non_matching_string() {
2119 let mut value = serde_json::json!("/other/src/file.ts");
2120 strip_root_prefix(&mut value, "/project/");
2121 assert_eq!(value, "/other/src/file.ts");
2122 }
2123
2124 #[test]
2125 fn strip_root_prefix_recurses_into_arrays() {
2126 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
2127 strip_root_prefix(&mut value, "/project/");
2128 assert_eq!(value[0], "a.ts");
2129 assert_eq!(value[1], "b.ts");
2130 assert_eq!(value[2], "/other/c.ts");
2131 }
2132
2133 #[test]
2134 fn strip_root_prefix_recurses_into_nested_objects() {
2135 let mut value = serde_json::json!({
2136 "outer": {
2137 "path": "/project/src/nested.ts"
2138 }
2139 });
2140 strip_root_prefix(&mut value, "/project/");
2141 assert_eq!(value["outer"]["path"], "src/nested.ts");
2142 }
2143
2144 #[test]
2145 fn strip_root_prefix_leaves_numbers_and_booleans() {
2146 let mut value = serde_json::json!({
2147 "line": 42,
2148 "is_type_only": false,
2149 "path": "/project/src/file.ts"
2150 });
2151 strip_root_prefix(&mut value, "/project/");
2152 assert_eq!(value["line"], 42);
2153 assert_eq!(value["is_type_only"], false);
2154 assert_eq!(value["path"], "src/file.ts");
2155 }
2156
2157 #[test]
2158 fn strip_root_prefix_normalizes_windows_separators() {
2159 let mut value = serde_json::json!(r"/project\src\file.ts");
2160 strip_root_prefix(&mut value, "/project/");
2161 assert_eq!(value, "src/file.ts");
2162 }
2163
2164 #[test]
2165 fn strip_root_prefix_handles_empty_string_after_strip() {
2166 let mut value = serde_json::json!("/project/");
2169 strip_root_prefix(&mut value, "/project/");
2170 assert_eq!(value, "");
2171 }
2172
2173 #[test]
2174 fn strip_root_prefix_deeply_nested_array_of_objects() {
2175 let mut value = serde_json::json!({
2176 "groups": [{
2177 "instances": [{
2178 "file": "/project/src/a.ts"
2179 }, {
2180 "file": "/project/src/b.ts"
2181 }]
2182 }]
2183 });
2184 strip_root_prefix(&mut value, "/project/");
2185 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
2186 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
2187 }
2188
2189 #[test]
2192 fn json_full_sample_results_total_issues_correct() {
2193 let root = PathBuf::from("/project");
2194 let results = sample_results(&root);
2195 let elapsed = Duration::from_millis(100);
2196 let output = build_json(&results, &root, elapsed).expect("should serialize");
2197
2198 assert_eq!(output["total_issues"], results.total_issues());
2204 }
2205
2206 #[test]
2207 fn json_full_sample_no_absolute_paths_in_output() {
2208 let root = PathBuf::from("/project");
2209 let results = sample_results(&root);
2210 let elapsed = Duration::from_millis(0);
2211 let output = build_json(&results, &root, elapsed).expect("should serialize");
2212
2213 let json_str = serde_json::to_string(&output).expect("should stringify");
2214 assert!(!json_str.contains("/project/src/"));
2216 assert!(!json_str.contains("/project/package.json"));
2217 }
2218
2219 #[test]
2222 fn json_output_is_deterministic() {
2223 let root = PathBuf::from("/project");
2224 let results = sample_results(&root);
2225 let elapsed = Duration::from_millis(50);
2226
2227 let output1 = build_json(&results, &root, elapsed).expect("first build");
2228 let output2 = build_json(&results, &root, elapsed).expect("second build");
2229
2230 assert_eq!(output1, output2);
2231 }
2232
2233 #[test]
2236 fn json_results_fields_do_not_shadow_metadata() {
2237 let root = PathBuf::from("/project");
2240 let results = AnalysisResults::default();
2241 let elapsed = Duration::from_millis(99);
2242 let output = build_json(&results, &root, elapsed).expect("should serialize");
2243
2244 assert_eq!(output["schema_version"], 4);
2246 assert_eq!(output["elapsed_ms"], 99);
2247 }
2248
2249 #[test]
2252 fn json_all_issue_type_arrays_present_in_empty_results() {
2253 let root = PathBuf::from("/project");
2254 let results = AnalysisResults::default();
2255 let elapsed = Duration::from_millis(0);
2256 let output = build_json(&results, &root, elapsed).expect("should serialize");
2257
2258 let expected_arrays = [
2259 "unused_files",
2260 "unused_exports",
2261 "unused_types",
2262 "unused_dependencies",
2263 "unused_dev_dependencies",
2264 "unused_optional_dependencies",
2265 "unused_enum_members",
2266 "unused_class_members",
2267 "unresolved_imports",
2268 "unlisted_dependencies",
2269 "duplicate_exports",
2270 "type_only_dependencies",
2271 "test_only_dependencies",
2272 "circular_dependencies",
2273 ];
2274 for key in &expected_arrays {
2275 assert!(
2276 output[key].is_array(),
2277 "expected '{key}' to be an array in JSON output"
2278 );
2279 }
2280 }
2281
2282 #[test]
2285 fn insert_meta_adds_key_to_object() {
2286 let mut output = serde_json::json!({ "foo": 1 });
2287 let meta = serde_json::json!({ "docs": "https://example.com" });
2288 insert_meta(&mut output, meta.clone());
2289 assert_eq!(output["_meta"], meta);
2290 }
2291
2292 #[test]
2293 fn insert_meta_noop_on_non_object() {
2294 let mut output = serde_json::json!([1, 2, 3]);
2295 let meta = serde_json::json!({ "docs": "https://example.com" });
2296 insert_meta(&mut output, meta);
2297 assert!(output.is_array());
2299 }
2300
2301 #[test]
2302 fn insert_meta_overwrites_existing_meta() {
2303 let mut output = serde_json::json!({ "_meta": "old" });
2304 let meta = serde_json::json!({ "new": true });
2305 insert_meta(&mut output, meta.clone());
2306 assert_eq!(output["_meta"], meta);
2307 }
2308
2309 #[test]
2312 fn build_json_envelope_has_metadata_fields() {
2313 let report = serde_json::json!({ "findings": [] });
2314 let elapsed = Duration::from_millis(42);
2315 let output = build_json_envelope(report, elapsed);
2316
2317 assert_eq!(output["schema_version"], 4);
2318 assert!(output["version"].is_string());
2319 assert_eq!(output["elapsed_ms"], 42);
2320 assert!(output["findings"].is_array());
2321 }
2322
2323 #[test]
2324 fn build_json_envelope_metadata_appears_first() {
2325 let report = serde_json::json!({ "data": "value" });
2326 let output = build_json_envelope(report, Duration::from_millis(10));
2327
2328 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
2329 assert_eq!(keys[0], "schema_version");
2330 assert_eq!(keys[1], "version");
2331 assert_eq!(keys[2], "elapsed_ms");
2332 }
2333
2334 #[test]
2335 fn build_json_envelope_non_object_report() {
2336 let report = serde_json::json!("not an object");
2338 let output = build_json_envelope(report, Duration::from_millis(0));
2339
2340 let obj = output.as_object().unwrap();
2341 assert_eq!(obj.len(), 3);
2342 assert!(obj.contains_key("schema_version"));
2343 assert!(obj.contains_key("version"));
2344 assert!(obj.contains_key("elapsed_ms"));
2345 }
2346
2347 #[test]
2350 fn strip_root_prefix_null_unchanged() {
2351 let mut value = serde_json::Value::Null;
2352 strip_root_prefix(&mut value, "/project/");
2353 assert!(value.is_null());
2354 }
2355
2356 #[test]
2359 fn strip_root_prefix_empty_string() {
2360 let mut value = serde_json::json!("");
2361 strip_root_prefix(&mut value, "/project/");
2362 assert_eq!(value, "");
2363 }
2364
2365 #[test]
2368 fn strip_root_prefix_mixed_types() {
2369 let mut value = serde_json::json!({
2370 "path": "/project/src/file.ts",
2371 "line": 42,
2372 "flag": true,
2373 "nested": {
2374 "items": ["/project/a.ts", 99, null, "/project/b.ts"],
2375 "deep": { "path": "/project/c.ts" }
2376 }
2377 });
2378 strip_root_prefix(&mut value, "/project/");
2379 assert_eq!(value["path"], "src/file.ts");
2380 assert_eq!(value["line"], 42);
2381 assert_eq!(value["flag"], true);
2382 assert_eq!(value["nested"]["items"][0], "a.ts");
2383 assert_eq!(value["nested"]["items"][1], 99);
2384 assert!(value["nested"]["items"][2].is_null());
2385 assert_eq!(value["nested"]["items"][3], "b.ts");
2386 assert_eq!(value["nested"]["deep"]["path"], "c.ts");
2387 }
2388
2389 #[test]
2392 fn json_check_meta_integrates_correctly() {
2393 let root = PathBuf::from("/project");
2394 let results = AnalysisResults::default();
2395 let elapsed = Duration::from_millis(0);
2396 let mut output = build_json(&results, &root, elapsed).expect("should serialize");
2397 insert_meta(&mut output, crate::explain::check_meta());
2398
2399 assert!(output["_meta"]["docs"].is_string());
2400 assert!(output["_meta"]["rules"].is_object());
2401 }
2402
2403 #[test]
2406 fn json_unused_member_kind_serialized() {
2407 let root = PathBuf::from("/project");
2408 let mut results = AnalysisResults::default();
2409 results.unused_enum_members.push(UnusedMember {
2410 path: root.join("src/enums.ts"),
2411 parent_name: "Color".to_string(),
2412 member_name: "Red".to_string(),
2413 kind: MemberKind::EnumMember,
2414 line: 3,
2415 col: 2,
2416 });
2417 results.unused_class_members.push(UnusedMember {
2418 path: root.join("src/class.ts"),
2419 parent_name: "Foo".to_string(),
2420 member_name: "bar".to_string(),
2421 kind: MemberKind::ClassMethod,
2422 line: 10,
2423 col: 4,
2424 });
2425
2426 let elapsed = Duration::from_millis(0);
2427 let output = build_json(&results, &root, elapsed).expect("should serialize");
2428
2429 let enum_member = &output["unused_enum_members"][0];
2430 assert!(enum_member["kind"].is_string());
2431 let class_member = &output["unused_class_members"][0];
2432 assert!(class_member["kind"].is_string());
2433 }
2434
2435 #[test]
2438 fn json_unused_export_has_actions() {
2439 let root = PathBuf::from("/project");
2440 let mut results = AnalysisResults::default();
2441 results.unused_exports.push(UnusedExport {
2442 path: root.join("src/utils.ts"),
2443 export_name: "helperFn".to_string(),
2444 is_type_only: false,
2445 line: 10,
2446 col: 4,
2447 span_start: 120,
2448 is_re_export: false,
2449 });
2450 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2451
2452 let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2453 assert_eq!(actions.len(), 2);
2454
2455 assert_eq!(actions[0]["type"], "remove-export");
2457 assert_eq!(actions[0]["auto_fixable"], true);
2458 assert!(actions[0].get("note").is_none());
2459
2460 assert_eq!(actions[1]["type"], "suppress-line");
2462 assert_eq!(
2463 actions[1]["comment"],
2464 "// fallow-ignore-next-line unused-export"
2465 );
2466 }
2467
2468 #[test]
2469 fn json_unused_file_has_file_suppress_and_note() {
2470 let root = PathBuf::from("/project");
2471 let mut results = AnalysisResults::default();
2472 results.unused_files.push(UnusedFile {
2473 path: root.join("src/dead.ts"),
2474 });
2475 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2476
2477 let actions = output["unused_files"][0]["actions"].as_array().unwrap();
2478 assert_eq!(actions[0]["type"], "delete-file");
2479 assert_eq!(actions[0]["auto_fixable"], false);
2480 assert!(actions[0]["note"].is_string());
2481 assert_eq!(actions[1]["type"], "suppress-file");
2482 assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
2483 }
2484
2485 #[test]
2486 fn json_unused_dependency_has_config_suppress_with_package_name() {
2487 let root = PathBuf::from("/project");
2488 let mut results = AnalysisResults::default();
2489 results.unused_dependencies.push(UnusedDependency {
2490 package_name: "lodash".to_string(),
2491 location: DependencyLocation::Dependencies,
2492 path: root.join("package.json"),
2493 line: 5,
2494 });
2495 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2496
2497 let actions = output["unused_dependencies"][0]["actions"]
2498 .as_array()
2499 .unwrap();
2500 assert_eq!(actions[0]["type"], "remove-dependency");
2501 assert_eq!(actions[0]["auto_fixable"], true);
2502
2503 assert_eq!(actions[1]["type"], "add-to-config");
2505 assert_eq!(actions[1]["config_key"], "ignoreDependencies");
2506 assert_eq!(actions[1]["value"], "lodash");
2507 }
2508
2509 #[test]
2510 fn json_empty_results_have_no_actions_in_empty_arrays() {
2511 let root = PathBuf::from("/project");
2512 let results = AnalysisResults::default();
2513 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2514
2515 assert!(output["unused_exports"].as_array().unwrap().is_empty());
2517 assert!(output["unused_files"].as_array().unwrap().is_empty());
2518 }
2519
2520 #[test]
2521 fn json_all_issue_types_have_actions() {
2522 let root = PathBuf::from("/project");
2523 let results = sample_results(&root);
2524 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2525
2526 let issue_keys = [
2527 "unused_files",
2528 "unused_exports",
2529 "unused_types",
2530 "unused_dependencies",
2531 "unused_dev_dependencies",
2532 "unused_optional_dependencies",
2533 "unused_enum_members",
2534 "unused_class_members",
2535 "unresolved_imports",
2536 "unlisted_dependencies",
2537 "duplicate_exports",
2538 "type_only_dependencies",
2539 "test_only_dependencies",
2540 "circular_dependencies",
2541 ];
2542
2543 for key in &issue_keys {
2544 let arr = output[key].as_array().unwrap();
2545 if !arr.is_empty() {
2546 let actions = arr[0]["actions"].as_array();
2547 assert!(
2548 actions.is_some() && !actions.unwrap().is_empty(),
2549 "missing actions for {key}"
2550 );
2551 }
2552 }
2553 }
2554
2555 #[test]
2558 fn health_finding_has_actions() {
2559 let mut output = serde_json::json!({
2560 "findings": [{
2561 "path": "src/utils.ts",
2562 "name": "processData",
2563 "line": 10,
2564 "col": 0,
2565 "cyclomatic": 25,
2566 "cognitive": 30,
2567 "line_count": 150,
2568 "exceeded": "both"
2569 }]
2570 });
2571
2572 inject_health_actions(&mut output, HealthActionOptions::default());
2573
2574 let actions = output["findings"][0]["actions"].as_array().unwrap();
2575 assert_eq!(actions.len(), 2);
2576 assert_eq!(actions[0]["type"], "refactor-function");
2577 assert_eq!(actions[0]["auto_fixable"], false);
2578 assert!(
2579 actions[0]["description"]
2580 .as_str()
2581 .unwrap()
2582 .contains("processData")
2583 );
2584 assert_eq!(actions[1]["type"], "suppress-line");
2585 assert_eq!(
2586 actions[1]["comment"],
2587 "// fallow-ignore-next-line complexity"
2588 );
2589 }
2590
2591 #[test]
2592 fn refactoring_target_has_actions() {
2593 let mut output = serde_json::json!({
2594 "targets": [{
2595 "path": "src/big-module.ts",
2596 "priority": 85.0,
2597 "efficiency": 42.5,
2598 "recommendation": "Split module: 12 exports, 4 unused",
2599 "category": "split_high_impact",
2600 "effort": "medium",
2601 "confidence": "high",
2602 "evidence": { "unused_exports": 4 }
2603 }]
2604 });
2605
2606 inject_health_actions(&mut output, HealthActionOptions::default());
2607
2608 let actions = output["targets"][0]["actions"].as_array().unwrap();
2609 assert_eq!(actions.len(), 2);
2610 assert_eq!(actions[0]["type"], "apply-refactoring");
2611 assert_eq!(
2612 actions[0]["description"],
2613 "Split module: 12 exports, 4 unused"
2614 );
2615 assert_eq!(actions[0]["category"], "split_high_impact");
2616 assert_eq!(actions[1]["type"], "suppress-line");
2618 }
2619
2620 #[test]
2621 fn refactoring_target_without_evidence_has_no_suppress() {
2622 let mut output = serde_json::json!({
2623 "targets": [{
2624 "path": "src/simple.ts",
2625 "priority": 30.0,
2626 "efficiency": 15.0,
2627 "recommendation": "Consider extracting helper functions",
2628 "category": "extract_complex_functions",
2629 "effort": "small",
2630 "confidence": "medium"
2631 }]
2632 });
2633
2634 inject_health_actions(&mut output, HealthActionOptions::default());
2635
2636 let actions = output["targets"][0]["actions"].as_array().unwrap();
2637 assert_eq!(actions.len(), 1);
2638 assert_eq!(actions[0]["type"], "apply-refactoring");
2639 }
2640
2641 #[test]
2642 fn health_empty_findings_no_actions() {
2643 let mut output = serde_json::json!({
2644 "findings": [],
2645 "targets": []
2646 });
2647
2648 inject_health_actions(&mut output, HealthActionOptions::default());
2649
2650 assert!(output["findings"].as_array().unwrap().is_empty());
2651 assert!(output["targets"].as_array().unwrap().is_empty());
2652 }
2653
2654 #[test]
2655 fn hotspot_has_actions() {
2656 let mut output = serde_json::json!({
2657 "hotspots": [{
2658 "path": "src/utils.ts",
2659 "complexity_score": 45.0,
2660 "churn_score": 12,
2661 "hotspot_score": 540.0
2662 }]
2663 });
2664
2665 inject_health_actions(&mut output, HealthActionOptions::default());
2666
2667 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2668 assert_eq!(actions.len(), 2);
2669 assert_eq!(actions[0]["type"], "refactor-file");
2670 assert!(
2671 actions[0]["description"]
2672 .as_str()
2673 .unwrap()
2674 .contains("src/utils.ts")
2675 );
2676 assert_eq!(actions[1]["type"], "add-tests");
2677 }
2678
2679 #[test]
2680 fn hotspot_low_bus_factor_emits_action() {
2681 let mut output = serde_json::json!({
2682 "hotspots": [{
2683 "path": "src/api.ts",
2684 "ownership": {
2685 "bus_factor": 1,
2686 "contributor_count": 1,
2687 "top_contributor": {"identifier": "alice@x", "share": 1.0, "stale_days": 5, "commits": 30},
2688 "unowned": null,
2689 "drift": false,
2690 }
2691 }]
2692 });
2693
2694 inject_health_actions(&mut output, HealthActionOptions::default());
2695
2696 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2697 assert!(
2698 actions
2699 .iter()
2700 .filter_map(|a| a["type"].as_str())
2701 .any(|t| t == "low-bus-factor"),
2702 "low-bus-factor action should be present",
2703 );
2704 let bus = actions
2705 .iter()
2706 .find(|a| a["type"] == "low-bus-factor")
2707 .unwrap();
2708 assert!(bus["description"].as_str().unwrap().contains("alice@x"));
2709 }
2710
2711 #[test]
2712 fn hotspot_unowned_emits_action_with_pattern() {
2713 let mut output = serde_json::json!({
2714 "hotspots": [{
2715 "path": "src/api/users.ts",
2716 "ownership": {
2717 "bus_factor": 2,
2718 "contributor_count": 4,
2719 "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
2720 "unowned": true,
2721 "drift": false,
2722 }
2723 }]
2724 });
2725
2726 inject_health_actions(&mut output, HealthActionOptions::default());
2727
2728 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2729 let unowned = actions
2730 .iter()
2731 .find(|a| a["type"] == "unowned-hotspot")
2732 .expect("unowned-hotspot action should be present");
2733 assert_eq!(unowned["suggested_pattern"], "/src/api/");
2736 assert_eq!(unowned["heuristic"], "directory-deepest");
2737 }
2738
2739 #[test]
2740 fn hotspot_unowned_skipped_when_codeowners_missing() {
2741 let mut output = serde_json::json!({
2742 "hotspots": [{
2743 "path": "src/api.ts",
2744 "ownership": {
2745 "bus_factor": 2,
2746 "contributor_count": 4,
2747 "top_contributor": {"identifier": "alice@x", "share": 0.5, "stale_days": 5, "commits": 10},
2748 "unowned": null,
2749 "drift": false,
2750 }
2751 }]
2752 });
2753
2754 inject_health_actions(&mut output, HealthActionOptions::default());
2755
2756 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2757 assert!(
2758 !actions.iter().any(|a| a["type"] == "unowned-hotspot"),
2759 "unowned action must not fire when CODEOWNERS file is absent"
2760 );
2761 }
2762
2763 #[test]
2764 fn hotspot_drift_emits_action() {
2765 let mut output = serde_json::json!({
2766 "hotspots": [{
2767 "path": "src/old.ts",
2768 "ownership": {
2769 "bus_factor": 1,
2770 "contributor_count": 2,
2771 "top_contributor": {"identifier": "bob@x", "share": 0.9, "stale_days": 1, "commits": 18},
2772 "unowned": null,
2773 "drift": true,
2774 "drift_reason": "original author alice@x has 5% share",
2775 }
2776 }]
2777 });
2778
2779 inject_health_actions(&mut output, HealthActionOptions::default());
2780
2781 let actions = output["hotspots"][0]["actions"].as_array().unwrap();
2782 let drift = actions
2783 .iter()
2784 .find(|a| a["type"] == "ownership-drift")
2785 .expect("ownership-drift action should be present");
2786 assert!(drift["description"].as_str().unwrap().contains("alice@x"));
2787 }
2788
2789 #[test]
2792 fn codeowners_pattern_uses_deepest_directory() {
2793 assert_eq!(
2796 suggest_codeowners_pattern("src/api/users/handlers.ts"),
2797 "/src/api/users/"
2798 );
2799 }
2800
2801 #[test]
2802 fn codeowners_pattern_for_root_file() {
2803 assert_eq!(suggest_codeowners_pattern("README.md"), "/README.md");
2804 }
2805
2806 #[test]
2807 fn codeowners_pattern_normalizes_backslashes() {
2808 assert_eq!(
2809 suggest_codeowners_pattern("src\\api\\users.ts"),
2810 "/src/api/"
2811 );
2812 }
2813
2814 #[test]
2815 fn codeowners_pattern_two_level_path() {
2816 assert_eq!(suggest_codeowners_pattern("src/foo.ts"), "/src/");
2817 }
2818
2819 #[test]
2820 fn health_finding_suppress_has_placement() {
2821 let mut output = serde_json::json!({
2822 "findings": [{
2823 "path": "src/utils.ts",
2824 "name": "processData",
2825 "line": 10,
2826 "col": 0,
2827 "cyclomatic": 25,
2828 "cognitive": 30,
2829 "line_count": 150,
2830 "exceeded": "both"
2831 }]
2832 });
2833
2834 inject_health_actions(&mut output, HealthActionOptions::default());
2835
2836 let suppress = &output["findings"][0]["actions"][1];
2837 assert_eq!(suppress["placement"], "above-function-declaration");
2838 }
2839
2840 #[test]
2841 fn html_template_health_finding_uses_html_suppression() {
2842 let mut output = serde_json::json!({
2843 "findings": [{
2844 "path": "src/app.component.html",
2845 "name": "<template>",
2846 "line": 1,
2847 "col": 0,
2848 "cyclomatic": 25,
2849 "cognitive": 30,
2850 "line_count": 40,
2851 "exceeded": "both"
2852 }]
2853 });
2854
2855 inject_health_actions(&mut output, HealthActionOptions::default());
2856
2857 let suppress = &output["findings"][0]["actions"][1];
2858 assert_eq!(suppress["type"], "suppress-file");
2859 assert_eq!(
2860 suppress["comment"],
2861 "<!-- fallow-ignore-file complexity -->"
2862 );
2863 assert_eq!(suppress["placement"], "top-of-template");
2864 }
2865
2866 #[test]
2869 fn clone_family_has_actions() {
2870 let mut output = serde_json::json!({
2871 "clone_families": [{
2872 "files": ["src/a.ts", "src/b.ts"],
2873 "groups": [
2874 { "instances": [{"file": "src/a.ts"}, {"file": "src/b.ts"}], "token_count": 100, "line_count": 20 }
2875 ],
2876 "total_duplicated_lines": 20,
2877 "total_duplicated_tokens": 100,
2878 "suggestions": [
2879 { "kind": "ExtractFunction", "description": "Extract shared validation logic", "estimated_savings": 15 }
2880 ]
2881 }]
2882 });
2883
2884 inject_dupes_actions(&mut output);
2885
2886 let actions = output["clone_families"][0]["actions"].as_array().unwrap();
2887 assert_eq!(actions.len(), 3);
2888 assert_eq!(actions[0]["type"], "extract-shared");
2889 assert_eq!(actions[0]["auto_fixable"], false);
2890 assert!(
2891 actions[0]["description"]
2892 .as_str()
2893 .unwrap()
2894 .contains("20 lines")
2895 );
2896 assert_eq!(actions[1]["type"], "apply-suggestion");
2898 assert!(
2899 actions[1]["description"]
2900 .as_str()
2901 .unwrap()
2902 .contains("validation logic")
2903 );
2904 assert_eq!(actions[2]["type"], "suppress-line");
2906 assert_eq!(
2907 actions[2]["comment"],
2908 "// fallow-ignore-next-line code-duplication"
2909 );
2910 }
2911
2912 #[test]
2913 fn clone_group_has_actions() {
2914 let mut output = serde_json::json!({
2915 "clone_groups": [{
2916 "instances": [
2917 {"file": "src/a.ts", "start_line": 1, "end_line": 10},
2918 {"file": "src/b.ts", "start_line": 5, "end_line": 14}
2919 ],
2920 "token_count": 50,
2921 "line_count": 10
2922 }]
2923 });
2924
2925 inject_dupes_actions(&mut output);
2926
2927 let actions = output["clone_groups"][0]["actions"].as_array().unwrap();
2928 assert_eq!(actions.len(), 2);
2929 assert_eq!(actions[0]["type"], "extract-shared");
2930 assert!(
2931 actions[0]["description"]
2932 .as_str()
2933 .unwrap()
2934 .contains("10 lines")
2935 );
2936 assert!(
2937 actions[0]["description"]
2938 .as_str()
2939 .unwrap()
2940 .contains("2 instances")
2941 );
2942 assert_eq!(actions[1]["type"], "suppress-line");
2943 }
2944
2945 #[test]
2946 fn dupes_empty_results_no_actions() {
2947 let mut output = serde_json::json!({
2948 "clone_families": [],
2949 "clone_groups": []
2950 });
2951
2952 inject_dupes_actions(&mut output);
2953
2954 assert!(output["clone_families"].as_array().unwrap().is_empty());
2955 assert!(output["clone_groups"].as_array().unwrap().is_empty());
2956 }
2957
2958 fn crap_only_finding_envelope(
2967 coverage_tier: Option<&str>,
2968 cyclomatic: u16,
2969 max_cyclomatic_threshold: u16,
2970 ) -> serde_json::Value {
2971 crap_only_finding_envelope_with_max_crap(
2972 coverage_tier,
2973 cyclomatic,
2974 12,
2975 max_cyclomatic_threshold,
2976 15,
2977 30.0,
2978 )
2979 }
2980
2981 fn crap_only_finding_envelope_with_cognitive(
2982 coverage_tier: Option<&str>,
2983 cyclomatic: u16,
2984 cognitive: u16,
2985 max_cyclomatic_threshold: u16,
2986 ) -> serde_json::Value {
2987 crap_only_finding_envelope_with_max_crap(
2988 coverage_tier,
2989 cyclomatic,
2990 cognitive,
2991 max_cyclomatic_threshold,
2992 15,
2993 30.0,
2994 )
2995 }
2996
2997 fn crap_only_finding_envelope_with_max_crap(
2998 coverage_tier: Option<&str>,
2999 cyclomatic: u16,
3000 cognitive: u16,
3001 max_cyclomatic_threshold: u16,
3002 max_cognitive_threshold: u16,
3003 max_crap_threshold: f64,
3004 ) -> serde_json::Value {
3005 let mut finding = serde_json::json!({
3006 "path": "src/risk.ts",
3007 "name": "computeScore",
3008 "line": 12,
3009 "col": 0,
3010 "cyclomatic": cyclomatic,
3011 "cognitive": cognitive,
3012 "line_count": 40,
3013 "exceeded": "crap",
3014 "crap": 35.5,
3015 });
3016 if let Some(tier) = coverage_tier {
3017 finding["coverage_tier"] = serde_json::Value::String(tier.to_owned());
3018 }
3019 serde_json::json!({
3020 "findings": [finding],
3021 "summary": {
3022 "max_cyclomatic_threshold": max_cyclomatic_threshold,
3023 "max_cognitive_threshold": max_cognitive_threshold,
3024 "max_crap_threshold": max_crap_threshold,
3025 },
3026 })
3027 }
3028
3029 #[test]
3030 fn crap_only_tier_none_emits_add_tests() {
3031 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3032 inject_health_actions(&mut output, HealthActionOptions::default());
3033 let actions = output["findings"][0]["actions"].as_array().unwrap();
3034 assert!(
3035 actions.iter().any(|a| a["type"] == "add-tests"),
3036 "tier=none crap-only must emit add-tests, got {actions:?}"
3037 );
3038 assert!(
3039 !actions.iter().any(|a| a["type"] == "increase-coverage"),
3040 "tier=none must not emit increase-coverage"
3041 );
3042 }
3043
3044 #[test]
3045 fn crap_only_tier_partial_emits_increase_coverage() {
3046 let mut output = crap_only_finding_envelope(Some("partial"), 6, 20);
3047 inject_health_actions(&mut output, HealthActionOptions::default());
3048 let actions = output["findings"][0]["actions"].as_array().unwrap();
3049 assert!(
3050 actions.iter().any(|a| a["type"] == "increase-coverage"),
3051 "tier=partial crap-only must emit increase-coverage, got {actions:?}"
3052 );
3053 assert!(
3054 !actions.iter().any(|a| a["type"] == "add-tests"),
3055 "tier=partial must not emit add-tests"
3056 );
3057 }
3058
3059 #[test]
3060 fn crap_only_tier_high_emits_increase_coverage_when_full_coverage_can_clear_crap() {
3061 let mut output = crap_only_finding_envelope(Some("high"), 20, 30);
3065 inject_health_actions(&mut output, HealthActionOptions::default());
3066 let actions = output["findings"][0]["actions"].as_array().unwrap();
3067 assert!(
3068 actions.iter().any(|a| a["type"] == "increase-coverage"),
3069 "tier=high crap-only must still emit increase-coverage when full coverage can clear CRAP, got {actions:?}"
3070 );
3071 assert!(
3072 !actions.iter().any(|a| a["type"] == "refactor-function"),
3073 "coverage-remediable crap-only findings should not get refactor-function unless near the cyclomatic threshold"
3074 );
3075 assert!(
3076 !actions.iter().any(|a| a["type"] == "add-tests"),
3077 "tier=high must not emit add-tests"
3078 );
3079 }
3080
3081 #[test]
3082 fn crap_only_emits_refactor_when_full_coverage_cannot_clear_crap() {
3083 let mut output =
3087 crap_only_finding_envelope_with_max_crap(Some("high"), 35, 12, 50, 15, 30.0);
3088 inject_health_actions(&mut output, HealthActionOptions::default());
3089 let actions = output["findings"][0]["actions"].as_array().unwrap();
3090 assert!(
3091 actions.iter().any(|a| a["type"] == "refactor-function"),
3092 "full-coverage-impossible CRAP-only finding must emit refactor-function, got {actions:?}"
3093 );
3094 assert!(
3095 !actions.iter().any(|a| a["type"] == "increase-coverage"),
3096 "must not emit increase-coverage when even 100% coverage cannot clear CRAP"
3097 );
3098 assert!(
3099 !actions.iter().any(|a| a["type"] == "add-tests"),
3100 "must not emit add-tests when even 100% coverage cannot clear CRAP"
3101 );
3102 }
3103
3104 #[test]
3105 fn crap_only_high_cc_appends_secondary_refactor() {
3106 let mut output = crap_only_finding_envelope(Some("none"), 16, 20);
3109 inject_health_actions(&mut output, HealthActionOptions::default());
3110 let actions = output["findings"][0]["actions"].as_array().unwrap();
3111 assert!(
3112 actions.iter().any(|a| a["type"] == "add-tests"),
3113 "near-threshold crap-only still emits the primary tier action"
3114 );
3115 assert!(
3116 actions.iter().any(|a| a["type"] == "refactor-function"),
3117 "near-threshold crap-only must also emit secondary refactor-function"
3118 );
3119 }
3120
3121 #[test]
3122 fn crap_only_far_below_threshold_no_secondary_refactor() {
3123 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3125 inject_health_actions(&mut output, HealthActionOptions::default());
3126 let actions = output["findings"][0]["actions"].as_array().unwrap();
3127 assert!(
3128 !actions.iter().any(|a| a["type"] == "refactor-function"),
3129 "low-CC crap-only should not get a secondary refactor-function"
3130 );
3131 }
3132
3133 #[test]
3134 fn crap_only_near_threshold_low_cognitive_no_secondary_refactor() {
3135 let mut output = crap_only_finding_envelope_with_cognitive(Some("none"), 17, 2, 20);
3144 inject_health_actions(&mut output, HealthActionOptions::default());
3145 let actions = output["findings"][0]["actions"].as_array().unwrap();
3146 assert!(
3147 actions.iter().any(|a| a["type"] == "add-tests"),
3148 "primary tier action still emits"
3149 );
3150 assert!(
3151 !actions.iter().any(|a| a["type"] == "refactor-function"),
3152 "near-threshold CC with cognitive below floor must NOT emit secondary refactor (got {actions:?})"
3153 );
3154 }
3155
3156 #[test]
3157 fn crap_only_near_threshold_high_cognitive_emits_secondary_refactor() {
3158 let mut output = crap_only_finding_envelope_with_cognitive(Some("none"), 16, 10, 20);
3164 inject_health_actions(&mut output, HealthActionOptions::default());
3165 let actions = output["findings"][0]["actions"].as_array().unwrap();
3166 assert!(
3167 actions.iter().any(|a| a["type"] == "add-tests"),
3168 "primary tier action still emits"
3169 );
3170 assert!(
3171 actions.iter().any(|a| a["type"] == "refactor-function"),
3172 "near-threshold CC with cognitive above floor must emit secondary refactor (got {actions:?})"
3173 );
3174 }
3175
3176 #[test]
3177 fn cyclomatic_only_emits_only_refactor_function() {
3178 let mut output = serde_json::json!({
3179 "findings": [{
3180 "path": "src/cyclo.ts",
3181 "name": "branchy",
3182 "line": 5,
3183 "col": 0,
3184 "cyclomatic": 25,
3185 "cognitive": 10,
3186 "line_count": 80,
3187 "exceeded": "cyclomatic",
3188 }],
3189 "summary": { "max_cyclomatic_threshold": 20 },
3190 });
3191 inject_health_actions(&mut output, HealthActionOptions::default());
3192 let actions = output["findings"][0]["actions"].as_array().unwrap();
3193 assert!(
3194 actions.iter().any(|a| a["type"] == "refactor-function"),
3195 "non-CRAP findings emit refactor-function"
3196 );
3197 assert!(
3198 !actions.iter().any(|a| a["type"] == "add-tests"),
3199 "non-CRAP findings must not emit add-tests"
3200 );
3201 assert!(
3202 !actions.iter().any(|a| a["type"] == "increase-coverage"),
3203 "non-CRAP findings must not emit increase-coverage"
3204 );
3205 }
3206
3207 #[test]
3210 fn suppress_line_omitted_when_baseline_active() {
3211 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3212 inject_health_actions(
3213 &mut output,
3214 HealthActionOptions {
3215 omit_suppress_line: true,
3216 omit_reason: Some("baseline-active"),
3217 },
3218 );
3219 let actions = output["findings"][0]["actions"].as_array().unwrap();
3220 assert!(
3221 !actions.iter().any(|a| a["type"] == "suppress-line"),
3222 "baseline-active must not emit suppress-line, got {actions:?}"
3223 );
3224 assert_eq!(
3225 output["actions_meta"]["suppression_hints_omitted"],
3226 serde_json::Value::Bool(true)
3227 );
3228 assert_eq!(output["actions_meta"]["reason"], "baseline-active");
3229 assert_eq!(output["actions_meta"]["scope"], "health-findings");
3230 }
3231
3232 #[test]
3233 fn suppress_line_omitted_when_config_disabled() {
3234 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3235 inject_health_actions(
3236 &mut output,
3237 HealthActionOptions {
3238 omit_suppress_line: true,
3239 omit_reason: Some("config-disabled"),
3240 },
3241 );
3242 assert_eq!(output["actions_meta"]["reason"], "config-disabled");
3243 }
3244
3245 #[test]
3246 fn suppress_line_emitted_by_default() {
3247 let mut output = crap_only_finding_envelope(Some("none"), 6, 20);
3248 inject_health_actions(&mut output, HealthActionOptions::default());
3249 let actions = output["findings"][0]["actions"].as_array().unwrap();
3250 assert!(
3251 actions.iter().any(|a| a["type"] == "suppress-line"),
3252 "default opts must emit suppress-line"
3253 );
3254 assert!(
3255 output.get("actions_meta").is_none(),
3256 "actions_meta must be absent when no omission occurred"
3257 );
3258 }
3259
3260 #[test]
3267 fn every_emitted_health_action_type_is_in_schema_enum() {
3268 let cases = [
3272 ("crap", Some("none"), 6_u16, 20_u16),
3274 ("crap", Some("partial"), 6, 20),
3275 ("crap", Some("high"), 12, 20),
3276 ("crap", Some("none"), 16, 20), ("cyclomatic", None, 25, 20),
3278 ("cognitive_crap", Some("partial"), 6, 20),
3279 ("all", Some("none"), 25, 20),
3280 ];
3281
3282 let mut emitted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3283 for (exceeded, tier, cc, max) in cases {
3284 let mut finding = serde_json::json!({
3285 "path": "src/x.ts",
3286 "name": "fn",
3287 "line": 1,
3288 "col": 0,
3289 "cyclomatic": cc,
3290 "cognitive": 5,
3291 "line_count": 10,
3292 "exceeded": exceeded,
3293 "crap": 35.0,
3294 });
3295 if let Some(t) = tier {
3296 finding["coverage_tier"] = serde_json::Value::String(t.to_owned());
3297 }
3298 let mut output = serde_json::json!({
3299 "findings": [finding],
3300 "summary": { "max_cyclomatic_threshold": max },
3301 });
3302 inject_health_actions(&mut output, HealthActionOptions::default());
3303 for action in output["findings"][0]["actions"].as_array().unwrap() {
3304 if let Some(ty) = action["type"].as_str() {
3305 emitted.insert(ty.to_owned());
3306 }
3307 }
3308 }
3309
3310 let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3312 .join("..")
3313 .join("..")
3314 .join("docs")
3315 .join("output-schema.json");
3316 let raw = std::fs::read_to_string(&schema_path)
3317 .expect("docs/output-schema.json must be readable for the drift-guard test");
3318 let schema: serde_json::Value = serde_json::from_str(&raw).expect("schema parses");
3319 let enum_values: std::collections::BTreeSet<String> =
3320 schema["definitions"]["HealthFindingAction"]["properties"]["type"]["enum"]
3321 .as_array()
3322 .expect("HealthFindingAction.type.enum is an array")
3323 .iter()
3324 .filter_map(|v| v.as_str().map(str::to_owned))
3325 .collect();
3326
3327 for ty in &emitted {
3328 assert!(
3329 enum_values.contains(ty),
3330 "build_health_finding_actions emitted action type `{ty}` but \
3331 docs/output-schema.json HealthFindingAction.type enum does \
3332 not list it. Add it to the schema (and any downstream \
3333 typed consumers) when introducing a new action type."
3334 );
3335 }
3336 }
3337}