1use anyhow::{Context, Result, bail};
2use std::fs;
3use std::path::Path;
4use std::process::{Command, Stdio};
5use walkdir::WalkDir;
6
7use crate::{
8 architecture, command_exists, generated_surfaces, mcp_registry, patterns, provider_manifest,
9 run_cargo, run_cmd, scripts_lane_b, scripts_lane_d, test_siblings, web_source,
10};
11
12pub(crate) fn contract_audit() -> Result<()> {
13 println!("==> contract-audit: local static/spec checks only");
14 println!("==> [1/13] cargo xtask check-architecture");
15 architecture::check(std::path::Path::new(".")).context("architecture check failed")?;
16
17 println!("==> [2/13] cargo xtask patterns");
18 patterns::run(patterns::PatternOptions::default()).context("patterns contract check failed")?;
19
20 println!("==> [3/13] cargo xtask check-test-siblings");
21 test_siblings::check().context("test sibling check failed")?;
22
23 println!("==> [4/13] cargo xtask check-docs");
24 check_docs().context("generated docs check failed")?;
25
26 println!("==> [5/13] cargo xtask check-stale-claims");
27 check_stale_claims().context("stale claim check failed")?;
28
29 println!("==> [6/13] cargo xtask check-schema-docs --check");
30 scripts_lane_d::check_schema_docs(&["--check".to_owned()])
31 .context("schema docs check failed")?;
32
33 println!("==> [7/13] cargo xtask check-openapi --check");
34 scripts_lane_d::check_openapi(&["--check".to_owned()]).context("OpenAPI docs check failed")?;
35
36 println!("==> [8/13] cargo xtask check-mcp-registry");
37 mcp_registry::check_default(std::path::Path::new("."))
38 .context("MCP registry manifest check failed")?;
39
40 println!("==> [9/13] cargo xtask check-provider-manifest-contract");
41 provider_manifest::check().context("provider manifest contract check failed")?;
42
43 println!("==> [10/13] cargo xtask check-palette-manifest --check");
44 generated_surfaces::check_palette_manifest(&["--check".to_owned()])
45 .context("Palette manifest check failed")?;
46
47 println!("==> [11/13] cargo xtask generate-provider-surfaces --check");
48 generated_surfaces::provider_surfaces(&["--check".to_owned()])
49 .context("provider surfaces check failed")?;
50
51 println!("==> [12/13] cargo xtask check-scaffold-intent-contract");
52 scripts_lane_d::check_scaffold_intent_contract()
53 .context("scaffold intent contract check failed")?;
54
55 println!("==> [13/13] cargo xtask test-soma-features");
56 scripts_lane_b::test_soma_features(std::path::Path::new("."))
57 .context("Soma feature smoke failed")?;
58
59 println!("==> contract-audit: passed; no live upstream services were contacted");
60 Ok(())
61}
62
63pub(crate) fn generate_docs() -> Result<()> {
64 run_cmd("python3", &["scripts/generate-docs.py", "--write"])
65 .context("generated docs update failed")
66}
67
68pub(crate) fn check_docs() -> Result<()> {
69 run_cmd("python3", &["scripts/generate-docs.py", "--check"])
70 .context("generated docs are stale; run `cargo xtask generate-docs`")?;
71 run_cmd("python3", &["scripts/check-python-platform-policy.py"])
72 .context("Python platform policy check failed")?;
73 run_cmd("python3", &["scripts/python-platform-gates.py"])
74 .context("Python platform performance gate failed")
75}
76
77pub(crate) fn check_stale_claims() -> Result<()> {
78 run_cmd("python3", &["scripts/check-stale-claims.py"]).context("stale claim check failed")
79}
80
81pub(crate) fn dist() -> Result<()> {
82 const BINARY_NAME: &str = "soma";
83
84 println!("==> Building release binary: {BINARY_NAME}");
85 run_cargo(&["build", "--release", "--locked", "--bin", BINARY_NAME])?;
86
87 let target_dir = std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "target".into());
88 let artifact = std::path::Path::new(&target_dir)
89 .join("release")
90 .join(BINARY_NAME);
91 if !artifact.exists() {
92 bail!("Release binary not found at {artifact:?} — build must have failed");
93 }
94
95 println!("==> Built {artifact:?}");
96 println!("==> Run `just install-local` to install it to ~/.local/bin for plugin use");
97 Ok(())
98}
99
100pub(crate) fn doc(args: &[String]) -> Result<()> {
119 let mut cargo_args = vec![
120 "doc".to_owned(),
121 "--workspace".to_owned(),
122 "--no-deps".to_owned(),
123 "--locked".to_owned(),
124 ];
125 let mut all_features = true;
126 let mut open = false;
127 let mut strict = false;
128 let mut docsrs_cfg = false;
129 let mut packages: Vec<String> = Vec::new();
130 let mut document_private_items = false;
131
132 let mut iter = args.iter();
133 while let Some(arg) = iter.next() {
134 match arg.as_str() {
135 "--open" => open = true,
136 "--strict" => strict = true,
137 "--docsrs-cfg" => docsrs_cfg = true,
138 "--no-all-features" => all_features = false,
139 "--document-private-items" => document_private_items = true,
140 "--all-features" => all_features = true,
141 "-p" | "--package" => {
142 let pkg = iter
143 .next()
144 .ok_or_else(|| anyhow::anyhow!("`{arg}` requires a package name"))?;
145 packages.push(pkg.clone());
146 }
147 "--help" | "-h" => {
148 println!("{DOC_HELP}");
149 return Ok(());
150 }
151 other => bail!("Unknown `cargo xtask doc` option: {other:?}"),
152 }
153 }
154
155 if all_features {
156 cargo_args.push("--all-features".to_owned());
157 }
158 if document_private_items {
159 cargo_args.push("--document-private-items".to_owned());
160 }
161 for pkg in &packages {
162 cargo_args.push("-p".to_owned());
163 cargo_args.push(pkg.clone());
164 }
165 if open {
166 cargo_args.push("--open".to_owned());
167 }
168
169 print_doc_plan(
170 all_features,
171 &packages,
172 document_private_items,
173 open,
174 strict,
175 docsrs_cfg,
176 );
177
178 let mut cmd = Command::new("cargo");
183 cmd.args(&cargo_args);
184 let mut rustdocflags: Vec<String> = std::env::var("RUSTDOCFLAGS")
185 .ok()
186 .filter(|value| !value.trim().is_empty())
187 .into_iter()
188 .collect();
189 if strict {
190 rustdocflags.push("-D warnings".to_owned());
191 }
192 if docsrs_cfg {
193 rustdocflags.push("--cfg docsrs".to_owned());
194 }
195 if !rustdocflags.is_empty() {
196 cmd.env("RUSTDOCFLAGS", rustdocflags.join(" "));
197 }
198 cmd.stdin(Stdio::null());
199
200 let status = cmd
201 .status()
202 .with_context(|| "Failed to spawn `cargo doc`")?;
203 if !status.success() {
204 bail!("`cargo doc` exited with status {status}");
205 }
206
207 let target_dir = std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "target".into());
208 let doc_root = std::path::Path::new(&target_dir).join("doc");
209
210 crate::doc_site::emit(&doc_root)?;
213
214 println!();
215 println!("==> Rustdoc generated under {doc_root:?}");
216 if !open {
217 println!(
218 " Open {}/index.html (or pass --open)",
219 doc_root.display()
220 );
221 }
222 if !strict {
223 println!(" Note: warnings were not fatal. CI enforces them via `--strict`.");
224 }
225 Ok(())
226}
227
228fn run_doc_strict() -> Result<()> {
233 let status = Command::new("cargo")
234 .args([
235 "doc",
236 "--workspace",
237 "--no-deps",
238 "--all-features",
239 "--locked",
240 ])
241 .env("RUSTDOCFLAGS", "-D warnings")
242 .stdin(Stdio::null())
243 .status()
244 .context("Failed to spawn `cargo doc`")?;
245 if !status.success() {
246 bail!("`cargo doc` exited with status {status}");
247 }
248 Ok(())
249}
250
251fn print_doc_plan(
252 all_features: bool,
253 packages: &[String],
254 document_private_items: bool,
255 open: bool,
256 strict: bool,
257 docsrs_cfg: bool,
258) {
259 println!("==> cargo xtask doc");
260 println!(
261 " features: {}",
262 if all_features { "all" } else { "default" }
263 );
264 println!(
265 " packages: {}",
266 if packages.is_empty() {
267 "workspace".to_owned()
268 } else {
269 packages.join(", ")
270 }
271 );
272 println!(
273 " private items: {}",
274 if document_private_items {
275 "yes"
276 } else {
277 "no (public only)"
278 }
279 );
280 println!(" open in browser: {}", if open { "yes" } else { "no" });
281 println!(
282 " strict (-D warn): {}",
283 if strict { "yes" } else { "no" }
284 );
285 println!(
286 " docsrs cfg: {}",
287 if docsrs_cfg {
288 "yes (--cfg docsrs; needs nightly rustdoc)"
289 } else {
290 "no"
291 }
292 );
293}
294
295const DOC_HELP: &str = "cargo xtask doc — generate Rust API documentation (rustdoc)
296
297USAGE:
298 cargo xtask doc [OPTIONS]
299
300OPTIONS:
301 --open Open the generated docs in a browser
302 --strict Treat rustdoc warnings as errors
303 (RUSTDOCFLAGS=\"-D warnings\"; mirrors CI)
304 --docsrs-cfg Append `--cfg docsrs` to RUSTDOCFLAGS so
305 feature-requirement badges render
306 (doc_auto_cfg; requires a nightly toolchain)
307 --no-all-features Document default features only (faster)
308 --all-features Document all features (default)
309 --document-private-items Include private items (internal/team docs)
310 -p, --package <NAME> Document a single workspace package
311 (repeatable; --no-deps is always set)
312 -h, --help Show this help
313
314DEFAULTS:
315 Public items only, no dependency docs, all features. These match the
316 repo's documented cargo-doc posture and what `.github/workflows/docs.yml`
317 deploys to GitHub Pages. Every run also writes target/doc/index.html (a
318 landing page listing all workspace crates) plus openapi.html/openapi.json
319 (the REST contract rendered with Redoc).
320
321EXAMPLES:
322 cargo xtask doc # full workspace API docs
323 cargo xtask doc --open # ...and open in a browser
324 cargo xtask doc -p soma-application # one crate only
325 cargo xtask doc --strict # CI-grade (warnings are errors)
326 RUSTUP_TOOLCHAIN=nightly cargo xtask doc --docsrs-cfg # feature badges";
327
328pub(crate) fn ci() -> Result<()> {
329 println!("==> [1/15] cargo fmt --check");
330 run_cargo(&["fmt", "--all", "--", "--check"]).context("fmt failed — run `cargo fmt` to fix")?;
331
332 println!("==> [2/15] cargo xtask check-architecture");
333 architecture::check(std::path::Path::new(".")).context("architecture check failed")?;
334
335 println!("==> [3/15] minimal feature check + cargo clippy");
336 run_cargo(&[
337 "check",
338 "-p",
339 "soma-provider-adapters",
340 "--no-default-features",
341 ])
342 .context("soma-provider-adapters minimal feature check failed")?;
343 run_cargo(&["clippy", "--all-targets", "--", "-D", "warnings"]).context("clippy failed")?;
344
345 println!("==> [4/15] cargo doc --workspace --no-deps --all-features (-D warnings)");
346 run_doc_strict().context("rustdoc failed — run `cargo xtask doc --strict` to see details")?;
347
348 println!("==> [5/15] cargo nextest run --profile ci");
349 if command_exists("cargo-nextest") {
350 run_cargo(&["nextest", "run", "--profile", "ci"]).context("nextest failed")?;
351 } else {
352 eprintln!(" (nextest not installed — falling back to cargo test)");
353 run_cargo(&["test"]).context("cargo test failed")?;
354 }
355
356 println!("==> [6/15] taplo check");
357 if command_exists("taplo") {
358 run_cmd("taplo", &["check"]).context("taplo check failed — run `taplo format` to fix")?;
359 } else {
360 eprintln!(" (taplo not installed — skipping TOML format check)");
361 }
362
363 println!("==> [7/15] cargo xtask patterns");
364 patterns::run(patterns::PatternOptions::default())
365 .context("PATTERNS.md contract check failed")?;
366
367 println!("==> [8/15] cargo xtask check-test-siblings");
368 test_siblings::check().context("test sibling check failed")?;
369
370 println!("==> [9/15] cargo xtask check-docs");
371 check_docs().context("generated docs check failed")?;
372
373 println!("==> [10/15] cargo xtask check-stale-claims");
374 check_stale_claims().context("stale claim check failed")?;
375
376 println!("==> [11/15] cargo xtask check-mcp-registry");
377 mcp_registry::check_default(std::path::Path::new("."))
378 .context("MCP registry manifest check failed")?;
379
380 println!("==> [12/15] cargo xtask check-provider-manifest-contract");
381 provider_manifest::check().context("provider manifest contract check failed")?;
382
383 println!("==> [13/15] cargo xtask check-palette-manifest --check");
384 generated_surfaces::check_palette_manifest(&["--check".to_owned()])
385 .context("Palette manifest check failed")?;
386
387 println!("==> [14/15] cargo xtask check-web-source-sync");
388 web_source::check().context("web source bundle drifted from apps/web")?;
389
390 println!("==> [15/15] cargo audit");
391 if command_exists("cargo-audit") {
392 let ignored = accepted_advisories().context("failed to read deny.toml advisories")?;
401 let mut args = vec!["audit"];
402 for id in &ignored {
403 args.push("--ignore");
404 args.push(id);
405 }
406 run_cargo(&args)
407 .context("cargo audit found vulnerabilities outside deny.toml's accepted set")?;
408 } else {
409 eprintln!(
410 " (cargo-audit not installed — skipping; install with `cargo install cargo-audit`)"
411 );
412 }
413
414 println!("==> All CI checks passed!");
415 Ok(())
416}
417
418fn accepted_advisories() -> Result<Vec<String>> {
424 let path = Path::new("deny.toml");
425 if !path.exists() {
426 return Ok(Vec::new());
427 }
428 let text =
429 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
430 let value: toml::Value = toml::from_str(&text).context("deny.toml is not valid TOML")?;
431 let Some(entries) = value
432 .get("advisories")
433 .and_then(|advisories| advisories.get("ignore"))
434 .and_then(toml::Value::as_array)
435 else {
436 return Ok(Vec::new());
437 };
438 Ok(entries
439 .iter()
440 .filter_map(|entry| match entry {
441 toml::Value::String(id) => Some(id.clone()),
444 toml::Value::Table(table) => table
445 .get("id")
446 .and_then(toml::Value::as_str)
447 .map(ToOwned::to_owned),
448 _ => None,
449 })
450 .collect())
451}
452
453pub(crate) fn symlink_docs() -> Result<()> {
454 let mut created = 0usize;
455 let mut skipped = 0usize;
456
457 for entry in WalkDir::new(".")
458 .into_iter()
459 .filter_entry(|entry| {
460 let name = entry.file_name().to_string_lossy();
461 !matches!(name.as_ref(), ".git" | "target")
462 })
463 .filter_map(|entry| entry.ok())
464 {
465 if entry.file_name() != "CLAUDE.md" {
466 continue;
467 }
468
469 let dir = entry.path().parent().expect("CLAUDE.md has a parent");
470 for link_name in ["AGENTS.md", "GEMINI.md"] {
471 let link_path = dir.join(link_name);
472 if link_path.exists() || link_path.symlink_metadata().is_ok() {
473 println!(" skip {}", link_path.display());
474 skipped += 1;
475 continue;
476 }
477
478 #[cfg(unix)]
479 std::os::unix::fs::symlink("CLAUDE.md", &link_path)
480 .with_context(|| format!("Failed to create symlink at {}", link_path.display()))?;
481 #[cfg(windows)]
482 std::os::windows::fs::symlink_file("CLAUDE.md", &link_path).with_context(|| {
483 format!(
484 "Failed to create symlink at {} (may need developer mode on Windows)",
485 link_path.display()
486 )
487 })?;
488
489 println!(" link {} → CLAUDE.md", link_path.display());
490 created += 1;
491 }
492 }
493
494 println!("==> symlink-docs: {created} created, {skipped} already present");
495 Ok(())
496}
497
498pub(crate) fn check_env() -> Result<()> {
499 let required_vars: &[(&str, &str)] = &[];
500 let optional_vars: &[(&str, &str)] = &[
501 (
502 "SOMA_MCP_TOKEN",
503 "Static bearer token for /mcp (required in production; omit only in loopback dev mode)",
504 ),
505 (
506 "SOMA_MCP_HOST",
507 "Bind host (default 127.0.0.1 — set to 0.0.0.0 only with auth or trusted gateway)",
508 ),
509 ("SOMA_MCP_PORT", "Bind port (default 40060)"),
510 (
511 "RUST_LOG",
512 "Log filter (e.g. info,rmcp=warn — default: info in server mode, warn in stdio/cli)",
513 ),
514 ];
515
516 print_env_report(required_vars, optional_vars)
517}
518
519fn print_env_report(required_vars: &[(&str, &str)], optional_vars: &[(&str, &str)]) -> Result<()> {
520 let mut missing = Vec::new();
521
522 println!("==> Checking required environment variables:");
523 for &(var, desc) in required_vars {
524 match std::env::var(var) {
525 Ok(v) if !v.is_empty() => println!(" OK {var}"),
526 _ => {
527 println!(" MISSING {var}");
528 println!(" {desc}");
529 missing.push(var);
530 }
531 }
532 }
533
534 println!("\n==> Optional variables (missing = feature degraded, not error):");
535 for &(var, desc) in optional_vars {
536 match std::env::var(var) {
537 Ok(v) if !v.is_empty() => println!(" set {var} = {v}"),
538 _ => println!(" unset {var} ({desc})"),
539 }
540 }
541
542 if !missing.is_empty() {
543 bail!(
544 "\nMissing required environment variables: {}\n\
545 Copy .env.example to .env and fill in the values.",
546 missing.join(", ")
547 );
548 }
549
550 println!("\n==> All required environment variables are set.");
551 Ok(())
552}
553
554pub(crate) fn print_help() {
555 eprintln!("{HELP_TEXT}");
556}
557
558const HELP_TEXT: &str = "cargo xtask — repo automation for soma
559
560USAGE:
561 cargo xtask <command>
562
563COMMANDS:
564 dist Build release binary into Cargo target dir
565 ci Run all CI checks: fmt, clippy, nextest, taplo, audit
566 symlink-docs Create AGENTS.md + GEMINI.md symlinks next to every CLAUDE.md
567 check-env Validate required environment variables are set
568 check-architecture Validate workspace dependency-layer boundaries
569 check-test-siblings Verify every src/*.rs has a sibling *_tests.rs
570 patterns Check static contracts from docs/PATTERNS.md (--strict, --json)
571 contract-audit Run local static/spec checks without live upstream calls
572 scaffold Plan/apply/verify a generated project from Soma
573 codex-schema Rebuild/bisect the vendored codex-app-server-client schema
574 (see `cargo xtask codex-schema --help`)
575 cargo-generate Smoke-test real cargo-generate output (--no-cargo-check)
576 cargo-generate-post Internal generated-project rewrite command
577 generate-docs Generate volatile docs and metadata from canonical specs
578 doc Generate Rust API docs (rustdoc) for workspace crates (--open, --strict)
579 check-docs Validate generated docs and metadata are current
580 check-mcp-registry Validate server.json against docs/contracts/mcp-server.schema.json
581 check-stale-claims Fail on stale hardcoded Soma claims
582 check-cargo-generate Validate cargo-generate output
583 sync-web-source Copy apps/web into crates/soma-web/assets/source
584 check-web-source-sync Validate bundled web source matches apps/web
585 update-aurora-web Refresh Aurora registry components, validate, then sync
586 build-web Build optional apps/web static export
587 web-watch Watch apps/web and rebuild on changes
588 generate-cli Generate dist/soma-cli through mcporter
589 repair Rebuild and restart local soma-mcp runtime
590 test-mcp-auth Smoke-test HTTP MCP bearer auth
591 test-trace-headers Bounded live smoke for SOMA_MCP_TRACE_HEADERS
592 asciicheck Check/fix explicit files for non-ASCII characters
593 check-blob-size Check changed git blobs against size budget
594 block-env-commits Prevent staged .env secrets from being committed
595 check-coupled-files Check common companion-file drift in a diff
596 check-dependency-updates
597 Report Cargo dependency updates
598 check-file-size Check staged source files against size budgets
599 check-openapi Generate/check docs/generated/openapi.json
600 check-plugin-hook-contract
601 Audit cross-repo plugin hook contracts
602 run-ascii-check Check or fix tracked source/config/docs ASCII hygiene
603 check-plugin-stdio-smoke
604 Smoke-test installed plugin stdio binary
605 check-runtime-current Check systemd/Docker runtime artifact freshness
606 check-schema-docs Generate/check docs/MCP_SCHEMA.md
607 check-scaffold-intent-contract
608 Validate scaffold intent schema/examples
609 check-ts-client Regenerate/verify the checked-in codex-app-server-client TS REST client
610 sync-cargo Copy Cargo.lock into plugin data directories
611 pre-release-check Run release-readiness gate
612 refresh-docs Refresh ignored reference docs
613 test-soma-features
614 Run Soma invariant smoke tests
615 validate-plugin-layout
616 Validate Claude/Codex/Gemini plugin package layout
617 check-version-sync Validate release manifest version-file parity
618 check-release-versions [--base REF] [--head REF] [--mode pr|main] [--json]
619 Validate changed release components have fresh versions/tags
620 release-plan Print changed release components and candidate tags
621 sync-release-please-version
622 Sync version files to .release-please-manifest.json
623 bump-version Bump a component: cargo xtask bump-version soma patch
624 bump-soma-version Bump Soma component: cargo xtask bump-soma-version patch
625 changed-paths Classify changed files into CI routing categories
626 help Show this help
627
628CUSTOMIZE:
629 Add commands by extending the match block in xtask/src/main.rs.
630 Keep dependencies minimal — xtask should compile in seconds.";
631
632#[cfg(test)]
633#[path = "workspace_commands_tests.rs"]
634mod tests;