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