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