1use std::collections::HashSet;
2
3use crate::{
4 pack::loader::Pack,
5 procedures,
6 validate::gates::{CheckResult, GateResult, GateStatus},
7};
8
9const REQUIRED_MACHINE_FILES: &[&str] = &[
10 "system_prompt.md",
11 "glossary.json",
12 "ontology.json",
13 "rules.json",
14 "constraints.json",
15 "retrieval_chunks.jsonl",
16];
17
18pub fn run(pack: &Pack) -> GateResult {
20 let mut checks = Vec::new();
21
22 for file in REQUIRED_MACHINE_FILES {
24 if pack.machine_file(file).exists() {
25 checks.push(CheckResult::pass(format!("machine/{file} present")));
26 } else {
27 checks.push(CheckResult::fail(
28 format!("machine/{file} present"),
29 format!("machine/{file} is required but not found"),
30 ));
31 }
32 }
33
34 checks.push(CheckResult::pass("manifest.json present"));
36
37 for (name, result) in [
39 ("glossary.json", pack.load_glossary().map(|_| ())),
40 ("ontology.json", pack.load_ontology().map(|_| ())),
41 ("rules.json", pack.load_rules().map(|_| ())),
42 ("constraints.json", pack.load_constraints().map(|_| ())),
43 ("retrieval_chunks.jsonl", pack.load_chunks().map(|_| ())),
44 ] {
45 match result {
46 Ok(_) => checks.push(CheckResult::pass(format!("machine/{name} parses"))),
47 Err(e) => checks.push(CheckResult::fail(
48 format!("machine/{name} parses"),
49 e.to_string(),
50 )),
51 }
52 }
53
54 let sources_csv = pack.evidence_file("sources.csv");
56 if sources_csv.exists() {
57 let known_ids: HashSet<String> = std::fs::read_to_string(&sources_csv)
58 .unwrap_or_default()
59 .lines()
60 .skip(1) .filter_map(|line| {
62 line.split(',')
63 .next()
64 .map(|s| s.trim().trim_matches('"').to_string())
65 })
66 .filter(|s| !s.is_empty())
67 .collect();
68
69 let mut unresolved: Vec<String> = Vec::new();
70
71 if let Ok(Some(gf)) = pack.load_glossary() {
72 for t in &gf.terms {
73 if let Some(ref sr) = t.source_ref {
74 if sr != "generated" && !known_ids.contains(sr.as_str()) {
75 unresolved.push(format!("term/{}: {sr}", t.id));
76 }
77 }
78 }
79 }
80 if let Ok(Some(rf)) = pack.load_rules() {
81 for r in &rf.rules {
82 if let Some(ref sr) = r.source_ref {
83 if sr != "generated" && !known_ids.contains(sr.as_str()) {
84 unresolved.push(format!("rule/{}: {sr}", r.id));
85 }
86 }
87 }
88 }
89 if let Ok(Some(cf)) = pack.load_constraints() {
90 for c in cf.all_constraints() {
91 if let Some(ref sr) = c.source_ref {
92 if sr != "generated" && !known_ids.contains(sr.as_str()) {
93 unresolved.push(format!("constraint/{}: {sr}", c.id));
94 }
95 }
96 }
97 }
98
99 if unresolved.is_empty() {
100 checks.push(CheckResult::pass("source_ref resolution"));
101 } else {
102 checks.push(CheckResult::fail(
103 "source_ref resolution",
104 format!("unresolved refs: {}", unresolved.join(", ")),
105 ));
106 }
107 }
108
109 if pack.has_knowledge_graph() {
111 match pack.load_graph() {
112 Ok(Some(graph)) => {
113 let node_ids: HashSet<&str> = graph.nodes.iter().map(|n| n.id.as_str()).collect();
114 let broken: Vec<String> = graph
115 .edges
116 .iter()
117 .filter_map(|e| {
118 let src_ok = node_ids.contains(e.source.as_str());
119 let tgt_ok = node_ids.contains(e.target.as_str());
120 if !src_ok || !tgt_ok {
121 Some(format!("{}->{}", e.source, e.target))
122 } else {
123 None
124 }
125 })
126 .collect();
127 if broken.is_empty() {
128 checks.push(CheckResult::pass("knowledge_graph edge resolution"));
129 } else {
130 checks.push(CheckResult::fail(
131 "knowledge_graph edge resolution",
132 format!("broken edges: {}", broken.join(", ")),
133 ));
134 }
135 }
136 Ok(None) => checks.push(CheckResult::skip("knowledge_graph.json (not present)")),
137 Err(e) => checks.push(CheckResult::fail(
138 "knowledge_graph.json parses",
139 e.to_string(),
140 )),
141 }
142 }
143
144 if pack.has_procedures() {
146 match procedures::validate_all(pack) {
147 Ok(errors) if errors.is_empty() => {
148 checks.push(CheckResult::pass("machine/procedures/ completeness"));
149 }
150 Ok(errors) => {
151 for e in &errors {
152 checks.push(CheckResult::fail(
153 "machine/procedures/ completeness",
154 e.clone(),
155 ));
156 }
157 }
158 Err(e) => {
159 checks.push(CheckResult::fail(
160 "machine/procedures/ completeness",
161 e.to_string(),
162 ));
163 }
164 }
165 }
166
167 if pack.manifest.mcp.is_some() {
169 if pack.machine_file("mcp_manifest.json").exists() {
170 checks.push(CheckResult::pass(
171 "machine/mcp_manifest.json present (mcp configured)",
172 ));
173 } else {
174 checks.push(CheckResult::fail(
175 "machine/mcp_manifest.json present (mcp configured)",
176 "manifest.mcp is set but machine/mcp_manifest.json is missing",
177 ));
178 }
179 }
180
181 let failed = checks.iter().any(|c| c.status == GateStatus::Fail);
182 GateResult {
183 gate: 4,
184 status: if failed {
185 GateStatus::Fail
186 } else {
187 GateStatus::Pass
188 },
189 checks,
190 message: None,
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use tempfile::TempDir;
198
199 fn minimal_manifest_json() -> &'static str {
200 r#"{
201 "spec": "1.0.0",
202 "name": "test-pack",
203 "version": "1.0.0",
204 "domain": "testing",
205 "audience": "internal",
206 "intended_use": "unit tests",
207 "known_limitations": "none",
208 "update_date": "2026-01-01"
209 }"#
210 }
211
212 fn complete_pack(tmp: &TempDir) -> Pack {
215 std::fs::write(tmp.path().join("manifest.json"), minimal_manifest_json()).unwrap();
216 let machine = tmp.path().join("machine");
217 std::fs::create_dir_all(&machine).unwrap();
218 std::fs::write(machine.join("system_prompt.md"), "Be helpful.").unwrap();
219 std::fs::write(machine.join("glossary.json"), r#"{"terms": []}"#).unwrap();
220 std::fs::write(machine.join("ontology.json"), r#"{"entity_types": []}"#).unwrap();
221 std::fs::write(machine.join("rules.json"), r#"{"rules": []}"#).unwrap();
222 std::fs::write(
223 machine.join("constraints.json"),
224 r#"{"edge_cases": [], "anti_patterns": [], "hard_limits": []}"#,
225 )
226 .unwrap();
227 std::fs::write(machine.join("retrieval_chunks.jsonl"), "").unwrap();
228 Pack::open(tmp.path()).unwrap()
229 }
230
231 #[test]
232 fn all_required_files_present_and_valid_passes() {
233 let tmp = TempDir::new().unwrap();
234 let pack = complete_pack(&tmp);
235 let result = run(&pack);
236 assert_eq!(result.status, GateStatus::Pass);
237 assert_eq!(result.gate, 4);
238 }
239
240 #[test]
241 fn missing_required_file_fails() {
242 let tmp = TempDir::new().unwrap();
243 let pack = complete_pack(&tmp);
244 std::fs::remove_file(pack.machine_file("glossary.json")).unwrap();
245
246 let result = run(&pack);
247 assert_eq!(result.status, GateStatus::Fail);
248 assert!(result
249 .checks
250 .iter()
251 .any(|c| c.description.contains("glossary.json") && c.status == GateStatus::Fail));
252 }
253
254 #[test]
255 fn invalid_json_in_machine_file_fails() {
256 let tmp = TempDir::new().unwrap();
257 let pack = complete_pack(&tmp);
258 std::fs::write(pack.machine_file("rules.json"), "{ not valid").unwrap();
259
260 let result = run(&pack);
261 assert_eq!(result.status, GateStatus::Fail);
262 assert!(result
263 .checks
264 .iter()
265 .any(|c| c.description.contains("rules.json") && c.status == GateStatus::Fail));
266 }
267
268 #[test]
269 fn sources_csv_absent_skips_source_ref_check() {
270 let tmp = TempDir::new().unwrap();
271 let pack = complete_pack(&tmp);
272 let result = run(&pack);
273 assert!(!result
274 .checks
275 .iter()
276 .any(|c| c.description == "source_ref resolution"));
277 }
278
279 #[test]
280 fn unresolved_source_ref_fails() {
281 let tmp = TempDir::new().unwrap();
282 let pack = complete_pack(&tmp);
283 std::fs::write(
284 pack.machine_file("glossary.json"),
285 r#"{"terms": [{"id": "t1", "term": "Term", "definition": "def", "source_ref": "src-1"}]}"#,
286 )
287 .unwrap();
288 std::fs::create_dir_all(pack.evidence_dir()).unwrap();
289 std::fs::write(pack.evidence_file("sources.csv"), "id,title\n").unwrap();
290
291 let result = run(&pack);
292 assert_eq!(result.status, GateStatus::Fail);
293 assert!(result
294 .checks
295 .iter()
296 .any(|c| c.description == "source_ref resolution" && c.status == GateStatus::Fail));
297 }
298
299 #[test]
300 fn source_ref_generated_is_always_allowed() {
301 let tmp = TempDir::new().unwrap();
302 let pack = complete_pack(&tmp);
303 std::fs::write(
304 pack.machine_file("glossary.json"),
305 r#"{"terms": [{"id": "t1", "term": "Term", "definition": "def", "source_ref": "generated"}]}"#,
306 )
307 .unwrap();
308 std::fs::create_dir_all(pack.evidence_dir()).unwrap();
309 std::fs::write(pack.evidence_file("sources.csv"), "id,title\n").unwrap();
310
311 let result = run(&pack);
312 assert_eq!(result.status, GateStatus::Pass);
313 }
314
315 #[test]
316 fn resolved_source_ref_passes() {
317 let tmp = TempDir::new().unwrap();
318 let pack = complete_pack(&tmp);
319 std::fs::write(
320 pack.machine_file("glossary.json"),
321 r#"{"terms": [{"id": "t1", "term": "Term", "definition": "def", "source_ref": "src-1"}]}"#,
322 )
323 .unwrap();
324 std::fs::create_dir_all(pack.evidence_dir()).unwrap();
325 std::fs::write(
326 pack.evidence_file("sources.csv"),
327 "id,title\nsrc-1,Some Source\n",
328 )
329 .unwrap();
330
331 let result = run(&pack);
332 assert_eq!(result.status, GateStatus::Pass);
333 }
334
335 #[test]
336 fn knowledge_graph_broken_edge_fails() {
337 let tmp = TempDir::new().unwrap();
338 let pack = complete_pack(&tmp);
339 std::fs::write(
340 pack.machine_file("knowledge_graph.json"),
341 r#"{"nodes": [{"id": "n1", "node_type": "concept", "label": "N1"}], "edges": [{"source": "n1", "relation": "see-also", "target": "missing"}]}"#,
342 )
343 .unwrap();
344
345 let result = run(&pack);
346 assert_eq!(result.status, GateStatus::Fail);
347 assert!(result.checks.iter().any(|c| c
348 .description
349 .contains("knowledge_graph edge resolution")
350 && c.status == GateStatus::Fail));
351 }
352
353 #[test]
354 fn knowledge_graph_resolved_edges_pass() {
355 let tmp = TempDir::new().unwrap();
356 let pack = complete_pack(&tmp);
357 std::fs::write(
358 pack.machine_file("knowledge_graph.json"),
359 r#"{"nodes": [{"id": "n1", "node_type": "concept", "label": "N1"}, {"id": "n2", "node_type": "concept", "label": "N2"}], "edges": [{"source": "n1", "relation": "see-also", "target": "n2"}]}"#,
360 )
361 .unwrap();
362
363 let result = run(&pack);
364 assert_eq!(result.status, GateStatus::Pass);
365 }
366}