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(clippy::exit)]
452 std::process::exit(2);
453 }
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use crate::report::test_helpers::sample_results;
461 use fallow_core::extract::MemberKind;
462 use fallow_core::results::*;
463 use std::path::PathBuf;
464 use std::time::Duration;
465
466 #[test]
467 fn json_output_has_metadata_fields() {
468 let root = PathBuf::from("/project");
469 let results = AnalysisResults::default();
470 let elapsed = Duration::from_millis(123);
471 let output = build_json(&results, &root, elapsed).expect("should serialize");
472
473 assert_eq!(output["schema_version"], 3);
474 assert!(output["version"].is_string());
475 assert_eq!(output["elapsed_ms"], 123);
476 assert_eq!(output["total_issues"], 0);
477 }
478
479 #[test]
480 fn json_output_includes_issue_arrays() {
481 let root = PathBuf::from("/project");
482 let results = sample_results(&root);
483 let elapsed = Duration::from_millis(50);
484 let output = build_json(&results, &root, elapsed).expect("should serialize");
485
486 assert_eq!(output["unused_files"].as_array().unwrap().len(), 1);
487 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 1);
488 assert_eq!(output["unused_types"].as_array().unwrap().len(), 1);
489 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 1);
490 assert_eq!(
491 output["unused_dev_dependencies"].as_array().unwrap().len(),
492 1
493 );
494 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 1);
495 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 1);
496 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 1);
497 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 1);
498 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 1);
499 assert_eq!(
500 output["type_only_dependencies"].as_array().unwrap().len(),
501 1
502 );
503 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 1);
504 }
505
506 #[test]
507 fn json_metadata_fields_appear_first() {
508 let root = PathBuf::from("/project");
509 let results = AnalysisResults::default();
510 let elapsed = Duration::from_millis(0);
511 let output = build_json(&results, &root, elapsed).expect("should serialize");
512 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
513 assert_eq!(keys[0], "schema_version");
514 assert_eq!(keys[1], "version");
515 assert_eq!(keys[2], "elapsed_ms");
516 assert_eq!(keys[3], "total_issues");
517 }
518
519 #[test]
520 fn json_total_issues_matches_results() {
521 let root = PathBuf::from("/project");
522 let results = sample_results(&root);
523 let total = results.total_issues();
524 let elapsed = Duration::from_millis(0);
525 let output = build_json(&results, &root, elapsed).expect("should serialize");
526
527 assert_eq!(output["total_issues"], total);
528 }
529
530 #[test]
531 fn json_unused_export_contains_expected_fields() {
532 let root = PathBuf::from("/project");
533 let mut results = AnalysisResults::default();
534 results.unused_exports.push(UnusedExport {
535 path: root.join("src/utils.ts"),
536 export_name: "helperFn".to_string(),
537 is_type_only: false,
538 line: 10,
539 col: 4,
540 span_start: 120,
541 is_re_export: false,
542 });
543 let elapsed = Duration::from_millis(0);
544 let output = build_json(&results, &root, elapsed).expect("should serialize");
545
546 let export = &output["unused_exports"][0];
547 assert_eq!(export["export_name"], "helperFn");
548 assert_eq!(export["line"], 10);
549 assert_eq!(export["col"], 4);
550 assert_eq!(export["is_type_only"], false);
551 assert_eq!(export["span_start"], 120);
552 assert_eq!(export["is_re_export"], false);
553 }
554
555 #[test]
556 fn json_serializes_to_valid_json() {
557 let root = PathBuf::from("/project");
558 let results = sample_results(&root);
559 let elapsed = Duration::from_millis(42);
560 let output = build_json(&results, &root, elapsed).expect("should serialize");
561
562 let json_str = serde_json::to_string_pretty(&output).expect("should stringify");
563 let reparsed: serde_json::Value =
564 serde_json::from_str(&json_str).expect("JSON output should be valid JSON");
565 assert_eq!(reparsed, output);
566 }
567
568 #[test]
571 fn json_empty_results_produce_valid_structure() {
572 let root = PathBuf::from("/project");
573 let results = AnalysisResults::default();
574 let elapsed = Duration::from_millis(0);
575 let output = build_json(&results, &root, elapsed).expect("should serialize");
576
577 assert_eq!(output["total_issues"], 0);
578 assert_eq!(output["unused_files"].as_array().unwrap().len(), 0);
579 assert_eq!(output["unused_exports"].as_array().unwrap().len(), 0);
580 assert_eq!(output["unused_types"].as_array().unwrap().len(), 0);
581 assert_eq!(output["unused_dependencies"].as_array().unwrap().len(), 0);
582 assert_eq!(
583 output["unused_dev_dependencies"].as_array().unwrap().len(),
584 0
585 );
586 assert_eq!(output["unused_enum_members"].as_array().unwrap().len(), 0);
587 assert_eq!(output["unused_class_members"].as_array().unwrap().len(), 0);
588 assert_eq!(output["unresolved_imports"].as_array().unwrap().len(), 0);
589 assert_eq!(output["unlisted_dependencies"].as_array().unwrap().len(), 0);
590 assert_eq!(output["duplicate_exports"].as_array().unwrap().len(), 0);
591 assert_eq!(
592 output["type_only_dependencies"].as_array().unwrap().len(),
593 0
594 );
595 assert_eq!(output["circular_dependencies"].as_array().unwrap().len(), 0);
596 }
597
598 #[test]
599 fn json_empty_results_round_trips_through_string() {
600 let root = PathBuf::from("/project");
601 let results = AnalysisResults::default();
602 let elapsed = Duration::from_millis(0);
603 let output = build_json(&results, &root, elapsed).expect("should serialize");
604
605 let json_str = serde_json::to_string(&output).expect("should stringify");
606 let reparsed: serde_json::Value =
607 serde_json::from_str(&json_str).expect("should parse back");
608 assert_eq!(reparsed["total_issues"], 0);
609 }
610
611 #[test]
614 fn json_paths_are_relative_to_root() {
615 let root = PathBuf::from("/project");
616 let mut results = AnalysisResults::default();
617 results.unused_files.push(UnusedFile {
618 path: root.join("src/deep/nested/file.ts"),
619 });
620 let elapsed = Duration::from_millis(0);
621 let output = build_json(&results, &root, elapsed).expect("should serialize");
622
623 let path = output["unused_files"][0]["path"].as_str().unwrap();
624 assert_eq!(path, "src/deep/nested/file.ts");
625 assert!(!path.starts_with("/project"));
626 }
627
628 #[test]
629 fn json_strips_root_from_nested_locations() {
630 let root = PathBuf::from("/project");
631 let mut results = AnalysisResults::default();
632 results.unlisted_dependencies.push(UnlistedDependency {
633 package_name: "chalk".to_string(),
634 imported_from: vec![ImportSite {
635 path: root.join("src/cli.ts"),
636 line: 2,
637 col: 0,
638 }],
639 });
640 let elapsed = Duration::from_millis(0);
641 let output = build_json(&results, &root, elapsed).expect("should serialize");
642
643 let site_path = output["unlisted_dependencies"][0]["imported_from"][0]["path"]
644 .as_str()
645 .unwrap();
646 assert_eq!(site_path, "src/cli.ts");
647 }
648
649 #[test]
650 fn json_strips_root_from_duplicate_export_locations() {
651 let root = PathBuf::from("/project");
652 let mut results = AnalysisResults::default();
653 results.duplicate_exports.push(DuplicateExport {
654 export_name: "Config".to_string(),
655 locations: vec![
656 DuplicateLocation {
657 path: root.join("src/config.ts"),
658 line: 15,
659 col: 0,
660 },
661 DuplicateLocation {
662 path: root.join("src/types.ts"),
663 line: 30,
664 col: 0,
665 },
666 ],
667 });
668 let elapsed = Duration::from_millis(0);
669 let output = build_json(&results, &root, elapsed).expect("should serialize");
670
671 let loc0 = output["duplicate_exports"][0]["locations"][0]["path"]
672 .as_str()
673 .unwrap();
674 let loc1 = output["duplicate_exports"][0]["locations"][1]["path"]
675 .as_str()
676 .unwrap();
677 assert_eq!(loc0, "src/config.ts");
678 assert_eq!(loc1, "src/types.ts");
679 }
680
681 #[test]
682 fn json_strips_root_from_circular_dependency_files() {
683 let root = PathBuf::from("/project");
684 let mut results = AnalysisResults::default();
685 results.circular_dependencies.push(CircularDependency {
686 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
687 length: 2,
688 line: 1,
689 col: 0,
690 });
691 let elapsed = Duration::from_millis(0);
692 let output = build_json(&results, &root, elapsed).expect("should serialize");
693
694 let files = output["circular_dependencies"][0]["files"]
695 .as_array()
696 .unwrap();
697 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
698 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
699 }
700
701 #[test]
702 fn json_path_outside_root_not_stripped() {
703 let root = PathBuf::from("/project");
704 let mut results = AnalysisResults::default();
705 results.unused_files.push(UnusedFile {
706 path: PathBuf::from("/other/project/src/file.ts"),
707 });
708 let elapsed = Duration::from_millis(0);
709 let output = build_json(&results, &root, elapsed).expect("should serialize");
710
711 let path = output["unused_files"][0]["path"].as_str().unwrap();
712 assert!(path.contains("/other/project/"));
713 }
714
715 #[test]
718 fn json_unused_file_contains_path() {
719 let root = PathBuf::from("/project");
720 let mut results = AnalysisResults::default();
721 results.unused_files.push(UnusedFile {
722 path: root.join("src/orphan.ts"),
723 });
724 let elapsed = Duration::from_millis(0);
725 let output = build_json(&results, &root, elapsed).expect("should serialize");
726
727 let file = &output["unused_files"][0];
728 assert_eq!(file["path"], "src/orphan.ts");
729 }
730
731 #[test]
732 fn json_unused_type_contains_expected_fields() {
733 let root = PathBuf::from("/project");
734 let mut results = AnalysisResults::default();
735 results.unused_types.push(UnusedExport {
736 path: root.join("src/types.ts"),
737 export_name: "OldInterface".to_string(),
738 is_type_only: true,
739 line: 20,
740 col: 0,
741 span_start: 300,
742 is_re_export: false,
743 });
744 let elapsed = Duration::from_millis(0);
745 let output = build_json(&results, &root, elapsed).expect("should serialize");
746
747 let typ = &output["unused_types"][0];
748 assert_eq!(typ["export_name"], "OldInterface");
749 assert_eq!(typ["is_type_only"], true);
750 assert_eq!(typ["line"], 20);
751 assert_eq!(typ["path"], "src/types.ts");
752 }
753
754 #[test]
755 fn json_unused_dependency_contains_expected_fields() {
756 let root = PathBuf::from("/project");
757 let mut results = AnalysisResults::default();
758 results.unused_dependencies.push(UnusedDependency {
759 package_name: "axios".to_string(),
760 location: DependencyLocation::Dependencies,
761 path: root.join("package.json"),
762 line: 10,
763 });
764 let elapsed = Duration::from_millis(0);
765 let output = build_json(&results, &root, elapsed).expect("should serialize");
766
767 let dep = &output["unused_dependencies"][0];
768 assert_eq!(dep["package_name"], "axios");
769 assert_eq!(dep["line"], 10);
770 }
771
772 #[test]
773 fn json_unused_dev_dependency_contains_expected_fields() {
774 let root = PathBuf::from("/project");
775 let mut results = AnalysisResults::default();
776 results.unused_dev_dependencies.push(UnusedDependency {
777 package_name: "vitest".to_string(),
778 location: DependencyLocation::DevDependencies,
779 path: root.join("package.json"),
780 line: 15,
781 });
782 let elapsed = Duration::from_millis(0);
783 let output = build_json(&results, &root, elapsed).expect("should serialize");
784
785 let dep = &output["unused_dev_dependencies"][0];
786 assert_eq!(dep["package_name"], "vitest");
787 }
788
789 #[test]
790 fn json_unused_optional_dependency_contains_expected_fields() {
791 let root = PathBuf::from("/project");
792 let mut results = AnalysisResults::default();
793 results.unused_optional_dependencies.push(UnusedDependency {
794 package_name: "fsevents".to_string(),
795 location: DependencyLocation::OptionalDependencies,
796 path: root.join("package.json"),
797 line: 12,
798 });
799 let elapsed = Duration::from_millis(0);
800 let output = build_json(&results, &root, elapsed).expect("should serialize");
801
802 let dep = &output["unused_optional_dependencies"][0];
803 assert_eq!(dep["package_name"], "fsevents");
804 assert_eq!(output["total_issues"], 1);
805 }
806
807 #[test]
808 fn json_unused_enum_member_contains_expected_fields() {
809 let root = PathBuf::from("/project");
810 let mut results = AnalysisResults::default();
811 results.unused_enum_members.push(UnusedMember {
812 path: root.join("src/enums.ts"),
813 parent_name: "Color".to_string(),
814 member_name: "Purple".to_string(),
815 kind: MemberKind::EnumMember,
816 line: 5,
817 col: 2,
818 });
819 let elapsed = Duration::from_millis(0);
820 let output = build_json(&results, &root, elapsed).expect("should serialize");
821
822 let member = &output["unused_enum_members"][0];
823 assert_eq!(member["parent_name"], "Color");
824 assert_eq!(member["member_name"], "Purple");
825 assert_eq!(member["line"], 5);
826 assert_eq!(member["path"], "src/enums.ts");
827 }
828
829 #[test]
830 fn json_unused_class_member_contains_expected_fields() {
831 let root = PathBuf::from("/project");
832 let mut results = AnalysisResults::default();
833 results.unused_class_members.push(UnusedMember {
834 path: root.join("src/api.ts"),
835 parent_name: "ApiClient".to_string(),
836 member_name: "deprecatedFetch".to_string(),
837 kind: MemberKind::ClassMethod,
838 line: 100,
839 col: 4,
840 });
841 let elapsed = Duration::from_millis(0);
842 let output = build_json(&results, &root, elapsed).expect("should serialize");
843
844 let member = &output["unused_class_members"][0];
845 assert_eq!(member["parent_name"], "ApiClient");
846 assert_eq!(member["member_name"], "deprecatedFetch");
847 assert_eq!(member["line"], 100);
848 }
849
850 #[test]
851 fn json_unresolved_import_contains_expected_fields() {
852 let root = PathBuf::from("/project");
853 let mut results = AnalysisResults::default();
854 results.unresolved_imports.push(UnresolvedImport {
855 path: root.join("src/app.ts"),
856 specifier: "@acme/missing-pkg".to_string(),
857 line: 7,
858 col: 0,
859 specifier_col: 0,
860 });
861 let elapsed = Duration::from_millis(0);
862 let output = build_json(&results, &root, elapsed).expect("should serialize");
863
864 let import = &output["unresolved_imports"][0];
865 assert_eq!(import["specifier"], "@acme/missing-pkg");
866 assert_eq!(import["line"], 7);
867 assert_eq!(import["path"], "src/app.ts");
868 }
869
870 #[test]
871 fn json_unlisted_dependency_contains_import_sites() {
872 let root = PathBuf::from("/project");
873 let mut results = AnalysisResults::default();
874 results.unlisted_dependencies.push(UnlistedDependency {
875 package_name: "dotenv".to_string(),
876 imported_from: vec![
877 ImportSite {
878 path: root.join("src/config.ts"),
879 line: 1,
880 col: 0,
881 },
882 ImportSite {
883 path: root.join("src/server.ts"),
884 line: 3,
885 col: 0,
886 },
887 ],
888 });
889 let elapsed = Duration::from_millis(0);
890 let output = build_json(&results, &root, elapsed).expect("should serialize");
891
892 let dep = &output["unlisted_dependencies"][0];
893 assert_eq!(dep["package_name"], "dotenv");
894 let sites = dep["imported_from"].as_array().unwrap();
895 assert_eq!(sites.len(), 2);
896 assert_eq!(sites[0]["path"], "src/config.ts");
897 assert_eq!(sites[1]["path"], "src/server.ts");
898 }
899
900 #[test]
901 fn json_duplicate_export_contains_locations() {
902 let root = PathBuf::from("/project");
903 let mut results = AnalysisResults::default();
904 results.duplicate_exports.push(DuplicateExport {
905 export_name: "Button".to_string(),
906 locations: vec![
907 DuplicateLocation {
908 path: root.join("src/ui.ts"),
909 line: 10,
910 col: 0,
911 },
912 DuplicateLocation {
913 path: root.join("src/components.ts"),
914 line: 25,
915 col: 0,
916 },
917 ],
918 });
919 let elapsed = Duration::from_millis(0);
920 let output = build_json(&results, &root, elapsed).expect("should serialize");
921
922 let dup = &output["duplicate_exports"][0];
923 assert_eq!(dup["export_name"], "Button");
924 let locs = dup["locations"].as_array().unwrap();
925 assert_eq!(locs.len(), 2);
926 assert_eq!(locs[0]["line"], 10);
927 assert_eq!(locs[1]["line"], 25);
928 }
929
930 #[test]
931 fn json_type_only_dependency_contains_expected_fields() {
932 let root = PathBuf::from("/project");
933 let mut results = AnalysisResults::default();
934 results.type_only_dependencies.push(TypeOnlyDependency {
935 package_name: "zod".to_string(),
936 path: root.join("package.json"),
937 line: 8,
938 });
939 let elapsed = Duration::from_millis(0);
940 let output = build_json(&results, &root, elapsed).expect("should serialize");
941
942 let dep = &output["type_only_dependencies"][0];
943 assert_eq!(dep["package_name"], "zod");
944 assert_eq!(dep["line"], 8);
945 }
946
947 #[test]
948 fn json_circular_dependency_contains_expected_fields() {
949 let root = PathBuf::from("/project");
950 let mut results = AnalysisResults::default();
951 results.circular_dependencies.push(CircularDependency {
952 files: vec![
953 root.join("src/a.ts"),
954 root.join("src/b.ts"),
955 root.join("src/c.ts"),
956 ],
957 length: 3,
958 line: 5,
959 col: 0,
960 });
961 let elapsed = Duration::from_millis(0);
962 let output = build_json(&results, &root, elapsed).expect("should serialize");
963
964 let cycle = &output["circular_dependencies"][0];
965 assert_eq!(cycle["length"], 3);
966 assert_eq!(cycle["line"], 5);
967 let files = cycle["files"].as_array().unwrap();
968 assert_eq!(files.len(), 3);
969 }
970
971 #[test]
974 fn json_re_export_flagged_correctly() {
975 let root = PathBuf::from("/project");
976 let mut results = AnalysisResults::default();
977 results.unused_exports.push(UnusedExport {
978 path: root.join("src/index.ts"),
979 export_name: "reExported".to_string(),
980 is_type_only: false,
981 line: 1,
982 col: 0,
983 span_start: 0,
984 is_re_export: true,
985 });
986 let elapsed = Duration::from_millis(0);
987 let output = build_json(&results, &root, elapsed).expect("should serialize");
988
989 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
990 }
991
992 #[test]
995 fn json_schema_version_is_3() {
996 let root = PathBuf::from("/project");
997 let results = AnalysisResults::default();
998 let elapsed = Duration::from_millis(0);
999 let output = build_json(&results, &root, elapsed).expect("should serialize");
1000
1001 assert_eq!(output["schema_version"], SCHEMA_VERSION);
1002 assert_eq!(output["schema_version"], 3);
1003 }
1004
1005 #[test]
1008 fn json_version_matches_cargo_pkg_version() {
1009 let root = PathBuf::from("/project");
1010 let results = AnalysisResults::default();
1011 let elapsed = Duration::from_millis(0);
1012 let output = build_json(&results, &root, elapsed).expect("should serialize");
1013
1014 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1015 }
1016
1017 #[test]
1020 fn json_elapsed_ms_zero_duration() {
1021 let root = PathBuf::from("/project");
1022 let results = AnalysisResults::default();
1023 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1024
1025 assert_eq!(output["elapsed_ms"], 0);
1026 }
1027
1028 #[test]
1029 fn json_elapsed_ms_large_duration() {
1030 let root = PathBuf::from("/project");
1031 let results = AnalysisResults::default();
1032 let elapsed = Duration::from_secs(120);
1033 let output = build_json(&results, &root, elapsed).expect("should serialize");
1034
1035 assert_eq!(output["elapsed_ms"], 120_000);
1036 }
1037
1038 #[test]
1039 fn json_elapsed_ms_sub_millisecond_truncated() {
1040 let root = PathBuf::from("/project");
1041 let results = AnalysisResults::default();
1042 let elapsed = Duration::from_micros(500);
1044 let output = build_json(&results, &root, elapsed).expect("should serialize");
1045
1046 assert_eq!(output["elapsed_ms"], 0);
1047 }
1048
1049 #[test]
1052 fn json_multiple_unused_files() {
1053 let root = PathBuf::from("/project");
1054 let mut results = AnalysisResults::default();
1055 results.unused_files.push(UnusedFile {
1056 path: root.join("src/a.ts"),
1057 });
1058 results.unused_files.push(UnusedFile {
1059 path: root.join("src/b.ts"),
1060 });
1061 results.unused_files.push(UnusedFile {
1062 path: root.join("src/c.ts"),
1063 });
1064 let elapsed = Duration::from_millis(0);
1065 let output = build_json(&results, &root, elapsed).expect("should serialize");
1066
1067 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1068 assert_eq!(output["total_issues"], 3);
1069 }
1070
1071 #[test]
1074 fn strip_root_prefix_on_string_value() {
1075 let mut value = serde_json::json!("/project/src/file.ts");
1076 strip_root_prefix(&mut value, "/project/");
1077 assert_eq!(value, "src/file.ts");
1078 }
1079
1080 #[test]
1081 fn strip_root_prefix_leaves_non_matching_string() {
1082 let mut value = serde_json::json!("/other/src/file.ts");
1083 strip_root_prefix(&mut value, "/project/");
1084 assert_eq!(value, "/other/src/file.ts");
1085 }
1086
1087 #[test]
1088 fn strip_root_prefix_recurses_into_arrays() {
1089 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1090 strip_root_prefix(&mut value, "/project/");
1091 assert_eq!(value[0], "a.ts");
1092 assert_eq!(value[1], "b.ts");
1093 assert_eq!(value[2], "/other/c.ts");
1094 }
1095
1096 #[test]
1097 fn strip_root_prefix_recurses_into_nested_objects() {
1098 let mut value = serde_json::json!({
1099 "outer": {
1100 "path": "/project/src/nested.ts"
1101 }
1102 });
1103 strip_root_prefix(&mut value, "/project/");
1104 assert_eq!(value["outer"]["path"], "src/nested.ts");
1105 }
1106
1107 #[test]
1108 fn strip_root_prefix_leaves_numbers_and_booleans() {
1109 let mut value = serde_json::json!({
1110 "line": 42,
1111 "is_type_only": false,
1112 "path": "/project/src/file.ts"
1113 });
1114 strip_root_prefix(&mut value, "/project/");
1115 assert_eq!(value["line"], 42);
1116 assert_eq!(value["is_type_only"], false);
1117 assert_eq!(value["path"], "src/file.ts");
1118 }
1119
1120 #[test]
1121 fn strip_root_prefix_handles_empty_string_after_strip() {
1122 let mut value = serde_json::json!("/project/");
1125 strip_root_prefix(&mut value, "/project/");
1126 assert_eq!(value, "");
1127 }
1128
1129 #[test]
1130 fn strip_root_prefix_deeply_nested_array_of_objects() {
1131 let mut value = serde_json::json!({
1132 "groups": [{
1133 "instances": [{
1134 "file": "/project/src/a.ts"
1135 }, {
1136 "file": "/project/src/b.ts"
1137 }]
1138 }]
1139 });
1140 strip_root_prefix(&mut value, "/project/");
1141 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1142 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1143 }
1144
1145 #[test]
1148 fn json_full_sample_results_total_issues_correct() {
1149 let root = PathBuf::from("/project");
1150 let results = sample_results(&root);
1151 let elapsed = Duration::from_millis(100);
1152 let output = build_json(&results, &root, elapsed).expect("should serialize");
1153
1154 assert_eq!(output["total_issues"], results.total_issues());
1160 }
1161
1162 #[test]
1163 fn json_full_sample_no_absolute_paths_in_output() {
1164 let root = PathBuf::from("/project");
1165 let results = sample_results(&root);
1166 let elapsed = Duration::from_millis(0);
1167 let output = build_json(&results, &root, elapsed).expect("should serialize");
1168
1169 let json_str = serde_json::to_string(&output).expect("should stringify");
1170 assert!(!json_str.contains("/project/src/"));
1172 assert!(!json_str.contains("/project/package.json"));
1173 }
1174
1175 #[test]
1178 fn json_output_is_deterministic() {
1179 let root = PathBuf::from("/project");
1180 let results = sample_results(&root);
1181 let elapsed = Duration::from_millis(50);
1182
1183 let output1 = build_json(&results, &root, elapsed).expect("first build");
1184 let output2 = build_json(&results, &root, elapsed).expect("second build");
1185
1186 assert_eq!(output1, output2);
1187 }
1188
1189 #[test]
1192 fn json_results_fields_do_not_shadow_metadata() {
1193 let root = PathBuf::from("/project");
1196 let results = AnalysisResults::default();
1197 let elapsed = Duration::from_millis(99);
1198 let output = build_json(&results, &root, elapsed).expect("should serialize");
1199
1200 assert_eq!(output["schema_version"], 3);
1202 assert_eq!(output["elapsed_ms"], 99);
1203 }
1204
1205 #[test]
1208 fn json_all_issue_type_arrays_present_in_empty_results() {
1209 let root = PathBuf::from("/project");
1210 let results = AnalysisResults::default();
1211 let elapsed = Duration::from_millis(0);
1212 let output = build_json(&results, &root, elapsed).expect("should serialize");
1213
1214 let expected_arrays = [
1215 "unused_files",
1216 "unused_exports",
1217 "unused_types",
1218 "unused_dependencies",
1219 "unused_dev_dependencies",
1220 "unused_optional_dependencies",
1221 "unused_enum_members",
1222 "unused_class_members",
1223 "unresolved_imports",
1224 "unlisted_dependencies",
1225 "duplicate_exports",
1226 "type_only_dependencies",
1227 "test_only_dependencies",
1228 "circular_dependencies",
1229 ];
1230 for key in &expected_arrays {
1231 assert!(
1232 output[key].is_array(),
1233 "expected '{key}' to be an array in JSON output"
1234 );
1235 }
1236 }
1237
1238 #[test]
1241 fn insert_meta_adds_key_to_object() {
1242 let mut output = serde_json::json!({ "foo": 1 });
1243 let meta = serde_json::json!({ "docs": "https://example.com" });
1244 insert_meta(&mut output, meta.clone());
1245 assert_eq!(output["_meta"], meta);
1246 }
1247
1248 #[test]
1249 fn insert_meta_noop_on_non_object() {
1250 let mut output = serde_json::json!([1, 2, 3]);
1251 let meta = serde_json::json!({ "docs": "https://example.com" });
1252 insert_meta(&mut output, meta);
1253 assert!(output.is_array());
1255 }
1256
1257 #[test]
1258 fn insert_meta_overwrites_existing_meta() {
1259 let mut output = serde_json::json!({ "_meta": "old" });
1260 let meta = serde_json::json!({ "new": true });
1261 insert_meta(&mut output, meta.clone());
1262 assert_eq!(output["_meta"], meta);
1263 }
1264
1265 #[test]
1268 fn build_json_envelope_has_metadata_fields() {
1269 let report = serde_json::json!({ "findings": [] });
1270 let elapsed = Duration::from_millis(42);
1271 let output = build_json_envelope(report, elapsed);
1272
1273 assert_eq!(output["schema_version"], 3);
1274 assert!(output["version"].is_string());
1275 assert_eq!(output["elapsed_ms"], 42);
1276 assert!(output["findings"].is_array());
1277 }
1278
1279 #[test]
1280 fn build_json_envelope_metadata_appears_first() {
1281 let report = serde_json::json!({ "data": "value" });
1282 let output = build_json_envelope(report, Duration::from_millis(10));
1283
1284 let keys: Vec<&String> = output.as_object().unwrap().keys().collect();
1285 assert_eq!(keys[0], "schema_version");
1286 assert_eq!(keys[1], "version");
1287 assert_eq!(keys[2], "elapsed_ms");
1288 }
1289
1290 #[test]
1291 fn build_json_envelope_non_object_report() {
1292 let report = serde_json::json!("not an object");
1294 let output = build_json_envelope(report, Duration::from_millis(0));
1295
1296 let obj = output.as_object().unwrap();
1297 assert_eq!(obj.len(), 3);
1298 assert!(obj.contains_key("schema_version"));
1299 assert!(obj.contains_key("version"));
1300 assert!(obj.contains_key("elapsed_ms"));
1301 }
1302
1303 #[test]
1306 fn strip_root_prefix_null_unchanged() {
1307 let mut value = serde_json::Value::Null;
1308 strip_root_prefix(&mut value, "/project/");
1309 assert!(value.is_null());
1310 }
1311
1312 #[test]
1315 fn strip_root_prefix_empty_string() {
1316 let mut value = serde_json::json!("");
1317 strip_root_prefix(&mut value, "/project/");
1318 assert_eq!(value, "");
1319 }
1320
1321 #[test]
1324 fn strip_root_prefix_mixed_types() {
1325 let mut value = serde_json::json!({
1326 "path": "/project/src/file.ts",
1327 "line": 42,
1328 "flag": true,
1329 "nested": {
1330 "items": ["/project/a.ts", 99, null, "/project/b.ts"],
1331 "deep": { "path": "/project/c.ts" }
1332 }
1333 });
1334 strip_root_prefix(&mut value, "/project/");
1335 assert_eq!(value["path"], "src/file.ts");
1336 assert_eq!(value["line"], 42);
1337 assert_eq!(value["flag"], true);
1338 assert_eq!(value["nested"]["items"][0], "a.ts");
1339 assert_eq!(value["nested"]["items"][1], 99);
1340 assert!(value["nested"]["items"][2].is_null());
1341 assert_eq!(value["nested"]["items"][3], "b.ts");
1342 assert_eq!(value["nested"]["deep"]["path"], "c.ts");
1343 }
1344
1345 #[test]
1348 fn json_check_meta_integrates_correctly() {
1349 let root = PathBuf::from("/project");
1350 let results = AnalysisResults::default();
1351 let elapsed = Duration::from_millis(0);
1352 let mut output = build_json(&results, &root, elapsed).expect("should serialize");
1353 insert_meta(&mut output, crate::explain::check_meta());
1354
1355 assert!(output["_meta"]["docs"].is_string());
1356 assert!(output["_meta"]["rules"].is_object());
1357 }
1358
1359 #[test]
1362 fn json_unused_member_kind_serialized() {
1363 let root = PathBuf::from("/project");
1364 let mut results = AnalysisResults::default();
1365 results.unused_enum_members.push(UnusedMember {
1366 path: root.join("src/enums.ts"),
1367 parent_name: "Color".to_string(),
1368 member_name: "Red".to_string(),
1369 kind: MemberKind::EnumMember,
1370 line: 3,
1371 col: 2,
1372 });
1373 results.unused_class_members.push(UnusedMember {
1374 path: root.join("src/class.ts"),
1375 parent_name: "Foo".to_string(),
1376 member_name: "bar".to_string(),
1377 kind: MemberKind::ClassMethod,
1378 line: 10,
1379 col: 4,
1380 });
1381
1382 let elapsed = Duration::from_millis(0);
1383 let output = build_json(&results, &root, elapsed).expect("should serialize");
1384
1385 let enum_member = &output["unused_enum_members"][0];
1386 assert!(enum_member["kind"].is_string());
1387 let class_member = &output["unused_class_members"][0];
1388 assert!(class_member["kind"].is_string());
1389 }
1390
1391 #[test]
1394 fn json_unused_export_has_actions() {
1395 let root = PathBuf::from("/project");
1396 let mut results = AnalysisResults::default();
1397 results.unused_exports.push(UnusedExport {
1398 path: root.join("src/utils.ts"),
1399 export_name: "helperFn".to_string(),
1400 is_type_only: false,
1401 line: 10,
1402 col: 4,
1403 span_start: 120,
1404 is_re_export: false,
1405 });
1406 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1407
1408 let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
1409 assert_eq!(actions.len(), 2);
1410
1411 assert_eq!(actions[0]["type"], "remove-export");
1413 assert_eq!(actions[0]["auto_fixable"], true);
1414 assert!(actions[0].get("note").is_none());
1415
1416 assert_eq!(actions[1]["type"], "suppress-line");
1418 assert_eq!(
1419 actions[1]["comment"],
1420 "// fallow-ignore-next-line unused-export"
1421 );
1422 }
1423
1424 #[test]
1425 fn json_unused_file_has_file_suppress_and_note() {
1426 let root = PathBuf::from("/project");
1427 let mut results = AnalysisResults::default();
1428 results.unused_files.push(UnusedFile {
1429 path: root.join("src/dead.ts"),
1430 });
1431 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1432
1433 let actions = output["unused_files"][0]["actions"].as_array().unwrap();
1434 assert_eq!(actions[0]["type"], "delete-file");
1435 assert_eq!(actions[0]["auto_fixable"], false);
1436 assert!(actions[0]["note"].is_string());
1437 assert_eq!(actions[1]["type"], "suppress-file");
1438 assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
1439 }
1440
1441 #[test]
1442 fn json_unused_dependency_has_config_suppress_with_package_name() {
1443 let root = PathBuf::from("/project");
1444 let mut results = AnalysisResults::default();
1445 results.unused_dependencies.push(UnusedDependency {
1446 package_name: "lodash".to_string(),
1447 location: DependencyLocation::Dependencies,
1448 path: root.join("package.json"),
1449 line: 5,
1450 });
1451 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1452
1453 let actions = output["unused_dependencies"][0]["actions"]
1454 .as_array()
1455 .unwrap();
1456 assert_eq!(actions[0]["type"], "remove-dependency");
1457 assert_eq!(actions[0]["auto_fixable"], true);
1458
1459 assert_eq!(actions[1]["type"], "add-to-config");
1461 assert_eq!(actions[1]["config_key"], "ignoreDependencies");
1462 assert_eq!(actions[1]["value"], "lodash");
1463 }
1464
1465 #[test]
1466 fn json_empty_results_have_no_actions_in_empty_arrays() {
1467 let root = PathBuf::from("/project");
1468 let results = AnalysisResults::default();
1469 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1470
1471 assert!(output["unused_exports"].as_array().unwrap().is_empty());
1473 assert!(output["unused_files"].as_array().unwrap().is_empty());
1474 }
1475
1476 #[test]
1477 fn json_all_issue_types_have_actions() {
1478 let root = PathBuf::from("/project");
1479 let results = sample_results(&root);
1480 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1481
1482 let issue_keys = [
1483 "unused_files",
1484 "unused_exports",
1485 "unused_types",
1486 "unused_dependencies",
1487 "unused_dev_dependencies",
1488 "unused_optional_dependencies",
1489 "unused_enum_members",
1490 "unused_class_members",
1491 "unresolved_imports",
1492 "unlisted_dependencies",
1493 "duplicate_exports",
1494 "type_only_dependencies",
1495 "test_only_dependencies",
1496 "circular_dependencies",
1497 ];
1498
1499 for key in &issue_keys {
1500 let arr = output[key].as_array().unwrap();
1501 if !arr.is_empty() {
1502 let actions = arr[0]["actions"].as_array();
1503 assert!(
1504 actions.is_some() && !actions.unwrap().is_empty(),
1505 "missing actions for {key}"
1506 );
1507 }
1508 }
1509 }
1510}