1use std::path::Path;
2use std::process::ExitCode;
3use std::time::Duration;
4
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::AnalysisResults;
7
8use super::emit_json;
9use crate::explain;
10
11pub(super) fn print_json(
12 results: &AnalysisResults,
13 root: &Path,
14 elapsed: Duration,
15 explain: bool,
16 regression: Option<&crate::regression::RegressionOutcome>,
17) -> ExitCode {
18 match build_json(results, root, elapsed) {
19 Ok(mut output) => {
20 if let Some(outcome) = regression
21 && let serde_json::Value::Object(ref mut map) = output
22 {
23 map.insert("regression".to_string(), outcome.to_json());
24 }
25 if explain {
26 insert_meta(&mut output, explain::check_meta());
27 }
28 emit_json(&output, "JSON")
29 }
30 Err(e) => {
31 eprintln!("Error: failed to serialize results: {e}");
32 ExitCode::from(2)
33 }
34 }
35}
36
37const SCHEMA_VERSION: u32 = 3;
43
44fn build_json_envelope(report_value: serde_json::Value, elapsed: Duration) -> serde_json::Value {
50 let mut map = serde_json::Map::new();
51 map.insert(
52 "schema_version".to_string(),
53 serde_json::json!(SCHEMA_VERSION),
54 );
55 map.insert(
56 "version".to_string(),
57 serde_json::json!(env!("CARGO_PKG_VERSION")),
58 );
59 map.insert(
60 "elapsed_ms".to_string(),
61 serde_json::json!(elapsed.as_millis()),
62 );
63 if let serde_json::Value::Object(report_map) = report_value {
64 for (key, value) in report_map {
65 map.insert(key, value);
66 }
67 }
68 serde_json::Value::Object(map)
69}
70
71pub fn build_json(
80 results: &AnalysisResults,
81 root: &Path,
82 elapsed: Duration,
83) -> Result<serde_json::Value, serde_json::Error> {
84 let results_value = serde_json::to_value(results)?;
85
86 let mut map = serde_json::Map::new();
87 map.insert(
88 "schema_version".to_string(),
89 serde_json::json!(SCHEMA_VERSION),
90 );
91 map.insert(
92 "version".to_string(),
93 serde_json::json!(env!("CARGO_PKG_VERSION")),
94 );
95 map.insert(
96 "elapsed_ms".to_string(),
97 serde_json::json!(elapsed.as_millis()),
98 );
99 map.insert(
100 "total_issues".to_string(),
101 serde_json::json!(results.total_issues()),
102 );
103
104 if let serde_json::Value::Object(results_map) = results_value {
105 for (key, value) in results_map {
106 map.insert(key, value);
107 }
108 }
109
110 let mut output = serde_json::Value::Object(map);
111 let root_prefix = format!("{}/", root.display());
112 strip_root_prefix(&mut output, &root_prefix);
116 inject_actions(&mut output);
117 Ok(output)
118}
119
120fn strip_root_prefix(value: &mut serde_json::Value, prefix: &str) {
125 match value {
126 serde_json::Value::String(s) => {
127 if let Some(rest) = s.strip_prefix(prefix) {
128 *s = rest.to_string();
129 }
130 }
131 serde_json::Value::Array(arr) => {
132 for item in arr {
133 strip_root_prefix(item, prefix);
134 }
135 }
136 serde_json::Value::Object(map) => {
137 for (_, v) in map.iter_mut() {
138 strip_root_prefix(v, prefix);
139 }
140 }
141 _ => {}
142 }
143}
144
145enum SuppressKind {
149 InlineComment,
151 FileComment,
153 ConfigIgnoreDep,
155}
156
157struct ActionSpec {
159 fix_type: &'static str,
160 auto_fixable: bool,
161 description: &'static str,
162 note: Option<&'static str>,
163 suppress: SuppressKind,
164 issue_kind: &'static str,
165}
166
167fn actions_for_issue_type(key: &str) -> Option<ActionSpec> {
169 match key {
170 "unused_files" => Some(ActionSpec {
171 fix_type: "delete-file",
172 auto_fixable: false,
173 description: "Delete this file",
174 note: Some(
175 "File deletion may remove runtime functionality not visible to static analysis",
176 ),
177 suppress: SuppressKind::FileComment,
178 issue_kind: "unused-file",
179 }),
180 "unused_exports" => Some(ActionSpec {
181 fix_type: "remove-export",
182 auto_fixable: true,
183 description: "Remove the `export` keyword from the declaration",
184 note: None,
185 suppress: SuppressKind::InlineComment,
186 issue_kind: "unused-export",
187 }),
188 "unused_types" => Some(ActionSpec {
189 fix_type: "remove-export",
190 auto_fixable: true,
191 description: "Remove the `export` (or `export type`) keyword from the type declaration",
192 note: None,
193 suppress: SuppressKind::InlineComment,
194 issue_kind: "unused-type",
195 }),
196 "unused_dependencies" => Some(ActionSpec {
197 fix_type: "remove-dependency",
198 auto_fixable: true,
199 description: "Remove from dependencies in package.json",
200 note: None,
201 suppress: SuppressKind::ConfigIgnoreDep,
202 issue_kind: "unused-dependency",
203 }),
204 "unused_dev_dependencies" => Some(ActionSpec {
205 fix_type: "remove-dependency",
206 auto_fixable: true,
207 description: "Remove from devDependencies in package.json",
208 note: None,
209 suppress: SuppressKind::ConfigIgnoreDep,
210 issue_kind: "unused-dev-dependency",
211 }),
212 "unused_optional_dependencies" => Some(ActionSpec {
213 fix_type: "remove-dependency",
214 auto_fixable: true,
215 description: "Remove from optionalDependencies in package.json",
216 note: None,
217 suppress: SuppressKind::ConfigIgnoreDep,
218 issue_kind: "unused-dependency",
220 }),
221 "unused_enum_members" => Some(ActionSpec {
222 fix_type: "remove-enum-member",
223 auto_fixable: true,
224 description: "Remove this enum member",
225 note: None,
226 suppress: SuppressKind::InlineComment,
227 issue_kind: "unused-enum-member",
228 }),
229 "unused_class_members" => Some(ActionSpec {
230 fix_type: "remove-class-member",
231 auto_fixable: false,
232 description: "Remove this class member",
233 note: Some("Class member may be used via dependency injection or decorators"),
234 suppress: SuppressKind::InlineComment,
235 issue_kind: "unused-class-member",
236 }),
237 "unresolved_imports" => Some(ActionSpec {
238 fix_type: "resolve-import",
239 auto_fixable: false,
240 description: "Fix the import specifier or install the missing module",
241 note: Some("Verify the module path and check tsconfig paths configuration"),
242 suppress: SuppressKind::InlineComment,
243 issue_kind: "unresolved-import",
244 }),
245 "unlisted_dependencies" => Some(ActionSpec {
246 fix_type: "install-dependency",
247 auto_fixable: false,
248 description: "Add this package to dependencies in package.json",
249 note: Some("Verify this package should be a direct dependency before adding"),
250 suppress: SuppressKind::ConfigIgnoreDep,
251 issue_kind: "unlisted-dependency",
252 }),
253 "duplicate_exports" => Some(ActionSpec {
254 fix_type: "remove-duplicate",
255 auto_fixable: false,
256 description: "Keep one canonical export location and remove the others",
257 note: Some("Review all locations to determine which should be the canonical export"),
258 suppress: SuppressKind::InlineComment,
259 issue_kind: "duplicate-export",
260 }),
261 "type_only_dependencies" => Some(ActionSpec {
262 fix_type: "move-to-dev",
263 auto_fixable: false,
264 description: "Move to devDependencies (only type imports are used)",
265 note: Some(
266 "Type imports are erased at runtime so this dependency is not needed in production",
267 ),
268 suppress: SuppressKind::ConfigIgnoreDep,
269 issue_kind: "type-only-dependency",
270 }),
271 "test_only_dependencies" => Some(ActionSpec {
272 fix_type: "move-to-dev",
273 auto_fixable: false,
274 description: "Move to devDependencies (only test files import this)",
275 note: Some(
276 "Only test files import this package so it does not need to be a production dependency",
277 ),
278 suppress: SuppressKind::ConfigIgnoreDep,
279 issue_kind: "test-only-dependency",
280 }),
281 "circular_dependencies" => Some(ActionSpec {
282 fix_type: "refactor-cycle",
283 auto_fixable: false,
284 description: "Extract shared logic into a separate module to break the cycle",
285 note: Some(
286 "Circular imports can cause initialization issues and make code harder to reason about",
287 ),
288 suppress: SuppressKind::InlineComment,
289 issue_kind: "circular-dependency",
290 }),
291 _ => None,
292 }
293}
294
295fn build_actions(
297 item: &serde_json::Value,
298 issue_key: &str,
299 spec: &ActionSpec,
300) -> serde_json::Value {
301 let mut actions = Vec::with_capacity(2);
302
303 let mut fix_action = serde_json::json!({
305 "type": spec.fix_type,
306 "auto_fixable": spec.auto_fixable,
307 "description": spec.description,
308 });
309 if let Some(note) = spec.note {
310 fix_action["note"] = serde_json::json!(note);
311 }
312 if (issue_key == "unused_exports" || issue_key == "unused_types")
314 && item
315 .get("is_re_export")
316 .and_then(serde_json::Value::as_bool)
317 == Some(true)
318 {
319 fix_action["note"] = serde_json::json!(
320 "This finding originates from a re-export; verify it is not part of your public API before removing"
321 );
322 }
323 actions.push(fix_action);
324
325 match spec.suppress {
327 SuppressKind::InlineComment => {
328 let mut suppress = serde_json::json!({
329 "type": "suppress-line",
330 "auto_fixable": false,
331 "description": "Suppress with an inline comment above the line",
332 "comment": format!("// fallow-ignore-next-line {}", spec.issue_kind),
333 });
334 if issue_key == "duplicate_exports" {
336 suppress["scope"] = serde_json::json!("per-location");
337 }
338 actions.push(suppress);
339 }
340 SuppressKind::FileComment => {
341 actions.push(serde_json::json!({
342 "type": "suppress-file",
343 "auto_fixable": false,
344 "description": "Suppress with a file-level comment at the top of the file",
345 "comment": format!("// fallow-ignore-file {}", spec.issue_kind),
346 }));
347 }
348 SuppressKind::ConfigIgnoreDep => {
349 let pkg = item
351 .get("package_name")
352 .and_then(serde_json::Value::as_str)
353 .unwrap_or("package-name");
354 actions.push(serde_json::json!({
355 "type": "add-to-config",
356 "auto_fixable": false,
357 "description": format!("Add \"{pkg}\" to ignoreDependencies in fallow config"),
358 "config_key": "ignoreDependencies",
359 "value": pkg,
360 }));
361 }
362 }
363
364 serde_json::Value::Array(actions)
365}
366
367fn inject_actions(output: &mut serde_json::Value) {
372 let Some(map) = output.as_object_mut() else {
373 return;
374 };
375
376 for (key, value) in map.iter_mut() {
377 let Some(spec) = actions_for_issue_type(key) else {
378 continue;
379 };
380 let Some(arr) = value.as_array_mut() else {
381 continue;
382 };
383 for item in arr {
384 let actions = build_actions(item, key, &spec);
385 if let serde_json::Value::Object(obj) = item {
386 obj.insert("actions".to_string(), actions);
387 }
388 }
389 }
390}
391
392fn insert_meta(output: &mut serde_json::Value, meta: serde_json::Value) {
394 if let serde_json::Value::Object(map) = output {
395 map.insert("_meta".to_string(), meta);
396 }
397}
398
399pub(super) fn print_health_json(
400 report: &crate::health_types::HealthReport,
401 root: &Path,
402 elapsed: Duration,
403 explain: bool,
404) -> ExitCode {
405 let report_value = match serde_json::to_value(report) {
406 Ok(v) => v,
407 Err(e) => {
408 eprintln!("Error: failed to serialize health report: {e}");
409 return ExitCode::from(2);
410 }
411 };
412
413 let mut output = build_json_envelope(report_value, elapsed);
414 let root_prefix = format!("{}/", root.display());
415 strip_root_prefix(&mut output, &root_prefix);
416
417 if explain {
418 insert_meta(&mut output, explain::health_meta());
419 }
420
421 emit_json(&output, "JSON")
422}
423
424pub(super) fn print_duplication_json(
425 report: &DuplicationReport,
426 elapsed: Duration,
427 explain: bool,
428) -> ExitCode {
429 let report_value = match serde_json::to_value(report) {
430 Ok(v) => v,
431 Err(e) => {
432 eprintln!("Error: failed to serialize duplication report: {e}");
433 return ExitCode::from(2);
434 }
435 };
436
437 let mut output = build_json_envelope(report_value, elapsed);
438
439 if explain {
440 insert_meta(&mut output, explain::dupes_meta());
441 }
442
443 emit_json(&output, "JSON")
444}
445
446pub(super) fn print_trace_json<T: serde::Serialize>(value: &T) {
447 match serde_json::to_string_pretty(value) {
448 Ok(json) => println!("{json}"),
449 Err(e) => {
450 eprintln!("Error: failed to serialize trace output: {e}");
451 #[expect(
452 clippy::exit,
453 reason = "fatal serialization error requires immediate exit"
454 )]
455 std::process::exit(2);
456 }
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463 use crate::report::test_helpers::sample_results;
464 use fallow_core::extract::MemberKind;
465 use fallow_core::results::*;
466 use std::path::PathBuf;
467 use std::time::Duration;
468
469 #[test]
470 fn json_output_has_metadata_fields() {
471 let root = PathBuf::from("/project");
472 let results = AnalysisResults::default();
473 let elapsed = Duration::from_millis(123);
474 let output = build_json(&results, &root, elapsed).expect("should serialize");
475
476 assert_eq!(output["schema_version"], 3);
477 assert!(output["version"].is_string());
478 assert_eq!(output["elapsed_ms"], 123);
479 assert_eq!(output["total_issues"], 0);
480 }
481
482 #[test]
483 fn json_output_includes_issue_arrays() {
484 let root = PathBuf::from("/project");
485 let results = sample_results(&root);
486 let elapsed = Duration::from_millis(50);
487 let output = build_json(&results, &root, elapsed).expect("should serialize");
488
489 assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
490 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
491 assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
492 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
493 assert_eq!(
494 output["unused_dev_dependencies"].as_array().unwrap().len(),
495 1
496 );
497 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
498 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
499 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
500 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
501 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
502 assert_eq!(
503 output["type_only_dependencies"].as_array().unwrap().len(),
504 1
505 );
506 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
507 }
508
509 #[test]
510 fn json_metadata_fields_appear_first() {
511 let root = PathBuf::from("/project");
512 let results = AnalysisResults::default();
513 let elapsed = Duration::from_millis(0);
514 let output = build_json(&results, &root, elapsed).expect("should serialize");
515 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
516 assert_eq!(keys[0], "schema_version");
517 assert_eq!(keys[1], "version");
518 assert_eq!(keys[2], "elapsed_ms");
519 assert_eq!(keys[3], "total_issues");
520 }
521
522 #[test]
523 fn json_total_issues_matches_results() {
524 let root = PathBuf::from("/project");
525 let results = sample_results(&root);
526 let total = results.total_issues();
527 let elapsed = Duration::from_millis(0);
528 let output = build_json(&results, &root, elapsed).expect("should serialize");
529
530 assert_eq!(output["total_issues"], total);
531 }
532
533 #[test]
534 fn json_unused_export_contains_expected_fields() {
535 let root = PathBuf::from("/project");
536 let mut results = AnalysisResults::default();
537 results.unused_exports.push(UnusedExport {
538 path: root.join("src/utils.ts"),
539 export_name: "helperFn".to_string(),
540 is_type_only: false,
541 line: 10,
542 col: 4,
543 span_start: 120,
544 is_re_export: false,
545 });
546 let elapsed = Duration::from_millis(0);
547 let output = build_json(&results, &root, elapsed).expect("should serialize");
548
549 let export = &output["unused_exports"][0];
550 assert_eq!(export["export_name"], "helperFn");
551 assert_eq!(export["line"], 10);
552 assert_eq!(export["col"], 4);
553 assert_eq!(export["is_type_only"], false);
554 assert_eq!(export["span_start"], 120);
555 assert_eq!(export["is_re_export"], false);
556 }
557
558 #[test]
559 fn json_serializes_to_valid_json() {
560 let root = PathBuf::from("/project");
561 let results = sample_results(&root);
562 let elapsed = Duration::from_millis(42);
563 let output = build_json(&results, &root, elapsed).expect("should serialize");
564
565 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
566 let reparsed: serde_json::Value =
567 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
568 assert_eq!(reparsed, output);
569 }
570
571 #[test]
574 fn json_empty_results_produce_valid_structure() {
575 let root = PathBuf::from("/project");
576 let results = AnalysisResults::default();
577 let elapsed = Duration::from_millis(0);
578 let output = build_json(&results, &root, elapsed).expect("should serialize");
579
580 assert_eq!(output["total_issues"], 0);
581 assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
582 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
583 assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
584 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
585 assert_eq!(
586 output["unused_dev_dependencies"].as_array().unwrap().len(),
587 0
588 );
589 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
590 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
591 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
592 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
593 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
594 assert_eq!(
595 output["type_only_dependencies"].as_array().unwrap().len(),
596 0
597 );
598 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
599 }
600
601 #[test]
602 fn json_empty_results_round_trips_through_string() {
603 let root = PathBuf::from("/project");
604 let results = AnalysisResults::default();
605 let elapsed = Duration::from_millis(0);
606 let output = build_json(&results, &root, elapsed).expect("should serialize");
607
608 let json_str = serde_json::to_string(&output).expect("should stringify");
609 let reparsed: serde_json::Value =
610 serde_json::from_str(&json_str).expect("should parse back");
611 assert_eq!(reparsed["total_issues"], 0);
612 }
613
614 #[test]
617 fn json_paths_are_relative_to_root() {
618 let root = PathBuf::from("/project");
619 let mut results = AnalysisResults::default();
620 results.unused_files.push(UnusedFile {
621 path: root.join("src/deep/nested/file.ts"),
622 });
623 let elapsed = Duration::from_millis(0);
624 let output = build_json(&results, &root, elapsed).expect("should serialize");
625
626 let path = output["unused_files"][0]["path"].as_str().unwrap();
627 assert_eq!(path, "src/deep/nested/file.ts");
628 assert!(!path.starts_with("/project"));
629 }
630
631 #[test]
632 fn json_strips_root_from_nested_locations() {
633 let root = PathBuf::from("/project");
634 let mut results = AnalysisResults::default();
635 results.unlisted_dependencies.push(UnlistedDependency {
636 package_name: "chalk".to_string(),
637 imported_from: vec![ImportSite {
638 path: root.join("src/cli.ts"),
639 line: 2,
640 col: 0,
641 }],
642 });
643 let elapsed = Duration::from_millis(0);
644 let output = build_json(&results, &root, elapsed).expect("should serialize");
645
646 let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
647 .as_str()
648 .unwrap();
649 assert_eq!(site_path, "src/cli.ts");
650 }
651
652 #[test]
653 fn json_strips_root_from_duplicate_export_locations() {
654 let root = PathBuf::from("/project");
655 let mut results = AnalysisResults::default();
656 results.duplicate_exports.push(DuplicateExport {
657 export_name: "Config".to_string(),
658 locations: vec![
659 DuplicateLocation {
660 path: root.join("src/config.ts"),
661 line: 15,
662 col: 0,
663 },
664 DuplicateLocation {
665 path: root.join("src/types.ts"),
666 line: 30,
667 col: 0,
668 },
669 ],
670 });
671 let elapsed = Duration::from_millis(0);
672 let output = build_json(&results, &root, elapsed).expect("should serialize");
673
674 let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
675 .as_str()
676 .unwrap();
677 let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
678 .as_str()
679 .unwrap();
680 assert_eq!(loc0, "src/config.ts");
681 assert_eq!(loc1, "src/types.ts");
682 }
683
684 #[test]
685 fn json_strips_root_from_circular_dependency_files() {
686 let root = PathBuf::from("/project");
687 let mut results = AnalysisResults::default();
688 results.circular_dependencies.push(CircularDependency {
689 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
690 length: 2,
691 line: 1,
692 col: 0,
693 });
694 let elapsed = Duration::from_millis(0);
695 let output = build_json(&results, &root, elapsed).expect("should serialize");
696
697 let files = output["circular_dependencies"][0]["files"]
698 .as_array()
699 .unwrap();
700 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
701 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
702 }
703
704 #[test]
705 fn json_path_outside_root_not_stripped() {
706 let root = PathBuf::from("/project");
707 let mut results = AnalysisResults::default();
708 results.unused_files.push(UnusedFile {
709 path: PathBuf::from("/other/project/src/file.ts"),
710 });
711 let elapsed = Duration::from_millis(0);
712 let output = build_json(&results, &root, elapsed).expect("should serialize");
713
714 let path = output["unused_files"][0]["path"].as_str().unwrap();
715 assert!(path.contains("/other/project/"));
716 }
717
718 #[test]
721 fn json_unused_file_contains_path() {
722 let root = PathBuf::from("/project");
723 let mut results = AnalysisResults::default();
724 results.unused_files.push(UnusedFile {
725 path: root.join("src/orphan.ts"),
726 });
727 let elapsed = Duration::from_millis(0);
728 let output = build_json(&results, &root, elapsed).expect("should serialize");
729
730 let file = &output["unused_files"][0];
731 assert_eq!(file["path"], "src/orphan.ts");
732 }
733
734 #[test]
735 fn json_unused_type_contains_expected_fields() {
736 let root = PathBuf::from("/project");
737 let mut results = AnalysisResults::default();
738 results.unused_types.push(UnusedExport {
739 path: root.join("src/types.ts"),
740 export_name: "OldInterface".to_string(),
741 is_type_only: true,
742 line: 20,
743 col: 0,
744 span_start: 300,
745 is_re_export: false,
746 });
747 let elapsed = Duration::from_millis(0);
748 let output = build_json(&results, &root, elapsed).expect("should serialize");
749
750 let typ = &output["unused_types"][0];
751 assert_eq!(typ["export_name"], "OldInterface");
752 assert_eq!(typ["is_type_only"], true);
753 assert_eq!(typ["line"], 20);
754 assert_eq!(typ["path"], "src/types.ts");
755 }
756
757 #[test]
758 fn json_unused_dependency_contains_expected_fields() {
759 let root = PathBuf::from("/project");
760 let mut results = AnalysisResults::default();
761 results.unused_dependencies.push(UnusedDependency {
762 package_name: "axios".to_string(),
763 location: DependencyLocation::Dependencies,
764 path: root.join("package.json"),
765 line: 10,
766 });
767 let elapsed = Duration::from_millis(0);
768 let output = build_json(&results, &root, elapsed).expect("should serialize");
769
770 let dep = &output["unused_dependencies"][0];
771 assert_eq!(dep["package_name"], "axios");
772 assert_eq!(dep["line"], 10);
773 }
774
775 #[test]
776 fn json_unused_dev_dependency_contains_expected_fields() {
777 let root = PathBuf::from("/project");
778 let mut results = AnalysisResults::default();
779 results.unused_dev_dependencies.push(UnusedDependency {
780 package_name: "vitest".to_string(),
781 location: DependencyLocation::DevDependencies,
782 path: root.join("package.json"),
783 line: 15,
784 });
785 let elapsed = Duration::from_millis(0);
786 let output = build_json(&results, &root, elapsed).expect("should serialize");
787
788 let dep = &output["unused_dev_dependencies"][0];
789 assert_eq!(dep["package_name"], "vitest");
790 }
791
792 #[test]
793 fn json_unused_optional_dependency_contains_expected_fields() {
794 let root = PathBuf::from("/project");
795 let mut results = AnalysisResults::default();
796 results.unused_optional_dependencies.push(UnusedDependency {
797 package_name: "fsevents".to_string(),
798 location: DependencyLocation::OptionalDependencies,
799 path: root.join("package.json"),
800 line: 12,
801 });
802 let elapsed = Duration::from_millis(0);
803 let output = build_json(&results, &root, elapsed).expect("should serialize");
804
805 let dep = &output["unused_optional_dependencies"][0];
806 assert_eq!(dep["package_name"], "fsevents");
807 assert_eq!(output["total_issues"], 1);
808 }
809
810 #[test]
811 fn json_unused_enum_member_contains_expected_fields() {
812 let root = PathBuf::from("/project");
813 let mut results = AnalysisResults::default();
814 results.unused_enum_members.push(UnusedMember {
815 path: root.join("src/enums.ts"),
816 parent_name: "Color".to_string(),
817 member_name: "Purple".to_string(),
818 kind: MemberKind::EnumMember,
819 line: 5,
820 col: 2,
821 });
822 let elapsed = Duration::from_millis(0);
823 let output = build_json(&results, &root, elapsed).expect("should serialize");
824
825 let member = &output["unused_enum_members"][0];
826 assert_eq!(member["parent_name"], "Color");
827 assert_eq!(member["member_name"], "Purple");
828 assert_eq!(member["line"], 5);
829 assert_eq!(member["path"], "src/enums.ts");
830 }
831
832 #[test]
833 fn json_unused_class_member_contains_expected_fields() {
834 let root = PathBuf::from("/project");
835 let mut results = AnalysisResults::default();
836 results.unused_class_members.push(UnusedMember {
837 path: root.join("src/api.ts"),
838 parent_name: "ApiClient".to_string(),
839 member_name: "deprecatedFetch".to_string(),
840 kind: MemberKind::ClassMethod,
841 line: 100,
842 col: 4,
843 });
844 let elapsed = Duration::from_millis(0);
845 let output = build_json(&results, &root, elapsed).expect("should serialize");
846
847 let member = &output["unused_class_members"][0];
848 assert_eq!(member["parent_name"], "ApiClient");
849 assert_eq!(member["member_name"], "deprecatedFetch");
850 assert_eq!(member["line"], 100);
851 }
852
853 #[test]
854 fn json_unresolved_import_contains_expected_fields() {
855 let root = PathBuf::from("/project");
856 let mut results = AnalysisResults::default();
857 results.unresolved_imports.push(UnresolvedImport {
858 path: root.join("src/app.ts"),
859 specifier: "@acme/missing-pkg".to_string(),
860 line: 7,
861 col: 0,
862 specifier_col: 0,
863 });
864 let elapsed = Duration::from_millis(0);
865 let output = build_json(&results, &root, elapsed).expect("should serialize");
866
867 let import = &output["unresolved_imports"][0];
868 assert_eq!(import["specifier"], "@acme/missing-pkg");
869 assert_eq!(import["line"], 7);
870 assert_eq!(import["path"], "src/app.ts");
871 }
872
873 #[test]
874 fn json_unlisted_dependency_contains_import_sites() {
875 let root = PathBuf::from("/project");
876 let mut results = AnalysisResults::default();
877 results.unlisted_dependencies.push(UnlistedDependency {
878 package_name: "dotenv".to_string(),
879 imported_from: vec![
880 ImportSite {
881 path: root.join("src/config.ts"),
882 line: 1,
883 col: 0,
884 },
885 ImportSite {
886 path: root.join("src/server.ts"),
887 line: 3,
888 col: 0,
889 },
890 ],
891 });
892 let elapsed = Duration::from_millis(0);
893 let output = build_json(&results, &root, elapsed).expect("should serialize");
894
895 let dep = &output["unlisted_dependencies"][0];
896 assert_eq!(dep["package_name"], "dotenv");
897 let sites = dep["imported_from"].as_array().unwrap();
898 assert_eq!(sites.len(), 2);
899 assert_eq!(sites[0]["path"], "src/config.ts");
900 assert_eq!(sites[1]["path"], "src/server.ts");
901 }
902
903 #[test]
904 fn json_duplicate_export_contains_locations() {
905 let root = PathBuf::from("/project");
906 let mut results = AnalysisResults::default();
907 results.duplicate_exports.push(DuplicateExport {
908 export_name: "Button".to_string(),
909 locations: vec![
910 DuplicateLocation {
911 path: root.join("src/ui.ts"),
912 line: 10,
913 col: 0,
914 },
915 DuplicateLocation {
916 path: root.join("src/components.ts"),
917 line: 25,
918 col: 0,
919 },
920 ],
921 });
922 let elapsed = Duration::from_millis(0);
923 let output = build_json(&results, &root, elapsed).expect("should serialize");
924
925 let dup = &output["duplicate_exports"][0];
926 assert_eq!(dup["export_name"], "Button");
927 let locs = dup["locations"].as_array().unwrap();
928 assert_eq!(locs.len(), 2);
929 assert_eq!(locs[0]["line"], 10);
930 assert_eq!(locs[1]["line"], 25);
931 }
932
933 #[test]
934 fn json_type_only_dependency_contains_expected_fields() {
935 let root = PathBuf::from("/project");
936 let mut results = AnalysisResults::default();
937 results.type_only_dependencies.push(TypeOnlyDependency {
938 package_name: "zod".to_string(),
939 path: root.join("package.json"),
940 line: 8,
941 });
942 let elapsed = Duration::from_millis(0);
943 let output = build_json(&results, &root, elapsed).expect("should serialize");
944
945 let dep = &output["type_only_dependencies"][0];
946 assert_eq!(dep["package_name"], "zod");
947 assert_eq!(dep["line"], 8);
948 }
949
950 #[test]
951 fn json_circular_dependency_contains_expected_fields() {
952 let root = PathBuf::from("/project");
953 let mut results = AnalysisResults::default();
954 results.circular_dependencies.push(CircularDependency {
955 files: vec![
956 root.join("src/a.ts"),
957 root.join("src/b.ts"),
958 root.join("src/c.ts"),
959 ],
960 length: 3,
961 line: 5,
962 col: 0,
963 });
964 let elapsed = Duration::from_millis(0);
965 let output = build_json(&results, &root, elapsed).expect("should serialize");
966
967 let cycle = &output["circular_dependencies"][0];
968 assert_eq!(cycle["length"], 3);
969 assert_eq!(cycle["line"], 5);
970 let files = cycle["files"].as_array().unwrap();
971 assert_eq!(files.len(), 3);
972 }
973
974 #[test]
977 fn json_re_export_flagged_correctly() {
978 let root = PathBuf::from("/project");
979 let mut results = AnalysisResults::default();
980 results.unused_exports.push(UnusedExport {
981 path: root.join("src/index.ts"),
982 export_name: "reExported".to_string(),
983 is_type_only: false,
984 line: 1,
985 col: 0,
986 span_start: 0,
987 is_re_export: true,
988 });
989 let elapsed = Duration::from_millis(0);
990 let output = build_json(&results, &root, elapsed).expect("should serialize");
991
992 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
993 }
994
995 #[test]
998 fn json_schema_version_is_3() {
999 let root = PathBuf::from("/project");
1000 let results = AnalysisResults::default();
1001 let elapsed = Duration::from_millis(0);
1002 let output = build_json(&results, &root, elapsed).expect("should serialize");
1003
1004 assert_eq!(output["schema_version"], SCHEMA_VERSION);
1005 assert_eq!(output["schema_version"], 3);
1006 }
1007
1008 #[test]
1011 fn json_version_matches_cargo_pkg_version() {
1012 let root = PathBuf::from("/project");
1013 let results = AnalysisResults::default();
1014 let elapsed = Duration::from_millis(0);
1015 let output = build_json(&results, &root, elapsed).expect("should serialize");
1016
1017 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1018 }
1019
1020 #[test]
1023 fn json_elapsed_ms_zero_duration() {
1024 let root = PathBuf::from("/project");
1025 let results = AnalysisResults::default();
1026 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1027
1028 assert_eq!(output["elapsed_ms"], 0);
1029 }
1030
1031 #[test]
1032 fn json_elapsed_ms_large_duration() {
1033 let root = PathBuf::from("/project");
1034 let results = AnalysisResults::default();
1035 let elapsed = Duration::from_secs(120);
1036 let output = build_json(&results, &root, elapsed).expect("should serialize");
1037
1038 assert_eq!(output["elapsed_ms"], 120_000);
1039 }
1040
1041 #[test]
1042 fn json_elapsed_ms_sub_millisecond_truncated() {
1043 let root = PathBuf::from("/project");
1044 let results = AnalysisResults::default();
1045 let elapsed = Duration::from_micros(500);
1047 let output = build_json(&results, &root, elapsed).expect("should serialize");
1048
1049 assert_eq!(output["elapsed_ms"], 0);
1050 }
1051
1052 #[test]
1055 fn json_multiple_unused_files() {
1056 let root = PathBuf::from("/project");
1057 let mut results = AnalysisResults::default();
1058 results.unused_files.push(UnusedFile {
1059 path: root.join("src/a.ts"),
1060 });
1061 results.unused_files.push(UnusedFile {
1062 path: root.join("src/b.ts"),
1063 });
1064 results.unused_files.push(UnusedFile {
1065 path: root.join("src/c.ts"),
1066 });
1067 let elapsed = Duration::from_millis(0);
1068 let output = build_json(&results, &root, elapsed).expect("should serialize");
1069
1070 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1071 assert_eq!(output["total_issues"], 3);
1072 }
1073
1074 #[test]
1077 fn strip_root_prefix_on_string_value() {
1078 let mut value = serde_json::json!("/project/src/file.ts");
1079 strip_root_prefix(&mut value, "/project/");
1080 assert_eq!(value, "src/file.ts");
1081 }
1082
1083 #[test]
1084 fn strip_root_prefix_leaves_non_matching_string() {
1085 let mut value = serde_json::json!("/other/src/file.ts");
1086 strip_root_prefix(&mut value, "/project/");
1087 assert_eq!(value, "/other/src/file.ts");
1088 }
1089
1090 #[test]
1091 fn strip_root_prefix_recurses_into_arrays() {
1092 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1093 strip_root_prefix(&mut value, "/project/");
1094 assert_eq!(value[0], "a.ts");
1095 assert_eq!(value[1], "b.ts");
1096 assert_eq!(value[2], "/other/c.ts");
1097 }
1098
1099 #[test]
1100 fn strip_root_prefix_recurses_into_nested_objects() {
1101 let mut value = serde_json::json!({
1102 "outer": {
1103 "path": "/project/src/nested.ts"
1104 }
1105 });
1106 strip_root_prefix(&mut value, "/project/");
1107 assert_eq!(value["outer"]["path"], "src/nested.ts");
1108 }
1109
1110 #[test]
1111 fn strip_root_prefix_leaves_numbers_and_booleans() {
1112 let mut value = serde_json::json!({
1113 "line": 42,
1114 "is_type_only": false,
1115 "path": "/project/src/file.ts"
1116 });
1117 strip_root_prefix(&mut value, "/project/");
1118 assert_eq!(value["line"], 42);
1119 assert_eq!(value["is_type_only"], false);
1120 assert_eq!(value["path"], "src/file.ts");
1121 }
1122
1123 #[test]
1124 fn strip_root_prefix_handles_empty_string_after_strip() {
1125 let mut value = serde_json::json!("/project/");
1128 strip_root_prefix(&mut value, "/project/");
1129 assert_eq!(value, "");
1130 }
1131
1132 #[test]
1133 fn strip_root_prefix_deeply_nested_array_of_objects() {
1134 let mut value = serde_json::json!({
1135 "groups": [{
1136 "instances": [{
1137 "file": "/project/src/a.ts"
1138 }, {
1139 "file": "/project/src/b.ts"
1140 }]
1141 }]
1142 });
1143 strip_root_prefix(&mut value, "/project/");
1144 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1145 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1146 }
1147
1148 #[test]
1151 fn json_full_sample_results_total_issues_correct() {
1152 let root = PathBuf::from("/project");
1153 let results = sample_results(&root);
1154 let elapsed = Duration::from_millis(100);
1155 let output = build_json(&results, &root, elapsed).expect("should serialize");
1156
1157 assert_eq!(output["total_issues"], results.total_issues());
1163 }
1164
1165 #[test]
1166 fn json_full_sample_no_absolute_paths_in_output() {
1167 let root = PathBuf::from("/project");
1168 let results = sample_results(&root);
1169 let elapsed = Duration::from_millis(0);
1170 let output = build_json(&results, &root, elapsed).expect("should serialize");
1171
1172 let json_str = serde_json::to_string(&output).expect("should stringify");
1173 assert!(!json_str.contains("/project/src/"));
1175 assert!(!json_str.contains("/project/package.json"));
1176 }
1177
1178 #[test]
1181 fn json_output_is_deterministic() {
1182 let root = PathBuf::from("/project");
1183 let results = sample_results(&root);
1184 let elapsed = Duration::from_millis(50);
1185
1186 let output1 = build_json(&results, &root, elapsed).expect("first build");
1187 let output2 = build_json(&results, &root, elapsed).expect("second build");
1188
1189 assert_eq!(output1, output2);
1190 }
1191
1192 #[test]
1195 fn json_results_fields_do_not_shadow_metadata() {
1196 let root = PathBuf::from("/project");
1199 let results = AnalysisResults::default();
1200 let elapsed = Duration::from_millis(99);
1201 let output = build_json(&results, &root, elapsed).expect("should serialize");
1202
1203 assert_eq!(output["schema_version"], 3);
1205 assert_eq!(output["elapsed_ms"], 99);
1206 }
1207
1208 #[test]
1211 fn json_all_issue_type_arrays_present_in_empty_results() {
1212 let root = PathBuf::from("/project");
1213 let results = AnalysisResults::default();
1214 let elapsed = Duration::from_millis(0);
1215 let output = build_json(&results, &root, elapsed).expect("should serialize");
1216
1217 let expected_arrays = [
1218 "unused_files",
1219 "unused_exports",
1220 "unused_types",
1221 "unused_dependencies",
1222 "unused_dev_dependencies",
1223 "unused_optional_dependencies",
1224 "unused_enum_members",
1225 "unused_class_members",
1226 "unresolved_imports",
1227 "unlisted_dependencies",
1228 "duplicate_exports",
1229 "type_only_dependencies",
1230 "test_only_dependencies",
1231 "circular_dependencies",
1232 ];
1233 for key in &expected_arrays {
1234 assert!(
1235 output[key].is_array(),
1236 "expected '{key}' to be an array in JSON output"
1237 );
1238 }
1239 }
1240
1241 #[test]
1244 fn insert_meta_adds_key_to_object() {
1245 let mut output = serde_json::json!({ "foo": 1 });
1246 let meta = serde_json::json!({ "docs": "https://example.com" });
1247 insert_meta(&mut output, meta.clone());
1248 assert_eq!(output["_meta"], meta);
1249 }
1250
1251 #[test]
1252 fn insert_meta_noop_on_non_object() {
1253 let mut output = serde_json::json!([1, 2, 3]);
1254 let meta = serde_json::json!({ "docs": "https://example.com" });
1255 insert_meta(&mut output, meta);
1256 assert!(output.is_array());
1258 }
1259
1260 #[test]
1261 fn insert_meta_overwrites_existing_meta() {
1262 let mut output = serde_json::json!({ "_meta": "old" });
1263 let meta = serde_json::json!({ "new": true });
1264 insert_meta(&mut output, meta.clone());
1265 assert_eq!(output["_meta"], meta);
1266 }
1267
1268 #[test]
1271 fn build_json_envelope_has_metadata_fields() {
1272 let report = serde_json::json!({ "findings": [] });
1273 let elapsed = Duration::from_millis(42);
1274 let output = build_json_envelope(report, elapsed);
1275
1276 assert_eq!(output["schema_version"], 3);
1277 assert!(output["version"].is_string());
1278 assert_eq!(output["elapsed_ms"], 42);
1279 assert!(output["findings"].is_array());
1280 }
1281
1282 #[test]
1283 fn build_json_envelope_metadata_appears_first() {
1284 let report = serde_json::json!({ "data": "value" });
1285 let output = build_json_envelope(report, Duration::from_millis(10));
1286
1287 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1288 assert_eq!(keys[0], "schema_version");
1289 assert_eq!(keys[1], "version");
1290 assert_eq!(keys[2], "elapsed_ms");
1291 }
1292
1293 #[test]
1294 fn build_json_envelope_non_object_report() {
1295 let report = serde_json::json!("not an object");
1297 let output = build_json_envelope(report, Duration::from_millis(0));
1298
1299 let obj = output.as_object().unwrap();
1300 assert_eq!(obj.len(), 3);
1301 assert!(obj.contains_key("schema_version"));
1302 assert!(obj.contains_key("version"));
1303 assert!(obj.contains_key("elapsed_ms"));
1304 }
1305
1306 #[test]
1309 fn strip_root_prefix_null_unchanged() {
1310 let mut value = serde_json::Value::Null;
1311 strip_root_prefix(&mut value, "/project/");
1312 assert!(value.is_null());
1313 }
1314
1315 #[test]
1318 fn strip_root_prefix_empty_string() {
1319 let mut value = serde_json::json!("");
1320 strip_root_prefix(&mut value, "/project/");
1321 assert_eq!(value, "");
1322 }
1323
1324 #[test]
1327 fn strip_root_prefix_mixed_types() {
1328 let mut value = serde_json::json!({
1329 "path": "/project/src/file.ts",
1330 "line": 42,
1331 "flag": true,
1332 "nested": {
1333 "items": ["/project/a.ts", 99, null, "/project/b.ts"],
1334 "deep": { "path": "/project/c.ts" }
1335 }
1336 });
1337 strip_root_prefix(&mut value, "/project/");
1338 assert_eq!(value["path"], "src/file.ts");
1339 assert_eq!(value["line"], 42);
1340 assert_eq!(value["flag"], true);
1341 assert_eq!(value["nested"]["items"][0], "a.ts");
1342 assert_eq!(value["nested"]["items"][1], 99);
1343 assert!(value["nested"]["items"][2].is_null());
1344 assert_eq!(value["nested"]["items"][3], "b.ts");
1345 assert_eq!(value["nested"]["deep"]["path"], "c.ts");
1346 }
1347
1348 #[test]
1351 fn json_check_meta_integrates_correctly() {
1352 let root = PathBuf::from("/project");
1353 let results = AnalysisResults::default();
1354 let elapsed = Duration::from_millis(0);
1355 let mut output = build_json(&results, &root, elapsed).expect("should serialize");
1356 insert_meta(&mut output, crate::explain::check_meta());
1357
1358 assert!(output["_meta"]["docs"].is_string());
1359 assert!(output["_meta"]["rules"].is_object());
1360 }
1361
1362 #[test]
1365 fn json_unused_member_kind_serialized() {
1366 let root = PathBuf::from("/project");
1367 let mut results = AnalysisResults::default();
1368 results.unused_enum_members.push(UnusedMember {
1369 path: root.join("src/enums.ts"),
1370 parent_name: "Color".to_string(),
1371 member_name: "Red".to_string(),
1372 kind: MemberKind::EnumMember,
1373 line: 3,
1374 col: 2,
1375 });
1376 results.unused_class_members.push(UnusedMember {
1377 path: root.join("src/class.ts"),
1378 parent_name: "Foo".to_string(),
1379 member_name: "bar".to_string(),
1380 kind: MemberKind::ClassMethod,
1381 line: 10,
1382 col: 4,
1383 });
1384
1385 let elapsed = Duration::from_millis(0);
1386 let output = build_json(&results, &root, elapsed).expect("should serialize");
1387
1388 let enum_member = &output["unused_enum_members"][0];
1389 assert!(enum_member["kind"].is_string());
1390 let class_member = &output["unused_class_members"][0];
1391 assert!(class_member["kind"].is_string());
1392 }
1393
1394 #[test]
1397 fn json_unused_export_has_actions() {
1398 let root = PathBuf::from("/project");
1399 let mut results = AnalysisResults::default();
1400 results.unused_exports.push(UnusedExport {
1401 path: root.join("src/utils.ts"),
1402 export_name: "helperFn".to_string(),
1403 is_type_only: false,
1404 line: 10,
1405 col: 4,
1406 span_start: 120,
1407 is_re_export: false,
1408 });
1409 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1410
1411 let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
1412 assert_eq!(actions.len(), 2);
1413
1414 assert_eq!(actions[0]["type"], "remove-export");
1416 assert_eq!(actions[0]["auto_fixable"], true);
1417 assert!(actions[0].get("note").is_none());
1418
1419 assert_eq!(actions[1]["type"], "suppress-line");
1421 assert_eq!(
1422 actions[1]["comment"],
1423 "// fallow-ignore-next-line unused-export"
1424 );
1425 }
1426
1427 #[test]
1428 fn json_unused_file_has_file_suppress_and_note() {
1429 let root = PathBuf::from("/project");
1430 let mut results = AnalysisResults::default();
1431 results.unused_files.push(UnusedFile {
1432 path: root.join("src/dead.ts"),
1433 });
1434 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1435
1436 let actions = output["unused_files"][0]["actions"].as_array().unwrap();
1437 assert_eq!(actions[0]["type"], "delete-file");
1438 assert_eq!(actions[0]["auto_fixable"], false);
1439 assert!(actions[0]["note"].is_string());
1440 assert_eq!(actions[1]["type"], "suppress-file");
1441 assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
1442 }
1443
1444 #[test]
1445 fn json_unused_dependency_has_config_suppress_with_package_name() {
1446 let root = PathBuf::from("/project");
1447 let mut results = AnalysisResults::default();
1448 results.unused_dependencies.push(UnusedDependency {
1449 package_name: "lodash".to_string(),
1450 location: DependencyLocation::Dependencies,
1451 path: root.join("package.json"),
1452 line: 5,
1453 });
1454 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1455
1456 let actions = output["unused_dependencies"][0]["actions"]
1457 .as_array()
1458 .unwrap();
1459 assert_eq!(actions[0]["type"], "remove-dependency");
1460 assert_eq!(actions[0]["auto_fixable"], true);
1461
1462 assert_eq!(actions[1]["type"], "add-to-config");
1464 assert_eq!(actions[1]["config_key"], "ignoreDependencies");
1465 assert_eq!(actions[1]["value"], "lodash");
1466 }
1467
1468 #[test]
1469 fn json_empty_results_have_no_actions_in_empty_arrays() {
1470 let root = PathBuf::from("/project");
1471 let results = AnalysisResults::default();
1472 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1473
1474 assert!(output["unused_exports"].as_array().unwrap().is_empty());
1476 assert!(output["unused_files"].as_array().unwrap().is_empty());
1477 }
1478
1479 #[test]
1480 fn json_all_issue_types_have_actions() {
1481 let root = PathBuf::from("/project");
1482 let results = sample_results(&root);
1483 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1484
1485 let issue_keys = [
1486 "unused_files",
1487 "unused_exports",
1488 "unused_types",
1489 "unused_dependencies",
1490 "unused_dev_dependencies",
1491 "unused_optional_dependencies",
1492 "unused_enum_members",
1493 "unused_class_members",
1494 "unresolved_imports",
1495 "unlisted_dependencies",
1496 "duplicate_exports",
1497 "type_only_dependencies",
1498 "test_only_dependencies",
1499 "circular_dependencies",
1500 ];
1501
1502 for key in &issue_keys {
1503 let arr = output[key].as_array().unwrap();
1504 if !arr.is_empty() {
1505 let actions = arr[0]["actions"].as_array();
1506 assert!(
1507 actions.is_some() && !actions.unwrap().is_empty(),
1508 "missing actions for {key}"
1509 );
1510 }
1511 }
1512 }
1513}