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