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]
765pub fn build_sarif(
766 results: &AnalysisResults,
767 root: &Path,
768 rules: &RulesConfig,
769) -> serde_json::Value {
770 let mut sarif_results = Vec::new();
771 let mut snippets = SourceSnippetCache::default();
772
773 push_sarif_results(
774 &mut sarif_results,
775 &results.unused_files,
776 &mut snippets,
777 |f| sarif_unused_file_fields(&f.file, root, severity_to_sarif_level(rules.unused_files)),
778 );
779 push_sarif_results(
780 &mut sarif_results,
781 &results.unused_exports,
782 &mut snippets,
783 |e| {
784 sarif_export_fields(
785 &e.export,
786 root,
787 "fallow/unused-export",
788 severity_to_sarif_level(rules.unused_exports),
789 "Export",
790 "Re-export",
791 )
792 },
793 );
794 push_sarif_results(
795 &mut sarif_results,
796 &results.unused_types,
797 &mut snippets,
798 |e| {
799 sarif_export_fields(
800 &e.export,
801 root,
802 "fallow/unused-type",
803 severity_to_sarif_level(rules.unused_types),
804 "Type export",
805 "Type re-export",
806 )
807 },
808 );
809 push_sarif_results(
810 &mut sarif_results,
811 &results.private_type_leaks,
812 &mut snippets,
813 |e| {
814 sarif_private_type_leak_fields(
815 &e.leak,
816 root,
817 severity_to_sarif_level(rules.private_type_leaks),
818 )
819 },
820 );
821 push_dependency_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
822 push_member_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
823 push_sarif_results(
824 &mut sarif_results,
825 &results.unresolved_imports,
826 &mut snippets,
827 |i| {
828 sarif_unresolved_import_fields(
829 &i.import,
830 root,
831 severity_to_sarif_level(rules.unresolved_imports),
832 )
833 },
834 );
835 push_misc_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
836 push_graph_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
837 push_catalog_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
838
839 serde_json::json!({
840 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
841 "version": "2.1.0",
842 "runs": [{
843 "tool": {
844 "driver": {
845 "name": "fallow",
846 "version": env!("CARGO_PKG_VERSION"),
847 "informationUri": "https://github.com/fallow-rs/fallow",
848 "rules": build_sarif_rules(rules)
849 }
850 },
851 "results": sarif_results
852 }]
853 })
854}
855
856fn push_dependency_sarif_results(
857 sarif_results: &mut Vec<serde_json::Value>,
858 results: &AnalysisResults,
859 root: &Path,
860 rules: &RulesConfig,
861 snippets: &mut SourceSnippetCache,
862) {
863 push_sarif_results(sarif_results, &results.unused_dependencies, snippets, |d| {
864 sarif_dep_fields(
865 &d.dep,
866 root,
867 "fallow/unused-dependency",
868 severity_to_sarif_level(rules.unused_dependencies),
869 "dependencies",
870 )
871 });
872 push_sarif_results(
873 sarif_results,
874 &results.unused_dev_dependencies,
875 snippets,
876 |d| {
877 sarif_dep_fields(
878 &d.dep,
879 root,
880 "fallow/unused-dev-dependency",
881 severity_to_sarif_level(rules.unused_dev_dependencies),
882 "devDependencies",
883 )
884 },
885 );
886 push_sarif_results(
887 sarif_results,
888 &results.unused_optional_dependencies,
889 snippets,
890 |d| {
891 sarif_dep_fields(
892 &d.dep,
893 root,
894 "fallow/unused-optional-dependency",
895 severity_to_sarif_level(rules.unused_optional_dependencies),
896 "optionalDependencies",
897 )
898 },
899 );
900 push_sarif_results(
901 sarif_results,
902 &results.type_only_dependencies,
903 snippets,
904 |d| {
905 sarif_type_only_dep_fields(
906 &d.dep,
907 root,
908 severity_to_sarif_level(rules.type_only_dependencies),
909 )
910 },
911 );
912 push_sarif_results(
913 sarif_results,
914 &results.test_only_dependencies,
915 snippets,
916 |d| {
917 sarif_test_only_dep_fields(
918 &d.dep,
919 root,
920 severity_to_sarif_level(rules.test_only_dependencies),
921 )
922 },
923 );
924}
925
926fn push_member_sarif_results(
927 sarif_results: &mut Vec<serde_json::Value>,
928 results: &AnalysisResults,
929 root: &Path,
930 rules: &RulesConfig,
931 snippets: &mut SourceSnippetCache,
932) {
933 push_sarif_results(sarif_results, &results.unused_enum_members, snippets, |m| {
934 sarif_member_fields(
935 &m.member,
936 root,
937 "fallow/unused-enum-member",
938 severity_to_sarif_level(rules.unused_enum_members),
939 "Enum",
940 )
941 });
942 push_sarif_results(
943 sarif_results,
944 &results.unused_class_members,
945 snippets,
946 |m| {
947 sarif_member_fields(
948 &m.member,
949 root,
950 "fallow/unused-class-member",
951 severity_to_sarif_level(rules.unused_class_members),
952 "Class",
953 )
954 },
955 );
956}
957
958fn push_misc_sarif_results(
959 sarif_results: &mut Vec<serde_json::Value>,
960 results: &AnalysisResults,
961 root: &Path,
962 rules: &RulesConfig,
963 snippets: &mut SourceSnippetCache,
964) {
965 if !results.unlisted_dependencies.is_empty() {
966 push_sarif_unlisted_deps(
967 sarif_results,
968 &results.unlisted_dependencies,
969 root,
970 severity_to_sarif_level(rules.unlisted_dependencies),
971 snippets,
972 );
973 }
974 if !results.duplicate_exports.is_empty() {
975 push_sarif_duplicate_exports(
976 sarif_results,
977 &results.duplicate_exports,
978 root,
979 severity_to_sarif_level(rules.duplicate_exports),
980 snippets,
981 );
982 }
983}
984
985fn push_graph_sarif_results(
986 sarif_results: &mut Vec<serde_json::Value>,
987 results: &AnalysisResults,
988 root: &Path,
989 rules: &RulesConfig,
990 snippets: &mut SourceSnippetCache,
991) {
992 push_sarif_results(
993 sarif_results,
994 &results.circular_dependencies,
995 snippets,
996 |c| {
997 sarif_circular_dep_fields(
998 &c.cycle,
999 root,
1000 severity_to_sarif_level(rules.circular_dependencies),
1001 )
1002 },
1003 );
1004 push_sarif_results(sarif_results, &results.re_export_cycles, snippets, |c| {
1005 sarif_re_export_cycle_fields(
1006 &c.cycle,
1007 root,
1008 severity_to_sarif_level(rules.re_export_cycle),
1009 )
1010 });
1011 push_sarif_results(sarif_results, &results.boundary_violations, snippets, |v| {
1012 sarif_boundary_violation_fields(
1013 &v.violation,
1014 root,
1015 severity_to_sarif_level(rules.boundary_violation),
1016 )
1017 });
1018 push_sarif_results(sarif_results, &results.stale_suppressions, snippets, |s| {
1019 sarif_stale_suppression_fields(s, root, severity_to_sarif_level(rules.stale_suppressions))
1020 });
1021}
1022
1023fn push_catalog_sarif_results(
1024 sarif_results: &mut Vec<serde_json::Value>,
1025 results: &AnalysisResults,
1026 root: &Path,
1027 rules: &RulesConfig,
1028 snippets: &mut SourceSnippetCache,
1029) {
1030 push_sarif_results(
1031 sarif_results,
1032 &results.unused_catalog_entries,
1033 snippets,
1034 |e| {
1035 sarif_unused_catalog_entry_fields(
1036 e,
1037 root,
1038 severity_to_sarif_level(rules.unused_catalog_entries),
1039 )
1040 },
1041 );
1042 push_sarif_results(
1043 sarif_results,
1044 &results.empty_catalog_groups,
1045 snippets,
1046 |g| {
1047 sarif_empty_catalog_group_fields(
1048 g,
1049 root,
1050 severity_to_sarif_level(rules.empty_catalog_groups),
1051 )
1052 },
1053 );
1054 push_sarif_results(
1055 sarif_results,
1056 &results.unresolved_catalog_references,
1057 snippets,
1058 |f| {
1059 sarif_unresolved_catalog_reference_fields(
1060 f,
1061 root,
1062 severity_to_sarif_level(rules.unresolved_catalog_references),
1063 )
1064 },
1065 );
1066 push_sarif_results(
1067 sarif_results,
1068 &results.unused_dependency_overrides,
1069 snippets,
1070 |f| {
1071 sarif_unused_dependency_override_fields(
1072 f,
1073 root,
1074 severity_to_sarif_level(rules.unused_dependency_overrides),
1075 )
1076 },
1077 );
1078 push_sarif_results(
1079 sarif_results,
1080 &results.misconfigured_dependency_overrides,
1081 snippets,
1082 |f| {
1083 sarif_misconfigured_dependency_override_fields(
1084 f,
1085 root,
1086 severity_to_sarif_level(rules.misconfigured_dependency_overrides),
1087 )
1088 },
1089 );
1090}
1091
1092pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
1093 let sarif = build_sarif(results, root, rules);
1094 emit_json(&sarif, "SARIF")
1095}
1096
1097#[expect(
1103 clippy::expect_used,
1104 reason = "grouped SARIF entries are JSON objects created by build_sarif"
1105)]
1106pub(super) fn print_grouped_sarif(
1107 results: &AnalysisResults,
1108 root: &Path,
1109 rules: &RulesConfig,
1110 resolver: &OwnershipResolver,
1111) -> ExitCode {
1112 let mut sarif = build_sarif(results, root, rules);
1113
1114 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1115 for run in runs {
1116 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1117 for result in results {
1118 let uri = result
1119 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1120 .and_then(|v| v.as_str())
1121 .unwrap_or("");
1122 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1123 let owner =
1124 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1125 let props = result
1126 .as_object_mut()
1127 .expect("SARIF result should be an object")
1128 .entry("properties")
1129 .or_insert_with(|| serde_json::json!({}));
1130 props
1131 .as_object_mut()
1132 .expect("properties should be an object")
1133 .insert("owner".to_string(), serde_json::Value::String(owner));
1134 }
1135 }
1136 }
1137 }
1138
1139 emit_json(&sarif, "SARIF")
1140}
1141
1142#[expect(
1143 clippy::cast_possible_truncation,
1144 reason = "line/col numbers are bounded by source size"
1145)]
1146pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
1147 let mut sarif_results = Vec::new();
1148 let mut snippets = SourceSnippetCache::default();
1149
1150 for (i, group) in report.clone_groups.iter().enumerate() {
1151 for instance in &group.instances {
1152 let uri = relative_uri(&instance.file, root);
1153 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1154 sarif_results.push(sarif_result_with_snippet(
1155 "fallow/code-duplication",
1156 "warning",
1157 &format!(
1158 "Code clone group {} ({} lines, {} instances)",
1159 i + 1,
1160 group.line_count,
1161 group.instances.len()
1162 ),
1163 &uri,
1164 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1165 source_snippet.as_deref(),
1166 ));
1167 }
1168 }
1169
1170 let sarif = serde_json::json!({
1171 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1172 "version": "2.1.0",
1173 "runs": [{
1174 "tool": {
1175 "driver": {
1176 "name": "fallow",
1177 "version": env!("CARGO_PKG_VERSION"),
1178 "informationUri": "https://github.com/fallow-rs/fallow",
1179 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1180 }
1181 },
1182 "results": sarif_results
1183 }]
1184 });
1185
1186 emit_json(&sarif, "SARIF")
1187}
1188
1189#[expect(
1200 clippy::cast_possible_truncation,
1201 reason = "line/col numbers are bounded by source size"
1202)]
1203#[expect(
1204 clippy::expect_used,
1205 reason = "duplication SARIF entries are JSON objects created by sarif_result_with_snippet"
1206)]
1207pub(super) fn print_grouped_duplication_sarif(
1208 report: &DuplicationReport,
1209 root: &Path,
1210 resolver: &OwnershipResolver,
1211) -> ExitCode {
1212 let mut sarif_results = Vec::new();
1213 let mut snippets = SourceSnippetCache::default();
1214
1215 for (i, group) in report.clone_groups.iter().enumerate() {
1216 let primary_owner = super::dupes_grouping::largest_owner(group, root, resolver);
1217 for instance in &group.instances {
1218 let uri = relative_uri(&instance.file, root);
1219 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1220 let mut result = sarif_result_with_snippet(
1221 "fallow/code-duplication",
1222 "warning",
1223 &format!(
1224 "Code clone group {} ({} lines, {} instances)",
1225 i + 1,
1226 group.line_count,
1227 group.instances.len()
1228 ),
1229 &uri,
1230 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1231 source_snippet.as_deref(),
1232 );
1233 let props = result
1234 .as_object_mut()
1235 .expect("SARIF result should be an object")
1236 .entry("properties")
1237 .or_insert_with(|| serde_json::json!({}));
1238 props
1239 .as_object_mut()
1240 .expect("properties should be an object")
1241 .insert(
1242 "group".to_string(),
1243 serde_json::Value::String(primary_owner.clone()),
1244 );
1245 sarif_results.push(result);
1246 }
1247 }
1248
1249 let sarif = serde_json::json!({
1250 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1251 "version": "2.1.0",
1252 "runs": [{
1253 "tool": {
1254 "driver": {
1255 "name": "fallow",
1256 "version": env!("CARGO_PKG_VERSION"),
1257 "informationUri": "https://github.com/fallow-rs/fallow",
1258 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1259 }
1260 },
1261 "results": sarif_results
1262 }]
1263 });
1264
1265 emit_json(&sarif, "SARIF")
1266}
1267
1268#[must_use]
1269pub fn build_health_sarif(
1270 report: &crate::health_types::HealthReport,
1271 root: &Path,
1272) -> serde_json::Value {
1273 let mut sarif_results = Vec::new();
1274 let mut snippets = SourceSnippetCache::default();
1275
1276 append_complexity_sarif_results(&mut sarif_results, report, root, &mut snippets);
1277
1278 if let Some(ref production) = report.runtime_coverage {
1279 append_runtime_coverage_sarif_results(&mut sarif_results, production, root, &mut snippets);
1280 }
1281 if let Some(ref intelligence) = report.coverage_intelligence {
1282 append_coverage_intelligence_sarif_results(
1283 &mut sarif_results,
1284 intelligence,
1285 root,
1286 &mut snippets,
1287 );
1288 }
1289
1290 append_refactoring_target_sarif_results(&mut sarif_results, report, root);
1291 append_coverage_gap_sarif_results(&mut sarif_results, report, root, &mut snippets);
1292
1293 let health_rules = vec![
1294 sarif_rule(
1295 "fallow/high-cyclomatic-complexity",
1296 "Function has high cyclomatic complexity",
1297 "note",
1298 ),
1299 sarif_rule(
1300 "fallow/high-cognitive-complexity",
1301 "Function has high cognitive complexity",
1302 "note",
1303 ),
1304 sarif_rule(
1305 "fallow/high-complexity",
1306 "Function exceeds both complexity thresholds",
1307 "note",
1308 ),
1309 sarif_rule(
1310 "fallow/high-crap-score",
1311 "Function has a high CRAP score (high complexity combined with low coverage)",
1312 "warning",
1313 ),
1314 sarif_rule(
1315 "fallow/refactoring-target",
1316 "File identified as a high-priority refactoring candidate",
1317 "warning",
1318 ),
1319 sarif_rule(
1320 "fallow/untested-file",
1321 "Runtime-reachable file has no test dependency path",
1322 "warning",
1323 ),
1324 sarif_rule(
1325 "fallow/untested-export",
1326 "Runtime-reachable export has no test dependency path",
1327 "warning",
1328 ),
1329 sarif_rule(
1330 "fallow/runtime-safe-to-delete",
1331 "Function is statically unused and was never invoked in production",
1332 "warning",
1333 ),
1334 sarif_rule(
1335 "fallow/runtime-review-required",
1336 "Function is statically used but was never invoked in production",
1337 "warning",
1338 ),
1339 sarif_rule(
1340 "fallow/runtime-low-traffic",
1341 "Function was invoked below the low-traffic threshold relative to total trace count",
1342 "note",
1343 ),
1344 sarif_rule(
1345 "fallow/runtime-coverage-unavailable",
1346 "Runtime coverage could not be resolved for this function",
1347 "note",
1348 ),
1349 sarif_rule(
1350 "fallow/runtime-coverage",
1351 "Runtime coverage finding",
1352 "note",
1353 ),
1354 sarif_rule(
1355 "fallow/coverage-intelligence-risky-change",
1356 "Changed hot path combines high CRAP and low test coverage",
1357 "warning",
1358 ),
1359 sarif_rule(
1360 "fallow/coverage-intelligence-delete",
1361 "Static and runtime evidence indicate code can be deleted",
1362 "warning",
1363 ),
1364 sarif_rule(
1365 "fallow/coverage-intelligence-review",
1366 "Cold reachable uncovered code needs owner review",
1367 "warning",
1368 ),
1369 sarif_rule(
1370 "fallow/coverage-intelligence-refactor",
1371 "Hot covered code has high CRAP and should be refactored carefully",
1372 "warning",
1373 ),
1374 ];
1375
1376 serde_json::json!({
1377 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1378 "version": "2.1.0",
1379 "runs": [{
1380 "tool": {
1381 "driver": {
1382 "name": "fallow",
1383 "version": env!("CARGO_PKG_VERSION"),
1384 "informationUri": "https://github.com/fallow-rs/fallow",
1385 "rules": health_rules
1386 }
1387 },
1388 "results": sarif_results
1389 }]
1390 })
1391}
1392
1393fn append_complexity_sarif_results(
1394 sarif_results: &mut Vec<serde_json::Value>,
1395 report: &crate::health_types::HealthReport,
1396 root: &Path,
1397 snippets: &mut SourceSnippetCache,
1398) {
1399 for finding in &report.findings {
1400 let uri = relative_uri(&finding.path, root);
1401 let (rule_id, message) = health_complexity_sarif_message(finding, report);
1402 let level = match finding.severity {
1403 crate::health_types::FindingSeverity::Critical => "error",
1404 crate::health_types::FindingSeverity::High => "warning",
1405 crate::health_types::FindingSeverity::Moderate => "note",
1406 };
1407 let source_snippet = snippets.line(&finding.path, finding.line);
1408 sarif_results.push(sarif_result_with_snippet(
1409 rule_id,
1410 level,
1411 &message,
1412 &uri,
1413 Some((finding.line, finding.col + 1)),
1414 source_snippet.as_deref(),
1415 ));
1416 }
1417}
1418
1419fn health_complexity_sarif_message(
1420 finding: &crate::health_types::ComplexityViolation,
1421 report: &crate::health_types::HealthReport,
1422) -> (&'static str, String) {
1423 match finding.exceeded {
1424 crate::health_types::ExceededThreshold::Cyclomatic => (
1425 "fallow/high-cyclomatic-complexity",
1426 format!(
1427 "'{}' has cyclomatic complexity {} (threshold: {})",
1428 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
1429 ),
1430 ),
1431 crate::health_types::ExceededThreshold::Cognitive => (
1432 "fallow/high-cognitive-complexity",
1433 format!(
1434 "'{}' has cognitive complexity {} (threshold: {})",
1435 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
1436 ),
1437 ),
1438 crate::health_types::ExceededThreshold::Both => (
1439 "fallow/high-complexity",
1440 format!(
1441 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
1442 finding.name,
1443 finding.cyclomatic,
1444 report.summary.max_cyclomatic_threshold,
1445 finding.cognitive,
1446 report.summary.max_cognitive_threshold,
1447 ),
1448 ),
1449 crate::health_types::ExceededThreshold::Crap
1450 | crate::health_types::ExceededThreshold::CyclomaticCrap
1451 | crate::health_types::ExceededThreshold::CognitiveCrap
1452 | crate::health_types::ExceededThreshold::All => {
1453 let crap = finding.crap.unwrap_or(0.0);
1454 let coverage = finding
1455 .coverage_pct
1456 .map(|pct| format!(", coverage {pct:.0}%"))
1457 .unwrap_or_default();
1458 (
1459 "fallow/high-crap-score",
1460 format!(
1461 "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
1462 finding.name,
1463 crap,
1464 report.summary.max_crap_threshold,
1465 finding.cyclomatic,
1466 coverage,
1467 ),
1468 )
1469 }
1470 }
1471}
1472
1473fn append_refactoring_target_sarif_results(
1474 sarif_results: &mut Vec<serde_json::Value>,
1475 report: &crate::health_types::HealthReport,
1476 root: &Path,
1477) {
1478 for target in &report.targets {
1479 let uri = relative_uri(&target.path, root);
1480 let message = format!(
1481 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
1482 target.category.label(),
1483 target.recommendation,
1484 target.priority,
1485 target.efficiency,
1486 target.effort.label(),
1487 target.confidence.label(),
1488 );
1489 sarif_results.push(sarif_result(
1490 "fallow/refactoring-target",
1491 "warning",
1492 &message,
1493 &uri,
1494 None,
1495 ));
1496 }
1497}
1498
1499fn append_coverage_gap_sarif_results(
1500 sarif_results: &mut Vec<serde_json::Value>,
1501 report: &crate::health_types::HealthReport,
1502 root: &Path,
1503 snippets: &mut SourceSnippetCache,
1504) {
1505 let Some(ref gaps) = report.coverage_gaps else {
1506 return;
1507 };
1508 for item in &gaps.files {
1509 let uri = relative_uri(&item.file.path, root);
1510 let message = format!(
1511 "File is runtime-reachable but has no test dependency path ({} value export{})",
1512 item.file.value_export_count,
1513 if item.file.value_export_count == 1 {
1514 ""
1515 } else {
1516 "s"
1517 },
1518 );
1519 sarif_results.push(sarif_result(
1520 "fallow/untested-file",
1521 "warning",
1522 &message,
1523 &uri,
1524 None,
1525 ));
1526 }
1527
1528 for item in &gaps.exports {
1529 let uri = relative_uri(&item.export.path, root);
1530 let message = format!(
1531 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
1532 item.export.export_name
1533 );
1534 let source_snippet = snippets.line(&item.export.path, item.export.line);
1535 sarif_results.push(sarif_result_with_snippet(
1536 "fallow/untested-export",
1537 "warning",
1538 &message,
1539 &uri,
1540 Some((item.export.line, item.export.col + 1)),
1541 source_snippet.as_deref(),
1542 ));
1543 }
1544}
1545
1546fn append_runtime_coverage_sarif_results(
1547 sarif_results: &mut Vec<serde_json::Value>,
1548 production: &crate::health_types::RuntimeCoverageReport,
1549 root: &Path,
1550 snippets: &mut SourceSnippetCache,
1551) {
1552 for finding in &production.findings {
1553 let uri = relative_uri(&finding.path, root);
1554 let rule_id = match finding.verdict {
1555 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
1556 "fallow/runtime-safe-to-delete"
1557 }
1558 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
1559 "fallow/runtime-review-required"
1560 }
1561 crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
1562 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
1563 "fallow/runtime-coverage-unavailable"
1564 }
1565 crate::health_types::RuntimeCoverageVerdict::Active
1566 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
1567 };
1568 let level = match finding.verdict {
1569 crate::health_types::RuntimeCoverageVerdict::SafeToDelete
1570 | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
1571 _ => "note",
1572 };
1573 let invocations_hint = finding.invocations.map_or_else(
1574 || "untracked".to_owned(),
1575 |hits| format!("{hits} invocations"),
1576 );
1577 let message = format!(
1578 "'{}' runtime coverage verdict: {} ({})",
1579 finding.function,
1580 finding.verdict.human_label(),
1581 invocations_hint,
1582 );
1583 let source_snippet = snippets.line(&finding.path, finding.line);
1584 sarif_results.push(sarif_result_with_snippet(
1585 rule_id,
1586 level,
1587 &message,
1588 &uri,
1589 Some((finding.line, 1)),
1590 source_snippet.as_deref(),
1591 ));
1592 }
1593}
1594
1595fn append_coverage_intelligence_sarif_results(
1596 sarif_results: &mut Vec<serde_json::Value>,
1597 intelligence: &crate::health_types::CoverageIntelligenceReport,
1598 root: &Path,
1599 snippets: &mut SourceSnippetCache,
1600) {
1601 for finding in &intelligence.findings {
1602 let rule_id = coverage_intelligence_rule_id(finding.recommendation);
1603 let level = match finding.verdict {
1604 crate::health_types::CoverageIntelligenceVerdict::Clean
1605 | crate::health_types::CoverageIntelligenceVerdict::Unknown => continue,
1606 _ => "warning",
1607 };
1608 let uri = relative_uri(&finding.path, root);
1609 let identity = finding.identity.as_deref().unwrap_or("code");
1610 let signals = finding
1611 .signals
1612 .iter()
1613 .map(ToString::to_string)
1614 .collect::<Vec<_>>()
1615 .join(", ");
1616 let message = format!(
1617 "'{}' coverage intelligence verdict: {} ({}, signals: {})",
1618 identity, finding.verdict, finding.recommendation, signals,
1619 );
1620 let source_snippet = snippets.line(&finding.path, finding.line);
1621 let mut result = sarif_result_with_snippet(
1622 rule_id,
1623 level,
1624 &message,
1625 &uri,
1626 Some((finding.line, 1)),
1627 source_snippet.as_deref(),
1628 );
1629 result["properties"] = serde_json::json!({
1630 "coverage_intelligence_id": &finding.id,
1631 "verdict": finding.verdict,
1632 "recommendation": finding.recommendation,
1633 "confidence": finding.confidence,
1634 "signals": &finding.signals,
1635 "related_ids": &finding.related_ids,
1636 });
1637 sarif_results.push(result);
1638 }
1639}
1640
1641fn coverage_intelligence_rule_id(
1642 recommendation: crate::health_types::CoverageIntelligenceRecommendation,
1643) -> &'static str {
1644 match recommendation {
1645 crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
1646 "fallow/coverage-intelligence-risky-change"
1647 }
1648 crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
1649 "fallow/coverage-intelligence-delete"
1650 }
1651 crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
1652 "fallow/coverage-intelligence-review"
1653 }
1654 crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
1655 "fallow/coverage-intelligence-refactor"
1656 }
1657 }
1658}
1659
1660pub(super) fn print_health_sarif(
1661 report: &crate::health_types::HealthReport,
1662 root: &Path,
1663) -> ExitCode {
1664 let sarif = build_health_sarif(report, root);
1665 emit_json(&sarif, "SARIF")
1666}
1667
1668#[expect(
1679 clippy::expect_used,
1680 reason = "grouped health SARIF entries are JSON objects created by build_health_sarif"
1681)]
1682pub(super) fn print_grouped_health_sarif(
1683 report: &crate::health_types::HealthReport,
1684 root: &Path,
1685 resolver: &OwnershipResolver,
1686) -> ExitCode {
1687 let mut sarif = build_health_sarif(report, root);
1688
1689 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1690 for run in runs {
1691 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1692 for result in results {
1693 let uri = result
1694 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1695 .and_then(|v| v.as_str())
1696 .unwrap_or("");
1697 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1698 let group =
1699 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1700 let props = result
1701 .as_object_mut()
1702 .expect("SARIF result should be an object")
1703 .entry("properties")
1704 .or_insert_with(|| serde_json::json!({}));
1705 props
1706 .as_object_mut()
1707 .expect("properties should be an object")
1708 .insert("group".to_string(), serde_json::Value::String(group));
1709 }
1710 }
1711 }
1712 }
1713
1714 emit_json(&sarif, "SARIF")
1715}
1716
1717#[cfg(test)]
1718mod tests {
1719 use super::*;
1720 use crate::report::test_helpers::sample_results;
1721 use fallow_core::results::*;
1722 use std::path::PathBuf;
1723
1724 #[test]
1725 fn sarif_has_required_top_level_fields() {
1726 let root = PathBuf::from("/project");
1727 let results = AnalysisResults::default();
1728 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1729
1730 assert_eq!(
1731 sarif["$schema"],
1732 "https://json.schemastore.org/sarif-2.1.0.json"
1733 );
1734 assert_eq!(sarif["version"], "2.1.0");
1735 assert!(sarif["runs"].is_array());
1736 }
1737
1738 #[test]
1739 fn sarif_has_tool_driver_info() {
1740 let root = PathBuf::from("/project");
1741 let results = AnalysisResults::default();
1742 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1743
1744 let driver = &sarif["runs"][0]["tool"]["driver"];
1745 assert_eq!(driver["name"], "fallow");
1746 assert!(driver["version"].is_string());
1747 assert_eq!(
1748 driver["informationUri"],
1749 "https://github.com/fallow-rs/fallow"
1750 );
1751 }
1752
1753 #[test]
1754 fn sarif_declares_all_rules() {
1755 let root = PathBuf::from("/project");
1756 let results = AnalysisResults::default();
1757 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1758
1759 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1760 .as_array()
1761 .expect("rules should be an array");
1762 assert_eq!(rules.len(), 23);
1763
1764 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
1765 assert!(rule_ids.contains(&"fallow/unused-file"));
1766 assert!(rule_ids.contains(&"fallow/unused-export"));
1767 assert!(rule_ids.contains(&"fallow/unused-type"));
1768 assert!(rule_ids.contains(&"fallow/private-type-leak"));
1769 assert!(rule_ids.contains(&"fallow/unused-dependency"));
1770 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1771 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1772 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1773 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1774 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1775 assert!(rule_ids.contains(&"fallow/unused-class-member"));
1776 assert!(rule_ids.contains(&"fallow/unresolved-import"));
1777 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1778 assert!(rule_ids.contains(&"fallow/duplicate-export"));
1779 assert!(rule_ids.contains(&"fallow/circular-dependency"));
1780 assert!(rule_ids.contains(&"fallow/re-export-cycle"));
1781 assert!(rule_ids.contains(&"fallow/boundary-violation"));
1782 assert!(rule_ids.contains(&"fallow/unused-catalog-entry"));
1783 assert!(rule_ids.contains(&"fallow/empty-catalog-group"));
1784 assert!(rule_ids.contains(&"fallow/unresolved-catalog-reference"));
1785 assert!(rule_ids.contains(&"fallow/unused-dependency-override"));
1786 assert!(rule_ids.contains(&"fallow/misconfigured-dependency-override"));
1787 }
1788
1789 #[test]
1790 fn sarif_empty_results_no_results_entries() {
1791 let root = PathBuf::from("/project");
1792 let results = AnalysisResults::default();
1793 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1794
1795 let sarif_results = sarif["runs"][0]["results"]
1796 .as_array()
1797 .expect("results should be an array");
1798 assert!(sarif_results.is_empty());
1799 }
1800
1801 #[test]
1802 fn sarif_unused_file_result() {
1803 let root = PathBuf::from("/project");
1804 let mut results = AnalysisResults::default();
1805 results
1806 .unused_files
1807 .push(UnusedFileFinding::with_actions(UnusedFile {
1808 path: root.join("src/dead.ts"),
1809 }));
1810
1811 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1812 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1813 assert_eq!(entries.len(), 1);
1814
1815 let entry = &entries[0];
1816 assert_eq!(entry["ruleId"], "fallow/unused-file");
1817 assert_eq!(entry["level"], "error");
1818 assert_eq!(
1819 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1820 "src/dead.ts"
1821 );
1822 }
1823
1824 #[test]
1825 fn sarif_unused_export_includes_region() {
1826 let root = PathBuf::from("/project");
1827 let mut results = AnalysisResults::default();
1828 results
1829 .unused_exports
1830 .push(UnusedExportFinding::with_actions(UnusedExport {
1831 path: root.join("src/utils.ts"),
1832 export_name: "helperFn".to_string(),
1833 is_type_only: false,
1834 line: 10,
1835 col: 4,
1836 span_start: 120,
1837 is_re_export: false,
1838 }));
1839
1840 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1841 let entry = &sarif["runs"][0]["results"][0];
1842 assert_eq!(entry["ruleId"], "fallow/unused-export");
1843
1844 let region = &entry["locations"][0]["physicalLocation"]["region"];
1845 assert_eq!(region["startLine"], 10);
1846 assert_eq!(region["startColumn"], 5);
1847 }
1848
1849 #[test]
1850 fn sarif_unresolved_import_is_error_level() {
1851 let root = PathBuf::from("/project");
1852 let mut results = AnalysisResults::default();
1853 results
1854 .unresolved_imports
1855 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1856 path: root.join("src/app.ts"),
1857 specifier: "./missing".to_string(),
1858 line: 1,
1859 col: 0,
1860 specifier_col: 0,
1861 }));
1862
1863 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1864 let entry = &sarif["runs"][0]["results"][0];
1865 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
1866 assert_eq!(entry["level"], "error");
1867 }
1868
1869 #[test]
1870 fn sarif_unlisted_dependency_points_to_import_site() {
1871 let root = PathBuf::from("/project");
1872 let mut results = AnalysisResults::default();
1873 results
1874 .unlisted_dependencies
1875 .push(UnlistedDependencyFinding::with_actions(
1876 UnlistedDependency {
1877 package_name: "chalk".to_string(),
1878 imported_from: vec![ImportSite {
1879 path: root.join("src/cli.ts"),
1880 line: 3,
1881 col: 0,
1882 }],
1883 },
1884 ));
1885
1886 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1887 let entry = &sarif["runs"][0]["results"][0];
1888 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
1889 assert_eq!(entry["level"], "error");
1890 assert_eq!(
1891 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1892 "src/cli.ts"
1893 );
1894 let region = &entry["locations"][0]["physicalLocation"]["region"];
1895 assert_eq!(region["startLine"], 3);
1896 assert_eq!(region["startColumn"], 1);
1897 }
1898
1899 #[test]
1900 fn sarif_dependency_issues_point_to_package_json() {
1901 let root = PathBuf::from("/project");
1902 let mut results = AnalysisResults::default();
1903 results
1904 .unused_dependencies
1905 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1906 package_name: "lodash".to_string(),
1907 location: DependencyLocation::Dependencies,
1908 path: root.join("package.json"),
1909 line: 5,
1910 used_in_workspaces: Vec::new(),
1911 }));
1912 results
1913 .unused_dev_dependencies
1914 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1915 package_name: "jest".to_string(),
1916 location: DependencyLocation::DevDependencies,
1917 path: root.join("package.json"),
1918 line: 5,
1919 used_in_workspaces: Vec::new(),
1920 }));
1921
1922 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1923 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1924 for entry in entries {
1925 assert_eq!(
1926 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1927 "package.json"
1928 );
1929 }
1930 }
1931
1932 #[test]
1933 fn sarif_duplicate_export_emits_one_result_per_location() {
1934 let root = PathBuf::from("/project");
1935 let mut results = AnalysisResults::default();
1936 results
1937 .duplicate_exports
1938 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1939 export_name: "Config".to_string(),
1940 locations: vec![
1941 DuplicateLocation {
1942 path: root.join("src/a.ts"),
1943 line: 15,
1944 col: 0,
1945 },
1946 DuplicateLocation {
1947 path: root.join("src/b.ts"),
1948 line: 30,
1949 col: 0,
1950 },
1951 ],
1952 }));
1953
1954 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1955 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1956 assert_eq!(entries.len(), 2);
1957 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
1958 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
1959 assert_eq!(
1960 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1961 "src/a.ts"
1962 );
1963 assert_eq!(
1964 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1965 "src/b.ts"
1966 );
1967 }
1968
1969 #[test]
1970 fn sarif_all_issue_types_produce_results() {
1971 let root = PathBuf::from("/project");
1972 let results = sample_results(&root);
1973 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1974
1975 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1976 assert_eq!(entries.len(), results.total_issues() + 1);
1977
1978 let rule_ids: Vec<&str> = entries
1979 .iter()
1980 .map(|e| e["ruleId"].as_str().unwrap())
1981 .collect();
1982 assert!(rule_ids.contains(&"fallow/unused-file"));
1983 assert!(rule_ids.contains(&"fallow/unused-export"));
1984 assert!(rule_ids.contains(&"fallow/unused-type"));
1985 assert!(rule_ids.contains(&"fallow/unused-dependency"));
1986 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1987 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1988 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1989 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1990 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1991 assert!(rule_ids.contains(&"fallow/unused-class-member"));
1992 assert!(rule_ids.contains(&"fallow/unresolved-import"));
1993 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1994 assert!(rule_ids.contains(&"fallow/duplicate-export"));
1995 }
1996
1997 #[test]
1998 fn sarif_serializes_to_valid_json() {
1999 let root = PathBuf::from("/project");
2000 let results = sample_results(&root);
2001 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2002
2003 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
2004 let reparsed: serde_json::Value =
2005 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
2006 assert_eq!(reparsed, sarif);
2007 }
2008
2009 #[test]
2010 fn sarif_file_write_produces_valid_sarif() {
2011 let root = PathBuf::from("/project");
2012 let results = sample_results(&root);
2013 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2014 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
2015
2016 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
2017 let _ = std::fs::create_dir_all(&dir);
2018 let sarif_path = dir.join("results.sarif");
2019 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
2020
2021 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
2022 let parsed: serde_json::Value =
2023 serde_json::from_str(&contents).expect("file should contain valid JSON");
2024
2025 assert_eq!(parsed["version"], "2.1.0");
2026 assert_eq!(
2027 parsed["$schema"],
2028 "https://json.schemastore.org/sarif-2.1.0.json"
2029 );
2030 let sarif_results = parsed["runs"][0]["results"]
2031 .as_array()
2032 .expect("results should be an array");
2033 assert!(!sarif_results.is_empty());
2034
2035 let _ = std::fs::remove_file(&sarif_path);
2036 let _ = std::fs::remove_dir(&dir);
2037 }
2038
2039 #[test]
2040 fn health_sarif_empty_no_results() {
2041 let root = PathBuf::from("/project");
2042 let report = crate::health_types::HealthReport {
2043 summary: crate::health_types::HealthSummary {
2044 files_analyzed: 10,
2045 functions_analyzed: 50,
2046 ..Default::default()
2047 },
2048 ..Default::default()
2049 };
2050 let sarif = build_health_sarif(&report, &root);
2051 assert_eq!(sarif["version"], "2.1.0");
2052 let results = sarif["runs"][0]["results"].as_array().unwrap();
2053 assert!(results.is_empty());
2054 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2055 .as_array()
2056 .unwrap();
2057 assert_eq!(rules.len(), 16);
2058 }
2059
2060 #[test]
2061 fn health_sarif_coverage_intelligence_preserves_structured_properties() {
2062 use crate::health_types::{
2063 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2064 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2065 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2066 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2067 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2068 HealthReport, HealthSummary,
2069 };
2070
2071 let root = PathBuf::from("/project");
2072 let report = HealthReport {
2073 summary: HealthSummary {
2074 files_analyzed: 10,
2075 functions_analyzed: 50,
2076 ..Default::default()
2077 },
2078 coverage_intelligence: Some(CoverageIntelligenceReport {
2079 schema_version: CoverageIntelligenceSchemaVersion::V1,
2080 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2081 summary: CoverageIntelligenceSummary {
2082 findings: 1,
2083 high_confidence_deletes: 1,
2084 ..Default::default()
2085 },
2086 findings: vec![CoverageIntelligenceFinding {
2087 id: "fallow:coverage-intel:abc123".to_owned(),
2088 path: root.join("src/dead.ts"),
2089 identity: Some("deadPath".to_owned()),
2090 line: 9,
2091 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2092 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2093 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2094 confidence: CoverageIntelligenceConfidence::High,
2095 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2096 evidence: CoverageIntelligenceEvidence {
2097 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2098 ..Default::default()
2099 },
2100 actions: vec![CoverageIntelligenceAction {
2101 kind: "delete-after-confirming-owner".to_owned(),
2102 description: "Confirm ownership".to_owned(),
2103 auto_fixable: false,
2104 }],
2105 }],
2106 }),
2107 ..Default::default()
2108 };
2109
2110 let sarif = build_health_sarif(&report, &root);
2111 let result = &sarif["runs"][0]["results"][0];
2112 assert_eq!(result["ruleId"], "fallow/coverage-intelligence-delete");
2113 assert_eq!(
2114 result["properties"]["coverage_intelligence_id"],
2115 "fallow:coverage-intel:abc123"
2116 );
2117 assert_eq!(
2118 result["properties"]["recommendation"],
2119 "delete-after-confirming-owner"
2120 );
2121 assert_eq!(result["properties"]["confidence"], "high");
2122 assert_eq!(result["properties"]["signals"][0], "runtime_cold");
2123 assert_eq!(
2124 result["properties"]["related_ids"][0],
2125 "fallow:prod:deadbeef"
2126 );
2127 }
2128
2129 #[test]
2130 fn health_sarif_cyclomatic_only() {
2131 let root = PathBuf::from("/project");
2132 let report = crate::health_types::HealthReport {
2133 findings: vec![
2134 crate::health_types::ComplexityViolation {
2135 path: root.join("src/utils.ts"),
2136 name: "parseExpression".to_string(),
2137 line: 42,
2138 col: 0,
2139 cyclomatic: 25,
2140 cognitive: 10,
2141 line_count: 80,
2142 param_count: 0,
2143 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
2144 severity: crate::health_types::FindingSeverity::High,
2145 crap: None,
2146 coverage_pct: None,
2147 coverage_tier: None,
2148 coverage_source: None,
2149 inherited_from: None,
2150 component_rollup: None,
2151 contributions: Vec::new(),
2152 }
2153 .into(),
2154 ],
2155 summary: crate::health_types::HealthSummary {
2156 files_analyzed: 5,
2157 functions_analyzed: 20,
2158 functions_above_threshold: 1,
2159 ..Default::default()
2160 },
2161 ..Default::default()
2162 };
2163 let sarif = build_health_sarif(&report, &root);
2164 let entry = &sarif["runs"][0]["results"][0];
2165 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
2166 assert_eq!(entry["level"], "warning");
2167 assert!(
2168 entry["message"]["text"]
2169 .as_str()
2170 .unwrap()
2171 .contains("cyclomatic complexity 25")
2172 );
2173 assert_eq!(
2174 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2175 "src/utils.ts"
2176 );
2177 let region = &entry["locations"][0]["physicalLocation"]["region"];
2178 assert_eq!(region["startLine"], 42);
2179 assert_eq!(region["startColumn"], 1);
2180 }
2181
2182 #[test]
2183 fn health_sarif_cognitive_only() {
2184 let root = PathBuf::from("/project");
2185 let report = crate::health_types::HealthReport {
2186 findings: vec![
2187 crate::health_types::ComplexityViolation {
2188 path: root.join("src/api.ts"),
2189 name: "handleRequest".to_string(),
2190 line: 10,
2191 col: 4,
2192 cyclomatic: 8,
2193 cognitive: 20,
2194 line_count: 40,
2195 param_count: 0,
2196 exceeded: crate::health_types::ExceededThreshold::Cognitive,
2197 severity: crate::health_types::FindingSeverity::High,
2198 crap: None,
2199 coverage_pct: None,
2200 coverage_tier: None,
2201 coverage_source: None,
2202 inherited_from: None,
2203 component_rollup: None,
2204 contributions: Vec::new(),
2205 }
2206 .into(),
2207 ],
2208 summary: crate::health_types::HealthSummary {
2209 files_analyzed: 3,
2210 functions_analyzed: 10,
2211 functions_above_threshold: 1,
2212 ..Default::default()
2213 },
2214 ..Default::default()
2215 };
2216 let sarif = build_health_sarif(&report, &root);
2217 let entry = &sarif["runs"][0]["results"][0];
2218 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
2219 assert!(
2220 entry["message"]["text"]
2221 .as_str()
2222 .unwrap()
2223 .contains("cognitive complexity 20")
2224 );
2225 let region = &entry["locations"][0]["physicalLocation"]["region"];
2226 assert_eq!(region["startColumn"], 5); }
2228
2229 #[test]
2230 fn health_sarif_both_thresholds() {
2231 let root = PathBuf::from("/project");
2232 let report = crate::health_types::HealthReport {
2233 findings: vec![
2234 crate::health_types::ComplexityViolation {
2235 path: root.join("src/complex.ts"),
2236 name: "doEverything".to_string(),
2237 line: 1,
2238 col: 0,
2239 cyclomatic: 30,
2240 cognitive: 45,
2241 line_count: 100,
2242 param_count: 0,
2243 exceeded: crate::health_types::ExceededThreshold::Both,
2244 severity: crate::health_types::FindingSeverity::High,
2245 crap: None,
2246 coverage_pct: None,
2247 coverage_tier: None,
2248 coverage_source: None,
2249 inherited_from: None,
2250 component_rollup: None,
2251 contributions: Vec::new(),
2252 }
2253 .into(),
2254 ],
2255 summary: crate::health_types::HealthSummary {
2256 files_analyzed: 1,
2257 functions_analyzed: 1,
2258 functions_above_threshold: 1,
2259 ..Default::default()
2260 },
2261 ..Default::default()
2262 };
2263 let sarif = build_health_sarif(&report, &root);
2264 let entry = &sarif["runs"][0]["results"][0];
2265 assert_eq!(entry["ruleId"], "fallow/high-complexity");
2266 let msg = entry["message"]["text"].as_str().unwrap();
2267 assert!(msg.contains("cyclomatic complexity 30"));
2268 assert!(msg.contains("cognitive complexity 45"));
2269 }
2270
2271 #[test]
2272 fn health_sarif_crap_only_emits_crap_rule() {
2273 let root = PathBuf::from("/project");
2274 let report = crate::health_types::HealthReport {
2275 findings: vec![
2276 crate::health_types::ComplexityViolation {
2277 path: root.join("src/untested.ts"),
2278 name: "risky".to_string(),
2279 line: 8,
2280 col: 0,
2281 cyclomatic: 10,
2282 cognitive: 10,
2283 line_count: 20,
2284 param_count: 1,
2285 exceeded: crate::health_types::ExceededThreshold::Crap,
2286 severity: crate::health_types::FindingSeverity::High,
2287 crap: Some(82.2),
2288 coverage_pct: Some(12.0),
2289 coverage_tier: None,
2290 coverage_source: None,
2291 inherited_from: None,
2292 component_rollup: None,
2293 contributions: Vec::new(),
2294 }
2295 .into(),
2296 ],
2297 summary: crate::health_types::HealthSummary {
2298 files_analyzed: 1,
2299 functions_analyzed: 1,
2300 functions_above_threshold: 1,
2301 ..Default::default()
2302 },
2303 ..Default::default()
2304 };
2305 let sarif = build_health_sarif(&report, &root);
2306 let entry = &sarif["runs"][0]["results"][0];
2307 assert_eq!(entry["ruleId"], "fallow/high-crap-score");
2308 let msg = entry["message"]["text"].as_str().unwrap();
2309 assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
2310 assert!(msg.contains("coverage 12%"), "msg: {msg}");
2311 }
2312
2313 #[test]
2314 fn health_sarif_cyclomatic_crap_uses_crap_rule() {
2315 let root = PathBuf::from("/project");
2316 let report = crate::health_types::HealthReport {
2317 findings: vec![
2318 crate::health_types::ComplexityViolation {
2319 path: root.join("src/hot.ts"),
2320 name: "branchy".to_string(),
2321 line: 1,
2322 col: 0,
2323 cyclomatic: 67,
2324 cognitive: 12,
2325 line_count: 80,
2326 param_count: 1,
2327 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
2328 severity: crate::health_types::FindingSeverity::Critical,
2329 crap: Some(182.0),
2330 coverage_pct: None,
2331 coverage_tier: None,
2332 coverage_source: None,
2333 inherited_from: None,
2334 component_rollup: None,
2335 contributions: Vec::new(),
2336 }
2337 .into(),
2338 ],
2339 summary: crate::health_types::HealthSummary {
2340 files_analyzed: 1,
2341 functions_analyzed: 1,
2342 functions_above_threshold: 1,
2343 ..Default::default()
2344 },
2345 ..Default::default()
2346 };
2347 let sarif = build_health_sarif(&report, &root);
2348 let results = sarif["runs"][0]["results"].as_array().unwrap();
2349 assert_eq!(
2350 results.len(),
2351 1,
2352 "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
2353 );
2354 assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
2355 let msg = results[0]["message"]["text"].as_str().unwrap();
2356 assert!(msg.contains("CRAP score 182"), "msg: {msg}");
2357 assert!(!msg.contains("coverage"), "msg: {msg}");
2358 }
2359
2360 #[test]
2361 fn severity_to_sarif_level_error() {
2362 assert_eq!(severity_to_sarif_level(Severity::Error), "error");
2363 }
2364
2365 #[test]
2366 fn severity_to_sarif_level_warn() {
2367 assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
2368 }
2369
2370 #[test]
2371 #[should_panic(expected = "internal error: entered unreachable code")]
2372 fn severity_to_sarif_level_off() {
2373 let _ = severity_to_sarif_level(Severity::Off);
2374 }
2375
2376 #[test]
2377 fn sarif_re_export_has_properties() {
2378 let root = PathBuf::from("/project");
2379 let mut results = AnalysisResults::default();
2380 results
2381 .unused_exports
2382 .push(UnusedExportFinding::with_actions(UnusedExport {
2383 path: root.join("src/index.ts"),
2384 export_name: "reExported".to_string(),
2385 is_type_only: false,
2386 line: 1,
2387 col: 0,
2388 span_start: 0,
2389 is_re_export: true,
2390 }));
2391
2392 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2393 let entry = &sarif["runs"][0]["results"][0];
2394 assert_eq!(entry["properties"]["is_re_export"], true);
2395 let msg = entry["message"]["text"].as_str().unwrap();
2396 assert!(msg.starts_with("Re-export"));
2397 }
2398
2399 #[test]
2400 fn sarif_non_re_export_has_no_properties() {
2401 let root = PathBuf::from("/project");
2402 let mut results = AnalysisResults::default();
2403 results
2404 .unused_exports
2405 .push(UnusedExportFinding::with_actions(UnusedExport {
2406 path: root.join("src/utils.ts"),
2407 export_name: "foo".to_string(),
2408 is_type_only: false,
2409 line: 5,
2410 col: 0,
2411 span_start: 0,
2412 is_re_export: false,
2413 }));
2414
2415 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2416 let entry = &sarif["runs"][0]["results"][0];
2417 assert!(entry.get("properties").is_none());
2418 let msg = entry["message"]["text"].as_str().unwrap();
2419 assert!(msg.starts_with("Export"));
2420 }
2421
2422 #[test]
2423 fn sarif_type_re_export_message() {
2424 let root = PathBuf::from("/project");
2425 let mut results = AnalysisResults::default();
2426 results
2427 .unused_types
2428 .push(UnusedTypeFinding::with_actions(UnusedExport {
2429 path: root.join("src/index.ts"),
2430 export_name: "MyType".to_string(),
2431 is_type_only: true,
2432 line: 1,
2433 col: 0,
2434 span_start: 0,
2435 is_re_export: true,
2436 }));
2437
2438 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2439 let entry = &sarif["runs"][0]["results"][0];
2440 assert_eq!(entry["ruleId"], "fallow/unused-type");
2441 let msg = entry["message"]["text"].as_str().unwrap();
2442 assert!(msg.starts_with("Type re-export"));
2443 assert_eq!(entry["properties"]["is_re_export"], true);
2444 }
2445
2446 #[test]
2447 fn sarif_dependency_line_zero_skips_region() {
2448 let root = PathBuf::from("/project");
2449 let mut results = AnalysisResults::default();
2450 results
2451 .unused_dependencies
2452 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2453 package_name: "lodash".to_string(),
2454 location: DependencyLocation::Dependencies,
2455 path: root.join("package.json"),
2456 line: 0,
2457 used_in_workspaces: Vec::new(),
2458 }));
2459
2460 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2461 let entry = &sarif["runs"][0]["results"][0];
2462 let phys = &entry["locations"][0]["physicalLocation"];
2463 assert!(phys.get("region").is_none());
2464 }
2465
2466 #[test]
2467 fn sarif_dependency_line_nonzero_has_region() {
2468 let root = PathBuf::from("/project");
2469 let mut results = AnalysisResults::default();
2470 results
2471 .unused_dependencies
2472 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2473 package_name: "lodash".to_string(),
2474 location: DependencyLocation::Dependencies,
2475 path: root.join("package.json"),
2476 line: 7,
2477 used_in_workspaces: Vec::new(),
2478 }));
2479
2480 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2481 let entry = &sarif["runs"][0]["results"][0];
2482 let region = &entry["locations"][0]["physicalLocation"]["region"];
2483 assert_eq!(region["startLine"], 7);
2484 assert_eq!(region["startColumn"], 1);
2485 }
2486
2487 #[test]
2488 fn sarif_type_only_dep_line_zero_skips_region() {
2489 let root = PathBuf::from("/project");
2490 let mut results = AnalysisResults::default();
2491 results
2492 .type_only_dependencies
2493 .push(TypeOnlyDependencyFinding::with_actions(
2494 TypeOnlyDependency {
2495 package_name: "zod".to_string(),
2496 path: root.join("package.json"),
2497 line: 0,
2498 },
2499 ));
2500
2501 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2502 let entry = &sarif["runs"][0]["results"][0];
2503 let phys = &entry["locations"][0]["physicalLocation"];
2504 assert!(phys.get("region").is_none());
2505 }
2506
2507 #[test]
2508 fn sarif_circular_dep_line_zero_skips_region() {
2509 let root = PathBuf::from("/project");
2510 let mut results = AnalysisResults::default();
2511 results
2512 .circular_dependencies
2513 .push(CircularDependencyFinding::with_actions(
2514 CircularDependency {
2515 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2516 length: 2,
2517 line: 0,
2518 col: 0,
2519 edges: Vec::new(),
2520 is_cross_package: false,
2521 },
2522 ));
2523
2524 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2525 let entry = &sarif["runs"][0]["results"][0];
2526 let phys = &entry["locations"][0]["physicalLocation"];
2527 assert!(phys.get("region").is_none());
2528 }
2529
2530 #[test]
2531 fn sarif_circular_dep_line_nonzero_has_region() {
2532 let root = PathBuf::from("/project");
2533 let mut results = AnalysisResults::default();
2534 results
2535 .circular_dependencies
2536 .push(CircularDependencyFinding::with_actions(
2537 CircularDependency {
2538 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2539 length: 2,
2540 line: 5,
2541 col: 2,
2542 edges: Vec::new(),
2543 is_cross_package: false,
2544 },
2545 ));
2546
2547 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2548 let entry = &sarif["runs"][0]["results"][0];
2549 let region = &entry["locations"][0]["physicalLocation"]["region"];
2550 assert_eq!(region["startLine"], 5);
2551 assert_eq!(region["startColumn"], 3);
2552 }
2553
2554 #[test]
2555 fn sarif_unused_optional_dependency_result() {
2556 let root = PathBuf::from("/project");
2557 let mut results = AnalysisResults::default();
2558 results
2559 .unused_optional_dependencies
2560 .push(UnusedOptionalDependencyFinding::with_actions(
2561 UnusedDependency {
2562 package_name: "fsevents".to_string(),
2563 location: DependencyLocation::OptionalDependencies,
2564 path: root.join("package.json"),
2565 line: 12,
2566 used_in_workspaces: Vec::new(),
2567 },
2568 ));
2569
2570 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2571 let entry = &sarif["runs"][0]["results"][0];
2572 assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
2573 let msg = entry["message"]["text"].as_str().unwrap();
2574 assert!(msg.contains("optionalDependencies"));
2575 }
2576
2577 #[test]
2578 fn sarif_enum_member_message_format() {
2579 let root = PathBuf::from("/project");
2580 let mut results = AnalysisResults::default();
2581 results.unused_enum_members.push(
2582 fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
2583 path: root.join("src/enums.ts"),
2584 parent_name: "Color".to_string(),
2585 member_name: "Purple".to_string(),
2586 kind: fallow_core::extract::MemberKind::EnumMember,
2587 line: 5,
2588 col: 2,
2589 }),
2590 );
2591
2592 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2593 let entry = &sarif["runs"][0]["results"][0];
2594 assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
2595 let msg = entry["message"]["text"].as_str().unwrap();
2596 assert!(msg.contains("Enum member 'Color.Purple'"));
2597 let region = &entry["locations"][0]["physicalLocation"]["region"];
2598 assert_eq!(region["startColumn"], 3); }
2600
2601 #[test]
2602 fn sarif_class_member_message_format() {
2603 let root = PathBuf::from("/project");
2604 let mut results = AnalysisResults::default();
2605 results.unused_class_members.push(
2606 fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
2607 path: root.join("src/service.ts"),
2608 parent_name: "API".to_string(),
2609 member_name: "fetch".to_string(),
2610 kind: fallow_core::extract::MemberKind::ClassMethod,
2611 line: 10,
2612 col: 4,
2613 }),
2614 );
2615
2616 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2617 let entry = &sarif["runs"][0]["results"][0];
2618 assert_eq!(entry["ruleId"], "fallow/unused-class-member");
2619 let msg = entry["message"]["text"].as_str().unwrap();
2620 assert!(msg.contains("Class member 'API.fetch'"));
2621 }
2622
2623 #[test]
2624 #[expect(
2625 clippy::cast_possible_truncation,
2626 reason = "test line/col values are trivially small"
2627 )]
2628 fn duplication_sarif_structure() {
2629 use fallow_core::duplicates::*;
2630
2631 let root = PathBuf::from("/project");
2632 let report = DuplicationReport {
2633 clone_groups: vec![CloneGroup {
2634 instances: vec![
2635 CloneInstance {
2636 file: root.join("src/a.ts"),
2637 start_line: 1,
2638 end_line: 10,
2639 start_col: 0,
2640 end_col: 0,
2641 fragment: String::new(),
2642 },
2643 CloneInstance {
2644 file: root.join("src/b.ts"),
2645 start_line: 5,
2646 end_line: 14,
2647 start_col: 2,
2648 end_col: 0,
2649 fragment: String::new(),
2650 },
2651 ],
2652 token_count: 50,
2653 line_count: 10,
2654 }],
2655 clone_families: vec![],
2656 mirrored_directories: vec![],
2657 stats: DuplicationStats::default(),
2658 };
2659
2660 let sarif = serde_json::json!({
2661 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2662 "version": "2.1.0",
2663 "runs": [{
2664 "tool": {
2665 "driver": {
2666 "name": "fallow",
2667 "version": env!("CARGO_PKG_VERSION"),
2668 "informationUri": "https://github.com/fallow-rs/fallow",
2669 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2670 }
2671 },
2672 "results": []
2673 }]
2674 });
2675 let _ = sarif;
2676
2677 let mut sarif_results = Vec::new();
2678 for (i, group) in report.clone_groups.iter().enumerate() {
2679 for instance in &group.instances {
2680 sarif_results.push(sarif_result(
2681 "fallow/code-duplication",
2682 "warning",
2683 &format!(
2684 "Code clone group {} ({} lines, {} instances)",
2685 i + 1,
2686 group.line_count,
2687 group.instances.len()
2688 ),
2689 &super::super::relative_uri(&instance.file, &root),
2690 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2691 ));
2692 }
2693 }
2694 assert_eq!(sarif_results.len(), 2);
2695 assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
2696 assert!(
2697 sarif_results[0]["message"]["text"]
2698 .as_str()
2699 .unwrap()
2700 .contains("10 lines")
2701 );
2702 let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
2703 assert_eq!(region0["startLine"], 1);
2704 assert_eq!(region0["startColumn"], 1); let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
2706 assert_eq!(region1["startLine"], 5);
2707 assert_eq!(region1["startColumn"], 3); }
2709
2710 #[test]
2711 fn sarif_rule_known_id_has_full_description() {
2712 let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
2713 assert!(rule.get("fullDescription").is_some());
2714 assert!(rule.get("helpUri").is_some());
2715 }
2716
2717 #[test]
2718 fn sarif_rule_unknown_id_uses_fallback() {
2719 let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
2720 assert_eq!(rule["shortDescription"]["text"], "fallback text");
2721 assert!(rule.get("fullDescription").is_none());
2722 assert!(rule.get("helpUri").is_none());
2723 assert_eq!(rule["defaultConfiguration"]["level"], "warning");
2724 }
2725
2726 #[test]
2727 fn sarif_result_no_region_omits_region_key() {
2728 let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
2729 let phys = &result["locations"][0]["physicalLocation"];
2730 assert!(phys.get("region").is_none());
2731 assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
2732 }
2733
2734 #[test]
2735 fn sarif_result_with_region_includes_region() {
2736 let result = sarif_result(
2737 "rule/test",
2738 "error",
2739 "test msg",
2740 "src/file.ts",
2741 Some((10, 5)),
2742 );
2743 let region = &result["locations"][0]["physicalLocation"]["region"];
2744 assert_eq!(region["startLine"], 10);
2745 assert_eq!(region["startColumn"], 5);
2746 }
2747
2748 #[test]
2749 fn sarif_partial_fingerprint_ignores_rendered_message() {
2750 let a = sarif_result(
2751 "rule/test",
2752 "error",
2753 "first message",
2754 "src/file.ts",
2755 Some((10, 5)),
2756 );
2757 let b = sarif_result(
2758 "rule/test",
2759 "error",
2760 "rewritten message",
2761 "src/file.ts",
2762 Some((10, 5)),
2763 );
2764 assert_eq!(
2765 a["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
2766 b["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
2767 );
2768 }
2769
2770 #[test]
2771 fn health_sarif_includes_refactoring_targets() {
2772 use crate::health_types::*;
2773
2774 let root = PathBuf::from("/project");
2775 let report = HealthReport {
2776 summary: HealthSummary {
2777 files_analyzed: 10,
2778 functions_analyzed: 50,
2779 ..Default::default()
2780 },
2781 targets: vec![
2782 RefactoringTarget {
2783 path: root.join("src/complex.ts"),
2784 priority: 85.0,
2785 efficiency: 42.5,
2786 recommendation: "Split high-impact file".into(),
2787 category: RecommendationCategory::SplitHighImpact,
2788 effort: EffortEstimate::Medium,
2789 confidence: Confidence::High,
2790 factors: vec![],
2791 evidence: None,
2792 }
2793 .into(),
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(), 1);
2801 assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
2802 assert_eq!(entries[0]["level"], "warning");
2803 let msg = entries[0]["message"]["text"].as_str().unwrap();
2804 assert!(msg.contains("high impact"));
2805 assert!(msg.contains("Split high-impact file"));
2806 assert!(msg.contains("42.5"));
2807 }
2808
2809 #[test]
2810 fn health_sarif_includes_coverage_gaps() {
2811 use crate::health_types::*;
2812
2813 let root = PathBuf::from("/project");
2814 let report = HealthReport {
2815 summary: HealthSummary {
2816 files_analyzed: 10,
2817 functions_analyzed: 50,
2818 ..Default::default()
2819 },
2820 coverage_gaps: Some(CoverageGaps {
2821 summary: CoverageGapSummary {
2822 runtime_files: 2,
2823 covered_files: 0,
2824 file_coverage_pct: 0.0,
2825 untested_files: 1,
2826 untested_exports: 1,
2827 },
2828 files: vec![UntestedFileFinding::with_actions(
2829 UntestedFile {
2830 path: root.join("src/app.ts"),
2831 value_export_count: 2,
2832 },
2833 &root,
2834 )],
2835 exports: vec![UntestedExportFinding::with_actions(
2836 UntestedExport {
2837 path: root.join("src/app.ts"),
2838 export_name: "loader".into(),
2839 line: 12,
2840 col: 4,
2841 },
2842 &root,
2843 )],
2844 }),
2845 ..Default::default()
2846 };
2847
2848 let sarif = build_health_sarif(&report, &root);
2849 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2850 assert_eq!(entries.len(), 2);
2851 assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
2852 assert_eq!(
2853 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2854 "src/app.ts"
2855 );
2856 assert!(
2857 entries[0]["message"]["text"]
2858 .as_str()
2859 .unwrap()
2860 .contains("2 value exports")
2861 );
2862 assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
2863 assert_eq!(
2864 entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
2865 12
2866 );
2867 assert_eq!(
2868 entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
2869 5
2870 );
2871 }
2872
2873 #[test]
2874 fn health_sarif_rules_have_full_descriptions() {
2875 let root = PathBuf::from("/project");
2876 let report = crate::health_types::HealthReport::default();
2877 let sarif = build_health_sarif(&report, &root);
2878 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2879 .as_array()
2880 .unwrap();
2881 for rule in rules {
2882 let id = rule["id"].as_str().unwrap();
2883 assert!(
2884 rule.get("fullDescription").is_some(),
2885 "health rule {id} should have fullDescription"
2886 );
2887 assert!(
2888 rule.get("helpUri").is_some(),
2889 "health rule {id} should have helpUri"
2890 );
2891 }
2892 }
2893
2894 #[test]
2895 fn sarif_warn_severity_produces_warning_level() {
2896 let root = PathBuf::from("/project");
2897 let mut results = AnalysisResults::default();
2898 results
2899 .unused_files
2900 .push(UnusedFileFinding::with_actions(UnusedFile {
2901 path: root.join("src/dead.ts"),
2902 }));
2903
2904 let rules = RulesConfig {
2905 unused_files: Severity::Warn,
2906 ..RulesConfig::default()
2907 };
2908
2909 let sarif = build_sarif(&results, &root, &rules);
2910 let entry = &sarif["runs"][0]["results"][0];
2911 assert_eq!(entry["level"], "warning");
2912 }
2913
2914 #[test]
2915 fn sarif_unused_file_has_no_region() {
2916 let root = PathBuf::from("/project");
2917 let mut results = AnalysisResults::default();
2918 results
2919 .unused_files
2920 .push(UnusedFileFinding::with_actions(UnusedFile {
2921 path: root.join("src/dead.ts"),
2922 }));
2923
2924 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2925 let entry = &sarif["runs"][0]["results"][0];
2926 let phys = &entry["locations"][0]["physicalLocation"];
2927 assert!(phys.get("region").is_none());
2928 }
2929
2930 #[test]
2931 fn sarif_unlisted_dep_multiple_import_sites() {
2932 let root = PathBuf::from("/project");
2933 let mut results = AnalysisResults::default();
2934 results
2935 .unlisted_dependencies
2936 .push(UnlistedDependencyFinding::with_actions(
2937 UnlistedDependency {
2938 package_name: "dotenv".to_string(),
2939 imported_from: vec![
2940 ImportSite {
2941 path: root.join("src/a.ts"),
2942 line: 1,
2943 col: 0,
2944 },
2945 ImportSite {
2946 path: root.join("src/b.ts"),
2947 line: 5,
2948 col: 0,
2949 },
2950 ],
2951 },
2952 ));
2953
2954 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2955 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2956 assert_eq!(entries.len(), 2);
2957 assert_eq!(
2958 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2959 "src/a.ts"
2960 );
2961 assert_eq!(
2962 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2963 "src/b.ts"
2964 );
2965 }
2966
2967 #[test]
2968 fn sarif_unlisted_dep_no_import_sites() {
2969 let root = PathBuf::from("/project");
2970 let mut results = AnalysisResults::default();
2971 results
2972 .unlisted_dependencies
2973 .push(UnlistedDependencyFinding::with_actions(
2974 UnlistedDependency {
2975 package_name: "phantom".to_string(),
2976 imported_from: vec![],
2977 },
2978 ));
2979
2980 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2981 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2982 assert!(entries.is_empty());
2983 }
2984}