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