1use std::path::Path;
2use std::process::ExitCode;
3
4use fallow_config::{RulesConfig, Severity};
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::{
7 AnalysisResults, BoundaryViolation, CircularDependency, DuplicateExport, StaleSuppression,
8 TestOnlyDependency, TypeOnlyDependency, UnlistedDependency, UnresolvedImport, UnusedDependency,
9 UnusedExport, UnusedFile, UnusedMember,
10};
11
12use super::grouping::{self, OwnershipResolver};
13use super::{emit_json, relative_uri};
14use crate::explain;
15
16struct SarifFields {
18 rule_id: &'static str,
19 level: &'static str,
20 message: String,
21 uri: String,
22 region: Option<(u32, u32)>,
23 properties: Option<serde_json::Value>,
24}
25
26const fn severity_to_sarif_level(s: Severity) -> &'static str {
27 match s {
28 Severity::Error => "error",
29 Severity::Warn | Severity::Off => "warning",
30 }
31}
32
33fn sarif_result(
38 rule_id: &str,
39 level: &str,
40 message: &str,
41 uri: &str,
42 region: Option<(u32, u32)>,
43) -> serde_json::Value {
44 let mut physical_location = serde_json::json!({
45 "artifactLocation": { "uri": uri }
46 });
47 if let Some((line, col)) = region {
48 physical_location["region"] = serde_json::json!({
49 "startLine": line,
50 "startColumn": col
51 });
52 }
53 serde_json::json!({
54 "ruleId": rule_id,
55 "level": level,
56 "message": { "text": message },
57 "locations": [{ "physicalLocation": physical_location }]
58 })
59}
60
61fn push_sarif_results<T>(
63 sarif_results: &mut Vec<serde_json::Value>,
64 items: &[T],
65 extract: impl Fn(&T) -> SarifFields,
66) {
67 for item in items {
68 let fields = extract(item);
69 let mut result = sarif_result(
70 fields.rule_id,
71 fields.level,
72 &fields.message,
73 &fields.uri,
74 fields.region,
75 );
76 if let Some(props) = fields.properties {
77 result["properties"] = props;
78 }
79 sarif_results.push(result);
80 }
81}
82
83fn sarif_rule(id: &str, fallback_short: &str, level: &str) -> serde_json::Value {
86 explain::rule_by_id(id).map_or_else(
87 || {
88 serde_json::json!({
89 "id": id,
90 "shortDescription": { "text": fallback_short },
91 "defaultConfiguration": { "level": level }
92 })
93 },
94 |def| {
95 serde_json::json!({
96 "id": id,
97 "shortDescription": { "text": def.short },
98 "fullDescription": { "text": def.full },
99 "helpUri": explain::rule_docs_url(def),
100 "defaultConfiguration": { "level": level }
101 })
102 },
103 )
104}
105
106fn sarif_export_fields(
108 export: &UnusedExport,
109 root: &Path,
110 rule_id: &'static str,
111 level: &'static str,
112 kind: &str,
113 re_kind: &str,
114) -> SarifFields {
115 let label = if export.is_re_export { re_kind } else { kind };
116 SarifFields {
117 rule_id,
118 level,
119 message: format!(
120 "{} '{}' is never imported by other modules",
121 label, export.export_name
122 ),
123 uri: relative_uri(&export.path, root),
124 region: Some((export.line, export.col + 1)),
125 properties: if export.is_re_export {
126 Some(serde_json::json!({ "is_re_export": true }))
127 } else {
128 None
129 },
130 }
131}
132
133fn sarif_dep_fields(
135 dep: &UnusedDependency,
136 root: &Path,
137 rule_id: &'static str,
138 level: &'static str,
139 section: &str,
140) -> SarifFields {
141 SarifFields {
142 rule_id,
143 level,
144 message: format!(
145 "Package '{}' is in {} but never imported",
146 dep.package_name, section
147 ),
148 uri: relative_uri(&dep.path, root),
149 region: if dep.line > 0 {
150 Some((dep.line, 1))
151 } else {
152 None
153 },
154 properties: None,
155 }
156}
157
158fn sarif_member_fields(
160 member: &UnusedMember,
161 root: &Path,
162 rule_id: &'static str,
163 level: &'static str,
164 kind: &str,
165) -> SarifFields {
166 SarifFields {
167 rule_id,
168 level,
169 message: format!(
170 "{} member '{}.{}' is never referenced",
171 kind, member.parent_name, member.member_name
172 ),
173 uri: relative_uri(&member.path, root),
174 region: Some((member.line, member.col + 1)),
175 properties: None,
176 }
177}
178
179fn sarif_unused_file_fields(file: &UnusedFile, root: &Path, level: &'static str) -> SarifFields {
180 SarifFields {
181 rule_id: "fallow/unused-file",
182 level,
183 message: "File is not reachable from any entry point".to_string(),
184 uri: relative_uri(&file.path, root),
185 region: None,
186 properties: None,
187 }
188}
189
190fn sarif_type_only_dep_fields(
191 dep: &TypeOnlyDependency,
192 root: &Path,
193 level: &'static str,
194) -> SarifFields {
195 SarifFields {
196 rule_id: "fallow/type-only-dependency",
197 level,
198 message: format!(
199 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
200 dep.package_name
201 ),
202 uri: relative_uri(&dep.path, root),
203 region: if dep.line > 0 {
204 Some((dep.line, 1))
205 } else {
206 None
207 },
208 properties: None,
209 }
210}
211
212fn sarif_test_only_dep_fields(
213 dep: &TestOnlyDependency,
214 root: &Path,
215 level: &'static str,
216) -> SarifFields {
217 SarifFields {
218 rule_id: "fallow/test-only-dependency",
219 level,
220 message: format!(
221 "Package '{}' is only imported by test files (consider moving to devDependencies)",
222 dep.package_name
223 ),
224 uri: relative_uri(&dep.path, root),
225 region: if dep.line > 0 {
226 Some((dep.line, 1))
227 } else {
228 None
229 },
230 properties: None,
231 }
232}
233
234fn sarif_unresolved_import_fields(
235 import: &UnresolvedImport,
236 root: &Path,
237 level: &'static str,
238) -> SarifFields {
239 SarifFields {
240 rule_id: "fallow/unresolved-import",
241 level,
242 message: format!("Import '{}' could not be resolved", import.specifier),
243 uri: relative_uri(&import.path, root),
244 region: Some((import.line, import.col + 1)),
245 properties: None,
246 }
247}
248
249fn sarif_circular_dep_fields(
250 cycle: &CircularDependency,
251 root: &Path,
252 level: &'static str,
253) -> SarifFields {
254 let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
255 let mut display_chain = chain.clone();
256 if let Some(first) = chain.first() {
257 display_chain.push(first.clone());
258 }
259 let first_uri = chain.first().map_or_else(String::new, Clone::clone);
260 SarifFields {
261 rule_id: "fallow/circular-dependency",
262 level,
263 message: format!(
264 "Circular dependency{}: {}",
265 if cycle.is_cross_package {
266 " (cross-package)"
267 } else {
268 ""
269 },
270 display_chain.join(" \u{2192} ")
271 ),
272 uri: first_uri,
273 region: if cycle.line > 0 {
274 Some((cycle.line, cycle.col + 1))
275 } else {
276 None
277 },
278 properties: None,
279 }
280}
281
282fn sarif_boundary_violation_fields(
283 violation: &BoundaryViolation,
284 root: &Path,
285 level: &'static str,
286) -> SarifFields {
287 let from_uri = relative_uri(&violation.from_path, root);
288 let to_uri = relative_uri(&violation.to_path, root);
289 SarifFields {
290 rule_id: "fallow/boundary-violation",
291 level,
292 message: format!(
293 "Import from zone '{}' to zone '{}' is not allowed ({})",
294 violation.from_zone, violation.to_zone, to_uri,
295 ),
296 uri: from_uri,
297 region: if violation.line > 0 {
298 Some((violation.line, violation.col + 1))
299 } else {
300 None
301 },
302 properties: None,
303 }
304}
305
306fn sarif_stale_suppression_fields(
307 suppression: &StaleSuppression,
308 root: &Path,
309 level: &'static str,
310) -> SarifFields {
311 SarifFields {
312 rule_id: "fallow/stale-suppression",
313 level,
314 message: suppression.description(),
315 uri: relative_uri(&suppression.path, root),
316 region: Some((suppression.line, suppression.col + 1)),
317 properties: None,
318 }
319}
320
321fn push_sarif_unlisted_deps(
324 sarif_results: &mut Vec<serde_json::Value>,
325 deps: &[UnlistedDependency],
326 root: &Path,
327 level: &'static str,
328) {
329 for dep in deps {
330 for site in &dep.imported_from {
331 sarif_results.push(sarif_result(
332 "fallow/unlisted-dependency",
333 level,
334 &format!(
335 "Package '{}' is imported but not listed in package.json",
336 dep.package_name
337 ),
338 &relative_uri(&site.path, root),
339 Some((site.line, site.col + 1)),
340 ));
341 }
342 }
343}
344
345fn push_sarif_duplicate_exports(
348 sarif_results: &mut Vec<serde_json::Value>,
349 dups: &[DuplicateExport],
350 root: &Path,
351 level: &'static str,
352) {
353 for dup in dups {
354 for loc in &dup.locations {
355 sarif_results.push(sarif_result(
356 "fallow/duplicate-export",
357 level,
358 &format!("Export '{}' appears in multiple modules", dup.export_name),
359 &relative_uri(&loc.path, root),
360 Some((loc.line, loc.col + 1)),
361 ));
362 }
363 }
364}
365
366fn build_sarif_rules(rules: &RulesConfig) -> Vec<serde_json::Value> {
368 vec![
369 sarif_rule(
370 "fallow/unused-file",
371 "File is not reachable from any entry point",
372 severity_to_sarif_level(rules.unused_files),
373 ),
374 sarif_rule(
375 "fallow/unused-export",
376 "Export is never imported",
377 severity_to_sarif_level(rules.unused_exports),
378 ),
379 sarif_rule(
380 "fallow/unused-type",
381 "Type export is never imported",
382 severity_to_sarif_level(rules.unused_types),
383 ),
384 sarif_rule(
385 "fallow/unused-dependency",
386 "Dependency listed but never imported",
387 severity_to_sarif_level(rules.unused_dependencies),
388 ),
389 sarif_rule(
390 "fallow/unused-dev-dependency",
391 "Dev dependency listed but never imported",
392 severity_to_sarif_level(rules.unused_dev_dependencies),
393 ),
394 sarif_rule(
395 "fallow/unused-optional-dependency",
396 "Optional dependency listed but never imported",
397 severity_to_sarif_level(rules.unused_optional_dependencies),
398 ),
399 sarif_rule(
400 "fallow/type-only-dependency",
401 "Production dependency only used via type-only imports",
402 severity_to_sarif_level(rules.type_only_dependencies),
403 ),
404 sarif_rule(
405 "fallow/test-only-dependency",
406 "Production dependency only imported by test files",
407 severity_to_sarif_level(rules.test_only_dependencies),
408 ),
409 sarif_rule(
410 "fallow/unused-enum-member",
411 "Enum member is never referenced",
412 severity_to_sarif_level(rules.unused_enum_members),
413 ),
414 sarif_rule(
415 "fallow/unused-class-member",
416 "Class member is never referenced",
417 severity_to_sarif_level(rules.unused_class_members),
418 ),
419 sarif_rule(
420 "fallow/unresolved-import",
421 "Import could not be resolved",
422 severity_to_sarif_level(rules.unresolved_imports),
423 ),
424 sarif_rule(
425 "fallow/unlisted-dependency",
426 "Dependency used but not in package.json",
427 severity_to_sarif_level(rules.unlisted_dependencies),
428 ),
429 sarif_rule(
430 "fallow/duplicate-export",
431 "Export name appears in multiple modules",
432 severity_to_sarif_level(rules.duplicate_exports),
433 ),
434 sarif_rule(
435 "fallow/circular-dependency",
436 "Circular dependency chain detected",
437 severity_to_sarif_level(rules.circular_dependencies),
438 ),
439 sarif_rule(
440 "fallow/boundary-violation",
441 "Import crosses an architecture boundary",
442 severity_to_sarif_level(rules.boundary_violation),
443 ),
444 sarif_rule(
445 "fallow/stale-suppression",
446 "Suppression comment or tag no longer matches any issue",
447 severity_to_sarif_level(rules.stale_suppressions),
448 ),
449 ]
450}
451
452#[must_use]
453pub fn build_sarif(
454 results: &AnalysisResults,
455 root: &Path,
456 rules: &RulesConfig,
457) -> serde_json::Value {
458 let mut sarif_results = Vec::new();
459 let lvl_files = severity_to_sarif_level(rules.unused_files);
460 let lvl_exports = severity_to_sarif_level(rules.unused_exports);
461 let lvl_types = severity_to_sarif_level(rules.unused_types);
462 let lvl_deps = severity_to_sarif_level(rules.unused_dependencies);
463 let lvl_dev_deps = severity_to_sarif_level(rules.unused_dev_dependencies);
464 let lvl_opt_deps = severity_to_sarif_level(rules.unused_optional_dependencies);
465 let lvl_type_only = severity_to_sarif_level(rules.type_only_dependencies);
466 let lvl_test_only = severity_to_sarif_level(rules.test_only_dependencies);
467 let lvl_enum_members = severity_to_sarif_level(rules.unused_enum_members);
468 let lvl_class_members = severity_to_sarif_level(rules.unused_class_members);
469 let lvl_unresolved = severity_to_sarif_level(rules.unresolved_imports);
470 let lvl_unlisted = severity_to_sarif_level(rules.unlisted_dependencies);
471 let lvl_duplicate = severity_to_sarif_level(rules.duplicate_exports);
472 let lvl_circular = severity_to_sarif_level(rules.circular_dependencies);
473 let lvl_boundary = severity_to_sarif_level(rules.boundary_violation);
474 let lvl_stale = severity_to_sarif_level(rules.stale_suppressions);
475
476 push_sarif_results(&mut sarif_results, &results.unused_files, |f| {
477 sarif_unused_file_fields(f, root, lvl_files)
478 });
479 push_sarif_results(&mut sarif_results, &results.unused_exports, |e| {
480 sarif_export_fields(
481 e,
482 root,
483 "fallow/unused-export",
484 lvl_exports,
485 "Export",
486 "Re-export",
487 )
488 });
489 push_sarif_results(&mut sarif_results, &results.unused_types, |e| {
490 sarif_export_fields(
491 e,
492 root,
493 "fallow/unused-type",
494 lvl_types,
495 "Type export",
496 "Type re-export",
497 )
498 });
499 push_sarif_results(&mut sarif_results, &results.unused_dependencies, |d| {
500 sarif_dep_fields(
501 d,
502 root,
503 "fallow/unused-dependency",
504 lvl_deps,
505 "dependencies",
506 )
507 });
508 push_sarif_results(&mut sarif_results, &results.unused_dev_dependencies, |d| {
509 sarif_dep_fields(
510 d,
511 root,
512 "fallow/unused-dev-dependency",
513 lvl_dev_deps,
514 "devDependencies",
515 )
516 });
517 push_sarif_results(
518 &mut sarif_results,
519 &results.unused_optional_dependencies,
520 |d| {
521 sarif_dep_fields(
522 d,
523 root,
524 "fallow/unused-optional-dependency",
525 lvl_opt_deps,
526 "optionalDependencies",
527 )
528 },
529 );
530 push_sarif_results(&mut sarif_results, &results.type_only_dependencies, |d| {
531 sarif_type_only_dep_fields(d, root, lvl_type_only)
532 });
533 push_sarif_results(&mut sarif_results, &results.test_only_dependencies, |d| {
534 sarif_test_only_dep_fields(d, root, lvl_test_only)
535 });
536 push_sarif_results(&mut sarif_results, &results.unused_enum_members, |m| {
537 sarif_member_fields(
538 m,
539 root,
540 "fallow/unused-enum-member",
541 lvl_enum_members,
542 "Enum",
543 )
544 });
545 push_sarif_results(&mut sarif_results, &results.unused_class_members, |m| {
546 sarif_member_fields(
547 m,
548 root,
549 "fallow/unused-class-member",
550 lvl_class_members,
551 "Class",
552 )
553 });
554 push_sarif_results(&mut sarif_results, &results.unresolved_imports, |i| {
555 sarif_unresolved_import_fields(i, root, lvl_unresolved)
556 });
557 push_sarif_unlisted_deps(
558 &mut sarif_results,
559 &results.unlisted_dependencies,
560 root,
561 lvl_unlisted,
562 );
563 push_sarif_duplicate_exports(
564 &mut sarif_results,
565 &results.duplicate_exports,
566 root,
567 lvl_duplicate,
568 );
569 push_sarif_results(&mut sarif_results, &results.circular_dependencies, |c| {
570 sarif_circular_dep_fields(c, root, lvl_circular)
571 });
572 push_sarif_results(&mut sarif_results, &results.boundary_violations, |v| {
573 sarif_boundary_violation_fields(v, root, lvl_boundary)
574 });
575 push_sarif_results(&mut sarif_results, &results.stale_suppressions, |s| {
576 sarif_stale_suppression_fields(s, root, lvl_stale)
577 });
578
579 serde_json::json!({
580 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
581 "version": "2.1.0",
582 "runs": [{
583 "tool": {
584 "driver": {
585 "name": "fallow",
586 "version": env!("CARGO_PKG_VERSION"),
587 "informationUri": "https://github.com/fallow-rs/fallow",
588 "rules": build_sarif_rules(rules)
589 }
590 },
591 "results": sarif_results
592 }]
593 })
594}
595
596pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
597 let sarif = build_sarif(results, root, rules);
598 emit_json(&sarif, "SARIF")
599}
600
601pub(super) fn print_grouped_sarif(
607 results: &AnalysisResults,
608 root: &Path,
609 rules: &RulesConfig,
610 resolver: &OwnershipResolver,
611) -> ExitCode {
612 let mut sarif = build_sarif(results, root, rules);
613
614 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
616 for run in runs {
617 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
618 for result in results {
619 let uri = result
620 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
621 .and_then(|v| v.as_str())
622 .unwrap_or("");
623 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
626 let owner =
627 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
628 let props = result
629 .as_object_mut()
630 .expect("SARIF result should be an object")
631 .entry("properties")
632 .or_insert_with(|| serde_json::json!({}));
633 props
634 .as_object_mut()
635 .expect("properties should be an object")
636 .insert("owner".to_string(), serde_json::Value::String(owner));
637 }
638 }
639 }
640 }
641
642 emit_json(&sarif, "SARIF")
643}
644
645#[expect(
646 clippy::cast_possible_truncation,
647 reason = "line/col numbers are bounded by source size"
648)]
649pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
650 let mut sarif_results = Vec::new();
651
652 for (i, group) in report.clone_groups.iter().enumerate() {
653 for instance in &group.instances {
654 sarif_results.push(sarif_result(
655 "fallow/code-duplication",
656 "warning",
657 &format!(
658 "Code clone group {} ({} lines, {} instances)",
659 i + 1,
660 group.line_count,
661 group.instances.len()
662 ),
663 &relative_uri(&instance.file, root),
664 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
665 ));
666 }
667 }
668
669 let sarif = serde_json::json!({
670 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
671 "version": "2.1.0",
672 "runs": [{
673 "tool": {
674 "driver": {
675 "name": "fallow",
676 "version": env!("CARGO_PKG_VERSION"),
677 "informationUri": "https://github.com/fallow-rs/fallow",
678 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
679 }
680 },
681 "results": sarif_results
682 }]
683 });
684
685 emit_json(&sarif, "SARIF")
686}
687
688#[must_use]
694#[expect(
695 clippy::too_many_lines,
696 reason = "flat rules + results table: adding runtime-coverage rules pushed past the 150 line threshold but each section is a straightforward sequence of sarif_rule / sarif_result calls"
697)]
698pub fn build_health_sarif(
699 report: &crate::health_types::HealthReport,
700 root: &Path,
701) -> serde_json::Value {
702 use crate::health_types::ExceededThreshold;
703
704 let mut sarif_results = Vec::new();
705
706 for finding in &report.findings {
707 let uri = relative_uri(&finding.path, root);
708 let (rule_id, message) = match finding.exceeded {
712 ExceededThreshold::Cyclomatic => (
713 "fallow/high-cyclomatic-complexity",
714 format!(
715 "'{}' has cyclomatic complexity {} (threshold: {})",
716 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
717 ),
718 ),
719 ExceededThreshold::Cognitive => (
720 "fallow/high-cognitive-complexity",
721 format!(
722 "'{}' has cognitive complexity {} (threshold: {})",
723 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
724 ),
725 ),
726 ExceededThreshold::Both => (
727 "fallow/high-complexity",
728 format!(
729 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
730 finding.name,
731 finding.cyclomatic,
732 report.summary.max_cyclomatic_threshold,
733 finding.cognitive,
734 report.summary.max_cognitive_threshold,
735 ),
736 ),
737 ExceededThreshold::Crap
738 | ExceededThreshold::CyclomaticCrap
739 | ExceededThreshold::CognitiveCrap
740 | ExceededThreshold::All => {
741 let crap = finding.crap.unwrap_or(0.0);
742 let coverage = finding
743 .coverage_pct
744 .map(|pct| format!(", coverage {pct:.0}%"))
745 .unwrap_or_default();
746 (
747 "fallow/high-crap-score",
748 format!(
749 "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
750 finding.name,
751 crap,
752 report.summary.max_crap_threshold,
753 finding.cyclomatic,
754 coverage,
755 ),
756 )
757 }
758 };
759
760 let level = match finding.severity {
761 crate::health_types::FindingSeverity::Critical => "error",
762 crate::health_types::FindingSeverity::High => "warning",
763 crate::health_types::FindingSeverity::Moderate => "note",
764 };
765 sarif_results.push(sarif_result(
766 rule_id,
767 level,
768 &message,
769 &uri,
770 Some((finding.line, finding.col + 1)),
771 ));
772 }
773
774 if let Some(ref production) = report.runtime_coverage {
775 append_runtime_coverage_sarif_results(&mut sarif_results, production, root);
776 }
777
778 for target in &report.targets {
780 let uri = relative_uri(&target.path, root);
781 let message = format!(
782 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
783 target.category.label(),
784 target.recommendation,
785 target.priority,
786 target.efficiency,
787 target.effort.label(),
788 target.confidence.label(),
789 );
790 sarif_results.push(sarif_result(
791 "fallow/refactoring-target",
792 "warning",
793 &message,
794 &uri,
795 None,
796 ));
797 }
798
799 if let Some(ref gaps) = report.coverage_gaps {
800 for item in &gaps.files {
801 let uri = relative_uri(&item.path, root);
802 let message = format!(
803 "File is runtime-reachable but has no test dependency path ({} value export{})",
804 item.value_export_count,
805 if item.value_export_count == 1 {
806 ""
807 } else {
808 "s"
809 },
810 );
811 sarif_results.push(sarif_result(
812 "fallow/untested-file",
813 "warning",
814 &message,
815 &uri,
816 None,
817 ));
818 }
819
820 for item in &gaps.exports {
821 let uri = relative_uri(&item.path, root);
822 let message = format!(
823 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
824 item.export_name
825 );
826 sarif_results.push(sarif_result(
827 "fallow/untested-export",
828 "warning",
829 &message,
830 &uri,
831 Some((item.line, item.col + 1)),
832 ));
833 }
834 }
835
836 let health_rules = vec![
837 sarif_rule(
838 "fallow/high-cyclomatic-complexity",
839 "Function has high cyclomatic complexity",
840 "note",
841 ),
842 sarif_rule(
843 "fallow/high-cognitive-complexity",
844 "Function has high cognitive complexity",
845 "note",
846 ),
847 sarif_rule(
848 "fallow/high-complexity",
849 "Function exceeds both complexity thresholds",
850 "note",
851 ),
852 sarif_rule(
853 "fallow/high-crap-score",
854 "Function has a high CRAP score (high complexity combined with low coverage)",
855 "warning",
856 ),
857 sarif_rule(
858 "fallow/refactoring-target",
859 "File identified as a high-priority refactoring candidate",
860 "warning",
861 ),
862 sarif_rule(
863 "fallow/untested-file",
864 "Runtime-reachable file has no test dependency path",
865 "warning",
866 ),
867 sarif_rule(
868 "fallow/untested-export",
869 "Runtime-reachable export has no test dependency path",
870 "warning",
871 ),
872 sarif_rule(
873 "fallow/runtime-safe-to-delete",
874 "Function is statically unused and was never invoked in production",
875 "warning",
876 ),
877 sarif_rule(
878 "fallow/runtime-review-required",
879 "Function is statically used but was never invoked in production",
880 "warning",
881 ),
882 sarif_rule(
883 "fallow/runtime-low-traffic",
884 "Function was invoked below the low-traffic threshold relative to total trace count",
885 "note",
886 ),
887 sarif_rule(
888 "fallow/runtime-coverage-unavailable",
889 "Runtime coverage could not be resolved for this function",
890 "note",
891 ),
892 sarif_rule(
893 "fallow/runtime-coverage",
894 "Runtime coverage finding",
895 "note",
896 ),
897 ];
898
899 serde_json::json!({
900 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
901 "version": "2.1.0",
902 "runs": [{
903 "tool": {
904 "driver": {
905 "name": "fallow",
906 "version": env!("CARGO_PKG_VERSION"),
907 "informationUri": "https://github.com/fallow-rs/fallow",
908 "rules": health_rules
909 }
910 },
911 "results": sarif_results
912 }]
913 })
914}
915
916fn append_runtime_coverage_sarif_results(
917 sarif_results: &mut Vec<serde_json::Value>,
918 production: &crate::health_types::RuntimeCoverageReport,
919 root: &Path,
920) {
921 for finding in &production.findings {
922 let uri = relative_uri(&finding.path, root);
923 let rule_id = match finding.verdict {
924 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
925 "fallow/runtime-safe-to-delete"
926 }
927 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
928 "fallow/runtime-review-required"
929 }
930 crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
931 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
932 "fallow/runtime-coverage-unavailable"
933 }
934 crate::health_types::RuntimeCoverageVerdict::Active
935 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
936 };
937 let level = match finding.verdict {
938 crate::health_types::RuntimeCoverageVerdict::SafeToDelete
939 | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
940 _ => "note",
941 };
942 let invocations_hint = finding.invocations.map_or_else(
943 || "untracked".to_owned(),
944 |hits| format!("{hits} invocations"),
945 );
946 let message = format!(
947 "'{}' runtime coverage verdict: {} ({})",
948 finding.function,
949 finding.verdict.human_label(),
950 invocations_hint,
951 );
952 sarif_results.push(sarif_result(
953 rule_id,
954 level,
955 &message,
956 &uri,
957 Some((finding.line, 1)),
958 ));
959 }
960}
961
962pub(super) fn print_health_sarif(
963 report: &crate::health_types::HealthReport,
964 root: &Path,
965) -> ExitCode {
966 let sarif = build_health_sarif(report, root);
967 emit_json(&sarif, "SARIF")
968}
969
970pub(super) fn print_grouped_health_sarif(
981 report: &crate::health_types::HealthReport,
982 root: &Path,
983 resolver: &OwnershipResolver,
984) -> ExitCode {
985 let mut sarif = build_health_sarif(report, root);
986
987 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
988 for run in runs {
989 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
990 for result in results {
991 let uri = result
992 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
993 .and_then(|v| v.as_str())
994 .unwrap_or("");
995 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
996 let group =
997 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
998 let props = result
999 .as_object_mut()
1000 .expect("SARIF result should be an object")
1001 .entry("properties")
1002 .or_insert_with(|| serde_json::json!({}));
1003 props
1004 .as_object_mut()
1005 .expect("properties should be an object")
1006 .insert("group".to_string(), serde_json::Value::String(group));
1007 }
1008 }
1009 }
1010 }
1011
1012 emit_json(&sarif, "SARIF")
1013}
1014
1015#[cfg(test)]
1016mod tests {
1017 use super::*;
1018 use crate::report::test_helpers::sample_results;
1019 use fallow_core::results::*;
1020 use std::path::PathBuf;
1021
1022 #[test]
1023 fn sarif_has_required_top_level_fields() {
1024 let root = PathBuf::from("/project");
1025 let results = AnalysisResults::default();
1026 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1027
1028 assert_eq!(
1029 sarif["$schema"],
1030 "https://json.schemastore.org/sarif-2.1.0.json"
1031 );
1032 assert_eq!(sarif["version"], "2.1.0");
1033 assert!(sarif["runs"].is_array());
1034 }
1035
1036 #[test]
1037 fn sarif_has_tool_driver_info() {
1038 let root = PathBuf::from("/project");
1039 let results = AnalysisResults::default();
1040 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1041
1042 let driver = &sarif["runs"][0]["tool"]["driver"];
1043 assert_eq!(driver["name"], "fallow");
1044 assert!(driver["version"].is_string());
1045 assert_eq!(
1046 driver["informationUri"],
1047 "https://github.com/fallow-rs/fallow"
1048 );
1049 }
1050
1051 #[test]
1052 fn sarif_declares_all_rules() {
1053 let root = PathBuf::from("/project");
1054 let results = AnalysisResults::default();
1055 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1056
1057 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1058 .as_array()
1059 .expect("rules should be an array");
1060 assert_eq!(rules.len(), 16);
1061
1062 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
1063 assert!(rule_ids.contains(&"fallow/unused-file"));
1064 assert!(rule_ids.contains(&"fallow/unused-export"));
1065 assert!(rule_ids.contains(&"fallow/unused-type"));
1066 assert!(rule_ids.contains(&"fallow/unused-dependency"));
1067 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1068 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1069 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1070 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1071 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1072 assert!(rule_ids.contains(&"fallow/unused-class-member"));
1073 assert!(rule_ids.contains(&"fallow/unresolved-import"));
1074 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1075 assert!(rule_ids.contains(&"fallow/duplicate-export"));
1076 assert!(rule_ids.contains(&"fallow/circular-dependency"));
1077 assert!(rule_ids.contains(&"fallow/boundary-violation"));
1078 }
1079
1080 #[test]
1081 fn sarif_empty_results_no_results_entries() {
1082 let root = PathBuf::from("/project");
1083 let results = AnalysisResults::default();
1084 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1085
1086 let sarif_results = sarif["runs"][0]["results"]
1087 .as_array()
1088 .expect("results should be an array");
1089 assert!(sarif_results.is_empty());
1090 }
1091
1092 #[test]
1093 fn sarif_unused_file_result() {
1094 let root = PathBuf::from("/project");
1095 let mut results = AnalysisResults::default();
1096 results.unused_files.push(UnusedFile {
1097 path: root.join("src/dead.ts"),
1098 });
1099
1100 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1101 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1102 assert_eq!(entries.len(), 1);
1103
1104 let entry = &entries[0];
1105 assert_eq!(entry["ruleId"], "fallow/unused-file");
1106 assert_eq!(entry["level"], "error");
1108 assert_eq!(
1109 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1110 "src/dead.ts"
1111 );
1112 }
1113
1114 #[test]
1115 fn sarif_unused_export_includes_region() {
1116 let root = PathBuf::from("/project");
1117 let mut results = AnalysisResults::default();
1118 results.unused_exports.push(UnusedExport {
1119 path: root.join("src/utils.ts"),
1120 export_name: "helperFn".to_string(),
1121 is_type_only: false,
1122 line: 10,
1123 col: 4,
1124 span_start: 120,
1125 is_re_export: false,
1126 });
1127
1128 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1129 let entry = &sarif["runs"][0]["results"][0];
1130 assert_eq!(entry["ruleId"], "fallow/unused-export");
1131
1132 let region = &entry["locations"][0]["physicalLocation"]["region"];
1133 assert_eq!(region["startLine"], 10);
1134 assert_eq!(region["startColumn"], 5);
1136 }
1137
1138 #[test]
1139 fn sarif_unresolved_import_is_error_level() {
1140 let root = PathBuf::from("/project");
1141 let mut results = AnalysisResults::default();
1142 results.unresolved_imports.push(UnresolvedImport {
1143 path: root.join("src/app.ts"),
1144 specifier: "./missing".to_string(),
1145 line: 1,
1146 col: 0,
1147 specifier_col: 0,
1148 });
1149
1150 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1151 let entry = &sarif["runs"][0]["results"][0];
1152 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
1153 assert_eq!(entry["level"], "error");
1154 }
1155
1156 #[test]
1157 fn sarif_unlisted_dependency_points_to_import_site() {
1158 let root = PathBuf::from("/project");
1159 let mut results = AnalysisResults::default();
1160 results.unlisted_dependencies.push(UnlistedDependency {
1161 package_name: "chalk".to_string(),
1162 imported_from: vec![ImportSite {
1163 path: root.join("src/cli.ts"),
1164 line: 3,
1165 col: 0,
1166 }],
1167 });
1168
1169 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1170 let entry = &sarif["runs"][0]["results"][0];
1171 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
1172 assert_eq!(entry["level"], "error");
1173 assert_eq!(
1174 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1175 "src/cli.ts"
1176 );
1177 let region = &entry["locations"][0]["physicalLocation"]["region"];
1178 assert_eq!(region["startLine"], 3);
1179 assert_eq!(region["startColumn"], 1);
1180 }
1181
1182 #[test]
1183 fn sarif_dependency_issues_point_to_package_json() {
1184 let root = PathBuf::from("/project");
1185 let mut results = AnalysisResults::default();
1186 results.unused_dependencies.push(UnusedDependency {
1187 package_name: "lodash".to_string(),
1188 location: DependencyLocation::Dependencies,
1189 path: root.join("package.json"),
1190 line: 5,
1191 });
1192 results.unused_dev_dependencies.push(UnusedDependency {
1193 package_name: "jest".to_string(),
1194 location: DependencyLocation::DevDependencies,
1195 path: root.join("package.json"),
1196 line: 5,
1197 });
1198
1199 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1200 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1201 for entry in entries {
1202 assert_eq!(
1203 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1204 "package.json"
1205 );
1206 }
1207 }
1208
1209 #[test]
1210 fn sarif_duplicate_export_emits_one_result_per_location() {
1211 let root = PathBuf::from("/project");
1212 let mut results = AnalysisResults::default();
1213 results.duplicate_exports.push(DuplicateExport {
1214 export_name: "Config".to_string(),
1215 locations: vec![
1216 DuplicateLocation {
1217 path: root.join("src/a.ts"),
1218 line: 15,
1219 col: 0,
1220 },
1221 DuplicateLocation {
1222 path: root.join("src/b.ts"),
1223 line: 30,
1224 col: 0,
1225 },
1226 ],
1227 });
1228
1229 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1230 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1231 assert_eq!(entries.len(), 2);
1233 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
1234 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
1235 assert_eq!(
1236 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1237 "src/a.ts"
1238 );
1239 assert_eq!(
1240 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1241 "src/b.ts"
1242 );
1243 }
1244
1245 #[test]
1246 fn sarif_all_issue_types_produce_results() {
1247 let root = PathBuf::from("/project");
1248 let results = sample_results(&root);
1249 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1250
1251 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1252 assert_eq!(entries.len(), results.total_issues() + 1);
1254
1255 let rule_ids: Vec<&str> = entries
1256 .iter()
1257 .map(|e| e["ruleId"].as_str().unwrap())
1258 .collect();
1259 assert!(rule_ids.contains(&"fallow/unused-file"));
1260 assert!(rule_ids.contains(&"fallow/unused-export"));
1261 assert!(rule_ids.contains(&"fallow/unused-type"));
1262 assert!(rule_ids.contains(&"fallow/unused-dependency"));
1263 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1264 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1265 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1266 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1267 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1268 assert!(rule_ids.contains(&"fallow/unused-class-member"));
1269 assert!(rule_ids.contains(&"fallow/unresolved-import"));
1270 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1271 assert!(rule_ids.contains(&"fallow/duplicate-export"));
1272 }
1273
1274 #[test]
1275 fn sarif_serializes_to_valid_json() {
1276 let root = PathBuf::from("/project");
1277 let results = sample_results(&root);
1278 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1279
1280 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1281 let reparsed: serde_json::Value =
1282 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
1283 assert_eq!(reparsed, sarif);
1284 }
1285
1286 #[test]
1287 fn sarif_file_write_produces_valid_sarif() {
1288 let root = PathBuf::from("/project");
1289 let results = sample_results(&root);
1290 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1291 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1292
1293 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
1294 let _ = std::fs::create_dir_all(&dir);
1295 let sarif_path = dir.join("results.sarif");
1296 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
1297
1298 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
1299 let parsed: serde_json::Value =
1300 serde_json::from_str(&contents).expect("file should contain valid JSON");
1301
1302 assert_eq!(parsed["version"], "2.1.0");
1303 assert_eq!(
1304 parsed["$schema"],
1305 "https://json.schemastore.org/sarif-2.1.0.json"
1306 );
1307 let sarif_results = parsed["runs"][0]["results"]
1308 .as_array()
1309 .expect("results should be an array");
1310 assert!(!sarif_results.is_empty());
1311
1312 let _ = std::fs::remove_file(&sarif_path);
1314 let _ = std::fs::remove_dir(&dir);
1315 }
1316
1317 #[test]
1320 fn health_sarif_empty_no_results() {
1321 let root = PathBuf::from("/project");
1322 let report = crate::health_types::HealthReport {
1323 summary: crate::health_types::HealthSummary {
1324 files_analyzed: 10,
1325 functions_analyzed: 50,
1326 ..Default::default()
1327 },
1328 ..Default::default()
1329 };
1330 let sarif = build_health_sarif(&report, &root);
1331 assert_eq!(sarif["version"], "2.1.0");
1332 let results = sarif["runs"][0]["results"].as_array().unwrap();
1333 assert!(results.is_empty());
1334 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1335 .as_array()
1336 .unwrap();
1337 assert_eq!(rules.len(), 12);
1338 }
1339
1340 #[test]
1341 fn health_sarif_cyclomatic_only() {
1342 let root = PathBuf::from("/project");
1343 let report = crate::health_types::HealthReport {
1344 findings: vec![crate::health_types::HealthFinding {
1345 path: root.join("src/utils.ts"),
1346 name: "parseExpression".to_string(),
1347 line: 42,
1348 col: 0,
1349 cyclomatic: 25,
1350 cognitive: 10,
1351 line_count: 80,
1352 param_count: 0,
1353 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
1354 severity: crate::health_types::FindingSeverity::High,
1355 crap: None,
1356 coverage_pct: None,
1357 coverage_tier: None,
1358 }],
1359 summary: crate::health_types::HealthSummary {
1360 files_analyzed: 5,
1361 functions_analyzed: 20,
1362 functions_above_threshold: 1,
1363 ..Default::default()
1364 },
1365 ..Default::default()
1366 };
1367 let sarif = build_health_sarif(&report, &root);
1368 let entry = &sarif["runs"][0]["results"][0];
1369 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
1370 assert_eq!(entry["level"], "warning");
1371 assert!(
1372 entry["message"]["text"]
1373 .as_str()
1374 .unwrap()
1375 .contains("cyclomatic complexity 25")
1376 );
1377 assert_eq!(
1378 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1379 "src/utils.ts"
1380 );
1381 let region = &entry["locations"][0]["physicalLocation"]["region"];
1382 assert_eq!(region["startLine"], 42);
1383 assert_eq!(region["startColumn"], 1);
1384 }
1385
1386 #[test]
1387 fn health_sarif_cognitive_only() {
1388 let root = PathBuf::from("/project");
1389 let report = crate::health_types::HealthReport {
1390 findings: vec![crate::health_types::HealthFinding {
1391 path: root.join("src/api.ts"),
1392 name: "handleRequest".to_string(),
1393 line: 10,
1394 col: 4,
1395 cyclomatic: 8,
1396 cognitive: 20,
1397 line_count: 40,
1398 param_count: 0,
1399 exceeded: crate::health_types::ExceededThreshold::Cognitive,
1400 severity: crate::health_types::FindingSeverity::High,
1401 crap: None,
1402 coverage_pct: None,
1403 coverage_tier: None,
1404 }],
1405 summary: crate::health_types::HealthSummary {
1406 files_analyzed: 3,
1407 functions_analyzed: 10,
1408 functions_above_threshold: 1,
1409 ..Default::default()
1410 },
1411 ..Default::default()
1412 };
1413 let sarif = build_health_sarif(&report, &root);
1414 let entry = &sarif["runs"][0]["results"][0];
1415 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
1416 assert!(
1417 entry["message"]["text"]
1418 .as_str()
1419 .unwrap()
1420 .contains("cognitive complexity 20")
1421 );
1422 let region = &entry["locations"][0]["physicalLocation"]["region"];
1423 assert_eq!(region["startColumn"], 5); }
1425
1426 #[test]
1427 fn health_sarif_both_thresholds() {
1428 let root = PathBuf::from("/project");
1429 let report = crate::health_types::HealthReport {
1430 findings: vec![crate::health_types::HealthFinding {
1431 path: root.join("src/complex.ts"),
1432 name: "doEverything".to_string(),
1433 line: 1,
1434 col: 0,
1435 cyclomatic: 30,
1436 cognitive: 45,
1437 line_count: 100,
1438 param_count: 0,
1439 exceeded: crate::health_types::ExceededThreshold::Both,
1440 severity: crate::health_types::FindingSeverity::High,
1441 crap: None,
1442 coverage_pct: None,
1443 coverage_tier: None,
1444 }],
1445 summary: crate::health_types::HealthSummary {
1446 files_analyzed: 1,
1447 functions_analyzed: 1,
1448 functions_above_threshold: 1,
1449 ..Default::default()
1450 },
1451 ..Default::default()
1452 };
1453 let sarif = build_health_sarif(&report, &root);
1454 let entry = &sarif["runs"][0]["results"][0];
1455 assert_eq!(entry["ruleId"], "fallow/high-complexity");
1456 let msg = entry["message"]["text"].as_str().unwrap();
1457 assert!(msg.contains("cyclomatic complexity 30"));
1458 assert!(msg.contains("cognitive complexity 45"));
1459 }
1460
1461 #[test]
1462 fn health_sarif_crap_only_emits_crap_rule() {
1463 let root = PathBuf::from("/project");
1466 let report = crate::health_types::HealthReport {
1467 findings: vec![crate::health_types::HealthFinding {
1468 path: root.join("src/untested.ts"),
1469 name: "risky".to_string(),
1470 line: 8,
1471 col: 0,
1472 cyclomatic: 10,
1473 cognitive: 10,
1474 line_count: 20,
1475 param_count: 1,
1476 exceeded: crate::health_types::ExceededThreshold::Crap,
1477 severity: crate::health_types::FindingSeverity::High,
1478 crap: Some(82.2),
1479 coverage_pct: Some(12.0),
1480 coverage_tier: None,
1481 }],
1482 summary: crate::health_types::HealthSummary {
1483 files_analyzed: 1,
1484 functions_analyzed: 1,
1485 functions_above_threshold: 1,
1486 ..Default::default()
1487 },
1488 ..Default::default()
1489 };
1490 let sarif = build_health_sarif(&report, &root);
1491 let entry = &sarif["runs"][0]["results"][0];
1492 assert_eq!(entry["ruleId"], "fallow/high-crap-score");
1493 let msg = entry["message"]["text"].as_str().unwrap();
1494 assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
1495 assert!(msg.contains("coverage 12%"), "msg: {msg}");
1496 }
1497
1498 #[test]
1499 fn health_sarif_cyclomatic_crap_uses_crap_rule() {
1500 let root = PathBuf::from("/project");
1503 let report = crate::health_types::HealthReport {
1504 findings: vec![crate::health_types::HealthFinding {
1505 path: root.join("src/hot.ts"),
1506 name: "branchy".to_string(),
1507 line: 1,
1508 col: 0,
1509 cyclomatic: 67,
1510 cognitive: 12,
1511 line_count: 80,
1512 param_count: 1,
1513 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
1514 severity: crate::health_types::FindingSeverity::Critical,
1515 crap: Some(182.0),
1516 coverage_pct: None,
1517 coverage_tier: None,
1518 }],
1519 summary: crate::health_types::HealthSummary {
1520 files_analyzed: 1,
1521 functions_analyzed: 1,
1522 functions_above_threshold: 1,
1523 ..Default::default()
1524 },
1525 ..Default::default()
1526 };
1527 let sarif = build_health_sarif(&report, &root);
1528 let results = sarif["runs"][0]["results"].as_array().unwrap();
1529 assert_eq!(
1530 results.len(),
1531 1,
1532 "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
1533 );
1534 assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
1535 let msg = results[0]["message"]["text"].as_str().unwrap();
1536 assert!(msg.contains("CRAP score 182"), "msg: {msg}");
1537 assert!(!msg.contains("coverage"), "msg: {msg}");
1539 }
1540
1541 #[test]
1544 fn severity_to_sarif_level_error() {
1545 assert_eq!(severity_to_sarif_level(Severity::Error), "error");
1546 }
1547
1548 #[test]
1549 fn severity_to_sarif_level_warn() {
1550 assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
1551 }
1552
1553 #[test]
1554 fn severity_to_sarif_level_off() {
1555 assert_eq!(severity_to_sarif_level(Severity::Off), "warning");
1556 }
1557
1558 #[test]
1561 fn sarif_re_export_has_properties() {
1562 let root = PathBuf::from("/project");
1563 let mut results = AnalysisResults::default();
1564 results.unused_exports.push(UnusedExport {
1565 path: root.join("src/index.ts"),
1566 export_name: "reExported".to_string(),
1567 is_type_only: false,
1568 line: 1,
1569 col: 0,
1570 span_start: 0,
1571 is_re_export: true,
1572 });
1573
1574 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1575 let entry = &sarif["runs"][0]["results"][0];
1576 assert_eq!(entry["properties"]["is_re_export"], true);
1577 let msg = entry["message"]["text"].as_str().unwrap();
1578 assert!(msg.starts_with("Re-export"));
1579 }
1580
1581 #[test]
1582 fn sarif_non_re_export_has_no_properties() {
1583 let root = PathBuf::from("/project");
1584 let mut results = AnalysisResults::default();
1585 results.unused_exports.push(UnusedExport {
1586 path: root.join("src/utils.ts"),
1587 export_name: "foo".to_string(),
1588 is_type_only: false,
1589 line: 5,
1590 col: 0,
1591 span_start: 0,
1592 is_re_export: false,
1593 });
1594
1595 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1596 let entry = &sarif["runs"][0]["results"][0];
1597 assert!(entry.get("properties").is_none());
1598 let msg = entry["message"]["text"].as_str().unwrap();
1599 assert!(msg.starts_with("Export"));
1600 }
1601
1602 #[test]
1605 fn sarif_type_re_export_message() {
1606 let root = PathBuf::from("/project");
1607 let mut results = AnalysisResults::default();
1608 results.unused_types.push(UnusedExport {
1609 path: root.join("src/index.ts"),
1610 export_name: "MyType".to_string(),
1611 is_type_only: true,
1612 line: 1,
1613 col: 0,
1614 span_start: 0,
1615 is_re_export: true,
1616 });
1617
1618 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1619 let entry = &sarif["runs"][0]["results"][0];
1620 assert_eq!(entry["ruleId"], "fallow/unused-type");
1621 let msg = entry["message"]["text"].as_str().unwrap();
1622 assert!(msg.starts_with("Type re-export"));
1623 assert_eq!(entry["properties"]["is_re_export"], true);
1624 }
1625
1626 #[test]
1629 fn sarif_dependency_line_zero_skips_region() {
1630 let root = PathBuf::from("/project");
1631 let mut results = AnalysisResults::default();
1632 results.unused_dependencies.push(UnusedDependency {
1633 package_name: "lodash".to_string(),
1634 location: DependencyLocation::Dependencies,
1635 path: root.join("package.json"),
1636 line: 0,
1637 });
1638
1639 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1640 let entry = &sarif["runs"][0]["results"][0];
1641 let phys = &entry["locations"][0]["physicalLocation"];
1642 assert!(phys.get("region").is_none());
1643 }
1644
1645 #[test]
1646 fn sarif_dependency_line_nonzero_has_region() {
1647 let root = PathBuf::from("/project");
1648 let mut results = AnalysisResults::default();
1649 results.unused_dependencies.push(UnusedDependency {
1650 package_name: "lodash".to_string(),
1651 location: DependencyLocation::Dependencies,
1652 path: root.join("package.json"),
1653 line: 7,
1654 });
1655
1656 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1657 let entry = &sarif["runs"][0]["results"][0];
1658 let region = &entry["locations"][0]["physicalLocation"]["region"];
1659 assert_eq!(region["startLine"], 7);
1660 assert_eq!(region["startColumn"], 1);
1661 }
1662
1663 #[test]
1666 fn sarif_type_only_dep_line_zero_skips_region() {
1667 let root = PathBuf::from("/project");
1668 let mut results = AnalysisResults::default();
1669 results.type_only_dependencies.push(TypeOnlyDependency {
1670 package_name: "zod".to_string(),
1671 path: root.join("package.json"),
1672 line: 0,
1673 });
1674
1675 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1676 let entry = &sarif["runs"][0]["results"][0];
1677 let phys = &entry["locations"][0]["physicalLocation"];
1678 assert!(phys.get("region").is_none());
1679 }
1680
1681 #[test]
1684 fn sarif_circular_dep_line_zero_skips_region() {
1685 let root = PathBuf::from("/project");
1686 let mut results = AnalysisResults::default();
1687 results.circular_dependencies.push(CircularDependency {
1688 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1689 length: 2,
1690 line: 0,
1691 col: 0,
1692 is_cross_package: false,
1693 });
1694
1695 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1696 let entry = &sarif["runs"][0]["results"][0];
1697 let phys = &entry["locations"][0]["physicalLocation"];
1698 assert!(phys.get("region").is_none());
1699 }
1700
1701 #[test]
1702 fn sarif_circular_dep_line_nonzero_has_region() {
1703 let root = PathBuf::from("/project");
1704 let mut results = AnalysisResults::default();
1705 results.circular_dependencies.push(CircularDependency {
1706 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1707 length: 2,
1708 line: 5,
1709 col: 2,
1710 is_cross_package: false,
1711 });
1712
1713 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1714 let entry = &sarif["runs"][0]["results"][0];
1715 let region = &entry["locations"][0]["physicalLocation"]["region"];
1716 assert_eq!(region["startLine"], 5);
1717 assert_eq!(region["startColumn"], 3);
1718 }
1719
1720 #[test]
1723 fn sarif_unused_optional_dependency_result() {
1724 let root = PathBuf::from("/project");
1725 let mut results = AnalysisResults::default();
1726 results.unused_optional_dependencies.push(UnusedDependency {
1727 package_name: "fsevents".to_string(),
1728 location: DependencyLocation::OptionalDependencies,
1729 path: root.join("package.json"),
1730 line: 12,
1731 });
1732
1733 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1734 let entry = &sarif["runs"][0]["results"][0];
1735 assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
1736 let msg = entry["message"]["text"].as_str().unwrap();
1737 assert!(msg.contains("optionalDependencies"));
1738 }
1739
1740 #[test]
1743 fn sarif_enum_member_message_format() {
1744 let root = PathBuf::from("/project");
1745 let mut results = AnalysisResults::default();
1746 results
1747 .unused_enum_members
1748 .push(fallow_core::results::UnusedMember {
1749 path: root.join("src/enums.ts"),
1750 parent_name: "Color".to_string(),
1751 member_name: "Purple".to_string(),
1752 kind: fallow_core::extract::MemberKind::EnumMember,
1753 line: 5,
1754 col: 2,
1755 });
1756
1757 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1758 let entry = &sarif["runs"][0]["results"][0];
1759 assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
1760 let msg = entry["message"]["text"].as_str().unwrap();
1761 assert!(msg.contains("Enum member 'Color.Purple'"));
1762 let region = &entry["locations"][0]["physicalLocation"]["region"];
1763 assert_eq!(region["startColumn"], 3); }
1765
1766 #[test]
1767 fn sarif_class_member_message_format() {
1768 let root = PathBuf::from("/project");
1769 let mut results = AnalysisResults::default();
1770 results
1771 .unused_class_members
1772 .push(fallow_core::results::UnusedMember {
1773 path: root.join("src/service.ts"),
1774 parent_name: "API".to_string(),
1775 member_name: "fetch".to_string(),
1776 kind: fallow_core::extract::MemberKind::ClassMethod,
1777 line: 10,
1778 col: 4,
1779 });
1780
1781 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1782 let entry = &sarif["runs"][0]["results"][0];
1783 assert_eq!(entry["ruleId"], "fallow/unused-class-member");
1784 let msg = entry["message"]["text"].as_str().unwrap();
1785 assert!(msg.contains("Class member 'API.fetch'"));
1786 }
1787
1788 #[test]
1791 #[expect(
1792 clippy::cast_possible_truncation,
1793 reason = "test line/col values are trivially small"
1794 )]
1795 fn duplication_sarif_structure() {
1796 use fallow_core::duplicates::*;
1797
1798 let root = PathBuf::from("/project");
1799 let report = DuplicationReport {
1800 clone_groups: vec![CloneGroup {
1801 instances: vec![
1802 CloneInstance {
1803 file: root.join("src/a.ts"),
1804 start_line: 1,
1805 end_line: 10,
1806 start_col: 0,
1807 end_col: 0,
1808 fragment: String::new(),
1809 },
1810 CloneInstance {
1811 file: root.join("src/b.ts"),
1812 start_line: 5,
1813 end_line: 14,
1814 start_col: 2,
1815 end_col: 0,
1816 fragment: String::new(),
1817 },
1818 ],
1819 token_count: 50,
1820 line_count: 10,
1821 }],
1822 clone_families: vec![],
1823 mirrored_directories: vec![],
1824 stats: DuplicationStats::default(),
1825 };
1826
1827 let sarif = serde_json::json!({
1828 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1829 "version": "2.1.0",
1830 "runs": [{
1831 "tool": {
1832 "driver": {
1833 "name": "fallow",
1834 "version": env!("CARGO_PKG_VERSION"),
1835 "informationUri": "https://github.com/fallow-rs/fallow",
1836 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1837 }
1838 },
1839 "results": []
1840 }]
1841 });
1842 let _ = sarif;
1844
1845 let mut sarif_results = Vec::new();
1847 for (i, group) in report.clone_groups.iter().enumerate() {
1848 for instance in &group.instances {
1849 sarif_results.push(sarif_result(
1850 "fallow/code-duplication",
1851 "warning",
1852 &format!(
1853 "Code clone group {} ({} lines, {} instances)",
1854 i + 1,
1855 group.line_count,
1856 group.instances.len()
1857 ),
1858 &super::super::relative_uri(&instance.file, &root),
1859 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1860 ));
1861 }
1862 }
1863 assert_eq!(sarif_results.len(), 2);
1864 assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
1865 assert!(
1866 sarif_results[0]["message"]["text"]
1867 .as_str()
1868 .unwrap()
1869 .contains("10 lines")
1870 );
1871 let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
1872 assert_eq!(region0["startLine"], 1);
1873 assert_eq!(region0["startColumn"], 1); let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
1875 assert_eq!(region1["startLine"], 5);
1876 assert_eq!(region1["startColumn"], 3); }
1878
1879 #[test]
1882 fn sarif_rule_known_id_has_full_description() {
1883 let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
1884 assert!(rule.get("fullDescription").is_some());
1885 assert!(rule.get("helpUri").is_some());
1886 }
1887
1888 #[test]
1889 fn sarif_rule_unknown_id_uses_fallback() {
1890 let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
1891 assert_eq!(rule["shortDescription"]["text"], "fallback text");
1892 assert!(rule.get("fullDescription").is_none());
1893 assert!(rule.get("helpUri").is_none());
1894 assert_eq!(rule["defaultConfiguration"]["level"], "warning");
1895 }
1896
1897 #[test]
1900 fn sarif_result_no_region_omits_region_key() {
1901 let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
1902 let phys = &result["locations"][0]["physicalLocation"];
1903 assert!(phys.get("region").is_none());
1904 assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
1905 }
1906
1907 #[test]
1908 fn sarif_result_with_region_includes_region() {
1909 let result = sarif_result(
1910 "rule/test",
1911 "error",
1912 "test msg",
1913 "src/file.ts",
1914 Some((10, 5)),
1915 );
1916 let region = &result["locations"][0]["physicalLocation"]["region"];
1917 assert_eq!(region["startLine"], 10);
1918 assert_eq!(region["startColumn"], 5);
1919 }
1920
1921 #[test]
1924 fn health_sarif_includes_refactoring_targets() {
1925 use crate::health_types::*;
1926
1927 let root = PathBuf::from("/project");
1928 let report = HealthReport {
1929 summary: HealthSummary {
1930 files_analyzed: 10,
1931 functions_analyzed: 50,
1932 ..Default::default()
1933 },
1934 targets: vec![RefactoringTarget {
1935 path: root.join("src/complex.ts"),
1936 priority: 85.0,
1937 efficiency: 42.5,
1938 recommendation: "Split high-impact file".into(),
1939 category: RecommendationCategory::SplitHighImpact,
1940 effort: EffortEstimate::Medium,
1941 confidence: Confidence::High,
1942 factors: vec![],
1943 evidence: None,
1944 }],
1945 ..Default::default()
1946 };
1947
1948 let sarif = build_health_sarif(&report, &root);
1949 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1950 assert_eq!(entries.len(), 1);
1951 assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
1952 assert_eq!(entries[0]["level"], "warning");
1953 let msg = entries[0]["message"]["text"].as_str().unwrap();
1954 assert!(msg.contains("high impact"));
1955 assert!(msg.contains("Split high-impact file"));
1956 assert!(msg.contains("42.5"));
1957 }
1958
1959 #[test]
1960 fn health_sarif_includes_coverage_gaps() {
1961 use crate::health_types::*;
1962
1963 let root = PathBuf::from("/project");
1964 let report = HealthReport {
1965 summary: HealthSummary {
1966 files_analyzed: 10,
1967 functions_analyzed: 50,
1968 ..Default::default()
1969 },
1970 coverage_gaps: Some(CoverageGaps {
1971 summary: CoverageGapSummary {
1972 runtime_files: 2,
1973 covered_files: 0,
1974 file_coverage_pct: 0.0,
1975 untested_files: 1,
1976 untested_exports: 1,
1977 },
1978 files: vec![UntestedFile {
1979 path: root.join("src/app.ts"),
1980 value_export_count: 2,
1981 }],
1982 exports: vec![UntestedExport {
1983 path: root.join("src/app.ts"),
1984 export_name: "loader".into(),
1985 line: 12,
1986 col: 4,
1987 }],
1988 }),
1989 ..Default::default()
1990 };
1991
1992 let sarif = build_health_sarif(&report, &root);
1993 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1994 assert_eq!(entries.len(), 2);
1995 assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
1996 assert_eq!(
1997 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1998 "src/app.ts"
1999 );
2000 assert!(
2001 entries[0]["message"]["text"]
2002 .as_str()
2003 .unwrap()
2004 .contains("2 value exports")
2005 );
2006 assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
2007 assert_eq!(
2008 entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
2009 12
2010 );
2011 assert_eq!(
2012 entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
2013 5
2014 );
2015 }
2016
2017 #[test]
2020 fn health_sarif_rules_have_full_descriptions() {
2021 let root = PathBuf::from("/project");
2022 let report = crate::health_types::HealthReport::default();
2023 let sarif = build_health_sarif(&report, &root);
2024 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2025 .as_array()
2026 .unwrap();
2027 for rule in rules {
2028 let id = rule["id"].as_str().unwrap();
2029 assert!(
2030 rule.get("fullDescription").is_some(),
2031 "health rule {id} should have fullDescription"
2032 );
2033 assert!(
2034 rule.get("helpUri").is_some(),
2035 "health rule {id} should have helpUri"
2036 );
2037 }
2038 }
2039
2040 #[test]
2043 fn sarif_warn_severity_produces_warning_level() {
2044 let root = PathBuf::from("/project");
2045 let mut results = AnalysisResults::default();
2046 results.unused_files.push(UnusedFile {
2047 path: root.join("src/dead.ts"),
2048 });
2049
2050 let rules = RulesConfig {
2051 unused_files: Severity::Warn,
2052 ..RulesConfig::default()
2053 };
2054
2055 let sarif = build_sarif(&results, &root, &rules);
2056 let entry = &sarif["runs"][0]["results"][0];
2057 assert_eq!(entry["level"], "warning");
2058 }
2059
2060 #[test]
2063 fn sarif_unused_file_has_no_region() {
2064 let root = PathBuf::from("/project");
2065 let mut results = AnalysisResults::default();
2066 results.unused_files.push(UnusedFile {
2067 path: root.join("src/dead.ts"),
2068 });
2069
2070 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2071 let entry = &sarif["runs"][0]["results"][0];
2072 let phys = &entry["locations"][0]["physicalLocation"];
2073 assert!(phys.get("region").is_none());
2074 }
2075
2076 #[test]
2079 fn sarif_unlisted_dep_multiple_import_sites() {
2080 let root = PathBuf::from("/project");
2081 let mut results = AnalysisResults::default();
2082 results.unlisted_dependencies.push(UnlistedDependency {
2083 package_name: "dotenv".to_string(),
2084 imported_from: vec![
2085 ImportSite {
2086 path: root.join("src/a.ts"),
2087 line: 1,
2088 col: 0,
2089 },
2090 ImportSite {
2091 path: root.join("src/b.ts"),
2092 line: 5,
2093 col: 0,
2094 },
2095 ],
2096 });
2097
2098 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2099 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2100 assert_eq!(entries.len(), 2);
2102 assert_eq!(
2103 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2104 "src/a.ts"
2105 );
2106 assert_eq!(
2107 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2108 "src/b.ts"
2109 );
2110 }
2111
2112 #[test]
2115 fn sarif_unlisted_dep_no_import_sites() {
2116 let root = PathBuf::from("/project");
2117 let mut results = AnalysisResults::default();
2118 results.unlisted_dependencies.push(UnlistedDependency {
2119 package_name: "phantom".to_string(),
2120 imported_from: vec![],
2121 });
2122
2123 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2124 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2125 assert!(entries.is_empty());
2127 }
2128}