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