1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum ArchitecturePathDecision {
8 AllowedMutable,
9 DeniedImmutable,
10 DeniedUnknown,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ArchitecturePathReport {
15 pub path: String,
16 pub decision: ArchitecturePathDecision,
17 pub matched_prefix: Option<String>,
18 pub reason: String,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
22pub struct ArchitectureGuardReport {
23 pub total_paths: usize,
24 pub allowed_paths: Vec<String>,
25 pub denied_paths: Vec<String>,
26 pub unknown_paths: Vec<String>,
27 pub reports: Vec<ArchitecturePathReport>,
28}
29
30impl ArchitectureGuardReport {
31 #[must_use]
32 pub fn has_denials(&self) -> bool {
33 !self.denied_paths.is_empty()
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct ArchitectureBoundaryPolicy {
39 pub immutable_prefixes: BTreeSet<String>,
40 pub mutable_prefixes: BTreeSet<String>,
41}
42
43impl Default for ArchitectureBoundaryPolicy {
44 fn default() -> Self {
45 Self {
46 immutable_prefixes: BTreeSet::from([
47 "crates/kernel/src/contracts.rs".to_owned(),
48 "crates/kernel/src/errors.rs".to_owned(),
49 "crates/kernel/src/harness.rs".to_owned(),
50 "crates/kernel/src/kernel.rs".to_owned(),
51 "crates/kernel/src/policy.rs".to_owned(),
52 ]),
53 mutable_prefixes: BTreeSet::from([
54 "README.md".to_owned(),
55 "crates/daemon/src/".to_owned(),
56 "crates/kernel/src/audit.rs".to_owned(),
57 "crates/kernel/src/architecture.rs".to_owned(),
58 "crates/kernel/src/awareness.rs".to_owned(),
59 "crates/kernel/src/connector.rs".to_owned(),
60 "crates/kernel/src/integration.rs".to_owned(),
61 "crates/kernel/src/memory.rs".to_owned(),
62 "crates/kernel/src/plugin.rs".to_owned(),
63 "crates/kernel/src/plugin_ir.rs".to_owned(),
64 "crates/kernel/src/policy_ext.rs".to_owned(),
65 "crates/kernel/src/runtime.rs".to_owned(),
66 "crates/kernel/src/tests.rs".to_owned(),
67 "crates/kernel/src/tool.rs".to_owned(),
68 "docs/".to_owned(),
69 "examples/".to_owned(),
70 ]),
71 }
72 }
73}
74
75impl ArchitectureBoundaryPolicy {
76 #[must_use]
77 pub fn evaluate_paths<S: AsRef<str>>(&self, paths: &[S]) -> ArchitectureGuardReport {
78 let mut report = ArchitectureGuardReport::default();
79
80 let normalized_immutable: Vec<String> = self
81 .immutable_prefixes
82 .iter()
83 .map(|prefix| normalize(prefix))
84 .collect();
85 let normalized_mutable: Vec<String> = self
86 .mutable_prefixes
87 .iter()
88 .map(|prefix| normalize(prefix))
89 .collect();
90
91 for path in paths {
92 let normalized = normalize(path.as_ref());
93 report.total_paths = report.total_paths.saturating_add(1);
94
95 if let Some(prefix) = longest_prefix_match(&normalized, &normalized_immutable) {
96 report.denied_paths.push(normalized.clone());
97 report.reports.push(ArchitecturePathReport {
98 path: normalized,
99 decision: ArchitecturePathDecision::DeniedImmutable,
100 matched_prefix: Some(prefix.clone()),
101 reason: format!("path is protected by immutable core boundary: {prefix}"),
102 });
103 continue;
104 }
105
106 if let Some(prefix) = longest_prefix_match(&normalized, &normalized_mutable) {
107 report.allowed_paths.push(normalized.clone());
108 report.reports.push(ArchitecturePathReport {
109 path: normalized,
110 decision: ArchitecturePathDecision::AllowedMutable,
111 matched_prefix: Some(prefix.clone()),
112 reason: format!("path is inside mutable extension boundary: {prefix}"),
113 });
114 continue;
115 }
116
117 report.denied_paths.push(normalized.clone());
118 report.unknown_paths.push(normalized.clone());
119 report.reports.push(ArchitecturePathReport {
120 path: normalized,
121 decision: ArchitecturePathDecision::DeniedUnknown,
122 matched_prefix: None,
123 reason: "path is outside declared mutable boundaries".to_owned(),
124 });
125 }
126
127 report
128 }
129}
130
131fn longest_prefix_match<'a>(path: &str, prefixes: &'a [String]) -> Option<&'a String> {
132 prefixes
133 .iter()
134 .filter(|prefix| prefix_matches(path, prefix))
135 .max_by_key(|prefix| prefix.len())
136}
137
138fn prefix_matches(path: &str, prefix: &str) -> bool {
139 if prefix.is_empty() {
140 return false;
141 }
142 if path == prefix {
143 return true;
144 }
145
146 if let Some(trimmed) = prefix.strip_suffix('/') {
147 return path == trimmed || path.starts_with(prefix);
148 }
149
150 let with_slash = format!("{prefix}/");
151 path.starts_with(&with_slash)
152}
153
154fn normalize(path: &str) -> String {
155 let replaced = path.trim().replace('\\', "/");
156 let without_prefix = replaced
157 .strip_prefix("./")
158 .map_or(replaced.as_str(), |value| value);
159 without_prefix.trim_start_matches('/').to_owned()
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 #[test]
167 fn architecture_guard_denies_immutable_core_mutations() {
168 let policy = ArchitectureBoundaryPolicy::default();
169 let paths = [
170 "crates/kernel/src/kernel.rs",
171 "crates/kernel/src/contracts.rs",
172 "examples/spec/runtime-extension.json",
173 ];
174
175 let report = policy.evaluate_paths(&paths);
176 assert_eq!(report.total_paths, 3);
177 assert!(
178 report
179 .denied_paths
180 .contains(&"crates/kernel/src/kernel.rs".to_owned())
181 );
182 assert!(
183 report
184 .denied_paths
185 .contains(&"crates/kernel/src/contracts.rs".to_owned())
186 );
187 assert!(
188 report
189 .allowed_paths
190 .contains(&"examples/spec/runtime-extension.json".to_owned())
191 );
192 assert!(report.has_denials());
193 }
194
195 #[test]
196 fn architecture_guard_denies_unknown_paths_by_default() {
197 let policy = ArchitectureBoundaryPolicy::default();
198 let report = policy.evaluate_paths(&["scripts/internal/unsafe.sh"]);
199
200 assert_eq!(report.total_paths, 1);
201 assert!(
202 report
203 .denied_paths
204 .contains(&"scripts/internal/unsafe.sh".to_owned())
205 );
206 assert!(
207 report
208 .unknown_paths
209 .contains(&"scripts/internal/unsafe.sh".to_owned())
210 );
211 }
212
213 #[test]
214 fn architecture_guard_allows_extension_mutations() {
215 let policy = ArchitectureBoundaryPolicy::default();
216 let report = policy.evaluate_paths(&[
217 "./crates/daemon/src/main.rs",
218 "docs/layered-kernel-design.md",
219 "examples/spec/plugin-scan-hotplug.json",
220 ]);
221
222 assert_eq!(report.denied_paths.len(), 0);
223 assert_eq!(report.allowed_paths.len(), 3);
224 assert!(!report.has_denials());
225 }
226}