1use 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#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum GuardError {
15 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
29pub 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}