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