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