1use anyhow::Result;
2use std::path::Path;
3
4use super::{
5 reporter::PatternReporter,
6 util::{is_test_file, read_file},
7};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10enum SurfaceKind {
11 CoreRust,
12 OperationalRust,
13 Web,
14}
15
16struct SurfaceFile {
17 path: String,
18 kind: SurfaceKind,
19}
20
21const CORE_RUST_FORBIDDEN: &[&str] = &[
22 "reqwest::",
23 "hyper::Client",
24 "sqlx::",
25 "rusqlite::",
26 "tokio::fs",
27 "std::fs",
28 "std::process::Command",
29 "Command::new",
30 "SomaClient::new",
31 "crate::soma::SomaClient",
32 "soma_client::SomaClient",
33];
34
35const WEB_FORBIDDEN: &[&str] = &[
36 "child_process",
37 "node:child_process",
38 "fs.",
39 "node:fs",
40 "process.env.SOMA_",
41 "fetch(\"https://",
42 "fetch('https://",
43];
44
45const BUSINESS_HELPER_TOKENS: &[&str] = &[
46 "fn normalize_",
47 "fn validate_",
48 "fn transform_",
49 "fn calculate_",
50 "fn enrich_",
51 "fn apply_policy",
52];
53
54const RUST_DELEGATION_TOKENS: &[&str] = &[
55 ".application()",
56 "state.service",
57 "state.provider_registry",
58 "service.",
59 "execute_service_action",
60 "streamable_http_service",
61];
62
63pub(super) fn thin_surfaces(reporter: &mut PatternReporter) -> Result<()> {
64 let files = surface_files()?;
65 let mut failures = Vec::new();
66 let mut warnings = Vec::new();
67
68 for file in &files {
69 let text = read_file(&file.path);
70 match file.kind {
71 SurfaceKind::CoreRust => {
72 let text = strip_inline_test_module(&text);
73 check_core_rust_surface(file, text, &mut failures, &mut warnings)
74 }
75 SurfaceKind::OperationalRust => check_operational_surface(file, &text, &mut warnings),
76 SurfaceKind::Web => check_web_surface(file, &text, &mut failures, &mut warnings),
77 }
78 }
79
80 if !failures.is_empty() {
81 reporter.fail(
82 "surfaces",
83 format!(
84 "business/IO logic appears in surface files: {}. Hint: CLI/API/MCP/web surfaces should parse inputs, delegate to SomaService or API endpoints, and format responses only.",
85 failures.join("; ")
86 ),
87 );
88 }
89 if !warnings.is_empty() {
90 reporter.warn(
91 "surfaces",
92 format!(
93 "suspicious surface logic found: {}. Hint: verify this is protocol/UI glue rather than business logic; move validation, normalization, and transformations into app.rs.",
94 warnings.join("; ")
95 ),
96 );
97 }
98 if failures.is_empty() && warnings.is_empty() {
99 reporter.ok(
100 "surfaces",
101 format!("{} CLI/API/MCP/web surface files look thin", files.len()),
102 );
103 }
104 Ok(())
105}
106
107fn check_core_rust_surface(
108 file: &SurfaceFile,
109 text: &str,
110 failures: &mut Vec<String>,
111 warnings: &mut Vec<String>,
112) {
113 for token in CORE_RUST_FORBIDDEN {
114 if !core_token_applies(file, token) {
115 continue;
116 }
117 if text.contains(token) {
118 failures.push(format!("{} contains `{token}`", file.path));
119 }
120 }
121
122 for token in BUSINESS_HELPER_TOKENS {
123 if text.contains(token) {
124 warnings.push(format!("{} contains helper `{token}`", file.path));
125 }
126 }
127
128 let is_dispatch_surface = matches!(
129 file.path.as_str(),
130 "crates/soma/api/src/api.rs"
131 | "crates/soma/mcp/src/tools.rs"
132 | "crates/soma/cli/src/lib.rs"
133 );
134 if is_dispatch_surface
135 && !RUST_DELEGATION_TOKENS
136 .iter()
137 .any(|token| text.contains(token))
138 {
139 warnings.push(format!(
140 "{} has no obvious service delegation token",
141 file.path
142 ));
143 }
144
145 let json_macro_count = text.matches("json!({").count();
146 if json_macro_count > 12 {
147 warnings.push(format!(
148 "{} has {json_macro_count} json! object literals",
149 file.path
150 ));
151 }
152}
153
154fn core_token_applies(file: &SurfaceFile, token: &str) -> bool {
155 if file.path == "crates/soma/cli/src/lib.rs"
156 && matches!(
157 token,
158 "std::fs" | "std::process::Command" | "Command::new" | "SomaClient::new"
159 )
160 {
161 return false;
162 }
163 true
164}
165
166fn check_operational_surface(file: &SurfaceFile, text: &str, warnings: &mut Vec<String>) {
167 let is_diagnostics = file.path.starts_with("crates/soma/cli/src/doctor/")
169 || file.path == "crates/soma/cli/src/watch.rs";
170 for token in ["reqwest::", "sqlx::", "rusqlite::"] {
171 if text.contains(token) && !(token == "reqwest::" && is_diagnostics) {
172 warnings.push(format!(
173 "{} operational surface contains `{token}`; confirm it is diagnostics-only",
174 file.path
175 ));
176 }
177 }
178}
179
180fn check_web_surface(
181 file: &SurfaceFile,
182 text: &str,
183 failures: &mut Vec<String>,
184 warnings: &mut Vec<String>,
185) {
186 for token in WEB_FORBIDDEN {
187 if text.contains(token) {
188 failures.push(format!("{} contains `{token}`", file.path));
189 }
190 }
191
192 let is_definition_file = file.path == "apps/web/lib/soma.ts";
195 if !is_definition_file {
196 let hardcoded_action_count = ["greet", "echo", "status", "scaffold_intent"]
197 .iter()
198 .filter(|action| text.contains(&format!("\"{action}\"")))
199 .count();
200 if hardcoded_action_count > 3 {
201 warnings.push(format!(
202 "{} hardcodes {hardcoded_action_count} action names",
203 file.path
204 ));
205 }
206 }
207}
208
209fn strip_inline_test_module(text: &str) -> &str {
210 let lines = text.lines().collect::<Vec<_>>();
211 for index in 0..lines.len().saturating_sub(1) {
212 if lines[index].trim() != "#[cfg(test)]" {
213 continue;
214 }
215 let next = lines[index + 1].trim_start();
216 if next.starts_with("mod ") || next.starts_with("pub mod ") {
217 let byte_index = lines[..index].iter().map(|line| line.len() + 1).sum();
218 return &text[..byte_index];
219 }
220 }
221 text
222}
223
224fn surface_files() -> Result<Vec<SurfaceFile>> {
225 let output = crate::run_cmd_output("git", &["ls-files"])?;
226 Ok(output.lines().filter_map(surface_file).collect::<Vec<_>>())
227}
228
229fn surface_file(path: &str) -> Option<SurfaceFile> {
230 let kind = if matches!(
231 path,
232 "crates/soma/api/src/api.rs"
233 | "apps/soma/src/http.rs"
234 | "crates/soma/mcp/src/tools.rs"
235 | "crates/soma/mcp/src/rmcp_server.rs"
236 | "crates/soma/mcp/src/schemas.rs"
237 | "crates/soma/mcp/src/prompts.rs"
238 | "crates/soma/cli/src/lib.rs"
239 ) {
240 SurfaceKind::CoreRust
241 } else if path.starts_with("crates/soma/cli/src/") && path.ends_with(".rs") {
242 SurfaceKind::OperationalRust
243 } else if is_web_surface(path) {
244 SurfaceKind::Web
245 } else {
246 return None;
247 };
248
249 Some(SurfaceFile {
250 path: path.to_owned(),
251 kind,
252 })
253}
254
255fn is_web_surface(path: &str) -> bool {
256 let p = Path::new(path);
257 if is_test_file(p) {
258 return false;
259 }
260 let is_tsx = p.extension().and_then(|ext| ext.to_str()) == Some("tsx");
261 let is_ts = p.extension().and_then(|ext| ext.to_str()) == Some("ts");
262 (path.starts_with("apps/web/app/") && is_tsx) || (path.starts_with("apps/web/lib/") && is_ts)
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn classifies_core_rust_surfaces() {
271 let file = surface_file("crates/soma/mcp/src/tools.rs").expect("tools should be a surface");
272 assert_eq!(file.kind, SurfaceKind::CoreRust);
273 }
274
275 #[test]
276 fn classifies_operational_cli_surfaces() {
277 let file =
278 surface_file("crates/soma/cli/src/doctor.rs").expect("doctor should be a surface");
279 assert_eq!(file.kind, SurfaceKind::OperationalRust);
280 }
281
282 #[test]
283 fn classifies_web_surfaces() {
284 let file = surface_file("apps/web/app/page.tsx").expect("web page should be a surface");
285 assert_eq!(file.kind, SurfaceKind::Web);
286 }
287
288 #[test]
289 fn ignores_web_test_files() {
290 assert!(surface_file("apps/web/lib/soma.test.ts").is_none());
291 }
292
293 #[test]
294 fn ignores_service_layer() {
295 assert!(surface_file("src/app.rs").is_none());
296 }
297}