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