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