1use std::path::{Path, PathBuf};
4
5use fallow_config::{RulesConfig, Severity};
6use fallow_output::{
7 SarifDocumentInput, SarifResultInput, build_sarif_document, build_sarif_result,
8 issue_output_contracts, normalize_uri,
9};
10use fallow_types::{
11 issue_meta::issue_sarif_rule_description,
12 output_dead_code::*,
13 results::{
14 AnalysisResults, BoundaryCallViolation, BoundaryCoverageViolation, BoundaryViolation,
15 CircularDependency, DuplicatePropShape, DynamicSegmentNameConflict, InvalidClientExport,
16 MisplacedDirective, MixedClientServerBarrel, PolicyViolation, PolicyViolationSeverity,
17 PrivateTypeLeak, PropDrillingChain, RouteCollision, StaleSuppression, TestOnlyDependency,
18 ThinWrapper, TypeOnlyDependency, UnprovidedInject, UnrenderedComponent, UnresolvedImport,
19 UnusedComponentEmit, UnusedComponentInput, UnusedComponentOutput, UnusedComponentProp,
20 UnusedDependency, UnusedExport, UnusedFile, UnusedMember, UnusedServerAction,
21 UnusedSvelteEvent,
22 },
23};
24use rustc_hash::FxHashMap;
25
26fn relative_uri(path: &Path, root: &Path) -> String {
27 normalize_uri(
28 &path
29 .strip_prefix(root)
30 .unwrap_or(path)
31 .display()
32 .to_string(),
33 )
34}
35
36struct SarifFields {
38 rule_id: &'static str,
39 level: &'static str,
40 message: String,
41 uri: String,
42 region: Option<(u32, u32)>,
43 source_path: Option<PathBuf>,
44 properties: Option<serde_json::Value>,
45}
46
47#[derive(Default)]
48struct SourceSnippetCache {
49 files: FxHashMap<PathBuf, Vec<String>>,
50}
51
52impl SourceSnippetCache {
53 fn line(&mut self, path: &Path, line: u32) -> Option<String> {
54 if line == 0 {
55 return None;
56 }
57 if !self.files.contains_key(path) {
58 let lines = std::fs::read_to_string(path)
59 .ok()
60 .map(|source| source.lines().map(str::to_owned).collect())
61 .unwrap_or_default();
62 self.files.insert(path.to_path_buf(), lines);
63 }
64 self.files
65 .get(path)
66 .and_then(|lines| lines.get(line.saturating_sub(1) as usize))
67 .cloned()
68 }
69}
70
71#[derive(Clone, Copy)]
75struct SarifCtx<'a> {
76 results: &'a AnalysisResults,
77 root: &'a Path,
78 rules: &'a RulesConfig,
79}
80
81fn severity_to_sarif_level(s: Severity) -> &'static str {
82 match s {
83 Severity::Error => "error",
84 Severity::Warn => "warning",
85 Severity::Off => unreachable!(),
86 }
87}
88
89fn configured_sarif_level(s: Severity) -> &'static str {
90 match s {
91 Severity::Error | Severity::Warn => severity_to_sarif_level(s),
92 Severity::Off => "none",
93 }
94}
95
96fn sarif_result_with_snippet(
97 rule_id: &str,
98 level: &str,
99 message: &str,
100 uri: &str,
101 region: Option<(u32, u32)>,
102 snippet: Option<&str>,
103) -> serde_json::Value {
104 build_sarif_result(SarifResultInput {
105 rule_id,
106 level,
107 message,
108 uri,
109 region,
110 snippet,
111 })
112}
113
114fn push_sarif_results<T>(
116 sarif_results: &mut Vec<serde_json::Value>,
117 items: &[T],
118 snippets: &mut SourceSnippetCache,
119 mut extract: impl FnMut(&T) -> SarifFields,
120) {
121 for item in items {
122 let fields = extract(item);
123 let source_snippet = fields
124 .source_path
125 .as_deref()
126 .zip(fields.region)
127 .and_then(|(path, (line, _))| snippets.line(path, line));
128 let mut result = sarif_result_with_snippet(
129 fields.rule_id,
130 fields.level,
131 &fields.message,
132 &fields.uri,
133 fields.region,
134 source_snippet.as_deref(),
135 );
136 if let Some(props) = fields.properties {
137 result["properties"] = props;
138 }
139 sarif_results.push(result);
140 }
141}
142
143fn sarif_export_fields(
145 export: &UnusedExport,
146 root: &Path,
147 rule_id: &'static str,
148 level: &'static str,
149 kind: &str,
150 re_kind: &str,
151) -> SarifFields {
152 let label = if export.is_re_export { re_kind } else { kind };
153 SarifFields {
154 rule_id,
155 level,
156 message: format!(
157 "{} '{}' is never imported by other modules",
158 label, export.export_name
159 ),
160 uri: relative_uri(&export.path, root),
161 region: Some((export.line, export.col + 1)),
162 source_path: Some(export.path.clone()),
163 properties: if export.is_re_export {
164 Some(serde_json::json!({ "is_re_export": true }))
165 } else {
166 None
167 },
168 }
169}
170
171fn sarif_private_type_leak_fields(
172 leak: &PrivateTypeLeak,
173 root: &Path,
174 level: &'static str,
175) -> SarifFields {
176 SarifFields {
177 rule_id: "fallow/private-type-leak",
178 level,
179 message: format!(
180 "Export '{}' references private type '{}'",
181 leak.export_name, leak.type_name
182 ),
183 uri: relative_uri(&leak.path, root),
184 region: Some((leak.line, leak.col + 1)),
185 source_path: Some(leak.path.clone()),
186 properties: None,
187 }
188}
189
190fn sarif_dep_fields(
192 dep: &UnusedDependency,
193 root: &Path,
194 rule_id: &'static str,
195 level: &'static str,
196 section: &str,
197) -> SarifFields {
198 let workspace_context = if dep.used_in_workspaces.is_empty() {
199 String::new()
200 } else {
201 let workspaces = dep
202 .used_in_workspaces
203 .iter()
204 .map(|path| relative_uri(path, root))
205 .collect::<Vec<_>>()
206 .join(", ");
207 format!("; imported in other workspaces: {workspaces}")
208 };
209 SarifFields {
210 rule_id,
211 level,
212 message: format!(
213 "Package '{}' is in {} but never imported{}",
214 dep.package_name, section, workspace_context
215 ),
216 uri: relative_uri(&dep.path, root),
217 region: if dep.line > 0 {
218 Some((dep.line, 1))
219 } else {
220 None
221 },
222 source_path: (dep.line > 0).then(|| dep.path.clone()),
223 properties: None,
224 }
225}
226
227fn sarif_member_fields(
229 member: &UnusedMember,
230 root: &Path,
231 rule_id: &'static str,
232 level: &'static str,
233 kind: &str,
234) -> SarifFields {
235 SarifFields {
236 rule_id,
237 level,
238 message: format!(
239 "{} member '{}.{}' is never referenced",
240 kind, member.parent_name, member.member_name
241 ),
242 uri: relative_uri(&member.path, root),
243 region: Some((member.line, member.col + 1)),
244 source_path: Some(member.path.clone()),
245 properties: None,
246 }
247}
248
249fn sarif_unused_file_fields(file: &UnusedFile, root: &Path, level: &'static str) -> SarifFields {
250 SarifFields {
251 rule_id: "fallow/unused-file",
252 level,
253 message: "File is not reachable from any entry point".to_string(),
254 uri: relative_uri(&file.path, root),
255 region: None,
256 source_path: None,
257 properties: None,
258 }
259}
260
261fn sarif_type_only_dep_fields(
262 dep: &TypeOnlyDependency,
263 root: &Path,
264 level: &'static str,
265) -> SarifFields {
266 SarifFields {
267 rule_id: "fallow/type-only-dependency",
268 level,
269 message: format!(
270 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
271 dep.package_name
272 ),
273 uri: relative_uri(&dep.path, root),
274 region: if dep.line > 0 {
275 Some((dep.line, 1))
276 } else {
277 None
278 },
279 source_path: (dep.line > 0).then(|| dep.path.clone()),
280 properties: None,
281 }
282}
283
284fn sarif_test_only_dep_fields(
285 dep: &TestOnlyDependency,
286 root: &Path,
287 level: &'static str,
288) -> SarifFields {
289 SarifFields {
290 rule_id: "fallow/test-only-dependency",
291 level,
292 message: format!(
293 "Package '{}' is only imported by test files (consider moving to devDependencies)",
294 dep.package_name
295 ),
296 uri: relative_uri(&dep.path, root),
297 region: if dep.line > 0 {
298 Some((dep.line, 1))
299 } else {
300 None
301 },
302 source_path: (dep.line > 0).then(|| dep.path.clone()),
303 properties: None,
304 }
305}
306
307fn sarif_unresolved_import_fields(
308 import: &UnresolvedImport,
309 root: &Path,
310 level: &'static str,
311) -> SarifFields {
312 SarifFields {
313 rule_id: "fallow/unresolved-import",
314 level,
315 message: format!("Import '{}' could not be resolved", import.specifier),
316 uri: relative_uri(&import.path, root),
317 region: Some((import.line, import.col + 1)),
318 source_path: Some(import.path.clone()),
319 properties: None,
320 }
321}
322
323fn sarif_circular_dep_fields(
324 cycle: &CircularDependency,
325 root: &Path,
326 level: &'static str,
327) -> SarifFields {
328 let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
329 let mut display_chain = chain.clone();
330 if let Some(first) = chain.first() {
331 display_chain.push(first.clone());
332 }
333 let first_uri = chain.first().map_or_else(String::new, Clone::clone);
334 let first_path = cycle.files.first().cloned();
335 SarifFields {
336 rule_id: "fallow/circular-dependency",
337 level,
338 message: format!(
339 "Circular dependency{}: {}",
340 if cycle.is_cross_package {
341 " (cross-package)"
342 } else {
343 ""
344 },
345 display_chain.join(" \u{2192} ")
346 ),
347 uri: first_uri,
348 region: if cycle.line > 0 {
349 Some((cycle.line, cycle.col + 1))
350 } else {
351 None
352 },
353 source_path: (cycle.line > 0).then_some(first_path).flatten(),
354 properties: None,
355 }
356}
357
358fn sarif_re_export_cycle_fields(
359 cycle: &fallow_types::results::ReExportCycle,
360 root: &Path,
361 level: &'static str,
362) -> SarifFields {
363 let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
364 let first_uri = chain.first().map_or_else(String::new, Clone::clone);
365 let first_path = cycle.files.first().cloned();
366 let kind_tag = match cycle.kind {
367 fallow_types::results::ReExportCycleKind::SelfLoop => " (self-loop)",
368 fallow_types::results::ReExportCycleKind::MultiNode => "",
369 };
370 SarifFields {
371 rule_id: "fallow/re-export-cycle",
372 level,
373 message: format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
374 uri: first_uri,
375 region: None,
376 source_path: first_path,
377 properties: None,
378 }
379}
380
381fn sarif_boundary_violation_fields(
382 violation: &BoundaryViolation,
383 root: &Path,
384 level: &'static str,
385) -> SarifFields {
386 let from_uri = relative_uri(&violation.from_path, root);
387 let to_uri = relative_uri(&violation.to_path, root);
388 SarifFields {
389 rule_id: "fallow/boundary-violation",
390 level,
391 message: format!(
392 "Import from zone '{}' to zone '{}' is not allowed ({})",
393 violation.from_zone, violation.to_zone, to_uri,
394 ),
395 uri: from_uri,
396 region: if violation.line > 0 {
397 Some((violation.line, violation.col + 1))
398 } else {
399 None
400 },
401 source_path: (violation.line > 0).then(|| violation.from_path.clone()),
402 properties: None,
403 }
404}
405
406fn sarif_boundary_coverage_fields(
407 violation: &BoundaryCoverageViolation,
408 root: &Path,
409 level: &'static str,
410) -> SarifFields {
411 SarifFields {
412 rule_id: "fallow/boundary-coverage",
413 level,
414 message: "File does not match any configured architecture boundary zone".to_string(),
415 uri: relative_uri(&violation.path, root),
416 region: Some((violation.line, violation.col + 1)),
417 source_path: Some(violation.path.clone()),
418 properties: None,
419 }
420}
421
422fn sarif_boundary_call_fields(
423 violation: &BoundaryCallViolation,
424 root: &Path,
425 level: &'static str,
426) -> SarifFields {
427 SarifFields {
428 rule_id: "fallow/boundary-call-violation",
429 level,
430 message: format!(
431 "Call to `{}` matches forbidden pattern `{}` in zone '{}'",
432 violation.callee, violation.pattern, violation.zone
433 ),
434 uri: relative_uri(&violation.path, root),
435 region: Some((violation.line, violation.col + 1)),
436 source_path: Some(violation.path.clone()),
437 properties: None,
438 }
439}
440
441fn sarif_policy_violation_fields(violation: &PolicyViolation, root: &Path) -> SarifFields {
442 let level = match violation.severity {
443 PolicyViolationSeverity::Error => "error",
444 PolicyViolationSeverity::Warn => "warning",
445 };
446 let message = match &violation.message {
447 Some(message) => format!(
448 "Policy violation `{}/{}`: `{}` is banned. {message}",
449 violation.pack, violation.rule_id, violation.matched
450 ),
451 None => format!(
452 "Policy violation `{}/{}`: `{}` is banned",
453 violation.pack, violation.rule_id, violation.matched
454 ),
455 };
456 SarifFields {
457 rule_id: "fallow/policy-violation",
458 level,
459 message,
460 uri: relative_uri(&violation.path, root),
461 region: Some((violation.line, violation.col + 1)),
462 source_path: Some(violation.path.clone()),
463 properties: Some(serde_json::json!({
469 "policyRule": format!("{}/{}", violation.pack, violation.rule_id),
470 })),
471 }
472}
473
474fn sarif_invalid_client_export_fields(
475 export: &InvalidClientExport,
476 root: &Path,
477 level: &'static str,
478) -> SarifFields {
479 SarifFields {
480 rule_id: "fallow/invalid-client-export",
481 level,
482 message: format!(
483 "Export '{}' is not allowed in a \"{}\" file (Next.js server-only / route-config name)",
484 export.export_name, export.directive
485 ),
486 uri: relative_uri(&export.path, root),
487 region: Some((export.line, export.col + 1)),
488 source_path: Some(export.path.clone()),
489 properties: None,
490 }
491}
492
493fn sarif_mixed_client_server_barrel_fields(
494 barrel: &MixedClientServerBarrel,
495 root: &Path,
496 level: &'static str,
497) -> SarifFields {
498 SarifFields {
499 rule_id: "fallow/mixed-client-server-barrel",
500 level,
501 message: format!(
502 "Barrel re-exports both a \"use client\" module ('{}') and a server-only module ('{}'); one import drags the other's directive across the boundary",
503 barrel.client_origin, barrel.server_origin
504 ),
505 uri: relative_uri(&barrel.path, root),
506 region: Some((barrel.line, barrel.col + 1)),
507 source_path: Some(barrel.path.clone()),
508 properties: None,
509 }
510}
511
512fn sarif_misplaced_directive_fields(
513 directive_site: &MisplacedDirective,
514 root: &Path,
515 level: &'static str,
516) -> SarifFields {
517 SarifFields {
518 rule_id: "fallow/misplaced-directive",
519 level,
520 message: format!(
521 "Directive \"{}\" is not in the leading position, so the RSC bundler ignores it; move it to the top of the file",
522 directive_site.directive
523 ),
524 uri: relative_uri(&directive_site.path, root),
525 region: Some((directive_site.line, directive_site.col + 1)),
526 source_path: Some(directive_site.path.clone()),
527 properties: None,
528 }
529}
530
531fn sarif_unprovided_inject_fields(
532 inject: &UnprovidedInject,
533 root: &Path,
534 level: &'static str,
535) -> SarifFields {
536 SarifFields {
537 rule_id: "fallow/unprovided-inject",
538 level,
539 message: format!(
540 "inject(\"{}\") has no matching provide(\"{}\") in this project; at runtime it returns undefined; provide the key or remove this inject",
541 inject.key_name, inject.key_name
542 ),
543 uri: relative_uri(&inject.path, root),
544 region: Some((inject.line, inject.col + 1)),
545 source_path: Some(inject.path.clone()),
546 properties: None,
547 }
548}
549
550fn sarif_unrendered_component_fields(
551 component: &UnrenderedComponent,
552 root: &Path,
553 level: &'static str,
554) -> SarifFields {
555 SarifFields {
556 rule_id: "fallow/unrendered-component",
557 level,
558 message: format!(
559 "component \"{}\" is reachable but rendered nowhere in this project; render it somewhere or remove it",
560 component.component_name
561 ),
562 uri: relative_uri(&component.path, root),
563 region: Some((component.line, component.col + 1)),
564 source_path: Some(component.path.clone()),
565 properties: None,
566 }
567}
568
569fn sarif_unused_component_prop_fields(
570 prop: &UnusedComponentProp,
571 root: &Path,
572 level: &'static str,
573) -> SarifFields {
574 SarifFields {
575 rule_id: "fallow/unused-component-prop",
576 level,
577 message: format!(
578 "prop \"{}\" is declared but referenced nowhere inside component \"{}\"; remove it or use it",
579 prop.prop_name, prop.component_name
580 ),
581 uri: relative_uri(&prop.path, root),
582 region: Some((prop.line, prop.col + 1)),
583 source_path: Some(prop.path.clone()),
584 properties: None,
585 }
586}
587
588fn sarif_unused_component_emit_fields(
589 emit: &UnusedComponentEmit,
590 root: &Path,
591 level: &'static str,
592) -> SarifFields {
593 SarifFields {
594 rule_id: "fallow/unused-component-emit",
595 level,
596 message: format!(
597 "emit \"{}\" is declared but emitted nowhere inside component \"{}\"; remove it or emit it",
598 emit.emit_name, emit.component_name
599 ),
600 uri: relative_uri(&emit.path, root),
601 region: Some((emit.line, emit.col + 1)),
602 source_path: Some(emit.path.clone()),
603 properties: None,
604 }
605}
606
607fn sarif_unused_svelte_event_fields(
608 event: &UnusedSvelteEvent,
609 root: &Path,
610 level: &'static str,
611) -> SarifFields {
612 SarifFields {
613 rule_id: "fallow/unused-svelte-event",
614 level,
615 message: format!(
616 "event \"{}\" is dispatched by component \"{}\" but listened to nowhere in the project; remove it or listen for it",
617 event.event_name, event.component_name
618 ),
619 uri: relative_uri(&event.path, root),
620 region: Some((event.line, event.col + 1)),
621 source_path: Some(event.path.clone()),
622 properties: None,
623 }
624}
625
626fn sarif_unused_component_input_fields(
627 input: &UnusedComponentInput,
628 root: &Path,
629 level: &'static str,
630) -> SarifFields {
631 SarifFields {
632 rule_id: "fallow/unused-component-input",
633 level,
634 message: format!(
635 "input \"{}\" is declared but read nowhere inside component \"{}\"; remove it or use it",
636 input.input_name, input.component_name
637 ),
638 uri: relative_uri(&input.path, root),
639 region: Some((input.line, input.col + 1)),
640 source_path: Some(input.path.clone()),
641 properties: None,
642 }
643}
644
645fn sarif_unused_component_output_fields(
646 output: &UnusedComponentOutput,
647 root: &Path,
648 level: &'static str,
649) -> SarifFields {
650 SarifFields {
651 rule_id: "fallow/unused-component-output",
652 level,
653 message: format!(
654 "output \"{}\" is declared but emitted nowhere inside component \"{}\"; remove it or emit it",
655 output.output_name, output.component_name
656 ),
657 uri: relative_uri(&output.path, root),
658 region: Some((output.line, output.col + 1)),
659 source_path: Some(output.path.clone()),
660 properties: None,
661 }
662}
663
664fn sarif_unused_server_action_fields(
665 action: &UnusedServerAction,
666 root: &Path,
667 level: &'static str,
668) -> SarifFields {
669 SarifFields {
670 rule_id: "fallow/unused-server-action",
671 level,
672 message: format!(
673 "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",
674 action.action_name
675 ),
676 uri: relative_uri(&action.path, root),
677 region: Some((action.line, action.col + 1)),
678 source_path: Some(action.path.clone()),
679 properties: None,
680 }
681}
682
683fn sarif_unused_load_data_key_fields(
684 key: &fallow_types::results::UnusedLoadDataKey,
685 root: &Path,
686 level: &'static str,
687) -> SarifFields {
688 SarifFields {
689 rule_id: "fallow/unused-load-data-key",
690 level,
691 message: format!(
692 "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",
693 key.key_name
694 ),
695 uri: relative_uri(&key.path, root),
696 region: Some((key.line, key.col + 1)),
697 source_path: Some(key.path.clone()),
698 properties: None,
699 }
700}
701
702fn sarif_prop_drilling_fields(
703 chain: &PropDrillingChain,
704 root: &Path,
705 level: &'static str,
706) -> SarifFields {
707 let source = chain.hops.first();
710 let consumer = chain.hops.last();
711 let (path, line) = source.map_or((std::path::PathBuf::new(), 1), |h| (h.file.clone(), h.line));
712 let consumer_name = consumer.map_or("a distant component", |h| h.component.as_str());
713 SarifFields {
714 rule_id: "fallow/prop-drilling",
715 level,
716 message: format!(
717 "prop \"{}\" is forwarded unchanged through {} component(s) before \"{}\" consumes it; colocate, lift to context, or compose",
718 chain.prop, chain.depth, consumer_name
719 ),
720 uri: relative_uri(&path, root),
721 region: Some((line, 1)),
722 source_path: Some(path),
723 properties: None,
724 }
725}
726
727fn sarif_thin_wrapper_fields(
728 wrapper: &ThinWrapper,
729 root: &Path,
730 level: &'static str,
731) -> SarifFields {
732 SarifFields {
733 rule_id: "fallow/thin-wrapper",
734 level,
735 message: format!(
736 "\"{}\" is a thin wrapper: its whole body forwards props to \"{}\"; inline it at call sites or delete it",
737 wrapper.component, wrapper.child_component
738 ),
739 uri: relative_uri(&wrapper.file, root),
740 region: Some((wrapper.line, 1)),
741 source_path: Some(wrapper.file.clone()),
742 properties: None,
743 }
744}
745
746fn sarif_duplicate_prop_shape_fields(
747 shape: &DuplicatePropShape,
748 root: &Path,
749 level: &'static str,
750) -> SarifFields {
751 SarifFields {
752 rule_id: "fallow/duplicate-prop-shape",
753 level,
754 message: format!(
755 "\"{}\" shares an identical prop shape {{{}}} with {} other component(s); extract a shared Props type or base component",
756 shape.component,
757 shape.shape.join(", "),
758 shape.group_size.saturating_sub(1)
759 ),
760 uri: relative_uri(&shape.file, root),
761 region: Some((shape.line, 1)),
762 source_path: Some(shape.file.clone()),
763 properties: None,
764 }
765}
766
767fn sarif_route_collision_fields(
768 collision: &RouteCollision,
769 root: &Path,
770 level: &'static str,
771) -> SarifFields {
772 SarifFields {
773 rule_id: "fallow/route-collision",
774 level,
775 message: format!(
776 "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",
777 collision.url,
778 collision.conflicting_paths.len()
779 ),
780 uri: relative_uri(&collision.path, root),
781 region: Some((collision.line, collision.col + 1)),
782 source_path: Some(collision.path.clone()),
783 properties: None,
784 }
785}
786
787fn sarif_dynamic_segment_name_conflict_fields(
788 conflict: &DynamicSegmentNameConflict,
789 root: &Path,
790 level: &'static str,
791) -> SarifFields {
792 SarifFields {
793 rule_id: "fallow/dynamic-segment-name-conflict",
794 level,
795 message: format!(
796 "Dynamic segments at '{}' use different slug names ({}); Next.js requires one consistent name per dynamic path",
797 conflict.position,
798 conflict.conflicting_segments.join(", ")
799 ),
800 uri: relative_uri(&conflict.path, root),
801 region: Some((conflict.line, conflict.col + 1)),
802 source_path: Some(conflict.path.clone()),
803 properties: None,
804 }
805}
806
807fn sarif_stale_suppression_fields(
808 suppression: &StaleSuppression,
809 root: &Path,
810 level: &'static str,
811) -> SarifFields {
812 SarifFields {
813 rule_id: if suppression.missing_reason {
814 "fallow/missing-suppression-reason"
815 } else {
816 "fallow/stale-suppression"
817 },
818 level,
819 message: suppression.display_message(),
820 uri: relative_uri(&suppression.path, root),
821 region: Some((suppression.line, suppression.col + 1)),
822 source_path: Some(suppression.path.clone()),
823 properties: None,
824 }
825}
826
827fn stale_suppression_severity(suppression: &StaleSuppression, rules: &RulesConfig) -> Severity {
828 if suppression.missing_reason {
829 rules.require_suppression_reason
830 } else {
831 rules.stale_suppressions
832 }
833}
834
835fn sarif_unused_catalog_entry_fields(
836 entry: &UnusedCatalogEntryFinding,
837 root: &Path,
838 level: &'static str,
839) -> SarifFields {
840 let entry = &entry.entry;
841 let message = if entry.catalog_name == "default" {
842 format!(
843 "Catalog entry '{}' is not referenced by any workspace package",
844 entry.entry_name
845 )
846 } else {
847 format!(
848 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
849 entry.entry_name, entry.catalog_name
850 )
851 };
852 SarifFields {
853 rule_id: "fallow/unused-catalog-entry",
854 level,
855 message,
856 uri: relative_uri(&entry.path, root),
857 region: Some((entry.line, 1)),
858 source_path: Some(entry.path.clone()),
859 properties: None,
860 }
861}
862
863fn sarif_unused_dependency_override_fields(
864 finding: &UnusedDependencyOverrideFinding,
865 root: &Path,
866 level: &'static str,
867) -> SarifFields {
868 let finding = &finding.entry;
869 let mut message = format!(
870 "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
871 finding.raw_key, finding.version_range, finding.target_package,
872 );
873 if let Some(hint) = &finding.hint {
874 use std::fmt::Write as _;
875 let _ = write!(message, " ({hint})");
876 }
877 SarifFields {
878 rule_id: "fallow/unused-dependency-override",
879 level,
880 message,
881 uri: relative_uri(&finding.path, root),
882 region: Some((finding.line, 1)),
883 source_path: Some(finding.path.clone()),
884 properties: None,
885 }
886}
887
888fn sarif_misconfigured_dependency_override_fields(
889 finding: &MisconfiguredDependencyOverrideFinding,
890 root: &Path,
891 level: &'static str,
892) -> SarifFields {
893 let finding = &finding.entry;
894 let message = format!(
895 "Override `{}` -> `{}` is malformed: {}",
896 finding.raw_key,
897 finding.raw_value,
898 finding.reason.describe(),
899 );
900 SarifFields {
901 rule_id: "fallow/misconfigured-dependency-override",
902 level,
903 message,
904 uri: relative_uri(&finding.path, root),
905 region: Some((finding.line, 1)),
906 source_path: Some(finding.path.clone()),
907 properties: None,
908 }
909}
910
911fn sarif_unresolved_catalog_reference_fields(
912 finding: &UnresolvedCatalogReferenceFinding,
913 root: &Path,
914 level: &'static str,
915) -> SarifFields {
916 let finding = &finding.reference;
917 let catalog_phrase = if finding.catalog_name == "default" {
918 "the default catalog".to_string()
919 } else {
920 format!("catalog '{}'", finding.catalog_name)
921 };
922 let mut message = format!(
923 "Package '{}' is referenced via `catalog:{}` but {} does not declare it",
924 finding.entry_name,
925 if finding.catalog_name == "default" {
926 ""
927 } else {
928 finding.catalog_name.as_str()
929 },
930 catalog_phrase,
931 );
932 if !finding.available_in_catalogs.is_empty() {
933 use std::fmt::Write as _;
934 let _ = write!(
935 message,
936 " (available in: {})",
937 finding.available_in_catalogs.join(", ")
938 );
939 }
940 SarifFields {
941 rule_id: "fallow/unresolved-catalog-reference",
942 level,
943 message,
944 uri: relative_uri(&finding.path, root),
945 region: Some((finding.line, 1)),
946 source_path: Some(finding.path.clone()),
947 properties: None,
948 }
949}
950
951fn sarif_empty_catalog_group_fields(
952 group: &EmptyCatalogGroupFinding,
953 root: &Path,
954 level: &'static str,
955) -> SarifFields {
956 let group = &group.group;
957 SarifFields {
958 rule_id: "fallow/empty-catalog-group",
959 level,
960 message: format!("Catalog group '{}' has no entries", group.catalog_name),
961 uri: relative_uri(&group.path, root),
962 region: Some((group.line, 1)),
963 source_path: Some(group.path.clone()),
964 properties: None,
965 }
966}
967
968fn push_sarif_unlisted_deps(
971 sarif_results: &mut Vec<serde_json::Value>,
972 deps: &[UnlistedDependencyFinding],
973 root: &Path,
974 level: &'static str,
975 snippets: &mut SourceSnippetCache,
976) {
977 for entry in deps {
978 let dep = &entry.dep;
979 for site in &dep.imported_from {
980 let uri = relative_uri(&site.path, root);
981 let source_snippet = snippets.line(&site.path, site.line);
982 sarif_results.push(sarif_result_with_snippet(
983 "fallow/unlisted-dependency",
984 level,
985 &format!(
986 "Package '{}' is imported but not listed in package.json",
987 dep.package_name
988 ),
989 &uri,
990 Some((site.line, site.col + 1)),
991 source_snippet.as_deref(),
992 ));
993 }
994 }
995}
996
997fn push_sarif_duplicate_exports(
1000 sarif_results: &mut Vec<serde_json::Value>,
1001 dups: &[DuplicateExportFinding],
1002 root: &Path,
1003 level: &'static str,
1004 snippets: &mut SourceSnippetCache,
1005) {
1006 for dup in dups {
1007 let dup = &dup.export;
1008 for loc in &dup.locations {
1009 let uri = relative_uri(&loc.path, root);
1010 let source_snippet = snippets.line(&loc.path, loc.line);
1011 sarif_results.push(sarif_result_with_snippet(
1012 "fallow/duplicate-export",
1013 level,
1014 &format!("Export '{}' appears in multiple modules", dup.export_name),
1015 &uri,
1016 Some((loc.line, loc.col + 1)),
1017 source_snippet.as_deref(),
1018 ));
1019 }
1020 }
1021}
1022
1023fn build_sarif_rules(
1025 rules: &RulesConfig,
1026 rule_builder: &dyn Fn(&str, &str, &str) -> serde_json::Value,
1027) -> Vec<serde_json::Value> {
1028 let mut sarif_rules = Vec::new();
1029 for contract in issue_output_contracts() {
1030 for rule_id in contract.sarif_rule_ids {
1031 let severity = sarif_rule_severity(rules, contract.code, &rule_id);
1032 let description = issue_sarif_rule_description(&rule_id).unwrap_or_else(|| {
1033 panic!("dead-code SARIF rule {rule_id} is missing issue metadata")
1034 });
1035 sarif_rules.push(rule_builder(
1036 &rule_id,
1037 description,
1038 configured_sarif_level(severity),
1039 ));
1040 }
1041 }
1042 sarif_rules
1043}
1044
1045fn sarif_rule_severity(rules: &RulesConfig, issue_code: &str, rule_id: &str) -> Severity {
1046 if rule_id == "fallow/missing-suppression-reason" {
1047 return rules.require_suppression_reason;
1048 }
1049 dead_code_rule_severity(rules, issue_code)
1050 .unwrap_or_else(|| panic!("dead-code SARIF rule {rule_id} has no severity mapping"))
1051}
1052
1053fn dead_code_rule_severity(rules: &RulesConfig, issue_code: &str) -> Option<Severity> {
1054 let severity = match issue_code {
1055 "unused-file" => rules.unused_files,
1056 "unused-export" => rules.unused_exports,
1057 "unused-type" => rules.unused_types,
1058 "private-type-leak" => rules.private_type_leaks,
1059 "unused-dependency" => rules.unused_dependencies,
1060 "unused-dev-dependency" => rules.unused_dev_dependencies,
1061 "unused-optional-dependency" => rules.unused_optional_dependencies,
1062 "type-only-dependency" => rules.type_only_dependencies,
1063 "test-only-dependency" => rules.test_only_dependencies,
1064 "unused-enum-member" => rules.unused_enum_members,
1065 "unused-class-member" => rules.unused_class_members,
1066 "unused-store-member" => rules.unused_store_members,
1067 "unresolved-import" => rules.unresolved_imports,
1068 "unlisted-dependency" => rules.unlisted_dependencies,
1069 "duplicate-export" => rules.duplicate_exports,
1070 "circular-dependency" => rules.circular_dependencies,
1071 "re-export-cycle" => rules.re_export_cycle,
1072 "boundary-violation" | "boundary-coverage" | "boundary-call-violation" => {
1073 rules.boundary_violation
1074 }
1075 "policy-violation" => rules.policy_violation,
1076 "invalid-client-export" => rules.invalid_client_export,
1077 "mixed-client-server-barrel" => rules.mixed_client_server_barrel,
1078 "misplaced-directive" => rules.misplaced_directive,
1079 "unprovided-inject" => rules.unprovided_injects,
1080 "unrendered-component" => rules.unrendered_components,
1081 "unused-component-prop" => rules.unused_component_props,
1082 "unused-component-emit" => rules.unused_component_emits,
1083 "unused-component-input" => rules.unused_component_inputs,
1084 "unused-component-output" => rules.unused_component_outputs,
1085 "unused-svelte-event" => rules.unused_svelte_events,
1086 "unused-server-action" => rules.unused_server_actions,
1087 "unused-load-data-key" => rules.unused_load_data_keys,
1088 "prop-drilling" => rules.prop_drilling,
1089 "thin-wrapper" => rules.thin_wrapper,
1090 "duplicate-prop-shape" => rules.duplicate_prop_shape,
1091 "route-collision" => rules.route_collision,
1092 "dynamic-segment-name-conflict" => rules.dynamic_segment_name_conflict,
1093 "stale-suppression" => rules.stale_suppressions,
1094 "unused-catalog-entry" => rules.unused_catalog_entries,
1095 "empty-catalog-group" => rules.empty_catalog_groups,
1096 "unresolved-catalog-reference" => rules.unresolved_catalog_references,
1097 "unused-dependency-override" => rules.unused_dependency_overrides,
1098 "misconfigured-dependency-override" => rules.misconfigured_dependency_overrides,
1099 _ => return None,
1100 };
1101 Some(severity)
1102}
1103
1104#[must_use]
1105pub fn build_sarif(
1106 results: &AnalysisResults,
1107 root: &Path,
1108 rules: &RulesConfig,
1109 rule_builder: &dyn Fn(&str, &str, &str) -> serde_json::Value,
1110) -> serde_json::Value {
1111 let mut sarif_results = Vec::new();
1112 let mut snippets = SourceSnippetCache::default();
1113 let ctx = SarifCtx {
1114 results,
1115 root,
1116 rules,
1117 };
1118
1119 push_primary_dead_code_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1120 push_dependency_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1121 push_member_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1122 push_sarif_results(
1123 &mut sarif_results,
1124 &results.unresolved_imports,
1125 &mut snippets,
1126 |i| {
1127 sarif_unresolved_import_fields(
1128 &i.import,
1129 root,
1130 severity_to_sarif_level(rules.unresolved_imports),
1131 )
1132 },
1133 );
1134 push_misc_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1135 push_graph_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1136 push_catalog_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1137
1138 let sarif_rules = build_sarif_rules(rules, rule_builder);
1139 sarif_document(&sarif_results, &sarif_rules)
1140}
1141
1142fn push_primary_dead_code_sarif_results(
1143 sarif_results: &mut Vec<serde_json::Value>,
1144 ctx: &SarifCtx<'_>,
1145 snippets: &mut SourceSnippetCache,
1146) {
1147 let SarifCtx {
1148 results,
1149 root,
1150 rules,
1151 } = *ctx;
1152
1153 push_sarif_results(sarif_results, &results.unused_files, snippets, |finding| {
1154 sarif_unused_file_fields(
1155 &finding.file,
1156 root,
1157 severity_to_sarif_level(rules.unused_files),
1158 )
1159 });
1160 push_sarif_results(
1161 sarif_results,
1162 &results.unused_exports,
1163 snippets,
1164 |finding| {
1165 sarif_export_fields(
1166 &finding.export,
1167 root,
1168 "fallow/unused-export",
1169 severity_to_sarif_level(rules.unused_exports),
1170 "Export",
1171 "Re-export",
1172 )
1173 },
1174 );
1175 push_sarif_results(sarif_results, &results.unused_types, snippets, |finding| {
1176 sarif_export_fields(
1177 &finding.export,
1178 root,
1179 "fallow/unused-type",
1180 severity_to_sarif_level(rules.unused_types),
1181 "Type export",
1182 "Type re-export",
1183 )
1184 });
1185 push_sarif_results(
1186 sarif_results,
1187 &results.private_type_leaks,
1188 snippets,
1189 |finding| {
1190 sarif_private_type_leak_fields(
1191 &finding.leak,
1192 root,
1193 severity_to_sarif_level(rules.private_type_leaks),
1194 )
1195 },
1196 );
1197}
1198
1199fn sarif_document(
1200 sarif_results: &[serde_json::Value],
1201 sarif_rules: &[serde_json::Value],
1202) -> serde_json::Value {
1203 build_sarif_document(SarifDocumentInput {
1204 results: sarif_results,
1205 rules: sarif_rules,
1206 tool_version: env!("CARGO_PKG_VERSION"),
1207 })
1208}
1209
1210fn push_dependency_sarif_results(
1211 sarif_results: &mut Vec<serde_json::Value>,
1212 ctx: &SarifCtx<'_>,
1213 snippets: &mut SourceSnippetCache,
1214) {
1215 push_unused_dependency_sarif_results(sarif_results, ctx, snippets);
1216 push_classified_dependency_sarif_results(sarif_results, ctx, snippets);
1217}
1218
1219fn push_unused_dependency_sarif_results(
1221 sarif_results: &mut Vec<serde_json::Value>,
1222 ctx: &SarifCtx<'_>,
1223 snippets: &mut SourceSnippetCache,
1224) {
1225 let SarifCtx {
1226 results,
1227 root,
1228 rules,
1229 } = *ctx;
1230
1231 push_sarif_results(sarif_results, &results.unused_dependencies, snippets, |d| {
1232 sarif_dep_fields(
1233 &d.dep,
1234 root,
1235 "fallow/unused-dependency",
1236 severity_to_sarif_level(rules.unused_dependencies),
1237 "dependencies",
1238 )
1239 });
1240 push_sarif_results(
1241 sarif_results,
1242 &results.unused_dev_dependencies,
1243 snippets,
1244 |d| {
1245 sarif_dep_fields(
1246 &d.dep,
1247 root,
1248 "fallow/unused-dev-dependency",
1249 severity_to_sarif_level(rules.unused_dev_dependencies),
1250 "devDependencies",
1251 )
1252 },
1253 );
1254 push_sarif_results(
1255 sarif_results,
1256 &results.unused_optional_dependencies,
1257 snippets,
1258 |d| {
1259 sarif_dep_fields(
1260 &d.dep,
1261 root,
1262 "fallow/unused-optional-dependency",
1263 severity_to_sarif_level(rules.unused_optional_dependencies),
1264 "optionalDependencies",
1265 )
1266 },
1267 );
1268}
1269
1270fn push_classified_dependency_sarif_results(
1272 sarif_results: &mut Vec<serde_json::Value>,
1273 ctx: &SarifCtx<'_>,
1274 snippets: &mut SourceSnippetCache,
1275) {
1276 let SarifCtx {
1277 results,
1278 root,
1279 rules,
1280 } = *ctx;
1281
1282 push_sarif_results(
1283 sarif_results,
1284 &results.type_only_dependencies,
1285 snippets,
1286 |d| {
1287 sarif_type_only_dep_fields(
1288 &d.dep,
1289 root,
1290 severity_to_sarif_level(rules.type_only_dependencies),
1291 )
1292 },
1293 );
1294 push_sarif_results(
1295 sarif_results,
1296 &results.test_only_dependencies,
1297 snippets,
1298 |d| {
1299 sarif_test_only_dep_fields(
1300 &d.dep,
1301 root,
1302 severity_to_sarif_level(rules.test_only_dependencies),
1303 )
1304 },
1305 );
1306}
1307
1308fn push_member_sarif_results(
1309 sarif_results: &mut Vec<serde_json::Value>,
1310 ctx: &SarifCtx<'_>,
1311 snippets: &mut SourceSnippetCache,
1312) {
1313 let SarifCtx {
1314 results,
1315 root,
1316 rules,
1317 } = *ctx;
1318
1319 push_sarif_results(sarif_results, &results.unused_enum_members, snippets, |m| {
1320 sarif_member_fields(
1321 &m.member,
1322 root,
1323 "fallow/unused-enum-member",
1324 severity_to_sarif_level(rules.unused_enum_members),
1325 "Enum",
1326 )
1327 });
1328 push_sarif_results(
1329 sarif_results,
1330 &results.unused_class_members,
1331 snippets,
1332 |m| {
1333 sarif_member_fields(
1334 &m.member,
1335 root,
1336 "fallow/unused-class-member",
1337 severity_to_sarif_level(rules.unused_class_members),
1338 "Class",
1339 )
1340 },
1341 );
1342 push_sarif_results(
1343 sarif_results,
1344 &results.unused_store_members,
1345 snippets,
1346 |m| {
1347 sarif_member_fields(
1348 &m.member,
1349 root,
1350 "fallow/unused-store-member",
1351 severity_to_sarif_level(rules.unused_store_members),
1352 "Store",
1353 )
1354 },
1355 );
1356}
1357
1358fn push_misc_sarif_results(
1359 sarif_results: &mut Vec<serde_json::Value>,
1360 ctx: &SarifCtx<'_>,
1361 snippets: &mut SourceSnippetCache,
1362) {
1363 let SarifCtx {
1364 results,
1365 root,
1366 rules,
1367 } = *ctx;
1368
1369 if !results.unlisted_dependencies.is_empty() {
1370 push_sarif_unlisted_deps(
1371 sarif_results,
1372 &results.unlisted_dependencies,
1373 root,
1374 severity_to_sarif_level(rules.unlisted_dependencies),
1375 snippets,
1376 );
1377 }
1378 if !results.duplicate_exports.is_empty() {
1379 push_sarif_duplicate_exports(
1380 sarif_results,
1381 &results.duplicate_exports,
1382 root,
1383 severity_to_sarif_level(rules.duplicate_exports),
1384 snippets,
1385 );
1386 }
1387}
1388
1389fn push_component_contract_sarif_results(
1393 sarif_results: &mut Vec<serde_json::Value>,
1394 ctx: &SarifCtx<'_>,
1395 snippets: &mut SourceSnippetCache,
1396) {
1397 push_component_member_sarif_results(sarif_results, ctx, snippets);
1398 push_component_framework_sarif_results(sarif_results, ctx, snippets);
1399 push_component_shape_sarif_results(sarif_results, ctx, snippets);
1400}
1401
1402fn push_component_member_sarif_results(
1404 sarif_results: &mut Vec<serde_json::Value>,
1405 ctx: &SarifCtx<'_>,
1406 snippets: &mut SourceSnippetCache,
1407) {
1408 let SarifCtx {
1409 results,
1410 root,
1411 rules,
1412 } = *ctx;
1413
1414 push_sarif_results(
1415 sarif_results,
1416 &results.unused_component_props,
1417 snippets,
1418 |p| {
1419 sarif_unused_component_prop_fields(
1420 &p.prop,
1421 root,
1422 severity_to_sarif_level(rules.unused_component_props),
1423 )
1424 },
1425 );
1426 push_sarif_results(
1427 sarif_results,
1428 &results.unused_component_emits,
1429 snippets,
1430 |e| {
1431 sarif_unused_component_emit_fields(
1432 &e.emit,
1433 root,
1434 severity_to_sarif_level(rules.unused_component_emits),
1435 )
1436 },
1437 );
1438 push_sarif_results(
1439 sarif_results,
1440 &results.unused_component_inputs,
1441 snippets,
1442 |i| {
1443 sarif_unused_component_input_fields(
1444 &i.input,
1445 root,
1446 severity_to_sarif_level(rules.unused_component_inputs),
1447 )
1448 },
1449 );
1450 push_sarif_results(
1451 sarif_results,
1452 &results.unused_component_outputs,
1453 snippets,
1454 |o| {
1455 sarif_unused_component_output_fields(
1456 &o.output,
1457 root,
1458 severity_to_sarif_level(rules.unused_component_outputs),
1459 )
1460 },
1461 );
1462}
1463
1464fn push_component_framework_sarif_results(
1466 sarif_results: &mut Vec<serde_json::Value>,
1467 ctx: &SarifCtx<'_>,
1468 snippets: &mut SourceSnippetCache,
1469) {
1470 let SarifCtx {
1471 results,
1472 root,
1473 rules,
1474 } = *ctx;
1475
1476 push_sarif_results(
1477 sarif_results,
1478 &results.unused_svelte_events,
1479 snippets,
1480 |e| {
1481 sarif_unused_svelte_event_fields(
1482 &e.event,
1483 root,
1484 severity_to_sarif_level(rules.unused_svelte_events),
1485 )
1486 },
1487 );
1488 push_sarif_results(
1489 sarif_results,
1490 &results.unused_server_actions,
1491 snippets,
1492 |a| {
1493 sarif_unused_server_action_fields(
1494 &a.action,
1495 root,
1496 severity_to_sarif_level(rules.unused_server_actions),
1497 )
1498 },
1499 );
1500 push_sarif_results(
1501 sarif_results,
1502 &results.unused_load_data_keys,
1503 snippets,
1504 |k| {
1505 sarif_unused_load_data_key_fields(
1506 &k.key,
1507 root,
1508 severity_to_sarif_level(rules.unused_load_data_keys),
1509 )
1510 },
1511 );
1512}
1513
1514fn push_component_shape_sarif_results(
1516 sarif_results: &mut Vec<serde_json::Value>,
1517 ctx: &SarifCtx<'_>,
1518 snippets: &mut SourceSnippetCache,
1519) {
1520 let SarifCtx {
1521 results,
1522 root,
1523 rules,
1524 } = *ctx;
1525
1526 push_sarif_results(
1527 sarif_results,
1528 &results.prop_drilling_chains,
1529 snippets,
1530 |c| {
1531 sarif_prop_drilling_fields(&c.chain, root, severity_to_sarif_level(rules.prop_drilling))
1532 },
1533 );
1534 push_sarif_results(sarif_results, &results.thin_wrappers, snippets, |w| {
1535 sarif_thin_wrapper_fields(
1536 &w.wrapper,
1537 root,
1538 severity_to_sarif_level(rules.thin_wrapper),
1539 )
1540 });
1541 push_sarif_results(
1542 sarif_results,
1543 &results.duplicate_prop_shapes,
1544 snippets,
1545 |d| {
1546 sarif_duplicate_prop_shape_fields(
1547 &d.shape,
1548 root,
1549 severity_to_sarif_level(rules.duplicate_prop_shape),
1550 )
1551 },
1552 );
1553}
1554
1555fn push_graph_sarif_results(
1556 sarif_results: &mut Vec<serde_json::Value>,
1557 ctx: &SarifCtx<'_>,
1558 snippets: &mut SourceSnippetCache,
1559) {
1560 push_structure_sarif_results(sarif_results, ctx, snippets);
1561 push_framework_sarif_results(sarif_results, ctx, snippets);
1562 push_route_sarif_results(sarif_results, ctx, snippets);
1563 push_suppression_sarif_results(sarif_results, ctx, snippets);
1564}
1565
1566fn push_structure_sarif_results(
1567 sarif_results: &mut Vec<serde_json::Value>,
1568 ctx: &SarifCtx<'_>,
1569 snippets: &mut SourceSnippetCache,
1570) {
1571 push_cycle_sarif_results(sarif_results, ctx, snippets);
1572 push_boundary_sarif_results(sarif_results, ctx, snippets);
1573}
1574
1575fn push_cycle_sarif_results(
1577 sarif_results: &mut Vec<serde_json::Value>,
1578 ctx: &SarifCtx<'_>,
1579 snippets: &mut SourceSnippetCache,
1580) {
1581 let SarifCtx {
1582 results,
1583 root,
1584 rules,
1585 } = *ctx;
1586
1587 push_sarif_results(
1588 sarif_results,
1589 &results.circular_dependencies,
1590 snippets,
1591 |c| {
1592 sarif_circular_dep_fields(
1593 &c.cycle,
1594 root,
1595 severity_to_sarif_level(rules.circular_dependencies),
1596 )
1597 },
1598 );
1599 push_sarif_results(sarif_results, &results.re_export_cycles, snippets, |c| {
1600 sarif_re_export_cycle_fields(
1601 &c.cycle,
1602 root,
1603 severity_to_sarif_level(rules.re_export_cycle),
1604 )
1605 });
1606}
1607
1608fn push_boundary_sarif_results(
1610 sarif_results: &mut Vec<serde_json::Value>,
1611 ctx: &SarifCtx<'_>,
1612 snippets: &mut SourceSnippetCache,
1613) {
1614 let SarifCtx {
1615 results,
1616 root,
1617 rules,
1618 } = *ctx;
1619
1620 push_sarif_results(sarif_results, &results.boundary_violations, snippets, |v| {
1621 sarif_boundary_violation_fields(
1622 &v.violation,
1623 root,
1624 severity_to_sarif_level(rules.boundary_violation),
1625 )
1626 });
1627 push_sarif_results(
1628 sarif_results,
1629 &results.boundary_coverage_violations,
1630 snippets,
1631 |v| {
1632 sarif_boundary_coverage_fields(
1633 &v.violation,
1634 root,
1635 severity_to_sarif_level(rules.boundary_violation),
1636 )
1637 },
1638 );
1639 push_sarif_results(
1640 sarif_results,
1641 &results.boundary_call_violations,
1642 snippets,
1643 |v| {
1644 sarif_boundary_call_fields(
1645 &v.violation,
1646 root,
1647 severity_to_sarif_level(rules.boundary_violation),
1648 )
1649 },
1650 );
1651 push_sarif_results(sarif_results, &results.policy_violations, snippets, |v| {
1652 sarif_policy_violation_fields(&v.violation, root)
1653 });
1654}
1655
1656fn push_framework_sarif_results(
1657 sarif_results: &mut Vec<serde_json::Value>,
1658 ctx: &SarifCtx<'_>,
1659 snippets: &mut SourceSnippetCache,
1660) {
1661 push_framework_boundary_sarif_results(sarif_results, ctx, snippets);
1662 push_component_contract_sarif_results(sarif_results, ctx, snippets);
1663}
1664
1665fn push_framework_boundary_sarif_results(
1667 sarif_results: &mut Vec<serde_json::Value>,
1668 ctx: &SarifCtx<'_>,
1669 snippets: &mut SourceSnippetCache,
1670) {
1671 let SarifCtx {
1672 results,
1673 root,
1674 rules,
1675 } = *ctx;
1676
1677 push_sarif_results(
1678 sarif_results,
1679 &results.invalid_client_exports,
1680 snippets,
1681 |e| {
1682 sarif_invalid_client_export_fields(
1683 &e.export,
1684 root,
1685 severity_to_sarif_level(rules.invalid_client_export),
1686 )
1687 },
1688 );
1689 push_sarif_results(
1690 sarif_results,
1691 &results.mixed_client_server_barrels,
1692 snippets,
1693 |b| {
1694 sarif_mixed_client_server_barrel_fields(
1695 &b.barrel,
1696 root,
1697 severity_to_sarif_level(rules.mixed_client_server_barrel),
1698 )
1699 },
1700 );
1701 push_sarif_results(
1702 sarif_results,
1703 &results.misplaced_directives,
1704 snippets,
1705 |d| {
1706 sarif_misplaced_directive_fields(
1707 &d.directive_site,
1708 root,
1709 severity_to_sarif_level(rules.misplaced_directive),
1710 )
1711 },
1712 );
1713 push_sarif_results(sarif_results, &results.unprovided_injects, snippets, |i| {
1714 sarif_unprovided_inject_fields(
1715 &i.inject,
1716 root,
1717 severity_to_sarif_level(rules.unprovided_injects),
1718 )
1719 });
1720 push_sarif_results(
1721 sarif_results,
1722 &results.unrendered_components,
1723 snippets,
1724 |c| {
1725 sarif_unrendered_component_fields(
1726 &c.component,
1727 root,
1728 severity_to_sarif_level(rules.unrendered_components),
1729 )
1730 },
1731 );
1732}
1733
1734fn push_route_sarif_results(
1735 sarif_results: &mut Vec<serde_json::Value>,
1736 ctx: &SarifCtx<'_>,
1737 snippets: &mut SourceSnippetCache,
1738) {
1739 let SarifCtx {
1740 results,
1741 root,
1742 rules,
1743 } = *ctx;
1744
1745 push_sarif_results(sarif_results, &results.route_collisions, snippets, |c| {
1746 sarif_route_collision_fields(
1747 &c.collision,
1748 root,
1749 severity_to_sarif_level(rules.route_collision),
1750 )
1751 });
1752 push_sarif_results(
1753 sarif_results,
1754 &results.dynamic_segment_name_conflicts,
1755 snippets,
1756 |c| {
1757 sarif_dynamic_segment_name_conflict_fields(
1758 &c.conflict,
1759 root,
1760 severity_to_sarif_level(rules.dynamic_segment_name_conflict),
1761 )
1762 },
1763 );
1764}
1765
1766fn push_suppression_sarif_results(
1767 sarif_results: &mut Vec<serde_json::Value>,
1768 ctx: &SarifCtx<'_>,
1769 snippets: &mut SourceSnippetCache,
1770) {
1771 let SarifCtx {
1772 results,
1773 root,
1774 rules,
1775 } = *ctx;
1776
1777 push_sarif_results(sarif_results, &results.stale_suppressions, snippets, |s| {
1778 sarif_stale_suppression_fields(
1779 s,
1780 root,
1781 severity_to_sarif_level(stale_suppression_severity(s, rules)),
1782 )
1783 });
1784}
1785
1786fn push_catalog_sarif_results(
1787 sarif_results: &mut Vec<serde_json::Value>,
1788 ctx: &SarifCtx<'_>,
1789 snippets: &mut SourceSnippetCache,
1790) {
1791 push_catalog_entry_sarif_results(sarif_results, ctx, snippets);
1792 push_dependency_override_sarif_results(sarif_results, ctx, snippets);
1793}
1794
1795fn push_catalog_entry_sarif_results(
1797 sarif_results: &mut Vec<serde_json::Value>,
1798 ctx: &SarifCtx<'_>,
1799 snippets: &mut SourceSnippetCache,
1800) {
1801 let SarifCtx {
1802 results,
1803 root,
1804 rules,
1805 } = *ctx;
1806
1807 push_sarif_results(
1808 sarif_results,
1809 &results.unused_catalog_entries,
1810 snippets,
1811 |e| {
1812 sarif_unused_catalog_entry_fields(
1813 e,
1814 root,
1815 severity_to_sarif_level(rules.unused_catalog_entries),
1816 )
1817 },
1818 );
1819 push_sarif_results(
1820 sarif_results,
1821 &results.empty_catalog_groups,
1822 snippets,
1823 |g| {
1824 sarif_empty_catalog_group_fields(
1825 g,
1826 root,
1827 severity_to_sarif_level(rules.empty_catalog_groups),
1828 )
1829 },
1830 );
1831 push_sarif_results(
1832 sarif_results,
1833 &results.unresolved_catalog_references,
1834 snippets,
1835 |f| {
1836 sarif_unresolved_catalog_reference_fields(
1837 f,
1838 root,
1839 severity_to_sarif_level(rules.unresolved_catalog_references),
1840 )
1841 },
1842 );
1843}
1844
1845fn push_dependency_override_sarif_results(
1847 sarif_results: &mut Vec<serde_json::Value>,
1848 ctx: &SarifCtx<'_>,
1849 snippets: &mut SourceSnippetCache,
1850) {
1851 let SarifCtx {
1852 results,
1853 root,
1854 rules,
1855 } = *ctx;
1856
1857 push_sarif_results(
1858 sarif_results,
1859 &results.unused_dependency_overrides,
1860 snippets,
1861 |f| {
1862 sarif_unused_dependency_override_fields(
1863 f,
1864 root,
1865 severity_to_sarif_level(rules.unused_dependency_overrides),
1866 )
1867 },
1868 );
1869 push_sarif_results(
1870 sarif_results,
1871 &results.misconfigured_dependency_overrides,
1872 snippets,
1873 |f| {
1874 sarif_misconfigured_dependency_override_fields(
1875 f,
1876 root,
1877 severity_to_sarif_level(rules.misconfigured_dependency_overrides),
1878 )
1879 },
1880 );
1881}
1882
1883#[cfg(test)]
1884mod tests {
1885 use std::collections::BTreeSet;
1886 use std::path::Path;
1887
1888 use fallow_config::RulesConfig;
1889 use fallow_types::results::AnalysisResults;
1890
1891 use super::*;
1892
1893 fn test_rule_builder(id: &str, description: &str, level: &str) -> serde_json::Value {
1894 serde_json::json!({
1895 "id": id,
1896 "shortDescription": { "text": description },
1897 "defaultConfiguration": { "level": level }
1898 })
1899 }
1900
1901 #[test]
1902 fn sarif_rule_list_is_backed_by_issue_contracts() {
1903 let sarif = build_sarif(
1904 &AnalysisResults::default(),
1905 Path::new("."),
1906 &RulesConfig::default(),
1907 &test_rule_builder,
1908 );
1909 let Some(rules) = sarif
1910 .pointer("/runs/0/tool/driver/rules")
1911 .and_then(serde_json::Value::as_array)
1912 else {
1913 panic!("SARIF document should contain driver rules");
1914 };
1915
1916 let actual_ids = rules
1917 .iter()
1918 .filter_map(|rule| {
1919 rule.get("id")
1920 .and_then(serde_json::Value::as_str)
1921 .map(str::to_owned)
1922 })
1923 .collect::<BTreeSet<_>>();
1924 let expected_ids = issue_output_contracts()
1925 .flat_map(|contract| contract.sarif_rule_ids)
1926 .collect::<BTreeSet<_>>();
1927
1928 assert_eq!(actual_ids, expected_ids);
1929
1930 for rule in rules {
1931 let id = rule
1932 .get("id")
1933 .and_then(serde_json::Value::as_str)
1934 .expect("SARIF rule should have id");
1935 let description = rule
1936 .pointer("/shortDescription/text")
1937 .and_then(serde_json::Value::as_str)
1938 .expect("SARIF rule should have short description");
1939 assert_eq!(
1940 description,
1941 issue_sarif_rule_description(id).expect("SARIF rule description should resolve")
1942 );
1943 }
1944 }
1945
1946 #[test]
1947 fn sarif_registry_descriptions_match_explain_short_descriptions() {
1948 let mismatches = issue_output_contracts()
1949 .flat_map(|contract| contract.sarif_rule_ids)
1950 .filter_map(|rule_id| {
1951 let registry = issue_sarif_rule_description(&rule_id)?;
1952 let explain = crate::explain::rule_by_id(&rule_id)?;
1953 (registry != explain.short).then_some(format!(
1954 "{rule_id}: registry={registry:?}, explain={:?}",
1955 explain.short
1956 ))
1957 })
1958 .collect::<Vec<_>>();
1959
1960 assert!(
1961 mismatches.is_empty(),
1962 "SARIF registry descriptions drifted from explain short descriptions:\n{}",
1963 mismatches.join("\n")
1964 );
1965 }
1966}