Skip to main content

xtask/
architecture.rs

1use anyhow::{bail, Context, Result};
2use serde_json::Value;
3use std::collections::BTreeMap;
4use std::path::Path;
5use std::process::Command;
6
7pub(crate) use crate::architecture_graph::{Edge, Graph, Layer, Package};
8
9const APPLICATION_PORT_PATHS: &[&str] = &["crates/soma/application"];
10const CONCRETE_SHARED_ENGINE_PATHS: &[&str] = &[
11    "crates/shared/codemode",
12    "crates/shared/mcp/gateway",
13    "crates/shared/openapi",
14];
15const MCP_SURFACE_PATH: &str = "crates/soma/mcp";
16const MCP_FORBIDDEN_DIRECT_DEPENDENCIES: &[&str] =
17    &["crates/soma/runtime", "crates/shared/mcp/gateway"];
18
19// PR 19 deleted `crates/soma/service` and `crates/soma/contracts` (plan
20// section "PR 19: Delete legacy facades"): the legacy strangler-pattern
21// dependency and deprecated compatibility facade both no longer exist, so
22// there is nothing left to name here. `TEMPORARY_EXCEPTIONS` is empty —
23// `cargo xtask check-architecture` runs with zero exceptions (plan section
24// 13, "Legacy removal: architecture checker has no temporary exceptions").
25const TEMPORARY_EXCEPTIONS: &[ArchitectureException] = &[];
26
27#[derive(Debug)]
28pub(crate) struct ArchitectureException {
29    from_path: &'static str,
30    to_path: &'static str,
31    owner: &'static str,
32    reason: &'static str,
33    removal_pr: &'static str,
34    expiration_milestone: &'static str,
35}
36
37pub fn check(root: &Path) -> Result<()> {
38    let root = root
39        .canonicalize()
40        .with_context(|| format!("failed to canonicalize {}", root.display()))?;
41    let metadata = cargo_metadata(&root)?;
42    let graph = Graph::from_metadata(&root, &metadata)?;
43    let failures = check_graph(&graph, TEMPORARY_EXCEPTIONS);
44
45    if failures.is_empty() {
46        println!(
47            "Architecture check passed ({} workspace packages, {} internal edges).",
48            graph.packages.len(),
49            graph.edges.len()
50        );
51        return Ok(());
52    }
53
54    eprintln!("Architecture check failed:");
55    for failure in &failures {
56        eprintln!("\n{failure}");
57    }
58    bail!("architecture boundary check failed")
59}
60
61fn cargo_metadata(root: impl AsRef<Path>) -> Result<Value> {
62    let output = Command::new("cargo")
63        .args([
64            "metadata",
65            "--locked",
66            "--all-features",
67            "--no-deps",
68            "--format-version",
69            "1",
70        ])
71        .current_dir(root.as_ref())
72        .output()
73        .context("failed to run cargo metadata")?;
74    if !output.status.success() {
75        bail!(
76            "cargo metadata failed\nstdout:\n{}\nstderr:\n{}",
77            String::from_utf8_lossy(&output.stdout),
78            String::from_utf8_lossy(&output.stderr)
79        );
80    }
81    serde_json::from_slice(&output.stdout).context("cargo metadata emitted invalid JSON")
82}
83
84fn check_graph(graph: &Graph, exceptions: &[ArchitectureException]) -> Vec<String> {
85    let mut failures = Vec::new();
86    failures.extend(check_metadata_layers(graph));
87    failures.extend(check_exception_integrity(graph, exceptions));
88    failures.extend(check_direct_edges(graph, exceptions));
89    failures.extend(check_internal_cycles(graph));
90    failures
91}
92
93fn check_metadata_layers(graph: &Graph) -> Vec<String> {
94    graph
95        .packages
96        .values()
97        .filter_map(|package| match package.metadata_layer {
98            None => Some(format!(
99                "{} ({}) is missing [package.metadata.soma-architecture] layer = {:?}",
100                package.name,
101                package.rel_path,
102                package.layer.as_str()
103            )),
104            Some(layer) if layer != package.layer => Some(format!(
105                "{} ({}) declares architecture layer {:?}, but its path requires {:?}",
106                package.name,
107                package.rel_path,
108                layer.as_str(),
109                package.layer.as_str()
110            )),
111            _ => None,
112        })
113        .collect()
114}
115
116fn check_direct_edges(graph: &Graph, exceptions: &[ArchitectureException]) -> Vec<String> {
117    let mut failures = Vec::new();
118    for edge in graph.edges.iter().filter(|edge| edge.from != edge.to) {
119        if is_exception(graph, edge, exceptions) {
120            continue;
121        }
122        let from = graph.package(&edge.from);
123        let to = graph.package(&edge.to);
124        if from.rel_path == MCP_SURFACE_PATH
125            && MCP_FORBIDDEN_DIRECT_DEPENDENCIES.contains(&to.rel_path.as_str())
126        {
127            failures.push(format!(
128                "soma-mcp must depend on SomaApplication ports, not service/runtime/gateway engines\n  edge: {}",
129                graph.edge_label(edge)
130            ));
131        }
132        if from.layer == Layer::Shared && to.layer != Layer::Shared {
133            failures.push(format!(
134                "shared package {} ({}) depends on non-shared package {} ({})\n  edge: {}",
135                from.name,
136                from.rel_path,
137                to.name,
138                to.rel_path,
139                graph.edge_label(edge)
140            ));
141        }
142        if from.layer == Layer::Vendor && to.layer != Layer::Vendor {
143            failures.push(format!(
144                "vendor package {} ({}) depends on non-vendor package {} ({}); vendor crates wrap a third-party API and must stay standalone for reuse outside soma\n  edge: {}",
145                from.name,
146                from.rel_path,
147                to.name,
148                to.rel_path,
149                graph.edge_label(edge)
150            ));
151        }
152        failures.extend(check_layer_edge(graph, edge, from, to));
153    }
154    failures.extend(check_mixed_application_and_engine_edges(graph, exceptions));
155    failures
156}
157
158fn check_layer_edge(graph: &Graph, edge: &Edge, from: &Package, to: &Package) -> Vec<String> {
159    let mut failures = Vec::new();
160    if from.layer == Layer::ProductDomain
161        && !matches!(to.layer, Layer::Shared | Layer::ProductDomain)
162    {
163        failures.push(format!(
164            "product-domain packages must not depend outward to {}\n  edge: {}",
165            to.layer.as_str(),
166            graph.edge_label(edge)
167        ));
168    }
169
170    if from.layer == Layer::ProductApplication
171        && (matches!(
172            to.layer,
173            Layer::App
174                | Layer::Legacy
175                | Layer::ProductIntegration
176                | Layer::ProductRuntime
177                | Layer::ProductSurface
178        ) || is_concrete_shared_engine(to))
179    {
180        failures.push(format!(
181            "product-application packages must not depend on app/legacy/integration/runtime/surface/concrete engines without a live exception\n  edge: {}",
182            graph.edge_label(edge)
183        ));
184    }
185
186    if from.layer == Layer::ProductSurface && to.layer == Layer::ProductSurface {
187        failures.push(format!(
188            "product-surface packages must not depend on one another\n  edge: {}",
189            graph.edge_label(edge)
190        ));
191    }
192
193    // Plan section 3.20's target dependency shape for soma-integrations lists
194    // application ports and concrete shared engines only (auth,
195    // observability, client, provider-adapters, gateway, codemode, openapi)
196    // — never the runtime or surface layers built on top of it. A
197    // PR 18 review fix once routed protected-route HTTP middleware through
198    // soma-integrations because it needed both soma-runtime's `AppState` and
199    // soma-mcp's `McpState`, silently inverting this rule; that middleware
200    // now lives in soma-runtime instead (PR 19 review fix). Keep the rule
201    // enforced so a future change can't reintroduce the same inversion.
202    if from.layer == Layer::ProductIntegration
203        && matches!(to.layer, Layer::ProductRuntime | Layer::ProductSurface)
204    {
205        failures.push(format!(
206            "product-integration packages must not depend on product-runtime or product-surface crates (plan section 3.20's target dependency shape excludes soma-runtime/soma-mcp)\n  edge: {}",
207            graph.edge_label(edge)
208        ));
209    }
210    failures
211}
212
213fn check_mixed_application_and_engine_edges(
214    graph: &Graph,
215    exceptions: &[ArchitectureException],
216) -> Vec<String> {
217    graph
218        .packages
219        .values()
220        .filter_map(|package| {
221            let deps = graph.direct_dependencies_except(&package.id, exceptions);
222            let has_application_port = deps.iter().any(|package| is_application_port(package));
223            let has_concrete_engine = deps.iter().any(|package| is_concrete_shared_engine(package));
224            // `ProductRuntime` (crates/soma/runtime) is the third legitimate
225            // bridge layer alongside `App` and `ProductIntegration`: it is
226            // where the initialized `SomaApplication` handle and concrete
227            // engine state (e.g. `GatewayProductState`, a thin wrapper over
228            // `soma-gateway`'s `GatewayManager`) are bundled into the runtime
229            // handle every surface's `AppState` is built from (plan section
230            // 15, "initialized tasks/readiness"). This is the permanent
231            // architecture, not a migration artifact.
232            (has_application_port
233                && has_concrete_engine
234                && !matches!(
235                    package.layer,
236                    Layer::App | Layer::ProductIntegration | Layer::ProductRuntime
237                ))
238            .then(|| {
239                format!(
240                    "{} ({}) depends on both product application ports and concrete shared engines; move that bridge to apps/soma, crates/soma/integrations, or crates/soma/runtime",
241                    package.name, package.rel_path
242                )
243            })
244        })
245        .collect()
246}
247
248fn check_exception_integrity(graph: &Graph, exceptions: &[ArchitectureException]) -> Vec<String> {
249    exceptions
250        .iter()
251        .filter_map(|exception| exception_integrity_failure(graph, exception))
252        .collect()
253}
254
255fn exception_integrity_failure(graph: &Graph, exception: &ArchitectureException) -> Option<String> {
256    if exception.owner.is_empty()
257        || exception.reason.is_empty()
258        || exception.removal_pr.is_empty()
259        || exception.expiration_milestone.is_empty()
260    {
261        return Some(format!(
262            "architecture exception {} -> {} is missing owner, reason, removal PR, or expiration milestone",
263            exception.from_path, exception.to_path
264        ));
265    }
266
267    let matches = graph
268        .edges
269        .iter()
270        .filter(|edge| exception.matches(graph, edge))
271        .count();
272    match matches {
273        1 => None,
274        0 => Some(format!(
275            "architecture exception {} -> {} does not match a current normal workspace edge; remove stale exceptions or add the edge with its owning PR",
276            exception.from_path, exception.to_path
277        )),
278        _ => Some(format!(
279            "architecture exception {} -> {} matches {matches} edges; exceptions must identify one current edge",
280            exception.from_path, exception.to_path
281        )),
282    }
283}
284
285fn check_internal_cycles(graph: &Graph) -> Vec<String> {
286    find_cycle(graph)
287        .map(|cycle| {
288            vec![format!(
289                "internal dependency cycle detected: {}",
290                cycle.join(" -> ")
291            )]
292        })
293        .unwrap_or_default()
294}
295
296fn find_cycle(graph: &Graph) -> Option<Vec<String>> {
297    let mut states = BTreeMap::new();
298    let mut stack = Vec::new();
299    for id in graph.packages.keys() {
300        if states.get(id).copied().unwrap_or(0) == 0 {
301            if let Some(cycle) = visit_cycle(graph, id, &mut states, &mut stack) {
302                return Some(cycle);
303            }
304        }
305    }
306    None
307}
308
309fn visit_cycle(
310    graph: &Graph,
311    id: &str,
312    states: &mut BTreeMap<String, u8>,
313    stack: &mut Vec<String>,
314) -> Option<Vec<String>> {
315    states.insert(id.to_owned(), 1);
316    stack.push(id.to_owned());
317    for edge_index in graph.edges_by_from.get(id).into_iter().flatten() {
318        let edge = &graph.edges[*edge_index];
319        if edge.from == edge.to {
320            continue;
321        }
322        match states.get(&edge.to).copied().unwrap_or(0) {
323            0 => {
324                if let Some(cycle) = visit_cycle(graph, &edge.to, states, stack) {
325                    return Some(cycle);
326                }
327            }
328            1 => return Some(cycle_names(graph, stack, &edge.to)),
329            _ => {}
330        }
331    }
332    stack.pop();
333    states.insert(id.to_owned(), 2);
334    None
335}
336
337fn cycle_names(graph: &Graph, stack: &[String], repeated: &str) -> Vec<String> {
338    let start = stack.iter().position(|node| node == repeated).unwrap_or(0);
339    let mut cycle: Vec<String> = stack[start..]
340        .iter()
341        .map(|node| graph.package(node).name.clone())
342        .collect();
343    cycle.push(graph.package(repeated).name.clone());
344    cycle
345}
346
347fn is_exception(graph: &Graph, edge: &Edge, exceptions: &[ArchitectureException]) -> bool {
348    exceptions
349        .iter()
350        .any(|exception| exception.matches(graph, edge))
351}
352
353impl ArchitectureException {
354    pub(crate) fn matches(&self, graph: &Graph, edge: &Edge) -> bool {
355        let from = &graph.package(&edge.from).rel_path;
356        let to = &graph.package(&edge.to).rel_path;
357        self.from_path == from && self.to_path == to && edge.kind == "normal"
358    }
359}
360
361fn is_application_port(package: &Package) -> bool {
362    package.layer == Layer::ProductApplication
363        || APPLICATION_PORT_PATHS.contains(&package.rel_path.as_str())
364}
365
366fn is_concrete_shared_engine(package: &Package) -> bool {
367    CONCRETE_SHARED_ENGINE_PATHS.contains(&package.rel_path.as_str())
368}
369
370#[cfg(test)]
371#[path = "architecture_tests.rs"]
372mod tests;