Skip to main content

fallow_cli/report/
sarif.rs

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
25/// Intermediate fields extracted from an issue for SARIF result construction.
26struct 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/// Read-only context threaded through the SARIF result builders: the
61/// analysis results, project root, and rule severities. Bundled so the
62/// `push_*_sarif_results` family shares one parameter instead of three.
63#[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
81/// Build a single SARIF result object.
82///
83/// When `region` is `Some((line, col))`, a `region` block with 1-based
84/// `startLine` and `startColumn` is included in the physical location.
85fn 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
134/// Append SARIF results for a slice of items using a closure to extract fields.
135fn 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
163/// Build a SARIF rule definition with optional `fullDescription` and `helpUri`
164/// sourced from the centralized explain module.
165fn 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
186/// Extract SARIF fields for an unused export or type export.
187fn 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
233/// Extract SARIF fields for an unused dependency.
234fn 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
270/// Extract SARIF fields for an unused enum or class member.
271fn 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        // The SARIF rule id is the static `fallow/policy-violation`; the
507        // per-rule policy identity rides in properties so code-scanning
508        // consumers can group or filter per pack rule without parsing the
509        // message. Dynamic per-rule SARIF rule synthesis is a tracked
510        // follow-up shared with boundary zone rules.
511        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    // Anchor at the source hop (the prop owner). Path / line come from the first
751    // hop; the message names the depth and the consumer at the chain tail.
752    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
1011/// Unlisted deps fan out to one SARIF result per import site, so they do not
1012/// fit `push_sarif_results`. Keep the nested-loop shape in its own helper.
1013fn 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
1040/// Duplicate exports fan out to one SARIF result per location
1041/// (SARIF 2.1.0 section 3.27.12), so they do not fit `push_sarif_results`.
1042fn 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
1066/// Build the SARIF rules list from the current rules configuration.
1067fn 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
1485/// Push SARIF results for unused runtime, dev, and optional dependencies.
1486fn 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
1536/// Push SARIF results for type-only and test-only dependency misclassifications.
1537fn 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
1655/// Push the component-contract SARIF results (`unused-component-prop` and
1656/// `unused-component-emit`). Extracted from `push_graph_sarif_results` to keep
1657/// that function under the unit-size lint.
1658fn 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
1668/// Push SARIF results for unused component props, emits, inputs, and outputs.
1669fn 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
1730/// Push SARIF results for Svelte events, server actions, and load-data keys.
1731fn 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
1780/// Push SARIF results for prop drilling, thin wrappers, and duplicate prop shapes.
1781fn 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
1841/// Push SARIF results for circular dependencies and re-export cycles.
1842fn 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
1874/// Push SARIF results for boundary violations, coverage, calls, and policy violations.
1875fn 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
1931/// Push SARIF results for client exports, barrels, directives, injects, and unrendered components.
1932fn 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
2061/// Push SARIF results for unused catalog entries, empty groups, and unresolved references.
2062fn 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
2111/// Push SARIF results for unused and misconfigured dependency overrides.
2112fn 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/// Print SARIF output with owner properties added to each result.
2155///
2156/// Calls `build_sarif` to produce the standard SARIF JSON, then post-processes
2157/// each result to add `"properties": { "owner": "@team" }` by resolving the
2158/// artifact location URI through the `OwnershipResolver`.
2159#[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/// Print SARIF duplication output with a `properties.group` tag on every
2247/// result.
2248///
2249/// Each clone group is attributed to its largest owner (most instances; ties
2250/// broken alphabetically) via [`super::dupes_grouping::largest_owner`], and
2251/// every result emitted for that group's instances carries the same
2252/// `properties.group` value. This mirrors the health SARIF convention
2253/// (`print_grouped_health_sarif`) so consumers (GitHub Code Scanning, GitLab
2254/// Code Quality) can partition findings per team / package / directory
2255/// without re-resolving ownership.
2256#[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/// Print health SARIF with a per-result `properties.group` tag.
2756///
2757/// Mirrors the dead-code grouped SARIF pattern (`print_grouped_sarif`):
2758/// build the standard SARIF first, then post-process each result to inject
2759/// the resolver-derived group key on `properties.group`. Consumers that read
2760/// SARIF (GitHub Code Scanning, GitLab Code Quality) can then partition
2761/// findings per team / package / directory without dropping out of the
2762/// SARIF pipeline. Each finding's URI is decoded (`%5B` -> `[`, `%5D` -> `]`)
2763/// before resolution, matching the dead-code behaviour for paths containing
2764/// brackets like Next.js dynamic routes.
2765#[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); // col 4 + 1
3431    }
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); // col 2 + 1
3821    }
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); // start_col 0 + 1
3927        let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
3928        assert_eq!(region1["startLine"], 5);
3929        assert_eq!(region1["startColumn"], 3); // start_col 2 + 1
3930    }
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    // --- Lines 44-45: SourceSnippetCache returns None for line == 0 ---
4208    #[test]
4209    fn source_snippet_cache_line_zero_returns_none() {
4210        // An UnusedFile has no region; sarif_unused_file_fields sets source_path None
4211        // so the snippet path is never read. We exercise the line == 0 guard
4212        // indirectly through a dep finding with line = 0 (no snippet, no crash).
4213        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        // line == 0 means no region block
4227        let phys = &entry["locations"][0]["physicalLocation"];
4228        assert!(phys.get("region").is_none());
4229    }
4230
4231    // --- Lines 214-234: sarif_private_type_leak_fields (lines 1445-1453 push) ---
4232    #[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); // col 2 + 1
4266        assert_eq!(
4267            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
4268            "src/api.ts"
4269        );
4270    }
4271
4272    // --- Lines 244-253: sarif_dep_fields with non-empty used_in_workspaces ---
4273    #[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    // --- Lines 343-345 / 375-376 / 384-386: re-export cycle variants ---
4297    #[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        // MultiNode should NOT carry the (self-loop) tag
4336        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    // --- Lines 442-444: boundary violation with line == 0 ---
4344    #[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    // --- Lines 449-463: sarif_boundary_coverage_fields ---
4367    #[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); // col 0 + 1
4391    }
4392
4393    // --- Lines 465-482: sarif_boundary_call_fields ---
4394    #[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); // col 4 + 1
4420    }
4421
4422    // --- Lines 484-515: sarif_policy_violation_fields (with and without message) ---
4423    #[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        // Policy violations carry policyRule in properties
4454        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    // --- Lines 555-572: sarif_misplaced_directive_fields (lines 1971-1978 push) ---
4487    #[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); // col 0 + 1
4513    }
4514
4515    // --- Lines 536-553: sarif_mixed_client_server_barrel_fields (lines 1961-1965) ---
4516    #[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    // --- Lines 745-768: sarif_prop_drilling_fields (lines 1796-1799) ---
4546    #[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        // Anchored at the first hop
4586        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    // --- Lines 770-787: sarif_thin_wrapper_fields (lines 1800-1806) ---
4598    #[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    // --- Lines 789-808: sarif_duplicate_prop_shape_fields (lines 1807-1818) ---
4627    #[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    // --- Lines 810-828: sarif_route_collision_fields (lines 2011-2017) ---
4670    #[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    // --- Lines 830-847: sarif_dynamic_segment_name_conflict_fields (lines 2018-2029) ---
4695    #[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    // --- Lines 878-904: sarif_unused_catalog_entry_fields named-catalog branch ---
4725    #[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        // Named catalog message format: "Catalog entry 'X' (catalog 'Y') ..."
4745        assert!(msg.contains("react17"), "catalog name in message: {msg}");
4746        assert!(msg.contains("react"), "entry name in message: {msg}");
4747    }
4748
4749    // --- Lines 919-920: sarif_unused_catalog_entry_fields default-catalog branch ---
4750    #[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        // Default-catalog message format: "Catalog entry 'X' is not referenced ..."
4769        // (does NOT include "(catalog 'default')")
4770        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    // --- Lines 954-991: sarif_unresolved_catalog_reference_fields ---
4778    #[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    // --- Lines 982-983: available_in_catalogs non-empty branch ---
4824    #[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    // --- Health SARIF: lines 2163-2326 ---
4849    #[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    // --- Runtime coverage SARIF (lines 2601-2679) ---
5038    #[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        // invocations is None => "untracked" hint
5145        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        // LowTraffic maps to "note" level (not SafeToDelete/ReviewRequired)
5196        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    // --- Coverage intelligence: clean/unknown verdicts skip (lines 2691-2692) ---
5300    #[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    // Coverage intelligence rule-id variants (lines 2728-2745)
5345    #[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    // --- Lines 2747-2769: print_grouped_health_sarif decorates results with group property ---
5470    #[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        // Empty CODEOWNERS resolver produces an empty-string group
5514        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        // The group key is always present after the post-process pass
5547        assert!(entry["properties"].get("group").is_some());
5548    }
5549
5550    // --- Coverage gap plural/singular branches (lines 2599-2603) ---
5551    #[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        // value_export_count == 1 => singular "value export" (no trailing 's')
5584        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}