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