1mod toml;
56
57pub mod standards;
61
62pub mod resolution;
68
69pub mod projection;
75
76use crate::gate::{ArtefactRef, Gate, GateKind, GateOutcome};
77use crate::types::{MissionConfig, Role};
78use std::collections::HashSet;
79use std::path::{Component, Path, PathBuf};
80
81pub const PACK_MANIFEST: &str = "pack.toml";
83
84pub const SCHEMA_BASE: u32 = 2;
88
89pub const SCHEMA_CONTRACT: u32 = 3;
92
93pub const SCHEMA_STANDARDS: u32 = 4;
98
99pub const RESERVED_GATE_NAMES: &[&str] = &[
106 crate::contract_gates::VACUOUS_FILTER,
107 crate::contract_gates::WRONG_POLARITY,
108 crate::contract_gates::PASSES_ON_BASE,
109 crate::contract_gates::ENV_SENSITIVE,
110 "merge-gate-suite",
111];
112
113#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct Pack {
117 pub name: String,
118 pub schema: u32,
119 pub dir: PathBuf,
122 pub gates: Vec<PackGateDecl>,
123 pub prompts: Vec<PackPrompt>,
124 pub checklists: Vec<PackChecklist>,
125 pub artefact_stores: Vec<PackArtefactStore>,
126 pub standards: Option<standards::StandardsManifest>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct PackGateDecl {
138 pub name: String,
139 pub command: String,
140 pub when_paths: Vec<String>,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct PackPrompt {
149 pub name: String,
150 pub role: Role,
151 pub text: String,
153 pub source: PromptSource,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub enum PromptSource {
160 Inline,
161 File(String),
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct PackChecklist {
168 pub name: String,
169 pub items: Vec<String>,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct PackArtefactStore {
176 pub name: String,
177 pub kind: String,
178}
179
180impl Pack {
181 pub fn load(dir: &Path) -> Result<Option<Pack>, String> {
190 Self::load_with_trust(dir, standards::StandardsTrust::External)
191 }
192
193 pub fn load_with_trust(
198 dir: &Path,
199 trust: standards::StandardsTrust,
200 ) -> Result<Option<Pack>, String> {
201 let manifest_path = dir.join(PACK_MANIFEST);
202 let source = match std::fs::read_to_string(&manifest_path) {
203 Ok(source) => source,
204 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
205 Err(e) => return Err(format!("cannot read {}: {e}", manifest_path.display())),
206 };
207 Self::parse_with_trust(dir, &source, trust).map(Some)
208 }
209
210 pub fn parse(dir: &Path, source: &str) -> Result<Pack, String> {
215 Self::parse_with_trust(dir, source, standards::StandardsTrust::External)
216 }
217
218 pub fn parse_with_trust(
220 dir: &Path,
221 source: &str,
222 trust: standards::StandardsTrust,
223 ) -> Result<Pack, String> {
224 let doc = toml::parse(source).map_err(|e| format!("{PACK_MANIFEST}: {e}"))?;
225 Self::from_document(dir, &doc, trust)
226 }
227
228 fn from_document(
231 dir: &Path,
232 doc: &toml::Document,
233 trust: standards::StandardsTrust,
234 ) -> Result<Pack, String> {
235 for section in &doc.sections {
238 let name = match section {
239 toml::Section::Single(t) => t.name.as_str(),
240 toml::Section::Array { name, .. } => name.as_str(),
241 };
242 if ![
243 "pack",
244 "gate",
245 "prompt",
246 "checklist",
247 "artefact_store",
248 "standards",
249 ]
250 .contains(&name)
251 {
252 return Err(format!(
253 "{PACK_MANIFEST}: unknown section `{name}` (declared sections: [pack], \
254 [[gate]], [[prompt]], [[checklist]], [[artefact_store]], [standards])"
255 ));
256 }
257 }
258
259 let (name, schema) = manifest_header(doc)?;
260
261 let mut gates = Vec::new();
262 for (idx, item) in doc.array("gate").iter().enumerate() {
263 gates.push(load_gate(item, idx)?);
264 }
265 reject_duplicate_names("gate", gates.iter().map(|g| g.name.as_str()))?;
266
267 let mut prompts = Vec::new();
268 for (idx, item) in doc.array("prompt").iter().enumerate() {
269 prompts.push(load_prompt(item, idx, dir)?);
270 }
271 reject_duplicate_names("prompt", prompts.iter().map(|p| p.name.as_str()))?;
272
273 let mut checklists = Vec::new();
274 for (idx, item) in doc.array("checklist").iter().enumerate() {
275 checklists.push(load_checklist(item, idx)?);
276 }
277 reject_duplicate_names("checklist", checklists.iter().map(|c| c.name.as_str()))?;
278
279 let mut artefact_stores = Vec::new();
280 for (idx, item) in doc.array("artefact_store").iter().enumerate() {
281 artefact_stores.push(load_artefact_store(item, idx)?);
282 }
283 reject_duplicate_names(
284 "artefact_store",
285 artefact_stores.iter().map(|s| s.name.as_str()),
286 )?;
287
288 let standards = match standards_root_of(doc, schema)? {
292 Some(root) => Some(standards::load_from_pack_dir(dir, &root, &gates, trust)?),
293 None => None,
294 };
295
296 Ok(Pack {
297 name,
298 schema,
299 dir: dir.to_path_buf(),
300 gates,
301 prompts,
302 checklists,
303 artefact_stores,
304 standards,
305 })
306 }
307
308 pub fn gates_for_paths(&self, changed_paths: &[String]) -> Vec<&PackGateDecl> {
312 self.gates
313 .iter()
314 .filter(|g| crate::merge_gate::when_paths_match(&g.when_paths, changed_paths))
315 .collect()
316 }
317
318 pub fn prompt_section(&self, role: Role) -> String {
323 let mut section = String::new();
324 for prompt in self.prompts.iter().filter(|p| p.role == role) {
325 section.push_str(&format!(
326 "\n\n---\nPack guidance (pack `{}`, prompt `{}`):\n{}\n",
327 self.name,
328 prompt.name,
329 prompt.text.trim_end()
330 ));
331 }
332 section
333 }
334
335 pub fn describe(&self) -> String {
338 let gate_names = self
339 .gates
340 .iter()
341 .map(|g| g.name.as_str())
342 .collect::<Vec<_>>()
343 .join(", ");
344 let prompt_names = self
345 .prompts
346 .iter()
347 .map(|p| format!("{}→{}", p.name, role_target_name(p.role)))
348 .collect::<Vec<_>>()
349 .join(", ");
350 let standards = match &self.standards {
353 Some(m) => format!(
354 ", standards: {} RFC(s)/{} rule(s) digest sha256:{}",
355 m.rfcs.len(),
356 m.rules.len(),
357 m.digest
358 ),
359 None => String::new(),
360 };
361 format!(
362 "pack `{}` (schema {}) at {}: {} gate(s) [{}], {} prompt(s) [{}], \
363 {} checklist(s), {} artefact store(s) (checklists/stores are \
364 declaration-only: validated at load, never executed){standards}",
365 self.name,
366 self.schema,
367 self.dir.display(),
368 self.gates.len(),
369 gate_names,
370 self.prompts.len(),
371 prompt_names,
372 self.checklists.len(),
373 self.artefact_stores.len(),
374 )
375 }
376}
377
378pub fn load_for_config(cfg: &MissionConfig, repo_root: &Path) -> Result<Option<Pack>, String> {
390 let Some(configured) = cfg.pack_dir.as_deref() else {
391 return Ok(None);
392 };
393 let raw = Path::new(configured);
394 let (dir, trust) = if raw.is_absolute() {
395 (raw.to_path_buf(), standards::StandardsTrust::External)
396 } else {
397 validate_pack_relative_path(configured, "mission config", "packDir")?;
398 let dir = repo_root.join(raw);
399 let trust = standards::trust_for_dir(repo_root, &dir);
400 (dir, trust)
401 };
402 if !dir.is_dir() {
403 return Err(format!(
404 "packDir `{configured}` resolves to {}, which is not a directory",
405 dir.display()
406 ));
407 }
408 let Some(pack) = Pack::load_with_trust(&dir, trust)? else {
409 return Err(format!(
410 "packDir `{configured}` resolves to {}, which has no {PACK_MANIFEST} — \
411 it is not a pack",
412 dir.display()
413 ));
414 };
415 Ok(Some(pack))
416}
417
418pub fn render_lint(pack: &Pack) -> String {
421 let mut out = format!(
422 "pack `{}` (schema {}) at {} — valid\n",
423 pack.name,
424 pack.schema,
425 pack.dir.display()
426 );
427 out.push_str("gates (deterministic; final-gate, after the engine floor, advisory):\n");
428 if pack.gates.is_empty() {
429 out.push_str(" (none)\n");
430 }
431 for gate in &pack.gates {
432 let scoping = if gate.when_paths.is_empty() {
433 "unconditional".to_string()
434 } else {
435 format!("whenPaths: {}", gate.when_paths.join(", "))
436 };
437 out.push_str(&format!(
438 " - {}: `{}` ({scoping})\n",
439 gate.name, gate.command
440 ));
441 }
442 out.push_str("prompts (appended to the target role's prompt):\n");
443 if pack.prompts.is_empty() {
444 out.push_str(" (none)\n");
445 }
446 for prompt in &pack.prompts {
447 let source = match &prompt.source {
448 PromptSource::Inline => "inline text".to_string(),
449 PromptSource::File(rel) => format!("file {rel}"),
450 };
451 out.push_str(&format!(
452 " - {} → {} ({source})\n",
453 prompt.name,
454 role_target_name(prompt.role)
455 ));
456 }
457 out.push_str("checklists (declaration-only: validated at load, never executed):\n");
458 if pack.checklists.is_empty() {
459 out.push_str(" (none)\n");
460 }
461 for checklist in &pack.checklists {
462 out.push_str(&format!(
463 " - {} ({} item(s))\n",
464 checklist.name,
465 checklist.items.len()
466 ));
467 }
468 out.push_str("artefact stores (declaration-only: validated at load, never invoked):\n");
469 if pack.artefact_stores.is_empty() {
470 out.push_str(" (none)\n");
471 }
472 for store in &pack.artefact_stores {
473 out.push_str(&format!(" - {} (kind `{}`)\n", store.name, store.kind));
474 }
475 if let Some(manifest) = &pack.standards {
476 out.push_str(&standards::render_registration(manifest));
477 }
478 out
479}
480
481pub fn role_target_name(role: Role) -> &'static str {
483 match role {
484 Role::Worker => "worker",
485 Role::ValidatorScrutiny => "validator-scrutiny",
486 Role::ValidatorFunctional => "validator-functional",
487 Role::Orchestrator => "orchestrator",
488 }
489}
490
491pub struct PackGate {
497 name: String,
498 outcome: GateOutcome,
499}
500
501impl PackGate {
502 pub fn from_run(name: &str, command: &str, ok: bool, output: String) -> Self {
507 let artefact = ArtefactRef::new(command.to_string());
508 let outcome = if ok {
509 GateOutcome::pass(artefact)
510 } else {
511 GateOutcome::fail(artefact.with_detail(output))
512 };
513 Self {
514 name: name.to_string(),
515 outcome,
516 }
517 }
518
519 pub fn with_rule_ids(mut self, rule_ids: Vec<String>) -> Self {
523 self.outcome = self.outcome.with_rule_ids(rule_ids);
524 self
525 }
526}
527
528impl Gate for PackGate {
529 fn name(&self) -> &str {
530 &self.name
531 }
532
533 fn kind(&self) -> GateKind {
534 GateKind::Deterministic
535 }
536
537 fn evaluate(&self) -> GateOutcome {
538 self.outcome.clone()
539 }
540}
541
542fn manifest_header(doc: &toml::Document) -> Result<(String, u32), String> {
551 let header = doc
552 .single("pack")
553 .ok_or_else(|| format!("{PACK_MANIFEST}: missing required table `[pack]`"))?;
554 check_unknown(header, "[pack]", &["name", "schema"])?;
555 let name = required_string(header, "[pack]", "name")?;
556 let schema = match header.get("schema") {
557 Some(toml::Value::Integer(n)) => {
558 let n = *n;
559 if n == i64::from(SCHEMA_BASE)
560 || n == i64::from(SCHEMA_CONTRACT)
561 || n == i64::from(SCHEMA_STANDARDS)
562 {
563 n as u32
564 } else {
565 return Err(format!(
566 "[pack] field `schema` is {n}: supported versions are {SCHEMA_BASE} \
567 (base manifest), {SCHEMA_CONTRACT} (contract), and {SCHEMA_STANDARDS} \
568 (standards)"
569 ));
570 }
571 }
572 Some(v) => {
573 return Err(format!(
574 "[pack] field `schema` must be an integer, got {}",
575 v.type_name()
576 ))
577 }
578 None => return Err("[pack] is missing required field `schema`".to_string()),
579 };
580 Ok((name, schema))
581}
582
583fn standards_root_of(doc: &toml::Document, schema: u32) -> Result<Option<String>, String> {
589 let Some(table) = doc.single("standards") else {
590 return Ok(None);
591 };
592 if schema != SCHEMA_STANDARDS {
593 return Err(format!(
594 "[standards] requires [pack] field `schema` = {SCHEMA_STANDARDS} (this pack \
595 declares schema {schema}) — the standards root is additive at schema \
596 {SCHEMA_STANDARDS} only"
597 ));
598 }
599 check_unknown(table, "[standards]", &["root"])?;
600 let raw = required_string(table, "[standards]", "root")?;
601 validate_pack_relative_path(&raw, "[standards]", "root")?;
602 let normalized = crate::merge_gate::normalize_relative_path(&raw, false);
603 if normalized.is_empty() || normalized == "." {
604 return Err("[standards] field `root` must name a pack-relative directory".to_string());
605 }
606 Ok(Some(normalized))
607}
608
609fn entry_label(section: &str, index: usize, table: &toml::Table) -> String {
612 match table.get("name") {
613 Some(toml::Value::String(name)) => {
614 format!("[[{section}]] entry {} (name `{name}`)", index + 1)
615 }
616 _ => format!("[[{section}]] entry {}", index + 1),
617 }
618}
619
620fn check_unknown(table: &toml::Table, section: &str, known: &[&str]) -> Result<(), String> {
623 let unknown = table.unknown_keys(known);
624 if let Some(field) = unknown.first() {
625 return Err(format!(
626 "{section} has unknown field `{field}` (declared fields: {})",
627 known.join(", ")
628 ));
629 }
630 Ok(())
631}
632
633fn required_string(table: &toml::Table, section: &str, key: &str) -> Result<String, String> {
635 match table.get(key) {
636 Some(toml::Value::String(s)) if !s.trim().is_empty() => Ok(s.clone()),
637 Some(toml::Value::String(_)) => Err(format!(
638 "{section} field `{key}` must be a non-empty string"
639 )),
640 Some(v) => Err(format!(
641 "{section} field `{key}` must be a string, got {}",
642 v.type_name()
643 )),
644 None => Err(format!("{section} is missing required field `{key}`")),
645 }
646}
647
648fn optional_string(
650 table: &toml::Table,
651 section: &str,
652 key: &str,
653) -> Result<Option<String>, String> {
654 match table.get(key) {
655 Some(toml::Value::String(s)) => Ok(Some(s.clone())),
656 Some(v) => Err(format!(
657 "{section} field `{key}` must be a string, got {}",
658 v.type_name()
659 )),
660 None => Ok(None),
661 }
662}
663
664fn optional_string_array(
666 table: &toml::Table,
667 section: &str,
668 key: &str,
669) -> Result<Vec<String>, String> {
670 match table.get(key) {
671 Some(toml::Value::Array(items)) => {
672 let mut out = Vec::with_capacity(items.len());
673 for (idx, item) in items.iter().enumerate() {
674 match item {
675 toml::Value::String(s) if !s.trim().is_empty() => out.push(s.clone()),
676 toml::Value::String(_) => {
677 return Err(format!(
678 "{section} field `{key}` element {} must be a non-empty string",
679 idx + 1
680 ))
681 }
682 v => {
683 return Err(format!(
684 "{section} field `{key}` element {} must be a string, got {}",
685 idx + 1,
686 v.type_name()
687 ))
688 }
689 }
690 }
691 Ok(out)
692 }
693 Some(v) => Err(format!(
694 "{section} field `{key}` must be an array of strings, got {}",
695 v.type_name()
696 )),
697 None => Ok(Vec::new()),
698 }
699}
700
701fn load_gate(table: &toml::Table, index: usize) -> Result<PackGateDecl, String> {
704 let section = entry_label("gate", index, table);
705 check_unknown(table, §ion, &["name", "kind", "command", "whenPaths"])?;
706 let name = required_string(table, §ion, "name")?;
707 if let Some(kind) = optional_string(table, §ion, "kind")? {
708 if kind != "deterministic" {
709 return Err(format!(
710 "{section} field `kind` is `{kind}`: packs may register only deterministic \
711 gates in this slice — model-judged gates are declared by the engine, \
712 never by a pack"
713 ));
714 }
715 }
716 if RESERVED_GATE_NAMES.contains(&name.as_str()) {
717 return Err(format!(
718 "{section} field `name` is `{name}`: reserved for an engine floor gate — \
719 a pack can add gates after the floor, never impersonate it"
720 ));
721 }
722 let command = required_string(table, §ion, "command")?;
723 if command.contains(['\n', '\r', '\0']) {
724 return Err(format!(
725 "{section} field `command` must be a single non-NUL line"
726 ));
727 }
728 let mut when_paths = Vec::new();
729 for raw in optional_string_array(table, §ion, "whenPaths")? {
730 validate_pack_relative_path(&raw, §ion, "whenPaths")?;
731 let normalized = crate::merge_gate::normalize_relative_path(&raw, false);
732 if normalized.is_empty() || normalized == "." {
733 return Err(format!(
734 "{section} field `whenPaths` entries must name a repo path — \
735 omit whenPaths to run unconditionally"
736 ));
737 }
738 when_paths.push(normalized);
739 }
740 Ok(PackGateDecl {
741 name,
742 command,
743 when_paths,
744 })
745}
746
747fn load_prompt(table: &toml::Table, index: usize, pack_dir: &Path) -> Result<PackPrompt, String> {
751 let section = entry_label("prompt", index, table);
752 check_unknown(table, §ion, &["name", "role", "text", "textFile"])?;
753 let name = required_string(table, §ion, "name")?;
754 let role_raw = required_string(table, §ion, "role")?;
755 let role = match role_raw.as_str() {
756 "worker" => Role::Worker,
757 "validator-scrutiny" => Role::ValidatorScrutiny,
758 "validator-functional" => Role::ValidatorFunctional,
759 other => {
760 return Err(format!(
761 "{section} field `role` is `{other}`: supported targets are `worker`, \
762 `validator-scrutiny`, `validator-functional` (the session roles whose \
763 prompts runner.rs builds)"
764 ))
765 }
766 };
767 let inline = optional_string(table, §ion, "text")?;
768 let file = optional_string(table, §ion, "textFile")?;
769 let (text, source) = match (inline, file) {
770 (Some(_), Some(_)) => {
771 return Err(format!(
772 "{section} declares both `text` and `textFile` — exactly one is required"
773 ))
774 }
775 (None, None) => {
776 return Err(format!(
777 "{section} is missing required field `text` (or `textFile`)"
778 ))
779 }
780 (Some(text), None) => (text, PromptSource::Inline),
781 (None, Some(rel)) => {
782 validate_pack_relative_path(&rel, §ion, "textFile")?;
783 let normalized = crate::merge_gate::normalize_relative_path(&rel, false);
784 let text = read_pack_text_file_nofollow(pack_dir, &normalized, §ion)?;
785 (text, PromptSource::File(normalized))
786 }
787 };
788 if text.trim().is_empty() {
789 return Err(format!(
790 "{section} field `text` resolves to empty prompt text"
791 ));
792 }
793 Ok(PackPrompt {
794 name,
795 role,
796 text,
797 source,
798 })
799}
800
801fn load_checklist(table: &toml::Table, index: usize) -> Result<PackChecklist, String> {
803 let section = entry_label("checklist", index, table);
804 check_unknown(table, §ion, &["name", "items"])?;
805 let name = required_string(table, §ion, "name")?;
806 if table.get("items").is_none() {
807 return Err(format!("{section} is missing required field `items`"));
808 }
809 let items = optional_string_array(table, §ion, "items")?;
810 if items.is_empty() {
811 return Err(format!(
812 "{section} field `items` must list at least one item"
813 ));
814 }
815 Ok(PackChecklist { name, items })
816}
817
818fn load_artefact_store(table: &toml::Table, index: usize) -> Result<PackArtefactStore, String> {
820 let section = entry_label("artefact_store", index, table);
821 check_unknown(table, §ion, &["name", "kind"])?;
822 let name = required_string(table, §ion, "name")?;
823 let kind = required_string(table, §ion, "kind")?;
824 Ok(PackArtefactStore { name, kind })
825}
826
827fn reject_duplicate_names<'a>(
831 section: &str,
832 names: impl Iterator<Item = &'a str>,
833) -> Result<(), String> {
834 let mut seen = HashSet::new();
835 for name in names {
836 if !seen.insert(name) {
837 return Err(format!("duplicate [[{section}]] name `{name}`"));
838 }
839 }
840 Ok(())
841}
842
843fn validate_pack_relative_path(raw: &str, section: &str, field: &str) -> Result<(), String> {
847 let path = Path::new(raw);
848 if raw.trim().is_empty()
849 || path.is_absolute()
850 || path
851 .components()
852 .any(|part| !matches!(part, Component::CurDir | Component::Normal(_)))
853 {
854 return Err(format!(
855 "{section} field `{field}` must be a pack-relative path without parent \
856 components: {raw:?}"
857 ));
858 }
859 Ok(())
860}
861
862fn read_pack_text_file_nofollow(
877 pack_dir: &Path,
878 rel: &str,
879 section: &str,
880) -> Result<String, String> {
881 use cap_fs_ext::{DirExt as _, FollowSymlinks, OpenOptionsFollowExt as _};
882 use std::io::Read as _;
883
884 let display = pack_dir.join(rel);
885 let field_error = |message: String| format!("{section} field `textFile` = {rel:?} {message}");
886 let no_follow_refusal = |what: &str| {
887 field_error(format!(
888 "resolves through {what} ({}) — pack prompt files load no-follow so a pack \
889 cannot read outside its own directory",
890 display.display()
891 ))
892 };
893 let mut dir = cap_std::fs::Dir::open_ambient_dir(pack_dir, cap_std::ambient_authority())
894 .map_err(|e| field_error(format!("cannot open pack dir {}: {e}", pack_dir.display())))?;
895 let mut names = rel.split('/').peekable();
896 while let Some(name) = names.next() {
897 if names.peek().is_some() {
898 dir = dir
899 .open_dir_nofollow(name)
900 .map_err(|_| no_follow_refusal("a symlinked or non-directory component"))?;
901 } else {
902 let mut options = cap_std::fs::OpenOptions::new();
903 options.read(true).follow(FollowSymlinks::No);
904 let mut file = dir.open_with(name, &options).map_err(|e| {
905 if e.kind() == std::io::ErrorKind::NotFound {
906 field_error(format!("cannot be read at {}: {e}", display.display()))
907 } else {
908 no_follow_refusal("a symlink or other non-regular file")
909 }
910 })?;
911 let mut text = String::new();
912 file.read_to_string(&mut text).map_err(|e| {
913 field_error(format!("cannot be read at {}: {e}", display.display()))
914 })?;
915 return Ok(text);
916 }
917 }
918 Err(field_error("resolves to no file".to_string()))
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926 use crate::gate::GatePipeline;
927
928 fn pack_dir_with(manifest: &str, files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
931 let tmp = tempfile::tempdir().expect("tempdir");
932 let dir = tmp.path().join("pack");
933 std::fs::create_dir_all(&dir).unwrap();
934 std::fs::write(dir.join(PACK_MANIFEST), manifest).unwrap();
935 for (rel, body) in files {
936 let path = dir.join(rel);
937 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
938 std::fs::write(path, body).unwrap();
939 }
940 (tmp, dir)
941 }
942
943 const FULL_MANIFEST: &str = r#"
944[pack]
945name = "zz-synthetic-pack"
946schema = 3
947
948[[gate]]
949name = "zz-gate-one"
950command = "cd ."
951
952[[gate]]
953name = "zz-gate-two"
954command = "cd ."
955whenPaths = ["src/"]
956
957[[prompt]]
958name = "zz-prompt-worker"
959role = "worker"
960text = "zz inline worker guidance"
961
962[[prompt]]
963name = "zz-prompt-scrutiny"
964role = "validator-scrutiny"
965textFile = "prompts/scrutiny.md"
966
967[[checklist]]
968name = "zz-checklist"
969items = ["first", "second"]
970
971[[artefact_store]]
972name = "zz-store"
973kind = "local-dir"
974"#;
975
976 #[test]
977 fn pack_contract_full_pack_loads_all_sections() {
978 let (_tmp, dir) =
979 pack_dir_with(FULL_MANIFEST, &[("prompts/scrutiny.md", "zz file text\n")]);
980 let pack = Pack::load(&dir).expect("load").expect("a pack");
981 assert_eq!(pack.name, "zz-synthetic-pack");
982 assert_eq!(pack.schema, SCHEMA_CONTRACT);
983 assert_eq!(pack.gates.len(), 2);
984 assert_eq!(pack.gates[1].when_paths, vec!["src".to_string()]);
985 assert_eq!(pack.prompts.len(), 2);
986 assert_eq!(pack.prompts[1].text, "zz file text\n");
987 assert_eq!(
988 pack.prompts[1].source,
989 PromptSource::File("prompts/scrutiny.md".to_string())
990 );
991 assert_eq!(pack.checklists[0].items.len(), 2);
992 assert_eq!(pack.artefact_stores[0].kind, "local-dir");
993 }
994
995 #[test]
998 fn pack_contract_schema_two_base_manifest_registers_nothing() {
999 let (_tmp, dir) = pack_dir_with("[pack]\nname = \"kranz\"\nschema = 2\n", &[]);
1000 let pack = Pack::load(&dir).expect("load").expect("a pack");
1001 assert_eq!(pack.schema, SCHEMA_BASE);
1002 assert!(pack.gates.is_empty());
1003 assert!(pack.prompts.is_empty());
1004 assert!(pack.checklists.is_empty());
1005 assert!(pack.artefact_stores.is_empty());
1006 }
1007
1008 #[test]
1009 fn pack_contract_directory_without_manifest_is_not_a_pack() {
1010 let tmp = tempfile::tempdir().unwrap();
1011 assert_eq!(Pack::load(tmp.path()).expect("load"), None);
1012 }
1013
1014 #[test]
1017 fn pack_contract_unknown_field_fails_closed() {
1018 let (_tmp, dir) = pack_dir_with(
1019 "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"g\"\ncommand = \"true\"\nbogus = 1\n",
1020 &[],
1021 );
1022 let err = Pack::load(&dir).expect_err("must fail");
1023 assert!(err.contains("unknown field `bogus`"), "{err}");
1024 assert!(err.contains("[[gate]]"), "{err}");
1025 }
1026
1027 #[test]
1028 fn pack_contract_unknown_section_fails_closed() {
1029 let (_tmp, dir) = pack_dir_with(
1030 "[pack]\nname = \"x\"\nschema = 3\n\n[[gates]]\nname = \"g\"\ncommand = \"true\"\n",
1031 &[],
1032 );
1033 let err = Pack::load(&dir).expect_err("must fail");
1034 assert!(err.contains("unknown section `gates`"), "{err}");
1035 }
1036
1037 #[test]
1038 fn pack_contract_missing_required_key_fails_closed() {
1039 let (_tmp, dir) = pack_dir_with(
1041 "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"g\"\n",
1042 &[],
1043 );
1044 let err = Pack::load(&dir).expect_err("must fail");
1045 assert!(err.contains("missing required field `command`"), "{err}");
1046
1047 let (_tmp2, dir2) = pack_dir_with("[pack]\nname = \"x\"\n", &[]);
1049 let err = Pack::load(&dir2).expect_err("must fail");
1050 assert!(err.contains("missing required field `schema`"), "{err}");
1051 }
1052
1053 #[test]
1054 fn pack_contract_wrong_type_fails_closed() {
1055 let (_tmp, dir) = pack_dir_with("[pack]\nname = \"x\"\nschema = \"3\"\n", &[]);
1056 let err = Pack::load(&dir).expect_err("must fail");
1057 assert!(err.contains("field `schema` must be an integer"), "{err}");
1058
1059 let (_tmp2, dir2) = pack_dir_with(
1060 "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"g\"\ncommand = \"true\"\nwhenPaths = \"src\"\n",
1061 &[],
1062 );
1063 let err = Pack::load(&dir2).expect_err("must fail");
1064 assert!(
1065 err.contains("field `whenPaths` must be an array of strings"),
1066 "{err}"
1067 );
1068 }
1069
1070 #[test]
1071 fn pack_contract_duplicate_name_fails_closed() {
1072 let (_tmp, dir) = pack_dir_with(
1073 "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"g\"\ncommand = \"true\"\n\n[[gate]]\nname = \"g\"\ncommand = \"false\"\n",
1074 &[],
1075 );
1076 let err = Pack::load(&dir).expect_err("must fail");
1077 assert!(err.contains("duplicate [[gate]] name `g`"), "{err}");
1078 }
1079
1080 #[test]
1081 fn pack_contract_model_judged_gate_kind_refused() {
1082 let (_tmp, dir) = pack_dir_with(
1083 "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"g\"\ncommand = \"true\"\nkind = \"model-judged\"\n",
1084 &[],
1085 );
1086 let err = Pack::load(&dir).expect_err("must fail");
1087 assert!(err.contains("field `kind` is `model-judged`"), "{err}");
1088 assert!(err.contains("deterministic"), "{err}");
1089 }
1090
1091 #[test]
1092 fn pack_contract_engine_floor_gate_names_are_reserved() {
1093 for reserved in RESERVED_GATE_NAMES {
1094 let manifest = format!(
1095 "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"{reserved}\"\ncommand = \"true\"\n"
1096 );
1097 let (_tmp, dir) = pack_dir_with(&manifest, &[]);
1098 let err = Pack::load(&dir).expect_err("must fail");
1099 assert!(err.contains("reserved for an engine floor gate"), "{err}");
1100 }
1101 }
1102
1103 #[test]
1104 fn pack_contract_empty_gate_command_fails_closed() {
1105 let (_tmp, dir) = pack_dir_with(
1106 "[pack]\nname = \"x\"\nschema = 3\n\n[[gate]]\nname = \"g\"\ncommand = \" \"\n",
1107 &[],
1108 );
1109 let err = Pack::load(&dir).expect_err("must fail");
1110 assert!(
1111 err.contains("field `command` must be a non-empty string"),
1112 "{err}"
1113 );
1114 }
1115
1116 #[test]
1117 fn pack_contract_unsupported_schema_fails_closed() {
1118 let (_tmp, dir) = pack_dir_with("[pack]\nname = \"x\"\nschema = 5\n", &[]);
1119 let err = Pack::load(&dir).expect_err("must fail");
1120 assert!(err.contains("field `schema` is 5"), "{err}");
1121 }
1122
1123 #[test]
1124 fn pack_contract_prompt_text_and_textfile_are_exclusive() {
1125 let (_tmp, dir) = pack_dir_with(
1126 "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\ntext = \"t\"\ntextFile = \"p.md\"\n",
1127 &[],
1128 );
1129 let err = Pack::load(&dir).expect_err("must fail");
1130 assert!(err.contains("both `text` and `textFile`"), "{err}");
1131
1132 let (_tmp2, dir2) = pack_dir_with(
1133 "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\n",
1134 &[],
1135 );
1136 let err = Pack::load(&dir2).expect_err("must fail");
1137 assert!(err.contains("missing required field `text`"), "{err}");
1138 }
1139
1140 #[test]
1141 fn pack_contract_prompt_role_must_target_a_session_role() {
1142 let (_tmp, dir) = pack_dir_with(
1143 "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"orchestrator\"\ntext = \"t\"\n",
1144 &[],
1145 );
1146 let err = Pack::load(&dir).expect_err("must fail");
1147 assert!(err.contains("field `role` is `orchestrator`"), "{err}");
1148 }
1149
1150 #[test]
1151 fn pack_contract_prompt_textfile_must_stay_inside_the_pack() {
1152 let (_tmp, dir) = pack_dir_with(
1153 "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\ntextFile = \"../escape.md\"\n",
1154 &[],
1155 );
1156 let err = Pack::load(&dir).expect_err("must fail");
1157 assert!(
1158 err.contains("field `textFile` must be a pack-relative path"),
1159 "{err}"
1160 );
1161
1162 let (_tmp2, dir2) = pack_dir_with(
1163 "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\ntextFile = \"missing.md\"\n",
1164 &[],
1165 );
1166 let err = Pack::load(&dir2).expect_err("must fail");
1167 assert!(
1168 err.contains("field `textFile` = \"missing.md\" cannot be read"),
1169 "{err}"
1170 );
1171 }
1172
1173 #[test]
1174 fn pack_contract_checklist_requires_items() {
1175 let (_tmp, dir) = pack_dir_with(
1176 "[pack]\nname = \"x\"\nschema = 3\n\n[[checklist]]\nname = \"c\"\n",
1177 &[],
1178 );
1179 let err = Pack::load(&dir).expect_err("must fail");
1180 assert!(err.contains("missing required field `items`"), "{err}");
1181 }
1182
1183 #[test]
1186 fn pack_contract_prompt_section_targets_only_the_named_role() {
1187 let (_tmp, dir) =
1188 pack_dir_with(FULL_MANIFEST, &[("prompts/scrutiny.md", "zz file text\n")]);
1189 let pack = Pack::load(&dir).unwrap().unwrap();
1190 let worker = pack.prompt_section(Role::Worker);
1191 assert!(worker.contains("zz-prompt-worker"), "{worker}");
1192 assert!(worker.contains("zz inline worker guidance"), "{worker}");
1193 assert!(!worker.contains("zz-prompt-scrutiny"), "{worker}");
1194 let scrutiny = pack.prompt_section(Role::ValidatorScrutiny);
1195 assert!(scrutiny.contains("zz file text"), "{scrutiny}");
1196 assert!(!scrutiny.contains("zz-prompt-worker"), "{scrutiny}");
1197 assert_eq!(pack.prompt_section(Role::ValidatorFunctional), "");
1198 }
1199
1200 #[test]
1201 fn pack_contract_when_paths_scope_gates_like_the_merge_suite() {
1202 let (_tmp, dir) =
1203 pack_dir_with(FULL_MANIFEST, &[("prompts/scrutiny.md", "zz file text\n")]);
1204 let pack = Pack::load(&dir).unwrap().unwrap();
1205 let changed = vec!["crates/engine/src/lib.rs".to_string()];
1206 let applicable: Vec<&str> = pack
1207 .gates_for_paths(&changed)
1208 .iter()
1209 .map(|g| g.name.as_str())
1210 .collect();
1211 assert_eq!(applicable, vec!["zz-gate-one"], "scoped gate skipped");
1212 let changed = vec!["src/widget.ts".to_string()];
1213 let applicable: Vec<&str> = pack
1214 .gates_for_paths(&changed)
1215 .iter()
1216 .map(|g| g.name.as_str())
1217 .collect();
1218 assert_eq!(applicable, vec!["zz-gate-one", "zz-gate-two"]);
1219 }
1220
1221 #[test]
1225 fn pack_contract_gates_never_precede_or_displace_engine_floor_gates() {
1226 use crate::types::{Assertion, AssertionCheck};
1227 let contract = vec![Assertion {
1228 id: "a1".to_string(),
1229 statement: "s".to_string(),
1230 check: AssertionCheck::Command,
1231 command: Some("cargo test --workspace zz_pack_contract_floor 2>&1 | grep -qE 'test result: ok\\. [1-9]'".to_string()),
1232 negative_control: None,
1233 pty_script: None,
1234 }];
1235 let tree = tempfile::tempdir().unwrap();
1236 let mut pipeline = GatePipeline::new();
1237 crate::contract_gates::register_contract_gates(&mut pipeline, &contract, None, tree.path());
1239 let floor_len = pipeline.len();
1240 assert!(floor_len > 0, "floor gates registered");
1241 pipeline.register(Box::new(PackGate::from_run(
1242 "zz-pack-gate",
1243 "cd .",
1244 true,
1245 String::new(),
1246 )));
1247 let reports = pipeline.evaluate();
1248 let names: Vec<&str> = reports.iter().map(|r| r.name.as_str()).collect();
1249 assert_eq!(
1250 names.last(),
1251 Some(&"zz-pack-gate"),
1252 "the pack gate evaluates LAST: {names:?}"
1253 );
1254 let floor_names = &names[..floor_len];
1255 assert!(floor_names.contains(&crate::contract_gates::VACUOUS_FILTER));
1256 assert!(floor_names.contains(&crate::contract_gates::ENV_SENSITIVE));
1257 assert!(
1258 !floor_names.contains(&"zz-pack-gate"),
1259 "no pack gate inside the floor section"
1260 );
1261 assert_eq!(
1262 reports.len(),
1263 floor_len + 1,
1264 "the floor is intact — added to, never displaced"
1265 );
1266 }
1267
1268 #[test]
1269 fn pack_contract_pack_gate_carries_the_run_outcome() {
1270 use crate::gate::Gate;
1271 let pass = PackGate::from_run("g", "cd .", true, String::new());
1272 assert_eq!(pass.kind(), GateKind::Deterministic);
1273 assert!(pass.evaluate().passed());
1274 assert_eq!(pass.evaluate().artefact.reference, "cd .");
1275 let fail = PackGate::from_run("g", "cd .", false, "boom".to_string());
1276 assert!(!fail.evaluate().passed());
1277 assert_eq!(fail.evaluate().artefact.detail.as_deref(), Some("boom"));
1278 assert_eq!(fail.evaluate().score, None, "boolean-only gate");
1279 }
1280
1281 #[test]
1282 fn pack_contract_load_for_config_resolves_repo_relative_and_refuses_non_packs() {
1283 let repo = tempfile::tempdir().unwrap();
1284 let cfg = MissionConfig::default();
1286 assert_eq!(load_for_config(&cfg, repo.path()).unwrap(), None);
1287
1288 let (_tmp, pack_src) = pack_dir_with("[pack]\nname = \"x\"\nschema = 3\n", &[]);
1290 let rel = repo.path().join("my-pack");
1291 std::fs::create_dir_all(&rel).unwrap();
1292 std::fs::copy(pack_src.join(PACK_MANIFEST), rel.join(PACK_MANIFEST)).unwrap();
1293 let cfg = MissionConfig {
1294 pack_dir: Some("my-pack".to_string()),
1295 ..MissionConfig::default()
1296 };
1297 let pack = load_for_config(&cfg, repo.path()).unwrap().expect("a pack");
1298 assert_eq!(pack.name, "x");
1299
1300 std::fs::create_dir_all(repo.path().join("not-a-pack")).unwrap();
1302 let cfg = MissionConfig {
1303 pack_dir: Some("not-a-pack".to_string()),
1304 ..MissionConfig::default()
1305 };
1306 let err = load_for_config(&cfg, repo.path()).expect_err("must fail");
1307 assert!(err.contains("it is not a pack"), "{err}");
1308
1309 let cfg = MissionConfig {
1310 pack_dir: Some("missing-dir".to_string()),
1311 ..MissionConfig::default()
1312 };
1313 let err = load_for_config(&cfg, repo.path()).expect_err("must fail");
1314 assert!(err.contains("is not a directory"), "{err}");
1315
1316 let cfg = MissionConfig {
1320 pack_dir: Some("../pack".to_string()),
1321 ..MissionConfig::default()
1322 };
1323 let err = load_for_config(&cfg, repo.path()).expect_err("traversal must fail");
1324 assert!(err.contains("without parent components"), "{err}");
1325 }
1326
1327 #[cfg(unix)]
1337 #[test]
1338 fn pack_textfile_nofollow_refuses_a_symlinked_leaf() {
1339 use std::os::unix::fs::symlink;
1340 let tmp = tempfile::tempdir().unwrap();
1341 let outside_file = tmp.path().join("engine-readable-secret.md");
1342 std::fs::write(&outside_file, "sk-live-secret-value").unwrap();
1343 let pack = tmp.path().join("pack");
1344 std::fs::create_dir_all(pack.join("prompts")).unwrap();
1345 std::fs::write(
1346 pack.join(PACK_MANIFEST),
1347 "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\ntextFile = \"prompts/scrutiny.md\"\n",
1348 )
1349 .unwrap();
1350 symlink(&outside_file, pack.join("prompts").join("scrutiny.md")).unwrap();
1351
1352 let err = Pack::load(&pack).expect_err("a symlinked textFile must be refused");
1353 assert!(err.contains("field `textFile`"), "names the field: {err}");
1354 assert!(err.contains("no-follow"), "says why: {err}");
1355 }
1356
1357 #[cfg(unix)]
1360 #[test]
1361 fn pack_textfile_nofollow_refuses_a_symlinked_parent_dir() {
1362 use std::os::unix::fs::symlink;
1363 let tmp = tempfile::tempdir().unwrap();
1364 let outside = tmp.path().join("outside");
1365 std::fs::create_dir_all(&outside).unwrap();
1366 std::fs::write(outside.join("scrutiny.md"), "exfiltrated prompt text").unwrap();
1367 let pack = tmp.path().join("pack");
1368 std::fs::create_dir_all(&pack).unwrap();
1369 std::fs::write(
1370 pack.join(PACK_MANIFEST),
1371 "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\ntextFile = \"prompts/scrutiny.md\"\n",
1372 )
1373 .unwrap();
1374 symlink(&outside, pack.join("prompts")).unwrap();
1375
1376 let err = Pack::load(&pack).expect_err("a symlinked parent dir must be refused");
1377 assert!(err.contains("field `textFile`"), "names the field: {err}");
1378 assert!(err.contains("no-follow"), "says why: {err}");
1379 }
1380
1381 #[test]
1385 fn pack_textfile_nofollow_plain_in_pack_file_loads() {
1386 let (_tmp, dir) = pack_dir_with(
1387 "[pack]\nname = \"x\"\nschema = 3\n\n[[prompt]]\nname = \"p\"\nrole = \"worker\"\ntextFile = \"prompts/scrutiny.md\"\n",
1388 &[("prompts/scrutiny.md", "zz plain in-pack text\n")],
1389 );
1390 let pack = Pack::load(&dir).expect("load").expect("a pack");
1391 assert_eq!(pack.prompts[0].text, "zz plain in-pack text\n");
1392 assert_eq!(
1393 pack.prompts[0].source,
1394 PromptSource::File("prompts/scrutiny.md".to_string())
1395 );
1396 }
1397}