1use crate::error::{KernelError, Result};
3use regex::Regex;
4use serde_json::Value;
5use std::collections::BTreeSet;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9const PLUGIN_NAME: &str = "hikmah-stack";
10
11fn invalid(message: impl Into<String>) -> KernelError {
12 KernelError::Invalid(message.into())
13}
14
15pub fn validate_repo(root: impl AsRef<Path>) -> Result<Vec<String>> {
16 let root = root.as_ref();
17 let required = [
18 ".codex-plugin/plugin.json",
19 ".claude-plugin/plugin.json",
20 ".claude-plugin/marketplace.json",
21 ".agents/plugins/marketplace.json",
22 "kimi.plugin.json",
23 "hooks/hooks.json",
24 "hooks/codex.json",
25 "hooks/truth_gate.sh",
26 "hooks/truth_gate.py",
27 "hooks/truth_gate_cases.json",
28 "skills/operator-core/SKILL.md",
29 "skills/agent-radar/SKILL.md",
30 "skills/decision-forge/SKILL.md",
31 "skills/ship-guard/SKILL.md",
32 "skills/hikmah-orchestrator/SKILL.md",
33 "skills/cognitive-kernel/SKILL.md",
34 "docs/COGNITIVE_KERNEL.md",
35 "docs/MEMORY.md",
36 "docs/DECISION_PORT.md",
37 "runtime/hikmah-kernel/Cargo.toml",
38 ];
39 let mut notes = Vec::new();
40 for path in required {
41 if !root.join(path).is_file() {
42 return Err(invalid(format!("missing required file: {path}")));
43 }
44 }
45
46 let mut json = |path: &str| -> Result<Value> {
47 let text = fs::read_to_string(root.join(path))?;
48 let value: Value = serde_json::from_str(&text)
49 .map_err(|error| invalid(format!("{path} is not valid JSON: {error}")))?;
50 notes.push(format!("json ok: {path}"));
51 Ok(value)
52 };
53 let codex = json(".codex-plugin/plugin.json")?;
54 let claude = json(".claude-plugin/plugin.json")?;
55 let claude_market = json(".claude-plugin/marketplace.json")?;
56 let agents_market = json(".agents/plugins/marketplace.json")?;
57 let kimi = json("kimi.plugin.json")?;
58 let hooks = json("hooks/hooks.json")?;
59 let codex_hooks = json("hooks/codex.json")?;
60 let cases = json("hooks/truth_gate_cases.json")?;
61
62 let cargo_version = cargo_package_version(&root.join("runtime/hikmah-kernel/Cargo.toml"))?;
64 let mut versions = vec![
65 (
66 "runtime/hikmah-kernel/Cargo.toml".to_string(),
67 cargo_version.clone(),
68 ),
69 (
70 ".codex-plugin/plugin.json".into(),
71 field(&codex, "version")?,
72 ),
73 (
74 ".claude-plugin/plugin.json".into(),
75 field(&claude, "version")?,
76 ),
77 ("kimi.plugin.json".into(), field(&kimi, "version")?),
78 ];
79 for (index, plugin) in plugin_entries(&claude_market).iter().enumerate() {
80 if let Some(version) = plugin.get("version").and_then(Value::as_str) {
81 versions.push((
82 format!(".claude-plugin/marketplace.json plugins[{index}]"),
83 version.to_string(),
84 ));
85 }
86 }
87 for (path, version) in &versions {
88 if *version != cargo_version {
89 return Err(invalid(format!(
90 "version mismatch: {path} has {version}, Cargo.toml has {cargo_version}"
91 )));
92 }
93 }
94 notes.push(format!(
95 "version {cargo_version} in {} places",
96 versions.len()
97 ));
98
99 let mut names = vec![
101 (
102 ".codex-plugin/plugin.json".to_string(),
103 field(&codex, "name")?,
104 ),
105 (".claude-plugin/plugin.json".into(), field(&claude, "name")?),
106 ("kimi.plugin.json".into(), field(&kimi, "name")?),
107 ];
108 for (label, market) in [
109 (".claude-plugin/marketplace.json", &claude_market),
110 (".agents/plugins/marketplace.json", &agents_market),
111 ] {
112 for plugin in plugin_entries(market) {
113 names.push((label.to_string(), field(plugin, "name")?));
114 }
115 }
116 for (path, name) in &names {
117 if name != PLUGIN_NAME {
118 return Err(invalid(format!(
119 "plugin name mismatch: {path} has {name}, expected {PLUGIN_NAME}"
120 )));
121 }
122 }
123 notes.push(format!(
124 "plugin name {PLUGIN_NAME} in {} places",
125 names.len()
126 ));
127
128 let script = Regex::new(r"hooks/[A-Za-z0-9_.\-]+").expect("script regex");
130 let mut referenced = BTreeSet::new();
131 for config in [&hooks, &codex_hooks] {
132 collect_commands(config, &mut |command| {
133 for m in script.find_iter(command) {
134 referenced.insert(m.as_str().to_string());
135 }
136 });
137 }
138 for path in &referenced {
139 if !root.join(path).is_file() {
140 return Err(invalid(format!("hook command references missing {path}")));
141 }
142 }
143 notes.push(format!(
144 "{} hook script reference(s) resolve",
145 referenced.len()
146 ));
147
148 let case_count = cases
149 .get("cases")
150 .and_then(Value::as_array)
151 .map(Vec::len)
152 .unwrap_or(0);
153 if case_count == 0 {
154 return Err(invalid("hooks/truth_gate_cases.json has no cases"));
155 }
156 notes.push(format!("{case_count} Truth Gate golden cases"));
157
158 let mut skill_names = BTreeSet::new();
160 for entry in fs::read_dir(root.join("skills"))? {
161 let entry = entry?;
162 if !entry.file_type()?.is_dir() {
163 continue;
164 }
165 let dir_name = entry.file_name().to_string_lossy().to_string();
166 let skill_path: PathBuf = entry.path().join("SKILL.md");
167 if !skill_path.is_file() {
168 return Err(invalid(format!(
169 "skill directory missing SKILL.md: {}",
170 entry.path().display()
171 )));
172 }
173 let text = fs::read_to_string(&skill_path)?;
174 let name = parse_frontmatter_field(&text, "name").unwrap_or_default();
175 if name.is_empty() {
176 return Err(invalid(format!(
177 "skill missing name: {}",
178 skill_path.display()
179 )));
180 }
181 if name != dir_name {
182 return Err(invalid(format!(
183 "skill name `{name}` does not match its directory `{dir_name}`"
184 )));
185 }
186 let description = parse_frontmatter_field(&text, "description").unwrap_or_default();
187 if description.is_empty() {
188 return Err(invalid(format!(
189 "skill missing frontmatter description: {}",
190 skill_path.display()
191 )));
192 }
193 if !skill_names.insert(name.clone()) {
194 return Err(invalid(format!("duplicate skill name: {name}")));
195 }
196 check_skill_links(&entry.path())?;
197 }
198 notes.push(format!("{} unique skills", skill_names.len()));
199 Ok(notes)
200}
201
202fn check_skill_links(skill_dir: &Path) -> Result<()> {
205 let mut pending = vec![skill_dir.to_path_buf()];
206 while let Some(dir) = pending.pop() {
207 for entry in fs::read_dir(&dir)? {
208 let path = entry?.path();
209 if path.is_dir() {
210 pending.push(path);
211 continue;
212 }
213 if path.extension().and_then(|e| e.to_str()) != Some("md") {
214 continue;
215 }
216 let text = fs::read_to_string(&path)?;
217 for target in markdown_link_targets(&text) {
218 if let Some(problem) = skill_link_problem(skill_dir, &dir, target) {
219 return Err(invalid(format!(
220 "{}: link `{target}` {problem}; skills must be self-contained",
221 path.display()
222 )));
223 }
224 }
225 }
226 }
227 Ok(())
228}
229
230fn markdown_link_targets(text: &str) -> Vec<&str> {
231 let mut targets = Vec::new();
232 let mut rest = text;
233 while let Some(start) = rest.find("](") {
234 rest = &rest[start + 2..];
235 let Some(end) = rest.find(')') else { break };
236 let target = rest[..end].split_whitespace().next().unwrap_or("");
237 targets.push(target);
238 rest = &rest[end..];
239 }
240 targets
241}
242
243fn skill_link_problem(skill_dir: &Path, file_dir: &Path, target: &str) -> Option<&'static str> {
244 let path_part = target.split('#').next().unwrap_or("");
245 if path_part.is_empty() || target.contains("://") || target.starts_with("mailto:") {
246 return None;
247 }
248 if path_part.starts_with('/') {
249 return Some("is an absolute path");
250 }
251 let base = file_dir.strip_prefix(skill_dir).ok()?;
252 let mut depth: Vec<&std::ffi::OsStr> = base.iter().collect();
253 for part in Path::new(path_part).components() {
254 match part {
255 std::path::Component::CurDir => {}
256 std::path::Component::ParentDir => {
257 if depth.pop().is_none() {
258 return Some("escapes the skill directory");
259 }
260 }
261 std::path::Component::Normal(name) => depth.push(name),
262 _ => return Some("is not a relative path"),
263 }
264 }
265 let resolved = depth
266 .iter()
267 .fold(skill_dir.to_path_buf(), |acc, part| acc.join(part));
268 if resolved.exists() {
269 None
270 } else {
271 Some("does not resolve inside the skill directory")
272 }
273}
274
275fn field(value: &Value, key: &str) -> Result<String> {
276 value
277 .get(key)
278 .and_then(Value::as_str)
279 .map(ToOwned::to_owned)
280 .ok_or_else(|| invalid(format!("manifest missing `{key}`")))
281}
282
283fn plugin_entries(market: &Value) -> Vec<&Value> {
284 market
285 .get("plugins")
286 .and_then(Value::as_array)
287 .map(|plugins| plugins.iter().collect())
288 .unwrap_or_default()
289}
290
291fn collect_commands(value: &Value, visit: &mut dyn FnMut(&str)) {
292 match value {
293 Value::Object(map) => {
294 if let Some(command) = map.get("command").and_then(Value::as_str) {
295 visit(command);
296 }
297 for child in map.values() {
298 collect_commands(child, visit);
299 }
300 }
301 Value::Array(items) => {
302 for item in items {
303 collect_commands(item, visit);
304 }
305 }
306 _ => {}
307 }
308}
309
310fn cargo_package_version(path: &Path) -> Result<String> {
311 let text = fs::read_to_string(path)?;
312 let mut in_package = false;
313 for line in text.lines() {
314 let trimmed = line.trim();
315 if trimmed.starts_with('[') {
316 in_package = trimmed == "[package]";
317 continue;
318 }
319 if in_package {
320 if let Some(rest) = trimmed.strip_prefix("version") {
321 if let Some(value) = rest.trim_start().strip_prefix('=') {
322 return Ok(value.trim().trim_matches('"').to_string());
323 }
324 }
325 }
326 }
327 Err(invalid("Cargo.toml has no [package] version"))
328}
329
330fn parse_frontmatter_field(text: &str, key: &str) -> Option<String> {
331 let mut lines = text.lines();
332 if lines.next()?.trim() != "---" {
333 return None;
334 }
335 for line in lines {
336 let trimmed = line.trim();
337 if trimmed == "---" {
338 break;
339 }
340 if let Some(value) = trimmed.strip_prefix(&format!("{key}:")) {
341 return Some(value.trim().trim_matches('"').to_string());
342 }
343 }
344 None
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn skill_links_must_stay_inside_the_skill() {
353 let skill = std::env::temp_dir().join(format!("hikmah-skill-links-{}", std::process::id()));
354 fs::create_dir_all(skill.join("references")).unwrap();
355 fs::write(skill.join("references/notes.md"), "x").unwrap();
356 let refs = skill.join("references");
357
358 assert_eq!(
359 skill_link_problem(&skill, &skill, "references/notes.md"),
360 None
361 );
362 assert_eq!(
363 skill_link_problem(&skill, &refs, "../references/notes.md#top"),
364 None
365 );
366 assert_eq!(
367 skill_link_problem(&skill, &skill, "https://example.com/a.md"),
368 None
369 );
370 assert_eq!(skill_link_problem(&skill, &skill, "#section"), None);
371 assert!(skill_link_problem(&skill, &skill, "../../docs/MEMORY.md").is_some());
372 assert!(skill_link_problem(&skill, &refs, "../../other/SKILL.md").is_some());
373 assert!(skill_link_problem(&skill, &skill, "references/missing.md").is_some());
374 assert!(skill_link_problem(&skill, &skill, "/etc/passwd").is_some());
375
376 assert_eq!(
377 markdown_link_targets("see [a](x.md \"title\") and [b](https://e.com)"),
378 vec!["x.md", "https://e.com"]
379 );
380 fs::remove_dir_all(&skill).unwrap();
381 }
382}