Skip to main content

xtask/
architecture.rs

1use anyhow::{Context, Result, bail};
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        if let (Some(from_product), Some(to_product)) = (from.product, to.product)
153            && from_product != to_product
154        {
155            failures.push(format!(
156                "{} product package {} ({}) depends on {} product package {} ({}); products may share neutral crates or stable remote contracts, not import one another's internals\n  edge: {}",
157                from_product.as_str(),
158                from.name,
159                from.rel_path,
160                to_product.as_str(),
161                to.name,
162                to.rel_path,
163                graph.edge_label(edge)
164            ));
165        }
166        failures.extend(check_layer_edge(graph, edge, from, to));
167    }
168    failures.extend(check_mixed_application_and_engine_edges(graph, exceptions));
169    failures
170}
171
172fn check_layer_edge(graph: &Graph, edge: &Edge, from: &Package, to: &Package) -> Vec<String> {
173    let mut failures = Vec::new();
174    if from.layer == Layer::ProductDomain
175        && !matches!(to.layer, Layer::Shared | Layer::ProductDomain)
176    {
177        failures.push(format!(
178            "product-domain packages must not depend outward to {}\n  edge: {}",
179            to.layer.as_str(),
180            graph.edge_label(edge)
181        ));
182    }
183
184    if from.layer == Layer::ProductApplication
185        && (matches!(
186            to.layer,
187            Layer::App
188                | Layer::Legacy
189                | Layer::ProductIntegration
190                | Layer::ProductRuntime
191                | Layer::ProductSurface
192        ) || is_concrete_shared_engine(to))
193    {
194        failures.push(format!(
195            "product-application packages must not depend on app/legacy/integration/runtime/surface/concrete engines without a live exception\n  edge: {}",
196            graph.edge_label(edge)
197        ));
198    }
199
200    if from.layer == Layer::ProductSurface && to.layer == Layer::ProductSurface {
201        failures.push(format!(
202            "product-surface packages must not depend on one another\n  edge: {}",
203            graph.edge_label(edge)
204        ));
205    }
206
207    // Plan section 3.20's target dependency shape for soma-integrations lists
208    // application ports and concrete shared engines only (auth,
209    // observability, client, provider-adapters, gateway, codemode, openapi)
210    // — never the runtime or surface layers built on top of it. A
211    // PR 18 review fix once routed protected-route HTTP middleware through
212    // soma-integrations because it needed both soma-runtime's `AppState` and
213    // soma-mcp's `McpState`, silently inverting this rule; that middleware
214    // now lives in soma-runtime instead (PR 19 review fix). Keep the rule
215    // enforced so a future change can't reintroduce the same inversion.
216    if from.layer == Layer::ProductIntegration
217        && matches!(to.layer, Layer::ProductRuntime | Layer::ProductSurface)
218    {
219        failures.push(format!(
220            "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: {}",
221            graph.edge_label(edge)
222        ));
223    }
224    failures
225}
226
227fn check_mixed_application_and_engine_edges(
228    graph: &Graph,
229    exceptions: &[ArchitectureException],
230) -> Vec<String> {
231    graph
232        .packages
233        .values()
234        .filter_map(|package| {
235            let deps = graph.direct_dependencies_except(&package.id, exceptions);
236            let has_application_port = deps.iter().any(|package| is_application_port(package));
237            let has_concrete_engine = deps.iter().any(|package| is_concrete_shared_engine(package));
238            // `ProductRuntime` (crates/soma/runtime) is the third legitimate
239            // bridge layer alongside `App` and `ProductIntegration`: it is
240            // where the initialized `SomaApplication` handle and concrete
241            // engine state (e.g. `GatewayProductState`, a thin wrapper over
242            // `soma-gateway`'s `GatewayManager`) are bundled into the runtime
243            // handle every surface's `AppState` is built from (plan section
244            // 15, "initialized tasks/readiness"). This is the permanent
245            // architecture, not a migration artifact.
246            (has_application_port
247                && has_concrete_engine
248                && !matches!(
249                    package.layer,
250                    Layer::App | Layer::ProductIntegration | Layer::ProductRuntime
251                ))
252            .then(|| {
253                format!(
254                    "{} ({}) depends on both product application ports and concrete shared engines; move that bridge to apps/<product>, crates/<product>/integrations, or crates/<product>/runtime",
255                    package.name, package.rel_path
256                )
257            })
258        })
259        .collect()
260}
261
262fn check_exception_integrity(graph: &Graph, exceptions: &[ArchitectureException]) -> Vec<String> {
263    exceptions
264        .iter()
265        .filter_map(|exception| exception_integrity_failure(graph, exception))
266        .collect()
267}
268
269fn exception_integrity_failure(graph: &Graph, exception: &ArchitectureException) -> Option<String> {
270    if exception.owner.is_empty()
271        || exception.reason.is_empty()
272        || exception.removal_pr.is_empty()
273        || exception.expiration_milestone.is_empty()
274    {
275        return Some(format!(
276            "architecture exception {} -> {} is missing owner, reason, removal PR, or expiration milestone",
277            exception.from_path, exception.to_path
278        ));
279    }
280
281    let matches = graph
282        .edges
283        .iter()
284        .filter(|edge| exception.matches(graph, edge))
285        .count();
286    match matches {
287        1 => None,
288        0 => Some(format!(
289            "architecture exception {} -> {} does not match a current normal workspace edge; remove stale exceptions or add the edge with its owning PR",
290            exception.from_path, exception.to_path
291        )),
292        _ => Some(format!(
293            "architecture exception {} -> {} matches {matches} edges; exceptions must identify one current edge",
294            exception.from_path, exception.to_path
295        )),
296    }
297}
298
299fn check_internal_cycles(graph: &Graph) -> Vec<String> {
300    find_cycle(graph)
301        .map(|cycle| {
302            vec![format!(
303                "internal dependency cycle detected: {}",
304                cycle.join(" -> ")
305            )]
306        })
307        .unwrap_or_default()
308}
309
310fn find_cycle(graph: &Graph) -> Option<Vec<String>> {
311    let mut states = BTreeMap::new();
312    let mut stack = Vec::new();
313    for id in graph.packages.keys() {
314        if states.get(id).copied().unwrap_or(0) == 0
315            && let Some(cycle) = visit_cycle(graph, id, &mut states, &mut stack)
316        {
317            return Some(cycle);
318        }
319    }
320    None
321}
322
323fn visit_cycle(
324    graph: &Graph,
325    id: &str,
326    states: &mut BTreeMap<String, u8>,
327    stack: &mut Vec<String>,
328) -> Option<Vec<String>> {
329    states.insert(id.to_owned(), 1);
330    stack.push(id.to_owned());
331    for edge_index in graph.edges_by_from.get(id).into_iter().flatten() {
332        let edge = &graph.edges[*edge_index];
333        if edge.from == edge.to {
334            continue;
335        }
336        match states.get(&edge.to).copied().unwrap_or(0) {
337            0 => {
338                if let Some(cycle) = visit_cycle(graph, &edge.to, states, stack) {
339                    return Some(cycle);
340                }
341            }
342            1 => return Some(cycle_names(graph, stack, &edge.to)),
343            _ => {}
344        }
345    }
346    stack.pop();
347    states.insert(id.to_owned(), 2);
348    None
349}
350
351fn cycle_names(graph: &Graph, stack: &[String], repeated: &str) -> Vec<String> {
352    let start = stack.iter().position(|node| node == repeated).unwrap_or(0);
353    let mut cycle: Vec<String> = stack[start..]
354        .iter()
355        .map(|node| graph.package(node).name.clone())
356        .collect();
357    cycle.push(graph.package(repeated).name.clone());
358    cycle
359}
360
361fn is_exception(graph: &Graph, edge: &Edge, exceptions: &[ArchitectureException]) -> bool {
362    exceptions
363        .iter()
364        .any(|exception| exception.matches(graph, edge))
365}
366
367impl ArchitectureException {
368    pub(crate) fn matches(&self, graph: &Graph, edge: &Edge) -> bool {
369        let from = &graph.package(&edge.from).rel_path;
370        let to = &graph.package(&edge.to).rel_path;
371        self.from_path == from && self.to_path == to && edge.kind == "normal"
372    }
373}
374
375fn is_application_port(package: &Package) -> bool {
376    package.layer == Layer::ProductApplication
377        || APPLICATION_PORT_PATHS.contains(&package.rel_path.as_str())
378}
379
380fn is_concrete_shared_engine(package: &Package) -> bool {
381    CONCRETE_SHARED_ENGINE_PATHS.contains(&package.rel_path.as_str())
382}
383
384#[cfg(test)]
385#[path = "architecture_tests.rs"]
386mod tests;