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