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
19const 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 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 (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;