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