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