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, DuplicatePropShape, DynamicSegmentNameConflict,
9 EmptyCatalogGroupFinding, InvalidClientExport, MisconfiguredDependencyOverrideFinding,
10 MisplacedDirective, MixedClientServerBarrel, PolicyViolation, PolicyViolationSeverity,
11 PrivateTypeLeak, PropDrillingChain, RouteCollision, StaleSuppression, TestOnlyDependency,
12 ThinWrapper, TypeOnlyDependency, UnlistedDependencyFinding, UnprovidedInject,
13 UnrenderedComponent, UnresolvedCatalogReferenceFinding, UnresolvedImport,
14 UnusedCatalogEntryFinding, UnusedComponentEmit, UnusedComponentProp, UnusedDependency,
15 UnusedDependencyOverrideFinding, UnusedExport, UnusedFile, UnusedMember, UnusedServerAction,
16};
17use rustc_hash::FxHashMap;
18
19use super::ci::{fingerprint, severity};
20use super::grouping::{self, OwnershipResolver};
21use super::{emit_json, relative_uri};
22use crate::explain;
23
24struct SarifFields {
26 rule_id: &'static str,
27 level: &'static str,
28 message: String,
29 uri: String,
30 region: Option<(u32, u32)>,
31 source_path: Option<PathBuf>,
32 properties: Option<serde_json::Value>,
33}
34
35#[derive(Default)]
36struct SourceSnippetCache {
37 files: FxHashMap<PathBuf, Vec<String>>,
38}
39
40impl SourceSnippetCache {
41 fn line(&mut self, path: &Path, line: u32) -> Option<String> {
42 if line == 0 {
43 return None;
44 }
45 if !self.files.contains_key(path) {
46 let lines = std::fs::read_to_string(path)
47 .ok()
48 .map(|source| source.lines().map(str::to_owned).collect())
49 .unwrap_or_default();
50 self.files.insert(path.to_path_buf(), lines);
51 }
52 self.files
53 .get(path)
54 .and_then(|lines| lines.get(line.saturating_sub(1) as usize))
55 .cloned()
56 }
57}
58
59fn severity_to_sarif_level(s: Severity) -> &'static str {
60 severity::sarif_level(s)
61}
62
63fn configured_sarif_level(s: Severity) -> &'static str {
64 match s {
65 Severity::Error | Severity::Warn => severity_to_sarif_level(s),
66 Severity::Off => "none",
67 }
68}
69
70fn sarif_result(
75 rule_id: &str,
76 level: &str,
77 message: &str,
78 uri: &str,
79 region: Option<(u32, u32)>,
80) -> serde_json::Value {
81 sarif_result_with_snippet(rule_id, level, message, uri, region, None)
82}
83
84fn sarif_result_with_snippet(
85 rule_id: &str,
86 level: &str,
87 message: &str,
88 uri: &str,
89 region: Option<(u32, u32)>,
90 snippet: Option<&str>,
91) -> serde_json::Value {
92 let mut physical_location = serde_json::json!({
93 "artifactLocation": { "uri": uri }
94 });
95 if let Some((line, col)) = region {
96 physical_location["region"] = serde_json::json!({
97 "startLine": line,
98 "startColumn": col
99 });
100 }
101 let line = region.map_or_else(String::new, |(line, _)| line.to_string());
102 let col = region.map_or_else(String::new, |(_, col)| col.to_string());
103 let normalized_snippet = snippet
104 .map(fingerprint::normalize_snippet)
105 .filter(|snippet| !snippet.is_empty());
106 let partial_fingerprint = normalized_snippet.as_ref().map_or_else(
107 || fingerprint::fingerprint_hash(&[rule_id, uri, &line, &col]),
108 |snippet| fingerprint::finding_fingerprint(rule_id, uri, snippet),
109 );
110 let partial_fingerprint_ghas = partial_fingerprint.clone();
111 serde_json::json!({
112 "ruleId": rule_id,
113 "level": level,
114 "message": { "text": message },
115 "locations": [{ "physicalLocation": physical_location }],
116 "partialFingerprints": {
117 fingerprint::FINGERPRINT_KEY: partial_fingerprint,
118 fingerprint::GHAS_FINGERPRINT_KEY: partial_fingerprint_ghas
119 }
120 })
121}
122
123fn push_sarif_results<T>(
125 sarif_results: &mut Vec<serde_json::Value>,
126 items: &[T],
127 snippets: &mut SourceSnippetCache,
128 mut extract: impl FnMut(&T) -> SarifFields,
129) {
130 for item in items {
131 let fields = extract(item);
132 let source_snippet = fields
133 .source_path
134 .as_deref()
135 .zip(fields.region)
136 .and_then(|(path, (line, _))| snippets.line(path, line));
137 let mut result = sarif_result_with_snippet(
138 fields.rule_id,
139 fields.level,
140 &fields.message,
141 &fields.uri,
142 fields.region,
143 source_snippet.as_deref(),
144 );
145 if let Some(props) = fields.properties {
146 result["properties"] = props;
147 }
148 sarif_results.push(result);
149 }
150}
151
152fn sarif_rule(id: &str, fallback_short: &str, level: &str) -> serde_json::Value {
155 explain::rule_by_id(id).map_or_else(
156 || {
157 serde_json::json!({
158 "id": id,
159 "shortDescription": { "text": fallback_short },
160 "defaultConfiguration": { "level": level }
161 })
162 },
163 |def| {
164 serde_json::json!({
165 "id": id,
166 "shortDescription": { "text": def.short },
167 "fullDescription": { "text": def.full },
168 "helpUri": explain::rule_docs_url(def),
169 "defaultConfiguration": { "level": level }
170 })
171 },
172 )
173}
174
175fn sarif_export_fields(
177 export: &UnusedExport,
178 root: &Path,
179 rule_id: &'static str,
180 level: &'static str,
181 kind: &str,
182 re_kind: &str,
183) -> SarifFields {
184 let label = if export.is_re_export { re_kind } else { kind };
185 SarifFields {
186 rule_id,
187 level,
188 message: format!(
189 "{} '{}' is never imported by other modules",
190 label, export.export_name
191 ),
192 uri: relative_uri(&export.path, root),
193 region: Some((export.line, export.col + 1)),
194 source_path: Some(export.path.clone()),
195 properties: if export.is_re_export {
196 Some(serde_json::json!({ "is_re_export": true }))
197 } else {
198 None
199 },
200 }
201}
202
203fn sarif_private_type_leak_fields(
204 leak: &PrivateTypeLeak,
205 root: &Path,
206 level: &'static str,
207) -> SarifFields {
208 SarifFields {
209 rule_id: "fallow/private-type-leak",
210 level,
211 message: format!(
212 "Export '{}' references private type '{}'",
213 leak.export_name, leak.type_name
214 ),
215 uri: relative_uri(&leak.path, root),
216 region: Some((leak.line, leak.col + 1)),
217 source_path: Some(leak.path.clone()),
218 properties: None,
219 }
220}
221
222fn sarif_dep_fields(
224 dep: &UnusedDependency,
225 root: &Path,
226 rule_id: &'static str,
227 level: &'static str,
228 section: &str,
229) -> SarifFields {
230 let workspace_context = if dep.used_in_workspaces.is_empty() {
231 String::new()
232 } else {
233 let workspaces = dep
234 .used_in_workspaces
235 .iter()
236 .map(|path| relative_uri(path, root))
237 .collect::<Vec<_>>()
238 .join(", ");
239 format!("; imported in other workspaces: {workspaces}")
240 };
241 SarifFields {
242 rule_id,
243 level,
244 message: format!(
245 "Package '{}' is in {} but never imported{}",
246 dep.package_name, section, workspace_context
247 ),
248 uri: relative_uri(&dep.path, root),
249 region: if dep.line > 0 {
250 Some((dep.line, 1))
251 } else {
252 None
253 },
254 source_path: (dep.line > 0).then(|| dep.path.clone()),
255 properties: None,
256 }
257}
258
259fn sarif_member_fields(
261 member: &UnusedMember,
262 root: &Path,
263 rule_id: &'static str,
264 level: &'static str,
265 kind: &str,
266) -> SarifFields {
267 SarifFields {
268 rule_id,
269 level,
270 message: format!(
271 "{} member '{}.{}' is never referenced",
272 kind, member.parent_name, member.member_name
273 ),
274 uri: relative_uri(&member.path, root),
275 region: Some((member.line, member.col + 1)),
276 source_path: Some(member.path.clone()),
277 properties: None,
278 }
279}
280
281fn sarif_unused_file_fields(file: &UnusedFile, root: &Path, level: &'static str) -> SarifFields {
282 SarifFields {
283 rule_id: "fallow/unused-file",
284 level,
285 message: "File is not reachable from any entry point".to_string(),
286 uri: relative_uri(&file.path, root),
287 region: None,
288 source_path: None,
289 properties: None,
290 }
291}
292
293fn sarif_type_only_dep_fields(
294 dep: &TypeOnlyDependency,
295 root: &Path,
296 level: &'static str,
297) -> SarifFields {
298 SarifFields {
299 rule_id: "fallow/type-only-dependency",
300 level,
301 message: format!(
302 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
303 dep.package_name
304 ),
305 uri: relative_uri(&dep.path, root),
306 region: if dep.line > 0 {
307 Some((dep.line, 1))
308 } else {
309 None
310 },
311 source_path: (dep.line > 0).then(|| dep.path.clone()),
312 properties: None,
313 }
314}
315
316fn sarif_test_only_dep_fields(
317 dep: &TestOnlyDependency,
318 root: &Path,
319 level: &'static str,
320) -> SarifFields {
321 SarifFields {
322 rule_id: "fallow/test-only-dependency",
323 level,
324 message: format!(
325 "Package '{}' is only imported by test files (consider moving to devDependencies)",
326 dep.package_name
327 ),
328 uri: relative_uri(&dep.path, root),
329 region: if dep.line > 0 {
330 Some((dep.line, 1))
331 } else {
332 None
333 },
334 source_path: (dep.line > 0).then(|| dep.path.clone()),
335 properties: None,
336 }
337}
338
339fn sarif_unresolved_import_fields(
340 import: &UnresolvedImport,
341 root: &Path,
342 level: &'static str,
343) -> SarifFields {
344 SarifFields {
345 rule_id: "fallow/unresolved-import",
346 level,
347 message: format!("Import '{}' could not be resolved", import.specifier),
348 uri: relative_uri(&import.path, root),
349 region: Some((import.line, import.col + 1)),
350 source_path: Some(import.path.clone()),
351 properties: None,
352 }
353}
354
355fn sarif_circular_dep_fields(
356 cycle: &CircularDependency,
357 root: &Path,
358 level: &'static str,
359) -> SarifFields {
360 let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
361 let mut display_chain = chain.clone();
362 if let Some(first) = chain.first() {
363 display_chain.push(first.clone());
364 }
365 let first_uri = chain.first().map_or_else(String::new, Clone::clone);
366 let first_path = cycle.files.first().cloned();
367 SarifFields {
368 rule_id: "fallow/circular-dependency",
369 level,
370 message: format!(
371 "Circular dependency{}: {}",
372 if cycle.is_cross_package {
373 " (cross-package)"
374 } else {
375 ""
376 },
377 display_chain.join(" \u{2192} ")
378 ),
379 uri: first_uri,
380 region: if cycle.line > 0 {
381 Some((cycle.line, cycle.col + 1))
382 } else {
383 None
384 },
385 source_path: (cycle.line > 0).then_some(first_path).flatten(),
386 properties: None,
387 }
388}
389
390fn sarif_re_export_cycle_fields(
391 cycle: &fallow_core::results::ReExportCycle,
392 root: &Path,
393 level: &'static str,
394) -> SarifFields {
395 let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
396 let first_uri = chain.first().map_or_else(String::new, Clone::clone);
397 let first_path = cycle.files.first().cloned();
398 let kind_tag = match cycle.kind {
399 fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
400 fallow_core::results::ReExportCycleKind::MultiNode => "",
401 };
402 SarifFields {
403 rule_id: "fallow/re-export-cycle",
404 level,
405 message: format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
406 uri: first_uri,
407 region: None,
408 source_path: first_path,
409 properties: None,
410 }
411}
412
413fn sarif_boundary_violation_fields(
414 violation: &BoundaryViolation,
415 root: &Path,
416 level: &'static str,
417) -> SarifFields {
418 let from_uri = relative_uri(&violation.from_path, root);
419 let to_uri = relative_uri(&violation.to_path, root);
420 SarifFields {
421 rule_id: "fallow/boundary-violation",
422 level,
423 message: format!(
424 "Import from zone '{}' to zone '{}' is not allowed ({})",
425 violation.from_zone, violation.to_zone, to_uri,
426 ),
427 uri: from_uri,
428 region: if violation.line > 0 {
429 Some((violation.line, violation.col + 1))
430 } else {
431 None
432 },
433 source_path: (violation.line > 0).then(|| violation.from_path.clone()),
434 properties: None,
435 }
436}
437
438fn sarif_boundary_coverage_fields(
439 violation: &BoundaryCoverageViolation,
440 root: &Path,
441 level: &'static str,
442) -> SarifFields {
443 SarifFields {
444 rule_id: "fallow/boundary-coverage",
445 level,
446 message: "File does not match any configured architecture boundary zone".to_string(),
447 uri: relative_uri(&violation.path, root),
448 region: Some((violation.line, violation.col + 1)),
449 source_path: Some(violation.path.clone()),
450 properties: None,
451 }
452}
453
454fn sarif_boundary_call_fields(
455 violation: &BoundaryCallViolation,
456 root: &Path,
457 level: &'static str,
458) -> SarifFields {
459 SarifFields {
460 rule_id: "fallow/boundary-call-violation",
461 level,
462 message: format!(
463 "Call to `{}` matches forbidden pattern `{}` in zone '{}'",
464 violation.callee, violation.pattern, violation.zone
465 ),
466 uri: relative_uri(&violation.path, root),
467 region: Some((violation.line, violation.col + 1)),
468 source_path: Some(violation.path.clone()),
469 properties: None,
470 }
471}
472
473fn sarif_policy_violation_fields(violation: &PolicyViolation, root: &Path) -> SarifFields {
474 let level = match violation.severity {
475 PolicyViolationSeverity::Error => "error",
476 PolicyViolationSeverity::Warn => "warning",
477 };
478 let message = match &violation.message {
479 Some(message) => format!(
480 "Policy violation `{}/{}`: `{}` is banned. {message}",
481 violation.pack, violation.rule_id, violation.matched
482 ),
483 None => format!(
484 "Policy violation `{}/{}`: `{}` is banned",
485 violation.pack, violation.rule_id, violation.matched
486 ),
487 };
488 SarifFields {
489 rule_id: "fallow/policy-violation",
490 level,
491 message,
492 uri: relative_uri(&violation.path, root),
493 region: Some((violation.line, violation.col + 1)),
494 source_path: Some(violation.path.clone()),
495 properties: Some(serde_json::json!({
501 "policyRule": format!("{}/{}", violation.pack, violation.rule_id),
502 })),
503 }
504}
505
506fn sarif_invalid_client_export_fields(
507 export: &InvalidClientExport,
508 root: &Path,
509 level: &'static str,
510) -> SarifFields {
511 SarifFields {
512 rule_id: "fallow/invalid-client-export",
513 level,
514 message: format!(
515 "Export '{}' is not allowed in a \"{}\" file (Next.js server-only / route-config name)",
516 export.export_name, export.directive
517 ),
518 uri: relative_uri(&export.path, root),
519 region: Some((export.line, export.col + 1)),
520 source_path: Some(export.path.clone()),
521 properties: None,
522 }
523}
524
525fn sarif_mixed_client_server_barrel_fields(
526 barrel: &MixedClientServerBarrel,
527 root: &Path,
528 level: &'static str,
529) -> SarifFields {
530 SarifFields {
531 rule_id: "fallow/mixed-client-server-barrel",
532 level,
533 message: format!(
534 "Barrel re-exports both a \"use client\" module ('{}') and a server-only module ('{}'); one import drags the other's directive across the boundary",
535 barrel.client_origin, barrel.server_origin
536 ),
537 uri: relative_uri(&barrel.path, root),
538 region: Some((barrel.line, barrel.col + 1)),
539 source_path: Some(barrel.path.clone()),
540 properties: None,
541 }
542}
543
544fn sarif_misplaced_directive_fields(
545 directive_site: &MisplacedDirective,
546 root: &Path,
547 level: &'static str,
548) -> SarifFields {
549 SarifFields {
550 rule_id: "fallow/misplaced-directive",
551 level,
552 message: format!(
553 "Directive \"{}\" is not in the leading position, so the RSC bundler ignores it; move it to the top of the file",
554 directive_site.directive
555 ),
556 uri: relative_uri(&directive_site.path, root),
557 region: Some((directive_site.line, directive_site.col + 1)),
558 source_path: Some(directive_site.path.clone()),
559 properties: None,
560 }
561}
562
563fn sarif_unprovided_inject_fields(
564 inject: &UnprovidedInject,
565 root: &Path,
566 level: &'static str,
567) -> SarifFields {
568 SarifFields {
569 rule_id: "fallow/unprovided-inject",
570 level,
571 message: format!(
572 "inject(\"{}\") has no matching provide(\"{}\") in this project; at runtime it returns undefined; provide the key or remove this inject",
573 inject.key_name, inject.key_name
574 ),
575 uri: relative_uri(&inject.path, root),
576 region: Some((inject.line, inject.col + 1)),
577 source_path: Some(inject.path.clone()),
578 properties: None,
579 }
580}
581
582fn sarif_unrendered_component_fields(
583 component: &UnrenderedComponent,
584 root: &Path,
585 level: &'static str,
586) -> SarifFields {
587 SarifFields {
588 rule_id: "fallow/unrendered-component",
589 level,
590 message: format!(
591 "component \"{}\" is reachable but rendered nowhere in this project; render it somewhere or remove it",
592 component.component_name
593 ),
594 uri: relative_uri(&component.path, root),
595 region: Some((component.line, component.col + 1)),
596 source_path: Some(component.path.clone()),
597 properties: None,
598 }
599}
600
601fn sarif_unused_component_prop_fields(
602 prop: &UnusedComponentProp,
603 root: &Path,
604 level: &'static str,
605) -> SarifFields {
606 SarifFields {
607 rule_id: "fallow/unused-component-prop",
608 level,
609 message: format!(
610 "prop \"{}\" is declared but referenced nowhere inside component \"{}\"; remove it or use it",
611 prop.prop_name, prop.component_name
612 ),
613 uri: relative_uri(&prop.path, root),
614 region: Some((prop.line, prop.col + 1)),
615 source_path: Some(prop.path.clone()),
616 properties: None,
617 }
618}
619
620fn sarif_unused_component_emit_fields(
621 emit: &UnusedComponentEmit,
622 root: &Path,
623 level: &'static str,
624) -> SarifFields {
625 SarifFields {
626 rule_id: "fallow/unused-component-emit",
627 level,
628 message: format!(
629 "emit \"{}\" is declared but emitted nowhere inside component \"{}\"; remove it or emit it",
630 emit.emit_name, emit.component_name
631 ),
632 uri: relative_uri(&emit.path, root),
633 region: Some((emit.line, emit.col + 1)),
634 source_path: Some(emit.path.clone()),
635 properties: None,
636 }
637}
638
639fn sarif_unused_server_action_fields(
640 action: &UnusedServerAction,
641 root: &Path,
642 level: &'static str,
643) -> SarifFields {
644 SarifFields {
645 rule_id: "fallow/unused-server-action",
646 level,
647 message: format!(
648 "server action \"{}\" is exported from a \"use server\" file but no code in this project references it; wire it to a consumer or remove it",
649 action.action_name
650 ),
651 uri: relative_uri(&action.path, root),
652 region: Some((action.line, action.col + 1)),
653 source_path: Some(action.path.clone()),
654 properties: None,
655 }
656}
657
658fn sarif_unused_load_data_key_fields(
659 key: &fallow_core::results::UnusedLoadDataKey,
660 root: &Path,
661 level: &'static str,
662) -> SarifFields {
663 SarifFields {
664 rule_id: "fallow/unused-load-data-key",
665 level,
666 message: format!(
667 "load() return key \"{}\" is read by no consumer (sibling +page.svelte data.<key> or project-wide page.data.<key>); delete the key or wire a consumer",
668 key.key_name
669 ),
670 uri: relative_uri(&key.path, root),
671 region: Some((key.line, key.col + 1)),
672 source_path: Some(key.path.clone()),
673 properties: None,
674 }
675}
676
677fn sarif_prop_drilling_fields(
678 chain: &PropDrillingChain,
679 root: &Path,
680 level: &'static str,
681) -> SarifFields {
682 let source = chain.hops.first();
685 let consumer = chain.hops.last();
686 let (path, line) = source.map_or((std::path::PathBuf::new(), 1), |h| (h.file.clone(), h.line));
687 let consumer_name = consumer.map_or("a distant component", |h| h.component.as_str());
688 SarifFields {
689 rule_id: "fallow/prop-drilling",
690 level,
691 message: format!(
692 "prop \"{}\" is forwarded unchanged through {} component(s) before \"{}\" consumes it; colocate, lift to context, or compose",
693 chain.prop, chain.depth, consumer_name
694 ),
695 uri: relative_uri(&path, root),
696 region: Some((line, 1)),
697 source_path: Some(path),
698 properties: None,
699 }
700}
701
702fn sarif_thin_wrapper_fields(
703 wrapper: &ThinWrapper,
704 root: &Path,
705 level: &'static str,
706) -> SarifFields {
707 SarifFields {
708 rule_id: "fallow/thin-wrapper",
709 level,
710 message: format!(
711 "\"{}\" is a thin wrapper: its whole body forwards props to \"{}\"; inline it at call sites or delete it",
712 wrapper.component, wrapper.child_component
713 ),
714 uri: relative_uri(&wrapper.file, root),
715 region: Some((wrapper.line, 1)),
716 source_path: Some(wrapper.file.clone()),
717 properties: None,
718 }
719}
720
721fn sarif_duplicate_prop_shape_fields(
722 shape: &DuplicatePropShape,
723 root: &Path,
724 level: &'static str,
725) -> SarifFields {
726 SarifFields {
727 rule_id: "fallow/duplicate-prop-shape",
728 level,
729 message: format!(
730 "\"{}\" shares an identical prop shape {{{}}} with {} other component(s); extract a shared Props type or base component",
731 shape.component,
732 shape.shape.join(", "),
733 shape.group_size.saturating_sub(1)
734 ),
735 uri: relative_uri(&shape.file, root),
736 region: Some((shape.line, 1)),
737 source_path: Some(shape.file.clone()),
738 properties: None,
739 }
740}
741
742fn sarif_route_collision_fields(
743 collision: &RouteCollision,
744 root: &Path,
745 level: &'static str,
746) -> SarifFields {
747 SarifFields {
748 rule_id: "fallow/route-collision",
749 level,
750 message: format!(
751 "Route file resolves to '{}', which is also owned by {} other file(s); Next.js fails the build because a URL can have only one owner",
752 collision.url,
753 collision.conflicting_paths.len()
754 ),
755 uri: relative_uri(&collision.path, root),
756 region: Some((collision.line, collision.col + 1)),
757 source_path: Some(collision.path.clone()),
758 properties: None,
759 }
760}
761
762fn sarif_dynamic_segment_name_conflict_fields(
763 conflict: &DynamicSegmentNameConflict,
764 root: &Path,
765 level: &'static str,
766) -> SarifFields {
767 SarifFields {
768 rule_id: "fallow/dynamic-segment-name-conflict",
769 level,
770 message: format!(
771 "Dynamic segments at '{}' use different slug names ({}); Next.js requires one consistent name per dynamic path",
772 conflict.position,
773 conflict.conflicting_segments.join(", ")
774 ),
775 uri: relative_uri(&conflict.path, root),
776 region: Some((conflict.line, conflict.col + 1)),
777 source_path: Some(conflict.path.clone()),
778 properties: None,
779 }
780}
781
782fn sarif_stale_suppression_fields(
783 suppression: &StaleSuppression,
784 root: &Path,
785 level: &'static str,
786) -> SarifFields {
787 SarifFields {
788 rule_id: "fallow/stale-suppression",
789 level,
790 message: suppression.display_message(),
791 uri: relative_uri(&suppression.path, root),
792 region: Some((suppression.line, suppression.col + 1)),
793 source_path: Some(suppression.path.clone()),
794 properties: None,
795 }
796}
797
798fn sarif_unused_catalog_entry_fields(
799 entry: &UnusedCatalogEntryFinding,
800 root: &Path,
801 level: &'static str,
802) -> SarifFields {
803 let entry = &entry.entry;
804 let message = if entry.catalog_name == "default" {
805 format!(
806 "Catalog entry '{}' is not referenced by any workspace package",
807 entry.entry_name
808 )
809 } else {
810 format!(
811 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
812 entry.entry_name, entry.catalog_name
813 )
814 };
815 SarifFields {
816 rule_id: "fallow/unused-catalog-entry",
817 level,
818 message,
819 uri: relative_uri(&entry.path, root),
820 region: Some((entry.line, 1)),
821 source_path: Some(entry.path.clone()),
822 properties: None,
823 }
824}
825
826fn sarif_unused_dependency_override_fields(
827 finding: &UnusedDependencyOverrideFinding,
828 root: &Path,
829 level: &'static str,
830) -> SarifFields {
831 let finding = &finding.entry;
832 let mut message = format!(
833 "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
834 finding.raw_key, finding.version_range, finding.target_package,
835 );
836 if let Some(hint) = &finding.hint {
837 use std::fmt::Write as _;
838 let _ = write!(message, " ({hint})");
839 }
840 SarifFields {
841 rule_id: "fallow/unused-dependency-override",
842 level,
843 message,
844 uri: relative_uri(&finding.path, root),
845 region: Some((finding.line, 1)),
846 source_path: Some(finding.path.clone()),
847 properties: None,
848 }
849}
850
851fn sarif_misconfigured_dependency_override_fields(
852 finding: &MisconfiguredDependencyOverrideFinding,
853 root: &Path,
854 level: &'static str,
855) -> SarifFields {
856 let finding = &finding.entry;
857 let message = format!(
858 "Override `{}` -> `{}` is malformed: {}",
859 finding.raw_key,
860 finding.raw_value,
861 finding.reason.describe(),
862 );
863 SarifFields {
864 rule_id: "fallow/misconfigured-dependency-override",
865 level,
866 message,
867 uri: relative_uri(&finding.path, root),
868 region: Some((finding.line, 1)),
869 source_path: Some(finding.path.clone()),
870 properties: None,
871 }
872}
873
874fn sarif_unresolved_catalog_reference_fields(
875 finding: &UnresolvedCatalogReferenceFinding,
876 root: &Path,
877 level: &'static str,
878) -> SarifFields {
879 let finding = &finding.reference;
880 let catalog_phrase = if finding.catalog_name == "default" {
881 "the default catalog".to_string()
882 } else {
883 format!("catalog '{}'", finding.catalog_name)
884 };
885 let mut message = format!(
886 "Package '{}' is referenced via `catalog:{}` but {} does not declare it",
887 finding.entry_name,
888 if finding.catalog_name == "default" {
889 ""
890 } else {
891 finding.catalog_name.as_str()
892 },
893 catalog_phrase,
894 );
895 if !finding.available_in_catalogs.is_empty() {
896 use std::fmt::Write as _;
897 let _ = write!(
898 message,
899 " (available in: {})",
900 finding.available_in_catalogs.join(", ")
901 );
902 }
903 SarifFields {
904 rule_id: "fallow/unresolved-catalog-reference",
905 level,
906 message,
907 uri: relative_uri(&finding.path, root),
908 region: Some((finding.line, 1)),
909 source_path: Some(finding.path.clone()),
910 properties: None,
911 }
912}
913
914fn sarif_empty_catalog_group_fields(
915 group: &EmptyCatalogGroupFinding,
916 root: &Path,
917 level: &'static str,
918) -> SarifFields {
919 let group = &group.group;
920 SarifFields {
921 rule_id: "fallow/empty-catalog-group",
922 level,
923 message: format!("Catalog group '{}' has no entries", group.catalog_name),
924 uri: relative_uri(&group.path, root),
925 region: Some((group.line, 1)),
926 source_path: Some(group.path.clone()),
927 properties: None,
928 }
929}
930
931fn push_sarif_unlisted_deps(
934 sarif_results: &mut Vec<serde_json::Value>,
935 deps: &[UnlistedDependencyFinding],
936 root: &Path,
937 level: &'static str,
938 snippets: &mut SourceSnippetCache,
939) {
940 for entry in deps {
941 let dep = &entry.dep;
942 for site in &dep.imported_from {
943 let uri = relative_uri(&site.path, root);
944 let source_snippet = snippets.line(&site.path, site.line);
945 sarif_results.push(sarif_result_with_snippet(
946 "fallow/unlisted-dependency",
947 level,
948 &format!(
949 "Package '{}' is imported but not listed in package.json",
950 dep.package_name
951 ),
952 &uri,
953 Some((site.line, site.col + 1)),
954 source_snippet.as_deref(),
955 ));
956 }
957 }
958}
959
960fn push_sarif_duplicate_exports(
963 sarif_results: &mut Vec<serde_json::Value>,
964 dups: &[DuplicateExportFinding],
965 root: &Path,
966 level: &'static str,
967 snippets: &mut SourceSnippetCache,
968) {
969 for dup in dups {
970 let dup = &dup.export;
971 for loc in &dup.locations {
972 let uri = relative_uri(&loc.path, root);
973 let source_snippet = snippets.line(&loc.path, loc.line);
974 sarif_results.push(sarif_result_with_snippet(
975 "fallow/duplicate-export",
976 level,
977 &format!("Export '{}' appears in multiple modules", dup.export_name),
978 &uri,
979 Some((loc.line, loc.col + 1)),
980 source_snippet.as_deref(),
981 ));
982 }
983 }
984}
985
986fn build_sarif_rules(rules: &RulesConfig) -> Vec<serde_json::Value> {
988 let mut specs = Vec::new();
989 specs.extend(sarif_core_rule_specs(rules));
990 specs.extend(sarif_dependency_rule_specs(rules));
991 specs.extend(sarif_member_import_rule_specs(rules));
992 specs.extend(sarif_graph_rule_specs(rules));
993 specs.extend(sarif_workspace_rule_specs(rules));
994 specs
995 .into_iter()
996 .map(|(id, description, rule_severity)| {
997 sarif_rule(id, description, configured_sarif_level(rule_severity))
998 })
999 .collect()
1000}
1001
1002type SarifRuleSpec = (&'static str, &'static str, Severity);
1003
1004fn sarif_core_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1005 [
1006 (
1007 "fallow/unused-file",
1008 "File is not reachable from any entry point",
1009 rules.unused_files,
1010 ),
1011 (
1012 "fallow/unused-export",
1013 "Export is never imported",
1014 rules.unused_exports,
1015 ),
1016 (
1017 "fallow/unused-type",
1018 "Type export is never imported",
1019 rules.unused_types,
1020 ),
1021 (
1022 "fallow/private-type-leak",
1023 "Exported signature references a same-file private type",
1024 rules.private_type_leaks,
1025 ),
1026 ]
1027 .into()
1028}
1029
1030fn sarif_dependency_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1031 [
1032 (
1033 "fallow/unused-dependency",
1034 "Dependency listed but never imported",
1035 rules.unused_dependencies,
1036 ),
1037 (
1038 "fallow/unused-dev-dependency",
1039 "Dev dependency listed but never imported",
1040 rules.unused_dev_dependencies,
1041 ),
1042 (
1043 "fallow/unused-optional-dependency",
1044 "Optional dependency listed but never imported",
1045 rules.unused_optional_dependencies,
1046 ),
1047 (
1048 "fallow/type-only-dependency",
1049 "Production dependency only used via type-only imports",
1050 rules.type_only_dependencies,
1051 ),
1052 (
1053 "fallow/test-only-dependency",
1054 "Production dependency only imported by test files",
1055 rules.test_only_dependencies,
1056 ),
1057 ]
1058 .into()
1059}
1060
1061fn sarif_member_import_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1062 [
1063 (
1064 "fallow/unused-enum-member",
1065 "Enum member is never referenced",
1066 rules.unused_enum_members,
1067 ),
1068 (
1069 "fallow/unused-class-member",
1070 "Class member is never referenced",
1071 rules.unused_class_members,
1072 ),
1073 (
1074 "fallow/unused-store-member",
1075 "Store member is never referenced",
1076 rules.unused_store_members,
1077 ),
1078 (
1079 "fallow/unresolved-import",
1080 "Import could not be resolved",
1081 rules.unresolved_imports,
1082 ),
1083 (
1084 "fallow/unlisted-dependency",
1085 "Dependency used but not in package.json",
1086 rules.unlisted_dependencies,
1087 ),
1088 (
1089 "fallow/duplicate-export",
1090 "Export name appears in multiple modules",
1091 rules.duplicate_exports,
1092 ),
1093 ]
1094 .into()
1095}
1096
1097fn sarif_graph_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1098 let mut specs = sarif_cycle_rule_specs(rules);
1099 specs.extend(sarif_boundary_rule_specs(rules));
1100 specs.extend(sarif_framework_rule_specs(rules));
1101 specs.extend(sarif_component_rule_specs(rules));
1102 specs.push((
1103 "fallow/stale-suppression",
1104 "Suppression comment or tag no longer matches any issue",
1105 rules.stale_suppressions,
1106 ));
1107 specs
1108}
1109
1110fn sarif_cycle_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1111 vec![
1112 (
1113 "fallow/circular-dependency",
1114 "Circular dependency chain detected",
1115 rules.circular_dependencies,
1116 ),
1117 (
1118 "fallow/re-export-cycle",
1119 "Two or more barrel files re-export from each other in a loop",
1120 rules.re_export_cycle,
1121 ),
1122 ]
1123}
1124
1125fn sarif_boundary_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1126 vec![
1127 (
1128 "fallow/boundary-violation",
1129 "Import crosses an architecture boundary",
1130 rules.boundary_violation,
1131 ),
1132 (
1133 "fallow/boundary-coverage",
1134 "Source file matches no architecture boundary zone",
1135 rules.boundary_violation,
1136 ),
1137 (
1138 "fallow/boundary-call-violation",
1139 "Zoned file calls a callee its zone forbids",
1140 rules.boundary_violation,
1141 ),
1142 (
1143 "fallow/policy-violation",
1144 "Banned call or import matched a rule-pack rule",
1145 rules.policy_violation,
1146 ),
1147 ]
1148}
1149
1150fn sarif_framework_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1151 vec![
1152 (
1153 "fallow/invalid-client-export",
1154 "\"use client\" file exports a server-only / route-config name",
1155 rules.invalid_client_export,
1156 ),
1157 (
1158 "fallow/mixed-client-server-barrel",
1159 "Barrel re-exports both a \"use client\" module and a server-only module",
1160 rules.mixed_client_server_barrel,
1161 ),
1162 (
1163 "fallow/misplaced-directive",
1164 "\"use client\" / \"use server\" directive is not in the leading position and is ignored",
1165 rules.misplaced_directive,
1166 ),
1167 ]
1168}
1169
1170fn sarif_component_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1171 vec![
1172 (
1173 "fallow/unprovided-inject",
1174 "A Vue inject / Svelte getContext whose key is provided nowhere in the project",
1175 rules.unprovided_injects,
1176 ),
1177 (
1178 "fallow/unrendered-component",
1179 "A Vue / Svelte component reachable through a barrel but rendered nowhere in the project",
1180 rules.unrendered_components,
1181 ),
1182 (
1183 "fallow/unused-component-prop",
1184 "A Vue <script setup> defineProps prop referenced nowhere inside its own component",
1185 rules.unused_component_props,
1186 ),
1187 (
1188 "fallow/unused-component-emit",
1189 "A Vue <script setup> defineEmits event emitted nowhere inside its own component",
1190 rules.unused_component_emits,
1191 ),
1192 (
1193 "fallow/unused-server-action",
1194 "A Next.js Server Action exported from a \"use server\" file that no code in the project references",
1195 rules.unused_server_actions,
1196 ),
1197 (
1198 "fallow/unused-load-data-key",
1199 "A SvelteKit load() return-object key that no consumer reads (sibling +page.svelte data.<key> or project-wide page.data.<key>)",
1200 rules.unused_load_data_keys,
1201 ),
1202 (
1203 "fallow/prop-drilling",
1204 "A React/Preact prop forwarded unchanged through 3+ pass-through components to a distant consumer",
1205 rules.prop_drilling,
1206 ),
1207 (
1208 "fallow/thin-wrapper",
1209 "A React/Preact component whose whole body is a single spread-forwarded child render (a candidate for inlining)",
1210 rules.thin_wrapper,
1211 ),
1212 (
1213 "fallow/duplicate-prop-shape",
1214 "Three or more React/Preact components across two or more files declare an identical prop-name set (a missing shared Props type)",
1215 rules.duplicate_prop_shape,
1216 ),
1217 (
1218 "fallow/route-collision",
1219 "Two or more Next.js App Router route files resolve to the same URL",
1220 rules.route_collision,
1221 ),
1222 (
1223 "fallow/dynamic-segment-name-conflict",
1224 "Sibling Next.js dynamic route segments use different slug names at the same position",
1225 rules.dynamic_segment_name_conflict,
1226 ),
1227 ]
1228}
1229
1230fn sarif_workspace_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1231 [
1232 (
1233 "fallow/unused-catalog-entry",
1234 "pnpm catalog entry not referenced by any workspace package",
1235 rules.unused_catalog_entries,
1236 ),
1237 (
1238 "fallow/empty-catalog-group",
1239 "pnpm named catalog group has no entries",
1240 rules.empty_catalog_groups,
1241 ),
1242 (
1243 "fallow/unresolved-catalog-reference",
1244 "package.json catalog reference points at a catalog that does not declare the package",
1245 rules.unresolved_catalog_references,
1246 ),
1247 (
1248 "fallow/unused-dependency-override",
1249 "pnpm dependency override target is not declared or lockfile-resolved",
1250 rules.unused_dependency_overrides,
1251 ),
1252 (
1253 "fallow/misconfigured-dependency-override",
1254 "pnpm dependency override key or value is malformed",
1255 rules.misconfigured_dependency_overrides,
1256 ),
1257 ]
1258 .into()
1259}
1260
1261#[must_use]
1262pub fn build_sarif(
1263 results: &AnalysisResults,
1264 root: &Path,
1265 rules: &RulesConfig,
1266) -> serde_json::Value {
1267 let mut sarif_results = Vec::new();
1268 let mut snippets = SourceSnippetCache::default();
1269
1270 push_primary_dead_code_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1271 push_dependency_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1272 push_member_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1273 push_sarif_results(
1274 &mut sarif_results,
1275 &results.unresolved_imports,
1276 &mut snippets,
1277 |i| {
1278 sarif_unresolved_import_fields(
1279 &i.import,
1280 root,
1281 severity_to_sarif_level(rules.unresolved_imports),
1282 )
1283 },
1284 );
1285 push_misc_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1286 push_graph_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1287 push_catalog_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1288
1289 let sarif_rules = build_sarif_rules(rules);
1290 sarif_document(&sarif_results, &sarif_rules)
1291}
1292
1293fn push_primary_dead_code_sarif_results(
1294 sarif_results: &mut Vec<serde_json::Value>,
1295 results: &AnalysisResults,
1296 root: &Path,
1297 rules: &RulesConfig,
1298 snippets: &mut SourceSnippetCache,
1299) {
1300 push_sarif_results(sarif_results, &results.unused_files, snippets, |finding| {
1301 sarif_unused_file_fields(
1302 &finding.file,
1303 root,
1304 severity_to_sarif_level(rules.unused_files),
1305 )
1306 });
1307 push_sarif_results(
1308 sarif_results,
1309 &results.unused_exports,
1310 snippets,
1311 |finding| {
1312 sarif_export_fields(
1313 &finding.export,
1314 root,
1315 "fallow/unused-export",
1316 severity_to_sarif_level(rules.unused_exports),
1317 "Export",
1318 "Re-export",
1319 )
1320 },
1321 );
1322 push_sarif_results(sarif_results, &results.unused_types, snippets, |finding| {
1323 sarif_export_fields(
1324 &finding.export,
1325 root,
1326 "fallow/unused-type",
1327 severity_to_sarif_level(rules.unused_types),
1328 "Type export",
1329 "Type re-export",
1330 )
1331 });
1332 push_sarif_results(
1333 sarif_results,
1334 &results.private_type_leaks,
1335 snippets,
1336 |finding| {
1337 sarif_private_type_leak_fields(
1338 &finding.leak,
1339 root,
1340 severity_to_sarif_level(rules.private_type_leaks),
1341 )
1342 },
1343 );
1344}
1345
1346fn sarif_document(
1347 sarif_results: &[serde_json::Value],
1348 sarif_rules: &[serde_json::Value],
1349) -> serde_json::Value {
1350 serde_json::json!({
1351 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1352 "version": "2.1.0",
1353 "runs": [{
1354 "tool": {
1355 "driver": {
1356 "name": "fallow",
1357 "version": env!("CARGO_PKG_VERSION"),
1358 "informationUri": "https://github.com/fallow-rs/fallow",
1359 "rules": sarif_rules
1360 }
1361 },
1362 "results": sarif_results
1363 }]
1364 })
1365}
1366
1367fn push_dependency_sarif_results(
1368 sarif_results: &mut Vec<serde_json::Value>,
1369 results: &AnalysisResults,
1370 root: &Path,
1371 rules: &RulesConfig,
1372 snippets: &mut SourceSnippetCache,
1373) {
1374 push_sarif_results(sarif_results, &results.unused_dependencies, snippets, |d| {
1375 sarif_dep_fields(
1376 &d.dep,
1377 root,
1378 "fallow/unused-dependency",
1379 severity_to_sarif_level(rules.unused_dependencies),
1380 "dependencies",
1381 )
1382 });
1383 push_sarif_results(
1384 sarif_results,
1385 &results.unused_dev_dependencies,
1386 snippets,
1387 |d| {
1388 sarif_dep_fields(
1389 &d.dep,
1390 root,
1391 "fallow/unused-dev-dependency",
1392 severity_to_sarif_level(rules.unused_dev_dependencies),
1393 "devDependencies",
1394 )
1395 },
1396 );
1397 push_sarif_results(
1398 sarif_results,
1399 &results.unused_optional_dependencies,
1400 snippets,
1401 |d| {
1402 sarif_dep_fields(
1403 &d.dep,
1404 root,
1405 "fallow/unused-optional-dependency",
1406 severity_to_sarif_level(rules.unused_optional_dependencies),
1407 "optionalDependencies",
1408 )
1409 },
1410 );
1411 push_sarif_results(
1412 sarif_results,
1413 &results.type_only_dependencies,
1414 snippets,
1415 |d| {
1416 sarif_type_only_dep_fields(
1417 &d.dep,
1418 root,
1419 severity_to_sarif_level(rules.type_only_dependencies),
1420 )
1421 },
1422 );
1423 push_sarif_results(
1424 sarif_results,
1425 &results.test_only_dependencies,
1426 snippets,
1427 |d| {
1428 sarif_test_only_dep_fields(
1429 &d.dep,
1430 root,
1431 severity_to_sarif_level(rules.test_only_dependencies),
1432 )
1433 },
1434 );
1435}
1436
1437fn push_member_sarif_results(
1438 sarif_results: &mut Vec<serde_json::Value>,
1439 results: &AnalysisResults,
1440 root: &Path,
1441 rules: &RulesConfig,
1442 snippets: &mut SourceSnippetCache,
1443) {
1444 push_sarif_results(sarif_results, &results.unused_enum_members, snippets, |m| {
1445 sarif_member_fields(
1446 &m.member,
1447 root,
1448 "fallow/unused-enum-member",
1449 severity_to_sarif_level(rules.unused_enum_members),
1450 "Enum",
1451 )
1452 });
1453 push_sarif_results(
1454 sarif_results,
1455 &results.unused_class_members,
1456 snippets,
1457 |m| {
1458 sarif_member_fields(
1459 &m.member,
1460 root,
1461 "fallow/unused-class-member",
1462 severity_to_sarif_level(rules.unused_class_members),
1463 "Class",
1464 )
1465 },
1466 );
1467 push_sarif_results(
1468 sarif_results,
1469 &results.unused_store_members,
1470 snippets,
1471 |m| {
1472 sarif_member_fields(
1473 &m.member,
1474 root,
1475 "fallow/unused-store-member",
1476 severity_to_sarif_level(rules.unused_store_members),
1477 "Store",
1478 )
1479 },
1480 );
1481}
1482
1483fn push_misc_sarif_results(
1484 sarif_results: &mut Vec<serde_json::Value>,
1485 results: &AnalysisResults,
1486 root: &Path,
1487 rules: &RulesConfig,
1488 snippets: &mut SourceSnippetCache,
1489) {
1490 if !results.unlisted_dependencies.is_empty() {
1491 push_sarif_unlisted_deps(
1492 sarif_results,
1493 &results.unlisted_dependencies,
1494 root,
1495 severity_to_sarif_level(rules.unlisted_dependencies),
1496 snippets,
1497 );
1498 }
1499 if !results.duplicate_exports.is_empty() {
1500 push_sarif_duplicate_exports(
1501 sarif_results,
1502 &results.duplicate_exports,
1503 root,
1504 severity_to_sarif_level(rules.duplicate_exports),
1505 snippets,
1506 );
1507 }
1508}
1509
1510fn push_component_contract_sarif_results(
1514 sarif_results: &mut Vec<serde_json::Value>,
1515 results: &AnalysisResults,
1516 root: &Path,
1517 rules: &RulesConfig,
1518 snippets: &mut SourceSnippetCache,
1519) {
1520 push_sarif_results(
1521 sarif_results,
1522 &results.unused_component_props,
1523 snippets,
1524 |p| {
1525 sarif_unused_component_prop_fields(
1526 &p.prop,
1527 root,
1528 severity_to_sarif_level(rules.unused_component_props),
1529 )
1530 },
1531 );
1532 push_sarif_results(
1533 sarif_results,
1534 &results.unused_component_emits,
1535 snippets,
1536 |e| {
1537 sarif_unused_component_emit_fields(
1538 &e.emit,
1539 root,
1540 severity_to_sarif_level(rules.unused_component_emits),
1541 )
1542 },
1543 );
1544 push_sarif_results(
1545 sarif_results,
1546 &results.unused_server_actions,
1547 snippets,
1548 |a| {
1549 sarif_unused_server_action_fields(
1550 &a.action,
1551 root,
1552 severity_to_sarif_level(rules.unused_server_actions),
1553 )
1554 },
1555 );
1556 push_sarif_results(
1557 sarif_results,
1558 &results.unused_load_data_keys,
1559 snippets,
1560 |k| {
1561 sarif_unused_load_data_key_fields(
1562 &k.key,
1563 root,
1564 severity_to_sarif_level(rules.unused_load_data_keys),
1565 )
1566 },
1567 );
1568 push_sarif_results(
1569 sarif_results,
1570 &results.prop_drilling_chains,
1571 snippets,
1572 |c| {
1573 sarif_prop_drilling_fields(&c.chain, root, severity_to_sarif_level(rules.prop_drilling))
1574 },
1575 );
1576 push_sarif_results(sarif_results, &results.thin_wrappers, snippets, |w| {
1577 sarif_thin_wrapper_fields(
1578 &w.wrapper,
1579 root,
1580 severity_to_sarif_level(rules.thin_wrapper),
1581 )
1582 });
1583 push_sarif_results(
1584 sarif_results,
1585 &results.duplicate_prop_shapes,
1586 snippets,
1587 |d| {
1588 sarif_duplicate_prop_shape_fields(
1589 &d.shape,
1590 root,
1591 severity_to_sarif_level(rules.duplicate_prop_shape),
1592 )
1593 },
1594 );
1595}
1596
1597fn push_graph_sarif_results(
1598 sarif_results: &mut Vec<serde_json::Value>,
1599 results: &AnalysisResults,
1600 root: &Path,
1601 rules: &RulesConfig,
1602 snippets: &mut SourceSnippetCache,
1603) {
1604 push_structure_sarif_results(sarif_results, results, root, rules, snippets);
1605 push_framework_sarif_results(sarif_results, results, root, rules, snippets);
1606 push_route_sarif_results(sarif_results, results, root, rules, snippets);
1607 push_suppression_sarif_results(sarif_results, results, root, rules, snippets);
1608}
1609
1610fn push_structure_sarif_results(
1611 sarif_results: &mut Vec<serde_json::Value>,
1612 results: &AnalysisResults,
1613 root: &Path,
1614 rules: &RulesConfig,
1615 snippets: &mut SourceSnippetCache,
1616) {
1617 push_sarif_results(
1618 sarif_results,
1619 &results.circular_dependencies,
1620 snippets,
1621 |c| {
1622 sarif_circular_dep_fields(
1623 &c.cycle,
1624 root,
1625 severity_to_sarif_level(rules.circular_dependencies),
1626 )
1627 },
1628 );
1629 push_sarif_results(sarif_results, &results.re_export_cycles, snippets, |c| {
1630 sarif_re_export_cycle_fields(
1631 &c.cycle,
1632 root,
1633 severity_to_sarif_level(rules.re_export_cycle),
1634 )
1635 });
1636 push_sarif_results(sarif_results, &results.boundary_violations, snippets, |v| {
1637 sarif_boundary_violation_fields(
1638 &v.violation,
1639 root,
1640 severity_to_sarif_level(rules.boundary_violation),
1641 )
1642 });
1643 push_sarif_results(
1644 sarif_results,
1645 &results.boundary_coverage_violations,
1646 snippets,
1647 |v| {
1648 sarif_boundary_coverage_fields(
1649 &v.violation,
1650 root,
1651 severity_to_sarif_level(rules.boundary_violation),
1652 )
1653 },
1654 );
1655 push_sarif_results(
1656 sarif_results,
1657 &results.boundary_call_violations,
1658 snippets,
1659 |v| {
1660 sarif_boundary_call_fields(
1661 &v.violation,
1662 root,
1663 severity_to_sarif_level(rules.boundary_violation),
1664 )
1665 },
1666 );
1667 push_sarif_results(sarif_results, &results.policy_violations, snippets, |v| {
1668 sarif_policy_violation_fields(&v.violation, root)
1669 });
1670}
1671
1672fn push_framework_sarif_results(
1673 sarif_results: &mut Vec<serde_json::Value>,
1674 results: &AnalysisResults,
1675 root: &Path,
1676 rules: &RulesConfig,
1677 snippets: &mut SourceSnippetCache,
1678) {
1679 push_sarif_results(
1680 sarif_results,
1681 &results.invalid_client_exports,
1682 snippets,
1683 |e| {
1684 sarif_invalid_client_export_fields(
1685 &e.export,
1686 root,
1687 severity_to_sarif_level(rules.invalid_client_export),
1688 )
1689 },
1690 );
1691 push_sarif_results(
1692 sarif_results,
1693 &results.mixed_client_server_barrels,
1694 snippets,
1695 |b| {
1696 sarif_mixed_client_server_barrel_fields(
1697 &b.barrel,
1698 root,
1699 severity_to_sarif_level(rules.mixed_client_server_barrel),
1700 )
1701 },
1702 );
1703 push_sarif_results(
1704 sarif_results,
1705 &results.misplaced_directives,
1706 snippets,
1707 |d| {
1708 sarif_misplaced_directive_fields(
1709 &d.directive_site,
1710 root,
1711 severity_to_sarif_level(rules.misplaced_directive),
1712 )
1713 },
1714 );
1715 push_sarif_results(sarif_results, &results.unprovided_injects, snippets, |i| {
1716 sarif_unprovided_inject_fields(
1717 &i.inject,
1718 root,
1719 severity_to_sarif_level(rules.unprovided_injects),
1720 )
1721 });
1722 push_sarif_results(
1723 sarif_results,
1724 &results.unrendered_components,
1725 snippets,
1726 |c| {
1727 sarif_unrendered_component_fields(
1728 &c.component,
1729 root,
1730 severity_to_sarif_level(rules.unrendered_components),
1731 )
1732 },
1733 );
1734 push_component_contract_sarif_results(sarif_results, results, root, rules, snippets);
1735}
1736
1737fn push_route_sarif_results(
1738 sarif_results: &mut Vec<serde_json::Value>,
1739 results: &AnalysisResults,
1740 root: &Path,
1741 rules: &RulesConfig,
1742 snippets: &mut SourceSnippetCache,
1743) {
1744 push_sarif_results(sarif_results, &results.route_collisions, snippets, |c| {
1745 sarif_route_collision_fields(
1746 &c.collision,
1747 root,
1748 severity_to_sarif_level(rules.route_collision),
1749 )
1750 });
1751 push_sarif_results(
1752 sarif_results,
1753 &results.dynamic_segment_name_conflicts,
1754 snippets,
1755 |c| {
1756 sarif_dynamic_segment_name_conflict_fields(
1757 &c.conflict,
1758 root,
1759 severity_to_sarif_level(rules.dynamic_segment_name_conflict),
1760 )
1761 },
1762 );
1763}
1764
1765fn push_suppression_sarif_results(
1766 sarif_results: &mut Vec<serde_json::Value>,
1767 results: &AnalysisResults,
1768 root: &Path,
1769 rules: &RulesConfig,
1770 snippets: &mut SourceSnippetCache,
1771) {
1772 push_sarif_results(sarif_results, &results.stale_suppressions, snippets, |s| {
1773 sarif_stale_suppression_fields(s, root, severity_to_sarif_level(rules.stale_suppressions))
1774 });
1775}
1776
1777fn push_catalog_sarif_results(
1778 sarif_results: &mut Vec<serde_json::Value>,
1779 results: &AnalysisResults,
1780 root: &Path,
1781 rules: &RulesConfig,
1782 snippets: &mut SourceSnippetCache,
1783) {
1784 push_sarif_results(
1785 sarif_results,
1786 &results.unused_catalog_entries,
1787 snippets,
1788 |e| {
1789 sarif_unused_catalog_entry_fields(
1790 e,
1791 root,
1792 severity_to_sarif_level(rules.unused_catalog_entries),
1793 )
1794 },
1795 );
1796 push_sarif_results(
1797 sarif_results,
1798 &results.empty_catalog_groups,
1799 snippets,
1800 |g| {
1801 sarif_empty_catalog_group_fields(
1802 g,
1803 root,
1804 severity_to_sarif_level(rules.empty_catalog_groups),
1805 )
1806 },
1807 );
1808 push_sarif_results(
1809 sarif_results,
1810 &results.unresolved_catalog_references,
1811 snippets,
1812 |f| {
1813 sarif_unresolved_catalog_reference_fields(
1814 f,
1815 root,
1816 severity_to_sarif_level(rules.unresolved_catalog_references),
1817 )
1818 },
1819 );
1820 push_sarif_results(
1821 sarif_results,
1822 &results.unused_dependency_overrides,
1823 snippets,
1824 |f| {
1825 sarif_unused_dependency_override_fields(
1826 f,
1827 root,
1828 severity_to_sarif_level(rules.unused_dependency_overrides),
1829 )
1830 },
1831 );
1832 push_sarif_results(
1833 sarif_results,
1834 &results.misconfigured_dependency_overrides,
1835 snippets,
1836 |f| {
1837 sarif_misconfigured_dependency_override_fields(
1838 f,
1839 root,
1840 severity_to_sarif_level(rules.misconfigured_dependency_overrides),
1841 )
1842 },
1843 );
1844}
1845
1846pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
1847 let sarif = build_sarif(results, root, rules);
1848 emit_json(&sarif, "SARIF")
1849}
1850
1851#[expect(
1857 clippy::expect_used,
1858 reason = "grouped SARIF entries are JSON objects created by build_sarif"
1859)]
1860pub(super) fn print_grouped_sarif(
1861 results: &AnalysisResults,
1862 root: &Path,
1863 rules: &RulesConfig,
1864 resolver: &OwnershipResolver,
1865) -> ExitCode {
1866 let mut sarif = build_sarif(results, root, rules);
1867
1868 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
1869 for run in runs {
1870 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
1871 for result in results {
1872 let uri = result
1873 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
1874 .and_then(|v| v.as_str())
1875 .unwrap_or("");
1876 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
1877 let owner =
1878 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
1879 let props = result
1880 .as_object_mut()
1881 .expect("SARIF result should be an object")
1882 .entry("properties")
1883 .or_insert_with(|| serde_json::json!({}));
1884 props
1885 .as_object_mut()
1886 .expect("properties should be an object")
1887 .insert("owner".to_string(), serde_json::Value::String(owner));
1888 }
1889 }
1890 }
1891 }
1892
1893 emit_json(&sarif, "SARIF")
1894}
1895
1896#[expect(
1897 clippy::cast_possible_truncation,
1898 reason = "line/col numbers are bounded by source size"
1899)]
1900pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
1901 let mut sarif_results = Vec::new();
1902 let mut snippets = SourceSnippetCache::default();
1903
1904 for (i, group) in report.clone_groups.iter().enumerate() {
1905 for instance in &group.instances {
1906 let uri = relative_uri(&instance.file, root);
1907 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1908 sarif_results.push(sarif_result_with_snippet(
1909 "fallow/code-duplication",
1910 "warning",
1911 &format!(
1912 "Code clone group {} ({} lines, {} instances)",
1913 i + 1,
1914 group.line_count,
1915 group.instances.len()
1916 ),
1917 &uri,
1918 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1919 source_snippet.as_deref(),
1920 ));
1921 }
1922 }
1923
1924 let sarif = serde_json::json!({
1925 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1926 "version": "2.1.0",
1927 "runs": [{
1928 "tool": {
1929 "driver": {
1930 "name": "fallow",
1931 "version": env!("CARGO_PKG_VERSION"),
1932 "informationUri": "https://github.com/fallow-rs/fallow",
1933 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
1934 }
1935 },
1936 "results": sarif_results
1937 }]
1938 });
1939
1940 emit_json(&sarif, "SARIF")
1941}
1942
1943#[expect(
1954 clippy::cast_possible_truncation,
1955 reason = "line/col numbers are bounded by source size"
1956)]
1957#[expect(
1958 clippy::expect_used,
1959 reason = "duplication SARIF entries are JSON objects created by sarif_result_with_snippet"
1960)]
1961pub(super) fn print_grouped_duplication_sarif(
1962 report: &DuplicationReport,
1963 root: &Path,
1964 resolver: &OwnershipResolver,
1965) -> ExitCode {
1966 let mut sarif_results = Vec::new();
1967 let mut snippets = SourceSnippetCache::default();
1968
1969 for (i, group) in report.clone_groups.iter().enumerate() {
1970 let primary_owner = super::dupes_grouping::largest_owner(group, root, resolver);
1971 for instance in &group.instances {
1972 let uri = relative_uri(&instance.file, root);
1973 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
1974 let mut result = sarif_result_with_snippet(
1975 "fallow/code-duplication",
1976 "warning",
1977 &format!(
1978 "Code clone group {} ({} lines, {} instances)",
1979 i + 1,
1980 group.line_count,
1981 group.instances.len()
1982 ),
1983 &uri,
1984 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
1985 source_snippet.as_deref(),
1986 );
1987 let props = result
1988 .as_object_mut()
1989 .expect("SARIF result should be an object")
1990 .entry("properties")
1991 .or_insert_with(|| serde_json::json!({}));
1992 props
1993 .as_object_mut()
1994 .expect("properties should be an object")
1995 .insert(
1996 "group".to_string(),
1997 serde_json::Value::String(primary_owner.clone()),
1998 );
1999 sarif_results.push(result);
2000 }
2001 }
2002
2003 let sarif = serde_json::json!({
2004 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2005 "version": "2.1.0",
2006 "runs": [{
2007 "tool": {
2008 "driver": {
2009 "name": "fallow",
2010 "version": env!("CARGO_PKG_VERSION"),
2011 "informationUri": "https://github.com/fallow-rs/fallow",
2012 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2013 }
2014 },
2015 "results": sarif_results
2016 }]
2017 });
2018
2019 emit_json(&sarif, "SARIF")
2020}
2021
2022#[must_use]
2023pub fn build_health_sarif(
2024 report: &crate::health_types::HealthReport,
2025 root: &Path,
2026) -> serde_json::Value {
2027 let mut sarif_results = Vec::new();
2028 let mut snippets = SourceSnippetCache::default();
2029
2030 append_health_sarif_results(report, root, &mut sarif_results, &mut snippets);
2031 let health_rules = health_sarif_rules();
2032 health_sarif_document(&sarif_results, &health_rules)
2033}
2034
2035fn append_health_sarif_results(
2036 report: &crate::health_types::HealthReport,
2037 root: &Path,
2038 sarif_results: &mut Vec<serde_json::Value>,
2039 snippets: &mut SourceSnippetCache,
2040) {
2041 append_complexity_sarif_results(sarif_results, report, root, snippets);
2042
2043 if let Some(ref production) = report.runtime_coverage {
2044 append_runtime_coverage_sarif_results(sarif_results, production, root, snippets);
2045 }
2046 if let Some(ref intelligence) = report.coverage_intelligence {
2047 append_coverage_intelligence_sarif_results(sarif_results, intelligence, root, snippets);
2048 }
2049
2050 append_refactoring_target_sarif_results(sarif_results, report, root);
2051 append_coverage_gap_sarif_results(sarif_results, report, root, snippets);
2052}
2053
2054fn health_sarif_rules() -> Vec<serde_json::Value> {
2055 let mut rules = health_complexity_sarif_rules();
2056 rules.extend(health_runtime_sarif_rules());
2057 rules.extend(health_coverage_intelligence_sarif_rules());
2058 rules
2059}
2060
2061fn health_complexity_sarif_rules() -> Vec<serde_json::Value> {
2062 vec![
2063 sarif_rule(
2064 "fallow/high-cyclomatic-complexity",
2065 "Function has high cyclomatic complexity",
2066 "note",
2067 ),
2068 sarif_rule(
2069 "fallow/high-cognitive-complexity",
2070 "Function has high cognitive complexity",
2071 "note",
2072 ),
2073 sarif_rule(
2074 "fallow/high-complexity",
2075 "Function exceeds both complexity thresholds",
2076 "note",
2077 ),
2078 sarif_rule(
2079 "fallow/high-crap-score",
2080 "Function has a high CRAP score (high complexity combined with low coverage)",
2081 "warning",
2082 ),
2083 sarif_rule(
2084 "fallow/refactoring-target",
2085 "File identified as a high-priority refactoring candidate",
2086 "warning",
2087 ),
2088 ]
2089}
2090
2091fn health_runtime_sarif_rules() -> Vec<serde_json::Value> {
2092 vec![
2093 sarif_rule(
2094 "fallow/untested-file",
2095 "Runtime-reachable file has no test dependency path",
2096 "warning",
2097 ),
2098 sarif_rule(
2099 "fallow/untested-export",
2100 "Runtime-reachable export has no test dependency path",
2101 "warning",
2102 ),
2103 sarif_rule(
2104 "fallow/runtime-safe-to-delete",
2105 "Function is statically unused and was never invoked in production",
2106 "warning",
2107 ),
2108 sarif_rule(
2109 "fallow/runtime-review-required",
2110 "Function is statically used but was never invoked in production",
2111 "warning",
2112 ),
2113 sarif_rule(
2114 "fallow/runtime-low-traffic",
2115 "Function was invoked below the low-traffic threshold relative to total trace count",
2116 "note",
2117 ),
2118 sarif_rule(
2119 "fallow/runtime-coverage-unavailable",
2120 "Runtime coverage could not be resolved for this function",
2121 "note",
2122 ),
2123 sarif_rule(
2124 "fallow/runtime-coverage",
2125 "Runtime coverage finding",
2126 "note",
2127 ),
2128 ]
2129}
2130
2131fn health_coverage_intelligence_sarif_rules() -> Vec<serde_json::Value> {
2132 vec![
2133 sarif_rule(
2134 "fallow/coverage-intelligence-risky-change",
2135 "Changed hot path combines high CRAP and low test coverage",
2136 "warning",
2137 ),
2138 sarif_rule(
2139 "fallow/coverage-intelligence-delete",
2140 "Static and runtime evidence indicate code can be deleted",
2141 "warning",
2142 ),
2143 sarif_rule(
2144 "fallow/coverage-intelligence-review",
2145 "Cold reachable uncovered code needs owner review",
2146 "warning",
2147 ),
2148 sarif_rule(
2149 "fallow/coverage-intelligence-refactor",
2150 "Hot covered code has high CRAP and should be refactored carefully",
2151 "warning",
2152 ),
2153 ]
2154}
2155
2156fn health_sarif_document(
2157 sarif_results: &[serde_json::Value],
2158 health_rules: &[serde_json::Value],
2159) -> serde_json::Value {
2160 serde_json::json!({
2161 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2162 "version": "2.1.0",
2163 "runs": [{
2164 "tool": {
2165 "driver": {
2166 "name": "fallow",
2167 "version": env!("CARGO_PKG_VERSION"),
2168 "informationUri": "https://github.com/fallow-rs/fallow",
2169 "rules": health_rules
2170 }
2171 },
2172 "results": sarif_results
2173 }]
2174 })
2175}
2176
2177fn append_complexity_sarif_results(
2178 sarif_results: &mut Vec<serde_json::Value>,
2179 report: &crate::health_types::HealthReport,
2180 root: &Path,
2181 snippets: &mut SourceSnippetCache,
2182) {
2183 for finding in &report.findings {
2184 let uri = relative_uri(&finding.path, root);
2185 let (rule_id, message) = health_complexity_sarif_message(finding, report);
2186 let level = match finding.severity {
2187 crate::health_types::FindingSeverity::Critical => "error",
2188 crate::health_types::FindingSeverity::High => "warning",
2189 crate::health_types::FindingSeverity::Moderate => "note",
2190 };
2191 let source_snippet = snippets.line(&finding.path, finding.line);
2192 sarif_results.push(sarif_result_with_snippet(
2193 rule_id,
2194 level,
2195 &message,
2196 &uri,
2197 Some((finding.line, finding.col + 1)),
2198 source_snippet.as_deref(),
2199 ));
2200 }
2201}
2202
2203fn health_complexity_sarif_message(
2204 finding: &crate::health_types::ComplexityViolation,
2205 report: &crate::health_types::HealthReport,
2206) -> (&'static str, String) {
2207 match finding.exceeded {
2208 crate::health_types::ExceededThreshold::Cyclomatic => (
2209 "fallow/high-cyclomatic-complexity",
2210 format!(
2211 "'{}' has cyclomatic complexity {} (threshold: {})",
2212 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
2213 ),
2214 ),
2215 crate::health_types::ExceededThreshold::Cognitive => (
2216 "fallow/high-cognitive-complexity",
2217 format!(
2218 "'{}' has cognitive complexity {} (threshold: {})",
2219 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
2220 ),
2221 ),
2222 crate::health_types::ExceededThreshold::Both => (
2223 "fallow/high-complexity",
2224 format!(
2225 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
2226 finding.name,
2227 finding.cyclomatic,
2228 report.summary.max_cyclomatic_threshold,
2229 finding.cognitive,
2230 report.summary.max_cognitive_threshold,
2231 ),
2232 ),
2233 crate::health_types::ExceededThreshold::Crap
2234 | crate::health_types::ExceededThreshold::CyclomaticCrap
2235 | crate::health_types::ExceededThreshold::CognitiveCrap
2236 | crate::health_types::ExceededThreshold::All => {
2237 let crap = finding.crap.unwrap_or(0.0);
2238 let coverage = finding
2239 .coverage_pct
2240 .map(|pct| format!(", coverage {pct:.0}%"))
2241 .unwrap_or_default();
2242 (
2243 "fallow/high-crap-score",
2244 format!(
2245 "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
2246 finding.name,
2247 crap,
2248 report.summary.max_crap_threshold,
2249 finding.cyclomatic,
2250 coverage,
2251 ),
2252 )
2253 }
2254 }
2255}
2256
2257fn append_refactoring_target_sarif_results(
2258 sarif_results: &mut Vec<serde_json::Value>,
2259 report: &crate::health_types::HealthReport,
2260 root: &Path,
2261) {
2262 for target in &report.targets {
2263 let uri = relative_uri(&target.path, root);
2264 let message = format!(
2265 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
2266 target.category.label(),
2267 target.recommendation,
2268 target.priority,
2269 target.efficiency,
2270 target.effort.label(),
2271 target.confidence.label(),
2272 );
2273 sarif_results.push(sarif_result(
2274 "fallow/refactoring-target",
2275 "warning",
2276 &message,
2277 &uri,
2278 None,
2279 ));
2280 }
2281}
2282
2283fn append_coverage_gap_sarif_results(
2284 sarif_results: &mut Vec<serde_json::Value>,
2285 report: &crate::health_types::HealthReport,
2286 root: &Path,
2287 snippets: &mut SourceSnippetCache,
2288) {
2289 let Some(ref gaps) = report.coverage_gaps else {
2290 return;
2291 };
2292 for item in &gaps.files {
2293 let uri = relative_uri(&item.file.path, root);
2294 let message = format!(
2295 "File is runtime-reachable but has no test dependency path ({} value export{})",
2296 item.file.value_export_count,
2297 if item.file.value_export_count == 1 {
2298 ""
2299 } else {
2300 "s"
2301 },
2302 );
2303 sarif_results.push(sarif_result(
2304 "fallow/untested-file",
2305 "warning",
2306 &message,
2307 &uri,
2308 None,
2309 ));
2310 }
2311
2312 for item in &gaps.exports {
2313 let uri = relative_uri(&item.export.path, root);
2314 let message = format!(
2315 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
2316 item.export.export_name
2317 );
2318 let source_snippet = snippets.line(&item.export.path, item.export.line);
2319 sarif_results.push(sarif_result_with_snippet(
2320 "fallow/untested-export",
2321 "warning",
2322 &message,
2323 &uri,
2324 Some((item.export.line, item.export.col + 1)),
2325 source_snippet.as_deref(),
2326 ));
2327 }
2328}
2329
2330fn append_runtime_coverage_sarif_results(
2331 sarif_results: &mut Vec<serde_json::Value>,
2332 production: &crate::health_types::RuntimeCoverageReport,
2333 root: &Path,
2334 snippets: &mut SourceSnippetCache,
2335) {
2336 for finding in &production.findings {
2337 let uri = relative_uri(&finding.path, root);
2338 let rule_id = match finding.verdict {
2339 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
2340 "fallow/runtime-safe-to-delete"
2341 }
2342 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
2343 "fallow/runtime-review-required"
2344 }
2345 crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
2346 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
2347 "fallow/runtime-coverage-unavailable"
2348 }
2349 crate::health_types::RuntimeCoverageVerdict::Active
2350 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
2351 };
2352 let level = match finding.verdict {
2353 crate::health_types::RuntimeCoverageVerdict::SafeToDelete
2354 | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
2355 _ => "note",
2356 };
2357 let invocations_hint = finding.invocations.map_or_else(
2358 || "untracked".to_owned(),
2359 |hits| format!("{hits} invocations"),
2360 );
2361 let message = format!(
2362 "'{}' runtime coverage verdict: {} ({})",
2363 finding.function,
2364 finding.verdict.human_label(),
2365 invocations_hint,
2366 );
2367 let source_snippet = snippets.line(&finding.path, finding.line);
2368 sarif_results.push(sarif_result_with_snippet(
2369 rule_id,
2370 level,
2371 &message,
2372 &uri,
2373 Some((finding.line, 1)),
2374 source_snippet.as_deref(),
2375 ));
2376 }
2377}
2378
2379fn append_coverage_intelligence_sarif_results(
2380 sarif_results: &mut Vec<serde_json::Value>,
2381 intelligence: &crate::health_types::CoverageIntelligenceReport,
2382 root: &Path,
2383 snippets: &mut SourceSnippetCache,
2384) {
2385 for finding in &intelligence.findings {
2386 let rule_id = coverage_intelligence_rule_id(finding.recommendation);
2387 let level = match finding.verdict {
2388 crate::health_types::CoverageIntelligenceVerdict::Clean
2389 | crate::health_types::CoverageIntelligenceVerdict::Unknown => continue,
2390 _ => "warning",
2391 };
2392 let uri = relative_uri(&finding.path, root);
2393 let identity = finding.identity.as_deref().unwrap_or("code");
2394 let signals = finding
2395 .signals
2396 .iter()
2397 .map(ToString::to_string)
2398 .collect::<Vec<_>>()
2399 .join(", ");
2400 let message = format!(
2401 "'{}' coverage intelligence verdict: {} ({}, signals: {})",
2402 identity, finding.verdict, finding.recommendation, signals,
2403 );
2404 let source_snippet = snippets.line(&finding.path, finding.line);
2405 let mut result = sarif_result_with_snippet(
2406 rule_id,
2407 level,
2408 &message,
2409 &uri,
2410 Some((finding.line, 1)),
2411 source_snippet.as_deref(),
2412 );
2413 result["properties"] = serde_json::json!({
2414 "coverage_intelligence_id": &finding.id,
2415 "verdict": finding.verdict,
2416 "recommendation": finding.recommendation,
2417 "confidence": finding.confidence,
2418 "signals": &finding.signals,
2419 "related_ids": &finding.related_ids,
2420 });
2421 sarif_results.push(result);
2422 }
2423}
2424
2425fn coverage_intelligence_rule_id(
2426 recommendation: crate::health_types::CoverageIntelligenceRecommendation,
2427) -> &'static str {
2428 match recommendation {
2429 crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
2430 "fallow/coverage-intelligence-risky-change"
2431 }
2432 crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
2433 "fallow/coverage-intelligence-delete"
2434 }
2435 crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
2436 "fallow/coverage-intelligence-review"
2437 }
2438 crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
2439 "fallow/coverage-intelligence-refactor"
2440 }
2441 }
2442}
2443
2444pub(super) fn print_health_sarif(
2445 report: &crate::health_types::HealthReport,
2446 root: &Path,
2447) -> ExitCode {
2448 let sarif = build_health_sarif(report, root);
2449 emit_json(&sarif, "SARIF")
2450}
2451
2452#[expect(
2463 clippy::expect_used,
2464 reason = "grouped health SARIF entries are JSON objects created by build_health_sarif"
2465)]
2466pub(super) fn print_grouped_health_sarif(
2467 report: &crate::health_types::HealthReport,
2468 root: &Path,
2469 resolver: &OwnershipResolver,
2470) -> ExitCode {
2471 let mut sarif = build_health_sarif(report, root);
2472
2473 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
2474 for run in runs {
2475 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
2476 for result in results {
2477 let uri = result
2478 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
2479 .and_then(|v| v.as_str())
2480 .unwrap_or("");
2481 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
2482 let group =
2483 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
2484 let props = result
2485 .as_object_mut()
2486 .expect("SARIF result should be an object")
2487 .entry("properties")
2488 .or_insert_with(|| serde_json::json!({}));
2489 props
2490 .as_object_mut()
2491 .expect("properties should be an object")
2492 .insert("group".to_string(), serde_json::Value::String(group));
2493 }
2494 }
2495 }
2496 }
2497
2498 emit_json(&sarif, "SARIF")
2499}
2500
2501#[cfg(test)]
2502mod tests {
2503 use super::*;
2504 use crate::report::test_helpers::sample_results;
2505 use fallow_core::results::*;
2506 use std::path::PathBuf;
2507
2508 #[test]
2509 fn sarif_has_required_top_level_fields() {
2510 let root = PathBuf::from("/project");
2511 let results = AnalysisResults::default();
2512 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2513
2514 assert_eq!(
2515 sarif["$schema"],
2516 "https://json.schemastore.org/sarif-2.1.0.json"
2517 );
2518 assert_eq!(sarif["version"], "2.1.0");
2519 assert!(sarif["runs"].is_array());
2520 }
2521
2522 #[test]
2523 fn sarif_has_tool_driver_info() {
2524 let root = PathBuf::from("/project");
2525 let results = AnalysisResults::default();
2526 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2527
2528 let driver = &sarif["runs"][0]["tool"]["driver"];
2529 assert_eq!(driver["name"], "fallow");
2530 assert!(driver["version"].is_string());
2531 assert_eq!(
2532 driver["informationUri"],
2533 "https://github.com/fallow-rs/fallow"
2534 );
2535 }
2536
2537 #[test]
2538 fn sarif_declares_all_rules() {
2539 let root = PathBuf::from("/project");
2540 let results = AnalysisResults::default();
2541 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2542
2543 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2544 .as_array()
2545 .expect("rules should be an array");
2546 assert_eq!(rules.len(), 41);
2547
2548 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
2549 assert!(rule_ids.contains(&"fallow/duplicate-prop-shape"));
2550 assert!(rule_ids.contains(&"fallow/thin-wrapper"));
2551 assert!(rule_ids.contains(&"fallow/unrendered-component"));
2552 assert!(rule_ids.contains(&"fallow/unused-component-prop"));
2553 assert!(rule_ids.contains(&"fallow/unused-component-emit"));
2554 assert!(rule_ids.contains(&"fallow/unused-server-action"));
2555 assert!(rule_ids.contains(&"fallow/unused-load-data-key"));
2556 assert!(rule_ids.contains(&"fallow/prop-drilling"));
2557 assert!(rule_ids.contains(&"fallow/route-collision"));
2558 assert!(rule_ids.contains(&"fallow/dynamic-segment-name-conflict"));
2559 assert!(rule_ids.contains(&"fallow/unused-file"));
2560 assert!(rule_ids.contains(&"fallow/unused-export"));
2561 assert!(rule_ids.contains(&"fallow/unused-type"));
2562 assert!(rule_ids.contains(&"fallow/private-type-leak"));
2563 assert!(rule_ids.contains(&"fallow/unused-dependency"));
2564 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
2565 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
2566 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
2567 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
2568 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
2569 assert!(rule_ids.contains(&"fallow/unused-class-member"));
2570 assert!(rule_ids.contains(&"fallow/unused-store-member"));
2571 assert!(rule_ids.contains(&"fallow/unresolved-import"));
2572 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
2573 assert!(rule_ids.contains(&"fallow/duplicate-export"));
2574 assert!(rule_ids.contains(&"fallow/circular-dependency"));
2575 assert!(rule_ids.contains(&"fallow/re-export-cycle"));
2576 assert!(rule_ids.contains(&"fallow/boundary-violation"));
2577 assert!(rule_ids.contains(&"fallow/boundary-coverage"));
2578 assert!(rule_ids.contains(&"fallow/boundary-call-violation"));
2579 assert!(rule_ids.contains(&"fallow/policy-violation"));
2580 assert!(rule_ids.contains(&"fallow/unused-catalog-entry"));
2581 assert!(rule_ids.contains(&"fallow/empty-catalog-group"));
2582 assert!(rule_ids.contains(&"fallow/unresolved-catalog-reference"));
2583 assert!(rule_ids.contains(&"fallow/unused-dependency-override"));
2584 assert!(rule_ids.contains(&"fallow/misconfigured-dependency-override"));
2585 assert!(rule_ids.contains(&"fallow/invalid-client-export"));
2586 assert!(rule_ids.contains(&"fallow/mixed-client-server-barrel"));
2587 assert!(rule_ids.contains(&"fallow/misplaced-directive"));
2588 assert!(rule_ids.contains(&"fallow/unprovided-inject"));
2589 }
2590
2591 #[test]
2592 fn sarif_empty_results_no_results_entries() {
2593 let root = PathBuf::from("/project");
2594 let results = AnalysisResults::default();
2595 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2596
2597 let sarif_results = sarif["runs"][0]["results"]
2598 .as_array()
2599 .expect("results should be an array");
2600 assert!(sarif_results.is_empty());
2601 }
2602
2603 #[test]
2604 fn sarif_unused_file_result() {
2605 let root = PathBuf::from("/project");
2606 let mut results = AnalysisResults::default();
2607 results
2608 .unused_files
2609 .push(UnusedFileFinding::with_actions(UnusedFile {
2610 path: root.join("src/dead.ts"),
2611 }));
2612
2613 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2614 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2615 assert_eq!(entries.len(), 1);
2616
2617 let entry = &entries[0];
2618 assert_eq!(entry["ruleId"], "fallow/unused-file");
2619 assert_eq!(entry["level"], "error");
2620 assert_eq!(
2621 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2622 "src/dead.ts"
2623 );
2624 }
2625
2626 #[test]
2627 fn sarif_unused_export_includes_region() {
2628 let root = PathBuf::from("/project");
2629 let mut results = AnalysisResults::default();
2630 results
2631 .unused_exports
2632 .push(UnusedExportFinding::with_actions(UnusedExport {
2633 path: root.join("src/utils.ts"),
2634 export_name: "helperFn".to_string(),
2635 is_type_only: false,
2636 line: 10,
2637 col: 4,
2638 span_start: 120,
2639 is_re_export: false,
2640 }));
2641
2642 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2643 let entry = &sarif["runs"][0]["results"][0];
2644 assert_eq!(entry["ruleId"], "fallow/unused-export");
2645
2646 let region = &entry["locations"][0]["physicalLocation"]["region"];
2647 assert_eq!(region["startLine"], 10);
2648 assert_eq!(region["startColumn"], 5);
2649 }
2650
2651 #[test]
2652 fn sarif_unresolved_import_is_error_level() {
2653 let root = PathBuf::from("/project");
2654 let mut results = AnalysisResults::default();
2655 results
2656 .unresolved_imports
2657 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
2658 path: root.join("src/app.ts"),
2659 specifier: "./missing".to_string(),
2660 line: 1,
2661 col: 0,
2662 specifier_col: 0,
2663 }));
2664
2665 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2666 let entry = &sarif["runs"][0]["results"][0];
2667 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
2668 assert_eq!(entry["level"], "error");
2669 }
2670
2671 #[test]
2672 fn sarif_unlisted_dependency_points_to_import_site() {
2673 let root = PathBuf::from("/project");
2674 let mut results = AnalysisResults::default();
2675 results
2676 .unlisted_dependencies
2677 .push(UnlistedDependencyFinding::with_actions(
2678 UnlistedDependency {
2679 package_name: "chalk".to_string(),
2680 imported_from: vec![ImportSite {
2681 path: root.join("src/cli.ts"),
2682 line: 3,
2683 col: 0,
2684 }],
2685 },
2686 ));
2687
2688 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2689 let entry = &sarif["runs"][0]["results"][0];
2690 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
2691 assert_eq!(entry["level"], "error");
2692 assert_eq!(
2693 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2694 "src/cli.ts"
2695 );
2696 let region = &entry["locations"][0]["physicalLocation"]["region"];
2697 assert_eq!(region["startLine"], 3);
2698 assert_eq!(region["startColumn"], 1);
2699 }
2700
2701 #[test]
2702 fn sarif_dependency_issues_point_to_package_json() {
2703 let root = PathBuf::from("/project");
2704 let mut results = AnalysisResults::default();
2705 results
2706 .unused_dependencies
2707 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
2708 package_name: "lodash".to_string(),
2709 location: DependencyLocation::Dependencies,
2710 path: root.join("package.json"),
2711 line: 5,
2712 used_in_workspaces: Vec::new(),
2713 }));
2714 results
2715 .unused_dev_dependencies
2716 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
2717 package_name: "jest".to_string(),
2718 location: DependencyLocation::DevDependencies,
2719 path: root.join("package.json"),
2720 line: 5,
2721 used_in_workspaces: Vec::new(),
2722 }));
2723
2724 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2725 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2726 for entry in entries {
2727 assert_eq!(
2728 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2729 "package.json"
2730 );
2731 }
2732 }
2733
2734 #[test]
2735 fn sarif_duplicate_export_emits_one_result_per_location() {
2736 let root = PathBuf::from("/project");
2737 let mut results = AnalysisResults::default();
2738 results
2739 .duplicate_exports
2740 .push(DuplicateExportFinding::with_actions(DuplicateExport {
2741 export_name: "Config".to_string(),
2742 locations: vec![
2743 DuplicateLocation {
2744 path: root.join("src/a.ts"),
2745 line: 15,
2746 col: 0,
2747 },
2748 DuplicateLocation {
2749 path: root.join("src/b.ts"),
2750 line: 30,
2751 col: 0,
2752 },
2753 ],
2754 }));
2755
2756 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2757 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2758 assert_eq!(entries.len(), 2);
2759 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
2760 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
2761 assert_eq!(
2762 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2763 "src/a.ts"
2764 );
2765 assert_eq!(
2766 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2767 "src/b.ts"
2768 );
2769 }
2770
2771 #[test]
2772 fn sarif_all_issue_types_produce_results() {
2773 let root = PathBuf::from("/project");
2774 let results = sample_results(&root);
2775 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2776
2777 let entries = sarif["runs"][0]["results"].as_array().unwrap();
2778 assert_eq!(entries.len(), results.total_issues() + 1);
2779
2780 let rule_ids: Vec<&str> = entries
2781 .iter()
2782 .map(|e| e["ruleId"].as_str().unwrap())
2783 .collect();
2784 assert!(rule_ids.contains(&"fallow/unused-file"));
2785 assert!(rule_ids.contains(&"fallow/unused-export"));
2786 assert!(rule_ids.contains(&"fallow/unused-type"));
2787 assert!(rule_ids.contains(&"fallow/unused-dependency"));
2788 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
2789 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
2790 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
2791 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
2792 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
2793 assert!(rule_ids.contains(&"fallow/unused-class-member"));
2794 assert!(rule_ids.contains(&"fallow/unused-store-member"));
2795 assert!(rule_ids.contains(&"fallow/unresolved-import"));
2796 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
2797 assert!(rule_ids.contains(&"fallow/duplicate-export"));
2798 assert!(rule_ids.contains(&"fallow/unprovided-inject"));
2799 }
2800
2801 #[test]
2802 fn sarif_serializes_to_valid_json() {
2803 let root = PathBuf::from("/project");
2804 let results = sample_results(&root);
2805 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2806
2807 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
2808 let reparsed: serde_json::Value =
2809 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
2810 assert_eq!(reparsed, sarif);
2811 }
2812
2813 #[test]
2814 fn sarif_file_write_produces_valid_sarif() {
2815 let root = PathBuf::from("/project");
2816 let results = sample_results(&root);
2817 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2818 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
2819
2820 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
2821 let _ = std::fs::create_dir_all(&dir);
2822 let sarif_path = dir.join("results.sarif");
2823 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
2824
2825 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
2826 let parsed: serde_json::Value =
2827 serde_json::from_str(&contents).expect("file should contain valid JSON");
2828
2829 assert_eq!(parsed["version"], "2.1.0");
2830 assert_eq!(
2831 parsed["$schema"],
2832 "https://json.schemastore.org/sarif-2.1.0.json"
2833 );
2834 let sarif_results = parsed["runs"][0]["results"]
2835 .as_array()
2836 .expect("results should be an array");
2837 assert!(!sarif_results.is_empty());
2838
2839 let _ = std::fs::remove_file(&sarif_path);
2840 let _ = std::fs::remove_dir(&dir);
2841 }
2842
2843 #[test]
2844 fn health_sarif_empty_no_results() {
2845 let root = PathBuf::from("/project");
2846 let report = crate::health_types::HealthReport {
2847 summary: crate::health_types::HealthSummary {
2848 files_analyzed: 10,
2849 functions_analyzed: 50,
2850 ..Default::default()
2851 },
2852 ..Default::default()
2853 };
2854 let sarif = build_health_sarif(&report, &root);
2855 assert_eq!(sarif["version"], "2.1.0");
2856 let results = sarif["runs"][0]["results"].as_array().unwrap();
2857 assert!(results.is_empty());
2858 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2859 .as_array()
2860 .unwrap();
2861 assert_eq!(rules.len(), 16);
2862 }
2863
2864 #[test]
2865 fn health_sarif_coverage_intelligence_preserves_structured_properties() {
2866 use crate::health_types::{
2867 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2868 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2869 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2870 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2871 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2872 HealthReport, HealthSummary,
2873 };
2874
2875 let root = PathBuf::from("/project");
2876 let report = HealthReport {
2877 summary: HealthSummary {
2878 files_analyzed: 10,
2879 functions_analyzed: 50,
2880 ..Default::default()
2881 },
2882 coverage_intelligence: Some(CoverageIntelligenceReport {
2883 schema_version: CoverageIntelligenceSchemaVersion::V1,
2884 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2885 summary: CoverageIntelligenceSummary {
2886 findings: 1,
2887 high_confidence_deletes: 1,
2888 ..Default::default()
2889 },
2890 findings: vec![CoverageIntelligenceFinding {
2891 id: "fallow:coverage-intel:abc123".to_owned(),
2892 path: root.join("src/dead.ts"),
2893 identity: Some("deadPath".to_owned()),
2894 line: 9,
2895 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2896 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2897 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2898 confidence: CoverageIntelligenceConfidence::High,
2899 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
2900 evidence: CoverageIntelligenceEvidence {
2901 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2902 ..Default::default()
2903 },
2904 actions: vec![CoverageIntelligenceAction {
2905 kind: "delete-after-confirming-owner".to_owned(),
2906 description: "Confirm ownership".to_owned(),
2907 auto_fixable: false,
2908 }],
2909 }],
2910 }),
2911 ..Default::default()
2912 };
2913
2914 let sarif = build_health_sarif(&report, &root);
2915 let result = &sarif["runs"][0]["results"][0];
2916 assert_eq!(result["ruleId"], "fallow/coverage-intelligence-delete");
2917 assert_eq!(
2918 result["properties"]["coverage_intelligence_id"],
2919 "fallow:coverage-intel:abc123"
2920 );
2921 assert_eq!(
2922 result["properties"]["recommendation"],
2923 "delete-after-confirming-owner"
2924 );
2925 assert_eq!(result["properties"]["confidence"], "high");
2926 assert_eq!(result["properties"]["signals"][0], "runtime_cold");
2927 assert_eq!(
2928 result["properties"]["related_ids"][0],
2929 "fallow:prod:deadbeef"
2930 );
2931 }
2932
2933 #[test]
2934 fn health_sarif_cyclomatic_only() {
2935 let root = PathBuf::from("/project");
2936 let report = crate::health_types::HealthReport {
2937 findings: vec![
2938 crate::health_types::ComplexityViolation {
2939 path: root.join("src/utils.ts"),
2940 name: "parseExpression".to_string(),
2941 line: 42,
2942 col: 0,
2943 cyclomatic: 25,
2944 cognitive: 10,
2945 line_count: 80,
2946 param_count: 0,
2947 react_hook_count: 0,
2948 react_jsx_max_depth: 0,
2949 react_prop_count: 0,
2950 react_hook_profile: None,
2951 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
2952 severity: crate::health_types::FindingSeverity::High,
2953 crap: None,
2954 coverage_pct: None,
2955 coverage_tier: None,
2956 coverage_source: None,
2957 inherited_from: None,
2958 component_rollup: None,
2959 contributions: Vec::new(),
2960 effective_thresholds: None,
2961 threshold_source: None,
2962 }
2963 .into(),
2964 ],
2965 summary: crate::health_types::HealthSummary {
2966 files_analyzed: 5,
2967 functions_analyzed: 20,
2968 functions_above_threshold: 1,
2969 ..Default::default()
2970 },
2971 ..Default::default()
2972 };
2973 let sarif = build_health_sarif(&report, &root);
2974 let entry = &sarif["runs"][0]["results"][0];
2975 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
2976 assert_eq!(entry["level"], "warning");
2977 assert!(
2978 entry["message"]["text"]
2979 .as_str()
2980 .unwrap()
2981 .contains("cyclomatic complexity 25")
2982 );
2983 assert_eq!(
2984 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2985 "src/utils.ts"
2986 );
2987 let region = &entry["locations"][0]["physicalLocation"]["region"];
2988 assert_eq!(region["startLine"], 42);
2989 assert_eq!(region["startColumn"], 1);
2990 }
2991
2992 #[test]
2993 fn health_sarif_cognitive_only() {
2994 let root = PathBuf::from("/project");
2995 let report = crate::health_types::HealthReport {
2996 findings: vec![
2997 crate::health_types::ComplexityViolation {
2998 path: root.join("src/api.ts"),
2999 name: "handleRequest".to_string(),
3000 line: 10,
3001 col: 4,
3002 cyclomatic: 8,
3003 cognitive: 20,
3004 line_count: 40,
3005 param_count: 0,
3006 react_hook_count: 0,
3007 react_jsx_max_depth: 0,
3008 react_prop_count: 0,
3009 react_hook_profile: None,
3010 exceeded: crate::health_types::ExceededThreshold::Cognitive,
3011 severity: crate::health_types::FindingSeverity::High,
3012 crap: None,
3013 coverage_pct: None,
3014 coverage_tier: None,
3015 coverage_source: None,
3016 inherited_from: None,
3017 component_rollup: None,
3018 contributions: Vec::new(),
3019 effective_thresholds: None,
3020 threshold_source: None,
3021 }
3022 .into(),
3023 ],
3024 summary: crate::health_types::HealthSummary {
3025 files_analyzed: 3,
3026 functions_analyzed: 10,
3027 functions_above_threshold: 1,
3028 ..Default::default()
3029 },
3030 ..Default::default()
3031 };
3032 let sarif = build_health_sarif(&report, &root);
3033 let entry = &sarif["runs"][0]["results"][0];
3034 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
3035 assert!(
3036 entry["message"]["text"]
3037 .as_str()
3038 .unwrap()
3039 .contains("cognitive complexity 20")
3040 );
3041 let region = &entry["locations"][0]["physicalLocation"]["region"];
3042 assert_eq!(region["startColumn"], 5); }
3044
3045 #[test]
3046 fn health_sarif_both_thresholds() {
3047 let root = PathBuf::from("/project");
3048 let report = crate::health_types::HealthReport {
3049 findings: vec![
3050 crate::health_types::ComplexityViolation {
3051 path: root.join("src/complex.ts"),
3052 name: "doEverything".to_string(),
3053 line: 1,
3054 col: 0,
3055 cyclomatic: 30,
3056 cognitive: 45,
3057 line_count: 100,
3058 param_count: 0,
3059 react_hook_count: 0,
3060 react_jsx_max_depth: 0,
3061 react_prop_count: 0,
3062 react_hook_profile: None,
3063 exceeded: crate::health_types::ExceededThreshold::Both,
3064 severity: crate::health_types::FindingSeverity::High,
3065 crap: None,
3066 coverage_pct: None,
3067 coverage_tier: None,
3068 coverage_source: None,
3069 inherited_from: None,
3070 component_rollup: None,
3071 contributions: Vec::new(),
3072 effective_thresholds: None,
3073 threshold_source: None,
3074 }
3075 .into(),
3076 ],
3077 summary: crate::health_types::HealthSummary {
3078 files_analyzed: 1,
3079 functions_analyzed: 1,
3080 functions_above_threshold: 1,
3081 ..Default::default()
3082 },
3083 ..Default::default()
3084 };
3085 let sarif = build_health_sarif(&report, &root);
3086 let entry = &sarif["runs"][0]["results"][0];
3087 assert_eq!(entry["ruleId"], "fallow/high-complexity");
3088 let msg = entry["message"]["text"].as_str().unwrap();
3089 assert!(msg.contains("cyclomatic complexity 30"));
3090 assert!(msg.contains("cognitive complexity 45"));
3091 }
3092
3093 #[test]
3094 fn health_sarif_crap_only_emits_crap_rule() {
3095 let root = PathBuf::from("/project");
3096 let report = crate::health_types::HealthReport {
3097 findings: vec![
3098 crate::health_types::ComplexityViolation {
3099 path: root.join("src/untested.ts"),
3100 name: "risky".to_string(),
3101 line: 8,
3102 col: 0,
3103 cyclomatic: 10,
3104 cognitive: 10,
3105 line_count: 20,
3106 param_count: 1,
3107 react_hook_count: 0,
3108 react_jsx_max_depth: 0,
3109 react_prop_count: 0,
3110 react_hook_profile: None,
3111 exceeded: crate::health_types::ExceededThreshold::Crap,
3112 severity: crate::health_types::FindingSeverity::High,
3113 crap: Some(82.2),
3114 coverage_pct: Some(12.0),
3115 coverage_tier: None,
3116 coverage_source: None,
3117 inherited_from: None,
3118 component_rollup: None,
3119 contributions: Vec::new(),
3120 effective_thresholds: None,
3121 threshold_source: None,
3122 }
3123 .into(),
3124 ],
3125 summary: crate::health_types::HealthSummary {
3126 files_analyzed: 1,
3127 functions_analyzed: 1,
3128 functions_above_threshold: 1,
3129 ..Default::default()
3130 },
3131 ..Default::default()
3132 };
3133 let sarif = build_health_sarif(&report, &root);
3134 let entry = &sarif["runs"][0]["results"][0];
3135 assert_eq!(entry["ruleId"], "fallow/high-crap-score");
3136 let msg = entry["message"]["text"].as_str().unwrap();
3137 assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
3138 assert!(msg.contains("coverage 12%"), "msg: {msg}");
3139 }
3140
3141 #[test]
3142 fn health_sarif_cyclomatic_crap_uses_crap_rule() {
3143 let root = PathBuf::from("/project");
3144 let report = crate::health_types::HealthReport {
3145 findings: vec![
3146 crate::health_types::ComplexityViolation {
3147 path: root.join("src/hot.ts"),
3148 name: "branchy".to_string(),
3149 line: 1,
3150 col: 0,
3151 cyclomatic: 67,
3152 cognitive: 12,
3153 line_count: 80,
3154 param_count: 1,
3155 react_hook_count: 0,
3156 react_jsx_max_depth: 0,
3157 react_prop_count: 0,
3158 react_hook_profile: None,
3159 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
3160 severity: crate::health_types::FindingSeverity::Critical,
3161 crap: Some(182.0),
3162 coverage_pct: None,
3163 coverage_tier: None,
3164 coverage_source: None,
3165 inherited_from: None,
3166 component_rollup: None,
3167 contributions: Vec::new(),
3168 effective_thresholds: None,
3169 threshold_source: None,
3170 }
3171 .into(),
3172 ],
3173 summary: crate::health_types::HealthSummary {
3174 files_analyzed: 1,
3175 functions_analyzed: 1,
3176 functions_above_threshold: 1,
3177 ..Default::default()
3178 },
3179 ..Default::default()
3180 };
3181 let sarif = build_health_sarif(&report, &root);
3182 let results = sarif["runs"][0]["results"].as_array().unwrap();
3183 assert_eq!(
3184 results.len(),
3185 1,
3186 "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
3187 );
3188 assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
3189 let msg = results[0]["message"]["text"].as_str().unwrap();
3190 assert!(msg.contains("CRAP score 182"), "msg: {msg}");
3191 assert!(!msg.contains("coverage"), "msg: {msg}");
3192 }
3193
3194 #[test]
3195 fn severity_to_sarif_level_error() {
3196 assert_eq!(severity_to_sarif_level(Severity::Error), "error");
3197 }
3198
3199 #[test]
3200 fn severity_to_sarif_level_warn() {
3201 assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
3202 }
3203
3204 #[test]
3205 #[should_panic(expected = "internal error: entered unreachable code")]
3206 fn severity_to_sarif_level_off() {
3207 let _ = severity_to_sarif_level(Severity::Off);
3208 }
3209
3210 #[test]
3211 fn sarif_re_export_has_properties() {
3212 let root = PathBuf::from("/project");
3213 let mut results = AnalysisResults::default();
3214 results
3215 .unused_exports
3216 .push(UnusedExportFinding::with_actions(UnusedExport {
3217 path: root.join("src/index.ts"),
3218 export_name: "reExported".to_string(),
3219 is_type_only: false,
3220 line: 1,
3221 col: 0,
3222 span_start: 0,
3223 is_re_export: true,
3224 }));
3225
3226 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3227 let entry = &sarif["runs"][0]["results"][0];
3228 assert_eq!(entry["properties"]["is_re_export"], true);
3229 let msg = entry["message"]["text"].as_str().unwrap();
3230 assert!(msg.starts_with("Re-export"));
3231 }
3232
3233 #[test]
3234 fn sarif_non_re_export_has_no_properties() {
3235 let root = PathBuf::from("/project");
3236 let mut results = AnalysisResults::default();
3237 results
3238 .unused_exports
3239 .push(UnusedExportFinding::with_actions(UnusedExport {
3240 path: root.join("src/utils.ts"),
3241 export_name: "foo".to_string(),
3242 is_type_only: false,
3243 line: 5,
3244 col: 0,
3245 span_start: 0,
3246 is_re_export: false,
3247 }));
3248
3249 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3250 let entry = &sarif["runs"][0]["results"][0];
3251 assert!(entry.get("properties").is_none());
3252 let msg = entry["message"]["text"].as_str().unwrap();
3253 assert!(msg.starts_with("Export"));
3254 }
3255
3256 #[test]
3257 fn sarif_type_re_export_message() {
3258 let root = PathBuf::from("/project");
3259 let mut results = AnalysisResults::default();
3260 results
3261 .unused_types
3262 .push(UnusedTypeFinding::with_actions(UnusedExport {
3263 path: root.join("src/index.ts"),
3264 export_name: "MyType".to_string(),
3265 is_type_only: true,
3266 line: 1,
3267 col: 0,
3268 span_start: 0,
3269 is_re_export: true,
3270 }));
3271
3272 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3273 let entry = &sarif["runs"][0]["results"][0];
3274 assert_eq!(entry["ruleId"], "fallow/unused-type");
3275 let msg = entry["message"]["text"].as_str().unwrap();
3276 assert!(msg.starts_with("Type re-export"));
3277 assert_eq!(entry["properties"]["is_re_export"], true);
3278 }
3279
3280 #[test]
3281 fn sarif_dependency_line_zero_skips_region() {
3282 let root = PathBuf::from("/project");
3283 let mut results = AnalysisResults::default();
3284 results
3285 .unused_dependencies
3286 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3287 package_name: "lodash".to_string(),
3288 location: DependencyLocation::Dependencies,
3289 path: root.join("package.json"),
3290 line: 0,
3291 used_in_workspaces: Vec::new(),
3292 }));
3293
3294 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3295 let entry = &sarif["runs"][0]["results"][0];
3296 let phys = &entry["locations"][0]["physicalLocation"];
3297 assert!(phys.get("region").is_none());
3298 }
3299
3300 #[test]
3301 fn sarif_dependency_line_nonzero_has_region() {
3302 let root = PathBuf::from("/project");
3303 let mut results = AnalysisResults::default();
3304 results
3305 .unused_dependencies
3306 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3307 package_name: "lodash".to_string(),
3308 location: DependencyLocation::Dependencies,
3309 path: root.join("package.json"),
3310 line: 7,
3311 used_in_workspaces: Vec::new(),
3312 }));
3313
3314 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3315 let entry = &sarif["runs"][0]["results"][0];
3316 let region = &entry["locations"][0]["physicalLocation"]["region"];
3317 assert_eq!(region["startLine"], 7);
3318 assert_eq!(region["startColumn"], 1);
3319 }
3320
3321 #[test]
3322 fn sarif_type_only_dep_line_zero_skips_region() {
3323 let root = PathBuf::from("/project");
3324 let mut results = AnalysisResults::default();
3325 results
3326 .type_only_dependencies
3327 .push(TypeOnlyDependencyFinding::with_actions(
3328 TypeOnlyDependency {
3329 package_name: "zod".to_string(),
3330 path: root.join("package.json"),
3331 line: 0,
3332 },
3333 ));
3334
3335 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3336 let entry = &sarif["runs"][0]["results"][0];
3337 let phys = &entry["locations"][0]["physicalLocation"];
3338 assert!(phys.get("region").is_none());
3339 }
3340
3341 #[test]
3342 fn sarif_circular_dep_line_zero_skips_region() {
3343 let root = PathBuf::from("/project");
3344 let mut results = AnalysisResults::default();
3345 results
3346 .circular_dependencies
3347 .push(CircularDependencyFinding::with_actions(
3348 CircularDependency {
3349 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
3350 length: 2,
3351 line: 0,
3352 col: 0,
3353 edges: Vec::new(),
3354 is_cross_package: false,
3355 },
3356 ));
3357
3358 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3359 let entry = &sarif["runs"][0]["results"][0];
3360 let phys = &entry["locations"][0]["physicalLocation"];
3361 assert!(phys.get("region").is_none());
3362 }
3363
3364 #[test]
3365 fn sarif_circular_dep_line_nonzero_has_region() {
3366 let root = PathBuf::from("/project");
3367 let mut results = AnalysisResults::default();
3368 results
3369 .circular_dependencies
3370 .push(CircularDependencyFinding::with_actions(
3371 CircularDependency {
3372 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
3373 length: 2,
3374 line: 5,
3375 col: 2,
3376 edges: Vec::new(),
3377 is_cross_package: false,
3378 },
3379 ));
3380
3381 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3382 let entry = &sarif["runs"][0]["results"][0];
3383 let region = &entry["locations"][0]["physicalLocation"]["region"];
3384 assert_eq!(region["startLine"], 5);
3385 assert_eq!(region["startColumn"], 3);
3386 }
3387
3388 #[test]
3389 fn sarif_unused_optional_dependency_result() {
3390 let root = PathBuf::from("/project");
3391 let mut results = AnalysisResults::default();
3392 results
3393 .unused_optional_dependencies
3394 .push(UnusedOptionalDependencyFinding::with_actions(
3395 UnusedDependency {
3396 package_name: "fsevents".to_string(),
3397 location: DependencyLocation::OptionalDependencies,
3398 path: root.join("package.json"),
3399 line: 12,
3400 used_in_workspaces: Vec::new(),
3401 },
3402 ));
3403
3404 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3405 let entry = &sarif["runs"][0]["results"][0];
3406 assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
3407 let msg = entry["message"]["text"].as_str().unwrap();
3408 assert!(msg.contains("optionalDependencies"));
3409 }
3410
3411 #[test]
3412 fn sarif_enum_member_message_format() {
3413 let root = PathBuf::from("/project");
3414 let mut results = AnalysisResults::default();
3415 results.unused_enum_members.push(
3416 fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
3417 path: root.join("src/enums.ts"),
3418 parent_name: "Color".to_string(),
3419 member_name: "Purple".to_string(),
3420 kind: fallow_core::extract::MemberKind::EnumMember,
3421 line: 5,
3422 col: 2,
3423 }),
3424 );
3425
3426 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3427 let entry = &sarif["runs"][0]["results"][0];
3428 assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
3429 let msg = entry["message"]["text"].as_str().unwrap();
3430 assert!(msg.contains("Enum member 'Color.Purple'"));
3431 let region = &entry["locations"][0]["physicalLocation"]["region"];
3432 assert_eq!(region["startColumn"], 3); }
3434
3435 #[test]
3436 fn sarif_class_member_message_format() {
3437 let root = PathBuf::from("/project");
3438 let mut results = AnalysisResults::default();
3439 results.unused_class_members.push(
3440 fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
3441 path: root.join("src/service.ts"),
3442 parent_name: "API".to_string(),
3443 member_name: "fetch".to_string(),
3444 kind: fallow_core::extract::MemberKind::ClassMethod,
3445 line: 10,
3446 col: 4,
3447 }),
3448 );
3449
3450 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3451 let entry = &sarif["runs"][0]["results"][0];
3452 assert_eq!(entry["ruleId"], "fallow/unused-class-member");
3453 let msg = entry["message"]["text"].as_str().unwrap();
3454 assert!(msg.contains("Class member 'API.fetch'"));
3455 }
3456
3457 #[test]
3458 #[expect(
3459 clippy::cast_possible_truncation,
3460 reason = "test line/col values are trivially small"
3461 )]
3462 fn duplication_sarif_structure() {
3463 use fallow_core::duplicates::*;
3464
3465 let root = PathBuf::from("/project");
3466 let report = DuplicationReport {
3467 clone_groups: vec![CloneGroup {
3468 instances: vec![
3469 CloneInstance {
3470 file: root.join("src/a.ts"),
3471 start_line: 1,
3472 end_line: 10,
3473 start_col: 0,
3474 end_col: 0,
3475 fragment: String::new(),
3476 },
3477 CloneInstance {
3478 file: root.join("src/b.ts"),
3479 start_line: 5,
3480 end_line: 14,
3481 start_col: 2,
3482 end_col: 0,
3483 fragment: String::new(),
3484 },
3485 ],
3486 token_count: 50,
3487 line_count: 10,
3488 }],
3489 clone_families: vec![],
3490 mirrored_directories: vec![],
3491 stats: DuplicationStats::default(),
3492 };
3493
3494 let sarif = serde_json::json!({
3495 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
3496 "version": "2.1.0",
3497 "runs": [{
3498 "tool": {
3499 "driver": {
3500 "name": "fallow",
3501 "version": env!("CARGO_PKG_VERSION"),
3502 "informationUri": "https://github.com/fallow-rs/fallow",
3503 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
3504 }
3505 },
3506 "results": []
3507 }]
3508 });
3509 let _ = sarif;
3510
3511 let mut sarif_results = Vec::new();
3512 for (i, group) in report.clone_groups.iter().enumerate() {
3513 for instance in &group.instances {
3514 sarif_results.push(sarif_result(
3515 "fallow/code-duplication",
3516 "warning",
3517 &format!(
3518 "Code clone group {} ({} lines, {} instances)",
3519 i + 1,
3520 group.line_count,
3521 group.instances.len()
3522 ),
3523 &super::super::relative_uri(&instance.file, &root),
3524 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
3525 ));
3526 }
3527 }
3528 assert_eq!(sarif_results.len(), 2);
3529 assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
3530 assert!(
3531 sarif_results[0]["message"]["text"]
3532 .as_str()
3533 .unwrap()
3534 .contains("10 lines")
3535 );
3536 let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
3537 assert_eq!(region0["startLine"], 1);
3538 assert_eq!(region0["startColumn"], 1); let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
3540 assert_eq!(region1["startLine"], 5);
3541 assert_eq!(region1["startColumn"], 3); }
3543
3544 #[test]
3545 fn sarif_rule_known_id_has_full_description() {
3546 let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
3547 assert!(rule.get("fullDescription").is_some());
3548 assert!(rule.get("helpUri").is_some());
3549 }
3550
3551 #[test]
3552 fn sarif_rule_unknown_id_uses_fallback() {
3553 let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
3554 assert_eq!(rule["shortDescription"]["text"], "fallback text");
3555 assert!(rule.get("fullDescription").is_none());
3556 assert!(rule.get("helpUri").is_none());
3557 assert_eq!(rule["defaultConfiguration"]["level"], "warning");
3558 }
3559
3560 #[test]
3561 fn sarif_result_no_region_omits_region_key() {
3562 let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
3563 let phys = &result["locations"][0]["physicalLocation"];
3564 assert!(phys.get("region").is_none());
3565 assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
3566 }
3567
3568 #[test]
3569 fn sarif_result_with_region_includes_region() {
3570 let result = sarif_result(
3571 "rule/test",
3572 "error",
3573 "test msg",
3574 "src/file.ts",
3575 Some((10, 5)),
3576 );
3577 let region = &result["locations"][0]["physicalLocation"]["region"];
3578 assert_eq!(region["startLine"], 10);
3579 assert_eq!(region["startColumn"], 5);
3580 }
3581
3582 #[test]
3583 fn sarif_partial_fingerprint_ignores_rendered_message() {
3584 let a = sarif_result(
3585 "rule/test",
3586 "error",
3587 "first message",
3588 "src/file.ts",
3589 Some((10, 5)),
3590 );
3591 let b = sarif_result(
3592 "rule/test",
3593 "error",
3594 "rewritten message",
3595 "src/file.ts",
3596 Some((10, 5)),
3597 );
3598 assert_eq!(
3599 a["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
3600 b["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
3601 );
3602 }
3603
3604 #[test]
3605 fn health_sarif_includes_refactoring_targets() {
3606 use crate::health_types::*;
3607
3608 let root = PathBuf::from("/project");
3609 let report = HealthReport {
3610 summary: HealthSummary {
3611 files_analyzed: 10,
3612 functions_analyzed: 50,
3613 ..Default::default()
3614 },
3615 targets: vec![
3616 RefactoringTarget {
3617 path: root.join("src/complex.ts"),
3618 priority: 85.0,
3619 efficiency: 42.5,
3620 recommendation: "Split high-impact file".into(),
3621 category: RecommendationCategory::SplitHighImpact,
3622 effort: EffortEstimate::Medium,
3623 confidence: Confidence::High,
3624 factors: vec![],
3625 evidence: None,
3626 }
3627 .into(),
3628 ],
3629 ..Default::default()
3630 };
3631
3632 let sarif = build_health_sarif(&report, &root);
3633 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3634 assert_eq!(entries.len(), 1);
3635 assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
3636 assert_eq!(entries[0]["level"], "warning");
3637 let msg = entries[0]["message"]["text"].as_str().unwrap();
3638 assert!(msg.contains("high impact"));
3639 assert!(msg.contains("Split high-impact file"));
3640 assert!(msg.contains("42.5"));
3641 }
3642
3643 #[test]
3644 fn health_sarif_includes_coverage_gaps() {
3645 use crate::health_types::*;
3646
3647 let root = PathBuf::from("/project");
3648 let report = HealthReport {
3649 summary: HealthSummary {
3650 files_analyzed: 10,
3651 functions_analyzed: 50,
3652 ..Default::default()
3653 },
3654 coverage_gaps: Some(CoverageGaps {
3655 summary: CoverageGapSummary {
3656 runtime_files: 2,
3657 covered_files: 0,
3658 file_coverage_pct: 0.0,
3659 untested_files: 1,
3660 untested_exports: 1,
3661 },
3662 files: vec![UntestedFileFinding::with_actions(
3663 UntestedFile {
3664 path: root.join("src/app.ts"),
3665 value_export_count: 2,
3666 },
3667 &root,
3668 )],
3669 exports: vec![UntestedExportFinding::with_actions(
3670 UntestedExport {
3671 path: root.join("src/app.ts"),
3672 export_name: "loader".into(),
3673 line: 12,
3674 col: 4,
3675 },
3676 &root,
3677 )],
3678 }),
3679 ..Default::default()
3680 };
3681
3682 let sarif = build_health_sarif(&report, &root);
3683 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3684 assert_eq!(entries.len(), 2);
3685 assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
3686 assert_eq!(
3687 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3688 "src/app.ts"
3689 );
3690 assert!(
3691 entries[0]["message"]["text"]
3692 .as_str()
3693 .unwrap()
3694 .contains("2 value exports")
3695 );
3696 assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
3697 assert_eq!(
3698 entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
3699 12
3700 );
3701 assert_eq!(
3702 entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
3703 5
3704 );
3705 }
3706
3707 #[test]
3708 fn health_sarif_rules_have_full_descriptions() {
3709 let root = PathBuf::from("/project");
3710 let report = crate::health_types::HealthReport::default();
3711 let sarif = build_health_sarif(&report, &root);
3712 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
3713 .as_array()
3714 .unwrap();
3715 for rule in rules {
3716 let id = rule["id"].as_str().unwrap();
3717 assert!(
3718 rule.get("fullDescription").is_some(),
3719 "health rule {id} should have fullDescription"
3720 );
3721 assert!(
3722 rule.get("helpUri").is_some(),
3723 "health rule {id} should have helpUri"
3724 );
3725 }
3726 }
3727
3728 #[test]
3729 fn sarif_warn_severity_produces_warning_level() {
3730 let root = PathBuf::from("/project");
3731 let mut results = AnalysisResults::default();
3732 results
3733 .unused_files
3734 .push(UnusedFileFinding::with_actions(UnusedFile {
3735 path: root.join("src/dead.ts"),
3736 }));
3737
3738 let rules = RulesConfig {
3739 unused_files: Severity::Warn,
3740 ..RulesConfig::default()
3741 };
3742
3743 let sarif = build_sarif(&results, &root, &rules);
3744 let entry = &sarif["runs"][0]["results"][0];
3745 assert_eq!(entry["level"], "warning");
3746 }
3747
3748 #[test]
3749 fn sarif_unused_file_has_no_region() {
3750 let root = PathBuf::from("/project");
3751 let mut results = AnalysisResults::default();
3752 results
3753 .unused_files
3754 .push(UnusedFileFinding::with_actions(UnusedFile {
3755 path: root.join("src/dead.ts"),
3756 }));
3757
3758 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3759 let entry = &sarif["runs"][0]["results"][0];
3760 let phys = &entry["locations"][0]["physicalLocation"];
3761 assert!(phys.get("region").is_none());
3762 }
3763
3764 #[test]
3765 fn sarif_unlisted_dep_multiple_import_sites() {
3766 let root = PathBuf::from("/project");
3767 let mut results = AnalysisResults::default();
3768 results
3769 .unlisted_dependencies
3770 .push(UnlistedDependencyFinding::with_actions(
3771 UnlistedDependency {
3772 package_name: "dotenv".to_string(),
3773 imported_from: vec![
3774 ImportSite {
3775 path: root.join("src/a.ts"),
3776 line: 1,
3777 col: 0,
3778 },
3779 ImportSite {
3780 path: root.join("src/b.ts"),
3781 line: 5,
3782 col: 0,
3783 },
3784 ],
3785 },
3786 ));
3787
3788 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3789 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3790 assert_eq!(entries.len(), 2);
3791 assert_eq!(
3792 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3793 "src/a.ts"
3794 );
3795 assert_eq!(
3796 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3797 "src/b.ts"
3798 );
3799 }
3800
3801 #[test]
3802 fn sarif_unlisted_dep_no_import_sites() {
3803 let root = PathBuf::from("/project");
3804 let mut results = AnalysisResults::default();
3805 results
3806 .unlisted_dependencies
3807 .push(UnlistedDependencyFinding::with_actions(
3808 UnlistedDependency {
3809 package_name: "phantom".to_string(),
3810 imported_from: vec![],
3811 },
3812 ));
3813
3814 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3815 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3816 assert!(entries.is_empty());
3817 }
3818}