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
60fn severity_to_sarif_level(s: Severity) -> &'static str {
61    severity::sarif_level(s)
62}
63
64fn configured_sarif_level(s: Severity) -> &'static str {
65    match s {
66        Severity::Error | Severity::Warn => severity_to_sarif_level(s),
67        Severity::Off => "none",
68    }
69}
70
71/// Build a single SARIF result object.
72///
73/// When `region` is `Some((line, col))`, a `region` block with 1-based
74/// `startLine` and `startColumn` is included in the physical location.
75fn sarif_result(
76    rule_id: &str,
77    level: &str,
78    message: &str,
79    uri: &str,
80    region: Option<(u32, u32)>,
81) -> serde_json::Value {
82    sarif_result_with_snippet(rule_id, level, message, uri, region, None)
83}
84
85fn sarif_result_with_snippet(
86    rule_id: &str,
87    level: &str,
88    message: &str,
89    uri: &str,
90    region: Option<(u32, u32)>,
91    snippet: Option<&str>,
92) -> serde_json::Value {
93    let mut physical_location = serde_json::json!({
94        "artifactLocation": { "uri": uri }
95    });
96    if let Some((line, col)) = region {
97        physical_location["region"] = serde_json::json!({
98            "startLine": line,
99            "startColumn": col
100        });
101    }
102    let line = region.map_or_else(String::new, |(line, _)| line.to_string());
103    let col = region.map_or_else(String::new, |(_, col)| col.to_string());
104    let normalized_snippet = snippet
105        .map(fingerprint::normalize_snippet)
106        .filter(|snippet| !snippet.is_empty());
107    let partial_fingerprint = normalized_snippet.as_ref().map_or_else(
108        || fingerprint::fingerprint_hash(&[rule_id, uri, &line, &col]),
109        |snippet| fingerprint::finding_fingerprint(rule_id, uri, snippet),
110    );
111    let partial_fingerprint_ghas = partial_fingerprint.clone();
112    serde_json::json!({
113        "ruleId": rule_id,
114        "level": level,
115        "message": { "text": message },
116        "locations": [{ "physicalLocation": physical_location }],
117        "partialFingerprints": {
118            fingerprint::FINGERPRINT_KEY: partial_fingerprint,
119            fingerprint::GHAS_FINGERPRINT_KEY: partial_fingerprint_ghas
120        }
121    })
122}
123
124/// Append SARIF results for a slice of items using a closure to extract fields.
125fn push_sarif_results<T>(
126    sarif_results: &mut Vec<serde_json::Value>,
127    items: &[T],
128    snippets: &mut SourceSnippetCache,
129    mut extract: impl FnMut(&T) -> SarifFields,
130) {
131    for item in items {
132        let fields = extract(item);
133        let source_snippet = fields
134            .source_path
135            .as_deref()
136            .zip(fields.region)
137            .and_then(|(path, (line, _))| snippets.line(path, line));
138        let mut result = sarif_result_with_snippet(
139            fields.rule_id,
140            fields.level,
141            &fields.message,
142            &fields.uri,
143            fields.region,
144            source_snippet.as_deref(),
145        );
146        if let Some(props) = fields.properties {
147            result["properties"] = props;
148        }
149        sarif_results.push(result);
150    }
151}
152
153/// Build a SARIF rule definition with optional `fullDescription` and `helpUri`
154/// sourced from the centralized explain module.
155fn sarif_rule(id: &str, fallback_short: &str, level: &str) -> serde_json::Value {
156    explain::rule_by_id(id).map_or_else(
157        || {
158            serde_json::json!({
159                "id": id,
160                "shortDescription": { "text": fallback_short },
161                "defaultConfiguration": { "level": level }
162            })
163        },
164        |def| {
165            serde_json::json!({
166                "id": id,
167                "shortDescription": { "text": def.short },
168                "fullDescription": { "text": def.full },
169                "helpUri": explain::rule_docs_url(def),
170                "defaultConfiguration": { "level": level }
171            })
172        },
173    )
174}
175
176/// Extract SARIF fields for an unused export or type export.
177fn sarif_export_fields(
178    export: &UnusedExport,
179    root: &Path,
180    rule_id: &'static str,
181    level: &'static str,
182    kind: &str,
183    re_kind: &str,
184) -> SarifFields {
185    let label = if export.is_re_export { re_kind } else { kind };
186    SarifFields {
187        rule_id,
188        level,
189        message: format!(
190            "{} '{}' is never imported by other modules",
191            label, export.export_name
192        ),
193        uri: relative_uri(&export.path, root),
194        region: Some((export.line, export.col + 1)),
195        source_path: Some(export.path.clone()),
196        properties: if export.is_re_export {
197            Some(serde_json::json!({ "is_re_export": true }))
198        } else {
199            None
200        },
201    }
202}
203
204fn sarif_private_type_leak_fields(
205    leak: &PrivateTypeLeak,
206    root: &Path,
207    level: &'static str,
208) -> SarifFields {
209    SarifFields {
210        rule_id: "fallow/private-type-leak",
211        level,
212        message: format!(
213            "Export '{}' references private type '{}'",
214            leak.export_name, leak.type_name
215        ),
216        uri: relative_uri(&leak.path, root),
217        region: Some((leak.line, leak.col + 1)),
218        source_path: Some(leak.path.clone()),
219        properties: None,
220    }
221}
222
223/// Extract SARIF fields for an unused dependency.
224fn sarif_dep_fields(
225    dep: &UnusedDependency,
226    root: &Path,
227    rule_id: &'static str,
228    level: &'static str,
229    section: &str,
230) -> SarifFields {
231    let workspace_context = if dep.used_in_workspaces.is_empty() {
232        String::new()
233    } else {
234        let workspaces = dep
235            .used_in_workspaces
236            .iter()
237            .map(|path| relative_uri(path, root))
238            .collect::<Vec<_>>()
239            .join(", ");
240        format!("; imported in other workspaces: {workspaces}")
241    };
242    SarifFields {
243        rule_id,
244        level,
245        message: format!(
246            "Package '{}' is in {} but never imported{}",
247            dep.package_name, section, workspace_context
248        ),
249        uri: relative_uri(&dep.path, root),
250        region: if dep.line > 0 {
251            Some((dep.line, 1))
252        } else {
253            None
254        },
255        source_path: (dep.line > 0).then(|| dep.path.clone()),
256        properties: None,
257    }
258}
259
260/// Extract SARIF fields for an unused enum or class member.
261fn sarif_member_fields(
262    member: &UnusedMember,
263    root: &Path,
264    rule_id: &'static str,
265    level: &'static str,
266    kind: &str,
267) -> SarifFields {
268    SarifFields {
269        rule_id,
270        level,
271        message: format!(
272            "{} member '{}.{}' is never referenced",
273            kind, member.parent_name, member.member_name
274        ),
275        uri: relative_uri(&member.path, root),
276        region: Some((member.line, member.col + 1)),
277        source_path: Some(member.path.clone()),
278        properties: None,
279    }
280}
281
282fn sarif_unused_file_fields(file: &UnusedFile, root: &Path, level: &'static str) -> SarifFields {
283    SarifFields {
284        rule_id: "fallow/unused-file",
285        level,
286        message: "File is not reachable from any entry point".to_string(),
287        uri: relative_uri(&file.path, root),
288        region: None,
289        source_path: None,
290        properties: None,
291    }
292}
293
294fn sarif_type_only_dep_fields(
295    dep: &TypeOnlyDependency,
296    root: &Path,
297    level: &'static str,
298) -> SarifFields {
299    SarifFields {
300        rule_id: "fallow/type-only-dependency",
301        level,
302        message: format!(
303            "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
304            dep.package_name
305        ),
306        uri: relative_uri(&dep.path, root),
307        region: if dep.line > 0 {
308            Some((dep.line, 1))
309        } else {
310            None
311        },
312        source_path: (dep.line > 0).then(|| dep.path.clone()),
313        properties: None,
314    }
315}
316
317fn sarif_test_only_dep_fields(
318    dep: &TestOnlyDependency,
319    root: &Path,
320    level: &'static str,
321) -> SarifFields {
322    SarifFields {
323        rule_id: "fallow/test-only-dependency",
324        level,
325        message: format!(
326            "Package '{}' is only imported by test files (consider moving to devDependencies)",
327            dep.package_name
328        ),
329        uri: relative_uri(&dep.path, root),
330        region: if dep.line > 0 {
331            Some((dep.line, 1))
332        } else {
333            None
334        },
335        source_path: (dep.line > 0).then(|| dep.path.clone()),
336        properties: None,
337    }
338}
339
340fn sarif_unresolved_import_fields(
341    import: &UnresolvedImport,
342    root: &Path,
343    level: &'static str,
344) -> SarifFields {
345    SarifFields {
346        rule_id: "fallow/unresolved-import",
347        level,
348        message: format!("Import '{}' could not be resolved", import.specifier),
349        uri: relative_uri(&import.path, root),
350        region: Some((import.line, import.col + 1)),
351        source_path: Some(import.path.clone()),
352        properties: None,
353    }
354}
355
356fn sarif_circular_dep_fields(
357    cycle: &CircularDependency,
358    root: &Path,
359    level: &'static str,
360) -> SarifFields {
361    let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
362    let mut display_chain = chain.clone();
363    if let Some(first) = chain.first() {
364        display_chain.push(first.clone());
365    }
366    let first_uri = chain.first().map_or_else(String::new, Clone::clone);
367    let first_path = cycle.files.first().cloned();
368    SarifFields {
369        rule_id: "fallow/circular-dependency",
370        level,
371        message: format!(
372            "Circular dependency{}: {}",
373            if cycle.is_cross_package {
374                " (cross-package)"
375            } else {
376                ""
377            },
378            display_chain.join(" \u{2192} ")
379        ),
380        uri: first_uri,
381        region: if cycle.line > 0 {
382            Some((cycle.line, cycle.col + 1))
383        } else {
384            None
385        },
386        source_path: (cycle.line > 0).then_some(first_path).flatten(),
387        properties: None,
388    }
389}
390
391fn sarif_re_export_cycle_fields(
392    cycle: &fallow_core::results::ReExportCycle,
393    root: &Path,
394    level: &'static str,
395) -> SarifFields {
396    let chain: Vec<String> = cycle.files.iter().map(|p| relative_uri(p, root)).collect();
397    let first_uri = chain.first().map_or_else(String::new, Clone::clone);
398    let first_path = cycle.files.first().cloned();
399    let kind_tag = match cycle.kind {
400        fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
401        fallow_core::results::ReExportCycleKind::MultiNode => "",
402    };
403    SarifFields {
404        rule_id: "fallow/re-export-cycle",
405        level,
406        message: format!("Re-export cycle{}: {}", kind_tag, chain.join(" <-> ")),
407        uri: first_uri,
408        region: None,
409        source_path: first_path,
410        properties: None,
411    }
412}
413
414fn sarif_boundary_violation_fields(
415    violation: &BoundaryViolation,
416    root: &Path,
417    level: &'static str,
418) -> SarifFields {
419    let from_uri = relative_uri(&violation.from_path, root);
420    let to_uri = relative_uri(&violation.to_path, root);
421    SarifFields {
422        rule_id: "fallow/boundary-violation",
423        level,
424        message: format!(
425            "Import from zone '{}' to zone '{}' is not allowed ({})",
426            violation.from_zone, violation.to_zone, to_uri,
427        ),
428        uri: from_uri,
429        region: if violation.line > 0 {
430            Some((violation.line, violation.col + 1))
431        } else {
432            None
433        },
434        source_path: (violation.line > 0).then(|| violation.from_path.clone()),
435        properties: None,
436    }
437}
438
439fn sarif_boundary_coverage_fields(
440    violation: &BoundaryCoverageViolation,
441    root: &Path,
442    level: &'static str,
443) -> SarifFields {
444    SarifFields {
445        rule_id: "fallow/boundary-coverage",
446        level,
447        message: "File does not match any configured architecture boundary zone".to_string(),
448        uri: relative_uri(&violation.path, root),
449        region: Some((violation.line, violation.col + 1)),
450        source_path: Some(violation.path.clone()),
451        properties: None,
452    }
453}
454
455fn sarif_boundary_call_fields(
456    violation: &BoundaryCallViolation,
457    root: &Path,
458    level: &'static str,
459) -> SarifFields {
460    SarifFields {
461        rule_id: "fallow/boundary-call-violation",
462        level,
463        message: format!(
464            "Call to `{}` matches forbidden pattern `{}` in zone '{}'",
465            violation.callee, violation.pattern, violation.zone
466        ),
467        uri: relative_uri(&violation.path, root),
468        region: Some((violation.line, violation.col + 1)),
469        source_path: Some(violation.path.clone()),
470        properties: None,
471    }
472}
473
474fn sarif_policy_violation_fields(violation: &PolicyViolation, root: &Path) -> SarifFields {
475    let level = match violation.severity {
476        PolicyViolationSeverity::Error => "error",
477        PolicyViolationSeverity::Warn => "warning",
478    };
479    let message = match &violation.message {
480        Some(message) => format!(
481            "Policy violation `{}/{}`: `{}` is banned. {message}",
482            violation.pack, violation.rule_id, violation.matched
483        ),
484        None => format!(
485            "Policy violation `{}/{}`: `{}` is banned",
486            violation.pack, violation.rule_id, violation.matched
487        ),
488    };
489    SarifFields {
490        rule_id: "fallow/policy-violation",
491        level,
492        message,
493        uri: relative_uri(&violation.path, root),
494        region: Some((violation.line, violation.col + 1)),
495        source_path: Some(violation.path.clone()),
496        // The SARIF rule id is the static `fallow/policy-violation`; the
497        // per-rule policy identity rides in properties so code-scanning
498        // consumers can group or filter per pack rule without parsing the
499        // message. Dynamic per-rule SARIF rule synthesis is a tracked
500        // follow-up shared with boundary zone rules.
501        properties: Some(serde_json::json!({
502            "policyRule": format!("{}/{}", violation.pack, violation.rule_id),
503        })),
504    }
505}
506
507fn sarif_invalid_client_export_fields(
508    export: &InvalidClientExport,
509    root: &Path,
510    level: &'static str,
511) -> SarifFields {
512    SarifFields {
513        rule_id: "fallow/invalid-client-export",
514        level,
515        message: format!(
516            "Export '{}' is not allowed in a \"{}\" file (Next.js server-only / route-config name)",
517            export.export_name, export.directive
518        ),
519        uri: relative_uri(&export.path, root),
520        region: Some((export.line, export.col + 1)),
521        source_path: Some(export.path.clone()),
522        properties: None,
523    }
524}
525
526fn sarif_mixed_client_server_barrel_fields(
527    barrel: &MixedClientServerBarrel,
528    root: &Path,
529    level: &'static str,
530) -> SarifFields {
531    SarifFields {
532        rule_id: "fallow/mixed-client-server-barrel",
533        level,
534        message: format!(
535            "Barrel re-exports both a \"use client\" module ('{}') and a server-only module ('{}'); one import drags the other's directive across the boundary",
536            barrel.client_origin, barrel.server_origin
537        ),
538        uri: relative_uri(&barrel.path, root),
539        region: Some((barrel.line, barrel.col + 1)),
540        source_path: Some(barrel.path.clone()),
541        properties: None,
542    }
543}
544
545fn sarif_misplaced_directive_fields(
546    directive_site: &MisplacedDirective,
547    root: &Path,
548    level: &'static str,
549) -> SarifFields {
550    SarifFields {
551        rule_id: "fallow/misplaced-directive",
552        level,
553        message: format!(
554            "Directive \"{}\" is not in the leading position, so the RSC bundler ignores it; move it to the top of the file",
555            directive_site.directive
556        ),
557        uri: relative_uri(&directive_site.path, root),
558        region: Some((directive_site.line, directive_site.col + 1)),
559        source_path: Some(directive_site.path.clone()),
560        properties: None,
561    }
562}
563
564fn sarif_unprovided_inject_fields(
565    inject: &UnprovidedInject,
566    root: &Path,
567    level: &'static str,
568) -> SarifFields {
569    SarifFields {
570        rule_id: "fallow/unprovided-inject",
571        level,
572        message: format!(
573            "inject(\"{}\") has no matching provide(\"{}\") in this project; at runtime it returns undefined; provide the key or remove this inject",
574            inject.key_name, inject.key_name
575        ),
576        uri: relative_uri(&inject.path, root),
577        region: Some((inject.line, inject.col + 1)),
578        source_path: Some(inject.path.clone()),
579        properties: None,
580    }
581}
582
583fn sarif_unrendered_component_fields(
584    component: &UnrenderedComponent,
585    root: &Path,
586    level: &'static str,
587) -> SarifFields {
588    SarifFields {
589        rule_id: "fallow/unrendered-component",
590        level,
591        message: format!(
592            "component \"{}\" is reachable but rendered nowhere in this project; render it somewhere or remove it",
593            component.component_name
594        ),
595        uri: relative_uri(&component.path, root),
596        region: Some((component.line, component.col + 1)),
597        source_path: Some(component.path.clone()),
598        properties: None,
599    }
600}
601
602fn sarif_unused_component_prop_fields(
603    prop: &UnusedComponentProp,
604    root: &Path,
605    level: &'static str,
606) -> SarifFields {
607    SarifFields {
608        rule_id: "fallow/unused-component-prop",
609        level,
610        message: format!(
611            "prop \"{}\" is declared but referenced nowhere inside component \"{}\"; remove it or use it",
612            prop.prop_name, prop.component_name
613        ),
614        uri: relative_uri(&prop.path, root),
615        region: Some((prop.line, prop.col + 1)),
616        source_path: Some(prop.path.clone()),
617        properties: None,
618    }
619}
620
621fn sarif_unused_component_emit_fields(
622    emit: &UnusedComponentEmit,
623    root: &Path,
624    level: &'static str,
625) -> SarifFields {
626    SarifFields {
627        rule_id: "fallow/unused-component-emit",
628        level,
629        message: format!(
630            "emit \"{}\" is declared but emitted nowhere inside component \"{}\"; remove it or emit it",
631            emit.emit_name, emit.component_name
632        ),
633        uri: relative_uri(&emit.path, root),
634        region: Some((emit.line, emit.col + 1)),
635        source_path: Some(emit.path.clone()),
636        properties: None,
637    }
638}
639
640fn sarif_unused_svelte_event_fields(
641    event: &UnusedSvelteEvent,
642    root: &Path,
643    level: &'static str,
644) -> SarifFields {
645    SarifFields {
646        rule_id: "fallow/unused-svelte-event",
647        level,
648        message: format!(
649            "event \"{}\" is dispatched by component \"{}\" but listened to nowhere in the project; remove it or listen for it",
650            event.event_name, event.component_name
651        ),
652        uri: relative_uri(&event.path, root),
653        region: Some((event.line, event.col + 1)),
654        source_path: Some(event.path.clone()),
655        properties: None,
656    }
657}
658
659fn sarif_unused_component_input_fields(
660    input: &UnusedComponentInput,
661    root: &Path,
662    level: &'static str,
663) -> SarifFields {
664    SarifFields {
665        rule_id: "fallow/unused-component-input",
666        level,
667        message: format!(
668            "input \"{}\" is declared but read nowhere inside component \"{}\"; remove it or use it",
669            input.input_name, input.component_name
670        ),
671        uri: relative_uri(&input.path, root),
672        region: Some((input.line, input.col + 1)),
673        source_path: Some(input.path.clone()),
674        properties: None,
675    }
676}
677
678fn sarif_unused_component_output_fields(
679    output: &UnusedComponentOutput,
680    root: &Path,
681    level: &'static str,
682) -> SarifFields {
683    SarifFields {
684        rule_id: "fallow/unused-component-output",
685        level,
686        message: format!(
687            "output \"{}\" is declared but emitted nowhere inside component \"{}\"; remove it or emit it",
688            output.output_name, output.component_name
689        ),
690        uri: relative_uri(&output.path, root),
691        region: Some((output.line, output.col + 1)),
692        source_path: Some(output.path.clone()),
693        properties: None,
694    }
695}
696
697fn sarif_unused_server_action_fields(
698    action: &UnusedServerAction,
699    root: &Path,
700    level: &'static str,
701) -> SarifFields {
702    SarifFields {
703        rule_id: "fallow/unused-server-action",
704        level,
705        message: format!(
706            "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",
707            action.action_name
708        ),
709        uri: relative_uri(&action.path, root),
710        region: Some((action.line, action.col + 1)),
711        source_path: Some(action.path.clone()),
712        properties: None,
713    }
714}
715
716fn sarif_unused_load_data_key_fields(
717    key: &fallow_core::results::UnusedLoadDataKey,
718    root: &Path,
719    level: &'static str,
720) -> SarifFields {
721    SarifFields {
722        rule_id: "fallow/unused-load-data-key",
723        level,
724        message: format!(
725            "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",
726            key.key_name
727        ),
728        uri: relative_uri(&key.path, root),
729        region: Some((key.line, key.col + 1)),
730        source_path: Some(key.path.clone()),
731        properties: None,
732    }
733}
734
735fn sarif_prop_drilling_fields(
736    chain: &PropDrillingChain,
737    root: &Path,
738    level: &'static str,
739) -> SarifFields {
740    // Anchor at the source hop (the prop owner). Path / line come from the first
741    // hop; the message names the depth and the consumer at the chain tail.
742    let source = chain.hops.first();
743    let consumer = chain.hops.last();
744    let (path, line) = source.map_or((std::path::PathBuf::new(), 1), |h| (h.file.clone(), h.line));
745    let consumer_name = consumer.map_or("a distant component", |h| h.component.as_str());
746    SarifFields {
747        rule_id: "fallow/prop-drilling",
748        level,
749        message: format!(
750            "prop \"{}\" is forwarded unchanged through {} component(s) before \"{}\" consumes it; colocate, lift to context, or compose",
751            chain.prop, chain.depth, consumer_name
752        ),
753        uri: relative_uri(&path, root),
754        region: Some((line, 1)),
755        source_path: Some(path),
756        properties: None,
757    }
758}
759
760fn sarif_thin_wrapper_fields(
761    wrapper: &ThinWrapper,
762    root: &Path,
763    level: &'static str,
764) -> SarifFields {
765    SarifFields {
766        rule_id: "fallow/thin-wrapper",
767        level,
768        message: format!(
769            "\"{}\" is a thin wrapper: its whole body forwards props to \"{}\"; inline it at call sites or delete it",
770            wrapper.component, wrapper.child_component
771        ),
772        uri: relative_uri(&wrapper.file, root),
773        region: Some((wrapper.line, 1)),
774        source_path: Some(wrapper.file.clone()),
775        properties: None,
776    }
777}
778
779fn sarif_duplicate_prop_shape_fields(
780    shape: &DuplicatePropShape,
781    root: &Path,
782    level: &'static str,
783) -> SarifFields {
784    SarifFields {
785        rule_id: "fallow/duplicate-prop-shape",
786        level,
787        message: format!(
788            "\"{}\" shares an identical prop shape {{{}}} with {} other component(s); extract a shared Props type or base component",
789            shape.component,
790            shape.shape.join(", "),
791            shape.group_size.saturating_sub(1)
792        ),
793        uri: relative_uri(&shape.file, root),
794        region: Some((shape.line, 1)),
795        source_path: Some(shape.file.clone()),
796        properties: None,
797    }
798}
799
800fn sarif_route_collision_fields(
801    collision: &RouteCollision,
802    root: &Path,
803    level: &'static str,
804) -> SarifFields {
805    SarifFields {
806        rule_id: "fallow/route-collision",
807        level,
808        message: format!(
809            "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",
810            collision.url,
811            collision.conflicting_paths.len()
812        ),
813        uri: relative_uri(&collision.path, root),
814        region: Some((collision.line, collision.col + 1)),
815        source_path: Some(collision.path.clone()),
816        properties: None,
817    }
818}
819
820fn sarif_dynamic_segment_name_conflict_fields(
821    conflict: &DynamicSegmentNameConflict,
822    root: &Path,
823    level: &'static str,
824) -> SarifFields {
825    SarifFields {
826        rule_id: "fallow/dynamic-segment-name-conflict",
827        level,
828        message: format!(
829            "Dynamic segments at '{}' use different slug names ({}); Next.js requires one consistent name per dynamic path",
830            conflict.position,
831            conflict.conflicting_segments.join(", ")
832        ),
833        uri: relative_uri(&conflict.path, root),
834        region: Some((conflict.line, conflict.col + 1)),
835        source_path: Some(conflict.path.clone()),
836        properties: None,
837    }
838}
839
840fn sarif_stale_suppression_fields(
841    suppression: &StaleSuppression,
842    root: &Path,
843    level: &'static str,
844) -> SarifFields {
845    SarifFields {
846        rule_id: if suppression.missing_reason {
847            "fallow/missing-suppression-reason"
848        } else {
849            "fallow/stale-suppression"
850        },
851        level,
852        message: suppression.display_message(),
853        uri: relative_uri(&suppression.path, root),
854        region: Some((suppression.line, suppression.col + 1)),
855        source_path: Some(suppression.path.clone()),
856        properties: None,
857    }
858}
859
860fn stale_suppression_severity(suppression: &StaleSuppression, rules: &RulesConfig) -> Severity {
861    if suppression.missing_reason {
862        rules.require_suppression_reason
863    } else {
864        rules.stale_suppressions
865    }
866}
867
868fn sarif_unused_catalog_entry_fields(
869    entry: &UnusedCatalogEntryFinding,
870    root: &Path,
871    level: &'static str,
872) -> SarifFields {
873    let entry = &entry.entry;
874    let message = if entry.catalog_name == "default" {
875        format!(
876            "Catalog entry '{}' is not referenced by any workspace package",
877            entry.entry_name
878        )
879    } else {
880        format!(
881            "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package",
882            entry.entry_name, entry.catalog_name
883        )
884    };
885    SarifFields {
886        rule_id: "fallow/unused-catalog-entry",
887        level,
888        message,
889        uri: relative_uri(&entry.path, root),
890        region: Some((entry.line, 1)),
891        source_path: Some(entry.path.clone()),
892        properties: None,
893    }
894}
895
896fn sarif_unused_dependency_override_fields(
897    finding: &UnusedDependencyOverrideFinding,
898    root: &Path,
899    level: &'static str,
900) -> SarifFields {
901    let finding = &finding.entry;
902    let mut message = format!(
903        "Override `{}` forces version `{}` but `{}` is not declared by any workspace package or resolved in pnpm-lock.yaml",
904        finding.raw_key, finding.version_range, finding.target_package,
905    );
906    if let Some(hint) = &finding.hint {
907        use std::fmt::Write as _;
908        let _ = write!(message, " ({hint})");
909    }
910    SarifFields {
911        rule_id: "fallow/unused-dependency-override",
912        level,
913        message,
914        uri: relative_uri(&finding.path, root),
915        region: Some((finding.line, 1)),
916        source_path: Some(finding.path.clone()),
917        properties: None,
918    }
919}
920
921fn sarif_misconfigured_dependency_override_fields(
922    finding: &MisconfiguredDependencyOverrideFinding,
923    root: &Path,
924    level: &'static str,
925) -> SarifFields {
926    let finding = &finding.entry;
927    let message = format!(
928        "Override `{}` -> `{}` is malformed: {}",
929        finding.raw_key,
930        finding.raw_value,
931        finding.reason.describe(),
932    );
933    SarifFields {
934        rule_id: "fallow/misconfigured-dependency-override",
935        level,
936        message,
937        uri: relative_uri(&finding.path, root),
938        region: Some((finding.line, 1)),
939        source_path: Some(finding.path.clone()),
940        properties: None,
941    }
942}
943
944fn sarif_unresolved_catalog_reference_fields(
945    finding: &UnresolvedCatalogReferenceFinding,
946    root: &Path,
947    level: &'static str,
948) -> SarifFields {
949    let finding = &finding.reference;
950    let catalog_phrase = if finding.catalog_name == "default" {
951        "the default catalog".to_string()
952    } else {
953        format!("catalog '{}'", finding.catalog_name)
954    };
955    let mut message = format!(
956        "Package '{}' is referenced via `catalog:{}` but {} does not declare it",
957        finding.entry_name,
958        if finding.catalog_name == "default" {
959            ""
960        } else {
961            finding.catalog_name.as_str()
962        },
963        catalog_phrase,
964    );
965    if !finding.available_in_catalogs.is_empty() {
966        use std::fmt::Write as _;
967        let _ = write!(
968            message,
969            " (available in: {})",
970            finding.available_in_catalogs.join(", ")
971        );
972    }
973    SarifFields {
974        rule_id: "fallow/unresolved-catalog-reference",
975        level,
976        message,
977        uri: relative_uri(&finding.path, root),
978        region: Some((finding.line, 1)),
979        source_path: Some(finding.path.clone()),
980        properties: None,
981    }
982}
983
984fn sarif_empty_catalog_group_fields(
985    group: &EmptyCatalogGroupFinding,
986    root: &Path,
987    level: &'static str,
988) -> SarifFields {
989    let group = &group.group;
990    SarifFields {
991        rule_id: "fallow/empty-catalog-group",
992        level,
993        message: format!("Catalog group '{}' has no entries", group.catalog_name),
994        uri: relative_uri(&group.path, root),
995        region: Some((group.line, 1)),
996        source_path: Some(group.path.clone()),
997        properties: None,
998    }
999}
1000
1001/// Unlisted deps fan out to one SARIF result per import site, so they do not
1002/// fit `push_sarif_results`. Keep the nested-loop shape in its own helper.
1003fn push_sarif_unlisted_deps(
1004    sarif_results: &mut Vec<serde_json::Value>,
1005    deps: &[UnlistedDependencyFinding],
1006    root: &Path,
1007    level: &'static str,
1008    snippets: &mut SourceSnippetCache,
1009) {
1010    for entry in deps {
1011        let dep = &entry.dep;
1012        for site in &dep.imported_from {
1013            let uri = relative_uri(&site.path, root);
1014            let source_snippet = snippets.line(&site.path, site.line);
1015            sarif_results.push(sarif_result_with_snippet(
1016                "fallow/unlisted-dependency",
1017                level,
1018                &format!(
1019                    "Package '{}' is imported but not listed in package.json",
1020                    dep.package_name
1021                ),
1022                &uri,
1023                Some((site.line, site.col + 1)),
1024                source_snippet.as_deref(),
1025            ));
1026        }
1027    }
1028}
1029
1030/// Duplicate exports fan out to one SARIF result per location
1031/// (SARIF 2.1.0 section 3.27.12), so they do not fit `push_sarif_results`.
1032fn push_sarif_duplicate_exports(
1033    sarif_results: &mut Vec<serde_json::Value>,
1034    dups: &[DuplicateExportFinding],
1035    root: &Path,
1036    level: &'static str,
1037    snippets: &mut SourceSnippetCache,
1038) {
1039    for dup in dups {
1040        let dup = &dup.export;
1041        for loc in &dup.locations {
1042            let uri = relative_uri(&loc.path, root);
1043            let source_snippet = snippets.line(&loc.path, loc.line);
1044            sarif_results.push(sarif_result_with_snippet(
1045                "fallow/duplicate-export",
1046                level,
1047                &format!("Export '{}' appears in multiple modules", dup.export_name),
1048                &uri,
1049                Some((loc.line, loc.col + 1)),
1050                source_snippet.as_deref(),
1051            ));
1052        }
1053    }
1054}
1055
1056/// Build the SARIF rules list from the current rules configuration.
1057fn build_sarif_rules(rules: &RulesConfig) -> Vec<serde_json::Value> {
1058    let mut specs = Vec::new();
1059    specs.extend(sarif_core_rule_specs(rules));
1060    specs.extend(sarif_dependency_rule_specs(rules));
1061    specs.extend(sarif_member_import_rule_specs(rules));
1062    specs.extend(sarif_graph_rule_specs(rules));
1063    specs.extend(sarif_workspace_rule_specs(rules));
1064    specs
1065        .into_iter()
1066        .map(|(id, description, rule_severity)| {
1067            sarif_rule(id, description, configured_sarif_level(rule_severity))
1068        })
1069        .collect()
1070}
1071
1072type SarifRuleSpec = (&'static str, &'static str, Severity);
1073
1074fn sarif_core_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1075    [
1076        (
1077            "fallow/unused-file",
1078            "File is not reachable from any entry point",
1079            rules.unused_files,
1080        ),
1081        (
1082            "fallow/unused-export",
1083            "Export is never imported",
1084            rules.unused_exports,
1085        ),
1086        (
1087            "fallow/unused-type",
1088            "Type export is never imported",
1089            rules.unused_types,
1090        ),
1091        (
1092            "fallow/private-type-leak",
1093            "Exported signature references a same-file private type",
1094            rules.private_type_leaks,
1095        ),
1096    ]
1097    .into()
1098}
1099
1100fn sarif_dependency_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1101    [
1102        (
1103            "fallow/unused-dependency",
1104            "Dependency listed but never imported",
1105            rules.unused_dependencies,
1106        ),
1107        (
1108            "fallow/unused-dev-dependency",
1109            "Dev dependency listed but never imported",
1110            rules.unused_dev_dependencies,
1111        ),
1112        (
1113            "fallow/unused-optional-dependency",
1114            "Optional dependency listed but never imported",
1115            rules.unused_optional_dependencies,
1116        ),
1117        (
1118            "fallow/type-only-dependency",
1119            "Production dependency only used via type-only imports",
1120            rules.type_only_dependencies,
1121        ),
1122        (
1123            "fallow/test-only-dependency",
1124            "Production dependency only imported by test files",
1125            rules.test_only_dependencies,
1126        ),
1127    ]
1128    .into()
1129}
1130
1131fn sarif_member_import_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1132    [
1133        (
1134            "fallow/unused-enum-member",
1135            "Enum member is never referenced",
1136            rules.unused_enum_members,
1137        ),
1138        (
1139            "fallow/unused-class-member",
1140            "Class member is never referenced",
1141            rules.unused_class_members,
1142        ),
1143        (
1144            "fallow/unused-store-member",
1145            "Store member is never referenced",
1146            rules.unused_store_members,
1147        ),
1148        (
1149            "fallow/unresolved-import",
1150            "Import could not be resolved",
1151            rules.unresolved_imports,
1152        ),
1153        (
1154            "fallow/unlisted-dependency",
1155            "Dependency used but not in package.json",
1156            rules.unlisted_dependencies,
1157        ),
1158        (
1159            "fallow/duplicate-export",
1160            "Export name appears in multiple modules",
1161            rules.duplicate_exports,
1162        ),
1163    ]
1164    .into()
1165}
1166
1167fn sarif_graph_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1168    let mut specs = sarif_cycle_rule_specs(rules);
1169    specs.extend(sarif_boundary_rule_specs(rules));
1170    specs.extend(sarif_framework_rule_specs(rules));
1171    specs.extend(sarif_component_rule_specs(rules));
1172    specs.push((
1173        "fallow/stale-suppression",
1174        "Suppression comment or tag no longer matches any issue",
1175        rules.stale_suppressions,
1176    ));
1177    specs.push((
1178        "fallow/missing-suppression-reason",
1179        "Suppression comment or tag is missing a required reason",
1180        rules.require_suppression_reason,
1181    ));
1182    specs
1183}
1184
1185fn sarif_cycle_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1186    vec![
1187        (
1188            "fallow/circular-dependency",
1189            "Circular dependency chain detected",
1190            rules.circular_dependencies,
1191        ),
1192        (
1193            "fallow/re-export-cycle",
1194            "Two or more barrel files re-export from each other in a loop",
1195            rules.re_export_cycle,
1196        ),
1197    ]
1198}
1199
1200fn sarif_boundary_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1201    vec![
1202        (
1203            "fallow/boundary-violation",
1204            "Import crosses an architecture boundary",
1205            rules.boundary_violation,
1206        ),
1207        (
1208            "fallow/boundary-coverage",
1209            "Source file matches no architecture boundary zone",
1210            rules.boundary_violation,
1211        ),
1212        (
1213            "fallow/boundary-call-violation",
1214            "Zoned file calls a callee its zone forbids",
1215            rules.boundary_violation,
1216        ),
1217        (
1218            "fallow/policy-violation",
1219            "Banned usage matched a rule-pack rule",
1220            rules.policy_violation,
1221        ),
1222    ]
1223}
1224
1225fn sarif_framework_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1226    vec![
1227        (
1228            "fallow/invalid-client-export",
1229            "\"use client\" file exports a server-only / route-config name",
1230            rules.invalid_client_export,
1231        ),
1232        (
1233            "fallow/mixed-client-server-barrel",
1234            "Barrel re-exports both a \"use client\" module and a server-only module",
1235            rules.mixed_client_server_barrel,
1236        ),
1237        (
1238            "fallow/misplaced-directive",
1239            "\"use client\" / \"use server\" directive is not in the leading position and is ignored",
1240            rules.misplaced_directive,
1241        ),
1242    ]
1243}
1244
1245fn sarif_component_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1246    vec![
1247        (
1248            "fallow/unprovided-inject",
1249            "A Vue inject / Svelte getContext whose key is provided nowhere in the project",
1250            rules.unprovided_injects,
1251        ),
1252        (
1253            "fallow/unrendered-component",
1254            "A Vue / Svelte component reachable through a barrel but rendered nowhere in the project",
1255            rules.unrendered_components,
1256        ),
1257        (
1258            "fallow/unused-component-prop",
1259            "A Vue <script setup> defineProps prop referenced nowhere inside its own component",
1260            rules.unused_component_props,
1261        ),
1262        (
1263            "fallow/unused-component-emit",
1264            "A Vue <script setup> defineEmits event emitted nowhere inside its own component",
1265            rules.unused_component_emits,
1266        ),
1267        (
1268            "fallow/unused-component-input",
1269            "An Angular @Input() / signal input() / model() input read nowhere inside its own component",
1270            rules.unused_component_inputs,
1271        ),
1272        (
1273            "fallow/unused-component-output",
1274            "An Angular @Output() / signal output() output emitted nowhere inside its own component",
1275            rules.unused_component_outputs,
1276        ),
1277        (
1278            "fallow/unused-svelte-event",
1279            "A Svelte component dispatching a createEventDispatcher event whose name is listened to nowhere in the project",
1280            rules.unused_svelte_events,
1281        ),
1282        (
1283            "fallow/unused-server-action",
1284            "A Next.js Server Action exported from a \"use server\" file that no code in the project references",
1285            rules.unused_server_actions,
1286        ),
1287        (
1288            "fallow/unused-load-data-key",
1289            "A SvelteKit load() return-object key that no consumer reads (sibling +page.svelte data.<key> or project-wide page.data.<key>)",
1290            rules.unused_load_data_keys,
1291        ),
1292        (
1293            "fallow/prop-drilling",
1294            "A React/Preact prop forwarded unchanged through 3+ pass-through components to a distant consumer",
1295            rules.prop_drilling,
1296        ),
1297        (
1298            "fallow/thin-wrapper",
1299            "A React/Preact component whose whole body is a single spread-forwarded child render (a candidate for inlining)",
1300            rules.thin_wrapper,
1301        ),
1302        (
1303            "fallow/duplicate-prop-shape",
1304            "Three or more React/Preact components across two or more files declare an identical prop-name set (a missing shared Props type)",
1305            rules.duplicate_prop_shape,
1306        ),
1307        (
1308            "fallow/route-collision",
1309            "Two or more Next.js App Router route files resolve to the same URL",
1310            rules.route_collision,
1311        ),
1312        (
1313            "fallow/dynamic-segment-name-conflict",
1314            "Sibling Next.js dynamic route segments use different slug names at the same position",
1315            rules.dynamic_segment_name_conflict,
1316        ),
1317    ]
1318}
1319
1320fn sarif_workspace_rule_specs(rules: &RulesConfig) -> Vec<SarifRuleSpec> {
1321    [
1322        (
1323            "fallow/unused-catalog-entry",
1324            "pnpm catalog entry not referenced by any workspace package",
1325            rules.unused_catalog_entries,
1326        ),
1327        (
1328            "fallow/empty-catalog-group",
1329            "pnpm named catalog group has no entries",
1330            rules.empty_catalog_groups,
1331        ),
1332        (
1333            "fallow/unresolved-catalog-reference",
1334            "package.json catalog reference points at a catalog that does not declare the package",
1335            rules.unresolved_catalog_references,
1336        ),
1337        (
1338            "fallow/unused-dependency-override",
1339            "pnpm dependency override target is not declared or lockfile-resolved",
1340            rules.unused_dependency_overrides,
1341        ),
1342        (
1343            "fallow/misconfigured-dependency-override",
1344            "pnpm dependency override key or value is malformed",
1345            rules.misconfigured_dependency_overrides,
1346        ),
1347    ]
1348    .into()
1349}
1350
1351#[must_use]
1352pub fn build_sarif(
1353    results: &AnalysisResults,
1354    root: &Path,
1355    rules: &RulesConfig,
1356) -> serde_json::Value {
1357    let mut sarif_results = Vec::new();
1358    let mut snippets = SourceSnippetCache::default();
1359
1360    push_primary_dead_code_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1361    push_dependency_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1362    push_member_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1363    push_sarif_results(
1364        &mut sarif_results,
1365        &results.unresolved_imports,
1366        &mut snippets,
1367        |i| {
1368            sarif_unresolved_import_fields(
1369                &i.import,
1370                root,
1371                severity_to_sarif_level(rules.unresolved_imports),
1372            )
1373        },
1374    );
1375    push_misc_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1376    push_graph_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1377    push_catalog_sarif_results(&mut sarif_results, results, root, rules, &mut snippets);
1378
1379    let sarif_rules = build_sarif_rules(rules);
1380    sarif_document(&sarif_results, &sarif_rules)
1381}
1382
1383fn push_primary_dead_code_sarif_results(
1384    sarif_results: &mut Vec<serde_json::Value>,
1385    results: &AnalysisResults,
1386    root: &Path,
1387    rules: &RulesConfig,
1388    snippets: &mut SourceSnippetCache,
1389) {
1390    push_sarif_results(sarif_results, &results.unused_files, snippets, |finding| {
1391        sarif_unused_file_fields(
1392            &finding.file,
1393            root,
1394            severity_to_sarif_level(rules.unused_files),
1395        )
1396    });
1397    push_sarif_results(
1398        sarif_results,
1399        &results.unused_exports,
1400        snippets,
1401        |finding| {
1402            sarif_export_fields(
1403                &finding.export,
1404                root,
1405                "fallow/unused-export",
1406                severity_to_sarif_level(rules.unused_exports),
1407                "Export",
1408                "Re-export",
1409            )
1410        },
1411    );
1412    push_sarif_results(sarif_results, &results.unused_types, snippets, |finding| {
1413        sarif_export_fields(
1414            &finding.export,
1415            root,
1416            "fallow/unused-type",
1417            severity_to_sarif_level(rules.unused_types),
1418            "Type export",
1419            "Type re-export",
1420        )
1421    });
1422    push_sarif_results(
1423        sarif_results,
1424        &results.private_type_leaks,
1425        snippets,
1426        |finding| {
1427            sarif_private_type_leak_fields(
1428                &finding.leak,
1429                root,
1430                severity_to_sarif_level(rules.private_type_leaks),
1431            )
1432        },
1433    );
1434}
1435
1436fn sarif_document(
1437    sarif_results: &[serde_json::Value],
1438    sarif_rules: &[serde_json::Value],
1439) -> serde_json::Value {
1440    serde_json::json!({
1441        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1442        "version": "2.1.0",
1443        "runs": [{
1444            "tool": {
1445                "driver": {
1446                    "name": "fallow",
1447                    "version": env!("CARGO_PKG_VERSION"),
1448                    "informationUri": "https://github.com/fallow-rs/fallow",
1449                    "rules": sarif_rules
1450                }
1451            },
1452            "results": sarif_results
1453        }]
1454    })
1455}
1456
1457fn push_dependency_sarif_results(
1458    sarif_results: &mut Vec<serde_json::Value>,
1459    results: &AnalysisResults,
1460    root: &Path,
1461    rules: &RulesConfig,
1462    snippets: &mut SourceSnippetCache,
1463) {
1464    push_unused_dependency_sarif_results(sarif_results, results, root, rules, snippets);
1465    push_classified_dependency_sarif_results(sarif_results, results, root, rules, snippets);
1466}
1467
1468/// Push SARIF results for unused runtime, dev, and optional dependencies.
1469fn push_unused_dependency_sarif_results(
1470    sarif_results: &mut Vec<serde_json::Value>,
1471    results: &AnalysisResults,
1472    root: &Path,
1473    rules: &RulesConfig,
1474    snippets: &mut SourceSnippetCache,
1475) {
1476    push_sarif_results(sarif_results, &results.unused_dependencies, snippets, |d| {
1477        sarif_dep_fields(
1478            &d.dep,
1479            root,
1480            "fallow/unused-dependency",
1481            severity_to_sarif_level(rules.unused_dependencies),
1482            "dependencies",
1483        )
1484    });
1485    push_sarif_results(
1486        sarif_results,
1487        &results.unused_dev_dependencies,
1488        snippets,
1489        |d| {
1490            sarif_dep_fields(
1491                &d.dep,
1492                root,
1493                "fallow/unused-dev-dependency",
1494                severity_to_sarif_level(rules.unused_dev_dependencies),
1495                "devDependencies",
1496            )
1497        },
1498    );
1499    push_sarif_results(
1500        sarif_results,
1501        &results.unused_optional_dependencies,
1502        snippets,
1503        |d| {
1504            sarif_dep_fields(
1505                &d.dep,
1506                root,
1507                "fallow/unused-optional-dependency",
1508                severity_to_sarif_level(rules.unused_optional_dependencies),
1509                "optionalDependencies",
1510            )
1511        },
1512    );
1513}
1514
1515/// Push SARIF results for type-only and test-only dependency misclassifications.
1516fn push_classified_dependency_sarif_results(
1517    sarif_results: &mut Vec<serde_json::Value>,
1518    results: &AnalysisResults,
1519    root: &Path,
1520    rules: &RulesConfig,
1521    snippets: &mut SourceSnippetCache,
1522) {
1523    push_sarif_results(
1524        sarif_results,
1525        &results.type_only_dependencies,
1526        snippets,
1527        |d| {
1528            sarif_type_only_dep_fields(
1529                &d.dep,
1530                root,
1531                severity_to_sarif_level(rules.type_only_dependencies),
1532            )
1533        },
1534    );
1535    push_sarif_results(
1536        sarif_results,
1537        &results.test_only_dependencies,
1538        snippets,
1539        |d| {
1540            sarif_test_only_dep_fields(
1541                &d.dep,
1542                root,
1543                severity_to_sarif_level(rules.test_only_dependencies),
1544            )
1545        },
1546    );
1547}
1548
1549fn push_member_sarif_results(
1550    sarif_results: &mut Vec<serde_json::Value>,
1551    results: &AnalysisResults,
1552    root: &Path,
1553    rules: &RulesConfig,
1554    snippets: &mut SourceSnippetCache,
1555) {
1556    push_sarif_results(sarif_results, &results.unused_enum_members, snippets, |m| {
1557        sarif_member_fields(
1558            &m.member,
1559            root,
1560            "fallow/unused-enum-member",
1561            severity_to_sarif_level(rules.unused_enum_members),
1562            "Enum",
1563        )
1564    });
1565    push_sarif_results(
1566        sarif_results,
1567        &results.unused_class_members,
1568        snippets,
1569        |m| {
1570            sarif_member_fields(
1571                &m.member,
1572                root,
1573                "fallow/unused-class-member",
1574                severity_to_sarif_level(rules.unused_class_members),
1575                "Class",
1576            )
1577        },
1578    );
1579    push_sarif_results(
1580        sarif_results,
1581        &results.unused_store_members,
1582        snippets,
1583        |m| {
1584            sarif_member_fields(
1585                &m.member,
1586                root,
1587                "fallow/unused-store-member",
1588                severity_to_sarif_level(rules.unused_store_members),
1589                "Store",
1590            )
1591        },
1592    );
1593}
1594
1595fn push_misc_sarif_results(
1596    sarif_results: &mut Vec<serde_json::Value>,
1597    results: &AnalysisResults,
1598    root: &Path,
1599    rules: &RulesConfig,
1600    snippets: &mut SourceSnippetCache,
1601) {
1602    if !results.unlisted_dependencies.is_empty() {
1603        push_sarif_unlisted_deps(
1604            sarif_results,
1605            &results.unlisted_dependencies,
1606            root,
1607            severity_to_sarif_level(rules.unlisted_dependencies),
1608            snippets,
1609        );
1610    }
1611    if !results.duplicate_exports.is_empty() {
1612        push_sarif_duplicate_exports(
1613            sarif_results,
1614            &results.duplicate_exports,
1615            root,
1616            severity_to_sarif_level(rules.duplicate_exports),
1617            snippets,
1618        );
1619    }
1620}
1621
1622/// Push the component-contract SARIF results (`unused-component-prop` and
1623/// `unused-component-emit`). Extracted from `push_graph_sarif_results` to keep
1624/// that function under the unit-size lint.
1625fn push_component_contract_sarif_results(
1626    sarif_results: &mut Vec<serde_json::Value>,
1627    results: &AnalysisResults,
1628    root: &Path,
1629    rules: &RulesConfig,
1630    snippets: &mut SourceSnippetCache,
1631) {
1632    push_component_member_sarif_results(sarif_results, results, root, rules, snippets);
1633    push_component_framework_sarif_results(sarif_results, results, root, rules, snippets);
1634    push_component_shape_sarif_results(sarif_results, results, root, rules, snippets);
1635}
1636
1637/// Push SARIF results for unused component props, emits, inputs, and outputs.
1638fn push_component_member_sarif_results(
1639    sarif_results: &mut Vec<serde_json::Value>,
1640    results: &AnalysisResults,
1641    root: &Path,
1642    rules: &RulesConfig,
1643    snippets: &mut SourceSnippetCache,
1644) {
1645    push_sarif_results(
1646        sarif_results,
1647        &results.unused_component_props,
1648        snippets,
1649        |p| {
1650            sarif_unused_component_prop_fields(
1651                &p.prop,
1652                root,
1653                severity_to_sarif_level(rules.unused_component_props),
1654            )
1655        },
1656    );
1657    push_sarif_results(
1658        sarif_results,
1659        &results.unused_component_emits,
1660        snippets,
1661        |e| {
1662            sarif_unused_component_emit_fields(
1663                &e.emit,
1664                root,
1665                severity_to_sarif_level(rules.unused_component_emits),
1666            )
1667        },
1668    );
1669    push_sarif_results(
1670        sarif_results,
1671        &results.unused_component_inputs,
1672        snippets,
1673        |i| {
1674            sarif_unused_component_input_fields(
1675                &i.input,
1676                root,
1677                severity_to_sarif_level(rules.unused_component_inputs),
1678            )
1679        },
1680    );
1681    push_sarif_results(
1682        sarif_results,
1683        &results.unused_component_outputs,
1684        snippets,
1685        |o| {
1686            sarif_unused_component_output_fields(
1687                &o.output,
1688                root,
1689                severity_to_sarif_level(rules.unused_component_outputs),
1690            )
1691        },
1692    );
1693}
1694
1695/// Push SARIF results for Svelte events, server actions, and load-data keys.
1696fn push_component_framework_sarif_results(
1697    sarif_results: &mut Vec<serde_json::Value>,
1698    results: &AnalysisResults,
1699    root: &Path,
1700    rules: &RulesConfig,
1701    snippets: &mut SourceSnippetCache,
1702) {
1703    push_sarif_results(
1704        sarif_results,
1705        &results.unused_svelte_events,
1706        snippets,
1707        |e| {
1708            sarif_unused_svelte_event_fields(
1709                &e.event,
1710                root,
1711                severity_to_sarif_level(rules.unused_svelte_events),
1712            )
1713        },
1714    );
1715    push_sarif_results(
1716        sarif_results,
1717        &results.unused_server_actions,
1718        snippets,
1719        |a| {
1720            sarif_unused_server_action_fields(
1721                &a.action,
1722                root,
1723                severity_to_sarif_level(rules.unused_server_actions),
1724            )
1725        },
1726    );
1727    push_sarif_results(
1728        sarif_results,
1729        &results.unused_load_data_keys,
1730        snippets,
1731        |k| {
1732            sarif_unused_load_data_key_fields(
1733                &k.key,
1734                root,
1735                severity_to_sarif_level(rules.unused_load_data_keys),
1736            )
1737        },
1738    );
1739}
1740
1741/// Push SARIF results for prop drilling, thin wrappers, and duplicate prop shapes.
1742fn push_component_shape_sarif_results(
1743    sarif_results: &mut Vec<serde_json::Value>,
1744    results: &AnalysisResults,
1745    root: &Path,
1746    rules: &RulesConfig,
1747    snippets: &mut SourceSnippetCache,
1748) {
1749    push_sarif_results(
1750        sarif_results,
1751        &results.prop_drilling_chains,
1752        snippets,
1753        |c| {
1754            sarif_prop_drilling_fields(&c.chain, root, severity_to_sarif_level(rules.prop_drilling))
1755        },
1756    );
1757    push_sarif_results(sarif_results, &results.thin_wrappers, snippets, |w| {
1758        sarif_thin_wrapper_fields(
1759            &w.wrapper,
1760            root,
1761            severity_to_sarif_level(rules.thin_wrapper),
1762        )
1763    });
1764    push_sarif_results(
1765        sarif_results,
1766        &results.duplicate_prop_shapes,
1767        snippets,
1768        |d| {
1769            sarif_duplicate_prop_shape_fields(
1770                &d.shape,
1771                root,
1772                severity_to_sarif_level(rules.duplicate_prop_shape),
1773            )
1774        },
1775    );
1776}
1777
1778fn push_graph_sarif_results(
1779    sarif_results: &mut Vec<serde_json::Value>,
1780    results: &AnalysisResults,
1781    root: &Path,
1782    rules: &RulesConfig,
1783    snippets: &mut SourceSnippetCache,
1784) {
1785    push_structure_sarif_results(sarif_results, results, root, rules, snippets);
1786    push_framework_sarif_results(sarif_results, results, root, rules, snippets);
1787    push_route_sarif_results(sarif_results, results, root, rules, snippets);
1788    push_suppression_sarif_results(sarif_results, results, root, rules, snippets);
1789}
1790
1791fn push_structure_sarif_results(
1792    sarif_results: &mut Vec<serde_json::Value>,
1793    results: &AnalysisResults,
1794    root: &Path,
1795    rules: &RulesConfig,
1796    snippets: &mut SourceSnippetCache,
1797) {
1798    push_cycle_sarif_results(sarif_results, results, root, rules, snippets);
1799    push_boundary_sarif_results(sarif_results, results, root, rules, snippets);
1800}
1801
1802/// Push SARIF results for circular dependencies and re-export cycles.
1803fn push_cycle_sarif_results(
1804    sarif_results: &mut Vec<serde_json::Value>,
1805    results: &AnalysisResults,
1806    root: &Path,
1807    rules: &RulesConfig,
1808    snippets: &mut SourceSnippetCache,
1809) {
1810    push_sarif_results(
1811        sarif_results,
1812        &results.circular_dependencies,
1813        snippets,
1814        |c| {
1815            sarif_circular_dep_fields(
1816                &c.cycle,
1817                root,
1818                severity_to_sarif_level(rules.circular_dependencies),
1819            )
1820        },
1821    );
1822    push_sarif_results(sarif_results, &results.re_export_cycles, snippets, |c| {
1823        sarif_re_export_cycle_fields(
1824            &c.cycle,
1825            root,
1826            severity_to_sarif_level(rules.re_export_cycle),
1827        )
1828    });
1829}
1830
1831/// Push SARIF results for boundary violations, coverage, calls, and policy violations.
1832fn push_boundary_sarif_results(
1833    sarif_results: &mut Vec<serde_json::Value>,
1834    results: &AnalysisResults,
1835    root: &Path,
1836    rules: &RulesConfig,
1837    snippets: &mut SourceSnippetCache,
1838) {
1839    push_sarif_results(sarif_results, &results.boundary_violations, snippets, |v| {
1840        sarif_boundary_violation_fields(
1841            &v.violation,
1842            root,
1843            severity_to_sarif_level(rules.boundary_violation),
1844        )
1845    });
1846    push_sarif_results(
1847        sarif_results,
1848        &results.boundary_coverage_violations,
1849        snippets,
1850        |v| {
1851            sarif_boundary_coverage_fields(
1852                &v.violation,
1853                root,
1854                severity_to_sarif_level(rules.boundary_violation),
1855            )
1856        },
1857    );
1858    push_sarif_results(
1859        sarif_results,
1860        &results.boundary_call_violations,
1861        snippets,
1862        |v| {
1863            sarif_boundary_call_fields(
1864                &v.violation,
1865                root,
1866                severity_to_sarif_level(rules.boundary_violation),
1867            )
1868        },
1869    );
1870    push_sarif_results(sarif_results, &results.policy_violations, snippets, |v| {
1871        sarif_policy_violation_fields(&v.violation, root)
1872    });
1873}
1874
1875fn push_framework_sarif_results(
1876    sarif_results: &mut Vec<serde_json::Value>,
1877    results: &AnalysisResults,
1878    root: &Path,
1879    rules: &RulesConfig,
1880    snippets: &mut SourceSnippetCache,
1881) {
1882    push_framework_boundary_sarif_results(sarif_results, results, root, rules, snippets);
1883    push_component_contract_sarif_results(sarif_results, results, root, rules, snippets);
1884}
1885
1886/// Push SARIF results for client exports, barrels, directives, injects, and unrendered components.
1887fn push_framework_boundary_sarif_results(
1888    sarif_results: &mut Vec<serde_json::Value>,
1889    results: &AnalysisResults,
1890    root: &Path,
1891    rules: &RulesConfig,
1892    snippets: &mut SourceSnippetCache,
1893) {
1894    push_sarif_results(
1895        sarif_results,
1896        &results.invalid_client_exports,
1897        snippets,
1898        |e| {
1899            sarif_invalid_client_export_fields(
1900                &e.export,
1901                root,
1902                severity_to_sarif_level(rules.invalid_client_export),
1903            )
1904        },
1905    );
1906    push_sarif_results(
1907        sarif_results,
1908        &results.mixed_client_server_barrels,
1909        snippets,
1910        |b| {
1911            sarif_mixed_client_server_barrel_fields(
1912                &b.barrel,
1913                root,
1914                severity_to_sarif_level(rules.mixed_client_server_barrel),
1915            )
1916        },
1917    );
1918    push_sarif_results(
1919        sarif_results,
1920        &results.misplaced_directives,
1921        snippets,
1922        |d| {
1923            sarif_misplaced_directive_fields(
1924                &d.directive_site,
1925                root,
1926                severity_to_sarif_level(rules.misplaced_directive),
1927            )
1928        },
1929    );
1930    push_sarif_results(sarif_results, &results.unprovided_injects, snippets, |i| {
1931        sarif_unprovided_inject_fields(
1932            &i.inject,
1933            root,
1934            severity_to_sarif_level(rules.unprovided_injects),
1935        )
1936    });
1937    push_sarif_results(
1938        sarif_results,
1939        &results.unrendered_components,
1940        snippets,
1941        |c| {
1942            sarif_unrendered_component_fields(
1943                &c.component,
1944                root,
1945                severity_to_sarif_level(rules.unrendered_components),
1946            )
1947        },
1948    );
1949}
1950
1951fn push_route_sarif_results(
1952    sarif_results: &mut Vec<serde_json::Value>,
1953    results: &AnalysisResults,
1954    root: &Path,
1955    rules: &RulesConfig,
1956    snippets: &mut SourceSnippetCache,
1957) {
1958    push_sarif_results(sarif_results, &results.route_collisions, snippets, |c| {
1959        sarif_route_collision_fields(
1960            &c.collision,
1961            root,
1962            severity_to_sarif_level(rules.route_collision),
1963        )
1964    });
1965    push_sarif_results(
1966        sarif_results,
1967        &results.dynamic_segment_name_conflicts,
1968        snippets,
1969        |c| {
1970            sarif_dynamic_segment_name_conflict_fields(
1971                &c.conflict,
1972                root,
1973                severity_to_sarif_level(rules.dynamic_segment_name_conflict),
1974            )
1975        },
1976    );
1977}
1978
1979fn push_suppression_sarif_results(
1980    sarif_results: &mut Vec<serde_json::Value>,
1981    results: &AnalysisResults,
1982    root: &Path,
1983    rules: &RulesConfig,
1984    snippets: &mut SourceSnippetCache,
1985) {
1986    push_sarif_results(sarif_results, &results.stale_suppressions, snippets, |s| {
1987        sarif_stale_suppression_fields(
1988            s,
1989            root,
1990            severity_to_sarif_level(stale_suppression_severity(s, rules)),
1991        )
1992    });
1993}
1994
1995fn push_catalog_sarif_results(
1996    sarif_results: &mut Vec<serde_json::Value>,
1997    results: &AnalysisResults,
1998    root: &Path,
1999    rules: &RulesConfig,
2000    snippets: &mut SourceSnippetCache,
2001) {
2002    push_catalog_entry_sarif_results(sarif_results, results, root, rules, snippets);
2003    push_dependency_override_sarif_results(sarif_results, results, root, rules, snippets);
2004}
2005
2006/// Push SARIF results for unused catalog entries, empty groups, and unresolved references.
2007fn push_catalog_entry_sarif_results(
2008    sarif_results: &mut Vec<serde_json::Value>,
2009    results: &AnalysisResults,
2010    root: &Path,
2011    rules: &RulesConfig,
2012    snippets: &mut SourceSnippetCache,
2013) {
2014    push_sarif_results(
2015        sarif_results,
2016        &results.unused_catalog_entries,
2017        snippets,
2018        |e| {
2019            sarif_unused_catalog_entry_fields(
2020                e,
2021                root,
2022                severity_to_sarif_level(rules.unused_catalog_entries),
2023            )
2024        },
2025    );
2026    push_sarif_results(
2027        sarif_results,
2028        &results.empty_catalog_groups,
2029        snippets,
2030        |g| {
2031            sarif_empty_catalog_group_fields(
2032                g,
2033                root,
2034                severity_to_sarif_level(rules.empty_catalog_groups),
2035            )
2036        },
2037    );
2038    push_sarif_results(
2039        sarif_results,
2040        &results.unresolved_catalog_references,
2041        snippets,
2042        |f| {
2043            sarif_unresolved_catalog_reference_fields(
2044                f,
2045                root,
2046                severity_to_sarif_level(rules.unresolved_catalog_references),
2047            )
2048        },
2049    );
2050}
2051
2052/// Push SARIF results for unused and misconfigured dependency overrides.
2053fn push_dependency_override_sarif_results(
2054    sarif_results: &mut Vec<serde_json::Value>,
2055    results: &AnalysisResults,
2056    root: &Path,
2057    rules: &RulesConfig,
2058    snippets: &mut SourceSnippetCache,
2059) {
2060    push_sarif_results(
2061        sarif_results,
2062        &results.unused_dependency_overrides,
2063        snippets,
2064        |f| {
2065            sarif_unused_dependency_override_fields(
2066                f,
2067                root,
2068                severity_to_sarif_level(rules.unused_dependency_overrides),
2069            )
2070        },
2071    );
2072    push_sarif_results(
2073        sarif_results,
2074        &results.misconfigured_dependency_overrides,
2075        snippets,
2076        |f| {
2077            sarif_misconfigured_dependency_override_fields(
2078                f,
2079                root,
2080                severity_to_sarif_level(rules.misconfigured_dependency_overrides),
2081            )
2082        },
2083    );
2084}
2085
2086pub(super) fn print_sarif(results: &AnalysisResults, root: &Path, rules: &RulesConfig) -> ExitCode {
2087    let sarif = build_sarif(results, root, rules);
2088    emit_json(&sarif, "SARIF")
2089}
2090
2091/// Print SARIF output with owner properties added to each result.
2092///
2093/// Calls `build_sarif` to produce the standard SARIF JSON, then post-processes
2094/// each result to add `"properties": { "owner": "@team" }` by resolving the
2095/// artifact location URI through the `OwnershipResolver`.
2096#[expect(
2097    clippy::expect_used,
2098    reason = "grouped SARIF entries are JSON objects created by build_sarif"
2099)]
2100pub(super) fn print_grouped_sarif(
2101    results: &AnalysisResults,
2102    root: &Path,
2103    rules: &RulesConfig,
2104    resolver: &OwnershipResolver,
2105) -> ExitCode {
2106    let mut sarif = build_sarif(results, root, rules);
2107
2108    if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
2109        for run in runs {
2110            if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
2111                for result in results {
2112                    let uri = result
2113                        .pointer("/locations/0/physicalLocation/artifactLocation/uri")
2114                        .and_then(|v| v.as_str())
2115                        .unwrap_or("");
2116                    let decoded = uri.replace("%5B", "[").replace("%5D", "]");
2117                    let owner =
2118                        grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
2119                    let props = result
2120                        .as_object_mut()
2121                        .expect("SARIF result should be an object")
2122                        .entry("properties")
2123                        .or_insert_with(|| serde_json::json!({}));
2124                    props
2125                        .as_object_mut()
2126                        .expect("properties should be an object")
2127                        .insert("owner".to_string(), serde_json::Value::String(owner));
2128                }
2129            }
2130        }
2131    }
2132
2133    emit_json(&sarif, "SARIF")
2134}
2135
2136#[expect(
2137    clippy::cast_possible_truncation,
2138    reason = "line/col numbers are bounded by source size"
2139)]
2140pub(super) fn print_duplication_sarif(report: &DuplicationReport, root: &Path) -> ExitCode {
2141    let mut sarif_results = Vec::new();
2142    let mut snippets = SourceSnippetCache::default();
2143
2144    for (i, group) in report.clone_groups.iter().enumerate() {
2145        for instance in &group.instances {
2146            let uri = relative_uri(&instance.file, root);
2147            let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
2148            sarif_results.push(sarif_result_with_snippet(
2149                "fallow/code-duplication",
2150                "warning",
2151                &format!(
2152                    "Code clone group {} ({} lines, {} instances)",
2153                    i + 1,
2154                    group.line_count,
2155                    group.instances.len()
2156                ),
2157                &uri,
2158                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2159                source_snippet.as_deref(),
2160            ));
2161        }
2162    }
2163
2164    let sarif = serde_json::json!({
2165        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2166        "version": "2.1.0",
2167        "runs": [{
2168            "tool": {
2169                "driver": {
2170                    "name": "fallow",
2171                    "version": env!("CARGO_PKG_VERSION"),
2172                    "informationUri": "https://github.com/fallow-rs/fallow",
2173                    "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2174                }
2175            },
2176            "results": sarif_results
2177        }]
2178    });
2179
2180    emit_json(&sarif, "SARIF")
2181}
2182
2183/// Print SARIF duplication output with a `properties.group` tag on every
2184/// result.
2185///
2186/// Each clone group is attributed to its largest owner (most instances; ties
2187/// broken alphabetically) via [`super::dupes_grouping::largest_owner`], and
2188/// every result emitted for that group's instances carries the same
2189/// `properties.group` value. This mirrors the health SARIF convention
2190/// (`print_grouped_health_sarif`) so consumers (GitHub Code Scanning, GitLab
2191/// Code Quality) can partition findings per team / package / directory
2192/// without re-resolving ownership.
2193#[expect(
2194    clippy::cast_possible_truncation,
2195    reason = "line/col numbers are bounded by source size"
2196)]
2197#[expect(
2198    clippy::expect_used,
2199    reason = "duplication SARIF entries are JSON objects created by sarif_result_with_snippet"
2200)]
2201pub(super) fn print_grouped_duplication_sarif(
2202    report: &DuplicationReport,
2203    root: &Path,
2204    resolver: &OwnershipResolver,
2205) -> ExitCode {
2206    let mut sarif_results = Vec::new();
2207    let mut snippets = SourceSnippetCache::default();
2208
2209    for (i, group) in report.clone_groups.iter().enumerate() {
2210        let primary_owner = super::dupes_grouping::largest_owner(group, root, resolver);
2211        for instance in &group.instances {
2212            let uri = relative_uri(&instance.file, root);
2213            let source_snippet = snippets.line(&instance.file, instance.start_line as u32);
2214            let mut result = sarif_result_with_snippet(
2215                "fallow/code-duplication",
2216                "warning",
2217                &format!(
2218                    "Code clone group {} ({} lines, {} instances)",
2219                    i + 1,
2220                    group.line_count,
2221                    group.instances.len()
2222                ),
2223                &uri,
2224                Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
2225                source_snippet.as_deref(),
2226            );
2227            let props = result
2228                .as_object_mut()
2229                .expect("SARIF result should be an object")
2230                .entry("properties")
2231                .or_insert_with(|| serde_json::json!({}));
2232            props
2233                .as_object_mut()
2234                .expect("properties should be an object")
2235                .insert(
2236                    "group".to_string(),
2237                    serde_json::Value::String(primary_owner.clone()),
2238                );
2239            sarif_results.push(result);
2240        }
2241    }
2242
2243    let sarif = serde_json::json!({
2244        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2245        "version": "2.1.0",
2246        "runs": [{
2247            "tool": {
2248                "driver": {
2249                    "name": "fallow",
2250                    "version": env!("CARGO_PKG_VERSION"),
2251                    "informationUri": "https://github.com/fallow-rs/fallow",
2252                    "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
2253                }
2254            },
2255            "results": sarif_results
2256        }]
2257    });
2258
2259    emit_json(&sarif, "SARIF")
2260}
2261
2262#[must_use]
2263pub fn build_health_sarif(
2264    report: &crate::health_types::HealthReport,
2265    root: &Path,
2266) -> serde_json::Value {
2267    let mut sarif_results = Vec::new();
2268    let mut snippets = SourceSnippetCache::default();
2269
2270    append_health_sarif_results(report, root, &mut sarif_results, &mut snippets);
2271    let health_rules = health_sarif_rules();
2272    health_sarif_document(&sarif_results, &health_rules)
2273}
2274
2275fn append_health_sarif_results(
2276    report: &crate::health_types::HealthReport,
2277    root: &Path,
2278    sarif_results: &mut Vec<serde_json::Value>,
2279    snippets: &mut SourceSnippetCache,
2280) {
2281    append_complexity_sarif_results(sarif_results, report, root, snippets);
2282
2283    if let Some(ref production) = report.runtime_coverage {
2284        append_runtime_coverage_sarif_results(sarif_results, production, root, snippets);
2285    }
2286    if let Some(ref intelligence) = report.coverage_intelligence {
2287        append_coverage_intelligence_sarif_results(sarif_results, intelligence, root, snippets);
2288    }
2289
2290    append_refactoring_target_sarif_results(sarif_results, report, root);
2291    append_coverage_gap_sarif_results(sarif_results, report, root, snippets);
2292}
2293
2294fn health_sarif_rules() -> Vec<serde_json::Value> {
2295    let mut rules = health_complexity_sarif_rules();
2296    rules.extend(health_runtime_sarif_rules());
2297    rules.extend(health_coverage_intelligence_sarif_rules());
2298    rules
2299}
2300
2301fn health_complexity_sarif_rules() -> Vec<serde_json::Value> {
2302    vec![
2303        sarif_rule(
2304            "fallow/high-cyclomatic-complexity",
2305            "Function has high cyclomatic complexity",
2306            "note",
2307        ),
2308        sarif_rule(
2309            "fallow/high-cognitive-complexity",
2310            "Function has high cognitive complexity",
2311            "note",
2312        ),
2313        sarif_rule(
2314            "fallow/high-complexity",
2315            "Function exceeds both complexity thresholds",
2316            "note",
2317        ),
2318        sarif_rule(
2319            "fallow/high-crap-score",
2320            "Function has a high CRAP score (high complexity combined with low coverage)",
2321            "warning",
2322        ),
2323        sarif_rule(
2324            "fallow/refactoring-target",
2325            "File identified as a high-priority refactoring candidate",
2326            "warning",
2327        ),
2328    ]
2329}
2330
2331fn health_runtime_sarif_rules() -> Vec<serde_json::Value> {
2332    vec![
2333        sarif_rule(
2334            "fallow/untested-file",
2335            "Runtime-reachable file has no test dependency path",
2336            "warning",
2337        ),
2338        sarif_rule(
2339            "fallow/untested-export",
2340            "Runtime-reachable export has no test dependency path",
2341            "warning",
2342        ),
2343        sarif_rule(
2344            "fallow/runtime-safe-to-delete",
2345            "Function is statically unused and was never invoked in production",
2346            "warning",
2347        ),
2348        sarif_rule(
2349            "fallow/runtime-review-required",
2350            "Function is statically used but was never invoked in production",
2351            "warning",
2352        ),
2353        sarif_rule(
2354            "fallow/runtime-low-traffic",
2355            "Function was invoked below the low-traffic threshold relative to total trace count",
2356            "note",
2357        ),
2358        sarif_rule(
2359            "fallow/runtime-coverage-unavailable",
2360            "Runtime coverage could not be resolved for this function",
2361            "note",
2362        ),
2363        sarif_rule(
2364            "fallow/runtime-coverage",
2365            "Runtime coverage finding",
2366            "note",
2367        ),
2368    ]
2369}
2370
2371fn health_coverage_intelligence_sarif_rules() -> Vec<serde_json::Value> {
2372    vec![
2373        sarif_rule(
2374            "fallow/coverage-intelligence-risky-change",
2375            "Changed hot path combines high CRAP and low test coverage",
2376            "warning",
2377        ),
2378        sarif_rule(
2379            "fallow/coverage-intelligence-delete",
2380            "Static and runtime evidence indicate code can be deleted",
2381            "warning",
2382        ),
2383        sarif_rule(
2384            "fallow/coverage-intelligence-review",
2385            "Cold reachable uncovered code needs owner review",
2386            "warning",
2387        ),
2388        sarif_rule(
2389            "fallow/coverage-intelligence-refactor",
2390            "Hot covered code has high CRAP and should be refactored carefully",
2391            "warning",
2392        ),
2393    ]
2394}
2395
2396fn health_sarif_document(
2397    sarif_results: &[serde_json::Value],
2398    health_rules: &[serde_json::Value],
2399) -> serde_json::Value {
2400    serde_json::json!({
2401        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2402        "version": "2.1.0",
2403        "runs": [{
2404            "tool": {
2405                "driver": {
2406                    "name": "fallow",
2407                    "version": env!("CARGO_PKG_VERSION"),
2408                    "informationUri": "https://github.com/fallow-rs/fallow",
2409                    "rules": health_rules
2410                }
2411            },
2412            "results": sarif_results
2413        }]
2414    })
2415}
2416
2417fn append_complexity_sarif_results(
2418    sarif_results: &mut Vec<serde_json::Value>,
2419    report: &crate::health_types::HealthReport,
2420    root: &Path,
2421    snippets: &mut SourceSnippetCache,
2422) {
2423    for finding in &report.findings {
2424        let uri = relative_uri(&finding.path, root);
2425        let (rule_id, message) = health_complexity_sarif_message(finding, report);
2426        let level = match finding.severity {
2427            crate::health_types::FindingSeverity::Critical => "error",
2428            crate::health_types::FindingSeverity::High => "warning",
2429            crate::health_types::FindingSeverity::Moderate => "note",
2430        };
2431        let source_snippet = snippets.line(&finding.path, finding.line);
2432        sarif_results.push(sarif_result_with_snippet(
2433            rule_id,
2434            level,
2435            &message,
2436            &uri,
2437            Some((finding.line, finding.col + 1)),
2438            source_snippet.as_deref(),
2439        ));
2440    }
2441}
2442
2443fn health_complexity_sarif_message(
2444    finding: &crate::health_types::ComplexityViolation,
2445    report: &crate::health_types::HealthReport,
2446) -> (&'static str, String) {
2447    match finding.exceeded {
2448        crate::health_types::ExceededThreshold::Cyclomatic => (
2449            "fallow/high-cyclomatic-complexity",
2450            format!(
2451                "'{}' has cyclomatic complexity {} (threshold: {})",
2452                finding.name, finding.cyclomatic, report.summary.max_cyclomatic_threshold,
2453            ),
2454        ),
2455        crate::health_types::ExceededThreshold::Cognitive => (
2456            "fallow/high-cognitive-complexity",
2457            format!(
2458                "'{}' has cognitive complexity {} (threshold: {})",
2459                finding.name, finding.cognitive, report.summary.max_cognitive_threshold,
2460            ),
2461        ),
2462        crate::health_types::ExceededThreshold::Both => (
2463            "fallow/high-complexity",
2464            format!(
2465                "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
2466                finding.name,
2467                finding.cyclomatic,
2468                report.summary.max_cyclomatic_threshold,
2469                finding.cognitive,
2470                report.summary.max_cognitive_threshold,
2471            ),
2472        ),
2473        crate::health_types::ExceededThreshold::Crap
2474        | crate::health_types::ExceededThreshold::CyclomaticCrap
2475        | crate::health_types::ExceededThreshold::CognitiveCrap
2476        | crate::health_types::ExceededThreshold::All => {
2477            let crap = finding.crap.unwrap_or(0.0);
2478            let coverage = finding
2479                .coverage_pct
2480                .map(|pct| format!(", coverage {pct:.0}%"))
2481                .unwrap_or_default();
2482            (
2483                "fallow/high-crap-score",
2484                format!(
2485                    "'{}' has CRAP score {:.1} (threshold: {:.1}, cyclomatic {}{})",
2486                    finding.name,
2487                    crap,
2488                    report.summary.max_crap_threshold,
2489                    finding.cyclomatic,
2490                    coverage,
2491                ),
2492            )
2493        }
2494    }
2495}
2496
2497fn append_refactoring_target_sarif_results(
2498    sarif_results: &mut Vec<serde_json::Value>,
2499    report: &crate::health_types::HealthReport,
2500    root: &Path,
2501) {
2502    for target in &report.targets {
2503        let uri = relative_uri(&target.path, root);
2504        let message = format!(
2505            "[{}] {} (priority: {:.1}, efficiency: {:.1}, effort: {}, confidence: {})",
2506            target.category.label(),
2507            target.recommendation,
2508            target.priority,
2509            target.efficiency,
2510            target.effort.label(),
2511            target.confidence.label(),
2512        );
2513        sarif_results.push(sarif_result(
2514            "fallow/refactoring-target",
2515            "warning",
2516            &message,
2517            &uri,
2518            None,
2519        ));
2520    }
2521}
2522
2523fn append_coverage_gap_sarif_results(
2524    sarif_results: &mut Vec<serde_json::Value>,
2525    report: &crate::health_types::HealthReport,
2526    root: &Path,
2527    snippets: &mut SourceSnippetCache,
2528) {
2529    let Some(ref gaps) = report.coverage_gaps else {
2530        return;
2531    };
2532    for item in &gaps.files {
2533        let uri = relative_uri(&item.file.path, root);
2534        let message = format!(
2535            "File is runtime-reachable but has no test dependency path ({} value export{})",
2536            item.file.value_export_count,
2537            if item.file.value_export_count == 1 {
2538                ""
2539            } else {
2540                "s"
2541            },
2542        );
2543        sarif_results.push(sarif_result(
2544            "fallow/untested-file",
2545            "warning",
2546            &message,
2547            &uri,
2548            None,
2549        ));
2550    }
2551
2552    for item in &gaps.exports {
2553        let uri = relative_uri(&item.export.path, root);
2554        let message = format!(
2555            "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
2556            item.export.export_name
2557        );
2558        let source_snippet = snippets.line(&item.export.path, item.export.line);
2559        sarif_results.push(sarif_result_with_snippet(
2560            "fallow/untested-export",
2561            "warning",
2562            &message,
2563            &uri,
2564            Some((item.export.line, item.export.col + 1)),
2565            source_snippet.as_deref(),
2566        ));
2567    }
2568}
2569
2570fn append_runtime_coverage_sarif_results(
2571    sarif_results: &mut Vec<serde_json::Value>,
2572    production: &crate::health_types::RuntimeCoverageReport,
2573    root: &Path,
2574    snippets: &mut SourceSnippetCache,
2575) {
2576    for finding in &production.findings {
2577        let uri = relative_uri(&finding.path, root);
2578        let rule_id = match finding.verdict {
2579            crate::health_types::RuntimeCoverageVerdict::SafeToDelete => {
2580                "fallow/runtime-safe-to-delete"
2581            }
2582            crate::health_types::RuntimeCoverageVerdict::ReviewRequired => {
2583                "fallow/runtime-review-required"
2584            }
2585            crate::health_types::RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
2586            crate::health_types::RuntimeCoverageVerdict::CoverageUnavailable => {
2587                "fallow/runtime-coverage-unavailable"
2588            }
2589            crate::health_types::RuntimeCoverageVerdict::Active
2590            | crate::health_types::RuntimeCoverageVerdict::Unknown => "fallow/runtime-coverage",
2591        };
2592        let level = match finding.verdict {
2593            crate::health_types::RuntimeCoverageVerdict::SafeToDelete
2594            | crate::health_types::RuntimeCoverageVerdict::ReviewRequired => "warning",
2595            _ => "note",
2596        };
2597        let invocations_hint = finding.invocations.map_or_else(
2598            || "untracked".to_owned(),
2599            |hits| format!("{hits} invocations"),
2600        );
2601        let message = format!(
2602            "'{}' runtime coverage verdict: {} ({})",
2603            finding.function,
2604            finding.verdict.human_label(),
2605            invocations_hint,
2606        );
2607        let source_snippet = snippets.line(&finding.path, finding.line);
2608        sarif_results.push(sarif_result_with_snippet(
2609            rule_id,
2610            level,
2611            &message,
2612            &uri,
2613            Some((finding.line, 1)),
2614            source_snippet.as_deref(),
2615        ));
2616    }
2617}
2618
2619fn append_coverage_intelligence_sarif_results(
2620    sarif_results: &mut Vec<serde_json::Value>,
2621    intelligence: &crate::health_types::CoverageIntelligenceReport,
2622    root: &Path,
2623    snippets: &mut SourceSnippetCache,
2624) {
2625    for finding in &intelligence.findings {
2626        let rule_id = coverage_intelligence_rule_id(finding.recommendation);
2627        let level = match finding.verdict {
2628            crate::health_types::CoverageIntelligenceVerdict::Clean
2629            | crate::health_types::CoverageIntelligenceVerdict::Unknown => continue,
2630            _ => "warning",
2631        };
2632        let uri = relative_uri(&finding.path, root);
2633        let identity = finding.identity.as_deref().unwrap_or("code");
2634        let signals = finding
2635            .signals
2636            .iter()
2637            .map(ToString::to_string)
2638            .collect::<Vec<_>>()
2639            .join(", ");
2640        let message = format!(
2641            "'{}' coverage intelligence verdict: {} ({}, signals: {})",
2642            identity, finding.verdict, finding.recommendation, signals,
2643        );
2644        let source_snippet = snippets.line(&finding.path, finding.line);
2645        let mut result = sarif_result_with_snippet(
2646            rule_id,
2647            level,
2648            &message,
2649            &uri,
2650            Some((finding.line, 1)),
2651            source_snippet.as_deref(),
2652        );
2653        result["properties"] = serde_json::json!({
2654            "coverage_intelligence_id": &finding.id,
2655            "verdict": finding.verdict,
2656            "recommendation": finding.recommendation,
2657            "confidence": finding.confidence,
2658            "signals": &finding.signals,
2659            "related_ids": &finding.related_ids,
2660        });
2661        sarif_results.push(result);
2662    }
2663}
2664
2665fn coverage_intelligence_rule_id(
2666    recommendation: crate::health_types::CoverageIntelligenceRecommendation,
2667) -> &'static str {
2668    match recommendation {
2669        crate::health_types::CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
2670            "fallow/coverage-intelligence-risky-change"
2671        }
2672        crate::health_types::CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
2673            "fallow/coverage-intelligence-delete"
2674        }
2675        crate::health_types::CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
2676            "fallow/coverage-intelligence-review"
2677        }
2678        crate::health_types::CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
2679            "fallow/coverage-intelligence-refactor"
2680        }
2681    }
2682}
2683
2684pub(super) fn print_health_sarif(
2685    report: &crate::health_types::HealthReport,
2686    root: &Path,
2687) -> ExitCode {
2688    let sarif = build_health_sarif(report, root);
2689    emit_json(&sarif, "SARIF")
2690}
2691
2692/// Print health SARIF with a per-result `properties.group` tag.
2693///
2694/// Mirrors the dead-code grouped SARIF pattern (`print_grouped_sarif`):
2695/// build the standard SARIF first, then post-process each result to inject
2696/// the resolver-derived group key on `properties.group`. Consumers that read
2697/// SARIF (GitHub Code Scanning, GitLab Code Quality) can then partition
2698/// findings per team / package / directory without dropping out of the
2699/// SARIF pipeline. Each finding's URI is decoded (`%5B` -> `[`, `%5D` -> `]`)
2700/// before resolution, matching the dead-code behaviour for paths containing
2701/// brackets like Next.js dynamic routes.
2702#[expect(
2703    clippy::expect_used,
2704    reason = "grouped health SARIF entries are JSON objects created by build_health_sarif"
2705)]
2706pub(super) fn print_grouped_health_sarif(
2707    report: &crate::health_types::HealthReport,
2708    root: &Path,
2709    resolver: &OwnershipResolver,
2710) -> ExitCode {
2711    let mut sarif = build_health_sarif(report, root);
2712
2713    if let Some(runs) = sarif.get_mut("runs").and_then(|r| r.as_array_mut()) {
2714        for run in runs {
2715            if let Some(results) = run.get_mut("results").and_then(|r| r.as_array_mut()) {
2716                for result in results {
2717                    let uri = result
2718                        .pointer("/locations/0/physicalLocation/artifactLocation/uri")
2719                        .and_then(|v| v.as_str())
2720                        .unwrap_or("");
2721                    let decoded = uri.replace("%5B", "[").replace("%5D", "]");
2722                    let group =
2723                        grouping::resolve_owner(Path::new(&decoded), Path::new(""), resolver);
2724                    let props = result
2725                        .as_object_mut()
2726                        .expect("SARIF result should be an object")
2727                        .entry("properties")
2728                        .or_insert_with(|| serde_json::json!({}));
2729                    props
2730                        .as_object_mut()
2731                        .expect("properties should be an object")
2732                        .insert("group".to_string(), serde_json::Value::String(group));
2733                }
2734            }
2735        }
2736    }
2737
2738    emit_json(&sarif, "SARIF")
2739}
2740
2741#[cfg(test)]
2742mod tests {
2743    use super::*;
2744    use crate::report::test_helpers::sample_results;
2745    use fallow_core::results::*;
2746    use std::path::PathBuf;
2747
2748    #[test]
2749    fn sarif_has_required_top_level_fields() {
2750        let root = PathBuf::from("/project");
2751        let results = AnalysisResults::default();
2752        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2753
2754        assert_eq!(
2755            sarif["$schema"],
2756            "https://json.schemastore.org/sarif-2.1.0.json"
2757        );
2758        assert_eq!(sarif["version"], "2.1.0");
2759        assert!(sarif["runs"].is_array());
2760    }
2761
2762    #[test]
2763    fn sarif_missing_suppression_reason_uses_reason_rule_severity() {
2764        let root = PathBuf::from("/project");
2765        let mut results = AnalysisResults::default();
2766        results.stale_suppressions.push(StaleSuppression {
2767            path: root.join("src/file.ts"),
2768            line: 1,
2769            col: 0,
2770            origin: SuppressionOrigin::Comment {
2771                issue_kind: Some("unused-exports".to_string()),
2772                reason: None,
2773                is_file_level: false,
2774                kind_known: true,
2775            },
2776            missing_reason: true,
2777            actions: StaleSuppression::actions_for(true),
2778        });
2779        let rules = RulesConfig {
2780            stale_suppressions: Severity::Off,
2781            require_suppression_reason: Severity::Error,
2782            ..Default::default()
2783        };
2784
2785        let sarif = build_sarif(&results, &root, &rules);
2786
2787        assert_eq!(
2788            sarif["runs"][0]["results"][0]["ruleId"],
2789            "fallow/missing-suppression-reason"
2790        );
2791        assert_eq!(sarif["runs"][0]["results"][0]["level"], "error");
2792        assert!(
2793            sarif["runs"][0]["tool"]["driver"]["rules"]
2794                .as_array()
2795                .unwrap()
2796                .iter()
2797                .any(|rule| rule["id"].as_str().unwrap() == "fallow/missing-suppression-reason")
2798        );
2799    }
2800
2801    #[test]
2802    fn sarif_stale_and_missing_suppression_have_distinct_identities() {
2803        let root = PathBuf::from("/project");
2804        let mut results = AnalysisResults::default();
2805        let origin = SuppressionOrigin::Comment {
2806            issue_kind: Some("unused-exports".to_string()),
2807            reason: None,
2808            is_file_level: false,
2809            kind_known: true,
2810        };
2811        results.stale_suppressions.push(StaleSuppression {
2812            path: root.join("src/file.ts"),
2813            line: 1,
2814            col: 0,
2815            origin: origin.clone(),
2816            missing_reason: false,
2817            actions: StaleSuppression::actions_for(false),
2818        });
2819        results.stale_suppressions.push(StaleSuppression {
2820            path: root.join("src/file.ts"),
2821            line: 1,
2822            col: 0,
2823            origin,
2824            missing_reason: true,
2825            actions: StaleSuppression::actions_for(true),
2826        });
2827        let rules = RulesConfig {
2828            stale_suppressions: Severity::Warn,
2829            require_suppression_reason: Severity::Error,
2830            ..Default::default()
2831        };
2832
2833        let sarif = build_sarif(&results, &root, &rules);
2834        let results = sarif["runs"][0]["results"].as_array().unwrap();
2835
2836        assert_eq!(results[0]["ruleId"], "fallow/stale-suppression");
2837        assert_eq!(results[1]["ruleId"], "fallow/missing-suppression-reason");
2838        assert_ne!(
2839            results[0]["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
2840            results[1]["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
2841        );
2842    }
2843
2844    #[test]
2845    fn sarif_has_tool_driver_info() {
2846        let root = PathBuf::from("/project");
2847        let results = AnalysisResults::default();
2848        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2849
2850        let driver = &sarif["runs"][0]["tool"]["driver"];
2851        assert_eq!(driver["name"], "fallow");
2852        assert!(driver["version"].is_string());
2853        assert_eq!(
2854            driver["informationUri"],
2855            "https://github.com/fallow-rs/fallow"
2856        );
2857    }
2858
2859    #[test]
2860    fn sarif_declares_all_rules() {
2861        let root = PathBuf::from("/project");
2862        let results = AnalysisResults::default();
2863        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2864
2865        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
2866            .as_array()
2867            .expect("rules should be an array");
2868        assert_eq!(rules.len(), 45);
2869
2870        let rule_ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
2871        assert!(rule_ids.contains(&"fallow/duplicate-prop-shape"));
2872        assert!(rule_ids.contains(&"fallow/thin-wrapper"));
2873        assert!(rule_ids.contains(&"fallow/unrendered-component"));
2874        assert!(rule_ids.contains(&"fallow/unused-component-prop"));
2875        assert!(rule_ids.contains(&"fallow/unused-component-emit"));
2876        assert!(rule_ids.contains(&"fallow/unused-component-input"));
2877        assert!(rule_ids.contains(&"fallow/unused-component-output"));
2878        assert!(rule_ids.contains(&"fallow/unused-svelte-event"));
2879        assert!(rule_ids.contains(&"fallow/unused-server-action"));
2880        assert!(rule_ids.contains(&"fallow/unused-load-data-key"));
2881        assert!(rule_ids.contains(&"fallow/prop-drilling"));
2882        assert!(rule_ids.contains(&"fallow/route-collision"));
2883        assert!(rule_ids.contains(&"fallow/dynamic-segment-name-conflict"));
2884        assert!(rule_ids.contains(&"fallow/unused-file"));
2885        assert!(rule_ids.contains(&"fallow/unused-export"));
2886        assert!(rule_ids.contains(&"fallow/unused-type"));
2887        assert!(rule_ids.contains(&"fallow/private-type-leak"));
2888        assert!(rule_ids.contains(&"fallow/unused-dependency"));
2889        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
2890        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
2891        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
2892        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
2893        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
2894        assert!(rule_ids.contains(&"fallow/unused-class-member"));
2895        assert!(rule_ids.contains(&"fallow/unused-store-member"));
2896        assert!(rule_ids.contains(&"fallow/unresolved-import"));
2897        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
2898        assert!(rule_ids.contains(&"fallow/duplicate-export"));
2899        assert!(rule_ids.contains(&"fallow/circular-dependency"));
2900        assert!(rule_ids.contains(&"fallow/re-export-cycle"));
2901        assert!(rule_ids.contains(&"fallow/boundary-violation"));
2902        assert!(rule_ids.contains(&"fallow/boundary-coverage"));
2903        assert!(rule_ids.contains(&"fallow/boundary-call-violation"));
2904        assert!(rule_ids.contains(&"fallow/policy-violation"));
2905        assert!(rule_ids.contains(&"fallow/unused-catalog-entry"));
2906        assert!(rule_ids.contains(&"fallow/empty-catalog-group"));
2907        assert!(rule_ids.contains(&"fallow/unresolved-catalog-reference"));
2908        assert!(rule_ids.contains(&"fallow/unused-dependency-override"));
2909        assert!(rule_ids.contains(&"fallow/misconfigured-dependency-override"));
2910        assert!(rule_ids.contains(&"fallow/invalid-client-export"));
2911        assert!(rule_ids.contains(&"fallow/mixed-client-server-barrel"));
2912        assert!(rule_ids.contains(&"fallow/misplaced-directive"));
2913        assert!(rule_ids.contains(&"fallow/unprovided-inject"));
2914    }
2915
2916    #[test]
2917    fn sarif_empty_results_no_results_entries() {
2918        let root = PathBuf::from("/project");
2919        let results = AnalysisResults::default();
2920        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2921
2922        let sarif_results = sarif["runs"][0]["results"]
2923            .as_array()
2924            .expect("results should be an array");
2925        assert!(sarif_results.is_empty());
2926    }
2927
2928    #[test]
2929    fn sarif_unused_file_result() {
2930        let root = PathBuf::from("/project");
2931        let mut results = AnalysisResults::default();
2932        results
2933            .unused_files
2934            .push(UnusedFileFinding::with_actions(UnusedFile {
2935                path: root.join("src/dead.ts"),
2936            }));
2937
2938        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2939        let entries = sarif["runs"][0]["results"].as_array().unwrap();
2940        assert_eq!(entries.len(), 1);
2941
2942        let entry = &entries[0];
2943        assert_eq!(entry["ruleId"], "fallow/unused-file");
2944        assert_eq!(entry["level"], "error");
2945        assert_eq!(
2946            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
2947            "src/dead.ts"
2948        );
2949    }
2950
2951    #[test]
2952    fn sarif_unused_export_includes_region() {
2953        let root = PathBuf::from("/project");
2954        let mut results = AnalysisResults::default();
2955        results
2956            .unused_exports
2957            .push(UnusedExportFinding::with_actions(UnusedExport {
2958                path: root.join("src/utils.ts"),
2959                export_name: "helperFn".to_string(),
2960                is_type_only: false,
2961                line: 10,
2962                col: 4,
2963                span_start: 120,
2964                is_re_export: false,
2965            }));
2966
2967        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2968        let entry = &sarif["runs"][0]["results"][0];
2969        assert_eq!(entry["ruleId"], "fallow/unused-export");
2970
2971        let region = &entry["locations"][0]["physicalLocation"]["region"];
2972        assert_eq!(region["startLine"], 10);
2973        assert_eq!(region["startColumn"], 5);
2974    }
2975
2976    #[test]
2977    fn sarif_unresolved_import_is_error_level() {
2978        let root = PathBuf::from("/project");
2979        let mut results = AnalysisResults::default();
2980        results
2981            .unresolved_imports
2982            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
2983                path: root.join("src/app.ts"),
2984                specifier: "./missing".to_string(),
2985                line: 1,
2986                col: 0,
2987                specifier_col: 0,
2988            }));
2989
2990        let sarif = build_sarif(&results, &root, &RulesConfig::default());
2991        let entry = &sarif["runs"][0]["results"][0];
2992        assert_eq!(entry["ruleId"], "fallow/unresolved-import");
2993        assert_eq!(entry["level"], "error");
2994    }
2995
2996    #[test]
2997    fn sarif_unlisted_dependency_points_to_import_site() {
2998        let root = PathBuf::from("/project");
2999        let mut results = AnalysisResults::default();
3000        results
3001            .unlisted_dependencies
3002            .push(UnlistedDependencyFinding::with_actions(
3003                UnlistedDependency {
3004                    package_name: "chalk".to_string(),
3005                    imported_from: vec![ImportSite {
3006                        path: root.join("src/cli.ts"),
3007                        line: 3,
3008                        col: 0,
3009                    }],
3010                },
3011            ));
3012
3013        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3014        let entry = &sarif["runs"][0]["results"][0];
3015        assert_eq!(entry["ruleId"], "fallow/unlisted-dependency");
3016        assert_eq!(entry["level"], "error");
3017        assert_eq!(
3018            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3019            "src/cli.ts"
3020        );
3021        let region = &entry["locations"][0]["physicalLocation"]["region"];
3022        assert_eq!(region["startLine"], 3);
3023        assert_eq!(region["startColumn"], 1);
3024    }
3025
3026    #[test]
3027    fn sarif_dependency_issues_point_to_package_json() {
3028        let root = PathBuf::from("/project");
3029        let mut results = AnalysisResults::default();
3030        results
3031            .unused_dependencies
3032            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3033                package_name: "lodash".to_string(),
3034                location: DependencyLocation::Dependencies,
3035                path: root.join("package.json"),
3036                line: 5,
3037                used_in_workspaces: Vec::new(),
3038            }));
3039        results
3040            .unused_dev_dependencies
3041            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
3042                package_name: "jest".to_string(),
3043                location: DependencyLocation::DevDependencies,
3044                path: root.join("package.json"),
3045                line: 5,
3046                used_in_workspaces: Vec::new(),
3047            }));
3048
3049        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3050        let entries = sarif["runs"][0]["results"].as_array().unwrap();
3051        for entry in entries {
3052            assert_eq!(
3053                entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3054                "package.json"
3055            );
3056        }
3057    }
3058
3059    #[test]
3060    fn sarif_duplicate_export_emits_one_result_per_location() {
3061        let root = PathBuf::from("/project");
3062        let mut results = AnalysisResults::default();
3063        results
3064            .duplicate_exports
3065            .push(DuplicateExportFinding::with_actions(DuplicateExport {
3066                export_name: "Config".to_string(),
3067                locations: vec![
3068                    DuplicateLocation {
3069                        path: root.join("src/a.ts"),
3070                        line: 15,
3071                        col: 0,
3072                    },
3073                    DuplicateLocation {
3074                        path: root.join("src/b.ts"),
3075                        line: 30,
3076                        col: 0,
3077                    },
3078                ],
3079            }));
3080
3081        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3082        let entries = sarif["runs"][0]["results"].as_array().unwrap();
3083        assert_eq!(entries.len(), 2);
3084        assert_eq!(entries[0]["ruleId"], "fallow/duplicate-export");
3085        assert_eq!(entries[1]["ruleId"], "fallow/duplicate-export");
3086        assert_eq!(
3087            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3088            "src/a.ts"
3089        );
3090        assert_eq!(
3091            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3092            "src/b.ts"
3093        );
3094    }
3095
3096    #[test]
3097    fn sarif_all_issue_types_produce_results() {
3098        let root = PathBuf::from("/project");
3099        let results = sample_results(&root);
3100        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3101
3102        let entries = sarif["runs"][0]["results"].as_array().unwrap();
3103        assert_eq!(entries.len(), results.total_issues() + 1);
3104
3105        let rule_ids: Vec<&str> = entries
3106            .iter()
3107            .map(|e| e["ruleId"].as_str().unwrap())
3108            .collect();
3109        assert!(rule_ids.contains(&"fallow/unused-file"));
3110        assert!(rule_ids.contains(&"fallow/unused-export"));
3111        assert!(rule_ids.contains(&"fallow/unused-type"));
3112        assert!(rule_ids.contains(&"fallow/unused-dependency"));
3113        assert!(rule_ids.contains(&"fallow/unused-dev-dependency"));
3114        assert!(rule_ids.contains(&"fallow/unused-optional-dependency"));
3115        assert!(rule_ids.contains(&"fallow/type-only-dependency"));
3116        assert!(rule_ids.contains(&"fallow/test-only-dependency"));
3117        assert!(rule_ids.contains(&"fallow/unused-enum-member"));
3118        assert!(rule_ids.contains(&"fallow/unused-class-member"));
3119        assert!(rule_ids.contains(&"fallow/unused-store-member"));
3120        assert!(rule_ids.contains(&"fallow/unresolved-import"));
3121        assert!(rule_ids.contains(&"fallow/unlisted-dependency"));
3122        assert!(rule_ids.contains(&"fallow/duplicate-export"));
3123        assert!(rule_ids.contains(&"fallow/unprovided-inject"));
3124    }
3125
3126    #[test]
3127    fn sarif_serializes_to_valid_json() {
3128        let root = PathBuf::from("/project");
3129        let results = sample_results(&root);
3130        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3131
3132        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
3133        let reparsed: serde_json::Value =
3134            serde_json::from_str(&json_str).expect("SARIF output should be valid JSON");
3135        assert_eq!(reparsed, sarif);
3136    }
3137
3138    #[test]
3139    fn sarif_file_write_produces_valid_sarif() {
3140        let root = PathBuf::from("/project");
3141        let results = sample_results(&root);
3142        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3143        let json_str = serde_json::to_string_pretty(&sarif).expect("SARIF should serialize");
3144
3145        let dir = std::env::temp_dir().join("fallow-test-sarif-file");
3146        let _ = std::fs::create_dir_all(&dir);
3147        let sarif_path = dir.join("results.sarif");
3148        std::fs::write(&sarif_path, &json_str).expect("should write SARIF file");
3149
3150        let contents = std::fs::read_to_string(&sarif_path).expect("should read SARIF file");
3151        let parsed: serde_json::Value =
3152            serde_json::from_str(&contents).expect("file should contain valid JSON");
3153
3154        assert_eq!(parsed["version"], "2.1.0");
3155        assert_eq!(
3156            parsed["$schema"],
3157            "https://json.schemastore.org/sarif-2.1.0.json"
3158        );
3159        let sarif_results = parsed["runs"][0]["results"]
3160            .as_array()
3161            .expect("results should be an array");
3162        assert!(!sarif_results.is_empty());
3163
3164        let _ = std::fs::remove_file(&sarif_path);
3165        let _ = std::fs::remove_dir(&dir);
3166    }
3167
3168    #[test]
3169    fn health_sarif_empty_no_results() {
3170        let root = PathBuf::from("/project");
3171        let report = crate::health_types::HealthReport {
3172            summary: crate::health_types::HealthSummary {
3173                files_analyzed: 10,
3174                functions_analyzed: 50,
3175                ..Default::default()
3176            },
3177            ..Default::default()
3178        };
3179        let sarif = build_health_sarif(&report, &root);
3180        assert_eq!(sarif["version"], "2.1.0");
3181        let results = sarif["runs"][0]["results"].as_array().unwrap();
3182        assert!(results.is_empty());
3183        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
3184            .as_array()
3185            .unwrap();
3186        assert_eq!(rules.len(), 16);
3187    }
3188
3189    #[test]
3190    fn health_sarif_coverage_intelligence_preserves_structured_properties() {
3191        use crate::health_types::{
3192            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
3193            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
3194            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
3195            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
3196            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
3197            HealthReport, HealthSummary,
3198        };
3199
3200        let root = PathBuf::from("/project");
3201        let report = HealthReport {
3202            summary: HealthSummary {
3203                files_analyzed: 10,
3204                functions_analyzed: 50,
3205                ..Default::default()
3206            },
3207            coverage_intelligence: Some(CoverageIntelligenceReport {
3208                schema_version: CoverageIntelligenceSchemaVersion::V1,
3209                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
3210                summary: CoverageIntelligenceSummary {
3211                    findings: 1,
3212                    high_confidence_deletes: 1,
3213                    ..Default::default()
3214                },
3215                findings: vec![CoverageIntelligenceFinding {
3216                    id: "fallow:coverage-intel:abc123".to_owned(),
3217                    path: root.join("src/dead.ts"),
3218                    identity: Some("deadPath".to_owned()),
3219                    line: 9,
3220                    verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
3221                    signals: vec![CoverageIntelligenceSignal::RuntimeCold],
3222                    recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
3223                    confidence: CoverageIntelligenceConfidence::High,
3224                    related_ids: vec!["fallow:prod:deadbeef".to_owned()],
3225                    evidence: CoverageIntelligenceEvidence {
3226                        match_confidence: CoverageIntelligenceMatchConfidence::Direct,
3227                        ..Default::default()
3228                    },
3229                    actions: vec![CoverageIntelligenceAction {
3230                        kind: "delete-after-confirming-owner".to_owned(),
3231                        description: "Confirm ownership".to_owned(),
3232                        auto_fixable: false,
3233                    }],
3234                }],
3235            }),
3236            ..Default::default()
3237        };
3238
3239        let sarif = build_health_sarif(&report, &root);
3240        let result = &sarif["runs"][0]["results"][0];
3241        assert_eq!(result["ruleId"], "fallow/coverage-intelligence-delete");
3242        assert_eq!(
3243            result["properties"]["coverage_intelligence_id"],
3244            "fallow:coverage-intel:abc123"
3245        );
3246        assert_eq!(
3247            result["properties"]["recommendation"],
3248            "delete-after-confirming-owner"
3249        );
3250        assert_eq!(result["properties"]["confidence"], "high");
3251        assert_eq!(result["properties"]["signals"][0], "runtime_cold");
3252        assert_eq!(
3253            result["properties"]["related_ids"][0],
3254            "fallow:prod:deadbeef"
3255        );
3256    }
3257
3258    #[test]
3259    fn health_sarif_cyclomatic_only() {
3260        let root = PathBuf::from("/project");
3261        let report = crate::health_types::HealthReport {
3262            findings: vec![
3263                crate::health_types::ComplexityViolation {
3264                    path: root.join("src/utils.ts"),
3265                    name: "parseExpression".to_string(),
3266                    line: 42,
3267                    col: 0,
3268                    cyclomatic: 25,
3269                    cognitive: 10,
3270                    line_count: 80,
3271                    param_count: 0,
3272                    react_hook_count: 0,
3273                    react_jsx_max_depth: 0,
3274                    react_prop_count: 0,
3275                    react_hook_profile: None,
3276                    exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
3277                    severity: crate::health_types::FindingSeverity::High,
3278                    crap: None,
3279                    coverage_pct: None,
3280                    coverage_tier: None,
3281                    coverage_source: None,
3282                    inherited_from: None,
3283                    component_rollup: None,
3284                    contributions: Vec::new(),
3285                    effective_thresholds: None,
3286                    threshold_source: None,
3287                }
3288                .into(),
3289            ],
3290            summary: crate::health_types::HealthSummary {
3291                files_analyzed: 5,
3292                functions_analyzed: 20,
3293                functions_above_threshold: 1,
3294                ..Default::default()
3295            },
3296            ..Default::default()
3297        };
3298        let sarif = build_health_sarif(&report, &root);
3299        let entry = &sarif["runs"][0]["results"][0];
3300        assert_eq!(entry["ruleId"], "fallow/high-cyclomatic-complexity");
3301        assert_eq!(entry["level"], "warning");
3302        assert!(
3303            entry["message"]["text"]
3304                .as_str()
3305                .unwrap()
3306                .contains("cyclomatic complexity 25")
3307        );
3308        assert_eq!(
3309            entry["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
3310            "src/utils.ts"
3311        );
3312        let region = &entry["locations"][0]["physicalLocation"]["region"];
3313        assert_eq!(region["startLine"], 42);
3314        assert_eq!(region["startColumn"], 1);
3315    }
3316
3317    #[test]
3318    fn health_sarif_cognitive_only() {
3319        let root = PathBuf::from("/project");
3320        let report = crate::health_types::HealthReport {
3321            findings: vec![
3322                crate::health_types::ComplexityViolation {
3323                    path: root.join("src/api.ts"),
3324                    name: "handleRequest".to_string(),
3325                    line: 10,
3326                    col: 4,
3327                    cyclomatic: 8,
3328                    cognitive: 20,
3329                    line_count: 40,
3330                    param_count: 0,
3331                    react_hook_count: 0,
3332                    react_jsx_max_depth: 0,
3333                    react_prop_count: 0,
3334                    react_hook_profile: None,
3335                    exceeded: crate::health_types::ExceededThreshold::Cognitive,
3336                    severity: crate::health_types::FindingSeverity::High,
3337                    crap: None,
3338                    coverage_pct: None,
3339                    coverage_tier: None,
3340                    coverage_source: None,
3341                    inherited_from: None,
3342                    component_rollup: None,
3343                    contributions: Vec::new(),
3344                    effective_thresholds: None,
3345                    threshold_source: None,
3346                }
3347                .into(),
3348            ],
3349            summary: crate::health_types::HealthSummary {
3350                files_analyzed: 3,
3351                functions_analyzed: 10,
3352                functions_above_threshold: 1,
3353                ..Default::default()
3354            },
3355            ..Default::default()
3356        };
3357        let sarif = build_health_sarif(&report, &root);
3358        let entry = &sarif["runs"][0]["results"][0];
3359        assert_eq!(entry["ruleId"], "fallow/high-cognitive-complexity");
3360        assert!(
3361            entry["message"]["text"]
3362                .as_str()
3363                .unwrap()
3364                .contains("cognitive complexity 20")
3365        );
3366        let region = &entry["locations"][0]["physicalLocation"]["region"];
3367        assert_eq!(region["startColumn"], 5); // col 4 + 1
3368    }
3369
3370    #[test]
3371    fn health_sarif_both_thresholds() {
3372        let root = PathBuf::from("/project");
3373        let report = crate::health_types::HealthReport {
3374            findings: vec![
3375                crate::health_types::ComplexityViolation {
3376                    path: root.join("src/complex.ts"),
3377                    name: "doEverything".to_string(),
3378                    line: 1,
3379                    col: 0,
3380                    cyclomatic: 30,
3381                    cognitive: 45,
3382                    line_count: 100,
3383                    param_count: 0,
3384                    react_hook_count: 0,
3385                    react_jsx_max_depth: 0,
3386                    react_prop_count: 0,
3387                    react_hook_profile: None,
3388                    exceeded: crate::health_types::ExceededThreshold::Both,
3389                    severity: crate::health_types::FindingSeverity::High,
3390                    crap: None,
3391                    coverage_pct: None,
3392                    coverage_tier: None,
3393                    coverage_source: None,
3394                    inherited_from: None,
3395                    component_rollup: None,
3396                    contributions: Vec::new(),
3397                    effective_thresholds: None,
3398                    threshold_source: None,
3399                }
3400                .into(),
3401            ],
3402            summary: crate::health_types::HealthSummary {
3403                files_analyzed: 1,
3404                functions_analyzed: 1,
3405                functions_above_threshold: 1,
3406                ..Default::default()
3407            },
3408            ..Default::default()
3409        };
3410        let sarif = build_health_sarif(&report, &root);
3411        let entry = &sarif["runs"][0]["results"][0];
3412        assert_eq!(entry["ruleId"], "fallow/high-complexity");
3413        let msg = entry["message"]["text"].as_str().unwrap();
3414        assert!(msg.contains("cyclomatic complexity 30"));
3415        assert!(msg.contains("cognitive complexity 45"));
3416    }
3417
3418    #[test]
3419    fn health_sarif_crap_only_emits_crap_rule() {
3420        let root = PathBuf::from("/project");
3421        let report = crate::health_types::HealthReport {
3422            findings: vec![
3423                crate::health_types::ComplexityViolation {
3424                    path: root.join("src/untested.ts"),
3425                    name: "risky".to_string(),
3426                    line: 8,
3427                    col: 0,
3428                    cyclomatic: 10,
3429                    cognitive: 10,
3430                    line_count: 20,
3431                    param_count: 1,
3432                    react_hook_count: 0,
3433                    react_jsx_max_depth: 0,
3434                    react_prop_count: 0,
3435                    react_hook_profile: None,
3436                    exceeded: crate::health_types::ExceededThreshold::Crap,
3437                    severity: crate::health_types::FindingSeverity::High,
3438                    crap: Some(82.2),
3439                    coverage_pct: Some(12.0),
3440                    coverage_tier: None,
3441                    coverage_source: None,
3442                    inherited_from: None,
3443                    component_rollup: None,
3444                    contributions: Vec::new(),
3445                    effective_thresholds: None,
3446                    threshold_source: None,
3447                }
3448                .into(),
3449            ],
3450            summary: crate::health_types::HealthSummary {
3451                files_analyzed: 1,
3452                functions_analyzed: 1,
3453                functions_above_threshold: 1,
3454                ..Default::default()
3455            },
3456            ..Default::default()
3457        };
3458        let sarif = build_health_sarif(&report, &root);
3459        let entry = &sarif["runs"][0]["results"][0];
3460        assert_eq!(entry["ruleId"], "fallow/high-crap-score");
3461        let msg = entry["message"]["text"].as_str().unwrap();
3462        assert!(msg.contains("CRAP score 82.2"), "msg: {msg}");
3463        assert!(msg.contains("coverage 12%"), "msg: {msg}");
3464    }
3465
3466    #[test]
3467    fn health_sarif_cyclomatic_crap_uses_crap_rule() {
3468        let root = PathBuf::from("/project");
3469        let report = crate::health_types::HealthReport {
3470            findings: vec![
3471                crate::health_types::ComplexityViolation {
3472                    path: root.join("src/hot.ts"),
3473                    name: "branchy".to_string(),
3474                    line: 1,
3475                    col: 0,
3476                    cyclomatic: 67,
3477                    cognitive: 12,
3478                    line_count: 80,
3479                    param_count: 1,
3480                    react_hook_count: 0,
3481                    react_jsx_max_depth: 0,
3482                    react_prop_count: 0,
3483                    react_hook_profile: None,
3484                    exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
3485                    severity: crate::health_types::FindingSeverity::Critical,
3486                    crap: Some(182.0),
3487                    coverage_pct: None,
3488                    coverage_tier: None,
3489                    coverage_source: None,
3490                    inherited_from: None,
3491                    component_rollup: None,
3492                    contributions: Vec::new(),
3493                    effective_thresholds: None,
3494                    threshold_source: None,
3495                }
3496                .into(),
3497            ],
3498            summary: crate::health_types::HealthSummary {
3499                files_analyzed: 1,
3500                functions_analyzed: 1,
3501                functions_above_threshold: 1,
3502                ..Default::default()
3503            },
3504            ..Default::default()
3505        };
3506        let sarif = build_health_sarif(&report, &root);
3507        let results = sarif["runs"][0]["results"].as_array().unwrap();
3508        assert_eq!(
3509            results.len(),
3510            1,
3511            "CyclomaticCrap should emit a single SARIF result under the CRAP rule"
3512        );
3513        assert_eq!(results[0]["ruleId"], "fallow/high-crap-score");
3514        let msg = results[0]["message"]["text"].as_str().unwrap();
3515        assert!(msg.contains("CRAP score 182"), "msg: {msg}");
3516        assert!(!msg.contains("coverage"), "msg: {msg}");
3517    }
3518
3519    #[test]
3520    fn severity_to_sarif_level_error() {
3521        assert_eq!(severity_to_sarif_level(Severity::Error), "error");
3522    }
3523
3524    #[test]
3525    fn severity_to_sarif_level_warn() {
3526        assert_eq!(severity_to_sarif_level(Severity::Warn), "warning");
3527    }
3528
3529    #[test]
3530    #[should_panic(expected = "internal error: entered unreachable code")]
3531    fn severity_to_sarif_level_off() {
3532        let _ = severity_to_sarif_level(Severity::Off);
3533    }
3534
3535    #[test]
3536    fn sarif_re_export_has_properties() {
3537        let root = PathBuf::from("/project");
3538        let mut results = AnalysisResults::default();
3539        results
3540            .unused_exports
3541            .push(UnusedExportFinding::with_actions(UnusedExport {
3542                path: root.join("src/index.ts"),
3543                export_name: "reExported".to_string(),
3544                is_type_only: false,
3545                line: 1,
3546                col: 0,
3547                span_start: 0,
3548                is_re_export: true,
3549            }));
3550
3551        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3552        let entry = &sarif["runs"][0]["results"][0];
3553        assert_eq!(entry["properties"]["is_re_export"], true);
3554        let msg = entry["message"]["text"].as_str().unwrap();
3555        assert!(msg.starts_with("Re-export"));
3556    }
3557
3558    #[test]
3559    fn sarif_non_re_export_has_no_properties() {
3560        let root = PathBuf::from("/project");
3561        let mut results = AnalysisResults::default();
3562        results
3563            .unused_exports
3564            .push(UnusedExportFinding::with_actions(UnusedExport {
3565                path: root.join("src/utils.ts"),
3566                export_name: "foo".to_string(),
3567                is_type_only: false,
3568                line: 5,
3569                col: 0,
3570                span_start: 0,
3571                is_re_export: false,
3572            }));
3573
3574        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3575        let entry = &sarif["runs"][0]["results"][0];
3576        assert!(entry.get("properties").is_none());
3577        let msg = entry["message"]["text"].as_str().unwrap();
3578        assert!(msg.starts_with("Export"));
3579    }
3580
3581    #[test]
3582    fn sarif_type_re_export_message() {
3583        let root = PathBuf::from("/project");
3584        let mut results = AnalysisResults::default();
3585        results
3586            .unused_types
3587            .push(UnusedTypeFinding::with_actions(UnusedExport {
3588                path: root.join("src/index.ts"),
3589                export_name: "MyType".to_string(),
3590                is_type_only: true,
3591                line: 1,
3592                col: 0,
3593                span_start: 0,
3594                is_re_export: true,
3595            }));
3596
3597        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3598        let entry = &sarif["runs"][0]["results"][0];
3599        assert_eq!(entry["ruleId"], "fallow/unused-type");
3600        let msg = entry["message"]["text"].as_str().unwrap();
3601        assert!(msg.starts_with("Type re-export"));
3602        assert_eq!(entry["properties"]["is_re_export"], true);
3603    }
3604
3605    #[test]
3606    fn sarif_dependency_line_zero_skips_region() {
3607        let root = PathBuf::from("/project");
3608        let mut results = AnalysisResults::default();
3609        results
3610            .unused_dependencies
3611            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3612                package_name: "lodash".to_string(),
3613                location: DependencyLocation::Dependencies,
3614                path: root.join("package.json"),
3615                line: 0,
3616                used_in_workspaces: Vec::new(),
3617            }));
3618
3619        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3620        let entry = &sarif["runs"][0]["results"][0];
3621        let phys = &entry["locations"][0]["physicalLocation"];
3622        assert!(phys.get("region").is_none());
3623    }
3624
3625    #[test]
3626    fn sarif_dependency_line_nonzero_has_region() {
3627        let root = PathBuf::from("/project");
3628        let mut results = AnalysisResults::default();
3629        results
3630            .unused_dependencies
3631            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
3632                package_name: "lodash".to_string(),
3633                location: DependencyLocation::Dependencies,
3634                path: root.join("package.json"),
3635                line: 7,
3636                used_in_workspaces: Vec::new(),
3637            }));
3638
3639        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3640        let entry = &sarif["runs"][0]["results"][0];
3641        let region = &entry["locations"][0]["physicalLocation"]["region"];
3642        assert_eq!(region["startLine"], 7);
3643        assert_eq!(region["startColumn"], 1);
3644    }
3645
3646    #[test]
3647    fn sarif_type_only_dep_line_zero_skips_region() {
3648        let root = PathBuf::from("/project");
3649        let mut results = AnalysisResults::default();
3650        results
3651            .type_only_dependencies
3652            .push(TypeOnlyDependencyFinding::with_actions(
3653                TypeOnlyDependency {
3654                    package_name: "zod".to_string(),
3655                    path: root.join("package.json"),
3656                    line: 0,
3657                },
3658            ));
3659
3660        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3661        let entry = &sarif["runs"][0]["results"][0];
3662        let phys = &entry["locations"][0]["physicalLocation"];
3663        assert!(phys.get("region").is_none());
3664    }
3665
3666    #[test]
3667    fn sarif_circular_dep_line_zero_skips_region() {
3668        let root = PathBuf::from("/project");
3669        let mut results = AnalysisResults::default();
3670        results
3671            .circular_dependencies
3672            .push(CircularDependencyFinding::with_actions(
3673                CircularDependency {
3674                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
3675                    length: 2,
3676                    line: 0,
3677                    col: 0,
3678                    edges: Vec::new(),
3679                    is_cross_package: false,
3680                },
3681            ));
3682
3683        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3684        let entry = &sarif["runs"][0]["results"][0];
3685        let phys = &entry["locations"][0]["physicalLocation"];
3686        assert!(phys.get("region").is_none());
3687    }
3688
3689    #[test]
3690    fn sarif_circular_dep_line_nonzero_has_region() {
3691        let root = PathBuf::from("/project");
3692        let mut results = AnalysisResults::default();
3693        results
3694            .circular_dependencies
3695            .push(CircularDependencyFinding::with_actions(
3696                CircularDependency {
3697                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
3698                    length: 2,
3699                    line: 5,
3700                    col: 2,
3701                    edges: Vec::new(),
3702                    is_cross_package: false,
3703                },
3704            ));
3705
3706        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3707        let entry = &sarif["runs"][0]["results"][0];
3708        let region = &entry["locations"][0]["physicalLocation"]["region"];
3709        assert_eq!(region["startLine"], 5);
3710        assert_eq!(region["startColumn"], 3);
3711    }
3712
3713    #[test]
3714    fn sarif_unused_optional_dependency_result() {
3715        let root = PathBuf::from("/project");
3716        let mut results = AnalysisResults::default();
3717        results
3718            .unused_optional_dependencies
3719            .push(UnusedOptionalDependencyFinding::with_actions(
3720                UnusedDependency {
3721                    package_name: "fsevents".to_string(),
3722                    location: DependencyLocation::OptionalDependencies,
3723                    path: root.join("package.json"),
3724                    line: 12,
3725                    used_in_workspaces: Vec::new(),
3726                },
3727            ));
3728
3729        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3730        let entry = &sarif["runs"][0]["results"][0];
3731        assert_eq!(entry["ruleId"], "fallow/unused-optional-dependency");
3732        let msg = entry["message"]["text"].as_str().unwrap();
3733        assert!(msg.contains("optionalDependencies"));
3734    }
3735
3736    #[test]
3737    fn sarif_enum_member_message_format() {
3738        let root = PathBuf::from("/project");
3739        let mut results = AnalysisResults::default();
3740        results.unused_enum_members.push(
3741            fallow_core::results::UnusedEnumMemberFinding::with_actions(UnusedMember {
3742                path: root.join("src/enums.ts"),
3743                parent_name: "Color".to_string(),
3744                member_name: "Purple".to_string(),
3745                kind: fallow_core::extract::MemberKind::EnumMember,
3746                line: 5,
3747                col: 2,
3748            }),
3749        );
3750
3751        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3752        let entry = &sarif["runs"][0]["results"][0];
3753        assert_eq!(entry["ruleId"], "fallow/unused-enum-member");
3754        let msg = entry["message"]["text"].as_str().unwrap();
3755        assert!(msg.contains("Enum member 'Color.Purple'"));
3756        let region = &entry["locations"][0]["physicalLocation"]["region"];
3757        assert_eq!(region["startColumn"], 3); // col 2 + 1
3758    }
3759
3760    #[test]
3761    fn sarif_class_member_message_format() {
3762        let root = PathBuf::from("/project");
3763        let mut results = AnalysisResults::default();
3764        results.unused_class_members.push(
3765            fallow_core::results::UnusedClassMemberFinding::with_actions(UnusedMember {
3766                path: root.join("src/service.ts"),
3767                parent_name: "API".to_string(),
3768                member_name: "fetch".to_string(),
3769                kind: fallow_core::extract::MemberKind::ClassMethod,
3770                line: 10,
3771                col: 4,
3772            }),
3773        );
3774
3775        let sarif = build_sarif(&results, &root, &RulesConfig::default());
3776        let entry = &sarif["runs"][0]["results"][0];
3777        assert_eq!(entry["ruleId"], "fallow/unused-class-member");
3778        let msg = entry["message"]["text"].as_str().unwrap();
3779        assert!(msg.contains("Class member 'API.fetch'"));
3780    }
3781
3782    #[test]
3783    #[expect(
3784        clippy::cast_possible_truncation,
3785        reason = "test line/col values are trivially small"
3786    )]
3787    fn duplication_sarif_structure() {
3788        use fallow_core::duplicates::*;
3789
3790        let root = PathBuf::from("/project");
3791        let report = DuplicationReport {
3792            clone_groups: vec![CloneGroup {
3793                instances: vec![
3794                    CloneInstance {
3795                        file: root.join("src/a.ts"),
3796                        start_line: 1,
3797                        end_line: 10,
3798                        start_col: 0,
3799                        end_col: 0,
3800                        fragment: String::new(),
3801                    },
3802                    CloneInstance {
3803                        file: root.join("src/b.ts"),
3804                        start_line: 5,
3805                        end_line: 14,
3806                        start_col: 2,
3807                        end_col: 0,
3808                        fragment: String::new(),
3809                    },
3810                ],
3811                token_count: 50,
3812                line_count: 10,
3813            }],
3814            clone_families: vec![],
3815            mirrored_directories: vec![],
3816            stats: DuplicationStats::default(),
3817        };
3818
3819        let sarif = serde_json::json!({
3820            "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
3821            "version": "2.1.0",
3822            "runs": [{
3823                "tool": {
3824                    "driver": {
3825                        "name": "fallow",
3826                        "version": env!("CARGO_PKG_VERSION"),
3827                        "informationUri": "https://github.com/fallow-rs/fallow",
3828                        "rules": [sarif_rule("fallow/code-duplication", "Duplicated code block", "warning")]
3829                    }
3830                },
3831                "results": []
3832            }]
3833        });
3834        let _ = sarif;
3835
3836        let mut sarif_results = Vec::new();
3837        for (i, group) in report.clone_groups.iter().enumerate() {
3838            for instance in &group.instances {
3839                sarif_results.push(sarif_result(
3840                    "fallow/code-duplication",
3841                    "warning",
3842                    &format!(
3843                        "Code clone group {} ({} lines, {} instances)",
3844                        i + 1,
3845                        group.line_count,
3846                        group.instances.len()
3847                    ),
3848                    &super::super::relative_uri(&instance.file, &root),
3849                    Some((instance.start_line as u32, (instance.start_col + 1) as u32)),
3850                ));
3851            }
3852        }
3853        assert_eq!(sarif_results.len(), 2);
3854        assert_eq!(sarif_results[0]["ruleId"], "fallow/code-duplication");
3855        assert!(
3856            sarif_results[0]["message"]["text"]
3857                .as_str()
3858                .unwrap()
3859                .contains("10 lines")
3860        );
3861        let region0 = &sarif_results[0]["locations"][0]["physicalLocation"]["region"];
3862        assert_eq!(region0["startLine"], 1);
3863        assert_eq!(region0["startColumn"], 1); // start_col 0 + 1
3864        let region1 = &sarif_results[1]["locations"][0]["physicalLocation"]["region"];
3865        assert_eq!(region1["startLine"], 5);
3866        assert_eq!(region1["startColumn"], 3); // start_col 2 + 1
3867    }
3868
3869    #[test]
3870    fn sarif_rule_known_id_has_full_description() {
3871        let rule = sarif_rule("fallow/unused-file", "fallback text", "error");
3872        assert!(rule.get("fullDescription").is_some());
3873        assert!(rule.get("helpUri").is_some());
3874    }
3875
3876    #[test]
3877    fn sarif_rule_unknown_id_uses_fallback() {
3878        let rule = sarif_rule("fallow/nonexistent", "fallback text", "warning");
3879        assert_eq!(rule["shortDescription"]["text"], "fallback text");
3880        assert!(rule.get("fullDescription").is_none());
3881        assert!(rule.get("helpUri").is_none());
3882        assert_eq!(rule["defaultConfiguration"]["level"], "warning");
3883    }
3884
3885    #[test]
3886    fn sarif_result_no_region_omits_region_key() {
3887        let result = sarif_result("rule/test", "error", "test msg", "src/file.ts", None);
3888        let phys = &result["locations"][0]["physicalLocation"];
3889        assert!(phys.get("region").is_none());
3890        assert_eq!(phys["artifactLocation"]["uri"], "src/file.ts");
3891    }
3892
3893    #[test]
3894    fn sarif_result_with_region_includes_region() {
3895        let result = sarif_result(
3896            "rule/test",
3897            "error",
3898            "test msg",
3899            "src/file.ts",
3900            Some((10, 5)),
3901        );
3902        let region = &result["locations"][0]["physicalLocation"]["region"];
3903        assert_eq!(region["startLine"], 10);
3904        assert_eq!(region["startColumn"], 5);
3905    }
3906
3907    #[test]
3908    fn sarif_partial_fingerprint_ignores_rendered_message() {
3909        let a = sarif_result(
3910            "rule/test",
3911            "error",
3912            "first message",
3913            "src/file.ts",
3914            Some((10, 5)),
3915        );
3916        let b = sarif_result(
3917            "rule/test",
3918            "error",
3919            "rewritten message",
3920            "src/file.ts",
3921            Some((10, 5)),
3922        );
3923        assert_eq!(
3924            a["partialFingerprints"][fingerprint::FINGERPRINT_KEY],
3925            b["partialFingerprints"][fingerprint::FINGERPRINT_KEY]
3926        );
3927    }
3928
3929    #[test]
3930    fn health_sarif_includes_refactoring_targets() {
3931        use crate::health_types::*;
3932
3933        let root = PathBuf::from("/project");
3934        let report = HealthReport {
3935            summary: HealthSummary {
3936                files_analyzed: 10,
3937                functions_analyzed: 50,
3938                ..Default::default()
3939            },
3940            targets: vec![
3941                RefactoringTarget {
3942                    path: root.join("src/complex.ts"),
3943                    priority: 85.0,
3944                    efficiency: 42.5,
3945                    recommendation: "Split high-impact file".into(),
3946                    category: RecommendationCategory::SplitHighImpact,
3947                    effort: EffortEstimate::Medium,
3948                    confidence: Confidence::High,
3949                    factors: vec![],
3950                    evidence: None,
3951                }
3952                .into(),
3953            ],
3954            ..Default::default()
3955        };
3956
3957        let sarif = build_health_sarif(&report, &root);
3958        let entries = sarif["runs"][0]["results"].as_array().unwrap();
3959        assert_eq!(entries.len(), 1);
3960        assert_eq!(entries[0]["ruleId"], "fallow/refactoring-target");
3961        assert_eq!(entries[0]["level"], "warning");
3962        let msg = entries[0]["message"]["text"].as_str().unwrap();
3963        assert!(msg.contains("high impact"));
3964        assert!(msg.contains("Split high-impact file"));
3965        assert!(msg.contains("42.5"));
3966    }
3967
3968    #[test]
3969    fn health_sarif_includes_coverage_gaps() {
3970        use crate::health_types::*;
3971
3972        let root = PathBuf::from("/project");
3973        let report = HealthReport {
3974            summary: HealthSummary {
3975                files_analyzed: 10,
3976                functions_analyzed: 50,
3977                ..Default::default()
3978            },
3979            coverage_gaps: Some(CoverageGaps {
3980                summary: CoverageGapSummary {
3981                    runtime_files: 2,
3982                    covered_files: 0,
3983                    file_coverage_pct: 0.0,
3984                    untested_files: 1,
3985                    untested_exports: 1,
3986                },
3987                files: vec![UntestedFileFinding::with_actions(
3988                    UntestedFile {
3989                        path: root.join("src/app.ts"),
3990                        value_export_count: 2,
3991                    },
3992                    &root,
3993                )],
3994                exports: vec![UntestedExportFinding::with_actions(
3995                    UntestedExport {
3996                        path: root.join("src/app.ts"),
3997                        export_name: "loader".into(),
3998                        line: 12,
3999                        col: 4,
4000                    },
4001                    &root,
4002                )],
4003            }),
4004            ..Default::default()
4005        };
4006
4007        let sarif = build_health_sarif(&report, &root);
4008        let entries = sarif["runs"][0]["results"].as_array().unwrap();
4009        assert_eq!(entries.len(), 2);
4010        assert_eq!(entries[0]["ruleId"], "fallow/untested-file");
4011        assert_eq!(
4012            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
4013            "src/app.ts"
4014        );
4015        assert!(
4016            entries[0]["message"]["text"]
4017                .as_str()
4018                .unwrap()
4019                .contains("2 value exports")
4020        );
4021        assert_eq!(entries[1]["ruleId"], "fallow/untested-export");
4022        assert_eq!(
4023            entries[1]["locations"][0]["physicalLocation"]["region"]["startLine"],
4024            12
4025        );
4026        assert_eq!(
4027            entries[1]["locations"][0]["physicalLocation"]["region"]["startColumn"],
4028            5
4029        );
4030    }
4031
4032    #[test]
4033    fn health_sarif_rules_have_full_descriptions() {
4034        let root = PathBuf::from("/project");
4035        let report = crate::health_types::HealthReport::default();
4036        let sarif = build_health_sarif(&report, &root);
4037        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
4038            .as_array()
4039            .unwrap();
4040        for rule in rules {
4041            let id = rule["id"].as_str().unwrap();
4042            assert!(
4043                rule.get("fullDescription").is_some(),
4044                "health rule {id} should have fullDescription"
4045            );
4046            assert!(
4047                rule.get("helpUri").is_some(),
4048                "health rule {id} should have helpUri"
4049            );
4050        }
4051    }
4052
4053    #[test]
4054    fn sarif_warn_severity_produces_warning_level() {
4055        let root = PathBuf::from("/project");
4056        let mut results = AnalysisResults::default();
4057        results
4058            .unused_files
4059            .push(UnusedFileFinding::with_actions(UnusedFile {
4060                path: root.join("src/dead.ts"),
4061            }));
4062
4063        let rules = RulesConfig {
4064            unused_files: Severity::Warn,
4065            ..RulesConfig::default()
4066        };
4067
4068        let sarif = build_sarif(&results, &root, &rules);
4069        let entry = &sarif["runs"][0]["results"][0];
4070        assert_eq!(entry["level"], "warning");
4071    }
4072
4073    #[test]
4074    fn sarif_unused_file_has_no_region() {
4075        let root = PathBuf::from("/project");
4076        let mut results = AnalysisResults::default();
4077        results
4078            .unused_files
4079            .push(UnusedFileFinding::with_actions(UnusedFile {
4080                path: root.join("src/dead.ts"),
4081            }));
4082
4083        let sarif = build_sarif(&results, &root, &RulesConfig::default());
4084        let entry = &sarif["runs"][0]["results"][0];
4085        let phys = &entry["locations"][0]["physicalLocation"];
4086        assert!(phys.get("region").is_none());
4087    }
4088
4089    #[test]
4090    fn sarif_unlisted_dep_multiple_import_sites() {
4091        let root = PathBuf::from("/project");
4092        let mut results = AnalysisResults::default();
4093        results
4094            .unlisted_dependencies
4095            .push(UnlistedDependencyFinding::with_actions(
4096                UnlistedDependency {
4097                    package_name: "dotenv".to_string(),
4098                    imported_from: vec![
4099                        ImportSite {
4100                            path: root.join("src/a.ts"),
4101                            line: 1,
4102                            col: 0,
4103                        },
4104                        ImportSite {
4105                            path: root.join("src/b.ts"),
4106                            line: 5,
4107                            col: 0,
4108                        },
4109                    ],
4110                },
4111            ));
4112
4113        let sarif = build_sarif(&results, &root, &RulesConfig::default());
4114        let entries = sarif["runs"][0]["results"].as_array().unwrap();
4115        assert_eq!(entries.len(), 2);
4116        assert_eq!(
4117            entries[0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
4118            "src/a.ts"
4119        );
4120        assert_eq!(
4121            entries[1]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
4122            "src/b.ts"
4123        );
4124    }
4125
4126    #[test]
4127    fn sarif_unlisted_dep_no_import_sites() {
4128        let root = PathBuf::from("/project");
4129        let mut results = AnalysisResults::default();
4130        results
4131            .unlisted_dependencies
4132            .push(UnlistedDependencyFinding::with_actions(
4133                UnlistedDependency {
4134                    package_name: "phantom".to_string(),
4135                    imported_from: vec![],
4136                },
4137            ));
4138
4139        let sarif = build_sarif(&results, &root, &RulesConfig::default());
4140        let entries = sarif["runs"][0]["results"].as_array().unwrap();
4141        assert!(entries.is_empty());
4142    }
4143}