1use std::path::{Path, PathBuf};
2use std::process::ExitCode;
3
4use fallow_config::{RulesConfig, Severity};
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::{
7 AnalysisResults, BoundaryViolation, CircularDependency, DuplicateExportFinding,
8 EmptyCatalogGroupFinding, MisconfiguredDependencyOverrideFinding, PrivateTypeLeak,
9 StaleSuppression, TestOnlyDependency, TypeOnlyDependency, UnlistedDependencyFinding,
10 UnresolvedCatalogReferenceFinding, UnresolvedImport, UnusedCatalogEntryFinding,
11 UnusedDependency, UnusedDependencyOverrideFinding, UnusedExport, UnusedFile, UnusedMember,
12};
13use rustc_hash::FxHashMap;
14
15use super::ci::{fingerprint, severity};
16use super::grouping::{self, OwnershipResolver};
17use super::{emit_json, relative_uri};
18use crate::explain;
19
20struct SarifFields {
22 rule_id: &'static str,
23 level: &'static str,
24 message: String,
25 uri: String,
26 region: Option<(u32, u32)>,
27 source_path: Option<PathBuf>,
28 properties: Option<serde_json::Value>,
29}
30
31#[derive(Default)]
32struct SourceSnippetCache {
33 files: FxHashMap<PathBuf, Vec<String>>,
34}
35
36impl SourceSnippetCache {
37 fn line(&mut self, path: &Path, line: u32) -> Option<String> {
38 if line == 0 {
39 return None;
40 }
41 if !self.files.contains_key(path) {
42 let lines = std::fs::read_to_string(path)
43 .ok()
44 .map(|source| source.lines().map(str::to_owned).collect())
45 .unwrap_or_default();
46 self.files.insert(path.to_path_buf(), lines);
47 }
48 self.files
49 .get(path)
50 .and_then(|lines| lines.get(line.saturating_sub(1) as usize))
51 .cloned()
52 }
53}
54
55fn severity_to_sarif_level(s: Severity) -> &'static str {
56 severity::sarif_level(s)
57}
58
59fn configured_sarif_level(s: Severity) -> &'static str {
60 match s {
61 Severity::Error | Severity::Warn => severity_to_sarif_level(s),
62 Severity::Off => "none",
63 }
64}
65
66fn sarif_result(
71 rule_id: &str,
72 level: &str,
73 message: &str,
74 uri: &str,
75 region: Option<(u32, u32)>,
76) -> serde_json::Value {
77 sarif_result_with_snippet(rule_id, level, message, uri, region, None)
78}
79
80fn sarif_result_with_snippet(
81 rule_id: &str,
82 level: &str,
83 message: &str,
84 uri: &str,
85 region: Option<(u32, u32)>,
86 snippet: Option<&str>,
87) -> serde_json::Value {
88 let mut physical_location = serde_json::json!({
89 "artifactLocation": { "uri": uri }
90 });
91 if let Some((line, col)) = region {
92 physical_location["region"] = serde_json::json!({
93 "startLine": line,
94 "startColumn": col
95 });
96 }
97 let line = region.map_or_else(String::new, |(line, _)| line.to_string());
98 let col = region.map_or_else(String::new, |(_, col)| col.to_string());
99 let normalized_snippet = snippet
100 .map(fingerprint::normalize_snippet)
101 .filter(|snippet| !snippet.is_empty());
102 let partial_fingerprint = normalized_snippet.as_ref().map_or_else(
103 || fingerprint::fingerprint_hash(&[rule_id, uri, &line, &col]),
104 |snippet| fingerprint::finding_fingerprint(rule_id, uri, snippet),
105 );
106 let partial_fingerprint_ghas = partial_fingerprint.clone();
107 serde_json::json!({
108 "ruleId": rule_id,
109 "level": level,
110 "message": { "text": message },
111 "locations": [{ "physicalLocation": physical_location }],
112 "partialFingerprints": {
113 fingerprint::FINGERPRINT_KEY: partial_fingerprint,
114 fingerprint::GHAS_FINGERPRINT_KEY: partial_fingerprint_ghas
115 }
116 })
117}
118
119fn push_sarif_results<T>(
121 sarif_results: &mut Vec<serde_json::Value>,
122 items: &[T],
123 snippets: &mut SourceSnippetCache,
124 mut extract: impl FnMut(&T) -> SarifFields,
125) {
126 for item in items {
127 let fields = extract(item);
128 let source_snippet = fields
129 .source_path
130 .as_deref()
131 .zip(fields.region)
132 .and_then(|(path, (line, _))| snippets.line(path, line));
133 let mut result = sarif_result_with_snippet(
134 fields.rule_id,
135 fields.level,
136 &fields.message,
137 &fields.uri,
138 fields.region,
139 source_snippet.as_deref(),
140 );
141 if let Some(props) = fields.properties {
142 result["properties"] = props;
143 }
144 sarif_results.push(result);
145 }
146}
147
148fn sarif_rule(id: &str, fallback_short: &str, level: &str) -> serde_json::Value {
151 explain::rule_by_id(id).map_or_else(
152 || {
153 serde_json::json!({
154 "id": id,
155 "shortDescription": { "text": fallback_short },
156 "defaultConfiguration": { "level": level }
157 })
158 },
159 |def| {
160 serde_json::json!({
161 "id": id,
162 "shortDescription": { "text": def.short },
163 "fullDescription": { "text": def.full },
164 "helpUri": explain::rule_docs_url(def),
165 "defaultConfiguration": { "level": level }
166 })
167 },
168 )
169}
170
171fn sarif_export_fields(
173 export: &UnusedExport,
174 root: &Path,
175 rule_id: &'static str,
176 level: &'static str,
177 kind: &str,
178 re_kind: &str,
179) -> SarifFields {
180 let label = if export.is_re_export { re_kind } else { kind };
181 SarifFields {
182 rule_id,
183 level,
184 message: format!(
185 "{} '{}' is never imported by other modules",
186 label, export.export_name
187 ),
188 uri: relative_uri(&export.path, root),
189 region: Some((export.line, export.col + 1)),
190 source_path: Some(export.path.clone()),
191 properties: if export.is_re_export {
192 Some(serde_json::json!({ "is_re_export": true }))
193 } else {
194 None
195 },
196 }
197}
198
199fn sarif_private_type_leak_fields(
200 leak: &PrivateTypeLeak,
201 root: &Path,
202 level: &'static str,
203) -> SarifFields {
204 SarifFields {
205 rule_id: "fallow/private-type-leak",
206 level,
207 message: format!(
208 "Export '{}' references private type '{}'",
209 leak.export_name, leak.type_name
210 ),
211 uri: relative_uri(&leak.path, root),
212 region: Some((leak.line, leak.col + 1)),
213 source_path: Some(leak.path.clone()),
214 properties: None,
215 }
216}
217
218fn sarif_dep_fields(
220 dep: &UnusedDependency,
221 root: &Path,
222 rule_id: &'static str,
223 level: &'static str,
224 section: &str,
225) -> SarifFields {
226 let workspace_context = if dep.used_in_workspaces.is_empty() {
227 String::new()
228 } else {
229 let workspaces = dep
230 .used_in_workspaces
231 .iter()
232 .map(|path| relative_uri(path, root))
233 .collect::<Vec<_>>()
234 .join(", ");
235 format!("; imported in other workspaces: {workspaces}")
236 };
237 SarifFields {
238 rule_id,
239 level,
240 message: format!(
241 "Package '{}' is in {} but never imported{}",
242 dep.package_name, section, workspace_context
243 ),
244 uri: relative_uri(&dep.path, root),
245 region: if dep.line > 0 {
246 Some((dep.line, 1))
247 } else {
248 None
249 },
250 source_path: (dep.line > 0).then(|| dep.path.clone()),
251 properties: None,
252 }
253}
254
255fn sarif_member_fields(
257 member: &UnusedMember,
258 root: &Path,
259 rule_id: &'static str,
260 level: &'static str,
261 kind: &str,
262) -> SarifFields {
263 SarifFields {
264 rule_id,
265 level,
266 message: format!(
267 "{} member '{}.{}' is never referenced",
268 kind, member.parent_name, member.member_name
269 ),
270 uri: relative_uri(&member.path, root),
271 region: Some((member.line, member.col + 1)),
272 source_path: Some(member.path.clone()),
273 properties: None,
274 }
275}
276
277fn sarif_unused_file_fields(file: &UnusedFile, root: &Path, level: &'static str) -> SarifFields {
278 SarifFields {
279 rule_id: "fallow/unused-file",
280 level,
281 message: "File is not reachable from any entry point".to_string(),
282 uri: relative_uri(&file.path, root),
283 region: None,
284 source_path: None,
285 properties: None,
286 }
287}
288
289fn sarif_type_only_dep_fields(
290 dep: &TypeOnlyDependency,
291 root: &Path,
292 level: &'static str,
293) -> SarifFields {
294 SarifFields {
295 rule_id: "fallow/type-only-dependency",
296 level,
297 message: format!(
298 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
299 dep.package_name
300 ),
301 uri: relative_uri(&dep.path, root),
302 region: if dep.line > 0 {
303 Some((dep.line, 1))
304 } else {
305 None
306 },
307 source_path: (dep.line > 0).then(|| dep.path.clone()),
308 properties: None,
309 }
310}
311
312fn sarif_test_only_dep_fields(
313 dep: &TestOnlyDependency,
314 root: &Path,
315 level: &'static str,
316) -> SarifFields {
317 SarifFields {
318 rule_id: "fallow/test-only-dependency",
319 level,
320 message: format!(
321 "Package '{}' is only imported by test files (consider moving to devDependencies)",
322 dep.package_name
323 ),
324 uri: relative_uri(&dep.path, root),
325 region: if dep.line > 0 {
326 Some((dep.line, 1))
327 } else {
328 None
329 },
330 source_path: (dep.line > 0).then(|| dep.path.clone()),
331 properties: None,
332 }
333}
334
335fn sarif_unresolved_import_fields(
336 import: &UnresolvedImport,
337 root: &Path,
338 level: &'static str,
339) -> SarifFields {
340 SarifFields {
341 rule_id: "fallow/unresolved-import",
342 level,
343 message: format!("Import '{}' could not be resolved", import.specifier),
344 uri: relative_uri(&import.path, root),
345 region: Some((import.line, import.col + 1)),
346 source_path: Some(import.path.clone()),
347 properties: None,
348 }
349}
350
351fn sarif_circular_dep_fields(
352 cycle: &CircularDependency,
353 root: &Path,
354 level: &'static str,
355) -> SarifFields {
356 let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
357 let mut display_chain = chain.clone();
358 if let Some(first) = chain.first() {
359 display_chain.push(first.clone());
360 }
361 let first_uri = chain.first().map_or_else(String::new, Clone::clone);
362 let first_path = cycle.files.first().cloned();
363 SarifFields {
364 rule_id: "fallow/circular-dependency",
365 level,
366 message: format!(
367 "Circular dependency{}: {}",
368 if cycle.is_cross_package {
369 " (cross-package)"
370 } else {
371 ""
372 },
373 display_chain.join(" \u{2192} ")
374 ),
375 uri: first_uri,
376 region: if cycle.line > 0 {
377 Some((cycle.line, cycle.col + 1))
378 } else {
379 None
380 },
381 source_path: (cycle.line > 0).then_some(first_path).flatten(),
382 properties: None,
383 }
384}
385
386fn sarif_re_export_cycle_fields(
387 cycle: &fallow_core::results::ReExportCycle,
388 root: &Path,
389 level: &'static str,
390) -> SarifFields {
391 let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
392 let first_uri = chain.first().map_or_else(String::new, Clone::clone);
393 let first_path = cycle.files.first().cloned();
394 let kind_tag = match cycle.kind {
395 fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
396 fallow_core::results::ReExportCycleKind::MultiNode => "",
397 };
398 SarifFields {
399 rule_id: "fallow/re-export-cycle",
400 level,
401 message: format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
402 uri: first_uri,
403 region: None,
404 source_path: first_path,
405 properties: None,
406 }
407}
408
409fn sarif_boundary_violation_fields(
410 violation: &BoundaryViolation,
411 root: &Path,
412 level: &'static str,
413) -> SarifFields {
414 let from_uri = relative_uri(&violation.from_path, root);
415 let to_uri = relative_uri(&violation.to_path, root);
416 SarifFields {
417 rule_id: "fallow/boundary-violation",
418 level,
419 message: format!(
420 "Import from zone '{}' to zone '{}' is not allowed ({})",
421 violation.from_zone, violation.to_zone, to_uri,
422 ),
423 uri: from_uri,
424 region: if violation.line > 0 {
425 Some((violation.line, violation.col + 1))
426 } else {
427 None
428 },
429 source_path: (violation.line > 0).then(|| violation.from_path.clone()),
430 properties: None,
431 }
432}
433
434fn sarif_stale_suppression_fields(
435 suppression: &StaleSuppression,
436 root: &Path,
437 level: &'static str,
438) -> SarifFields {
439 SarifFields {
440 rule_id: "fallow/stale-suppression",
441 level,
442 message: suppression.display_message(),
443 uri: relative_uri(&suppression.path, root),
444 region: Some((suppression.line, suppression.col + 1)),
445 source_path: Some(suppression.path.clone()),
446 properties: None,
447 }
448}
449
450fn sarif_unused_catalog_entry_fields(
451 entry: &UnusedCatalogEntryFinding,
452 root: &Path,
453 level: &'static str,
454) -> SarifFields {
455 let entry = &entry.entry;
456 let message = if entry.catalog_name == "default" {
457 format!(
458 "Catalog entry '{}' is not referenced by any workspace package",
459 entry.entry_name
460 )
461 } else {
462 format!(
463 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
464 entry.entry_name, entry.catalog_name
465 )
466 };
467 SarifFields {
468 rule_id: "fallow/unused-catalog-entry",
469 level,
470 message,
471 uri: relative_uri(&entry.path, root),
472 region: Some((entry.line, 1)),
473 source_path: Some(entry.path.clone()),
474 properties: None,
475 }
476}
477
478fn sarif_unused_dependency_override_fields(
479 finding: &UnusedDependencyOverrideFinding,
480 root: &Path,
481 level: &'static str,
482) -> SarifFields {
483 let finding = &finding.entry;
484 let mut message = format!(
485 "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
486 finding.raw_key, finding.version_range, finding.target_package,
487 );
488 if let Some(hint) = &finding.hint {
489 use std::fmt::Write as _;
490 let _ = write!(message, " ({hint})");
491 }
492 SarifFields {
493 rule_id: "fallow/unused-dependency-override",
494 level,
495 message,
496 uri: relative_uri(&finding.path, root),
497 region: Some((finding.line, 1)),
498 source_path: Some(finding.path.clone()),
499 properties: None,
500 }
501}
502
503fn sarif_misconfigured_dependency_override_fields(
504 finding: &MisconfiguredDependencyOverrideFinding,
505 root: &Path,
506 level: &'static str,
507) -> SarifFields {
508 let finding = &finding.entry;
509 let message = format!(
510 "Override `{}` -> `{}` is malformed: {}",
511 finding.raw_key,
512 finding.raw_value,
513 finding.reason.describe(),
514 );
515 SarifFields {
516 rule_id: "fallow/misconfigured-dependency-override",
517 level,
518 message,
519 uri: relative_uri(&finding.path, root),
520 region: Some((finding.line, 1)),
521 source_path: Some(finding.path.clone()),
522 properties: None,
523 }
524}
525
526fn sarif_unresolved_catalog_reference_fields(
527 finding: &UnresolvedCatalogReferenceFinding,
528 root: &Path,
529 level: &'static str,
530) -> SarifFields {
531 let finding = &finding.reference;
532 let catalog_phrase = if finding.catalog_name == "default" {
533 "the default catalog".to_string()
534 } else {
535 format!("catalog '{}'", finding.catalog_name)
536 };
537 let mut message = format!(
538 "Package '{}' is referenced via `catalog:{}` but {} does not declare it",
539 finding.entry_name,
540 if finding.catalog_name == "default" {
541 ""
542 } else {
543 finding.catalog_name.as_str()
544 },
545 catalog_phrase,
546 );
547 if !finding.available_in_catalogs.is_empty() {
548 use std::fmt::Write as _;
549 let _ = write!(
550 message,
551 " (available in: {})",
552 finding.available_in_catalogs.join(", ")
553 );
554 }
555 SarifFields {
556 rule_id: "fallow/unresolved-catalog-reference",
557 level,
558 message,
559 uri: relative_uri(&finding.path, root),
560 region: Some((finding.line, 1)),
561 source_path: Some(finding.path.clone()),
562 properties: None,
563 }
564}
565
566fn sarif_empty_catalog_group_fields(
567 group: &EmptyCatalogGroupFinding,
568 root: &Path,
569 level: &'static str,
570) -> SarifFields {
571 let group = &group.group;
572 SarifFields {
573 rule_id: "fallow/empty-catalog-group",
574 level,
575 message: format!("Catalog group '{}' has no entries", group.catalog_name),
576 uri: relative_uri(&group.path, root),
577 region: Some((group.line, 1)),
578 source_path: Some(group.path.clone()),
579 properties: None,
580 }
581}
582
583fn push_sarif_unlisted_deps(
586 sarif_results: &mut Vec<serde_json::Value>,
587 deps: &[UnlistedDependencyFinding],
588 root: &Path,
589 level: &'static str,
590 snippets: &mut SourceSnippetCache,
591) {
592 for entry in deps {
593 let dep = &entry.dep;
594 for site in &dep.imported_from {
595 let uri = relative_uri(&site.path, root);
596 let source_snippet = snippets.line(&site.path, site.line);
597 sarif_results.push(sarif_result_with_snippet(
598 "fallow/unlisted-dependency",
599 level,
600 &format!(
601 "Package '{}' is imported but not listed in package.json",
602 dep.package_name
603 ),
604 &uri,
605 Some((site.line, site.col + 1)),
606 source_snippet.as_deref(),
607 ));
608 }
609 }
610}
611
612fn push_sarif_duplicate_exports(
615 sarif_results: &mut Vec<serde_json::Value>,
616 dups: &[DuplicateExportFinding],
617 root: &Path,
618 level: &'static str,
619 snippets: &mut SourceSnippetCache,
620) {
621 for dup in dups {
622 let dup = &dup.export;
623 for loc in &dup.locations {
624 let uri = relative_uri(&loc.path, root);
625 let source_snippet = snippets.line(&loc.path, loc.line);
626 sarif_results.push(sarif_result_with_snippet(
627 "fallow/duplicate-export",
628 level,
629 &format!("Export '{}' appears in multiple modules", dup.export_name),
630 &uri,
631 Some((loc.line, loc.col + 1)),
632 source_snippet.as_deref(),
633 ));
634 }
635 }
636}
637
638fn build_sarif_rules(rules: &RulesConfig) -> Vec<serde_json::Value> {
640 [
641 (
642 "fallow/unused-file",
643 "File is not reachable from any entry point",
644 rules.unused_files,
645 ),
646 (
647 "fallow/unused-export",
648 "Export is never imported",
649 rules.unused_exports,
650 ),
651 (
652 "fallow/unused-type",
653 "Type export is never imported",
654 rules.unused_types,
655 ),
656 (
657 "fallow/private-type-leak",
658 "Exported signature references a same-file private type",
659 rules.private_type_leaks,
660 ),
661 (
662 "fallow/unused-dependency",
663 "Dependency listed but never imported",
664 rules.unused_dependencies,
665 ),
666 (
667 "fallow/unused-dev-dependency",
668 "Dev dependency listed but never imported",
669 rules.unused_dev_dependencies,
670 ),
671 (
672 "fallow/unused-optional-dependency",
673 "Optional dependency listed but never imported",
674 rules.unused_optional_dependencies,
675 ),
676 (
677 "fallow/type-only-dependency",
678 "Production dependency only used via type-only imports",
679 rules.type_only_dependencies,
680 ),
681 (
682 "fallow/test-only-dependency",
683 "Production dependency only imported by test files",
684 rules.test_only_dependencies,
685 ),
686 (
687 "fallow/unused-enum-member",
688 "Enum member is never referenced",
689 rules.unused_enum_members,
690 ),
691 (
692 "fallow/unused-class-member",
693 "Class member is never referenced",
694 rules.unused_class_members,
695 ),
696 (
697 "fallow/unresolved-import",
698 "Import could not be resolved",
699 rules.unresolved_imports,
700 ),
701 (
702 "fallow/unlisted-dependency",
703 "Dependency used but not in package.json",
704 rules.unlisted_dependencies,
705 ),
706 (
707 "fallow/duplicate-export",
708 "Export name appears in multiple modules",
709 rules.duplicate_exports,
710 ),
711 (
712 "fallow/circular-dependency",
713 "Circular dependency chain detected",
714 rules.circular_dependencies,
715 ),
716 (
717 "fallow/re-export-cycle",
718 "Two or more barrel files re-export from each other in a loop",
719 rules.re_export_cycle,
720 ),
721 (
722 "fallow/boundary-violation",
723 "Import crosses an architecture boundary",
724 rules.boundary_violation,
725 ),
726 (
727 "fallow/stale-suppression",
728 "Suppression comment or tag no longer matches any issue",
729 rules.stale_suppressions,
730 ),
731 (
732 "fallow/unused-catalog-entry",
733 "pnpm catalog entry not referenced by any workspace package",
734 rules.unused_catalog_entries,
735 ),
736 (
737 "fallow/empty-catalog-group",
738 "pnpm named catalog group has no entries",
739 rules.empty_catalog_groups,
740 ),
741 (
742 "fallow/unresolved-catalog-reference",
743 "package.json catalog reference points at a catalog that does not declare the package",
744 rules.unresolved_catalog_references,
745 ),
746 (
747 "fallow/unused-dependency-override",
748 "pnpm dependency override target is not declared or lockfile-resolved",
749 rules.unused_dependency_overrides,
750 ),
751 (
752 "fallow/misconfigured-dependency-override",
753 "pnpm dependency override key or value is malformed",
754 rules.misconfigured_dependency_overrides,
755 ),
756 ]
757 .into_iter()
758 .map(|(id, description, rule_severity)| {
759 sarif_rule(id, description, configured_sarif_level(rule_severity))
760 })
761 .collect()
762}
763
764#[must_use]
765#[expect(
766 clippy::too_many_lines,
767 reason = "SARIF builds one flat result list across every analysis family"
768)]
769pub fn build_sarif(
770 results: &AnalysisResults,
771 root: &Path,
772 rules: &RulesConfig,
773) -> serde_json::Value {
774 let mut sarif_results = Vec::new();
775 let mut snippets = SourceSnippetCache::default();
776
777 push_sarif_results(
778 &mut sarif_results,
779 &results.unused_files,
780 &mut snippets,
781 |f| sarif_unused_file_fields(&f.file, root, severity_to_sarif_level(rules.unused_files)),
782 );
783 push_sarif_results(
784 &mut sarif_results,
785 &results.unused_exports,
786 &mut snippets,
787 |e| {
788 sarif_export_fields(
789 &e.export,
790 root,
791 "fallow/unused-export",
792 severity_to_sarif_level(rules.unused_exports),
793 "Export",
794 "Re-export",
795 )
796 },
797 );
798 push_sarif_results(
799 &mut sarif_results,
800 &results.unused_types,
801 &mut snippets,
802 |e| {
803 sarif_export_fields(
804 &e.export,
805 root,
806 "fallow/unused-type",
807 severity_to_sarif_level(rules.unused_types),
808 "Type export",
809 "Type re-export",
810 )
811 },
812 );
813 push_sarif_results(
814 &mut sarif_results,
815 &results.private_type_leaks,
816 &mut snippets,
817 |e| {
818 sarif_private_type_leak_fields(
819 &e.leak,
820 root,
821 severity_to_sarif_level(rules.private_type_leaks),
822 )
823 },
824 );
825 push_sarif_results(
826 &mut sarif_results,
827 &results.unused_dependencies,
828 &mut snippets,
829 |d| {
830 sarif_dep_fields(
831 &d.dep,
832 root,
833 "fallow/unused-dependency",
834 severity_to_sarif_level(rules.unused_dependencies),
835 "dependencies",
836 )
837 },
838 );
839 push_sarif_results(
840 &mut sarif_results,
841 &results.unused_dev_dependencies,
842 &mut snippets,
843 |d| {
844 sarif_dep_fields(
845 &d.dep,
846 root,
847 "fallow/unused-dev-dependency",
848 severity_to_sarif_level(rules.unused_dev_dependencies),
849 "devDependencies",
850 )
851 },
852 );
853 push_sarif_results(
854 &mut sarif_results,
855 &results.unused_optional_dependencies,
856 &mut snippets,
857 |d| {
858 sarif_dep_fields(
859 &d.dep,
860 root,
861 "fallow/unused-optional-dependency",
862 severity_to_sarif_level(rules.unused_optional_dependencies),
863 "optionalDependencies",
864 )
865 },
866 );
867 push_sarif_results(
868 &mut sarif_results,
869 &results.type_only_dependencies,
870 &mut snippets,
871 |d| {
872 sarif_type_only_dep_fields(
873 &d.dep,
874 root,
875 severity_to_sarif_level(rules.type_only_dependencies),
876 )
877 },
878 );
879 push_sarif_results(
880 &mut sarif_results,
881 &results.test_only_dependencies,
882 &mut snippets,
883 |d| {
884 sarif_test_only_dep_fields(
885 &d.dep,
886 root,
887 severity_to_sarif_level(rules.test_only_dependencies),
888 )
889 },
890 );
891 push_sarif_results(
892 &mut sarif_results,
893 &results.unused_enum_members,
894 &mut snippets,
895 |m| {
896 sarif_member_fields(
897 &m.member,
898 root,
899 "fallow/unused-enum-member",
900 severity_to_sarif_level(rules.unused_enum_members),
901 "Enum",
902 )
903 },
904 );
905 push_sarif_results(
906 &mut sarif_results,
907 &results.unused_class_members,
908 &mut snippets,
909 |m| {
910 sarif_member_fields(
911 &m.member,
912 root,
913 "fallow/unused-class-member",
914 severity_to_sarif_level(rules.unused_class_members),
915 "Class",
916 )
917 },
918 );
919 push_sarif_results(
920 &mut sarif_results,
921 &results.unresolved_imports,
922 &mut snippets,
923 |i| {
924 sarif_unresolved_import_fields(
925 &i.import,
926 root,
927 severity_to_sarif_level(rules.unresolved_imports),
928 )
929 },
930 );
931 if !results.unlisted_dependencies.is_empty() {
932 push_sarif_unlisted_deps(
933 &mut sarif_results,
934 &results.unlisted_dependencies,
935 root,
936 severity_to_sarif_level(rules.unlisted_dependencies),
937 &mut snippets,
938 );
939 }
940 if !results.duplicate_exports.is_empty() {
941 push_sarif_duplicate_exports(
942 &mut sarif_results,
943 &results.duplicate_exports,
944 root,
945 severity_to_sarif_level(rules.duplicate_exports),
946 &mut snippets,
947 );
948 }
949 push_sarif_results(
950 &mut sarif_results,
951 &results.circular_dependencies,
952 &mut snippets,
953 |c| {
954 sarif_circular_dep_fields(
955 &c.cycle,
956 root,
957 severity_to_sarif_level(rules.circular_dependencies),
958 )
959 },
960 );
961 push_sarif_results(
962 &mut sarif_results,
963 &results.re_export_cycles,
964 &mut snippets,
965 |c| {
966 sarif_re_export_cycle_fields(
967 &c.cycle,
968 root,
969 severity_to_sarif_level(rules.re_export_cycle),
970 )
971 },
972 );
973 push_sarif_results(
974 &mut sarif_results,
975 &results.boundary_violations,
976 &mut snippets,
977 |v| {
978 sarif_boundary_violation_fields(
979 &v.violation,
980 root,
981 severity_to_sarif_level(rules.boundary_violation),
982 )
983 },
984 );
985 push_sarif_results(
986 &mut sarif_results,
987 &results.stale_suppressions,
988 &mut snippets,
989 |s| {
990 sarif_stale_suppression_fields(
991 s,
992 root,
993 severity_to_sarif_level(rules.stale_suppressions),
994 )
995 },
996 );
997 push_sarif_results(
998 &mut sarif_results,
999 &results.unused_catalog_entries,
1000 &mut snippets,
1001 |e| {
1002 sarif_unused_catalog_entry_fields(
1003 e,
1004 root,
1005 severity_to_sarif_level(rules.unused_catalog_entries),
1006 )
1007 },
1008 );
1009 push_sarif_results(
1010 &mut sarif_results,
1011 &results.empty_catalog_groups,
1012 &mut snippets,
1013 |g| {
1014 sarif_empty_catalog_group_fields(
1015 g,
1016 root,
1017 severity_to_sarif_level(rules.empty_catalog_groups),
1018 )
1019 },
1020 );
1021 push_sarif_results(
1022 &mut sarif_results,
1023 &results.unresolved_catalog_references,
1024 &mut snippets,
1025 |f| {
1026 sarif_unresolved_catalog_reference_fields(
1027 f,
1028 root,
1029 severity_to_sarif_level(rules.unresolved_catalog_references),
1030 )
1031 },
1032 );
1033 push_sarif_results(
1034 &mut sarif_results,
1035 &results.unused_dependency_overrides,
1036 &mut snippets,
1037 |f| {
1038 sarif_unused_dependency_override_fields(
1039 f,
1040 root,
1041 severity_to_sarif_level(rules.unused_dependency_overrides),
1042 )
1043 },
1044 );
1045 push_sarif_results(
1046 &mut sarif_results,
1047 &results.misconfigured_dependency_overrides,
1048 &mut snippets,
1049 |f| {
1050 sarif_misconfigured_dependency_override_fields(
1051 f,
1052 root,
1053 severity_to_sarif_level(rules.misconfigured_dependency_overrides),
1054 )
1055 },
1056 );
1057
1058 serde_json::json!({
1059 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1060 "version": "2.1.0",
1061 "runs": [{
1062 "tool": {
1063 "driver": {
1064 "name": "fallow",
1065 "version": env!("CARGO_PKG_VERSION"),
1066 "informationUri": "https://github.com/fallow-rs/fallow",
1067 "rules": build_sarif_rules(rules)
1068 }
1069 },
1070 "results": sarif_results
1071 }]
1072 })
1073}
1074
1075pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
1076 let sarif = build_sarif(results, root, rules);
1077 emit_json(&sarif, "SARIF")
1078}
1079
1080#[expect(
1086 clippy::expect_used,
1087 reason = "grouped SARIF entries are JSON objects created by build_sarif"
1088)]
1089pub(super) fn print_grouped_sarif(
1090 results: &AnalysisResults,
1091 root: &Path,
1092 rules: &RulesConfig,
1093 resolver: &OwnershipResolver,
1094) -> ExitCode {
1095 let mut sarif = build_sarif(results, root, rules);
1096
1097 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1098 for run in runs {
1099 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1100 for result in results {
1101 let uri = result
1102 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1103 .and_then(|v| v.as_str())
1104 .unwrap_or("");
1105 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1106 let owner =
1107 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1108 let props = result
1109 .as_object_mut()
1110 .expect("SARIF result should be an object")
1111 .entry("properties")
1112 .or_insert_with(|| serde_json::json!({}));
1113 props
1114 .as_object_mut()
1115 .expect("properties should be an object")
1116 .insert("owner".to_string(), serde_json::Value::String(owner));
1117 }
1118 }
1119 }
1120 }
1121
1122 emit_json(&sarif, "SARIF")
1123}
1124
1125#[expect(
1126 clippy::cast_possible_truncation,
1127 reason = "line/col numbers are bounded by source size"
1128)]
1129pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
1130 let mut sarif_results = Vec::new();
1131 let mut snippets = SourceSnippetCache::default();
1132
1133 for (i, group) in report.clone_groups.iter().enumerate() {
1134 for instance in &group.instances {
1135 let uri = relative_uri(&instance.file, root);
1136 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1137 sarif_results.push(sarif_result_with_snippet(
1138 "fallow/code-duplication",
1139 "warning",
1140 &format!(
1141 "Code clone group {} ({} lines, {} instances)",
1142 i + 1,
1143 group.line_count,
1144 group.instances.len()
1145 ),
1146 &uri,
1147 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1148 source_snippet.as_deref(),
1149 ));
1150 }
1151 }
1152
1153 let sarif = serde_json::json!({
1154 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1155 "version": "2.1.0",
1156 "runs": [{
1157 "tool": {
1158 "driver": {
1159 "name": "fallow",
1160 "version": env!("CARGO_PKG_VERSION"),
1161 "informationUri": "https://github.com/fallow-rs/fallow",
1162 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1163 }
1164 },
1165 "results": sarif_results
1166 }]
1167 });
1168
1169 emit_json(&sarif, "SARIF")
1170}
1171
1172#[expect(
1183 clippy::cast_possible_truncation,
1184 reason = "line/col numbers are bounded by source size"
1185)]
1186#[expect(
1187 clippy::expect_used,
1188 reason = "duplication SARIF entries are JSON objects created by sarif_result_with_snippet"
1189)]
1190pub(super) fn print_grouped_duplication_sarif(
1191 report: &DuplicationReport,
1192 root: &Path,
1193 resolver: &OwnershipResolver,
1194) -> ExitCode {
1195 let mut sarif_results = Vec::new();
1196 let mut snippets = SourceSnippetCache::default();
1197
1198 for (i, group) in report.clone_groups.iter().enumerate() {
1199 let primary_owner = super::dupes_grouping::largest_owner(group, root, resolver);
1200 for instance in &group.instances {
1201 let uri = relative_uri(&instance.file, root);
1202 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1203 let mut result = sarif_result_with_snippet(
1204 "fallow/code-duplication",
1205 "warning",
1206 &format!(
1207 "Code clone group {} ({} lines, {} instances)",
1208 i + 1,
1209 group.line_count,
1210 group.instances.len()
1211 ),
1212 &uri,
1213 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1214 source_snippet.as_deref(),
1215 );
1216 let props = result
1217 .as_object_mut()
1218 .expect("SARIF result should be an object")
1219 .entry("properties")
1220 .or_insert_with(|| serde_json::json!({}));
1221 props
1222 .as_object_mut()
1223 .expect("properties should be an object")
1224 .insert(
1225 "group".to_string(),
1226 serde_json::Value::String(primary_owner.clone()),
1227 );
1228 sarif_results.push(result);
1229 }
1230 }
1231
1232 let sarif = serde_json::json!({
1233 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1234 "version": "2.1.0",
1235 "runs": [{
1236 "tool": {
1237 "driver": {
1238 "name": "fallow",
1239 "version": env!("CARGO_PKG_VERSION"),
1240 "informationUri": "https://github.com/fallow-rs/fallow",
1241 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1242 }
1243 },
1244 "results": sarif_results
1245 }]
1246 });
1247
1248 emit_json(&sarif, "SARIF")
1249}
1250
1251#[must_use]
1252#[expect(
1253 clippy::too_many_lines,
1254 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"
1255)]
1256pub fn build_health_sarif(
1257 report: &crate::health_types::HealthReport,
1258 root: &Path,
1259) -> serde_json::Value {
1260 use crate::health_types::ExceededThreshold;
1261
1262 let mut sarif_results = Vec::new();
1263 let mut snippets = SourceSnippetCache::default();
1264
1265 for finding in &report.findings {
1266 let uri = relative_uri(&finding.path, root);
1267 let (rule_id, message) = match finding.exceeded {
1268 ExceededThreshold::Cyclomatic => (
1269 "fallow/high-cyclomatic-complexity",
1270 format!(
1271 "'{}' has cyclomatic complexity {} (threshold: {})",
1272 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
1273 ),
1274 ),
1275 ExceededThreshold::Cognitive => (
1276 "fallow/high-cognitive-complexity",
1277 format!(
1278 "'{}' has cognitive complexity {} (threshold: {})",
1279 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
1280 ),
1281 ),
1282 ExceededThreshold::Both => (
1283 "fallow/high-complexity",
1284 format!(
1285 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
1286 finding.name,
1287 finding.cyclomatic,
1288 report.summary.max_cyclomatic_threshold,
1289 finding.cognitive,
1290 report.summary.max_cognitive_threshold,
1291 ),
1292 ),
1293 ExceededThreshold::Crap
1294 | ExceededThreshold::CyclomaticCrap
1295 | ExceededThreshold::CognitiveCrap
1296 | ExceededThreshold::All => {
1297 let crap = finding.crap.unwrap_or(0.0);
1298 let coverage = finding
1299 .coverage_pct
1300 .map(|pct| format!(", coverage {pct:.0}%"))
1301 .unwrap_or_default();
1302 (
1303 "fallow/high-crap-score",
1304 format!(
1305 "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
1306 finding.name,
1307 crap,
1308 report.summary.max_crap_threshold,
1309 finding.cyclomatic,
1310 coverage,
1311 ),
1312 )
1313 }
1314 };
1315
1316 let level = match finding.severity {
1317 crate::health_types::FindingSeverity::Critical => "error",
1318 crate::health_types::FindingSeverity::High => "warning",
1319 crate::health_types::FindingSeverity::Moderate => "note",
1320 };
1321 let source_snippet = snippets.line(&finding.path, finding.line);
1322 sarif_results.push(sarif_result_with_snippet(
1323 rule_id,
1324 level,
1325 &message,
1326 &uri,
1327 Some((finding.line, finding.col + 1)),
1328 source_snippet.as_deref(),
1329 ));
1330 }
1331
1332 if let Some(ref production) = report.runtime_coverage {
1333 append_runtime_coverage_sarif_results(&mut sarif_results, production, root, &mut snippets);
1334 }
1335 if let Some(ref intelligence) = report.coverage_intelligence {
1336 append_coverage_intelligence_sarif_results(
1337 &mut sarif_results,
1338 intelligence,
1339 root,
1340 &mut snippets,
1341 );
1342 }
1343
1344 for target in &report.targets {
1345 let uri = relative_uri(&target.path, root);
1346 let message = format!(
1347 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
1348 target.category.label(),
1349 target.recommendation,
1350 target.priority,
1351 target.efficiency,
1352 target.effort.label(),
1353 target.confidence.label(),
1354 );
1355 sarif_results.push(sarif_result(
1356 "fallow/refactoring-target",
1357 "warning",
1358 &message,
1359 &uri,
1360 None,
1361 ));
1362 }
1363
1364 if let Some(ref gaps) = report.coverage_gaps {
1365 for item in &gaps.files {
1366 let uri = relative_uri(&item.file.path, root);
1367 let message = format!(
1368 "File is runtime-reachable but has no test dependency path ({} value export{})",
1369 item.file.value_export_count,
1370 if item.file.value_export_count == 1 {
1371 ""
1372 } else {
1373 "s"
1374 },
1375 );
1376 sarif_results.push(sarif_result(
1377 "fallow/untested-file",
1378 "warning",
1379 &message,
1380 &uri,
1381 None,
1382 ));
1383 }
1384
1385 for item in &gaps.exports {
1386 let uri = relative_uri(&item.export.path, root);
1387 let message = format!(
1388 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
1389 item.export.export_name
1390 );
1391 let source_snippet = snippets.line(&item.export.path, item.export.line);
1392 sarif_results.push(sarif_result_with_snippet(
1393 "fallow/untested-export",
1394 "warning",
1395 &message,
1396 &uri,
1397 Some((item.export.line, item.export.col + 1)),
1398 source_snippet.as_deref(),
1399 ));
1400 }
1401 }
1402
1403 let health_rules = vec![
1404 sarif_rule(
1405 "fallow/high-cyclomatic-complexity",
1406 "Function has high cyclomatic complexity",
1407 "note",
1408 ),
1409 sarif_rule(
1410 "fallow/high-cognitive-complexity",
1411 "Function has high cognitive complexity",
1412 "note",
1413 ),
1414 sarif_rule(
1415 "fallow/high-complexity",
1416 "Function exceeds both complexity thresholds",
1417 "note",
1418 ),
1419 sarif_rule(
1420 "fallow/high-crap-score",
1421 "Function has a high CRAP score (high complexity combined with low coverage)",
1422 "warning",
1423 ),
1424 sarif_rule(
1425 "fallow/refactoring-target",
1426 "File identified as a high-priority refactoring candidate",
1427 "warning",
1428 ),
1429 sarif_rule(
1430 "fallow/untested-file",
1431 "Runtime-reachable file has no test dependency path",
1432 "warning",
1433 ),
1434 sarif_rule(
1435 "fallow/untested-export",
1436 "Runtime-reachable export has no test dependency path",
1437 "warning",
1438 ),
1439 sarif_rule(
1440 "fallow/runtime-safe-to-delete",
1441 "Function is statically unused and was never invoked in production",
1442 "warning",
1443 ),
1444 sarif_rule(
1445 "fallow/runtime-review-required",
1446 "Function is statically used but was never invoked in production",
1447 "warning",
1448 ),
1449 sarif_rule(
1450 "fallow/runtime-low-traffic",
1451 "Function was invoked below the low-traffic threshold relative to total trace count",
1452 "note",
1453 ),
1454 sarif_rule(
1455 "fallow/runtime-coverage-unavailable",
1456 "Runtime coverage could not be resolved for this function",
1457 "note",
1458 ),
1459 sarif_rule(
1460 "fallow/runtime-coverage",
1461 "Runtime coverage finding",
1462 "note",
1463 ),
1464 sarif_rule(
1465 "fallow/coverage-intelligence-risky-change",
1466 "Changed hot path combines high CRAP and low test coverage",
1467 "warning",
1468 ),
1469 sarif_rule(
1470 "fallow/coverage-intelligence-delete",
1471 "Static and runtime evidence indicate code can be deleted",
1472 "warning",
1473 ),
1474 sarif_rule(
1475 "fallow/coverage-intelligence-review",
1476 "Cold reachable uncovered code needs owner review",
1477 "warning",
1478 ),
1479 sarif_rule(
1480 "fallow/coverage-intelligence-refactor",
1481 "Hot covered code has high CRAP and should be refactored carefully",
1482 "warning",
1483 ),
1484 ];
1485
1486 serde_json::json!({
1487 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1488 "version": "2.1.0",
1489 "runs": [{
1490 "tool": {
1491 "driver": {
1492 "name": "fallow",
1493 "version": env!("CARGO_PKG_VERSION"),
1494 "informationUri": "https://github.com/fallow-rs/fallow",
1495 "rules": health_rules
1496 }
1497 },
1498 "results": sarif_results
1499 }]
1500 })
1501}
1502
1503fn append_runtime_coverage_sarif_results(
1504 sarif_results: &mut Vec<serde_json::Value>,
1505 production: &crate::health_types::RuntimeCoverageReport,
1506 root: &Path,
1507 snippets: &mut SourceSnippetCache,
1508) {
1509 for finding in &production.findings {
1510 let uri = relative_uri(&finding.path, root);
1511 let rule_id = match finding.verdict {
1512 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
1513 "fallow/runtime-safe-to-delete"
1514 }
1515 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
1516 "fallow/runtime-review-required"
1517 }
1518 crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
1519 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
1520 "fallow/runtime-coverage-unavailable"
1521 }
1522 crate::health_types::RuntimeCoverageVerdict::Active
1523 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
1524 };
1525 let level = match finding.verdict {
1526 crate::health_types::RuntimeCoverageVerdict::SafeToDelete
1527 | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
1528 _ => "note",
1529 };
1530 let invocations_hint = finding.invocations.map_or_else(
1531 || "untracked".to_owned(),
1532 |hits| format!("{hits} invocations"),
1533 );
1534 let message = format!(
1535 "'{}' runtime coverage verdict: {} ({})",
1536 finding.function,
1537 finding.verdict.human_label(),
1538 invocations_hint,
1539 );
1540 let source_snippet = snippets.line(&finding.path, finding.line);
1541 sarif_results.push(sarif_result_with_snippet(
1542 rule_id,
1543 level,
1544 &message,
1545 &uri,
1546 Some((finding.line, 1)),
1547 source_snippet.as_deref(),
1548 ));
1549 }
1550}
1551
1552fn append_coverage_intelligence_sarif_results(
1553 sarif_results: &mut Vec<serde_json::Value>,
1554 intelligence: &crate::health_types::CoverageIntelligenceReport,
1555 root: &Path,
1556 snippets: &mut SourceSnippetCache,
1557) {
1558 for finding in &intelligence.findings {
1559 let rule_id = coverage_intelligence_rule_id(finding.recommendation);
1560 let level = match finding.verdict {
1561 crate::health_types::CoverageIntelligenceVerdict::Clean
1562 | crate::health_types::CoverageIntelligenceVerdict::Unknown => continue,
1563 _ => "warning",
1564 };
1565 let uri = relative_uri(&finding.path, root);
1566 let identity = finding.identity.as_deref().unwrap_or("code");
1567 let signals = finding
1568 .signals
1569 .iter()
1570 .map(ToString::to_string)
1571 .collect::<Vec<_>>()
1572 .join(", ");
1573 let message = format!(
1574 "'{}' coverage intelligence verdict: {} ({}, signals: {})",
1575 identity, finding.verdict, finding.recommendation, signals,
1576 );
1577 let source_snippet = snippets.line(&finding.path, finding.line);
1578 let mut result = sarif_result_with_snippet(
1579 rule_id,
1580 level,
1581 &message,
1582 &uri,
1583 Some((finding.line, 1)),
1584 source_snippet.as_deref(),
1585 );
1586 result["properties"] = serde_json::json!({
1587 "coverage_intelligence_id": &finding.id,
1588 "verdict": finding.verdict,
1589 "recommendation": finding.recommendation,
1590 "confidence": finding.confidence,
1591 "signals": &finding.signals,
1592 "related_ids": &finding.related_ids,
1593 });
1594 sarif_results.push(result);
1595 }
1596}
1597
1598fn coverage_intelligence_rule_id(
1599 recommendation: crate::health_types::CoverageIntelligenceRecommendation,
1600) -> &'static str {
1601 match recommendation {
1602 crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
1603 "fallow/coverage-intelligence-risky-change"
1604 }
1605 crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
1606 "fallow/coverage-intelligence-delete"
1607 }
1608 crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
1609 "fallow/coverage-intelligence-review"
1610 }
1611 crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
1612 "fallow/coverage-intelligence-refactor"
1613 }
1614 }
1615}
1616
1617pub(super) fn print_health_sarif(
1618 report: &crate::health_types::HealthReport,
1619 root: &Path,
1620) -> ExitCode {
1621 let sarif = build_health_sarif(report, root);
1622 emit_json(&sarif, "SARIF")
1623}
1624
1625#[expect(
1636 clippy::expect_used,
1637 reason = "grouped health SARIF entries are JSON objects created by build_health_sarif"
1638)]
1639pub(super) fn print_grouped_health_sarif(
1640 report: &crate::health_types::HealthReport,
1641 root: &Path,
1642 resolver: &OwnershipResolver,
1643) -> ExitCode {
1644 let mut sarif = build_health_sarif(report, root);
1645
1646 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1647 for run in runs {
1648 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1649 for result in results {
1650 let uri = result
1651 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1652 .and_then(|v| v.as_str())
1653 .unwrap_or("");
1654 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1655 let group =
1656 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1657 let props = result
1658 .as_object_mut()
1659 .expect("SARIF result should be an object")
1660 .entry("properties")
1661 .or_insert_with(|| serde_json::json!({}));
1662 props
1663 .as_object_mut()
1664 .expect("properties should be an object")
1665 .insert("group".to_string(), serde_json::Value::String(group));
1666 }
1667 }
1668 }
1669 }
1670
1671 emit_json(&sarif, "SARIF")
1672}
1673
1674#[cfg(test)]
1675mod tests {
1676 use super::*;
1677 use crate::report::test_helpers::sample_results;
1678 use fallow_core::results::*;
1679 use std::path::PathBuf;
1680
1681 #[test]
1682 fn sarif_has_required_top_level_fields() {
1683 let root = PathBuf::from("/project");
1684 let results = AnalysisResults::default();
1685 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1686
1687 assert_eq!(
1688 sarif["$schema"],
1689 "https://json.schemastore.org/sarif-2.1.0.json"
1690 );
1691 assert_eq!(sarif["version"], "2.1.0");
1692 assert!(sarif["runs"].is_array());
1693 }
1694
1695 #[test]
1696 fn sarif_has_tool_driver_info() {
1697 let root = PathBuf::from("/project");
1698 let results = AnalysisResults::default();
1699 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1700
1701 let driver = &sarif["runs"][0]["tool"]["driver"];
1702 assert_eq!(driver["name"], "fallow");
1703 assert!(driver["version"].is_string());
1704 assert_eq!(
1705 driver["informationUri"],
1706 "https://github.com/fallow-rs/fallow"
1707 );
1708 }
1709
1710 #[test]
1711 fn sarif_declares_all_rules() {
1712 let root = PathBuf::from("/project");
1713 let results = AnalysisResults::default();
1714 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1715
1716 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1717 .as_array()
1718 .expect("rules should be an array");
1719 assert_eq!(rules.len(), 23);
1720
1721 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
1722 assert!(rule_ids.contains(&"fallow/unused-file"));
1723 assert!(rule_ids.contains(&"fallow/unused-export"));
1724 assert!(rule_ids.contains(&"fallow/unused-type"));
1725 assert!(rule_ids.contains(&"fallow/private-type-leak"));
1726 assert!(rule_ids.contains(&"fallow/unused-dependency"));
1727 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1728 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1729 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1730 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1731 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1732 assert!(rule_ids.contains(&"fallow/unused-class-member"));
1733 assert!(rule_ids.contains(&"fallow/unresolved-import"));
1734 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1735 assert!(rule_ids.contains(&"fallow/duplicate-export"));
1736 assert!(rule_ids.contains(&"fallow/circular-dependency"));
1737 assert!(rule_ids.contains(&"fallow/re-export-cycle"));
1738 assert!(rule_ids.contains(&"fallow/boundary-violation"));
1739 assert!(rule_ids.contains(&"fallow/unused-catalog-entry"));
1740 assert!(rule_ids.contains(&"fallow/empty-catalog-group"));
1741 assert!(rule_ids.contains(&"fallow/unresolved-catalog-reference"));
1742 assert!(rule_ids.contains(&"fallow/unused-dependency-override"));
1743 assert!(rule_ids.contains(&"fallow/misconfigured-dependency-override"));
1744 }
1745
1746 #[test]
1747 fn sarif_empty_results_no_results_entries() {
1748 let root = PathBuf::from("/project");
1749 let results = AnalysisResults::default();
1750 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1751
1752 let sarif_results = sarif["runs"][0]["results"]
1753 .as_array()
1754 .expect("results should be an array");
1755 assert!(sarif_results.is_empty());
1756 }
1757
1758 #[test]
1759 fn sarif_unused_file_result() {
1760 let root = PathBuf::from("/project");
1761 let mut results = AnalysisResults::default();
1762 results
1763 .unused_files
1764 .push(UnusedFileFinding::with_actions(UnusedFile {
1765 path: root.join("src/dead.ts"),
1766 }));
1767
1768 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1769 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1770 assert_eq!(entries.len(), 1);
1771
1772 let entry = &entries[0];
1773 assert_eq!(entry["ruleId"], "fallow/unused-file");
1774 assert_eq!(entry["level"], "error");
1775 assert_eq!(
1776 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1777 "src/dead.ts"
1778 );
1779 }
1780
1781 #[test]
1782 fn sarif_unused_export_includes_region() {
1783 let root = PathBuf::from("/project");
1784 let mut results = AnalysisResults::default();
1785 results
1786 .unused_exports
1787 .push(UnusedExportFinding::with_actions(UnusedExport {
1788 path: root.join("src/utils.ts"),
1789 export_name: "helperFn".to_string(),
1790 is_type_only: false,
1791 line: 10,
1792 col: 4,
1793 span_start: 120,
1794 is_re_export: false,
1795 }));
1796
1797 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1798 let entry = &sarif["runs"][0]["results"][0];
1799 assert_eq!(entry["ruleId"], "fallow/unused-export");
1800
1801 let region = &entry["locations"][0]["physicalLocation"]["region"];
1802 assert_eq!(region["startLine"], 10);
1803 assert_eq!(region["startColumn"], 5);
1804 }
1805
1806 #[test]
1807 fn sarif_unresolved_import_is_error_level() {
1808 let root = PathBuf::from("/project");
1809 let mut results = AnalysisResults::default();
1810 results
1811 .unresolved_imports
1812 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1813 path: root.join("src/app.ts"),
1814 specifier: "./missing".to_string(),
1815 line: 1,
1816 col: 0,
1817 specifier_col: 0,
1818 }));
1819
1820 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1821 let entry = &sarif["runs"][0]["results"][0];
1822 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
1823 assert_eq!(entry["level"], "error");
1824 }
1825
1826 #[test]
1827 fn sarif_unlisted_dependency_points_to_import_site() {
1828 let root = PathBuf::from("/project");
1829 let mut results = AnalysisResults::default();
1830 results
1831 .unlisted_dependencies
1832 .push(UnlistedDependencyFinding::with_actions(
1833 UnlistedDependency {
1834 package_name: "chalk".to_string(),
1835 imported_from: vec![ImportSite {
1836 path: root.join("src/cli.ts"),
1837 line: 3,
1838 col: 0,
1839 }],
1840 },
1841 ));
1842
1843 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1844 let entry = &sarif["runs"][0]["results"][0];
1845 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
1846 assert_eq!(entry["level"], "error");
1847 assert_eq!(
1848 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1849 "src/cli.ts"
1850 );
1851 let region = &entry["locations"][0]["physicalLocation"]["region"];
1852 assert_eq!(region["startLine"], 3);
1853 assert_eq!(region["startColumn"], 1);
1854 }
1855
1856 #[test]
1857 fn sarif_dependency_issues_point_to_package_json() {
1858 let root = PathBuf::from("/project");
1859 let mut results = AnalysisResults::default();
1860 results
1861 .unused_dependencies
1862 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1863 package_name: "lodash".to_string(),
1864 location: DependencyLocation::Dependencies,
1865 path: root.join("package.json"),
1866 line: 5,
1867 used_in_workspaces: Vec::new(),
1868 }));
1869 results
1870 .unused_dev_dependencies
1871 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1872 package_name: "jest".to_string(),
1873 location: DependencyLocation::DevDependencies,
1874 path: root.join("package.json"),
1875 line: 5,
1876 used_in_workspaces: Vec::new(),
1877 }));
1878
1879 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1880 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1881 for entry in entries {
1882 assert_eq!(
1883 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1884 "package.json"
1885 );
1886 }
1887 }
1888
1889 #[test]
1890 fn sarif_duplicate_export_emits_one_result_per_location() {
1891 let root = PathBuf::from("/project");
1892 let mut results = AnalysisResults::default();
1893 results
1894 .duplicate_exports
1895 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1896 export_name: "Config".to_string(),
1897 locations: vec![
1898 DuplicateLocation {
1899 path: root.join("src/a.ts"),
1900 line: 15,
1901 col: 0,
1902 },
1903 DuplicateLocation {
1904 path: root.join("src/b.ts"),
1905 line: 30,
1906 col: 0,
1907 },
1908 ],
1909 }));
1910
1911 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1912 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1913 assert_eq!(entries.len(), 2);
1914 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
1915 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
1916 assert_eq!(
1917 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1918 "src/a.ts"
1919 );
1920 assert_eq!(
1921 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1922 "src/b.ts"
1923 );
1924 }
1925
1926 #[test]
1927 fn sarif_all_issue_types_produce_results() {
1928 let root = PathBuf::from("/project");
1929 let results = sample_results(&root);
1930 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1931
1932 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1933 assert_eq!(entries.len(), results.total_issues() + 1);
1934
1935 let rule_ids: Vec<&str> = entries
1936 .iter()
1937 .map(|e| e["ruleId"].as_str().unwrap())
1938 .collect();
1939 assert!(rule_ids.contains(&"fallow/unused-file"));
1940 assert!(rule_ids.contains(&"fallow/unused-export"));
1941 assert!(rule_ids.contains(&"fallow/unused-type"));
1942 assert!(rule_ids.contains(&"fallow/unused-dependency"));
1943 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1944 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1945 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1946 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1947 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1948 assert!(rule_ids.contains(&"fallow/unused-class-member"));
1949 assert!(rule_ids.contains(&"fallow/unresolved-import"));
1950 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1951 assert!(rule_ids.contains(&"fallow/duplicate-export"));
1952 }
1953
1954 #[test]
1955 fn sarif_serializes_to_valid_json() {
1956 let root = PathBuf::from("/project");
1957 let results = sample_results(&root);
1958 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1959
1960 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1961 let reparsed: serde_json::Value =
1962 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
1963 assert_eq!(reparsed, sarif);
1964 }
1965
1966 #[test]
1967 fn sarif_file_write_produces_valid_sarif() {
1968 let root = PathBuf::from("/project");
1969 let results = sample_results(&root);
1970 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1971 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1972
1973 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
1974 let _ = std::fs::create_dir_all(&dir);
1975 let sarif_path = dir.join("results.sarif");
1976 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
1977
1978 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
1979 let parsed: serde_json::Value =
1980 serde_json::from_str(&contents).expect("file should contain valid JSON");
1981
1982 assert_eq!(parsed["version"], "2.1.0");
1983 assert_eq!(
1984 parsed["$schema"],
1985 "https://json.schemastore.org/sarif-2.1.0.json"
1986 );
1987 let sarif_results = parsed["runs"][0]["results"]
1988 .as_array()
1989 .expect("results should be an array");
1990 assert!(!sarif_results.is_empty());
1991
1992 let _ = std::fs::remove_file(&sarif_path);
1993 let _ = std::fs::remove_dir(&dir);
1994 }
1995
1996 #[test]
1997 fn health_sarif_empty_no_results() {
1998 let root = PathBuf::from("/project");
1999 let report = crate::health_types::HealthReport {
2000 summary: crate::health_types::HealthSummary {
2001 files_analyzed: 10,
2002 functions_analyzed: 50,
2003 ..Default::default()
2004 },
2005 ..Default::default()
2006 };
2007 let sarif = build_health_sarif(&report, &root);
2008 assert_eq!(sarif["version"], "2.1.0");
2009 let results = sarif["runs"][0]["results"].as_array().unwrap();
2010 assert!(results.is_empty());
2011 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2012 .as_array()
2013 .unwrap();
2014 assert_eq!(rules.len(), 16);
2015 }
2016
2017 #[test]
2018 fn health_sarif_coverage_intelligence_preserves_structured_properties() {
2019 use crate::health_types::{
2020 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2021 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2022 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2023 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2024 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2025 HealthReport, HealthSummary,
2026 };
2027
2028 let root = PathBuf::from("/project");
2029 let report = HealthReport {
2030 summary: HealthSummary {
2031 files_analyzed: 10,
2032 functions_analyzed: 50,
2033 ..Default::default()
2034 },
2035 coverage_intelligence: Some(CoverageIntelligenceReport {
2036 schema_version: CoverageIntelligenceSchemaVersion::V1,
2037 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2038 summary: CoverageIntelligenceSummary {
2039 findings: 1,
2040 high_confidence_deletes: 1,
2041 ..Default::default()
2042 },
2043 findings: vec![CoverageIntelligenceFinding {
2044 id: "fallow:coverage-intel:abc123".to_owned(),
2045 path: root.join("src/dead.ts"),
2046 identity: Some("deadPath".to_owned()),
2047 line: 9,
2048 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2049 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2050 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2051 confidence: CoverageIntelligenceConfidence::High,
2052 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2053 evidence: CoverageIntelligenceEvidence {
2054 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2055 ..Default::default()
2056 },
2057 actions: vec![CoverageIntelligenceAction {
2058 kind: "delete-after-confirming-owner".to_owned(),
2059 description: "Confirm ownership".to_owned(),
2060 auto_fixable: false,
2061 }],
2062 }],
2063 }),
2064 ..Default::default()
2065 };
2066
2067 let sarif = build_health_sarif(&report, &root);
2068 let result = &sarif["runs"][0]["results"][0];
2069 assert_eq!(result["ruleId"], "fallow/coverage-intelligence-delete");
2070 assert_eq!(
2071 result["properties"]["coverage_intelligence_id"],
2072 "fallow:coverage-intel:abc123"
2073 );
2074 assert_eq!(
2075 result["properties"]["recommendation"],
2076 "delete-after-confirming-owner"
2077 );
2078 assert_eq!(result["properties"]["confidence"], "high");
2079 assert_eq!(result["properties"]["signals"][0], "runtime_cold");
2080 assert_eq!(
2081 result["properties"]["related_ids"][0],
2082 "fallow:prod:deadbeef"
2083 );
2084 }
2085
2086 #[test]
2087 fn health_sarif_cyclomatic_only() {
2088 let root = PathBuf::from("/project");
2089 let report = crate::health_types::HealthReport {
2090 findings: vec![
2091 crate::health_types::ComplexityViolation {
2092 path: root.join("src/utils.ts"),
2093 name: "parseExpression".to_string(),
2094 line: 42,
2095 col: 0,
2096 cyclomatic: 25,
2097 cognitive: 10,
2098 line_count: 80,
2099 param_count: 0,
2100 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
2101 severity: crate::health_types::FindingSeverity::High,
2102 crap: None,
2103 coverage_pct: None,
2104 coverage_tier: None,
2105 coverage_source: None,
2106 inherited_from: None,
2107 component_rollup: None,
2108 }
2109 .into(),
2110 ],
2111 summary: crate::health_types::HealthSummary {
2112 files_analyzed: 5,
2113 functions_analyzed: 20,
2114 functions_above_threshold: 1,
2115 ..Default::default()
2116 },
2117 ..Default::default()
2118 };
2119 let sarif = build_health_sarif(&report, &root);
2120 let entry = &sarif["runs"][0]["results"][0];
2121 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
2122 assert_eq!(entry["level"], "warning");
2123 assert!(
2124 entry["message"]["text"]
2125 .as_str()
2126 .unwrap()
2127 .contains("cyclomatic complexity 25")
2128 );
2129 assert_eq!(
2130 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2131 "src/utils.ts"
2132 );
2133 let region = &entry["locations"][0]["physicalLocation"]["region"];
2134 assert_eq!(region["startLine"], 42);
2135 assert_eq!(region["startColumn"], 1);
2136 }
2137
2138 #[test]
2139 fn health_sarif_cognitive_only() {
2140 let root = PathBuf::from("/project");
2141 let report = crate::health_types::HealthReport {
2142 findings: vec![
2143 crate::health_types::ComplexityViolation {
2144 path: root.join("src/api.ts"),
2145 name: "handleRequest".to_string(),
2146 line: 10,
2147 col: 4,
2148 cyclomatic: 8,
2149 cognitive: 20,
2150 line_count: 40,
2151 param_count: 0,
2152 exceeded: crate::health_types::ExceededThreshold::Cognitive,
2153 severity: crate::health_types::FindingSeverity::High,
2154 crap: None,
2155 coverage_pct: None,
2156 coverage_tier: None,
2157 coverage_source: None,
2158 inherited_from: None,
2159 component_rollup: None,
2160 }
2161 .into(),
2162 ],
2163 summary: crate::health_types::HealthSummary {
2164 files_analyzed: 3,
2165 functions_analyzed: 10,
2166 functions_above_threshold: 1,
2167 ..Default::default()
2168 },
2169 ..Default::default()
2170 };
2171 let sarif = build_health_sarif(&report, &root);
2172 let entry = &sarif["runs"][0]["results"][0];
2173 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
2174 assert!(
2175 entry["message"]["text"]
2176 .as_str()
2177 .unwrap()
2178 .contains("cognitive complexity 20")
2179 );
2180 let region = &entry["locations"][0]["physicalLocation"]["region"];
2181 assert_eq!(region["startColumn"], 5); }
2183
2184 #[test]
2185 fn health_sarif_both_thresholds() {
2186 let root = PathBuf::from("/project");
2187 let report = crate::health_types::HealthReport {
2188 findings: vec![
2189 crate::health_types::ComplexityViolation {
2190 path: root.join("src/complex.ts"),
2191 name: "doEverything".to_string(),
2192 line: 1,
2193 col: 0,
2194 cyclomatic: 30,
2195 cognitive: 45,
2196 line_count: 100,
2197 param_count: 0,
2198 exceeded: crate::health_types::ExceededThreshold::Both,
2199 severity: crate::health_types::FindingSeverity::High,
2200 crap: None,
2201 coverage_pct: None,
2202 coverage_tier: None,
2203 coverage_source: None,
2204 inherited_from: None,
2205 component_rollup: None,
2206 }
2207 .into(),
2208 ],
2209 summary: crate::health_types::HealthSummary {
2210 files_analyzed: 1,
2211 functions_analyzed: 1,
2212 functions_above_threshold: 1,
2213 ..Default::default()
2214 },
2215 ..Default::default()
2216 };
2217 let sarif = build_health_sarif(&report, &root);
2218 let entry = &sarif["runs"][0]["results"][0];
2219 assert_eq!(entry["ruleId"], "fallow/high-complexity");
2220 let msg = entry["message"]["text"].as_str().unwrap();
2221 assert!(msg.contains("cyclomatic complexity 30"));
2222 assert!(msg.contains("cognitive complexity 45"));
2223 }
2224
2225 #[test]
2226 fn health_sarif_crap_only_emits_crap_rule() {
2227 let root = PathBuf::from("/project");
2228 let report = crate::health_types::HealthReport {
2229 findings: vec![
2230 crate::health_types::ComplexityViolation {
2231 path: root.join("src/untested.ts"),
2232 name: "risky".to_string(),
2233 line: 8,
2234 col: 0,
2235 cyclomatic: 10,
2236 cognitive: 10,
2237 line_count: 20,
2238 param_count: 1,
2239 exceeded: crate::health_types::ExceededThreshold::Crap,
2240 severity: crate::health_types::FindingSeverity::High,
2241 crap: Some(82.2),
2242 coverage_pct: Some(12.0),
2243 coverage_tier: None,
2244 coverage_source: None,
2245 inherited_from: None,
2246 component_rollup: None,
2247 }
2248 .into(),
2249 ],
2250 summary: crate::health_types::HealthSummary {
2251 files_analyzed: 1,
2252 functions_analyzed: 1,
2253 functions_above_threshold: 1,
2254 ..Default::default()
2255 },
2256 ..Default::default()
2257 };
2258 let sarif = build_health_sarif(&report, &root);
2259 let entry = &sarif["runs"][0]["results"][0];
2260 assert_eq!(entry["ruleId"], "fallow/high-crap-score");
2261 let msg = entry["message"]["text"].as_str().unwrap();
2262 assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
2263 assert!(msg.contains("coverage 12%"), "msg: {msg}");
2264 }
2265
2266 #[test]
2267 fn health_sarif_cyclomatic_crap_uses_crap_rule() {
2268 let root = PathBuf::from("/project");
2269 let report = crate::health_types::HealthReport {
2270 findings: vec![
2271 crate::health_types::ComplexityViolation {
2272 path: root.join("src/hot.ts"),
2273 name: "branchy".to_string(),
2274 line: 1,
2275 col: 0,
2276 cyclomatic: 67,
2277 cognitive: 12,
2278 line_count: 80,
2279 param_count: 1,
2280 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
2281 severity: crate::health_types::FindingSeverity::Critical,
2282 crap: Some(182.0),
2283 coverage_pct: None,
2284 coverage_tier: None,
2285 coverage_source: None,
2286 inherited_from: None,
2287 component_rollup: None,
2288 }
2289 .into(),
2290 ],
2291 summary: crate::health_types::HealthSummary {
2292 files_analyzed: 1,
2293 functions_analyzed: 1,
2294 functions_above_threshold: 1,
2295 ..Default::default()
2296 },
2297 ..Default::default()
2298 };
2299 let sarif = build_health_sarif(&report, &root);
2300 let results = sarif["runs"][0]["results"].as_array().unwrap();
2301 assert_eq!(
2302 results.len(),
2303 1,
2304 "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
2305 );
2306 assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
2307 let msg = results[0]["message"]["text"].as_str().unwrap();
2308 assert!(msg.contains("CRAP score 182"), "msg: {msg}");
2309 assert!(!msg.contains("coverage"), "msg: {msg}");
2310 }
2311
2312 #[test]
2313 fn severity_to_sarif_level_error() {
2314 assert_eq!(severity_to_sarif_level(Severity::Error), "error");
2315 }
2316
2317 #[test]
2318 fn severity_to_sarif_level_warn() {
2319 assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
2320 }
2321
2322 #[test]
2323 #[should_panic(expected = "internal error: entered unreachable code")]
2324 fn severity_to_sarif_level_off() {
2325 let _ = severity_to_sarif_level(Severity::Off);
2326 }
2327
2328 #[test]
2329 fn sarif_re_export_has_properties() {
2330 let root = PathBuf::from("/project");
2331 let mut results = AnalysisResults::default();
2332 results
2333 .unused_exports
2334 .push(UnusedExportFinding::with_actions(UnusedExport {
2335 path: root.join("src/index.ts"),
2336 export_name: "reExported".to_string(),
2337 is_type_only: false,
2338 line: 1,
2339 col: 0,
2340 span_start: 0,
2341 is_re_export: true,
2342 }));
2343
2344 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2345 let entry = &sarif["runs"][0]["results"][0];
2346 assert_eq!(entry["properties"]["is_re_export"], true);
2347 let msg = entry["message"]["text"].as_str().unwrap();
2348 assert!(msg.starts_with("Re-export"));
2349 }
2350
2351 #[test]
2352 fn sarif_non_re_export_has_no_properties() {
2353 let root = PathBuf::from("/project");
2354 let mut results = AnalysisResults::default();
2355 results
2356 .unused_exports
2357 .push(UnusedExportFinding::with_actions(UnusedExport {
2358 path: root.join("src/utils.ts"),
2359 export_name: "foo".to_string(),
2360 is_type_only: false,
2361 line: 5,
2362 col: 0,
2363 span_start: 0,
2364 is_re_export: false,
2365 }));
2366
2367 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2368 let entry = &sarif["runs"][0]["results"][0];
2369 assert!(entry.get("properties").is_none());
2370 let msg = entry["message"]["text"].as_str().unwrap();
2371 assert!(msg.starts_with("Export"));
2372 }
2373
2374 #[test]
2375 fn sarif_type_re_export_message() {
2376 let root = PathBuf::from("/project");
2377 let mut results = AnalysisResults::default();
2378 results
2379 .unused_types
2380 .push(UnusedTypeFinding::with_actions(UnusedExport {
2381 path: root.join("src/index.ts"),
2382 export_name: "MyType".to_string(),
2383 is_type_only: true,
2384 line: 1,
2385 col: 0,
2386 span_start: 0,
2387 is_re_export: true,
2388 }));
2389
2390 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2391 let entry = &sarif["runs"][0]["results"][0];
2392 assert_eq!(entry["ruleId"], "fallow/unused-type");
2393 let msg = entry["message"]["text"].as_str().unwrap();
2394 assert!(msg.starts_with("Type re-export"));
2395 assert_eq!(entry["properties"]["is_re_export"], true);
2396 }
2397
2398 #[test]
2399 fn sarif_dependency_line_zero_skips_region() {
2400 let root = PathBuf::from("/project");
2401 let mut results = AnalysisResults::default();
2402 results
2403 .unused_dependencies
2404 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2405 package_name: "lodash".to_string(),
2406 location: DependencyLocation::Dependencies,
2407 path: root.join("package.json"),
2408 line: 0,
2409 used_in_workspaces: Vec::new(),
2410 }));
2411
2412 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2413 let entry = &sarif["runs"][0]["results"][0];
2414 let phys = &entry["locations"][0]["physicalLocation"];
2415 assert!(phys.get("region").is_none());
2416 }
2417
2418 #[test]
2419 fn sarif_dependency_line_nonzero_has_region() {
2420 let root = PathBuf::from("/project");
2421 let mut results = AnalysisResults::default();
2422 results
2423 .unused_dependencies
2424 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2425 package_name: "lodash".to_string(),
2426 location: DependencyLocation::Dependencies,
2427 path: root.join("package.json"),
2428 line: 7,
2429 used_in_workspaces: Vec::new(),
2430 }));
2431
2432 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2433 let entry = &sarif["runs"][0]["results"][0];
2434 let region = &entry["locations"][0]["physicalLocation"]["region"];
2435 assert_eq!(region["startLine"], 7);
2436 assert_eq!(region["startColumn"], 1);
2437 }
2438
2439 #[test]
2440 fn sarif_type_only_dep_line_zero_skips_region() {
2441 let root = PathBuf::from("/project");
2442 let mut results = AnalysisResults::default();
2443 results
2444 .type_only_dependencies
2445 .push(TypeOnlyDependencyFinding::with_actions(
2446 TypeOnlyDependency {
2447 package_name: "zod".to_string(),
2448 path: root.join("package.json"),
2449 line: 0,
2450 },
2451 ));
2452
2453 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2454 let entry = &sarif["runs"][0]["results"][0];
2455 let phys = &entry["locations"][0]["physicalLocation"];
2456 assert!(phys.get("region").is_none());
2457 }
2458
2459 #[test]
2460 fn sarif_circular_dep_line_zero_skips_region() {
2461 let root = PathBuf::from("/project");
2462 let mut results = AnalysisResults::default();
2463 results
2464 .circular_dependencies
2465 .push(CircularDependencyFinding::with_actions(
2466 CircularDependency {
2467 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2468 length: 2,
2469 line: 0,
2470 col: 0,
2471 is_cross_package: false,
2472 },
2473 ));
2474
2475 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2476 let entry = &sarif["runs"][0]["results"][0];
2477 let phys = &entry["locations"][0]["physicalLocation"];
2478 assert!(phys.get("region").is_none());
2479 }
2480
2481 #[test]
2482 fn sarif_circular_dep_line_nonzero_has_region() {
2483 let root = PathBuf::from("/project");
2484 let mut results = AnalysisResults::default();
2485 results
2486 .circular_dependencies
2487 .push(CircularDependencyFinding::with_actions(
2488 CircularDependency {
2489 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2490 length: 2,
2491 line: 5,
2492 col: 2,
2493 is_cross_package: false,
2494 },
2495 ));
2496
2497 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2498 let entry = &sarif["runs"][0]["results"][0];
2499 let region = &entry["locations"][0]["physicalLocation"]["region"];
2500 assert_eq!(region["startLine"], 5);
2501 assert_eq!(region["startColumn"], 3);
2502 }
2503
2504 #[test]
2505 fn sarif_unused_optional_dependency_result() {
2506 let root = PathBuf::from("/project");
2507 let mut results = AnalysisResults::default();
2508 results
2509 .unused_optional_dependencies
2510 .push(UnusedOptionalDependencyFinding::with_actions(
2511 UnusedDependency {
2512 package_name: "fsevents".to_string(),
2513 location: DependencyLocation::OptionalDependencies,
2514 path: root.join("package.json"),
2515 line: 12,
2516 used_in_workspaces: Vec::new(),
2517 },
2518 ));
2519
2520 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2521 let entry = &sarif["runs"][0]["results"][0];
2522 assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
2523 let msg = entry["message"]["text"].as_str().unwrap();
2524 assert!(msg.contains("optionalDependencies"));
2525 }
2526
2527 #[test]
2528 fn sarif_enum_member_message_format() {
2529 let root = PathBuf::from("/project");
2530 let mut results = AnalysisResults::default();
2531 results.unused_enum_members.push(
2532 fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
2533 path: root.join("src/enums.ts"),
2534 parent_name: "Color".to_string(),
2535 member_name: "Purple".to_string(),
2536 kind: fallow_core::extract::MemberKind::EnumMember,
2537 line: 5,
2538 col: 2,
2539 }),
2540 );
2541
2542 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2543 let entry = &sarif["runs"][0]["results"][0];
2544 assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
2545 let msg = entry["message"]["text"].as_str().unwrap();
2546 assert!(msg.contains("Enum member 'Color.Purple'"));
2547 let region = &entry["locations"][0]["physicalLocation"]["region"];
2548 assert_eq!(region["startColumn"], 3); }
2550
2551 #[test]
2552 fn sarif_class_member_message_format() {
2553 let root = PathBuf::from("/project");
2554 let mut results = AnalysisResults::default();
2555 results.unused_class_members.push(
2556 fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
2557 path: root.join("src/service.ts"),
2558 parent_name: "API".to_string(),
2559 member_name: "fetch".to_string(),
2560 kind: fallow_core::extract::MemberKind::ClassMethod,
2561 line: 10,
2562 col: 4,
2563 }),
2564 );
2565
2566 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2567 let entry = &sarif["runs"][0]["results"][0];
2568 assert_eq!(entry["ruleId"], "fallow/unused-class-member");
2569 let msg = entry["message"]["text"].as_str().unwrap();
2570 assert!(msg.contains("Class member 'API.fetch'"));
2571 }
2572
2573 #[test]
2574 #[expect(
2575 clippy::cast_possible_truncation,
2576 reason = "test line/col values are trivially small"
2577 )]
2578 fn duplication_sarif_structure() {
2579 use fallow_core::duplicates::*;
2580
2581 let root = PathBuf::from("/project");
2582 let report = DuplicationReport {
2583 clone_groups: vec![CloneGroup {
2584 instances: vec![
2585 CloneInstance {
2586 file: root.join("src/a.ts"),
2587 start_line: 1,
2588 end_line: 10,
2589 start_col: 0,
2590 end_col: 0,
2591 fragment: String::new(),
2592 },
2593 CloneInstance {
2594 file: root.join("src/b.ts"),
2595 start_line: 5,
2596 end_line: 14,
2597 start_col: 2,
2598 end_col: 0,
2599 fragment: String::new(),
2600 },
2601 ],
2602 token_count: 50,
2603 line_count: 10,
2604 }],
2605 clone_families: vec![],
2606 mirrored_directories: vec![],
2607 stats: DuplicationStats::default(),
2608 };
2609
2610 let sarif = serde_json::json!({
2611 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2612 "version": "2.1.0",
2613 "runs": [{
2614 "tool": {
2615 "driver": {
2616 "name": "fallow",
2617 "version": env!("CARGO_PKG_VERSION"),
2618 "informationUri": "https://github.com/fallow-rs/fallow",
2619 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2620 }
2621 },
2622 "results": []
2623 }]
2624 });
2625 let _ = sarif;
2626
2627 let mut sarif_results = Vec::new();
2628 for (i, group) in report.clone_groups.iter().enumerate() {
2629 for instance in &group.instances {
2630 sarif_results.push(sarif_result(
2631 "fallow/code-duplication",
2632 "warning",
2633 &format!(
2634 "Code clone group {} ({} lines, {} instances)",
2635 i + 1,
2636 group.line_count,
2637 group.instances.len()
2638 ),
2639 &super::super::relative_uri(&instance.file, &root),
2640 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2641 ));
2642 }
2643 }
2644 assert_eq!(sarif_results.len(), 2);
2645 assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
2646 assert!(
2647 sarif_results[0]["message"]["text"]
2648 .as_str()
2649 .unwrap()
2650 .contains("10 lines")
2651 );
2652 let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
2653 assert_eq!(region0["startLine"], 1);
2654 assert_eq!(region0["startColumn"], 1); let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
2656 assert_eq!(region1["startLine"], 5);
2657 assert_eq!(region1["startColumn"], 3); }
2659
2660 #[test]
2661 fn sarif_rule_known_id_has_full_description() {
2662 let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
2663 assert!(rule.get("fullDescription").is_some());
2664 assert!(rule.get("helpUri").is_some());
2665 }
2666
2667 #[test]
2668 fn sarif_rule_unknown_id_uses_fallback() {
2669 let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
2670 assert_eq!(rule["shortDescription"]["text"], "fallback text");
2671 assert!(rule.get("fullDescription").is_none());
2672 assert!(rule.get("helpUri").is_none());
2673 assert_eq!(rule["defaultConfiguration"]["level"], "warning");
2674 }
2675
2676 #[test]
2677 fn sarif_result_no_region_omits_region_key() {
2678 let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
2679 let phys = &result["locations"][0]["physicalLocation"];
2680 assert!(phys.get("region").is_none());
2681 assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
2682 }
2683
2684 #[test]
2685 fn sarif_result_with_region_includes_region() {
2686 let result = sarif_result(
2687 "rule/test",
2688 "error",
2689 "test msg",
2690 "src/file.ts",
2691 Some((10, 5)),
2692 );
2693 let region = &result["locations"][0]["physicalLocation"]["region"];
2694 assert_eq!(region["startLine"], 10);
2695 assert_eq!(region["startColumn"], 5);
2696 }
2697
2698 #[test]
2699 fn sarif_partial_fingerprint_ignores_rendered_message() {
2700 let a = sarif_result(
2701 "rule/test",
2702 "error",
2703 "first message",
2704 "src/file.ts",
2705 Some((10, 5)),
2706 );
2707 let b = sarif_result(
2708 "rule/test",
2709 "error",
2710 "rewritten message",
2711 "src/file.ts",
2712 Some((10, 5)),
2713 );
2714 assert_eq!(
2715 a["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
2716 b["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
2717 );
2718 }
2719
2720 #[test]
2721 fn health_sarif_includes_refactoring_targets() {
2722 use crate::health_types::*;
2723
2724 let root = PathBuf::from("/project");
2725 let report = HealthReport {
2726 summary: HealthSummary {
2727 files_analyzed: 10,
2728 functions_analyzed: 50,
2729 ..Default::default()
2730 },
2731 targets: vec![
2732 RefactoringTarget {
2733 path: root.join("src/complex.ts"),
2734 priority: 85.0,
2735 efficiency: 42.5,
2736 recommendation: "Split high-impact file".into(),
2737 category: RecommendationCategory::SplitHighImpact,
2738 effort: EffortEstimate::Medium,
2739 confidence: Confidence::High,
2740 factors: vec![],
2741 evidence: None,
2742 }
2743 .into(),
2744 ],
2745 ..Default::default()
2746 };
2747
2748 let sarif = build_health_sarif(&report, &root);
2749 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2750 assert_eq!(entries.len(), 1);
2751 assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
2752 assert_eq!(entries[0]["level"], "warning");
2753 let msg = entries[0]["message"]["text"].as_str().unwrap();
2754 assert!(msg.contains("high impact"));
2755 assert!(msg.contains("Split high-impact file"));
2756 assert!(msg.contains("42.5"));
2757 }
2758
2759 #[test]
2760 fn health_sarif_includes_coverage_gaps() {
2761 use crate::health_types::*;
2762
2763 let root = PathBuf::from("/project");
2764 let report = HealthReport {
2765 summary: HealthSummary {
2766 files_analyzed: 10,
2767 functions_analyzed: 50,
2768 ..Default::default()
2769 },
2770 coverage_gaps: Some(CoverageGaps {
2771 summary: CoverageGapSummary {
2772 runtime_files: 2,
2773 covered_files: 0,
2774 file_coverage_pct: 0.0,
2775 untested_files: 1,
2776 untested_exports: 1,
2777 },
2778 files: vec![UntestedFileFinding::with_actions(
2779 UntestedFile {
2780 path: root.join("src/app.ts"),
2781 value_export_count: 2,
2782 },
2783 &root,
2784 )],
2785 exports: vec![UntestedExportFinding::with_actions(
2786 UntestedExport {
2787 path: root.join("src/app.ts"),
2788 export_name: "loader".into(),
2789 line: 12,
2790 col: 4,
2791 },
2792 &root,
2793 )],
2794 }),
2795 ..Default::default()
2796 };
2797
2798 let sarif = build_health_sarif(&report, &root);
2799 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2800 assert_eq!(entries.len(), 2);
2801 assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
2802 assert_eq!(
2803 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2804 "src/app.ts"
2805 );
2806 assert!(
2807 entries[0]["message"]["text"]
2808 .as_str()
2809 .unwrap()
2810 .contains("2 value exports")
2811 );
2812 assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
2813 assert_eq!(
2814 entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
2815 12
2816 );
2817 assert_eq!(
2818 entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
2819 5
2820 );
2821 }
2822
2823 #[test]
2824 fn health_sarif_rules_have_full_descriptions() {
2825 let root = PathBuf::from("/project");
2826 let report = crate::health_types::HealthReport::default();
2827 let sarif = build_health_sarif(&report, &root);
2828 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2829 .as_array()
2830 .unwrap();
2831 for rule in rules {
2832 let id = rule["id"].as_str().unwrap();
2833 assert!(
2834 rule.get("fullDescription").is_some(),
2835 "health rule {id} should have fullDescription"
2836 );
2837 assert!(
2838 rule.get("helpUri").is_some(),
2839 "health rule {id} should have helpUri"
2840 );
2841 }
2842 }
2843
2844 #[test]
2845 fn sarif_warn_severity_produces_warning_level() {
2846 let root = PathBuf::from("/project");
2847 let mut results = AnalysisResults::default();
2848 results
2849 .unused_files
2850 .push(UnusedFileFinding::with_actions(UnusedFile {
2851 path: root.join("src/dead.ts"),
2852 }));
2853
2854 let rules = RulesConfig {
2855 unused_files: Severity::Warn,
2856 ..RulesConfig::default()
2857 };
2858
2859 let sarif = build_sarif(&results, &root, &rules);
2860 let entry = &sarif["runs"][0]["results"][0];
2861 assert_eq!(entry["level"], "warning");
2862 }
2863
2864 #[test]
2865 fn sarif_unused_file_has_no_region() {
2866 let root = PathBuf::from("/project");
2867 let mut results = AnalysisResults::default();
2868 results
2869 .unused_files
2870 .push(UnusedFileFinding::with_actions(UnusedFile {
2871 path: root.join("src/dead.ts"),
2872 }));
2873
2874 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2875 let entry = &sarif["runs"][0]["results"][0];
2876 let phys = &entry["locations"][0]["physicalLocation"];
2877 assert!(phys.get("region").is_none());
2878 }
2879
2880 #[test]
2881 fn sarif_unlisted_dep_multiple_import_sites() {
2882 let root = PathBuf::from("/project");
2883 let mut results = AnalysisResults::default();
2884 results
2885 .unlisted_dependencies
2886 .push(UnlistedDependencyFinding::with_actions(
2887 UnlistedDependency {
2888 package_name: "dotenv".to_string(),
2889 imported_from: vec![
2890 ImportSite {
2891 path: root.join("src/a.ts"),
2892 line: 1,
2893 col: 0,
2894 },
2895 ImportSite {
2896 path: root.join("src/b.ts"),
2897 line: 5,
2898 col: 0,
2899 },
2900 ],
2901 },
2902 ));
2903
2904 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2905 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2906 assert_eq!(entries.len(), 2);
2907 assert_eq!(
2908 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2909 "src/a.ts"
2910 );
2911 assert_eq!(
2912 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2913 "src/b.ts"
2914 );
2915 }
2916
2917 #[test]
2918 fn sarif_unlisted_dep_no_import_sites() {
2919 let root = PathBuf::from("/project");
2920 let mut results = AnalysisResults::default();
2921 results
2922 .unlisted_dependencies
2923 .push(UnlistedDependencyFinding::with_actions(
2924 UnlistedDependency {
2925 package_name: "phantom".to_string(),
2926 imported_from: vec![],
2927 },
2928 ));
2929
2930 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2931 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2932 assert!(entries.is_empty());
2933 }
2934}