1use anyhow::{Context, Result, bail};
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 ":!:crates/synapse/import/**",
186 ])?;
187 let files: Vec<String> = output
188 .lines()
189 .filter(|path| Path::new(path).is_file())
190 .map(str::to_owned)
191 .collect();
192
193 if files.is_empty() {
194 println!("No files to check");
195 return Ok(());
196 }
197
198 let mut ascii_args = Vec::new();
199 if fix {
200 ascii_args.push("--fix".to_owned());
201 }
202 ascii_args.extend(files);
203 crate::scripts_lane_d::asciicheck(&ascii_args)
204}
205
206pub fn check_plugin_stdio_smoke() -> Result<()> {
207 let bin = std::env::var("BIN").unwrap_or_else(|_| "soma".to_owned());
208 let timeout_secs = std::env::var("TIMEOUT_SECS").unwrap_or_else(|_| "5".to_owned());
209
210 if !command_on_path(&bin) {
211 bail!("plugin stdio smoke: {bin} is not on PATH\nrun: just install-local");
212 }
213
214 let input = [
215 r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"plugin-stdio-smoke","version":"0.0.0"}}}"#,
216 r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#,
217 r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"soma","arguments":{"action":"status"}}}"#,
218 ]
219 .join("\n");
220
221 let mut child = Command::new("timeout")
222 .arg(format!("{timeout_secs}s"))
223 .arg(&bin)
224 .arg("mcp")
225 .env("SOMA_API_URL", "")
226 .env("RUST_LOG", "warn")
227 .stdin(Stdio::piped())
228 .stdout(Stdio::piped())
229 .stderr(Stdio::inherit())
230 .spawn()
231 .with_context(|| format!("failed to spawn timeout/{bin}"))?;
232
233 {
234 use std::io::Write;
235 let stdin = child.stdin.as_mut().context("failed to open child stdin")?;
236 stdin
237 .write_all(input.as_bytes())
238 .context("failed to write JSON-RPC smoke input")?;
239 stdin
240 .write_all(b"\n")
241 .context("failed to write trailing newline")?;
242 }
243
244 let output = child
245 .wait_with_output()
246 .context("failed to read plugin stdio smoke output")?;
247 if !output.status.success() {
248 bail!("plugin stdio smoke command exited with {}", output.status);
249 }
250 let stdout =
251 String::from_utf8(output.stdout).context("plugin stdio smoke emitted non-UTF-8 stdout")?;
252
253 for line in stdout.lines().filter(|line| !line.trim().is_empty()) {
254 let value: serde_json::Value = serde_json::from_str(line)
255 .with_context(|| format!("invalid JSON-RPC line from stdio smoke: {line}"))?;
256 if value.get("id").and_then(serde_json::Value::as_i64) != Some(2) {
257 continue;
258 }
259 if value
260 .pointer("/result/structuredContent/status")
261 .and_then(serde_json::Value::as_str)
262 == Some("ok")
263 {
264 println!("plugin stdio smoke passed");
265 return Ok(());
266 }
267 bail!("plugin stdio smoke response for id=2 did not report status=ok: {value}");
268 }
269
270 bail!("plugin stdio smoke did not receive a tools/call response with id=2")
271}
272
273fn current_dir() -> PathBuf {
274 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
275}
276
277fn env_path(name: &str) -> Option<PathBuf> {
278 std::env::var_os(name)
279 .filter(|value| !value.is_empty())
280 .map(PathBuf::from)
281}
282
283fn env_usize(name: &str, default: usize) -> Result<usize> {
284 match std::env::var(name) {
285 Ok(value) => value
286 .parse()
287 .with_context(|| format!("{name} must be a positive integer")),
288 Err(std::env::VarError::NotPresent) => Ok(default),
289 Err(error) => Err(error).with_context(|| format!("failed to read {name}")),
290 }
291}
292
293fn git_output(args: &[&str]) -> Result<String> {
294 run_cmd_output("git", args)
295}
296
297fn git_ref_exists(ref_name: &str) -> bool {
298 Command::new("git")
299 .args(["rev-parse", "--verify", ref_name])
300 .stdin(Stdio::null())
301 .stdout(Stdio::null())
302 .stderr(Stdio::null())
303 .status()
304 .map(|status| status.success())
305 .unwrap_or(false)
306}
307
308fn command_on_path(name: &str) -> bool {
309 if name.contains('/') {
310 return Path::new(name).is_file();
311 }
312 let Some(paths) = std::env::var_os("PATH") else {
313 return false;
314 };
315 std::env::split_paths(&paths).any(|dir| dir.join(name).is_file())
316}
317
318fn is_blocked_env_path(path: &str) -> bool {
319 if path.ends_with(".env.example") {
320 return false;
321 }
322 path.rsplit('/')
323 .next()
324 .is_some_and(|name| name.contains(".env"))
325}
326
327fn source_limit(path: &str, max_rs: usize, max_ts: usize) -> Option<usize> {
328 if path.ends_with(".rs") {
329 Some(max_rs)
330 } else if path.ends_with(".ts") || path.ends_with(".tsx") {
331 Some(max_ts)
332 } else {
333 None
334 }
335}
336
337fn is_test_file(path: &str) -> bool {
338 path.contains("/test/")
339 || path.contains("/tests/")
340 || path.ends_with("_test.rs")
341 || path.ends_with("/tests.rs")
342 || path.ends_with(".test.ts")
343 || path.ends_with(".test.tsx")
344 || path.ends_with(".spec.ts")
345 || path.ends_with(".spec.tsx")
346 || path.contains("/__tests__/")
347}
348
349fn rust_production_lines(text: &str) -> usize {
350 count_effective_loc(text, trailing_rust_test_module_start(text))
351}
352
353fn trailing_rust_test_module_start(text: &str) -> Option<usize> {
354 let mut cfg_line: Option<usize> = None;
355 for (index, raw_line) in text.lines().enumerate() {
356 let line_number = index + 1;
357 let line = raw_line.trim_start();
358 if line.contains("#[cfg(test)]") {
359 cfg_line = Some(line_number);
360 continue;
361 }
362 if let Some(start) = cfg_line
363 && is_rust_mod_line(line)
364 {
365 return Some(start);
366 }
367 cfg_line = None;
368 }
369 None
370}
371
372fn is_rust_mod_line(line: &str) -> bool {
373 let line = line.strip_prefix("pub ").unwrap_or(line);
374 let Some(rest) = line.strip_prefix("mod ") else {
375 return false;
376 };
377 let Some((name, tail)) = rest.split_once(' ') else {
378 return false;
379 };
380 !name.is_empty()
381 && name.chars().all(|ch| ch.is_ascii_lowercase() || ch == '_')
382 && tail.trim_start().starts_with('{')
383}
384
385fn count_effective_loc(text: &str, stop_before_line: Option<usize>) -> usize {
386 let mut count = 0usize;
387 let mut in_block = false;
388
389 for (index, raw_line) in text.lines().enumerate() {
390 let line_number = index + 1;
391 if stop_before_line.is_some_and(|stop| line_number >= stop) {
392 break;
393 }
394
395 let mut line = raw_line.trim_start();
396 if line.is_empty() {
397 continue;
398 }
399
400 if in_block {
401 if let Some(end) = line.find("*/") {
402 line = line[end + 2..].trim_start();
403 in_block = false;
404 if line.is_empty() {
405 continue;
406 }
407 } else {
408 continue;
409 }
410 }
411
412 if line.starts_with("//") {
413 continue;
414 }
415
416 if line.starts_with("/*") {
417 if let Some(end) = line.find("*/") {
418 line = line[end + 2..].trim_start();
419 if line.is_empty() {
420 continue;
421 }
422 } else {
423 in_block = true;
424 continue;
425 }
426 }
427
428 count += 1;
429 }
430
431 count
432}
433
434fn changed_path(paths: &[&str], pattern: &str) -> bool {
435 paths.iter().any(|path| glob_match(pattern, path))
436}
437
438fn glob_match(pattern: &str, path: &str) -> bool {
439 if let Some((prefix, suffix)) = pattern.split_once('*') {
440 path.starts_with(prefix) && path.ends_with(suffix)
441 } else {
442 path == pattern
443 }
444}
445
446fn same_file_bytes(left: &Path, right: &Path) -> Result<bool> {
447 if !right.exists() {
448 return Ok(false);
449 }
450 let left = std::fs::read(left).with_context(|| format!("failed to read {}", left.display()))?;
451 let right =
452 std::fs::read(right).with_context(|| format!("failed to read {}", right.display()))?;
453 Ok(left == right)
454}
455
456fn run_cargo_fetch(repo_root: &Path) -> Result<()> {
457 let manifest = repo_root.join("Cargo.toml");
458 let manifest = manifest
459 .to_str()
460 .with_context(|| format!("non-UTF-8 manifest path: {}", manifest.display()))?;
461 run_cargo(&["fetch", "--manifest-path", manifest])
462}
463
464#[cfg(test)]
465mod tests {
466 use super::{
467 changed_path, command_on_path, count_effective_loc, glob_match, is_blocked_env_path,
468 is_test_file, rust_production_lines, trailing_rust_test_module_start,
469 };
470
471 #[test]
472 fn blocks_env_files_except_examples() {
473 assert!(is_blocked_env_path(".env"));
474 assert!(is_blocked_env_path("config/.env.local"));
475 assert!(is_blocked_env_path("services/foo.env.prod"));
476 assert!(!is_blocked_env_path(".env.example"));
477 assert!(!is_blocked_env_path("docs/env.example.md"));
478 }
479
480 #[test]
481 fn matches_bash_style_single_star_patterns_used_by_coupled_check() {
482 assert!(glob_match("scripts/*", "scripts/check-coupled-files.sh"));
483 assert!(glob_match("plugins/soma/*", "plugins/soma/hooks/setup.sh"));
484 assert!(glob_match("Justfile", "Justfile"));
485 assert!(!glob_match("Justfile", "docs/Justfile"));
486 }
487
488 #[test]
489 fn changed_path_checks_any_changed_path() {
490 let paths = ["README.md", "scripts/check-coupled-files.sh"];
491 assert!(changed_path(&paths, "scripts/*"));
492 assert!(!changed_path(&paths, "lefthook.yml"));
493 }
494
495 #[test]
496 fn command_on_path_handles_absolute_missing_path() {
497 assert!(!command_on_path("/definitely/not/a/real/soma"));
498 }
499
500 #[test]
501 fn test_file_detection_matches_precommit_scope() {
502 assert!(is_test_file("crates/foo/tests/integration.rs"));
503 assert!(is_test_file("src/widget_test.rs"));
504 assert!(is_test_file("src/tests.rs"));
505 assert!(is_test_file("apps/web/button.test.tsx"));
506 assert!(is_test_file("apps/web/__tests__/button.ts"));
507 assert!(!is_test_file("src/app.rs"));
508 }
509
510 #[test]
511 fn effective_loc_ignores_comments_blanks_and_blocks() {
512 let text = r#"
513// comment
514
515/* block
516 comment */
517fn main() {}
518/* inline */ let x = 1;
519"#;
520 assert_eq!(count_effective_loc(text, None), 2);
521 }
522
523 #[test]
524 fn rust_production_lines_cut_before_trailing_test_module() {
525 let text = r#"
526pub fn production() {}
527
528#[cfg(test)]
529mod tests {
530 #[test]
531 fn works() {}
532}
533"#;
534 assert_eq!(trailing_rust_test_module_start(text), Some(4));
535 assert_eq!(rust_production_lines(text), 1);
536 }
537
538 #[test]
539 fn rust_production_lines_do_not_cut_for_cfg_test_function() {
540 let text = r#"
541pub fn production() {}
542
543#[cfg(test)]
544fn helper() {}
545"#;
546 assert_eq!(trailing_rust_test_module_start(text), None);
547 assert_eq!(rust_production_lines(text), 3);
548 }
549}