Skip to main content

xtask/
scripts_lane_d.rs

1//! Lane D Rust migrations for Python scripts that have
2//! wrappers in `scripts/`.
3//!
4//! Intended parent wiring:
5//! - `asciicheck` maps to `scripts/asciicheck.py`
6//! - `check_openapi` maps to `scripts/check-openapi.py`
7//! - `check_schema_docs` maps to `scripts/check-schema-docs.py`
8//! - `check_scaffold_intent_contract` maps to
9//!   `scripts/check-scaffold-intent-contract.py`
10//! - `check_cargo_generate` delegates to the existing xtask cargo-generate
11//!   implementation; the Python file is already only a thin wrapper.
12
13use anyhow::{Context, Result, bail};
14use serde_json::{Value, json};
15use std::collections::BTreeSet;
16use std::fs;
17use std::path::{Path, PathBuf};
18
19const REST_SCHEMAS: &[(&str, Option<&str>, &str)] = &[
20    ("greet", Some("GreetRequest"), "GreetResponse"),
21    ("echo", Some("EchoRequest"), "EchoResponse"),
22    ("status", None, "StatusResponse"),
23    ("python_environment_status", None, "PythonOperatorResponse"),
24    (
25        "python_environment_prune_plan",
26        Some("PythonEnvironmentPrunePlanRequest"),
27        "PythonOperatorResponse",
28    ),
29    (
30        "python_environment_prune",
31        Some("PythonEnvironmentPruneRequest"),
32        "PythonOperatorResponse",
33    ),
34    (
35        "python_environment_repair",
36        Some("PythonProviderMutationRequest"),
37        "PythonOperatorResponse",
38    ),
39    (
40        "python_environment_update",
41        Some("PythonProviderMutationRequest"),
42        "PythonOperatorResponse",
43    ),
44    ("python_worker_status", None, "PythonOperatorResponse"),
45    (
46        "python_worker_cancel",
47        Some("PythonWorkerMutationRequest"),
48        "PythonOperatorResponse",
49    ),
50    (
51        "python_worker_reset",
52        Some("PythonWorkerMutationRequest"),
53        "PythonOperatorResponse",
54    ),
55    ("python_generation_status", None, "PythonOperatorResponse"),
56    (
57        "python_generation_rollback",
58        Some("PythonGenerationRollbackRequest"),
59        "PythonOperatorResponse",
60    ),
61    (
62        "python_graduation_status",
63        Some("PythonGraduationStatusRequest"),
64        "PythonOperatorResponse",
65    ),
66    (
67        "python_graduation_apply",
68        Some("PythonGraduationApplyRequest"),
69        "PythonOperatorResponse",
70    ),
71    ("help", None, "HelpResponse"),
72];
73
74const SURFACES: &[&str] = &["api", "cli", "mcp", "web"];
75const AUTH_KINDS: &[&str] = &["api-key", "bearer", "both", "none", "oauth", "other"];
76const TRANSPORTS: &[&str] = &["dual", "http", "stdio"];
77const BINARY_PROFILES: &[&str] = &["local-adapter", "server-full"];
78const PRIMITIVES: &[&str] = &["elicitation", "prompts", "resources", "tools"];
79const DEPLOYMENTS: &[&str] = &["docker", "none", "systemd"];
80const PLUGINS: &[&str] = &["claude", "codex", "gemini"];
81
82#[derive(Clone, Debug, Eq, PartialEq)]
83struct ActionEntry {
84    name: String,
85    description: String,
86    scope: String,
87    doc_scope: String,
88    transport: String,
89    rest_method: Option<String>,
90    rest_path: Option<String>,
91    cost: String,
92}
93
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95pub enum CheckMode {
96    Check,
97    Write,
98    CheckAndWrite,
99}
100
101impl CheckMode {
102    /// Parses the `--write`/`--check` flag pair shared by every
103    /// regenerate-or-verify xtask command (`check-openapi`,
104    /// `check-schema-docs`, `check-ts-client`).
105    ///
106    /// Returns `Ok(None)` when `--help` was handled and the caller should do
107    /// nothing else. That is why this returns an `Option` rather than a bare
108    /// `CheckMode`: printing usage and then running the command anyway (which
109    /// is what returning a default mode here used to do) surprises anyone who
110    /// asked what a command does and got it done to them instead.
111    pub(crate) fn parse(args: &[String], usage: &str) -> Result<Option<Self>> {
112        let mut write = false;
113        let mut check = false;
114        for arg in args {
115            match arg.as_str() {
116                "--write" => write = true,
117                "--check" => check = true,
118                "--help" | "-h" => {
119                    println!("{usage}");
120                    return Ok(None);
121                }
122                unknown => bail!("unknown option: {unknown}\n\n{usage}"),
123            }
124        }
125        Ok(Some(match (check, write) {
126            (false, false) | (true, false) => Self::Check,
127            (false, true) => Self::Write,
128            // Both flags means "regenerate, then verify the result" - useful
129            // enough to keep rather than reject, and the only sequencing that
130            // makes sense for a pair of flags whose whole point is a
131            // generated artifact.
132            (true, true) => Self::CheckAndWrite,
133        }))
134    }
135
136    pub(crate) fn should_check(self) -> bool {
137        matches!(self, Self::Check | Self::CheckAndWrite)
138    }
139
140    pub(crate) fn should_write(self) -> bool {
141        matches!(self, Self::Write | Self::CheckAndWrite)
142    }
143}
144
145pub fn asciicheck(args: &[String]) -> Result<()> {
146    let (fix, files) = parse_asciicheck_args(args)?;
147    let mut has_errors = false;
148    for file in files {
149        has_errors |= lint_utf8_ascii(Path::new(file), fix)?;
150    }
151    if has_errors {
152        bail!("ASCII check failed");
153    }
154    Ok(())
155}
156
157pub fn check_openapi(args: &[String]) -> Result<()> {
158    let Some(mode) =
159        CheckMode::parse(args, "Usage: cargo xtask check-openapi [--write] [--check]")?
160    else {
161        return Ok(());
162    };
163    let root = current_dir()?;
164    let rendered_value = render_openapi(&root)?;
165    let rendered = canonical_json(&rendered_value)?;
166    let out = root.join("docs/generated/openapi.json");
167    let mut failures = validate_openapi(&root, &rendered_value)?;
168
169    if mode.should_write() {
170        if let Some(parent) = out.parent() {
171            fs::create_dir_all(parent)
172                .with_context(|| format!("failed to create {}", parent.display()))?;
173        }
174        fs::write(&out, &rendered).with_context(|| format!("failed to write {}", out.display()))?;
175        println!("wrote {}", relative_display(&root, &out));
176    }
177
178    if mode.should_check() {
179        if !out.exists() {
180            failures.push(
181                "docs/generated/openapi.json is missing; run scripts/check-openapi.py --write"
182                    .to_owned(),
183            );
184        } else if read(&out)? != rendered {
185            failures.push(
186                "docs/generated/openapi.json is stale; run scripts/check-openapi.py --write"
187                    .to_owned(),
188            );
189        }
190    }
191
192    finish_failures(failures)?;
193    if mode.should_check() {
194        println!("OpenAPI schema is current");
195    }
196    Ok(())
197}
198
199pub fn check_schema_docs(args: &[String]) -> Result<()> {
200    let Some(mode) = CheckMode::parse(
201        args,
202        "Usage: cargo xtask check-schema-docs [--write] [--check]",
203    )?
204    else {
205        return Ok(());
206    };
207    let root = current_dir()?;
208    let doc = root.join("docs/MCP_SCHEMA.md");
209    let rendered = render_schema_docs(&root)?;
210
211    if mode.should_write() {
212        fs::write(&doc, &rendered).with_context(|| format!("failed to write {}", doc.display()))?;
213        println!("wrote {}", relative_display(&root, &doc));
214    }
215
216    let mut failures = Vec::new();
217    if mode.should_check() {
218        if !doc.exists() {
219            failures.push("docs/MCP_SCHEMA.md is missing; run --write".to_owned());
220        } else if read(&doc)? != rendered {
221            failures.push("docs/MCP_SCHEMA.md is stale; run --write".to_owned());
222        }
223        let actions = extract_actions(&root)?;
224        failures.extend(check_schema_mentions(&root, &actions)?);
225        failures.extend(check_schema_scope(&root, &actions)?);
226    }
227
228    finish_failures(failures)?;
229    if mode.should_check() {
230        println!("schema docs are current");
231    }
232    Ok(())
233}
234
235pub fn check_scaffold_intent_contract() -> Result<()> {
236    let root = current_dir()?;
237    let schema = root.join("docs/contracts/scaffold-intent.schema.json");
238    let examples = root.join("docs/contracts/examples");
239
240    validate_scaffold_schema(&schema)?;
241    let mut found = false;
242    for entry in
243        fs::read_dir(&examples).with_context(|| format!("failed to read {}", examples.display()))?
244    {
245        let path = entry?.path();
246        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
247            continue;
248        };
249        if name.starts_with("scaffold-intent-") && name.ends_with(".json") {
250            found = true;
251            let payload = load_json(&path)?;
252            validate_scaffold_payload(&payload, &path)?;
253        }
254    }
255    require(
256        found,
257        format!("{}: no scaffold intent examples found", examples.display()),
258    )?;
259    println!("scaffold intent contract and examples are valid");
260    Ok(())
261}
262
263pub fn check_cargo_generate(args: &[String]) -> Result<()> {
264    crate::cargo_generate::run(args)
265}
266
267fn parse_asciicheck_args(args: &[String]) -> Result<(bool, Vec<&str>)> {
268    let mut fix = false;
269    let mut files = Vec::new();
270    for arg in args {
271        match arg.as_str() {
272            "--fix" => fix = true,
273            "--help" | "-h" => {
274                println!("Usage: cargo xtask asciicheck [--fix] <files>...");
275                return Ok((false, Vec::new()));
276            }
277            file => files.push(file),
278        }
279    }
280    if files.is_empty() {
281        bail!("the following required arguments were not provided: files");
282    }
283    Ok((fix, files))
284}
285
286fn lint_utf8_ascii(filename: &Path, fix: bool) -> Result<bool> {
287    let raw =
288        fs::read(filename).with_context(|| format!("failed to read {}", filename.display()))?;
289    let text = match String::from_utf8(raw.clone()) {
290        Ok(text) => text,
291        Err(exc) => {
292            let offset = exc.utf8_error().valid_up_to();
293            let partial = &raw[..offset];
294            let line = partial.iter().filter(|byte| **byte == b'\n').count() + 1;
295            let col = offset
296                - partial
297                    .iter()
298                    .rposition(|byte| *byte == b'\n')
299                    .map(|index| index + 1)
300                    .unwrap_or(0)
301                + 1;
302            println!("{}: UTF-8 decoding error:", filename.display());
303            println!("  byte offset: {offset}");
304            println!("  reason: {}", exc.utf8_error());
305            println!("  location: line {line}, column {col}");
306            return Ok(true);
307        }
308    };
309
310    let mut errors = Vec::new();
311    for (line_index, line) in text.split_inclusive('\n').enumerate() {
312        for (col_index, ch) in line.chars().enumerate() {
313            let codepoint = ch as u32;
314            if matches!(ch, '\n' | '\r' | '\t') {
315                continue;
316            }
317            if !(0x20..=0x7e).contains(&codepoint) && !allowed_unicode(codepoint) {
318                errors.push((line_index + 1, col_index + 1, ch, codepoint));
319            }
320        }
321    }
322
323    if !errors.is_empty() {
324        println!("{}:", filename.display());
325        for (lineno, colno, ch, codepoint) in &errors {
326            println!(
327                "  line {lineno}, column {colno}: U+{codepoint:04X} ({})",
328                escape_char(*ch)
329            );
330        }
331    }
332
333    if !errors.is_empty() && fix {
334        let mut replacements = 0usize;
335        let new_contents: String = text
336            .chars()
337            .flat_map(|ch| {
338                if let Some(replacement) = substitution(ch as u32) {
339                    replacements += 1;
340                    replacement.chars().collect::<Vec<_>>()
341                } else {
342                    vec![ch]
343                }
344            })
345            .collect();
346        fs::write(filename, new_contents)
347            .with_context(|| format!("failed to write {}", filename.display()))?;
348        println!("  fixed {replacements} replaceable character(s)");
349    }
350
351    Ok(!errors.is_empty())
352}
353
354fn allowed_unicode(codepoint: u32) -> bool {
355    matches!(
356        codepoint,
357        0x00A7
358            | 0x00D7
359            | 0x2013
360            | 0x2014
361            | 0x2026
362            | 0x20AC
363            | 0x2190
364            | 0x2192
365            | 0x2193
366            | 0x2194
367            | 0x2248
368            | 0x2264
369            | 0x2265
370            | 0x2500
371            | 0x2501
372            | 0x2502
373            | 0x250C
374            | 0x2510
375            | 0x2514
376            | 0x2518
377            | 0x251C
378            | 0x253C
379            | 0x25B2
380            | 0x25B6
381            | 0x25BC
382            | 0x26A0
383            | 0x2713
384            | 0x2717
385            | 0xFE0F
386    )
387}
388
389fn substitution(codepoint: u32) -> Option<&'static str> {
390    match codepoint {
391        0x00A0 | 0x202F => Some(" "),
392        0x2011 | 0x2013 | 0x2014 => Some("-"),
393        0x2018 | 0x2019 => Some("'"),
394        0x201C | 0x201D => Some("\""),
395        0x2026 => Some("..."),
396        _ => None,
397    }
398}
399
400fn escape_char(ch: char) -> String {
401    match ch {
402        '\n' => "\\n".to_owned(),
403        '\r' => "\\r".to_owned(),
404        '\t' => "\\t".to_owned(),
405        '\'' => "\\'".to_owned(),
406        '"' => "\\\"".to_owned(),
407        '\\' => "\\\\".to_owned(),
408        other => other.to_string(),
409    }
410}
411
412fn render_openapi(root: &Path) -> Result<Value> {
413    let entries = action_entries(root)?;
414    let rest_actions: Vec<&ActionEntry> = entries
415        .iter()
416        .filter(|entry| {
417            entry.transport == "Any" && entry.rest_method.is_some() && entry.rest_path.is_some()
418        })
419        .collect();
420    let action_names: Vec<String> = rest_actions
421        .iter()
422        .map(|entry| entry.name.clone())
423        .collect();
424    let version = package_version(root)?;
425    let port = default_mcp_port(root)?;
426
427    let mut paths = serde_json::Map::new();
428    paths.insert(
429        "/health".to_owned(),
430        json!({"get":{"tags":["health"],"summary":"Liveness probe","operationId":"getHealth","security":[],"responses":{"200":{"description":"Server is alive","content":{"application/json":{"schema":schema_ref("HealthResponse"),"examples":{"ok":{"value":{"status":"ok"}}}}}}}}}),
431    );
432    paths.insert(
433        "/openapi.json".to_owned(),
434        json!({"get":{"tags":["health"],"summary":"OpenAPI schema","operationId":"getOpenApiSchema","security":[],"responses":{"200":{"description":"Generated OpenAPI schema for the REST surface","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}}}),
435    );
436    paths.insert(
437        "/status".to_owned(),
438        json!({"get":{"tags":["health"],"summary":"Local runtime status","operationId":"getLocalStatus","security":[],"responses":{"200":{"description":"Runtime status with secrets redacted","content":{"application/json":{"schema":schema_ref("StatusResponse")}}},"500":{"$ref":"#/components/responses/InternalError"}}}}),
439    );
440    paths.insert(
441        "/v1/capabilities".to_owned(),
442        json!({"get":{"tags":["capabilities"],"summary":"Direct REST route inventory","operationId":"getCapabilities","security":[{"BearerAuth":[]},{}],"responses":{"200":{"description":"Supported direct REST routes and metadata","content":{"application/json":{"schema":schema_ref("CapabilitiesResponse")}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}}),
443    );
444    paths.insert(
445        "/v1/providers".to_owned(),
446        json!({"get":{"tags":["provider-tools"],"summary":"Live provider inventory","operationId":"getProviders","security":[{"BearerAuth":[]},{}],"responses":{"200":{"description":"Live provider catalog, including dropped provider tools and MCP primitives","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}}),
447    );
448    paths.insert(
449        "/v1/tools/{action}".to_owned(),
450        json!({"post":{"tags":["provider-tools"],"summary":"Run a provider tool","description":"Generic REST route for dropped provider tools. Tools with custom REST overlays may also expose dedicated direct routes.","operationId":"runProviderTool","security":[{"BearerAuth":[]},{}],"parameters":[{"name":"action","in":"path","required":true,"description":"Provider tool action name","schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"responses":{"200":{"description":"Provider tool response envelope","content":{"application/json":{"schema":success_envelope(json!({"type":"object","additionalProperties":true}))}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Unknown action or surface not exposed","content":{"application/json":{"schema":schema_ref("ErrorResponse")}}},"500":{"$ref":"#/components/responses/InternalError"}}}}),
451    );
452
453    for action in &rest_actions {
454        let Some((request_schema, response_schema)) = rest_schemas(&action.name) else {
455            continue;
456        };
457        let method = action
458            .rest_method
459            .as_deref()
460            .expect("rest actions are filtered to require method")
461            .to_ascii_lowercase();
462        let path = action
463            .rest_path
464            .as_deref()
465            .expect("rest actions are filtered to require path");
466        let mut operation = json!({
467            "tags": ["direct-rest"],
468            "summary": format!("Run {}", action.name),
469            "description": "Direct REST route over the shared service layer.",
470            "operationId": format!("{method}{}", title_no_underscore(&action.name)),
471            "security": [{"BearerAuth":[]},{}],
472            "responses": {
473                "200": {"description": format!("{} result envelope", action.name), "content": {"application/json": {"schema": success_envelope(schema_ref(response_schema))}}},
474                "400": {"$ref":"#/components/responses/BadRequest"},
475                "401": {"$ref":"#/components/responses/Unauthorized"},
476                "403": {"$ref":"#/components/responses/Forbidden"},
477                "500": {"$ref":"#/components/responses/InternalError"}
478            }
479        });
480        if let Some(request_schema) = request_schema {
481            operation["requestBody"] = json!({
482                "required": true,
483                "content": {
484                    "application/json": {
485                        "schema": schema_ref(request_schema),
486                        "examples": { action.name.clone(): { "value": param_example(&action.name) } }
487                    }
488                }
489            });
490        }
491        paths.insert(path.to_owned(), json!({method: operation}));
492    }
493
494    let direct_rest_routes = rest_actions
495        .iter()
496        .map(|action| {
497            (
498                action.name.clone(),
499                json!({
500                    "method": action.rest_method.as_deref().unwrap_or_default().to_uppercase(),
501                    "path": action.rest_path.as_deref().unwrap_or_default(),
502                }),
503            )
504        })
505        .collect::<serde_json::Map<_, _>>();
506    let action_costs = entries
507        .iter()
508        .map(|entry| (entry.name.clone(), json!(entry.cost)))
509        .collect::<serde_json::Map<_, _>>();
510    let mcp_only: Vec<String> = entries
511        .iter()
512        .filter(|entry| entry.transport == "McpOnly")
513        .map(|entry| entry.name.clone())
514        .collect();
515
516    Ok(json!({
517        "openapi": "3.1.0",
518        "info": {
519            "title": "Soma MCP REST API",
520            "version": version,
521            "description": "Generated OpenAPI schema for Soma's direct REST surface. Auth modes: loopback/trusted-gateway deployments may have no local auth; mounted bearer mode uses SOMA_MCP_TOKEN; OAuth mode uses bearer JWTs. REST actions require their action-specific scopes when auth is mounted.",
522            "contact": {
523                "name": "dinglebear.ai",
524                "url": "https://dinglebear.ai"
525            },
526            "license": {
527                "name": "MIT",
528                "url": "https://github.com/dinglebear-ai/soma/blob/main/LICENSE"
529            }
530        },
531        "servers": [
532            {"url": format!("http://localhost:{port}"),"description":"Default local development server"},
533            {
534                "url": "https://{host}",
535                "description": "Reverse-proxied Soma deployment",
536                "variables": {
537                    "host": {
538                        "default": "soma.dinglebear.ai",
539                        "description": "Public Soma host configured with SOMA_MCP_PUBLIC_URL"
540                    }
541                }
542            }
543        ],
544        "externalDocs": {
545            "description": "Soma documentation",
546            "url": "https://github.com/dinglebear-ai/soma/tree/main/docs"
547        },
548        "tags": [
549            {"name":"health","description":"Unauthenticated runtime probes"},
550            {"name":"capabilities","description":"REST route inventory"},
551            {"name":"direct-rest","description":"Typed REST routes"},
552            {"name":"provider-tools","description":"Dynamic provider inventory and generic tool execution"}
553        ],
554        "paths": paths,
555        "components": {
556            "securitySchemes": {"BearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"opaque","description":"Static bearer token in bearer mode; OAuth mode also uses bearer JWTs. Loopback and trusted-gateway modes may not require local auth."}},
557            "schemas": openapi_schemas(action_names.clone()),
558            "responses": {
559                "BadRequest":{"description":"Validation error","content":{"application/json":{"schema":schema_ref("ErrorResponse")}}},
560                "Unauthorized":{"description":"Missing or invalid authentication","content":{"application/json":{"schema":schema_ref("ErrorResponse")}}},
561                "Forbidden":{"description":"Authenticated request lacks the required scope","content":{"application/json":{"schema":schema_ref("ErrorResponse")}}},
562                "InternalError":{"description":"Internal server error","content":{"application/json":{"schema":schema_ref("ErrorResponse")}}}
563            }
564        },
565        "x-soma": {
566            "publisher": {
567                "name": "dinglebear.ai",
568                "url": "https://dinglebear.ai"
569            },
570            "homepage": "https://soma.dinglebear.ai",
571            "repository": "https://github.com/dinglebear-ai/soma",
572            "support": "https://github.com/dinglebear-ai/soma/issues",
573            "security_policy": "https://github.com/dinglebear-ai/soma/security/policy",
574            "keywords": [
575                "mcp",
576                "mcp-server",
577                "model-context-protocol",
578                "rmcp",
579                "rust",
580                "agent-tools",
581                "ai-agents",
582                "provider-runtime",
583                "providers",
584                "developer-tools",
585                "automation",
586                "openapi",
587                "docker",
588                "cli",
589                "server-runtime"
590            ],
591            "source": "scripts/check-openapi.py",
592            "action_metadata": "crates/soma/domain/src/actions.rs",
593            "preferred_rest_style": "direct_routes",
594            "binary": "soma",
595            "server_binary": "soma",
596            "node_package": "@dinglebear/soma",
597            "oci_image": format!("ghcr.io/dinglebear-ai/soma:{version}"),
598            "mcp_registry": "server.json",
599            "provider_directory_env": "SOMA_PROVIDER_DIR",
600            "auth_modes": ["loopback-dev", "bearer", "oauth", "trusted-gateway"],
601            "transports": ["stdio", "streamable-http"],
602            "surfaces": ["mcp", "cli", "rest", "web", "docker", "plugins"],
603            "documentation": {
604                "quickstart": "docs/QUICKSTART.md",
605                "configuration": "docs/CONFIG.md",
606                "environment": "docs/ENV.md",
607                "provider_surfaces": "docs/generated/provider-surfaces.md"
608            },
609            "rest_actions": action_names,
610            "direct_rest_routes": direct_rest_routes,
611            "action_costs": action_costs,
612            "mcp_only_actions": mcp_only
613        }
614    }))
615}
616
617fn openapi_schemas(action_names: Vec<String>) -> Value {
618    json!({
619        "ActionName":{"type":"string","enum":action_names,"description":"REST-capable action names from crates/soma/domain/src/actions.rs."},
620        "GreetRequest":{"type":"object","additionalProperties":false,"properties":{"name":{"type":"string","description":"Name to greet. Omit to greet the world."}}},
621        "EchoRequest":{"type":"object","additionalProperties":false,"required":["message"],"properties":{"message":{"type":"string","minLength":1,"description":"Message to echo back. Must not be empty."}}},
622        "ActionResponse":{"oneOf":[schema_ref("GreetResponse"),schema_ref("EchoResponse"),schema_ref("StatusResponse"),schema_ref("PythonOperatorResponse"),schema_ref("HelpResponse"),schema_ref("RestTruncationResponse")]},
623        "GreetResponse":{"type":"object","required":["greeting","target"],"properties":{"greeting":{"type":"string"},"target":{"type":"string"},"server":{"type":"string"}},"additionalProperties":true},
624        "EchoResponse":{"type":"object","required":["echo"],"properties":{"echo":{"type":"string"}},"additionalProperties":true},
625        "StatusResponse":{"type":"object","required":["status"],"properties":{"status":{"type":"string"},"note":{"type":"string"},"server":{"type":"string"},"version":{"type":"string"},"transport":{"type":"string"}},"additionalProperties":true},
626        "PythonEnvironmentPrunePlanRequest":{"type":"object","additionalProperties":false,"required":["stale_before_unix_seconds"],"properties":{"stale_before_unix_seconds":{"type":"integer","minimum":0},"max_entries":{"type":"integer","minimum":1,"maximum":1000}}},
627        "PythonEnvironmentPruneRequest":{"type":"object","additionalProperties":false,"required":["stale_before_unix_seconds","confirm"],"properties":{"stale_before_unix_seconds":{"type":"integer","minimum":0},"max_entries":{"type":"integer","minimum":1,"maximum":1000},"confirm":{"type":"boolean","const":true}}},
628        "PythonProviderMutationRequest":{"type":"object","additionalProperties":false,"required":["provider_path","confirm"],"properties":{"provider_path":{"type":"string","minLength":1,"maxLength":4096},"confirm":{"type":"boolean","const":true}}},
629        "PythonWorkerMutationRequest":{"type":"object","additionalProperties":false,"required":["provider","confirm"],"properties":{"provider":{"type":"string","minLength":1,"maxLength":256},"confirm":{"type":"boolean","const":true}}},
630        "PythonGenerationRollbackRequest":{"type":"object","additionalProperties":false,"required":["generation_id","confirm"],"properties":{"generation_id":{"type":"integer","minimum":1},"confirm":{"type":"boolean","const":true}}},
631        "PythonGraduationStatusRequest":{"type":"object","additionalProperties":false,"required":["workspace"],"properties":{"workspace":{"type":"string","minLength":1,"maxLength":4096}}},
632        "PythonGraduationApplyRequest":{"type":"object","additionalProperties":false,"required":["operation","workspace","confirm"],"properties":{"operation":{"type":"string","enum":["graduate","build-component","verify-component","compare","activate","rollback"]},"workspace":{"type":"string","minLength":1,"maxLength":4096},"source":{"type":"string","minLength":1,"maxLength":4096},"component":{"type":"string","minLength":1,"maxLength":4096},"fixtures":{"type":"string","minLength":1,"maxLength":4096},"confirm":{"type":"boolean","const":true}}},
633        "PythonOperatorResponse":{"type":"object","additionalProperties":true,"description":"Action-specific Python environment, worker, or generation result."},
634        "ProviderProgressEvent":{"type":"object","additionalProperties":false,"required":["current"],"properties":{"current":{"type":"integer","minimum":0},"total":{"type":["integer","null"],"minimum":0},"message":{"type":["string","null"],"maxLength":1024}}},
635        "HealthResponse":{"type":"object","required":["status"],"properties":{"status":{"type":"string","const":"ok"}},"additionalProperties":false},
636        "CapabilitiesResponse":{"type":"object","required":["server","version","preferred_rest_style","supported_routes","routes"],"properties":{"server":{"type":"string"},"version":{"type":"string"},"preferred_rest_style":{"type":"string","const":"direct_routes"},"supported_routes":{"type":"array","items":{"type":"string"}},"routes":{"type":"array","items":schema_ref("RestRoute")}},"additionalProperties":false},
637        "RestRoute":{"type":"object","required":["method","path","auth","description"],"properties":{"method":{"type":"string"},"path":{"type":"string"},"action":{"type":["string","null"]},"auth":{"type":"string"},"description":{"type":"string"}},"additionalProperties":false},
638        "HelpResponse":{"type":"object","required":["actions","mcp_only_actions","usage","examples"],"properties":{"actions":{"type":"array","items":schema_ref("ActionName")},"mcp_only_actions":{"type":"array","items":{"type":"string"}},"usage":{"type":"string"},"examples":{"type":"object","additionalProperties":true}},"additionalProperties":true},
639        "ErrorResponse":{"type":"object","required":["error"],"properties":{"error":{"type":"string"}},"additionalProperties":false},
640        "RestTruncationResponse":{"type":"object","required":["truncated","error","max_response_bytes","hint"],"properties":{"truncated":{"type":"boolean","const":true},"error":{"type":"string","const":"response exceeded REST response size limit"},"max_response_bytes":{"type":"integer","minimum":1},"hint":{"type":"string"}},"additionalProperties":false}
641    })
642}
643
644fn validate_openapi(root: &Path, value: &Value) -> Result<Vec<String>> {
645    let mut failures = Vec::new();
646    if value.get("openapi").and_then(Value::as_str) != Some("3.1.0") {
647        failures.push("OpenAPI version must be 3.1.0".to_owned());
648    }
649    for path in required_openapi_paths(root)? {
650        if value
651            .pointer(&format!("/paths/{}", escape_pointer(&path)))
652            .is_none()
653        {
654            failures.push(format!("missing path {path}"));
655        }
656    }
657    if let Some(paths) = value.get("paths").and_then(Value::as_object) {
658        for (path, methods) in paths {
659            if let Some(methods) = methods.as_object() {
660                for (method, operation) in methods {
661                    if operation.get("operationId").is_none_or(Value::is_null) {
662                        failures.push(format!(
663                            "{} {path} is missing operationId",
664                            method.to_uppercase()
665                        ));
666                    }
667                }
668            }
669        }
670    }
671
672    let entries = action_entries(root)?;
673    if entries.len() != action_spec_count(root)? {
674        failures.push(format!(
675            "ActionSpec parser drifted: parsed {} entries from {} specs",
676            entries.len(),
677            action_spec_count(root)?
678        ));
679    }
680    let expected: Vec<String> = entries
681        .iter()
682        .filter(|entry| entry.transport == "Any")
683        .map(|entry| entry.name.clone())
684        .collect();
685    let action_enum = value
686        .pointer("/components/schemas/ActionName/enum")
687        .cloned()
688        .unwrap_or(Value::Null);
689    if action_enum != json!(expected) {
690        failures.push(format!(
691            "ActionName enum drifted: expected {expected:?}, got {action_enum}"
692        ));
693    }
694    let missing_route_metadata: Vec<String> = entries
695        .iter()
696        .filter(|entry| entry.transport == "Any")
697        .filter(|entry| entry.rest_method.is_none() || entry.rest_path.is_none())
698        .map(|entry| entry.name.clone())
699        .collect();
700    if !missing_route_metadata.is_empty() {
701        failures.push(format!(
702            "ACTION_SPECS entries are missing REST route metadata: {missing_route_metadata:?}"
703        ));
704    }
705    for name in entries
706        .iter()
707        .filter(|entry| entry.transport == "McpOnly")
708        .map(|entry| &entry.name)
709    {
710        if action_enum
711            .as_array()
712            .is_some_and(|items| items.iter().any(|item| item.as_str() == Some(name)))
713        {
714            failures.push(format!(
715                "MCP-only action {name:?} must not appear in REST ActionName enum"
716            ));
717        }
718    }
719    if value.pointer("/x-soma/rest_actions") != Some(&json!(expected)) {
720        failures.push(format!(
721            "x-soma rest_actions drifted: expected {expected:?}, got {}",
722            value
723                .pointer("/x-soma/rest_actions")
724                .unwrap_or(&Value::Null)
725        ));
726    }
727    let expected_mcp_only: Vec<String> = entries
728        .iter()
729        .filter(|entry| entry.transport == "McpOnly")
730        .map(|entry| entry.name.clone())
731        .collect();
732    if value.pointer("/x-soma/mcp_only_actions") != Some(&json!(expected_mcp_only)) {
733        failures.push("x-soma mcp_only_actions drifted".to_owned());
734    }
735    for action in entries
736        .iter()
737        .filter(|entry| entry.transport == "Any")
738        .filter(|entry| entry.rest_method.is_some() && entry.rest_path.is_some())
739    {
740        let method = action.rest_method.as_deref().unwrap().to_ascii_lowercase();
741        let path = action.rest_path.as_deref().unwrap();
742        if !expected.iter().any(|expected| expected == &action.name) {
743            continue;
744        }
745        if value
746            .pointer(&format!("/paths/{}/{}", escape_pointer(path), method))
747            .is_none()
748        {
749            failures.push(format!(
750                "missing direct REST operation {} {path}",
751                method.to_uppercase()
752            ));
753        }
754    }
755    if value.pointer("/paths/~1v1~1example").is_some() {
756        failures.push("/v1/soma must not be present; REST uses direct routes only".to_owned());
757    }
758    if value.pointer("/paths/~1v1~1capabilities/get/responses/200/content/application~1json/schema")
759        != Some(&schema_ref("CapabilitiesResponse"))
760    {
761        failures.push("/v1/capabilities must return CapabilitiesResponse".to_owned());
762    }
763    if value
764        .pointer("/components/schemas/StatusResponse/properties/api_url")
765        .is_some()
766    {
767        failures.push(
768            "StatusResponse must not advertise api_url on the public status schema".to_owned(),
769        );
770    }
771    Ok(failures)
772}
773
774fn render_schema_docs(root: &Path) -> Result<String> {
775    let actions = action_entries(root)?;
776    let mut lines = vec![
777        "---".to_owned(),
778        "title: \"MCP Schema Contract\"".to_owned(),
779        "created: 2026-05-14".to_owned(),
780        "updated: 2026-07-30".to_owned(),
781        "---".to_owned(),
782        "".to_owned(),
783        "# MCP Schema Contract".to_owned(),
784        "".to_owned(),
785        "Generated from `crates/soma/domain/src/actions.rs` and checked against the schema, README, skill docs, help text, and scope routing.".to_owned(),
786        "".to_owned(),
787        "Run:".to_owned(),
788        "".to_owned(),
789        "```bash".to_owned(),
790        "cargo xtask check-schema-docs --write".to_owned(),
791        "cargo xtask check-schema-docs --check".to_owned(),
792        "```".to_owned(),
793        "".to_owned(),
794        "## Tool".to_owned(),
795        "".to_owned(),
796        "| Field | Value |".to_owned(),
797        "|---|---|".to_owned(),
798        "| Tool name | `soma` |".to_owned(),
799        "| Schema resource | `soma://schema/mcp-tool` |".to_owned(),
800        "| Dispatch parameter | `action` |".to_owned(),
801        "".to_owned(),
802        "## Actions".to_owned(),
803        "".to_owned(),
804        "| Action | Scope | Cost | Description |".to_owned(),
805        "|---|---|---|---|".to_owned(),
806    ];
807    for action in &actions {
808        lines.push(format!(
809            "| `{}` | {} | `{}` | {} |",
810            action.name, action.doc_scope, action.cost, action.description
811        ));
812    }
813    lines.extend(SCHEMA_DOC_TAIL.iter().map(|line| (*line).to_owned()));
814    Ok(lines.join("\n"))
815}
816
817const SCHEMA_DOC_TAIL: &[&str] = &[
818    "",
819    "## Drift Rules",
820    "",
821    "- `ACTION_SPECS` in `crates/soma/domain/src/actions.rs` is the canonical action and scope list.",
822    "- Action cost is planner metadata. Use `cheap` for first-pass reads, `moderate` for bounded workflow setup, `expensive` for broad scans or long-running work, and `write` for mutating operations.",
823    "- `crates/soma/mcp/src/schemas.rs` must derive its enum from `ACTION_SPECS`.",
824    "- The MCP tool schema must reject unknown top-level parameters except reserved `_response_*` continuation fields, and encode action-specific requirements that fit the single-tool dispatch model.",
825    "- `help` is intentionally public and must have no required scope.",
826    "- `crates/soma/mcp/src/tools.rs`, `README.md`, and `plugins/soma/skills/soma/SKILL.md` must mention every action.",
827    "- `crates/soma/mcp/src/rmcp_server.rs` owns stable resources and must keep `soma://schema/mcp-tool` wired to `tool_definitions()`.",
828    "- `crates/soma/mcp/src/prompts.rs` owns stable prompts and must keep `quick_start` covered by prompt tests.",
829    "",
830    "## Resources",
831    "",
832    "| URI | Source | Contract |",
833    "|---|---|---|",
834    "| `soma://schema/mcp-tool` | `crates/soma/mcp/src/rmcp_server.rs` | Returns `tool_definitions()` as `application/json`. |",
835    "",
836    "## Prompts",
837    "",
838    "| Prompt | Source | Contract |",
839    "|---|---|---|",
840    "| `quick_start` | `crates/soma/mcp/src/prompts.rs` | Guides a client to call `status` and `greet`. |",
841    "",
842    "## Input Validation",
843    "",
844    "- `action` is always required.",
845    "- `echo` conditionally requires non-empty `message`.",
846    "- `greet` accepts optional `name` and defaults to World.",
847    "- `elicit_name` and `scaffold_intent` collect their extra fields through MCP elicitation, not direct tool-call arguments.",
848    "- Unknown top-level parameters are rejected by the schema except reserved MCP adapter continuation fields.",
849    "",
850    "## Reserved Adapter Parameters",
851    "",
852    "Oversized MCP responses are returned as `kind=mcp_response_page` envelopes. Continuation calls reuse the same tool and original arguments, plus these reserved fields:",
853    "",
854    "| Parameter | Type | Purpose |",
855    "|---|---|---|",
856    "| `_response_cursor` | string | Cursor for cached serialized response data. Required with `_response_offset`. |",
857    "| `_response_offset` | integer | Byte offset into the cached serialized response. |",
858    "| `_response_page_bytes` | integer | Page size in bytes, from 1 to 16000. |",
859    "",
860    "The adapter strips these fields before dispatching to the service layer.",
861    "",
862];
863
864fn check_schema_mentions(root: &Path, actions: &[ActionEntry]) -> Result<Vec<String>> {
865    let mut failures = Vec::new();
866    for (label, path) in [
867        ("README.md", root.join("README.md")),
868        (
869            "plugins/soma/skills/soma/SKILL.md",
870            root.join("plugins/soma/skills/soma/SKILL.md"),
871        ),
872    ] {
873        let text = read(&path)?;
874        for action in actions {
875            if !text.contains(&action.name) {
876                failures.push(format!("{label} does not mention action `{}`", action.name));
877            }
878        }
879    }
880    let tools_text = read(root.join("crates/soma/mcp/src/tools.rs"))?;
881    if !tools_text.contains("ACTION_SPECS") || !tools_text.contains("build_help_text") {
882        failures.push(
883            "crates/soma/mcp/src/tools.rs HELP_TEXT must be derived from ACTION_SPECS".to_owned(),
884        );
885    }
886    Ok(failures)
887}
888
889fn check_schema_scope(root: &Path, actions: &[ActionEntry]) -> Result<Vec<String>> {
890    let mut failures = Vec::new();
891    let action_names: BTreeSet<&str> = actions.iter().map(|action| action.name.as_str()).collect();
892    let scope_names: BTreeSet<&str> = actions.iter().map(|action| action.name.as_str()).collect();
893    let cost_names: BTreeSet<&str> = actions.iter().map(|action| action.name.as_str()).collect();
894    if scope_names != action_names {
895        failures.push("ACTION_SPECS action names and scope entries are out of sync".to_owned());
896    }
897    if cost_names != action_names {
898        failures.push("ACTION_SPECS action names and cost entries are out of sync".to_owned());
899    }
900    if actions
901        .iter()
902        .find(|action| action.name == "help")
903        .map(|action| action.scope.as_str())
904        != Some("public")
905    {
906        failures.push("help must be public".to_owned());
907    }
908    for action in actions.iter().filter(|action| action.name != "help") {
909        if action.scope == "public" {
910            failures.push(format!(
911                "action `{}` must declare a required scope",
912                action.name
913            ));
914        }
915    }
916    let schema_text = read(root.join("crates/soma/mcp/src/schemas.rs"))?;
917    if !schema_text.contains("tool_definitions_for_catalogs")
918        || !schema_text.contains("action_names(catalogs)")
919    {
920        failures.push(
921            "crates/soma/mcp/src/schemas.rs must derive action enum from provider catalogs"
922                .to_owned(),
923        );
924    }
925    if !schema_text.contains("\"additionalProperties\": false") {
926        failures.push(
927            "crates/soma/mcp/src/schemas.rs must reject unknown top-level properties".to_owned(),
928        );
929    }
930    if !schema_text.contains("required_param_conditionals(catalogs)")
931        || !schema_text.contains("\"then\": { \"required\": required }")
932    {
933        failures.push("crates/soma/mcp/src/schemas.rs must derive required action parameters from provider catalogs".to_owned());
934    }
935    // The `ServerHandler` impl is split across rmcp_server.rs and its
936    // rmcp_server/ submodules (the module outgrew the PATTERNS.md size hard
937    // limit), so scan the whole module tree rather than just the root file.
938    let mut rmcp_server_text = read(root.join("crates/soma/mcp/src/rmcp_server.rs"))?;
939    let rmcp_server_dir = root.join("crates/soma/mcp/src/rmcp_server");
940    if rmcp_server_dir.is_dir() {
941        let mut submodules = std::fs::read_dir(&rmcp_server_dir)?
942            .filter_map(std::result::Result::ok)
943            .map(|entry| entry.path())
944            .filter(|path| path.extension().is_some_and(|ext| ext == "rs"))
945            .collect::<Vec<_>>();
946        submodules.sort();
947        for path in submodules {
948            rmcp_server_text.push_str(&read(path)?);
949        }
950    }
951    if !rmcp_server_text.contains("soma://schema/mcp-tool")
952        || !rmcp_server_text.contains("tool_definitions_for_state")
953    {
954        failures.push("crates/soma/mcp/src/rmcp_server.rs (or its rmcp_server/ submodules) must expose the schema resource from the state-backed tool definitions".to_owned());
955    }
956    let prompts_text = read(root.join("crates/soma/mcp/src/prompts.rs"))?;
957    if !prompts_text.contains("quick_start") {
958        failures.push("crates/soma/mcp/src/prompts.rs must expose quick_start prompt".to_owned());
959    }
960    Ok(failures)
961}
962
963fn validate_scaffold_schema(path: &Path) -> Result<()> {
964    let schema = load_json(path)?;
965    require(
966        schema.as_object().is_some(),
967        format!("{}: schema root must be an object", path.display()),
968    )?;
969    require(
970        schema.get("$schema").and_then(Value::as_str)
971            == Some("https://json-schema.org/draft/2020-12/schema"),
972        format!("{}: expected JSON Schema draft 2020-12", path.display()),
973    )?;
974    require(
975        schema
976            .pointer("/properties/kind/const")
977            .and_then(Value::as_str)
978            == Some("soma_scaffold_intent"),
979        format!("{}: kind const drifted", path.display()),
980    )?;
981    let expected_required = str_set(&[
982        "kind",
983        "schema_version",
984        "server_category",
985        "required_surfaces",
986        "project",
987        "upstream",
988        "runtime",
989        "mcp_primitives",
990        "deployment",
991        "plugins",
992        "publish_mcp",
993        "crawl_docs",
994        "handoff",
995        "policy",
996    ]);
997    let required = string_set(schema.get("required").unwrap_or(&Value::Null));
998    require(
999        required == expected_required,
1000        format!(
1001            "{}: root required fields drifted: {:?}",
1002            path.display(),
1003            sorted_set(&required)
1004        ),
1005    )?;
1006    let properties = schema
1007        .get("properties")
1008        .and_then(Value::as_object)
1009        .map(|map| map.keys().map(String::as_str).collect::<BTreeSet<_>>())
1010        .unwrap_or_default();
1011    require(
1012        expected_required.iter().all(|key| properties.contains(key)),
1013        format!(
1014            "{}: root properties missing required fields",
1015            path.display()
1016        ),
1017    )?;
1018    require(
1019        !properties.contains("actions"),
1020        format!("{}: legacy actions property must not exist", path.display()),
1021    )?;
1022    require(
1023        !schema.to_string().contains("resource_groups"),
1024        format!(
1025            "{}: legacy resource_groups field must not exist",
1026            path.display()
1027        ),
1028    )?;
1029    let auth_enum = string_set(
1030        schema
1031            .pointer("/properties/upstream/properties/auth_kind/enum")
1032            .unwrap_or(&Value::Null),
1033    );
1034    require(
1035        auth_enum == str_set(AUTH_KINDS),
1036        format!(
1037            "{}: auth_kind enum mismatch: {:?}",
1038            path.display(),
1039            sorted_set(&auth_enum)
1040        ),
1041    )?;
1042    Ok(())
1043}
1044
1045fn validate_scaffold_payload(payload: &Value, source: &Path) -> Result<()> {
1046    let Some(obj) = payload.as_object() else {
1047        bail!("{}: root must be an object", source.display());
1048    };
1049    let root_keys = str_set(&[
1050        "kind",
1051        "schema_version",
1052        "server_category",
1053        "required_surfaces",
1054        "project",
1055        "upstream",
1056        "runtime",
1057        "mcp_primitives",
1058        "deployment",
1059        "plugins",
1060        "publish_mcp",
1061        "crawl_docs",
1062        "handoff",
1063        "policy",
1064    ]);
1065    require_keys(obj, source, "", &root_keys)?;
1066    require_no_extra(obj, source, "", &root_keys)?;
1067    require(
1068        payload.get("kind").and_then(Value::as_str) == Some("soma_scaffold_intent"),
1069        format!("{}: invalid kind", source.display()),
1070    )?;
1071    require(
1072        payload.get("schema_version").and_then(Value::as_i64) == Some(1),
1073        format!("{}: invalid schema_version", source.display()),
1074    )?;
1075
1076    let category = required_str(payload, "server_category", source)?;
1077    require(
1078        matches!(category, "upstream-client" | "application-platform"),
1079        format!("{}: invalid server_category", source.display()),
1080    )?;
1081    let surfaces = unique_list(
1082        payload.get("required_surfaces").unwrap_or(&Value::Null),
1083        format!("{}: required_surfaces", source.display()),
1084        Some(SURFACES),
1085    )?;
1086    if category == "upstream-client" {
1087        require(
1088            surfaces == ["mcp", "cli"],
1089            format!(
1090                "{}: upstream-client must use ['mcp', 'cli']",
1091                source.display()
1092            ),
1093        )?;
1094    } else {
1095        require(
1096            string_slice_set(&surfaces) == str_set(&["api", "cli", "mcp", "web"]),
1097            format!(
1098                "{}: application-platform must include api, cli, mcp, web",
1099                source.display()
1100            ),
1101        )?;
1102    }
1103
1104    validate_project(payload.get("project").unwrap_or(&Value::Null), source)?;
1105    validate_upstream(payload.get("upstream").unwrap_or(&Value::Null), source)?;
1106    validate_runtime(
1107        payload.get("runtime").unwrap_or(&Value::Null),
1108        source,
1109        category,
1110    )?;
1111    unique_list(
1112        payload.get("mcp_primitives").unwrap_or(&Value::Null),
1113        format!("{}: mcp_primitives", source.display()),
1114        Some(PRIMITIVES),
1115    )?;
1116    require(
1117        in_allowed(required_str(payload, "deployment", source)?, DEPLOYMENTS),
1118        format!("{}: invalid deployment", source.display()),
1119    )?;
1120    unique_list(
1121        payload.get("plugins").unwrap_or(&Value::Null),
1122        format!("{}: plugins", source.display()),
1123        Some(PLUGINS),
1124    )?;
1125    require(
1126        payload
1127            .get("publish_mcp")
1128            .and_then(Value::as_bool)
1129            .is_some(),
1130        format!("{}: publish_mcp must be boolean", source.display()),
1131    )?;
1132    validate_crawl(payload.get("crawl_docs").unwrap_or(&Value::Null), source)?;
1133    validate_handoff(payload.get("handoff").unwrap_or(&Value::Null), source)?;
1134    validate_policy(payload.get("policy").unwrap_or(&Value::Null), source)?;
1135    Ok(())
1136}
1137
1138fn validate_project(value: &Value, source: &Path) -> Result<()> {
1139    let obj = object(value, source, "project")?;
1140    let keys = str_set(&[
1141        "display_name",
1142        "crate_name",
1143        "binary_name",
1144        "service_name",
1145        "env_prefix",
1146    ]);
1147    require_keys(obj, source, "project", &keys)?;
1148    require_no_extra(obj, source, "project", &keys)?;
1149    require(
1150        !required_obj_str(obj, "display_name", source, "project")?.is_empty(),
1151        format!("{}: project.display_name required", source.display()),
1152    )?;
1153    require(
1154        is_crate_name(required_obj_str(obj, "crate_name", source, "project")?),
1155        format!("{}: invalid crate_name", source.display()),
1156    )?;
1157    require(
1158        is_crate_name(required_obj_str(obj, "binary_name", source, "project")?),
1159        format!("{}: invalid binary_name", source.display()),
1160    )?;
1161    require(
1162        is_ident(required_obj_str(obj, "service_name", source, "project")?),
1163        format!("{}: invalid service_name", source.display()),
1164    )?;
1165    require(
1166        is_env(required_obj_str(obj, "env_prefix", source, "project")?),
1167        format!("{}: invalid env_prefix", source.display()),
1168    )?;
1169    Ok(())
1170}
1171
1172fn validate_upstream(value: &Value, source: &Path) -> Result<()> {
1173    let obj = object(value, source, "upstream")?;
1174    let keys = str_set(&["base_url_env", "auth_kind"]);
1175    require_no_extra(obj, source, "upstream", &keys)?;
1176    require(
1177        is_api_url_env(required_obj_str(obj, "base_url_env", source, "upstream")?),
1178        format!("{}: invalid upstream.base_url_env", source.display()),
1179    )?;
1180    require(
1181        in_allowed(
1182            required_obj_str(obj, "auth_kind", source, "upstream")?,
1183            AUTH_KINDS,
1184        ),
1185        format!("{}: invalid auth_kind", source.display()),
1186    )?;
1187    Ok(())
1188}
1189
1190fn validate_runtime(value: &Value, source: &Path, category: &str) -> Result<()> {
1191    let obj = object(value, source, "runtime")?;
1192    let keys = str_set(&["host", "port", "binary_profile", "mcp_transport"]);
1193    require_no_extra(obj, source, "runtime", &keys)?;
1194    require(
1195        !required_obj_str(obj, "host", source, "runtime")?.is_empty(),
1196        format!("{}: runtime.host required", source.display()),
1197    )?;
1198    let port = obj.get("port").and_then(Value::as_i64).unwrap_or_default();
1199    require(
1200        (1..=65535).contains(&port),
1201        format!("{}: runtime.port out of range", source.display()),
1202    )?;
1203    let profile = required_obj_str(obj, "binary_profile", source, "runtime")?;
1204    require(
1205        in_allowed(profile, BINARY_PROFILES),
1206        format!("{}: invalid runtime.binary_profile", source.display()),
1207    )?;
1208    if category == "upstream-client" {
1209        require(
1210            profile == "local-adapter",
1211            format!(
1212                "{}: upstream-client must default to local-adapter binary profile",
1213                source.display()
1214            ),
1215        )?;
1216    } else {
1217        require(
1218            profile == "server-full",
1219            format!(
1220                "{}: application-platform must default to server-full binary profile",
1221                source.display()
1222            ),
1223        )?;
1224    }
1225    require(
1226        in_allowed(
1227            required_obj_str(obj, "mcp_transport", source, "runtime")?,
1228            TRANSPORTS,
1229        ),
1230        format!("{}: invalid runtime.mcp_transport", source.display()),
1231    )?;
1232    Ok(())
1233}
1234
1235fn validate_crawl(value: &Value, source: &Path) -> Result<()> {
1236    let obj = object(value, source, "crawl_docs")?;
1237    let keys = str_set(&["urls", "repos", "search_topics"]);
1238    require_no_extra(obj, source, "crawl_docs", &keys)?;
1239    for key in ["urls", "repos", "search_topics"] {
1240        let values = unique_list(
1241            obj.get(key).unwrap_or(&Value::Null),
1242            format!("{}: crawl_docs.{key}", source.display()),
1243            None,
1244        )?;
1245        require(
1246            values.iter().all(|item| !item.is_empty()),
1247            format!(
1248                "{}: crawl_docs.{key} entries must be non-empty strings",
1249                source.display()
1250            ),
1251        )?;
1252        if matches!(key, "urls" | "repos") {
1253            require(
1254                values.iter().all(|item| is_uri(item)),
1255                format!(
1256                    "{}: crawl_docs.{key} entries must be URIs",
1257                    source.display()
1258                ),
1259            )?;
1260        }
1261    }
1262    Ok(())
1263}
1264
1265fn validate_handoff(value: &Value, source: &Path) -> Result<()> {
1266    let obj = object(value, source, "handoff")?;
1267    let keys = str_set(&["recommended_skill", "instructions"]);
1268    require_keys(obj, source, "handoff", &keys)?;
1269    require_no_extra(obj, source, "handoff", &keys)?;
1270    require(
1271        required_obj_str(obj, "recommended_skill", source, "handoff")? == "scaffold-project",
1272        format!(
1273            "{}: handoff.recommended_skill must be scaffold-project",
1274            source.display()
1275        ),
1276    )?;
1277    require(
1278        required_obj_str(obj, "instructions", source, "handoff")?
1279            .to_lowercase()
1280            .contains("approve"),
1281        format!(
1282            "{}: handoff instructions must mention approval",
1283            source.display()
1284        ),
1285    )?;
1286    Ok(())
1287}
1288
1289fn validate_policy(value: &Value, source: &Path) -> Result<()> {
1290    let obj = object(value, source, "policy")?;
1291    let keys = str_set(&[
1292        "business_action_minimum_surfaces",
1293        "upstream_client_surfaces",
1294        "application_platform_surfaces",
1295        "binary_profiles",
1296    ]);
1297    require_keys(obj, source, "policy", &keys)?;
1298    require_no_extra(obj, source, "policy", &keys)?;
1299    require(
1300        string_vec(
1301            obj.get("business_action_minimum_surfaces")
1302                .unwrap_or(&Value::Null),
1303        ) == ["mcp", "cli"],
1304        format!(
1305            "{}: business action minimum must be ['mcp', 'cli']",
1306            source.display()
1307        ),
1308    )?;
1309    require(
1310        string_vec(obj.get("upstream_client_surfaces").unwrap_or(&Value::Null)) == ["mcp", "cli"],
1311        format!("{}: upstream policy mismatch", source.display()),
1312    )?;
1313    require(
1314        string_slice_set(&string_vec(
1315            obj.get("application_platform_surfaces")
1316                .unwrap_or(&Value::Null),
1317        )) == str_set(&["api", "cli", "mcp", "web"]),
1318        format!("{}: application policy mismatch", source.display()),
1319    )?;
1320    let profiles = object(
1321        obj.get("binary_profiles").unwrap_or(&Value::Null),
1322        source,
1323        "policy.binary_profiles",
1324    )?;
1325    let profile_keys = str_set(&[
1326        "upstream_client_default",
1327        "application_platform_default",
1328        "gateway_shared_default",
1329    ]);
1330    require_no_extra(profiles, source, "policy.binary_profiles", &profile_keys)?;
1331    require(
1332        required_obj_str(
1333            profiles,
1334            "upstream_client_default",
1335            source,
1336            "policy.binary_profiles",
1337        )? == "local-adapter",
1338        format!(
1339            "{}: upstream binary profile policy mismatch",
1340            source.display()
1341        ),
1342    )?;
1343    require(
1344        required_obj_str(
1345            profiles,
1346            "application_platform_default",
1347            source,
1348            "policy.binary_profiles",
1349        )? == "server-full",
1350        format!(
1351            "{}: application binary profile policy mismatch",
1352            source.display()
1353        ),
1354    )?;
1355    require(
1356        required_obj_str(
1357            profiles,
1358            "gateway_shared_default",
1359            source,
1360            "policy.binary_profiles",
1361        )? == "server-full",
1362        format!(
1363            "{}: gateway binary profile policy mismatch",
1364            source.display()
1365        ),
1366    )?;
1367    Ok(())
1368}
1369
1370fn action_entries(root: &Path) -> Result<Vec<ActionEntry>> {
1371    let mut entries = parse_action_entries(&read(root.join("crates/soma/domain/src/actions.rs"))?);
1372    let python_entries = parse_action_entries(&read(
1373        root.join("crates/soma/domain/src/actions_python.rs"),
1374    )?);
1375    let insertion = entries
1376        .iter()
1377        .position(|entry| entry.name == "status")
1378        .map_or(entries.len(), |index| index + 1);
1379    entries.splice(insertion..insertion, python_entries);
1380    Ok(entries)
1381}
1382
1383fn extract_actions(root: &Path) -> Result<Vec<ActionEntry>> {
1384    action_entries(root)
1385}
1386
1387fn parse_action_entries(text: &str) -> Vec<ActionEntry> {
1388    action_blocks(text)
1389        .into_iter()
1390        .filter_map(|entry| {
1391            let name = field_string(entry, "name")?;
1392            let description = field_string(entry, "description")?;
1393            let scope_expr = field_expr(entry, "required_scope")?;
1394            let transport = enum_variant(entry, "transport", "ActionTransport")?;
1395            let rest_method = option_string_expr(field_expr(entry, "rest_method")?.as_str())?;
1396            let rest_path = option_string_expr(field_expr(entry, "rest_path")?.as_str())?;
1397            let cost = enum_variant(entry, "cost", "ActionCost")?.to_lowercase();
1398            let (scope, doc_scope) = match scope_expr.trim() {
1399                "None" => ("public".to_owned(), "public".to_owned()),
1400                "Some(READ_SCOPE)" => ("soma:read".to_owned(), "`soma:read`".to_owned()),
1401                "Some(WRITE_SCOPE)" => ("soma:write".to_owned(), "`soma:write`".to_owned()),
1402                _ => ("soma:__deny__".to_owned(), "`soma:__deny__`".to_owned()),
1403            };
1404            Some(ActionEntry {
1405                name,
1406                description,
1407                scope,
1408                doc_scope,
1409                transport,
1410                rest_method,
1411                rest_path,
1412                cost,
1413            })
1414        })
1415        .collect()
1416}
1417
1418fn action_spec_count(root: &Path) -> Result<usize> {
1419    let actions = read(root.join("crates/soma/domain/src/actions.rs"))?;
1420    let python = read(root.join("crates/soma/domain/src/actions_python.rs"))?;
1421    Ok(actions.matches("    ActionSpec {").count() + python.matches("= ActionSpec {").count())
1422}
1423
1424fn action_blocks(text: &str) -> Vec<&str> {
1425    let mut blocks = Vec::new();
1426    let mut rest = text;
1427    while let Some(start) = rest.find("ActionSpec") {
1428        rest = &rest[start + "ActionSpec".len()..];
1429        let Some(open) = rest.find('{') else {
1430            break;
1431        };
1432        rest = &rest[open + 1..];
1433        let Some(close) = rest.find('}') else {
1434            break;
1435        };
1436        blocks.push(&rest[..close]);
1437        rest = &rest[close + 1..];
1438    }
1439    blocks
1440}
1441
1442fn field_string(entry: &str, name: &str) -> Option<String> {
1443    let marker = format!("{name}:");
1444    let start = entry.find(&marker)? + marker.len();
1445    let after = entry[start..].trim_start();
1446    let after = after.strip_prefix('"')?;
1447    let end = after.find('"')?;
1448    Some(after[..end].to_owned())
1449}
1450
1451fn field_expr(entry: &str, name: &str) -> Option<String> {
1452    let marker = format!("{name}:");
1453    let start = entry.find(&marker)? + marker.len();
1454    let after = entry[start..].trim_start();
1455    let end = after.find([',', '\n']).unwrap_or(after.len());
1456    Some(after[..end].trim().to_owned())
1457}
1458
1459fn enum_variant(entry: &str, field: &str, enum_name: &str) -> Option<String> {
1460    let expr = field_expr(entry, field)?;
1461    let marker = format!("{enum_name}::");
1462    let variant = expr.strip_prefix(&marker)?;
1463    Some(variant.trim().to_owned())
1464}
1465
1466fn package_version(root: &Path) -> Result<String> {
1467    for manifest in [root.join("apps/soma/Cargo.toml"), root.join("Cargo.toml")] {
1468        if !manifest.exists() {
1469            continue;
1470        }
1471        let text = read(&manifest)?;
1472        for line in text.lines() {
1473            let line = line.trim();
1474            if let Some(value) = line
1475                .strip_prefix("version")
1476                .and_then(|line| line.trim_start().strip_prefix('='))
1477            {
1478                return Ok(value.trim().trim_matches('"').to_owned());
1479            }
1480        }
1481    }
1482    bail!("could not find package version in Cargo.toml")
1483}
1484
1485fn default_mcp_port(root: &Path) -> Result<u16> {
1486    let text = read(root.join("crates/soma/config/src/config.rs"))?;
1487    let Some(start) = text.find("fn default_mcp_port()") else {
1488        bail!("could not find default_mcp_port in config.rs");
1489    };
1490    let after = &text[start..];
1491    let Some(open) = after.find('{') else {
1492        bail!("could not parse default_mcp_port in config.rs");
1493    };
1494    let Some(close) = after[open + 1..].find('}') else {
1495        bail!("could not parse default_mcp_port in config.rs");
1496    };
1497    let value = after[open + 1..open + 1 + close].trim();
1498    value
1499        .parse::<u16>()
1500        .with_context(|| format!("could not parse default_mcp_port value {value:?}"))
1501}
1502
1503fn option_string_expr(value: &str) -> Option<Option<String>> {
1504    let value = value.trim();
1505    if value == "None" {
1506        return Some(None);
1507    }
1508    let inner = value.strip_prefix("Some(\"")?.strip_suffix("\")")?;
1509    Some(Some(inner.to_owned()))
1510}
1511
1512fn schema_ref(name: &str) -> Value {
1513    json!({"$ref": format!("#/components/schemas/{name}")})
1514}
1515
1516fn success_envelope(output: Value) -> Value {
1517    json!({
1518        "type": "object",
1519        "additionalProperties": false,
1520        "required": ["output", "request_id", "progress"],
1521        "properties": {
1522            "output": output,
1523            "request_id": {"type": "string", "minLength": 1},
1524            "progress": {
1525                "type": "array",
1526                "maxItems": 256,
1527                "items": schema_ref("ProviderProgressEvent")
1528            }
1529        }
1530    })
1531}
1532
1533fn param_example(action: &str) -> Value {
1534    match action {
1535        "greet" => json!({"name":"Alice"}),
1536        "echo" => json!({"message":"Hello!"}),
1537        "python_environment_prune_plan" => {
1538            json!({"stale_before_unix_seconds": 1_700_000_000, "max_entries": 100})
1539        }
1540        "python_environment_prune" => {
1541            json!({"stale_before_unix_seconds": 1_700_000_000, "max_entries": 100, "confirm": true})
1542        }
1543        "python_environment_repair" | "python_environment_update" => {
1544            json!({"provider_path": "example.py", "confirm": true})
1545        }
1546        "python_worker_cancel" | "python_worker_reset" => {
1547            json!({"provider": "example", "confirm": true})
1548        }
1549        "python_generation_rollback" => json!({"generation_id": 1, "confirm": true}),
1550        "python_graduation_status" => {
1551            json!({"workspace": "/srv/soma/graduations/example"})
1552        }
1553        "python_graduation_apply" => json!({
1554            "operation": "compare",
1555            "workspace": "/srv/soma/graduations/example",
1556            "component": "/srv/soma/graduations/example/artifacts/candidate.wasm",
1557            "fixtures": "/srv/soma/graduations/example/fixtures/conformance-v1.json",
1558            "confirm": true
1559        }),
1560        _ => json!({}),
1561    }
1562}
1563
1564fn rest_schemas(action: &str) -> Option<(Option<&'static str>, &'static str)> {
1565    REST_SCHEMAS
1566        .iter()
1567        .find(|(name, _, _)| *name == action)
1568        .map(|(_, request, response)| (*request, *response))
1569}
1570
1571fn title_no_underscore(value: &str) -> String {
1572    value
1573        .split('_')
1574        .filter(|part| !part.is_empty())
1575        .map(|part| {
1576            let mut chars = part.chars();
1577            match chars.next() {
1578                Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
1579                None => String::new(),
1580            }
1581        })
1582        .collect()
1583}
1584
1585fn required_openapi_paths(root: &Path) -> Result<Vec<String>> {
1586    let mut paths = vec![
1587        "/health".to_owned(),
1588        "/openapi.json".to_owned(),
1589        "/status".to_owned(),
1590        "/v1/capabilities".to_owned(),
1591        "/v1/providers".to_owned(),
1592        "/v1/tools/{action}".to_owned(),
1593    ];
1594    paths.extend(
1595        action_entries(root)?
1596            .into_iter()
1597            .filter(|entry| entry.transport == "Any")
1598            .filter_map(|entry| entry.rest_path),
1599    );
1600    Ok(paths)
1601}
1602
1603fn escape_pointer(value: &str) -> String {
1604    value.replace('~', "~0").replace('/', "~1")
1605}
1606
1607fn load_json(path: &Path) -> Result<Value> {
1608    let text = read(path)?;
1609    serde_json::from_str(&text).with_context(|| format!("{}: invalid JSON", path.display()))
1610}
1611
1612fn read(path: impl AsRef<Path>) -> Result<String> {
1613    let path = path.as_ref();
1614    fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))
1615}
1616
1617fn canonical_json(value: &Value) -> Result<String> {
1618    Ok(format!("{}\n", serde_json::to_string_pretty(value)?))
1619}
1620
1621fn current_dir() -> Result<PathBuf> {
1622    std::env::current_dir().context("failed to read current directory")
1623}
1624
1625fn relative_display(root: &Path, path: &Path) -> String {
1626    path.strip_prefix(root)
1627        .unwrap_or(path)
1628        .to_string_lossy()
1629        .replace('\\', "/")
1630}
1631
1632fn finish_failures(failures: Vec<String>) -> Result<()> {
1633    if failures.is_empty() {
1634        return Ok(());
1635    }
1636    for failure in &failures {
1637        eprintln!("FAIL: {failure}");
1638    }
1639    bail!("check failed")
1640}
1641
1642fn require(condition: bool, message: String) -> Result<()> {
1643    if condition { Ok(()) } else { bail!(message) }
1644}
1645
1646fn object<'a>(
1647    value: &'a Value,
1648    source: &Path,
1649    path: &str,
1650) -> Result<&'a serde_json::Map<String, Value>> {
1651    value
1652        .as_object()
1653        .with_context(|| format!("{}: {path} must be object", source.display()))
1654}
1655
1656fn require_keys(
1657    obj: &serde_json::Map<String, Value>,
1658    source: &Path,
1659    path: &str,
1660    keys: &BTreeSet<&str>,
1661) -> Result<()> {
1662    let actual: BTreeSet<&str> = obj.keys().map(String::as_str).collect();
1663    let missing: Vec<&str> = keys.difference(&actual).copied().collect();
1664    let label = if path.is_empty() {
1665        source.display().to_string()
1666    } else {
1667        format!("{}: {path}", source.display())
1668    };
1669    require(
1670        missing.is_empty(),
1671        format!("{label}: missing required keys: {missing:?}"),
1672    )
1673}
1674
1675fn require_no_extra(
1676    obj: &serde_json::Map<String, Value>,
1677    source: &Path,
1678    path: &str,
1679    keys: &BTreeSet<&str>,
1680) -> Result<()> {
1681    let actual: BTreeSet<&str> = obj.keys().map(String::as_str).collect();
1682    let extra: Vec<&str> = actual.difference(keys).copied().collect();
1683    let label = if path.is_empty() {
1684        source.display().to_string()
1685    } else {
1686        format!("{}: {path}", source.display())
1687    };
1688    require(
1689        extra.is_empty(),
1690        format!("{label}: unexpected keys: {extra:?}"),
1691    )
1692}
1693
1694fn required_str<'a>(payload: &'a Value, key: &str, source: &Path) -> Result<&'a str> {
1695    payload
1696        .get(key)
1697        .and_then(Value::as_str)
1698        .with_context(|| format!("{}: {key} required", source.display()))
1699}
1700
1701fn required_obj_str<'a>(
1702    obj: &'a serde_json::Map<String, Value>,
1703    key: &str,
1704    source: &Path,
1705    path: &str,
1706) -> Result<&'a str> {
1707    obj.get(key)
1708        .and_then(Value::as_str)
1709        .with_context(|| format!("{}: {path}.{key} required", source.display()))
1710}
1711
1712fn unique_list(value: &Value, path: String, allowed: Option<&[&str]>) -> Result<Vec<String>> {
1713    let Some(items) = value.as_array() else {
1714        bail!("{path}: expected list");
1715    };
1716    let values: Vec<String> = items
1717        .iter()
1718        .filter_map(Value::as_str)
1719        .map(str::to_owned)
1720        .collect();
1721    require(
1722        values.len() == items.len(),
1723        format!("{path}: entries must be strings"),
1724    )?;
1725    let set: BTreeSet<&str> = values.iter().map(String::as_str).collect();
1726    require(
1727        values.len() == set.len(),
1728        format!("{path}: duplicate values are not allowed"),
1729    )?;
1730    if let Some(allowed) = allowed {
1731        let invalid: Vec<&str> = set
1732            .iter()
1733            .copied()
1734            .filter(|item| !in_allowed(item, allowed))
1735            .collect();
1736        require(
1737            invalid.is_empty(),
1738            format!(
1739                "{path}: invalid values: {invalid:?}; allowed={:?}",
1740                sorted_slice(allowed)
1741            ),
1742        )?;
1743    }
1744    Ok(values)
1745}
1746
1747fn string_vec(value: &Value) -> Vec<String> {
1748    value
1749        .as_array()
1750        .map(|items| {
1751            items
1752                .iter()
1753                .filter_map(Value::as_str)
1754                .map(str::to_owned)
1755                .collect()
1756        })
1757        .unwrap_or_default()
1758}
1759
1760fn string_set(value: &Value) -> BTreeSet<&str> {
1761    value
1762        .as_array()
1763        .map(|items| items.iter().filter_map(Value::as_str).collect())
1764        .unwrap_or_default()
1765}
1766
1767fn str_set<'a>(items: &'a [&'a str]) -> BTreeSet<&'a str> {
1768    items.iter().copied().collect()
1769}
1770
1771fn string_slice_set(items: &[String]) -> BTreeSet<&str> {
1772    items.iter().map(String::as_str).collect()
1773}
1774
1775fn sorted_set(set: &BTreeSet<&str>) -> Vec<String> {
1776    set.iter().map(|item| (*item).to_owned()).collect()
1777}
1778
1779fn sorted_slice<'a>(items: &'a [&'a str]) -> Vec<&'a str> {
1780    let mut items = items.to_vec();
1781    items.sort_unstable();
1782    items
1783}
1784
1785fn in_allowed(value: &str, allowed: &[&str]) -> bool {
1786    allowed.contains(&value)
1787}
1788
1789fn is_uri(value: &str) -> bool {
1790    for scheme in ["http://", "https://", "ssh://", "git://"] {
1791        if let Some(rest) = value.strip_prefix(scheme) {
1792            return !rest.split('/').next().unwrap_or("").is_empty();
1793        }
1794    }
1795    false
1796}
1797
1798fn is_crate_name(value: &str) -> bool {
1799    let mut chars = value.chars();
1800    matches!(chars.next(), Some(ch) if ch.is_ascii_lowercase())
1801        && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1802}
1803
1804fn is_ident(value: &str) -> bool {
1805    let mut chars = value.chars();
1806    matches!(chars.next(), Some(ch) if ch.is_ascii_lowercase())
1807        && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
1808}
1809
1810fn is_env(value: &str) -> bool {
1811    let mut chars = value.chars();
1812    matches!(chars.next(), Some(ch) if ch.is_ascii_uppercase())
1813        && chars.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
1814}
1815
1816fn is_api_url_env(value: &str) -> bool {
1817    is_env(value) && value.ends_with("_API_URL")
1818}
1819
1820#[cfg(test)]
1821mod tests {
1822    use super::{
1823        allowed_unicode, escape_char, is_api_url_env, is_crate_name, is_ident, is_uri,
1824        parse_action_entries, substitution, title_no_underscore,
1825    };
1826
1827    #[test]
1828    fn parses_action_specs_like_python_regex() {
1829        let text = r#"
1830            ActionSpec {
1831                name: "greet",
1832                description: "Return a greeting.",
1833                required_scope: Some(READ_SCOPE),
1834                transport: ActionTransport::Any,
1835                rest_method: Some("POST"),
1836                rest_path: Some("/v1/greet"),
1837                cost: ActionCost::Cheap,
1838            },
1839            ActionSpec {
1840                name: "elicit_name",
1841                description: "Ask for a name.",
1842                required_scope: Some(WRITE_SCOPE),
1843                transport: ActionTransport::McpOnly,
1844                rest_method: None,
1845                rest_path: None,
1846                cost: ActionCost::Moderate,
1847            },
1848            ActionSpec {
1849                name: "help",
1850                description: "Show help.",
1851                required_scope: None,
1852                transport: ActionTransport::Any,
1853                rest_method: Some("GET"),
1854                rest_path: Some("/v1/help"),
1855                cost: ActionCost::Cheap,
1856            },
1857        "#;
1858        let entries = parse_action_entries(text);
1859        assert_eq!(entries.len(), 3);
1860        assert_eq!(entries[0].name, "greet");
1861        assert_eq!(entries[0].description, "Return a greeting.");
1862        assert_eq!(entries[0].scope, "soma:read");
1863        assert_eq!(entries[0].rest_method.as_deref(), Some("POST"));
1864        assert_eq!(entries[0].rest_path.as_deref(), Some("/v1/greet"));
1865        assert_eq!(entries[1].transport, "McpOnly");
1866        assert_eq!(entries[1].rest_method, None);
1867        assert_eq!(entries[2].doc_scope, "public");
1868    }
1869
1870    #[test]
1871    fn ascii_allowlist_and_substitutions_match_script_policy() {
1872        assert!(allowed_unicode(0x2713));
1873        assert!(allowed_unicode(0x253C));
1874        assert!(allowed_unicode(0x25B2));
1875        assert!(allowed_unicode(0x25B6));
1876        assert!(allowed_unicode(0x25BC));
1877        assert!(!allowed_unicode(0x00E9));
1878        assert_eq!(substitution(0x2014), Some("-"));
1879        assert_eq!(substitution(0x2026), Some("..."));
1880        assert_eq!(escape_char('\\'), "\\\\");
1881    }
1882
1883    #[test]
1884    fn scaffold_regex_replacements_match_python_patterns() {
1885        assert!(is_crate_name("foo-bar1"));
1886        assert!(!is_crate_name("Foo"));
1887        assert!(is_ident("foo_bar1"));
1888        assert!(!is_ident("foo-bar"));
1889        assert!(is_api_url_env("FOO_API_URL"));
1890        assert!(!is_api_url_env("FOO_URL"));
1891        assert!(is_uri("https://example.com/docs"));
1892        assert!(!is_uri("mailto:test@example.com"));
1893    }
1894
1895    #[test]
1896    fn openapi_operation_id_title_matches_python_title_replace() {
1897        assert_eq!(title_no_underscore("scaffold_intent"), "ScaffoldIntent");
1898        assert_eq!(title_no_underscore("greet"), "Greet");
1899    }
1900}