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