Skip to main content

loong_kernel/
architecture.rs

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}