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_complexity_sarif_results(&mut sarif_results, report, root, &mut snippets);
1426
1427 if let Some(ref production) = report.runtime_coverage {
1428 append_runtime_coverage_sarif_results(&mut sarif_results, production, root, &mut snippets);
1429 }
1430 if let Some(ref intelligence) = report.coverage_intelligence {
1431 append_coverage_intelligence_sarif_results(
1432 &mut sarif_results,
1433 intelligence,
1434 root,
1435 &mut snippets,
1436 );
1437 }
1438
1439 append_refactoring_target_sarif_results(&mut sarif_results, report, root);
1440 append_coverage_gap_sarif_results(&mut sarif_results, report, root, &mut snippets);
1441
1442 let health_rules = vec![
1443 sarif_rule(
1444 "fallow/high-cyclomatic-complexity",
1445 "Function has high cyclomatic complexity",
1446 "note",
1447 ),
1448 sarif_rule(
1449 "fallow/high-cognitive-complexity",
1450 "Function has high cognitive complexity",
1451 "note",
1452 ),
1453 sarif_rule(
1454 "fallow/high-complexity",
1455 "Function exceeds both complexity thresholds",
1456 "note",
1457 ),
1458 sarif_rule(
1459 "fallow/high-crap-score",
1460 "Function has a high CRAP score (high complexity combined with low coverage)",
1461 "warning",
1462 ),
1463 sarif_rule(
1464 "fallow/refactoring-target",
1465 "File identified as a high-priority refactoring candidate",
1466 "warning",
1467 ),
1468 sarif_rule(
1469 "fallow/untested-file",
1470 "Runtime-reachable file has no test dependency path",
1471 "warning",
1472 ),
1473 sarif_rule(
1474 "fallow/untested-export",
1475 "Runtime-reachable export has no test dependency path",
1476 "warning",
1477 ),
1478 sarif_rule(
1479 "fallow/runtime-safe-to-delete",
1480 "Function is statically unused and was never invoked in production",
1481 "warning",
1482 ),
1483 sarif_rule(
1484 "fallow/runtime-review-required",
1485 "Function is statically used but was never invoked in production",
1486 "warning",
1487 ),
1488 sarif_rule(
1489 "fallow/runtime-low-traffic",
1490 "Function was invoked below the low-traffic threshold relative to total trace count",
1491 "note",
1492 ),
1493 sarif_rule(
1494 "fallow/runtime-coverage-unavailable",
1495 "Runtime coverage could not be resolved for this function",
1496 "note",
1497 ),
1498 sarif_rule(
1499 "fallow/runtime-coverage",
1500 "Runtime coverage finding",
1501 "note",
1502 ),
1503 sarif_rule(
1504 "fallow/coverage-intelligence-risky-change",
1505 "Changed hot path combines high CRAP and low test coverage",
1506 "warning",
1507 ),
1508 sarif_rule(
1509 "fallow/coverage-intelligence-delete",
1510 "Static and runtime evidence indicate code can be deleted",
1511 "warning",
1512 ),
1513 sarif_rule(
1514 "fallow/coverage-intelligence-review",
1515 "Cold reachable uncovered code needs owner review",
1516 "warning",
1517 ),
1518 sarif_rule(
1519 "fallow/coverage-intelligence-refactor",
1520 "Hot covered code has high CRAP and should be refactored carefully",
1521 "warning",
1522 ),
1523 ];
1524
1525 serde_json::json!({
1526 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1527 "version": "2.1.0",
1528 "runs": [{
1529 "tool": {
1530 "driver": {
1531 "name": "fallow",
1532 "version": env!("CARGO_PKG_VERSION"),
1533 "informationUri": "https://github.com/fallow-rs/fallow",
1534 "rules": health_rules
1535 }
1536 },
1537 "results": sarif_results
1538 }]
1539 })
1540}
1541
1542fn append_complexity_sarif_results(
1543 sarif_results: &mut Vec<serde_json::Value>,
1544 report: &crate::health_types::HealthReport,
1545 root: &Path,
1546 snippets: &mut SourceSnippetCache,
1547) {
1548 for finding in &report.findings {
1549 let uri = relative_uri(&finding.path, root);
1550 let (rule_id, message) = health_complexity_sarif_message(finding, report);
1551 let level = match finding.severity {
1552 crate::health_types::FindingSeverity::Critical => "error",
1553 crate::health_types::FindingSeverity::High => "warning",
1554 crate::health_types::FindingSeverity::Moderate => "note",
1555 };
1556 let source_snippet = snippets.line(&finding.path, finding.line);
1557 sarif_results.push(sarif_result_with_snippet(
1558 rule_id,
1559 level,
1560 &message,
1561 &uri,
1562 Some((finding.line, finding.col + 1)),
1563 source_snippet.as_deref(),
1564 ));
1565 }
1566}
1567
1568fn health_complexity_sarif_message(
1569 finding: &crate::health_types::ComplexityViolation,
1570 report: &crate::health_types::HealthReport,
1571) -> (&'static str, String) {
1572 match finding.exceeded {
1573 crate::health_types::ExceededThreshold::Cyclomatic => (
1574 "fallow/high-cyclomatic-complexity",
1575 format!(
1576 "'{}' has cyclomatic complexity {} (threshold: {})",
1577 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
1578 ),
1579 ),
1580 crate::health_types::ExceededThreshold::Cognitive => (
1581 "fallow/high-cognitive-complexity",
1582 format!(
1583 "'{}' has cognitive complexity {} (threshold: {})",
1584 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
1585 ),
1586 ),
1587 crate::health_types::ExceededThreshold::Both => (
1588 "fallow/high-complexity",
1589 format!(
1590 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
1591 finding.name,
1592 finding.cyclomatic,
1593 report.summary.max_cyclomatic_threshold,
1594 finding.cognitive,
1595 report.summary.max_cognitive_threshold,
1596 ),
1597 ),
1598 crate::health_types::ExceededThreshold::Crap
1599 | crate::health_types::ExceededThreshold::CyclomaticCrap
1600 | crate::health_types::ExceededThreshold::CognitiveCrap
1601 | crate::health_types::ExceededThreshold::All => {
1602 let crap = finding.crap.unwrap_or(0.0);
1603 let coverage = finding
1604 .coverage_pct
1605 .map(|pct| format!(", coverage {pct:.0}%"))
1606 .unwrap_or_default();
1607 (
1608 "fallow/high-crap-score",
1609 format!(
1610 "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
1611 finding.name,
1612 crap,
1613 report.summary.max_crap_threshold,
1614 finding.cyclomatic,
1615 coverage,
1616 ),
1617 )
1618 }
1619 }
1620}
1621
1622fn append_refactoring_target_sarif_results(
1623 sarif_results: &mut Vec<serde_json::Value>,
1624 report: &crate::health_types::HealthReport,
1625 root: &Path,
1626) {
1627 for target in &report.targets {
1628 let uri = relative_uri(&target.path, root);
1629 let message = format!(
1630 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
1631 target.category.label(),
1632 target.recommendation,
1633 target.priority,
1634 target.efficiency,
1635 target.effort.label(),
1636 target.confidence.label(),
1637 );
1638 sarif_results.push(sarif_result(
1639 "fallow/refactoring-target",
1640 "warning",
1641 &message,
1642 &uri,
1643 None,
1644 ));
1645 }
1646}
1647
1648fn append_coverage_gap_sarif_results(
1649 sarif_results: &mut Vec<serde_json::Value>,
1650 report: &crate::health_types::HealthReport,
1651 root: &Path,
1652 snippets: &mut SourceSnippetCache,
1653) {
1654 let Some(ref gaps) = report.coverage_gaps else {
1655 return;
1656 };
1657 for item in &gaps.files {
1658 let uri = relative_uri(&item.file.path, root);
1659 let message = format!(
1660 "File is runtime-reachable but has no test dependency path ({} value export{})",
1661 item.file.value_export_count,
1662 if item.file.value_export_count == 1 {
1663 ""
1664 } else {
1665 "s"
1666 },
1667 );
1668 sarif_results.push(sarif_result(
1669 "fallow/untested-file",
1670 "warning",
1671 &message,
1672 &uri,
1673 None,
1674 ));
1675 }
1676
1677 for item in &gaps.exports {
1678 let uri = relative_uri(&item.export.path, root);
1679 let message = format!(
1680 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
1681 item.export.export_name
1682 );
1683 let source_snippet = snippets.line(&item.export.path, item.export.line);
1684 sarif_results.push(sarif_result_with_snippet(
1685 "fallow/untested-export",
1686 "warning",
1687 &message,
1688 &uri,
1689 Some((item.export.line, item.export.col + 1)),
1690 source_snippet.as_deref(),
1691 ));
1692 }
1693}
1694
1695fn append_runtime_coverage_sarif_results(
1696 sarif_results: &mut Vec<serde_json::Value>,
1697 production: &crate::health_types::RuntimeCoverageReport,
1698 root: &Path,
1699 snippets: &mut SourceSnippetCache,
1700) {
1701 for finding in &production.findings {
1702 let uri = relative_uri(&finding.path, root);
1703 let rule_id = match finding.verdict {
1704 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
1705 "fallow/runtime-safe-to-delete"
1706 }
1707 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
1708 "fallow/runtime-review-required"
1709 }
1710 crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
1711 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
1712 "fallow/runtime-coverage-unavailable"
1713 }
1714 crate::health_types::RuntimeCoverageVerdict::Active
1715 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
1716 };
1717 let level = match finding.verdict {
1718 crate::health_types::RuntimeCoverageVerdict::SafeToDelete
1719 | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
1720 _ => "note",
1721 };
1722 let invocations_hint = finding.invocations.map_or_else(
1723 || "untracked".to_owned(),
1724 |hits| format!("{hits} invocations"),
1725 );
1726 let message = format!(
1727 "'{}' runtime coverage verdict: {} ({})",
1728 finding.function,
1729 finding.verdict.human_label(),
1730 invocations_hint,
1731 );
1732 let source_snippet = snippets.line(&finding.path, finding.line);
1733 sarif_results.push(sarif_result_with_snippet(
1734 rule_id,
1735 level,
1736 &message,
1737 &uri,
1738 Some((finding.line, 1)),
1739 source_snippet.as_deref(),
1740 ));
1741 }
1742}
1743
1744fn append_coverage_intelligence_sarif_results(
1745 sarif_results: &mut Vec<serde_json::Value>,
1746 intelligence: &crate::health_types::CoverageIntelligenceReport,
1747 root: &Path,
1748 snippets: &mut SourceSnippetCache,
1749) {
1750 for finding in &intelligence.findings {
1751 let rule_id = coverage_intelligence_rule_id(finding.recommendation);
1752 let level = match finding.verdict {
1753 crate::health_types::CoverageIntelligenceVerdict::Clean
1754 | crate::health_types::CoverageIntelligenceVerdict::Unknown => continue,
1755 _ => "warning",
1756 };
1757 let uri = relative_uri(&finding.path, root);
1758 let identity = finding.identity.as_deref().unwrap_or("code");
1759 let signals = finding
1760 .signals
1761 .iter()
1762 .map(ToString::to_string)
1763 .collect::<Vec<_>>()
1764 .join(", ");
1765 let message = format!(
1766 "'{}' coverage intelligence verdict: {} ({}, signals: {})",
1767 identity, finding.verdict, finding.recommendation, signals,
1768 );
1769 let source_snippet = snippets.line(&finding.path, finding.line);
1770 let mut result = sarif_result_with_snippet(
1771 rule_id,
1772 level,
1773 &message,
1774 &uri,
1775 Some((finding.line, 1)),
1776 source_snippet.as_deref(),
1777 );
1778 result["properties"] = serde_json::json!({
1779 "coverage_intelligence_id": &finding.id,
1780 "verdict": finding.verdict,
1781 "recommendation": finding.recommendation,
1782 "confidence": finding.confidence,
1783 "signals": &finding.signals,
1784 "related_ids": &finding.related_ids,
1785 });
1786 sarif_results.push(result);
1787 }
1788}
1789
1790fn coverage_intelligence_rule_id(
1791 recommendation: crate::health_types::CoverageIntelligenceRecommendation,
1792) -> &'static str {
1793 match recommendation {
1794 crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
1795 "fallow/coverage-intelligence-risky-change"
1796 }
1797 crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
1798 "fallow/coverage-intelligence-delete"
1799 }
1800 crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
1801 "fallow/coverage-intelligence-review"
1802 }
1803 crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
1804 "fallow/coverage-intelligence-refactor"
1805 }
1806 }
1807}
1808
1809pub(super) fn print_health_sarif(
1810 report: &crate::health_types::HealthReport,
1811 root: &Path,
1812) -> ExitCode {
1813 let sarif = build_health_sarif(report, root);
1814 emit_json(&sarif, "SARIF")
1815}
1816
1817#[expect(
1828 clippy::expect_used,
1829 reason = "grouped health SARIF entries are JSON objects created by build_health_sarif"
1830)]
1831pub(super) fn print_grouped_health_sarif(
1832 report: &crate::health_types::HealthReport,
1833 root: &Path,
1834 resolver: &OwnershipResolver,
1835) -> ExitCode {
1836 let mut sarif = build_health_sarif(report, root);
1837
1838 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1839 for run in runs {
1840 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1841 for result in results {
1842 let uri = result
1843 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1844 .and_then(|v| v.as_str())
1845 .unwrap_or("");
1846 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1847 let group =
1848 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1849 let props = result
1850 .as_object_mut()
1851 .expect("SARIF result should be an object")
1852 .entry("properties")
1853 .or_insert_with(|| serde_json::json!({}));
1854 props
1855 .as_object_mut()
1856 .expect("properties should be an object")
1857 .insert("group".to_string(), serde_json::Value::String(group));
1858 }
1859 }
1860 }
1861 }
1862
1863 emit_json(&sarif, "SARIF")
1864}
1865
1866#[cfg(test)]
1867mod tests {
1868 use super::*;
1869 use crate::report::test_helpers::sample_results;
1870 use fallow_core::results::*;
1871 use std::path::PathBuf;
1872
1873 #[test]
1874 fn sarif_has_required_top_level_fields() {
1875 let root = PathBuf::from("/project");
1876 let results = AnalysisResults::default();
1877 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1878
1879 assert_eq!(
1880 sarif["$schema"],
1881 "https://json.schemastore.org/sarif-2.1.0.json"
1882 );
1883 assert_eq!(sarif["version"], "2.1.0");
1884 assert!(sarif["runs"].is_array());
1885 }
1886
1887 #[test]
1888 fn sarif_has_tool_driver_info() {
1889 let root = PathBuf::from("/project");
1890 let results = AnalysisResults::default();
1891 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1892
1893 let driver = &sarif["runs"][0]["tool"]["driver"];
1894 assert_eq!(driver["name"], "fallow");
1895 assert!(driver["version"].is_string());
1896 assert_eq!(
1897 driver["informationUri"],
1898 "https://github.com/fallow-rs/fallow"
1899 );
1900 }
1901
1902 #[test]
1903 fn sarif_declares_all_rules() {
1904 let root = PathBuf::from("/project");
1905 let results = AnalysisResults::default();
1906 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1907
1908 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
1909 .as_array()
1910 .expect("rules should be an array");
1911 assert_eq!(rules.len(), 26);
1912
1913 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
1914 assert!(rule_ids.contains(&"fallow/unused-file"));
1915 assert!(rule_ids.contains(&"fallow/unused-export"));
1916 assert!(rule_ids.contains(&"fallow/unused-type"));
1917 assert!(rule_ids.contains(&"fallow/private-type-leak"));
1918 assert!(rule_ids.contains(&"fallow/unused-dependency"));
1919 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
1920 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
1921 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
1922 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
1923 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
1924 assert!(rule_ids.contains(&"fallow/unused-class-member"));
1925 assert!(rule_ids.contains(&"fallow/unresolved-import"));
1926 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
1927 assert!(rule_ids.contains(&"fallow/duplicate-export"));
1928 assert!(rule_ids.contains(&"fallow/circular-dependency"));
1929 assert!(rule_ids.contains(&"fallow/re-export-cycle"));
1930 assert!(rule_ids.contains(&"fallow/boundary-violation"));
1931 assert!(rule_ids.contains(&"fallow/boundary-coverage"));
1932 assert!(rule_ids.contains(&"fallow/boundary-call-violation"));
1933 assert!(rule_ids.contains(&"fallow/policy-violation"));
1934 assert!(rule_ids.contains(&"fallow/unused-catalog-entry"));
1935 assert!(rule_ids.contains(&"fallow/empty-catalog-group"));
1936 assert!(rule_ids.contains(&"fallow/unresolved-catalog-reference"));
1937 assert!(rule_ids.contains(&"fallow/unused-dependency-override"));
1938 assert!(rule_ids.contains(&"fallow/misconfigured-dependency-override"));
1939 }
1940
1941 #[test]
1942 fn sarif_empty_results_no_results_entries() {
1943 let root = PathBuf::from("/project");
1944 let results = AnalysisResults::default();
1945 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1946
1947 let sarif_results = sarif["runs"][0]["results"]
1948 .as_array()
1949 .expect("results should be an array");
1950 assert!(sarif_results.is_empty());
1951 }
1952
1953 #[test]
1954 fn sarif_unused_file_result() {
1955 let root = PathBuf::from("/project");
1956 let mut results = AnalysisResults::default();
1957 results
1958 .unused_files
1959 .push(UnusedFileFinding::with_actions(UnusedFile {
1960 path: root.join("src/dead.ts"),
1961 }));
1962
1963 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1964 let entries = sarif["runs"][0]["results"].as_array().unwrap();
1965 assert_eq!(entries.len(), 1);
1966
1967 let entry = &entries[0];
1968 assert_eq!(entry["ruleId"], "fallow/unused-file");
1969 assert_eq!(entry["level"], "error");
1970 assert_eq!(
1971 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
1972 "src/dead.ts"
1973 );
1974 }
1975
1976 #[test]
1977 fn sarif_unused_export_includes_region() {
1978 let root = PathBuf::from("/project");
1979 let mut results = AnalysisResults::default();
1980 results
1981 .unused_exports
1982 .push(UnusedExportFinding::with_actions(UnusedExport {
1983 path: root.join("src/utils.ts"),
1984 export_name: "helperFn".to_string(),
1985 is_type_only: false,
1986 line: 10,
1987 col: 4,
1988 span_start: 120,
1989 is_re_export: false,
1990 }));
1991
1992 let sarif = build_sarif(&results, &root, &RulesConfig::default());
1993 let entry = &sarif["runs"][0]["results"][0];
1994 assert_eq!(entry["ruleId"], "fallow/unused-export");
1995
1996 let region = &entry["locations"][0]["physicalLocation"]["region"];
1997 assert_eq!(region["startLine"], 10);
1998 assert_eq!(region["startColumn"], 5);
1999 }
2000
2001 #[test]
2002 fn sarif_unresolved_import_is_error_level() {
2003 let root = PathBuf::from("/project");
2004 let mut results = AnalysisResults::default();
2005 results
2006 .unresolved_imports
2007 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
2008 path: root.join("src/app.ts"),
2009 specifier: "./missing".to_string(),
2010 line: 1,
2011 col: 0,
2012 specifier_col: 0,
2013 }));
2014
2015 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2016 let entry = &sarif["runs"][0]["results"][0];
2017 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
2018 assert_eq!(entry["level"], "error");
2019 }
2020
2021 #[test]
2022 fn sarif_unlisted_dependency_points_to_import_site() {
2023 let root = PathBuf::from("/project");
2024 let mut results = AnalysisResults::default();
2025 results
2026 .unlisted_dependencies
2027 .push(UnlistedDependencyFinding::with_actions(
2028 UnlistedDependency {
2029 package_name: "chalk".to_string(),
2030 imported_from: vec![ImportSite {
2031 path: root.join("src/cli.ts"),
2032 line: 3,
2033 col: 0,
2034 }],
2035 },
2036 ));
2037
2038 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2039 let entry = &sarif["runs"][0]["results"][0];
2040 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
2041 assert_eq!(entry["level"], "error");
2042 assert_eq!(
2043 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2044 "src/cli.ts"
2045 );
2046 let region = &entry["locations"][0]["physicalLocation"]["region"];
2047 assert_eq!(region["startLine"], 3);
2048 assert_eq!(region["startColumn"], 1);
2049 }
2050
2051 #[test]
2052 fn sarif_dependency_issues_point_to_package_json() {
2053 let root = PathBuf::from("/project");
2054 let mut results = AnalysisResults::default();
2055 results
2056 .unused_dependencies
2057 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2058 package_name: "lodash".to_string(),
2059 location: DependencyLocation::Dependencies,
2060 path: root.join("package.json"),
2061 line: 5,
2062 used_in_workspaces: Vec::new(),
2063 }));
2064 results
2065 .unused_dev_dependencies
2066 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
2067 package_name: "jest".to_string(),
2068 location: DependencyLocation::DevDependencies,
2069 path: root.join("package.json"),
2070 line: 5,
2071 used_in_workspaces: Vec::new(),
2072 }));
2073
2074 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2075 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2076 for entry in entries {
2077 assert_eq!(
2078 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2079 "package.json"
2080 );
2081 }
2082 }
2083
2084 #[test]
2085 fn sarif_duplicate_export_emits_one_result_per_location() {
2086 let root = PathBuf::from("/project");
2087 let mut results = AnalysisResults::default();
2088 results
2089 .duplicate_exports
2090 .push(DuplicateExportFinding::with_actions(DuplicateExport {
2091 export_name: "Config".to_string(),
2092 locations: vec![
2093 DuplicateLocation {
2094 path: root.join("src/a.ts"),
2095 line: 15,
2096 col: 0,
2097 },
2098 DuplicateLocation {
2099 path: root.join("src/b.ts"),
2100 line: 30,
2101 col: 0,
2102 },
2103 ],
2104 }));
2105
2106 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2107 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2108 assert_eq!(entries.len(), 2);
2109 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
2110 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
2111 assert_eq!(
2112 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2113 "src/a.ts"
2114 );
2115 assert_eq!(
2116 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2117 "src/b.ts"
2118 );
2119 }
2120
2121 #[test]
2122 fn sarif_all_issue_types_produce_results() {
2123 let root = PathBuf::from("/project");
2124 let results = sample_results(&root);
2125 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2126
2127 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2128 assert_eq!(entries.len(), results.total_issues() + 1);
2129
2130 let rule_ids: Vec<&str> = entries
2131 .iter()
2132 .map(|e| e["ruleId"].as_str().unwrap())
2133 .collect();
2134 assert!(rule_ids.contains(&"fallow/unused-file"));
2135 assert!(rule_ids.contains(&"fallow/unused-export"));
2136 assert!(rule_ids.contains(&"fallow/unused-type"));
2137 assert!(rule_ids.contains(&"fallow/unused-dependency"));
2138 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
2139 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
2140 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
2141 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
2142 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
2143 assert!(rule_ids.contains(&"fallow/unused-class-member"));
2144 assert!(rule_ids.contains(&"fallow/unresolved-import"));
2145 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
2146 assert!(rule_ids.contains(&"fallow/duplicate-export"));
2147 }
2148
2149 #[test]
2150 fn sarif_serializes_to_valid_json() {
2151 let root = PathBuf::from("/project");
2152 let results = sample_results(&root);
2153 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2154
2155 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
2156 let reparsed: serde_json::Value =
2157 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
2158 assert_eq!(reparsed, sarif);
2159 }
2160
2161 #[test]
2162 fn sarif_file_write_produces_valid_sarif() {
2163 let root = PathBuf::from("/project");
2164 let results = sample_results(&root);
2165 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2166 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
2167
2168 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
2169 let _ = std::fs::create_dir_all(&dir);
2170 let sarif_path = dir.join("results.sarif");
2171 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
2172
2173 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
2174 let parsed: serde_json::Value =
2175 serde_json::from_str(&contents).expect("file should contain valid JSON");
2176
2177 assert_eq!(parsed["version"], "2.1.0");
2178 assert_eq!(
2179 parsed["$schema"],
2180 "https://json.schemastore.org/sarif-2.1.0.json"
2181 );
2182 let sarif_results = parsed["runs"][0]["results"]
2183 .as_array()
2184 .expect("results should be an array");
2185 assert!(!sarif_results.is_empty());
2186
2187 let _ = std::fs::remove_file(&sarif_path);
2188 let _ = std::fs::remove_dir(&dir);
2189 }
2190
2191 #[test]
2192 fn health_sarif_empty_no_results() {
2193 let root = PathBuf::from("/project");
2194 let report = crate::health_types::HealthReport {
2195 summary: crate::health_types::HealthSummary {
2196 files_analyzed: 10,
2197 functions_analyzed: 50,
2198 ..Default::default()
2199 },
2200 ..Default::default()
2201 };
2202 let sarif = build_health_sarif(&report, &root);
2203 assert_eq!(sarif["version"], "2.1.0");
2204 let results = sarif["runs"][0]["results"].as_array().unwrap();
2205 assert!(results.is_empty());
2206 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2207 .as_array()
2208 .unwrap();
2209 assert_eq!(rules.len(), 16);
2210 }
2211
2212 #[test]
2213 fn health_sarif_coverage_intelligence_preserves_structured_properties() {
2214 use crate::health_types::{
2215 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2216 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2217 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2218 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2219 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2220 HealthReport, HealthSummary,
2221 };
2222
2223 let root = PathBuf::from("/project");
2224 let report = HealthReport {
2225 summary: HealthSummary {
2226 files_analyzed: 10,
2227 functions_analyzed: 50,
2228 ..Default::default()
2229 },
2230 coverage_intelligence: Some(CoverageIntelligenceReport {
2231 schema_version: CoverageIntelligenceSchemaVersion::V1,
2232 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2233 summary: CoverageIntelligenceSummary {
2234 findings: 1,
2235 high_confidence_deletes: 1,
2236 ..Default::default()
2237 },
2238 findings: vec![CoverageIntelligenceFinding {
2239 id: "fallow:coverage-intel:abc123".to_owned(),
2240 path: root.join("src/dead.ts"),
2241 identity: Some("deadPath".to_owned()),
2242 line: 9,
2243 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2244 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2245 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2246 confidence: CoverageIntelligenceConfidence::High,
2247 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2248 evidence: CoverageIntelligenceEvidence {
2249 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2250 ..Default::default()
2251 },
2252 actions: vec![CoverageIntelligenceAction {
2253 kind: "delete-after-confirming-owner".to_owned(),
2254 description: "Confirm ownership".to_owned(),
2255 auto_fixable: false,
2256 }],
2257 }],
2258 }),
2259 ..Default::default()
2260 };
2261
2262 let sarif = build_health_sarif(&report, &root);
2263 let result = &sarif["runs"][0]["results"][0];
2264 assert_eq!(result["ruleId"], "fallow/coverage-intelligence-delete");
2265 assert_eq!(
2266 result["properties"]["coverage_intelligence_id"],
2267 "fallow:coverage-intel:abc123"
2268 );
2269 assert_eq!(
2270 result["properties"]["recommendation"],
2271 "delete-after-confirming-owner"
2272 );
2273 assert_eq!(result["properties"]["confidence"], "high");
2274 assert_eq!(result["properties"]["signals"][0], "runtime_cold");
2275 assert_eq!(
2276 result["properties"]["related_ids"][0],
2277 "fallow:prod:deadbeef"
2278 );
2279 }
2280
2281 #[test]
2282 fn health_sarif_cyclomatic_only() {
2283 let root = PathBuf::from("/project");
2284 let report = crate::health_types::HealthReport {
2285 findings: vec![
2286 crate::health_types::ComplexityViolation {
2287 path: root.join("src/utils.ts"),
2288 name: "parseExpression".to_string(),
2289 line: 42,
2290 col: 0,
2291 cyclomatic: 25,
2292 cognitive: 10,
2293 line_count: 80,
2294 param_count: 0,
2295 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
2296 severity: crate::health_types::FindingSeverity::High,
2297 crap: None,
2298 coverage_pct: None,
2299 coverage_tier: None,
2300 coverage_source: None,
2301 inherited_from: None,
2302 component_rollup: None,
2303 contributions: Vec::new(),
2304 }
2305 .into(),
2306 ],
2307 summary: crate::health_types::HealthSummary {
2308 files_analyzed: 5,
2309 functions_analyzed: 20,
2310 functions_above_threshold: 1,
2311 ..Default::default()
2312 },
2313 ..Default::default()
2314 };
2315 let sarif = build_health_sarif(&report, &root);
2316 let entry = &sarif["runs"][0]["results"][0];
2317 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
2318 assert_eq!(entry["level"], "warning");
2319 assert!(
2320 entry["message"]["text"]
2321 .as_str()
2322 .unwrap()
2323 .contains("cyclomatic complexity 25")
2324 );
2325 assert_eq!(
2326 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2327 "src/utils.ts"
2328 );
2329 let region = &entry["locations"][0]["physicalLocation"]["region"];
2330 assert_eq!(region["startLine"], 42);
2331 assert_eq!(region["startColumn"], 1);
2332 }
2333
2334 #[test]
2335 fn health_sarif_cognitive_only() {
2336 let root = PathBuf::from("/project");
2337 let report = crate::health_types::HealthReport {
2338 findings: vec![
2339 crate::health_types::ComplexityViolation {
2340 path: root.join("src/api.ts"),
2341 name: "handleRequest".to_string(),
2342 line: 10,
2343 col: 4,
2344 cyclomatic: 8,
2345 cognitive: 20,
2346 line_count: 40,
2347 param_count: 0,
2348 exceeded: crate::health_types::ExceededThreshold::Cognitive,
2349 severity: crate::health_types::FindingSeverity::High,
2350 crap: None,
2351 coverage_pct: None,
2352 coverage_tier: None,
2353 coverage_source: None,
2354 inherited_from: None,
2355 component_rollup: None,
2356 contributions: Vec::new(),
2357 }
2358 .into(),
2359 ],
2360 summary: crate::health_types::HealthSummary {
2361 files_analyzed: 3,
2362 functions_analyzed: 10,
2363 functions_above_threshold: 1,
2364 ..Default::default()
2365 },
2366 ..Default::default()
2367 };
2368 let sarif = build_health_sarif(&report, &root);
2369 let entry = &sarif["runs"][0]["results"][0];
2370 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
2371 assert!(
2372 entry["message"]["text"]
2373 .as_str()
2374 .unwrap()
2375 .contains("cognitive complexity 20")
2376 );
2377 let region = &entry["locations"][0]["physicalLocation"]["region"];
2378 assert_eq!(region["startColumn"], 5); }
2380
2381 #[test]
2382 fn health_sarif_both_thresholds() {
2383 let root = PathBuf::from("/project");
2384 let report = crate::health_types::HealthReport {
2385 findings: vec![
2386 crate::health_types::ComplexityViolation {
2387 path: root.join("src/complex.ts"),
2388 name: "doEverything".to_string(),
2389 line: 1,
2390 col: 0,
2391 cyclomatic: 30,
2392 cognitive: 45,
2393 line_count: 100,
2394 param_count: 0,
2395 exceeded: crate::health_types::ExceededThreshold::Both,
2396 severity: crate::health_types::FindingSeverity::High,
2397 crap: None,
2398 coverage_pct: None,
2399 coverage_tier: None,
2400 coverage_source: None,
2401 inherited_from: None,
2402 component_rollup: None,
2403 contributions: Vec::new(),
2404 }
2405 .into(),
2406 ],
2407 summary: crate::health_types::HealthSummary {
2408 files_analyzed: 1,
2409 functions_analyzed: 1,
2410 functions_above_threshold: 1,
2411 ..Default::default()
2412 },
2413 ..Default::default()
2414 };
2415 let sarif = build_health_sarif(&report, &root);
2416 let entry = &sarif["runs"][0]["results"][0];
2417 assert_eq!(entry["ruleId"], "fallow/high-complexity");
2418 let msg = entry["message"]["text"].as_str().unwrap();
2419 assert!(msg.contains("cyclomatic complexity 30"));
2420 assert!(msg.contains("cognitive complexity 45"));
2421 }
2422
2423 #[test]
2424 fn health_sarif_crap_only_emits_crap_rule() {
2425 let root = PathBuf::from("/project");
2426 let report = crate::health_types::HealthReport {
2427 findings: vec![
2428 crate::health_types::ComplexityViolation {
2429 path: root.join("src/untested.ts"),
2430 name: "risky".to_string(),
2431 line: 8,
2432 col: 0,
2433 cyclomatic: 10,
2434 cognitive: 10,
2435 line_count: 20,
2436 param_count: 1,
2437 exceeded: crate::health_types::ExceededThreshold::Crap,
2438 severity: crate::health_types::FindingSeverity::High,
2439 crap: Some(82.2),
2440 coverage_pct: Some(12.0),
2441 coverage_tier: None,
2442 coverage_source: None,
2443 inherited_from: None,
2444 component_rollup: None,
2445 contributions: Vec::new(),
2446 }
2447 .into(),
2448 ],
2449 summary: crate::health_types::HealthSummary {
2450 files_analyzed: 1,
2451 functions_analyzed: 1,
2452 functions_above_threshold: 1,
2453 ..Default::default()
2454 },
2455 ..Default::default()
2456 };
2457 let sarif = build_health_sarif(&report, &root);
2458 let entry = &sarif["runs"][0]["results"][0];
2459 assert_eq!(entry["ruleId"], "fallow/high-crap-score");
2460 let msg = entry["message"]["text"].as_str().unwrap();
2461 assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
2462 assert!(msg.contains("coverage 12%"), "msg: {msg}");
2463 }
2464
2465 #[test]
2466 fn health_sarif_cyclomatic_crap_uses_crap_rule() {
2467 let root = PathBuf::from("/project");
2468 let report = crate::health_types::HealthReport {
2469 findings: vec![
2470 crate::health_types::ComplexityViolation {
2471 path: root.join("src/hot.ts"),
2472 name: "branchy".to_string(),
2473 line: 1,
2474 col: 0,
2475 cyclomatic: 67,
2476 cognitive: 12,
2477 line_count: 80,
2478 param_count: 1,
2479 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
2480 severity: crate::health_types::FindingSeverity::Critical,
2481 crap: Some(182.0),
2482 coverage_pct: None,
2483 coverage_tier: None,
2484 coverage_source: None,
2485 inherited_from: None,
2486 component_rollup: None,
2487 contributions: Vec::new(),
2488 }
2489 .into(),
2490 ],
2491 summary: crate::health_types::HealthSummary {
2492 files_analyzed: 1,
2493 functions_analyzed: 1,
2494 functions_above_threshold: 1,
2495 ..Default::default()
2496 },
2497 ..Default::default()
2498 };
2499 let sarif = build_health_sarif(&report, &root);
2500 let results = sarif["runs"][0]["results"].as_array().unwrap();
2501 assert_eq!(
2502 results.len(),
2503 1,
2504 "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
2505 );
2506 assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
2507 let msg = results[0]["message"]["text"].as_str().unwrap();
2508 assert!(msg.contains("CRAP score 182"), "msg: {msg}");
2509 assert!(!msg.contains("coverage"), "msg: {msg}");
2510 }
2511
2512 #[test]
2513 fn severity_to_sarif_level_error() {
2514 assert_eq!(severity_to_sarif_level(Severity::Error), "error");
2515 }
2516
2517 #[test]
2518 fn severity_to_sarif_level_warn() {
2519 assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
2520 }
2521
2522 #[test]
2523 #[should_panic(expected = "internal error: entered unreachable code")]
2524 fn severity_to_sarif_level_off() {
2525 let _ = severity_to_sarif_level(Severity::Off);
2526 }
2527
2528 #[test]
2529 fn sarif_re_export_has_properties() {
2530 let root = PathBuf::from("/project");
2531 let mut results = AnalysisResults::default();
2532 results
2533 .unused_exports
2534 .push(UnusedExportFinding::with_actions(UnusedExport {
2535 path: root.join("src/index.ts"),
2536 export_name: "reExported".to_string(),
2537 is_type_only: false,
2538 line: 1,
2539 col: 0,
2540 span_start: 0,
2541 is_re_export: true,
2542 }));
2543
2544 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2545 let entry = &sarif["runs"][0]["results"][0];
2546 assert_eq!(entry["properties"]["is_re_export"], true);
2547 let msg = entry["message"]["text"].as_str().unwrap();
2548 assert!(msg.starts_with("Re-export"));
2549 }
2550
2551 #[test]
2552 fn sarif_non_re_export_has_no_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/utils.ts"),
2559 export_name: "foo".to_string(),
2560 is_type_only: false,
2561 line: 5,
2562 col: 0,
2563 span_start: 0,
2564 is_re_export: false,
2565 }));
2566
2567 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2568 let entry = &sarif["runs"][0]["results"][0];
2569 assert!(entry.get("properties").is_none());
2570 let msg = entry["message"]["text"].as_str().unwrap();
2571 assert!(msg.starts_with("Export"));
2572 }
2573
2574 #[test]
2575 fn sarif_type_re_export_message() {
2576 let root = PathBuf::from("/project");
2577 let mut results = AnalysisResults::default();
2578 results
2579 .unused_types
2580 .push(UnusedTypeFinding::with_actions(UnusedExport {
2581 path: root.join("src/index.ts"),
2582 export_name: "MyType".to_string(),
2583 is_type_only: true,
2584 line: 1,
2585 col: 0,
2586 span_start: 0,
2587 is_re_export: true,
2588 }));
2589
2590 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2591 let entry = &sarif["runs"][0]["results"][0];
2592 assert_eq!(entry["ruleId"], "fallow/unused-type");
2593 let msg = entry["message"]["text"].as_str().unwrap();
2594 assert!(msg.starts_with("Type re-export"));
2595 assert_eq!(entry["properties"]["is_re_export"], true);
2596 }
2597
2598 #[test]
2599 fn sarif_dependency_line_zero_skips_region() {
2600 let root = PathBuf::from("/project");
2601 let mut results = AnalysisResults::default();
2602 results
2603 .unused_dependencies
2604 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2605 package_name: "lodash".to_string(),
2606 location: DependencyLocation::Dependencies,
2607 path: root.join("package.json"),
2608 line: 0,
2609 used_in_workspaces: Vec::new(),
2610 }));
2611
2612 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2613 let entry = &sarif["runs"][0]["results"][0];
2614 let phys = &entry["locations"][0]["physicalLocation"];
2615 assert!(phys.get("region").is_none());
2616 }
2617
2618 #[test]
2619 fn sarif_dependency_line_nonzero_has_region() {
2620 let root = PathBuf::from("/project");
2621 let mut results = AnalysisResults::default();
2622 results
2623 .unused_dependencies
2624 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2625 package_name: "lodash".to_string(),
2626 location: DependencyLocation::Dependencies,
2627 path: root.join("package.json"),
2628 line: 7,
2629 used_in_workspaces: Vec::new(),
2630 }));
2631
2632 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2633 let entry = &sarif["runs"][0]["results"][0];
2634 let region = &entry["locations"][0]["physicalLocation"]["region"];
2635 assert_eq!(region["startLine"], 7);
2636 assert_eq!(region["startColumn"], 1);
2637 }
2638
2639 #[test]
2640 fn sarif_type_only_dep_line_zero_skips_region() {
2641 let root = PathBuf::from("/project");
2642 let mut results = AnalysisResults::default();
2643 results
2644 .type_only_dependencies
2645 .push(TypeOnlyDependencyFinding::with_actions(
2646 TypeOnlyDependency {
2647 package_name: "zod".to_string(),
2648 path: root.join("package.json"),
2649 line: 0,
2650 },
2651 ));
2652
2653 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2654 let entry = &sarif["runs"][0]["results"][0];
2655 let phys = &entry["locations"][0]["physicalLocation"];
2656 assert!(phys.get("region").is_none());
2657 }
2658
2659 #[test]
2660 fn sarif_circular_dep_line_zero_skips_region() {
2661 let root = PathBuf::from("/project");
2662 let mut results = AnalysisResults::default();
2663 results
2664 .circular_dependencies
2665 .push(CircularDependencyFinding::with_actions(
2666 CircularDependency {
2667 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
2668 length: 2,
2669 line: 0,
2670 col: 0,
2671 edges: Vec::new(),
2672 is_cross_package: false,
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_nonzero_has_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: 5,
2693 col: 2,
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 region = &entry["locations"][0]["physicalLocation"]["region"];
2702 assert_eq!(region["startLine"], 5);
2703 assert_eq!(region["startColumn"], 3);
2704 }
2705
2706 #[test]
2707 fn sarif_unused_optional_dependency_result() {
2708 let root = PathBuf::from("/project");
2709 let mut results = AnalysisResults::default();
2710 results
2711 .unused_optional_dependencies
2712 .push(UnusedOptionalDependencyFinding::with_actions(
2713 UnusedDependency {
2714 package_name: "fsevents".to_string(),
2715 location: DependencyLocation::OptionalDependencies,
2716 path: root.join("package.json"),
2717 line: 12,
2718 used_in_workspaces: Vec::new(),
2719 },
2720 ));
2721
2722 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2723 let entry = &sarif["runs"][0]["results"][0];
2724 assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
2725 let msg = entry["message"]["text"].as_str().unwrap();
2726 assert!(msg.contains("optionalDependencies"));
2727 }
2728
2729 #[test]
2730 fn sarif_enum_member_message_format() {
2731 let root = PathBuf::from("/project");
2732 let mut results = AnalysisResults::default();
2733 results.unused_enum_members.push(
2734 fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
2735 path: root.join("src/enums.ts"),
2736 parent_name: "Color".to_string(),
2737 member_name: "Purple".to_string(),
2738 kind: fallow_core::extract::MemberKind::EnumMember,
2739 line: 5,
2740 col: 2,
2741 }),
2742 );
2743
2744 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2745 let entry = &sarif["runs"][0]["results"][0];
2746 assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
2747 let msg = entry["message"]["text"].as_str().unwrap();
2748 assert!(msg.contains("Enum member 'Color.Purple'"));
2749 let region = &entry["locations"][0]["physicalLocation"]["region"];
2750 assert_eq!(region["startColumn"], 3); }
2752
2753 #[test]
2754 fn sarif_class_member_message_format() {
2755 let root = PathBuf::from("/project");
2756 let mut results = AnalysisResults::default();
2757 results.unused_class_members.push(
2758 fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
2759 path: root.join("src/service.ts"),
2760 parent_name: "API".to_string(),
2761 member_name: "fetch".to_string(),
2762 kind: fallow_core::extract::MemberKind::ClassMethod,
2763 line: 10,
2764 col: 4,
2765 }),
2766 );
2767
2768 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2769 let entry = &sarif["runs"][0]["results"][0];
2770 assert_eq!(entry["ruleId"], "fallow/unused-class-member");
2771 let msg = entry["message"]["text"].as_str().unwrap();
2772 assert!(msg.contains("Class member 'API.fetch'"));
2773 }
2774
2775 #[test]
2776 #[expect(
2777 clippy::cast_possible_truncation,
2778 reason = "test line/col values are trivially small"
2779 )]
2780 fn duplication_sarif_structure() {
2781 use fallow_core::duplicates::*;
2782
2783 let root = PathBuf::from("/project");
2784 let report = DuplicationReport {
2785 clone_groups: vec![CloneGroup {
2786 instances: vec![
2787 CloneInstance {
2788 file: root.join("src/a.ts"),
2789 start_line: 1,
2790 end_line: 10,
2791 start_col: 0,
2792 end_col: 0,
2793 fragment: String::new(),
2794 },
2795 CloneInstance {
2796 file: root.join("src/b.ts"),
2797 start_line: 5,
2798 end_line: 14,
2799 start_col: 2,
2800 end_col: 0,
2801 fragment: String::new(),
2802 },
2803 ],
2804 token_count: 50,
2805 line_count: 10,
2806 }],
2807 clone_families: vec![],
2808 mirrored_directories: vec![],
2809 stats: DuplicationStats::default(),
2810 };
2811
2812 let sarif = serde_json::json!({
2813 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2814 "version": "2.1.0",
2815 "runs": [{
2816 "tool": {
2817 "driver": {
2818 "name": "fallow",
2819 "version": env!("CARGO_PKG_VERSION"),
2820 "informationUri": "https://github.com/fallow-rs/fallow",
2821 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2822 }
2823 },
2824 "results": []
2825 }]
2826 });
2827 let _ = sarif;
2828
2829 let mut sarif_results = Vec::new();
2830 for (i, group) in report.clone_groups.iter().enumerate() {
2831 for instance in &group.instances {
2832 sarif_results.push(sarif_result(
2833 "fallow/code-duplication",
2834 "warning",
2835 &format!(
2836 "Code clone group {} ({} lines, {} instances)",
2837 i + 1,
2838 group.line_count,
2839 group.instances.len()
2840 ),
2841 &super::super::relative_uri(&instance.file, &root),
2842 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2843 ));
2844 }
2845 }
2846 assert_eq!(sarif_results.len(), 2);
2847 assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
2848 assert!(
2849 sarif_results[0]["message"]["text"]
2850 .as_str()
2851 .unwrap()
2852 .contains("10 lines")
2853 );
2854 let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
2855 assert_eq!(region0["startLine"], 1);
2856 assert_eq!(region0["startColumn"], 1); let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
2858 assert_eq!(region1["startLine"], 5);
2859 assert_eq!(region1["startColumn"], 3); }
2861
2862 #[test]
2863 fn sarif_rule_known_id_has_full_description() {
2864 let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
2865 assert!(rule.get("fullDescription").is_some());
2866 assert!(rule.get("helpUri").is_some());
2867 }
2868
2869 #[test]
2870 fn sarif_rule_unknown_id_uses_fallback() {
2871 let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
2872 assert_eq!(rule["shortDescription"]["text"], "fallback text");
2873 assert!(rule.get("fullDescription").is_none());
2874 assert!(rule.get("helpUri").is_none());
2875 assert_eq!(rule["defaultConfiguration"]["level"], "warning");
2876 }
2877
2878 #[test]
2879 fn sarif_result_no_region_omits_region_key() {
2880 let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
2881 let phys = &result["locations"][0]["physicalLocation"];
2882 assert!(phys.get("region").is_none());
2883 assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
2884 }
2885
2886 #[test]
2887 fn sarif_result_with_region_includes_region() {
2888 let result = sarif_result(
2889 "rule/test",
2890 "error",
2891 "test msg",
2892 "src/file.ts",
2893 Some((10, 5)),
2894 );
2895 let region = &result["locations"][0]["physicalLocation"]["region"];
2896 assert_eq!(region["startLine"], 10);
2897 assert_eq!(region["startColumn"], 5);
2898 }
2899
2900 #[test]
2901 fn sarif_partial_fingerprint_ignores_rendered_message() {
2902 let a = sarif_result(
2903 "rule/test",
2904 "error",
2905 "first message",
2906 "src/file.ts",
2907 Some((10, 5)),
2908 );
2909 let b = sarif_result(
2910 "rule/test",
2911 "error",
2912 "rewritten message",
2913 "src/file.ts",
2914 Some((10, 5)),
2915 );
2916 assert_eq!(
2917 a["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
2918 b["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
2919 );
2920 }
2921
2922 #[test]
2923 fn health_sarif_includes_refactoring_targets() {
2924 use crate::health_types::*;
2925
2926 let root = PathBuf::from("/project");
2927 let report = HealthReport {
2928 summary: HealthSummary {
2929 files_analyzed: 10,
2930 functions_analyzed: 50,
2931 ..Default::default()
2932 },
2933 targets: vec![
2934 RefactoringTarget {
2935 path: root.join("src/complex.ts"),
2936 priority: 85.0,
2937 efficiency: 42.5,
2938 recommendation: "Split high-impact file".into(),
2939 category: RecommendationCategory::SplitHighImpact,
2940 effort: EffortEstimate::Medium,
2941 confidence: Confidence::High,
2942 factors: vec![],
2943 evidence: None,
2944 }
2945 .into(),
2946 ],
2947 ..Default::default()
2948 };
2949
2950 let sarif = build_health_sarif(&report, &root);
2951 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2952 assert_eq!(entries.len(), 1);
2953 assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
2954 assert_eq!(entries[0]["level"], "warning");
2955 let msg = entries[0]["message"]["text"].as_str().unwrap();
2956 assert!(msg.contains("high impact"));
2957 assert!(msg.contains("Split high-impact file"));
2958 assert!(msg.contains("42.5"));
2959 }
2960
2961 #[test]
2962 fn health_sarif_includes_coverage_gaps() {
2963 use crate::health_types::*;
2964
2965 let root = PathBuf::from("/project");
2966 let report = HealthReport {
2967 summary: HealthSummary {
2968 files_analyzed: 10,
2969 functions_analyzed: 50,
2970 ..Default::default()
2971 },
2972 coverage_gaps: Some(CoverageGaps {
2973 summary: CoverageGapSummary {
2974 runtime_files: 2,
2975 covered_files: 0,
2976 file_coverage_pct: 0.0,
2977 untested_files: 1,
2978 untested_exports: 1,
2979 },
2980 files: vec![UntestedFileFinding::with_actions(
2981 UntestedFile {
2982 path: root.join("src/app.ts"),
2983 value_export_count: 2,
2984 },
2985 &root,
2986 )],
2987 exports: vec![UntestedExportFinding::with_actions(
2988 UntestedExport {
2989 path: root.join("src/app.ts"),
2990 export_name: "loader".into(),
2991 line: 12,
2992 col: 4,
2993 },
2994 &root,
2995 )],
2996 }),
2997 ..Default::default()
2998 };
2999
3000 let sarif = build_health_sarif(&report, &root);
3001 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3002 assert_eq!(entries.len(), 2);
3003 assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
3004 assert_eq!(
3005 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3006 "src/app.ts"
3007 );
3008 assert!(
3009 entries[0]["message"]["text"]
3010 .as_str()
3011 .unwrap()
3012 .contains("2 value exports")
3013 );
3014 assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
3015 assert_eq!(
3016 entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
3017 12
3018 );
3019 assert_eq!(
3020 entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
3021 5
3022 );
3023 }
3024
3025 #[test]
3026 fn health_sarif_rules_have_full_descriptions() {
3027 let root = PathBuf::from("/project");
3028 let report = crate::health_types::HealthReport::default();
3029 let sarif = build_health_sarif(&report, &root);
3030 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
3031 .as_array()
3032 .unwrap();
3033 for rule in rules {
3034 let id = rule["id"].as_str().unwrap();
3035 assert!(
3036 rule.get("fullDescription").is_some(),
3037 "health rule {id} should have fullDescription"
3038 );
3039 assert!(
3040 rule.get("helpUri").is_some(),
3041 "health rule {id} should have helpUri"
3042 );
3043 }
3044 }
3045
3046 #[test]
3047 fn sarif_warn_severity_produces_warning_level() {
3048 let root = PathBuf::from("/project");
3049 let mut results = AnalysisResults::default();
3050 results
3051 .unused_files
3052 .push(UnusedFileFinding::with_actions(UnusedFile {
3053 path: root.join("src/dead.ts"),
3054 }));
3055
3056 let rules = RulesConfig {
3057 unused_files: Severity::Warn,
3058 ..RulesConfig::default()
3059 };
3060
3061 let sarif = build_sarif(&results, &root, &rules);
3062 let entry = &sarif["runs"][0]["results"][0];
3063 assert_eq!(entry["level"], "warning");
3064 }
3065
3066 #[test]
3067 fn sarif_unused_file_has_no_region() {
3068 let root = PathBuf::from("/project");
3069 let mut results = AnalysisResults::default();
3070 results
3071 .unused_files
3072 .push(UnusedFileFinding::with_actions(UnusedFile {
3073 path: root.join("src/dead.ts"),
3074 }));
3075
3076 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3077 let entry = &sarif["runs"][0]["results"][0];
3078 let phys = &entry["locations"][0]["physicalLocation"];
3079 assert!(phys.get("region").is_none());
3080 }
3081
3082 #[test]
3083 fn sarif_unlisted_dep_multiple_import_sites() {
3084 let root = PathBuf::from("/project");
3085 let mut results = AnalysisResults::default();
3086 results
3087 .unlisted_dependencies
3088 .push(UnlistedDependencyFinding::with_actions(
3089 UnlistedDependency {
3090 package_name: "dotenv".to_string(),
3091 imported_from: vec![
3092 ImportSite {
3093 path: root.join("src/a.ts"),
3094 line: 1,
3095 col: 0,
3096 },
3097 ImportSite {
3098 path: root.join("src/b.ts"),
3099 line: 5,
3100 col: 0,
3101 },
3102 ],
3103 },
3104 ));
3105
3106 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3107 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3108 assert_eq!(entries.len(), 2);
3109 assert_eq!(
3110 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3111 "src/a.ts"
3112 );
3113 assert_eq!(
3114 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3115 "src/b.ts"
3116 );
3117 }
3118
3119 #[test]
3120 fn sarif_unlisted_dep_no_import_sites() {
3121 let root = PathBuf::from("/project");
3122 let mut results = AnalysisResults::default();
3123 results
3124 .unlisted_dependencies
3125 .push(UnlistedDependencyFinding::with_actions(
3126 UnlistedDependency {
3127 package_name: "phantom".to_string(),
3128 imported_from: vec![],
3129 },
3130 ));
3131
3132 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3133 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3134 assert!(entries.is_empty());
3135 }
3136}