Skip to main content

fallow_engine/
guard.rs

1//! Typed guard report assembly for pre-edit architecture guidance.
2
3use std::fmt;
4use std::path::{Component, Path};
5
6use fallow_config::{ResolvedBoundaryConfig, ResolvedConfig, RulePackRule, RulePackRuleKind};
7use fallow_types::guard::{
8    GuardBoundary, GuardFileReport, GuardPolicyRule, GuardReport, GuardSeverities, GuardZone,
9};
10use rustc_hash::FxHashSet;
11
12/// Error returned when a guard target cannot be represented safely.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum GuardError {
15    /// The requested target is outside the resolved project root.
16    OutsideRoot(String),
17}
18
19impl fmt::Display for GuardError {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        match self {
22            Self::OutsideRoot(path) => write!(f, "guard target is outside project root: {path}"),
23        }
24    }
25}
26
27impl std::error::Error for GuardError {}
28
29/// Build a typed guard report for one or more target files.
30///
31/// Paths may be project-relative or absolute under `config.root`. Returned
32/// paths are project-root-relative and use forward slashes.
33///
34/// # Errors
35///
36/// Returns [`GuardError::OutsideRoot`] for absolute paths outside the project
37/// root or relative paths containing parent-directory traversal.
38pub fn build_guard_report(
39    config: &ResolvedConfig,
40    files: &[String],
41) -> Result<GuardReport, GuardError> {
42    let mut reports = Vec::with_capacity(files.len());
43    for file in files {
44        reports.push(build_file_report(config, file)?);
45    }
46    Ok(GuardReport { files: reports })
47}
48
49fn build_file_report(config: &ResolvedConfig, input: &str) -> Result<GuardFileReport, GuardError> {
50    let rel_path = normalize_target_path(config, input)?;
51    let full_path = config.root.join(&rel_path);
52    let rules = config.resolve_rules_for_path(&full_path);
53    let zone_name = config.boundaries.classify_zone(&rel_path);
54    let zone = zone_name.and_then(|name| guard_zone(&config.boundaries, name));
55    let boundary = guard_boundary(&config.boundaries, &rel_path, zone_name);
56    let notes = guard_notes(config, zone_name, boundary.coverage_required);
57
58    Ok(GuardFileReport {
59        exists: full_path.exists(),
60        boundary,
61        policy_rules: guard_policy_rules(config, &rel_path, rules.policy_violation),
62        severities: GuardSeverities {
63            boundary_violation: rules.boundary_violation.to_string(),
64            policy_violation: rules.policy_violation.to_string(),
65        },
66        path: rel_path,
67        zone,
68        notes,
69    })
70}
71
72fn normalize_target_path(config: &ResolvedConfig, input: &str) -> Result<String, GuardError> {
73    let normalized = input.replace('\\', "/");
74    let path = Path::new(&normalized);
75    if looks_windows_absolute(&normalized) && !path.is_absolute() {
76        return Err(GuardError::OutsideRoot(input.to_string()));
77    }
78    let relative = if path.is_absolute() {
79        path.strip_prefix(&config.root)
80            .map_err(|_| GuardError::OutsideRoot(input.to_string()))?
81    } else {
82        path
83    };
84    normalize_relative_path(relative, input)
85}
86
87fn looks_windows_absolute(path: &str) -> bool {
88    let bytes = path.as_bytes();
89    bytes.len() >= 3 && bytes[1] == b':' && bytes[2] == b'/'
90}
91
92fn normalize_relative_path(path: &Path, original: &str) -> Result<String, GuardError> {
93    let mut parts = Vec::new();
94    for component in path.components() {
95        match component {
96            Component::CurDir => {}
97            Component::Normal(part) => parts.push(part.to_string_lossy().replace('\\', "/")),
98            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
99                return Err(GuardError::OutsideRoot(original.to_string()));
100            }
101        }
102    }
103    Ok(parts.join("/"))
104}
105
106fn guard_zone(boundaries: &ResolvedBoundaryConfig, name: &str) -> Option<GuardZone> {
107    boundaries
108        .zones
109        .iter()
110        .find(|zone| zone.name == name)
111        .map(|zone| GuardZone {
112            name: zone.name.clone(),
113            patterns: zone.patterns.clone(),
114        })
115}
116
117fn guard_boundary(
118    boundaries: &ResolvedBoundaryConfig,
119    rel_path: &str,
120    zone_name: Option<&str>,
121) -> GuardBoundary {
122    let configured = boundaries_configured(boundaries);
123    let coverage_required = zone_name.is_none()
124        && boundaries.coverage.require_all_files
125        && !boundaries.allows_unmatched(rel_path);
126
127    let Some(zone_name) = zone_name else {
128        return GuardBoundary {
129            configured,
130            unrestricted: true,
131            allowed_zones: Vec::new(),
132            allowed_type_only_zones: Vec::new(),
133            forbidden_calls: Vec::new(),
134            coverage_required,
135        };
136    };
137
138    let forbidden_calls = boundaries
139        .calls_forbidden_by_zone
140        .get(zone_name)
141        .cloned()
142        .unwrap_or_default();
143    let Some(rule) = boundaries
144        .rules
145        .iter()
146        .find(|rule| rule.from_zone == zone_name)
147    else {
148        return GuardBoundary {
149            configured,
150            unrestricted: true,
151            allowed_zones: Vec::new(),
152            allowed_type_only_zones: Vec::new(),
153            forbidden_calls,
154            coverage_required,
155        };
156    };
157
158    let mut allowed_zones = vec![zone_name.to_string()];
159    allowed_zones.extend(rule.allowed_zones.iter().cloned());
160    allowed_zones.sort();
161    allowed_zones.dedup();
162
163    GuardBoundary {
164        configured,
165        unrestricted: false,
166        allowed_zones,
167        allowed_type_only_zones: rule.allow_type_only_zones.clone(),
168        forbidden_calls,
169        coverage_required,
170    }
171}
172
173fn guard_notes(
174    config: &ResolvedConfig,
175    zone_name: Option<&str>,
176    coverage_required: bool,
177) -> Vec<String> {
178    let mut notes = Vec::new();
179    if boundaries_configured(&config.boundaries) && zone_name.is_none() {
180        notes.push(
181            "Files outside every zone are unrestricted for import and call checks.".to_string(),
182        );
183        if coverage_required {
184            notes.push(
185                "boundaries.coverage.requireAllFiles is enabled: reachable files with no zone are reported as boundary-coverage violations."
186                    .to_string(),
187            );
188        }
189    }
190    if !boundaries_configured(&config.boundaries) && config.rule_packs.is_empty() {
191        notes.push("No boundary zones or rule packs are configured.".to_string());
192    }
193    if zone_name.is_some() {
194        notes.push("Same-zone imports are always allowed.".to_string());
195    }
196    notes
197}
198
199fn boundaries_configured(boundaries: &ResolvedBoundaryConfig) -> bool {
200    !boundaries.zones.is_empty() || !boundaries.logical_groups.is_empty()
201}
202
203fn guard_policy_rules(
204    config: &ResolvedConfig,
205    rel_path: &str,
206    master_severity: fallow_config::Severity,
207) -> Vec<GuardPolicyRule> {
208    if master_severity == fallow_config::Severity::Off {
209        return Vec::new();
210    }
211
212    rules_applying_to_path(config, rel_path)
213        .into_iter()
214        .filter_map(|(pack, rule)| guard_policy_rule(pack, rule, master_severity))
215        .collect()
216}
217
218fn rules_applying_to_path<'a>(
219    config: &'a ResolvedConfig,
220    rel_path: &str,
221) -> Vec<(&'a str, &'a RulePackRule)> {
222    let zone = config.boundaries.classify_zone(rel_path);
223    config
224        .rule_packs
225        .iter()
226        .flat_map(|pack| {
227            pack.rules
228                .iter()
229                .filter(move |rule| {
230                    raw_rule_scope_applies(rule, &config.boundaries, rel_path, zone)
231                })
232                .map(|rule| (pack.name.as_str(), rule))
233        })
234        .collect()
235}
236
237fn raw_rule_scope_applies(
238    rule: &RulePackRule,
239    boundaries: &ResolvedBoundaryConfig,
240    relative: &str,
241    zone: Option<&str>,
242) -> bool {
243    let files = compile_scope_globs(&rule.files);
244    let exclude = compile_scope_globs(&rule.exclude);
245    let zones = rule.zones.iter().cloned().collect();
246    let zone = zone.or_else(|| boundaries.classify_zone(relative));
247    compiled_scope_applies(&files, &exclude, &zones, relative, zone)
248}
249
250fn compile_scope_globs(patterns: &[String]) -> Vec<globset::GlobMatcher> {
251    patterns
252        .iter()
253        .filter_map(|pattern| globset::Glob::new(pattern).ok())
254        .map(|glob| glob.compile_matcher())
255        .collect()
256}
257
258fn compiled_scope_applies(
259    files: &[globset::GlobMatcher],
260    exclude: &[globset::GlobMatcher],
261    zones: &FxHashSet<String>,
262    relative: &str,
263    zone: Option<&str>,
264) -> bool {
265    (files.is_empty() || files.iter().any(|matcher| matcher.is_match(relative)))
266        && !exclude.iter().any(|matcher| matcher.is_match(relative))
267        && (zones.is_empty() || zone.is_some_and(|zone| zones.contains(zone)))
268}
269
270fn guard_policy_rule(
271    pack: &str,
272    rule: &RulePackRule,
273    master_severity: fallow_config::Severity,
274) -> Option<GuardPolicyRule> {
275    let severity = rule.severity.unwrap_or(master_severity);
276    if severity == fallow_config::Severity::Off {
277        return None;
278    }
279
280    Some(GuardPolicyRule {
281        pack: pack.to_string(),
282        rule_id: rule.id.clone(),
283        kind: rule_kind(rule.kind).to_string(),
284        patterns: rule_patterns(rule),
285        message: rule.message.clone(),
286        severity: severity.to_string(),
287        suppress_token: format!("policy-violation:{pack}/{}", rule.id),
288    })
289}
290
291const fn rule_kind(kind: RulePackRuleKind) -> &'static str {
292    match kind {
293        RulePackRuleKind::BannedCall => "banned-call",
294        RulePackRuleKind::BannedImport => "banned-import",
295        RulePackRuleKind::BannedEffect => "banned-effect",
296        RulePackRuleKind::BannedExport => "banned-export",
297    }
298}
299
300fn rule_patterns(rule: &RulePackRule) -> Vec<String> {
301    match rule.kind {
302        RulePackRuleKind::BannedCall => rule.callees.clone(),
303        RulePackRuleKind::BannedImport => rule.specifiers.clone(),
304        RulePackRuleKind::BannedEffect => rule
305            .effects
306            .iter()
307            .map(|effect| effect.as_str().to_string())
308            .collect(),
309        RulePackRuleKind::BannedExport => rule.exports.clone(),
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use fallow_config::{
317        BoundaryCallsConfig, BoundaryConfig, BoundaryCoverageConfig, BoundaryRule, BoundaryZone,
318        EffectKind, FallowConfig, ForbiddenCallRule, ForbiddenCallee, OutputFormat, RulePackDef,
319        RulePackRule, RulePackRuleKind, RulesConfig, Severity,
320    };
321    use std::fs;
322
323    fn rule(id: &str, kind: RulePackRuleKind) -> RulePackRule {
324        RulePackRule {
325            id: id.to_string(),
326            kind,
327            callees: Vec::new(),
328            specifiers: Vec::new(),
329            effects: Vec::new(),
330            exports: Vec::new(),
331            ignore_type_only: false,
332            files: Vec::new(),
333            exclude: Vec::new(),
334            zones: Vec::new(),
335            message: None,
336            severity: None,
337        }
338    }
339
340    fn pack(rules: Vec<RulePackRule>) -> RulePackDef {
341        RulePackDef {
342            schema: None,
343            version: 1,
344            name: "team-policy".to_string(),
345            description: None,
346            rules,
347        }
348    }
349
350    fn resolve(root: &Path, configure: impl FnOnce(&mut FallowConfig)) -> ResolvedConfig {
351        let mut config = FallowConfig {
352            rules: RulesConfig {
353                policy_violation: Severity::Warn,
354                ..RulesConfig::default()
355            },
356            ..FallowConfig::default()
357        };
358        configure(&mut config);
359        config.resolve(root.to_path_buf(), OutputFormat::Json, 1, true, true, None)
360    }
361
362    #[test]
363    fn zoned_file_reports_allow_rule_and_forbidden_call() {
364        let temp = tempfile::tempdir().expect("tempdir");
365        fs::create_dir_all(temp.path().join("src/domain")).expect("create dir");
366        fs::write(temp.path().join("src/domain/user.ts"), "").expect("write file");
367        let config = resolve(temp.path(), |config| {
368            config.boundaries = BoundaryConfig {
369                zones: vec![
370                    BoundaryZone {
371                        name: "domain".to_string(),
372                        patterns: vec!["src/domain/**".to_string()],
373                        auto_discover: Vec::new(),
374                        root: None,
375                    },
376                    BoundaryZone {
377                        name: "shared".to_string(),
378                        patterns: vec!["src/shared/**".to_string()],
379                        auto_discover: Vec::new(),
380                        root: None,
381                    },
382                ],
383                rules: vec![BoundaryRule {
384                    from: "domain".to_string(),
385                    allow: vec!["shared".to_string()],
386                    allow_type_only: vec!["ui".to_string()],
387                }],
388                calls: BoundaryCallsConfig {
389                    forbidden: vec![ForbiddenCallRule {
390                        from: "domain".to_string(),
391                        callee: ForbiddenCallee::Single("child_process.*".to_string()),
392                    }],
393                },
394                ..BoundaryConfig::default()
395            };
396        });
397
398        let report =
399            build_guard_report(&config, &["src/domain/user.ts".to_string()]).expect("report");
400        let file = &report.files[0];
401
402        assert!(file.exists);
403        assert_eq!(
404            file.zone.as_ref().map(|zone| zone.name.as_str()),
405            Some("domain")
406        );
407        assert!(!file.boundary.unrestricted);
408        assert_eq!(file.boundary.allowed_zones, vec!["domain", "shared"]);
409        assert_eq!(file.boundary.allowed_type_only_zones, vec!["ui"]);
410        assert_eq!(file.boundary.forbidden_calls, vec!["child_process.*"]);
411        assert!(file.notes.iter().any(|note| note.contains("Same-zone")));
412    }
413
414    fn required_coverage_config(root: &Path) -> ResolvedConfig {
415        resolve(root, |config| {
416            config.boundaries = BoundaryConfig {
417                zones: vec![BoundaryZone {
418                    name: "domain".to_string(),
419                    patterns: vec!["src/domain/**".to_string()],
420                    auto_discover: Vec::new(),
421                    root: None,
422                }],
423                coverage: BoundaryCoverageConfig {
424                    require_all_files: true,
425                    allow_unmatched: vec!["src/generated/**".to_string()],
426                },
427                ..BoundaryConfig::default()
428            };
429        })
430    }
431
432    #[test]
433    fn unzoned_file_reports_required_coverage() {
434        let temp = tempfile::tempdir().expect("tempdir");
435        let config = required_coverage_config(temp.path());
436
437        let report =
438            build_guard_report(&config, &["src/ui/button.ts".to_string()]).expect("report");
439        let file = &report.files[0];
440
441        assert!(file.zone.is_none());
442        assert!(file.boundary.unrestricted);
443        assert!(file.boundary.coverage_required);
444        assert!(
445            file.notes
446                .iter()
447                .any(|note| note.contains("outside every zone"))
448        );
449
450        let allowed =
451            build_guard_report(&config, &["src/generated/client.ts".to_string()]).expect("report");
452        assert!(!allowed.files[0].boundary.coverage_required);
453    }
454
455    #[test]
456    fn required_coverage_notes_state_the_requirement() {
457        let temp = tempfile::tempdir().expect("tempdir");
458        let config = required_coverage_config(temp.path());
459
460        let report =
461            build_guard_report(&config, &["src/ui/button.ts".to_string()]).expect("report");
462        let notes = &report.files[0].notes;
463
464        assert!(
465            notes
466                .iter()
467                .any(|note| note.contains("requireAllFiles") && note.contains("boundary-coverage")),
468            "unzoned file under required coverage must state the requirement: {notes:?}"
469        );
470        assert!(
471            !notes
472                .iter()
473                .any(|note| note.contains("unrestricted for boundary checks")),
474            "the unrestricted note must not claim to cover the coverage check: {notes:?}"
475        );
476
477        let allowed =
478            build_guard_report(&config, &["src/generated/client.ts".to_string()]).expect("report");
479        assert!(
480            !allowed.files[0]
481                .notes
482                .iter()
483                .any(|note| note.contains("requireAllFiles")),
484            "allowUnmatched paths must not state a coverage requirement"
485        );
486    }
487
488    #[test]
489    fn pack_rule_scope_filters_policy_rules() {
490        let temp = tempfile::tempdir().expect("tempdir");
491        let mut domain_rule = rule("pure-domain", RulePackRuleKind::BannedEffect);
492        domain_rule.effects = vec![EffectKind::Network];
493        domain_rule.files = vec!["src/domain/**".to_string()];
494        let mut excluded_rule = rule("no-generated-process", RulePackRuleKind::BannedCall);
495        excluded_rule.callees = vec!["child_process.*".to_string()];
496        excluded_rule.exclude = vec!["src/domain/**".to_string()];
497        let mut config = resolve(temp.path(), |_| {});
498        config.rule_packs = vec![pack(vec![domain_rule, excluded_rule])];
499
500        let report =
501            build_guard_report(&config, &["src/domain/user.ts".to_string()]).expect("report");
502        let rules = &report.files[0].policy_rules;
503
504        assert_eq!(rules.len(), 1);
505        assert_eq!(rules[0].rule_id, "pure-domain");
506        assert_eq!(rules[0].kind, "banned-effect");
507        assert_eq!(rules[0].patterns, vec!["network"]);
508        assert_eq!(
509            rules[0].suppress_token,
510            "policy-violation:team-policy/pure-domain"
511        );
512        assert_eq!(rules[0].severity, "warn");
513    }
514
515    #[test]
516    fn compiled_rule_scopes_match_individual_file_reports() {
517        let temp = tempfile::tempdir().expect("tempdir");
518        let mut config = resolve(temp.path(), |config| {
519            config.boundaries = BoundaryConfig {
520                zones: vec![
521                    BoundaryZone {
522                        name: "domain".to_string(),
523                        patterns: vec!["src/domain/**".to_string()],
524                        auto_discover: Vec::new(),
525                        root: None,
526                    },
527                    BoundaryZone {
528                        name: "app".to_string(),
529                        patterns: vec!["src/app/**".to_string()],
530                        auto_discover: Vec::new(),
531                        root: None,
532                    },
533                ],
534                ..BoundaryConfig::default()
535            };
536        });
537        let mut domain_rule = rule("domain-only", RulePackRuleKind::BannedImport);
538        domain_rule.files = vec!["src/domain/**".to_string()];
539        domain_rule.exclude = vec!["src/domain/generated/**".to_string()];
540        let mut app_rule = rule("app-zone", RulePackRuleKind::BannedCall);
541        app_rule.zones = vec!["app".to_string()];
542        let mut invalid_glob_rule = rule("invalid-glob", RulePackRuleKind::BannedExport);
543        invalid_glob_rule.files = vec!["[".to_string()];
544        config.rule_packs = vec![pack(vec![domain_rule, app_rule, invalid_glob_rule])];
545
546        let files = vec![
547            "src/domain/user.ts".to_string(),
548            "src/domain/generated/client.ts".to_string(),
549            "src/app/page.ts".to_string(),
550            "src/other.ts".to_string(),
551        ];
552        let batch = build_guard_report(&config, &files).expect("batch report");
553        let individual = files
554            .iter()
555            .flat_map(|file| {
556                build_guard_report(&config, std::slice::from_ref(file))
557                    .expect("individual report")
558                    .files
559            })
560            .collect::<Vec<_>>();
561
562        assert_eq!(
563            serde_json::to_value(&batch.files).expect("serialize batch reports"),
564            serde_json::to_value(&individual).expect("serialize individual reports")
565        );
566        let rule_ids = batch
567            .files
568            .iter()
569            .map(|file| {
570                file.policy_rules
571                    .iter()
572                    .map(|rule| rule.rule_id.as_str())
573                    .collect::<Vec<_>>()
574            })
575            .collect::<Vec<_>>();
576        assert_eq!(
577            rule_ids,
578            vec![
579                vec!["domain-only", "invalid-glob"],
580                vec!["invalid-glob"],
581                vec!["app-zone", "invalid-glob"],
582                vec!["invalid-glob"],
583            ]
584        );
585    }
586
587    #[test]
588    fn nonexistent_target_reports_exists_false() {
589        let temp = tempfile::tempdir().expect("tempdir");
590        let config = resolve(temp.path(), |_| {});
591
592        let report = build_guard_report(&config, &["src/missing.ts".to_string()]).expect("report");
593
594        assert_eq!(report.files[0].path, "src/missing.ts");
595        assert!(!report.files[0].exists);
596    }
597
598    #[test]
599    fn path_outside_root_errors() {
600        let temp = tempfile::tempdir().expect("tempdir");
601        let config = resolve(temp.path(), |_| {});
602
603        let err = build_guard_report(&config, &["../outside.ts".to_string()]).unwrap_err();
604
605        assert!(matches!(err, GuardError::OutsideRoot(_)));
606    }
607}