xtask/patterns/
actions.rs1use super::{reporter::PatternReporter, util::read_file};
2
3const ACTION_TEST_COVERAGE_EXCEPTIONS: &[&str] = &[
4 "elicit_name",
6];
7
8const ACTION_SPECS_PATH: &str = "crates/soma/domain/src/actions.rs";
12
13pub(super) fn action_surfaces(reporter: &mut PatternReporter) {
14 let actions_text = read_file(ACTION_SPECS_PATH);
15 let action_specs = action_specs_body(&actions_text).unwrap_or(&actions_text);
16 let action_names = extract_action_names(action_specs);
17 let mcp_only = extract_mcp_only_actions(action_specs);
18
19 if action_names.is_empty() {
20 reporter.fail(
21 "actions",
22 format!("could not parse ACTION_SPECS from {ACTION_SPECS_PATH}"),
23 );
24 return;
25 }
26
27 let schema = read_file("crates/soma/mcp/src/schemas.rs");
28 let tools = read_file("crates/soma/mcp/src/tools.rs");
29 let tests = read_file("apps/soma/tests/tool_dispatch.rs");
30 let cli = read_file("crates/soma/cli/src/lib.rs");
31
32 let schema_uses_metadata = schema.contains("tool_definitions_for_catalogs")
33 && schema.contains("action_names(catalogs)");
34 let missing_schema = if schema_uses_metadata {
35 Vec::new()
36 } else {
37 action_names
38 .iter()
39 .filter(|action| !schema.contains(&format!("\"{action}\"")))
40 .cloned()
41 .collect::<Vec<_>>()
42 };
43 let help_uses_metadata =
44 tools.contains("for spec in ACTION_SPECS") && tools.contains("spec.description");
45 let missing_help = if help_uses_metadata {
46 Vec::new()
47 } else {
48 action_names
49 .iter()
50 .filter(|action| {
51 !tools.contains(&format!("### {action}")) && !tools.contains(&format!("`{action}`"))
52 })
53 .cloned()
54 .collect::<Vec<_>>()
55 };
56 let missing_tests = action_names
57 .iter()
58 .filter(|action| {
59 action.as_str() != "help"
60 && !ACTION_TEST_COVERAGE_EXCEPTIONS.contains(&action.as_str())
61 && !tests.contains(action.as_str())
62 })
63 .cloned()
64 .collect::<Vec<_>>();
65 let missing_cli = action_names
66 .iter()
67 .filter(|action| action.as_str() != "help" && !mcp_only.contains(action))
68 .filter(|action| {
69 !cli.contains(&format!("\"{action}\"")) && !cli.contains(&variant_name(action))
70 })
71 .cloned()
72 .collect::<Vec<_>>();
73
74 if !missing_schema.is_empty() {
75 reporter.fail(
76 "actions",
77 format!(
78 "schemas.rs missing action(s): {}",
79 missing_schema.join(", ")
80 ),
81 );
82 }
83 if !missing_help.is_empty() {
84 reporter.fail(
85 "actions",
86 format!(
87 "mcp/tools.rs HELP_TEXT missing action(s): {}. Hint: add `### <action>` docs to HELP_TEXT.",
88 missing_help.join(", ")
89 ),
90 );
91 }
92 if !missing_tests.is_empty() {
93 reporter.warn(
94 "actions",
95 format!(
96 "apps/soma/tests/tool_dispatch.rs may be missing action coverage: {}. Hint: add a direct dispatch/service test or an explicit exception.",
97 missing_tests.join(", ")
98 ),
99 );
100 }
101 if !missing_cli.is_empty() {
102 reporter.warn(
103 "cli-mcp-parity",
104 format!(
105 "CLI may be missing non-MCP-only action(s): {}. Hint: add a Command variant, parse arm, and dispatch arm.",
106 missing_cli.join(", ")
107 ),
108 );
109 }
110 if missing_schema.is_empty()
111 && missing_help.is_empty()
112 && missing_tests.is_empty()
113 && missing_cli.is_empty()
114 {
115 reporter.ok(
116 "actions",
117 format!(
118 "{} actions appear in schema/help/tests/CLI surfaces",
119 action_names.len()
120 ),
121 );
122 }
123}
124
125fn action_specs_body(text: &str) -> Option<&str> {
126 let start = text.find("ACTION_SPECS")?;
127 let after_start = &text[start..];
128 let end = after_start.find("];")?;
129 Some(&after_start[..end])
130}
131
132fn extract_action_names(text: &str) -> Vec<String> {
133 text.lines()
134 .filter_map(|line| {
135 let (_, after_name) = line.split_once("name:")?;
136 let start = after_name.find('"')? + 1;
137 let rest = &after_name[start..];
138 let end = rest.find('"')?;
139 Some(rest[..end].to_string())
140 })
141 .collect()
142}
143
144fn extract_mcp_only_actions(text: &str) -> Vec<String> {
145 let mut actions = Vec::new();
146 for block in text.split("ActionSpec").skip(1) {
147 let Some(end) = block.find('}') else {
148 continue;
149 };
150 let block = &block[..end];
151 if !block.contains("ActionTransport::McpOnly") {
152 continue;
153 }
154 if let Some(name) = extract_action_names(block).into_iter().next() {
155 actions.push(name);
156 }
157 }
158 actions
159}
160
161fn variant_name(action: &str) -> String {
162 action
163 .split('_')
164 .filter(|part| !part.is_empty())
165 .map(|part| {
166 let mut chars = part.chars();
167 match chars.next() {
168 Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
169 None => String::new(),
170 }
171 })
172 .collect::<String>()
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 const ACTIONS: &str = r#"
180pub const ACTION_SPECS: &[ActionSpec] = &[
181 ActionSpec {
182 name: "greet",
183 required_scope: Some(READ_SCOPE),
184 transport: ActionTransport::Any,
185 },
186 ActionSpec {
187 name: "elicit_name",
188 required_scope: Some(READ_SCOPE),
189 transport: ActionTransport::McpOnly,
190 },
191];
192
193pub fn rest_help() {
194 let example = "Alice";
195}
196"#;
197
198 #[test]
199 fn action_specs_body_limits_parsing_to_metadata_block() {
200 let body = action_specs_body(ACTIONS).expect("ACTION_SPECS body should parse");
201 assert!(body.contains("greet"));
202 assert!(!body.contains("Alice"));
203 }
204
205 #[test]
206 fn action_name_parser_ignores_non_metadata_names() {
207 let body = action_specs_body(ACTIONS).unwrap();
208 assert_eq!(extract_action_names(body), vec!["greet", "elicit_name"]);
209 }
210
211 #[test]
212 fn mcp_only_parser_detects_transport_restriction() {
213 let body = action_specs_body(ACTIONS).unwrap();
214 assert_eq!(extract_mcp_only_actions(body), vec!["elicit_name"]);
215 }
216
217 #[test]
218 fn variant_name_matches_cli_enum_style() {
219 assert_eq!(variant_name("elicit_name"), "ElicitName");
220 }
221}