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