1use anyhow::Result;
2use std::{fs, path::Path};
3use walkdir::WalkDir;
4
5use super::{
6 reporter::PatternReporter,
7 util::{
8 contains_top_level_json_key, display_path, effective_loc, is_size_exempt, is_test_file,
9 read_file, size_limit,
10 },
11};
12
13const REQUIRED_PATTERN_FILES: &[&str] = &[
14 "crates/soma/client/src/client.rs",
15 "crates/soma/application/src/service.rs",
16 "crates/soma/domain/src/actions.rs",
22 "crates/soma/mcp/src/lib.rs",
23 "crates/soma/mcp/src/tools.rs",
24 "crates/soma/mcp/src/schemas.rs",
25 "crates/soma/mcp/src/rmcp_server.rs",
26 "apps/soma/src/http.rs",
27 "crates/soma/mcp/src/prompts.rs",
28 "crates/soma/config/src/config.rs",
29 "crates/soma/cli/src/lib.rs",
30 "apps/soma/src/bin/soma.rs",
31 "apps/soma/src/lib.rs",
32 "apps/soma/tests/tool_dispatch.rs",
33 "config.soma.toml",
34 "taplo.toml",
35 "lefthook.yml",
36 "install.sh",
37 "entrypoint.sh",
38 "server.json",
39];
40
41const FORBIDDEN_SHIM_TOKENS: &[&str] = &[
42 "reqwest::",
43 "hyper::Client",
44 "sqlx::",
45 "rusqlite::",
46 "tokio::fs",
47 "std::fs",
48 "std::process::Command",
49 "Command::new",
50];
51
52pub(super) fn required_files(reporter: &mut PatternReporter) {
53 let missing = REQUIRED_PATTERN_FILES
54 .iter()
55 .copied()
56 .filter(|path| !Path::new(path).is_file())
57 .collect::<Vec<_>>();
58
59 if missing.is_empty() {
60 reporter.ok(
61 "required-files",
62 format!("{} expected files present", REQUIRED_PATTERN_FILES.len()),
63 );
64 } else {
65 reporter.fail(
66 "required-files",
67 format!("missing pattern files: {}", missing.join(", ")),
68 );
69 }
70}
71
72pub(super) fn no_mod_rs(reporter: &mut PatternReporter) {
73 let mod_files = WalkDir::new(".")
74 .into_iter()
75 .filter_entry(|entry| {
76 let name = entry.file_name().to_string_lossy();
77 !matches!(name.as_ref(), ".git" | "target")
78 })
79 .filter_map(|entry| entry.ok())
80 .filter(|entry| entry.file_type().is_file() && entry.file_name() == "mod.rs")
81 .map(|entry| display_path(entry.path()))
82 .collect::<Vec<_>>();
83
84 if mod_files.is_empty() {
85 reporter.ok("modern-rust", "no mod.rs files found");
86 } else {
87 reporter.fail(
88 "modern-rust",
89 format!("mod.rs files are prohibited: {}", mod_files.join(", ")),
90 );
91 }
92}
93
94pub(super) fn file_sizes(reporter: &mut PatternReporter) -> Result<()> {
95 let output = crate::run_cmd_output("git", &["ls-files", "*.rs", "*.ts", "*.tsx"])?;
96 let mut warnings = Vec::new();
97 let mut failures = Vec::new();
98
99 for line in output.lines().filter(|line| !line.trim().is_empty()) {
100 let path = Path::new(line);
101 if !path.exists() {
102 continue;
103 }
104 if is_test_file(path) {
105 continue;
106 }
107 if is_size_exempt(path) {
108 continue;
109 }
110 let Some(limit) = size_limit(path) else {
111 continue;
112 };
113 let loc = effective_loc(path)?;
114 if loc > limit * 2 {
115 failures.push(format!(
116 "{}: {loc} effective lines (hard limit {})",
117 display_path(path),
118 limit * 2
119 ));
120 } else if loc > limit {
121 warnings.push(format!(
122 "{}: {loc} effective lines (target {limit})",
123 display_path(path)
124 ));
125 }
126 }
127
128 if !failures.is_empty() {
129 reporter.fail(
130 "file-size",
131 format!(
132 "module size hard-limit violation(s): {}",
133 failures.join("; ")
134 ),
135 );
136 }
137 if !warnings.is_empty() {
138 reporter.warn(
139 "file-size",
140 format!(
141 "above PATTERNS.md target; split opportunistically: {}. Hint: move unrelated UI, CLI, or handler concerns into focused modules.",
142 warnings.join("; ")
143 ),
144 );
145 }
146 if failures.is_empty() && warnings.is_empty() {
147 reporter.ok(
148 "file-size",
149 "source files are within PATTERNS.md size targets",
150 );
151 }
152 Ok(())
153}
154
155pub(super) fn thin_shims(reporter: &mut PatternReporter) {
156 let policies = [
157 (
158 "crates/soma/mcp/src/tools.rs",
159 &["state.application()", ".execute_action("][..],
160 FORBIDDEN_SHIM_TOKENS,
161 ),
162 (
163 "crates/soma/cli/src/lib.rs",
164 &["SomaApplication", ".execute_action("][..],
165 &["reqwest::", "hyper::Client", "sqlx::", "rusqlite::"][..],
166 ),
167 (
168 "crates/soma/api/src/api.rs",
169 &[".application()", ".execute_action("][..],
170 FORBIDDEN_SHIM_TOKENS,
171 ),
172 ];
173
174 for (path, required, forbidden) in policies {
175 let text = read_file(path);
176 let missing = required
177 .iter()
178 .copied()
179 .filter(|token| !text.contains(token))
180 .collect::<Vec<_>>();
181 let found_forbidden = forbidden
182 .iter()
183 .copied()
184 .filter(|token| text.contains(token))
185 .collect::<Vec<_>>();
186
187 if !missing.is_empty() {
188 reporter.warn(
189 "thin-shim",
190 format!(
191 "{path} does not contain expected delegation token(s): {}. Hint: shims should parse inputs and delegate to SomaApplication.",
192 missing.join(", ")
193 ),
194 );
195 }
196 if !found_forbidden.is_empty() {
197 reporter.fail(
198 "thin-shim",
199 format!(
200 "{path} contains forbidden implementation token(s): {}. Hint: move network, filesystem, and business logic into service/client layers.",
201 found_forbidden.join(", ")
202 ),
203 );
204 }
205 if missing.is_empty() && found_forbidden.is_empty() {
206 reporter.ok("thin-shim", format!("{path} looks like a delegation shim"));
207 }
208 }
209}
210
211pub(super) fn routes(reporter: &mut PatternReporter) {
212 let routes = read_file("apps/soma/src/http.rs");
213 let missing = ["\"/mcp\"", "\"/health\"", "\"/status\""]
214 .iter()
215 .copied()
216 .filter(|route| !routes.contains(route))
217 .collect::<Vec<_>>();
218
219 if missing.is_empty() {
220 reporter.ok("routes", "MCP, health, and status routes are wired");
221 } else {
222 reporter.fail(
223 "routes",
224 format!("missing expected HTTP route(s): {}", missing.join(", ")),
225 );
226 }
227}
228
229pub(super) fn plugins(reporter: &mut PatternReporter) {
230 let manifests = WalkDir::new("plugins")
231 .into_iter()
232 .filter_map(|entry| entry.ok())
233 .filter(|entry| entry.file_type().is_file() && entry.file_name() == "plugin.json")
234 .map(|entry| entry.into_path())
235 .collect::<Vec<_>>();
236
237 let failures = manifests
238 .iter()
239 .filter_map(|manifest| {
240 let text = fs::read_to_string(manifest).ok()?;
241 contains_top_level_json_key(&text, "version").then(|| {
242 format!(
243 "{} contains forbidden version field",
244 display_path(manifest)
245 )
246 })
247 })
248 .collect::<Vec<_>>();
249
250 if failures.is_empty() {
251 reporter.ok(
252 "plugins",
253 format!("{} plugin manifest(s) omit version", manifests.len()),
254 );
255 } else {
256 reporter.fail("plugins", failures.join("; "));
257 }
258
259 let hook_path = Path::new("plugins/soma/hooks/hooks.json");
260 if hook_path.exists() {
261 let hook = read_file("plugins/soma/hooks/hooks.json");
262 if hook.contains("plugin-setup.sh") {
264 reporter.fail(
265 "plugins",
266 "hooks.json must not reference the removed plugin-setup.sh wrapper",
267 );
268 } else if !hook.contains("soma setup plugin-hook") {
269 reporter.fail(
270 "plugins",
271 "hooks.json must call `soma setup plugin-hook` directly",
272 );
273 } else {
274 reporter.ok(
275 "plugins",
276 "plugin hooks call the binary's setup plugin-hook directly",
277 );
278 }
279 }
280}
281
282pub(super) fn config_and_auth(reporter: &mut PatternReporter) {
283 let gitignore = read_file(".gitignore");
284 if gitignore.contains(".env") {
285 reporter.ok("config", ".env is ignored");
286 } else {
287 reporter.fail("config", ".gitignore should ignore .env secrets");
288 }
289
290 let server = read_file("crates/soma/runtime/src/server.rs");
291 let config = read_file("crates/soma/config/src/config.rs");
294 if !server.contains("LoopbackDev") || !server.contains("Mounted") {
295 reporter.fail(
296 "auth",
297 "AuthPolicy should include LoopbackDev and Mounted states",
298 );
299 } else if !config.contains("no_auth") || !config.contains("allowed_hosts") {
300 reporter.warn(
301 "auth",
302 "config.rs may be missing no_auth/allowed_hosts policy wiring. Hint: keep bind/auth safety checks centralized in config/server setup.",
303 );
304 } else {
305 reporter.ok("auth", "auth policy states and config toggles are present");
306 }
307}
308
309pub(super) fn tooling(reporter: &mut PatternReporter) {
310 let lefthook = read_file("lefthook.yml");
311 let taplo = read_file("taplo.toml");
312 let mut missing = Vec::new();
313
314 for script in [
318 "scripts/check-schema-docs.py",
319 "scripts/check-openapi.py",
320 "scripts/check-scaffold-intent-contract.py",
321 "scripts/validate-plugin-layout.sh",
322 "scripts/test-soma-features.sh",
323 ] {
324 if !Path::new(script).is_file() {
325 missing.push(script.to_string());
326 }
327 }
328
329 if !lefthook.contains("taplo check") {
330 missing.push("lefthook.yml:taplo check".to_string());
331 }
332 if !taplo.contains("column_width") {
333 missing.push("taplo.toml:formatting".to_string());
334 }
335
336 if missing.is_empty() {
337 reporter.ok(
338 "tooling",
339 "CI enforcement scripts, lefthook, and taplo config are present",
340 );
341 } else {
342 reporter.fail(
343 "tooling",
344 format!(
345 "missing expected tooling component(s): {}",
346 missing.join(", ")
347 ),
348 );
349 }
350}