Skip to main content

xtask/patterns/
checks.rs

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    // actions.rs/config.rs moved from crates/soma/contracts to
17    // crates/soma/domain / crates/soma/config (plan section 6.2; PR 13);
18    // app.rs/service.rs moved from crates/soma/service into
19    // crates/soma/application (PR 19). Both legacy crates are deleted;
20    // check the real canonical locations, not the removed facades.
21    "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        // The hook must call the installed PATH binary directly (no plugin-setup.sh wrapper).
263        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    // config.rs moved from crates/soma/contracts to crates/soma/config
292    // (plan section 3.18; PR 13). crates/soma/contracts was deleted in PR 19.
293    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    // Check that the scripts CI relies on for enforcement actually exist.
315    // Checking scripts rather than Justfile targets means this passes even when
316    // the Justfile is restructured, and fails when a script is accidentally deleted.
317    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}