Skip to main content

xtask/
scripts.rs

1//! Rust implementations for small scripts that have thin wrappers
2//! in `scripts/`.
3
4use 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        // The locked Synapse donor snapshot intentionally contains Unicode
184        // status glyphs. Its bytes are verified by check-synapse-product-import.
185        ":!: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}