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