1use anyhow::{Context, Result};
4use std::collections::BTreeSet;
5use std::path::Path;
6
7pub const REQUIRED_SECTIONS: &[&str] = &[
8 "Decision Boundary",
9 "Proposed Skills",
10 "Resource Inventory",
11 "Invocation Policy",
12 "Validation Plan",
13 "Recommendation",
14];
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct CompositionPlanReport {
18 pub missing_sections: Vec<&'static str>,
19 pub candidate_names: BTreeSet<String>,
20 pub missing_guidance: Vec<&'static str>,
21}
22
23impl CompositionPlanReport {
24 pub fn is_valid(&self) -> bool {
25 self.missing_sections.is_empty()
26 && !self.candidate_names.is_empty()
27 && self.missing_guidance.is_empty()
28 }
29
30 pub fn error_messages(&self) -> Vec<String> {
31 let mut messages = Vec::new();
32 if !self.missing_sections.is_empty() {
33 messages.push(format!(
34 "missing required section(s): {}",
35 self.missing_sections.join(", ")
36 ));
37 }
38 if self.candidate_names.is_empty() {
39 messages.push(
40 "no candidate skill entries found; include at least one `name: skill-name` entry"
41 .to_string(),
42 );
43 }
44 messages.extend(
45 self.missing_guidance
46 .iter()
47 .map(|message| message.to_string()),
48 );
49 messages
50 }
51}
52
53pub fn validate_composition_plan_path(path: &Path) -> Result<CompositionPlanReport> {
54 let content = std::fs::read_to_string(path)
55 .with_context(|| format!("failed to read {}", path.display()))?;
56 Ok(validate_composition_plan(&content))
57}
58
59pub fn validate_composition_plan(content: &str) -> CompositionPlanReport {
60 let headings: BTreeSet<String> = content.lines().filter_map(markdown_heading).collect();
61 let missing_sections = REQUIRED_SECTIONS
62 .iter()
63 .copied()
64 .filter(|section| !headings.contains(*section))
65 .collect();
66 let candidate_names = content.lines().filter_map(candidate_skill_name).collect();
67 let missing_guidance = missing_guidance(content);
68
69 CompositionPlanReport {
70 missing_sections,
71 candidate_names,
72 missing_guidance,
73 }
74}
75
76fn markdown_heading(line: &str) -> Option<String> {
77 let trimmed = line.trim();
78 if !trimmed.starts_with('#') {
79 return None;
80 }
81 let level = trimmed.chars().take_while(|c| *c == '#').count();
82 if level < 2 {
83 return None;
84 }
85 let title = trimmed[level..].trim();
86 if title.is_empty() {
87 return None;
88 }
89 Some(title.to_string())
90}
91
92fn candidate_skill_name(line: &str) -> Option<String> {
93 let trimmed = line.trim_start();
94 let is_candidate_line = trimmed.starts_with('-') || trimmed.starts_with('#');
95 if !is_candidate_line {
96 return None;
97 }
98
99 let lower = trimmed.to_ascii_lowercase();
100 let start = lower.find("name:")? + "name:".len();
101 let after_name = trimmed[start..].trim_start().trim_start_matches('`');
102 let name: String = after_name
103 .chars()
104 .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
105 .collect();
106
107 if is_hyphen_case_skill_name(&name) {
108 Some(name)
109 } else {
110 None
111 }
112}
113
114fn is_hyphen_case_skill_name(name: &str) -> bool {
115 let parts: Vec<&str> = name.split('-').collect();
116 parts.len() >= 2
117 && parts.iter().all(|part| {
118 !part.is_empty()
119 && part
120 .chars()
121 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
122 })
123}
124
125fn missing_guidance(content: &str) -> Vec<&'static str> {
126 let mut messages = Vec::new();
127 if !decision_boundary_has_one_vs_many_rationale(content) {
128 messages.push(
129 "decision boundary must explain the one-skill-vs-many rationale with explicit keep/split criteria",
130 );
131 }
132 if mentions_skill_creation(content) && !has_skill_creator_handoff_boundary(content) {
133 messages.push(
134 "skill-creator handoff boundary missing; name when compose-skills stops and skill-creator begins",
135 );
136 }
137 messages
138}
139
140fn decision_boundary_has_one_vs_many_rationale(content: &str) -> bool {
141 let Some(body) = section_body(content, "Decision Boundary") else {
142 return true;
143 };
144 let lower = body.to_ascii_lowercase();
145 let one_skill_language = lower.contains("one skill")
146 || lower.contains("single skill")
147 || lower.contains("keep it as one")
148 || lower.contains("keep one skill");
149 let split_language = lower.contains("split")
150 || lower.contains("many")
151 || lower.contains("several")
152 || lower.contains("larger than one")
153 || lower.contains("separate");
154 one_skill_language && split_language
155}
156
157fn mentions_skill_creation(content: &str) -> bool {
158 let lower = content.to_ascii_lowercase();
159 lower.contains("skill-creator") || lower.contains("skill creation")
160}
161
162fn has_skill_creator_handoff_boundary(content: &str) -> bool {
163 let lower = content.to_ascii_lowercase();
164 lower.contains("skill-creator") && (lower.contains("handoff") || lower.contains("hand off"))
165}
166
167fn section_body<'a>(content: &'a str, title: &str) -> Option<&'a str> {
168 let mut in_section = false;
169 let mut start = 0;
170 let mut end = content.len();
171
172 for line in content.lines() {
173 let line_start = line.as_ptr() as usize - content.as_ptr() as usize;
174 if let Some(heading) = markdown_heading(line) {
175 if in_section {
176 end = line_start;
177 break;
178 }
179 if heading == title {
180 in_section = true;
181 start = line_start + line.len();
182 }
183 }
184 }
185
186 in_section.then(|| content[start..end].trim())
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 fn valid_plan() -> &'static str {
194 r#"
195## Decision Boundary
196Keep one skill until reuse or safety needs a split.
197
198## Proposed Skills
199- name: compose-skills
200 purpose: Design small skill systems.
201
202## Resource Inventory
203Use references and scripts where they remove repeated reasoning.
204
205## Invocation Policy
206The planning skill is implicit.
207
208## Validation Plan
209Run positive, chained, and negative trigger checks.
210
211## Recommendation
212Create the standalone planning skill.
213"#
214 }
215
216 #[test]
217 fn accepts_valid_composition_plan() {
218 let report = validate_composition_plan(valid_plan());
219 assert!(report.is_valid());
220 assert_eq!(
221 report.candidate_names,
222 BTreeSet::from(["compose-skills".to_string()])
223 );
224 }
225
226 #[test]
227 fn reports_missing_sections_and_missing_candidates() {
228 let report = validate_composition_plan("## Decision Boundary\n\nNo candidates.\n");
229 assert!(!report.is_valid());
230 assert_eq!(
231 report.missing_sections,
232 vec![
233 "Proposed Skills",
234 "Resource Inventory",
235 "Invocation Policy",
236 "Validation Plan",
237 "Recommendation"
238 ]
239 );
240 assert!(report.candidate_names.is_empty());
241 }
242
243 #[test]
244 fn reports_golden_diagnostics_for_malformed_fixture() {
245 let report = validate_composition_plan(include_str!(
246 "../skills/compose-skills/references/fixtures/malformed-plan.md"
247 ));
248 assert_eq!(
249 report.error_messages(),
250 vec![
251 "missing required section(s): Resource Inventory, Invocation Policy, Validation Plan, Recommendation",
252 "no candidate skill entries found; include at least one `name: skill-name` entry",
253 "decision boundary must explain the one-skill-vs-many rationale with explicit keep/split criteria",
254 ]
255 );
256 }
257
258 #[test]
259 fn reports_missing_one_skill_vs_many_rationale() {
260 let report = validate_composition_plan(
261 r#"
262## Decision Boundary
263Create this skill because it sounds useful.
264
265## Proposed Skills
266- name: useful-skill
267
268## Resource Inventory
269Use SKILL.md.
270
271## Invocation Policy
272Implicit.
273
274## Validation Plan
275Forward-test trigger prompts.
276
277## Recommendation
278Proceed.
279"#,
280 );
281 assert_eq!(
282 report.error_messages(),
283 vec![
284 "decision boundary must explain the one-skill-vs-many rationale with explicit keep/split criteria",
285 ]
286 );
287 }
288
289 #[test]
290 fn reports_missing_skill_creator_handoff_boundary() {
291 let report = validate_composition_plan(
292 r#"
293## Decision Boundary
294Keep one skill until there is enough reuse to split into several skills.
295
296## Proposed Skills
297- name: compose-skills
298 purpose: Plan skills before implementation.
299- name: skill-creator
300 purpose: Implement the approved skill.
301
302## Resource Inventory
303Use SKILL.md and templates.
304
305## Invocation Policy
306Implicit.
307
308## Validation Plan
309Forward-test chained prompts.
310
311## Recommendation
312Proceed with both skills.
313"#,
314 );
315 assert_eq!(
316 report.error_messages(),
317 vec![
318 "skill-creator handoff boundary missing; name when compose-skills stops and skill-creator begins",
319 ]
320 );
321 }
322
323 #[test]
324 fn requires_hyphen_case_candidate_names() {
325 let report = validate_composition_plan(
326 "## Proposed Skills\n- name: skill\n- name: ComposeSkills\n- name: compose-skills\n",
327 );
328 assert_eq!(
329 report.candidate_names,
330 BTreeSet::from(["compose-skills".to_string()])
331 );
332 }
333
334 #[test]
335 fn fixture_rejects_malformed_plan() {
336 let report = validate_composition_plan(include_str!(
337 "../skills/compose-skills/references/fixtures/malformed-plan.md"
338 ));
339 assert!(!report.is_valid());
340 assert!(report.candidate_names.is_empty());
341 assert!(report.missing_sections.contains(&"Resource Inventory"));
342 }
343
344 #[test]
345 fn fixture_accepts_ambiguous_one_vs_many_split() {
346 let report = validate_composition_plan(include_str!(
347 "../skills/compose-skills/references/fixtures/ambiguous-one-vs-many.md"
348 ));
349 assert!(report.is_valid());
350 assert_eq!(
351 report.candidate_names,
352 BTreeSet::from(["documentation-cleanup".to_string()])
353 );
354 }
355
356 #[test]
357 fn fixture_accepts_agent_doc_workflow_decomposition() {
358 let report = validate_composition_plan(include_str!(
359 "../skills/compose-skills/references/fixtures/agent-doc-decomposition.md"
360 ));
361 assert!(report.is_valid());
362 assert_eq!(
363 report.candidate_names,
364 BTreeSet::from([
365 "agent-doc-route-diagnostics".to_string(),
366 "agent-doc-session".to_string()
367 ])
368 );
369 }
370
371 #[test]
372 fn fixture_accepts_oversized_workflow_handoff_example() {
373 let report = validate_composition_plan(include_str!(
374 "../skills/compose-skills/references/fixtures/oversized-workflow-handoff.md"
375 ));
376 assert!(report.is_valid());
377 assert_eq!(
378 report.candidate_names,
379 BTreeSet::from([
380 "customer-research".to_string(),
381 "launch-copy".to_string(),
382 "skill-creator".to_string()
383 ])
384 );
385 }
386}