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_boundary_violation_fields(
387 violation: &BoundaryViolation,
388 root: &Path,
389 level: &'static str,
390) -> SarifFields {
391 let from_uri = relative_uri(&violation.from_path, root);
392 let to_uri = relative_uri(&violation.to_path, root);
393 SarifFields {
394 rule_id: "fallow/boundary-violation",
395 level,
396 message: format!(
397 "Import from zone '{}' to zone '{}' is not allowed ({})",
398 violation.from_zone, violation.to_zone, to_uri,
399 ),
400 uri: from_uri,
401 region: if violation.line > 0 {
402 Some((violation.line, violation.col + 1))
403 } else {
404 None
405 },
406 source_path: (violation.line > 0).then(|| violation.from_path.clone()),
407 properties: None,
408 }
409}
410
411fn sarif_stale_suppression_fields(
412 suppression: &StaleSuppression,
413 root: &Path,
414 level: &'static str,
415) -> SarifFields {
416 SarifFields {
417 rule_id: "fallow/stale-suppression",
418 level,
419 message: suppression.description(),
420 uri: relative_uri(&suppression.path, root),
421 region: Some((suppression.line, suppression.col + 1)),
422 source_path: Some(suppression.path.clone()),
423 properties: None,
424 }
425}
426
427fn sarif_unused_catalog_entry_fields(
428 entry: &UnusedCatalogEntryFinding,
429 root: &Path,
430 level: &'static str,
431) -> SarifFields {
432 let entry = &entry.entry;
433 let message = if entry.catalog_name == "default" {
434 format!(
435 "Catalog entry '{}' is not referenced by any workspace package",
436 entry.entry_name
437 )
438 } else {
439 format!(
440 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
441 entry.entry_name, entry.catalog_name
442 )
443 };
444 SarifFields {
445 rule_id: "fallow/unused-catalog-entry",
446 level,
447 message,
448 uri: relative_uri(&entry.path, root),
449 region: Some((entry.line, 1)),
450 source_path: Some(entry.path.clone()),
451 properties: None,
452 }
453}
454
455fn sarif_unused_dependency_override_fields(
456 finding: &UnusedDependencyOverrideFinding,
457 root: &Path,
458 level: &'static str,
459) -> SarifFields {
460 let finding = &finding.entry;
461 let mut message = format!(
462 "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
463 finding.raw_key, finding.version_range, finding.target_package,
464 );
465 if let Some(hint) = &finding.hint {
466 use std::fmt::Write as _;
467 let _ = write!(message, " ({hint})");
468 }
469 SarifFields {
470 rule_id: "fallow/unused-dependency-override",
471 level,
472 message,
473 uri: relative_uri(&finding.path, root),
474 region: Some((finding.line, 1)),
475 source_path: Some(finding.path.clone()),
476 properties: None,
477 }
478}
479
480fn sarif_misconfigured_dependency_override_fields(
481 finding: &MisconfiguredDependencyOverrideFinding,
482 root: &Path,
483 level: &'static str,
484) -> SarifFields {
485 let finding = &finding.entry;
486 let message = format!(
487 "Override `{}` -> `{}` is malformed: {}",
488 finding.raw_key,
489 finding.raw_value,
490 finding.reason.describe(),
491 );
492 SarifFields {
493 rule_id: "fallow/misconfigured-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_unresolved_catalog_reference_fields(
504 finding: &UnresolvedCatalogReferenceFinding,
505 root: &Path,
506 level: &'static str,
507) -> SarifFields {
508 let finding = &finding.reference;
509 let catalog_phrase = if finding.catalog_name == "default" {
510 "the default catalog".to_string()
511 } else {
512 format!("catalog '{}'", finding.catalog_name)
513 };
514 let mut message = format!(
515 "Package '{}' is referenced via `catalog:{}` but {} does not declare it",
516 finding.entry_name,
517 if finding.catalog_name == "default" {
518 ""
519 } else {
520 finding.catalog_name.as_str()
521 },
522 catalog_phrase,
523 );
524 if !finding.available_in_catalogs.is_empty() {
525 use std::fmt::Write as _;
526 let _ = write!(
527 message,
528 " (available in: {})",
529 finding.available_in_catalogs.join(", ")
530 );
531 }
532 SarifFields {
533 rule_id: "fallow/unresolved-catalog-reference",
534 level,
535 message,
536 uri: relative_uri(&finding.path, root),
537 region: Some((finding.line, 1)),
538 source_path: Some(finding.path.clone()),
539 properties: None,
540 }
541}
542
543fn sarif_empty_catalog_group_fields(
544 group: &EmptyCatalogGroupFinding,
545 root: &Path,
546 level: &'static str,
547) -> SarifFields {
548 let group = &group.group;
549 SarifFields {
550 rule_id: "fallow/empty-catalog-group",
551 level,
552 message: format!("Catalog group '{}' has no entries", group.catalog_name),
553 uri: relative_uri(&group.path, root),
554 region: Some((group.line, 1)),
555 source_path: Some(group.path.clone()),
556 properties: None,
557 }
558}
559
560fn push_sarif_unlisted_deps(
563 sarif_results: &mut Vec<serde_json::Value>,
564 deps: &[UnlistedDependencyFinding],
565 root: &Path,
566 level: &'static str,
567 snippets: &mut SourceSnippetCache,
568) {
569 for entry in deps {
570 let dep = &entry.dep;
571 for site in &dep.imported_from {
572 let uri = relative_uri(&site.path, root);
573 let source_snippet = snippets.line(&site.path, site.line);
574 sarif_results.push(sarif_result_with_snippet(
575 "fallow/unlisted-dependency",
576 level,
577 &format!(
578 "Package '{}' is imported but not listed in package.json",
579 dep.package_name
580 ),
581 &uri,
582 Some((site.line, site.col + 1)),
583 source_snippet.as_deref(),
584 ));
585 }
586 }
587}
588
589fn push_sarif_duplicate_exports(
592 sarif_results: &mut Vec<serde_json::Value>,
593 dups: &[DuplicateExportFinding],
594 root: &Path,
595 level: &'static str,
596 snippets: &mut SourceSnippetCache,
597) {
598 for dup in dups {
599 let dup = &dup.export;
600 for loc in &dup.locations {
601 let uri = relative_uri(&loc.path, root);
602 let source_snippet = snippets.line(&loc.path, loc.line);
603 sarif_results.push(sarif_result_with_snippet(
604 "fallow/duplicate-export",
605 level,
606 &format!("Export '{}' appears in multiple modules", dup.export_name),
607 &uri,
608 Some((loc.line, loc.col + 1)),
609 source_snippet.as_deref(),
610 ));
611 }
612 }
613}
614
615fn build_sarif_rules(rules: &RulesConfig) -> Vec<serde_json::Value> {
617 [
618 (
619 "fallow/unused-file",
620 "File is not reachable from any entry point",
621 rules.unused_files,
622 ),
623 (
624 "fallow/unused-export",
625 "Export is never imported",
626 rules.unused_exports,
627 ),
628 (
629 "fallow/unused-type",
630 "Type export is never imported",
631 rules.unused_types,
632 ),
633 (
634 "fallow/private-type-leak",
635 "Exported signature references a same-file private type",
636 rules.private_type_leaks,
637 ),
638 (
639 "fallow/unused-dependency",
640 "Dependency listed but never imported",
641 rules.unused_dependencies,
642 ),
643 (
644 "fallow/unused-dev-dependency",
645 "Dev dependency listed but never imported",
646 rules.unused_dev_dependencies,
647 ),
648 (
649 "fallow/unused-optional-dependency",
650 "Optional dependency listed but never imported",
651 rules.unused_optional_dependencies,
652 ),
653 (
654 "fallow/type-only-dependency",
655 "Production dependency only used via type-only imports",
656 rules.type_only_dependencies,
657 ),
658 (
659 "fallow/test-only-dependency",
660 "Production dependency only imported by test files",
661 rules.test_only_dependencies,
662 ),
663 (
664 "fallow/unused-enum-member",
665 "Enum member is never referenced",
666 rules.unused_enum_members,
667 ),
668 (
669 "fallow/unused-class-member",
670 "Class member is never referenced",
671 rules.unused_class_members,
672 ),
673 (
674 "fallow/unresolved-import",
675 "Import could not be resolved",
676 rules.unresolved_imports,
677 ),
678 (
679 "fallow/unlisted-dependency",
680 "Dependency used but not in package.json",
681 rules.unlisted_dependencies,
682 ),
683 (
684 "fallow/duplicate-export",
685 "Export name appears in multiple modules",
686 rules.duplicate_exports,
687 ),
688 (
689 "fallow/circular-dependency",
690 "Circular dependency chain detected",
691 rules.circular_dependencies,
692 ),
693 (
694 "fallow/boundary-violation",
695 "Import crosses an architecture boundary",
696 rules.boundary_violation,
697 ),
698 (
699 "fallow/stale-suppression",
700 "Suppression comment or tag no longer matches any issue",
701 rules.stale_suppressions,
702 ),
703 (
704 "fallow/unused-catalog-entry",
705 "pnpm catalog entry not referenced by any workspace package",
706 rules.unused_catalog_entries,
707 ),
708 (
709 "fallow/empty-catalog-group",
710 "pnpm named catalog group has no entries",
711 rules.empty_catalog_groups,
712 ),
713 (
714 "fallow/unresolved-catalog-reference",
715 "package.json catalog reference points at a catalog that does not declare the package",
716 rules.unresolved_catalog_references,
717 ),
718 (
719 "fallow/unused-dependency-override",
720 "pnpm dependency override target is not declared or lockfile-resolved",
721 rules.unused_dependency_overrides,
722 ),
723 (
724 "fallow/misconfigured-dependency-override",
725 "pnpm dependency override key or value is malformed",
726 rules.misconfigured_dependency_overrides,
727 ),
728 ]
729 .into_iter()
730 .map(|(id, description, rule_severity)| {
731 sarif_rule(id, description, configured_sarif_level(rule_severity))
732 })
733 .collect()
734}
735
736#[must_use]
737#[expect(
738 clippy::too_many_lines,
739 reason = "SARIF builds one flat result list across every analysis family"
740)]
741pub fn build_sarif(
742 results: &AnalysisResults,
743 root: &Path,
744 rules: &RulesConfig,
745) -> serde_json::Value {
746 let mut sarif_results = Vec::new();
747 let mut snippets = SourceSnippetCache::default();
748
749 push_sarif_results(
750 &mut sarif_results,
751 &results.unused_files,
752 &mut snippets,
753 |f| sarif_unused_file_fields(&f.file, root, severity_to_sarif_level(rules.unused_files)),
754 );
755 push_sarif_results(
756 &mut sarif_results,
757 &results.unused_exports,
758 &mut snippets,
759 |e| {
760 sarif_export_fields(
761 &e.export,
762 root,
763 "fallow/unused-export",
764 severity_to_sarif_level(rules.unused_exports),
765 "Export",
766 "Re-export",
767 )
768 },
769 );
770 push_sarif_results(
771 &mut sarif_results,
772 &results.unused_types,
773 &mut snippets,
774 |e| {
775 sarif_export_fields(
776 &e.export,
777 root,
778 "fallow/unused-type",
779 severity_to_sarif_level(rules.unused_types),
780 "Type export",
781 "Type re-export",
782 )
783 },
784 );
785 push_sarif_results(
786 &mut sarif_results,
787 &results.private_type_leaks,
788 &mut snippets,
789 |e| {
790 sarif_private_type_leak_fields(
791 &e.leak,
792 root,
793 severity_to_sarif_level(rules.private_type_leaks),
794 )
795 },
796 );
797 push_sarif_results(
798 &mut sarif_results,
799 &results.unused_dependencies,
800 &mut snippets,
801 |d| {
802 sarif_dep_fields(
803 &d.dep,
804 root,
805 "fallow/unused-dependency",
806 severity_to_sarif_level(rules.unused_dependencies),
807 "dependencies",
808 )
809 },
810 );
811 push_sarif_results(
812 &mut sarif_results,
813 &results.unused_dev_dependencies,
814 &mut snippets,
815 |d| {
816 sarif_dep_fields(
817 &d.dep,
818 root,
819 "fallow/unused-dev-dependency",
820 severity_to_sarif_level(rules.unused_dev_dependencies),
821 "devDependencies",
822 )
823 },
824 );
825 push_sarif_results(
826 &mut sarif_results,
827 &results.unused_optional_dependencies,
828 &mut snippets,
829 |d| {
830 sarif_dep_fields(
831 &d.dep,
832 root,
833 "fallow/unused-optional-dependency",
834 severity_to_sarif_level(rules.unused_optional_dependencies),
835 "optionalDependencies",
836 )
837 },
838 );
839 push_sarif_results(
840 &mut sarif_results,
841 &results.type_only_dependencies,
842 &mut snippets,
843 |d| {
844 sarif_type_only_dep_fields(
845 &d.dep,
846 root,
847 severity_to_sarif_level(rules.type_only_dependencies),
848 )
849 },
850 );
851 push_sarif_results(
852 &mut sarif_results,
853 &results.test_only_dependencies,
854 &mut snippets,
855 |d| {
856 sarif_test_only_dep_fields(
857 &d.dep,
858 root,
859 severity_to_sarif_level(rules.test_only_dependencies),
860 )
861 },
862 );
863 push_sarif_results(
864 &mut sarif_results,
865 &results.unused_enum_members,
866 &mut snippets,
867 |m| {
868 sarif_member_fields(
869 &m.member,
870 root,
871 "fallow/unused-enum-member",
872 severity_to_sarif_level(rules.unused_enum_members),
873 "Enum",
874 )
875 },
876 );
877 push_sarif_results(
878 &mut sarif_results,
879 &results.unused_class_members,
880 &mut snippets,
881 |m| {
882 sarif_member_fields(
883 &m.member,
884 root,
885 "fallow/unused-class-member",
886 severity_to_sarif_level(rules.unused_class_members),
887 "Class",
888 )
889 },
890 );
891 push_sarif_results(
892 &mut sarif_results,
893 &results.unresolved_imports,
894 &mut snippets,
895 |i| {
896 sarif_unresolved_import_fields(
897 &i.import,
898 root,
899 severity_to_sarif_level(rules.unresolved_imports),
900 )
901 },
902 );
903 if !results.unlisted_dependencies.is_empty() {
904 push_sarif_unlisted_deps(
905 &mut sarif_results,
906 &results.unlisted_dependencies,
907 root,
908 severity_to_sarif_level(rules.unlisted_dependencies),
909 &mut snippets,
910 );
911 }
912 if !results.duplicate_exports.is_empty() {
913 push_sarif_duplicate_exports(
914 &mut sarif_results,
915 &results.duplicate_exports,
916 root,
917 severity_to_sarif_level(rules.duplicate_exports),
918 &mut snippets,
919 );
920 }
921 push_sarif_results(
922 &mut sarif_results,
923 &results.circular_dependencies,
924 &mut snippets,
925 |c| {
926 sarif_circular_dep_fields(
927 &c.cycle,
928 root,
929 severity_to_sarif_level(rules.circular_dependencies),
930 )
931 },
932 );
933 push_sarif_results(
934 &mut sarif_results,
935 &results.boundary_violations,
936 &mut snippets,
937 |v| {
938 sarif_boundary_violation_fields(
939 &v.violation,
940 root,
941 severity_to_sarif_level(rules.boundary_violation),
942 )
943 },
944 );
945 push_sarif_results(
946 &mut sarif_results,
947 &results.stale_suppressions,
948 &mut snippets,
949 |s| {
950 sarif_stale_suppression_fields(
951 s,
952 root,
953 severity_to_sarif_level(rules.stale_suppressions),
954 )
955 },
956 );
957 push_sarif_results(
958 &mut sarif_results,
959 &results.unused_catalog_entries,
960 &mut snippets,
961 |e| {
962 sarif_unused_catalog_entry_fields(
963 e,
964 root,
965 severity_to_sarif_level(rules.unused_catalog_entries),
966 )
967 },
968 );
969 push_sarif_results(
970 &mut sarif_results,
971 &results.empty_catalog_groups,
972 &mut snippets,
973 |g| {
974 sarif_empty_catalog_group_fields(
975 g,
976 root,
977 severity_to_sarif_level(rules.empty_catalog_groups),
978 )
979 },
980 );
981 push_sarif_results(
982 &mut sarif_results,
983 &results.unresolved_catalog_references,
984 &mut snippets,
985 |f| {
986 sarif_unresolved_catalog_reference_fields(
987 f,
988 root,
989 severity_to_sarif_level(rules.unresolved_catalog_references),
990 )
991 },
992 );
993 push_sarif_results(
994 &mut sarif_results,
995 &results.unused_dependency_overrides,
996 &mut snippets,
997 |f| {
998 sarif_unused_dependency_override_fields(
999 f,
1000 root,
1001 severity_to_sarif_level(rules.unused_dependency_overrides),
1002 )
1003 },
1004 );
1005 push_sarif_results(
1006 &mut sarif_results,
1007 &results.misconfigured_dependency_overrides,
1008 &mut snippets,
1009 |f| {
1010 sarif_misconfigured_dependency_override_fields(
1011 f,
1012 root,
1013 severity_to_sarif_level(rules.misconfigured_dependency_overrides),
1014 )
1015 },
1016 );
1017
1018 serde_json::json!({
1019 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1020 "version": "2.1.0",
1021 "runs": [{
1022 "tool": {
1023 "driver": {
1024 "name": "fallow",
1025 "version": env!("CARGO_PKG_VERSION"),
1026 "informationUri": "https://github.com/fallow-rs/fallow",
1027 "rules": build_sarif_rules(rules)
1028 }
1029 },
1030 "results": sarif_results
1031 }]
1032 })
1033}
1034
1035pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
1036 let sarif = build_sarif(results, root, rules);
1037 emit_json(&sarif, "SARIF")
1038}
1039
1040pub(super) fn print_grouped_sarif(
1046 results: &AnalysisResults,
1047 root: &Path,
1048 rules: &RulesConfig,
1049 resolver: &OwnershipResolver,
1050) -> ExitCode {
1051 let mut sarif = build_sarif(results, root, rules);
1052
1053 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1055 for run in runs {
1056 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1057 for result in results {
1058 let uri = result
1059 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1060 .and_then(|v| v.as_str())
1061 .unwrap_or("");
1062 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1065 let owner =
1066 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1067 let props = result
1068 .as_object_mut()
1069 .expect("SARIF result should be an object")
1070 .entry("properties")
1071 .or_insert_with(|| serde_json::json!({}));
1072 props
1073 .as_object_mut()
1074 .expect("properties should be an object")
1075 .insert("owner".to_string(), serde_json::Value::String(owner));
1076 }
1077 }
1078 }
1079 }
1080
1081 emit_json(&sarif, "SARIF")
1082}
1083
1084#[expect(
1085 clippy::cast_possible_truncation,
1086 reason = "line/col numbers are bounded by source size"
1087)]
1088pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
1089 let mut sarif_results = Vec::new();
1090 let mut snippets = SourceSnippetCache::default();
1091
1092 for (i, group) in report.clone_groups.iter().enumerate() {
1093 for instance in &group.instances {
1094 let uri = relative_uri(&instance.file, root);
1095 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1096 sarif_results.push(sarif_result_with_snippet(
1097 "fallow/code-duplication",
1098 "warning",
1099 &format!(
1100 "Code clone group {} ({} lines, {} instances)",
1101 i + 1,
1102 group.line_count,
1103 group.instances.len()
1104 ),
1105 &uri,
1106 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1107 source_snippet.as_deref(),
1108 ));
1109 }
1110 }
1111
1112 let sarif = serde_json::json!({
1113 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1114 "version": "2.1.0",
1115 "runs": [{
1116 "tool": {
1117 "driver": {
1118 "name": "fallow",
1119 "version": env!("CARGO_PKG_VERSION"),
1120 "informationUri": "https://github.com/fallow-rs/fallow",
1121 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1122 }
1123 },
1124 "results": sarif_results
1125 }]
1126 });
1127
1128 emit_json(&sarif, "SARIF")
1129}
1130
1131#[expect(
1142 clippy::cast_possible_truncation,
1143 reason = "line/col numbers are bounded by source size"
1144)]
1145pub(super) fn print_grouped_duplication_sarif(
1146 report: &DuplicationReport,
1147 root: &Path,
1148 resolver: &OwnershipResolver,
1149) -> ExitCode {
1150 let mut sarif_results = Vec::new();
1151 let mut snippets = SourceSnippetCache::default();
1152
1153 for (i, group) in report.clone_groups.iter().enumerate() {
1154 let primary_owner = super::dupes_grouping::largest_owner(group, root, resolver);
1158 for instance in &group.instances {
1159 let uri = relative_uri(&instance.file, root);
1160 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1161 let mut result = sarif_result_with_snippet(
1162 "fallow/code-duplication",
1163 "warning",
1164 &format!(
1165 "Code clone group {} ({} lines, {} instances)",
1166 i + 1,
1167 group.line_count,
1168 group.instances.len()
1169 ),
1170 &uri,
1171 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1172 source_snippet.as_deref(),
1173 );
1174 let props = result
1175 .as_object_mut()
1176 .expect("SARIF result should be an object")
1177 .entry("properties")
1178 .or_insert_with(|| serde_json::json!({}));
1179 props
1180 .as_object_mut()
1181 .expect("properties should be an object")
1182 .insert(
1183 "group".to_string(),
1184 serde_json::Value::String(primary_owner.clone()),
1185 );
1186 sarif_results.push(result);
1187 }
1188 }
1189
1190 let sarif = serde_json::json!({
1191 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1192 "version": "2.1.0",
1193 "runs": [{
1194 "tool": {
1195 "driver": {
1196 "name": "fallow",
1197 "version": env!("CARGO_PKG_VERSION"),
1198 "informationUri": "https://github.com/fallow-rs/fallow",
1199 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1200 }
1201 },
1202 "results": sarif_results
1203 }]
1204 });
1205
1206 emit_json(&sarif, "SARIF")
1207}
1208
1209#[must_use]
1215#[expect(
1216 clippy::too_many_lines,
1217 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"
1218)]
1219pub fn build_health_sarif(
1220 report: &crate::health_types::HealthReport,
1221 root: &Path,
1222) -> serde_json::Value {
1223 use crate::health_types::ExceededThreshold;
1224
1225 let mut sarif_results = Vec::new();
1226 let mut snippets = SourceSnippetCache::default();
1227
1228 for finding in &report.findings {
1229 let uri = relative_uri(&finding.path, root);
1230 let (rule_id, message) = match finding.exceeded {
1234 ExceededThreshold::Cyclomatic => (
1235 "fallow/high-cyclomatic-complexity",
1236 format!(
1237 "'{}' has cyclomatic complexity {} (threshold: {})",
1238 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
1239 ),
1240 ),
1241 ExceededThreshold::Cognitive => (
1242 "fallow/high-cognitive-complexity",
1243 format!(
1244 "'{}' has cognitive complexity {} (threshold: {})",
1245 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
1246 ),
1247 ),
1248 ExceededThreshold::Both => (
1249 "fallow/high-complexity",
1250 format!(
1251 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
1252 finding.name,
1253 finding.cyclomatic,
1254 report.summary.max_cyclomatic_threshold,
1255 finding.cognitive,
1256 report.summary.max_cognitive_threshold,
1257 ),
1258 ),
1259 ExceededThreshold::Crap
1260 | ExceededThreshold::CyclomaticCrap
1261 | ExceededThreshold::CognitiveCrap
1262 | ExceededThreshold::All => {
1263 let crap = finding.crap.unwrap_or(0.0);
1264 let coverage = finding
1265 .coverage_pct
1266 .map(|pct| format!(", coverage {pct:.0}%"))
1267 .unwrap_or_default();
1268 (
1269 "fallow/high-crap-score",
1270 format!(
1271 "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
1272 finding.name,
1273 crap,
1274 report.summary.max_crap_threshold,
1275 finding.cyclomatic,
1276 coverage,
1277 ),
1278 )
1279 }
1280 };
1281
1282 let level = match finding.severity {
1283 crate::health_types::FindingSeverity::Critical => "error",
1284 crate::health_types::FindingSeverity::High => "warning",
1285 crate::health_types::FindingSeverity::Moderate => "note",
1286 };
1287 let source_snippet = snippets.line(&finding.path, finding.line);
1288 sarif_results.push(sarif_result_with_snippet(
1289 rule_id,
1290 level,
1291 &message,
1292 &uri,
1293 Some((finding.line, finding.col + 1)),
1294 source_snippet.as_deref(),
1295 ));
1296 }
1297
1298 if let Some(ref production) = report.runtime_coverage {
1299 append_runtime_coverage_sarif_results(&mut sarif_results, production, root, &mut snippets);
1300 }
1301
1302 for target in &report.targets {
1304 let uri = relative_uri(&target.path, root);
1305 let message = format!(
1306 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
1307 target.category.label(),
1308 target.recommendation,
1309 target.priority,
1310 target.efficiency,
1311 target.effort.label(),
1312 target.confidence.label(),
1313 );
1314 sarif_results.push(sarif_result(
1315 "fallow/refactoring-target",
1316 "warning",
1317 &message,
1318 &uri,
1319 None,
1320 ));
1321 }
1322
1323 if let Some(ref gaps) = report.coverage_gaps {
1324 for item in &gaps.files {
1325 let uri = relative_uri(&item.file.path, root);
1326 let message = format!(
1327 "File is runtime-reachable but has no test dependency path ({} value export{})",
1328 item.file.value_export_count,
1329 if item.file.value_export_count == 1 {
1330 ""
1331 } else {
1332 "s"
1333 },
1334 );
1335 sarif_results.push(sarif_result(
1336 "fallow/untested-file",
1337 "warning",
1338 &message,
1339 &uri,
1340 None,
1341 ));
1342 }
1343
1344 for item in &gaps.exports {
1345 let uri = relative_uri(&item.export.path, root);
1346 let message = format!(
1347 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
1348 item.export.export_name
1349 );
1350 let source_snippet = snippets.line(&item.export.path, item.export.line);
1351 sarif_results.push(sarif_result_with_snippet(
1352 "fallow/untested-export",
1353 "warning",
1354 &message,
1355 &uri,
1356 Some((item.export.line, item.export.col + 1)),
1357 source_snippet.as_deref(),
1358 ));
1359 }
1360 }
1361
1362 let health_rules = vec![
1363 sarif_rule(
1364 "fallow/high-cyclomatic-complexity",
1365 "Function has high cyclomatic complexity",
1366 "note",
1367 ),
1368 sarif_rule(
1369 "fallow/high-cognitive-complexity",
1370 "Function has high cognitive complexity",
1371 "note",
1372 ),
1373 sarif_rule(
1374 "fallow/high-complexity",
1375 "Function exceeds both complexity thresholds",
1376 "note",
1377 ),
1378 sarif_rule(
1379 "fallow/high-crap-score",
1380 "Function has a high CRAP score (high complexity combined with low coverage)",
1381 "warning",
1382 ),
1383 sarif_rule(
1384 "fallow/refactoring-target",
1385 "File identified as a high-priority refactoring candidate",
1386 "warning",
1387 ),
1388 sarif_rule(
1389 "fallow/untested-file",
1390 "Runtime-reachable file has no test dependency path",
1391 "warning",
1392 ),
1393 sarif_rule(
1394 "fallow/untested-export",
1395 "Runtime-reachable export has no test dependency path",
1396 "warning",
1397 ),
1398 sarif_rule(
1399 "fallow/runtime-safe-to-delete",
1400 "Function is statically unused and was never invoked in production",
1401 "warning",
1402 ),
1403 sarif_rule(
1404 "fallow/runtime-review-required",
1405 "Function is statically used but was never invoked in production",
1406 "warning",
1407 ),
1408 sarif_rule(
1409 "fallow/runtime-low-traffic",
1410 "Function was invoked below the low-traffic threshold relative to total trace count",
1411 "note",
1412 ),
1413 sarif_rule(
1414 "fallow/runtime-coverage-unavailable",
1415 "Runtime coverage could not be resolved for this function",
1416 "note",
1417 ),
1418 sarif_rule(
1419 "fallow/runtime-coverage",
1420 "Runtime coverage finding",
1421 "note",
1422 ),
1423 ];
1424
1425 serde_json::json!({
1426 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1427 "version": "2.1.0",
1428 "runs": [{
1429 "tool": {
1430 "driver": {
1431 "name": "fallow",
1432 "version": env!("CARGO_PKG_VERSION"),
1433 "informationUri": "https://github.com/fallow-rs/fallow",
1434 "rules": health_rules
1435 }
1436 },
1437 "results": sarif_results
1438 }]
1439 })
1440}
1441
1442fn append_runtime_coverage_sarif_results(
1452 sarif_results: &mut Vec<serde_json::Value>,
1453 production: &crate::health_types::RuntimeCoverageReport,
1454 root: &Path,
1455 snippets: &mut SourceSnippetCache,
1456) {
1457 for finding in &production.findings {
1458 let uri = relative_uri(&finding.path, root);
1459 let rule_id = match finding.verdict {
1460 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
1461 "fallow/runtime-safe-to-delete"
1462 }
1463 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
1464 "fallow/runtime-review-required"
1465 }
1466 crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
1467 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
1468 "fallow/runtime-coverage-unavailable"
1469 }
1470 crate::health_types::RuntimeCoverageVerdict::Active
1471 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
1472 };
1473 let level = match finding.verdict {
1474 crate::health_types::RuntimeCoverageVerdict::SafeToDelete
1475 | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
1476 _ => "note",
1477 };
1478 let invocations_hint = finding.invocations.map_or_else(
1479 || "untracked".to_owned(),
1480 |hits| format!("{hits} invocations"),
1481 );
1482 let message = format!(
1483 "'{}' runtime coverage verdict: {} ({})",
1484 finding.function,
1485 finding.verdict.human_label(),
1486 invocations_hint,
1487 );
1488 let source_snippet = snippets.line(&finding.path, finding.line);
1489 sarif_results.push(sarif_result_with_snippet(
1490 rule_id,
1491 level,
1492 &message,
1493 &uri,
1494 Some((finding.line, 1)),
1495 source_snippet.as_deref(),
1496 ));
1497 }
1498}
1499
1500pub(super) fn print_health_sarif(
1501 report: &crate::health_types::HealthReport,
1502 root: &Path,
1503) -> ExitCode {
1504 let sarif = build_health_sarif(report, root);
1505 emit_json(&sarif, "SARIF")
1506}
1507
1508pub(super) fn print_grouped_health_sarif(
1519 report: &crate::health_types::HealthReport,
1520 root: &Path,
1521 resolver: &OwnershipResolver,
1522) -> ExitCode {
1523 let mut sarif = build_health_sarif(report, root);
1524
1525 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1526 for run in runs {
1527 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1528 for result in results {
1529 let uri = result
1530 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1531 .and_then(|v| v.as_str())
1532 .unwrap_or("");
1533 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1534 let group =
1535 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1536 let props = result
1537 .as_object_mut()
1538 .expect("SARIF result should be an object")
1539 .entry("properties")
1540 .or_insert_with(|| serde_json::json!({}));
1541 props
1542 .as_object_mut()
1543 .expect("properties should be an object")
1544 .insert("group".to_string(), serde_json::Value::String(group));
1545 }
1546 }
1547 }
1548 }
1549
1550 emit_json(&sarif, "SARIF")
1551}
1552
1553#[cfg(test)]
1554mod tests {
1555 use super::*;
1556 use crate::report::test_helpers::sample_results;
1557 use fallow_core::results::*;
1558 use std::path::PathBuf;
1559
1560 #[test]
1561 fn sarif_has_required_top_level_fields() {
1562 let root = PathBuf::from("/project");
1563 let results = AnalysisResults::default();
1564 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1565
1566 assert_eq!(
1567 sarif["$schema"],
1568 "https://json.schemastore.org/sarif-2.1.0.json"
1569 );
1570 assert_eq!(sarif["version"], "2.1.0");
1571 assert!(sarif["runs"].is_array());
1572 }
1573
1574 #[test]
1575 fn sarif_has_tool_driver_info() {
1576 let root = PathBuf::from("/project");
1577 let results = AnalysisResults::default();
1578 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1579
1580 let driver = &sarif["runs"][0]["tool"]["driver"];
1581 assert_eq!(driver["name"], "fallow");
1582 assert!(driver["version"].is_string());
1583 assert_eq!(
1584 driver["informationUri"],
1585 "https://github.com/fallow-rs/fallow"
1586 );
1587 }
1588
1589 #[test]
1590 fn sarif_declares_all_rules() {
1591 let root = PathBuf::from("/project");
1592 let results = AnalysisResults::default();
1593 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1594
1595 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1596 .as_array()
1597 .expect("rules should be an array");
1598 assert_eq!(rules.len(), 22);
1599
1600 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
1601 assert!(rule_ids.contains(&"fallow/unused-file"));
1602 assert!(rule_ids.contains(&"fallow/unused-export"));
1603 assert!(rule_ids.contains(&"fallow/unused-type"));
1604 assert!(rule_ids.contains(&"fallow/private-type-leak"));
1605 assert!(rule_ids.contains(&"fallow/unused-dependency"));
1606 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1607 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1608 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1609 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1610 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1611 assert!(rule_ids.contains(&"fallow/unused-class-member"));
1612 assert!(rule_ids.contains(&"fallow/unresolved-import"));
1613 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1614 assert!(rule_ids.contains(&"fallow/duplicate-export"));
1615 assert!(rule_ids.contains(&"fallow/circular-dependency"));
1616 assert!(rule_ids.contains(&"fallow/boundary-violation"));
1617 assert!(rule_ids.contains(&"fallow/unused-catalog-entry"));
1618 assert!(rule_ids.contains(&"fallow/empty-catalog-group"));
1619 assert!(rule_ids.contains(&"fallow/unresolved-catalog-reference"));
1620 assert!(rule_ids.contains(&"fallow/unused-dependency-override"));
1621 assert!(rule_ids.contains(&"fallow/misconfigured-dependency-override"));
1622 }
1623
1624 #[test]
1625 fn sarif_empty_results_no_results_entries() {
1626 let root = PathBuf::from("/project");
1627 let results = AnalysisResults::default();
1628 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1629
1630 let sarif_results = sarif["runs"][0]["results"]
1631 .as_array()
1632 .expect("results should be an array");
1633 assert!(sarif_results.is_empty());
1634 }
1635
1636 #[test]
1637 fn sarif_unused_file_result() {
1638 let root = PathBuf::from("/project");
1639 let mut results = AnalysisResults::default();
1640 results
1641 .unused_files
1642 .push(UnusedFileFinding::with_actions(UnusedFile {
1643 path: root.join("src/dead.ts"),
1644 }));
1645
1646 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1647 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1648 assert_eq!(entries.len(), 1);
1649
1650 let entry = &entries[0];
1651 assert_eq!(entry["ruleId"], "fallow/unused-file");
1652 assert_eq!(entry["level"], "error");
1654 assert_eq!(
1655 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1656 "src/dead.ts"
1657 );
1658 }
1659
1660 #[test]
1661 fn sarif_unused_export_includes_region() {
1662 let root = PathBuf::from("/project");
1663 let mut results = AnalysisResults::default();
1664 results
1665 .unused_exports
1666 .push(UnusedExportFinding::with_actions(UnusedExport {
1667 path: root.join("src/utils.ts"),
1668 export_name: "helperFn".to_string(),
1669 is_type_only: false,
1670 line: 10,
1671 col: 4,
1672 span_start: 120,
1673 is_re_export: false,
1674 }));
1675
1676 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1677 let entry = &sarif["runs"][0]["results"][0];
1678 assert_eq!(entry["ruleId"], "fallow/unused-export");
1679
1680 let region = &entry["locations"][0]["physicalLocation"]["region"];
1681 assert_eq!(region["startLine"], 10);
1682 assert_eq!(region["startColumn"], 5);
1684 }
1685
1686 #[test]
1687 fn sarif_unresolved_import_is_error_level() {
1688 let root = PathBuf::from("/project");
1689 let mut results = AnalysisResults::default();
1690 results
1691 .unresolved_imports
1692 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1693 path: root.join("src/app.ts"),
1694 specifier: "./missing".to_string(),
1695 line: 1,
1696 col: 0,
1697 specifier_col: 0,
1698 }));
1699
1700 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1701 let entry = &sarif["runs"][0]["results"][0];
1702 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
1703 assert_eq!(entry["level"], "error");
1704 }
1705
1706 #[test]
1707 fn sarif_unlisted_dependency_points_to_import_site() {
1708 let root = PathBuf::from("/project");
1709 let mut results = AnalysisResults::default();
1710 results
1711 .unlisted_dependencies
1712 .push(UnlistedDependencyFinding::with_actions(
1713 UnlistedDependency {
1714 package_name: "chalk".to_string(),
1715 imported_from: vec![ImportSite {
1716 path: root.join("src/cli.ts"),
1717 line: 3,
1718 col: 0,
1719 }],
1720 },
1721 ));
1722
1723 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1724 let entry = &sarif["runs"][0]["results"][0];
1725 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
1726 assert_eq!(entry["level"], "error");
1727 assert_eq!(
1728 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1729 "src/cli.ts"
1730 );
1731 let region = &entry["locations"][0]["physicalLocation"]["region"];
1732 assert_eq!(region["startLine"], 3);
1733 assert_eq!(region["startColumn"], 1);
1734 }
1735
1736 #[test]
1737 fn sarif_dependency_issues_point_to_package_json() {
1738 let root = PathBuf::from("/project");
1739 let mut results = AnalysisResults::default();
1740 results
1741 .unused_dependencies
1742 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1743 package_name: "lodash".to_string(),
1744 location: DependencyLocation::Dependencies,
1745 path: root.join("package.json"),
1746 line: 5,
1747 used_in_workspaces: Vec::new(),
1748 }));
1749 results
1750 .unused_dev_dependencies
1751 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1752 package_name: "jest".to_string(),
1753 location: DependencyLocation::DevDependencies,
1754 path: root.join("package.json"),
1755 line: 5,
1756 used_in_workspaces: Vec::new(),
1757 }));
1758
1759 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1760 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1761 for entry in entries {
1762 assert_eq!(
1763 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1764 "package.json"
1765 );
1766 }
1767 }
1768
1769 #[test]
1770 fn sarif_duplicate_export_emits_one_result_per_location() {
1771 let root = PathBuf::from("/project");
1772 let mut results = AnalysisResults::default();
1773 results
1774 .duplicate_exports
1775 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1776 export_name: "Config".to_string(),
1777 locations: vec![
1778 DuplicateLocation {
1779 path: root.join("src/a.ts"),
1780 line: 15,
1781 col: 0,
1782 },
1783 DuplicateLocation {
1784 path: root.join("src/b.ts"),
1785 line: 30,
1786 col: 0,
1787 },
1788 ],
1789 }));
1790
1791 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1792 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1793 assert_eq!(entries.len(), 2);
1795 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
1796 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
1797 assert_eq!(
1798 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1799 "src/a.ts"
1800 );
1801 assert_eq!(
1802 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1803 "src/b.ts"
1804 );
1805 }
1806
1807 #[test]
1808 fn sarif_all_issue_types_produce_results() {
1809 let root = PathBuf::from("/project");
1810 let results = sample_results(&root);
1811 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1812
1813 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1814 assert_eq!(entries.len(), results.total_issues() + 1);
1816
1817 let rule_ids: Vec<&str> = entries
1818 .iter()
1819 .map(|e| e["ruleId"].as_str().unwrap())
1820 .collect();
1821 assert!(rule_ids.contains(&"fallow/unused-file"));
1822 assert!(rule_ids.contains(&"fallow/unused-export"));
1823 assert!(rule_ids.contains(&"fallow/unused-type"));
1824 assert!(rule_ids.contains(&"fallow/unused-dependency"));
1825 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1826 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1827 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1828 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1829 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1830 assert!(rule_ids.contains(&"fallow/unused-class-member"));
1831 assert!(rule_ids.contains(&"fallow/unresolved-import"));
1832 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1833 assert!(rule_ids.contains(&"fallow/duplicate-export"));
1834 }
1835
1836 #[test]
1837 fn sarif_serializes_to_valid_json() {
1838 let root = PathBuf::from("/project");
1839 let results = sample_results(&root);
1840 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1841
1842 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1843 let reparsed: serde_json::Value =
1844 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
1845 assert_eq!(reparsed, sarif);
1846 }
1847
1848 #[test]
1849 fn sarif_file_write_produces_valid_sarif() {
1850 let root = PathBuf::from("/project");
1851 let results = sample_results(&root);
1852 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1853 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
1854
1855 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
1856 let _ = std::fs::create_dir_all(&dir);
1857 let sarif_path = dir.join("results.sarif");
1858 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
1859
1860 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
1861 let parsed: serde_json::Value =
1862 serde_json::from_str(&contents).expect("file should contain valid JSON");
1863
1864 assert_eq!(parsed["version"], "2.1.0");
1865 assert_eq!(
1866 parsed["$schema"],
1867 "https://json.schemastore.org/sarif-2.1.0.json"
1868 );
1869 let sarif_results = parsed["runs"][0]["results"]
1870 .as_array()
1871 .expect("results should be an array");
1872 assert!(!sarif_results.is_empty());
1873
1874 let _ = std::fs::remove_file(&sarif_path);
1876 let _ = std::fs::remove_dir(&dir);
1877 }
1878
1879 #[test]
1882 fn health_sarif_empty_no_results() {
1883 let root = PathBuf::from("/project");
1884 let report = crate::health_types::HealthReport {
1885 summary: crate::health_types::HealthSummary {
1886 files_analyzed: 10,
1887 functions_analyzed: 50,
1888 ..Default::default()
1889 },
1890 ..Default::default()
1891 };
1892 let sarif = build_health_sarif(&report, &root);
1893 assert_eq!(sarif["version"], "2.1.0");
1894 let results = sarif["runs"][0]["results"].as_array().unwrap();
1895 assert!(results.is_empty());
1896 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1897 .as_array()
1898 .unwrap();
1899 assert_eq!(rules.len(), 12);
1900 }
1901
1902 #[test]
1903 fn health_sarif_cyclomatic_only() {
1904 let root = PathBuf::from("/project");
1905 let report = crate::health_types::HealthReport {
1906 findings: vec![
1907 crate::health_types::ComplexityViolation {
1908 path: root.join("src/utils.ts"),
1909 name: "parseExpression".to_string(),
1910 line: 42,
1911 col: 0,
1912 cyclomatic: 25,
1913 cognitive: 10,
1914 line_count: 80,
1915 param_count: 0,
1916 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
1917 severity: crate::health_types::FindingSeverity::High,
1918 crap: None,
1919 coverage_pct: None,
1920 coverage_tier: None,
1921 coverage_source: None,
1922 inherited_from: None,
1923 component_rollup: None,
1924 }
1925 .into(),
1926 ],
1927 summary: crate::health_types::HealthSummary {
1928 files_analyzed: 5,
1929 functions_analyzed: 20,
1930 functions_above_threshold: 1,
1931 ..Default::default()
1932 },
1933 ..Default::default()
1934 };
1935 let sarif = build_health_sarif(&report, &root);
1936 let entry = &sarif["runs"][0]["results"][0];
1937 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
1938 assert_eq!(entry["level"], "warning");
1939 assert!(
1940 entry["message"]["text"]
1941 .as_str()
1942 .unwrap()
1943 .contains("cyclomatic complexity 25")
1944 );
1945 assert_eq!(
1946 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1947 "src/utils.ts"
1948 );
1949 let region = &entry["locations"][0]["physicalLocation"]["region"];
1950 assert_eq!(region["startLine"], 42);
1951 assert_eq!(region["startColumn"], 1);
1952 }
1953
1954 #[test]
1955 fn health_sarif_cognitive_only() {
1956 let root = PathBuf::from("/project");
1957 let report = crate::health_types::HealthReport {
1958 findings: vec![
1959 crate::health_types::ComplexityViolation {
1960 path: root.join("src/api.ts"),
1961 name: "handleRequest".to_string(),
1962 line: 10,
1963 col: 4,
1964 cyclomatic: 8,
1965 cognitive: 20,
1966 line_count: 40,
1967 param_count: 0,
1968 exceeded: crate::health_types::ExceededThreshold::Cognitive,
1969 severity: crate::health_types::FindingSeverity::High,
1970 crap: None,
1971 coverage_pct: None,
1972 coverage_tier: None,
1973 coverage_source: None,
1974 inherited_from: None,
1975 component_rollup: None,
1976 }
1977 .into(),
1978 ],
1979 summary: crate::health_types::HealthSummary {
1980 files_analyzed: 3,
1981 functions_analyzed: 10,
1982 functions_above_threshold: 1,
1983 ..Default::default()
1984 },
1985 ..Default::default()
1986 };
1987 let sarif = build_health_sarif(&report, &root);
1988 let entry = &sarif["runs"][0]["results"][0];
1989 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
1990 assert!(
1991 entry["message"]["text"]
1992 .as_str()
1993 .unwrap()
1994 .contains("cognitive complexity 20")
1995 );
1996 let region = &entry["locations"][0]["physicalLocation"]["region"];
1997 assert_eq!(region["startColumn"], 5); }
1999
2000 #[test]
2001 fn health_sarif_both_thresholds() {
2002 let root = PathBuf::from("/project");
2003 let report = crate::health_types::HealthReport {
2004 findings: vec![
2005 crate::health_types::ComplexityViolation {
2006 path: root.join("src/complex.ts"),
2007 name: "doEverything".to_string(),
2008 line: 1,
2009 col: 0,
2010 cyclomatic: 30,
2011 cognitive: 45,
2012 line_count: 100,
2013 param_count: 0,
2014 exceeded: crate::health_types::ExceededThreshold::Both,
2015 severity: crate::health_types::FindingSeverity::High,
2016 crap: None,
2017 coverage_pct: None,
2018 coverage_tier: None,
2019 coverage_source: None,
2020 inherited_from: None,
2021 component_rollup: None,
2022 }
2023 .into(),
2024 ],
2025 summary: crate::health_types::HealthSummary {
2026 files_analyzed: 1,
2027 functions_analyzed: 1,
2028 functions_above_threshold: 1,
2029 ..Default::default()
2030 },
2031 ..Default::default()
2032 };
2033 let sarif = build_health_sarif(&report, &root);
2034 let entry = &sarif["runs"][0]["results"][0];
2035 assert_eq!(entry["ruleId"], "fallow/high-complexity");
2036 let msg = entry["message"]["text"].as_str().unwrap();
2037 assert!(msg.contains("cyclomatic complexity 30"));
2038 assert!(msg.contains("cognitive complexity 45"));
2039 }
2040
2041 #[test]
2042 fn health_sarif_crap_only_emits_crap_rule() {
2043 let root = PathBuf::from("/project");
2046 let report = crate::health_types::HealthReport {
2047 findings: vec![
2048 crate::health_types::ComplexityViolation {
2049 path: root.join("src/untested.ts"),
2050 name: "risky".to_string(),
2051 line: 8,
2052 col: 0,
2053 cyclomatic: 10,
2054 cognitive: 10,
2055 line_count: 20,
2056 param_count: 1,
2057 exceeded: crate::health_types::ExceededThreshold::Crap,
2058 severity: crate::health_types::FindingSeverity::High,
2059 crap: Some(82.2),
2060 coverage_pct: Some(12.0),
2061 coverage_tier: None,
2062 coverage_source: None,
2063 inherited_from: None,
2064 component_rollup: None,
2065 }
2066 .into(),
2067 ],
2068 summary: crate::health_types::HealthSummary {
2069 files_analyzed: 1,
2070 functions_analyzed: 1,
2071 functions_above_threshold: 1,
2072 ..Default::default()
2073 },
2074 ..Default::default()
2075 };
2076 let sarif = build_health_sarif(&report, &root);
2077 let entry = &sarif["runs"][0]["results"][0];
2078 assert_eq!(entry["ruleId"], "fallow/high-crap-score");
2079 let msg = entry["message"]["text"].as_str().unwrap();
2080 assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
2081 assert!(msg.contains("coverage 12%"), "msg: {msg}");
2082 }
2083
2084 #[test]
2085 fn health_sarif_cyclomatic_crap_uses_crap_rule() {
2086 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/hot.ts"),
2093 name: "branchy".to_string(),
2094 line: 1,
2095 col: 0,
2096 cyclomatic: 67,
2097 cognitive: 12,
2098 line_count: 80,
2099 param_count: 1,
2100 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
2101 severity: crate::health_types::FindingSeverity::Critical,
2102 crap: Some(182.0),
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: 1,
2113 functions_analyzed: 1,
2114 functions_above_threshold: 1,
2115 ..Default::default()
2116 },
2117 ..Default::default()
2118 };
2119 let sarif = build_health_sarif(&report, &root);
2120 let results = sarif["runs"][0]["results"].as_array().unwrap();
2121 assert_eq!(
2122 results.len(),
2123 1,
2124 "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
2125 );
2126 assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
2127 let msg = results[0]["message"]["text"].as_str().unwrap();
2128 assert!(msg.contains("CRAP score 182"), "msg: {msg}");
2129 assert!(!msg.contains("coverage"), "msg: {msg}");
2131 }
2132
2133 #[test]
2136 fn severity_to_sarif_level_error() {
2137 assert_eq!(severity_to_sarif_level(Severity::Error), "error");
2138 }
2139
2140 #[test]
2141 fn severity_to_sarif_level_warn() {
2142 assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
2143 }
2144
2145 #[test]
2146 #[should_panic(expected = "internal error: entered unreachable code")]
2147 fn severity_to_sarif_level_off() {
2148 let _ = severity_to_sarif_level(Severity::Off);
2149 }
2150
2151 #[test]
2154 fn sarif_re_export_has_properties() {
2155 let root = PathBuf::from("/project");
2156 let mut results = AnalysisResults::default();
2157 results
2158 .unused_exports
2159 .push(UnusedExportFinding::with_actions(UnusedExport {
2160 path: root.join("src/index.ts"),
2161 export_name: "reExported".to_string(),
2162 is_type_only: false,
2163 line: 1,
2164 col: 0,
2165 span_start: 0,
2166 is_re_export: true,
2167 }));
2168
2169 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2170 let entry = &sarif["runs"][0]["results"][0];
2171 assert_eq!(entry["properties"]["is_re_export"], true);
2172 let msg = entry["message"]["text"].as_str().unwrap();
2173 assert!(msg.starts_with("Re-export"));
2174 }
2175
2176 #[test]
2177 fn sarif_non_re_export_has_no_properties() {
2178 let root = PathBuf::from("/project");
2179 let mut results = AnalysisResults::default();
2180 results
2181 .unused_exports
2182 .push(UnusedExportFinding::with_actions(UnusedExport {
2183 path: root.join("src/utils.ts"),
2184 export_name: "foo".to_string(),
2185 is_type_only: false,
2186 line: 5,
2187 col: 0,
2188 span_start: 0,
2189 is_re_export: false,
2190 }));
2191
2192 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2193 let entry = &sarif["runs"][0]["results"][0];
2194 assert!(entry.get("properties").is_none());
2195 let msg = entry["message"]["text"].as_str().unwrap();
2196 assert!(msg.starts_with("Export"));
2197 }
2198
2199 #[test]
2202 fn sarif_type_re_export_message() {
2203 let root = PathBuf::from("/project");
2204 let mut results = AnalysisResults::default();
2205 results
2206 .unused_types
2207 .push(UnusedTypeFinding::with_actions(UnusedExport {
2208 path: root.join("src/index.ts"),
2209 export_name: "MyType".to_string(),
2210 is_type_only: true,
2211 line: 1,
2212 col: 0,
2213 span_start: 0,
2214 is_re_export: true,
2215 }));
2216
2217 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2218 let entry = &sarif["runs"][0]["results"][0];
2219 assert_eq!(entry["ruleId"], "fallow/unused-type");
2220 let msg = entry["message"]["text"].as_str().unwrap();
2221 assert!(msg.starts_with("Type re-export"));
2222 assert_eq!(entry["properties"]["is_re_export"], true);
2223 }
2224
2225 #[test]
2228 fn sarif_dependency_line_zero_skips_region() {
2229 let root = PathBuf::from("/project");
2230 let mut results = AnalysisResults::default();
2231 results
2232 .unused_dependencies
2233 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2234 package_name: "lodash".to_string(),
2235 location: DependencyLocation::Dependencies,
2236 path: root.join("package.json"),
2237 line: 0,
2238 used_in_workspaces: Vec::new(),
2239 }));
2240
2241 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2242 let entry = &sarif["runs"][0]["results"][0];
2243 let phys = &entry["locations"][0]["physicalLocation"];
2244 assert!(phys.get("region").is_none());
2245 }
2246
2247 #[test]
2248 fn sarif_dependency_line_nonzero_has_region() {
2249 let root = PathBuf::from("/project");
2250 let mut results = AnalysisResults::default();
2251 results
2252 .unused_dependencies
2253 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2254 package_name: "lodash".to_string(),
2255 location: DependencyLocation::Dependencies,
2256 path: root.join("package.json"),
2257 line: 7,
2258 used_in_workspaces: Vec::new(),
2259 }));
2260
2261 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2262 let entry = &sarif["runs"][0]["results"][0];
2263 let region = &entry["locations"][0]["physicalLocation"]["region"];
2264 assert_eq!(region["startLine"], 7);
2265 assert_eq!(region["startColumn"], 1);
2266 }
2267
2268 #[test]
2271 fn sarif_type_only_dep_line_zero_skips_region() {
2272 let root = PathBuf::from("/project");
2273 let mut results = AnalysisResults::default();
2274 results
2275 .type_only_dependencies
2276 .push(TypeOnlyDependencyFinding::with_actions(
2277 TypeOnlyDependency {
2278 package_name: "zod".to_string(),
2279 path: root.join("package.json"),
2280 line: 0,
2281 },
2282 ));
2283
2284 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2285 let entry = &sarif["runs"][0]["results"][0];
2286 let phys = &entry["locations"][0]["physicalLocation"];
2287 assert!(phys.get("region").is_none());
2288 }
2289
2290 #[test]
2293 fn sarif_circular_dep_line_zero_skips_region() {
2294 let root = PathBuf::from("/project");
2295 let mut results = AnalysisResults::default();
2296 results
2297 .circular_dependencies
2298 .push(CircularDependencyFinding::with_actions(
2299 CircularDependency {
2300 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2301 length: 2,
2302 line: 0,
2303 col: 0,
2304 is_cross_package: false,
2305 },
2306 ));
2307
2308 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2309 let entry = &sarif["runs"][0]["results"][0];
2310 let phys = &entry["locations"][0]["physicalLocation"];
2311 assert!(phys.get("region").is_none());
2312 }
2313
2314 #[test]
2315 fn sarif_circular_dep_line_nonzero_has_region() {
2316 let root = PathBuf::from("/project");
2317 let mut results = AnalysisResults::default();
2318 results
2319 .circular_dependencies
2320 .push(CircularDependencyFinding::with_actions(
2321 CircularDependency {
2322 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2323 length: 2,
2324 line: 5,
2325 col: 2,
2326 is_cross_package: false,
2327 },
2328 ));
2329
2330 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2331 let entry = &sarif["runs"][0]["results"][0];
2332 let region = &entry["locations"][0]["physicalLocation"]["region"];
2333 assert_eq!(region["startLine"], 5);
2334 assert_eq!(region["startColumn"], 3);
2335 }
2336
2337 #[test]
2340 fn sarif_unused_optional_dependency_result() {
2341 let root = PathBuf::from("/project");
2342 let mut results = AnalysisResults::default();
2343 results
2344 .unused_optional_dependencies
2345 .push(UnusedOptionalDependencyFinding::with_actions(
2346 UnusedDependency {
2347 package_name: "fsevents".to_string(),
2348 location: DependencyLocation::OptionalDependencies,
2349 path: root.join("package.json"),
2350 line: 12,
2351 used_in_workspaces: Vec::new(),
2352 },
2353 ));
2354
2355 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2356 let entry = &sarif["runs"][0]["results"][0];
2357 assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
2358 let msg = entry["message"]["text"].as_str().unwrap();
2359 assert!(msg.contains("optionalDependencies"));
2360 }
2361
2362 #[test]
2365 fn sarif_enum_member_message_format() {
2366 let root = PathBuf::from("/project");
2367 let mut results = AnalysisResults::default();
2368 results.unused_enum_members.push(
2369 fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
2370 path: root.join("src/enums.ts"),
2371 parent_name: "Color".to_string(),
2372 member_name: "Purple".to_string(),
2373 kind: fallow_core::extract::MemberKind::EnumMember,
2374 line: 5,
2375 col: 2,
2376 }),
2377 );
2378
2379 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2380 let entry = &sarif["runs"][0]["results"][0];
2381 assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
2382 let msg = entry["message"]["text"].as_str().unwrap();
2383 assert!(msg.contains("Enum member 'Color.Purple'"));
2384 let region = &entry["locations"][0]["physicalLocation"]["region"];
2385 assert_eq!(region["startColumn"], 3); }
2387
2388 #[test]
2389 fn sarif_class_member_message_format() {
2390 let root = PathBuf::from("/project");
2391 let mut results = AnalysisResults::default();
2392 results.unused_class_members.push(
2393 fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
2394 path: root.join("src/service.ts"),
2395 parent_name: "API".to_string(),
2396 member_name: "fetch".to_string(),
2397 kind: fallow_core::extract::MemberKind::ClassMethod,
2398 line: 10,
2399 col: 4,
2400 }),
2401 );
2402
2403 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2404 let entry = &sarif["runs"][0]["results"][0];
2405 assert_eq!(entry["ruleId"], "fallow/unused-class-member");
2406 let msg = entry["message"]["text"].as_str().unwrap();
2407 assert!(msg.contains("Class member 'API.fetch'"));
2408 }
2409
2410 #[test]
2413 #[expect(
2414 clippy::cast_possible_truncation,
2415 reason = "test line/col values are trivially small"
2416 )]
2417 fn duplication_sarif_structure() {
2418 use fallow_core::duplicates::*;
2419
2420 let root = PathBuf::from("/project");
2421 let report = DuplicationReport {
2422 clone_groups: vec![CloneGroup {
2423 instances: vec![
2424 CloneInstance {
2425 file: root.join("src/a.ts"),
2426 start_line: 1,
2427 end_line: 10,
2428 start_col: 0,
2429 end_col: 0,
2430 fragment: String::new(),
2431 },
2432 CloneInstance {
2433 file: root.join("src/b.ts"),
2434 start_line: 5,
2435 end_line: 14,
2436 start_col: 2,
2437 end_col: 0,
2438 fragment: String::new(),
2439 },
2440 ],
2441 token_count: 50,
2442 line_count: 10,
2443 }],
2444 clone_families: vec![],
2445 mirrored_directories: vec![],
2446 stats: DuplicationStats::default(),
2447 };
2448
2449 let sarif = serde_json::json!({
2450 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2451 "version": "2.1.0",
2452 "runs": [{
2453 "tool": {
2454 "driver": {
2455 "name": "fallow",
2456 "version": env!("CARGO_PKG_VERSION"),
2457 "informationUri": "https://github.com/fallow-rs/fallow",
2458 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2459 }
2460 },
2461 "results": []
2462 }]
2463 });
2464 let _ = sarif;
2466
2467 let mut sarif_results = Vec::new();
2469 for (i, group) in report.clone_groups.iter().enumerate() {
2470 for instance in &group.instances {
2471 sarif_results.push(sarif_result(
2472 "fallow/code-duplication",
2473 "warning",
2474 &format!(
2475 "Code clone group {} ({} lines, {} instances)",
2476 i + 1,
2477 group.line_count,
2478 group.instances.len()
2479 ),
2480 &super::super::relative_uri(&instance.file, &root),
2481 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2482 ));
2483 }
2484 }
2485 assert_eq!(sarif_results.len(), 2);
2486 assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
2487 assert!(
2488 sarif_results[0]["message"]["text"]
2489 .as_str()
2490 .unwrap()
2491 .contains("10 lines")
2492 );
2493 let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
2494 assert_eq!(region0["startLine"], 1);
2495 assert_eq!(region0["startColumn"], 1); let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
2497 assert_eq!(region1["startLine"], 5);
2498 assert_eq!(region1["startColumn"], 3); }
2500
2501 #[test]
2504 fn sarif_rule_known_id_has_full_description() {
2505 let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
2506 assert!(rule.get("fullDescription").is_some());
2507 assert!(rule.get("helpUri").is_some());
2508 }
2509
2510 #[test]
2511 fn sarif_rule_unknown_id_uses_fallback() {
2512 let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
2513 assert_eq!(rule["shortDescription"]["text"], "fallback text");
2514 assert!(rule.get("fullDescription").is_none());
2515 assert!(rule.get("helpUri").is_none());
2516 assert_eq!(rule["defaultConfiguration"]["level"], "warning");
2517 }
2518
2519 #[test]
2522 fn sarif_result_no_region_omits_region_key() {
2523 let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
2524 let phys = &result["locations"][0]["physicalLocation"];
2525 assert!(phys.get("region").is_none());
2526 assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
2527 }
2528
2529 #[test]
2530 fn sarif_result_with_region_includes_region() {
2531 let result = sarif_result(
2532 "rule/test",
2533 "error",
2534 "test msg",
2535 "src/file.ts",
2536 Some((10, 5)),
2537 );
2538 let region = &result["locations"][0]["physicalLocation"]["region"];
2539 assert_eq!(region["startLine"], 10);
2540 assert_eq!(region["startColumn"], 5);
2541 }
2542
2543 #[test]
2544 fn sarif_partial_fingerprint_ignores_rendered_message() {
2545 let a = sarif_result(
2546 "rule/test",
2547 "error",
2548 "first message",
2549 "src/file.ts",
2550 Some((10, 5)),
2551 );
2552 let b = sarif_result(
2553 "rule/test",
2554 "error",
2555 "rewritten message",
2556 "src/file.ts",
2557 Some((10, 5)),
2558 );
2559 assert_eq!(
2560 a["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
2561 b["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
2562 );
2563 }
2564
2565 #[test]
2568 fn health_sarif_includes_refactoring_targets() {
2569 use crate::health_types::*;
2570
2571 let root = PathBuf::from("/project");
2572 let report = HealthReport {
2573 summary: HealthSummary {
2574 files_analyzed: 10,
2575 functions_analyzed: 50,
2576 ..Default::default()
2577 },
2578 targets: vec![
2579 RefactoringTarget {
2580 path: root.join("src/complex.ts"),
2581 priority: 85.0,
2582 efficiency: 42.5,
2583 recommendation: "Split high-impact file".into(),
2584 category: RecommendationCategory::SplitHighImpact,
2585 effort: EffortEstimate::Medium,
2586 confidence: Confidence::High,
2587 factors: vec![],
2588 evidence: None,
2589 }
2590 .into(),
2591 ],
2592 ..Default::default()
2593 };
2594
2595 let sarif = build_health_sarif(&report, &root);
2596 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2597 assert_eq!(entries.len(), 1);
2598 assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
2599 assert_eq!(entries[0]["level"], "warning");
2600 let msg = entries[0]["message"]["text"].as_str().unwrap();
2601 assert!(msg.contains("high impact"));
2602 assert!(msg.contains("Split high-impact file"));
2603 assert!(msg.contains("42.5"));
2604 }
2605
2606 #[test]
2607 fn health_sarif_includes_coverage_gaps() {
2608 use crate::health_types::*;
2609
2610 let root = PathBuf::from("/project");
2611 let report = HealthReport {
2612 summary: HealthSummary {
2613 files_analyzed: 10,
2614 functions_analyzed: 50,
2615 ..Default::default()
2616 },
2617 coverage_gaps: Some(CoverageGaps {
2618 summary: CoverageGapSummary {
2619 runtime_files: 2,
2620 covered_files: 0,
2621 file_coverage_pct: 0.0,
2622 untested_files: 1,
2623 untested_exports: 1,
2624 },
2625 files: vec![UntestedFileFinding::with_actions(
2626 UntestedFile {
2627 path: root.join("src/app.ts"),
2628 value_export_count: 2,
2629 },
2630 &root,
2631 )],
2632 exports: vec![UntestedExportFinding::with_actions(
2633 UntestedExport {
2634 path: root.join("src/app.ts"),
2635 export_name: "loader".into(),
2636 line: 12,
2637 col: 4,
2638 },
2639 &root,
2640 )],
2641 }),
2642 ..Default::default()
2643 };
2644
2645 let sarif = build_health_sarif(&report, &root);
2646 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2647 assert_eq!(entries.len(), 2);
2648 assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
2649 assert_eq!(
2650 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2651 "src/app.ts"
2652 );
2653 assert!(
2654 entries[0]["message"]["text"]
2655 .as_str()
2656 .unwrap()
2657 .contains("2 value exports")
2658 );
2659 assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
2660 assert_eq!(
2661 entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
2662 12
2663 );
2664 assert_eq!(
2665 entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
2666 5
2667 );
2668 }
2669
2670 #[test]
2673 fn health_sarif_rules_have_full_descriptions() {
2674 let root = PathBuf::from("/project");
2675 let report = crate::health_types::HealthReport::default();
2676 let sarif = build_health_sarif(&report, &root);
2677 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2678 .as_array()
2679 .unwrap();
2680 for rule in rules {
2681 let id = rule["id"].as_str().unwrap();
2682 assert!(
2683 rule.get("fullDescription").is_some(),
2684 "health rule {id} should have fullDescription"
2685 );
2686 assert!(
2687 rule.get("helpUri").is_some(),
2688 "health rule {id} should have helpUri"
2689 );
2690 }
2691 }
2692
2693 #[test]
2696 fn sarif_warn_severity_produces_warning_level() {
2697 let root = PathBuf::from("/project");
2698 let mut results = AnalysisResults::default();
2699 results
2700 .unused_files
2701 .push(UnusedFileFinding::with_actions(UnusedFile {
2702 path: root.join("src/dead.ts"),
2703 }));
2704
2705 let rules = RulesConfig {
2706 unused_files: Severity::Warn,
2707 ..RulesConfig::default()
2708 };
2709
2710 let sarif = build_sarif(&results, &root, &rules);
2711 let entry = &sarif["runs"][0]["results"][0];
2712 assert_eq!(entry["level"], "warning");
2713 }
2714
2715 #[test]
2718 fn sarif_unused_file_has_no_region() {
2719 let root = PathBuf::from("/project");
2720 let mut results = AnalysisResults::default();
2721 results
2722 .unused_files
2723 .push(UnusedFileFinding::with_actions(UnusedFile {
2724 path: root.join("src/dead.ts"),
2725 }));
2726
2727 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2728 let entry = &sarif["runs"][0]["results"][0];
2729 let phys = &entry["locations"][0]["physicalLocation"];
2730 assert!(phys.get("region").is_none());
2731 }
2732
2733 #[test]
2736 fn sarif_unlisted_dep_multiple_import_sites() {
2737 let root = PathBuf::from("/project");
2738 let mut results = AnalysisResults::default();
2739 results
2740 .unlisted_dependencies
2741 .push(UnlistedDependencyFinding::with_actions(
2742 UnlistedDependency {
2743 package_name: "dotenv".to_string(),
2744 imported_from: vec![
2745 ImportSite {
2746 path: root.join("src/a.ts"),
2747 line: 1,
2748 col: 0,
2749 },
2750 ImportSite {
2751 path: root.join("src/b.ts"),
2752 line: 5,
2753 col: 0,
2754 },
2755 ],
2756 },
2757 ));
2758
2759 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2760 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2761 assert_eq!(entries.len(), 2);
2763 assert_eq!(
2764 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2765 "src/a.ts"
2766 );
2767 assert_eq!(
2768 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2769 "src/b.ts"
2770 );
2771 }
2772
2773 #[test]
2776 fn sarif_unlisted_dep_no_import_sites() {
2777 let root = PathBuf::from("/project");
2778 let mut results = AnalysisResults::default();
2779 results
2780 .unlisted_dependencies
2781 .push(UnlistedDependencyFinding::with_actions(
2782 UnlistedDependency {
2783 package_name: "phantom".to_string(),
2784 imported_from: vec![],
2785 },
2786 ));
2787
2788 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2789 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2790 assert!(entries.is_empty());
2792 }
2793}