1use 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 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 (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 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}