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