1use std::path::{Path, PathBuf};
2use std::process::ExitCode;
3
4use fallow_config::{RulesConfig, Severity};
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::{
7 AnalysisResults, BoundaryCallViolation, BoundaryCoverageViolation, BoundaryViolation,
8 CircularDependency, DuplicateExportFinding, DuplicatePropShape, DynamicSegmentNameConflict,
9 EmptyCatalogGroupFinding, InvalidClientExport, MisconfiguredDependencyOverrideFinding,
10 MisplacedDirective, MixedClientServerBarrel, PolicyViolation, PolicyViolationSeverity,
11 PrivateTypeLeak, PropDrillingChain, RouteCollision, StaleSuppression, TestOnlyDependency,
12 ThinWrapper, TypeOnlyDependency, UnlistedDependencyFinding, UnprovidedInject,
13 UnrenderedComponent, UnresolvedCatalogReferenceFinding, UnresolvedImport,
14 UnusedCatalogEntryFinding, UnusedComponentEmit, UnusedComponentInput, UnusedComponentOutput,
15 UnusedComponentProp, UnusedDependency, UnusedDependencyOverrideFinding, UnusedExport,
16 UnusedFile, UnusedMember, UnusedServerAction, UnusedSvelteEvent,
17};
18use rustc_hash::FxHashMap;
19
20use super::ci::{fingerprint, severity};
21use super::grouping::{self, OwnershipResolver};
22use super::{emit_json, relative_uri};
23use crate::explain;
24
25struct SarifFields {
27 rule_id: &'static str,
28 level: &'static str,
29 message: String,
30 uri: String,
31 region: Option<(u32, u32)>,
32 source_path: Option<PathBuf>,
33 properties: Option<serde_json::Value>,
34}
35
36#[derive(Default)]
37struct SourceSnippetCache {
38 files: FxHashMap<PathBuf, Vec<String>>,
39}
40
41impl SourceSnippetCache {
42 fn line(&mut self, path: &Path, line: u32) -> Option<String> {
43 if line == 0 {
44 return None;
45 }
46 if !self.files.contains_key(path) {
47 let lines = std::fs::read_to_string(path)
48 .ok()
49 .map(|source| source.lines().map(str::to_owned).collect())
50 .unwrap_or_default();
51 self.files.insert(path.to_path_buf(), lines);
52 }
53 self.files
54 .get(path)
55 .and_then(|lines| lines.get(line.saturating_sub(1) as usize))
56 .cloned()
57 }
58}
59
60#[derive(Clone, Copy)]
64struct SarifCtx<'a> {
65 results: &'a AnalysisResults,
66 root: &'a Path,
67 rules: &'a RulesConfig,
68}
69
70fn severity_to_sarif_level(s: Severity) -> &'static str {
71 severity::sarif_level(s)
72}
73
74fn configured_sarif_level(s: Severity) -> &'static str {
75 match s {
76 Severity::Error | Severity::Warn => severity_to_sarif_level(s),
77 Severity::Off => "none",
78 }
79}
80
81fn sarif_result(
86 rule_id: &str,
87 level: &str,
88 message: &str,
89 uri: &str,
90 region: Option<(u32, u32)>,
91) -> serde_json::Value {
92 sarif_result_with_snippet(rule_id, level, message, uri, region, None)
93}
94
95fn sarif_result_with_snippet(
96 rule_id: &str,
97 level: &str,
98 message: &str,
99 uri: &str,
100 region: Option<(u32, u32)>,
101 snippet: Option<&str>,
102) -> serde_json::Value {
103 let mut physical_location = serde_json::json!({
104 "artifactLocation": { "uri": uri }
105 });
106 if let Some((line, col)) = region {
107 physical_location["region"] = serde_json::json!({
108 "startLine": line,
109 "startColumn": col
110 });
111 }
112 let line = region.map_or_else(String::new, |(line, _)| line.to_string());
113 let col = region.map_or_else(String::new, |(_, col)| col.to_string());
114 let normalized_snippet = snippet
115 .map(fingerprint::normalize_snippet)
116 .filter(|snippet| !snippet.is_empty());
117 let partial_fingerprint = normalized_snippet.as_ref().map_or_else(
118 || fingerprint::fingerprint_hash(&[rule_id, uri, &line, &col]),
119 |snippet| fingerprint::finding_fingerprint(rule_id, uri, snippet),
120 );
121 let partial_fingerprint_ghas = partial_fingerprint.clone();
122 serde_json::json!({
123 "ruleId": rule_id,
124 "level": level,
125 "message": { "text": message },
126 "locations": [{ "physicalLocation": physical_location }],
127 "partialFingerprints": {
128 fingerprint::FINGERPRINT_KEY: partial_fingerprint,
129 fingerprint::GHAS_FINGERPRINT_KEY: partial_fingerprint_ghas
130 }
131 })
132}
133
134fn push_sarif_results<T>(
136 sarif_results: &mut Vec<serde_json::Value>,
137 items: &[T],
138 snippets: &mut SourceSnippetCache,
139 mut extract: impl FnMut(&T) -> SarifFields,
140) {
141 for item in items {
142 let fields = extract(item);
143 let source_snippet = fields
144 .source_path
145 .as_deref()
146 .zip(fields.region)
147 .and_then(|(path, (line, _))| snippets.line(path, line));
148 let mut result = sarif_result_with_snippet(
149 fields.rule_id,
150 fields.level,
151 &fields.message,
152 &fields.uri,
153 fields.region,
154 source_snippet.as_deref(),
155 );
156 if let Some(props) = fields.properties {
157 result["properties"] = props;
158 }
159 sarif_results.push(result);
160 }
161}
162
163fn sarif_rule(id: &str, fallback_short: &str, level: &str) -> serde_json::Value {
166 explain::rule_by_id(id).map_or_else(
167 || {
168 serde_json::json!({
169 "id": id,
170 "shortDescription": { "text": fallback_short },
171 "defaultConfiguration": { "level": level }
172 })
173 },
174 |def| {
175 serde_json::json!({
176 "id": id,
177 "shortDescription": { "text": def.short },
178 "fullDescription": { "text": def.full },
179 "helpUri": explain::rule_docs_url(def),
180 "defaultConfiguration": { "level": level }
181 })
182 },
183 )
184}
185
186fn sarif_export_fields(
188 export: &UnusedExport,
189 root: &Path,
190 rule_id: &'static str,
191 level: &'static str,
192 kind: &str,
193 re_kind: &str,
194) -> SarifFields {
195 let label = if export.is_re_export { re_kind } else { kind };
196 SarifFields {
197 rule_id,
198 level,
199 message: format!(
200 "{} '{}' is never imported by other modules",
201 label, export.export_name
202 ),
203 uri: relative_uri(&export.path, root),
204 region: Some((export.line, export.col + 1)),
205 source_path: Some(export.path.clone()),
206 properties: if export.is_re_export {
207 Some(serde_json::json!({ "is_re_export": true }))
208 } else {
209 None
210 },
211 }
212}
213
214fn sarif_private_type_leak_fields(
215 leak: &PrivateTypeLeak,
216 root: &Path,
217 level: &'static str,
218) -> SarifFields {
219 SarifFields {
220 rule_id: "fallow/private-type-leak",
221 level,
222 message: format!(
223 "Export '{}' references private type '{}'",
224 leak.export_name, leak.type_name
225 ),
226 uri: relative_uri(&leak.path, root),
227 region: Some((leak.line, leak.col + 1)),
228 source_path: Some(leak.path.clone()),
229 properties: None,
230 }
231}
232
233fn sarif_dep_fields(
235 dep: &UnusedDependency,
236 root: &Path,
237 rule_id: &'static str,
238 level: &'static str,
239 section: &str,
240) -> SarifFields {
241 let workspace_context = if dep.used_in_workspaces.is_empty() {
242 String::new()
243 } else {
244 let workspaces = dep
245 .used_in_workspaces
246 .iter()
247 .map(|path| relative_uri(path, root))
248 .collect::<Vec<_>>()
249 .join(", ");
250 format!("; imported in other workspaces: {workspaces}")
251 };
252 SarifFields {
253 rule_id,
254 level,
255 message: format!(
256 "Package '{}' is in {} but never imported{}",
257 dep.package_name, section, workspace_context
258 ),
259 uri: relative_uri(&dep.path, root),
260 region: if dep.line > 0 {
261 Some((dep.line, 1))
262 } else {
263 None
264 },
265 source_path: (dep.line > 0).then(|| dep.path.clone()),
266 properties: None,
267 }
268}
269
270fn sarif_member_fields(
272 member: &UnusedMember,
273 root: &Path,
274 rule_id: &'static str,
275 level: &'static str,
276 kind: &str,
277) -> SarifFields {
278 SarifFields {
279 rule_id,
280 level,
281 message: format!(
282 "{} member '{}.{}' is never referenced",
283 kind, member.parent_name, member.member_name
284 ),
285 uri: relative_uri(&member.path, root),
286 region: Some((member.line, member.col + 1)),
287 source_path: Some(member.path.clone()),
288 properties: None,
289 }
290}
291
292fn sarif_unused_file_fields(file: &UnusedFile, root: &Path, level: &'static str) -> SarifFields {
293 SarifFields {
294 rule_id: "fallow/unused-file",
295 level,
296 message: "File is not reachable from any entry point".to_string(),
297 uri: relative_uri(&file.path, root),
298 region: None,
299 source_path: None,
300 properties: None,
301 }
302}
303
304fn sarif_type_only_dep_fields(
305 dep: &TypeOnlyDependency,
306 root: &Path,
307 level: &'static str,
308) -> SarifFields {
309 SarifFields {
310 rule_id: "fallow/type-only-dependency",
311 level,
312 message: format!(
313 "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
314 dep.package_name
315 ),
316 uri: relative_uri(&dep.path, root),
317 region: if dep.line > 0 {
318 Some((dep.line, 1))
319 } else {
320 None
321 },
322 source_path: (dep.line > 0).then(|| dep.path.clone()),
323 properties: None,
324 }
325}
326
327fn sarif_test_only_dep_fields(
328 dep: &TestOnlyDependency,
329 root: &Path,
330 level: &'static str,
331) -> SarifFields {
332 SarifFields {
333 rule_id: "fallow/test-only-dependency",
334 level,
335 message: format!(
336 "Package '{}' is only imported by test files (consider moving to devDependencies)",
337 dep.package_name
338 ),
339 uri: relative_uri(&dep.path, root),
340 region: if dep.line > 0 {
341 Some((dep.line, 1))
342 } else {
343 None
344 },
345 source_path: (dep.line > 0).then(|| dep.path.clone()),
346 properties: None,
347 }
348}
349
350fn sarif_unresolved_import_fields(
351 import: &UnresolvedImport,
352 root: &Path,
353 level: &'static str,
354) -> SarifFields {
355 SarifFields {
356 rule_id: "fallow/unresolved-import",
357 level,
358 message: format!("Import '{}' could not be resolved", import.specifier),
359 uri: relative_uri(&import.path, root),
360 region: Some((import.line, import.col + 1)),
361 source_path: Some(import.path.clone()),
362 properties: None,
363 }
364}
365
366fn sarif_circular_dep_fields(
367 cycle: &CircularDependency,
368 root: &Path,
369 level: &'static str,
370) -> SarifFields {
371 let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
372 let mut display_chain = chain.clone();
373 if let Some(first) = chain.first() {
374 display_chain.push(first.clone());
375 }
376 let first_uri = chain.first().map_or_else(String::new, Clone::clone);
377 let first_path = cycle.files.first().cloned();
378 SarifFields {
379 rule_id: "fallow/circular-dependency",
380 level,
381 message: format!(
382 "Circular dependency{}: {}",
383 if cycle.is_cross_package {
384 " (cross-package)"
385 } else {
386 ""
387 },
388 display_chain.join(" \u{2192} ")
389 ),
390 uri: first_uri,
391 region: if cycle.line > 0 {
392 Some((cycle.line, cycle.col + 1))
393 } else {
394 None
395 },
396 source_path: (cycle.line > 0).then_some(first_path).flatten(),
397 properties: None,
398 }
399}
400
401fn sarif_re_export_cycle_fields(
402 cycle: &fallow_core::results::ReExportCycle,
403 root: &Path,
404 level: &'static str,
405) -> SarifFields {
406 let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
407 let first_uri = chain.first().map_or_else(String::new, Clone::clone);
408 let first_path = cycle.files.first().cloned();
409 let kind_tag = match cycle.kind {
410 fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
411 fallow_core::results::ReExportCycleKind::MultiNode => "",
412 };
413 SarifFields {
414 rule_id: "fallow/re-export-cycle",
415 level,
416 message: format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
417 uri: first_uri,
418 region: None,
419 source_path: first_path,
420 properties: None,
421 }
422}
423
424fn sarif_boundary_violation_fields(
425 violation: &BoundaryViolation,
426 root: &Path,
427 level: &'static str,
428) -> SarifFields {
429 let from_uri = relative_uri(&violation.from_path, root);
430 let to_uri = relative_uri(&violation.to_path, root);
431 SarifFields {
432 rule_id: "fallow/boundary-violation",
433 level,
434 message: format!(
435 "Import from zone '{}' to zone '{}' is not allowed ({})",
436 violation.from_zone, violation.to_zone, to_uri,
437 ),
438 uri: from_uri,
439 region: if violation.line > 0 {
440 Some((violation.line, violation.col + 1))
441 } else {
442 None
443 },
444 source_path: (violation.line > 0).then(|| violation.from_path.clone()),
445 properties: None,
446 }
447}
448
449fn sarif_boundary_coverage_fields(
450 violation: &BoundaryCoverageViolation,
451 root: &Path,
452 level: &'static str,
453) -> SarifFields {
454 SarifFields {
455 rule_id: "fallow/boundary-coverage",
456 level,
457 message: "File does not match any configured architecture boundary zone".to_string(),
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_boundary_call_fields(
466 violation: &BoundaryCallViolation,
467 root: &Path,
468 level: &'static str,
469) -> SarifFields {
470 SarifFields {
471 rule_id: "fallow/boundary-call-violation",
472 level,
473 message: format!(
474 "Call to `{}` matches forbidden pattern `{}` in zone '{}'",
475 violation.callee, violation.pattern, violation.zone
476 ),
477 uri: relative_uri(&violation.path, root),
478 region: Some((violation.line, violation.col + 1)),
479 source_path: Some(violation.path.clone()),
480 properties: None,
481 }
482}
483
484fn sarif_policy_violation_fields(violation: &PolicyViolation, root: &Path) -> SarifFields {
485 let level = match violation.severity {
486 PolicyViolationSeverity::Error => "error",
487 PolicyViolationSeverity::Warn => "warning",
488 };
489 let message = match &violation.message {
490 Some(message) => format!(
491 "Policy violation `{}/{}`: `{}` is banned. {message}",
492 violation.pack, violation.rule_id, violation.matched
493 ),
494 None => format!(
495 "Policy violation `{}/{}`: `{}` is banned",
496 violation.pack, violation.rule_id, violation.matched
497 ),
498 };
499 SarifFields {
500 rule_id: "fallow/policy-violation",
501 level,
502 message,
503 uri: relative_uri(&violation.path, root),
504 region: Some((violation.line, violation.col + 1)),
505 source_path: Some(violation.path.clone()),
506 properties: Some(serde_json::json!({
512 "policyRule": format!("{}/{}", violation.pack, violation.rule_id),
513 })),
514 }
515}
516
517fn sarif_invalid_client_export_fields(
518 export: &InvalidClientExport,
519 root: &Path,
520 level: &'static str,
521) -> SarifFields {
522 SarifFields {
523 rule_id: "fallow/invalid-client-export",
524 level,
525 message: format!(
526 "Export '{}' is not allowed in a \"{}\" file (Next.js server-only / route-config name)",
527 export.export_name, export.directive
528 ),
529 uri: relative_uri(&export.path, root),
530 region: Some((export.line, export.col + 1)),
531 source_path: Some(export.path.clone()),
532 properties: None,
533 }
534}
535
536fn sarif_mixed_client_server_barrel_fields(
537 barrel: &MixedClientServerBarrel,
538 root: &Path,
539 level: &'static str,
540) -> SarifFields {
541 SarifFields {
542 rule_id: "fallow/mixed-client-server-barrel",
543 level,
544 message: format!(
545 "Barrel re-exports both a \"use client\" module ('{}') and a server-only module ('{}'); one import drags the other's directive across the boundary",
546 barrel.client_origin, barrel.server_origin
547 ),
548 uri: relative_uri(&barrel.path, root),
549 region: Some((barrel.line, barrel.col + 1)),
550 source_path: Some(barrel.path.clone()),
551 properties: None,
552 }
553}
554
555fn sarif_misplaced_directive_fields(
556 directive_site: &MisplacedDirective,
557 root: &Path,
558 level: &'static str,
559) -> SarifFields {
560 SarifFields {
561 rule_id: "fallow/misplaced-directive",
562 level,
563 message: format!(
564 "Directive \"{}\" is not in the leading position, so the RSC bundler ignores it; move it to the top of the file",
565 directive_site.directive
566 ),
567 uri: relative_uri(&directive_site.path, root),
568 region: Some((directive_site.line, directive_site.col + 1)),
569 source_path: Some(directive_site.path.clone()),
570 properties: None,
571 }
572}
573
574fn sarif_unprovided_inject_fields(
575 inject: &UnprovidedInject,
576 root: &Path,
577 level: &'static str,
578) -> SarifFields {
579 SarifFields {
580 rule_id: "fallow/unprovided-inject",
581 level,
582 message: format!(
583 "inject(\"{}\") has no matching provide(\"{}\") in this project; at runtime it returns undefined; provide the key or remove this inject",
584 inject.key_name, inject.key_name
585 ),
586 uri: relative_uri(&inject.path, root),
587 region: Some((inject.line, inject.col + 1)),
588 source_path: Some(inject.path.clone()),
589 properties: None,
590 }
591}
592
593fn sarif_unrendered_component_fields(
594 component: &UnrenderedComponent,
595 root: &Path,
596 level: &'static str,
597) -> SarifFields {
598 SarifFields {
599 rule_id: "fallow/unrendered-component",
600 level,
601 message: format!(
602 "component \"{}\" is reachable but rendered nowhere in this project; render it somewhere or remove it",
603 component.component_name
604 ),
605 uri: relative_uri(&component.path, root),
606 region: Some((component.line, component.col + 1)),
607 source_path: Some(component.path.clone()),
608 properties: None,
609 }
610}
611
612fn sarif_unused_component_prop_fields(
613 prop: &UnusedComponentProp,
614 root: &Path,
615 level: &'static str,
616) -> SarifFields {
617 SarifFields {
618 rule_id: "fallow/unused-component-prop",
619 level,
620 message: format!(
621 "prop \"{}\" is declared but referenced nowhere inside component \"{}\"; remove it or use it",
622 prop.prop_name, prop.component_name
623 ),
624 uri: relative_uri(&prop.path, root),
625 region: Some((prop.line, prop.col + 1)),
626 source_path: Some(prop.path.clone()),
627 properties: None,
628 }
629}
630
631fn sarif_unused_component_emit_fields(
632 emit: &UnusedComponentEmit,
633 root: &Path,
634 level: &'static str,
635) -> SarifFields {
636 SarifFields {
637 rule_id: "fallow/unused-component-emit",
638 level,
639 message: format!(
640 "emit \"{}\" is declared but emitted nowhere inside component \"{}\"; remove it or emit it",
641 emit.emit_name, emit.component_name
642 ),
643 uri: relative_uri(&emit.path, root),
644 region: Some((emit.line, emit.col + 1)),
645 source_path: Some(emit.path.clone()),
646 properties: None,
647 }
648}
649
650fn sarif_unused_svelte_event_fields(
651 event: &UnusedSvelteEvent,
652 root: &Path,
653 level: &'static str,
654) -> SarifFields {
655 SarifFields {
656 rule_id: "fallow/unused-svelte-event",
657 level,
658 message: format!(
659 "event \"{}\" is dispatched by component \"{}\" but listened to nowhere in the project; remove it or listen for it",
660 event.event_name, event.component_name
661 ),
662 uri: relative_uri(&event.path, root),
663 region: Some((event.line, event.col + 1)),
664 source_path: Some(event.path.clone()),
665 properties: None,
666 }
667}
668
669fn sarif_unused_component_input_fields(
670 input: &UnusedComponentInput,
671 root: &Path,
672 level: &'static str,
673) -> SarifFields {
674 SarifFields {
675 rule_id: "fallow/unused-component-input",
676 level,
677 message: format!(
678 "input \"{}\" is declared but read nowhere inside component \"{}\"; remove it or use it",
679 input.input_name, input.component_name
680 ),
681 uri: relative_uri(&input.path, root),
682 region: Some((input.line, input.col + 1)),
683 source_path: Some(input.path.clone()),
684 properties: None,
685 }
686}
687
688fn sarif_unused_component_output_fields(
689 output: &UnusedComponentOutput,
690 root: &Path,
691 level: &'static str,
692) -> SarifFields {
693 SarifFields {
694 rule_id: "fallow/unused-component-output",
695 level,
696 message: format!(
697 "output \"{}\" is declared but emitted nowhere inside component \"{}\"; remove it or emit it",
698 output.output_name, output.component_name
699 ),
700 uri: relative_uri(&output.path, root),
701 region: Some((output.line, output.col + 1)),
702 source_path: Some(output.path.clone()),
703 properties: None,
704 }
705}
706
707fn sarif_unused_server_action_fields(
708 action: &UnusedServerAction,
709 root: &Path,
710 level: &'static str,
711) -> SarifFields {
712 SarifFields {
713 rule_id: "fallow/unused-server-action",
714 level,
715 message: format!(
716 "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",
717 action.action_name
718 ),
719 uri: relative_uri(&action.path, root),
720 region: Some((action.line, action.col + 1)),
721 source_path: Some(action.path.clone()),
722 properties: None,
723 }
724}
725
726fn sarif_unused_load_data_key_fields(
727 key: &fallow_core::results::UnusedLoadDataKey,
728 root: &Path,
729 level: &'static str,
730) -> SarifFields {
731 SarifFields {
732 rule_id: "fallow/unused-load-data-key",
733 level,
734 message: format!(
735 "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",
736 key.key_name
737 ),
738 uri: relative_uri(&key.path, root),
739 region: Some((key.line, key.col + 1)),
740 source_path: Some(key.path.clone()),
741 properties: None,
742 }
743}
744
745fn sarif_prop_drilling_fields(
746 chain: &PropDrillingChain,
747 root: &Path,
748 level: &'static str,
749) -> SarifFields {
750 let source = chain.hops.first();
753 let consumer = chain.hops.last();
754 let (path, line) = source.map_or((std::path::PathBuf::new(), 1), |h| (h.file.clone(), h.line));
755 let consumer_name = consumer.map_or("a distant component", |h| h.component.as_str());
756 SarifFields {
757 rule_id: "fallow/prop-drilling",
758 level,
759 message: format!(
760 "prop \"{}\" is forwarded unchanged through {} component(s) before \"{}\" consumes it; colocate, lift to context, or compose",
761 chain.prop, chain.depth, consumer_name
762 ),
763 uri: relative_uri(&path, root),
764 region: Some((line, 1)),
765 source_path: Some(path),
766 properties: None,
767 }
768}
769
770fn sarif_thin_wrapper_fields(
771 wrapper: &ThinWrapper,
772 root: &Path,
773 level: &'static str,
774) -> SarifFields {
775 SarifFields {
776 rule_id: "fallow/thin-wrapper",
777 level,
778 message: format!(
779 "\"{}\" is a thin wrapper: its whole body forwards props to \"{}\"; inline it at call sites or delete it",
780 wrapper.component, wrapper.child_component
781 ),
782 uri: relative_uri(&wrapper.file, root),
783 region: Some((wrapper.line, 1)),
784 source_path: Some(wrapper.file.clone()),
785 properties: None,
786 }
787}
788
789fn sarif_duplicate_prop_shape_fields(
790 shape: &DuplicatePropShape,
791 root: &Path,
792 level: &'static str,
793) -> SarifFields {
794 SarifFields {
795 rule_id: "fallow/duplicate-prop-shape",
796 level,
797 message: format!(
798 "\"{}\" shares an identical prop shape {{{}}} with {} other component(s); extract a shared Props type or base component",
799 shape.component,
800 shape.shape.join(", "),
801 shape.group_size.saturating_sub(1)
802 ),
803 uri: relative_uri(&shape.file, root),
804 region: Some((shape.line, 1)),
805 source_path: Some(shape.file.clone()),
806 properties: None,
807 }
808}
809
810fn sarif_route_collision_fields(
811 collision: &RouteCollision,
812 root: &Path,
813 level: &'static str,
814) -> SarifFields {
815 SarifFields {
816 rule_id: "fallow/route-collision",
817 level,
818 message: format!(
819 "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",
820 collision.url,
821 collision.conflicting_paths.len()
822 ),
823 uri: relative_uri(&collision.path, root),
824 region: Some((collision.line, collision.col + 1)),
825 source_path: Some(collision.path.clone()),
826 properties: None,
827 }
828}
829
830fn sarif_dynamic_segment_name_conflict_fields(
831 conflict: &DynamicSegmentNameConflict,
832 root: &Path,
833 level: &'static str,
834) -> SarifFields {
835 SarifFields {
836 rule_id: "fallow/dynamic-segment-name-conflict",
837 level,
838 message: format!(
839 "Dynamic segments at '{}' use different slug names ({}); Next.js requires one consistent name per dynamic path",
840 conflict.position,
841 conflict.conflicting_segments.join(", ")
842 ),
843 uri: relative_uri(&conflict.path, root),
844 region: Some((conflict.line, conflict.col + 1)),
845 source_path: Some(conflict.path.clone()),
846 properties: None,
847 }
848}
849
850fn sarif_stale_suppression_fields(
851 suppression: &StaleSuppression,
852 root: &Path,
853 level: &'static str,
854) -> SarifFields {
855 SarifFields {
856 rule_id: if suppression.missing_reason {
857 "fallow/missing-suppression-reason"
858 } else {
859 "fallow/stale-suppression"
860 },
861 level,
862 message: suppression.display_message(),
863 uri: relative_uri(&suppression.path, root),
864 region: Some((suppression.line, suppression.col + 1)),
865 source_path: Some(suppression.path.clone()),
866 properties: None,
867 }
868}
869
870fn stale_suppression_severity(suppression: &StaleSuppression, rules: &RulesConfig) -> Severity {
871 if suppression.missing_reason {
872 rules.require_suppression_reason
873 } else {
874 rules.stale_suppressions
875 }
876}
877
878fn sarif_unused_catalog_entry_fields(
879 entry: &UnusedCatalogEntryFinding,
880 root: &Path,
881 level: &'static str,
882) -> SarifFields {
883 let entry = &entry.entry;
884 let message = if entry.catalog_name == "default" {
885 format!(
886 "Catalog entry '{}' is not referenced by any workspace package",
887 entry.entry_name
888 )
889 } else {
890 format!(
891 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
892 entry.entry_name, entry.catalog_name
893 )
894 };
895 SarifFields {
896 rule_id: "fallow/unused-catalog-entry",
897 level,
898 message,
899 uri: relative_uri(&entry.path, root),
900 region: Some((entry.line, 1)),
901 source_path: Some(entry.path.clone()),
902 properties: None,
903 }
904}
905
906fn sarif_unused_dependency_override_fields(
907 finding: &UnusedDependencyOverrideFinding,
908 root: &Path,
909 level: &'static str,
910) -> SarifFields {
911 let finding = &finding.entry;
912 let mut message = format!(
913 "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
914 finding.raw_key, finding.version_range, finding.target_package,
915 );
916 if let Some(hint) = &finding.hint {
917 use std::fmt::Write as _;
918 let _ = write!(message, " ({hint})");
919 }
920 SarifFields {
921 rule_id: "fallow/unused-dependency-override",
922 level,
923 message,
924 uri: relative_uri(&finding.path, root),
925 region: Some((finding.line, 1)),
926 source_path: Some(finding.path.clone()),
927 properties: None,
928 }
929}
930
931fn sarif_misconfigured_dependency_override_fields(
932 finding: &MisconfiguredDependencyOverrideFinding,
933 root: &Path,
934 level: &'static str,
935) -> SarifFields {
936 let finding = &finding.entry;
937 let message = format!(
938 "Override `{}` -> `{}` is malformed: {}",
939 finding.raw_key,
940 finding.raw_value,
941 finding.reason.describe(),
942 );
943 SarifFields {
944 rule_id: "fallow/misconfigured-dependency-override",
945 level,
946 message,
947 uri: relative_uri(&finding.path, root),
948 region: Some((finding.line, 1)),
949 source_path: Some(finding.path.clone()),
950 properties: None,
951 }
952}
953
954fn sarif_unresolved_catalog_reference_fields(
955 finding: &UnresolvedCatalogReferenceFinding,
956 root: &Path,
957 level: &'static str,
958) -> SarifFields {
959 let finding = &finding.reference;
960 let catalog_phrase = if finding.catalog_name == "default" {
961 "the default catalog".to_string()
962 } else {
963 format!("catalog '{}'", finding.catalog_name)
964 };
965 let mut message = format!(
966 "Package '{}' is referenced via `catalog:{}` but {} does not declare it",
967 finding.entry_name,
968 if finding.catalog_name == "default" {
969 ""
970 } else {
971 finding.catalog_name.as_str()
972 },
973 catalog_phrase,
974 );
975 if !finding.available_in_catalogs.is_empty() {
976 use std::fmt::Write as _;
977 let _ = write!(
978 message,
979 " (available in: {})",
980 finding.available_in_catalogs.join(", ")
981 );
982 }
983 SarifFields {
984 rule_id: "fallow/unresolved-catalog-reference",
985 level,
986 message,
987 uri: relative_uri(&finding.path, root),
988 region: Some((finding.line, 1)),
989 source_path: Some(finding.path.clone()),
990 properties: None,
991 }
992}
993
994fn sarif_empty_catalog_group_fields(
995 group: &EmptyCatalogGroupFinding,
996 root: &Path,
997 level: &'static str,
998) -> SarifFields {
999 let group = &group.group;
1000 SarifFields {
1001 rule_id: "fallow/empty-catalog-group",
1002 level,
1003 message: format!("Catalog group '{}' has no entries", group.catalog_name),
1004 uri: relative_uri(&group.path, root),
1005 region: Some((group.line, 1)),
1006 source_path: Some(group.path.clone()),
1007 properties: None,
1008 }
1009}
1010
1011fn push_sarif_unlisted_deps(
1014 sarif_results: &mut Vec<serde_json::Value>,
1015 deps: &[UnlistedDependencyFinding],
1016 root: &Path,
1017 level: &'static str,
1018 snippets: &mut SourceSnippetCache,
1019) {
1020 for entry in deps {
1021 let dep = &entry.dep;
1022 for site in &dep.imported_from {
1023 let uri = relative_uri(&site.path, root);
1024 let source_snippet = snippets.line(&site.path, site.line);
1025 sarif_results.push(sarif_result_with_snippet(
1026 "fallow/unlisted-dependency",
1027 level,
1028 &format!(
1029 "Package '{}' is imported but not listed in package.json",
1030 dep.package_name
1031 ),
1032 &uri,
1033 Some((site.line, site.col + 1)),
1034 source_snippet.as_deref(),
1035 ));
1036 }
1037 }
1038}
1039
1040fn push_sarif_duplicate_exports(
1043 sarif_results: &mut Vec<serde_json::Value>,
1044 dups: &[DuplicateExportFinding],
1045 root: &Path,
1046 level: &'static str,
1047 snippets: &mut SourceSnippetCache,
1048) {
1049 for dup in dups {
1050 let dup = &dup.export;
1051 for loc in &dup.locations {
1052 let uri = relative_uri(&loc.path, root);
1053 let source_snippet = snippets.line(&loc.path, loc.line);
1054 sarif_results.push(sarif_result_with_snippet(
1055 "fallow/duplicate-export",
1056 level,
1057 &format!("Export '{}' appears in multiple modules", dup.export_name),
1058 &uri,
1059 Some((loc.line, loc.col + 1)),
1060 source_snippet.as_deref(),
1061 ));
1062 }
1063 }
1064}
1065
1066fn build_sarif_rules(rules: &RulesConfig) -> Vec<serde_json::Value> {
1068 let mut specs = Vec::new();
1069 specs.extend(sarif_core_rule_specs(rules));
1070 specs.extend(sarif_dependency_rule_specs(rules));
1071 specs.extend(sarif_member_import_rule_specs(rules));
1072 specs.extend(sarif_graph_rule_specs(rules));
1073 specs.extend(sarif_workspace_rule_specs(rules));
1074 specs
1075 .into_iter()
1076 .map(|(id, description, rule_severity)| {
1077 sarif_rule(id, description, configured_sarif_level(rule_severity))
1078 })
1079 .collect()
1080}
1081
1082type SarifRuleSpec = (&'static str, &'static str, Severity);
1083
1084fn sarif_core_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1085 [
1086 (
1087 "fallow/unused-file",
1088 "File is not reachable from any entry point",
1089 rules.unused_files,
1090 ),
1091 (
1092 "fallow/unused-export",
1093 "Export is never imported",
1094 rules.unused_exports,
1095 ),
1096 (
1097 "fallow/unused-type",
1098 "Type export is never imported",
1099 rules.unused_types,
1100 ),
1101 (
1102 "fallow/private-type-leak",
1103 "Exported signature references a same-file private type",
1104 rules.private_type_leaks,
1105 ),
1106 ]
1107 .into()
1108}
1109
1110fn sarif_dependency_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1111 [
1112 (
1113 "fallow/unused-dependency",
1114 "Dependency listed but never imported",
1115 rules.unused_dependencies,
1116 ),
1117 (
1118 "fallow/unused-dev-dependency",
1119 "Dev dependency listed but never imported",
1120 rules.unused_dev_dependencies,
1121 ),
1122 (
1123 "fallow/unused-optional-dependency",
1124 "Optional dependency listed but never imported",
1125 rules.unused_optional_dependencies,
1126 ),
1127 (
1128 "fallow/type-only-dependency",
1129 "Production dependency only used via type-only imports",
1130 rules.type_only_dependencies,
1131 ),
1132 (
1133 "fallow/test-only-dependency",
1134 "Production dependency only imported by test files",
1135 rules.test_only_dependencies,
1136 ),
1137 ]
1138 .into()
1139}
1140
1141fn sarif_member_import_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1142 [
1143 (
1144 "fallow/unused-enum-member",
1145 "Enum member is never referenced",
1146 rules.unused_enum_members,
1147 ),
1148 (
1149 "fallow/unused-class-member",
1150 "Class member is never referenced",
1151 rules.unused_class_members,
1152 ),
1153 (
1154 "fallow/unused-store-member",
1155 "Store member is never referenced",
1156 rules.unused_store_members,
1157 ),
1158 (
1159 "fallow/unresolved-import",
1160 "Import could not be resolved",
1161 rules.unresolved_imports,
1162 ),
1163 (
1164 "fallow/unlisted-dependency",
1165 "Dependency used but not in package.json",
1166 rules.unlisted_dependencies,
1167 ),
1168 (
1169 "fallow/duplicate-export",
1170 "Export name appears in multiple modules",
1171 rules.duplicate_exports,
1172 ),
1173 ]
1174 .into()
1175}
1176
1177fn sarif_graph_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1178 let mut specs = sarif_cycle_rule_specs(rules);
1179 specs.extend(sarif_boundary_rule_specs(rules));
1180 specs.extend(sarif_framework_rule_specs(rules));
1181 specs.extend(sarif_component_rule_specs(rules));
1182 specs.push((
1183 "fallow/stale-suppression",
1184 "Suppression comment or tag no longer matches any issue",
1185 rules.stale_suppressions,
1186 ));
1187 specs.push((
1188 "fallow/missing-suppression-reason",
1189 "Suppression comment or tag is missing a required reason",
1190 rules.require_suppression_reason,
1191 ));
1192 specs
1193}
1194
1195fn sarif_cycle_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1196 vec![
1197 (
1198 "fallow/circular-dependency",
1199 "Circular dependency chain detected",
1200 rules.circular_dependencies,
1201 ),
1202 (
1203 "fallow/re-export-cycle",
1204 "Two or more barrel files re-export from each other in a loop",
1205 rules.re_export_cycle,
1206 ),
1207 ]
1208}
1209
1210fn sarif_boundary_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1211 vec![
1212 (
1213 "fallow/boundary-violation",
1214 "Import crosses an architecture boundary",
1215 rules.boundary_violation,
1216 ),
1217 (
1218 "fallow/boundary-coverage",
1219 "Source file matches no architecture boundary zone",
1220 rules.boundary_violation,
1221 ),
1222 (
1223 "fallow/boundary-call-violation",
1224 "Zoned file calls a callee its zone forbids",
1225 rules.boundary_violation,
1226 ),
1227 (
1228 "fallow/policy-violation",
1229 "Banned usage matched a rule-pack rule",
1230 rules.policy_violation,
1231 ),
1232 ]
1233}
1234
1235fn sarif_framework_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1236 vec![
1237 (
1238 "fallow/invalid-client-export",
1239 "\"use client\" file exports a server-only / route-config name",
1240 rules.invalid_client_export,
1241 ),
1242 (
1243 "fallow/mixed-client-server-barrel",
1244 "Barrel re-exports both a \"use client\" module and a server-only module",
1245 rules.mixed_client_server_barrel,
1246 ),
1247 (
1248 "fallow/misplaced-directive",
1249 "\"use client\" / \"use server\" directive is not in the leading position and is ignored",
1250 rules.misplaced_directive,
1251 ),
1252 ]
1253}
1254
1255fn sarif_component_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1256 vec![
1257 (
1258 "fallow/unprovided-inject",
1259 "A Vue inject / Svelte getContext whose key is provided nowhere in the project",
1260 rules.unprovided_injects,
1261 ),
1262 (
1263 "fallow/unrendered-component",
1264 "A Vue / Svelte component reachable through a barrel but rendered nowhere in the project",
1265 rules.unrendered_components,
1266 ),
1267 (
1268 "fallow/unused-component-prop",
1269 "A Vue <script setup> defineProps prop referenced nowhere inside its own component",
1270 rules.unused_component_props,
1271 ),
1272 (
1273 "fallow/unused-component-emit",
1274 "A Vue <script setup> defineEmits event emitted nowhere inside its own component",
1275 rules.unused_component_emits,
1276 ),
1277 (
1278 "fallow/unused-component-input",
1279 "An Angular @Input() / signal input() / model() input read nowhere inside its own component",
1280 rules.unused_component_inputs,
1281 ),
1282 (
1283 "fallow/unused-component-output",
1284 "An Angular @Output() / signal output() output emitted nowhere inside its own component",
1285 rules.unused_component_outputs,
1286 ),
1287 (
1288 "fallow/unused-svelte-event",
1289 "A Svelte component dispatching a createEventDispatcher event whose name is listened to nowhere in the project",
1290 rules.unused_svelte_events,
1291 ),
1292 (
1293 "fallow/unused-server-action",
1294 "A Next.js Server Action exported from a \"use server\" file that no code in the project references",
1295 rules.unused_server_actions,
1296 ),
1297 (
1298 "fallow/unused-load-data-key",
1299 "A SvelteKit load() return-object key that no consumer reads (sibling +page.svelte data.<key> or project-wide page.data.<key>)",
1300 rules.unused_load_data_keys,
1301 ),
1302 (
1303 "fallow/prop-drilling",
1304 "A React/Preact prop forwarded unchanged through 3+ pass-through components to a distant consumer",
1305 rules.prop_drilling,
1306 ),
1307 (
1308 "fallow/thin-wrapper",
1309 "A React/Preact component whose whole body is a single spread-forwarded child render (a candidate for inlining)",
1310 rules.thin_wrapper,
1311 ),
1312 (
1313 "fallow/duplicate-prop-shape",
1314 "Three or more React/Preact components across two or more files declare an identical prop-name set (a missing shared Props type)",
1315 rules.duplicate_prop_shape,
1316 ),
1317 (
1318 "fallow/route-collision",
1319 "Two or more Next.js App Router route files resolve to the same URL",
1320 rules.route_collision,
1321 ),
1322 (
1323 "fallow/dynamic-segment-name-conflict",
1324 "Sibling Next.js dynamic route segments use different slug names at the same position",
1325 rules.dynamic_segment_name_conflict,
1326 ),
1327 ]
1328}
1329
1330fn sarif_workspace_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1331 [
1332 (
1333 "fallow/unused-catalog-entry",
1334 "pnpm catalog entry not referenced by any workspace package",
1335 rules.unused_catalog_entries,
1336 ),
1337 (
1338 "fallow/empty-catalog-group",
1339 "pnpm named catalog group has no entries",
1340 rules.empty_catalog_groups,
1341 ),
1342 (
1343 "fallow/unresolved-catalog-reference",
1344 "package.json catalog reference points at a catalog that does not declare the package",
1345 rules.unresolved_catalog_references,
1346 ),
1347 (
1348 "fallow/unused-dependency-override",
1349 "pnpm dependency override target is not declared or lockfile-resolved",
1350 rules.unused_dependency_overrides,
1351 ),
1352 (
1353 "fallow/misconfigured-dependency-override",
1354 "pnpm dependency override key or value is malformed",
1355 rules.misconfigured_dependency_overrides,
1356 ),
1357 ]
1358 .into()
1359}
1360
1361#[must_use]
1362pub fn build_sarif(
1363 results: &AnalysisResults,
1364 root: &Path,
1365 rules: &RulesConfig,
1366) -> serde_json::Value {
1367 let mut sarif_results = Vec::new();
1368 let mut snippets = SourceSnippetCache::default();
1369 let ctx = SarifCtx {
1370 results,
1371 root,
1372 rules,
1373 };
1374
1375 push_primary_dead_code_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1376 push_dependency_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1377 push_member_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1378 push_sarif_results(
1379 &mut sarif_results,
1380 &results.unresolved_imports,
1381 &mut snippets,
1382 |i| {
1383 sarif_unresolved_import_fields(
1384 &i.import,
1385 root,
1386 severity_to_sarif_level(rules.unresolved_imports),
1387 )
1388 },
1389 );
1390 push_misc_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1391 push_graph_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1392 push_catalog_sarif_results(&mut sarif_results, &ctx, &mut snippets);
1393
1394 let sarif_rules = build_sarif_rules(rules);
1395 sarif_document(&sarif_results, &sarif_rules)
1396}
1397
1398fn push_primary_dead_code_sarif_results(
1399 sarif_results: &mut Vec<serde_json::Value>,
1400 ctx: &SarifCtx<'_>,
1401 snippets: &mut SourceSnippetCache,
1402) {
1403 let SarifCtx {
1404 results,
1405 root,
1406 rules,
1407 } = *ctx;
1408
1409 push_sarif_results(sarif_results, &results.unused_files, snippets, |finding| {
1410 sarif_unused_file_fields(
1411 &finding.file,
1412 root,
1413 severity_to_sarif_level(rules.unused_files),
1414 )
1415 });
1416 push_sarif_results(
1417 sarif_results,
1418 &results.unused_exports,
1419 snippets,
1420 |finding| {
1421 sarif_export_fields(
1422 &finding.export,
1423 root,
1424 "fallow/unused-export",
1425 severity_to_sarif_level(rules.unused_exports),
1426 "Export",
1427 "Re-export",
1428 )
1429 },
1430 );
1431 push_sarif_results(sarif_results, &results.unused_types, snippets, |finding| {
1432 sarif_export_fields(
1433 &finding.export,
1434 root,
1435 "fallow/unused-type",
1436 severity_to_sarif_level(rules.unused_types),
1437 "Type export",
1438 "Type re-export",
1439 )
1440 });
1441 push_sarif_results(
1442 sarif_results,
1443 &results.private_type_leaks,
1444 snippets,
1445 |finding| {
1446 sarif_private_type_leak_fields(
1447 &finding.leak,
1448 root,
1449 severity_to_sarif_level(rules.private_type_leaks),
1450 )
1451 },
1452 );
1453}
1454
1455fn sarif_document(
1456 sarif_results: &[serde_json::Value],
1457 sarif_rules: &[serde_json::Value],
1458) -> serde_json::Value {
1459 serde_json::json!({
1460 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1461 "version": "2.1.0",
1462 "runs": [{
1463 "tool": {
1464 "driver": {
1465 "name": "fallow",
1466 "version": env!("CARGO_PKG_VERSION"),
1467 "informationUri": "https://github.com/fallow-rs/fallow",
1468 "rules": sarif_rules
1469 }
1470 },
1471 "results": sarif_results
1472 }]
1473 })
1474}
1475
1476fn push_dependency_sarif_results(
1477 sarif_results: &mut Vec<serde_json::Value>,
1478 ctx: &SarifCtx<'_>,
1479 snippets: &mut SourceSnippetCache,
1480) {
1481 push_unused_dependency_sarif_results(sarif_results, ctx, snippets);
1482 push_classified_dependency_sarif_results(sarif_results, ctx, snippets);
1483}
1484
1485fn push_unused_dependency_sarif_results(
1487 sarif_results: &mut Vec<serde_json::Value>,
1488 ctx: &SarifCtx<'_>,
1489 snippets: &mut SourceSnippetCache,
1490) {
1491 let SarifCtx {
1492 results,
1493 root,
1494 rules,
1495 } = *ctx;
1496
1497 push_sarif_results(sarif_results, &results.unused_dependencies, snippets, |d| {
1498 sarif_dep_fields(
1499 &d.dep,
1500 root,
1501 "fallow/unused-dependency",
1502 severity_to_sarif_level(rules.unused_dependencies),
1503 "dependencies",
1504 )
1505 });
1506 push_sarif_results(
1507 sarif_results,
1508 &results.unused_dev_dependencies,
1509 snippets,
1510 |d| {
1511 sarif_dep_fields(
1512 &d.dep,
1513 root,
1514 "fallow/unused-dev-dependency",
1515 severity_to_sarif_level(rules.unused_dev_dependencies),
1516 "devDependencies",
1517 )
1518 },
1519 );
1520 push_sarif_results(
1521 sarif_results,
1522 &results.unused_optional_dependencies,
1523 snippets,
1524 |d| {
1525 sarif_dep_fields(
1526 &d.dep,
1527 root,
1528 "fallow/unused-optional-dependency",
1529 severity_to_sarif_level(rules.unused_optional_dependencies),
1530 "optionalDependencies",
1531 )
1532 },
1533 );
1534}
1535
1536fn push_classified_dependency_sarif_results(
1538 sarif_results: &mut Vec<serde_json::Value>,
1539 ctx: &SarifCtx<'_>,
1540 snippets: &mut SourceSnippetCache,
1541) {
1542 let SarifCtx {
1543 results,
1544 root,
1545 rules,
1546 } = *ctx;
1547
1548 push_sarif_results(
1549 sarif_results,
1550 &results.type_only_dependencies,
1551 snippets,
1552 |d| {
1553 sarif_type_only_dep_fields(
1554 &d.dep,
1555 root,
1556 severity_to_sarif_level(rules.type_only_dependencies),
1557 )
1558 },
1559 );
1560 push_sarif_results(
1561 sarif_results,
1562 &results.test_only_dependencies,
1563 snippets,
1564 |d| {
1565 sarif_test_only_dep_fields(
1566 &d.dep,
1567 root,
1568 severity_to_sarif_level(rules.test_only_dependencies),
1569 )
1570 },
1571 );
1572}
1573
1574fn push_member_sarif_results(
1575 sarif_results: &mut Vec<serde_json::Value>,
1576 ctx: &SarifCtx<'_>,
1577 snippets: &mut SourceSnippetCache,
1578) {
1579 let SarifCtx {
1580 results,
1581 root,
1582 rules,
1583 } = *ctx;
1584
1585 push_sarif_results(sarif_results, &results.unused_enum_members, snippets, |m| {
1586 sarif_member_fields(
1587 &m.member,
1588 root,
1589 "fallow/unused-enum-member",
1590 severity_to_sarif_level(rules.unused_enum_members),
1591 "Enum",
1592 )
1593 });
1594 push_sarif_results(
1595 sarif_results,
1596 &results.unused_class_members,
1597 snippets,
1598 |m| {
1599 sarif_member_fields(
1600 &m.member,
1601 root,
1602 "fallow/unused-class-member",
1603 severity_to_sarif_level(rules.unused_class_members),
1604 "Class",
1605 )
1606 },
1607 );
1608 push_sarif_results(
1609 sarif_results,
1610 &results.unused_store_members,
1611 snippets,
1612 |m| {
1613 sarif_member_fields(
1614 &m.member,
1615 root,
1616 "fallow/unused-store-member",
1617 severity_to_sarif_level(rules.unused_store_members),
1618 "Store",
1619 )
1620 },
1621 );
1622}
1623
1624fn push_misc_sarif_results(
1625 sarif_results: &mut Vec<serde_json::Value>,
1626 ctx: &SarifCtx<'_>,
1627 snippets: &mut SourceSnippetCache,
1628) {
1629 let SarifCtx {
1630 results,
1631 root,
1632 rules,
1633 } = *ctx;
1634
1635 if !results.unlisted_dependencies.is_empty() {
1636 push_sarif_unlisted_deps(
1637 sarif_results,
1638 &results.unlisted_dependencies,
1639 root,
1640 severity_to_sarif_level(rules.unlisted_dependencies),
1641 snippets,
1642 );
1643 }
1644 if !results.duplicate_exports.is_empty() {
1645 push_sarif_duplicate_exports(
1646 sarif_results,
1647 &results.duplicate_exports,
1648 root,
1649 severity_to_sarif_level(rules.duplicate_exports),
1650 snippets,
1651 );
1652 }
1653}
1654
1655fn push_component_contract_sarif_results(
1659 sarif_results: &mut Vec<serde_json::Value>,
1660 ctx: &SarifCtx<'_>,
1661 snippets: &mut SourceSnippetCache,
1662) {
1663 push_component_member_sarif_results(sarif_results, ctx, snippets);
1664 push_component_framework_sarif_results(sarif_results, ctx, snippets);
1665 push_component_shape_sarif_results(sarif_results, ctx, snippets);
1666}
1667
1668fn push_component_member_sarif_results(
1670 sarif_results: &mut Vec<serde_json::Value>,
1671 ctx: &SarifCtx<'_>,
1672 snippets: &mut SourceSnippetCache,
1673) {
1674 let SarifCtx {
1675 results,
1676 root,
1677 rules,
1678 } = *ctx;
1679
1680 push_sarif_results(
1681 sarif_results,
1682 &results.unused_component_props,
1683 snippets,
1684 |p| {
1685 sarif_unused_component_prop_fields(
1686 &p.prop,
1687 root,
1688 severity_to_sarif_level(rules.unused_component_props),
1689 )
1690 },
1691 );
1692 push_sarif_results(
1693 sarif_results,
1694 &results.unused_component_emits,
1695 snippets,
1696 |e| {
1697 sarif_unused_component_emit_fields(
1698 &e.emit,
1699 root,
1700 severity_to_sarif_level(rules.unused_component_emits),
1701 )
1702 },
1703 );
1704 push_sarif_results(
1705 sarif_results,
1706 &results.unused_component_inputs,
1707 snippets,
1708 |i| {
1709 sarif_unused_component_input_fields(
1710 &i.input,
1711 root,
1712 severity_to_sarif_level(rules.unused_component_inputs),
1713 )
1714 },
1715 );
1716 push_sarif_results(
1717 sarif_results,
1718 &results.unused_component_outputs,
1719 snippets,
1720 |o| {
1721 sarif_unused_component_output_fields(
1722 &o.output,
1723 root,
1724 severity_to_sarif_level(rules.unused_component_outputs),
1725 )
1726 },
1727 );
1728}
1729
1730fn push_component_framework_sarif_results(
1732 sarif_results: &mut Vec<serde_json::Value>,
1733 ctx: &SarifCtx<'_>,
1734 snippets: &mut SourceSnippetCache,
1735) {
1736 let SarifCtx {
1737 results,
1738 root,
1739 rules,
1740 } = *ctx;
1741
1742 push_sarif_results(
1743 sarif_results,
1744 &results.unused_svelte_events,
1745 snippets,
1746 |e| {
1747 sarif_unused_svelte_event_fields(
1748 &e.event,
1749 root,
1750 severity_to_sarif_level(rules.unused_svelte_events),
1751 )
1752 },
1753 );
1754 push_sarif_results(
1755 sarif_results,
1756 &results.unused_server_actions,
1757 snippets,
1758 |a| {
1759 sarif_unused_server_action_fields(
1760 &a.action,
1761 root,
1762 severity_to_sarif_level(rules.unused_server_actions),
1763 )
1764 },
1765 );
1766 push_sarif_results(
1767 sarif_results,
1768 &results.unused_load_data_keys,
1769 snippets,
1770 |k| {
1771 sarif_unused_load_data_key_fields(
1772 &k.key,
1773 root,
1774 severity_to_sarif_level(rules.unused_load_data_keys),
1775 )
1776 },
1777 );
1778}
1779
1780fn push_component_shape_sarif_results(
1782 sarif_results: &mut Vec<serde_json::Value>,
1783 ctx: &SarifCtx<'_>,
1784 snippets: &mut SourceSnippetCache,
1785) {
1786 let SarifCtx {
1787 results,
1788 root,
1789 rules,
1790 } = *ctx;
1791
1792 push_sarif_results(
1793 sarif_results,
1794 &results.prop_drilling_chains,
1795 snippets,
1796 |c| {
1797 sarif_prop_drilling_fields(&c.chain, root, severity_to_sarif_level(rules.prop_drilling))
1798 },
1799 );
1800 push_sarif_results(sarif_results, &results.thin_wrappers, snippets, |w| {
1801 sarif_thin_wrapper_fields(
1802 &w.wrapper,
1803 root,
1804 severity_to_sarif_level(rules.thin_wrapper),
1805 )
1806 });
1807 push_sarif_results(
1808 sarif_results,
1809 &results.duplicate_prop_shapes,
1810 snippets,
1811 |d| {
1812 sarif_duplicate_prop_shape_fields(
1813 &d.shape,
1814 root,
1815 severity_to_sarif_level(rules.duplicate_prop_shape),
1816 )
1817 },
1818 );
1819}
1820
1821fn push_graph_sarif_results(
1822 sarif_results: &mut Vec<serde_json::Value>,
1823 ctx: &SarifCtx<'_>,
1824 snippets: &mut SourceSnippetCache,
1825) {
1826 push_structure_sarif_results(sarif_results, ctx, snippets);
1827 push_framework_sarif_results(sarif_results, ctx, snippets);
1828 push_route_sarif_results(sarif_results, ctx, snippets);
1829 push_suppression_sarif_results(sarif_results, ctx, snippets);
1830}
1831
1832fn push_structure_sarif_results(
1833 sarif_results: &mut Vec<serde_json::Value>,
1834 ctx: &SarifCtx<'_>,
1835 snippets: &mut SourceSnippetCache,
1836) {
1837 push_cycle_sarif_results(sarif_results, ctx, snippets);
1838 push_boundary_sarif_results(sarif_results, ctx, snippets);
1839}
1840
1841fn push_cycle_sarif_results(
1843 sarif_results: &mut Vec<serde_json::Value>,
1844 ctx: &SarifCtx<'_>,
1845 snippets: &mut SourceSnippetCache,
1846) {
1847 let SarifCtx {
1848 results,
1849 root,
1850 rules,
1851 } = *ctx;
1852
1853 push_sarif_results(
1854 sarif_results,
1855 &results.circular_dependencies,
1856 snippets,
1857 |c| {
1858 sarif_circular_dep_fields(
1859 &c.cycle,
1860 root,
1861 severity_to_sarif_level(rules.circular_dependencies),
1862 )
1863 },
1864 );
1865 push_sarif_results(sarif_results, &results.re_export_cycles, snippets, |c| {
1866 sarif_re_export_cycle_fields(
1867 &c.cycle,
1868 root,
1869 severity_to_sarif_level(rules.re_export_cycle),
1870 )
1871 });
1872}
1873
1874fn push_boundary_sarif_results(
1876 sarif_results: &mut Vec<serde_json::Value>,
1877 ctx: &SarifCtx<'_>,
1878 snippets: &mut SourceSnippetCache,
1879) {
1880 let SarifCtx {
1881 results,
1882 root,
1883 rules,
1884 } = *ctx;
1885
1886 push_sarif_results(sarif_results, &results.boundary_violations, snippets, |v| {
1887 sarif_boundary_violation_fields(
1888 &v.violation,
1889 root,
1890 severity_to_sarif_level(rules.boundary_violation),
1891 )
1892 });
1893 push_sarif_results(
1894 sarif_results,
1895 &results.boundary_coverage_violations,
1896 snippets,
1897 |v| {
1898 sarif_boundary_coverage_fields(
1899 &v.violation,
1900 root,
1901 severity_to_sarif_level(rules.boundary_violation),
1902 )
1903 },
1904 );
1905 push_sarif_results(
1906 sarif_results,
1907 &results.boundary_call_violations,
1908 snippets,
1909 |v| {
1910 sarif_boundary_call_fields(
1911 &v.violation,
1912 root,
1913 severity_to_sarif_level(rules.boundary_violation),
1914 )
1915 },
1916 );
1917 push_sarif_results(sarif_results, &results.policy_violations, snippets, |v| {
1918 sarif_policy_violation_fields(&v.violation, root)
1919 });
1920}
1921
1922fn push_framework_sarif_results(
1923 sarif_results: &mut Vec<serde_json::Value>,
1924 ctx: &SarifCtx<'_>,
1925 snippets: &mut SourceSnippetCache,
1926) {
1927 push_framework_boundary_sarif_results(sarif_results, ctx, snippets);
1928 push_component_contract_sarif_results(sarif_results, ctx, snippets);
1929}
1930
1931fn push_framework_boundary_sarif_results(
1933 sarif_results: &mut Vec<serde_json::Value>,
1934 ctx: &SarifCtx<'_>,
1935 snippets: &mut SourceSnippetCache,
1936) {
1937 let SarifCtx {
1938 results,
1939 root,
1940 rules,
1941 } = *ctx;
1942
1943 push_sarif_results(
1944 sarif_results,
1945 &results.invalid_client_exports,
1946 snippets,
1947 |e| {
1948 sarif_invalid_client_export_fields(
1949 &e.export,
1950 root,
1951 severity_to_sarif_level(rules.invalid_client_export),
1952 )
1953 },
1954 );
1955 push_sarif_results(
1956 sarif_results,
1957 &results.mixed_client_server_barrels,
1958 snippets,
1959 |b| {
1960 sarif_mixed_client_server_barrel_fields(
1961 &b.barrel,
1962 root,
1963 severity_to_sarif_level(rules.mixed_client_server_barrel),
1964 )
1965 },
1966 );
1967 push_sarif_results(
1968 sarif_results,
1969 &results.misplaced_directives,
1970 snippets,
1971 |d| {
1972 sarif_misplaced_directive_fields(
1973 &d.directive_site,
1974 root,
1975 severity_to_sarif_level(rules.misplaced_directive),
1976 )
1977 },
1978 );
1979 push_sarif_results(sarif_results, &results.unprovided_injects, snippets, |i| {
1980 sarif_unprovided_inject_fields(
1981 &i.inject,
1982 root,
1983 severity_to_sarif_level(rules.unprovided_injects),
1984 )
1985 });
1986 push_sarif_results(
1987 sarif_results,
1988 &results.unrendered_components,
1989 snippets,
1990 |c| {
1991 sarif_unrendered_component_fields(
1992 &c.component,
1993 root,
1994 severity_to_sarif_level(rules.unrendered_components),
1995 )
1996 },
1997 );
1998}
1999
2000fn push_route_sarif_results(
2001 sarif_results: &mut Vec<serde_json::Value>,
2002 ctx: &SarifCtx<'_>,
2003 snippets: &mut SourceSnippetCache,
2004) {
2005 let SarifCtx {
2006 results,
2007 root,
2008 rules,
2009 } = *ctx;
2010
2011 push_sarif_results(sarif_results, &results.route_collisions, snippets, |c| {
2012 sarif_route_collision_fields(
2013 &c.collision,
2014 root,
2015 severity_to_sarif_level(rules.route_collision),
2016 )
2017 });
2018 push_sarif_results(
2019 sarif_results,
2020 &results.dynamic_segment_name_conflicts,
2021 snippets,
2022 |c| {
2023 sarif_dynamic_segment_name_conflict_fields(
2024 &c.conflict,
2025 root,
2026 severity_to_sarif_level(rules.dynamic_segment_name_conflict),
2027 )
2028 },
2029 );
2030}
2031
2032fn push_suppression_sarif_results(
2033 sarif_results: &mut Vec<serde_json::Value>,
2034 ctx: &SarifCtx<'_>,
2035 snippets: &mut SourceSnippetCache,
2036) {
2037 let SarifCtx {
2038 results,
2039 root,
2040 rules,
2041 } = *ctx;
2042
2043 push_sarif_results(sarif_results, &results.stale_suppressions, snippets, |s| {
2044 sarif_stale_suppression_fields(
2045 s,
2046 root,
2047 severity_to_sarif_level(stale_suppression_severity(s, rules)),
2048 )
2049 });
2050}
2051
2052fn push_catalog_sarif_results(
2053 sarif_results: &mut Vec<serde_json::Value>,
2054 ctx: &SarifCtx<'_>,
2055 snippets: &mut SourceSnippetCache,
2056) {
2057 push_catalog_entry_sarif_results(sarif_results, ctx, snippets);
2058 push_dependency_override_sarif_results(sarif_results, ctx, snippets);
2059}
2060
2061fn push_catalog_entry_sarif_results(
2063 sarif_results: &mut Vec<serde_json::Value>,
2064 ctx: &SarifCtx<'_>,
2065 snippets: &mut SourceSnippetCache,
2066) {
2067 let SarifCtx {
2068 results,
2069 root,
2070 rules,
2071 } = *ctx;
2072
2073 push_sarif_results(
2074 sarif_results,
2075 &results.unused_catalog_entries,
2076 snippets,
2077 |e| {
2078 sarif_unused_catalog_entry_fields(
2079 e,
2080 root,
2081 severity_to_sarif_level(rules.unused_catalog_entries),
2082 )
2083 },
2084 );
2085 push_sarif_results(
2086 sarif_results,
2087 &results.empty_catalog_groups,
2088 snippets,
2089 |g| {
2090 sarif_empty_catalog_group_fields(
2091 g,
2092 root,
2093 severity_to_sarif_level(rules.empty_catalog_groups),
2094 )
2095 },
2096 );
2097 push_sarif_results(
2098 sarif_results,
2099 &results.unresolved_catalog_references,
2100 snippets,
2101 |f| {
2102 sarif_unresolved_catalog_reference_fields(
2103 f,
2104 root,
2105 severity_to_sarif_level(rules.unresolved_catalog_references),
2106 )
2107 },
2108 );
2109}
2110
2111fn push_dependency_override_sarif_results(
2113 sarif_results: &mut Vec<serde_json::Value>,
2114 ctx: &SarifCtx<'_>,
2115 snippets: &mut SourceSnippetCache,
2116) {
2117 let SarifCtx {
2118 results,
2119 root,
2120 rules,
2121 } = *ctx;
2122
2123 push_sarif_results(
2124 sarif_results,
2125 &results.unused_dependency_overrides,
2126 snippets,
2127 |f| {
2128 sarif_unused_dependency_override_fields(
2129 f,
2130 root,
2131 severity_to_sarif_level(rules.unused_dependency_overrides),
2132 )
2133 },
2134 );
2135 push_sarif_results(
2136 sarif_results,
2137 &results.misconfigured_dependency_overrides,
2138 snippets,
2139 |f| {
2140 sarif_misconfigured_dependency_override_fields(
2141 f,
2142 root,
2143 severity_to_sarif_level(rules.misconfigured_dependency_overrides),
2144 )
2145 },
2146 );
2147}
2148
2149pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
2150 let sarif = build_sarif(results, root, rules);
2151 emit_json(&sarif, "SARIF")
2152}
2153
2154#[expect(
2160 clippy::expect_used,
2161 reason = "grouped SARIF entries are JSON objects created by build_sarif"
2162)]
2163pub(super) fn print_grouped_sarif(
2164 results: &AnalysisResults,
2165 root: &Path,
2166 rules: &RulesConfig,
2167 resolver: &OwnershipResolver,
2168) -> ExitCode {
2169 let mut sarif = build_sarif(results, root, rules);
2170
2171 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
2172 for run in runs {
2173 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
2174 for result in results {
2175 let uri = result
2176 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
2177 .and_then(|v| v.as_str())
2178 .unwrap_or("");
2179 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
2180 let owner =
2181 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
2182 let props = result
2183 .as_object_mut()
2184 .expect("SARIF result should be an object")
2185 .entry("properties")
2186 .or_insert_with(|| serde_json::json!({}));
2187 props
2188 .as_object_mut()
2189 .expect("properties should be an object")
2190 .insert("owner".to_string(), serde_json::Value::String(owner));
2191 }
2192 }
2193 }
2194 }
2195
2196 emit_json(&sarif, "SARIF")
2197}
2198
2199#[expect(
2200 clippy::cast_possible_truncation,
2201 reason = "line/col numbers are bounded by source size"
2202)]
2203pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
2204 let mut sarif_results = Vec::new();
2205 let mut snippets = SourceSnippetCache::default();
2206
2207 for (i, group) in report.clone_groups.iter().enumerate() {
2208 for instance in &group.instances {
2209 let uri = relative_uri(&instance.file, root);
2210 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
2211 sarif_results.push(sarif_result_with_snippet(
2212 "fallow/code-duplication",
2213 "warning",
2214 &format!(
2215 "Code clone group {} ({} lines, {} instances)",
2216 i + 1,
2217 group.line_count,
2218 group.instances.len()
2219 ),
2220 &uri,
2221 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2222 source_snippet.as_deref(),
2223 ));
2224 }
2225 }
2226
2227 let sarif = serde_json::json!({
2228 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2229 "version": "2.1.0",
2230 "runs": [{
2231 "tool": {
2232 "driver": {
2233 "name": "fallow",
2234 "version": env!("CARGO_PKG_VERSION"),
2235 "informationUri": "https://github.com/fallow-rs/fallow",
2236 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2237 }
2238 },
2239 "results": sarif_results
2240 }]
2241 });
2242
2243 emit_json(&sarif, "SARIF")
2244}
2245
2246#[expect(
2257 clippy::cast_possible_truncation,
2258 reason = "line/col numbers are bounded by source size"
2259)]
2260#[expect(
2261 clippy::expect_used,
2262 reason = "duplication SARIF entries are JSON objects created by sarif_result_with_snippet"
2263)]
2264pub(super) fn print_grouped_duplication_sarif(
2265 report: &DuplicationReport,
2266 root: &Path,
2267 resolver: &OwnershipResolver,
2268) -> ExitCode {
2269 let mut sarif_results = Vec::new();
2270 let mut snippets = SourceSnippetCache::default();
2271
2272 for (i, group) in report.clone_groups.iter().enumerate() {
2273 let primary_owner = super::dupes_grouping::largest_owner(group, root, resolver);
2274 for instance in &group.instances {
2275 let uri = relative_uri(&instance.file, root);
2276 let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
2277 let mut result = sarif_result_with_snippet(
2278 "fallow/code-duplication",
2279 "warning",
2280 &format!(
2281 "Code clone group {} ({} lines, {} instances)",
2282 i + 1,
2283 group.line_count,
2284 group.instances.len()
2285 ),
2286 &uri,
2287 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2288 source_snippet.as_deref(),
2289 );
2290 let props = result
2291 .as_object_mut()
2292 .expect("SARIF result should be an object")
2293 .entry("properties")
2294 .or_insert_with(|| serde_json::json!({}));
2295 props
2296 .as_object_mut()
2297 .expect("properties should be an object")
2298 .insert(
2299 "group".to_string(),
2300 serde_json::Value::String(primary_owner.clone()),
2301 );
2302 sarif_results.push(result);
2303 }
2304 }
2305
2306 let sarif = serde_json::json!({
2307 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2308 "version": "2.1.0",
2309 "runs": [{
2310 "tool": {
2311 "driver": {
2312 "name": "fallow",
2313 "version": env!("CARGO_PKG_VERSION"),
2314 "informationUri": "https://github.com/fallow-rs/fallow",
2315 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2316 }
2317 },
2318 "results": sarif_results
2319 }]
2320 });
2321
2322 emit_json(&sarif, "SARIF")
2323}
2324
2325#[must_use]
2326pub fn build_health_sarif(
2327 report: &crate::health_types::HealthReport,
2328 root: &Path,
2329) -> serde_json::Value {
2330 let mut sarif_results = Vec::new();
2331 let mut snippets = SourceSnippetCache::default();
2332
2333 append_health_sarif_results(report, root, &mut sarif_results, &mut snippets);
2334 let health_rules = health_sarif_rules();
2335 health_sarif_document(&sarif_results, &health_rules)
2336}
2337
2338fn append_health_sarif_results(
2339 report: &crate::health_types::HealthReport,
2340 root: &Path,
2341 sarif_results: &mut Vec<serde_json::Value>,
2342 snippets: &mut SourceSnippetCache,
2343) {
2344 append_complexity_sarif_results(sarif_results, report, root, snippets);
2345
2346 if let Some(ref production) = report.runtime_coverage {
2347 append_runtime_coverage_sarif_results(sarif_results, production, root, snippets);
2348 }
2349 if let Some(ref intelligence) = report.coverage_intelligence {
2350 append_coverage_intelligence_sarif_results(sarif_results, intelligence, root, snippets);
2351 }
2352
2353 append_refactoring_target_sarif_results(sarif_results, report, root);
2354 append_coverage_gap_sarif_results(sarif_results, report, root, snippets);
2355}
2356
2357fn health_sarif_rules() -> Vec<serde_json::Value> {
2358 let mut rules = health_complexity_sarif_rules();
2359 rules.extend(health_runtime_sarif_rules());
2360 rules.extend(health_coverage_intelligence_sarif_rules());
2361 rules
2362}
2363
2364fn health_complexity_sarif_rules() -> Vec<serde_json::Value> {
2365 vec![
2366 sarif_rule(
2367 "fallow/high-cyclomatic-complexity",
2368 "Function has high cyclomatic complexity",
2369 "note",
2370 ),
2371 sarif_rule(
2372 "fallow/high-cognitive-complexity",
2373 "Function has high cognitive complexity",
2374 "note",
2375 ),
2376 sarif_rule(
2377 "fallow/high-complexity",
2378 "Function exceeds both complexity thresholds",
2379 "note",
2380 ),
2381 sarif_rule(
2382 "fallow/high-crap-score",
2383 "Function has a high CRAP score (high complexity combined with low coverage)",
2384 "warning",
2385 ),
2386 sarif_rule(
2387 "fallow/refactoring-target",
2388 "File identified as a high-priority refactoring candidate",
2389 "warning",
2390 ),
2391 ]
2392}
2393
2394fn health_runtime_sarif_rules() -> Vec<serde_json::Value> {
2395 vec![
2396 sarif_rule(
2397 "fallow/untested-file",
2398 "Runtime-reachable file has no test dependency path",
2399 "warning",
2400 ),
2401 sarif_rule(
2402 "fallow/untested-export",
2403 "Runtime-reachable export has no test dependency path",
2404 "warning",
2405 ),
2406 sarif_rule(
2407 "fallow/runtime-safe-to-delete",
2408 "Function is statically unused and was never invoked in production",
2409 "warning",
2410 ),
2411 sarif_rule(
2412 "fallow/runtime-review-required",
2413 "Function is statically used but was never invoked in production",
2414 "warning",
2415 ),
2416 sarif_rule(
2417 "fallow/runtime-low-traffic",
2418 "Function was invoked below the low-traffic threshold relative to total trace count",
2419 "note",
2420 ),
2421 sarif_rule(
2422 "fallow/runtime-coverage-unavailable",
2423 "Runtime coverage could not be resolved for this function",
2424 "note",
2425 ),
2426 sarif_rule(
2427 "fallow/runtime-coverage",
2428 "Runtime coverage finding",
2429 "note",
2430 ),
2431 ]
2432}
2433
2434fn health_coverage_intelligence_sarif_rules() -> Vec<serde_json::Value> {
2435 vec![
2436 sarif_rule(
2437 "fallow/coverage-intelligence-risky-change",
2438 "Changed hot path combines high CRAP and low test coverage",
2439 "warning",
2440 ),
2441 sarif_rule(
2442 "fallow/coverage-intelligence-delete",
2443 "Static and runtime evidence indicate code can be deleted",
2444 "warning",
2445 ),
2446 sarif_rule(
2447 "fallow/coverage-intelligence-review",
2448 "Cold reachable uncovered code needs owner review",
2449 "warning",
2450 ),
2451 sarif_rule(
2452 "fallow/coverage-intelligence-refactor",
2453 "Hot covered code has high CRAP and should be refactored carefully",
2454 "warning",
2455 ),
2456 ]
2457}
2458
2459fn health_sarif_document(
2460 sarif_results: &[serde_json::Value],
2461 health_rules: &[serde_json::Value],
2462) -> serde_json::Value {
2463 serde_json::json!({
2464 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2465 "version": "2.1.0",
2466 "runs": [{
2467 "tool": {
2468 "driver": {
2469 "name": "fallow",
2470 "version": env!("CARGO_PKG_VERSION"),
2471 "informationUri": "https://github.com/fallow-rs/fallow",
2472 "rules": health_rules
2473 }
2474 },
2475 "results": sarif_results
2476 }]
2477 })
2478}
2479
2480fn append_complexity_sarif_results(
2481 sarif_results: &mut Vec<serde_json::Value>,
2482 report: &crate::health_types::HealthReport,
2483 root: &Path,
2484 snippets: &mut SourceSnippetCache,
2485) {
2486 for finding in &report.findings {
2487 let uri = relative_uri(&finding.path, root);
2488 let (rule_id, message) = health_complexity_sarif_message(finding, report);
2489 let level = match finding.severity {
2490 crate::health_types::FindingSeverity::Critical => "error",
2491 crate::health_types::FindingSeverity::High => "warning",
2492 crate::health_types::FindingSeverity::Moderate => "note",
2493 };
2494 let source_snippet = snippets.line(&finding.path, finding.line);
2495 sarif_results.push(sarif_result_with_snippet(
2496 rule_id,
2497 level,
2498 &message,
2499 &uri,
2500 Some((finding.line, finding.col + 1)),
2501 source_snippet.as_deref(),
2502 ));
2503 }
2504}
2505
2506fn health_complexity_sarif_message(
2507 finding: &crate::health_types::ComplexityViolation,
2508 report: &crate::health_types::HealthReport,
2509) -> (&'static str, String) {
2510 match finding.exceeded {
2511 crate::health_types::ExceededThreshold::Cyclomatic => (
2512 "fallow/high-cyclomatic-complexity",
2513 format!(
2514 "'{}' has cyclomatic complexity {} (threshold: {})",
2515 finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
2516 ),
2517 ),
2518 crate::health_types::ExceededThreshold::Cognitive => (
2519 "fallow/high-cognitive-complexity",
2520 format!(
2521 "'{}' has cognitive complexity {} (threshold: {})",
2522 finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
2523 ),
2524 ),
2525 crate::health_types::ExceededThreshold::Both => (
2526 "fallow/high-complexity",
2527 format!(
2528 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
2529 finding.name,
2530 finding.cyclomatic,
2531 report.summary.max_cyclomatic_threshold,
2532 finding.cognitive,
2533 report.summary.max_cognitive_threshold,
2534 ),
2535 ),
2536 crate::health_types::ExceededThreshold::Crap
2537 | crate::health_types::ExceededThreshold::CyclomaticCrap
2538 | crate::health_types::ExceededThreshold::CognitiveCrap
2539 | crate::health_types::ExceededThreshold::All => {
2540 let crap = finding.crap.unwrap_or(0.0);
2541 let coverage = finding
2542 .coverage_pct
2543 .map(|pct| format!(", coverage {pct:.0}%"))
2544 .unwrap_or_default();
2545 (
2546 "fallow/high-crap-score",
2547 format!(
2548 "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
2549 finding.name,
2550 crap,
2551 report.summary.max_crap_threshold,
2552 finding.cyclomatic,
2553 coverage,
2554 ),
2555 )
2556 }
2557 }
2558}
2559
2560fn append_refactoring_target_sarif_results(
2561 sarif_results: &mut Vec<serde_json::Value>,
2562 report: &crate::health_types::HealthReport,
2563 root: &Path,
2564) {
2565 for target in &report.targets {
2566 let uri = relative_uri(&target.path, root);
2567 let message = format!(
2568 "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
2569 target.category.label(),
2570 target.recommendation,
2571 target.priority,
2572 target.efficiency,
2573 target.effort.label(),
2574 target.confidence.label(),
2575 );
2576 sarif_results.push(sarif_result(
2577 "fallow/refactoring-target",
2578 "warning",
2579 &message,
2580 &uri,
2581 None,
2582 ));
2583 }
2584}
2585
2586fn append_coverage_gap_sarif_results(
2587 sarif_results: &mut Vec<serde_json::Value>,
2588 report: &crate::health_types::HealthReport,
2589 root: &Path,
2590 snippets: &mut SourceSnippetCache,
2591) {
2592 let Some(ref gaps) = report.coverage_gaps else {
2593 return;
2594 };
2595 for item in &gaps.files {
2596 let uri = relative_uri(&item.file.path, root);
2597 let message = format!(
2598 "File is runtime-reachable but has no test dependency path ({} value export{})",
2599 item.file.value_export_count,
2600 if item.file.value_export_count == 1 {
2601 ""
2602 } else {
2603 "s"
2604 },
2605 );
2606 sarif_results.push(sarif_result(
2607 "fallow/untested-file",
2608 "warning",
2609 &message,
2610 &uri,
2611 None,
2612 ));
2613 }
2614
2615 for item in &gaps.exports {
2616 let uri = relative_uri(&item.export.path, root);
2617 let message = format!(
2618 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
2619 item.export.export_name
2620 );
2621 let source_snippet = snippets.line(&item.export.path, item.export.line);
2622 sarif_results.push(sarif_result_with_snippet(
2623 "fallow/untested-export",
2624 "warning",
2625 &message,
2626 &uri,
2627 Some((item.export.line, item.export.col + 1)),
2628 source_snippet.as_deref(),
2629 ));
2630 }
2631}
2632
2633fn append_runtime_coverage_sarif_results(
2634 sarif_results: &mut Vec<serde_json::Value>,
2635 production: &crate::health_types::RuntimeCoverageReport,
2636 root: &Path,
2637 snippets: &mut SourceSnippetCache,
2638) {
2639 for finding in &production.findings {
2640 let uri = relative_uri(&finding.path, root);
2641 let rule_id = match finding.verdict {
2642 crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
2643 "fallow/runtime-safe-to-delete"
2644 }
2645 crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
2646 "fallow/runtime-review-required"
2647 }
2648 crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
2649 crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
2650 "fallow/runtime-coverage-unavailable"
2651 }
2652 crate::health_types::RuntimeCoverageVerdict::Active
2653 | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
2654 };
2655 let level = match finding.verdict {
2656 crate::health_types::RuntimeCoverageVerdict::SafeToDelete
2657 | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
2658 _ => "note",
2659 };
2660 let invocations_hint = finding.invocations.map_or_else(
2661 || "untracked".to_owned(),
2662 |hits| format!("{hits} invocations"),
2663 );
2664 let message = format!(
2665 "'{}' runtime coverage verdict: {} ({})",
2666 finding.function,
2667 finding.verdict.human_label(),
2668 invocations_hint,
2669 );
2670 let source_snippet = snippets.line(&finding.path, finding.line);
2671 sarif_results.push(sarif_result_with_snippet(
2672 rule_id,
2673 level,
2674 &message,
2675 &uri,
2676 Some((finding.line, 1)),
2677 source_snippet.as_deref(),
2678 ));
2679 }
2680}
2681
2682fn append_coverage_intelligence_sarif_results(
2683 sarif_results: &mut Vec<serde_json::Value>,
2684 intelligence: &crate::health_types::CoverageIntelligenceReport,
2685 root: &Path,
2686 snippets: &mut SourceSnippetCache,
2687) {
2688 for finding in &intelligence.findings {
2689 let rule_id = coverage_intelligence_rule_id(finding.recommendation);
2690 let level = match finding.verdict {
2691 crate::health_types::CoverageIntelligenceVerdict::Clean
2692 | crate::health_types::CoverageIntelligenceVerdict::Unknown => continue,
2693 _ => "warning",
2694 };
2695 let uri = relative_uri(&finding.path, root);
2696 let identity = finding.identity.as_deref().unwrap_or("code");
2697 let signals = finding
2698 .signals
2699 .iter()
2700 .map(ToString::to_string)
2701 .collect::<Vec<_>>()
2702 .join(", ");
2703 let message = format!(
2704 "'{}' coverage intelligence verdict: {} ({}, signals: {})",
2705 identity, finding.verdict, finding.recommendation, signals,
2706 );
2707 let source_snippet = snippets.line(&finding.path, finding.line);
2708 let mut result = sarif_result_with_snippet(
2709 rule_id,
2710 level,
2711 &message,
2712 &uri,
2713 Some((finding.line, 1)),
2714 source_snippet.as_deref(),
2715 );
2716 result["properties"] = serde_json::json!({
2717 "coverage_intelligence_id": &finding.id,
2718 "verdict": finding.verdict,
2719 "recommendation": finding.recommendation,
2720 "confidence": finding.confidence,
2721 "signals": &finding.signals,
2722 "related_ids": &finding.related_ids,
2723 });
2724 sarif_results.push(result);
2725 }
2726}
2727
2728fn coverage_intelligence_rule_id(
2729 recommendation: crate::health_types::CoverageIntelligenceRecommendation,
2730) -> &'static str {
2731 match recommendation {
2732 crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
2733 "fallow/coverage-intelligence-risky-change"
2734 }
2735 crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
2736 "fallow/coverage-intelligence-delete"
2737 }
2738 crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
2739 "fallow/coverage-intelligence-review"
2740 }
2741 crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
2742 "fallow/coverage-intelligence-refactor"
2743 }
2744 }
2745}
2746
2747pub(super) fn print_health_sarif(
2748 report: &crate::health_types::HealthReport,
2749 root: &Path,
2750) -> ExitCode {
2751 let sarif = build_health_sarif(report, root);
2752 emit_json(&sarif, "SARIF")
2753}
2754
2755#[expect(
2766 clippy::expect_used,
2767 reason = "grouped health SARIF entries are JSON objects created by build_health_sarif"
2768)]
2769pub(super) fn print_grouped_health_sarif(
2770 report: &crate::health_types::HealthReport,
2771 root: &Path,
2772 resolver: &OwnershipResolver,
2773) -> ExitCode {
2774 let mut sarif = build_health_sarif(report, root);
2775
2776 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
2777 for run in runs {
2778 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
2779 for result in results {
2780 let uri = result
2781 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
2782 .and_then(|v| v.as_str())
2783 .unwrap_or("");
2784 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
2785 let group =
2786 grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
2787 let props = result
2788 .as_object_mut()
2789 .expect("SARIF result should be an object")
2790 .entry("properties")
2791 .or_insert_with(|| serde_json::json!({}));
2792 props
2793 .as_object_mut()
2794 .expect("properties should be an object")
2795 .insert("group".to_string(), serde_json::Value::String(group));
2796 }
2797 }
2798 }
2799 }
2800
2801 emit_json(&sarif, "SARIF")
2802}
2803
2804#[cfg(test)]
2805mod tests {
2806 use super::*;
2807 use crate::report::test_helpers::sample_results;
2808 use fallow_core::results::*;
2809 use std::path::PathBuf;
2810
2811 #[test]
2812 fn sarif_has_required_top_level_fields() {
2813 let root = PathBuf::from("/project");
2814 let results = AnalysisResults::default();
2815 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2816
2817 assert_eq!(
2818 sarif["$schema"],
2819 "https://json.schemastore.org/sarif-2.1.0.json"
2820 );
2821 assert_eq!(sarif["version"], "2.1.0");
2822 assert!(sarif["runs"].is_array());
2823 }
2824
2825 #[test]
2826 fn sarif_missing_suppression_reason_uses_reason_rule_severity() {
2827 let root = PathBuf::from("/project");
2828 let mut results = AnalysisResults::default();
2829 results.stale_suppressions.push(StaleSuppression {
2830 path: root.join("src/file.ts"),
2831 line: 1,
2832 col: 0,
2833 origin: SuppressionOrigin::Comment {
2834 issue_kind: Some("unused-exports".to_string()),
2835 reason: None,
2836 is_file_level: false,
2837 kind_known: true,
2838 },
2839 missing_reason: true,
2840 actions: StaleSuppression::actions_for(true),
2841 });
2842 let rules = RulesConfig {
2843 stale_suppressions: Severity::Off,
2844 require_suppression_reason: Severity::Error,
2845 ..Default::default()
2846 };
2847
2848 let sarif = build_sarif(&results, &root, &rules);
2849
2850 assert_eq!(
2851 sarif["runs"][0]["results"][0]["ruleId"],
2852 "fallow/missing-suppression-reason"
2853 );
2854 assert_eq!(sarif["runs"][0]["results"][0]["level"], "error");
2855 assert!(
2856 sarif["runs"][0]["tool"]["driver"]["rules"]
2857 .as_array()
2858 .unwrap()
2859 .iter()
2860 .any(|rule| rule["id"].as_str().unwrap() == "fallow/missing-suppression-reason")
2861 );
2862 }
2863
2864 #[test]
2865 fn sarif_stale_and_missing_suppression_have_distinct_identities() {
2866 let root = PathBuf::from("/project");
2867 let mut results = AnalysisResults::default();
2868 let origin = SuppressionOrigin::Comment {
2869 issue_kind: Some("unused-exports".to_string()),
2870 reason: None,
2871 is_file_level: false,
2872 kind_known: true,
2873 };
2874 results.stale_suppressions.push(StaleSuppression {
2875 path: root.join("src/file.ts"),
2876 line: 1,
2877 col: 0,
2878 origin: origin.clone(),
2879 missing_reason: false,
2880 actions: StaleSuppression::actions_for(false),
2881 });
2882 results.stale_suppressions.push(StaleSuppression {
2883 path: root.join("src/file.ts"),
2884 line: 1,
2885 col: 0,
2886 origin,
2887 missing_reason: true,
2888 actions: StaleSuppression::actions_for(true),
2889 });
2890 let rules = RulesConfig {
2891 stale_suppressions: Severity::Warn,
2892 require_suppression_reason: Severity::Error,
2893 ..Default::default()
2894 };
2895
2896 let sarif = build_sarif(&results, &root, &rules);
2897 let results = sarif["runs"][0]["results"].as_array().unwrap();
2898
2899 assert_eq!(results[0]["ruleId"], "fallow/stale-suppression");
2900 assert_eq!(results[1]["ruleId"], "fallow/missing-suppression-reason");
2901 assert_ne!(
2902 results[0]["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
2903 results[1]["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
2904 );
2905 }
2906
2907 #[test]
2908 fn sarif_has_tool_driver_info() {
2909 let root = PathBuf::from("/project");
2910 let results = AnalysisResults::default();
2911 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2912
2913 let driver = &sarif["runs"][0]["tool"]["driver"];
2914 assert_eq!(driver["name"], "fallow");
2915 assert!(driver["version"].is_string());
2916 assert_eq!(
2917 driver["informationUri"],
2918 "https://github.com/fallow-rs/fallow"
2919 );
2920 }
2921
2922 #[test]
2923 fn sarif_declares_all_rules() {
2924 let root = PathBuf::from("/project");
2925 let results = AnalysisResults::default();
2926 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2927
2928 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2929 .as_array()
2930 .expect("rules should be an array");
2931 assert_eq!(rules.len(), 45);
2932
2933 let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
2934 assert!(rule_ids.contains(&"fallow/duplicate-prop-shape"));
2935 assert!(rule_ids.contains(&"fallow/thin-wrapper"));
2936 assert!(rule_ids.contains(&"fallow/unrendered-component"));
2937 assert!(rule_ids.contains(&"fallow/unused-component-prop"));
2938 assert!(rule_ids.contains(&"fallow/unused-component-emit"));
2939 assert!(rule_ids.contains(&"fallow/unused-component-input"));
2940 assert!(rule_ids.contains(&"fallow/unused-component-output"));
2941 assert!(rule_ids.contains(&"fallow/unused-svelte-event"));
2942 assert!(rule_ids.contains(&"fallow/unused-server-action"));
2943 assert!(rule_ids.contains(&"fallow/unused-load-data-key"));
2944 assert!(rule_ids.contains(&"fallow/prop-drilling"));
2945 assert!(rule_ids.contains(&"fallow/route-collision"));
2946 assert!(rule_ids.contains(&"fallow/dynamic-segment-name-conflict"));
2947 assert!(rule_ids.contains(&"fallow/unused-file"));
2948 assert!(rule_ids.contains(&"fallow/unused-export"));
2949 assert!(rule_ids.contains(&"fallow/unused-type"));
2950 assert!(rule_ids.contains(&"fallow/private-type-leak"));
2951 assert!(rule_ids.contains(&"fallow/unused-dependency"));
2952 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
2953 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
2954 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
2955 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
2956 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
2957 assert!(rule_ids.contains(&"fallow/unused-class-member"));
2958 assert!(rule_ids.contains(&"fallow/unused-store-member"));
2959 assert!(rule_ids.contains(&"fallow/unresolved-import"));
2960 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
2961 assert!(rule_ids.contains(&"fallow/duplicate-export"));
2962 assert!(rule_ids.contains(&"fallow/circular-dependency"));
2963 assert!(rule_ids.contains(&"fallow/re-export-cycle"));
2964 assert!(rule_ids.contains(&"fallow/boundary-violation"));
2965 assert!(rule_ids.contains(&"fallow/boundary-coverage"));
2966 assert!(rule_ids.contains(&"fallow/boundary-call-violation"));
2967 assert!(rule_ids.contains(&"fallow/policy-violation"));
2968 assert!(rule_ids.contains(&"fallow/unused-catalog-entry"));
2969 assert!(rule_ids.contains(&"fallow/empty-catalog-group"));
2970 assert!(rule_ids.contains(&"fallow/unresolved-catalog-reference"));
2971 assert!(rule_ids.contains(&"fallow/unused-dependency-override"));
2972 assert!(rule_ids.contains(&"fallow/misconfigured-dependency-override"));
2973 assert!(rule_ids.contains(&"fallow/invalid-client-export"));
2974 assert!(rule_ids.contains(&"fallow/mixed-client-server-barrel"));
2975 assert!(rule_ids.contains(&"fallow/misplaced-directive"));
2976 assert!(rule_ids.contains(&"fallow/unprovided-inject"));
2977 }
2978
2979 #[test]
2980 fn sarif_empty_results_no_results_entries() {
2981 let root = PathBuf::from("/project");
2982 let results = AnalysisResults::default();
2983 let sarif = build_sarif(&results, &root, &RulesConfig::default());
2984
2985 let sarif_results = sarif["runs"][0]["results"]
2986 .as_array()
2987 .expect("results should be an array");
2988 assert!(sarif_results.is_empty());
2989 }
2990
2991 #[test]
2992 fn sarif_unused_file_result() {
2993 let root = PathBuf::from("/project");
2994 let mut results = AnalysisResults::default();
2995 results
2996 .unused_files
2997 .push(UnusedFileFinding::with_actions(UnusedFile {
2998 path: root.join("src/dead.ts"),
2999 }));
3000
3001 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3002 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3003 assert_eq!(entries.len(), 1);
3004
3005 let entry = &entries[0];
3006 assert_eq!(entry["ruleId"], "fallow/unused-file");
3007 assert_eq!(entry["level"], "error");
3008 assert_eq!(
3009 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3010 "src/dead.ts"
3011 );
3012 }
3013
3014 #[test]
3015 fn sarif_unused_export_includes_region() {
3016 let root = PathBuf::from("/project");
3017 let mut results = AnalysisResults::default();
3018 results
3019 .unused_exports
3020 .push(UnusedExportFinding::with_actions(UnusedExport {
3021 path: root.join("src/utils.ts"),
3022 export_name: "helperFn".to_string(),
3023 is_type_only: false,
3024 line: 10,
3025 col: 4,
3026 span_start: 120,
3027 is_re_export: false,
3028 }));
3029
3030 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3031 let entry = &sarif["runs"][0]["results"][0];
3032 assert_eq!(entry["ruleId"], "fallow/unused-export");
3033
3034 let region = &entry["locations"][0]["physicalLocation"]["region"];
3035 assert_eq!(region["startLine"], 10);
3036 assert_eq!(region["startColumn"], 5);
3037 }
3038
3039 #[test]
3040 fn sarif_unresolved_import_is_error_level() {
3041 let root = PathBuf::from("/project");
3042 let mut results = AnalysisResults::default();
3043 results
3044 .unresolved_imports
3045 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
3046 path: root.join("src/app.ts"),
3047 specifier: "./missing".to_string(),
3048 line: 1,
3049 col: 0,
3050 specifier_col: 0,
3051 }));
3052
3053 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3054 let entry = &sarif["runs"][0]["results"][0];
3055 assert_eq!(entry["ruleId"], "fallow/unresolved-import");
3056 assert_eq!(entry["level"], "error");
3057 }
3058
3059 #[test]
3060 fn sarif_unlisted_dependency_points_to_import_site() {
3061 let root = PathBuf::from("/project");
3062 let mut results = AnalysisResults::default();
3063 results
3064 .unlisted_dependencies
3065 .push(UnlistedDependencyFinding::with_actions(
3066 UnlistedDependency {
3067 package_name: "chalk".to_string(),
3068 imported_from: vec![ImportSite {
3069 path: root.join("src/cli.ts"),
3070 line: 3,
3071 col: 0,
3072 }],
3073 },
3074 ));
3075
3076 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3077 let entry = &sarif["runs"][0]["results"][0];
3078 assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
3079 assert_eq!(entry["level"], "error");
3080 assert_eq!(
3081 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3082 "src/cli.ts"
3083 );
3084 let region = &entry["locations"][0]["physicalLocation"]["region"];
3085 assert_eq!(region["startLine"], 3);
3086 assert_eq!(region["startColumn"], 1);
3087 }
3088
3089 #[test]
3090 fn sarif_dependency_issues_point_to_package_json() {
3091 let root = PathBuf::from("/project");
3092 let mut results = AnalysisResults::default();
3093 results
3094 .unused_dependencies
3095 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3096 package_name: "lodash".to_string(),
3097 location: DependencyLocation::Dependencies,
3098 path: root.join("package.json"),
3099 line: 5,
3100 used_in_workspaces: Vec::new(),
3101 }));
3102 results
3103 .unused_dev_dependencies
3104 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
3105 package_name: "jest".to_string(),
3106 location: DependencyLocation::DevDependencies,
3107 path: root.join("package.json"),
3108 line: 5,
3109 used_in_workspaces: Vec::new(),
3110 }));
3111
3112 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3113 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3114 for entry in entries {
3115 assert_eq!(
3116 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3117 "package.json"
3118 );
3119 }
3120 }
3121
3122 #[test]
3123 fn sarif_duplicate_export_emits_one_result_per_location() {
3124 let root = PathBuf::from("/project");
3125 let mut results = AnalysisResults::default();
3126 results
3127 .duplicate_exports
3128 .push(DuplicateExportFinding::with_actions(DuplicateExport {
3129 export_name: "Config".to_string(),
3130 locations: vec![
3131 DuplicateLocation {
3132 path: root.join("src/a.ts"),
3133 line: 15,
3134 col: 0,
3135 },
3136 DuplicateLocation {
3137 path: root.join("src/b.ts"),
3138 line: 30,
3139 col: 0,
3140 },
3141 ],
3142 }));
3143
3144 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3145 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3146 assert_eq!(entries.len(), 2);
3147 assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
3148 assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
3149 assert_eq!(
3150 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3151 "src/a.ts"
3152 );
3153 assert_eq!(
3154 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3155 "src/b.ts"
3156 );
3157 }
3158
3159 #[test]
3160 fn sarif_all_issue_types_produce_results() {
3161 let root = PathBuf::from("/project");
3162 let results = sample_results(&root);
3163 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3164
3165 let entries = sarif["runs"][0]["results"].as_array().unwrap();
3166 assert_eq!(entries.len(), results.total_issues() + 1);
3167
3168 let rule_ids: Vec<&str> = entries
3169 .iter()
3170 .map(|e| e["ruleId"].as_str().unwrap())
3171 .collect();
3172 assert!(rule_ids.contains(&"fallow/unused-file"));
3173 assert!(rule_ids.contains(&"fallow/unused-export"));
3174 assert!(rule_ids.contains(&"fallow/unused-type"));
3175 assert!(rule_ids.contains(&"fallow/unused-dependency"));
3176 assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
3177 assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
3178 assert!(rule_ids.contains(&"fallow/type-only-dependency"));
3179 assert!(rule_ids.contains(&"fallow/test-only-dependency"));
3180 assert!(rule_ids.contains(&"fallow/unused-enum-member"));
3181 assert!(rule_ids.contains(&"fallow/unused-class-member"));
3182 assert!(rule_ids.contains(&"fallow/unused-store-member"));
3183 assert!(rule_ids.contains(&"fallow/unresolved-import"));
3184 assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
3185 assert!(rule_ids.contains(&"fallow/duplicate-export"));
3186 assert!(rule_ids.contains(&"fallow/unprovided-inject"));
3187 }
3188
3189 #[test]
3190 fn sarif_serializes_to_valid_json() {
3191 let root = PathBuf::from("/project");
3192 let results = sample_results(&root);
3193 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3194
3195 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
3196 let reparsed: serde_json::Value =
3197 serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
3198 assert_eq!(reparsed, sarif);
3199 }
3200
3201 #[test]
3202 fn sarif_file_write_produces_valid_sarif() {
3203 let root = PathBuf::from("/project");
3204 let results = sample_results(&root);
3205 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3206 let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
3207
3208 let dir = std::env::temp_dir().join("fallow-test-sarif-file");
3209 let _ = std::fs::create_dir_all(&dir);
3210 let sarif_path = dir.join("results.sarif");
3211 std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
3212
3213 let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
3214 let parsed: serde_json::Value =
3215 serde_json::from_str(&contents).expect("file should contain valid JSON");
3216
3217 assert_eq!(parsed["version"], "2.1.0");
3218 assert_eq!(
3219 parsed["$schema"],
3220 "https://json.schemastore.org/sarif-2.1.0.json"
3221 );
3222 let sarif_results = parsed["runs"][0]["results"]
3223 .as_array()
3224 .expect("results should be an array");
3225 assert!(!sarif_results.is_empty());
3226
3227 let _ = std::fs::remove_file(&sarif_path);
3228 let _ = std::fs::remove_dir(&dir);
3229 }
3230
3231 #[test]
3232 fn health_sarif_empty_no_results() {
3233 let root = PathBuf::from("/project");
3234 let report = crate::health_types::HealthReport {
3235 summary: crate::health_types::HealthSummary {
3236 files_analyzed: 10,
3237 functions_analyzed: 50,
3238 ..Default::default()
3239 },
3240 ..Default::default()
3241 };
3242 let sarif = build_health_sarif(&report, &root);
3243 assert_eq!(sarif["version"], "2.1.0");
3244 let results = sarif["runs"][0]["results"].as_array().unwrap();
3245 assert!(results.is_empty());
3246 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
3247 .as_array()
3248 .unwrap();
3249 assert_eq!(rules.len(), 16);
3250 }
3251
3252 #[test]
3253 fn health_sarif_coverage_intelligence_preserves_structured_properties() {
3254 use crate::health_types::{
3255 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
3256 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
3257 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
3258 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
3259 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
3260 HealthReport, HealthSummary,
3261 };
3262
3263 let root = PathBuf::from("/project");
3264 let report = HealthReport {
3265 summary: HealthSummary {
3266 files_analyzed: 10,
3267 functions_analyzed: 50,
3268 ..Default::default()
3269 },
3270 coverage_intelligence: Some(CoverageIntelligenceReport {
3271 schema_version: CoverageIntelligenceSchemaVersion::V1,
3272 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
3273 summary: CoverageIntelligenceSummary {
3274 findings: 1,
3275 high_confidence_deletes: 1,
3276 ..Default::default()
3277 },
3278 findings: vec![CoverageIntelligenceFinding {
3279 id: "fallow:coverage-intel:abc123".to_owned(),
3280 path: root.join("src/dead.ts"),
3281 identity: Some("deadPath".to_owned()),
3282 line: 9,
3283 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
3284 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
3285 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
3286 confidence: CoverageIntelligenceConfidence::High,
3287 related_ids: vec!["fallow:prod:deadbeef".to_owned()],
3288 evidence: CoverageIntelligenceEvidence {
3289 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
3290 ..Default::default()
3291 },
3292 actions: vec![CoverageIntelligenceAction {
3293 kind: "delete-after-confirming-owner".to_owned(),
3294 description: "Confirm ownership".to_owned(),
3295 auto_fixable: false,
3296 }],
3297 }],
3298 }),
3299 ..Default::default()
3300 };
3301
3302 let sarif = build_health_sarif(&report, &root);
3303 let result = &sarif["runs"][0]["results"][0];
3304 assert_eq!(result["ruleId"], "fallow/coverage-intelligence-delete");
3305 assert_eq!(
3306 result["properties"]["coverage_intelligence_id"],
3307 "fallow:coverage-intel:abc123"
3308 );
3309 assert_eq!(
3310 result["properties"]["recommendation"],
3311 "delete-after-confirming-owner"
3312 );
3313 assert_eq!(result["properties"]["confidence"], "high");
3314 assert_eq!(result["properties"]["signals"][0], "runtime_cold");
3315 assert_eq!(
3316 result["properties"]["related_ids"][0],
3317 "fallow:prod:deadbeef"
3318 );
3319 }
3320
3321 #[test]
3322 fn health_sarif_cyclomatic_only() {
3323 let root = PathBuf::from("/project");
3324 let report = crate::health_types::HealthReport {
3325 findings: vec![
3326 crate::health_types::ComplexityViolation {
3327 path: root.join("src/utils.ts"),
3328 name: "parseExpression".to_string(),
3329 line: 42,
3330 col: 0,
3331 cyclomatic: 25,
3332 cognitive: 10,
3333 line_count: 80,
3334 param_count: 0,
3335 react_hook_count: 0,
3336 react_jsx_max_depth: 0,
3337 react_prop_count: 0,
3338 react_hook_profile: None,
3339 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
3340 severity: crate::health_types::FindingSeverity::High,
3341 crap: None,
3342 coverage_pct: None,
3343 coverage_tier: None,
3344 coverage_source: None,
3345 inherited_from: None,
3346 component_rollup: None,
3347 contributions: Vec::new(),
3348 effective_thresholds: None,
3349 threshold_source: None,
3350 }
3351 .into(),
3352 ],
3353 summary: crate::health_types::HealthSummary {
3354 files_analyzed: 5,
3355 functions_analyzed: 20,
3356 functions_above_threshold: 1,
3357 ..Default::default()
3358 },
3359 ..Default::default()
3360 };
3361 let sarif = build_health_sarif(&report, &root);
3362 let entry = &sarif["runs"][0]["results"][0];
3363 assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
3364 assert_eq!(entry["level"], "warning");
3365 assert!(
3366 entry["message"]["text"]
3367 .as_str()
3368 .unwrap()
3369 .contains("cyclomatic complexity 25")
3370 );
3371 assert_eq!(
3372 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3373 "src/utils.ts"
3374 );
3375 let region = &entry["locations"][0]["physicalLocation"]["region"];
3376 assert_eq!(region["startLine"], 42);
3377 assert_eq!(region["startColumn"], 1);
3378 }
3379
3380 #[test]
3381 fn health_sarif_cognitive_only() {
3382 let root = PathBuf::from("/project");
3383 let report = crate::health_types::HealthReport {
3384 findings: vec![
3385 crate::health_types::ComplexityViolation {
3386 path: root.join("src/api.ts"),
3387 name: "handleRequest".to_string(),
3388 line: 10,
3389 col: 4,
3390 cyclomatic: 8,
3391 cognitive: 20,
3392 line_count: 40,
3393 param_count: 0,
3394 react_hook_count: 0,
3395 react_jsx_max_depth: 0,
3396 react_prop_count: 0,
3397 react_hook_profile: None,
3398 exceeded: crate::health_types::ExceededThreshold::Cognitive,
3399 severity: crate::health_types::FindingSeverity::High,
3400 crap: None,
3401 coverage_pct: None,
3402 coverage_tier: None,
3403 coverage_source: None,
3404 inherited_from: None,
3405 component_rollup: None,
3406 contributions: Vec::new(),
3407 effective_thresholds: None,
3408 threshold_source: None,
3409 }
3410 .into(),
3411 ],
3412 summary: crate::health_types::HealthSummary {
3413 files_analyzed: 3,
3414 functions_analyzed: 10,
3415 functions_above_threshold: 1,
3416 ..Default::default()
3417 },
3418 ..Default::default()
3419 };
3420 let sarif = build_health_sarif(&report, &root);
3421 let entry = &sarif["runs"][0]["results"][0];
3422 assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
3423 assert!(
3424 entry["message"]["text"]
3425 .as_str()
3426 .unwrap()
3427 .contains("cognitive complexity 20")
3428 );
3429 let region = &entry["locations"][0]["physicalLocation"]["region"];
3430 assert_eq!(region["startColumn"], 5); }
3432
3433 #[test]
3434 fn health_sarif_both_thresholds() {
3435 let root = PathBuf::from("/project");
3436 let report = crate::health_types::HealthReport {
3437 findings: vec![
3438 crate::health_types::ComplexityViolation {
3439 path: root.join("src/complex.ts"),
3440 name: "doEverything".to_string(),
3441 line: 1,
3442 col: 0,
3443 cyclomatic: 30,
3444 cognitive: 45,
3445 line_count: 100,
3446 param_count: 0,
3447 react_hook_count: 0,
3448 react_jsx_max_depth: 0,
3449 react_prop_count: 0,
3450 react_hook_profile: None,
3451 exceeded: crate::health_types::ExceededThreshold::Both,
3452 severity: crate::health_types::FindingSeverity::High,
3453 crap: None,
3454 coverage_pct: None,
3455 coverage_tier: None,
3456 coverage_source: None,
3457 inherited_from: None,
3458 component_rollup: None,
3459 contributions: Vec::new(),
3460 effective_thresholds: None,
3461 threshold_source: None,
3462 }
3463 .into(),
3464 ],
3465 summary: crate::health_types::HealthSummary {
3466 files_analyzed: 1,
3467 functions_analyzed: 1,
3468 functions_above_threshold: 1,
3469 ..Default::default()
3470 },
3471 ..Default::default()
3472 };
3473 let sarif = build_health_sarif(&report, &root);
3474 let entry = &sarif["runs"][0]["results"][0];
3475 assert_eq!(entry["ruleId"], "fallow/high-complexity");
3476 let msg = entry["message"]["text"].as_str().unwrap();
3477 assert!(msg.contains("cyclomatic complexity 30"));
3478 assert!(msg.contains("cognitive complexity 45"));
3479 }
3480
3481 #[test]
3482 fn health_sarif_crap_only_emits_crap_rule() {
3483 let root = PathBuf::from("/project");
3484 let report = crate::health_types::HealthReport {
3485 findings: vec![
3486 crate::health_types::ComplexityViolation {
3487 path: root.join("src/untested.ts"),
3488 name: "risky".to_string(),
3489 line: 8,
3490 col: 0,
3491 cyclomatic: 10,
3492 cognitive: 10,
3493 line_count: 20,
3494 param_count: 1,
3495 react_hook_count: 0,
3496 react_jsx_max_depth: 0,
3497 react_prop_count: 0,
3498 react_hook_profile: None,
3499 exceeded: crate::health_types::ExceededThreshold::Crap,
3500 severity: crate::health_types::FindingSeverity::High,
3501 crap: Some(82.2),
3502 coverage_pct: Some(12.0),
3503 coverage_tier: None,
3504 coverage_source: None,
3505 inherited_from: None,
3506 component_rollup: None,
3507 contributions: Vec::new(),
3508 effective_thresholds: None,
3509 threshold_source: None,
3510 }
3511 .into(),
3512 ],
3513 summary: crate::health_types::HealthSummary {
3514 files_analyzed: 1,
3515 functions_analyzed: 1,
3516 functions_above_threshold: 1,
3517 ..Default::default()
3518 },
3519 ..Default::default()
3520 };
3521 let sarif = build_health_sarif(&report, &root);
3522 let entry = &sarif["runs"][0]["results"][0];
3523 assert_eq!(entry["ruleId"], "fallow/high-crap-score");
3524 let msg = entry["message"]["text"].as_str().unwrap();
3525 assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
3526 assert!(msg.contains("coverage 12%"), "msg: {msg}");
3527 }
3528
3529 #[test]
3530 fn health_sarif_cyclomatic_crap_uses_crap_rule() {
3531 let root = PathBuf::from("/project");
3532 let report = crate::health_types::HealthReport {
3533 findings: vec![
3534 crate::health_types::ComplexityViolation {
3535 path: root.join("src/hot.ts"),
3536 name: "branchy".to_string(),
3537 line: 1,
3538 col: 0,
3539 cyclomatic: 67,
3540 cognitive: 12,
3541 line_count: 80,
3542 param_count: 1,
3543 react_hook_count: 0,
3544 react_jsx_max_depth: 0,
3545 react_prop_count: 0,
3546 react_hook_profile: None,
3547 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
3548 severity: crate::health_types::FindingSeverity::Critical,
3549 crap: Some(182.0),
3550 coverage_pct: None,
3551 coverage_tier: None,
3552 coverage_source: None,
3553 inherited_from: None,
3554 component_rollup: None,
3555 contributions: Vec::new(),
3556 effective_thresholds: None,
3557 threshold_source: None,
3558 }
3559 .into(),
3560 ],
3561 summary: crate::health_types::HealthSummary {
3562 files_analyzed: 1,
3563 functions_analyzed: 1,
3564 functions_above_threshold: 1,
3565 ..Default::default()
3566 },
3567 ..Default::default()
3568 };
3569 let sarif = build_health_sarif(&report, &root);
3570 let results = sarif["runs"][0]["results"].as_array().unwrap();
3571 assert_eq!(
3572 results.len(),
3573 1,
3574 "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
3575 );
3576 assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
3577 let msg = results[0]["message"]["text"].as_str().unwrap();
3578 assert!(msg.contains("CRAP score 182"), "msg: {msg}");
3579 assert!(!msg.contains("coverage"), "msg: {msg}");
3580 }
3581
3582 #[test]
3583 fn severity_to_sarif_level_error() {
3584 assert_eq!(severity_to_sarif_level(Severity::Error), "error");
3585 }
3586
3587 #[test]
3588 fn severity_to_sarif_level_warn() {
3589 assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
3590 }
3591
3592 #[test]
3593 #[should_panic(expected = "internal error: entered unreachable code")]
3594 fn severity_to_sarif_level_off() {
3595 let _ = severity_to_sarif_level(Severity::Off);
3596 }
3597
3598 #[test]
3599 fn sarif_re_export_has_properties() {
3600 let root = PathBuf::from("/project");
3601 let mut results = AnalysisResults::default();
3602 results
3603 .unused_exports
3604 .push(UnusedExportFinding::with_actions(UnusedExport {
3605 path: root.join("src/index.ts"),
3606 export_name: "reExported".to_string(),
3607 is_type_only: false,
3608 line: 1,
3609 col: 0,
3610 span_start: 0,
3611 is_re_export: true,
3612 }));
3613
3614 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3615 let entry = &sarif["runs"][0]["results"][0];
3616 assert_eq!(entry["properties"]["is_re_export"], true);
3617 let msg = entry["message"]["text"].as_str().unwrap();
3618 assert!(msg.starts_with("Re-export"));
3619 }
3620
3621 #[test]
3622 fn sarif_non_re_export_has_no_properties() {
3623 let root = PathBuf::from("/project");
3624 let mut results = AnalysisResults::default();
3625 results
3626 .unused_exports
3627 .push(UnusedExportFinding::with_actions(UnusedExport {
3628 path: root.join("src/utils.ts"),
3629 export_name: "foo".to_string(),
3630 is_type_only: false,
3631 line: 5,
3632 col: 0,
3633 span_start: 0,
3634 is_re_export: false,
3635 }));
3636
3637 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3638 let entry = &sarif["runs"][0]["results"][0];
3639 assert!(entry.get("properties").is_none());
3640 let msg = entry["message"]["text"].as_str().unwrap();
3641 assert!(msg.starts_with("Export"));
3642 }
3643
3644 #[test]
3645 fn sarif_type_re_export_message() {
3646 let root = PathBuf::from("/project");
3647 let mut results = AnalysisResults::default();
3648 results
3649 .unused_types
3650 .push(UnusedTypeFinding::with_actions(UnusedExport {
3651 path: root.join("src/index.ts"),
3652 export_name: "MyType".to_string(),
3653 is_type_only: true,
3654 line: 1,
3655 col: 0,
3656 span_start: 0,
3657 is_re_export: true,
3658 }));
3659
3660 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3661 let entry = &sarif["runs"][0]["results"][0];
3662 assert_eq!(entry["ruleId"], "fallow/unused-type");
3663 let msg = entry["message"]["text"].as_str().unwrap();
3664 assert!(msg.starts_with("Type re-export"));
3665 assert_eq!(entry["properties"]["is_re_export"], true);
3666 }
3667
3668 #[test]
3669 fn sarif_dependency_line_zero_skips_region() {
3670 let root = PathBuf::from("/project");
3671 let mut results = AnalysisResults::default();
3672 results
3673 .unused_dependencies
3674 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3675 package_name: "lodash".to_string(),
3676 location: DependencyLocation::Dependencies,
3677 path: root.join("package.json"),
3678 line: 0,
3679 used_in_workspaces: Vec::new(),
3680 }));
3681
3682 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3683 let entry = &sarif["runs"][0]["results"][0];
3684 let phys = &entry["locations"][0]["physicalLocation"];
3685 assert!(phys.get("region").is_none());
3686 }
3687
3688 #[test]
3689 fn sarif_dependency_line_nonzero_has_region() {
3690 let root = PathBuf::from("/project");
3691 let mut results = AnalysisResults::default();
3692 results
3693 .unused_dependencies
3694 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3695 package_name: "lodash".to_string(),
3696 location: DependencyLocation::Dependencies,
3697 path: root.join("package.json"),
3698 line: 7,
3699 used_in_workspaces: Vec::new(),
3700 }));
3701
3702 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3703 let entry = &sarif["runs"][0]["results"][0];
3704 let region = &entry["locations"][0]["physicalLocation"]["region"];
3705 assert_eq!(region["startLine"], 7);
3706 assert_eq!(region["startColumn"], 1);
3707 }
3708
3709 #[test]
3710 fn sarif_type_only_dep_line_zero_skips_region() {
3711 let root = PathBuf::from("/project");
3712 let mut results = AnalysisResults::default();
3713 results
3714 .type_only_dependencies
3715 .push(TypeOnlyDependencyFinding::with_actions(
3716 TypeOnlyDependency {
3717 package_name: "zod".to_string(),
3718 path: root.join("package.json"),
3719 line: 0,
3720 },
3721 ));
3722
3723 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3724 let entry = &sarif["runs"][0]["results"][0];
3725 let phys = &entry["locations"][0]["physicalLocation"];
3726 assert!(phys.get("region").is_none());
3727 }
3728
3729 #[test]
3730 fn sarif_circular_dep_line_zero_skips_region() {
3731 let root = PathBuf::from("/project");
3732 let mut results = AnalysisResults::default();
3733 results
3734 .circular_dependencies
3735 .push(CircularDependencyFinding::with_actions(
3736 CircularDependency {
3737 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
3738 length: 2,
3739 line: 0,
3740 col: 0,
3741 edges: Vec::new(),
3742 is_cross_package: false,
3743 },
3744 ));
3745
3746 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3747 let entry = &sarif["runs"][0]["results"][0];
3748 let phys = &entry["locations"][0]["physicalLocation"];
3749 assert!(phys.get("region").is_none());
3750 }
3751
3752 #[test]
3753 fn sarif_circular_dep_line_nonzero_has_region() {
3754 let root = PathBuf::from("/project");
3755 let mut results = AnalysisResults::default();
3756 results
3757 .circular_dependencies
3758 .push(CircularDependencyFinding::with_actions(
3759 CircularDependency {
3760 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
3761 length: 2,
3762 line: 5,
3763 col: 2,
3764 edges: Vec::new(),
3765 is_cross_package: false,
3766 },
3767 ));
3768
3769 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3770 let entry = &sarif["runs"][0]["results"][0];
3771 let region = &entry["locations"][0]["physicalLocation"]["region"];
3772 assert_eq!(region["startLine"], 5);
3773 assert_eq!(region["startColumn"], 3);
3774 }
3775
3776 #[test]
3777 fn sarif_unused_optional_dependency_result() {
3778 let root = PathBuf::from("/project");
3779 let mut results = AnalysisResults::default();
3780 results
3781 .unused_optional_dependencies
3782 .push(UnusedOptionalDependencyFinding::with_actions(
3783 UnusedDependency {
3784 package_name: "fsevents".to_string(),
3785 location: DependencyLocation::OptionalDependencies,
3786 path: root.join("package.json"),
3787 line: 12,
3788 used_in_workspaces: Vec::new(),
3789 },
3790 ));
3791
3792 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3793 let entry = &sarif["runs"][0]["results"][0];
3794 assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
3795 let msg = entry["message"]["text"].as_str().unwrap();
3796 assert!(msg.contains("optionalDependencies"));
3797 }
3798
3799 #[test]
3800 fn sarif_enum_member_message_format() {
3801 let root = PathBuf::from("/project");
3802 let mut results = AnalysisResults::default();
3803 results.unused_enum_members.push(
3804 fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
3805 path: root.join("src/enums.ts"),
3806 parent_name: "Color".to_string(),
3807 member_name: "Purple".to_string(),
3808 kind: fallow_core::extract::MemberKind::EnumMember,
3809 line: 5,
3810 col: 2,
3811 }),
3812 );
3813
3814 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3815 let entry = &sarif["runs"][0]["results"][0];
3816 assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
3817 let msg = entry["message"]["text"].as_str().unwrap();
3818 assert!(msg.contains("Enum member 'Color.Purple'"));
3819 let region = &entry["locations"][0]["physicalLocation"]["region"];
3820 assert_eq!(region["startColumn"], 3); }
3822
3823 #[test]
3824 fn sarif_class_member_message_format() {
3825 let root = PathBuf::from("/project");
3826 let mut results = AnalysisResults::default();
3827 results.unused_class_members.push(
3828 fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
3829 path: root.join("src/service.ts"),
3830 parent_name: "API".to_string(),
3831 member_name: "fetch".to_string(),
3832 kind: fallow_core::extract::MemberKind::ClassMethod,
3833 line: 10,
3834 col: 4,
3835 }),
3836 );
3837
3838 let sarif = build_sarif(&results, &root, &RulesConfig::default());
3839 let entry = &sarif["runs"][0]["results"][0];
3840 assert_eq!(entry["ruleId"], "fallow/unused-class-member");
3841 let msg = entry["message"]["text"].as_str().unwrap();
3842 assert!(msg.contains("Class member 'API.fetch'"));
3843 }
3844
3845 #[test]
3846 #[expect(
3847 clippy::cast_possible_truncation,
3848 reason = "test line/col values are trivially small"
3849 )]
3850 fn duplication_sarif_structure() {
3851 use fallow_core::duplicates::*;
3852
3853 let root = PathBuf::from("/project");
3854 let report = DuplicationReport {
3855 clone_groups: vec![CloneGroup {
3856 instances: vec![
3857 CloneInstance {
3858 file: root.join("src/a.ts"),
3859 start_line: 1,
3860 end_line: 10,
3861 start_col: 0,
3862 end_col: 0,
3863 fragment: String::new(),
3864 },
3865 CloneInstance {
3866 file: root.join("src/b.ts"),
3867 start_line: 5,
3868 end_line: 14,
3869 start_col: 2,
3870 end_col: 0,
3871 fragment: String::new(),
3872 },
3873 ],
3874 token_count: 50,
3875 line_count: 10,
3876 }],
3877 clone_families: vec![],
3878 mirrored_directories: vec![],
3879 stats: DuplicationStats::default(),
3880 };
3881
3882 let sarif = serde_json::json!({
3883 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
3884 "version": "2.1.0",
3885 "runs": [{
3886 "tool": {
3887 "driver": {
3888 "name": "fallow",
3889 "version": env!("CARGO_PKG_VERSION"),
3890 "informationUri": "https://github.com/fallow-rs/fallow",
3891 "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
3892 }
3893 },
3894 "results": []
3895 }]
3896 });
3897 let _ = sarif;
3898
3899 let mut sarif_results = Vec::new();
3900 for (i, group) in report.clone_groups.iter().enumerate() {
3901 for instance in &group.instances {
3902 sarif_results.push(sarif_result(
3903 "fallow/code-duplication",
3904 "warning",
3905 &format!(
3906 "Code clone group {} ({} lines, {} instances)",
3907 i + 1,
3908 group.line_count,
3909 group.instances.len()
3910 ),
3911 &super::super::relative_uri(&instance.file, &root),
3912 Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
3913 ));
3914 }
3915 }
3916 assert_eq!(sarif_results.len(), 2);
3917 assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
3918 assert!(
3919 sarif_results[0]["message"]["text"]
3920 .as_str()
3921 .unwrap()
3922 .contains("10 lines")
3923 );
3924 let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
3925 assert_eq!(region0["startLine"], 1);
3926 assert_eq!(region0["startColumn"], 1); let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
3928 assert_eq!(region1["startLine"], 5);
3929 assert_eq!(region1["startColumn"], 3); }
3931
3932 #[test]
3933 fn sarif_rule_known_id_has_full_description() {
3934 let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
3935 assert!(rule.get("fullDescription").is_some());
3936 assert!(rule.get("helpUri").is_some());
3937 }
3938
3939 #[test]
3940 fn sarif_rule_unknown_id_uses_fallback() {
3941 let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
3942 assert_eq!(rule["shortDescription"]["text"], "fallback text");
3943 assert!(rule.get("fullDescription").is_none());
3944 assert!(rule.get("helpUri").is_none());
3945 assert_eq!(rule["defaultConfiguration"]["level"], "warning");
3946 }
3947
3948 #[test]
3949 fn sarif_result_no_region_omits_region_key() {
3950 let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
3951 let phys = &result["locations"][0]["physicalLocation"];
3952 assert!(phys.get("region").is_none());
3953 assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
3954 }
3955
3956 #[test]
3957 fn sarif_result_with_region_includes_region() {
3958 let result = sarif_result(
3959 "rule/test",
3960 "error",
3961 "test msg",
3962 "src/file.ts",
3963 Some((10, 5)),
3964 );
3965 let region = &result["locations"][0]["physicalLocation"]["region"];
3966 assert_eq!(region["startLine"], 10);
3967 assert_eq!(region["startColumn"], 5);
3968 }
3969
3970 #[test]
3971 fn sarif_partial_fingerprint_ignores_rendered_message() {
3972 let a = sarif_result(
3973 "rule/test",
3974 "error",
3975 "first message",
3976 "src/file.ts",
3977 Some((10, 5)),
3978 );
3979 let b = sarif_result(
3980 "rule/test",
3981 "error",
3982 "rewritten message",
3983 "src/file.ts",
3984 Some((10, 5)),
3985 );
3986 assert_eq!(
3987 a["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
3988 b["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
3989 );
3990 }
3991
3992 #[test]
3993 fn health_sarif_includes_refactoring_targets() {
3994 use crate::health_types::*;
3995
3996 let root = PathBuf::from("/project");
3997 let report = HealthReport {
3998 summary: HealthSummary {
3999 files_analyzed: 10,
4000 functions_analyzed: 50,
4001 ..Default::default()
4002 },
4003 targets: vec![
4004 RefactoringTarget {
4005 path: root.join("src/complex.ts"),
4006 priority: 85.0,
4007 efficiency: 42.5,
4008 recommendation: "Split high-impact file".into(),
4009 category: RecommendationCategory::SplitHighImpact,
4010 effort: EffortEstimate::Medium,
4011 confidence: Confidence::High,
4012 factors: vec![],
4013 evidence: None,
4014 }
4015 .into(),
4016 ],
4017 ..Default::default()
4018 };
4019
4020 let sarif = build_health_sarif(&report, &root);
4021 let entries = sarif["runs"][0]["results"].as_array().unwrap();
4022 assert_eq!(entries.len(), 1);
4023 assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
4024 assert_eq!(entries[0]["level"], "warning");
4025 let msg = entries[0]["message"]["text"].as_str().unwrap();
4026 assert!(msg.contains("high impact"));
4027 assert!(msg.contains("Split high-impact file"));
4028 assert!(msg.contains("42.5"));
4029 }
4030
4031 #[test]
4032 fn health_sarif_includes_coverage_gaps() {
4033 use crate::health_types::*;
4034
4035 let root = PathBuf::from("/project");
4036 let report = HealthReport {
4037 summary: HealthSummary {
4038 files_analyzed: 10,
4039 functions_analyzed: 50,
4040 ..Default::default()
4041 },
4042 coverage_gaps: Some(CoverageGaps {
4043 summary: CoverageGapSummary {
4044 runtime_files: 2,
4045 covered_files: 0,
4046 file_coverage_pct: 0.0,
4047 untested_files: 1,
4048 untested_exports: 1,
4049 },
4050 files: vec![UntestedFileFinding::with_actions(
4051 UntestedFile {
4052 path: root.join("src/app.ts"),
4053 value_export_count: 2,
4054 },
4055 &root,
4056 )],
4057 exports: vec![UntestedExportFinding::with_actions(
4058 UntestedExport {
4059 path: root.join("src/app.ts"),
4060 export_name: "loader".into(),
4061 line: 12,
4062 col: 4,
4063 },
4064 &root,
4065 )],
4066 }),
4067 ..Default::default()
4068 };
4069
4070 let sarif = build_health_sarif(&report, &root);
4071 let entries = sarif["runs"][0]["results"].as_array().unwrap();
4072 assert_eq!(entries.len(), 2);
4073 assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
4074 assert_eq!(
4075 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
4076 "src/app.ts"
4077 );
4078 assert!(
4079 entries[0]["message"]["text"]
4080 .as_str()
4081 .unwrap()
4082 .contains("2 value exports")
4083 );
4084 assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
4085 assert_eq!(
4086 entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
4087 12
4088 );
4089 assert_eq!(
4090 entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
4091 5
4092 );
4093 }
4094
4095 #[test]
4096 fn health_sarif_rules_have_full_descriptions() {
4097 let root = PathBuf::from("/project");
4098 let report = crate::health_types::HealthReport::default();
4099 let sarif = build_health_sarif(&report, &root);
4100 let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
4101 .as_array()
4102 .unwrap();
4103 for rule in rules {
4104 let id = rule["id"].as_str().unwrap();
4105 assert!(
4106 rule.get("fullDescription").is_some(),
4107 "health rule {id} should have fullDescription"
4108 );
4109 assert!(
4110 rule.get("helpUri").is_some(),
4111 "health rule {id} should have helpUri"
4112 );
4113 }
4114 }
4115
4116 #[test]
4117 fn sarif_warn_severity_produces_warning_level() {
4118 let root = PathBuf::from("/project");
4119 let mut results = AnalysisResults::default();
4120 results
4121 .unused_files
4122 .push(UnusedFileFinding::with_actions(UnusedFile {
4123 path: root.join("src/dead.ts"),
4124 }));
4125
4126 let rules = RulesConfig {
4127 unused_files: Severity::Warn,
4128 ..RulesConfig::default()
4129 };
4130
4131 let sarif = build_sarif(&results, &root, &rules);
4132 let entry = &sarif["runs"][0]["results"][0];
4133 assert_eq!(entry["level"], "warning");
4134 }
4135
4136 #[test]
4137 fn sarif_unused_file_has_no_region() {
4138 let root = PathBuf::from("/project");
4139 let mut results = AnalysisResults::default();
4140 results
4141 .unused_files
4142 .push(UnusedFileFinding::with_actions(UnusedFile {
4143 path: root.join("src/dead.ts"),
4144 }));
4145
4146 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4147 let entry = &sarif["runs"][0]["results"][0];
4148 let phys = &entry["locations"][0]["physicalLocation"];
4149 assert!(phys.get("region").is_none());
4150 }
4151
4152 #[test]
4153 fn sarif_unlisted_dep_multiple_import_sites() {
4154 let root = PathBuf::from("/project");
4155 let mut results = AnalysisResults::default();
4156 results
4157 .unlisted_dependencies
4158 .push(UnlistedDependencyFinding::with_actions(
4159 UnlistedDependency {
4160 package_name: "dotenv".to_string(),
4161 imported_from: vec![
4162 ImportSite {
4163 path: root.join("src/a.ts"),
4164 line: 1,
4165 col: 0,
4166 },
4167 ImportSite {
4168 path: root.join("src/b.ts"),
4169 line: 5,
4170 col: 0,
4171 },
4172 ],
4173 },
4174 ));
4175
4176 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4177 let entries = sarif["runs"][0]["results"].as_array().unwrap();
4178 assert_eq!(entries.len(), 2);
4179 assert_eq!(
4180 entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
4181 "src/a.ts"
4182 );
4183 assert_eq!(
4184 entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
4185 "src/b.ts"
4186 );
4187 }
4188
4189 #[test]
4190 fn sarif_unlisted_dep_no_import_sites() {
4191 let root = PathBuf::from("/project");
4192 let mut results = AnalysisResults::default();
4193 results
4194 .unlisted_dependencies
4195 .push(UnlistedDependencyFinding::with_actions(
4196 UnlistedDependency {
4197 package_name: "phantom".to_string(),
4198 imported_from: vec![],
4199 },
4200 ));
4201
4202 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4203 let entries = sarif["runs"][0]["results"].as_array().unwrap();
4204 assert!(entries.is_empty());
4205 }
4206
4207 #[test]
4209 fn source_snippet_cache_line_zero_returns_none() {
4210 let root = PathBuf::from("/project");
4214 let mut results = AnalysisResults::default();
4215 results
4216 .unused_dependencies
4217 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
4218 package_name: "zero-line".to_string(),
4219 location: DependencyLocation::Dependencies,
4220 path: root.join("package.json"),
4221 line: 0,
4222 used_in_workspaces: Vec::new(),
4223 }));
4224 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4225 let entry = &sarif["runs"][0]["results"][0];
4226 let phys = &entry["locations"][0]["physicalLocation"];
4228 assert!(phys.get("region").is_none());
4229 }
4230
4231 #[test]
4233 fn sarif_private_type_leak_result() {
4234 let root = PathBuf::from("/project");
4235 let mut results = AnalysisResults::default();
4236 results
4237 .private_type_leaks
4238 .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
4239 path: root.join("src/api.ts"),
4240 export_name: "publicFn".to_string(),
4241 type_name: "_InternalType".to_string(),
4242 line: 7,
4243 col: 2,
4244 span_start: 0,
4245 }));
4246 let rules = RulesConfig {
4247 private_type_leaks: Severity::Error,
4248 ..RulesConfig::default()
4249 };
4250 let sarif = build_sarif(&results, &root, &rules);
4251 let entry = &sarif["runs"][0]["results"][0];
4252 assert_eq!(entry["ruleId"], "fallow/private-type-leak");
4253 assert_eq!(entry["level"], "error");
4254 let msg = entry["message"]["text"].as_str().unwrap();
4255 assert!(
4256 msg.contains("publicFn"),
4257 "message should mention the export name"
4258 );
4259 assert!(
4260 msg.contains("_InternalType"),
4261 "message should mention the private type"
4262 );
4263 let region = &entry["locations"][0]["physicalLocation"]["region"];
4264 assert_eq!(region["startLine"], 7);
4265 assert_eq!(region["startColumn"], 3); assert_eq!(
4267 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
4268 "src/api.ts"
4269 );
4270 }
4271
4272 #[test]
4274 fn sarif_dep_with_workspace_context_in_message() {
4275 let root = PathBuf::from("/project");
4276 let mut results = AnalysisResults::default();
4277 results
4278 .unused_dependencies
4279 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
4280 package_name: "shared-lib".to_string(),
4281 location: DependencyLocation::Dependencies,
4282 path: root.join("packages/app/package.json"),
4283 line: 4,
4284 used_in_workspaces: vec![root.join("packages/other/package.json")],
4285 }));
4286 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4287 let entry = &sarif["runs"][0]["results"][0];
4288 assert_eq!(entry["ruleId"], "fallow/unused-dependency");
4289 let msg = entry["message"]["text"].as_str().unwrap();
4290 assert!(
4291 msg.contains("imported in other workspaces"),
4292 "workspace hint should appear: {msg}"
4293 );
4294 }
4295
4296 #[test]
4298 fn sarif_re_export_cycle_self_loop() {
4299 let root = PathBuf::from("/project");
4300 let mut results = AnalysisResults::default();
4301 results
4302 .re_export_cycles
4303 .push(ReExportCycleFinding::with_actions(ReExportCycle {
4304 files: vec![root.join("src/barrel.ts")],
4305 kind: ReExportCycleKind::SelfLoop,
4306 }));
4307 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4308 let entry = &sarif["runs"][0]["results"][0];
4309 assert_eq!(entry["ruleId"], "fallow/re-export-cycle");
4310 let msg = entry["message"]["text"].as_str().unwrap();
4311 assert!(
4312 msg.contains("(self-loop)"),
4313 "self-loop tag should be present: {msg}"
4314 );
4315 assert!(
4316 msg.contains("src/barrel.ts"),
4317 "file should be in the message: {msg}"
4318 );
4319 }
4320
4321 #[test]
4322 fn sarif_re_export_cycle_multi_node() {
4323 let root = PathBuf::from("/project");
4324 let mut results = AnalysisResults::default();
4325 results
4326 .re_export_cycles
4327 .push(ReExportCycleFinding::with_actions(ReExportCycle {
4328 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
4329 kind: ReExportCycleKind::MultiNode,
4330 }));
4331 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4332 let entry = &sarif["runs"][0]["results"][0];
4333 assert_eq!(entry["ruleId"], "fallow/re-export-cycle");
4334 let msg = entry["message"]["text"].as_str().unwrap();
4335 assert!(
4337 !msg.contains("(self-loop)"),
4338 "multi-node should not have self-loop tag: {msg}"
4339 );
4340 assert!(msg.contains("src/a.ts"), "first file should appear: {msg}");
4341 }
4342
4343 #[test]
4345 fn sarif_boundary_violation_line_zero_skips_region() {
4346 let root = PathBuf::from("/project");
4347 let mut results = AnalysisResults::default();
4348 results
4349 .boundary_violations
4350 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
4351 from_path: root.join("src/ui/Btn.tsx"),
4352 to_path: root.join("src/db/query.ts"),
4353 from_zone: "ui".to_string(),
4354 to_zone: "db".to_string(),
4355 import_specifier: "src/db/query.ts".to_string(),
4356 line: 0,
4357 col: 0,
4358 }));
4359 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4360 let entry = &sarif["runs"][0]["results"][0];
4361 assert_eq!(entry["ruleId"], "fallow/boundary-violation");
4362 let phys = &entry["locations"][0]["physicalLocation"];
4363 assert!(phys.get("region").is_none());
4364 }
4365
4366 #[test]
4368 fn sarif_boundary_coverage_result() {
4369 let root = PathBuf::from("/project");
4370 let mut results = AnalysisResults::default();
4371 results
4372 .boundary_coverage_violations
4373 .push(BoundaryCoverageViolationFinding::with_actions(
4374 BoundaryCoverageViolation {
4375 path: root.join("src/orphan.ts"),
4376 line: 1,
4377 col: 0,
4378 },
4379 ));
4380 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4381 let entry = &sarif["runs"][0]["results"][0];
4382 assert_eq!(entry["ruleId"], "fallow/boundary-coverage");
4383 let msg = entry["message"]["text"].as_str().unwrap();
4384 assert!(
4385 msg.contains("architecture boundary zone"),
4386 "message should describe coverage gap: {msg}"
4387 );
4388 let region = &entry["locations"][0]["physicalLocation"]["region"];
4389 assert_eq!(region["startLine"], 1);
4390 assert_eq!(region["startColumn"], 1); }
4392
4393 #[test]
4395 fn sarif_boundary_call_violation_result() {
4396 let root = PathBuf::from("/project");
4397 let mut results = AnalysisResults::default();
4398 results
4399 .boundary_call_violations
4400 .push(BoundaryCallViolationFinding::with_actions(
4401 BoundaryCallViolation {
4402 path: root.join("src/browser/index.ts"),
4403 line: 10,
4404 col: 4,
4405 zone: "browser".to_string(),
4406 callee: "fs.readFileSync".to_string(),
4407 pattern: "fs.*".to_string(),
4408 },
4409 ));
4410 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4411 let entry = &sarif["runs"][0]["results"][0];
4412 assert_eq!(entry["ruleId"], "fallow/boundary-call-violation");
4413 let msg = entry["message"]["text"].as_str().unwrap();
4414 assert!(msg.contains("fs.readFileSync"), "callee in message: {msg}");
4415 assert!(msg.contains("fs.*"), "pattern in message: {msg}");
4416 assert!(msg.contains("browser"), "zone in message: {msg}");
4417 let region = &entry["locations"][0]["physicalLocation"]["region"];
4418 assert_eq!(region["startLine"], 10);
4419 assert_eq!(region["startColumn"], 5); }
4421
4422 #[test]
4424 fn sarif_policy_violation_with_message() {
4425 let root = PathBuf::from("/project");
4426 let mut results = AnalysisResults::default();
4427 results
4428 .policy_violations
4429 .push(PolicyViolationFinding::with_actions(PolicyViolation {
4430 path: root.join("src/service.ts"),
4431 line: 3,
4432 col: 0,
4433 pack: "security".to_string(),
4434 rule_id: "no-eval".to_string(),
4435 kind: PolicyRuleKind::BannedCall,
4436 matched: "eval".to_string(),
4437 severity: PolicyViolationSeverity::Error,
4438 message: Some("eval is a security hazard".to_string()),
4439 }));
4440 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4441 let entry = &sarif["runs"][0]["results"][0];
4442 assert_eq!(entry["ruleId"], "fallow/policy-violation");
4443 assert_eq!(entry["level"], "error");
4444 let msg = entry["message"]["text"].as_str().unwrap();
4445 assert!(
4446 msg.contains("security/no-eval"),
4447 "pack/rule in message: {msg}"
4448 );
4449 assert!(
4450 msg.contains("eval is a security hazard"),
4451 "custom message in output: {msg}"
4452 );
4453 assert_eq!(entry["properties"]["policyRule"], "security/no-eval");
4455 }
4456
4457 #[test]
4458 fn sarif_policy_violation_without_message() {
4459 let root = PathBuf::from("/project");
4460 let mut results = AnalysisResults::default();
4461 results
4462 .policy_violations
4463 .push(PolicyViolationFinding::with_actions(PolicyViolation {
4464 path: root.join("src/legacy.ts"),
4465 line: 1,
4466 col: 0,
4467 pack: "style".to_string(),
4468 rule_id: "no-moment".to_string(),
4469 kind: PolicyRuleKind::BannedImport,
4470 matched: "moment".to_string(),
4471 severity: PolicyViolationSeverity::Warn,
4472 message: None,
4473 }));
4474 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4475 let entry = &sarif["runs"][0]["results"][0];
4476 assert_eq!(entry["ruleId"], "fallow/policy-violation");
4477 assert_eq!(entry["level"], "warning");
4478 let msg = entry["message"]["text"].as_str().unwrap();
4479 assert!(msg.contains("style/no-moment"), "pack/rule: {msg}");
4480 assert!(
4481 !msg.contains("None"),
4482 "None should not appear when message is absent: {msg}"
4483 );
4484 }
4485
4486 #[test]
4488 fn sarif_misplaced_directive_result() {
4489 let root = PathBuf::from("/project");
4490 let mut results = AnalysisResults::default();
4491 results
4492 .misplaced_directives
4493 .push(MisplacedDirectiveFinding::with_actions(
4494 MisplacedDirective {
4495 path: root.join("src/components/Client.tsx"),
4496 directive: "use client".to_string(),
4497 line: 5,
4498 col: 0,
4499 },
4500 ));
4501 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4502 let entry = &sarif["runs"][0]["results"][0];
4503 assert_eq!(entry["ruleId"], "fallow/misplaced-directive");
4504 let msg = entry["message"]["text"].as_str().unwrap();
4505 assert!(msg.contains("use client"), "directive in message: {msg}");
4506 assert!(
4507 msg.contains("leading position"),
4508 "guidance in message: {msg}"
4509 );
4510 let region = &entry["locations"][0]["physicalLocation"]["region"];
4511 assert_eq!(region["startLine"], 5);
4512 assert_eq!(region["startColumn"], 1); }
4514
4515 #[test]
4517 fn sarif_mixed_client_server_barrel_result() {
4518 let root = PathBuf::from("/project");
4519 let mut results = AnalysisResults::default();
4520 results
4521 .mixed_client_server_barrels
4522 .push(MixedClientServerBarrelFinding::with_actions(
4523 MixedClientServerBarrel {
4524 path: root.join("src/components/index.ts"),
4525 client_origin: "Button.tsx".to_string(),
4526 server_origin: "actions.ts".to_string(),
4527 line: 2,
4528 col: 0,
4529 },
4530 ));
4531 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4532 let entry = &sarif["runs"][0]["results"][0];
4533 assert_eq!(entry["ruleId"], "fallow/mixed-client-server-barrel");
4534 let msg = entry["message"]["text"].as_str().unwrap();
4535 assert!(
4536 msg.contains("Button.tsx"),
4537 "client origin in message: {msg}"
4538 );
4539 assert!(
4540 msg.contains("actions.ts"),
4541 "server origin in message: {msg}"
4542 );
4543 }
4544
4545 #[test]
4547 fn sarif_prop_drilling_result() {
4548 use fallow_types::output_dead_code::PropDrillingChainFinding;
4549 let root = PathBuf::from("/project");
4550 let mut results = AnalysisResults::default();
4551 results
4552 .prop_drilling_chains
4553 .push(PropDrillingChainFinding::with_actions(PropDrillingChain {
4554 prop: "userId".to_string(),
4555 depth: 3,
4556 hops: vec![
4557 PropDrillHop {
4558 file: root.join("src/Page.tsx"),
4559 line: 10,
4560 component: "Page".to_string(),
4561 },
4562 PropDrillHop {
4563 file: root.join("src/Section.tsx"),
4564 line: 5,
4565 component: "Section".to_string(),
4566 },
4567 PropDrillHop {
4568 file: root.join("src/Widget.tsx"),
4569 line: 3,
4570 component: "Widget".to_string(),
4571 },
4572 ],
4573 }));
4574 let rules = RulesConfig {
4575 prop_drilling: Severity::Warn,
4576 ..RulesConfig::default()
4577 };
4578 let sarif = build_sarif(&results, &root, &rules);
4579 let entry = &sarif["runs"][0]["results"][0];
4580 assert_eq!(entry["ruleId"], "fallow/prop-drilling");
4581 let msg = entry["message"]["text"].as_str().unwrap();
4582 assert!(msg.contains("userId"), "prop name in message: {msg}");
4583 assert!(msg.contains('3'), "depth in message: {msg}");
4584 assert!(msg.contains("Widget"), "consumer in message: {msg}");
4585 assert_eq!(
4587 entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
4588 .as_str()
4589 .unwrap()
4590 .replace('\\', "/"),
4591 "src/Page.tsx"
4592 );
4593 let region = &entry["locations"][0]["physicalLocation"]["region"];
4594 assert_eq!(region["startLine"], 10);
4595 }
4596
4597 #[test]
4599 fn sarif_thin_wrapper_result() {
4600 use fallow_types::output_dead_code::ThinWrapperFinding;
4601 let root = PathBuf::from("/project");
4602 let mut results = AnalysisResults::default();
4603 results
4604 .thin_wrappers
4605 .push(ThinWrapperFinding::with_actions(ThinWrapper {
4606 file: root.join("src/AliasBtn.tsx"),
4607 line: 4,
4608 component: "AliasBtn".to_string(),
4609 child_component: "Button".to_string(),
4610 }));
4611 let rules = RulesConfig {
4612 thin_wrapper: Severity::Warn,
4613 ..RulesConfig::default()
4614 };
4615 let sarif = build_sarif(&results, &root, &rules);
4616 let entry = &sarif["runs"][0]["results"][0];
4617 assert_eq!(entry["ruleId"], "fallow/thin-wrapper");
4618 let msg = entry["message"]["text"].as_str().unwrap();
4619 assert!(msg.contains("AliasBtn"), "wrapper name in message: {msg}");
4620 assert!(msg.contains("Button"), "child name in message: {msg}");
4621 let region = &entry["locations"][0]["physicalLocation"]["region"];
4622 assert_eq!(region["startLine"], 4);
4623 assert_eq!(region["startColumn"], 1);
4624 }
4625
4626 #[test]
4628 fn sarif_duplicate_prop_shape_result() {
4629 let root = PathBuf::from("/project");
4630 let mut results = AnalysisResults::default();
4631 results
4632 .duplicate_prop_shapes
4633 .push(DuplicatePropShapeFinding::with_actions(
4634 DuplicatePropShape {
4635 file: root.join("src/CardA.tsx"),
4636 line: 2,
4637 component: "CardA".to_string(),
4638 shape: vec!["id".to_string(), "label".to_string(), "onClick".to_string()],
4639 group_size: 3,
4640 sharing_components: vec![
4641 DuplicatePropShapeMember {
4642 file: root.join("src/CardB.tsx"),
4643 line: 2,
4644 component: "CardB".to_string(),
4645 },
4646 DuplicatePropShapeMember {
4647 file: root.join("src/CardC.tsx"),
4648 line: 2,
4649 component: "CardC".to_string(),
4650 },
4651 ],
4652 },
4653 ));
4654 let rules = RulesConfig {
4655 duplicate_prop_shape: Severity::Warn,
4656 ..RulesConfig::default()
4657 };
4658 let sarif = build_sarif(&results, &root, &rules);
4659 let entry = &sarif["runs"][0]["results"][0];
4660 assert_eq!(entry["ruleId"], "fallow/duplicate-prop-shape");
4661 let msg = entry["message"]["text"].as_str().unwrap();
4662 assert!(msg.contains("CardA"), "component in message: {msg}");
4663 assert!(msg.contains("id"), "shape in message: {msg}");
4664 let region = &entry["locations"][0]["physicalLocation"]["region"];
4665 assert_eq!(region["startLine"], 2);
4666 assert_eq!(region["startColumn"], 1);
4667 }
4668
4669 #[test]
4671 fn sarif_route_collision_result() {
4672 let root = PathBuf::from("/project");
4673 let mut results = AnalysisResults::default();
4674 results
4675 .route_collisions
4676 .push(RouteCollisionFinding::with_actions(RouteCollision {
4677 path: root.join("src/app/about/page.tsx"),
4678 url: "/about".to_string(),
4679 conflicting_paths: vec![root.join("src/app/(marketing)/about/page.tsx")],
4680 line: 1,
4681 col: 0,
4682 }));
4683 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4684 let entry = &sarif["runs"][0]["results"][0];
4685 assert_eq!(entry["ruleId"], "fallow/route-collision");
4686 let msg = entry["message"]["text"].as_str().unwrap();
4687 assert!(msg.contains("/about"), "URL in message: {msg}");
4688 assert!(
4689 msg.contains("1 other file"),
4690 "conflict count in message: {msg}"
4691 );
4692 }
4693
4694 #[test]
4696 fn sarif_dynamic_segment_name_conflict_result() {
4697 let root = PathBuf::from("/project");
4698 let mut results = AnalysisResults::default();
4699 results.dynamic_segment_name_conflicts.push(
4700 DynamicSegmentNameConflictFinding::with_actions(DynamicSegmentNameConflict {
4701 path: root.join("src/app/shop/[id]/page.tsx"),
4702 position: "/shop".to_string(),
4703 conflicting_segments: vec!["[id]".to_string(), "[slug]".to_string()],
4704 conflicting_paths: vec![root.join("src/app/shop/[slug]/page.tsx")],
4705 line: 1,
4706 col: 0,
4707 }),
4708 );
4709 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4710 let entry = &sarif["runs"][0]["results"][0];
4711 assert_eq!(entry["ruleId"], "fallow/dynamic-segment-name-conflict");
4712 let msg = entry["message"]["text"].as_str().unwrap();
4713 assert!(msg.contains("/shop"), "position in message: {msg}");
4714 assert!(
4715 msg.contains("[id]"),
4716 "conflicting segment in message: {msg}"
4717 );
4718 assert!(
4719 msg.contains("[slug]"),
4720 "conflicting segment in message: {msg}"
4721 );
4722 }
4723
4724 #[test]
4726 fn sarif_unused_catalog_entry_named_catalog() {
4727 let root = PathBuf::from("/project");
4728 let mut results = AnalysisResults::default();
4729 results
4730 .unused_catalog_entries
4731 .push(UnusedCatalogEntryFinding::with_actions(
4732 UnusedCatalogEntry {
4733 entry_name: "react".to_string(),
4734 catalog_name: "react17".to_string(),
4735 path: root.join("pnpm-workspace.yaml"),
4736 line: 5,
4737 hardcoded_consumers: vec![],
4738 },
4739 ));
4740 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4741 let entry = &sarif["runs"][0]["results"][0];
4742 assert_eq!(entry["ruleId"], "fallow/unused-catalog-entry");
4743 let msg = entry["message"]["text"].as_str().unwrap();
4744 assert!(msg.contains("react17"), "catalog name in message: {msg}");
4746 assert!(msg.contains("react"), "entry name in message: {msg}");
4747 }
4748
4749 #[test]
4751 fn sarif_unused_catalog_entry_default_catalog() {
4752 let root = PathBuf::from("/project");
4753 let mut results = AnalysisResults::default();
4754 results
4755 .unused_catalog_entries
4756 .push(UnusedCatalogEntryFinding::with_actions(
4757 UnusedCatalogEntry {
4758 entry_name: "lodash".to_string(),
4759 catalog_name: "default".to_string(),
4760 path: root.join("pnpm-workspace.yaml"),
4761 line: 3,
4762 hardcoded_consumers: vec![],
4763 },
4764 ));
4765 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4766 let entry = &sarif["runs"][0]["results"][0];
4767 let msg = entry["message"]["text"].as_str().unwrap();
4768 assert!(msg.contains("lodash"), "entry name in message: {msg}");
4771 assert!(
4772 !msg.contains("(catalog 'default')"),
4773 "default catalog should not appear in parentheses: {msg}"
4774 );
4775 }
4776
4777 #[test]
4779 fn sarif_unresolved_catalog_reference_named_catalog() {
4780 let root = PathBuf::from("/project");
4781 let mut results = AnalysisResults::default();
4782 results.unresolved_catalog_references.push(
4783 UnresolvedCatalogReferenceFinding::with_actions(UnresolvedCatalogReference {
4784 entry_name: "zod".to_string(),
4785 catalog_name: "peer-deps".to_string(),
4786 path: root.join("packages/app/package.json"),
4787 line: 7,
4788 available_in_catalogs: vec![],
4789 }),
4790 );
4791 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4792 let entry = &sarif["runs"][0]["results"][0];
4793 assert_eq!(entry["ruleId"], "fallow/unresolved-catalog-reference");
4794 let msg = entry["message"]["text"].as_str().unwrap();
4795 assert!(msg.contains("zod"), "package name in message: {msg}");
4796 assert!(msg.contains("peer-deps"), "catalog name in message: {msg}");
4797 }
4798
4799 #[test]
4800 fn sarif_unresolved_catalog_reference_default_catalog() {
4801 let root = PathBuf::from("/project");
4802 let mut results = AnalysisResults::default();
4803 results.unresolved_catalog_references.push(
4804 UnresolvedCatalogReferenceFinding::with_actions(UnresolvedCatalogReference {
4805 entry_name: "typescript".to_string(),
4806 catalog_name: "default".to_string(),
4807 path: root.join("packages/app/package.json"),
4808 line: 4,
4809 available_in_catalogs: vec![],
4810 }),
4811 );
4812 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4813 let entry = &sarif["runs"][0]["results"][0];
4814 assert_eq!(entry["ruleId"], "fallow/unresolved-catalog-reference");
4815 let msg = entry["message"]["text"].as_str().unwrap();
4816 assert!(msg.contains("typescript"), "package name in message: {msg}");
4817 assert!(
4818 msg.contains("the default catalog"),
4819 "default catalog description in message: {msg}"
4820 );
4821 }
4822
4823 #[test]
4825 fn sarif_unresolved_catalog_reference_with_available_catalogs() {
4826 let root = PathBuf::from("/project");
4827 let mut results = AnalysisResults::default();
4828 results.unresolved_catalog_references.push(
4829 UnresolvedCatalogReferenceFinding::with_actions(UnresolvedCatalogReference {
4830 entry_name: "react".to_string(),
4831 catalog_name: "react18".to_string(),
4832 path: root.join("packages/ui/package.json"),
4833 line: 6,
4834 available_in_catalogs: vec!["default".to_string(), "react17".to_string()],
4835 }),
4836 );
4837 let sarif = build_sarif(&results, &root, &RulesConfig::default());
4838 let entry = &sarif["runs"][0]["results"][0];
4839 let msg = entry["message"]["text"].as_str().unwrap();
4840 assert!(
4841 msg.contains("available in:"),
4842 "available catalogs hint in message: {msg}"
4843 );
4844 assert!(msg.contains("default"), "default in available list: {msg}");
4845 assert!(msg.contains("react17"), "react17 in available list: {msg}");
4846 }
4847
4848 #[test]
4850 fn health_sarif_critical_severity_maps_to_error_level() {
4851 let root = PathBuf::from("/project");
4852 let report = crate::health_types::HealthReport {
4853 findings: vec![
4854 crate::health_types::ComplexityViolation {
4855 path: root.join("src/behemoth.ts"),
4856 name: "doAll".to_string(),
4857 line: 1,
4858 col: 0,
4859 cyclomatic: 50,
4860 cognitive: 60,
4861 line_count: 300,
4862 param_count: 0,
4863 react_hook_count: 0,
4864 react_jsx_max_depth: 0,
4865 react_prop_count: 0,
4866 react_hook_profile: None,
4867 exceeded: crate::health_types::ExceededThreshold::Both,
4868 severity: crate::health_types::FindingSeverity::Critical,
4869 crap: None,
4870 coverage_pct: None,
4871 coverage_tier: None,
4872 coverage_source: None,
4873 inherited_from: None,
4874 component_rollup: None,
4875 contributions: Vec::new(),
4876 effective_thresholds: None,
4877 threshold_source: None,
4878 }
4879 .into(),
4880 ],
4881 summary: crate::health_types::HealthSummary {
4882 files_analyzed: 1,
4883 functions_analyzed: 1,
4884 functions_above_threshold: 1,
4885 ..Default::default()
4886 },
4887 ..Default::default()
4888 };
4889 let sarif = build_health_sarif(&report, &root);
4890 let entry = &sarif["runs"][0]["results"][0];
4891 assert_eq!(entry["level"], "error");
4892 }
4893
4894 #[test]
4895 fn health_sarif_moderate_severity_maps_to_note_level() {
4896 let root = PathBuf::from("/project");
4897 let report = crate::health_types::HealthReport {
4898 findings: vec![
4899 crate::health_types::ComplexityViolation {
4900 path: root.join("src/mild.ts"),
4901 name: "parseArgs".to_string(),
4902 line: 5,
4903 col: 0,
4904 cyclomatic: 15,
4905 cognitive: 8,
4906 line_count: 40,
4907 param_count: 0,
4908 react_hook_count: 0,
4909 react_jsx_max_depth: 0,
4910 react_prop_count: 0,
4911 react_hook_profile: None,
4912 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
4913 severity: crate::health_types::FindingSeverity::Moderate,
4914 crap: None,
4915 coverage_pct: None,
4916 coverage_tier: None,
4917 coverage_source: None,
4918 inherited_from: None,
4919 component_rollup: None,
4920 contributions: Vec::new(),
4921 effective_thresholds: None,
4922 threshold_source: None,
4923 }
4924 .into(),
4925 ],
4926 summary: crate::health_types::HealthSummary {
4927 files_analyzed: 1,
4928 functions_analyzed: 1,
4929 functions_above_threshold: 1,
4930 ..Default::default()
4931 },
4932 ..Default::default()
4933 };
4934 let sarif = build_health_sarif(&report, &root);
4935 let entry = &sarif["runs"][0]["results"][0];
4936 assert_eq!(entry["level"], "note");
4937 }
4938
4939 #[test]
4940 fn health_sarif_crap_no_coverage_omits_coverage_phrase() {
4941 let root = PathBuf::from("/project");
4942 let report = crate::health_types::HealthReport {
4943 findings: vec![
4944 crate::health_types::ComplexityViolation {
4945 path: root.join("src/risky.ts"),
4946 name: "fragile".to_string(),
4947 line: 1,
4948 col: 0,
4949 cyclomatic: 20,
4950 cognitive: 5,
4951 line_count: 60,
4952 param_count: 0,
4953 react_hook_count: 0,
4954 react_jsx_max_depth: 0,
4955 react_prop_count: 0,
4956 react_hook_profile: None,
4957 exceeded: crate::health_types::ExceededThreshold::CognitiveCrap,
4958 severity: crate::health_types::FindingSeverity::High,
4959 crap: Some(60.0),
4960 coverage_pct: None,
4961 coverage_tier: None,
4962 coverage_source: None,
4963 inherited_from: None,
4964 component_rollup: None,
4965 contributions: Vec::new(),
4966 effective_thresholds: None,
4967 threshold_source: None,
4968 }
4969 .into(),
4970 ],
4971 summary: crate::health_types::HealthSummary {
4972 files_analyzed: 1,
4973 functions_analyzed: 1,
4974 functions_above_threshold: 1,
4975 ..Default::default()
4976 },
4977 ..Default::default()
4978 };
4979 let sarif = build_health_sarif(&report, &root);
4980 let entry = &sarif["runs"][0]["results"][0];
4981 assert_eq!(entry["ruleId"], "fallow/high-crap-score");
4982 let msg = entry["message"]["text"].as_str().unwrap();
4983 assert!(msg.contains("CRAP score 60.0"), "crap score in msg: {msg}");
4984 assert!(
4985 !msg.contains("coverage"),
4986 "no coverage phrase when pct absent: {msg}"
4987 );
4988 }
4989
4990 #[test]
4991 fn health_sarif_all_exceeded_threshold_uses_crap_rule() {
4992 let root = PathBuf::from("/project");
4993 let report = crate::health_types::HealthReport {
4994 findings: vec![
4995 crate::health_types::ComplexityViolation {
4996 path: root.join("src/monster.ts"),
4997 name: "giant".to_string(),
4998 line: 1,
4999 col: 0,
5000 cyclomatic: 80,
5001 cognitive: 90,
5002 line_count: 400,
5003 param_count: 0,
5004 react_hook_count: 0,
5005 react_jsx_max_depth: 0,
5006 react_prop_count: 0,
5007 react_hook_profile: None,
5008 exceeded: crate::health_types::ExceededThreshold::All,
5009 severity: crate::health_types::FindingSeverity::Critical,
5010 crap: Some(200.0),
5011 coverage_pct: Some(5.0),
5012 coverage_tier: None,
5013 coverage_source: None,
5014 inherited_from: None,
5015 component_rollup: None,
5016 contributions: Vec::new(),
5017 effective_thresholds: None,
5018 threshold_source: None,
5019 }
5020 .into(),
5021 ],
5022 summary: crate::health_types::HealthSummary {
5023 files_analyzed: 1,
5024 functions_analyzed: 1,
5025 functions_above_threshold: 1,
5026 ..Default::default()
5027 },
5028 ..Default::default()
5029 };
5030 let sarif = build_health_sarif(&report, &root);
5031 let entry = &sarif["runs"][0]["results"][0];
5032 assert_eq!(entry["ruleId"], "fallow/high-crap-score");
5033 let msg = entry["message"]["text"].as_str().unwrap();
5034 assert!(msg.contains("coverage 5%"), "coverage in msg: {msg}");
5035 }
5036
5037 #[test]
5039 fn health_sarif_runtime_coverage_safe_to_delete() {
5040 use crate::health_types::{
5041 RuntimeCoverageConfidence, RuntimeCoverageEvidence, RuntimeCoverageFinding,
5042 RuntimeCoverageReport, RuntimeCoverageReportVerdict, RuntimeCoverageSummary,
5043 RuntimeCoverageVerdict,
5044 };
5045
5046 let root = PathBuf::from("/project");
5047 let report = crate::health_types::HealthReport {
5048 summary: crate::health_types::HealthSummary::default(),
5049 runtime_coverage: Some(RuntimeCoverageReport {
5050 schema_version: crate::health_types::RuntimeCoverageSchemaVersion::V1,
5051 verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
5052 signals: vec![],
5053 summary: RuntimeCoverageSummary::default(),
5054 findings: vec![RuntimeCoverageFinding {
5055 id: "fallow:prod:abc123".to_string(),
5056 stable_id: None,
5057 source_hash: None,
5058 path: root.join("src/dead.ts"),
5059 function: "orphan".to_string(),
5060 line: 3,
5061 verdict: RuntimeCoverageVerdict::SafeToDelete,
5062 invocations: Some(0),
5063 confidence: RuntimeCoverageConfidence::High,
5064 evidence: RuntimeCoverageEvidence {
5065 static_status: "unused".to_string(),
5066 test_coverage: "not_covered".to_string(),
5067 v8_tracking: "tracked".to_string(),
5068 untracked_reason: None,
5069 observation_days: 30,
5070 deployments_observed: 1,
5071 },
5072 actions: vec![],
5073 }],
5074 hot_paths: vec![],
5075 blast_radius: vec![],
5076 importance: vec![],
5077 watermark: None,
5078 warnings: vec![],
5079 }),
5080 ..Default::default()
5081 };
5082 let sarif = build_health_sarif(&report, &root);
5083 let entry = &sarif["runs"][0]["results"][0];
5084 assert_eq!(entry["ruleId"], "fallow/runtime-safe-to-delete");
5085 assert_eq!(entry["level"], "warning");
5086 let msg = entry["message"]["text"].as_str().unwrap();
5087 assert!(msg.contains("orphan"), "function name in msg: {msg}");
5088 assert!(msg.contains("safe to delete"), "verdict in msg: {msg}");
5089 assert!(
5090 msg.contains("0 invocations"),
5091 "invocation count in msg: {msg}"
5092 );
5093 }
5094
5095 #[test]
5096 fn health_sarif_runtime_coverage_review_required() {
5097 use crate::health_types::{
5098 RuntimeCoverageConfidence, RuntimeCoverageEvidence, RuntimeCoverageFinding,
5099 RuntimeCoverageReport, RuntimeCoverageReportVerdict, RuntimeCoverageSummary,
5100 RuntimeCoverageVerdict,
5101 };
5102
5103 let root = PathBuf::from("/project");
5104 let report = crate::health_types::HealthReport {
5105 summary: crate::health_types::HealthSummary::default(),
5106 runtime_coverage: Some(RuntimeCoverageReport {
5107 schema_version: crate::health_types::RuntimeCoverageSchemaVersion::V1,
5108 verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
5109 signals: vec![],
5110 summary: RuntimeCoverageSummary::default(),
5111 findings: vec![RuntimeCoverageFinding {
5112 id: "fallow:prod:def456".to_string(),
5113 stable_id: None,
5114 source_hash: None,
5115 path: root.join("src/maybe.ts"),
5116 function: "maybeUsed".to_string(),
5117 line: 7,
5118 verdict: RuntimeCoverageVerdict::ReviewRequired,
5119 invocations: None,
5120 confidence: RuntimeCoverageConfidence::Medium,
5121 evidence: RuntimeCoverageEvidence {
5122 static_status: "used".to_string(),
5123 test_coverage: "not_covered".to_string(),
5124 v8_tracking: "tracked".to_string(),
5125 untracked_reason: None,
5126 observation_days: 7,
5127 deployments_observed: 1,
5128 },
5129 actions: vec![],
5130 }],
5131 hot_paths: vec![],
5132 blast_radius: vec![],
5133 importance: vec![],
5134 watermark: None,
5135 warnings: vec![],
5136 }),
5137 ..Default::default()
5138 };
5139 let sarif = build_health_sarif(&report, &root);
5140 let entry = &sarif["runs"][0]["results"][0];
5141 assert_eq!(entry["ruleId"], "fallow/runtime-review-required");
5142 assert_eq!(entry["level"], "warning");
5143 let msg = entry["message"]["text"].as_str().unwrap();
5144 assert!(msg.contains("untracked"), "untracked hint in msg: {msg}");
5146 }
5147
5148 #[test]
5149 fn health_sarif_runtime_coverage_low_traffic_verdict() {
5150 use crate::health_types::{
5151 RuntimeCoverageConfidence, RuntimeCoverageEvidence, RuntimeCoverageFinding,
5152 RuntimeCoverageReport, RuntimeCoverageReportVerdict, RuntimeCoverageSummary,
5153 RuntimeCoverageVerdict,
5154 };
5155
5156 let root = PathBuf::from("/project");
5157 let report = crate::health_types::HealthReport {
5158 summary: crate::health_types::HealthSummary::default(),
5159 runtime_coverage: Some(RuntimeCoverageReport {
5160 schema_version: crate::health_types::RuntimeCoverageSchemaVersion::V1,
5161 verdict: RuntimeCoverageReportVerdict::Unknown,
5162 signals: vec![],
5163 summary: RuntimeCoverageSummary::default(),
5164 findings: vec![RuntimeCoverageFinding {
5165 id: "fallow:prod:ghi789".to_string(),
5166 stable_id: None,
5167 source_hash: None,
5168 path: root.join("src/rare.ts"),
5169 function: "rarelyUsed".to_string(),
5170 line: 2,
5171 verdict: RuntimeCoverageVerdict::LowTraffic,
5172 invocations: Some(3),
5173 confidence: RuntimeCoverageConfidence::Low,
5174 evidence: RuntimeCoverageEvidence {
5175 static_status: "used".to_string(),
5176 test_coverage: "covered".to_string(),
5177 v8_tracking: "tracked".to_string(),
5178 untracked_reason: None,
5179 observation_days: 14,
5180 deployments_observed: 1,
5181 },
5182 actions: vec![],
5183 }],
5184 hot_paths: vec![],
5185 blast_radius: vec![],
5186 importance: vec![],
5187 watermark: None,
5188 warnings: vec![],
5189 }),
5190 ..Default::default()
5191 };
5192 let sarif = build_health_sarif(&report, &root);
5193 let entry = &sarif["runs"][0]["results"][0];
5194 assert_eq!(entry["ruleId"], "fallow/runtime-low-traffic");
5195 assert_eq!(entry["level"], "note");
5197 }
5198
5199 #[test]
5200 fn health_sarif_runtime_coverage_unavailable_verdict() {
5201 use crate::health_types::{
5202 RuntimeCoverageConfidence, RuntimeCoverageEvidence, RuntimeCoverageFinding,
5203 RuntimeCoverageReport, RuntimeCoverageReportVerdict, RuntimeCoverageSummary,
5204 RuntimeCoverageVerdict,
5205 };
5206
5207 let root = PathBuf::from("/project");
5208 let report = crate::health_types::HealthReport {
5209 summary: crate::health_types::HealthSummary::default(),
5210 runtime_coverage: Some(RuntimeCoverageReport {
5211 schema_version: crate::health_types::RuntimeCoverageSchemaVersion::V1,
5212 verdict: RuntimeCoverageReportVerdict::Unknown,
5213 signals: vec![],
5214 summary: RuntimeCoverageSummary::default(),
5215 findings: vec![RuntimeCoverageFinding {
5216 id: "fallow:prod:jkl000".to_string(),
5217 stable_id: None,
5218 source_hash: None,
5219 path: root.join("src/unknown.ts"),
5220 function: "mysteryFn".to_string(),
5221 line: 1,
5222 verdict: RuntimeCoverageVerdict::CoverageUnavailable,
5223 invocations: None,
5224 confidence: RuntimeCoverageConfidence::None,
5225 evidence: RuntimeCoverageEvidence {
5226 static_status: "used".to_string(),
5227 test_coverage: "not_covered".to_string(),
5228 v8_tracking: "untracked".to_string(),
5229 untracked_reason: Some("lazy_parsed".to_string()),
5230 observation_days: 0,
5231 deployments_observed: 0,
5232 },
5233 actions: vec![],
5234 }],
5235 hot_paths: vec![],
5236 blast_radius: vec![],
5237 importance: vec![],
5238 watermark: None,
5239 warnings: vec![],
5240 }),
5241 ..Default::default()
5242 };
5243 let sarif = build_health_sarif(&report, &root);
5244 let entry = &sarif["runs"][0]["results"][0];
5245 assert_eq!(entry["ruleId"], "fallow/runtime-coverage-unavailable");
5246 assert_eq!(entry["level"], "note");
5247 }
5248
5249 #[test]
5250 fn health_sarif_runtime_active_verdict_maps_to_generic_rule() {
5251 use crate::health_types::{
5252 RuntimeCoverageConfidence, RuntimeCoverageEvidence, RuntimeCoverageFinding,
5253 RuntimeCoverageReport, RuntimeCoverageReportVerdict, RuntimeCoverageSummary,
5254 RuntimeCoverageVerdict,
5255 };
5256
5257 let root = PathBuf::from("/project");
5258 let report = crate::health_types::HealthReport {
5259 summary: crate::health_types::HealthSummary::default(),
5260 runtime_coverage: Some(RuntimeCoverageReport {
5261 schema_version: crate::health_types::RuntimeCoverageSchemaVersion::V1,
5262 verdict: RuntimeCoverageReportVerdict::Clean,
5263 signals: vec![],
5264 summary: RuntimeCoverageSummary::default(),
5265 findings: vec![RuntimeCoverageFinding {
5266 id: "fallow:prod:mno111".to_string(),
5267 stable_id: None,
5268 source_hash: None,
5269 path: root.join("src/hot.ts"),
5270 function: "hotPath".to_string(),
5271 line: 10,
5272 verdict: RuntimeCoverageVerdict::Active,
5273 invocations: Some(10_000),
5274 confidence: RuntimeCoverageConfidence::VeryHigh,
5275 evidence: RuntimeCoverageEvidence {
5276 static_status: "used".to_string(),
5277 test_coverage: "covered".to_string(),
5278 v8_tracking: "tracked".to_string(),
5279 untracked_reason: None,
5280 observation_days: 30,
5281 deployments_observed: 5,
5282 },
5283 actions: vec![],
5284 }],
5285 hot_paths: vec![],
5286 blast_radius: vec![],
5287 importance: vec![],
5288 watermark: None,
5289 warnings: vec![],
5290 }),
5291 ..Default::default()
5292 };
5293 let sarif = build_health_sarif(&report, &root);
5294 let entry = &sarif["runs"][0]["results"][0];
5295 assert_eq!(entry["ruleId"], "fallow/runtime-coverage");
5296 assert_eq!(entry["level"], "note");
5297 }
5298
5299 #[test]
5301 fn health_sarif_coverage_intelligence_clean_verdict_skipped() {
5302 use crate::health_types::{
5303 CoverageIntelligenceConfidence, CoverageIntelligenceEvidence,
5304 CoverageIntelligenceFinding, CoverageIntelligenceMatchConfidence,
5305 CoverageIntelligenceRecommendation, CoverageIntelligenceReport,
5306 CoverageIntelligenceSchemaVersion, CoverageIntelligenceSummary,
5307 CoverageIntelligenceVerdict, HealthReport, HealthSummary,
5308 };
5309
5310 let root = PathBuf::from("/project");
5311 let report = HealthReport {
5312 summary: HealthSummary::default(),
5313 coverage_intelligence: Some(CoverageIntelligenceReport {
5314 schema_version: CoverageIntelligenceSchemaVersion::V1,
5315 verdict: CoverageIntelligenceVerdict::Clean,
5316 summary: CoverageIntelligenceSummary::default(),
5317 findings: vec![CoverageIntelligenceFinding {
5318 id: "fallow:coverage-intel:cleanid".to_string(),
5319 path: root.join("src/clean.ts"),
5320 identity: Some("cleanFn".to_string()),
5321 line: 1,
5322 verdict: CoverageIntelligenceVerdict::Clean,
5323 signals: vec![],
5324 recommendation: CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge,
5325 confidence: CoverageIntelligenceConfidence::High,
5326 related_ids: vec![],
5327 evidence: CoverageIntelligenceEvidence {
5328 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
5329 ..Default::default()
5330 },
5331 actions: vec![],
5332 }],
5333 }),
5334 ..Default::default()
5335 };
5336 let sarif = build_health_sarif(&report, &root);
5337 let results = sarif["runs"][0]["results"].as_array().unwrap();
5338 assert!(
5339 results.is_empty(),
5340 "Clean verdict should produce no SARIF results"
5341 );
5342 }
5343
5344 #[test]
5346 fn health_sarif_coverage_intelligence_risky_change_rule_id() {
5347 use crate::health_types::{
5348 CoverageIntelligenceConfidence, CoverageIntelligenceEvidence,
5349 CoverageIntelligenceFinding, CoverageIntelligenceMatchConfidence,
5350 CoverageIntelligenceRecommendation, CoverageIntelligenceReport,
5351 CoverageIntelligenceSchemaVersion, CoverageIntelligenceSummary,
5352 CoverageIntelligenceVerdict, HealthReport, HealthSummary,
5353 };
5354
5355 let root = PathBuf::from("/project");
5356 let report = HealthReport {
5357 summary: HealthSummary::default(),
5358 coverage_intelligence: Some(CoverageIntelligenceReport {
5359 schema_version: CoverageIntelligenceSchemaVersion::V1,
5360 verdict: CoverageIntelligenceVerdict::RiskyChangeDetected,
5361 summary: CoverageIntelligenceSummary::default(),
5362 findings: vec![CoverageIntelligenceFinding {
5363 id: "fallow:coverage-intel:risky1".to_string(),
5364 path: root.join("src/risky.ts"),
5365 identity: Some("riskyFn".to_string()),
5366 line: 4,
5367 verdict: CoverageIntelligenceVerdict::RiskyChangeDetected,
5368 signals: vec![],
5369 recommendation: CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge,
5370 confidence: CoverageIntelligenceConfidence::High,
5371 related_ids: vec![],
5372 evidence: CoverageIntelligenceEvidence {
5373 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
5374 ..Default::default()
5375 },
5376 actions: vec![],
5377 }],
5378 }),
5379 ..Default::default()
5380 };
5381 let sarif = build_health_sarif(&report, &root);
5382 let entry = &sarif["runs"][0]["results"][0];
5383 assert_eq!(entry["ruleId"], "fallow/coverage-intelligence-risky-change");
5384 }
5385
5386 #[test]
5387 fn health_sarif_coverage_intelligence_review_rule_id() {
5388 use crate::health_types::{
5389 CoverageIntelligenceConfidence, CoverageIntelligenceEvidence,
5390 CoverageIntelligenceFinding, CoverageIntelligenceMatchConfidence,
5391 CoverageIntelligenceRecommendation, CoverageIntelligenceReport,
5392 CoverageIntelligenceSchemaVersion, CoverageIntelligenceSummary,
5393 CoverageIntelligenceVerdict, HealthReport, HealthSummary,
5394 };
5395
5396 let root = PathBuf::from("/project");
5397 let report = HealthReport {
5398 summary: HealthSummary::default(),
5399 coverage_intelligence: Some(CoverageIntelligenceReport {
5400 schema_version: CoverageIntelligenceSchemaVersion::V1,
5401 verdict: CoverageIntelligenceVerdict::ReviewRequired,
5402 summary: CoverageIntelligenceSummary::default(),
5403 findings: vec![CoverageIntelligenceFinding {
5404 id: "fallow:coverage-intel:review1".to_string(),
5405 path: root.join("src/cold.ts"),
5406 identity: Some("coldFn".to_string()),
5407 line: 2,
5408 verdict: CoverageIntelligenceVerdict::ReviewRequired,
5409 signals: vec![],
5410 recommendation: CoverageIntelligenceRecommendation::ReviewBeforeChanging,
5411 confidence: CoverageIntelligenceConfidence::Medium,
5412 related_ids: vec![],
5413 evidence: CoverageIntelligenceEvidence {
5414 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
5415 ..Default::default()
5416 },
5417 actions: vec![],
5418 }],
5419 }),
5420 ..Default::default()
5421 };
5422 let sarif = build_health_sarif(&report, &root);
5423 let entry = &sarif["runs"][0]["results"][0];
5424 assert_eq!(entry["ruleId"], "fallow/coverage-intelligence-review");
5425 }
5426
5427 #[test]
5428 fn health_sarif_coverage_intelligence_refactor_rule_id() {
5429 use crate::health_types::{
5430 CoverageIntelligenceConfidence, CoverageIntelligenceEvidence,
5431 CoverageIntelligenceFinding, CoverageIntelligenceMatchConfidence,
5432 CoverageIntelligenceRecommendation, CoverageIntelligenceReport,
5433 CoverageIntelligenceSchemaVersion, CoverageIntelligenceSummary,
5434 CoverageIntelligenceVerdict, HealthReport, HealthSummary,
5435 };
5436
5437 let root = PathBuf::from("/project");
5438 let report = HealthReport {
5439 summary: HealthSummary::default(),
5440 coverage_intelligence: Some(CoverageIntelligenceReport {
5441 schema_version: CoverageIntelligenceSchemaVersion::V1,
5442 verdict: CoverageIntelligenceVerdict::RefactorCarefully,
5443 summary: CoverageIntelligenceSummary::default(),
5444 findings: vec![CoverageIntelligenceFinding {
5445 id: "fallow:coverage-intel:refactor1".to_string(),
5446 path: root.join("src/hot_complex.ts"),
5447 identity: Some("hotComplexFn".to_string()),
5448 line: 6,
5449 verdict: CoverageIntelligenceVerdict::RefactorCarefully,
5450 signals: vec![],
5451 recommendation:
5452 CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior,
5453 confidence: CoverageIntelligenceConfidence::High,
5454 related_ids: vec![],
5455 evidence: CoverageIntelligenceEvidence {
5456 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
5457 ..Default::default()
5458 },
5459 actions: vec![],
5460 }],
5461 }),
5462 ..Default::default()
5463 };
5464 let sarif = build_health_sarif(&report, &root);
5465 let entry = &sarif["runs"][0]["results"][0];
5466 assert_eq!(entry["ruleId"], "fallow/coverage-intelligence-refactor");
5467 }
5468
5469 #[test]
5471 fn health_grouped_sarif_adds_group_property() {
5472 use crate::report::grouping::OwnershipResolver;
5473
5474 let root = PathBuf::from("/project");
5475 let report = crate::health_types::HealthReport {
5476 findings: vec![
5477 crate::health_types::ComplexityViolation {
5478 path: root.join("src/utils.ts"),
5479 name: "parseExpr".to_string(),
5480 line: 10,
5481 col: 0,
5482 cyclomatic: 20,
5483 cognitive: 10,
5484 line_count: 50,
5485 param_count: 0,
5486 react_hook_count: 0,
5487 react_jsx_max_depth: 0,
5488 react_prop_count: 0,
5489 react_hook_profile: None,
5490 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
5491 severity: crate::health_types::FindingSeverity::High,
5492 crap: None,
5493 coverage_pct: None,
5494 coverage_tier: None,
5495 coverage_source: None,
5496 inherited_from: None,
5497 component_rollup: None,
5498 contributions: Vec::new(),
5499 effective_thresholds: None,
5500 threshold_source: None,
5501 }
5502 .into(),
5503 ],
5504 summary: crate::health_types::HealthSummary {
5505 files_analyzed: 1,
5506 functions_analyzed: 1,
5507 functions_above_threshold: 1,
5508 ..Default::default()
5509 },
5510 ..Default::default()
5511 };
5512
5513 let resolver = OwnershipResolver::Directory;
5515 let mut sarif = build_health_sarif(&report, &root);
5516
5517 if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
5518 for run in runs {
5519 if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
5520 for result in results {
5521 let uri = result
5522 .pointer("/locations/0/physicalLocation/artifactLocation/uri")
5523 .and_then(|v| v.as_str())
5524 .unwrap_or("");
5525 let decoded = uri.replace("%5B", "[").replace("%5D", "]");
5526 let group = super::super::grouping::resolve_owner(
5527 std::path::Path::new(&decoded),
5528 std::path::Path::new(""),
5529 &resolver,
5530 );
5531 let props = result
5532 .as_object_mut()
5533 .unwrap()
5534 .entry("properties")
5535 .or_insert_with(|| serde_json::json!({}));
5536 props
5537 .as_object_mut()
5538 .unwrap()
5539 .insert("group".to_string(), serde_json::Value::String(group));
5540 }
5541 }
5542 }
5543 }
5544
5545 let entry = &sarif["runs"][0]["results"][0];
5546 assert!(entry["properties"].get("group").is_some());
5548 }
5549
5550 #[test]
5552 fn health_sarif_coverage_gap_single_export_singular_message() {
5553 use crate::health_types::{
5554 CoverageGapSummary, CoverageGaps, HealthReport, HealthSummary, UntestedFile,
5555 UntestedFileFinding,
5556 };
5557
5558 let root = PathBuf::from("/project");
5559 let report = HealthReport {
5560 summary: HealthSummary::default(),
5561 coverage_gaps: Some(CoverageGaps {
5562 summary: CoverageGapSummary {
5563 runtime_files: 1,
5564 covered_files: 0,
5565 file_coverage_pct: 0.0,
5566 untested_files: 1,
5567 untested_exports: 0,
5568 },
5569 files: vec![UntestedFileFinding::with_actions(
5570 UntestedFile {
5571 path: root.join("src/solo.ts"),
5572 value_export_count: 1,
5573 },
5574 &root,
5575 )],
5576 exports: vec![],
5577 }),
5578 ..Default::default()
5579 };
5580 let sarif = build_health_sarif(&report, &root);
5581 let entry = &sarif["runs"][0]["results"][0];
5582 let msg = entry["message"]["text"].as_str().unwrap();
5583 assert!(
5585 msg.contains("1 value export)"),
5586 "singular export in msg: {msg}"
5587 );
5588 assert!(
5589 !msg.contains("exports)"),
5590 "plural should not appear for count=1: {msg}"
5591 );
5592 }
5593
5594 #[test]
5595 fn health_sarif_coverage_gap_plural_exports_message() {
5596 use crate::health_types::{
5597 CoverageGapSummary, CoverageGaps, HealthReport, HealthSummary, UntestedFile,
5598 UntestedFileFinding,
5599 };
5600
5601 let root = PathBuf::from("/project");
5602 let report = HealthReport {
5603 summary: HealthSummary::default(),
5604 coverage_gaps: Some(CoverageGaps {
5605 summary: CoverageGapSummary {
5606 runtime_files: 1,
5607 covered_files: 0,
5608 file_coverage_pct: 0.0,
5609 untested_files: 1,
5610 untested_exports: 0,
5611 },
5612 files: vec![UntestedFileFinding::with_actions(
5613 UntestedFile {
5614 path: root.join("src/multi.ts"),
5615 value_export_count: 5,
5616 },
5617 &root,
5618 )],
5619 exports: vec![],
5620 }),
5621 ..Default::default()
5622 };
5623 let sarif = build_health_sarif(&report, &root);
5624 let entry = &sarif["runs"][0]["results"][0];
5625 let msg = entry["message"]["text"].as_str().unwrap();
5626 assert!(
5627 msg.contains("5 value exports)"),
5628 "plural exports in msg: {msg}"
5629 );
5630 }
5631}