1use crate::report::sink::outln;
2use std::collections::BTreeMap;
3use std::path::Path;
4use std::process::ExitCode;
5use std::time::Duration;
6
7use fallow_core::duplicates::DuplicationReport;
8use fallow_core::results::AnalysisResults;
9use fallow_types::envelope::{CheckSummary, ElapsedMs, EntryPoints, SchemaVersion, ToolVersion};
10
11use super::{emit_json, normalize_uri};
12use crate::explain;
13use crate::output_dupes::DupesReportPayload;
14use crate::output_envelope::{
15 CheckGroupedEntry, CheckGroupedOutput, CheckOutput, DupesOutput, FallowOutput, GroupByMode,
16 HealthOutput, serialize_root_output,
17};
18use crate::report::grouping::{OwnershipResolver, ResultGroup};
19
20fn apply_config_fixable_to_duplicate_exports(results: &mut AnalysisResults, config_fixable: bool) {
21 if !config_fixable {
22 return;
23 }
24 for finding in &mut results.duplicate_exports {
25 finding.set_config_fixable(true);
26 }
27}
28
29pub(super) struct PrintJsonInput<'a> {
30 pub(super) results: &'a AnalysisResults,
31 pub(super) root: &'a Path,
32 pub(super) elapsed: Duration,
33 pub(super) explain: bool,
34 pub(super) regression: Option<&'a crate::regression::RegressionOutcome>,
35 pub(super) baseline_matched: Option<(usize, usize)>,
36 pub(super) config_fixable: bool,
37}
38
39pub(super) fn print_json(input: &PrintJsonInput<'_>) -> ExitCode {
40 let results = input.results;
41 let root = input.root;
42 let elapsed = input.elapsed;
43 let explain = input.explain;
44 let regression = input.regression;
45 let baseline_matched = input.baseline_matched;
46 let config_fixable = input.config_fixable;
47 match build_json_with_config_fixable(results, root, elapsed, config_fixable) {
48 Ok(mut output) => {
49 if let Some(outcome) = regression
50 && let serde_json::Value::Object(ref mut map) = output
51 {
52 map.insert("regression".to_string(), outcome.to_json());
53 }
54 if let Some((entries, matched)) = baseline_matched
55 && let serde_json::Value::Object(ref mut map) = output
56 {
57 map.insert(
58 "baseline".to_string(),
59 serde_json::json!({
60 "entries": entries,
61 "matched": matched,
62 }),
63 );
64 }
65 if explain {
66 insert_meta(&mut output, explain::check_meta());
67 }
68 emit_json(&output, "JSON")
69 }
70 Err(e) => {
71 eprintln!("Error: failed to serialize results: {e}");
72 ExitCode::from(2)
73 }
74 }
75}
76
77#[must_use]
78pub(super) struct PrintGroupedJsonInput<'a> {
79 pub(super) groups: &'a [ResultGroup],
80 pub(super) original: &'a AnalysisResults,
81 pub(super) root: &'a Path,
82 pub(super) elapsed: Duration,
83 pub(super) explain: bool,
84 pub(super) resolver: &'a OwnershipResolver,
85 pub(super) config_fixable: bool,
86}
87
88pub(super) fn print_grouped_json(input: &PrintGroupedJsonInput<'_>) -> ExitCode {
89 let groups = input.groups;
90 let original = input.original;
91 let root = input.root;
92 let elapsed = input.elapsed;
93 let explain = input.explain;
94 let resolver = input.resolver;
95 let config_fixable = input.config_fixable;
96 let entries: Vec<CheckGroupedEntry> = groups
97 .iter()
98 .map(|group| {
99 let mut results = group.results.clone();
100 apply_config_fixable_to_duplicate_exports(&mut results, config_fixable);
101 CheckGroupedEntry {
102 key: group.key.clone(),
103 owners: group.owners.clone(),
104 total_issues: results.total_issues(),
105 results,
106 }
107 })
108 .collect();
109
110 let envelope = CheckGroupedOutput {
111 schema_version: SchemaVersion(SCHEMA_VERSION),
112 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
113 elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
114 grouped_by: group_by_mode_from_label(resolver.mode_label()),
115 total_issues: original.total_issues(),
116 groups: entries,
117 meta: None,
118 next_steps: crate::report::suggestions::build_dead_code_next_steps(original, root),
119 };
120
121 let mut output = match serialize_root_output(FallowOutput::CheckGrouped(envelope)) {
122 Ok(value) => value,
123 Err(e) => {
124 eprintln!("Error: failed to serialize grouped results: {e}");
125 return ExitCode::from(2);
126 }
127 };
128
129 let root_prefix = format!("{}/", root.display());
130 if let Some(arr) = output.get_mut("groups").and_then(|v| v.as_array_mut()) {
131 for entry in arr {
132 strip_root_prefix(entry, &root_prefix);
133 harmonize_multi_kind_suppress_line_actions(entry);
134 }
135 }
136
137 if explain {
138 insert_meta(&mut output, explain::check_meta());
139 }
140
141 emit_json(&output, "JSON")
142}
143
144#[allow(
145 clippy::redundant_pub_crate,
146 reason = "used through report module re-export by combined.rs, audit.rs, flags.rs"
147)]
148pub(crate) const SCHEMA_VERSION: u32 = 7;
149
150#[allow(
151 dead_code,
152 reason = "used by the fallow-cli library target for embedders, but dead in the binary target"
153)]
154pub fn build_json(
155 results: &AnalysisResults,
156 root: &Path,
157 elapsed: Duration,
158) -> Result<serde_json::Value, serde_json::Error> {
159 build_json_with_config_fixable(
160 results,
161 root,
162 elapsed,
163 crate::fix::is_config_fixable(root, None),
164 )
165}
166
167pub fn build_json_with_config_fixable(
168 results: &AnalysisResults,
169 root: &Path,
170 elapsed: Duration,
171 config_fixable: bool,
172) -> Result<serde_json::Value, serde_json::Error> {
173 let mut envelope = build_check_output(results, root, elapsed, config_fixable);
174 envelope.next_steps = crate::report::suggestions::build_dead_code_next_steps(results, root);
175 let mut output = serialize_root_output(FallowOutput::Check(envelope))?;
176 postprocess_check_json(&mut output, root);
177 Ok(output)
178}
179
180pub fn build_check_json_payload_with_config_fixable(
181 results: &AnalysisResults,
182 root: &Path,
183 elapsed: Duration,
184 config_fixable: bool,
185) -> Result<serde_json::Value, serde_json::Error> {
186 let envelope = build_check_output(results, root, elapsed, config_fixable);
187 let mut output = serde_json::to_value(&envelope)?;
188 postprocess_check_json(&mut output, root);
189 Ok(output)
190}
191
192fn build_check_output(
193 results: &AnalysisResults,
194 root: &Path,
195 elapsed: Duration,
196 config_fixable: bool,
197) -> CheckOutput {
198 let mut owned_results = results.clone();
199 apply_config_fixable_to_duplicate_exports(&mut owned_results, config_fixable);
200 CheckOutput {
201 schema_version: SchemaVersion(SCHEMA_VERSION),
202 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
203 elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
204 total_issues: owned_results.total_issues(),
205 entry_points: owned_results
206 .entry_point_summary
207 .as_ref()
208 .map(|ep| EntryPoints {
209 total: ep.total,
210 sources: ep
211 .by_source
212 .iter()
213 .map(|(k, v)| (k.replace(' ', "_"), *v))
214 .collect(),
215 }),
216 summary: build_check_summary(&owned_results),
217 results: owned_results,
218 baseline_deltas: None,
219 baseline: None,
220 regression: None,
221 meta: None,
222 workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
223 next_steps: Vec::new(),
227 }
228}
229
230fn postprocess_check_json(output: &mut serde_json::Value, root: &Path) {
231 let root_prefix = format!("{}/", root.display());
232 strip_root_prefix(output, &root_prefix);
233 harmonize_multi_kind_suppress_line_actions(output);
234}
235
236fn build_check_summary(results: &AnalysisResults) -> CheckSummary {
238 CheckSummary {
239 total_issues: results.total_issues(),
240 unused_files: results.unused_files.len(),
241 unused_exports: results.unused_exports.len(),
242 unused_types: results.unused_types.len(),
243 private_type_leaks: results.private_type_leaks.len(),
244 unused_dependencies: results.unused_dependencies.len()
245 + results.unused_dev_dependencies.len()
246 + results.unused_optional_dependencies.len(),
247 unused_enum_members: results.unused_enum_members.len(),
248 unused_class_members: results.unused_class_members.len(),
249 unresolved_imports: results.unresolved_imports.len(),
250 unlisted_dependencies: results.unlisted_dependencies.len(),
251 duplicate_exports: results.duplicate_exports.len(),
252 type_only_dependencies: results.type_only_dependencies.len(),
253 test_only_dependencies: results.test_only_dependencies.len(),
254 circular_dependencies: results.circular_dependencies.len(),
255 re_export_cycles: results.re_export_cycles.len(),
256 boundary_violations: results.boundary_violations.len(),
257 boundary_coverage_violations: results.boundary_coverage_violations.len(),
258 boundary_call_violations: results.boundary_call_violations.len(),
259 policy_violations: results.policy_violations.len(),
260 stale_suppressions: results.stale_suppressions.len(),
261 unused_catalog_entries: results.unused_catalog_entries.len(),
262 empty_catalog_groups: results.empty_catalog_groups.len(),
263 unresolved_catalog_references: results.unresolved_catalog_references.len(),
264 unused_dependency_overrides: results.unused_dependency_overrides.len(),
265 misconfigured_dependency_overrides: results.misconfigured_dependency_overrides.len(),
266 }
267}
268
269pub fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
274 match value {
275 serde_json::Value::String(s) => {
276 if let Some(rest) = s.strip_prefix(prefix) {
277 *s = rest.to_string();
278 } else {
279 let normalized = normalize_uri(s);
280 let normalized_prefix = normalize_uri(prefix);
281 if let Some(rest) = normalized.strip_prefix(&normalized_prefix) {
282 *s = rest.to_string();
283 } else if let Some(stripped) =
284 strip_embedded_root_prefixes(&normalized, &normalized_prefix)
285 {
286 *s = stripped;
287 }
288 }
289 }
290 serde_json::Value::Array(arr) => {
291 for item in arr {
292 strip_root_prefix(item, prefix);
293 }
294 }
295 serde_json::Value::Object(map) => {
296 for (_, v) in map.iter_mut() {
297 strip_root_prefix(v, prefix);
298 }
299 }
300 _ => {}
301 }
302}
303
304fn strip_embedded_root_prefixes(value: &str, prefix: &str) -> Option<String> {
305 let mut output = String::with_capacity(value.len());
306 let mut changed = false;
307 let mut last = 0;
308 let mut search_from = 0;
309
310 while let Some(offset) = value[search_from..].find(prefix) {
311 let index = search_from + offset;
312 let can_strip = index > 0
313 && value[..index]
314 .chars()
315 .next_back()
316 .is_some_and(is_embedded_path_boundary);
317
318 if can_strip {
319 output.push_str(&value[last..index]);
320 last = index + prefix.len();
321 changed = true;
322 }
323
324 search_from = index + prefix.len();
325 }
326
327 if changed {
328 output.push_str(&value[last..]);
329 Some(output)
330 } else {
331 None
332 }
333}
334
335fn is_embedded_path_boundary(c: char) -> bool {
336 c.is_whitespace() || matches!(c, '"' | '\'' | '`' | '(' | '[' | '{' | ':' | '=')
337}
338
339type SuppressAnchor = (String, u64);
340
341#[allow(
342 clippy::redundant_pub_crate,
343 reason = "used through report module re-export by audit.rs"
344)]
345pub(crate) fn harmonize_multi_kind_suppress_line_actions(output: &mut serde_json::Value) {
346 let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
347 collect_suppress_line_anchors(output, &mut anchors);
348
349 anchors.retain(|_, kinds| {
350 sort_suppression_kinds(kinds);
351 kinds.dedup();
352 kinds.len() > 1
353 });
354 if anchors.is_empty() {
355 return;
356 }
357
358 rewrite_suppress_line_actions(output, &anchors);
359}
360
361fn collect_suppress_line_anchors(
362 value: &serde_json::Value,
363 anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
364) {
365 match value {
366 serde_json::Value::Object(map) => {
367 if let Some(anchor) = suppression_anchor(map)
368 && let Some(actions) = map.get("actions").and_then(serde_json::Value::as_array)
369 {
370 for action in actions {
371 if let Some(comment) = suppress_line_comment(action) {
372 for kind in parse_suppress_line_comment(comment) {
373 let kinds = anchors.entry(anchor.clone()).or_default();
374 if !kinds.iter().any(|existing| existing == &kind) {
375 kinds.push(kind);
376 }
377 }
378 }
379 }
380 }
381
382 for child in map.values() {
383 collect_suppress_line_anchors(child, anchors);
384 }
385 }
386 serde_json::Value::Array(items) => {
387 for item in items {
388 collect_suppress_line_anchors(item, anchors);
389 }
390 }
391 _ => {}
392 }
393}
394
395fn rewrite_suppress_line_actions(
396 value: &mut serde_json::Value,
397 anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
398) {
399 match value {
400 serde_json::Value::Object(map) => {
401 if let Some(anchor) = suppression_anchor(map)
402 && let Some(kinds) = anchors.get(&anchor)
403 {
404 let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
405 if let Some(actions) = map
406 .get_mut("actions")
407 .and_then(serde_json::Value::as_array_mut)
408 {
409 for action in actions {
410 if suppress_line_comment(action).is_some()
411 && let serde_json::Value::Object(action_map) = action
412 {
413 action_map.insert("comment".to_string(), serde_json::json!(comment));
414 }
415 }
416 }
417 }
418
419 for child in map.values_mut() {
420 rewrite_suppress_line_actions(child, anchors);
421 }
422 }
423 serde_json::Value::Array(items) => {
424 for item in items {
425 rewrite_suppress_line_actions(item, anchors);
426 }
427 }
428 _ => {}
429 }
430}
431
432fn suppression_anchor(map: &serde_json::Map<String, serde_json::Value>) -> Option<SuppressAnchor> {
433 let path = map
434 .get("path")
435 .or_else(|| map.get("from_path"))
436 .and_then(serde_json::Value::as_str)?;
437 let line = map.get("line").and_then(serde_json::Value::as_u64)?;
438 Some((path.to_string(), line))
439}
440
441fn suppress_line_comment(action: &serde_json::Value) -> Option<&str> {
442 (action.get("type").and_then(serde_json::Value::as_str) == Some("suppress-line"))
443 .then_some(())
444 .and_then(|()| action.get("comment").and_then(serde_json::Value::as_str))
445}
446
447fn parse_suppress_line_comment(comment: &str) -> Vec<String> {
448 comment
449 .strip_prefix("// fallow-ignore-next-line ")
450 .map(|rest| {
451 rest.split(|c: char| c == ',' || c.is_whitespace())
452 .filter(|token| !token.is_empty())
453 .map(str::to_string)
454 .collect()
455 })
456 .unwrap_or_default()
457}
458
459fn sort_suppression_kinds(kinds: &mut [String]) {
460 kinds.sort_by_key(|kind| suppression_kind_rank(kind));
461}
462
463fn suppression_kind_rank(kind: &str) -> usize {
464 match kind {
465 "unused-file" => 0,
466 "unused-export" => 1,
467 "unused-type" => 2,
468 "private-type-leak" => 3,
469 "unused-enum-member" => 4,
470 "unused-class-member" => 5,
471 "unresolved-import" => 6,
472 "unlisted-dependency" => 7,
473 "duplicate-export" => 8,
474 "circular-dependency" => 9,
475 "re-export-cycle" => 10,
476 "boundary-violation" => 11,
477 "code-duplication" => 12,
478 "complexity" => 13,
479 _ => usize::MAX,
480 }
481}
482
483pub fn build_baseline_deltas_json<'a>(
484 total_delta: i64,
485 per_category: impl Iterator<Item = (&'a str, usize, usize, i64)>,
486) -> serde_json::Value {
487 let mut per_cat = serde_json::Map::new();
488 for (cat, current, baseline, delta) in per_category {
489 per_cat.insert(
490 cat.to_string(),
491 serde_json::json!({
492 "current": current,
493 "baseline": baseline,
494 "delta": delta,
495 }),
496 );
497 }
498 serde_json::json!({
499 "total_delta": total_delta,
500 "per_category": per_cat
501 })
502}
503
504fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
506 if let serde_json::Value::Object(map) = output {
507 let telemetry = map
508 .get("_meta")
509 .and_then(|existing| existing.get("telemetry"))
510 .cloned();
511 let mut meta = meta;
512 if let (Some(telemetry), Some(meta_map)) = (telemetry, meta.as_object_mut()) {
513 meta_map.insert("telemetry".to_string(), telemetry);
514 }
515 map.insert("_meta".to_string(), meta);
516 }
517}
518
519pub fn build_health_json(
520 report: &crate::health_types::HealthReport,
521 root: &Path,
522 elapsed: Duration,
523 explain: bool,
524) -> Result<serde_json::Value, serde_json::Error> {
525 let envelope = HealthOutput {
526 schema_version: SchemaVersion(SCHEMA_VERSION),
527 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
528 elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
529 report: report.clone(),
530 grouped_by: None,
531 groups: None,
532 meta: None,
533 workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
534 next_steps: crate::report::suggestions::build_health_next_steps(report, root),
535 };
536 let mut output = serialize_root_output(FallowOutput::Health(envelope))?;
537 let root_prefix = format!("{}/", root.display());
538 strip_root_prefix(&mut output, &root_prefix);
539 if explain {
540 insert_meta(&mut output, explain::health_meta());
541 }
542 Ok(output)
543}
544
545pub(super) fn print_health_json(
546 report: &crate::health_types::HealthReport,
547 root: &Path,
548 elapsed: Duration,
549 explain: bool,
550) -> ExitCode {
551 match build_health_json(report, root, elapsed, explain) {
552 Ok(output) => emit_json(&output, "JSON"),
553 Err(e) => {
554 eprintln!("Error: failed to serialize health report: {e}");
555 ExitCode::from(2)
556 }
557 }
558}
559
560pub fn build_grouped_health_json(
561 report: &crate::health_types::HealthReport,
562 grouping: &crate::health_types::HealthGrouping,
563 root: &Path,
564 elapsed: Duration,
565 explain: bool,
566) -> Result<serde_json::Value, serde_json::Error> {
567 let root_prefix = format!("{}/", root.display());
568 let envelope = HealthOutput {
569 schema_version: SchemaVersion(SCHEMA_VERSION),
570 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
571 elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
572 report: report.clone(),
573 grouped_by: Some(group_by_mode_from_label(grouping.mode)),
574 groups: None,
575 meta: None,
576 workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
577 next_steps: crate::report::suggestions::build_health_next_steps(report, root),
578 };
579 let mut output = serialize_root_output(FallowOutput::Health(envelope))?;
580 strip_root_prefix(&mut output, &root_prefix);
581
582 let group_values: Vec<serde_json::Value> = grouping
583 .groups
584 .iter()
585 .map(|g| {
586 let mut value = serde_json::to_value(g)?;
587 strip_root_prefix(&mut value, &root_prefix);
588 Ok(value)
589 })
590 .collect::<Result<_, serde_json::Error>>()?;
591
592 if let serde_json::Value::Object(ref mut map) = output {
593 map.insert("groups".to_string(), serde_json::Value::Array(group_values));
594 }
595
596 if explain {
597 insert_meta(&mut output, explain::health_meta());
598 }
599
600 Ok(output)
601}
602
603pub(super) fn print_grouped_health_json(
604 report: &crate::health_types::HealthReport,
605 grouping: &crate::health_types::HealthGrouping,
606 root: &Path,
607 elapsed: Duration,
608 explain: bool,
609) -> ExitCode {
610 match build_grouped_health_json(report, grouping, root, elapsed, explain) {
611 Ok(output) => emit_json(&output, "JSON"),
612 Err(e) => {
613 eprintln!("Error: failed to serialize grouped health report: {e}");
614 ExitCode::from(2)
615 }
616 }
617}
618
619pub fn build_duplication_json(
620 report: &DuplicationReport,
621 root: &Path,
622 elapsed: Duration,
623 explain: bool,
624) -> Result<serde_json::Value, serde_json::Error> {
625 let payload = DupesReportPayload::from_report(report);
626 let next_steps = crate::report::suggestions::build_dupes_next_steps(&payload, root);
627 let envelope = DupesOutput {
628 schema_version: SchemaVersion(SCHEMA_VERSION),
629 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
630 elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
631 report: payload,
632 grouped_by: None,
633 total_issues: None,
634 groups: None,
635 meta: None,
636 workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
637 next_steps,
638 };
639 let mut output = serialize_root_output(FallowOutput::Dupes(envelope))?;
640 let root_prefix = format!("{}/", root.display());
641 strip_root_prefix(&mut output, &root_prefix);
642
643 if explain {
644 insert_meta(&mut output, explain::dupes_meta());
645 }
646
647 Ok(output)
648}
649
650pub(super) fn print_duplication_json(
651 report: &DuplicationReport,
652 root: &Path,
653 elapsed: Duration,
654 explain: bool,
655) -> ExitCode {
656 match build_duplication_json(report, root, elapsed, explain) {
657 Ok(output) => emit_json(&output, "JSON"),
658 Err(e) => {
659 eprintln!("Error: failed to serialize duplication report: {e}");
660 ExitCode::from(2)
661 }
662 }
663}
664
665pub fn build_grouped_duplication_json(
666 report: &DuplicationReport,
667 grouping: &super::dupes_grouping::DuplicationGrouping,
668 root: &Path,
669 elapsed: Duration,
670 explain: bool,
671) -> Result<serde_json::Value, serde_json::Error> {
672 let root_prefix = format!("{}/", root.display());
673 let payload = DupesReportPayload::from_report(report);
674 let next_steps = crate::report::suggestions::build_dupes_next_steps(&payload, root);
675 let envelope = DupesOutput {
676 schema_version: SchemaVersion(SCHEMA_VERSION),
677 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
678 elapsed_ms: ElapsedMs(elapsed.as_millis() as u64),
679 report: payload,
680 grouped_by: Some(group_by_mode_from_label(grouping.mode)),
681 total_issues: Some(report.clone_groups.len()),
682 groups: None,
683 meta: None,
684 workspace_diagnostics: crate::runtime_support::workspace_diagnostics_for(root),
685 next_steps,
686 };
687 let mut output = serialize_root_output(FallowOutput::Dupes(envelope))?;
688 strip_root_prefix(&mut output, &root_prefix);
689
690 let group_values: Vec<serde_json::Value> = grouping
691 .groups
692 .iter()
693 .map(|g| {
694 let mut value = serde_json::to_value(g)?;
695 strip_root_prefix(&mut value, &root_prefix);
696 Ok(value)
697 })
698 .collect::<Result<_, serde_json::Error>>()?;
699
700 if let serde_json::Value::Object(ref mut map) = output {
701 map.insert("groups".to_string(), serde_json::Value::Array(group_values));
702 }
703
704 if explain {
705 insert_meta(&mut output, explain::dupes_meta());
706 }
707
708 Ok(output)
709}
710
711fn group_by_mode_from_label(label: &str) -> GroupByMode {
712 match label {
713 "directory" => GroupByMode::Directory,
714 "package" => GroupByMode::Package,
715 "section" => GroupByMode::Section,
716 _ => GroupByMode::Owner,
717 }
718}
719
720pub(super) fn print_grouped_duplication_json(
721 report: &DuplicationReport,
722 grouping: &super::dupes_grouping::DuplicationGrouping,
723 root: &Path,
724 elapsed: Duration,
725 explain: bool,
726) -> ExitCode {
727 match build_grouped_duplication_json(report, grouping, root, elapsed, explain) {
728 Ok(output) => emit_json(&output, "JSON"),
729 Err(e) => {
730 eprintln!("Error: failed to serialize grouped duplication report: {e}");
731 ExitCode::from(2)
732 }
733 }
734}
735
736pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
737 match serde_json::to_string_pretty(value) {
738 Ok(json) => outln!("{json}"),
739 Err(e) => {
740 eprintln!("Error: failed to serialize trace output: {e}");
741 #[expect(
742 clippy::exit,
743 reason = "fatal serialization error requires immediate exit"
744 )]
745 std::process::exit(2);
746 }
747 }
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753 use crate::health_types::{
754 RuntimeCoverageAction, RuntimeCoverageConfidence, RuntimeCoverageDataSource,
755 RuntimeCoverageEvidence, RuntimeCoverageFinding, RuntimeCoverageHotPath,
756 RuntimeCoverageMessage, RuntimeCoverageReport, RuntimeCoverageReportVerdict,
757 RuntimeCoverageSchemaVersion, RuntimeCoverageSummary, RuntimeCoverageVerdict,
758 RuntimeCoverageWatermark,
759 };
760 use crate::report::test_helpers::sample_results;
761 use fallow_core::extract::MemberKind;
762 use fallow_core::results::*;
763 use std::path::PathBuf;
764 use std::time::Duration;
765
766 #[test]
767 fn json_output_has_metadata_fields() {
768 let root = PathBuf::from("/project");
769 let results = AnalysisResults::default();
770 let elapsed = Duration::from_millis(123);
771 let output = build_json(&results, &root, elapsed).expect("should serialize");
772
773 assert_eq!(output["kind"], "dead-code");
774 assert_eq!(output["schema_version"], 7);
775 assert!(output["version"].is_string());
776 assert_eq!(output["elapsed_ms"], 123);
777 assert_eq!(output["total_issues"], 0);
778 }
779
780 #[test]
781 fn json_output_includes_issue_arrays() {
782 let root = PathBuf::from("/project");
783 let results = sample_results(&root);
784 let elapsed = Duration::from_millis(50);
785 let output = build_json(&results, &root, elapsed).expect("should serialize");
786
787 assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
788 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
789 assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
790 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
791 assert_eq!(
792 output["unused_dev_dependencies"].as_array().unwrap().len(),
793 1
794 );
795 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
796 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
797 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
798 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
799 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
800 assert_eq!(
801 output["type_only_dependencies"].as_array().unwrap().len(),
802 1
803 );
804 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
805 }
806
807 #[test]
808 fn health_json_includes_runtime_coverage_with_relative_paths_and_actions() {
809 let root = PathBuf::from("/project");
810 let report = crate::health_types::HealthReport {
811 runtime_coverage: Some(RuntimeCoverageReport {
812 schema_version: RuntimeCoverageSchemaVersion::V1,
813 verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
814 signals: Vec::new(),
815 summary: RuntimeCoverageSummary {
816 data_source: RuntimeCoverageDataSource::Local,
817 last_received_at: None,
818 functions_tracked: 3,
819 functions_hit: 1,
820 functions_unhit: 1,
821 functions_untracked: 1,
822 coverage_percent: 33.3,
823 trace_count: 2_847_291,
824 period_days: 30,
825 deployments_seen: 14,
826 capture_quality: Some(crate::health_types::RuntimeCoverageCaptureQuality {
827 window_seconds: 720,
828 instances_observed: 1,
829 lazy_parse_warning: true,
830 untracked_ratio_percent: 42.5,
831 }),
832 },
833 findings: vec![RuntimeCoverageFinding {
834 id: "fallow:prod:deadbeef".to_owned(),
835 stable_id: None,
836 path: root.join("src/cold.ts"),
837 function: "coldPath".to_owned(),
838 line: 12,
839 verdict: RuntimeCoverageVerdict::ReviewRequired,
840 invocations: Some(0),
841 confidence: RuntimeCoverageConfidence::Medium,
842 evidence: RuntimeCoverageEvidence {
843 static_status: "used".to_owned(),
844 test_coverage: "not_covered".to_owned(),
845 v8_tracking: "tracked".to_owned(),
846 untracked_reason: None,
847 observation_days: 30,
848 deployments_observed: 14,
849 },
850 actions: vec![RuntimeCoverageAction {
851 kind: "review-deletion".to_owned(),
852 description: "Tracked in runtime coverage with zero invocations."
853 .to_owned(),
854 auto_fixable: false,
855 }],
856 source_hash: None,
857 }],
858 hot_paths: vec![RuntimeCoverageHotPath {
859 id: "fallow:hot:cafebabe".to_owned(),
860 stable_id: None,
861 path: root.join("src/hot.ts"),
862 function: "hotPath".to_owned(),
863 line: 3,
864 end_line: 9,
865 invocations: 250,
866 percentile: 99,
867 actions: vec![],
868 }],
869 blast_radius: vec![],
870 importance: vec![],
871 watermark: Some(RuntimeCoverageWatermark::LicenseExpiredGrace),
872 warnings: vec![RuntimeCoverageMessage {
873 code: "partial-merge".to_owned(),
874 message: "Merged coverage omitted one chunk.".to_owned(),
875 }],
876 }),
877 ..Default::default()
878 };
879
880 let envelope = HealthOutput {
881 schema_version: SchemaVersion(SCHEMA_VERSION),
882 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
883 elapsed_ms: ElapsedMs(7),
884 report,
885 grouped_by: None,
886 groups: None,
887 meta: None,
888 workspace_diagnostics: Vec::new(),
889 next_steps: Vec::new(),
890 };
891 let mut output = serde_json::to_value(&envelope).expect("should serialize health envelope");
892 strip_root_prefix(&mut output, "/project/");
893
894 assert_eq!(
895 output["runtime_coverage"]["verdict"],
896 serde_json::Value::String("cold-code-detected".to_owned())
897 );
898 assert_eq!(
899 output["runtime_coverage"]["schema_version"],
900 serde_json::Value::String("1".to_owned())
901 );
902 assert_eq!(
903 output["runtime_coverage"]["summary"]["functions_tracked"],
904 serde_json::Value::from(3)
905 );
906 assert_eq!(
907 output["runtime_coverage"]["summary"]["coverage_percent"],
908 serde_json::Value::from(33.3)
909 );
910 let finding = &output["runtime_coverage"]["findings"][0];
911 assert_eq!(finding["path"], "src/cold.ts");
912 assert_eq!(finding["verdict"], "review_required");
913 assert_eq!(finding["id"], "fallow:prod:deadbeef");
914 assert_eq!(finding["actions"][0]["type"], "review-deletion");
915 let hot_path = &output["runtime_coverage"]["hot_paths"][0];
916 assert_eq!(hot_path["path"], "src/hot.ts");
917 assert_eq!(hot_path["function"], "hotPath");
918 assert_eq!(hot_path["percentile"], 99);
919 assert_eq!(
920 output["runtime_coverage"]["watermark"],
921 serde_json::Value::String("license-expired-grace".to_owned())
922 );
923 assert_eq!(
924 output["runtime_coverage"]["warnings"][0]["code"],
925 serde_json::Value::String("partial-merge".to_owned())
926 );
927 }
928
929 #[test]
930 fn json_metadata_fields_appear_first() {
931 let root = PathBuf::from("/project");
932 let results = AnalysisResults::default();
933 let elapsed = Duration::from_millis(0);
934 let output = build_json(&results, &root, elapsed).expect("should serialize");
935 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
936 assert_eq!(keys[0], "kind");
937 assert_eq!(keys[1], "schema_version");
938 assert_eq!(keys[2], "version");
939 assert_eq!(keys[3], "elapsed_ms");
940 assert_eq!(keys[4], "total_issues");
941 }
942
943 #[test]
944 fn json_total_issues_matches_results() {
945 let root = PathBuf::from("/project");
946 let results = sample_results(&root);
947 let total = results.total_issues();
948 let elapsed = Duration::from_millis(0);
949 let output = build_json(&results, &root, elapsed).expect("should serialize");
950
951 assert_eq!(output["total_issues"], total);
952 }
953
954 #[test]
955 fn json_unused_export_contains_expected_fields() {
956 let root = PathBuf::from("/project");
957 let mut results = AnalysisResults::default();
958 results
959 .unused_exports
960 .push(UnusedExportFinding::with_actions(UnusedExport {
961 path: root.join("src/utils.ts"),
962 export_name: "helperFn".to_string(),
963 is_type_only: false,
964 line: 10,
965 col: 4,
966 span_start: 120,
967 is_re_export: false,
968 }));
969 let elapsed = Duration::from_millis(0);
970 let output = build_json(&results, &root, elapsed).expect("should serialize");
971
972 let export = &output["unused_exports"][0];
973 assert_eq!(export["export_name"], "helperFn");
974 assert_eq!(export["line"], 10);
975 assert_eq!(export["col"], 4);
976 assert_eq!(export["is_type_only"], false);
977 assert_eq!(export["span_start"], 120);
978 assert_eq!(export["is_re_export"], false);
979 }
980
981 #[test]
982 fn json_serializes_to_valid_json() {
983 let root = PathBuf::from("/project");
984 let results = sample_results(&root);
985 let elapsed = Duration::from_millis(42);
986 let output = build_json(&results, &root, elapsed).expect("should serialize");
987
988 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
989 let reparsed: serde_json::Value =
990 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
991 assert_eq!(reparsed, output);
992 }
993
994 #[test]
995 fn json_empty_results_produce_valid_structure() {
996 let root = PathBuf::from("/project");
997 let results = AnalysisResults::default();
998 let elapsed = Duration::from_millis(0);
999 let output = build_json(&results, &root, elapsed).expect("should serialize");
1000
1001 assert_eq!(output["total_issues"], 0);
1002 assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
1003 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
1004 assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
1005 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
1006 assert_eq!(
1007 output["unused_dev_dependencies"].as_array().unwrap().len(),
1008 0
1009 );
1010 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
1011 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
1012 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
1013 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
1014 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
1015 assert_eq!(
1016 output["type_only_dependencies"].as_array().unwrap().len(),
1017 0
1018 );
1019 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
1020 }
1021
1022 #[test]
1023 fn json_empty_results_round_trips_through_string() {
1024 let root = PathBuf::from("/project");
1025 let results = AnalysisResults::default();
1026 let elapsed = Duration::from_millis(0);
1027 let output = build_json(&results, &root, elapsed).expect("should serialize");
1028
1029 let json_str = serde_json::to_string(&output).expect("should stringify");
1030 let reparsed: serde_json::Value =
1031 serde_json::from_str(&json_str).expect("should parse back");
1032 assert_eq!(reparsed["total_issues"], 0);
1033 }
1034
1035 #[test]
1036 fn json_paths_are_relative_to_root() {
1037 let root = PathBuf::from("/project");
1038 let mut results = AnalysisResults::default();
1039 results
1040 .unused_files
1041 .push(UnusedFileFinding::with_actions(UnusedFile {
1042 path: root.join("src/deep/nested/file.ts"),
1043 }));
1044 let elapsed = Duration::from_millis(0);
1045 let output = build_json(&results, &root, elapsed).expect("should serialize");
1046
1047 let path = output["unused_files"][0]["path"].as_str().unwrap();
1048 assert_eq!(path, "src/deep/nested/file.ts");
1049 assert!(!path.starts_with("/project"));
1050 }
1051
1052 #[test]
1053 fn json_strips_root_from_nested_locations() {
1054 let root = PathBuf::from("/project");
1055 let mut results = AnalysisResults::default();
1056 results
1057 .unlisted_dependencies
1058 .push(UnlistedDependencyFinding::with_actions(
1059 UnlistedDependency {
1060 package_name: "chalk".to_string(),
1061 imported_from: vec![ImportSite {
1062 path: root.join("src/cli.ts"),
1063 line: 2,
1064 col: 0,
1065 }],
1066 },
1067 ));
1068 let elapsed = Duration::from_millis(0);
1069 let output = build_json(&results, &root, elapsed).expect("should serialize");
1070
1071 let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
1072 .as_str()
1073 .unwrap();
1074 assert_eq!(site_path, "src/cli.ts");
1075 }
1076
1077 #[test]
1078 fn json_strips_root_from_duplicate_export_locations() {
1079 let root = PathBuf::from("/project");
1080 let mut results = AnalysisResults::default();
1081 results
1082 .duplicate_exports
1083 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1084 export_name: "Config".to_string(),
1085 locations: vec![
1086 DuplicateLocation {
1087 path: root.join("src/config.ts"),
1088 line: 15,
1089 col: 0,
1090 },
1091 DuplicateLocation {
1092 path: root.join("src/types.ts"),
1093 line: 30,
1094 col: 0,
1095 },
1096 ],
1097 }));
1098 let elapsed = Duration::from_millis(0);
1099 let output = build_json(&results, &root, elapsed).expect("should serialize");
1100
1101 let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
1102 .as_str()
1103 .unwrap();
1104 let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
1105 .as_str()
1106 .unwrap();
1107 assert_eq!(loc0, "src/config.ts");
1108 assert_eq!(loc1, "src/types.ts");
1109 }
1110
1111 #[test]
1112 fn json_strips_root_from_circular_dependency_files() {
1113 let root = PathBuf::from("/project");
1114 let mut results = AnalysisResults::default();
1115 results
1116 .circular_dependencies
1117 .push(CircularDependencyFinding::with_actions(
1118 CircularDependency {
1119 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1120 length: 2,
1121 line: 1,
1122 col: 0,
1123 edges: Vec::new(),
1124 is_cross_package: false,
1125 },
1126 ));
1127 let elapsed = Duration::from_millis(0);
1128 let output = build_json(&results, &root, elapsed).expect("should serialize");
1129
1130 let files = output["circular_dependencies"][0]["files"]
1131 .as_array()
1132 .unwrap();
1133 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
1134 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
1135 }
1136
1137 #[test]
1138 fn json_path_outside_root_not_stripped() {
1139 let root = PathBuf::from("/project");
1140 let mut results = AnalysisResults::default();
1141 results
1142 .unused_files
1143 .push(UnusedFileFinding::with_actions(UnusedFile {
1144 path: PathBuf::from("/other/project/src/file.ts"),
1145 }));
1146 let elapsed = Duration::from_millis(0);
1147 let output = build_json(&results, &root, elapsed).expect("should serialize");
1148
1149 let path = output["unused_files"][0]["path"].as_str().unwrap();
1150 assert!(path.contains("/other/project/"));
1151 }
1152
1153 #[test]
1154 fn json_unused_file_contains_path() {
1155 let root = PathBuf::from("/project");
1156 let mut results = AnalysisResults::default();
1157 results
1158 .unused_files
1159 .push(UnusedFileFinding::with_actions(UnusedFile {
1160 path: root.join("src/orphan.ts"),
1161 }));
1162 let elapsed = Duration::from_millis(0);
1163 let output = build_json(&results, &root, elapsed).expect("should serialize");
1164
1165 let file = &output["unused_files"][0];
1166 assert_eq!(file["path"], "src/orphan.ts");
1167 }
1168
1169 #[test]
1170 fn json_unused_type_contains_expected_fields() {
1171 let root = PathBuf::from("/project");
1172 let mut results = AnalysisResults::default();
1173 results
1174 .unused_types
1175 .push(UnusedTypeFinding::with_actions(UnusedExport {
1176 path: root.join("src/types.ts"),
1177 export_name: "OldInterface".to_string(),
1178 is_type_only: true,
1179 line: 20,
1180 col: 0,
1181 span_start: 300,
1182 is_re_export: false,
1183 }));
1184 let elapsed = Duration::from_millis(0);
1185 let output = build_json(&results, &root, elapsed).expect("should serialize");
1186
1187 let typ = &output["unused_types"][0];
1188 assert_eq!(typ["export_name"], "OldInterface");
1189 assert_eq!(typ["is_type_only"], true);
1190 assert_eq!(typ["line"], 20);
1191 assert_eq!(typ["path"], "src/types.ts");
1192 }
1193
1194 #[test]
1195 fn json_unused_dependency_contains_expected_fields() {
1196 let root = PathBuf::from("/project");
1197 let mut results = AnalysisResults::default();
1198 results
1199 .unused_dependencies
1200 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1201 package_name: "axios".to_string(),
1202 location: DependencyLocation::Dependencies,
1203 path: root.join("package.json"),
1204 line: 10,
1205 used_in_workspaces: Vec::new(),
1206 }));
1207 let elapsed = Duration::from_millis(0);
1208 let output = build_json(&results, &root, elapsed).expect("should serialize");
1209
1210 let dep = &output["unused_dependencies"][0];
1211 assert_eq!(dep["package_name"], "axios");
1212 assert_eq!(dep["line"], 10);
1213 assert!(dep.get("used_in_workspaces").is_none());
1214 }
1215
1216 #[test]
1217 fn json_unused_dependency_includes_cross_workspace_context() {
1218 let root = PathBuf::from("/project");
1219 let mut results = AnalysisResults::default();
1220 results
1221 .unused_dependencies
1222 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1223 package_name: "lodash-es".to_string(),
1224 location: DependencyLocation::Dependencies,
1225 path: root.join("packages/shared/package.json"),
1226 line: 6,
1227 used_in_workspaces: vec![root.join("packages/consumer")],
1228 }));
1229 let elapsed = Duration::from_millis(0);
1230 let output = build_json(&results, &root, elapsed).expect("should serialize");
1231
1232 let dep = &output["unused_dependencies"][0];
1233 assert_eq!(
1234 dep["used_in_workspaces"],
1235 serde_json::json!(["packages/consumer"])
1236 );
1237 }
1238
1239 #[test]
1240 fn json_unused_dev_dependency_contains_expected_fields() {
1241 let root = PathBuf::from("/project");
1242 let mut results = AnalysisResults::default();
1243 results
1244 .unused_dev_dependencies
1245 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1246 package_name: "vitest".to_string(),
1247 location: DependencyLocation::DevDependencies,
1248 path: root.join("package.json"),
1249 line: 15,
1250 used_in_workspaces: Vec::new(),
1251 }));
1252 let elapsed = Duration::from_millis(0);
1253 let output = build_json(&results, &root, elapsed).expect("should serialize");
1254
1255 let dep = &output["unused_dev_dependencies"][0];
1256 assert_eq!(dep["package_name"], "vitest");
1257 }
1258
1259 #[test]
1260 fn json_unused_optional_dependency_contains_expected_fields() {
1261 let root = PathBuf::from("/project");
1262 let mut results = AnalysisResults::default();
1263 results
1264 .unused_optional_dependencies
1265 .push(UnusedOptionalDependencyFinding::with_actions(
1266 UnusedDependency {
1267 package_name: "fsevents".to_string(),
1268 location: DependencyLocation::OptionalDependencies,
1269 path: root.join("package.json"),
1270 line: 12,
1271 used_in_workspaces: Vec::new(),
1272 },
1273 ));
1274 let elapsed = Duration::from_millis(0);
1275 let output = build_json(&results, &root, elapsed).expect("should serialize");
1276
1277 let dep = &output["unused_optional_dependencies"][0];
1278 assert_eq!(dep["package_name"], "fsevents");
1279 assert_eq!(output["total_issues"], 1);
1280 }
1281
1282 #[test]
1283 fn json_unused_enum_member_contains_expected_fields() {
1284 let root = PathBuf::from("/project");
1285 let mut results = AnalysisResults::default();
1286 results
1287 .unused_enum_members
1288 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1289 path: root.join("src/enums.ts"),
1290 parent_name: "Color".to_string(),
1291 member_name: "Purple".to_string(),
1292 kind: MemberKind::EnumMember,
1293 line: 5,
1294 col: 2,
1295 }));
1296 let elapsed = Duration::from_millis(0);
1297 let output = build_json(&results, &root, elapsed).expect("should serialize");
1298
1299 let member = &output["unused_enum_members"][0];
1300 assert_eq!(member["parent_name"], "Color");
1301 assert_eq!(member["member_name"], "Purple");
1302 assert_eq!(member["line"], 5);
1303 assert_eq!(member["path"], "src/enums.ts");
1304 }
1305
1306 #[test]
1307 fn json_unused_class_member_contains_expected_fields() {
1308 let root = PathBuf::from("/project");
1309 let mut results = AnalysisResults::default();
1310 results
1311 .unused_class_members
1312 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
1313 path: root.join("src/api.ts"),
1314 parent_name: "ApiClient".to_string(),
1315 member_name: "deprecatedFetch".to_string(),
1316 kind: MemberKind::ClassMethod,
1317 line: 100,
1318 col: 4,
1319 }));
1320 let elapsed = Duration::from_millis(0);
1321 let output = build_json(&results, &root, elapsed).expect("should serialize");
1322
1323 let member = &output["unused_class_members"][0];
1324 assert_eq!(member["parent_name"], "ApiClient");
1325 assert_eq!(member["member_name"], "deprecatedFetch");
1326 assert_eq!(member["line"], 100);
1327 }
1328
1329 #[test]
1330 fn json_unresolved_import_contains_expected_fields() {
1331 let root = PathBuf::from("/project");
1332 let mut results = AnalysisResults::default();
1333 results
1334 .unresolved_imports
1335 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1336 path: root.join("src/app.ts"),
1337 specifier: "@acme/missing-pkg".to_string(),
1338 line: 7,
1339 col: 0,
1340 specifier_col: 0,
1341 }));
1342 let elapsed = Duration::from_millis(0);
1343 let output = build_json(&results, &root, elapsed).expect("should serialize");
1344
1345 let import = &output["unresolved_imports"][0];
1346 assert_eq!(import["specifier"], "@acme/missing-pkg");
1347 assert_eq!(import["line"], 7);
1348 assert_eq!(import["path"], "src/app.ts");
1349 }
1350
1351 #[test]
1352 fn json_unlisted_dependency_contains_import_sites() {
1353 let root = PathBuf::from("/project");
1354 let mut results = AnalysisResults::default();
1355 results
1356 .unlisted_dependencies
1357 .push(UnlistedDependencyFinding::with_actions(
1358 UnlistedDependency {
1359 package_name: "dotenv".to_string(),
1360 imported_from: vec![
1361 ImportSite {
1362 path: root.join("src/config.ts"),
1363 line: 1,
1364 col: 0,
1365 },
1366 ImportSite {
1367 path: root.join("src/server.ts"),
1368 line: 3,
1369 col: 0,
1370 },
1371 ],
1372 },
1373 ));
1374 let elapsed = Duration::from_millis(0);
1375 let output = build_json(&results, &root, elapsed).expect("should serialize");
1376
1377 let dep = &output["unlisted_dependencies"][0];
1378 assert_eq!(dep["package_name"], "dotenv");
1379 let sites = dep["imported_from"].as_array().unwrap();
1380 assert_eq!(sites.len(), 2);
1381 assert_eq!(sites[0]["path"], "src/config.ts");
1382 assert_eq!(sites[1]["path"], "src/server.ts");
1383 }
1384
1385 #[test]
1386 fn json_duplicate_export_contains_locations() {
1387 let root = PathBuf::from("/project");
1388 let mut results = AnalysisResults::default();
1389 results
1390 .duplicate_exports
1391 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1392 export_name: "Button".to_string(),
1393 locations: vec![
1394 DuplicateLocation {
1395 path: root.join("src/ui.ts"),
1396 line: 10,
1397 col: 0,
1398 },
1399 DuplicateLocation {
1400 path: root.join("src/components.ts"),
1401 line: 25,
1402 col: 0,
1403 },
1404 ],
1405 }));
1406 let elapsed = Duration::from_millis(0);
1407 let output = build_json(&results, &root, elapsed).expect("should serialize");
1408
1409 let dup = &output["duplicate_exports"][0];
1410 assert_eq!(dup["export_name"], "Button");
1411 let locs = dup["locations"].as_array().unwrap();
1412 assert_eq!(locs.len(), 2);
1413 assert_eq!(locs[0]["line"], 10);
1414 assert_eq!(locs[1]["line"], 25);
1415 }
1416
1417 #[test]
1418 fn duplicate_export_add_to_config_is_auto_fixable_when_config_exists() {
1419 let dir = tempfile::tempdir().unwrap();
1420 let root = dir.path();
1421 std::fs::write(root.join(".fallowrc.json"), "{}\n").unwrap();
1422 let mut results = AnalysisResults::default();
1423 results
1424 .duplicate_exports
1425 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1426 export_name: "Button".to_string(),
1427 locations: vec![
1428 DuplicateLocation {
1429 path: root.join("src/ui.ts"),
1430 line: 10,
1431 col: 0,
1432 },
1433 DuplicateLocation {
1434 path: root.join("src/components.ts"),
1435 line: 25,
1436 col: 0,
1437 },
1438 ],
1439 }));
1440
1441 let output = build_json(&results, root, Duration::ZERO).unwrap();
1442 let actions = output["duplicate_exports"][0]["actions"]
1443 .as_array()
1444 .unwrap();
1445 assert_eq!(actions[0]["type"], "add-to-config");
1446 assert_eq!(actions[0]["auto_fixable"], true);
1447 }
1448
1449 #[test]
1450 fn duplicate_export_add_to_config_is_auto_fixable_when_create_fallback_allowed() {
1451 let dir = tempfile::tempdir().unwrap();
1452 let root = dir.path();
1453 let mut results = AnalysisResults::default();
1454 results
1455 .duplicate_exports
1456 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1457 export_name: "Button".to_string(),
1458 locations: vec![
1459 DuplicateLocation {
1460 path: root.join("src/ui.ts"),
1461 line: 10,
1462 col: 0,
1463 },
1464 DuplicateLocation {
1465 path: root.join("src/components.ts"),
1466 line: 25,
1467 col: 0,
1468 },
1469 ],
1470 }));
1471
1472 let output = build_json(&results, root, Duration::ZERO).unwrap();
1473 let actions = output["duplicate_exports"][0]["actions"]
1474 .as_array()
1475 .unwrap();
1476 assert_eq!(actions[0]["type"], "add-to-config");
1477 assert_eq!(actions[0]["auto_fixable"], true);
1478 }
1479
1480 #[test]
1481 fn duplicate_export_add_to_config_is_not_auto_fixable_in_monorepo_subpackage() {
1482 let dir = tempfile::tempdir().unwrap();
1483 let workspace = dir.path();
1484 std::fs::write(
1485 workspace.join("pnpm-workspace.yaml"),
1486 "packages:\n - 'packages/*'\n",
1487 )
1488 .unwrap();
1489 let sub = workspace.join("packages/ui");
1490 std::fs::create_dir_all(&sub).unwrap();
1491 let mut results = AnalysisResults::default();
1492 results
1493 .duplicate_exports
1494 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1495 export_name: "Button".to_string(),
1496 locations: vec![
1497 DuplicateLocation {
1498 path: sub.join("src/ui.ts"),
1499 line: 10,
1500 col: 0,
1501 },
1502 DuplicateLocation {
1503 path: sub.join("src/components.ts"),
1504 line: 25,
1505 col: 0,
1506 },
1507 ],
1508 }));
1509
1510 let output = build_json(&results, &sub, Duration::ZERO).unwrap();
1511 let actions = output["duplicate_exports"][0]["actions"]
1512 .as_array()
1513 .unwrap();
1514 assert_eq!(actions[0]["type"], "add-to-config");
1515 assert_eq!(actions[0]["auto_fixable"], false);
1516 }
1517
1518 #[test]
1519 fn json_type_only_dependency_contains_expected_fields() {
1520 let root = PathBuf::from("/project");
1521 let mut results = AnalysisResults::default();
1522 results
1523 .type_only_dependencies
1524 .push(TypeOnlyDependencyFinding::with_actions(
1525 TypeOnlyDependency {
1526 package_name: "zod".to_string(),
1527 path: root.join("package.json"),
1528 line: 8,
1529 },
1530 ));
1531 let elapsed = Duration::from_millis(0);
1532 let output = build_json(&results, &root, elapsed).expect("should serialize");
1533
1534 let dep = &output["type_only_dependencies"][0];
1535 assert_eq!(dep["package_name"], "zod");
1536 assert_eq!(dep["line"], 8);
1537 }
1538
1539 #[test]
1540 fn json_circular_dependency_contains_expected_fields() {
1541 let root = PathBuf::from("/project");
1542 let mut results = AnalysisResults::default();
1543 results
1544 .circular_dependencies
1545 .push(CircularDependencyFinding::with_actions(
1546 CircularDependency {
1547 files: vec![
1548 root.join("src/a.ts"),
1549 root.join("src/b.ts"),
1550 root.join("src/c.ts"),
1551 ],
1552 length: 3,
1553 line: 5,
1554 col: 0,
1555 edges: Vec::new(),
1556 is_cross_package: false,
1557 },
1558 ));
1559 let elapsed = Duration::from_millis(0);
1560 let output = build_json(&results, &root, elapsed).expect("should serialize");
1561
1562 let cycle = &output["circular_dependencies"][0];
1563 assert_eq!(cycle["length"], 3);
1564 assert_eq!(cycle["line"], 5);
1565 let files = cycle["files"].as_array().unwrap();
1566 assert_eq!(files.len(), 3);
1567 }
1568
1569 #[test]
1570 fn json_re_export_flagged_correctly() {
1571 let root = PathBuf::from("/project");
1572 let mut results = AnalysisResults::default();
1573 results
1574 .unused_exports
1575 .push(UnusedExportFinding::with_actions(UnusedExport {
1576 path: root.join("src/index.ts"),
1577 export_name: "reExported".to_string(),
1578 is_type_only: false,
1579 line: 1,
1580 col: 0,
1581 span_start: 0,
1582 is_re_export: true,
1583 }));
1584 let elapsed = Duration::from_millis(0);
1585 let output = build_json(&results, &root, elapsed).expect("should serialize");
1586
1587 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
1588 }
1589
1590 #[test]
1591 fn json_schema_version_is_pinned() {
1592 let root = PathBuf::from("/project");
1593 let results = AnalysisResults::default();
1594 let elapsed = Duration::from_millis(0);
1595 let output = build_json(&results, &root, elapsed).expect("should serialize");
1596
1597 assert_eq!(output["schema_version"], SCHEMA_VERSION);
1598 assert_eq!(output["schema_version"], 7);
1599 }
1600
1601 #[test]
1602 fn json_version_matches_cargo_pkg_version() {
1603 let root = PathBuf::from("/project");
1604 let results = AnalysisResults::default();
1605 let elapsed = Duration::from_millis(0);
1606 let output = build_json(&results, &root, elapsed).expect("should serialize");
1607
1608 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1609 }
1610
1611 #[test]
1612 fn json_elapsed_ms_zero_duration() {
1613 let root = PathBuf::from("/project");
1614 let results = AnalysisResults::default();
1615 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1616
1617 assert_eq!(output["elapsed_ms"], 0);
1618 }
1619
1620 #[test]
1621 fn json_elapsed_ms_large_duration() {
1622 let root = PathBuf::from("/project");
1623 let results = AnalysisResults::default();
1624 let elapsed = Duration::from_mins(2);
1625 let output = build_json(&results, &root, elapsed).expect("should serialize");
1626
1627 assert_eq!(output["elapsed_ms"], 120_000);
1628 }
1629
1630 #[test]
1631 fn json_elapsed_ms_sub_millisecond_truncated() {
1632 let root = PathBuf::from("/project");
1633 let results = AnalysisResults::default();
1634 let elapsed = Duration::from_micros(500);
1635 let output = build_json(&results, &root, elapsed).expect("should serialize");
1636
1637 assert_eq!(output["elapsed_ms"], 0);
1638 }
1639
1640 #[test]
1641 fn json_multiple_unused_files() {
1642 let root = PathBuf::from("/project");
1643 let mut results = AnalysisResults::default();
1644 results
1645 .unused_files
1646 .push(UnusedFileFinding::with_actions(UnusedFile {
1647 path: root.join("src/a.ts"),
1648 }));
1649 results
1650 .unused_files
1651 .push(UnusedFileFinding::with_actions(UnusedFile {
1652 path: root.join("src/b.ts"),
1653 }));
1654 results
1655 .unused_files
1656 .push(UnusedFileFinding::with_actions(UnusedFile {
1657 path: root.join("src/c.ts"),
1658 }));
1659 let elapsed = Duration::from_millis(0);
1660 let output = build_json(&results, &root, elapsed).expect("should serialize");
1661
1662 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1663 assert_eq!(output["total_issues"], 3);
1664 }
1665
1666 #[test]
1667 fn strip_root_prefix_on_string_value() {
1668 let mut value = serde_json::json!("/project/src/file.ts");
1669 strip_root_prefix(&mut value, "/project/");
1670 assert_eq!(value, "src/file.ts");
1671 }
1672
1673 #[test]
1674 fn strip_root_prefix_leaves_non_matching_string() {
1675 let mut value = serde_json::json!("/other/src/file.ts");
1676 strip_root_prefix(&mut value, "/project/");
1677 assert_eq!(value, "/other/src/file.ts");
1678 }
1679
1680 #[test]
1681 fn strip_root_prefix_recurses_into_arrays() {
1682 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1683 strip_root_prefix(&mut value, "/project/");
1684 assert_eq!(value[0], "a.ts");
1685 assert_eq!(value[1], "b.ts");
1686 assert_eq!(value[2], "/other/c.ts");
1687 }
1688
1689 #[test]
1690 fn strip_root_prefix_recurses_into_nested_objects() {
1691 let mut value = serde_json::json!({
1692 "outer": {
1693 "path": "/project/src/nested.ts"
1694 }
1695 });
1696 strip_root_prefix(&mut value, "/project/");
1697 assert_eq!(value["outer"]["path"], "src/nested.ts");
1698 }
1699
1700 #[test]
1701 fn strip_root_prefix_leaves_numbers_and_booleans() {
1702 let mut value = serde_json::json!({
1703 "line": 42,
1704 "is_type_only": false,
1705 "path": "/project/src/file.ts"
1706 });
1707 strip_root_prefix(&mut value, "/project/");
1708 assert_eq!(value["line"], 42);
1709 assert_eq!(value["is_type_only"], false);
1710 assert_eq!(value["path"], "src/file.ts");
1711 }
1712
1713 #[test]
1714 fn strip_root_prefix_normalizes_windows_separators() {
1715 let mut value = serde_json::json!(r"/project\src\file.ts");
1716 strip_root_prefix(&mut value, "/project/");
1717 assert_eq!(value, "src/file.ts");
1718 }
1719
1720 #[test]
1721 fn strip_root_prefix_rewrites_embedded_path_strings() {
1722 let mut value =
1723 serde_json::json!("Add \"/project/src/file.ts\" to boundaries.coverage.allowUnmatched");
1724 strip_root_prefix(&mut value, "/project/");
1725 assert_eq!(
1726 value,
1727 "Add \"src/file.ts\" to boundaries.coverage.allowUnmatched"
1728 );
1729 }
1730
1731 #[test]
1732 fn strip_root_prefix_handles_empty_string_after_strip() {
1733 let mut value = serde_json::json!("/project/");
1734 strip_root_prefix(&mut value, "/project/");
1735 assert_eq!(value, "");
1736 }
1737
1738 #[test]
1739 fn strip_root_prefix_deeply_nested_array_of_objects() {
1740 let mut value = serde_json::json!({
1741 "groups": [{
1742 "instances": [{
1743 "file": "/project/src/a.ts"
1744 }, {
1745 "file": "/project/src/b.ts"
1746 }]
1747 }]
1748 });
1749 strip_root_prefix(&mut value, "/project/");
1750 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1751 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1752 }
1753
1754 #[test]
1755 fn json_full_sample_results_total_issues_correct() {
1756 let root = PathBuf::from("/project");
1757 let results = sample_results(&root);
1758 let elapsed = Duration::from_millis(100);
1759 let output = build_json(&results, &root, elapsed).expect("should serialize");
1760
1761 assert_eq!(output["total_issues"], results.total_issues());
1762 }
1763
1764 #[test]
1765 fn json_full_sample_no_absolute_paths_in_output() {
1766 let root = PathBuf::from("/project");
1767 let results = sample_results(&root);
1768 let elapsed = Duration::from_millis(0);
1769 let output = build_json(&results, &root, elapsed).expect("should serialize");
1770
1771 let json_str = serde_json::to_string(&output).expect("should stringify");
1772 assert!(!json_str.contains("/project/src/"));
1773 assert!(!json_str.contains("/project/package.json"));
1774 }
1775
1776 #[test]
1777 fn json_output_is_deterministic() {
1778 let root = PathBuf::from("/project");
1779 let results = sample_results(&root);
1780 let elapsed = Duration::from_millis(50);
1781
1782 let output1 = build_json(&results, &root, elapsed).expect("first build");
1783 let output2 = build_json(&results, &root, elapsed).expect("second build");
1784
1785 assert_eq!(output1, output2);
1786 }
1787
1788 #[test]
1789 fn json_results_fields_do_not_shadow_metadata() {
1790 let root = PathBuf::from("/project");
1791 let results = AnalysisResults::default();
1792 let elapsed = Duration::from_millis(99);
1793 let output = build_json(&results, &root, elapsed).expect("should serialize");
1794
1795 assert_eq!(output["kind"], "dead-code");
1796 assert_eq!(output["schema_version"], 7);
1797 assert_eq!(output["elapsed_ms"], 99);
1798 }
1799
1800 #[test]
1801 fn json_all_issue_type_arrays_present_in_empty_results() {
1802 let root = PathBuf::from("/project");
1803 let results = AnalysisResults::default();
1804 let elapsed = Duration::from_millis(0);
1805 let output = build_json(&results, &root, elapsed).expect("should serialize");
1806
1807 let expected_arrays = [
1808 "unused_files",
1809 "unused_exports",
1810 "unused_types",
1811 "unused_dependencies",
1812 "unused_dev_dependencies",
1813 "unused_optional_dependencies",
1814 "unused_enum_members",
1815 "unused_class_members",
1816 "unresolved_imports",
1817 "unlisted_dependencies",
1818 "duplicate_exports",
1819 "type_only_dependencies",
1820 "test_only_dependencies",
1821 "circular_dependencies",
1822 ];
1823 for key in &expected_arrays {
1824 assert!(
1825 output[key].is_array(),
1826 "expected '{key}' to be an array in JSON output"
1827 );
1828 }
1829 }
1830
1831 #[test]
1832 fn insert_meta_adds_key_to_object() {
1833 let mut output = serde_json::json!({ "foo": 1 });
1834 let meta = serde_json::json!({ "docs": "https://example.com" });
1835 insert_meta(&mut output, meta.clone());
1836 assert_eq!(output["_meta"], meta);
1837 }
1838
1839 #[test]
1840 fn insert_meta_noop_on_non_object() {
1841 let mut output = serde_json::json!([1, 2, 3]);
1842 let meta = serde_json::json!({ "docs": "https://example.com" });
1843 insert_meta(&mut output, meta);
1844 assert!(output.is_array());
1845 }
1846
1847 #[test]
1848 fn insert_meta_overwrites_existing_meta() {
1849 let mut output = serde_json::json!({ "_meta": "old" });
1850 let meta = serde_json::json!({ "new": true });
1851 insert_meta(&mut output, meta.clone());
1852 assert_eq!(output["_meta"], meta);
1853 }
1854
1855 #[test]
1856 fn insert_meta_preserves_existing_telemetry_meta() {
1857 let mut output = serde_json::json!({
1858 "_meta": {
1859 "telemetry": {
1860 "analysis_run_id": "run_test123"
1861 }
1862 }
1863 });
1864 insert_meta(
1865 &mut output,
1866 serde_json::json!({ "docs": "https://example.com" }),
1867 );
1868
1869 assert_eq!(
1870 output["_meta"]["docs"].as_str(),
1871 Some("https://example.com")
1872 );
1873 assert_eq!(
1874 output["_meta"]["telemetry"]["analysis_run_id"].as_str(),
1875 Some("run_test123")
1876 );
1877 }
1878
1879 #[test]
1880 fn strip_root_prefix_null_unchanged() {
1881 let mut value = serde_json::Value::Null;
1882 strip_root_prefix(&mut value, "/project/");
1883 assert!(value.is_null());
1884 }
1885
1886 #[test]
1887 fn strip_root_prefix_empty_string() {
1888 let mut value = serde_json::json!("");
1889 strip_root_prefix(&mut value, "/project/");
1890 assert_eq!(value, "");
1891 }
1892
1893 #[test]
1894 fn strip_root_prefix_mixed_types() {
1895 let mut value = serde_json::json!({
1896 "path": "/project/src/file.ts",
1897 "line": 42,
1898 "flag": true,
1899 "nested": {
1900 "items": ["/project/a.ts", 99, null, "/project/b.ts"],
1901 "deep": { "path": "/project/c.ts" }
1902 }
1903 });
1904 strip_root_prefix(&mut value, "/project/");
1905 assert_eq!(value["path"], "src/file.ts");
1906 assert_eq!(value["line"], 42);
1907 assert_eq!(value["flag"], true);
1908 assert_eq!(value["nested"]["items"][0], "a.ts");
1909 assert_eq!(value["nested"]["items"][1], 99);
1910 assert!(value["nested"]["items"][2].is_null());
1911 assert_eq!(value["nested"]["items"][3], "b.ts");
1912 assert_eq!(value["nested"]["deep"]["path"], "c.ts");
1913 }
1914
1915 #[test]
1916 fn json_check_meta_integrates_correctly() {
1917 let root = PathBuf::from("/project");
1918 let results = AnalysisResults::default();
1919 let elapsed = Duration::from_millis(0);
1920 let mut output = build_json(&results, &root, elapsed).expect("should serialize");
1921 insert_meta(&mut output, crate::explain::check_meta());
1922
1923 assert!(output["_meta"]["docs"].is_string());
1924 assert!(output["_meta"]["rules"].is_object());
1925 }
1926
1927 #[test]
1928 fn json_unused_member_kind_serialized() {
1929 let root = PathBuf::from("/project");
1930 let mut results = AnalysisResults::default();
1931 results
1932 .unused_enum_members
1933 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1934 path: root.join("src/enums.ts"),
1935 parent_name: "Color".to_string(),
1936 member_name: "Red".to_string(),
1937 kind: MemberKind::EnumMember,
1938 line: 3,
1939 col: 2,
1940 }));
1941 results
1942 .unused_class_members
1943 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
1944 path: root.join("src/class.ts"),
1945 parent_name: "Foo".to_string(),
1946 member_name: "bar".to_string(),
1947 kind: MemberKind::ClassMethod,
1948 line: 10,
1949 col: 4,
1950 }));
1951
1952 let elapsed = Duration::from_millis(0);
1953 let output = build_json(&results, &root, elapsed).expect("should serialize");
1954
1955 let enum_member = &output["unused_enum_members"][0];
1956 assert!(enum_member["kind"].is_string());
1957 let class_member = &output["unused_class_members"][0];
1958 assert!(class_member["kind"].is_string());
1959 }
1960
1961 #[test]
1962 fn json_unused_export_has_actions() {
1963 let root = PathBuf::from("/project");
1964 let mut results = AnalysisResults::default();
1965 results
1966 .unused_exports
1967 .push(UnusedExportFinding::with_actions(UnusedExport {
1968 path: root.join("src/utils.ts"),
1969 export_name: "helperFn".to_string(),
1970 is_type_only: false,
1971 line: 10,
1972 col: 4,
1973 span_start: 120,
1974 is_re_export: false,
1975 }));
1976 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1977
1978 let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
1979 assert_eq!(actions.len(), 2);
1980
1981 assert_eq!(actions[0]["type"], "remove-export");
1982 assert_eq!(actions[0]["auto_fixable"], true);
1983 assert!(actions[0].get("note").is_none());
1984
1985 assert_eq!(actions[1]["type"], "suppress-line");
1986 assert_eq!(
1987 actions[1]["comment"],
1988 "// fallow-ignore-next-line unused-export"
1989 );
1990 }
1991
1992 #[test]
1993 fn json_boundary_coverage_action_descriptions_use_relative_paths() {
1994 let root = PathBuf::from("/project");
1995 let mut results = AnalysisResults::default();
1996 results
1997 .boundary_coverage_violations
1998 .push(BoundaryCoverageViolationFinding::with_actions(
1999 BoundaryCoverageViolation {
2000 path: root.join("src/middleware/error.ts"),
2001 line: 1,
2002 col: 0,
2003 },
2004 ));
2005
2006 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2007 let action = &output["boundary_coverage_violations"][0]["actions"][1];
2008
2009 assert_eq!(
2010 output["boundary_coverage_violations"][0]["path"],
2011 "src/middleware/error.ts"
2012 );
2013 assert_eq!(action["value"], "src/middleware/error.ts");
2014 assert_eq!(
2015 action["description"],
2016 "Add \"src/middleware/error.ts\" to boundaries.coverage.allowUnmatched in fallow config"
2017 );
2018 }
2019
2020 #[test]
2021 fn json_same_line_findings_share_multi_kind_suppression_comment() {
2022 let root = PathBuf::from("/project");
2023 let mut results = AnalysisResults::default();
2024 results
2025 .unused_exports
2026 .push(UnusedExportFinding::with_actions(UnusedExport {
2027 path: root.join("src/api.ts"),
2028 export_name: "helperFn".to_string(),
2029 is_type_only: false,
2030 line: 10,
2031 col: 4,
2032 span_start: 120,
2033 is_re_export: false,
2034 }));
2035 results
2036 .unused_types
2037 .push(UnusedTypeFinding::with_actions(UnusedExport {
2038 path: root.join("src/api.ts"),
2039 export_name: "OldType".to_string(),
2040 is_type_only: true,
2041 line: 10,
2042 col: 0,
2043 span_start: 60,
2044 is_re_export: false,
2045 }));
2046 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2047
2048 let export_actions = output["unused_exports"][0]["actions"].as_array().unwrap();
2049 let type_actions = output["unused_types"][0]["actions"].as_array().unwrap();
2050 assert_eq!(
2051 export_actions[1]["comment"],
2052 "// fallow-ignore-next-line unused-export, unused-type"
2053 );
2054 assert_eq!(
2055 type_actions[1]["comment"],
2056 "// fallow-ignore-next-line unused-export, unused-type"
2057 );
2058 }
2059
2060 #[test]
2061 fn audit_like_json_shares_suppression_comment_across_dead_code_and_complexity() {
2062 let mut output = serde_json::json!({
2063 "dead_code": {
2064 "unused_exports": [{
2065 "path": "src/main.ts",
2066 "line": 1,
2067 "actions": [
2068 { "type": "remove-export", "auto_fixable": true },
2069 {
2070 "type": "suppress-line",
2071 "auto_fixable": false,
2072 "comment": "// fallow-ignore-next-line unused-export"
2073 }
2074 ]
2075 }]
2076 },
2077 "complexity": {
2078 "findings": [{
2079 "path": "src/main.ts",
2080 "line": 1,
2081 "actions": [
2082 { "type": "refactor-function", "auto_fixable": false },
2083 {
2084 "type": "suppress-line",
2085 "auto_fixable": false,
2086 "comment": "// fallow-ignore-next-line complexity"
2087 }
2088 ]
2089 }]
2090 }
2091 });
2092
2093 harmonize_multi_kind_suppress_line_actions(&mut output);
2094
2095 assert_eq!(
2096 output["dead_code"]["unused_exports"][0]["actions"][1]["comment"],
2097 "// fallow-ignore-next-line unused-export, complexity"
2098 );
2099 assert_eq!(
2100 output["complexity"]["findings"][0]["actions"][1]["comment"],
2101 "// fallow-ignore-next-line unused-export, complexity"
2102 );
2103 }
2104
2105 #[test]
2106 fn json_unused_file_has_file_suppress_and_note() {
2107 let root = PathBuf::from("/project");
2108 let mut results = AnalysisResults::default();
2109 results
2110 .unused_files
2111 .push(UnusedFileFinding::with_actions(UnusedFile {
2112 path: root.join("src/dead.ts"),
2113 }));
2114 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2115
2116 let actions = output["unused_files"][0]["actions"].as_array().unwrap();
2117 assert_eq!(actions[0]["type"], "delete-file");
2118 assert_eq!(actions[0]["auto_fixable"], false);
2119 assert!(actions[0]["note"].is_string());
2120 assert_eq!(actions[1]["type"], "suppress-file");
2121 assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
2122 }
2123
2124 #[test]
2125 fn json_unused_dependency_has_config_suppress_with_package_name() {
2126 let root = PathBuf::from("/project");
2127 let mut results = AnalysisResults::default();
2128 results
2129 .unused_dependencies
2130 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2131 package_name: "lodash".to_string(),
2132 location: DependencyLocation::Dependencies,
2133 path: root.join("package.json"),
2134 line: 5,
2135 used_in_workspaces: Vec::new(),
2136 }));
2137 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2138
2139 let actions = output["unused_dependencies"][0]["actions"]
2140 .as_array()
2141 .unwrap();
2142 assert_eq!(actions[0]["type"], "remove-dependency");
2143 assert_eq!(actions[0]["auto_fixable"], true);
2144
2145 assert_eq!(actions[1]["type"], "add-to-config");
2146 assert_eq!(actions[1]["config_key"], "ignoreDependencies");
2147 assert_eq!(actions[1]["value"], "lodash");
2148 }
2149
2150 #[test]
2151 fn json_cross_workspace_dependency_is_not_auto_fixable() {
2152 let root = PathBuf::from("/project");
2153 let mut results = AnalysisResults::default();
2154 results
2155 .unused_dependencies
2156 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2157 package_name: "lodash-es".to_string(),
2158 location: DependencyLocation::Dependencies,
2159 path: root.join("packages/shared/package.json"),
2160 line: 5,
2161 used_in_workspaces: vec![root.join("packages/consumer")],
2162 }));
2163 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2164
2165 let actions = output["unused_dependencies"][0]["actions"]
2166 .as_array()
2167 .unwrap();
2168 assert_eq!(actions[0]["type"], "move-dependency");
2169 assert_eq!(actions[0]["auto_fixable"], false);
2170 assert!(
2171 actions[0]["note"]
2172 .as_str()
2173 .unwrap()
2174 .contains("will not remove")
2175 );
2176 assert_eq!(actions[1]["type"], "add-to-config");
2177 }
2178
2179 #[test]
2180 fn json_empty_results_have_no_actions_in_empty_arrays() {
2181 let root = PathBuf::from("/project");
2182 let results = AnalysisResults::default();
2183 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2184
2185 assert!(output["unused_exports"].as_array().unwrap().is_empty());
2186 assert!(output["unused_files"].as_array().unwrap().is_empty());
2187 }
2188
2189 #[test]
2190 fn json_all_issue_types_have_actions() {
2191 let root = PathBuf::from("/project");
2192 let results = sample_results(&root);
2193 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2194
2195 let issue_keys = [
2196 "unused_files",
2197 "unused_exports",
2198 "unused_types",
2199 "unused_dependencies",
2200 "unused_dev_dependencies",
2201 "unused_optional_dependencies",
2202 "unused_enum_members",
2203 "unused_class_members",
2204 "unresolved_imports",
2205 "unlisted_dependencies",
2206 "duplicate_exports",
2207 "type_only_dependencies",
2208 "test_only_dependencies",
2209 "circular_dependencies",
2210 ];
2211
2212 for key in &issue_keys {
2213 let arr = output[key].as_array().unwrap();
2214 if !arr.is_empty() {
2215 let actions = arr[0]["actions"].as_array();
2216 assert!(
2217 actions.is_some() && !actions.unwrap().is_empty(),
2218 "missing actions for {key}"
2219 );
2220 }
2221 }
2222 }
2223
2224 fn build_actions_for_finding_json(
2231 finding_json: serde_json::Value,
2232 opts: crate::health_types::HealthActionOptions,
2233 max_cyclomatic_threshold: u16,
2234 max_cognitive_threshold: u16,
2235 max_crap_threshold: f64,
2236 ) -> Vec<serde_json::Value> {
2237 let mut value = finding_json;
2238 if let Some(map) = value.as_object_mut() {
2239 map.entry("col".to_string())
2240 .or_insert(serde_json::Value::from(0_u32));
2241 map.entry("line_count".to_string())
2242 .or_insert(serde_json::Value::from(0_u32));
2243 map.entry("param_count".to_string())
2244 .or_insert(serde_json::Value::from(0_u8));
2245 map.entry("severity".to_string())
2246 .or_insert(serde_json::Value::String("moderate".to_string()));
2247 }
2248 let violation = synthesize_complexity_violation(&value);
2249 let ctx = crate::health_types::HealthActionContext {
2250 opts,
2251 max_cyclomatic_threshold,
2252 max_cognitive_threshold,
2253 max_crap_threshold,
2254 crap_refactor_band: 5,
2255 };
2256 let finding = crate::health_types::HealthFinding::with_actions(violation, &ctx);
2257 let serialized = serde_json::to_value(&finding).expect("serialize HealthFinding");
2258 serialized["actions"]
2259 .as_array()
2260 .cloned()
2261 .unwrap_or_default()
2262 }
2263
2264 fn synthesize_complexity_violation(
2269 value: &serde_json::Value,
2270 ) -> crate::health_types::ComplexityViolation {
2271 use crate::health_types::{
2272 CoverageSource, CoverageTier, ExceededThreshold, FindingSeverity,
2273 };
2274 let exceeded = match value["exceeded"].as_str().unwrap_or("crap") {
2275 "cyclomatic" => ExceededThreshold::Cyclomatic,
2276 "cognitive" => ExceededThreshold::Cognitive,
2277 "both" => ExceededThreshold::Both,
2278 "crap" => ExceededThreshold::Crap,
2279 "cyclomatic_crap" => ExceededThreshold::CyclomaticCrap,
2280 "cognitive_crap" => ExceededThreshold::CognitiveCrap,
2281 "all" => ExceededThreshold::All,
2282 other => panic!("unknown exceeded label: {other}"),
2283 };
2284 let severity = match value["severity"].as_str().unwrap_or("moderate") {
2285 "moderate" => FindingSeverity::Moderate,
2286 "high" => FindingSeverity::High,
2287 "critical" => FindingSeverity::Critical,
2288 other => panic!("unknown severity label: {other}"),
2289 };
2290 let coverage_tier = value
2291 .get("coverage_tier")
2292 .and_then(|v| v.as_str())
2293 .map(|t| match t {
2294 "none" => CoverageTier::None,
2295 "partial" => CoverageTier::Partial,
2296 "high" => CoverageTier::High,
2297 other => panic!("unknown coverage_tier label: {other}"),
2298 });
2299 let coverage_source =
2300 value
2301 .get("coverage_source")
2302 .and_then(|v| v.as_str())
2303 .map(|s| match s {
2304 "istanbul" => CoverageSource::Istanbul,
2305 "estimated" => CoverageSource::Estimated,
2306 "estimated_component_inherited" => CoverageSource::EstimatedComponentInherited,
2307 other => panic!("unknown coverage_source label: {other}"),
2308 });
2309 crate::health_types::ComplexityViolation {
2310 path: std::path::PathBuf::from(value["path"].as_str().unwrap_or("src/x.ts")),
2311 name: value["name"].as_str().unwrap_or("fn").to_string(),
2312 line: u32::try_from(value["line"].as_u64().unwrap_or(0)).unwrap_or(0),
2313 col: u32::try_from(value["col"].as_u64().unwrap_or(0)).unwrap_or(0),
2314 cyclomatic: u16::try_from(value["cyclomatic"].as_u64().unwrap_or(0)).unwrap_or(0),
2315 cognitive: u16::try_from(value["cognitive"].as_u64().unwrap_or(0)).unwrap_or(0),
2316 line_count: u32::try_from(value["line_count"].as_u64().unwrap_or(0)).unwrap_or(0),
2317 param_count: u8::try_from(value["param_count"].as_u64().unwrap_or(0)).unwrap_or(0),
2318 exceeded,
2319 severity,
2320 crap: value.get("crap").and_then(|v| v.as_f64()),
2321 coverage_pct: value.get("coverage_pct").and_then(|v| v.as_f64()),
2322 coverage_tier,
2323 coverage_source,
2324 inherited_from: value
2325 .get("inherited_from")
2326 .and_then(|v| v.as_str())
2327 .map(std::path::PathBuf::from),
2328 component_rollup: value.get("component_rollup").and_then(|v| {
2329 let map = v.as_object()?;
2330 Some(crate::health_types::ComponentRollup {
2331 component: map.get("component")?.as_str()?.to_string(),
2332 class_worst_function: map.get("class_worst_function")?.as_str()?.to_string(),
2333 class_cyclomatic: u16::try_from(map.get("class_cyclomatic")?.as_u64()?).ok()?,
2334 class_cognitive: u16::try_from(map.get("class_cognitive")?.as_u64()?).ok()?,
2335 template_path: std::path::PathBuf::from(map.get("template_path")?.as_str()?),
2336 template_cyclomatic: u16::try_from(map.get("template_cyclomatic")?.as_u64()?)
2337 .ok()?,
2338 template_cognitive: u16::try_from(map.get("template_cognitive")?.as_u64()?)
2339 .ok()?,
2340 })
2341 }),
2342 contributions: Vec::new(),
2343 }
2344 }
2345
2346 #[test]
2347 fn health_finding_has_actions() {
2348 let actions = build_actions_for_finding_json(
2349 serde_json::json!({
2350 "path": "src/utils.ts",
2351 "name": "processData",
2352 "line": 10,
2353 "col": 0,
2354 "cyclomatic": 25,
2355 "cognitive": 30,
2356 "line_count": 150,
2357 "exceeded": "both"
2358 }),
2359 crate::health_types::HealthActionOptions::default(),
2360 20,
2361 15,
2362 30.0,
2363 );
2364
2365 assert_eq!(actions.len(), 2);
2366 assert_eq!(actions[0]["type"], "refactor-function");
2367 assert_eq!(actions[0]["auto_fixable"], false);
2368 assert!(
2369 actions[0]["description"]
2370 .as_str()
2371 .unwrap()
2372 .contains("processData")
2373 );
2374 assert_eq!(actions[1]["type"], "suppress-line");
2375 assert_eq!(
2376 actions[1]["comment"],
2377 "// fallow-ignore-next-line complexity"
2378 );
2379 }
2380
2381 #[test]
2382 fn health_finding_suppress_has_placement() {
2383 let actions = build_actions_for_finding_json(
2384 serde_json::json!({
2385 "path": "src/utils.ts",
2386 "name": "processData",
2387 "line": 10,
2388 "col": 0,
2389 "cyclomatic": 25,
2390 "cognitive": 30,
2391 "line_count": 150,
2392 "exceeded": "both"
2393 }),
2394 crate::health_types::HealthActionOptions::default(),
2395 20,
2396 15,
2397 30.0,
2398 );
2399
2400 assert_eq!(actions[1]["placement"], "above-function-declaration");
2401 }
2402
2403 #[test]
2404 fn html_template_health_finding_uses_html_suppression() {
2405 let actions = build_actions_for_finding_json(
2406 serde_json::json!({
2407 "path": "src/app.component.html",
2408 "name": "<template>",
2409 "line": 1,
2410 "col": 0,
2411 "cyclomatic": 25,
2412 "cognitive": 30,
2413 "line_count": 40,
2414 "exceeded": "both"
2415 }),
2416 crate::health_types::HealthActionOptions::default(),
2417 20,
2418 15,
2419 30.0,
2420 );
2421
2422 let suppress = &actions[1];
2423 assert_eq!(suppress["type"], "suppress-file");
2424 assert_eq!(
2425 suppress["comment"],
2426 "<!-- fallow-ignore-file complexity -->"
2427 );
2428 assert_eq!(suppress["placement"], "top-of-template");
2429 }
2430
2431 #[test]
2432 fn inline_template_health_finding_uses_decorator_suppression() {
2433 let actions = build_actions_for_finding_json(
2434 serde_json::json!({
2435 "path": "src/app.component.ts",
2436 "name": "<template>",
2437 "line": 5,
2438 "col": 0,
2439 "cyclomatic": 25,
2440 "cognitive": 30,
2441 "line_count": 40,
2442 "exceeded": "both"
2443 }),
2444 crate::health_types::HealthActionOptions::default(),
2445 20,
2446 15,
2447 30.0,
2448 );
2449
2450 let refactor = &actions[0];
2451 assert_eq!(refactor["type"], "refactor-function");
2452 assert!(
2453 refactor["description"]
2454 .as_str()
2455 .unwrap()
2456 .contains("template complexity")
2457 );
2458 let suppress = &actions[1];
2459 assert_eq!(suppress["type"], "suppress-line");
2460 assert_eq!(
2461 suppress["description"],
2462 "Suppress with an inline comment above the Angular decorator"
2463 );
2464 assert_eq!(suppress["placement"], "above-angular-decorator");
2465 }
2466
2467 fn crap_only_finding_envelope(
2474 coverage_tier: Option<&str>,
2475 cyclomatic: u16,
2476 max_cyclomatic_threshold: u16,
2477 ) -> serde_json::Value {
2478 crap_only_finding_envelope_with_max_crap(
2479 coverage_tier,
2480 cyclomatic,
2481 12,
2482 max_cyclomatic_threshold,
2483 15,
2484 30.0,
2485 )
2486 }
2487
2488 fn crap_only_finding_envelope_with_cognitive(
2489 coverage_tier: Option<&str>,
2490 cyclomatic: u16,
2491 cognitive: u16,
2492 max_cyclomatic_threshold: u16,
2493 ) -> serde_json::Value {
2494 crap_only_finding_envelope_with_max_crap(
2495 coverage_tier,
2496 cyclomatic,
2497 cognitive,
2498 max_cyclomatic_threshold,
2499 15,
2500 30.0,
2501 )
2502 }
2503
2504 fn crap_only_finding_envelope_with_max_crap(
2512 coverage_tier: Option<&str>,
2513 cyclomatic: u16,
2514 cognitive: u16,
2515 max_cyclomatic_threshold: u16,
2516 max_cognitive_threshold: u16,
2517 max_crap_threshold: f64,
2518 ) -> serde_json::Value {
2519 build_finding_envelope_with_ctx(
2520 coverage_tier,
2521 cyclomatic,
2522 cognitive,
2523 max_cyclomatic_threshold,
2524 max_cognitive_threshold,
2525 max_crap_threshold,
2526 crate::health_types::HealthActionOptions::default(),
2527 )
2528 }
2529
2530 fn build_finding_envelope_with_ctx(
2534 coverage_tier: Option<&str>,
2535 cyclomatic: u16,
2536 cognitive: u16,
2537 max_cyclomatic_threshold: u16,
2538 max_cognitive_threshold: u16,
2539 max_crap_threshold: f64,
2540 action_opts: crate::health_types::HealthActionOptions,
2541 ) -> serde_json::Value {
2542 let tier = coverage_tier.map(|t| match t {
2543 "none" => crate::health_types::CoverageTier::None,
2544 "partial" => crate::health_types::CoverageTier::Partial,
2545 "high" => crate::health_types::CoverageTier::High,
2546 other => panic!("unknown coverage tier label: {other}"),
2547 });
2548 let violation = crate::health_types::ComplexityViolation {
2549 path: std::path::PathBuf::from("src/risk.ts"),
2550 name: "computeScore".to_string(),
2551 line: 12,
2552 col: 0,
2553 cyclomatic,
2554 cognitive,
2555 line_count: 40,
2556 param_count: 0,
2557 exceeded: crate::health_types::ExceededThreshold::Crap,
2558 severity: crate::health_types::FindingSeverity::Moderate,
2559 crap: Some(35.5),
2560 coverage_pct: None,
2561 coverage_tier: tier,
2562 coverage_source: None,
2563 inherited_from: None,
2564 component_rollup: None,
2565 contributions: Vec::new(),
2566 };
2567 let ctx = crate::health_types::HealthActionContext {
2568 opts: action_opts,
2569 max_cyclomatic_threshold,
2570 max_cognitive_threshold,
2571 max_crap_threshold,
2572 crap_refactor_band: 5,
2573 };
2574 let finding = crate::health_types::HealthFinding::with_actions(violation, &ctx);
2575 let actions_meta = if action_opts.omit_suppress_line {
2576 Some(serde_json::json!({
2577 "suppression_hints_omitted": true,
2578 "reason": action_opts.omit_reason.unwrap_or("unspecified"),
2579 "scope": "health-findings",
2580 }))
2581 } else {
2582 None
2583 };
2584 let mut envelope = serde_json::json!({
2585 "findings": [serde_json::to_value(&finding).unwrap()],
2586 "summary": {
2587 "max_cyclomatic_threshold": max_cyclomatic_threshold,
2588 "max_cognitive_threshold": max_cognitive_threshold,
2589 "max_crap_threshold": max_crap_threshold,
2590 },
2591 });
2592 if let Some(meta) = actions_meta
2593 && let Some(map) = envelope.as_object_mut()
2594 {
2595 map.insert("actions_meta".to_string(), meta);
2596 }
2597 envelope
2598 }
2599
2600 #[test]
2601 fn crap_only_tier_none_emits_add_tests() {
2602 let output = crap_only_finding_envelope(Some("none"), 6, 20);
2603 let actions = output["findings"][0]["actions"].as_array().unwrap();
2604 assert!(
2605 actions.iter().any(|a| a["type"] == "add-tests"),
2606 "tier=none crap-only must emit add-tests, got {actions:?}"
2607 );
2608 assert!(
2609 !actions.iter().any(|a| a["type"] == "increase-coverage"),
2610 "tier=none must not emit increase-coverage"
2611 );
2612 }
2613
2614 #[test]
2615 fn crap_only_tier_partial_emits_increase_coverage() {
2616 let output = crap_only_finding_envelope(Some("partial"), 6, 20);
2617 let actions = output["findings"][0]["actions"].as_array().unwrap();
2618 assert!(
2619 actions.iter().any(|a| a["type"] == "increase-coverage"),
2620 "tier=partial crap-only must emit increase-coverage, got {actions:?}"
2621 );
2622 assert!(
2623 !actions.iter().any(|a| a["type"] == "add-tests"),
2624 "tier=partial must not emit add-tests"
2625 );
2626 }
2627
2628 #[test]
2629 fn crap_only_tier_high_emits_increase_coverage_when_full_coverage_can_clear_crap() {
2630 let output = crap_only_finding_envelope(Some("high"), 20, 30);
2631 let actions = output["findings"][0]["actions"].as_array().unwrap();
2632 assert!(
2633 actions.iter().any(|a| a["type"] == "increase-coverage"),
2634 "tier=high crap-only must still emit increase-coverage when full coverage can clear CRAP, got {actions:?}"
2635 );
2636 assert!(
2637 !actions.iter().any(|a| a["type"] == "refactor-function"),
2638 "coverage-remediable crap-only findings should not get refactor-function unless near the cyclomatic threshold"
2639 );
2640 assert!(
2641 !actions.iter().any(|a| a["type"] == "add-tests"),
2642 "tier=high must not emit add-tests"
2643 );
2644 }
2645
2646 #[test]
2647 fn crap_only_emits_refactor_when_full_coverage_cannot_clear_crap() {
2648 let output = crap_only_finding_envelope_with_max_crap(Some("high"), 35, 12, 50, 15, 30.0);
2649 let actions = output["findings"][0]["actions"].as_array().unwrap();
2650 assert!(
2651 actions.iter().any(|a| a["type"] == "refactor-function"),
2652 "full-coverage-impossible CRAP-only finding must emit refactor-function, got {actions:?}"
2653 );
2654 assert!(
2655 !actions.iter().any(|a| a["type"] == "increase-coverage"),
2656 "must not emit increase-coverage when even 100% coverage cannot clear CRAP"
2657 );
2658 assert!(
2659 !actions.iter().any(|a| a["type"] == "add-tests"),
2660 "must not emit add-tests when even 100% coverage cannot clear CRAP"
2661 );
2662 }
2663
2664 #[test]
2665 fn crap_only_high_cc_appends_secondary_refactor() {
2666 let output = crap_only_finding_envelope(Some("none"), 16, 20);
2667 let actions = output["findings"][0]["actions"].as_array().unwrap();
2668 assert!(
2669 actions.iter().any(|a| a["type"] == "add-tests"),
2670 "near-threshold crap-only still emits the primary tier action"
2671 );
2672 assert!(
2673 actions.iter().any(|a| a["type"] == "refactor-function"),
2674 "near-threshold crap-only must also emit secondary refactor-function"
2675 );
2676 }
2677
2678 #[test]
2679 fn crap_only_far_below_threshold_no_secondary_refactor() {
2680 let output = crap_only_finding_envelope(Some("none"), 6, 20);
2681 let actions = output["findings"][0]["actions"].as_array().unwrap();
2682 assert!(
2683 !actions.iter().any(|a| a["type"] == "refactor-function"),
2684 "low-CC crap-only should not get a secondary refactor-function"
2685 );
2686 }
2687
2688 #[test]
2689 fn crap_only_near_threshold_low_cognitive_no_secondary_refactor() {
2690 let output = crap_only_finding_envelope_with_cognitive(Some("none"), 17, 2, 20);
2691 let actions = output["findings"][0]["actions"].as_array().unwrap();
2692 assert!(
2693 actions.iter().any(|a| a["type"] == "add-tests"),
2694 "primary tier action still emits"
2695 );
2696 assert!(
2697 !actions.iter().any(|a| a["type"] == "refactor-function"),
2698 "near-threshold CC with cognitive below floor must NOT emit secondary refactor (got {actions:?})"
2699 );
2700 }
2701
2702 #[test]
2703 fn crap_only_near_threshold_high_cognitive_emits_secondary_refactor() {
2704 let output = crap_only_finding_envelope_with_cognitive(Some("none"), 16, 10, 20);
2705 let actions = output["findings"][0]["actions"].as_array().unwrap();
2706 assert!(
2707 actions.iter().any(|a| a["type"] == "add-tests"),
2708 "primary tier action still emits"
2709 );
2710 assert!(
2711 actions.iter().any(|a| a["type"] == "refactor-function"),
2712 "near-threshold CC with cognitive above floor must emit secondary refactor (got {actions:?})"
2713 );
2714 }
2715
2716 #[test]
2717 fn crap_only_secondary_refactor_respects_configured_band() {
2718 let violation = crate::health_types::ComplexityViolation {
2719 path: std::path::PathBuf::from("src/risk.ts"),
2720 name: "computeScore".to_string(),
2721 line: 12,
2722 col: 0,
2723 cyclomatic: 14,
2724 cognitive: 10,
2725 line_count: 40,
2726 param_count: 0,
2727 exceeded: crate::health_types::ExceededThreshold::Crap,
2728 severity: crate::health_types::FindingSeverity::Moderate,
2729 crap: Some(35.5),
2730 coverage_pct: None,
2731 coverage_tier: Some(crate::health_types::CoverageTier::None),
2732 coverage_source: None,
2733 inherited_from: None,
2734 component_rollup: None,
2735 contributions: Vec::new(),
2736 };
2737 let narrow_ctx = crate::health_types::HealthActionContext {
2738 opts: crate::health_types::HealthActionOptions::default(),
2739 max_cyclomatic_threshold: 20,
2740 max_cognitive_threshold: 15,
2741 max_crap_threshold: 30.0,
2742 crap_refactor_band: 5,
2743 };
2744 let wide_ctx = crate::health_types::HealthActionContext {
2745 crap_refactor_band: 6,
2746 ..narrow_ctx
2747 };
2748
2749 let narrow_actions =
2750 crate::health_types::build_health_finding_actions(&violation, &narrow_ctx);
2751 let wide_actions = crate::health_types::build_health_finding_actions(&violation, &wide_ctx);
2752
2753 assert!(
2754 !narrow_actions.iter().any(|a| {
2755 matches!(
2756 a.kind,
2757 fallow_types::output_health::HealthFindingActionType::RefactorFunction
2758 )
2759 }),
2760 "default band should not refactor a CRAP-only finding 6 below max cyclomatic"
2761 );
2762 assert!(
2763 wide_actions.iter().any(|a| {
2764 matches!(
2765 a.kind,
2766 fallow_types::output_health::HealthFindingActionType::RefactorFunction
2767 )
2768 }),
2769 "configured wider band should emit the secondary refactor action"
2770 );
2771 }
2772
2773 #[test]
2774 fn cyclomatic_only_emits_only_refactor_function() {
2775 let actions = build_actions_for_finding_json(
2776 serde_json::json!({
2777 "path": "src/cyclo.ts",
2778 "name": "branchy",
2779 "line": 5,
2780 "col": 0,
2781 "cyclomatic": 25,
2782 "cognitive": 10,
2783 "line_count": 80,
2784 "exceeded": "cyclomatic",
2785 }),
2786 crate::health_types::HealthActionOptions::default(),
2787 20,
2788 15,
2789 30.0,
2790 );
2791 assert!(
2792 actions.iter().any(|a| a["type"] == "refactor-function"),
2793 "non-CRAP findings emit refactor-function"
2794 );
2795 assert!(
2796 !actions.iter().any(|a| a["type"] == "add-tests"),
2797 "non-CRAP findings must not emit add-tests"
2798 );
2799 assert!(
2800 !actions.iter().any(|a| a["type"] == "increase-coverage"),
2801 "non-CRAP findings must not emit increase-coverage"
2802 );
2803 }
2804
2805 #[test]
2806 fn suppress_line_omitted_when_baseline_active() {
2807 let output = build_finding_envelope_with_ctx(
2808 Some("none"),
2809 6,
2810 12,
2811 20,
2812 15,
2813 30.0,
2814 crate::health_types::HealthActionOptions {
2815 omit_suppress_line: true,
2816 omit_reason: Some("baseline-active"),
2817 },
2818 );
2819 let actions = output["findings"][0]["actions"].as_array().unwrap();
2820 assert!(
2821 !actions.iter().any(|a| a["type"] == "suppress-line"),
2822 "baseline-active must not emit suppress-line, got {actions:?}"
2823 );
2824 assert_eq!(
2825 output["actions_meta"]["suppression_hints_omitted"],
2826 serde_json::Value::Bool(true)
2827 );
2828 assert_eq!(output["actions_meta"]["reason"], "baseline-active");
2829 assert_eq!(output["actions_meta"]["scope"], "health-findings");
2830 }
2831
2832 #[test]
2833 fn suppress_line_omitted_when_config_disabled() {
2834 let output = build_finding_envelope_with_ctx(
2835 Some("none"),
2836 6,
2837 12,
2838 20,
2839 15,
2840 30.0,
2841 crate::health_types::HealthActionOptions {
2842 omit_suppress_line: true,
2843 omit_reason: Some("config-disabled"),
2844 },
2845 );
2846 assert_eq!(output["actions_meta"]["reason"], "config-disabled");
2847 }
2848
2849 #[test]
2850 fn suppress_line_emitted_by_default() {
2851 let output = crap_only_finding_envelope(Some("none"), 6, 20);
2852 let actions = output["findings"][0]["actions"].as_array().unwrap();
2853 assert!(
2854 actions.iter().any(|a| a["type"] == "suppress-line"),
2855 "default opts must emit suppress-line"
2856 );
2857 assert!(
2858 output.get("actions_meta").is_none(),
2859 "actions_meta must be absent when no omission occurred"
2860 );
2861 }
2862
2863 #[test]
2870 fn every_emitted_health_action_type_is_in_schema_enum() {
2871 let cases = [
2872 ("crap", Some("none"), 6_u16, 20_u16),
2873 ("crap", Some("partial"), 6, 20),
2874 ("crap", Some("high"), 12, 20),
2875 ("crap", Some("none"), 16, 20), ("cyclomatic", None, 25, 20),
2877 ("cognitive_crap", Some("partial"), 6, 20),
2878 ("all", Some("none"), 25, 20),
2879 ];
2880
2881 let mut emitted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2882 for (exceeded, tier, cc, max) in cases {
2883 let mut finding = serde_json::json!({
2884 "path": "src/x.ts",
2885 "name": "fn",
2886 "line": 1,
2887 "col": 0,
2888 "cyclomatic": cc,
2889 "cognitive": 5,
2890 "line_count": 10,
2891 "exceeded": exceeded,
2892 "crap": 35.0,
2893 });
2894 if let Some(t) = tier {
2895 finding["coverage_tier"] = serde_json::Value::String(t.to_owned());
2896 }
2897 let actions = build_actions_for_finding_json(
2898 finding,
2899 crate::health_types::HealthActionOptions::default(),
2900 max,
2901 15,
2902 30.0,
2903 );
2904 for action in &actions {
2905 if let Some(ty) = action["type"].as_str() {
2906 emitted.insert(ty.to_owned());
2907 }
2908 }
2909 }
2910
2911 let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2912 .join("..")
2913 .join("..")
2914 .join("docs")
2915 .join("output-schema.json");
2916 let raw = std::fs::read_to_string(&schema_path)
2917 .expect("docs/output-schema.json must be readable for the drift-guard test");
2918 let schema: serde_json::Value = serde_json::from_str(&raw).expect("schema parses");
2919 let type_field = &schema["definitions"]["HealthFindingAction"]["properties"]["type"];
2920 let type_def = if let Some(reference) = type_field.get("$ref").and_then(|r| r.as_str()) {
2921 let name = reference
2922 .strip_prefix("#/definitions/")
2923 .expect("HealthFindingAction.type $ref points into #/definitions/");
2924 &schema["definitions"][name]
2925 } else {
2926 type_field
2927 };
2928 let mut enum_values: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2929 if let Some(arr) = type_def.get("enum").and_then(|e| e.as_array()) {
2930 for v in arr {
2931 if let Some(s) = v.as_str() {
2932 enum_values.insert(s.to_owned());
2933 }
2934 }
2935 }
2936 if let Some(arr) = type_def.get("oneOf").and_then(|e| e.as_array()) {
2937 for branch in arr {
2938 if let Some(s) = branch.get("const").and_then(|c| c.as_str()) {
2939 enum_values.insert(s.to_owned());
2940 }
2941 }
2942 }
2943 assert!(
2944 !enum_values.is_empty(),
2945 "could not extract HealthFindingActionType variants from schema (neither `enum` nor `oneOf` with `const` branches)"
2946 );
2947
2948 for ty in &emitted {
2949 assert!(
2950 enum_values.contains(ty),
2951 "build_health_finding_actions emitted action type `{ty}` but \
2952 docs/output-schema.json HealthFindingAction.type enum does \
2953 not list it. Add it to the schema (and any downstream \
2954 typed consumers) when introducing a new action type."
2955 );
2956 }
2957 }
2958
2959 #[test]
2973 fn no_new_post_pass_helpers_in_json_rs() {
2974 const POST_PASS_ALLOW_LIST: &[(&str, &str)] = &[];
2975 let source_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2976 .join("src")
2977 .join("report")
2978 .join("json.rs");
2979 let source = std::fs::read_to_string(&source_path).expect(
2980 "crates/cli/src/report/json.rs must be readable for the post-pass drift-guard test",
2981 );
2982 let mut found: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2983 for line in source.lines() {
2984 if let Some(name) = extract_post_pass_fn_name(line) {
2985 found.insert(name.to_owned());
2986 }
2987 }
2988 let allow: std::collections::BTreeSet<&'static str> =
2989 POST_PASS_ALLOW_LIST.iter().map(|(name, _)| *name).collect();
2990 let unexpected: Vec<&str> = found
2991 .iter()
2992 .filter(|name| !allow.contains(name.as_str()))
2993 .map(String::as_str)
2994 .collect();
2995 let stale: Vec<&str> = allow
2996 .iter()
2997 .filter(|name| !found.contains(**name))
2998 .copied()
2999 .collect();
3000 assert!(
3001 unexpected.is_empty(),
3002 "new post-pass helper(s) defined in crates/cli/src/report/json.rs are not in \
3003 POST_PASS_ALLOW_LIST: {unexpected:?}.\n\
3004 The typed `serde(flatten)` envelope is the source of truth for `actions[]` on \
3005 every finding. If a new post-pass is genuinely needed, file a tracking issue, \
3006 add the entry to POST_PASS_ALLOW_LIST with the issue link as the reason, and \
3007 reference the issue in the PR body. See issue #412 for context."
3008 );
3009 assert!(
3010 stale.is_empty(),
3011 "stale entries in POST_PASS_ALLOW_LIST (function no longer defined in \
3012 crates/cli/src/report/json.rs): {stale:?}.\n\
3013 Remove them in the same commit that retired the function."
3014 );
3015 }
3016
3017 fn extract_post_pass_fn_name(line: &str) -> Option<&str> {
3022 let trimmed = line.trim_start();
3023 if trimmed.starts_with("//") {
3024 return None;
3025 }
3026 let mut rest = trimmed;
3027 if let Some(after) = rest.strip_prefix("pub") {
3028 let after = after.trim_start();
3029 rest = if let Some(after) = after.strip_prefix('(') {
3030 let close = after.find(')')?;
3031 after[close + 1..].trim_start()
3032 } else {
3033 after
3034 };
3035 }
3036 for prefix in ["async ", "const ", "unsafe "] {
3037 if let Some(after) = rest.strip_prefix(prefix) {
3038 rest = after.trim_start();
3039 }
3040 }
3041 let after_fn = rest.strip_prefix("fn ")?;
3042 let name_end = after_fn
3043 .find(|c: char| !c.is_alphanumeric() && c != '_')
3044 .unwrap_or(after_fn.len());
3045 let name = &after_fn[..name_end];
3046 if name.starts_with("inject_") || name.starts_with("augment_") {
3047 Some(name)
3048 } else {
3049 None
3050 }
3051 }
3052}