Skip to main content

fallow_core/analyze/policy/
mod.rs

1use rustc_hash::FxHashMap;
2use rustc_hash::FxHashSet;
3
4use fallow_config::{
5    EffectKind, ResolvedBoundaryConfig, ResolvedConfig, RulePackDef, RulePackRule,
6    RulePackRuleKind, Severity,
7};
8use fallow_types::extract::ModuleInfo;
9use fallow_types::results::{PolicyRuleKind, PolicyViolation, PolicyViolationSeverity};
10
11use crate::discover::FileId;
12use crate::graph::ModuleGraph;
13use crate::suppress::SuppressionContext;
14
15use super::boundary_calls::canonical_callee_path;
16use super::security::{CalleePattern, catalogue_matchers};
17use super::{LineOffsetsMap, byte_offset_to_line_col};
18
19/// One rule-pack rule with its matchers compiled for evaluation.
20struct CompiledRule<'a> {
21    pack: &'a str,
22    rule: &'a RulePackRule,
23    /// Parsed callee patterns (`banned-call` rules only).
24    callee_patterns: Vec<CalleePattern>,
25    effects: FxHashSet<EffectKind>,
26    zones: FxHashSet<String>,
27    files: Vec<globset::GlobMatcher>,
28    exclude: Vec<globset::GlobMatcher>,
29}
30
31impl CompiledRule<'_> {
32    /// Whether this rule applies to a project-root-relative file path.
33    fn applies_to(&self, relative: &str, zone: Option<&str>) -> bool {
34        compiled_scope_applies(&self.files, &self.exclude, &self.zones, relative, zone)
35    }
36
37    /// Per-rule severity overriding the file's effective master severity.
38    fn effective_severity(&self, master: Severity) -> Severity {
39        self.rule.severity.unwrap_or(master)
40    }
41
42    /// Whether any callee pattern matches the written callee path or its
43    /// import-resolved canonical form (same two-pass matching as
44    /// `boundaries.calls.forbidden`).
45    fn matches_callee(&self, module: &ModuleInfo, callee_path: &str) -> bool {
46        if self
47            .callee_patterns
48            .iter()
49            .any(|pattern| pattern.matches(callee_path))
50        {
51            return true;
52        }
53        canonical_callee_path(module, callee_path).is_some_and(|canonical| {
54            self.callee_patterns
55                .iter()
56                .any(|pattern| pattern.matches(&canonical))
57        })
58    }
59
60    fn matches_effect(
61        &self,
62        module: &ModuleInfo,
63        callee_path: &str,
64        declared_deps: &FxHashSet<String>,
65    ) -> Option<EffectKind> {
66        effect_for_callee(module, callee_path, declared_deps)
67            .filter(|effect| self.effects.contains(effect))
68    }
69}
70
71/// Return rule-pack rules whose file and zone scope applies to a path.
72#[must_use]
73pub fn rules_applying_to_path<'a>(
74    rule_packs: &'a [RulePackDef],
75    boundaries: &ResolvedBoundaryConfig,
76    rel_path: &str,
77) -> Vec<(&'a str, &'a RulePackRule)> {
78    let zone = boundaries.classify_zone(rel_path);
79    rule_packs
80        .iter()
81        .flat_map(|pack| {
82            pack.rules
83                .iter()
84                .filter(move |rule| raw_rule_scope_applies(rule, boundaries, rel_path, zone))
85                .map(|rule| (pack.name.as_str(), rule))
86        })
87        .collect()
88}
89
90fn raw_rule_scope_applies(
91    rule: &RulePackRule,
92    boundaries: &ResolvedBoundaryConfig,
93    relative: &str,
94    zone: Option<&str>,
95) -> bool {
96    let files = compile_scope_globs(&rule.files);
97    let exclude = compile_scope_globs(&rule.exclude);
98    let zones = rule.zones.iter().cloned().collect();
99    let zone = zone.or_else(|| boundaries.classify_zone(relative));
100    compiled_scope_applies(&files, &exclude, &zones, relative, zone)
101}
102
103fn compile_scope_globs(patterns: &[String]) -> Vec<globset::GlobMatcher> {
104    // Rule-pack loading validates these globs. Dropping an unexpected parse
105    // failure keeps this introspection helper defensive like `compile_rules`.
106    patterns
107        .iter()
108        .filter_map(|pattern| globset::Glob::new(pattern).ok())
109        .map(|glob| glob.compile_matcher())
110        .collect()
111}
112
113fn compiled_scope_applies(
114    files: &[globset::GlobMatcher],
115    exclude: &[globset::GlobMatcher],
116    zones: &FxHashSet<String>,
117    relative: &str,
118    zone: Option<&str>,
119) -> bool {
120    (files.is_empty() || files.iter().any(|m| m.is_match(relative)))
121        && !exclude.iter().any(|m| m.is_match(relative))
122        && (zones.is_empty() || zone.is_some_and(|zone| zones.contains(zone)))
123}
124
125/// Detect banned calls, imports, and catalogue-derived effects declared by the
126/// configured rule packs (`rulePacks`), reporting one `policy-violation`
127/// finding per match.
128///
129/// Severity model: each file's master severity is
130/// `resolve_rules_for_path(...).policy_violation` (so per-file `overrides`
131/// apply); a rule-level `severity` overrides that master per finding. Master
132/// `off` is a kill switch for the file (per-rule severity cannot resurrect
133/// it); rule-level `off` disables only that rule. Emitted findings therefore
134/// carry only `error` or `warn`.
135///
136/// Banned-call matching mirrors `boundaries.calls.forbidden`: the written
137/// callee path and an import-resolved canonical path are both tried, so
138/// `child_process.*` covers named, namespace, and default imports from
139/// `child_process` / `node:child_process`. One finding is reported per unique
140/// callee path per file (the `callee_uses` capture dedups per path, first
141/// occurrence wins). Banned-import matching is segment-aware on the RAW
142/// specifier over imports and re-exports; `require()` calls and dynamic
143/// `import()` are documented false negatives in v1.
144///
145/// Multi-rule matching is intentionally asymmetric: for `banned-call` the
146/// first applicable rule in config order wins per callee (mirroring the
147/// boundary forbidden-call behavior). `banned-effect` follows the same first
148/// applicable rule policy over catalogue-derived effects. Every
149/// `banned-import` rule that matches a specifier emits its own finding, because
150/// each rule carries its own message and severity.
151pub fn find_policy_violations(
152    graph: &ModuleGraph,
153    modules: &[ModuleInfo],
154    config: &ResolvedConfig,
155    declared_deps: &FxHashSet<String>,
156    suppressions: &SuppressionContext<'_>,
157    line_offsets_by_file: &LineOffsetsMap<'_>,
158) -> Vec<PolicyViolation> {
159    if config.rule_packs.is_empty() {
160        return Vec::new();
161    }
162
163    let rules = compile_rules(config);
164    if rules.is_empty() {
165        return Vec::new();
166    }
167
168    let modules_by_id: FxHashMap<FileId, &ModuleInfo> =
169        modules.iter().map(|m| (m.file_id, m)).collect();
170
171    // Track how many files each `files`-scoped rule applied to so a rule
172    // whose globs match nothing warns instead of silently reporting zero
173    // findings forever (mirrors the boundary zero-zone warn).
174    let mut scoped_file_counts: Vec<usize> = vec![0; rules.len()];
175    let mut zones_by_file: FxHashMap<FileId, Option<&str>> = FxHashMap::default();
176
177    let mut violations = Vec::new();
178    for node in &graph.modules {
179        let zone = *zones_by_file.entry(node.file_id).or_insert_with(|| {
180            node.path.strip_prefix(&config.root).ok().and_then(|path| {
181                let relative = path.to_string_lossy().replace('\\', "/");
182                config.boundaries.classify_zone(&relative)
183            })
184        });
185        collect_node_policy_violations(&mut PolicyNodeInput {
186            node,
187            config,
188            rules: &rules,
189            zone,
190            modules_by_id: &modules_by_id,
191            declared_deps,
192            suppressions,
193            line_offsets_by_file,
194            scoped_file_counts: &mut scoped_file_counts,
195            violations: &mut violations,
196        });
197    }
198
199    for (index, rule) in rules.iter().enumerate() {
200        if !rule.rule.files.is_empty() && scoped_file_counts[index] == 0 {
201            tracing::warn!(
202                "rule pack '{}': rule '{}' has `files` globs that matched no analyzed file; the \
203                 rule currently enforces nothing",
204                rule.pack,
205                rule.rule.id
206            );
207        }
208    }
209
210    violations
211}
212
213/// Inputs threaded into the per-node policy scan: the node, the compiled rules,
214/// the module lookup, the shared analysis context, and the mutable accumulators.
215struct PolicyNodeInput<'a> {
216    node: &'a crate::graph::ModuleNode,
217    config: &'a ResolvedConfig,
218    rules: &'a [CompiledRule<'a>],
219    zone: Option<&'a str>,
220    modules_by_id: &'a FxHashMap<FileId, &'a ModuleInfo>,
221    declared_deps: &'a FxHashSet<String>,
222    suppressions: &'a SuppressionContext<'a>,
223    line_offsets_by_file: &'a LineOffsetsMap<'a>,
224    scoped_file_counts: &'a mut [usize],
225    violations: &'a mut Vec<PolicyViolation>,
226}
227
228/// Evaluate every banned-import / banned-effect / banned-call rule against one
229/// reachable-or-entry module, bumping per-rule scope counts and appending
230/// findings. Off-master and out-of-scope nodes are skipped.
231fn collect_node_policy_violations(input: &mut PolicyNodeInput<'_>) {
232    let node = input.node;
233    let Some(scope) = scoped_policy_rules(
234        node,
235        input.config,
236        input.rules,
237        input.zone,
238        input.scoped_file_counts,
239    ) else {
240        return;
241    };
242
243    let Some(module) = input.modules_by_id.get(&node.file_id) else {
244        return;
245    };
246
247    collect_banned_imports(&mut PolicyCollectionInput {
248        in_scope: &scope.in_scope,
249        module,
250        node,
251        master: scope.master,
252        declared_deps: input.declared_deps,
253        suppressions: input.suppressions,
254        line_offsets_by_file: input.line_offsets_by_file,
255        violations: input.violations,
256    });
257    collect_banned_exports(&mut PolicyCollectionInput {
258        in_scope: &scope.in_scope,
259        module,
260        node,
261        master: scope.master,
262        declared_deps: input.declared_deps,
263        suppressions: input.suppressions,
264        line_offsets_by_file: input.line_offsets_by_file,
265        violations: input.violations,
266    });
267    collect_banned_effects(&mut PolicyCollectionInput {
268        in_scope: &scope.in_scope,
269        module,
270        node,
271        master: scope.master,
272        declared_deps: input.declared_deps,
273        suppressions: input.suppressions,
274        line_offsets_by_file: input.line_offsets_by_file,
275        violations: input.violations,
276    });
277    collect_banned_calls(&mut PolicyCollectionInput {
278        in_scope: &scope.in_scope,
279        module,
280        node,
281        master: scope.master,
282        declared_deps: input.declared_deps,
283        suppressions: input.suppressions,
284        line_offsets_by_file: input.line_offsets_by_file,
285        violations: input.violations,
286    });
287}
288
289struct ScopedPolicyRules<'a> {
290    master: Severity,
291    in_scope: Vec<(usize, &'a CompiledRule<'a>)>,
292}
293
294fn scoped_policy_rules<'a>(
295    node: &crate::graph::ModuleNode,
296    config: &ResolvedConfig,
297    rules: &'a [CompiledRule<'a>],
298    zone: Option<&str>,
299    scoped_file_counts: &mut [usize],
300) -> Option<ScopedPolicyRules<'a>> {
301    if !node.is_reachable() && !node.is_entry_point() {
302        return None;
303    }
304    let Ok(relative) = node.path.strip_prefix(&config.root) else {
305        return None;
306    };
307    let relative = relative.to_string_lossy().replace('\\', "/");
308
309    let master = config.resolve_rules_for_path(&node.path).policy_violation;
310    if master == Severity::Off {
311        return None;
312    }
313
314    let in_scope: Vec<(usize, &CompiledRule<'_>)> = rules
315        .iter()
316        .enumerate()
317        .filter(|(_, rule)| rule.applies_to(&relative, zone))
318        .collect();
319    if in_scope.is_empty() {
320        return None;
321    }
322    for (index, _) in &in_scope {
323        scoped_file_counts[*index] += 1;
324    }
325
326    Some(ScopedPolicyRules { master, in_scope })
327}
328
329/// Compile every loaded pack rule. Rules pinned to `severity: "off"` are
330/// dropped here: they are disabled regardless of the master severity.
331fn compile_rules(config: &ResolvedConfig) -> Vec<CompiledRule<'_>> {
332    let mut rules = Vec::new();
333    for pack in &config.rule_packs {
334        for rule in &pack.rules {
335            if rule.severity == Some(Severity::Off) {
336                continue;
337            }
338            // Patterns and globs are validated at config load; a parse
339            // failure here only drops that single pattern (defensive).
340            let callee_patterns = rule
341                .callees
342                .iter()
343                .filter_map(|raw| CalleePattern::parse(raw))
344                .collect();
345            let effects = rule.effects.iter().copied().collect();
346            let zones = rule.zones.iter().cloned().collect();
347            rules.push(CompiledRule {
348                pack: pack.name.as_str(),
349                rule,
350                callee_patterns,
351                effects,
352                zones,
353                files: compile_scope_globs(&rule.files),
354                exclude: compile_scope_globs(&rule.exclude),
355            });
356        }
357    }
358    rules
359}
360
361/// Emit one finding per `banned-import` rule match over the module's imports
362/// and re-exports.
363struct PolicyCollectionInput<'a> {
364    in_scope: &'a [(usize, &'a CompiledRule<'a>)],
365    module: &'a ModuleInfo,
366    node: &'a crate::graph::ModuleNode,
367    master: Severity,
368    declared_deps: &'a FxHashSet<String>,
369    suppressions: &'a SuppressionContext<'a>,
370    line_offsets_by_file: &'a LineOffsetsMap<'a>,
371    violations: &'a mut Vec<PolicyViolation>,
372}
373
374fn collect_banned_imports(input: &mut PolicyCollectionInput<'_>) {
375    let ctx = BannedImportCtx {
376        node: input.node,
377        suppressions: input.suppressions,
378        line_offsets_by_file: input.line_offsets_by_file,
379    };
380    for (_, rule) in input.in_scope {
381        if rule.rule.kind != RulePackRuleKind::BannedImport {
382            continue;
383        }
384        let Some(severity) = wire_severity(rule.effective_severity(input.master)) else {
385            continue;
386        };
387        let sites = input
388            .module
389            .imports
390            .iter()
391            .map(|import| {
392                (
393                    import.source.as_str(),
394                    import.is_type_only,
395                    import.span.start,
396                )
397            })
398            .chain(input.module.re_exports.iter().map(|re_export| {
399                (
400                    re_export.source.as_str(),
401                    re_export.is_type_only,
402                    re_export.span.start,
403                )
404            }));
405        for (source, is_type_only, span_start) in sites {
406            push_banned_import_if_matched(
407                &ctx,
408                rule,
409                severity,
410                &BannedImportSite {
411                    source,
412                    is_type_only,
413                    span_start,
414                },
415                input.violations,
416            );
417        }
418    }
419}
420
421/// A single import / re-export specifier site evaluated by `banned-import`.
422struct BannedImportSite<'a> {
423    source: &'a str,
424    is_type_only: bool,
425    span_start: u32,
426}
427
428/// Per-module emission context shared across every `banned-import` site check.
429struct BannedImportCtx<'a> {
430    node: &'a crate::graph::ModuleNode,
431    suppressions: &'a SuppressionContext<'a>,
432    line_offsets_by_file: &'a LineOffsetsMap<'a>,
433}
434
435/// Push a `banned-import` violation when `site`'s specifier matches the rule and
436/// is neither type-only-skipped nor suppressed.
437fn push_banned_import_if_matched(
438    ctx: &BannedImportCtx<'_>,
439    rule: &CompiledRule<'_>,
440    severity: PolicyViolationSeverity,
441    site: &BannedImportSite<'_>,
442    violations: &mut Vec<PolicyViolation>,
443) {
444    if rule.rule.ignore_type_only && site.is_type_only {
445        return;
446    }
447    if !rule
448        .rule
449        .specifiers
450        .iter()
451        .any(|specifier| specifier_matches(site.source, specifier))
452    {
453        return;
454    }
455    let (line, col) =
456        byte_offset_to_line_col(ctx.line_offsets_by_file, ctx.node.file_id, site.span_start);
457    if ctx
458        .suppressions
459        .is_policy_suppressed(ctx.node.file_id, line, rule.pack, &rule.rule.id)
460    {
461        return;
462    }
463    violations.push(PolicyViolation {
464        path: ctx.node.path.clone(),
465        line,
466        col,
467        pack: rule.pack.to_owned(),
468        rule_id: rule.rule.id.clone(),
469        kind: PolicyRuleKind::BannedImport,
470        matched: site.source.to_owned(),
471        severity,
472        message: rule.rule.message.clone(),
473    });
474}
475
476/// Emit one finding per `banned-export` rule match over the module's direct
477/// exports. Re-exports are intentionally handled by `banned-import`.
478fn collect_banned_exports(input: &mut PolicyCollectionInput<'_>) {
479    for (_, rule) in input.in_scope {
480        if rule.rule.kind != RulePackRuleKind::BannedExport {
481            continue;
482        }
483        let Some(severity) = wire_severity(rule.effective_severity(input.master)) else {
484            continue;
485        };
486        for export in &input.module.exports {
487            if rule.rule.ignore_type_only && export.is_type_only {
488                continue;
489            }
490            if !rule
491                .rule
492                .exports
493                .iter()
494                .any(|pattern| export_pattern_matches(&export.name, pattern))
495            {
496                continue;
497            }
498            let (line, col) = byte_offset_to_line_col(
499                input.line_offsets_by_file,
500                input.node.file_id,
501                export.span.start,
502            );
503            if input.suppressions.is_policy_suppressed(
504                input.node.file_id,
505                line,
506                rule.pack,
507                &rule.rule.id,
508            ) {
509                continue;
510            }
511            input.violations.push(PolicyViolation {
512                path: input.node.path.clone(),
513                line,
514                col,
515                pack: rule.pack.to_owned(),
516                rule_id: rule.rule.id.clone(),
517                kind: PolicyRuleKind::BannedExport,
518                matched: export.name.to_string(),
519                severity,
520                message: rule.rule.message.clone(),
521            });
522        }
523    }
524}
525
526fn collect_banned_effects(input: &mut PolicyCollectionInput<'_>) {
527    for callee_use in &input.module.callee_uses {
528        let matched = input.in_scope.iter().find_map(|(_, rule)| {
529            if rule.rule.kind != RulePackRuleKind::BannedEffect {
530                return None;
531            }
532            let severity = wire_severity(rule.effective_severity(input.master))?;
533            rule.matches_effect(input.module, &callee_use.callee_path, input.declared_deps)
534                .map(|effect| (rule, severity, effect))
535        });
536        let Some((rule, severity, effect)) = matched else {
537            continue;
538        };
539        let (line, col) = byte_offset_to_line_col(
540            input.line_offsets_by_file,
541            input.node.file_id,
542            callee_use.span_start,
543        );
544        if input.suppressions.is_policy_suppressed(
545            input.node.file_id,
546            line,
547            rule.pack,
548            &rule.rule.id,
549        ) {
550            continue;
551        }
552        input.violations.push(PolicyViolation {
553            path: input.node.path.clone(),
554            line,
555            col,
556            pack: rule.pack.to_owned(),
557            rule_id: rule.rule.id.clone(),
558            kind: PolicyRuleKind::BannedEffect,
559            matched: format!("{}: {}", effect.as_str(), callee_use.callee_path),
560            severity,
561            message: rule.rule.message.clone(),
562        });
563    }
564}
565
566/// Emit one finding per unique callee path matched by the first applicable
567/// `banned-call` rule (config order), mirroring the boundary forbidden-call
568/// first-pattern-wins behavior.
569fn collect_banned_calls(input: &mut PolicyCollectionInput<'_>) {
570    for callee_use in &input.module.callee_uses {
571        let matched = input.in_scope.iter().find_map(|(_, rule)| {
572            if rule.rule.kind != RulePackRuleKind::BannedCall {
573                return None;
574            }
575            let severity = wire_severity(rule.effective_severity(input.master))?;
576            rule.matches_callee(input.module, &callee_use.callee_path)
577                .then_some((rule, severity))
578        });
579        let Some((rule, severity)) = matched else {
580            continue;
581        };
582        let (line, col) = byte_offset_to_line_col(
583            input.line_offsets_by_file,
584            input.node.file_id,
585            callee_use.span_start,
586        );
587        if input.suppressions.is_policy_suppressed(
588            input.node.file_id,
589            line,
590            rule.pack,
591            &rule.rule.id,
592        ) {
593            continue;
594        }
595        input.violations.push(PolicyViolation {
596            path: input.node.path.clone(),
597            line,
598            col,
599            pack: rule.pack.to_owned(),
600            rule_id: rule.rule.id.clone(),
601            kind: PolicyRuleKind::BannedCall,
602            matched: callee_use.callee_path.clone(),
603            severity,
604            message: rule.rule.message.clone(),
605        });
606    }
607}
608
609fn effect_for_callee(
610    module: &ModuleInfo,
611    callee_path: &str,
612    declared_deps: &FxHashSet<String>,
613) -> Option<EffectKind> {
614    let written = catalogue_matchers()
615        .iter()
616        .find(|matcher| matcher_matches_callee(matcher, module, callee_path, declared_deps))
617        .map(|matcher| matcher.effect);
618    if written.is_some() {
619        return written;
620    }
621    let canonical = canonical_callee_path(module, callee_path)?;
622    catalogue_matchers()
623        .iter()
624        .find(|matcher| matcher_matches_callee(matcher, module, &canonical, declared_deps))
625        .map(|matcher| matcher.effect)
626}
627
628fn matcher_matches_callee(
629    matcher: &super::security::Matcher,
630    module: &ModuleInfo,
631    callee_path: &str,
632    declared_deps: &FxHashSet<String>,
633) -> bool {
634    matcher.enabler_satisfied(declared_deps)
635        && provenance_satisfied(matcher, module, callee_path)
636        && matcher.first_matching_pattern(callee_path).is_some()
637}
638
639fn provenance_satisfied(
640    matcher: &super::security::Matcher,
641    module: &ModuleInfo,
642    callee_path: &str,
643) -> bool {
644    let Some(spec) = &matcher.import_provenance else {
645        return true;
646    };
647    let leading_ident = callee_path.split('.').next().unwrap_or(callee_path);
648    module.imports.iter().any(|imp| {
649        import_source_matches(&imp.source, spec)
650            && (!requires_binding_trace(matcher) || imp.local_name == leading_ident)
651    }) || module.require_calls.iter().any(|call| {
652        import_source_matches(&call.source, spec)
653            && (!requires_binding_trace(matcher)
654                || call.local_name.as_deref() == Some(leading_ident)
655                || call
656                    .destructured_names
657                    .iter()
658                    .any(|name| name == leading_ident))
659    })
660}
661
662fn requires_binding_trace(matcher: &super::security::Matcher) -> bool {
663    matches!(
664        matcher.id.as_str(),
665        "command-injection"
666            | "permissive-cors"
667            | "electron-unsafe-webpreferences"
668            | "insecure-temp-file"
669            | "jwt-alg-none"
670            | "jwt-verify-missing-algorithms"
671            | "tls-validation-disabled"
672            | "mysql-multiple-statements"
673            | "world-writable-permission"
674    ) || (matcher.id == "weak-crypto" && matcher.is_literal_aware())
675}
676
677fn import_source_matches(source: &str, spec: &str) -> bool {
678    fn strip_node_prefix(value: &str) -> &str {
679        value.strip_prefix("node:").unwrap_or(value)
680    }
681
682    let source = strip_node_prefix(source);
683    let spec = strip_node_prefix(spec);
684    source == spec
685        || source
686            .strip_prefix(spec)
687            .is_some_and(|rest| rest.starts_with('/'))
688}
689
690/// Segment-aware raw-specifier match: the pattern matches exactly or at a
691/// `/` boundary, so `moment` covers `moment/locale/nl` but never
692/// `moment-timezone`.
693fn specifier_matches(raw: &str, pattern: &str) -> bool {
694    if let Some(prefix) = pattern.strip_suffix("/*") {
695        return raw
696            .strip_prefix(prefix)
697            .is_some_and(|rest| rest.starts_with('/'));
698    }
699    raw == pattern
700        || raw
701            .strip_prefix(pattern)
702            .is_some_and(|rest| rest.starts_with('/'))
703}
704
705fn export_pattern_matches(name: &fallow_types::extract::ExportName, pattern: &str) -> bool {
706    pattern.strip_suffix('*').map_or_else(
707        || name.matches_str(pattern),
708        |prefix| name.to_string().starts_with(prefix),
709    )
710}
711
712/// Map an effective config severity onto the wire enum. `Off` yields `None`
713/// (the rule emits nothing).
714const fn wire_severity(severity: Severity) -> Option<PolicyViolationSeverity> {
715    match severity {
716        Severity::Error => Some(PolicyViolationSeverity::Error),
717        Severity::Warn => Some(PolicyViolationSeverity::Warn),
718        Severity::Off => None,
719    }
720}
721
722#[cfg(test)]
723mod tests;