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 let mut specs = Vec::new();
711 specs.extend(sarif_core_rule_specs(rules));
712 specs.extend(sarif_dependency_rule_specs(rules));
713 specs.extend(sarif_member_import_rule_specs(rules));
714 specs.extend(sarif_graph_rule_specs(rules));
715 specs.extend(sarif_workspace_rule_specs(rules));
716 specs
717 .into_iter()
718 .map(|(id, description, rule_severity)| {
719 sarif_rule(id, description, configured_sarif_level(rule_severity))
720 })
721 .collect()
722}
723
724type SarifRuleSpec = (&'static str, &'static str, Severity);
725
726fn sarif_core_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
727 [
728 (
729 "fallow/unused-file",
730 "File is not reachable from any entry point",
731 rules.unused_files,
732 ),
733 (
734 "fallow/unused-export",
735 "Export is never imported",
736 rules.unused_exports,
737 ),
738 (
739 "fallow/unused-type",
740 "Type export is never imported",
741 rules.unused_types,
742 ),
743 (
744 "fallow/private-type-leak",
745 "Exported signature references a same-file private type",
746 rules.private_type_leaks,
747 ),
748 ]
749 .into()
750}
751
752fn sarif_dependency_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
753 [
754 (
755 "fallow/unused-dependency",
756 "Dependency listed but never imported",
757 rules.unused_dependencies,
758 ),
759 (
760 "fallow/unused-dev-dependency",
761 "Dev dependency listed but never imported",
762 rules.unused_dev_dependencies,
763 ),
764 (
765 "fallow/unused-optional-dependency",
766 "Optional dependency listed but never imported",
767 rules.unused_optional_dependencies,
768 ),
769 (
770 "fallow/type-only-dependency",
771 "Production dependency only used via type-only imports",
772 rules.type_only_dependencies,
773 ),
774 (
775 "fallow/test-only-dependency",
776 "Production dependency only imported by test files",
777 rules.test_only_dependencies,
778 ),
779 ]
780 .into()
781}
782
783fn sarif_member_import_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
784 [
785 (
786 "fallow/unused-enum-member",
787 "Enum member is never referenced",
788 rules.unused_enum_members,
789 ),
790 (
791 "fallow/unused-class-member",
792 "Class member is never referenced",
793 rules.unused_class_members,
794 ),
795 (
796 "fallow/unresolved-import",
797 "Import could not be resolved",
798 rules.unresolved_imports,
799 ),
800 (
801 "fallow/unlisted-dependency",
802 "Dependency used but not in package.json",
803 rules.unlisted_dependencies,
804 ),
805 (
806 "fallow/duplicate-export",
807 "Export name appears in multiple modules",
808 rules.duplicate_exports,
809 ),
810 ]
811 .into()
812}
813
814fn sarif_graph_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
815 [
816 (
817 "fallow/circular-dependency",
818 "Circular dependency chain detected",
819 rules.circular_dependencies,
820 ),
821 (
822 "fallow/re-export-cycle",
823 "Two or more barrel files re-export from each other in a loop",
824 rules.re_export_cycle,
825 ),
826 (
827 "fallow/boundary-violation",
828 "Import crosses an architecture boundary",
829 rules.boundary_violation,
830 ),
831 (
832 "fallow/boundary-coverage",
833 "Source file matches no architecture boundary zone",
834 rules.boundary_violation,
835 ),
836 (
837 "fallow/boundary-call-violation",
838 "Zoned file calls a callee its zone forbids",
839 rules.boundary_violation,
840 ),
841 (
842 "fallow/policy-violation",
843 "Banned call or import matched a rule-pack rule",
844 rules.policy_violation,
845 ),
846 (
847 "fallow/stale-suppression",
848 "Suppression comment or tag no longer matches any issue",
849 rules.stale_suppressions,
850 ),
851 ]
852 .into()
853}
854
855fn sarif_workspace_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
856 [
857 (
858 "fallow/unused-catalog-entry",
859 "pnpm catalog entry not referenced by any workspace package",
860 rules.unused_catalog_entries,
861 ),
862 (
863 "fallow/empty-catalog-group",
864 "pnpm named catalog group has no entries",
865 rules.empty_catalog_groups,
866 ),
867 (
868 "fallow/unresolved-catalog-reference",
869 "package.json catalog reference points at a catalog that does not declare the package",
870 rules.unresolved_catalog_references,
871 ),
872 (
873 "fallow/unused-dependency-override",
874 "pnpm dependency override target is not declared or lockfile-resolved",
875 rules.unused_dependency_overrides,
876 ),
877 (
878 "fallow/misconfigured-dependency-override",
879 "pnpm dependency override key or value is malformed",
880 rules.misconfigured_dependency_overrides,
881 ),
882 ]
883 .into()
884}
885
886#[must_use]
887pub fn build_sarif(
888 results: &AnalysisResults,
889 root: &Path,
890 rules: &RulesConfig,
891) -> serde_json::Value {
892 let mut sarif_results = Vec::new();
893 let mut snippets = SourceSnippetCache::default();
894
895 push_sarif_results(
896 &mut sarif_results,
897 &results.unused_files,
898 &mut snippets,
899 |f| sarif_unused_file_fields(&f.file, root, severity_to_sarif_level(rules.unused_files)),
900 );
901 push_sarif_results(
902 &mut sarif_results,
903 &results.unused_exports,
904 &mut snippets,
905 |e| {
906 sarif_export_fields(
907 &e.export,
908 root,
909 "fallow/unused-export",
910 severity_to_sarif_level(rules.unused_exports),
911 "Export",
912 "Re-export",
913 )
914 },
915 );
916 push_sarif_results(
917 &mut sarif_results,
918 &results.unused_types,
919 &mut snippets,
920 |e| {
921 sarif_export_fields(
922 &e.export,
923 root,
924 "fallow/unused-type",
925 severity_to_sarif_level(rules.unused_types),
926 "Type export",
927 "Type re-export",
928 )
929 },
930 );
931 push_sarif_results(
932 &mut sarif_results,
933 &results.private_type_leaks,
934 &mut snippets,
935 |e| {
936 sarif_private_type_leak_fields(
937 &e.leak,
938 root,
939 severity_to_sarif_level(rules.private_type_leaks),
940 )
941 },
942 );
943 push_dependency_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
944 push_member_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
945 push_sarif_results(
946 &mut sarif_results,
947 &results.unresolved_imports,
948 &mut snippets,
949 |i| {
950 sarif_unresolved_import_fields(
951 &i.import,
952 root,
953 severity_to_sarif_level(rules.unresolved_imports),
954 )
955 },
956 );
957 push_misc_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
958 push_graph_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
959 push_catalog_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
960
961 serde_json::json!({
962 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
963 "version": "2.1.0",
964 "runs": [{
965 "tool": {
966 "driver": {
967 "name": "fallow",
968 "version": env!("CARGO_PKG_VERSION"),
969 "informationUri": "https://github.com/fallow-rs/fallow",
970 "rules": build_sarif_rules(rules)
971 }
972 },
973 "results": sarif_results
974 }]
975 })
976}
977
978fn push_dependency_sarif_results(
979 sarif_results: &mut Vec<serde_json::Value>,
980 results: &AnalysisResults,
981 root: &Path,
982 rules: &RulesConfig,
983 snippets: &mut SourceSnippetCache,
984) {
985 push_sarif_results(sarif_results, &results.unused_dependencies, snippets, |d| {
986 sarif_dep_fields(
987 &d.dep,
988 root,
989 "fallow/unused-dependency",
990 severity_to_sarif_level(rules.unused_dependencies),
991 "dependencies",
992 )
993 });
994 push_sarif_results(
995 sarif_results,
996 &results.unused_dev_dependencies,
997 snippets,
998 |d| {
999 sarif_dep_fields(
1000 &d.dep,
1001 root,
1002 "fallow/unused-dev-dependency",
1003 severity_to_sarif_level(rules.unused_dev_dependencies),
1004 "devDependencies",
1005 )
1006 },
1007 );
1008 push_sarif_results(
1009 sarif_results,
1010 &results.unused_optional_dependencies,
1011 snippets,
1012 |d| {
1013 sarif_dep_fields(
1014 &d.dep,
1015 root,
1016 "fallow/unused-optional-dependency",
1017 severity_to_sarif_level(rules.unused_optional_dependencies),
1018 "optionalDependencies",
1019 )
1020 },
1021 );
1022 push_sarif_results(
1023 sarif_results,
1024 &results.type_only_dependencies,
1025 snippets,
1026 |d| {
1027 sarif_type_only_dep_fields(
1028 &d.dep,
1029 root,
1030 severity_to_sarif_level(rules.type_only_dependencies),
1031 )
1032 },
1033 );
1034 push_sarif_results(
1035 sarif_results,
1036 &results.test_only_dependencies,
1037 snippets,
1038 |d| {
1039 sarif_test_only_dep_fields(
1040 &d.dep,
1041 root,
1042 severity_to_sarif_level(rules.test_only_dependencies),
1043 )
1044 },
1045 );
1046}
1047
1048fn push_member_sarif_results(
1049 sarif_results: &mut Vec<serde_json::Value>,
1050 results: &AnalysisResults,
1051 root: &Path,
1052 rules: &RulesConfig,
1053 snippets: &mut SourceSnippetCache,
1054) {
1055 push_sarif_results(sarif_results, &results.unused_enum_members, snippets, |m| {
1056 sarif_member_fields(
1057 &m.member,
1058 root,
1059 "fallow/unused-enum-member",
1060 severity_to_sarif_level(rules.unused_enum_members),
1061 "Enum",
1062 )
1063 });
1064 push_sarif_results(
1065 sarif_results,
1066 &results.unused_class_members,
1067 snippets,
1068 |m| {
1069 sarif_member_fields(
1070 &m.member,
1071 root,
1072 "fallow/unused-class-member",
1073 severity_to_sarif_level(rules.unused_class_members),
1074 "Class",
1075 )
1076 },
1077 );
1078}
1079
1080fn push_misc_sarif_results(
1081 sarif_results: &mut Vec<serde_json::Value>,
1082 results: &AnalysisResults,
1083 root: &Path,
1084 rules: &RulesConfig,
1085 snippets: &mut SourceSnippetCache,
1086) {
1087 if !results.unlisted_dependencies.is_empty() {
1088 push_sarif_unlisted_deps(
1089 sarif_results,
1090 &results.unlisted_dependencies,
1091 root,
1092 severity_to_sarif_level(rules.unlisted_dependencies),
1093 snippets,
1094 );
1095 }
1096 if !results.duplicate_exports.is_empty() {
1097 push_sarif_duplicate_exports(
1098 sarif_results,
1099 &results.duplicate_exports,
1100 root,
1101 severity_to_sarif_level(rules.duplicate_exports),
1102 snippets,
1103 );
1104 }
1105}
1106
1107fn push_graph_sarif_results(
1108 sarif_results: &mut Vec<serde_json::Value>,
1109 results: &AnalysisResults,
1110 root: &Path,
1111 rules: &RulesConfig,
1112 snippets: &mut SourceSnippetCache,
1113) {
1114 push_sarif_results(
1115 sarif_results,
1116 &results.circular_dependencies,
1117 snippets,
1118 |c| {
1119 sarif_circular_dep_fields(
1120 &c.cycle,
1121 root,
1122 severity_to_sarif_level(rules.circular_dependencies),
1123 )
1124 },
1125 );
1126 push_sarif_results(sarif_results, &results.re_export_cycles, snippets, |c| {
1127 sarif_re_export_cycle_fields(
1128 &c.cycle,
1129 root,
1130 severity_to_sarif_level(rules.re_export_cycle),
1131 )
1132 });
1133 push_sarif_results(sarif_results, &results.boundary_violations, snippets, |v| {
1134 sarif_boundary_violation_fields(
1135 &v.violation,
1136 root,
1137 severity_to_sarif_level(rules.boundary_violation),
1138 )
1139 });
1140 push_sarif_results(
1141 sarif_results,
1142 &results.boundary_coverage_violations,
1143 snippets,
1144 |v| {
1145 sarif_boundary_coverage_fields(
1146 &v.violation,
1147 root,
1148 severity_to_sarif_level(rules.boundary_violation),
1149 )
1150 },
1151 );
1152 push_sarif_results(
1153 sarif_results,
1154 &results.boundary_call_violations,
1155 snippets,
1156 |v| {
1157 sarif_boundary_call_fields(
1158 &v.violation,
1159 root,
1160 severity_to_sarif_level(rules.boundary_violation),
1161 )
1162 },
1163 );
1164 push_sarif_results(sarif_results, &results.policy_violations, snippets, |v| {
1165 sarif_policy_violation_fields(&v.violation, root)
1166 });
1167 push_sarif_results(sarif_results, &results.stale_suppressions, snippets, |s| {
1168 sarif_stale_suppression_fields(s, root, severity_to_sarif_level(rules.stale_suppressions))
1169 });
1170}
1171
1172fn push_catalog_sarif_results(
1173 sarif_results: &mut Vec<serde_json::Value>,
1174 results: &AnalysisResults,
1175 root: &Path,
1176 rules: &RulesConfig,
1177 snippets: &mut SourceSnippetCache,
1178) {
1179 push_sarif_results(
1180 sarif_results,
1181 &results.unused_catalog_entries,
1182 snippets,
1183 |e| {
1184 sarif_unused_catalog_entry_fields(
1185 e,
1186 root,
1187 severity_to_sarif_level(rules.unused_catalog_entries),
1188 )
1189 },
1190 );
1191 push_sarif_results(
1192 sarif_results,
1193 &results.empty_catalog_groups,
1194 snippets,
1195 |g| {
1196 sarif_empty_catalog_group_fields(
1197 g,
1198 root,
1199 severity_to_sarif_level(rules.empty_catalog_groups),
1200 )
1201 },
1202 );
1203 push_sarif_results(
1204 sarif_results,
1205 &results.unresolved_catalog_references,
1206 snippets,
1207 |f| {
1208 sarif_unresolved_catalog_reference_fields(
1209 f,
1210 root,
1211 severity_to_sarif_level(rules.unresolved_catalog_references),
1212 )
1213 },
1214 );
1215 push_sarif_results(
1216 sarif_results,
1217 &results.unused_dependency_overrides,
1218 snippets,
1219 |f| {
1220 sarif_unused_dependency_override_fields(
1221 f,
1222 root,
1223 severity_to_sarif_level(rules.unused_dependency_overrides),
1224 )
1225 },
1226 );
1227 push_sarif_results(
1228 sarif_results,
1229 &results.misconfigured_dependency_overrides,
1230 snippets,
1231 |f| {
1232 sarif_misconfigured_dependency_override_fields(
1233 f,
1234 root,
1235 severity_to_sarif_level(rules.misconfigured_dependency_overrides),
1236 )
1237 },
1238 );
1239}
1240
1241pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
1242 let sarif = build_sarif(results, root, rules);
1243 emit_json(&sarif, "SARIF")
1244}
1245
1246#[expect(
1252 clippy::expect_used,
1253 reason = "grouped SARIF entries are JSON objects created by build_sarif"
1254)]
1255pub(super) fn print_grouped_sarif(
1256 results: &AnalysisResults,
1257 root: &Path,
1258 rules: &RulesConfig,
1259 resolver: &OwnershipResolver,
1260) -> ExitCode {
1261 let mut sarif = build_sarif(results, root, rules);
1262
1263 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1264 for run in runs {
1265 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1266 for result in results {
1267 let uri = result
1268 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1269 .and_then(|v| v.as_str())
1270 .unwrap_or("");
1271 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1272 let owner =
1273 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1274 let props = result
1275 .as_object_mut()
1276 .expect("SARIF result should be an object")
1277 .entry("properties")
1278 .or_insert_with(|| serde_json::json!({}));
1279 props
1280 .as_object_mut()
1281 .expect("properties should be an object")
1282 .insert("owner".to_string(), serde_json::Value::String(owner));
1283 }
1284 }
1285 }
1286 }
1287
1288 emit_json(&sarif, "SARIF")
1289}
1290
1291#[expect(
1292 clippy::cast_possible_truncation,
1293 reason = "line/col numbers are bounded by source size"
1294)]
1295pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
1296 let mut sarif_results = Vec::new();
1297 let mut snippets = SourceSnippetCache::default();
1298
1299 for (i, group) in report.clone_groups.iter().enumerate() {
1300 for instance in &group.instances {
1301 let uri = relative_uri(&instance.file, root);
1302 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1303 sarif_results.push(sarif_result_with_snippet(
1304 "fallow/code-duplication",
1305 "warning",
1306 &format!(
1307 "Code clone group {} ({} lines, {} instances)",
1308 i + 1,
1309 group.line_count,
1310 group.instances.len()
1311 ),
1312 &uri,
1313 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1314 source_snippet.as_deref(),
1315 ));
1316 }
1317 }
1318
1319 let sarif = serde_json::json!({
1320 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1321 "version": "2.1.0",
1322 "runs": [{
1323 "tool": {
1324 "driver": {
1325 "name": "fallow",
1326 "version": env!("CARGO_PKG_VERSION"),
1327 "informationUri": "https://github.com/fallow-rs/fallow",
1328 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1329 }
1330 },
1331 "results": sarif_results
1332 }]
1333 });
1334
1335 emit_json(&sarif, "SARIF")
1336}
1337
1338#[expect(
1349 clippy::cast_possible_truncation,
1350 reason = "line/col numbers are bounded by source size"
1351)]
1352#[expect(
1353 clippy::expect_used,
1354 reason = "duplication SARIF entries are JSON objects created by sarif_result_with_snippet"
1355)]
1356pub(super) fn print_grouped_duplication_sarif(
1357 report: &DuplicationReport,
1358 root: &Path,
1359 resolver: &OwnershipResolver,
1360) -> ExitCode {
1361 let mut sarif_results = Vec::new();
1362 let mut snippets = SourceSnippetCache::default();
1363
1364 for (i, group) in report.clone_groups.iter().enumerate() {
1365 let primary_owner = super::dupes_grouping::largest_owner(group, root, resolver);
1366 for instance in &group.instances {
1367 let uri = relative_uri(&instance.file, root);
1368 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1369 let mut result = sarif_result_with_snippet(
1370 "fallow/code-duplication",
1371 "warning",
1372 &format!(
1373 "Code clone group {} ({} lines, {} instances)",
1374 i + 1,
1375 group.line_count,
1376 group.instances.len()
1377 ),
1378 &uri,
1379 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1380 source_snippet.as_deref(),
1381 );
1382 let props = result
1383 .as_object_mut()
1384 .expect("SARIF result should be an object")
1385 .entry("properties")
1386 .or_insert_with(|| serde_json::json!({}));
1387 props
1388 .as_object_mut()
1389 .expect("properties should be an object")
1390 .insert(
1391 "group".to_string(),
1392 serde_json::Value::String(primary_owner.clone()),
1393 );
1394 sarif_results.push(result);
1395 }
1396 }
1397
1398 let sarif = serde_json::json!({
1399 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1400 "version": "2.1.0",
1401 "runs": [{
1402 "tool": {
1403 "driver": {
1404 "name": "fallow",
1405 "version": env!("CARGO_PKG_VERSION"),
1406 "informationUri": "https://github.com/fallow-rs/fallow",
1407 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1408 }
1409 },
1410 "results": sarif_results
1411 }]
1412 });
1413
1414 emit_json(&sarif, "SARIF")
1415}
1416
1417#[must_use]
1418pub fn build_health_sarif(
1419 report: &crate::health_types::HealthReport,
1420 root: &Path,
1421) -> serde_json::Value {
1422 let mut sarif_results = Vec::new();
1423 let mut snippets = SourceSnippetCache::default();
1424
1425 append_health_sarif_results(report, root, &mut sarif_results, &mut snippets);
1426 let health_rules = health_sarif_rules();
1427 health_sarif_document(&sarif_results, &health_rules)
1428}
1429
1430fn append_health_sarif_results(
1431 report: &crate::health_types::HealthReport,
1432 root: &Path,
1433 sarif_results: &mut Vec<serde_json::Value>,
1434 snippets: &mut SourceSnippetCache,
1435) {
1436 append_complexity_sarif_results(sarif_results, report, root, snippets);
1437
1438 if let Some(ref production) = report.runtime_coverage {
1439 append_runtime_coverage_sarif_results(sarif_results, production, root, snippets);
1440 }
1441 if let Some(ref intelligence) = report.coverage_intelligence {
1442 append_coverage_intelligence_sarif_results(sarif_results, intelligence, root, snippets);
1443 }
1444
1445 append_refactoring_target_sarif_results(sarif_results, report, root);
1446 append_coverage_gap_sarif_results(sarif_results, report, root, snippets);
1447}
1448
1449fn health_sarif_rules() -> Vec<serde_json::Value> {
1450 vec![
1451 sarif_rule(
1452 "fallow/high-cyclomatic-complexity",
1453 "Function has high cyclomatic complexity",
1454 "note",
1455 ),
1456 sarif_rule(
1457 "fallow/high-cognitive-complexity",
1458 "Function has high cognitive complexity",
1459 "note",
1460 ),
1461 sarif_rule(
1462 "fallow/high-complexity",
1463 "Function exceeds both complexity thresholds",
1464 "note",
1465 ),
1466 sarif_rule(
1467 "fallow/high-crap-score",
1468 "Function has a high CRAP score (high complexity combined with low coverage)",
1469 "warning",
1470 ),
1471 sarif_rule(
1472 "fallow/refactoring-target",
1473 "File identified as a high-priority refactoring candidate",
1474 "warning",
1475 ),
1476 sarif_rule(
1477 "fallow/untested-file",
1478 "Runtime-reachable file has no test dependency path",
1479 "warning",
1480 ),
1481 sarif_rule(
1482 "fallow/untested-export",
1483 "Runtime-reachable export has no test dependency path",
1484 "warning",
1485 ),
1486 sarif_rule(
1487 "fallow/runtime-safe-to-delete",
1488 "Function is statically unused and was never invoked in production",
1489 "warning",
1490 ),
1491 sarif_rule(
1492 "fallow/runtime-review-required",
1493 "Function is statically used but was never invoked in production",
1494 "warning",
1495 ),
1496 sarif_rule(
1497 "fallow/runtime-low-traffic",
1498 "Function was invoked below the low-traffic threshold relative to total trace count",
1499 "note",
1500 ),
1501 sarif_rule(
1502 "fallow/runtime-coverage-unavailable",
1503 "Runtime coverage could not be resolved for this function",
1504 "note",
1505 ),
1506 sarif_rule(
1507 "fallow/runtime-coverage",
1508 "Runtime coverage finding",
1509 "note",
1510 ),
1511 sarif_rule(
1512 "fallow/coverage-intelligence-risky-change",
1513 "Changed hot path combines high CRAP and low test coverage",
1514 "warning",
1515 ),
1516 sarif_rule(
1517 "fallow/coverage-intelligence-delete",
1518 "Static and runtime evidence indicate code can be deleted",
1519 "warning",
1520 ),
1521 sarif_rule(
1522 "fallow/coverage-intelligence-review",
1523 "Cold reachable uncovered code needs owner review",
1524 "warning",
1525 ),
1526 sarif_rule(
1527 "fallow/coverage-intelligence-refactor",
1528 "Hot covered code has high CRAP and should be refactored carefully",
1529 "warning",
1530 ),
1531 ]
1532}
1533
1534fn health_sarif_document(
1535 sarif_results: &[serde_json::Value],
1536 health_rules: &[serde_json::Value],
1537) -> serde_json::Value {
1538 serde_json::json!({
1539 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1540 "version": "2.1.0",
1541 "runs": [{
1542 "tool": {
1543 "driver": {
1544 "name": "fallow",
1545 "version": env!("CARGO_PKG_VERSION"),
1546 "informationUri": "https://github.com/fallow-rs/fallow",
1547 "rules": health_rules
1548 }
1549 },
1550 "results": sarif_results
1551 }]
1552 })
1553}
1554
1555fn append_complexity_sarif_results(
1556 sarif_results: &mut Vec<serde_json::Value>,
1557 report: &crate::health_types::HealthReport,
1558 root: &Path,
1559 snippets: &mut SourceSnippetCache,
1560) {
1561 for finding in &report.findings {
1562 let uri = relative_uri(&finding.path, root);
1563 let (rule_id, message) = health_complexity_sarif_message(finding, report);
1564 let level = match finding.severity {
1565 crate::health_types::FindingSeverity::Critical => "error",
1566 crate::health_types::FindingSeverity::High => "warning",
1567 crate::health_types::FindingSeverity::Moderate => "note",
1568 };
1569 let source_snippet = snippets.line(&finding.path, finding.line);
1570 sarif_results.push(sarif_result_with_snippet(
1571 rule_id,
1572 level,
1573 &message,
1574 &uri,
1575 Some((finding.line, finding.col + 1)),
1576 source_snippet.as_deref(),
1577 ));
1578 }
1579}
1580
1581fn health_complexity_sarif_message(
1582 finding: &crate::health_types::ComplexityViolation,
1583 report: &crate::health_types::HealthReport,
1584) -> (&'static str, String) {
1585 match finding.exceeded {
1586 crate::health_types::ExceededThreshold::Cyclomatic => (
1587 "fallow/high-cyclomatic-complexity",
1588 format!(
1589 "'{}' has cyclomatic complexity {} (threshold: {})",
1590 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
1591 ),
1592 ),
1593 crate::health_types::ExceededThreshold::Cognitive => (
1594 "fallow/high-cognitive-complexity",
1595 format!(
1596 "'{}' has cognitive complexity {} (threshold: {})",
1597 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
1598 ),
1599 ),
1600 crate::health_types::ExceededThreshold::Both => (
1601 "fallow/high-complexity",
1602 format!(
1603 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
1604 finding.name,
1605 finding.cyclomatic,
1606 report.summary.max_cyclomatic_threshold,
1607 finding.cognitive,
1608 report.summary.max_cognitive_threshold,
1609 ),
1610 ),
1611 crate::health_types::ExceededThreshold::Crap
1612 | crate::health_types::ExceededThreshold::CyclomaticCrap
1613 | crate::health_types::ExceededThreshold::CognitiveCrap
1614 | crate::health_types::ExceededThreshold::All => {
1615 let crap = finding.crap.unwrap_or(0.0);
1616 let coverage = finding
1617 .coverage_pct
1618 .map(|pct| format!(", coverage {pct:.0}%"))
1619 .unwrap_or_default();
1620 (
1621 "fallow/high-crap-score",
1622 format!(
1623 "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
1624 finding.name,
1625 crap,
1626 report.summary.max_crap_threshold,
1627 finding.cyclomatic,
1628 coverage,
1629 ),
1630 )
1631 }
1632 }
1633}
1634
1635fn append_refactoring_target_sarif_results(
1636 sarif_results: &mut Vec<serde_json::Value>,
1637 report: &crate::health_types::HealthReport,
1638 root: &Path,
1639) {
1640 for target in &report.targets {
1641 let uri = relative_uri(&target.path, root);
1642 let message = format!(
1643 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
1644 target.category.label(),
1645 target.recommendation,
1646 target.priority,
1647 target.efficiency,
1648 target.effort.label(),
1649 target.confidence.label(),
1650 );
1651 sarif_results.push(sarif_result(
1652 "fallow/refactoring-target",
1653 "warning",
1654 &message,
1655 &uri,
1656 None,
1657 ));
1658 }
1659}
1660
1661fn append_coverage_gap_sarif_results(
1662 sarif_results: &mut Vec<serde_json::Value>,
1663 report: &crate::health_types::HealthReport,
1664 root: &Path,
1665 snippets: &mut SourceSnippetCache,
1666) {
1667 let Some(ref gaps) = report.coverage_gaps else {
1668 return;
1669 };
1670 for item in &gaps.files {
1671 let uri = relative_uri(&item.file.path, root);
1672 let message = format!(
1673 "File is runtime-reachable but has no test dependency path ({} value export{})",
1674 item.file.value_export_count,
1675 if item.file.value_export_count == 1 {
1676 ""
1677 } else {
1678 "s"
1679 },
1680 );
1681 sarif_results.push(sarif_result(
1682 "fallow/untested-file",
1683 "warning",
1684 &message,
1685 &uri,
1686 None,
1687 ));
1688 }
1689
1690 for item in &gaps.exports {
1691 let uri = relative_uri(&item.export.path, root);
1692 let message = format!(
1693 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
1694 item.export.export_name
1695 );
1696 let source_snippet = snippets.line(&item.export.path, item.export.line);
1697 sarif_results.push(sarif_result_with_snippet(
1698 "fallow/untested-export",
1699 "warning",
1700 &message,
1701 &uri,
1702 Some((item.export.line, item.export.col + 1)),
1703 source_snippet.as_deref(),
1704 ));
1705 }
1706}
1707
1708fn append_runtime_coverage_sarif_results(
1709 sarif_results: &mut Vec<serde_json::Value>,
1710 production: &crate::health_types::RuntimeCoverageReport,
1711 root: &Path,
1712 snippets: &mut SourceSnippetCache,
1713) {
1714 for finding in &production.findings {
1715 let uri = relative_uri(&finding.path, root);
1716 let rule_id = match finding.verdict {
1717 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
1718 "fallow/runtime-safe-to-delete"
1719 }
1720 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
1721 "fallow/runtime-review-required"
1722 }
1723 crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
1724 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
1725 "fallow/runtime-coverage-unavailable"
1726 }
1727 crate::health_types::RuntimeCoverageVerdict::Active
1728 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
1729 };
1730 let level = match finding.verdict {
1731 crate::health_types::RuntimeCoverageVerdict::SafeToDelete
1732 | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
1733 _ => "note",
1734 };
1735 let invocations_hint = finding.invocations.map_or_else(
1736 || "untracked".to_owned(),
1737 |hits| format!("{hits} invocations"),
1738 );
1739 let message = format!(
1740 "'{}' runtime coverage verdict: {} ({})",
1741 finding.function,
1742 finding.verdict.human_label(),
1743 invocations_hint,
1744 );
1745 let source_snippet = snippets.line(&finding.path, finding.line);
1746 sarif_results.push(sarif_result_with_snippet(
1747 rule_id,
1748 level,
1749 &message,
1750 &uri,
1751 Some((finding.line, 1)),
1752 source_snippet.as_deref(),
1753 ));
1754 }
1755}
1756
1757fn append_coverage_intelligence_sarif_results(
1758 sarif_results: &mut Vec<serde_json::Value>,
1759 intelligence: &crate::health_types::CoverageIntelligenceReport,
1760 root: &Path,
1761 snippets: &mut SourceSnippetCache,
1762) {
1763 for finding in &intelligence.findings {
1764 let rule_id = coverage_intelligence_rule_id(finding.recommendation);
1765 let level = match finding.verdict {
1766 crate::health_types::CoverageIntelligenceVerdict::Clean
1767 | crate::health_types::CoverageIntelligenceVerdict::Unknown => continue,
1768 _ => "warning",
1769 };
1770 let uri = relative_uri(&finding.path, root);
1771 let identity = finding.identity.as_deref().unwrap_or("code");
1772 let signals = finding
1773 .signals
1774 .iter()
1775 .map(ToString::to_string)
1776 .collect::<Vec<_>>()
1777 .join(", ");
1778 let message = format!(
1779 "'{}' coverage intelligence verdict: {} ({}, signals: {})",
1780 identity, finding.verdict, finding.recommendation, signals,
1781 );
1782 let source_snippet = snippets.line(&finding.path, finding.line);
1783 let mut result = sarif_result_with_snippet(
1784 rule_id,
1785 level,
1786 &message,
1787 &uri,
1788 Some((finding.line, 1)),
1789 source_snippet.as_deref(),
1790 );
1791 result["properties"] = serde_json::json!({
1792 "coverage_intelligence_id": &finding.id,
1793 "verdict": finding.verdict,
1794 "recommendation": finding.recommendation,
1795 "confidence": finding.confidence,
1796 "signals": &finding.signals,
1797 "related_ids": &finding.related_ids,
1798 });
1799 sarif_results.push(result);
1800 }
1801}
1802
1803fn coverage_intelligence_rule_id(
1804 recommendation: crate::health_types::CoverageIntelligenceRecommendation,
1805) -> &'static str {
1806 match recommendation {
1807 crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
1808 "fallow/coverage-intelligence-risky-change"
1809 }
1810 crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
1811 "fallow/coverage-intelligence-delete"
1812 }
1813 crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
1814 "fallow/coverage-intelligence-review"
1815 }
1816 crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
1817 "fallow/coverage-intelligence-refactor"
1818 }
1819 }
1820}
1821
1822pub(super) fn print_health_sarif(
1823 report: &crate::health_types::HealthReport,
1824 root: &Path,
1825) -> ExitCode {
1826 let sarif = build_health_sarif(report, root);
1827 emit_json(&sarif, "SARIF")
1828}
1829
1830#[expect(
1841 clippy::expect_used,
1842 reason = "grouped health SARIF entries are JSON objects created by build_health_sarif"
1843)]
1844pub(super) fn print_grouped_health_sarif(
1845 report: &crate::health_types::HealthReport,
1846 root: &Path,
1847 resolver: &OwnershipResolver,
1848) -> ExitCode {
1849 let mut sarif = build_health_sarif(report, root);
1850
1851 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1852 for run in runs {
1853 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1854 for result in results {
1855 let uri = result
1856 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1857 .and_then(|v| v.as_str())
1858 .unwrap_or("");
1859 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1860 let group =
1861 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1862 let props = result
1863 .as_object_mut()
1864 .expect("SARIF result should be an object")
1865 .entry("properties")
1866 .or_insert_with(|| serde_json::json!({}));
1867 props
1868 .as_object_mut()
1869 .expect("properties should be an object")
1870 .insert("group".to_string(), serde_json::Value::String(group));
1871 }
1872 }
1873 }
1874 }
1875
1876 emit_json(&sarif, "SARIF")
1877}
1878
1879#[cfg(test)]
1880mod tests {
1881 use super::*;
1882 use crate::report::test_helpers::sample_results;
1883 use fallow_core::results::*;
1884 use std::path::PathBuf;
1885
1886 #[test]
1887 fn sarif_has_required_top_level_fields() {
1888 let root = PathBuf::from("/project");
1889 let results = AnalysisResults::default();
1890 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1891
1892 assert_eq!(
1893 sarif["$schema"],
1894 "https://json.schemastore.org/sarif-2.1.0.json"
1895 );
1896 assert_eq!(sarif["version"], "2.1.0");
1897 assert!(sarif["runs"].is_array());
1898 }
1899
1900 #[test]
1901 fn sarif_has_tool_driver_info() {
1902 let root = PathBuf::from("/project");
1903 let results = AnalysisResults::default();
1904 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1905
1906 let driver = &sarif["runs"][0]["tool"]["driver"];
1907 assert_eq!(driver["name"], "fallow");
1908 assert!(driver["version"].is_string());
1909 assert_eq!(
1910 driver["informationUri"],
1911 "https://github.com/fallow-rs/fallow"
1912 );
1913 }
1914
1915 #[test]
1916 fn sarif_declares_all_rules() {
1917 let root = PathBuf::from("/project");
1918 let results = AnalysisResults::default();
1919 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1920
1921 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1922 .as_array()
1923 .expect("rules should be an array");
1924 assert_eq!(rules.len(), 26);
1925
1926 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
1927 assert!(rule_ids.contains(&"fallow/unused-file"));
1928 assert!(rule_ids.contains(&"fallow/unused-export"));
1929 assert!(rule_ids.contains(&"fallow/unused-type"));
1930 assert!(rule_ids.contains(&"fallow/private-type-leak"));
1931 assert!(rule_ids.contains(&"fallow/unused-dependency"));
1932 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1933 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1934 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1935 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1936 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1937 assert!(rule_ids.contains(&"fallow/unused-class-member"));
1938 assert!(rule_ids.contains(&"fallow/unresolved-import"));
1939 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1940 assert!(rule_ids.contains(&"fallow/duplicate-export"));
1941 assert!(rule_ids.contains(&"fallow/circular-dependency"));
1942 assert!(rule_ids.contains(&"fallow/re-export-cycle"));
1943 assert!(rule_ids.contains(&"fallow/boundary-violation"));
1944 assert!(rule_ids.contains(&"fallow/boundary-coverage"));
1945 assert!(rule_ids.contains(&"fallow/boundary-call-violation"));
1946 assert!(rule_ids.contains(&"fallow/policy-violation"));
1947 assert!(rule_ids.contains(&"fallow/unused-catalog-entry"));
1948 assert!(rule_ids.contains(&"fallow/empty-catalog-group"));
1949 assert!(rule_ids.contains(&"fallow/unresolved-catalog-reference"));
1950 assert!(rule_ids.contains(&"fallow/unused-dependency-override"));
1951 assert!(rule_ids.contains(&"fallow/misconfigured-dependency-override"));
1952 }
1953
1954 #[test]
1955 fn sarif_empty_results_no_results_entries() {
1956 let root = PathBuf::from("/project");
1957 let results = AnalysisResults::default();
1958 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1959
1960 let sarif_results = sarif["runs"][0]["results"]
1961 .as_array()
1962 .expect("results should be an array");
1963 assert!(sarif_results.is_empty());
1964 }
1965
1966 #[test]
1967 fn sarif_unused_file_result() {
1968 let root = PathBuf::from("/project");
1969 let mut results = AnalysisResults::default();
1970 results
1971 .unused_files
1972 .push(UnusedFileFinding::with_actions(UnusedFile {
1973 path: root.join("src/dead.ts"),
1974 }));
1975
1976 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1977 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1978 assert_eq!(entries.len(), 1);
1979
1980 let entry = &entries[0];
1981 assert_eq!(entry["ruleId"], "fallow/unused-file");
1982 assert_eq!(entry["level"], "error");
1983 assert_eq!(
1984 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1985 "src/dead.ts"
1986 );
1987 }
1988
1989 #[test]
1990 fn sarif_unused_export_includes_region() {
1991 let root = PathBuf::from("/project");
1992 let mut results = AnalysisResults::default();
1993 results
1994 .unused_exports
1995 .push(UnusedExportFinding::with_actions(UnusedExport {
1996 path: root.join("src/utils.ts"),
1997 export_name: "helperFn".to_string(),
1998 is_type_only: false,
1999 line: 10,
2000 col: 4,
2001 span_start: 120,
2002 is_re_export: false,
2003 }));
2004
2005 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2006 let entry = &sarif["runs"][0]["results"][0];
2007 assert_eq!(entry["ruleId"], "fallow/unused-export");
2008
2009 let region = &entry["locations"][0]["physicalLocation"]["region"];
2010 assert_eq!(region["startLine"], 10);
2011 assert_eq!(region["startColumn"], 5);
2012 }
2013
2014 #[test]
2015 fn sarif_unresolved_import_is_error_level() {
2016 let root = PathBuf::from("/project");
2017 let mut results = AnalysisResults::default();
2018 results
2019 .unresolved_imports
2020 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
2021 path: root.join("src/app.ts"),
2022 specifier: "./missing".to_string(),
2023 line: 1,
2024 col: 0,
2025 specifier_col: 0,
2026 }));
2027
2028 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2029 let entry = &sarif["runs"][0]["results"][0];
2030 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
2031 assert_eq!(entry["level"], "error");
2032 }
2033
2034 #[test]
2035 fn sarif_unlisted_dependency_points_to_import_site() {
2036 let root = PathBuf::from("/project");
2037 let mut results = AnalysisResults::default();
2038 results
2039 .unlisted_dependencies
2040 .push(UnlistedDependencyFinding::with_actions(
2041 UnlistedDependency {
2042 package_name: "chalk".to_string(),
2043 imported_from: vec![ImportSite {
2044 path: root.join("src/cli.ts"),
2045 line: 3,
2046 col: 0,
2047 }],
2048 },
2049 ));
2050
2051 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2052 let entry = &sarif["runs"][0]["results"][0];
2053 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
2054 assert_eq!(entry["level"], "error");
2055 assert_eq!(
2056 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2057 "src/cli.ts"
2058 );
2059 let region = &entry["locations"][0]["physicalLocation"]["region"];
2060 assert_eq!(region["startLine"], 3);
2061 assert_eq!(region["startColumn"], 1);
2062 }
2063
2064 #[test]
2065 fn sarif_dependency_issues_point_to_package_json() {
2066 let root = PathBuf::from("/project");
2067 let mut results = AnalysisResults::default();
2068 results
2069 .unused_dependencies
2070 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2071 package_name: "lodash".to_string(),
2072 location: DependencyLocation::Dependencies,
2073 path: root.join("package.json"),
2074 line: 5,
2075 used_in_workspaces: Vec::new(),
2076 }));
2077 results
2078 .unused_dev_dependencies
2079 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
2080 package_name: "jest".to_string(),
2081 location: DependencyLocation::DevDependencies,
2082 path: root.join("package.json"),
2083 line: 5,
2084 used_in_workspaces: Vec::new(),
2085 }));
2086
2087 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2088 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2089 for entry in entries {
2090 assert_eq!(
2091 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2092 "package.json"
2093 );
2094 }
2095 }
2096
2097 #[test]
2098 fn sarif_duplicate_export_emits_one_result_per_location() {
2099 let root = PathBuf::from("/project");
2100 let mut results = AnalysisResults::default();
2101 results
2102 .duplicate_exports
2103 .push(DuplicateExportFinding::with_actions(DuplicateExport {
2104 export_name: "Config".to_string(),
2105 locations: vec![
2106 DuplicateLocation {
2107 path: root.join("src/a.ts"),
2108 line: 15,
2109 col: 0,
2110 },
2111 DuplicateLocation {
2112 path: root.join("src/b.ts"),
2113 line: 30,
2114 col: 0,
2115 },
2116 ],
2117 }));
2118
2119 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2120 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2121 assert_eq!(entries.len(), 2);
2122 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
2123 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
2124 assert_eq!(
2125 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2126 "src/a.ts"
2127 );
2128 assert_eq!(
2129 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2130 "src/b.ts"
2131 );
2132 }
2133
2134 #[test]
2135 fn sarif_all_issue_types_produce_results() {
2136 let root = PathBuf::from("/project");
2137 let results = sample_results(&root);
2138 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2139
2140 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2141 assert_eq!(entries.len(), results.total_issues() + 1);
2142
2143 let rule_ids: Vec<&str> = entries
2144 .iter()
2145 .map(|e| e["ruleId"].as_str().unwrap())
2146 .collect();
2147 assert!(rule_ids.contains(&"fallow/unused-file"));
2148 assert!(rule_ids.contains(&"fallow/unused-export"));
2149 assert!(rule_ids.contains(&"fallow/unused-type"));
2150 assert!(rule_ids.contains(&"fallow/unused-dependency"));
2151 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
2152 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
2153 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
2154 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
2155 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
2156 assert!(rule_ids.contains(&"fallow/unused-class-member"));
2157 assert!(rule_ids.contains(&"fallow/unresolved-import"));
2158 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
2159 assert!(rule_ids.contains(&"fallow/duplicate-export"));
2160 }
2161
2162 #[test]
2163 fn sarif_serializes_to_valid_json() {
2164 let root = PathBuf::from("/project");
2165 let results = sample_results(&root);
2166 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2167
2168 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
2169 let reparsed: serde_json::Value =
2170 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
2171 assert_eq!(reparsed, sarif);
2172 }
2173
2174 #[test]
2175 fn sarif_file_write_produces_valid_sarif() {
2176 let root = PathBuf::from("/project");
2177 let results = sample_results(&root);
2178 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2179 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
2180
2181 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
2182 let _ = std::fs::create_dir_all(&dir);
2183 let sarif_path = dir.join("results.sarif");
2184 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
2185
2186 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
2187 let parsed: serde_json::Value =
2188 serde_json::from_str(&contents).expect("file should contain valid JSON");
2189
2190 assert_eq!(parsed["version"], "2.1.0");
2191 assert_eq!(
2192 parsed["$schema"],
2193 "https://json.schemastore.org/sarif-2.1.0.json"
2194 );
2195 let sarif_results = parsed["runs"][0]["results"]
2196 .as_array()
2197 .expect("results should be an array");
2198 assert!(!sarif_results.is_empty());
2199
2200 let _ = std::fs::remove_file(&sarif_path);
2201 let _ = std::fs::remove_dir(&dir);
2202 }
2203
2204 #[test]
2205 fn health_sarif_empty_no_results() {
2206 let root = PathBuf::from("/project");
2207 let report = crate::health_types::HealthReport {
2208 summary: crate::health_types::HealthSummary {
2209 files_analyzed: 10,
2210 functions_analyzed: 50,
2211 ..Default::default()
2212 },
2213 ..Default::default()
2214 };
2215 let sarif = build_health_sarif(&report, &root);
2216 assert_eq!(sarif["version"], "2.1.0");
2217 let results = sarif["runs"][0]["results"].as_array().unwrap();
2218 assert!(results.is_empty());
2219 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2220 .as_array()
2221 .unwrap();
2222 assert_eq!(rules.len(), 16);
2223 }
2224
2225 #[test]
2226 fn health_sarif_coverage_intelligence_preserves_structured_properties() {
2227 use crate::health_types::{
2228 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2229 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2230 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2231 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2232 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2233 HealthReport, HealthSummary,
2234 };
2235
2236 let root = PathBuf::from("/project");
2237 let report = HealthReport {
2238 summary: HealthSummary {
2239 files_analyzed: 10,
2240 functions_analyzed: 50,
2241 ..Default::default()
2242 },
2243 coverage_intelligence: Some(CoverageIntelligenceReport {
2244 schema_version: CoverageIntelligenceSchemaVersion::V1,
2245 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2246 summary: CoverageIntelligenceSummary {
2247 findings: 1,
2248 high_confidence_deletes: 1,
2249 ..Default::default()
2250 },
2251 findings: vec![CoverageIntelligenceFinding {
2252 id: "fallow:coverage-intel:abc123".to_owned(),
2253 path: root.join("src/dead.ts"),
2254 identity: Some("deadPath".to_owned()),
2255 line: 9,
2256 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2257 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2258 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2259 confidence: CoverageIntelligenceConfidence::High,
2260 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2261 evidence: CoverageIntelligenceEvidence {
2262 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2263 ..Default::default()
2264 },
2265 actions: vec![CoverageIntelligenceAction {
2266 kind: "delete-after-confirming-owner".to_owned(),
2267 description: "Confirm ownership".to_owned(),
2268 auto_fixable: false,
2269 }],
2270 }],
2271 }),
2272 ..Default::default()
2273 };
2274
2275 let sarif = build_health_sarif(&report, &root);
2276 let result = &sarif["runs"][0]["results"][0];
2277 assert_eq!(result["ruleId"], "fallow/coverage-intelligence-delete");
2278 assert_eq!(
2279 result["properties"]["coverage_intelligence_id"],
2280 "fallow:coverage-intel:abc123"
2281 );
2282 assert_eq!(
2283 result["properties"]["recommendation"],
2284 "delete-after-confirming-owner"
2285 );
2286 assert_eq!(result["properties"]["confidence"], "high");
2287 assert_eq!(result["properties"]["signals"][0], "runtime_cold");
2288 assert_eq!(
2289 result["properties"]["related_ids"][0],
2290 "fallow:prod:deadbeef"
2291 );
2292 }
2293
2294 #[test]
2295 fn health_sarif_cyclomatic_only() {
2296 let root = PathBuf::from("/project");
2297 let report = crate::health_types::HealthReport {
2298 findings: vec![
2299 crate::health_types::ComplexityViolation {
2300 path: root.join("src/utils.ts"),
2301 name: "parseExpression".to_string(),
2302 line: 42,
2303 col: 0,
2304 cyclomatic: 25,
2305 cognitive: 10,
2306 line_count: 80,
2307 param_count: 0,
2308 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
2309 severity: crate::health_types::FindingSeverity::High,
2310 crap: None,
2311 coverage_pct: None,
2312 coverage_tier: None,
2313 coverage_source: None,
2314 inherited_from: None,
2315 component_rollup: None,
2316 contributions: Vec::new(),
2317 effective_thresholds: None,
2318 threshold_source: None,
2319 }
2320 .into(),
2321 ],
2322 summary: crate::health_types::HealthSummary {
2323 files_analyzed: 5,
2324 functions_analyzed: 20,
2325 functions_above_threshold: 1,
2326 ..Default::default()
2327 },
2328 ..Default::default()
2329 };
2330 let sarif = build_health_sarif(&report, &root);
2331 let entry = &sarif["runs"][0]["results"][0];
2332 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
2333 assert_eq!(entry["level"], "warning");
2334 assert!(
2335 entry["message"]["text"]
2336 .as_str()
2337 .unwrap()
2338 .contains("cyclomatic complexity 25")
2339 );
2340 assert_eq!(
2341 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2342 "src/utils.ts"
2343 );
2344 let region = &entry["locations"][0]["physicalLocation"]["region"];
2345 assert_eq!(region["startLine"], 42);
2346 assert_eq!(region["startColumn"], 1);
2347 }
2348
2349 #[test]
2350 fn health_sarif_cognitive_only() {
2351 let root = PathBuf::from("/project");
2352 let report = crate::health_types::HealthReport {
2353 findings: vec![
2354 crate::health_types::ComplexityViolation {
2355 path: root.join("src/api.ts"),
2356 name: "handleRequest".to_string(),
2357 line: 10,
2358 col: 4,
2359 cyclomatic: 8,
2360 cognitive: 20,
2361 line_count: 40,
2362 param_count: 0,
2363 exceeded: crate::health_types::ExceededThreshold::Cognitive,
2364 severity: crate::health_types::FindingSeverity::High,
2365 crap: None,
2366 coverage_pct: None,
2367 coverage_tier: None,
2368 coverage_source: None,
2369 inherited_from: None,
2370 component_rollup: None,
2371 contributions: Vec::new(),
2372 effective_thresholds: None,
2373 threshold_source: None,
2374 }
2375 .into(),
2376 ],
2377 summary: crate::health_types::HealthSummary {
2378 files_analyzed: 3,
2379 functions_analyzed: 10,
2380 functions_above_threshold: 1,
2381 ..Default::default()
2382 },
2383 ..Default::default()
2384 };
2385 let sarif = build_health_sarif(&report, &root);
2386 let entry = &sarif["runs"][0]["results"][0];
2387 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
2388 assert!(
2389 entry["message"]["text"]
2390 .as_str()
2391 .unwrap()
2392 .contains("cognitive complexity 20")
2393 );
2394 let region = &entry["locations"][0]["physicalLocation"]["region"];
2395 assert_eq!(region["startColumn"], 5); }
2397
2398 #[test]
2399 fn health_sarif_both_thresholds() {
2400 let root = PathBuf::from("/project");
2401 let report = crate::health_types::HealthReport {
2402 findings: vec![
2403 crate::health_types::ComplexityViolation {
2404 path: root.join("src/complex.ts"),
2405 name: "doEverything".to_string(),
2406 line: 1,
2407 col: 0,
2408 cyclomatic: 30,
2409 cognitive: 45,
2410 line_count: 100,
2411 param_count: 0,
2412 exceeded: crate::health_types::ExceededThreshold::Both,
2413 severity: crate::health_types::FindingSeverity::High,
2414 crap: None,
2415 coverage_pct: None,
2416 coverage_tier: None,
2417 coverage_source: None,
2418 inherited_from: None,
2419 component_rollup: None,
2420 contributions: Vec::new(),
2421 effective_thresholds: None,
2422 threshold_source: None,
2423 }
2424 .into(),
2425 ],
2426 summary: crate::health_types::HealthSummary {
2427 files_analyzed: 1,
2428 functions_analyzed: 1,
2429 functions_above_threshold: 1,
2430 ..Default::default()
2431 },
2432 ..Default::default()
2433 };
2434 let sarif = build_health_sarif(&report, &root);
2435 let entry = &sarif["runs"][0]["results"][0];
2436 assert_eq!(entry["ruleId"], "fallow/high-complexity");
2437 let msg = entry["message"]["text"].as_str().unwrap();
2438 assert!(msg.contains("cyclomatic complexity 30"));
2439 assert!(msg.contains("cognitive complexity 45"));
2440 }
2441
2442 #[test]
2443 fn health_sarif_crap_only_emits_crap_rule() {
2444 let root = PathBuf::from("/project");
2445 let report = crate::health_types::HealthReport {
2446 findings: vec![
2447 crate::health_types::ComplexityViolation {
2448 path: root.join("src/untested.ts"),
2449 name: "risky".to_string(),
2450 line: 8,
2451 col: 0,
2452 cyclomatic: 10,
2453 cognitive: 10,
2454 line_count: 20,
2455 param_count: 1,
2456 exceeded: crate::health_types::ExceededThreshold::Crap,
2457 severity: crate::health_types::FindingSeverity::High,
2458 crap: Some(82.2),
2459 coverage_pct: Some(12.0),
2460 coverage_tier: None,
2461 coverage_source: None,
2462 inherited_from: None,
2463 component_rollup: None,
2464 contributions: Vec::new(),
2465 effective_thresholds: None,
2466 threshold_source: None,
2467 }
2468 .into(),
2469 ],
2470 summary: crate::health_types::HealthSummary {
2471 files_analyzed: 1,
2472 functions_analyzed: 1,
2473 functions_above_threshold: 1,
2474 ..Default::default()
2475 },
2476 ..Default::default()
2477 };
2478 let sarif = build_health_sarif(&report, &root);
2479 let entry = &sarif["runs"][0]["results"][0];
2480 assert_eq!(entry["ruleId"], "fallow/high-crap-score");
2481 let msg = entry["message"]["text"].as_str().unwrap();
2482 assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
2483 assert!(msg.contains("coverage 12%"), "msg: {msg}");
2484 }
2485
2486 #[test]
2487 fn health_sarif_cyclomatic_crap_uses_crap_rule() {
2488 let root = PathBuf::from("/project");
2489 let report = crate::health_types::HealthReport {
2490 findings: vec![
2491 crate::health_types::ComplexityViolation {
2492 path: root.join("src/hot.ts"),
2493 name: "branchy".to_string(),
2494 line: 1,
2495 col: 0,
2496 cyclomatic: 67,
2497 cognitive: 12,
2498 line_count: 80,
2499 param_count: 1,
2500 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
2501 severity: crate::health_types::FindingSeverity::Critical,
2502 crap: Some(182.0),
2503 coverage_pct: None,
2504 coverage_tier: None,
2505 coverage_source: None,
2506 inherited_from: None,
2507 component_rollup: None,
2508 contributions: Vec::new(),
2509 effective_thresholds: None,
2510 threshold_source: None,
2511 }
2512 .into(),
2513 ],
2514 summary: crate::health_types::HealthSummary {
2515 files_analyzed: 1,
2516 functions_analyzed: 1,
2517 functions_above_threshold: 1,
2518 ..Default::default()
2519 },
2520 ..Default::default()
2521 };
2522 let sarif = build_health_sarif(&report, &root);
2523 let results = sarif["runs"][0]["results"].as_array().unwrap();
2524 assert_eq!(
2525 results.len(),
2526 1,
2527 "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
2528 );
2529 assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
2530 let msg = results[0]["message"]["text"].as_str().unwrap();
2531 assert!(msg.contains("CRAP score 182"), "msg: {msg}");
2532 assert!(!msg.contains("coverage"), "msg: {msg}");
2533 }
2534
2535 #[test]
2536 fn severity_to_sarif_level_error() {
2537 assert_eq!(severity_to_sarif_level(Severity::Error), "error");
2538 }
2539
2540 #[test]
2541 fn severity_to_sarif_level_warn() {
2542 assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
2543 }
2544
2545 #[test]
2546 #[should_panic(expected = "internal error: entered unreachable code")]
2547 fn severity_to_sarif_level_off() {
2548 let _ = severity_to_sarif_level(Severity::Off);
2549 }
2550
2551 #[test]
2552 fn sarif_re_export_has_properties() {
2553 let root = PathBuf::from("/project");
2554 let mut results = AnalysisResults::default();
2555 results
2556 .unused_exports
2557 .push(UnusedExportFinding::with_actions(UnusedExport {
2558 path: root.join("src/index.ts"),
2559 export_name: "reExported".to_string(),
2560 is_type_only: false,
2561 line: 1,
2562 col: 0,
2563 span_start: 0,
2564 is_re_export: true,
2565 }));
2566
2567 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2568 let entry = &sarif["runs"][0]["results"][0];
2569 assert_eq!(entry["properties"]["is_re_export"], true);
2570 let msg = entry["message"]["text"].as_str().unwrap();
2571 assert!(msg.starts_with("Re-export"));
2572 }
2573
2574 #[test]
2575 fn sarif_non_re_export_has_no_properties() {
2576 let root = PathBuf::from("/project");
2577 let mut results = AnalysisResults::default();
2578 results
2579 .unused_exports
2580 .push(UnusedExportFinding::with_actions(UnusedExport {
2581 path: root.join("src/utils.ts"),
2582 export_name: "foo".to_string(),
2583 is_type_only: false,
2584 line: 5,
2585 col: 0,
2586 span_start: 0,
2587 is_re_export: false,
2588 }));
2589
2590 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2591 let entry = &sarif["runs"][0]["results"][0];
2592 assert!(entry.get("properties").is_none());
2593 let msg = entry["message"]["text"].as_str().unwrap();
2594 assert!(msg.starts_with("Export"));
2595 }
2596
2597 #[test]
2598 fn sarif_type_re_export_message() {
2599 let root = PathBuf::from("/project");
2600 let mut results = AnalysisResults::default();
2601 results
2602 .unused_types
2603 .push(UnusedTypeFinding::with_actions(UnusedExport {
2604 path: root.join("src/index.ts"),
2605 export_name: "MyType".to_string(),
2606 is_type_only: true,
2607 line: 1,
2608 col: 0,
2609 span_start: 0,
2610 is_re_export: true,
2611 }));
2612
2613 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2614 let entry = &sarif["runs"][0]["results"][0];
2615 assert_eq!(entry["ruleId"], "fallow/unused-type");
2616 let msg = entry["message"]["text"].as_str().unwrap();
2617 assert!(msg.starts_with("Type re-export"));
2618 assert_eq!(entry["properties"]["is_re_export"], true);
2619 }
2620
2621 #[test]
2622 fn sarif_dependency_line_zero_skips_region() {
2623 let root = PathBuf::from("/project");
2624 let mut results = AnalysisResults::default();
2625 results
2626 .unused_dependencies
2627 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2628 package_name: "lodash".to_string(),
2629 location: DependencyLocation::Dependencies,
2630 path: root.join("package.json"),
2631 line: 0,
2632 used_in_workspaces: Vec::new(),
2633 }));
2634
2635 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2636 let entry = &sarif["runs"][0]["results"][0];
2637 let phys = &entry["locations"][0]["physicalLocation"];
2638 assert!(phys.get("region").is_none());
2639 }
2640
2641 #[test]
2642 fn sarif_dependency_line_nonzero_has_region() {
2643 let root = PathBuf::from("/project");
2644 let mut results = AnalysisResults::default();
2645 results
2646 .unused_dependencies
2647 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2648 package_name: "lodash".to_string(),
2649 location: DependencyLocation::Dependencies,
2650 path: root.join("package.json"),
2651 line: 7,
2652 used_in_workspaces: Vec::new(),
2653 }));
2654
2655 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2656 let entry = &sarif["runs"][0]["results"][0];
2657 let region = &entry["locations"][0]["physicalLocation"]["region"];
2658 assert_eq!(region["startLine"], 7);
2659 assert_eq!(region["startColumn"], 1);
2660 }
2661
2662 #[test]
2663 fn sarif_type_only_dep_line_zero_skips_region() {
2664 let root = PathBuf::from("/project");
2665 let mut results = AnalysisResults::default();
2666 results
2667 .type_only_dependencies
2668 .push(TypeOnlyDependencyFinding::with_actions(
2669 TypeOnlyDependency {
2670 package_name: "zod".to_string(),
2671 path: root.join("package.json"),
2672 line: 0,
2673 },
2674 ));
2675
2676 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2677 let entry = &sarif["runs"][0]["results"][0];
2678 let phys = &entry["locations"][0]["physicalLocation"];
2679 assert!(phys.get("region").is_none());
2680 }
2681
2682 #[test]
2683 fn sarif_circular_dep_line_zero_skips_region() {
2684 let root = PathBuf::from("/project");
2685 let mut results = AnalysisResults::default();
2686 results
2687 .circular_dependencies
2688 .push(CircularDependencyFinding::with_actions(
2689 CircularDependency {
2690 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2691 length: 2,
2692 line: 0,
2693 col: 0,
2694 edges: Vec::new(),
2695 is_cross_package: false,
2696 },
2697 ));
2698
2699 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2700 let entry = &sarif["runs"][0]["results"][0];
2701 let phys = &entry["locations"][0]["physicalLocation"];
2702 assert!(phys.get("region").is_none());
2703 }
2704
2705 #[test]
2706 fn sarif_circular_dep_line_nonzero_has_region() {
2707 let root = PathBuf::from("/project");
2708 let mut results = AnalysisResults::default();
2709 results
2710 .circular_dependencies
2711 .push(CircularDependencyFinding::with_actions(
2712 CircularDependency {
2713 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2714 length: 2,
2715 line: 5,
2716 col: 2,
2717 edges: Vec::new(),
2718 is_cross_package: false,
2719 },
2720 ));
2721
2722 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2723 let entry = &sarif["runs"][0]["results"][0];
2724 let region = &entry["locations"][0]["physicalLocation"]["region"];
2725 assert_eq!(region["startLine"], 5);
2726 assert_eq!(region["startColumn"], 3);
2727 }
2728
2729 #[test]
2730 fn sarif_unused_optional_dependency_result() {
2731 let root = PathBuf::from("/project");
2732 let mut results = AnalysisResults::default();
2733 results
2734 .unused_optional_dependencies
2735 .push(UnusedOptionalDependencyFinding::with_actions(
2736 UnusedDependency {
2737 package_name: "fsevents".to_string(),
2738 location: DependencyLocation::OptionalDependencies,
2739 path: root.join("package.json"),
2740 line: 12,
2741 used_in_workspaces: Vec::new(),
2742 },
2743 ));
2744
2745 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2746 let entry = &sarif["runs"][0]["results"][0];
2747 assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
2748 let msg = entry["message"]["text"].as_str().unwrap();
2749 assert!(msg.contains("optionalDependencies"));
2750 }
2751
2752 #[test]
2753 fn sarif_enum_member_message_format() {
2754 let root = PathBuf::from("/project");
2755 let mut results = AnalysisResults::default();
2756 results.unused_enum_members.push(
2757 fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
2758 path: root.join("src/enums.ts"),
2759 parent_name: "Color".to_string(),
2760 member_name: "Purple".to_string(),
2761 kind: fallow_core::extract::MemberKind::EnumMember,
2762 line: 5,
2763 col: 2,
2764 }),
2765 );
2766
2767 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2768 let entry = &sarif["runs"][0]["results"][0];
2769 assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
2770 let msg = entry["message"]["text"].as_str().unwrap();
2771 assert!(msg.contains("Enum member 'Color.Purple'"));
2772 let region = &entry["locations"][0]["physicalLocation"]["region"];
2773 assert_eq!(region["startColumn"], 3); }
2775
2776 #[test]
2777 fn sarif_class_member_message_format() {
2778 let root = PathBuf::from("/project");
2779 let mut results = AnalysisResults::default();
2780 results.unused_class_members.push(
2781 fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
2782 path: root.join("src/service.ts"),
2783 parent_name: "API".to_string(),
2784 member_name: "fetch".to_string(),
2785 kind: fallow_core::extract::MemberKind::ClassMethod,
2786 line: 10,
2787 col: 4,
2788 }),
2789 );
2790
2791 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2792 let entry = &sarif["runs"][0]["results"][0];
2793 assert_eq!(entry["ruleId"], "fallow/unused-class-member");
2794 let msg = entry["message"]["text"].as_str().unwrap();
2795 assert!(msg.contains("Class member 'API.fetch'"));
2796 }
2797
2798 #[test]
2799 #[expect(
2800 clippy::cast_possible_truncation,
2801 reason = "test line/col values are trivially small"
2802 )]
2803 fn duplication_sarif_structure() {
2804 use fallow_core::duplicates::*;
2805
2806 let root = PathBuf::from("/project");
2807 let report = DuplicationReport {
2808 clone_groups: vec![CloneGroup {
2809 instances: vec![
2810 CloneInstance {
2811 file: root.join("src/a.ts"),
2812 start_line: 1,
2813 end_line: 10,
2814 start_col: 0,
2815 end_col: 0,
2816 fragment: String::new(),
2817 },
2818 CloneInstance {
2819 file: root.join("src/b.ts"),
2820 start_line: 5,
2821 end_line: 14,
2822 start_col: 2,
2823 end_col: 0,
2824 fragment: String::new(),
2825 },
2826 ],
2827 token_count: 50,
2828 line_count: 10,
2829 }],
2830 clone_families: vec![],
2831 mirrored_directories: vec![],
2832 stats: DuplicationStats::default(),
2833 };
2834
2835 let sarif = serde_json::json!({
2836 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2837 "version": "2.1.0",
2838 "runs": [{
2839 "tool": {
2840 "driver": {
2841 "name": "fallow",
2842 "version": env!("CARGO_PKG_VERSION"),
2843 "informationUri": "https://github.com/fallow-rs/fallow",
2844 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2845 }
2846 },
2847 "results": []
2848 }]
2849 });
2850 let _ = sarif;
2851
2852 let mut sarif_results = Vec::new();
2853 for (i, group) in report.clone_groups.iter().enumerate() {
2854 for instance in &group.instances {
2855 sarif_results.push(sarif_result(
2856 "fallow/code-duplication",
2857 "warning",
2858 &format!(
2859 "Code clone group {} ({} lines, {} instances)",
2860 i + 1,
2861 group.line_count,
2862 group.instances.len()
2863 ),
2864 &super::super::relative_uri(&instance.file, &root),
2865 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2866 ));
2867 }
2868 }
2869 assert_eq!(sarif_results.len(), 2);
2870 assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
2871 assert!(
2872 sarif_results[0]["message"]["text"]
2873 .as_str()
2874 .unwrap()
2875 .contains("10 lines")
2876 );
2877 let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
2878 assert_eq!(region0["startLine"], 1);
2879 assert_eq!(region0["startColumn"], 1); let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
2881 assert_eq!(region1["startLine"], 5);
2882 assert_eq!(region1["startColumn"], 3); }
2884
2885 #[test]
2886 fn sarif_rule_known_id_has_full_description() {
2887 let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
2888 assert!(rule.get("fullDescription").is_some());
2889 assert!(rule.get("helpUri").is_some());
2890 }
2891
2892 #[test]
2893 fn sarif_rule_unknown_id_uses_fallback() {
2894 let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
2895 assert_eq!(rule["shortDescription"]["text"], "fallback text");
2896 assert!(rule.get("fullDescription").is_none());
2897 assert!(rule.get("helpUri").is_none());
2898 assert_eq!(rule["defaultConfiguration"]["level"], "warning");
2899 }
2900
2901 #[test]
2902 fn sarif_result_no_region_omits_region_key() {
2903 let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
2904 let phys = &result["locations"][0]["physicalLocation"];
2905 assert!(phys.get("region").is_none());
2906 assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
2907 }
2908
2909 #[test]
2910 fn sarif_result_with_region_includes_region() {
2911 let result = sarif_result(
2912 "rule/test",
2913 "error",
2914 "test msg",
2915 "src/file.ts",
2916 Some((10, 5)),
2917 );
2918 let region = &result["locations"][0]["physicalLocation"]["region"];
2919 assert_eq!(region["startLine"], 10);
2920 assert_eq!(region["startColumn"], 5);
2921 }
2922
2923 #[test]
2924 fn sarif_partial_fingerprint_ignores_rendered_message() {
2925 let a = sarif_result(
2926 "rule/test",
2927 "error",
2928 "first message",
2929 "src/file.ts",
2930 Some((10, 5)),
2931 );
2932 let b = sarif_result(
2933 "rule/test",
2934 "error",
2935 "rewritten message",
2936 "src/file.ts",
2937 Some((10, 5)),
2938 );
2939 assert_eq!(
2940 a["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
2941 b["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
2942 );
2943 }
2944
2945 #[test]
2946 fn health_sarif_includes_refactoring_targets() {
2947 use crate::health_types::*;
2948
2949 let root = PathBuf::from("/project");
2950 let report = HealthReport {
2951 summary: HealthSummary {
2952 files_analyzed: 10,
2953 functions_analyzed: 50,
2954 ..Default::default()
2955 },
2956 targets: vec![
2957 RefactoringTarget {
2958 path: root.join("src/complex.ts"),
2959 priority: 85.0,
2960 efficiency: 42.5,
2961 recommendation: "Split high-impact file".into(),
2962 category: RecommendationCategory::SplitHighImpact,
2963 effort: EffortEstimate::Medium,
2964 confidence: Confidence::High,
2965 factors: vec![],
2966 evidence: None,
2967 }
2968 .into(),
2969 ],
2970 ..Default::default()
2971 };
2972
2973 let sarif = build_health_sarif(&report, &root);
2974 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2975 assert_eq!(entries.len(), 1);
2976 assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
2977 assert_eq!(entries[0]["level"], "warning");
2978 let msg = entries[0]["message"]["text"].as_str().unwrap();
2979 assert!(msg.contains("high impact"));
2980 assert!(msg.contains("Split high-impact file"));
2981 assert!(msg.contains("42.5"));
2982 }
2983
2984 #[test]
2985 fn health_sarif_includes_coverage_gaps() {
2986 use crate::health_types::*;
2987
2988 let root = PathBuf::from("/project");
2989 let report = HealthReport {
2990 summary: HealthSummary {
2991 files_analyzed: 10,
2992 functions_analyzed: 50,
2993 ..Default::default()
2994 },
2995 coverage_gaps: Some(CoverageGaps {
2996 summary: CoverageGapSummary {
2997 runtime_files: 2,
2998 covered_files: 0,
2999 file_coverage_pct: 0.0,
3000 untested_files: 1,
3001 untested_exports: 1,
3002 },
3003 files: vec![UntestedFileFinding::with_actions(
3004 UntestedFile {
3005 path: root.join("src/app.ts"),
3006 value_export_count: 2,
3007 },
3008 &root,
3009 )],
3010 exports: vec![UntestedExportFinding::with_actions(
3011 UntestedExport {
3012 path: root.join("src/app.ts"),
3013 export_name: "loader".into(),
3014 line: 12,
3015 col: 4,
3016 },
3017 &root,
3018 )],
3019 }),
3020 ..Default::default()
3021 };
3022
3023 let sarif = build_health_sarif(&report, &root);
3024 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3025 assert_eq!(entries.len(), 2);
3026 assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
3027 assert_eq!(
3028 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3029 "src/app.ts"
3030 );
3031 assert!(
3032 entries[0]["message"]["text"]
3033 .as_str()
3034 .unwrap()
3035 .contains("2 value exports")
3036 );
3037 assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
3038 assert_eq!(
3039 entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
3040 12
3041 );
3042 assert_eq!(
3043 entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
3044 5
3045 );
3046 }
3047
3048 #[test]
3049 fn health_sarif_rules_have_full_descriptions() {
3050 let root = PathBuf::from("/project");
3051 let report = crate::health_types::HealthReport::default();
3052 let sarif = build_health_sarif(&report, &root);
3053 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
3054 .as_array()
3055 .unwrap();
3056 for rule in rules {
3057 let id = rule["id"].as_str().unwrap();
3058 assert!(
3059 rule.get("fullDescription").is_some(),
3060 "health rule {id} should have fullDescription"
3061 );
3062 assert!(
3063 rule.get("helpUri").is_some(),
3064 "health rule {id} should have helpUri"
3065 );
3066 }
3067 }
3068
3069 #[test]
3070 fn sarif_warn_severity_produces_warning_level() {
3071 let root = PathBuf::from("/project");
3072 let mut results = AnalysisResults::default();
3073 results
3074 .unused_files
3075 .push(UnusedFileFinding::with_actions(UnusedFile {
3076 path: root.join("src/dead.ts"),
3077 }));
3078
3079 let rules = RulesConfig {
3080 unused_files: Severity::Warn,
3081 ..RulesConfig::default()
3082 };
3083
3084 let sarif = build_sarif(&results, &root, &rules);
3085 let entry = &sarif["runs"][0]["results"][0];
3086 assert_eq!(entry["level"], "warning");
3087 }
3088
3089 #[test]
3090 fn sarif_unused_file_has_no_region() {
3091 let root = PathBuf::from("/project");
3092 let mut results = AnalysisResults::default();
3093 results
3094 .unused_files
3095 .push(UnusedFileFinding::with_actions(UnusedFile {
3096 path: root.join("src/dead.ts"),
3097 }));
3098
3099 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3100 let entry = &sarif["runs"][0]["results"][0];
3101 let phys = &entry["locations"][0]["physicalLocation"];
3102 assert!(phys.get("region").is_none());
3103 }
3104
3105 #[test]
3106 fn sarif_unlisted_dep_multiple_import_sites() {
3107 let root = PathBuf::from("/project");
3108 let mut results = AnalysisResults::default();
3109 results
3110 .unlisted_dependencies
3111 .push(UnlistedDependencyFinding::with_actions(
3112 UnlistedDependency {
3113 package_name: "dotenv".to_string(),
3114 imported_from: vec![
3115 ImportSite {
3116 path: root.join("src/a.ts"),
3117 line: 1,
3118 col: 0,
3119 },
3120 ImportSite {
3121 path: root.join("src/b.ts"),
3122 line: 5,
3123 col: 0,
3124 },
3125 ],
3126 },
3127 ));
3128
3129 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3130 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3131 assert_eq!(entries.len(), 2);
3132 assert_eq!(
3133 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3134 "src/a.ts"
3135 );
3136 assert_eq!(
3137 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3138 "src/b.ts"
3139 );
3140 }
3141
3142 #[test]
3143 fn sarif_unlisted_dep_no_import_sites() {
3144 let root = PathBuf::from("/project");
3145 let mut results = AnalysisResults::default();
3146 results
3147 .unlisted_dependencies
3148 .push(UnlistedDependencyFinding::with_actions(
3149 UnlistedDependency {
3150 package_name: "phantom".to_string(),
3151 imported_from: vec![],
3152 },
3153 ));
3154
3155 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3156 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3157 assert!(entries.is_empty());
3158 }
3159}