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 is_cross_package: false,
1040 },
1041 ));
1042 let elapsed = Duration::from_millis(0);
1043 let output = build_json(&results, &root, elapsed).expect("should serialize");
1044
1045 let files = output["circular_dependencies"][0]["files"]
1046 .as_array()
1047 .unwrap();
1048 assert_eq!(files[0].as_str().unwrap(), "src/a.ts");
1049 assert_eq!(files[1].as_str().unwrap(), "src/b.ts");
1050 }
1051
1052 #[test]
1053 fn json_path_outside_root_not_stripped() {
1054 let root = PathBuf::from("/project");
1055 let mut results = AnalysisResults::default();
1056 results
1057 .unused_files
1058 .push(UnusedFileFinding::with_actions(UnusedFile {
1059 path: PathBuf::from("/other/project/src/file.ts"),
1060 }));
1061 let elapsed = Duration::from_millis(0);
1062 let output = build_json(&results, &root, elapsed).expect("should serialize");
1063
1064 let path = output["unused_files"][0]["path"].as_str().unwrap();
1065 assert!(path.contains("/other/project/"));
1066 }
1067
1068 #[test]
1069 fn json_unused_file_contains_path() {
1070 let root = PathBuf::from("/project");
1071 let mut results = AnalysisResults::default();
1072 results
1073 .unused_files
1074 .push(UnusedFileFinding::with_actions(UnusedFile {
1075 path: root.join("src/orphan.ts"),
1076 }));
1077 let elapsed = Duration::from_millis(0);
1078 let output = build_json(&results, &root, elapsed).expect("should serialize");
1079
1080 let file = &output["unused_files"][0];
1081 assert_eq!(file["path"], "src/orphan.ts");
1082 }
1083
1084 #[test]
1085 fn json_unused_type_contains_expected_fields() {
1086 let root = PathBuf::from("/project");
1087 let mut results = AnalysisResults::default();
1088 results
1089 .unused_types
1090 .push(UnusedTypeFinding::with_actions(UnusedExport {
1091 path: root.join("src/types.ts"),
1092 export_name: "OldInterface".to_string(),
1093 is_type_only: true,
1094 line: 20,
1095 col: 0,
1096 span_start: 300,
1097 is_re_export: false,
1098 }));
1099 let elapsed = Duration::from_millis(0);
1100 let output = build_json(&results, &root, elapsed).expect("should serialize");
1101
1102 let typ = &output["unused_types"][0];
1103 assert_eq!(typ["export_name"], "OldInterface");
1104 assert_eq!(typ["is_type_only"], true);
1105 assert_eq!(typ["line"], 20);
1106 assert_eq!(typ["path"], "src/types.ts");
1107 }
1108
1109 #[test]
1110 fn json_unused_dependency_contains_expected_fields() {
1111 let root = PathBuf::from("/project");
1112 let mut results = AnalysisResults::default();
1113 results
1114 .unused_dependencies
1115 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1116 package_name: "axios".to_string(),
1117 location: DependencyLocation::Dependencies,
1118 path: root.join("package.json"),
1119 line: 10,
1120 used_in_workspaces: Vec::new(),
1121 }));
1122 let elapsed = Duration::from_millis(0);
1123 let output = build_json(&results, &root, elapsed).expect("should serialize");
1124
1125 let dep = &output["unused_dependencies"][0];
1126 assert_eq!(dep["package_name"], "axios");
1127 assert_eq!(dep["line"], 10);
1128 assert!(dep.get("used_in_workspaces").is_none());
1129 }
1130
1131 #[test]
1132 fn json_unused_dependency_includes_cross_workspace_context() {
1133 let root = PathBuf::from("/project");
1134 let mut results = AnalysisResults::default();
1135 results
1136 .unused_dependencies
1137 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1138 package_name: "lodash-es".to_string(),
1139 location: DependencyLocation::Dependencies,
1140 path: root.join("packages/shared/package.json"),
1141 line: 6,
1142 used_in_workspaces: vec![root.join("packages/consumer")],
1143 }));
1144 let elapsed = Duration::from_millis(0);
1145 let output = build_json(&results, &root, elapsed).expect("should serialize");
1146
1147 let dep = &output["unused_dependencies"][0];
1148 assert_eq!(
1149 dep["used_in_workspaces"],
1150 serde_json::json!(["packages/consumer"])
1151 );
1152 }
1153
1154 #[test]
1155 fn json_unused_dev_dependency_contains_expected_fields() {
1156 let root = PathBuf::from("/project");
1157 let mut results = AnalysisResults::default();
1158 results
1159 .unused_dev_dependencies
1160 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1161 package_name: "vitest".to_string(),
1162 location: DependencyLocation::DevDependencies,
1163 path: root.join("package.json"),
1164 line: 15,
1165 used_in_workspaces: Vec::new(),
1166 }));
1167 let elapsed = Duration::from_millis(0);
1168 let output = build_json(&results, &root, elapsed).expect("should serialize");
1169
1170 let dep = &output["unused_dev_dependencies"][0];
1171 assert_eq!(dep["package_name"], "vitest");
1172 }
1173
1174 #[test]
1175 fn json_unused_optional_dependency_contains_expected_fields() {
1176 let root = PathBuf::from("/project");
1177 let mut results = AnalysisResults::default();
1178 results
1179 .unused_optional_dependencies
1180 .push(UnusedOptionalDependencyFinding::with_actions(
1181 UnusedDependency {
1182 package_name: "fsevents".to_string(),
1183 location: DependencyLocation::OptionalDependencies,
1184 path: root.join("package.json"),
1185 line: 12,
1186 used_in_workspaces: Vec::new(),
1187 },
1188 ));
1189 let elapsed = Duration::from_millis(0);
1190 let output = build_json(&results, &root, elapsed).expect("should serialize");
1191
1192 let dep = &output["unused_optional_dependencies"][0];
1193 assert_eq!(dep["package_name"], "fsevents");
1194 assert_eq!(output["total_issues"], 1);
1195 }
1196
1197 #[test]
1198 fn json_unused_enum_member_contains_expected_fields() {
1199 let root = PathBuf::from("/project");
1200 let mut results = AnalysisResults::default();
1201 results
1202 .unused_enum_members
1203 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1204 path: root.join("src/enums.ts"),
1205 parent_name: "Color".to_string(),
1206 member_name: "Purple".to_string(),
1207 kind: MemberKind::EnumMember,
1208 line: 5,
1209 col: 2,
1210 }));
1211 let elapsed = Duration::from_millis(0);
1212 let output = build_json(&results, &root, elapsed).expect("should serialize");
1213
1214 let member = &output["unused_enum_members"][0];
1215 assert_eq!(member["parent_name"], "Color");
1216 assert_eq!(member["member_name"], "Purple");
1217 assert_eq!(member["line"], 5);
1218 assert_eq!(member["path"], "src/enums.ts");
1219 }
1220
1221 #[test]
1222 fn json_unused_class_member_contains_expected_fields() {
1223 let root = PathBuf::from("/project");
1224 let mut results = AnalysisResults::default();
1225 results
1226 .unused_class_members
1227 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
1228 path: root.join("src/api.ts"),
1229 parent_name: "ApiClient".to_string(),
1230 member_name: "deprecatedFetch".to_string(),
1231 kind: MemberKind::ClassMethod,
1232 line: 100,
1233 col: 4,
1234 }));
1235 let elapsed = Duration::from_millis(0);
1236 let output = build_json(&results, &root, elapsed).expect("should serialize");
1237
1238 let member = &output["unused_class_members"][0];
1239 assert_eq!(member["parent_name"], "ApiClient");
1240 assert_eq!(member["member_name"], "deprecatedFetch");
1241 assert_eq!(member["line"], 100);
1242 }
1243
1244 #[test]
1245 fn json_unresolved_import_contains_expected_fields() {
1246 let root = PathBuf::from("/project");
1247 let mut results = AnalysisResults::default();
1248 results
1249 .unresolved_imports
1250 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1251 path: root.join("src/app.ts"),
1252 specifier: "@acme/missing-pkg".to_string(),
1253 line: 7,
1254 col: 0,
1255 specifier_col: 0,
1256 }));
1257 let elapsed = Duration::from_millis(0);
1258 let output = build_json(&results, &root, elapsed).expect("should serialize");
1259
1260 let import = &output["unresolved_imports"][0];
1261 assert_eq!(import["specifier"], "@acme/missing-pkg");
1262 assert_eq!(import["line"], 7);
1263 assert_eq!(import["path"], "src/app.ts");
1264 }
1265
1266 #[test]
1267 fn json_unlisted_dependency_contains_import_sites() {
1268 let root = PathBuf::from("/project");
1269 let mut results = AnalysisResults::default();
1270 results
1271 .unlisted_dependencies
1272 .push(UnlistedDependencyFinding::with_actions(
1273 UnlistedDependency {
1274 package_name: "dotenv".to_string(),
1275 imported_from: vec![
1276 ImportSite {
1277 path: root.join("src/config.ts"),
1278 line: 1,
1279 col: 0,
1280 },
1281 ImportSite {
1282 path: root.join("src/server.ts"),
1283 line: 3,
1284 col: 0,
1285 },
1286 ],
1287 },
1288 ));
1289 let elapsed = Duration::from_millis(0);
1290 let output = build_json(&results, &root, elapsed).expect("should serialize");
1291
1292 let dep = &output["unlisted_dependencies"][0];
1293 assert_eq!(dep["package_name"], "dotenv");
1294 let sites = dep["imported_from"].as_array().unwrap();
1295 assert_eq!(sites.len(), 2);
1296 assert_eq!(sites[0]["path"], "src/config.ts");
1297 assert_eq!(sites[1]["path"], "src/server.ts");
1298 }
1299
1300 #[test]
1301 fn json_duplicate_export_contains_locations() {
1302 let root = PathBuf::from("/project");
1303 let mut results = AnalysisResults::default();
1304 results
1305 .duplicate_exports
1306 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1307 export_name: "Button".to_string(),
1308 locations: vec![
1309 DuplicateLocation {
1310 path: root.join("src/ui.ts"),
1311 line: 10,
1312 col: 0,
1313 },
1314 DuplicateLocation {
1315 path: root.join("src/components.ts"),
1316 line: 25,
1317 col: 0,
1318 },
1319 ],
1320 }));
1321 let elapsed = Duration::from_millis(0);
1322 let output = build_json(&results, &root, elapsed).expect("should serialize");
1323
1324 let dup = &output["duplicate_exports"][0];
1325 assert_eq!(dup["export_name"], "Button");
1326 let locs = dup["locations"].as_array().unwrap();
1327 assert_eq!(locs.len(), 2);
1328 assert_eq!(locs[0]["line"], 10);
1329 assert_eq!(locs[1]["line"], 25);
1330 }
1331
1332 #[test]
1333 fn duplicate_export_add_to_config_is_auto_fixable_when_config_exists() {
1334 let dir = tempfile::tempdir().unwrap();
1335 let root = dir.path();
1336 std::fs::write(root.join(".fallowrc.json"), "{}\n").unwrap();
1337 let mut results = AnalysisResults::default();
1338 results
1339 .duplicate_exports
1340 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1341 export_name: "Button".to_string(),
1342 locations: vec![
1343 DuplicateLocation {
1344 path: root.join("src/ui.ts"),
1345 line: 10,
1346 col: 0,
1347 },
1348 DuplicateLocation {
1349 path: root.join("src/components.ts"),
1350 line: 25,
1351 col: 0,
1352 },
1353 ],
1354 }));
1355
1356 let output = build_json(&results, root, Duration::ZERO).unwrap();
1357 let actions = output["duplicate_exports"][0]["actions"]
1358 .as_array()
1359 .unwrap();
1360 assert_eq!(actions[0]["type"], "add-to-config");
1361 assert_eq!(actions[0]["auto_fixable"], true);
1362 }
1363
1364 #[test]
1365 fn duplicate_export_add_to_config_is_auto_fixable_when_create_fallback_allowed() {
1366 let dir = tempfile::tempdir().unwrap();
1367 let root = dir.path();
1368 let mut results = AnalysisResults::default();
1369 results
1370 .duplicate_exports
1371 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1372 export_name: "Button".to_string(),
1373 locations: vec![
1374 DuplicateLocation {
1375 path: root.join("src/ui.ts"),
1376 line: 10,
1377 col: 0,
1378 },
1379 DuplicateLocation {
1380 path: root.join("src/components.ts"),
1381 line: 25,
1382 col: 0,
1383 },
1384 ],
1385 }));
1386
1387 let output = build_json(&results, root, Duration::ZERO).unwrap();
1388 let actions = output["duplicate_exports"][0]["actions"]
1389 .as_array()
1390 .unwrap();
1391 assert_eq!(actions[0]["type"], "add-to-config");
1392 assert_eq!(actions[0]["auto_fixable"], true);
1393 }
1394
1395 #[test]
1396 fn duplicate_export_add_to_config_is_not_auto_fixable_in_monorepo_subpackage() {
1397 let dir = tempfile::tempdir().unwrap();
1398 let workspace = dir.path();
1399 std::fs::write(
1400 workspace.join("pnpm-workspace.yaml"),
1401 "packages:\n - 'packages/*'\n",
1402 )
1403 .unwrap();
1404 let sub = workspace.join("packages/ui");
1405 std::fs::create_dir_all(&sub).unwrap();
1406 let mut results = AnalysisResults::default();
1407 results
1408 .duplicate_exports
1409 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1410 export_name: "Button".to_string(),
1411 locations: vec![
1412 DuplicateLocation {
1413 path: sub.join("src/ui.ts"),
1414 line: 10,
1415 col: 0,
1416 },
1417 DuplicateLocation {
1418 path: sub.join("src/components.ts"),
1419 line: 25,
1420 col: 0,
1421 },
1422 ],
1423 }));
1424
1425 let output = build_json(&results, &sub, Duration::ZERO).unwrap();
1426 let actions = output["duplicate_exports"][0]["actions"]
1427 .as_array()
1428 .unwrap();
1429 assert_eq!(actions[0]["type"], "add-to-config");
1430 assert_eq!(actions[0]["auto_fixable"], false);
1431 }
1432
1433 #[test]
1434 fn json_type_only_dependency_contains_expected_fields() {
1435 let root = PathBuf::from("/project");
1436 let mut results = AnalysisResults::default();
1437 results
1438 .type_only_dependencies
1439 .push(TypeOnlyDependencyFinding::with_actions(
1440 TypeOnlyDependency {
1441 package_name: "zod".to_string(),
1442 path: root.join("package.json"),
1443 line: 8,
1444 },
1445 ));
1446 let elapsed = Duration::from_millis(0);
1447 let output = build_json(&results, &root, elapsed).expect("should serialize");
1448
1449 let dep = &output["type_only_dependencies"][0];
1450 assert_eq!(dep["package_name"], "zod");
1451 assert_eq!(dep["line"], 8);
1452 }
1453
1454 #[test]
1455 fn json_circular_dependency_contains_expected_fields() {
1456 let root = PathBuf::from("/project");
1457 let mut results = AnalysisResults::default();
1458 results
1459 .circular_dependencies
1460 .push(CircularDependencyFinding::with_actions(
1461 CircularDependency {
1462 files: vec![
1463 root.join("src/a.ts"),
1464 root.join("src/b.ts"),
1465 root.join("src/c.ts"),
1466 ],
1467 length: 3,
1468 line: 5,
1469 col: 0,
1470 is_cross_package: false,
1471 },
1472 ));
1473 let elapsed = Duration::from_millis(0);
1474 let output = build_json(&results, &root, elapsed).expect("should serialize");
1475
1476 let cycle = &output["circular_dependencies"][0];
1477 assert_eq!(cycle["length"], 3);
1478 assert_eq!(cycle["line"], 5);
1479 let files = cycle["files"].as_array().unwrap();
1480 assert_eq!(files.len(), 3);
1481 }
1482
1483 #[test]
1484 fn json_re_export_flagged_correctly() {
1485 let root = PathBuf::from("/project");
1486 let mut results = AnalysisResults::default();
1487 results
1488 .unused_exports
1489 .push(UnusedExportFinding::with_actions(UnusedExport {
1490 path: root.join("src/index.ts"),
1491 export_name: "reExported".to_string(),
1492 is_type_only: false,
1493 line: 1,
1494 col: 0,
1495 span_start: 0,
1496 is_re_export: true,
1497 }));
1498 let elapsed = Duration::from_millis(0);
1499 let output = build_json(&results, &root, elapsed).expect("should serialize");
1500
1501 assert_eq!(output["unused_exports"][0]["is_re_export"], true);
1502 }
1503
1504 #[test]
1505 fn json_schema_version_is_pinned() {
1506 let root = PathBuf::from("/project");
1507 let results = AnalysisResults::default();
1508 let elapsed = Duration::from_millis(0);
1509 let output = build_json(&results, &root, elapsed).expect("should serialize");
1510
1511 assert_eq!(output["schema_version"], SCHEMA_VERSION);
1512 assert_eq!(output["schema_version"], 7);
1513 }
1514
1515 #[test]
1516 fn json_version_matches_cargo_pkg_version() {
1517 let root = PathBuf::from("/project");
1518 let results = AnalysisResults::default();
1519 let elapsed = Duration::from_millis(0);
1520 let output = build_json(&results, &root, elapsed).expect("should serialize");
1521
1522 assert_eq!(output["version"], env!("CARGO_PKG_VERSION"));
1523 }
1524
1525 #[test]
1526 fn json_elapsed_ms_zero_duration() {
1527 let root = PathBuf::from("/project");
1528 let results = AnalysisResults::default();
1529 let output = build_json(&results, &root, Duration::ZERO).expect("should serialize");
1530
1531 assert_eq!(output["elapsed_ms"], 0);
1532 }
1533
1534 #[test]
1535 fn json_elapsed_ms_large_duration() {
1536 let root = PathBuf::from("/project");
1537 let results = AnalysisResults::default();
1538 let elapsed = Duration::from_mins(2);
1539 let output = build_json(&results, &root, elapsed).expect("should serialize");
1540
1541 assert_eq!(output["elapsed_ms"], 120_000);
1542 }
1543
1544 #[test]
1545 fn json_elapsed_ms_sub_millisecond_truncated() {
1546 let root = PathBuf::from("/project");
1547 let results = AnalysisResults::default();
1548 let elapsed = Duration::from_micros(500);
1549 let output = build_json(&results, &root, elapsed).expect("should serialize");
1550
1551 assert_eq!(output["elapsed_ms"], 0);
1552 }
1553
1554 #[test]
1555 fn json_multiple_unused_files() {
1556 let root = PathBuf::from("/project");
1557 let mut results = AnalysisResults::default();
1558 results
1559 .unused_files
1560 .push(UnusedFileFinding::with_actions(UnusedFile {
1561 path: root.join("src/a.ts"),
1562 }));
1563 results
1564 .unused_files
1565 .push(UnusedFileFinding::with_actions(UnusedFile {
1566 path: root.join("src/b.ts"),
1567 }));
1568 results
1569 .unused_files
1570 .push(UnusedFileFinding::with_actions(UnusedFile {
1571 path: root.join("src/c.ts"),
1572 }));
1573 let elapsed = Duration::from_millis(0);
1574 let output = build_json(&results, &root, elapsed).expect("should serialize");
1575
1576 assert_eq!(output["unused_files"].as_array().unwrap().len(), 3);
1577 assert_eq!(output["total_issues"], 3);
1578 }
1579
1580 #[test]
1581 fn strip_root_prefix_on_string_value() {
1582 let mut value = serde_json::json!("/project/src/file.ts");
1583 strip_root_prefix(&mut value, "/project/");
1584 assert_eq!(value, "src/file.ts");
1585 }
1586
1587 #[test]
1588 fn strip_root_prefix_leaves_non_matching_string() {
1589 let mut value = serde_json::json!("/other/src/file.ts");
1590 strip_root_prefix(&mut value, "/project/");
1591 assert_eq!(value, "/other/src/file.ts");
1592 }
1593
1594 #[test]
1595 fn strip_root_prefix_recurses_into_arrays() {
1596 let mut value = serde_json::json!(["/project/a.ts", "/project/b.ts", "/other/c.ts"]);
1597 strip_root_prefix(&mut value, "/project/");
1598 assert_eq!(value[0], "a.ts");
1599 assert_eq!(value[1], "b.ts");
1600 assert_eq!(value[2], "/other/c.ts");
1601 }
1602
1603 #[test]
1604 fn strip_root_prefix_recurses_into_nested_objects() {
1605 let mut value = serde_json::json!({
1606 "outer": {
1607 "path": "/project/src/nested.ts"
1608 }
1609 });
1610 strip_root_prefix(&mut value, "/project/");
1611 assert_eq!(value["outer"]["path"], "src/nested.ts");
1612 }
1613
1614 #[test]
1615 fn strip_root_prefix_leaves_numbers_and_booleans() {
1616 let mut value = serde_json::json!({
1617 "line": 42,
1618 "is_type_only": false,
1619 "path": "/project/src/file.ts"
1620 });
1621 strip_root_prefix(&mut value, "/project/");
1622 assert_eq!(value["line"], 42);
1623 assert_eq!(value["is_type_only"], false);
1624 assert_eq!(value["path"], "src/file.ts");
1625 }
1626
1627 #[test]
1628 fn strip_root_prefix_normalizes_windows_separators() {
1629 let mut value = serde_json::json!(r"/project\src\file.ts");
1630 strip_root_prefix(&mut value, "/project/");
1631 assert_eq!(value, "src/file.ts");
1632 }
1633
1634 #[test]
1635 fn strip_root_prefix_handles_empty_string_after_strip() {
1636 let mut value = serde_json::json!("/project/");
1637 strip_root_prefix(&mut value, "/project/");
1638 assert_eq!(value, "");
1639 }
1640
1641 #[test]
1642 fn strip_root_prefix_deeply_nested_array_of_objects() {
1643 let mut value = serde_json::json!({
1644 "groups": [{
1645 "instances": [{
1646 "file": "/project/src/a.ts"
1647 }, {
1648 "file": "/project/src/b.ts"
1649 }]
1650 }]
1651 });
1652 strip_root_prefix(&mut value, "/project/");
1653 assert_eq!(value["groups"][0]["instances"][0]["file"], "src/a.ts");
1654 assert_eq!(value["groups"][0]["instances"][1]["file"], "src/b.ts");
1655 }
1656
1657 #[test]
1658 fn json_full_sample_results_total_issues_correct() {
1659 let root = PathBuf::from("/project");
1660 let results = sample_results(&root);
1661 let elapsed = Duration::from_millis(100);
1662 let output = build_json(&results, &root, elapsed).expect("should serialize");
1663
1664 assert_eq!(output["total_issues"], results.total_issues());
1665 }
1666
1667 #[test]
1668 fn json_full_sample_no_absolute_paths_in_output() {
1669 let root = PathBuf::from("/project");
1670 let results = sample_results(&root);
1671 let elapsed = Duration::from_millis(0);
1672 let output = build_json(&results, &root, elapsed).expect("should serialize");
1673
1674 let json_str = serde_json::to_string(&output).expect("should stringify");
1675 assert!(!json_str.contains("/project/src/"));
1676 assert!(!json_str.contains("/project/package.json"));
1677 }
1678
1679 #[test]
1680 fn json_output_is_deterministic() {
1681 let root = PathBuf::from("/project");
1682 let results = sample_results(&root);
1683 let elapsed = Duration::from_millis(50);
1684
1685 let output1 = build_json(&results, &root, elapsed).expect("first build");
1686 let output2 = build_json(&results, &root, elapsed).expect("second build");
1687
1688 assert_eq!(output1, output2);
1689 }
1690
1691 #[test]
1692 fn json_results_fields_do_not_shadow_metadata() {
1693 let root = PathBuf::from("/project");
1694 let results = AnalysisResults::default();
1695 let elapsed = Duration::from_millis(99);
1696 let output = build_json(&results, &root, elapsed).expect("should serialize");
1697
1698 assert_eq!(output["kind"], "dead-code");
1699 assert_eq!(output["schema_version"], 7);
1700 assert_eq!(output["elapsed_ms"], 99);
1701 }
1702
1703 #[test]
1704 fn json_all_issue_type_arrays_present_in_empty_results() {
1705 let root = PathBuf::from("/project");
1706 let results = AnalysisResults::default();
1707 let elapsed = Duration::from_millis(0);
1708 let output = build_json(&results, &root, elapsed).expect("should serialize");
1709
1710 let expected_arrays = [
1711 "unused_files",
1712 "unused_exports",
1713 "unused_types",
1714 "unused_dependencies",
1715 "unused_dev_dependencies",
1716 "unused_optional_dependencies",
1717 "unused_enum_members",
1718 "unused_class_members",
1719 "unresolved_imports",
1720 "unlisted_dependencies",
1721 "duplicate_exports",
1722 "type_only_dependencies",
1723 "test_only_dependencies",
1724 "circular_dependencies",
1725 ];
1726 for key in &expected_arrays {
1727 assert!(
1728 output[key].is_array(),
1729 "expected '{key}' to be an array in JSON output"
1730 );
1731 }
1732 }
1733
1734 #[test]
1735 fn insert_meta_adds_key_to_object() {
1736 let mut output = serde_json::json!({ "foo": 1 });
1737 let meta = serde_json::json!({ "docs": "https://example.com" });
1738 insert_meta(&mut output, meta.clone());
1739 assert_eq!(output["_meta"], meta);
1740 }
1741
1742 #[test]
1743 fn insert_meta_noop_on_non_object() {
1744 let mut output = serde_json::json!([1, 2, 3]);
1745 let meta = serde_json::json!({ "docs": "https://example.com" });
1746 insert_meta(&mut output, meta);
1747 assert!(output.is_array());
1748 }
1749
1750 #[test]
1751 fn insert_meta_overwrites_existing_meta() {
1752 let mut output = serde_json::json!({ "_meta": "old" });
1753 let meta = serde_json::json!({ "new": true });
1754 insert_meta(&mut output, meta.clone());
1755 assert_eq!(output["_meta"], meta);
1756 }
1757
1758 #[test]
1759 fn strip_root_prefix_null_unchanged() {
1760 let mut value = serde_json::Value::Null;
1761 strip_root_prefix(&mut value, "/project/");
1762 assert!(value.is_null());
1763 }
1764
1765 #[test]
1766 fn strip_root_prefix_empty_string() {
1767 let mut value = serde_json::json!("");
1768 strip_root_prefix(&mut value, "/project/");
1769 assert_eq!(value, "");
1770 }
1771
1772 #[test]
1773 fn strip_root_prefix_mixed_types() {
1774 let mut value = serde_json::json!({
1775 "path": "/project/src/file.ts",
1776 "line": 42,
1777 "flag": true,
1778 "nested": {
1779 "items": ["/project/a.ts", 99, null, "/project/b.ts"],
1780 "deep": { "path": "/project/c.ts" }
1781 }
1782 });
1783 strip_root_prefix(&mut value, "/project/");
1784 assert_eq!(value["path"], "src/file.ts");
1785 assert_eq!(value["line"], 42);
1786 assert_eq!(value["flag"], true);
1787 assert_eq!(value["nested"]["items"][0], "a.ts");
1788 assert_eq!(value["nested"]["items"][1], 99);
1789 assert!(value["nested"]["items"][2].is_null());
1790 assert_eq!(value["nested"]["items"][3], "b.ts");
1791 assert_eq!(value["nested"]["deep"]["path"], "c.ts");
1792 }
1793
1794 #[test]
1795 fn json_check_meta_integrates_correctly() {
1796 let root = PathBuf::from("/project");
1797 let results = AnalysisResults::default();
1798 let elapsed = Duration::from_millis(0);
1799 let mut output = build_json(&results, &root, elapsed).expect("should serialize");
1800 insert_meta(&mut output, crate::explain::check_meta());
1801
1802 assert!(output["_meta"]["docs"].is_string());
1803 assert!(output["_meta"]["rules"].is_object());
1804 }
1805
1806 #[test]
1807 fn json_unused_member_kind_serialized() {
1808 let root = PathBuf::from("/project");
1809 let mut results = AnalysisResults::default();
1810 results
1811 .unused_enum_members
1812 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1813 path: root.join("src/enums.ts"),
1814 parent_name: "Color".to_string(),
1815 member_name: "Red".to_string(),
1816 kind: MemberKind::EnumMember,
1817 line: 3,
1818 col: 2,
1819 }));
1820 results
1821 .unused_class_members
1822 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
1823 path: root.join("src/class.ts"),
1824 parent_name: "Foo".to_string(),
1825 member_name: "bar".to_string(),
1826 kind: MemberKind::ClassMethod,
1827 line: 10,
1828 col: 4,
1829 }));
1830
1831 let elapsed = Duration::from_millis(0);
1832 let output = build_json(&results, &root, elapsed).expect("should serialize");
1833
1834 let enum_member = &output["unused_enum_members"][0];
1835 assert!(enum_member["kind"].is_string());
1836 let class_member = &output["unused_class_members"][0];
1837 assert!(class_member["kind"].is_string());
1838 }
1839
1840 #[test]
1841 fn json_unused_export_has_actions() {
1842 let root = PathBuf::from("/project");
1843 let mut results = AnalysisResults::default();
1844 results
1845 .unused_exports
1846 .push(UnusedExportFinding::with_actions(UnusedExport {
1847 path: root.join("src/utils.ts"),
1848 export_name: "helperFn".to_string(),
1849 is_type_only: false,
1850 line: 10,
1851 col: 4,
1852 span_start: 120,
1853 is_re_export: false,
1854 }));
1855 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1856
1857 let actions = output["unused_exports"][0]["actions"].as_array().unwrap();
1858 assert_eq!(actions.len(), 2);
1859
1860 assert_eq!(actions[0]["type"], "remove-export");
1861 assert_eq!(actions[0]["auto_fixable"], true);
1862 assert!(actions[0].get("note").is_none());
1863
1864 assert_eq!(actions[1]["type"], "suppress-line");
1865 assert_eq!(
1866 actions[1]["comment"],
1867 "// fallow-ignore-next-line unused-export"
1868 );
1869 }
1870
1871 #[test]
1872 fn json_same_line_findings_share_multi_kind_suppression_comment() {
1873 let root = PathBuf::from("/project");
1874 let mut results = AnalysisResults::default();
1875 results
1876 .unused_exports
1877 .push(UnusedExportFinding::with_actions(UnusedExport {
1878 path: root.join("src/api.ts"),
1879 export_name: "helperFn".to_string(),
1880 is_type_only: false,
1881 line: 10,
1882 col: 4,
1883 span_start: 120,
1884 is_re_export: false,
1885 }));
1886 results
1887 .unused_types
1888 .push(UnusedTypeFinding::with_actions(UnusedExport {
1889 path: root.join("src/api.ts"),
1890 export_name: "OldType".to_string(),
1891 is_type_only: true,
1892 line: 10,
1893 col: 0,
1894 span_start: 60,
1895 is_re_export: false,
1896 }));
1897 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1898
1899 let export_actions = output["unused_exports"][0]["actions"].as_array().unwrap();
1900 let type_actions = output["unused_types"][0]["actions"].as_array().unwrap();
1901 assert_eq!(
1902 export_actions[1]["comment"],
1903 "// fallow-ignore-next-line unused-export, unused-type"
1904 );
1905 assert_eq!(
1906 type_actions[1]["comment"],
1907 "// fallow-ignore-next-line unused-export, unused-type"
1908 );
1909 }
1910
1911 #[test]
1912 fn audit_like_json_shares_suppression_comment_across_dead_code_and_complexity() {
1913 let mut output = serde_json::json!({
1914 "dead_code": {
1915 "unused_exports": [{
1916 "path": "src/main.ts",
1917 "line": 1,
1918 "actions": [
1919 { "type": "remove-export", "auto_fixable": true },
1920 {
1921 "type": "suppress-line",
1922 "auto_fixable": false,
1923 "comment": "// fallow-ignore-next-line unused-export"
1924 }
1925 ]
1926 }]
1927 },
1928 "complexity": {
1929 "findings": [{
1930 "path": "src/main.ts",
1931 "line": 1,
1932 "actions": [
1933 { "type": "refactor-function", "auto_fixable": false },
1934 {
1935 "type": "suppress-line",
1936 "auto_fixable": false,
1937 "comment": "// fallow-ignore-next-line complexity"
1938 }
1939 ]
1940 }]
1941 }
1942 });
1943
1944 harmonize_multi_kind_suppress_line_actions(&mut output);
1945
1946 assert_eq!(
1947 output["dead_code"]["unused_exports"][0]["actions"][1]["comment"],
1948 "// fallow-ignore-next-line unused-export, complexity"
1949 );
1950 assert_eq!(
1951 output["complexity"]["findings"][0]["actions"][1]["comment"],
1952 "// fallow-ignore-next-line unused-export, complexity"
1953 );
1954 }
1955
1956 #[test]
1957 fn json_unused_file_has_file_suppress_and_note() {
1958 let root = PathBuf::from("/project");
1959 let mut results = AnalysisResults::default();
1960 results
1961 .unused_files
1962 .push(UnusedFileFinding::with_actions(UnusedFile {
1963 path: root.join("src/dead.ts"),
1964 }));
1965 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1966
1967 let actions = output["unused_files"][0]["actions"].as_array().unwrap();
1968 assert_eq!(actions[0]["type"], "delete-file");
1969 assert_eq!(actions[0]["auto_fixable"], false);
1970 assert!(actions[0]["note"].is_string());
1971 assert_eq!(actions[1]["type"], "suppress-file");
1972 assert_eq!(actions[1]["comment"], "// fallow-ignore-file unused-file");
1973 }
1974
1975 #[test]
1976 fn json_unused_dependency_has_config_suppress_with_package_name() {
1977 let root = PathBuf::from("/project");
1978 let mut results = AnalysisResults::default();
1979 results
1980 .unused_dependencies
1981 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1982 package_name: "lodash".to_string(),
1983 location: DependencyLocation::Dependencies,
1984 path: root.join("package.json"),
1985 line: 5,
1986 used_in_workspaces: Vec::new(),
1987 }));
1988 let output = build_json(&results, &root, Duration::ZERO).unwrap();
1989
1990 let actions = output["unused_dependencies"][0]["actions"]
1991 .as_array()
1992 .unwrap();
1993 assert_eq!(actions[0]["type"], "remove-dependency");
1994 assert_eq!(actions[0]["auto_fixable"], true);
1995
1996 assert_eq!(actions[1]["type"], "add-to-config");
1997 assert_eq!(actions[1]["config_key"], "ignoreDependencies");
1998 assert_eq!(actions[1]["value"], "lodash");
1999 }
2000
2001 #[test]
2002 fn json_cross_workspace_dependency_is_not_auto_fixable() {
2003 let root = PathBuf::from("/project");
2004 let mut results = AnalysisResults::default();
2005 results
2006 .unused_dependencies
2007 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2008 package_name: "lodash-es".to_string(),
2009 location: DependencyLocation::Dependencies,
2010 path: root.join("packages/shared/package.json"),
2011 line: 5,
2012 used_in_workspaces: vec![root.join("packages/consumer")],
2013 }));
2014 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2015
2016 let actions = output["unused_dependencies"][0]["actions"]
2017 .as_array()
2018 .unwrap();
2019 assert_eq!(actions[0]["type"], "move-dependency");
2020 assert_eq!(actions[0]["auto_fixable"], false);
2021 assert!(
2022 actions[0]["note"]
2023 .as_str()
2024 .unwrap()
2025 .contains("will not remove")
2026 );
2027 assert_eq!(actions[1]["type"], "add-to-config");
2028 }
2029
2030 #[test]
2031 fn json_empty_results_have_no_actions_in_empty_arrays() {
2032 let root = PathBuf::from("/project");
2033 let results = AnalysisResults::default();
2034 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2035
2036 assert!(output["unused_exports"].as_array().unwrap().is_empty());
2037 assert!(output["unused_files"].as_array().unwrap().is_empty());
2038 }
2039
2040 #[test]
2041 fn json_all_issue_types_have_actions() {
2042 let root = PathBuf::from("/project");
2043 let results = sample_results(&root);
2044 let output = build_json(&results, &root, Duration::ZERO).unwrap();
2045
2046 let issue_keys = [
2047 "unused_files",
2048 "unused_exports",
2049 "unused_types",
2050 "unused_dependencies",
2051 "unused_dev_dependencies",
2052 "unused_optional_dependencies",
2053 "unused_enum_members",
2054 "unused_class_members",
2055 "unresolved_imports",
2056 "unlisted_dependencies",
2057 "duplicate_exports",
2058 "type_only_dependencies",
2059 "test_only_dependencies",
2060 "circular_dependencies",
2061 ];
2062
2063 for key in &issue_keys {
2064 let arr = output[key].as_array().unwrap();
2065 if !arr.is_empty() {
2066 let actions = arr[0]["actions"].as_array();
2067 assert!(
2068 actions.is_some() && !actions.unwrap().is_empty(),
2069 "missing actions for {key}"
2070 );
2071 }
2072 }
2073 }
2074
2075 fn build_actions_for_finding_json(
2082 finding_json: serde_json::Value,
2083 opts: crate::health_types::HealthActionOptions,
2084 max_cyclomatic_threshold: u16,
2085 max_cognitive_threshold: u16,
2086 max_crap_threshold: f64,
2087 ) -> Vec<serde_json::Value> {
2088 let mut value = finding_json;
2089 if let Some(map) = value.as_object_mut() {
2090 map.entry("col".to_string())
2091 .or_insert(serde_json::Value::from(0_u32));
2092 map.entry("line_count".to_string())
2093 .or_insert(serde_json::Value::from(0_u32));
2094 map.entry("param_count".to_string())
2095 .or_insert(serde_json::Value::from(0_u8));
2096 map.entry("severity".to_string())
2097 .or_insert(serde_json::Value::String("moderate".to_string()));
2098 }
2099 let violation = synthesize_complexity_violation(&value);
2100 let ctx = crate::health_types::HealthActionContext {
2101 opts,
2102 max_cyclomatic_threshold,
2103 max_cognitive_threshold,
2104 max_crap_threshold,
2105 crap_refactor_band: 5,
2106 };
2107 let finding = crate::health_types::HealthFinding::with_actions(violation, &ctx);
2108 let serialized = serde_json::to_value(&finding).expect("serialize HealthFinding");
2109 serialized["actions"]
2110 .as_array()
2111 .cloned()
2112 .unwrap_or_default()
2113 }
2114
2115 fn synthesize_complexity_violation(
2120 value: &serde_json::Value,
2121 ) -> crate::health_types::ComplexityViolation {
2122 use crate::health_types::{
2123 CoverageSource, CoverageTier, ExceededThreshold, FindingSeverity,
2124 };
2125 let exceeded = match value["exceeded"].as_str().unwrap_or("crap") {
2126 "cyclomatic" => ExceededThreshold::Cyclomatic,
2127 "cognitive" => ExceededThreshold::Cognitive,
2128 "both" => ExceededThreshold::Both,
2129 "crap" => ExceededThreshold::Crap,
2130 "cyclomatic_crap" => ExceededThreshold::CyclomaticCrap,
2131 "cognitive_crap" => ExceededThreshold::CognitiveCrap,
2132 "all" => ExceededThreshold::All,
2133 other => panic!("unknown exceeded label: {other}"),
2134 };
2135 let severity = match value["severity"].as_str().unwrap_or("moderate") {
2136 "moderate" => FindingSeverity::Moderate,
2137 "high" => FindingSeverity::High,
2138 "critical" => FindingSeverity::Critical,
2139 other => panic!("unknown severity label: {other}"),
2140 };
2141 let coverage_tier = value
2142 .get("coverage_tier")
2143 .and_then(|v| v.as_str())
2144 .map(|t| match t {
2145 "none" => CoverageTier::None,
2146 "partial" => CoverageTier::Partial,
2147 "high" => CoverageTier::High,
2148 other => panic!("unknown coverage_tier label: {other}"),
2149 });
2150 let coverage_source =
2151 value
2152 .get("coverage_source")
2153 .and_then(|v| v.as_str())
2154 .map(|s| match s {
2155 "istanbul" => CoverageSource::Istanbul,
2156 "estimated" => CoverageSource::Estimated,
2157 "estimated_component_inherited" => CoverageSource::EstimatedComponentInherited,
2158 other => panic!("unknown coverage_source label: {other}"),
2159 });
2160 crate::health_types::ComplexityViolation {
2161 path: std::path::PathBuf::from(value["path"].as_str().unwrap_or("src/x.ts")),
2162 name: value["name"].as_str().unwrap_or("fn").to_string(),
2163 line: u32::try_from(value["line"].as_u64().unwrap_or(0)).unwrap_or(0),
2164 col: u32::try_from(value["col"].as_u64().unwrap_or(0)).unwrap_or(0),
2165 cyclomatic: u16::try_from(value["cyclomatic"].as_u64().unwrap_or(0)).unwrap_or(0),
2166 cognitive: u16::try_from(value["cognitive"].as_u64().unwrap_or(0)).unwrap_or(0),
2167 line_count: u32::try_from(value["line_count"].as_u64().unwrap_or(0)).unwrap_or(0),
2168 param_count: u8::try_from(value["param_count"].as_u64().unwrap_or(0)).unwrap_or(0),
2169 exceeded,
2170 severity,
2171 crap: value.get("crap").and_then(|v| v.as_f64()),
2172 coverage_pct: value.get("coverage_pct").and_then(|v| v.as_f64()),
2173 coverage_tier,
2174 coverage_source,
2175 inherited_from: value
2176 .get("inherited_from")
2177 .and_then(|v| v.as_str())
2178 .map(std::path::PathBuf::from),
2179 component_rollup: value.get("component_rollup").and_then(|v| {
2180 let map = v.as_object()?;
2181 Some(crate::health_types::ComponentRollup {
2182 component: map.get("component")?.as_str()?.to_string(),
2183 class_worst_function: map.get("class_worst_function")?.as_str()?.to_string(),
2184 class_cyclomatic: u16::try_from(map.get("class_cyclomatic")?.as_u64()?).ok()?,
2185 class_cognitive: u16::try_from(map.get("class_cognitive")?.as_u64()?).ok()?,
2186 template_path: std::path::PathBuf::from(map.get("template_path")?.as_str()?),
2187 template_cyclomatic: u16::try_from(map.get("template_cyclomatic")?.as_u64()?)
2188 .ok()?,
2189 template_cognitive: u16::try_from(map.get("template_cognitive")?.as_u64()?)
2190 .ok()?,
2191 })
2192 }),
2193 }
2194 }
2195
2196 #[test]
2197 fn health_finding_has_actions() {
2198 let actions = build_actions_for_finding_json(
2199 serde_json::json!({
2200 "path": "src/utils.ts",
2201 "name": "processData",
2202 "line": 10,
2203 "col": 0,
2204 "cyclomatic": 25,
2205 "cognitive": 30,
2206 "line_count": 150,
2207 "exceeded": "both"
2208 }),
2209 crate::health_types::HealthActionOptions::default(),
2210 20,
2211 15,
2212 30.0,
2213 );
2214
2215 assert_eq!(actions.len(), 2);
2216 assert_eq!(actions[0]["type"], "refactor-function");
2217 assert_eq!(actions[0]["auto_fixable"], false);
2218 assert!(
2219 actions[0]["description"]
2220 .as_str()
2221 .unwrap()
2222 .contains("processData")
2223 );
2224 assert_eq!(actions[1]["type"], "suppress-line");
2225 assert_eq!(
2226 actions[1]["comment"],
2227 "// fallow-ignore-next-line complexity"
2228 );
2229 }
2230
2231 #[test]
2232 fn health_finding_suppress_has_placement() {
2233 let actions = build_actions_for_finding_json(
2234 serde_json::json!({
2235 "path": "src/utils.ts",
2236 "name": "processData",
2237 "line": 10,
2238 "col": 0,
2239 "cyclomatic": 25,
2240 "cognitive": 30,
2241 "line_count": 150,
2242 "exceeded": "both"
2243 }),
2244 crate::health_types::HealthActionOptions::default(),
2245 20,
2246 15,
2247 30.0,
2248 );
2249
2250 assert_eq!(actions[1]["placement"], "above-function-declaration");
2251 }
2252
2253 #[test]
2254 fn html_template_health_finding_uses_html_suppression() {
2255 let actions = build_actions_for_finding_json(
2256 serde_json::json!({
2257 "path": "src/app.component.html",
2258 "name": "<template>",
2259 "line": 1,
2260 "col": 0,
2261 "cyclomatic": 25,
2262 "cognitive": 30,
2263 "line_count": 40,
2264 "exceeded": "both"
2265 }),
2266 crate::health_types::HealthActionOptions::default(),
2267 20,
2268 15,
2269 30.0,
2270 );
2271
2272 let suppress = &actions[1];
2273 assert_eq!(suppress["type"], "suppress-file");
2274 assert_eq!(
2275 suppress["comment"],
2276 "<!-- fallow-ignore-file complexity -->"
2277 );
2278 assert_eq!(suppress["placement"], "top-of-template");
2279 }
2280
2281 #[test]
2282 fn inline_template_health_finding_uses_decorator_suppression() {
2283 let actions = build_actions_for_finding_json(
2284 serde_json::json!({
2285 "path": "src/app.component.ts",
2286 "name": "<template>",
2287 "line": 5,
2288 "col": 0,
2289 "cyclomatic": 25,
2290 "cognitive": 30,
2291 "line_count": 40,
2292 "exceeded": "both"
2293 }),
2294 crate::health_types::HealthActionOptions::default(),
2295 20,
2296 15,
2297 30.0,
2298 );
2299
2300 let refactor = &actions[0];
2301 assert_eq!(refactor["type"], "refactor-function");
2302 assert!(
2303 refactor["description"]
2304 .as_str()
2305 .unwrap()
2306 .contains("template complexity")
2307 );
2308 let suppress = &actions[1];
2309 assert_eq!(suppress["type"], "suppress-line");
2310 assert_eq!(
2311 suppress["description"],
2312 "Suppress with an inline comment above the Angular decorator"
2313 );
2314 assert_eq!(suppress["placement"], "above-angular-decorator");
2315 }
2316
2317 fn crap_only_finding_envelope(
2324 coverage_tier: Option<&str>,
2325 cyclomatic: u16,
2326 max_cyclomatic_threshold: u16,
2327 ) -> serde_json::Value {
2328 crap_only_finding_envelope_with_max_crap(
2329 coverage_tier,
2330 cyclomatic,
2331 12,
2332 max_cyclomatic_threshold,
2333 15,
2334 30.0,
2335 )
2336 }
2337
2338 fn crap_only_finding_envelope_with_cognitive(
2339 coverage_tier: Option<&str>,
2340 cyclomatic: u16,
2341 cognitive: u16,
2342 max_cyclomatic_threshold: u16,
2343 ) -> serde_json::Value {
2344 crap_only_finding_envelope_with_max_crap(
2345 coverage_tier,
2346 cyclomatic,
2347 cognitive,
2348 max_cyclomatic_threshold,
2349 15,
2350 30.0,
2351 )
2352 }
2353
2354 fn crap_only_finding_envelope_with_max_crap(
2362 coverage_tier: Option<&str>,
2363 cyclomatic: u16,
2364 cognitive: u16,
2365 max_cyclomatic_threshold: u16,
2366 max_cognitive_threshold: u16,
2367 max_crap_threshold: f64,
2368 ) -> serde_json::Value {
2369 build_finding_envelope_with_ctx(
2370 coverage_tier,
2371 cyclomatic,
2372 cognitive,
2373 max_cyclomatic_threshold,
2374 max_cognitive_threshold,
2375 max_crap_threshold,
2376 crate::health_types::HealthActionOptions::default(),
2377 )
2378 }
2379
2380 fn build_finding_envelope_with_ctx(
2384 coverage_tier: Option<&str>,
2385 cyclomatic: u16,
2386 cognitive: u16,
2387 max_cyclomatic_threshold: u16,
2388 max_cognitive_threshold: u16,
2389 max_crap_threshold: f64,
2390 action_opts: crate::health_types::HealthActionOptions,
2391 ) -> serde_json::Value {
2392 let tier = coverage_tier.map(|t| match t {
2393 "none" => crate::health_types::CoverageTier::None,
2394 "partial" => crate::health_types::CoverageTier::Partial,
2395 "high" => crate::health_types::CoverageTier::High,
2396 other => panic!("unknown coverage tier label: {other}"),
2397 });
2398 let violation = crate::health_types::ComplexityViolation {
2399 path: std::path::PathBuf::from("src/risk.ts"),
2400 name: "computeScore".to_string(),
2401 line: 12,
2402 col: 0,
2403 cyclomatic,
2404 cognitive,
2405 line_count: 40,
2406 param_count: 0,
2407 exceeded: crate::health_types::ExceededThreshold::Crap,
2408 severity: crate::health_types::FindingSeverity::Moderate,
2409 crap: Some(35.5),
2410 coverage_pct: None,
2411 coverage_tier: tier,
2412 coverage_source: None,
2413 inherited_from: None,
2414 component_rollup: None,
2415 };
2416 let ctx = crate::health_types::HealthActionContext {
2417 opts: action_opts,
2418 max_cyclomatic_threshold,
2419 max_cognitive_threshold,
2420 max_crap_threshold,
2421 crap_refactor_band: 5,
2422 };
2423 let finding = crate::health_types::HealthFinding::with_actions(violation, &ctx);
2424 let actions_meta = if action_opts.omit_suppress_line {
2425 Some(serde_json::json!({
2426 "suppression_hints_omitted": true,
2427 "reason": action_opts.omit_reason.unwrap_or("unspecified"),
2428 "scope": "health-findings",
2429 }))
2430 } else {
2431 None
2432 };
2433 let mut envelope = serde_json::json!({
2434 "findings": [serde_json::to_value(&finding).unwrap()],
2435 "summary": {
2436 "max_cyclomatic_threshold": max_cyclomatic_threshold,
2437 "max_cognitive_threshold": max_cognitive_threshold,
2438 "max_crap_threshold": max_crap_threshold,
2439 },
2440 });
2441 if let Some(meta) = actions_meta
2442 && let Some(map) = envelope.as_object_mut()
2443 {
2444 map.insert("actions_meta".to_string(), meta);
2445 }
2446 envelope
2447 }
2448
2449 #[test]
2450 fn crap_only_tier_none_emits_add_tests() {
2451 let output = crap_only_finding_envelope(Some("none"), 6, 20);
2452 let actions = output["findings"][0]["actions"].as_array().unwrap();
2453 assert!(
2454 actions.iter().any(|a| a["type"] == "add-tests"),
2455 "tier=none crap-only must emit add-tests, got {actions:?}"
2456 );
2457 assert!(
2458 !actions.iter().any(|a| a["type"] == "increase-coverage"),
2459 "tier=none must not emit increase-coverage"
2460 );
2461 }
2462
2463 #[test]
2464 fn crap_only_tier_partial_emits_increase_coverage() {
2465 let output = crap_only_finding_envelope(Some("partial"), 6, 20);
2466 let actions = output["findings"][0]["actions"].as_array().unwrap();
2467 assert!(
2468 actions.iter().any(|a| a["type"] == "increase-coverage"),
2469 "tier=partial crap-only must emit increase-coverage, got {actions:?}"
2470 );
2471 assert!(
2472 !actions.iter().any(|a| a["type"] == "add-tests"),
2473 "tier=partial must not emit add-tests"
2474 );
2475 }
2476
2477 #[test]
2478 fn crap_only_tier_high_emits_increase_coverage_when_full_coverage_can_clear_crap() {
2479 let output = crap_only_finding_envelope(Some("high"), 20, 30);
2480 let actions = output["findings"][0]["actions"].as_array().unwrap();
2481 assert!(
2482 actions.iter().any(|a| a["type"] == "increase-coverage"),
2483 "tier=high crap-only must still emit increase-coverage when full coverage can clear CRAP, got {actions:?}"
2484 );
2485 assert!(
2486 !actions.iter().any(|a| a["type"] == "refactor-function"),
2487 "coverage-remediable crap-only findings should not get refactor-function unless near the cyclomatic threshold"
2488 );
2489 assert!(
2490 !actions.iter().any(|a| a["type"] == "add-tests"),
2491 "tier=high must not emit add-tests"
2492 );
2493 }
2494
2495 #[test]
2496 fn crap_only_emits_refactor_when_full_coverage_cannot_clear_crap() {
2497 let output = crap_only_finding_envelope_with_max_crap(Some("high"), 35, 12, 50, 15, 30.0);
2498 let actions = output["findings"][0]["actions"].as_array().unwrap();
2499 assert!(
2500 actions.iter().any(|a| a["type"] == "refactor-function"),
2501 "full-coverage-impossible CRAP-only finding must emit refactor-function, got {actions:?}"
2502 );
2503 assert!(
2504 !actions.iter().any(|a| a["type"] == "increase-coverage"),
2505 "must not emit increase-coverage when even 100% coverage cannot clear CRAP"
2506 );
2507 assert!(
2508 !actions.iter().any(|a| a["type"] == "add-tests"),
2509 "must not emit add-tests when even 100% coverage cannot clear CRAP"
2510 );
2511 }
2512
2513 #[test]
2514 fn crap_only_high_cc_appends_secondary_refactor() {
2515 let output = crap_only_finding_envelope(Some("none"), 16, 20);
2516 let actions = output["findings"][0]["actions"].as_array().unwrap();
2517 assert!(
2518 actions.iter().any(|a| a["type"] == "add-tests"),
2519 "near-threshold crap-only still emits the primary tier action"
2520 );
2521 assert!(
2522 actions.iter().any(|a| a["type"] == "refactor-function"),
2523 "near-threshold crap-only must also emit secondary refactor-function"
2524 );
2525 }
2526
2527 #[test]
2528 fn crap_only_far_below_threshold_no_secondary_refactor() {
2529 let output = crap_only_finding_envelope(Some("none"), 6, 20);
2530 let actions = output["findings"][0]["actions"].as_array().unwrap();
2531 assert!(
2532 !actions.iter().any(|a| a["type"] == "refactor-function"),
2533 "low-CC crap-only should not get a secondary refactor-function"
2534 );
2535 }
2536
2537 #[test]
2538 fn crap_only_near_threshold_low_cognitive_no_secondary_refactor() {
2539 let output = crap_only_finding_envelope_with_cognitive(Some("none"), 17, 2, 20);
2540 let actions = output["findings"][0]["actions"].as_array().unwrap();
2541 assert!(
2542 actions.iter().any(|a| a["type"] == "add-tests"),
2543 "primary tier action still emits"
2544 );
2545 assert!(
2546 !actions.iter().any(|a| a["type"] == "refactor-function"),
2547 "near-threshold CC with cognitive below floor must NOT emit secondary refactor (got {actions:?})"
2548 );
2549 }
2550
2551 #[test]
2552 fn crap_only_near_threshold_high_cognitive_emits_secondary_refactor() {
2553 let output = crap_only_finding_envelope_with_cognitive(Some("none"), 16, 10, 20);
2554 let actions = output["findings"][0]["actions"].as_array().unwrap();
2555 assert!(
2556 actions.iter().any(|a| a["type"] == "add-tests"),
2557 "primary tier action still emits"
2558 );
2559 assert!(
2560 actions.iter().any(|a| a["type"] == "refactor-function"),
2561 "near-threshold CC with cognitive above floor must emit secondary refactor (got {actions:?})"
2562 );
2563 }
2564
2565 #[test]
2566 fn crap_only_secondary_refactor_respects_configured_band() {
2567 let violation = crate::health_types::ComplexityViolation {
2568 path: std::path::PathBuf::from("src/risk.ts"),
2569 name: "computeScore".to_string(),
2570 line: 12,
2571 col: 0,
2572 cyclomatic: 14,
2573 cognitive: 10,
2574 line_count: 40,
2575 param_count: 0,
2576 exceeded: crate::health_types::ExceededThreshold::Crap,
2577 severity: crate::health_types::FindingSeverity::Moderate,
2578 crap: Some(35.5),
2579 coverage_pct: None,
2580 coverage_tier: Some(crate::health_types::CoverageTier::None),
2581 coverage_source: None,
2582 inherited_from: None,
2583 component_rollup: None,
2584 };
2585 let narrow_ctx = crate::health_types::HealthActionContext {
2586 opts: crate::health_types::HealthActionOptions::default(),
2587 max_cyclomatic_threshold: 20,
2588 max_cognitive_threshold: 15,
2589 max_crap_threshold: 30.0,
2590 crap_refactor_band: 5,
2591 };
2592 let wide_ctx = crate::health_types::HealthActionContext {
2593 crap_refactor_band: 6,
2594 ..narrow_ctx
2595 };
2596
2597 let narrow_actions =
2598 crate::health_types::build_health_finding_actions(&violation, &narrow_ctx);
2599 let wide_actions = crate::health_types::build_health_finding_actions(&violation, &wide_ctx);
2600
2601 assert!(
2602 !narrow_actions.iter().any(|a| {
2603 matches!(
2604 a.kind,
2605 fallow_types::output_health::HealthFindingActionType::RefactorFunction
2606 )
2607 }),
2608 "default band should not refactor a CRAP-only finding 6 below max cyclomatic"
2609 );
2610 assert!(
2611 wide_actions.iter().any(|a| {
2612 matches!(
2613 a.kind,
2614 fallow_types::output_health::HealthFindingActionType::RefactorFunction
2615 )
2616 }),
2617 "configured wider band should emit the secondary refactor action"
2618 );
2619 }
2620
2621 #[test]
2622 fn cyclomatic_only_emits_only_refactor_function() {
2623 let actions = build_actions_for_finding_json(
2624 serde_json::json!({
2625 "path": "src/cyclo.ts",
2626 "name": "branchy",
2627 "line": 5,
2628 "col": 0,
2629 "cyclomatic": 25,
2630 "cognitive": 10,
2631 "line_count": 80,
2632 "exceeded": "cyclomatic",
2633 }),
2634 crate::health_types::HealthActionOptions::default(),
2635 20,
2636 15,
2637 30.0,
2638 );
2639 assert!(
2640 actions.iter().any(|a| a["type"] == "refactor-function"),
2641 "non-CRAP findings emit refactor-function"
2642 );
2643 assert!(
2644 !actions.iter().any(|a| a["type"] == "add-tests"),
2645 "non-CRAP findings must not emit add-tests"
2646 );
2647 assert!(
2648 !actions.iter().any(|a| a["type"] == "increase-coverage"),
2649 "non-CRAP findings must not emit increase-coverage"
2650 );
2651 }
2652
2653 #[test]
2654 fn suppress_line_omitted_when_baseline_active() {
2655 let output = build_finding_envelope_with_ctx(
2656 Some("none"),
2657 6,
2658 12,
2659 20,
2660 15,
2661 30.0,
2662 crate::health_types::HealthActionOptions {
2663 omit_suppress_line: true,
2664 omit_reason: Some("baseline-active"),
2665 },
2666 );
2667 let actions = output["findings"][0]["actions"].as_array().unwrap();
2668 assert!(
2669 !actions.iter().any(|a| a["type"] == "suppress-line"),
2670 "baseline-active must not emit suppress-line, got {actions:?}"
2671 );
2672 assert_eq!(
2673 output["actions_meta"]["suppression_hints_omitted"],
2674 serde_json::Value::Bool(true)
2675 );
2676 assert_eq!(output["actions_meta"]["reason"], "baseline-active");
2677 assert_eq!(output["actions_meta"]["scope"], "health-findings");
2678 }
2679
2680 #[test]
2681 fn suppress_line_omitted_when_config_disabled() {
2682 let output = build_finding_envelope_with_ctx(
2683 Some("none"),
2684 6,
2685 12,
2686 20,
2687 15,
2688 30.0,
2689 crate::health_types::HealthActionOptions {
2690 omit_suppress_line: true,
2691 omit_reason: Some("config-disabled"),
2692 },
2693 );
2694 assert_eq!(output["actions_meta"]["reason"], "config-disabled");
2695 }
2696
2697 #[test]
2698 fn suppress_line_emitted_by_default() {
2699 let output = crap_only_finding_envelope(Some("none"), 6, 20);
2700 let actions = output["findings"][0]["actions"].as_array().unwrap();
2701 assert!(
2702 actions.iter().any(|a| a["type"] == "suppress-line"),
2703 "default opts must emit suppress-line"
2704 );
2705 assert!(
2706 output.get("actions_meta").is_none(),
2707 "actions_meta must be absent when no omission occurred"
2708 );
2709 }
2710
2711 #[test]
2718 fn every_emitted_health_action_type_is_in_schema_enum() {
2719 let cases = [
2720 ("crap", Some("none"), 6_u16, 20_u16),
2721 ("crap", Some("partial"), 6, 20),
2722 ("crap", Some("high"), 12, 20),
2723 ("crap", Some("none"), 16, 20), ("cyclomatic", None, 25, 20),
2725 ("cognitive_crap", Some("partial"), 6, 20),
2726 ("all", Some("none"), 25, 20),
2727 ];
2728
2729 let mut emitted: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2730 for (exceeded, tier, cc, max) in cases {
2731 let mut finding = serde_json::json!({
2732 "path": "src/x.ts",
2733 "name": "fn",
2734 "line": 1,
2735 "col": 0,
2736 "cyclomatic": cc,
2737 "cognitive": 5,
2738 "line_count": 10,
2739 "exceeded": exceeded,
2740 "crap": 35.0,
2741 });
2742 if let Some(t) = tier {
2743 finding["coverage_tier"] = serde_json::Value::String(t.to_owned());
2744 }
2745 let actions = build_actions_for_finding_json(
2746 finding,
2747 crate::health_types::HealthActionOptions::default(),
2748 max,
2749 15,
2750 30.0,
2751 );
2752 for action in &actions {
2753 if let Some(ty) = action["type"].as_str() {
2754 emitted.insert(ty.to_owned());
2755 }
2756 }
2757 }
2758
2759 let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2760 .join("..")
2761 .join("..")
2762 .join("docs")
2763 .join("output-schema.json");
2764 let raw = std::fs::read_to_string(&schema_path)
2765 .expect("docs/output-schema.json must be readable for the drift-guard test");
2766 let schema: serde_json::Value = serde_json::from_str(&raw).expect("schema parses");
2767 let type_field = &schema["definitions"]["HealthFindingAction"]["properties"]["type"];
2768 let type_def = if let Some(reference) = type_field.get("$ref").and_then(|r| r.as_str()) {
2769 let name = reference
2770 .strip_prefix("#/definitions/")
2771 .expect("HealthFindingAction.type $ref points into #/definitions/");
2772 &schema["definitions"][name]
2773 } else {
2774 type_field
2775 };
2776 let mut enum_values: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2777 if let Some(arr) = type_def.get("enum").and_then(|e| e.as_array()) {
2778 for v in arr {
2779 if let Some(s) = v.as_str() {
2780 enum_values.insert(s.to_owned());
2781 }
2782 }
2783 }
2784 if let Some(arr) = type_def.get("oneOf").and_then(|e| e.as_array()) {
2785 for branch in arr {
2786 if let Some(s) = branch.get("const").and_then(|c| c.as_str()) {
2787 enum_values.insert(s.to_owned());
2788 }
2789 }
2790 }
2791 assert!(
2792 !enum_values.is_empty(),
2793 "could not extract HealthFindingActionType variants from schema (neither `enum` nor `oneOf` with `const` branches)"
2794 );
2795
2796 for ty in &emitted {
2797 assert!(
2798 enum_values.contains(ty),
2799 "build_health_finding_actions emitted action type `{ty}` but \
2800 docs/output-schema.json HealthFindingAction.type enum does \
2801 not list it. Add it to the schema (and any downstream \
2802 typed consumers) when introducing a new action type."
2803 );
2804 }
2805 }
2806
2807 #[test]
2821 fn no_new_post_pass_helpers_in_json_rs() {
2822 const POST_PASS_ALLOW_LIST: &[(&str, &str)] = &[];
2823 let source_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2824 .join("src")
2825 .join("report")
2826 .join("json.rs");
2827 let source = std::fs::read_to_string(&source_path).expect(
2828 "crates/cli/src/report/json.rs must be readable for the post-pass drift-guard test",
2829 );
2830 let mut found: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2831 for line in source.lines() {
2832 if let Some(name) = extract_post_pass_fn_name(line) {
2833 found.insert(name.to_owned());
2834 }
2835 }
2836 let allow: std::collections::BTreeSet<&'static str> =
2837 POST_PASS_ALLOW_LIST.iter().map(|(name, _)| *name).collect();
2838 let unexpected: Vec<&str> = found
2839 .iter()
2840 .filter(|name| !allow.contains(name.as_str()))
2841 .map(String::as_str)
2842 .collect();
2843 let stale: Vec<&str> = allow
2844 .iter()
2845 .filter(|name| !found.contains(**name))
2846 .copied()
2847 .collect();
2848 assert!(
2849 unexpected.is_empty(),
2850 "new post-pass helper(s) defined in crates/cli/src/report/json.rs are not in \
2851 POST_PASS_ALLOW_LIST: {unexpected:?}.\n\
2852 The typed `serde(flatten)` envelope is the source of truth for `actions[]` on \
2853 every finding. If a new post-pass is genuinely needed, file a tracking issue, \
2854 add the entry to POST_PASS_ALLOW_LIST with the issue link as the reason, and \
2855 reference the issue in the PR body. See issue #412 for context."
2856 );
2857 assert!(
2858 stale.is_empty(),
2859 "stale entries in POST_PASS_ALLOW_LIST (function no longer defined in \
2860 crates/cli/src/report/json.rs): {stale:?}.\n\
2861 Remove them in the same commit that retired the function."
2862 );
2863 }
2864
2865 fn extract_post_pass_fn_name(line: &str) -> Option<&str> {
2870 let trimmed = line.trim_start();
2871 if trimmed.starts_with("//") {
2872 return None;
2873 }
2874 let mut rest = trimmed;
2875 if let Some(after) = rest.strip_prefix("pub") {
2876 let after = after.trim_start();
2877 rest = if let Some(after) = after.strip_prefix('(') {
2878 let close = after.find(')')?;
2879 after[close + 1..].trim_start()
2880 } else {
2881 after
2882 };
2883 }
2884 for prefix in ["async ", "const ", "unsafe "] {
2885 if let Some(after) = rest.strip_prefix(prefix) {
2886 rest = after.trim_start();
2887 }
2888 }
2889 let after_fn = rest.strip_prefix("fn ")?;
2890 let name_end = after_fn
2891 .find(|c: char| !c.is_alphanumeric() && c != '_')
2892 .unwrap_or(after_fn.len());
2893 let name = &after_fn[..name_end];
2894 if name.starts_with("inject_") || name.starts_with("augment_") {
2895 Some(name)
2896 } else {
2897 None
2898 }
2899 }
2900}