1use anyhow::{bail, Context, Result};
5use std::path::{Path, PathBuf};
6use std::process::{Command, Stdio};
7
8use crate::{run_cargo, run_cmd_output};
9
10pub fn block_env_commits() -> Result<()> {
11 let staged = git_output(&["diff", "--cached", "--name-only"])?;
12 let blocked: Vec<&str> = staged
13 .lines()
14 .filter(|path| is_blocked_env_path(path))
15 .collect();
16
17 if blocked.is_empty() {
18 return Ok(());
19 }
20
21 eprintln!("block-env-commits: BLOCKED - .env file(s) staged for commit:");
22 for path in &blocked {
23 eprintln!(" {path}");
24 }
25 eprintln!();
26 eprintln!("Only .env.example is allowed to be committed.");
27 eprintln!("Remove the staged file(s) with: git restore --staged <file>");
28 eprintln!("Then add them to .gitignore if they aren't already.");
29 bail!(".env file(s) staged for commit")
30}
31
32pub fn check_coupled_files(args: &[String]) -> Result<()> {
33 if matches!(args.first().map(String::as_str), Some("--help" | "-h")) {
34 println!("Usage: cargo xtask check-coupled-files [BASE] [HEAD]");
35 return Ok(());
36 }
37 if args.len() > 2 {
38 bail!("Usage: cargo xtask check-coupled-files [BASE] [HEAD]");
39 }
40
41 let mut base = args.first().map(String::as_str).unwrap_or("origin/main");
42 let head = args.get(1).map(String::as_str).unwrap_or("HEAD");
43 if !git_ref_exists(base) {
44 base = "HEAD~1";
45 }
46
47 let changed = git_output(&["diff", "--name-only", base, head])?;
48 let changed: Vec<&str> = changed.lines().collect();
49 let mut issues = Vec::new();
50
51 if changed_path(&changed, "Justfile") && !changed_path(&changed, "lefthook.yml") {
52 issues.push("Justfile changed but lefthook.yml did not; confirm hook/recipe parity.");
53 }
54 if changed_path(&changed, "lefthook.yml") && !changed_path(&changed, "Justfile") {
55 issues.push(
56 "lefthook.yml changed but Justfile did not; confirm matching manual recipe exists.",
57 );
58 }
59 if changed_path(&changed, "scripts/*") && !changed_path(&changed, "scripts/README.md") {
60 issues.push("scripts changed but scripts/README.md did not; document new or changed script behavior.");
61 }
62 if changed_path(&changed, "crates/soma/mcp/src/schemas.rs")
63 && !changed_path(&changed, "docs/MCP_SCHEMA.md")
64 && crate::scripts_lane_d::check_schema_docs(&["--check".to_owned()]).is_err()
65 {
66 issues.push("crates/soma/mcp/src/schemas.rs changed but docs/MCP_SCHEMA.md did not; run scripts/check-schema-docs.py --write.");
67 }
68 if changed_path(&changed, "plugins/soma/*") && !changed_path(&changed, "docs/PLUGINS.md") {
69 issues.push("plugin package changed but docs/PLUGINS.md did not; confirm plugin docs are still current.");
70 }
71
72 if !issues.is_empty() {
73 eprintln!("Coupled-file check failed:");
74 for issue in &issues {
75 eprintln!(" - {issue}");
76 }
77 bail!("coupled-file check failed");
78 }
79
80 println!("Coupled-file check passed ({base}..{head}).");
81 Ok(())
82}
83
84pub fn check_file_size() -> Result<()> {
85 let max_rs = env_usize("MAX_RS", 350)?;
86 let max_ts = env_usize("MAX_TS", 300)?;
87 let staged = git_output(&["diff", "--cached", "--name-only", "--diff-filter=ACM"])?;
88 let mut violations = Vec::new();
89
90 for file in staged.lines() {
91 let path = Path::new(file);
92 if !path.is_file() || is_test_file(file) {
93 continue;
94 }
95
96 let Some(limit) = source_limit(file, max_rs, max_ts) else {
97 continue;
98 };
99 let text =
100 std::fs::read_to_string(path).with_context(|| format!("failed to read {file}"))?;
101 let lines = if file.ends_with(".rs") {
102 rust_production_lines(&text)
103 } else {
104 count_effective_loc(&text, None)
105 };
106
107 if lines > limit {
108 violations.push(format!(
109 " {file}: {lines} effective lines (limit: {limit})"
110 ));
111 }
112 }
113
114 if violations.is_empty() {
115 return Ok(());
116 }
117
118 eprintln!();
119 eprintln!("Monolithic staged file(s) detected; split them into focused modules:");
120 for violation in &violations {
121 eprintln!("{violation}");
122 }
123 eprintln!();
124 eprintln!("Limits: .rs={max_rs} production lines, .ts/.tsx={max_ts} lines; test files exempt.");
125 bail!("staged source file size budget exceeded")
126}
127
128pub fn sync_cargo() -> Result<()> {
129 let repo_root = env_path("CLAUDE_PLUGIN_ROOT").unwrap_or_else(current_dir);
130 let data_root = env_path("CLAUDE_PLUGIN_DATA").unwrap_or_else(|| repo_root.clone());
131 let src_lock = repo_root.join("Cargo.lock");
132 let dst_lock = data_root.join("Cargo.lock");
133
134 if !src_lock.is_file() {
135 bail!("sync-cargo.sh: missing lockfile at {}", src_lock.display());
136 }
137
138 if same_file_bytes(&src_lock, &dst_lock)? {
139 return Ok(());
140 }
141
142 std::fs::create_dir_all(&data_root)
143 .with_context(|| format!("failed to create {}", data_root.display()))?;
144
145 if let Err(copy_error) = std::fs::copy(&src_lock, &dst_lock) {
146 eprintln!(
147 "sync-cargo: failed to copy {} to {}: {copy_error}; falling back to cargo fetch",
148 src_lock.display(),
149 dst_lock.display()
150 );
151 if let Err(fetch_error) = run_cargo_fetch(&repo_root) {
152 let _ = std::fs::remove_file(&dst_lock);
153 return Err(fetch_error);
154 }
155 }
156
157 Ok(())
158}
159
160pub fn run_ascii_check(args: &[String]) -> Result<()> {
161 let fix = match args {
162 [] => false,
163 [arg] if arg == "--fix" => true,
164 [arg] if arg == "--help" || arg == "-h" => {
165 println!("Usage: cargo xtask run-ascii-check [--fix]");
166 return Ok(());
167 }
168 _ => bail!("Usage: cargo xtask run-ascii-check [--fix]"),
169 };
170
171 let output = git_output(&[
172 "ls-files",
173 "*.md",
174 "*.rs",
175 "*.toml",
176 "*.json",
177 "*.yml",
178 "*.yaml",
179 "*.sh",
180 "*.py",
181 ":!:docs/references/**",
182 ":!:docs/sessions/**",
183 ])?;
184 let files: Vec<String> = output
185 .lines()
186 .filter(|path| Path::new(path).is_file())
187 .map(str::to_owned)
188 .collect();
189
190 if files.is_empty() {
191 println!("No files to check");
192 return Ok(());
193 }
194
195 let mut ascii_args = Vec::new();
196 if fix {
197 ascii_args.push("--fix".to_owned());
198 }
199 ascii_args.extend(files);
200 crate::scripts_lane_d::asciicheck(&ascii_args)
201}
202
203pub fn check_plugin_stdio_smoke() -> Result<()> {
204 let bin = std::env::var("BIN").unwrap_or_else(|_| "soma".to_owned());
205 let timeout_secs = std::env::var("TIMEOUT_SECS").unwrap_or_else(|_| "5".to_owned());
206
207 if !command_on_path(&bin) {
208 bail!("plugin stdio smoke: {bin} is not on PATH\nrun: just install-local");
209 }
210
211 let input = [
212 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"plugin-stdio-smoke","version":"0.0.0"}}}"#,
213 r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#,
214 r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"soma","arguments":{"action":"status"}}}"#,
215 ]
216 .join("\n");
217
218 let mut child = Command::new("timeout")
219 .arg(format!("{timeout_secs}s"))
220 .arg(&bin)
221 .arg("mcp")
222 .env("SOMA_API_URL", "")
223 .env("RUST_LOG", "warn")
224 .stdin(Stdio::piped())
225 .stdout(Stdio::piped())
226 .stderr(Stdio::inherit())
227 .spawn()
228 .with_context(|| format!("failed to spawn timeout/{bin}"))?;
229
230 {
231 use std::io::Write;
232 let stdin = child.stdin.as_mut().context("failed to open child stdin")?;
233 stdin
234 .write_all(input.as_bytes())
235 .context("failed to write JSON-RPC smoke input")?;
236 stdin
237 .write_all(b"\n")
238 .context("failed to write trailing newline")?;
239 }
240
241 let output = child
242 .wait_with_output()
243 .context("failed to read plugin stdio smoke output")?;
244 if !output.status.success() {
245 bail!("plugin stdio smoke command exited with {}", output.status);
246 }
247 let stdout =
248 String::from_utf8(output.stdout).context("plugin stdio smoke emitted non-UTF-8 stdout")?;
249
250 for line in stdout.lines().filter(|line| !line.trim().is_empty()) {
251 let value: serde_json::Value = serde_json::from_str(line)
252 .with_context(|| format!("invalid JSON-RPC line from stdio smoke: {line}"))?;
253 if value.get("id").and_then(serde_json::Value::as_i64) != Some(2) {
254 continue;
255 }
256 if value
257 .pointer("/result/structuredContent/status")
258 .and_then(serde_json::Value::as_str)
259 == Some("ok")
260 {
261 println!("plugin stdio smoke passed");
262 return Ok(());
263 }
264 bail!("plugin stdio smoke response for id=2 did not report status=ok: {value}");
265 }
266
267 bail!("plugin stdio smoke did not receive a tools/call response with id=2")
268}
269
270fn current_dir() -> PathBuf {
271 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
272}
273
274fn env_path(name: &str) -> Option<PathBuf> {
275 std::env::var_os(name)
276 .filter(|value| !value.is_empty())
277 .map(PathBuf::from)
278}
279
280fn env_usize(name: &str, default: usize) -> Result<usize> {
281 match std::env::var(name) {
282 Ok(value) => value
283 .parse()
284 .with_context(|| format!("{name} must be a positive integer")),
285 Err(std::env::VarError::NotPresent) => Ok(default),
286 Err(error) => Err(error).with_context(|| format!("failed to read {name}")),
287 }
288}
289
290fn git_output(args: &[&str]) -> Result<String> {
291 run_cmd_output("git", args)
292}
293
294fn git_ref_exists(ref_name: &str) -> bool {
295 Command::new("git")
296 .args(["rev-parse", "--verify", ref_name])
297 .stdin(Stdio::null())
298 .stdout(Stdio::null())
299 .stderr(Stdio::null())
300 .status()
301 .map(|status| status.success())
302 .unwrap_or(false)
303}
304
305fn command_on_path(name: &str) -> bool {
306 if name.contains('/') {
307 return Path::new(name).is_file();
308 }
309 let Some(paths) = std::env::var_os("PATH") else {
310 return false;
311 };
312 std::env::split_paths(&paths).any(|dir| dir.join(name).is_file())
313}
314
315fn is_blocked_env_path(path: &str) -> bool {
316 if path.ends_with(".env.example") {
317 return false;
318 }
319 path.rsplit('/')
320 .next()
321 .is_some_and(|name| name.contains(".env"))
322}
323
324fn source_limit(path: &str, max_rs: usize, max_ts: usize) -> Option<usize> {
325 if path.ends_with(".rs") {
326 Some(max_rs)
327 } else if path.ends_with(".ts") || path.ends_with(".tsx") {
328 Some(max_ts)
329 } else {
330 None
331 }
332}
333
334fn is_test_file(path: &str) -> bool {
335 path.contains("/test/")
336 || path.contains("/tests/")
337 || path.ends_with("_test.rs")
338 || path.ends_with("/tests.rs")
339 || path.ends_with(".test.ts")
340 || path.ends_with(".test.tsx")
341 || path.ends_with(".spec.ts")
342 || path.ends_with(".spec.tsx")
343 || path.contains("/__tests__/")
344}
345
346fn rust_production_lines(text: &str) -> usize {
347 count_effective_loc(text, trailing_rust_test_module_start(text))
348}
349
350fn trailing_rust_test_module_start(text: &str) -> Option<usize> {
351 let mut cfg_line: Option<usize> = None;
352 for (index, raw_line) in text.lines().enumerate() {
353 let line_number = index + 1;
354 let line = raw_line.trim_start();
355 if line.contains("#[cfg(test)]") {
356 cfg_line = Some(line_number);
357 continue;
358 }
359 if let Some(start) = cfg_line {
360 if is_rust_mod_line(line) {
361 return Some(start);
362 }
363 }
364 cfg_line = None;
365 }
366 None
367}
368
369fn is_rust_mod_line(line: &str) -> bool {
370 let line = line.strip_prefix("pub ").unwrap_or(line);
371 let Some(rest) = line.strip_prefix("mod ") else {
372 return false;
373 };
374 let Some((name, tail)) = rest.split_once(' ') else {
375 return false;
376 };
377 !name.is_empty()
378 && name.chars().all(|ch| ch.is_ascii_lowercase() || ch == '_')
379 && tail.trim_start().starts_with('{')
380}
381
382fn count_effective_loc(text: &str, stop_before_line: Option<usize>) -> usize {
383 let mut count = 0usize;
384 let mut in_block = false;
385
386 for (index, raw_line) in text.lines().enumerate() {
387 let line_number = index + 1;
388 if stop_before_line.is_some_and(|stop| line_number >= stop) {
389 break;
390 }
391
392 let mut line = raw_line.trim_start();
393 if line.is_empty() {
394 continue;
395 }
396
397 if in_block {
398 if let Some(end) = line.find("*/") {
399 line = line[end + 2..].trim_start();
400 in_block = false;
401 if line.is_empty() {
402 continue;
403 }
404 } else {
405 continue;
406 }
407 }
408
409 if line.starts_with("//") {
410 continue;
411 }
412
413 if line.starts_with("/*") {
414 if let Some(end) = line.find("*/") {
415 line = line[end + 2..].trim_start();
416 if line.is_empty() {
417 continue;
418 }
419 } else {
420 in_block = true;
421 continue;
422 }
423 }
424
425 count += 1;
426 }
427
428 count
429}
430
431fn changed_path(paths: &[&str], pattern: &str) -> bool {
432 paths.iter().any(|path| glob_match(pattern, path))
433}
434
435fn glob_match(pattern: &str, path: &str) -> bool {
436 if let Some((prefix, suffix)) = pattern.split_once('*') {
437 path.starts_with(prefix) && path.ends_with(suffix)
438 } else {
439 path == pattern
440 }
441}
442
443fn same_file_bytes(left: &Path, right: &Path) -> Result<bool> {
444 if !right.exists() {
445 return Ok(false);
446 }
447 let left = std::fs::read(left).with_context(|| format!("failed to read {}", left.display()))?;
448 let right =
449 std::fs::read(right).with_context(|| format!("failed to read {}", right.display()))?;
450 Ok(left == right)
451}
452
453fn run_cargo_fetch(repo_root: &Path) -> Result<()> {
454 let manifest = repo_root.join("Cargo.toml");
455 let manifest = manifest
456 .to_str()
457 .with_context(|| format!("non-UTF-8 manifest path: {}", manifest.display()))?;
458 run_cargo(&["fetch", "--manifest-path", manifest])
459}
460
461#[cfg(test)]
462mod tests {
463 use super::{
464 changed_path, command_on_path, count_effective_loc, glob_match, is_blocked_env_path,
465 is_test_file, rust_production_lines, trailing_rust_test_module_start,
466 };
467
468 #[test]
469 fn blocks_env_files_except_examples() {
470 assert!(is_blocked_env_path(".env"));
471 assert!(is_blocked_env_path("config/.env.local"));
472 assert!(is_blocked_env_path("services/foo.env.prod"));
473 assert!(!is_blocked_env_path(".env.example"));
474 assert!(!is_blocked_env_path("docs/env.example.md"));
475 }
476
477 #[test]
478 fn matches_bash_style_single_star_patterns_used_by_coupled_check() {
479 assert!(glob_match("scripts/*", "scripts/check-coupled-files.sh"));
480 assert!(glob_match("plugins/soma/*", "plugins/soma/hooks/setup.sh"));
481 assert!(glob_match("Justfile", "Justfile"));
482 assert!(!glob_match("Justfile", "docs/Justfile"));
483 }
484
485 #[test]
486 fn changed_path_checks_any_changed_path() {
487 let paths = ["README.md", "scripts/check-coupled-files.sh"];
488 assert!(changed_path(&paths, "scripts/*"));
489 assert!(!changed_path(&paths, "lefthook.yml"));
490 }
491
492 #[test]
493 fn command_on_path_handles_absolute_missing_path() {
494 assert!(!command_on_path("/definitely/not/a/real/soma"));
495 }
496
497 #[test]
498 fn test_file_detection_matches_precommit_scope() {
499 assert!(is_test_file("crates/foo/tests/integration.rs"));
500 assert!(is_test_file("src/widget_test.rs"));
501 assert!(is_test_file("src/tests.rs"));
502 assert!(is_test_file("apps/web/button.test.tsx"));
503 assert!(is_test_file("apps/web/__tests__/button.ts"));
504 assert!(!is_test_file("src/app.rs"));
505 }
506
507 #[test]
508 fn effective_loc_ignores_comments_blanks_and_blocks() {
509 let text = r#"
510// comment
511
512/* block
513 comment */
514fn main() {}
515/* inline */ let x = 1;
516"#;
517 assert_eq!(count_effective_loc(text, None), 2);
518 }
519
520 #[test]
521 fn rust_production_lines_cut_before_trailing_test_module() {
522 let text = r#"
523pub fn production() {}
524
525#[cfg(test)]
526mod tests {
527 #[test]
528 fn works() {}
529}
530"#;
531 assert_eq!(trailing_rust_test_module_start(text), Some(4));
532 assert_eq!(rust_production_lines(text), 1);
533 }
534
535 #[test]
536 fn rust_production_lines_do_not_cut_for_cfg_test_function() {
537 let text = r#"
538pub fn production() {}
539
540#[cfg(test)]
541fn helper() {}
542"#;
543 assert_eq!(trailing_rust_test_module_start(text), None);
544 assert_eq!(rust_production_lines(text), 3);
545 }
546}