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#[expect(
699 clippy::cast_possible_truncation,
700 reason = "line/col numbers are bounded by source size"
701)]
702pub(super) fn print_grouped_duplication_sarif(
703 report: &DuplicationReport,
704 root: &Path,
705 resolver: &OwnershipResolver,
706) -> ExitCode {
707 let mut sarif_results = Vec::new();
708
709 for (i, group) in report.clone_groups.iter().enumerate() {
710 let primary_owner = super::dupes_grouping::largest_owner(group, root, resolver);
714 for instance in &group.instances {
715 let mut result = sarif_result(
716 "fallow/code-duplication",
717 "warning",
718 &format!(
719 "Code clone group {} ({} lines, {} instances)",
720 i + 1,
721 group.line_count,
722 group.instances.len()
723 ),
724 &relative_uri(&instance.file, root),
725 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
726 );
727 let props = result
728 .as_object_mut()
729 .expect("SARIF result should be an object")
730 .entry("properties")
731 .or_insert_with(|| serde_json::json!({}));
732 props
733 .as_object_mut()
734 .expect("properties should be an object")
735 .insert(
736 "group".to_string(),
737 serde_json::Value::String(primary_owner.clone()),
738 );
739 sarif_results.push(result);
740 }
741 }
742
743 let sarif = serde_json::json!({
744 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
745 "version": "2.1.0",
746 "runs": [{
747 "tool": {
748 "driver": {
749 "name": "fallow",
750 "version": env!("CARGO_PKG_VERSION"),
751 "informationUri": "https://github.com/fallow-rs/fallow",
752 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
753 }
754 },
755 "results": sarif_results
756 }]
757 });
758
759 emit_json(&sarif, "SARIF")
760}
761
762#[must_use]
768#[expect(
769 clippy::too_many_lines,
770 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"
771)]
772pub fn build_health_sarif(
773 report: &crate::health_types::HealthReport,
774 root: &Path,
775) -> serde_json::Value {
776 use crate::health_types::ExceededThreshold;
777
778 let mut sarif_results = Vec::new();
779
780 for finding in &report.findings {
781 let uri = relative_uri(&finding.path, root);
782 let (rule_id, message) = match finding.exceeded {
786 ExceededThreshold::Cyclomatic => (
787 "fallow/high-cyclomatic-complexity",
788 format!(
789 "'{}' has cyclomatic complexity {} (threshold: {})",
790 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
791 ),
792 ),
793 ExceededThreshold::Cognitive => (
794 "fallow/high-cognitive-complexity",
795 format!(
796 "'{}' has cognitive complexity {} (threshold: {})",
797 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
798 ),
799 ),
800 ExceededThreshold::Both => (
801 "fallow/high-complexity",
802 format!(
803 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
804 finding.name,
805 finding.cyclomatic,
806 report.summary.max_cyclomatic_threshold,
807 finding.cognitive,
808 report.summary.max_cognitive_threshold,
809 ),
810 ),
811 ExceededThreshold::Crap
812 | ExceededThreshold::CyclomaticCrap
813 | ExceededThreshold::CognitiveCrap
814 | ExceededThreshold::All => {
815 let crap = finding.crap.unwrap_or(0.0);
816 let coverage = finding
817 .coverage_pct
818 .map(|pct| format!(", coverage {pct:.0}%"))
819 .unwrap_or_default();
820 (
821 "fallow/high-crap-score",
822 format!(
823 "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
824 finding.name,
825 crap,
826 report.summary.max_crap_threshold,
827 finding.cyclomatic,
828 coverage,
829 ),
830 )
831 }
832 };
833
834 let level = match finding.severity {
835 crate::health_types::FindingSeverity::Critical => "error",
836 crate::health_types::FindingSeverity::High => "warning",
837 crate::health_types::FindingSeverity::Moderate => "note",
838 };
839 sarif_results.push(sarif_result(
840 rule_id,
841 level,
842 &message,
843 &uri,
844 Some((finding.line, finding.col + 1)),
845 ));
846 }
847
848 if let Some(ref production) = report.runtime_coverage {
849 append_runtime_coverage_sarif_results(&mut sarif_results, production, root);
850 }
851
852 for target in &report.targets {
854 let uri = relative_uri(&target.path, root);
855 let message = format!(
856 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
857 target.category.label(),
858 target.recommendation,
859 target.priority,
860 target.efficiency,
861 target.effort.label(),
862 target.confidence.label(),
863 );
864 sarif_results.push(sarif_result(
865 "fallow/refactoring-target",
866 "warning",
867 &message,
868 &uri,
869 None,
870 ));
871 }
872
873 if let Some(ref gaps) = report.coverage_gaps {
874 for item in &gaps.files {
875 let uri = relative_uri(&item.path, root);
876 let message = format!(
877 "File is runtime-reachable but has no test dependency path ({} value export{})",
878 item.value_export_count,
879 if item.value_export_count == 1 {
880 ""
881 } else {
882 "s"
883 },
884 );
885 sarif_results.push(sarif_result(
886 "fallow/untested-file",
887 "warning",
888 &message,
889 &uri,
890 None,
891 ));
892 }
893
894 for item in &gaps.exports {
895 let uri = relative_uri(&item.path, root);
896 let message = format!(
897 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
898 item.export_name
899 );
900 sarif_results.push(sarif_result(
901 "fallow/untested-export",
902 "warning",
903 &message,
904 &uri,
905 Some((item.line, item.col + 1)),
906 ));
907 }
908 }
909
910 let health_rules = vec![
911 sarif_rule(
912 "fallow/high-cyclomatic-complexity",
913 "Function has high cyclomatic complexity",
914 "note",
915 ),
916 sarif_rule(
917 "fallow/high-cognitive-complexity",
918 "Function has high cognitive complexity",
919 "note",
920 ),
921 sarif_rule(
922 "fallow/high-complexity",
923 "Function exceeds both complexity thresholds",
924 "note",
925 ),
926 sarif_rule(
927 "fallow/high-crap-score",
928 "Function has a high CRAP score (high complexity combined with low coverage)",
929 "warning",
930 ),
931 sarif_rule(
932 "fallow/refactoring-target",
933 "File identified as a high-priority refactoring candidate",
934 "warning",
935 ),
936 sarif_rule(
937 "fallow/untested-file",
938 "Runtime-reachable file has no test dependency path",
939 "warning",
940 ),
941 sarif_rule(
942 "fallow/untested-export",
943 "Runtime-reachable export has no test dependency path",
944 "warning",
945 ),
946 sarif_rule(
947 "fallow/runtime-safe-to-delete",
948 "Function is statically unused and was never invoked in production",
949 "warning",
950 ),
951 sarif_rule(
952 "fallow/runtime-review-required",
953 "Function is statically used but was never invoked in production",
954 "warning",
955 ),
956 sarif_rule(
957 "fallow/runtime-low-traffic",
958 "Function was invoked below the low-traffic threshold relative to total trace count",
959 "note",
960 ),
961 sarif_rule(
962 "fallow/runtime-coverage-unavailable",
963 "Runtime coverage could not be resolved for this function",
964 "note",
965 ),
966 sarif_rule(
967 "fallow/runtime-coverage",
968 "Runtime coverage finding",
969 "note",
970 ),
971 ];
972
973 serde_json::json!({
974 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
975 "version": "2.1.0",
976 "runs": [{
977 "tool": {
978 "driver": {
979 "name": "fallow",
980 "version": env!("CARGO_PKG_VERSION"),
981 "informationUri": "https://github.com/fallow-rs/fallow",
982 "rules": health_rules
983 }
984 },
985 "results": sarif_results
986 }]
987 })
988}
989
990fn append_runtime_coverage_sarif_results(
991 sarif_results: &mut Vec<serde_json::Value>,
992 production: &crate::health_types::RuntimeCoverageReport,
993 root: &Path,
994) {
995 for finding in &production.findings {
996 let uri = relative_uri(&finding.path, root);
997 let rule_id = match finding.verdict {
998 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
999 "fallow/runtime-safe-to-delete"
1000 }
1001 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
1002 "fallow/runtime-review-required"
1003 }
1004 crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
1005 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
1006 "fallow/runtime-coverage-unavailable"
1007 }
1008 crate::health_types::RuntimeCoverageVerdict::Active
1009 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
1010 };
1011 let level = match finding.verdict {
1012 crate::health_types::RuntimeCoverageVerdict::SafeToDelete
1013 | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
1014 _ => "note",
1015 };
1016 let invocations_hint = finding.invocations.map_or_else(
1017 || "untracked".to_owned(),
1018 |hits| format!("{hits} invocations"),
1019 );
1020 let message = format!(
1021 "'{}' runtime coverage verdict: {} ({})",
1022 finding.function,
1023 finding.verdict.human_label(),
1024 invocations_hint,
1025 );
1026 sarif_results.push(sarif_result(
1027 rule_id,
1028 level,
1029 &message,
1030 &uri,
1031 Some((finding.line, 1)),
1032 ));
1033 }
1034}
1035
1036pub(super) fn print_health_sarif(
1037 report: &crate::health_types::HealthReport,
1038 root: &Path,
1039) -> ExitCode {
1040 let sarif = build_health_sarif(report, root);
1041 emit_json(&sarif, "SARIF")
1042}
1043
1044pub(super) fn print_grouped_health_sarif(
1055 report: &crate::health_types::HealthReport,
1056 root: &Path,
1057 resolver: &OwnershipResolver,
1058) -> ExitCode {
1059 let mut sarif = build_health_sarif(report, root);
1060
1061 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1062 for run in runs {
1063 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1064 for result in results {
1065 let uri = result
1066 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1067 .and_then(|v| v.as_str())
1068 .unwrap_or("");
1069 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1070 let group =
1071 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1072 let props = result
1073 .as_object_mut()
1074 .expect("SARIF result should be an object")
1075 .entry("properties")
1076 .or_insert_with(|| serde_json::json!({}));
1077 props
1078 .as_object_mut()
1079 .expect("properties should be an object")
1080 .insert("group".to_string(), serde_json::Value::String(group));
1081 }
1082 }
1083 }
1084 }
1085
1086 emit_json(&sarif, "SARIF")
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091 use super::*;
1092 use crate::report::test_helpers::sample_results;
1093 use fallow_core::results::*;
1094 use std::path::PathBuf;
1095
1096 #[test]
1097 fn sarif_has_required_top_level_fields() {
1098 let root = PathBuf::from("/project");
1099 let results = AnalysisResults::default();
1100 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1101
1102 assert_eq!(
1103 sarif["$schema"],
1104 "https://json.schemastore.org/sarif-2.1.0.json"
1105 );
1106 assert_eq!(sarif["version"], "2.1.0");
1107 assert!(sarif["runs"].is_array());
1108 }
1109
1110 #[test]
1111 fn sarif_has_tool_driver_info() {
1112 let root = PathBuf::from("/project");
1113 let results = AnalysisResults::default();
1114 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1115
1116 let driver = &sarif["runs"][0]["tool"]["driver"];
1117 assert_eq!(driver["name"], "fallow");
1118 assert!(driver["version"].is_string());
1119 assert_eq!(
1120 driver["informationUri"],
1121 "https://github.com/fallow-rs/fallow"
1122 );
1123 }
1124
1125 #[test]
1126 fn sarif_declares_all_rules() {
1127 let root = PathBuf::from("/project");
1128 let results = AnalysisResults::default();
1129 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1130
1131 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1132 .as_array()
1133 .expect("rules should be an array");
1134 assert_eq!(rules.len(), 16);
1135
1136 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
1137 assert!(rule_ids.contains(&"fallow/unused-file"));
1138 assert!(rule_ids.contains(&"fallow/unused-export"));
1139 assert!(rule_ids.contains(&"fallow/unused-type"));
1140 assert!(rule_ids.contains(&"fallow/unused-dependency"));
1141 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1142 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1143 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1144 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1145 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1146 assert!(rule_ids.contains(&"fallow/unused-class-member"));
1147 assert!(rule_ids.contains(&"fallow/unresolved-import"));
1148 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1149 assert!(rule_ids.contains(&"fallow/duplicate-export"));
1150 assert!(rule_ids.contains(&"fallow/circular-dependency"));
1151 assert!(rule_ids.contains(&"fallow/boundary-violation"));
1152 }
1153
1154 #[test]
1155 fn sarif_empty_results_no_results_entries() {
1156 let root = PathBuf::from("/project");
1157 let results = AnalysisResults::default();
1158 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1159
1160 let sarif_results = sarif["runs"][0]["results"]
1161 .as_array()
1162 .expect("results should be an array");
1163 assert!(sarif_results.is_empty());
1164 }
1165
1166 #[test]
1167 fn sarif_unused_file_result() {
1168 let root = PathBuf::from("/project");
1169 let mut results = AnalysisResults::default();
1170 results.unused_files.push(UnusedFile {
1171 path: root.join("src/dead.ts"),
1172 });
1173
1174 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1175 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1176 assert_eq!(entries.len(), 1);
1177
1178 let entry = &entries[0];
1179 assert_eq!(entry["ruleId"], "fallow/unused-file");
1180 assert_eq!(entry["level"], "error");
1182 assert_eq!(
1183 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1184 "src/dead.ts"
1185 );
1186 }
1187
1188 #[test]
1189 fn sarif_unused_export_includes_region() {
1190 let root = PathBuf::from("/project");
1191 let mut results = AnalysisResults::default();
1192 results.unused_exports.push(UnusedExport {
1193 path: root.join("src/utils.ts"),
1194 export_name: "helperFn".to_string(),
1195 is_type_only: false,
1196 line: 10,
1197 col: 4,
1198 span_start: 120,
1199 is_re_export: false,
1200 });
1201
1202 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1203 let entry = &sarif["runs"][0]["results"][0];
1204 assert_eq!(entry["ruleId"], "fallow/unused-export");
1205
1206 let region = &entry["locations"][0]["physicalLocation"]["region"];
1207 assert_eq!(region["startLine"], 10);
1208 assert_eq!(region["startColumn"], 5);
1210 }
1211
1212 #[test]
1213 fn sarif_unresolved_import_is_error_level() {
1214 let root = PathBuf::from("/project");
1215 let mut results = AnalysisResults::default();
1216 results.unresolved_imports.push(UnresolvedImport {
1217 path: root.join("src/app.ts"),
1218 specifier: "./missing".to_string(),
1219 line: 1,
1220 col: 0,
1221 specifier_col: 0,
1222 });
1223
1224 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1225 let entry = &sarif["runs"][0]["results"][0];
1226 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
1227 assert_eq!(entry["level"], "error");
1228 }
1229
1230 #[test]
1231 fn sarif_unlisted_dependency_points_to_import_site() {
1232 let root = PathBuf::from("/project");
1233 let mut results = AnalysisResults::default();
1234 results.unlisted_dependencies.push(UnlistedDependency {
1235 package_name: "chalk".to_string(),
1236 imported_from: vec![ImportSite {
1237 path: root.join("src/cli.ts"),
1238 line: 3,
1239 col: 0,
1240 }],
1241 });
1242
1243 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1244 let entry = &sarif["runs"][0]["results"][0];
1245 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
1246 assert_eq!(entry["level"], "error");
1247 assert_eq!(
1248 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1249 "src/cli.ts"
1250 );
1251 let region = &entry["locations"][0]["physicalLocation"]["region"];
1252 assert_eq!(region["startLine"], 3);
1253 assert_eq!(region["startColumn"], 1);
1254 }
1255
1256 #[test]
1257 fn sarif_dependency_issues_point_to_package_json() {
1258 let root = PathBuf::from("/project");
1259 let mut results = AnalysisResults::default();
1260 results.unused_dependencies.push(UnusedDependency {
1261 package_name: "lodash".to_string(),
1262 location: DependencyLocation::Dependencies,
1263 path: root.join("package.json"),
1264 line: 5,
1265 });
1266 results.unused_dev_dependencies.push(UnusedDependency {
1267 package_name: "jest".to_string(),
1268 location: DependencyLocation::DevDependencies,
1269 path: root.join("package.json"),
1270 line: 5,
1271 });
1272
1273 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1274 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1275 for entry in entries {
1276 assert_eq!(
1277 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1278 "package.json"
1279 );
1280 }
1281 }
1282
1283 #[test]
1284 fn sarif_duplicate_export_emits_one_result_per_location() {
1285 let root = PathBuf::from("/project");
1286 let mut results = AnalysisResults::default();
1287 results.duplicate_exports.push(DuplicateExport {
1288 export_name: "Config".to_string(),
1289 locations: vec![
1290 DuplicateLocation {
1291 path: root.join("src/a.ts"),
1292 line: 15,
1293 col: 0,
1294 },
1295 DuplicateLocation {
1296 path: root.join("src/b.ts"),
1297 line: 30,
1298 col: 0,
1299 },
1300 ],
1301 });
1302
1303 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1304 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1305 assert_eq!(entries.len(), 2);
1307 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
1308 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
1309 assert_eq!(
1310 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1311 "src/a.ts"
1312 );
1313 assert_eq!(
1314 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1315 "src/b.ts"
1316 );
1317 }
1318
1319 #[test]
1320 fn sarif_all_issue_types_produce_results() {
1321 let root = PathBuf::from("/project");
1322 let results = sample_results(&root);
1323 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1324
1325 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1326 assert_eq!(entries.len(), results.total_issues() + 1);
1328
1329 let rule_ids: Vec<&str> = entries
1330 .iter()
1331 .map(|e| e["ruleId"].as_str().unwrap())
1332 .collect();
1333 assert!(rule_ids.contains(&"fallow/unused-file"));
1334 assert!(rule_ids.contains(&"fallow/unused-export"));
1335 assert!(rule_ids.contains(&"fallow/unused-type"));
1336 assert!(rule_ids.contains(&"fallow/unused-dependency"));
1337 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1338 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1339 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1340 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1341 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1342 assert!(rule_ids.contains(&"fallow/unused-class-member"));
1343 assert!(rule_ids.contains(&"fallow/unresolved-import"));
1344 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1345 assert!(rule_ids.contains(&"fallow/duplicate-export"));
1346 }
1347
1348 #[test]
1349 fn sarif_serializes_to_valid_json() {
1350 let root = PathBuf::from("/project");
1351 let results = sample_results(&root);
1352 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1353
1354 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1355 let reparsed: serde_json::Value =
1356 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
1357 assert_eq!(reparsed, sarif);
1358 }
1359
1360 #[test]
1361 fn sarif_file_write_produces_valid_sarif() {
1362 let root = PathBuf::from("/project");
1363 let results = sample_results(&root);
1364 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1365 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1366
1367 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
1368 let _ = std::fs::create_dir_all(&dir);
1369 let sarif_path = dir.join("results.sarif");
1370 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
1371
1372 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
1373 let parsed: serde_json::Value =
1374 serde_json::from_str(&contents).expect("file should contain valid JSON");
1375
1376 assert_eq!(parsed["version"], "2.1.0");
1377 assert_eq!(
1378 parsed["$schema"],
1379 "https://json.schemastore.org/sarif-2.1.0.json"
1380 );
1381 let sarif_results = parsed["runs"][0]["results"]
1382 .as_array()
1383 .expect("results should be an array");
1384 assert!(!sarif_results.is_empty());
1385
1386 let _ = std::fs::remove_file(&sarif_path);
1388 let _ = std::fs::remove_dir(&dir);
1389 }
1390
1391 #[test]
1394 fn health_sarif_empty_no_results() {
1395 let root = PathBuf::from("/project");
1396 let report = crate::health_types::HealthReport {
1397 summary: crate::health_types::HealthSummary {
1398 files_analyzed: 10,
1399 functions_analyzed: 50,
1400 ..Default::default()
1401 },
1402 ..Default::default()
1403 };
1404 let sarif = build_health_sarif(&report, &root);
1405 assert_eq!(sarif["version"], "2.1.0");
1406 let results = sarif["runs"][0]["results"].as_array().unwrap();
1407 assert!(results.is_empty());
1408 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1409 .as_array()
1410 .unwrap();
1411 assert_eq!(rules.len(), 12);
1412 }
1413
1414 #[test]
1415 fn health_sarif_cyclomatic_only() {
1416 let root = PathBuf::from("/project");
1417 let report = crate::health_types::HealthReport {
1418 findings: vec![crate::health_types::HealthFinding {
1419 path: root.join("src/utils.ts"),
1420 name: "parseExpression".to_string(),
1421 line: 42,
1422 col: 0,
1423 cyclomatic: 25,
1424 cognitive: 10,
1425 line_count: 80,
1426 param_count: 0,
1427 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
1428 severity: crate::health_types::FindingSeverity::High,
1429 crap: None,
1430 coverage_pct: None,
1431 coverage_tier: None,
1432 }],
1433 summary: crate::health_types::HealthSummary {
1434 files_analyzed: 5,
1435 functions_analyzed: 20,
1436 functions_above_threshold: 1,
1437 ..Default::default()
1438 },
1439 ..Default::default()
1440 };
1441 let sarif = build_health_sarif(&report, &root);
1442 let entry = &sarif["runs"][0]["results"][0];
1443 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
1444 assert_eq!(entry["level"], "warning");
1445 assert!(
1446 entry["message"]["text"]
1447 .as_str()
1448 .unwrap()
1449 .contains("cyclomatic complexity 25")
1450 );
1451 assert_eq!(
1452 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1453 "src/utils.ts"
1454 );
1455 let region = &entry["locations"][0]["physicalLocation"]["region"];
1456 assert_eq!(region["startLine"], 42);
1457 assert_eq!(region["startColumn"], 1);
1458 }
1459
1460 #[test]
1461 fn health_sarif_cognitive_only() {
1462 let root = PathBuf::from("/project");
1463 let report = crate::health_types::HealthReport {
1464 findings: vec![crate::health_types::HealthFinding {
1465 path: root.join("src/api.ts"),
1466 name: "handleRequest".to_string(),
1467 line: 10,
1468 col: 4,
1469 cyclomatic: 8,
1470 cognitive: 20,
1471 line_count: 40,
1472 param_count: 0,
1473 exceeded: crate::health_types::ExceededThreshold::Cognitive,
1474 severity: crate::health_types::FindingSeverity::High,
1475 crap: None,
1476 coverage_pct: None,
1477 coverage_tier: None,
1478 }],
1479 summary: crate::health_types::HealthSummary {
1480 files_analyzed: 3,
1481 functions_analyzed: 10,
1482 functions_above_threshold: 1,
1483 ..Default::default()
1484 },
1485 ..Default::default()
1486 };
1487 let sarif = build_health_sarif(&report, &root);
1488 let entry = &sarif["runs"][0]["results"][0];
1489 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
1490 assert!(
1491 entry["message"]["text"]
1492 .as_str()
1493 .unwrap()
1494 .contains("cognitive complexity 20")
1495 );
1496 let region = &entry["locations"][0]["physicalLocation"]["region"];
1497 assert_eq!(region["startColumn"], 5); }
1499
1500 #[test]
1501 fn health_sarif_both_thresholds() {
1502 let root = PathBuf::from("/project");
1503 let report = crate::health_types::HealthReport {
1504 findings: vec![crate::health_types::HealthFinding {
1505 path: root.join("src/complex.ts"),
1506 name: "doEverything".to_string(),
1507 line: 1,
1508 col: 0,
1509 cyclomatic: 30,
1510 cognitive: 45,
1511 line_count: 100,
1512 param_count: 0,
1513 exceeded: crate::health_types::ExceededThreshold::Both,
1514 severity: crate::health_types::FindingSeverity::High,
1515 crap: None,
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 entry = &sarif["runs"][0]["results"][0];
1529 assert_eq!(entry["ruleId"], "fallow/high-complexity");
1530 let msg = entry["message"]["text"].as_str().unwrap();
1531 assert!(msg.contains("cyclomatic complexity 30"));
1532 assert!(msg.contains("cognitive complexity 45"));
1533 }
1534
1535 #[test]
1536 fn health_sarif_crap_only_emits_crap_rule() {
1537 let root = PathBuf::from("/project");
1540 let report = crate::health_types::HealthReport {
1541 findings: vec![crate::health_types::HealthFinding {
1542 path: root.join("src/untested.ts"),
1543 name: "risky".to_string(),
1544 line: 8,
1545 col: 0,
1546 cyclomatic: 10,
1547 cognitive: 10,
1548 line_count: 20,
1549 param_count: 1,
1550 exceeded: crate::health_types::ExceededThreshold::Crap,
1551 severity: crate::health_types::FindingSeverity::High,
1552 crap: Some(82.2),
1553 coverage_pct: Some(12.0),
1554 coverage_tier: None,
1555 }],
1556 summary: crate::health_types::HealthSummary {
1557 files_analyzed: 1,
1558 functions_analyzed: 1,
1559 functions_above_threshold: 1,
1560 ..Default::default()
1561 },
1562 ..Default::default()
1563 };
1564 let sarif = build_health_sarif(&report, &root);
1565 let entry = &sarif["runs"][0]["results"][0];
1566 assert_eq!(entry["ruleId"], "fallow/high-crap-score");
1567 let msg = entry["message"]["text"].as_str().unwrap();
1568 assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
1569 assert!(msg.contains("coverage 12%"), "msg: {msg}");
1570 }
1571
1572 #[test]
1573 fn health_sarif_cyclomatic_crap_uses_crap_rule() {
1574 let root = PathBuf::from("/project");
1577 let report = crate::health_types::HealthReport {
1578 findings: vec![crate::health_types::HealthFinding {
1579 path: root.join("src/hot.ts"),
1580 name: "branchy".to_string(),
1581 line: 1,
1582 col: 0,
1583 cyclomatic: 67,
1584 cognitive: 12,
1585 line_count: 80,
1586 param_count: 1,
1587 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
1588 severity: crate::health_types::FindingSeverity::Critical,
1589 crap: Some(182.0),
1590 coverage_pct: None,
1591 coverage_tier: None,
1592 }],
1593 summary: crate::health_types::HealthSummary {
1594 files_analyzed: 1,
1595 functions_analyzed: 1,
1596 functions_above_threshold: 1,
1597 ..Default::default()
1598 },
1599 ..Default::default()
1600 };
1601 let sarif = build_health_sarif(&report, &root);
1602 let results = sarif["runs"][0]["results"].as_array().unwrap();
1603 assert_eq!(
1604 results.len(),
1605 1,
1606 "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
1607 );
1608 assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
1609 let msg = results[0]["message"]["text"].as_str().unwrap();
1610 assert!(msg.contains("CRAP score 182"), "msg: {msg}");
1611 assert!(!msg.contains("coverage"), "msg: {msg}");
1613 }
1614
1615 #[test]
1618 fn severity_to_sarif_level_error() {
1619 assert_eq!(severity_to_sarif_level(Severity::Error), "error");
1620 }
1621
1622 #[test]
1623 fn severity_to_sarif_level_warn() {
1624 assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
1625 }
1626
1627 #[test]
1628 fn severity_to_sarif_level_off() {
1629 assert_eq!(severity_to_sarif_level(Severity::Off), "warning");
1630 }
1631
1632 #[test]
1635 fn sarif_re_export_has_properties() {
1636 let root = PathBuf::from("/project");
1637 let mut results = AnalysisResults::default();
1638 results.unused_exports.push(UnusedExport {
1639 path: root.join("src/index.ts"),
1640 export_name: "reExported".to_string(),
1641 is_type_only: false,
1642 line: 1,
1643 col: 0,
1644 span_start: 0,
1645 is_re_export: true,
1646 });
1647
1648 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1649 let entry = &sarif["runs"][0]["results"][0];
1650 assert_eq!(entry["properties"]["is_re_export"], true);
1651 let msg = entry["message"]["text"].as_str().unwrap();
1652 assert!(msg.starts_with("Re-export"));
1653 }
1654
1655 #[test]
1656 fn sarif_non_re_export_has_no_properties() {
1657 let root = PathBuf::from("/project");
1658 let mut results = AnalysisResults::default();
1659 results.unused_exports.push(UnusedExport {
1660 path: root.join("src/utils.ts"),
1661 export_name: "foo".to_string(),
1662 is_type_only: false,
1663 line: 5,
1664 col: 0,
1665 span_start: 0,
1666 is_re_export: false,
1667 });
1668
1669 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1670 let entry = &sarif["runs"][0]["results"][0];
1671 assert!(entry.get("properties").is_none());
1672 let msg = entry["message"]["text"].as_str().unwrap();
1673 assert!(msg.starts_with("Export"));
1674 }
1675
1676 #[test]
1679 fn sarif_type_re_export_message() {
1680 let root = PathBuf::from("/project");
1681 let mut results = AnalysisResults::default();
1682 results.unused_types.push(UnusedExport {
1683 path: root.join("src/index.ts"),
1684 export_name: "MyType".to_string(),
1685 is_type_only: true,
1686 line: 1,
1687 col: 0,
1688 span_start: 0,
1689 is_re_export: true,
1690 });
1691
1692 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1693 let entry = &sarif["runs"][0]["results"][0];
1694 assert_eq!(entry["ruleId"], "fallow/unused-type");
1695 let msg = entry["message"]["text"].as_str().unwrap();
1696 assert!(msg.starts_with("Type re-export"));
1697 assert_eq!(entry["properties"]["is_re_export"], true);
1698 }
1699
1700 #[test]
1703 fn sarif_dependency_line_zero_skips_region() {
1704 let root = PathBuf::from("/project");
1705 let mut results = AnalysisResults::default();
1706 results.unused_dependencies.push(UnusedDependency {
1707 package_name: "lodash".to_string(),
1708 location: DependencyLocation::Dependencies,
1709 path: root.join("package.json"),
1710 line: 0,
1711 });
1712
1713 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1714 let entry = &sarif["runs"][0]["results"][0];
1715 let phys = &entry["locations"][0]["physicalLocation"];
1716 assert!(phys.get("region").is_none());
1717 }
1718
1719 #[test]
1720 fn sarif_dependency_line_nonzero_has_region() {
1721 let root = PathBuf::from("/project");
1722 let mut results = AnalysisResults::default();
1723 results.unused_dependencies.push(UnusedDependency {
1724 package_name: "lodash".to_string(),
1725 location: DependencyLocation::Dependencies,
1726 path: root.join("package.json"),
1727 line: 7,
1728 });
1729
1730 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1731 let entry = &sarif["runs"][0]["results"][0];
1732 let region = &entry["locations"][0]["physicalLocation"]["region"];
1733 assert_eq!(region["startLine"], 7);
1734 assert_eq!(region["startColumn"], 1);
1735 }
1736
1737 #[test]
1740 fn sarif_type_only_dep_line_zero_skips_region() {
1741 let root = PathBuf::from("/project");
1742 let mut results = AnalysisResults::default();
1743 results.type_only_dependencies.push(TypeOnlyDependency {
1744 package_name: "zod".to_string(),
1745 path: root.join("package.json"),
1746 line: 0,
1747 });
1748
1749 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1750 let entry = &sarif["runs"][0]["results"][0];
1751 let phys = &entry["locations"][0]["physicalLocation"];
1752 assert!(phys.get("region").is_none());
1753 }
1754
1755 #[test]
1758 fn sarif_circular_dep_line_zero_skips_region() {
1759 let root = PathBuf::from("/project");
1760 let mut results = AnalysisResults::default();
1761 results.circular_dependencies.push(CircularDependency {
1762 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1763 length: 2,
1764 line: 0,
1765 col: 0,
1766 is_cross_package: false,
1767 });
1768
1769 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1770 let entry = &sarif["runs"][0]["results"][0];
1771 let phys = &entry["locations"][0]["physicalLocation"];
1772 assert!(phys.get("region").is_none());
1773 }
1774
1775 #[test]
1776 fn sarif_circular_dep_line_nonzero_has_region() {
1777 let root = PathBuf::from("/project");
1778 let mut results = AnalysisResults::default();
1779 results.circular_dependencies.push(CircularDependency {
1780 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1781 length: 2,
1782 line: 5,
1783 col: 2,
1784 is_cross_package: false,
1785 });
1786
1787 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1788 let entry = &sarif["runs"][0]["results"][0];
1789 let region = &entry["locations"][0]["physicalLocation"]["region"];
1790 assert_eq!(region["startLine"], 5);
1791 assert_eq!(region["startColumn"], 3);
1792 }
1793
1794 #[test]
1797 fn sarif_unused_optional_dependency_result() {
1798 let root = PathBuf::from("/project");
1799 let mut results = AnalysisResults::default();
1800 results.unused_optional_dependencies.push(UnusedDependency {
1801 package_name: "fsevents".to_string(),
1802 location: DependencyLocation::OptionalDependencies,
1803 path: root.join("package.json"),
1804 line: 12,
1805 });
1806
1807 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1808 let entry = &sarif["runs"][0]["results"][0];
1809 assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
1810 let msg = entry["message"]["text"].as_str().unwrap();
1811 assert!(msg.contains("optionalDependencies"));
1812 }
1813
1814 #[test]
1817 fn sarif_enum_member_message_format() {
1818 let root = PathBuf::from("/project");
1819 let mut results = AnalysisResults::default();
1820 results
1821 .unused_enum_members
1822 .push(fallow_core::results::UnusedMember {
1823 path: root.join("src/enums.ts"),
1824 parent_name: "Color".to_string(),
1825 member_name: "Purple".to_string(),
1826 kind: fallow_core::extract::MemberKind::EnumMember,
1827 line: 5,
1828 col: 2,
1829 });
1830
1831 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1832 let entry = &sarif["runs"][0]["results"][0];
1833 assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
1834 let msg = entry["message"]["text"].as_str().unwrap();
1835 assert!(msg.contains("Enum member 'Color.Purple'"));
1836 let region = &entry["locations"][0]["physicalLocation"]["region"];
1837 assert_eq!(region["startColumn"], 3); }
1839
1840 #[test]
1841 fn sarif_class_member_message_format() {
1842 let root = PathBuf::from("/project");
1843 let mut results = AnalysisResults::default();
1844 results
1845 .unused_class_members
1846 .push(fallow_core::results::UnusedMember {
1847 path: root.join("src/service.ts"),
1848 parent_name: "API".to_string(),
1849 member_name: "fetch".to_string(),
1850 kind: fallow_core::extract::MemberKind::ClassMethod,
1851 line: 10,
1852 col: 4,
1853 });
1854
1855 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1856 let entry = &sarif["runs"][0]["results"][0];
1857 assert_eq!(entry["ruleId"], "fallow/unused-class-member");
1858 let msg = entry["message"]["text"].as_str().unwrap();
1859 assert!(msg.contains("Class member 'API.fetch'"));
1860 }
1861
1862 #[test]
1865 #[expect(
1866 clippy::cast_possible_truncation,
1867 reason = "test line/col values are trivially small"
1868 )]
1869 fn duplication_sarif_structure() {
1870 use fallow_core::duplicates::*;
1871
1872 let root = PathBuf::from("/project");
1873 let report = DuplicationReport {
1874 clone_groups: vec![CloneGroup {
1875 instances: vec![
1876 CloneInstance {
1877 file: root.join("src/a.ts"),
1878 start_line: 1,
1879 end_line: 10,
1880 start_col: 0,
1881 end_col: 0,
1882 fragment: String::new(),
1883 },
1884 CloneInstance {
1885 file: root.join("src/b.ts"),
1886 start_line: 5,
1887 end_line: 14,
1888 start_col: 2,
1889 end_col: 0,
1890 fragment: String::new(),
1891 },
1892 ],
1893 token_count: 50,
1894 line_count: 10,
1895 }],
1896 clone_families: vec![],
1897 mirrored_directories: vec![],
1898 stats: DuplicationStats::default(),
1899 };
1900
1901 let sarif = serde_json::json!({
1902 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1903 "version": "2.1.0",
1904 "runs": [{
1905 "tool": {
1906 "driver": {
1907 "name": "fallow",
1908 "version": env!("CARGO_PKG_VERSION"),
1909 "informationUri": "https://github.com/fallow-rs/fallow",
1910 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1911 }
1912 },
1913 "results": []
1914 }]
1915 });
1916 let _ = sarif;
1918
1919 let mut sarif_results = Vec::new();
1921 for (i, group) in report.clone_groups.iter().enumerate() {
1922 for instance in &group.instances {
1923 sarif_results.push(sarif_result(
1924 "fallow/code-duplication",
1925 "warning",
1926 &format!(
1927 "Code clone group {} ({} lines, {} instances)",
1928 i + 1,
1929 group.line_count,
1930 group.instances.len()
1931 ),
1932 &super::super::relative_uri(&instance.file, &root),
1933 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1934 ));
1935 }
1936 }
1937 assert_eq!(sarif_results.len(), 2);
1938 assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
1939 assert!(
1940 sarif_results[0]["message"]["text"]
1941 .as_str()
1942 .unwrap()
1943 .contains("10 lines")
1944 );
1945 let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
1946 assert_eq!(region0["startLine"], 1);
1947 assert_eq!(region0["startColumn"], 1); let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
1949 assert_eq!(region1["startLine"], 5);
1950 assert_eq!(region1["startColumn"], 3); }
1952
1953 #[test]
1956 fn sarif_rule_known_id_has_full_description() {
1957 let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
1958 assert!(rule.get("fullDescription").is_some());
1959 assert!(rule.get("helpUri").is_some());
1960 }
1961
1962 #[test]
1963 fn sarif_rule_unknown_id_uses_fallback() {
1964 let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
1965 assert_eq!(rule["shortDescription"]["text"], "fallback text");
1966 assert!(rule.get("fullDescription").is_none());
1967 assert!(rule.get("helpUri").is_none());
1968 assert_eq!(rule["defaultConfiguration"]["level"], "warning");
1969 }
1970
1971 #[test]
1974 fn sarif_result_no_region_omits_region_key() {
1975 let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
1976 let phys = &result["locations"][0]["physicalLocation"];
1977 assert!(phys.get("region").is_none());
1978 assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
1979 }
1980
1981 #[test]
1982 fn sarif_result_with_region_includes_region() {
1983 let result = sarif_result(
1984 "rule/test",
1985 "error",
1986 "test msg",
1987 "src/file.ts",
1988 Some((10, 5)),
1989 );
1990 let region = &result["locations"][0]["physicalLocation"]["region"];
1991 assert_eq!(region["startLine"], 10);
1992 assert_eq!(region["startColumn"], 5);
1993 }
1994
1995 #[test]
1998 fn health_sarif_includes_refactoring_targets() {
1999 use crate::health_types::*;
2000
2001 let root = PathBuf::from("/project");
2002 let report = HealthReport {
2003 summary: HealthSummary {
2004 files_analyzed: 10,
2005 functions_analyzed: 50,
2006 ..Default::default()
2007 },
2008 targets: vec![RefactoringTarget {
2009 path: root.join("src/complex.ts"),
2010 priority: 85.0,
2011 efficiency: 42.5,
2012 recommendation: "Split high-impact file".into(),
2013 category: RecommendationCategory::SplitHighImpact,
2014 effort: EffortEstimate::Medium,
2015 confidence: Confidence::High,
2016 factors: vec![],
2017 evidence: None,
2018 }],
2019 ..Default::default()
2020 };
2021
2022 let sarif = build_health_sarif(&report, &root);
2023 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2024 assert_eq!(entries.len(), 1);
2025 assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
2026 assert_eq!(entries[0]["level"], "warning");
2027 let msg = entries[0]["message"]["text"].as_str().unwrap();
2028 assert!(msg.contains("high impact"));
2029 assert!(msg.contains("Split high-impact file"));
2030 assert!(msg.contains("42.5"));
2031 }
2032
2033 #[test]
2034 fn health_sarif_includes_coverage_gaps() {
2035 use crate::health_types::*;
2036
2037 let root = PathBuf::from("/project");
2038 let report = HealthReport {
2039 summary: HealthSummary {
2040 files_analyzed: 10,
2041 functions_analyzed: 50,
2042 ..Default::default()
2043 },
2044 coverage_gaps: Some(CoverageGaps {
2045 summary: CoverageGapSummary {
2046 runtime_files: 2,
2047 covered_files: 0,
2048 file_coverage_pct: 0.0,
2049 untested_files: 1,
2050 untested_exports: 1,
2051 },
2052 files: vec![UntestedFile {
2053 path: root.join("src/app.ts"),
2054 value_export_count: 2,
2055 }],
2056 exports: vec![UntestedExport {
2057 path: root.join("src/app.ts"),
2058 export_name: "loader".into(),
2059 line: 12,
2060 col: 4,
2061 }],
2062 }),
2063 ..Default::default()
2064 };
2065
2066 let sarif = build_health_sarif(&report, &root);
2067 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2068 assert_eq!(entries.len(), 2);
2069 assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
2070 assert_eq!(
2071 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2072 "src/app.ts"
2073 );
2074 assert!(
2075 entries[0]["message"]["text"]
2076 .as_str()
2077 .unwrap()
2078 .contains("2 value exports")
2079 );
2080 assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
2081 assert_eq!(
2082 entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
2083 12
2084 );
2085 assert_eq!(
2086 entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
2087 5
2088 );
2089 }
2090
2091 #[test]
2094 fn health_sarif_rules_have_full_descriptions() {
2095 let root = PathBuf::from("/project");
2096 let report = crate::health_types::HealthReport::default();
2097 let sarif = build_health_sarif(&report, &root);
2098 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2099 .as_array()
2100 .unwrap();
2101 for rule in rules {
2102 let id = rule["id"].as_str().unwrap();
2103 assert!(
2104 rule.get("fullDescription").is_some(),
2105 "health rule {id} should have fullDescription"
2106 );
2107 assert!(
2108 rule.get("helpUri").is_some(),
2109 "health rule {id} should have helpUri"
2110 );
2111 }
2112 }
2113
2114 #[test]
2117 fn sarif_warn_severity_produces_warning_level() {
2118 let root = PathBuf::from("/project");
2119 let mut results = AnalysisResults::default();
2120 results.unused_files.push(UnusedFile {
2121 path: root.join("src/dead.ts"),
2122 });
2123
2124 let rules = RulesConfig {
2125 unused_files: Severity::Warn,
2126 ..RulesConfig::default()
2127 };
2128
2129 let sarif = build_sarif(&results, &root, &rules);
2130 let entry = &sarif["runs"][0]["results"][0];
2131 assert_eq!(entry["level"], "warning");
2132 }
2133
2134 #[test]
2137 fn sarif_unused_file_has_no_region() {
2138 let root = PathBuf::from("/project");
2139 let mut results = AnalysisResults::default();
2140 results.unused_files.push(UnusedFile {
2141 path: root.join("src/dead.ts"),
2142 });
2143
2144 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2145 let entry = &sarif["runs"][0]["results"][0];
2146 let phys = &entry["locations"][0]["physicalLocation"];
2147 assert!(phys.get("region").is_none());
2148 }
2149
2150 #[test]
2153 fn sarif_unlisted_dep_multiple_import_sites() {
2154 let root = PathBuf::from("/project");
2155 let mut results = AnalysisResults::default();
2156 results.unlisted_dependencies.push(UnlistedDependency {
2157 package_name: "dotenv".to_string(),
2158 imported_from: vec![
2159 ImportSite {
2160 path: root.join("src/a.ts"),
2161 line: 1,
2162 col: 0,
2163 },
2164 ImportSite {
2165 path: root.join("src/b.ts"),
2166 line: 5,
2167 col: 0,
2168 },
2169 ],
2170 });
2171
2172 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2173 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2174 assert_eq!(entries.len(), 2);
2176 assert_eq!(
2177 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2178 "src/a.ts"
2179 );
2180 assert_eq!(
2181 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2182 "src/b.ts"
2183 );
2184 }
2185
2186 #[test]
2189 fn sarif_unlisted_dep_no_import_sites() {
2190 let root = PathBuf::from("/project");
2191 let mut results = AnalysisResults::default();
2192 results.unlisted_dependencies.push(UnlistedDependency {
2193 package_name: "phantom".to_string(),
2194 imported_from: vec![],
2195 });
2196
2197 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2198 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2199 assert!(entries.is_empty());
2201 }
2202}