1use super::{PackGateDecl, PACK_MANIFEST};
63use sha2::{Digest as _, Sha256};
64use std::collections::{BTreeMap, HashSet};
65use std::path::{Path, PathBuf};
66
67pub const MAX_STANDARDS_FILE_BYTES: u64 = 256 * 1024;
71
72pub const MAX_STANDARDS_FILES: usize = 512;
74
75pub const MAX_STANDARDS_RULES: usize = 256;
77
78pub const MAX_STANDARDS_NORMALIZED_BYTES: usize = 1024 * 1024;
81
82const CANONICAL_HEADER: &str = "kranz-standards-manifest v1";
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum StandardsTrust {
92 RepoTracked,
95 External,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum RfcStatus {
104 Draft,
105 Approved,
106 Enforced,
107 Retired,
108}
109
110impl RfcStatus {
111 fn parse(raw: &str) -> Option<Self> {
112 match raw {
113 "draft" => Some(Self::Draft),
114 "approved" => Some(Self::Approved),
115 "enforced" => Some(Self::Enforced),
116 "retired" => Some(Self::Retired),
117 _ => None,
118 }
119 }
120
121 pub fn as_str(&self) -> &'static str {
122 match self {
123 Self::Draft => "draft",
124 Self::Approved => "approved",
125 Self::Enforced => "enforced",
126 Self::Retired => "retired",
127 }
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum RuleLevel {
136 Must,
137 Should,
138}
139
140impl RuleLevel {
141 fn parse(raw: &str) -> Option<Self> {
142 match raw {
143 "must" => Some(Self::Must),
144 "should" => Some(Self::Should),
145 _ => None,
146 }
147 }
148
149 pub fn as_str(&self) -> &'static str {
150 match self {
151 Self::Must => "must",
152 Self::Should => "should",
153 }
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum RuleStatus {
161 Active,
162 Retired,
163}
164
165impl RuleStatus {
166 fn parse(raw: &str) -> Option<Self> {
167 match raw {
168 "active" => Some(Self::Active),
169 "retired" => Some(Self::Retired),
170 _ => None,
171 }
172 }
173
174 pub fn as_str(&self) -> &'static str {
175 match self {
176 Self::Active => "active",
177 Self::Retired => "retired",
178 }
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum RuleStage {
186 Planning,
187 Implementation,
188 Validation,
189 Merge,
190}
191
192impl RuleStage {
193 pub(crate) fn parse(raw: &str) -> Option<Self> {
198 match raw {
199 "planning" => Some(Self::Planning),
200 "implementation" => Some(Self::Implementation),
201 "validation" => Some(Self::Validation),
202 "merge" => Some(Self::Merge),
203 _ => None,
204 }
205 }
206
207 pub fn as_str(&self) -> &'static str {
208 match self {
209 Self::Planning => "planning",
210 Self::Implementation => "implementation",
211 Self::Validation => "validation",
212 Self::Merge => "merge",
213 }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum Checker {
222 Gate(String),
226 AgentJudgement,
228 ManualAttestation,
230}
231
232impl Checker {
233 fn parse(raw: &str) -> Result<Self, String> {
234 match raw {
235 "agent-judgement" => Ok(Self::AgentJudgement),
236 "manual-attestation" => Ok(Self::ManualAttestation),
237 _ => match raw.strip_prefix("gate:") {
238 Some(id) if !id.is_empty() => Ok(Self::Gate(id.to_string())),
239 _ => Err(format!(
240 "supported checker forms are `gate:<id>` (a declared pack gate), \
241 `agent-judgement`, and `manual-attestation`, got `{raw}`"
242 )),
243 },
244 }
245 }
246
247 pub fn render(&self) -> String {
249 match self {
250 Self::Gate(id) => format!("gate:{id}"),
251 Self::AgentJudgement => "agent-judgement".to_string(),
252 Self::ManualAttestation => "manual-attestation".to_string(),
253 }
254 }
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct RfcMeta {
260 pub id: String,
261 pub title: String,
262 pub owner: String,
263 pub status: RfcStatus,
264 pub effective_at: Option<String>,
269 pub supersedes: Vec<String>,
271}
272
273#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct RuleMeta {
276 pub id: String,
277 pub revision: u64,
280 pub rfc: String,
282 pub level: RuleLevel,
283 pub status: RuleStatus,
284 pub statement: String,
286 pub domains: Vec<String>,
289 pub stages: Vec<RuleStage>,
291 pub when_paths: Vec<String>,
293 pub task_classes: Vec<String>,
296 pub checker: Option<Checker>,
299 pub waivable: bool,
301}
302
303#[derive(Debug, Clone, PartialEq, Eq)]
308pub struct StandardsManifest {
309 pub root: String,
311 pub rfcs: Vec<RfcMeta>,
312 pub rules: Vec<RuleMeta>,
313 pub gate_bindings: Vec<PackGateDecl>,
316 pub pack_gates: Vec<PackGateDecl>,
320 pub digest: String,
322 canonical: String,
323}
324
325impl StandardsManifest {
326 pub fn canonical_text(&self) -> &str {
331 &self.canonical
332 }
333
334 pub fn rule(&self, id: &str) -> Option<&RuleMeta> {
336 self.rules.iter().find(|r| r.id == id)
337 }
338
339 pub fn rfc(&self, id: &str) -> Option<&RfcMeta> {
341 self.rfcs.iter().find(|r| r.id == id)
342 }
343
344 pub fn effective_status(&self, rule: &RuleMeta) -> RfcStatus {
349 if rule.status == RuleStatus::Retired {
350 return RfcStatus::Retired;
351 }
352 self.rfc(&rule.rfc)
353 .map(|rfc| rfc.status)
354 .unwrap_or(RfcStatus::Retired)
355 }
356}
357
358pub(crate) fn load_from_pack_dir(
367 pack_dir: &Path,
368 root: &str,
369 gates: &[PackGateDecl],
370 trust: StandardsTrust,
371) -> Result<StandardsManifest, String> {
372 use cap_fs_ext::DirExt as _;
373
374 let display_root = pack_dir.join(root);
375 let mut dir = cap_std::fs::Dir::open_ambient_dir(pack_dir, cap_std::ambient_authority())
376 .map_err(|e| format!("cannot open pack dir {}: {e}", pack_dir.display()))?;
377 for name in root.split('/') {
380 dir = dir.open_dir_nofollow(name).map_err(|e| {
381 if e.kind() == std::io::ErrorKind::NotFound {
382 format!(
383 "[standards] root `{root}` does not exist in the pack ({})",
384 display_root.display()
385 )
386 } else {
387 format!(
388 "[standards] root `{root}` resolves through a symlinked or non-directory \
389 component ({}) — the standards corpus loads no-follow",
390 display_root.display()
391 )
392 }
393 })?;
394 }
395 let source = FsSource {
396 root_dir: dir,
397 display_root,
398 };
399 load_from_source(&source, root, gates, trust)
400}
401
402pub fn load_at_ref(
410 repo: &crate::git_ops::GitRepo,
411 refname: &str,
412 pack_rel_dir: &str,
413) -> Result<Option<StandardsManifest>, String> {
414 let oid = repo
415 .rev_parse(refname)
416 .map_err(|e| format!("cannot resolve ref `{refname}`: {e}"))?;
417 let manifest_rel = join_rel(pack_rel_dir, PACK_MANIFEST);
418 let Some(bytes) = repo
419 .show_file(&oid, &manifest_rel)
420 .map_err(|e| format!("cannot read {manifest_rel} at `{refname}`: {e}"))?
421 else {
422 return Ok(None);
423 };
424 let text = String::from_utf8(bytes)
425 .map_err(|_| format!("{manifest_rel} at `{refname}` is not valid UTF-8"))?;
426 let doc =
427 super::toml::parse(&text).map_err(|e| format!("{manifest_rel} at `{refname}`: {e}"))?;
428 let (_name, schema) =
432 super::manifest_header(&doc).map_err(|e| format!("{manifest_rel} at `{refname}`: {e}"))?;
433 let Some(root) = super::standards_root_of(&doc, schema)
434 .map_err(|e| format!("{manifest_rel} at `{refname}`: {e}"))?
435 else {
436 return Ok(None);
437 };
438 let mut gates = Vec::new();
441 for (idx, item) in doc.array("gate").iter().enumerate() {
442 gates.push(
443 super::load_gate(item, idx)
444 .map_err(|e| format!("{manifest_rel} at `{refname}`: {e}"))?,
445 );
446 }
447 super::reject_duplicate_names("gate", gates.iter().map(|g| g.name.as_str()))
448 .map_err(|e| format!("{manifest_rel} at `{refname}`: {e}"))?;
449 let prefix = join_rel(pack_rel_dir, &root);
450 let source = GitSource {
451 repo,
452 oid: &oid,
453 refname,
454 prefix,
455 };
456 load_from_source(&source, &root, &gates, StandardsTrust::RepoTracked).map(Some)
459}
460
461pub fn trust_for_dir(repo_root: &Path, pack_dir: &Path) -> StandardsTrust {
467 let Some(rel) = repo_relative_dir(repo_root, pack_dir) else {
468 return StandardsTrust::External;
469 };
470 let manifest_rel = join_rel(&rel, PACK_MANIFEST);
471 let Ok(repo) = crate::git_ops::GitRepo::open(repo_root) else {
472 return StandardsTrust::External;
473 };
474 match repo.is_tracked(&manifest_rel) {
475 Ok(true) => StandardsTrust::RepoTracked,
476 _ => StandardsTrust::External,
477 }
478}
479
480pub fn repo_relative_dir(repo_root: &Path, dir: &Path) -> Option<String> {
485 let repo_c = std::fs::canonicalize(repo_root).ok()?;
486 let dir_c = std::fs::canonicalize(dir).ok()?;
487 let rel = dir_c.strip_prefix(&repo_c).ok()?;
488 let mut out = String::new();
489 for part in rel.components() {
490 let std::path::Component::Normal(name) = part else {
491 return None;
492 };
493 if !out.is_empty() {
494 out.push('/');
495 }
496 out.push_str(name.to_str()?);
497 }
498 Some(out)
499}
500
501fn join_rel(dir: &str, leaf: &str) -> String {
504 if dir.is_empty() {
505 leaf.to_string()
506 } else {
507 format!("{dir}/{leaf}")
508 }
509}
510
511pub fn check_transitions(
529 base: Option<&StandardsManifest>,
530 proposed: &StandardsManifest,
531) -> Vec<String> {
532 let mut errors = Vec::new();
533
534 for rfc in proposed
535 .rfcs
536 .iter()
537 .filter(|r| r.status == RfcStatus::Enforced)
538 {
539 match base.and_then(|b| b.rfc(&rfc.id)) {
540 None => errors.push(format!(
541 "RFC `{}` is enforced but absent at the base — an RFC may not move \
542 absent/draft → enforced; land it approved first so the advisory period \
543 produces real evidence (D-B)",
544 rfc.id
545 )),
546 Some(base_rfc) if base_rfc.status == RfcStatus::Draft => errors.push(format!(
547 "RFC `{}` is enforced but was draft at the base — absent/draft → enforced \
548 is refused; promote through approved first (D-B)",
549 rfc.id
550 )),
551 Some(base_rfc) if base_rfc.status == RfcStatus::Retired => errors.push(format!(
552 "RFC `{}` is enforced but was retired at the base — a tombstone is one-way \
553 (D-B/D-C)",
554 rfc.id
555 )),
556 Some(_) => {}
557 }
558 }
559
560 for rule in &proposed.rules {
561 if proposed.effective_status(rule) != RfcStatus::Enforced {
562 continue;
563 }
564 let base_effective = match base {
565 Some(base) => base
566 .rule(&rule.id)
567 .map(|base_rule| base.effective_status(base_rule)),
568 None => None,
569 };
570 match base_effective {
571 Some(RfcStatus::Approved | RfcStatus::Enforced) => {}
572 Some(RfcStatus::Retired) => {}
575 Some(RfcStatus::Draft) => errors.push(format!(
576 "rule `{}` is enforced but was draft at the base — absent/draft → enforced \
577 is refused; an approved advisory period comes first (D-B)",
578 rule.id
579 )),
580 None => errors.push(format!(
581 "rule `{}` is enforced but absent at the base — new rules enter as draft or \
582 approved, never directly enforced (D-B)",
583 rule.id
584 )),
585 }
586 }
587
588 let Some(base) = base else {
589 return errors;
590 };
591 for base_rule in &base.rules {
592 let Some(proposed_rule) = proposed.rule(&base_rule.id) else {
593 errors.push(format!(
594 "rule `{}` (base revision {}) is gone — known rule IDs cannot disappear; \
595 retire the rule as a one-way tombstone instead of deleting it (D-C)",
596 base_rule.id, base_rule.revision
597 ));
598 continue;
599 };
600 if base_rule.status == RuleStatus::Retired && proposed_rule.status == RuleStatus::Active {
601 errors.push(format!(
602 "rule `{}` was retired at the base and cannot be reactivated — retirement \
603 is a one-way tombstone; a successor rule needs a new ID (D-B/D-C)",
604 base_rule.id
605 ));
606 continue;
607 }
608 if base_rule.status == RuleStatus::Active && proposed_rule.status == RuleStatus::Active {
609 if proposed_rule.revision < base_rule.revision {
610 errors.push(format!(
611 "rule `{}` revision moved backwards ({} → {}) — revisions are monotonic \
612 (D-C)",
613 base_rule.id, base_rule.revision, proposed_rule.revision
614 ));
615 } else if proposed_rule.revision == base_rule.revision {
616 if let Some(field) = semantic_change(base_rule, proposed_rule) {
617 errors.push(format!(
618 "rule `{}` changed `{field}` without a revision increment (still \
619 {}) — a semantic change to statement, level, scope, checker, or \
620 waiver posture requires a bump (D-C)",
621 base_rule.id, base_rule.revision
622 ));
623 }
624 }
625 }
626 }
627 errors
628}
629
630fn semantic_change(base: &RuleMeta, proposed: &RuleMeta) -> Option<&'static str> {
636 if base.statement != proposed.statement {
637 Some("statement")
638 } else if base.level != proposed.level {
639 Some("level")
640 } else if base.stages != proposed.stages {
641 Some("stages")
642 } else if base.when_paths != proposed.when_paths {
643 Some("when-paths")
644 } else if base.task_classes != proposed.task_classes {
645 Some("task-classes")
646 } else if base.checker != proposed.checker {
647 Some("checker")
648 } else if base.waivable != proposed.waivable {
649 Some("waivable")
650 } else {
651 None
652 }
653}
654
655pub fn render_registration(manifest: &StandardsManifest) -> String {
662 let tally = |status: RfcStatus| manifest.rfcs.iter().filter(|r| r.status == status).count();
663 let active_rules = manifest
664 .rules
665 .iter()
666 .filter(|r| r.status == RuleStatus::Active)
667 .count();
668 format!(
669 "standards (schema {} root `{}`):\n digest: sha256:{}\n RFCs: {} (draft {}, approved \
670 {}, enforced {}, retired {}); rules: {} (active {}, retired {}); gate bindings: {}\n",
671 super::SCHEMA_STANDARDS,
672 manifest.root,
673 manifest.digest,
674 manifest.rfcs.len(),
675 tally(RfcStatus::Draft),
676 tally(RfcStatus::Approved),
677 tally(RfcStatus::Enforced),
678 tally(RfcStatus::Retired),
679 manifest.rules.len(),
680 active_rules,
681 manifest.rules.len() - active_rules,
682 manifest.gate_bindings.len(),
683 )
684}
685
686pub fn render_manifest(manifest: &StandardsManifest, trust: StandardsTrust) -> String {
690 let mut out = format!(
691 "standards root `{}` — {} RFC(s), {} rule(s)\ndigest: sha256:{}\n",
692 manifest.root,
693 manifest.rfcs.len(),
694 manifest.rules.len(),
695 manifest.digest
696 );
697 out.push_str(match trust {
698 StandardsTrust::RepoTracked => "trust: repo-tracked — enforced rules may activate\n",
699 StandardsTrust::External => {
700 "trust: external/untracked — advisory only; enforced rules are refused at load \
701 (D-A/D-J)\n"
702 }
703 });
704 out.push_str("RFCs:\n");
705 if manifest.rfcs.is_empty() {
706 out.push_str(" (none)\n");
707 }
708 for rfc in &manifest.rfcs {
709 let effective = rfc
710 .effective_at
711 .as_deref()
712 .map(|ts| format!(", effective {ts}"))
713 .unwrap_or_default();
714 let supersedes = if rfc.supersedes.is_empty() {
715 String::new()
716 } else {
717 format!(", supersedes {}", rfc.supersedes.join(", "))
718 };
719 out.push_str(&format!(
720 " - {} \"{}\" — {}, owner {}{}{}\n",
721 rfc.id,
722 rfc.title,
723 rfc.status.as_str(),
724 rfc.owner,
725 effective,
726 supersedes
727 ));
728 }
729 out.push_str("rules:\n");
730 if manifest.rules.is_empty() {
731 out.push_str(" (none)\n");
732 }
733 for rule in &manifest.rules {
734 let checker = rule
735 .checker
736 .as_ref()
737 .map(Checker::render)
738 .unwrap_or_else(|| "-".to_string());
739 out.push_str(&format!(
740 " - {} r{} — {}, {}; checker {}; waivable: {}\n statement: {}\n",
741 rule.id,
742 rule.revision,
743 rule.level.as_str(),
744 manifest.effective_status(rule).as_str(),
745 checker,
746 rule.waivable,
747 rule.statement
748 ));
749 let list = |items: &[String]| {
750 if items.is_empty() {
751 "-".to_string()
752 } else {
753 items.join(", ")
754 }
755 };
756 let stages = rule
757 .stages
758 .iter()
759 .map(RuleStage::as_str)
760 .collect::<Vec<_>>()
761 .join(", ");
762 out.push_str(&format!(
763 " stages: {}; domains: {}; when-paths: {}; task-classes: {}\n",
764 stages,
765 list(&rule.domains),
766 list(&rule.when_paths),
767 list(&rule.task_classes)
768 ));
769 }
770 out
771}
772
773pub fn render_transition_report(
776 refname: &str,
777 base: Option<&StandardsManifest>,
778 errors: &[String],
779) -> String {
780 let base_desc = match base {
781 Some(base) => format!(
782 "base digest sha256:{}, {} RFC(s), {} rule(s)",
783 base.digest,
784 base.rfcs.len(),
785 base.rules.len()
786 ),
787 None => "no standards at the base ref".to_string(),
788 };
789 let mut out = format!("transition check against `{refname}` ({base_desc}):\n");
790 if errors.is_empty() {
791 out.push_str(" ok — no lifecycle violations\n");
792 } else {
793 for error in errors {
794 out.push_str(&format!(" REFUSED: {error}\n"));
795 }
796 }
797 out
798}
799
800struct SourceFile {
807 rel: String,
808 size: u64,
809 display: String,
810}
811
812trait CorpusSource {
816 fn list_files(&self) -> Result<Vec<SourceFile>, String>;
818 fn read_bytes(&self, file: &SourceFile) -> Result<Vec<u8>, String>;
820}
821
822struct FsSource {
825 root_dir: cap_std::fs::Dir,
827 display_root: PathBuf,
828}
829
830impl CorpusSource for FsSource {
831 fn list_files(&self) -> Result<Vec<SourceFile>, String> {
832 use cap_fs_ext::DirExt as _;
833
834 let mut out = Vec::new();
835 for (name, ftype) in sorted_entries(&self.root_dir, &self.display_root)? {
836 let display = self.display_root.join(&name);
837 check_entry_name(&name, &display)?;
838 if ftype.is_symlink() {
839 return Err(format!(
840 "{} resolves through a symlink — the standards corpus never follows \
841 symlinks (D-J)",
842 display.display()
843 ));
844 }
845 if !ftype.is_dir() {
846 return Err(format!(
847 "{} is not an RFC directory — the standards root holds one directory \
848 per RFC, nothing else",
849 display.display()
850 ));
851 }
852 let rfc_dir = self.root_dir.open_dir_nofollow(&name).map_err(|_| {
853 format!(
854 "{} resolves through a symlinked or non-directory component — the \
855 standards corpus never follows symlinks (D-J)",
856 display.display()
857 )
858 })?;
859 list_rfc_dir(&rfc_dir, &name, &display, &mut out)?;
860 }
861 Ok(out)
862 }
863
864 fn read_bytes(&self, file: &SourceFile) -> Result<Vec<u8>, String> {
865 use cap_fs_ext::{DirExt as _, FollowSymlinks, OpenOptionsFollowExt as _};
866 use std::io::Read as _;
867
868 let mut dir = self
869 .root_dir
870 .try_clone()
871 .map_err(|e| format!("{} cannot be opened: {e}", file.display))?;
872 let mut names = file.rel.split('/').peekable();
873 while let Some(name) = names.next() {
874 if names.peek().is_some() {
875 dir = dir.open_dir_nofollow(name).map_err(|_| {
876 format!(
877 "{} resolves through a symlinked or non-directory component — the \
878 standards corpus never follows symlinks (D-J)",
879 file.display
880 )
881 })?;
882 continue;
883 }
884 let meta = dir
887 .symlink_metadata(name)
888 .map_err(|e| format!("{} cannot be stat'ed: {e}", file.display))?;
889 let ftype = meta.file_type();
890 if ftype.is_symlink() {
891 return Err(format!(
892 "{} is a symlink — the standards corpus never follows symlinks (D-J)",
893 file.display
894 ));
895 }
896 if !ftype.is_file() {
897 return Err(format!(
898 "{} is not a regular file (FIFO/device/socket) — the standards corpus \
899 accepts regular files only (D-J)",
900 file.display
901 ));
902 }
903 let mut options = cap_std::fs::OpenOptions::new();
904 options.read(true).follow(FollowSymlinks::No);
905 let opened = dir
906 .open_with(name, &options)
907 .map_err(|e| format!("{} cannot be read: {e}", file.display))?;
908 let mut bytes = Vec::new();
909 opened
910 .take(MAX_STANDARDS_FILE_BYTES + 1)
911 .read_to_end(&mut bytes)
912 .map_err(|e| format!("{} cannot be read: {e}", file.display))?;
913 if bytes.len() as u64 > MAX_STANDARDS_FILE_BYTES {
914 return Err(format!(
915 "{} is {} bytes, over the {}-byte per-file cap",
916 file.display,
917 bytes.len(),
918 MAX_STANDARDS_FILE_BYTES
919 ));
920 }
921 return Ok(bytes);
922 }
923 Err(format!("{} resolves to no file", file.display))
926 }
927}
928
929fn list_rfc_dir(
931 dir: &cap_std::fs::Dir,
932 rel_prefix: &str,
933 display: &Path,
934 out: &mut Vec<SourceFile>,
935) -> Result<(), String> {
936 use cap_fs_ext::DirExt as _;
937
938 for (name, ftype) in sorted_entries(dir, display)? {
939 let entry_display = display.join(&name);
940 if ftype.is_symlink() {
941 return Err(format!(
942 "{} resolves through a symlink — the standards corpus never follows \
943 symlinks (D-J)",
944 entry_display.display()
945 ));
946 }
947 if name == "rfc.md" {
948 if !ftype.is_file() {
949 return Err(format!(
950 "{} must be a regular file",
951 entry_display.display()
952 ));
953 }
954 out.push(SourceFile {
955 rel: format!("{rel_prefix}/rfc.md"),
956 size: dir.symlink_metadata(&name).map(|m| m.len()).unwrap_or(0),
957 display: entry_display.display().to_string(),
958 });
959 continue;
960 }
961 if name == "rules" {
962 if !ftype.is_dir() {
963 return Err(format!(
964 "{} must be a directory holding rule files",
965 entry_display.display()
966 ));
967 }
968 let rules_dir = dir.open_dir_nofollow(&name).map_err(|_| {
969 format!(
970 "{} resolves through a symlinked or non-directory component — the \
971 standards corpus never follows symlinks (D-J)",
972 entry_display.display()
973 )
974 })?;
975 for (rule_name, rule_ftype) in sorted_entries(&rules_dir, &entry_display)? {
976 let rule_display = entry_display.join(&rule_name);
977 if rule_ftype.is_symlink() {
978 return Err(format!(
979 "{} resolves through a symlink — the standards corpus never \
980 follows symlinks (D-J)",
981 rule_display.display()
982 ));
983 }
984 if rule_ftype.is_dir() {
985 return Err(format!(
986 "{} is a directory — rules/ holds rule Markdown files only, no \
987 nested directories",
988 rule_display.display()
989 ));
990 }
991 if !rule_ftype.is_file() {
992 return Err(format!(
993 "{} is not a regular file (FIFO/device/socket) — the standards \
994 corpus accepts regular files only (D-J)",
995 rule_display.display()
996 ));
997 }
998 if !rule_name.ends_with(".md") {
999 return Err(format!(
1000 "{} is not a `.md` rule file — rules/ holds rule Markdown files \
1001 only",
1002 rule_display.display()
1003 ));
1004 }
1005 check_entry_name(&rule_name, &rule_display)?;
1006 out.push(SourceFile {
1007 rel: format!("{rel_prefix}/rules/{rule_name}"),
1008 size: rules_dir
1009 .symlink_metadata(&rule_name)
1010 .map(|m| m.len())
1011 .unwrap_or(0),
1012 display: rule_display.display().to_string(),
1013 });
1014 }
1015 continue;
1016 }
1017 return Err(format!(
1018 "{} is unexpected — an RFC directory holds `rfc.md` and `rules/`, nothing else",
1019 entry_display.display()
1020 ));
1021 }
1022 Ok(())
1023}
1024
1025struct GitSource<'a> {
1027 repo: &'a crate::git_ops::GitRepo,
1028 oid: &'a str,
1029 refname: &'a str,
1031 prefix: String,
1033}
1034
1035impl CorpusSource for GitSource<'_> {
1036 fn list_files(&self) -> Result<Vec<SourceFile>, String> {
1037 let entries = self
1038 .repo
1039 .ls_tree_recursive(self.oid, &self.prefix)
1040 .map_err(|e| format!("cannot list the standards root at `{}`: {e}", self.refname))?;
1041 if entries.is_empty() {
1042 return Err(format!(
1043 "[standards] root is declared but no tracked files exist under `{}` at \
1044 `{}`",
1045 self.prefix, self.refname
1046 ));
1047 }
1048 let mut out = Vec::new();
1049 for entry in entries {
1050 let display = format!("{}:{}", self.refname, entry.path);
1051 if entry.path == self.prefix {
1052 return Err(format!(
1053 "{display} is a file, not a directory tree — the standards root must \
1054 be a directory"
1055 ));
1056 }
1057 let Some(rel) = entry.path.strip_prefix(&format!("{}/", self.prefix)) else {
1058 return Err(format!(
1059 "git ls-tree reported {display} outside the standards root `{}`",
1060 self.prefix
1061 ));
1062 };
1063 if rel.starts_with('"') {
1064 return Err(format!(
1065 "{display} needed git quoting — corpus names stay inside ASCII \
1066 alphanumerics, `.`, `_`, `-`"
1067 ));
1068 }
1069 if entry.mode == "120000" {
1073 return Err(format!(
1074 "{display} is a tracked symlink — the standards corpus never follows \
1075 symlinks (D-J)"
1076 ));
1077 }
1078 if entry.kind != "blob" {
1079 return Err(format!(
1080 "{display} is a {} (mode {}) — the standards corpus accepts regular \
1081 files only (D-J)",
1082 entry.kind, entry.mode
1083 ));
1084 }
1085 let size = entry.size.unwrap_or(0);
1086 validate_corpus_rel(rel, &display)?;
1087 out.push(SourceFile {
1088 rel: rel.to_string(),
1089 size,
1090 display,
1091 });
1092 }
1093 Ok(out)
1094 }
1095
1096 fn read_bytes(&self, file: &SourceFile) -> Result<Vec<u8>, String> {
1097 let path = format!("{}/{}", self.prefix, file.rel);
1098 let bytes = self
1099 .repo
1100 .show_file(self.oid, &path)
1101 .map_err(|e| format!("{} cannot be read: {e}", file.display))?
1102 .ok_or_else(|| {
1103 format!(
1104 "{} vanished between listing and read — refusing to continue",
1105 file.display
1106 )
1107 })?;
1108 if bytes.len() as u64 > MAX_STANDARDS_FILE_BYTES {
1109 return Err(format!(
1110 "{} is {} bytes, over the {MAX_STANDARDS_FILE_BYTES}-byte per-file cap",
1111 file.display,
1112 bytes.len()
1113 ));
1114 }
1115 Ok(bytes)
1116 }
1117}
1118
1119fn sorted_entries(
1122 dir: &cap_std::fs::Dir,
1123 display: &Path,
1124) -> Result<Vec<(String, cap_std::fs::FileType)>, String> {
1125 let mut out = Vec::new();
1126 let entries = dir
1127 .entries()
1128 .map_err(|e| format!("cannot list {}: {e}", display.display()))?;
1129 for entry in entries {
1130 let entry = entry.map_err(|e| format!("cannot list {}: {e}", display.display()))?;
1131 let name = entry.file_name().into_string().map_err(|_| {
1132 format!(
1133 "{} holds a non-UTF-8 file name — the standards corpus requires UTF-8 names",
1134 display.display()
1135 )
1136 })?;
1137 let meta = dir
1139 .symlink_metadata(&name)
1140 .map_err(|e| format!("{} cannot be stat'ed: {e}", display.join(&name).display()))?;
1141 out.push((name, meta.file_type()));
1142 }
1143 out.sort_by(|a, b| a.0.cmp(&b.0));
1144 Ok(out)
1145}
1146
1147fn check_entry_name(name: &str, display: &Path) -> Result<(), String> {
1151 if name.starts_with('.')
1152 || !name
1153 .chars()
1154 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
1155 {
1156 return Err(format!(
1157 "{}: corpus entry names stay inside ASCII alphanumerics, `.`, `_`, `-` and \
1158 never start with `.`",
1159 display.display()
1160 ));
1161 }
1162 Ok(())
1163}
1164
1165fn validate_corpus_rel(rel: &str, display: &str) -> Result<(), String> {
1169 let parts: Vec<&str> = rel.split('/').collect();
1170 for part in &parts {
1171 if part.starts_with('.')
1172 || part.is_empty()
1173 || !part
1174 .chars()
1175 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
1176 {
1177 return Err(format!(
1178 "{display}: corpus entry names stay inside ASCII alphanumerics, `.`, `_`, \
1179 `-` and never start with `.`"
1180 ));
1181 }
1182 }
1183 let well_formed = match parts.as_slice() {
1184 [_dir, file] => *file == "rfc.md",
1185 [_dir, rules, file] => *rules == "rules" && file.ends_with(".md"),
1186 _ => false,
1187 };
1188 if !well_formed {
1189 return Err(format!(
1190 "{display}: unexpected path shape — the corpus holds `<RFC-dir>/rfc.md` and \
1191 `<RFC-dir>/rules/<rule>.md` only"
1192 ));
1193 }
1194 Ok(())
1195}
1196
1197fn load_from_source<S: CorpusSource>(
1204 source: &S,
1205 root: &str,
1206 gates: &[PackGateDecl],
1207 trust: StandardsTrust,
1208) -> Result<StandardsManifest, String> {
1209 let mut listing = source.list_files()?;
1210 listing.sort_by(|a, b| a.rel.cmp(&b.rel));
1211 if listing.len() > MAX_STANDARDS_FILES {
1212 return Err(format!(
1213 "[standards] root `{root}` holds {} files, over the {}-file cap",
1214 listing.len(),
1215 MAX_STANDARDS_FILES
1216 ));
1217 }
1218 for file in &listing {
1221 if file.size > MAX_STANDARDS_FILE_BYTES {
1222 return Err(format!(
1223 "{} is {} bytes, over the {}-byte per-file cap",
1224 file.display, file.size, MAX_STANDARDS_FILE_BYTES
1225 ));
1226 }
1227 }
1228 let mut groups: BTreeMap<String, (Option<&SourceFile>, Vec<&SourceFile>)> = BTreeMap::new();
1231 for file in &listing {
1232 let parts: Vec<&str> = file.rel.split('/').collect();
1233 let (dir, is_rfc) = match parts.as_slice() {
1234 [dir, name] if *name == "rfc.md" => ((*dir).to_string(), true),
1235 [dir, rules, name] if *rules == "rules" && name.ends_with(".md") => {
1236 ((*dir).to_string(), false)
1237 }
1238 _ => {
1239 return Err(format!(
1240 "{}: unexpected path shape — the corpus holds `<RFC-dir>/rfc.md` and \
1241 `<RFC-dir>/rules/<rule>.md` only",
1242 file.display
1243 ))
1244 }
1245 };
1246 let group = groups.entry(dir).or_insert_with(|| (None, Vec::new()));
1247 if is_rfc {
1248 if group.0.is_some() {
1249 return Err(format!(
1250 "{}: duplicate rfc.md in one RFC directory",
1251 file.display
1252 ));
1253 }
1254 group.0 = Some(file);
1255 } else {
1256 group.1.push(file);
1257 }
1258 }
1259
1260 let mut rfcs = Vec::new();
1261 let mut rules = Vec::new();
1262 for (dir, (rfc_file, rule_files)) in &groups {
1263 let Some(rfc_file) = rfc_file else {
1264 return Err(format!(
1265 "standards RFC directory `{dir}` has rules but no rfc.md ({})",
1266 rule_files[0].display
1267 ));
1268 };
1269 let text = read_text(source, rfc_file)?;
1270 let fm = parse_frontmatter(&text, &rfc_file.display)?;
1271 rfcs.push(load_rfc(&fm, &rfc_file.display)?);
1272 for rule_file in rule_files {
1273 if rules.len() >= MAX_STANDARDS_RULES {
1274 return Err(format!(
1275 "{}: the rule count exceeds the {}-rule cap",
1276 rule_file.display, MAX_STANDARDS_RULES
1277 ));
1278 }
1279 let text = read_text(source, rule_file)?;
1280 let fm = parse_frontmatter(&text, &rule_file.display)?;
1281 let rule = load_rule(&fm, &rule_file.display)?;
1282 if let Some(Checker::Gate(id)) = &rule.checker {
1285 if !gates.iter().any(|g| &g.name == id) {
1286 return Err(format!(
1287 "{}: field `checker`: rule `{}` checker `gate:{id}` names no \
1288 declared [[gate]] in this pack — the checker must resolve to a \
1289 pack gate at load (D-F)",
1290 rule_file.display, rule.id
1291 ));
1292 }
1293 }
1294 rules.push(rule);
1295 }
1296 }
1297 assemble(root, rfcs, rules, gates, trust)
1298}
1299
1300fn read_text<S: CorpusSource>(source: &S, file: &SourceFile) -> Result<String, String> {
1302 let bytes = source.read_bytes(file)?;
1303 String::from_utf8(bytes).map_err(|_| {
1304 format!(
1305 "{} is not valid UTF-8 — corpus files are UTF-8 text",
1306 file.display
1307 )
1308 })
1309}
1310
1311fn assemble(
1313 root: &str,
1314 mut rfcs: Vec<RfcMeta>,
1315 mut rules: Vec<RuleMeta>,
1316 gates: &[PackGateDecl],
1317 trust: StandardsTrust,
1318) -> Result<StandardsManifest, String> {
1319 let mut ids = HashSet::new();
1322 for rfc in &rfcs {
1323 if !ids.insert(rfc.id.as_str()) {
1324 return Err(format!(
1325 "duplicate standards id `{}` — RFC and rule IDs are pack-wide unique (D-C)",
1326 rfc.id
1327 ));
1328 }
1329 }
1330 for rule in &rules {
1331 if !ids.insert(rule.id.as_str()) {
1332 return Err(format!(
1333 "duplicate standards id `{}` — RFC and rule IDs are pack-wide unique (D-C)",
1334 rule.id
1335 ));
1336 }
1337 }
1338
1339 let status_of = |id: &str| rfcs.iter().find(|r| r.id == id).map(|r| r.status);
1340 let mut gate_bindings: BTreeMap<&str, &PackGateDecl> = BTreeMap::new();
1341 for rule in &rules {
1342 let Some(rfc_status) = status_of(&rule.rfc) else {
1343 return Err(format!(
1344 "rule `{}` names parent RFC `{}`, which does not exist in this pack — \
1345 orphan rules fail the load (D-C)",
1346 rule.id, rule.rfc
1347 ));
1348 };
1349 let effective = if rule.status == RuleStatus::Retired {
1350 RfcStatus::Retired
1351 } else {
1352 rfc_status
1353 };
1354 if let Some(Checker::Gate(id)) = &rule.checker {
1358 let Some(gate) = gates.iter().find(|g| &g.name == id) else {
1359 return Err(format!(
1360 "rule `{}` checker `gate:{id}` names no declared [[gate]] in this pack \
1361 — the checker must resolve to a pack gate at load (D-F)",
1362 rule.id
1363 ));
1364 };
1365 gate_bindings.insert(gate.name.as_str(), gate);
1366 }
1367 if matches!(effective, RfcStatus::Approved | RfcStatus::Enforced) && rule.checker.is_none()
1368 {
1369 return Err(format!(
1370 "rule `{}` is effectively {} (RFC `{}` is {}) but declares no checker — \
1371 promotion to approved requires a valid typed binding; only draft rules \
1372 may omit one (D-F)",
1373 rule.id,
1374 effective.as_str(),
1375 rule.rfc,
1376 rfc_status.as_str()
1377 ));
1378 }
1379 if trust == StandardsTrust::External && effective == RfcStatus::Enforced {
1381 return Err(format!(
1382 "rule `{}` is effectively enforced but this pack is external/untracked — \
1383 an external pack may supply approved advisory rules, never enforced ones, \
1384 in this slice (D-A/D-J). Remedy: vendor the pack into the repo as a \
1385 tracked, repo-relative packDir so its lifecycle is provable from base \
1386 history",
1387 rule.id
1388 ));
1389 }
1390 }
1391
1392 rfcs.sort_by(|a, b| a.id.cmp(&b.id));
1393 rules.sort_by(|a, b| a.id.cmp(&b.id));
1394 let gate_bindings: Vec<PackGateDecl> =
1395 gate_bindings.values().map(|gate| (*gate).clone()).collect();
1396 let canonical = canonical_text(&rfcs, &rules, &gate_bindings);
1397 if canonical.len() > MAX_STANDARDS_NORMALIZED_BYTES {
1398 return Err(format!(
1399 "the normalized standards manifest is {} bytes, over the {}-byte cap",
1400 canonical.len(),
1401 MAX_STANDARDS_NORMALIZED_BYTES
1402 ));
1403 }
1404 let digest = Sha256::digest(canonical.as_bytes());
1405 let digest = digest
1406 .iter()
1407 .map(|b| format!("{b:02x}"))
1408 .collect::<String>();
1409 Ok(StandardsManifest {
1410 root: root.to_string(),
1411 rfcs,
1412 rules,
1413 gate_bindings,
1414 pack_gates: gates.to_vec(),
1415 digest,
1416 canonical,
1417 })
1418}
1419
1420fn canonical_text(rfcs: &[RfcMeta], rules: &[RuleMeta], gates: &[PackGateDecl]) -> String {
1425 fn list_lines(out: &mut String, indent: &str, items: &[String]) {
1426 for item in items {
1427 out.push_str(&format!("{indent}- {item}\n"));
1428 }
1429 }
1430
1431 let mut out = format!("{CANONICAL_HEADER}\n");
1432 for rfc in rfcs {
1433 out.push_str(&format!("rfc {}\n", rfc.id));
1434 out.push_str(&format!(" title: {}\n", rfc.title));
1435 out.push_str(&format!(" owner: {}\n", rfc.owner));
1436 out.push_str(&format!(" status: {}\n", rfc.status.as_str()));
1437 out.push_str(&format!(
1438 " effective-at: {}\n",
1439 rfc.effective_at.as_deref().unwrap_or("-")
1440 ));
1441 out.push_str(" supersedes:\n");
1442 list_lines(&mut out, " ", &rfc.supersedes);
1443 }
1444 for rule in rules {
1445 out.push_str(&format!("rule {}\n", rule.id));
1446 out.push_str(&format!(" revision: {}\n", rule.revision));
1447 out.push_str(&format!(" rfc: {}\n", rule.rfc));
1448 out.push_str(&format!(" level: {}\n", rule.level.as_str()));
1449 out.push_str(&format!(" status: {}\n", rule.status.as_str()));
1450 out.push_str(&format!(" statement: {}\n", rule.statement));
1451 out.push_str(" domains:\n");
1452 list_lines(&mut out, " ", &rule.domains);
1453 out.push_str(" stages:\n");
1454 let stages: Vec<String> = rule.stages.iter().map(|s| s.as_str().to_string()).collect();
1455 list_lines(&mut out, " ", &stages);
1456 out.push_str(" when-paths:\n");
1457 list_lines(&mut out, " ", &rule.when_paths);
1458 out.push_str(" task-classes:\n");
1459 list_lines(&mut out, " ", &rule.task_classes);
1460 out.push_str(&format!(
1461 " checker: {}\n",
1462 rule.checker
1463 .as_ref()
1464 .map(Checker::render)
1465 .unwrap_or_else(|| "-".to_string())
1466 ));
1467 out.push_str(&format!(" waivable: {}\n", rule.waivable));
1468 }
1469 for gate in gates {
1470 out.push_str(&format!("gate {}\n", gate.name));
1471 out.push_str(&format!(" command: {}\n", gate.command));
1472 out.push_str(" when-paths:\n");
1473 list_lines(&mut out, " ", &gate.when_paths);
1474 }
1475 out
1476}
1477
1478#[derive(Debug, Clone, PartialEq, Eq)]
1484enum FieldValue {
1485 Scalar(String),
1486 List(Vec<String>),
1487}
1488
1489struct Frontmatter {
1492 fields: Vec<(String, FieldValue)>,
1493}
1494
1495impl Frontmatter {
1496 fn get(&self, key: &str) -> Option<&FieldValue> {
1497 self.fields.iter().find(|(k, _)| k == key).map(|(_, v)| v)
1498 }
1499
1500 fn check_unknown(&self, display: &str, known: &[&str]) -> Result<(), String> {
1503 for (key, _) in &self.fields {
1504 if !known.contains(&key.as_str()) {
1505 return Err(format!(
1506 "{display}: unknown frontmatter field `{key}` (declared fields: {})",
1507 known.join(", ")
1508 ));
1509 }
1510 }
1511 Ok(())
1512 }
1513
1514 fn scalar(&self, key: &str, display: &str) -> Result<Option<&str>, String> {
1515 match self.get(key) {
1516 Some(FieldValue::Scalar(value)) => Ok(Some(value.as_str())),
1517 Some(FieldValue::List(_)) => Err(format!(
1518 "{display}: field `{key}` must be a scalar, got a list"
1519 )),
1520 None => Ok(None),
1521 }
1522 }
1523
1524 fn required_scalar(&self, key: &str, display: &str) -> Result<String, String> {
1525 self.scalar(key, display)?
1526 .map(str::to_string)
1527 .ok_or_else(|| format!("{display}: missing required field `{key}`"))
1528 }
1529
1530 fn list(&self, key: &str, display: &str) -> Result<Option<&[String]>, String> {
1531 match self.get(key) {
1532 Some(FieldValue::List(items)) => Ok(Some(items.as_slice())),
1533 Some(FieldValue::Scalar(_)) => Err(format!(
1534 "{display}: field `{key}` must be a list (`{key}: [a, b]`), got a scalar"
1535 )),
1536 None => Ok(None),
1537 }
1538 }
1539
1540 fn required_list(&self, key: &str, display: &str) -> Result<Vec<String>, String> {
1541 self.list(key, display)?
1542 .map(<[String]>::to_vec)
1543 .ok_or_else(|| format!("{display}: missing required field `{key}`"))
1544 }
1545}
1546
1547fn parse_frontmatter(text: &str, display: &str) -> Result<Frontmatter, String> {
1551 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
1554 let mut lines = text.lines().enumerate();
1555 let Some((_, first)) = lines.next() else {
1556 return Err(format!(
1557 "{display}: empty file — expected a `---` frontmatter fence"
1558 ));
1559 };
1560 if first != "---" {
1561 return Err(format!(
1562 "{display}: line 1 must be the `---` frontmatter fence, got `{first}`"
1563 ));
1564 }
1565 let mut fields: Vec<(String, FieldValue)> = Vec::new();
1566 for (idx, line) in lines {
1567 let line_no = idx + 1;
1568 if line == "---" {
1569 return Ok(Frontmatter { fields });
1570 }
1571 if line.trim().is_empty() {
1572 continue;
1573 }
1574 if line.starts_with(char::is_whitespace) {
1575 return Err(format!(
1576 "{display}: line {line_no}: unexpected indentation — the frontmatter \
1577 subset has no nested or block values"
1578 ));
1579 }
1580 if line.contains('\t') {
1581 return Err(format!(
1582 "{display}: line {line_no}: tab characters are not in the frontmatter \
1583 subset"
1584 ));
1585 }
1586 let Some(colon) = line.find(':') else {
1587 return Err(format!(
1588 "{display}: line {line_no}: expected `key: value`, got `{line}`"
1589 ));
1590 };
1591 let key = &line[..colon];
1592 if !is_kebab_key(key) {
1593 return Err(format!(
1594 "{display}: line {line_no}: unsupported field name `{key}` (lowercase \
1595 kebab-case keys only)"
1596 ));
1597 }
1598 if fields.iter().any(|(k, _)| k == key) {
1599 return Err(format!(
1600 "{display}: line {line_no}: duplicate field `{key}`"
1601 ));
1602 }
1603 let raw = line[colon + 1..].trim();
1604 if raw.is_empty() {
1605 return Err(format!(
1606 "{display}: line {line_no}: field `{key}` has an empty value — omit \
1607 optional fields instead (implicit null is not in the subset)"
1608 ));
1609 }
1610 let value = parse_value(raw, display, line_no, key)?;
1611 fields.push((key.to_string(), value));
1612 }
1613 Err(format!(
1614 "{display}: missing the closing `---` frontmatter fence"
1615 ))
1616}
1617
1618fn is_kebab_key(key: &str) -> bool {
1621 let mut parts = key.split('-');
1622 let valid_part = |part: &str| {
1623 !part.is_empty()
1624 && part
1625 .chars()
1626 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
1627 };
1628 match parts.next() {
1629 Some(first) if first.chars().next().is_some_and(|c| c.is_ascii_lowercase()) => {
1630 valid_part(first) && parts.all(valid_part)
1631 }
1632 _ => false,
1633 }
1634}
1635
1636fn parse_value(raw: &str, display: &str, line_no: usize, key: &str) -> Result<FieldValue, String> {
1640 let refusal = |what: &str| {
1641 format!(
1642 "{display}: line {line_no}: field `{key}`: {what} is not in the frontmatter \
1643 subset"
1644 )
1645 };
1646 match raw.chars().next() {
1647 Some('[') => parse_inline_list(raw, display, line_no, key).map(FieldValue::List),
1648 Some('"') => {
1649 let (value, rest) = parse_quoted(&raw[1..], display, line_no, key)?;
1650 check_trailing(rest, display, line_no, key)?;
1651 Ok(FieldValue::Scalar(value))
1652 }
1653 Some('\'') => Err(refusal("single-quoted strings (use double quotes)")),
1654 Some('&') => Err(refusal("anchors")),
1655 Some('*') => Err(refusal("aliases")),
1656 Some('!') => Err(refusal("tags")),
1657 Some('|' | '>') => Err(refusal(
1658 "block scalars (values are single-line; the statement is one line)",
1659 )),
1660 Some('{') => Err(refusal("flow mappings")),
1661 _ => {
1662 let cut = raw.find(" #").unwrap_or(raw.len());
1665 let value = raw[..cut].trim();
1666 if value.is_empty() {
1667 return Err(format!(
1668 "{display}: line {line_no}: field `{key}` has an empty value — omit \
1669 optional fields instead"
1670 ));
1671 }
1672 Ok(FieldValue::Scalar(value.to_string()))
1673 }
1674 }
1675}
1676
1677fn check_trailing(rest: &str, display: &str, line_no: usize, key: &str) -> Result<(), String> {
1680 let rest = rest.trim();
1681 if rest.is_empty() || rest.starts_with('#') {
1682 Ok(())
1683 } else {
1684 Err(format!(
1685 "{display}: line {line_no}: field `{key}` has trailing text after the value"
1686 ))
1687 }
1688}
1689
1690fn parse_quoted<'a>(
1693 text: &'a str,
1694 display: &str,
1695 line_no: usize,
1696 key: &str,
1697) -> Result<(String, &'a str), String> {
1698 let mut out = String::new();
1699 let mut chars = text.char_indices();
1700 while let Some((idx, c)) = chars.next() {
1701 match c {
1702 '"' => return Ok((out, &text[idx + 1..])),
1703 '\\' => match chars.next() {
1704 Some((_, '"')) => out.push('"'),
1705 Some((_, '\\')) => out.push('\\'),
1706 Some((_, other)) => {
1707 return Err(format!(
1708 "{display}: line {line_no}: field `{key}`: unsupported escape \
1709 `\\{other}` (only `\\\"` and `\\\\` are in the subset)"
1710 ))
1711 }
1712 None => break,
1713 },
1714 c => out.push(c),
1715 }
1716 }
1717 Err(format!(
1718 "{display}: line {line_no}: field `{key}`: unterminated quoted string"
1719 ))
1720}
1721
1722fn parse_inline_list(
1725 raw: &str,
1726 display: &str,
1727 line_no: usize,
1728 key: &str,
1729) -> Result<Vec<String>, String> {
1730 let mut items = Vec::new();
1731 let mut rest = &raw[1..];
1732 loop {
1733 rest = rest.trim_start();
1734 if let Some(after) = rest.strip_prefix(']') {
1735 check_trailing(after, display, line_no, key)?;
1736 return Ok(items);
1737 }
1738 if rest.is_empty() {
1739 return Err(format!(
1740 "{display}: line {line_no}: field `{key}`: unterminated `[` in list value"
1741 ));
1742 }
1743 if let Some(after) = rest.strip_prefix('"') {
1744 let (value, after) = parse_quoted(after, display, line_no, key)?;
1745 items.push(value);
1746 rest = after;
1747 } else {
1748 let end = rest.find([',', ']']).ok_or_else(|| {
1749 format!(
1750 "{display}: line {line_no}: field `{key}`: unterminated `[` in \
1751 list value"
1752 )
1753 })?;
1754 let element = rest[..end].trim();
1755 if element.is_empty() {
1756 return Err(format!(
1757 "{display}: line {line_no}: field `{key}`: empty list element"
1758 ));
1759 }
1760 if element.contains(['#', '"', '[', '\'']) {
1761 return Err(format!(
1762 "{display}: line {line_no}: field `{key}`: bare list element \
1763 `{element}` contains a character the subset does not allow (quote \
1764 the element)"
1765 ));
1766 }
1767 items.push(element.to_string());
1768 rest = &rest[end..];
1769 }
1770 rest = rest.trim_start();
1771 match rest.strip_prefix(',') {
1772 Some(after) => rest = after,
1773 None => {
1774 if !rest.starts_with(']') {
1776 return Err(format!(
1777 "{display}: line {line_no}: field `{key}`: expected `,` or `]` in \
1778 list value"
1779 ));
1780 }
1781 }
1782 }
1783 }
1784}
1785
1786fn is_valid_id(raw: &str) -> bool {
1793 !raw.is_empty()
1794 && raw
1795 .chars()
1796 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
1797}
1798
1799fn required_id(fm: &Frontmatter, key: &str, display: &str) -> Result<String, String> {
1800 let id = fm.required_scalar(key, display)?;
1801 if !is_valid_id(&id) {
1802 return Err(format!(
1803 "{display}: field `{key}` is `{id}` — IDs use ASCII alphanumerics, `.`, `_`, \
1804 `-` only"
1805 ));
1806 }
1807 Ok(id)
1808}
1809
1810fn required_revision(fm: &Frontmatter, display: &str) -> Result<u64, String> {
1813 let raw = fm.required_scalar("revision", display)?;
1814 if raw.is_empty() || !raw.chars().all(|c| c.is_ascii_digit()) {
1815 return Err(format!(
1816 "{display}: field `revision` must be a positive integer, got `{raw}`"
1817 ));
1818 }
1819 match raw.parse::<u64>() {
1820 Ok(n) if n >= 1 => Ok(n),
1821 _ => Err(format!(
1822 "{display}: field `revision` must be a positive integer, got `{raw}`"
1823 )),
1824 }
1825}
1826
1827fn optional_bool(fm: &Frontmatter, key: &str, display: &str) -> Result<bool, String> {
1830 match fm.scalar(key, display)? {
1831 Some("true") => Ok(true),
1832 Some("false") | None => Ok(false),
1833 Some(other) => Err(format!(
1834 "{display}: field `{key}` must be exactly `true` or `false`, got `{other}`"
1835 )),
1836 }
1837}
1838
1839fn normalized_list(mut items: Vec<String>) -> Vec<String> {
1842 items.sort();
1843 items.dedup();
1844 items
1845}
1846
1847fn load_rfc(fm: &Frontmatter, display: &str) -> Result<RfcMeta, String> {
1849 fm.check_unknown(
1850 display,
1851 &[
1852 "id",
1853 "title",
1854 "owner",
1855 "status",
1856 "effective-at",
1857 "supersedes",
1858 ],
1859 )?;
1860 let id = required_id(fm, "id", display)?;
1861 let title = fm.required_scalar("title", display)?;
1862 let owner = fm.required_scalar("owner", display)?;
1863 let status_raw = fm.required_scalar("status", display)?;
1864 let status = RfcStatus::parse(&status_raw).ok_or_else(|| {
1865 format!(
1866 "{display}: field `status` is `{status_raw}` — RFC statuses are draft, \
1867 approved, enforced, retired (D-B)"
1868 )
1869 })?;
1870 let effective_at = match fm.scalar("effective-at", display)? {
1871 Some(raw) => {
1872 let parsed = chrono::DateTime::parse_from_rfc3339(raw).map_err(|_| {
1873 format!(
1874 "{display}: field `effective-at` must be an RFC3339 timestamp, got \
1875 `{raw}`"
1876 )
1877 })?;
1878 Some(
1881 parsed
1882 .with_timezone(&chrono::Utc)
1883 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1884 )
1885 }
1886 None => None,
1887 };
1888 let mut supersedes = Vec::new();
1889 if let Some(items) = fm.list("supersedes", display)? {
1890 for item in items {
1891 if !is_valid_id(item) {
1892 return Err(format!(
1893 "{display}: field `supersedes` element `{item}` is not a valid RFC id"
1894 ));
1895 }
1896 supersedes.push(item.clone());
1897 }
1898 }
1899 Ok(RfcMeta {
1900 id,
1901 title,
1902 owner,
1903 status,
1904 effective_at,
1905 supersedes: normalized_list(supersedes),
1906 })
1907}
1908
1909fn load_rule(fm: &Frontmatter, display: &str) -> Result<RuleMeta, String> {
1911 fm.check_unknown(
1912 display,
1913 &[
1914 "id",
1915 "revision",
1916 "rfc",
1917 "level",
1918 "status",
1919 "statement",
1920 "domains",
1921 "stages",
1922 "when-paths",
1923 "task-classes",
1924 "checker",
1925 "waivable",
1926 ],
1927 )?;
1928 let id = required_id(fm, "id", display)?;
1929 let revision = required_revision(fm, display)?;
1930 let rfc = required_id(fm, "rfc", display)?;
1931 let level_raw = fm.required_scalar("level", display)?;
1932 let level = RuleLevel::parse(&level_raw).ok_or_else(|| {
1933 format!(
1934 "{display}: field `level` is `{level_raw}` — RFC-2119 levels are must and \
1935 should (D-B has no `may` row)"
1936 )
1937 })?;
1938 let status_raw = fm.required_scalar("status", display)?;
1939 let status = RuleStatus::parse(&status_raw).ok_or_else(|| {
1940 format!(
1941 "{display}: field `status` is `{status_raw}` — rule statuses are active and \
1942 retired (D-B)"
1943 )
1944 })?;
1945 let statement = fm.required_scalar("statement", display)?;
1946 let domains = normalized_list(fm.required_list("domains", display)?);
1947 let mut stages = Vec::new();
1948 for raw in fm.required_list("stages", display)? {
1949 let Some(stage) = RuleStage::parse(&raw) else {
1950 return Err(format!(
1951 "{display}: field `stages` element `{raw}` — stages are planning, \
1952 implementation, validation, merge"
1953 ));
1954 };
1955 stages.push(stage);
1956 }
1957 if stages.is_empty() {
1958 return Err(format!(
1959 "{display}: field `stages` must list at least one stage — a rule applying \
1960 nowhere is not a rule"
1961 ));
1962 }
1963 stages.sort_by_key(|s| s.as_str());
1965 stages.dedup();
1966 let mut when_paths = Vec::new();
1967 if let Some(items) = fm.list("when-paths", display)? {
1968 for item in items {
1969 let path = Path::new(item);
1970 if item.trim().is_empty()
1971 || path.is_absolute()
1972 || path.components().any(|part| {
1973 !matches!(
1974 part,
1975 std::path::Component::CurDir | std::path::Component::Normal(_)
1976 )
1977 })
1978 {
1979 return Err(format!(
1980 "{display}: field `when-paths` entries must be repo-relative paths \
1981 without parent components: {item:?}"
1982 ));
1983 }
1984 let normalized = crate::merge_gate::normalize_relative_path(item, false);
1985 if normalized.is_empty() || normalized == "." {
1986 return Err(format!(
1987 "{display}: field `when-paths` entries must name a repo path — omit \
1988 the field to leave the rule unscoped"
1989 ));
1990 }
1991 when_paths.push(normalized);
1992 }
1993 }
1994 let mut task_classes = Vec::new();
1995 if let Some(items) = fm.list("task-classes", display)? {
1996 task_classes.extend(items.iter().cloned());
1997 }
1998 let checker = match fm.scalar("checker", display)? {
1999 Some(raw) => {
2000 Some(Checker::parse(raw).map_err(|e| format!("{display}: field `checker`: {e}"))?)
2001 }
2002 None => None,
2003 };
2004 let waivable = optional_bool(fm, "waivable", display)?;
2005 Ok(RuleMeta {
2006 id,
2007 revision,
2008 rfc,
2009 level,
2010 status,
2011 statement,
2012 domains,
2013 stages,
2014 when_paths: normalized_list(when_paths),
2015 task_classes: normalized_list(task_classes),
2016 checker,
2017 waivable,
2018 })
2019}
2020
2021#[cfg(test)]
2029mod tests {
2030 use super::*;
2031 use crate::pack::{SCHEMA_BASE, SCHEMA_CONTRACT, SCHEMA_STANDARDS};
2032
2033 fn pack_with(manifest: &str, files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
2035 let tmp = tempfile::tempdir().expect("tempdir");
2036 let dir = tmp.path().join("pack");
2037 std::fs::create_dir_all(&dir).unwrap();
2038 std::fs::write(dir.join(PACK_MANIFEST), manifest).unwrap();
2039 for (rel, body) in files {
2040 let path = dir.join(rel);
2041 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2042 std::fs::write(path, body).unwrap();
2043 }
2044 (tmp, dir)
2045 }
2046
2047 const PACK_TOML: &str = r#"
2050[pack]
2051name = "zz-standards-pack"
2052schema = 4
2053
2054[standards]
2055root = "standards"
2056
2057[[gate]]
2058name = "zz-gate-one"
2059command = "cd ."
2060"#;
2061
2062 const RFC_MD: &str = "\
2065---
2066id: RFC-001
2067title: zz synthetic safety standard
2068status: approved
2069owner: zz-platform
2070effective-at: 2026-09-01T02:00:00+02:00
2071---
2072Prose rationale — never hashed.
2073";
2074
2075 const RULE_ONE: &str = "\
2076---
2077id: ZZ-RULE-001
2078revision: 1
2079rfc: RFC-001
2080level: must
2081status: active
2082statement: zz synthetic must statement one.
2083domains: [zz-domain]
2084stages: [implementation, validation]
2085checker: gate:zz-gate-one
2086waivable: false
2087---
2088Rule one prose.
2089";
2090
2091 const RULE_TWO: &str = "\
2094---
2095id: ZZ-RULE-002
2096revision: 2
2097rfc: RFC-001
2098level: should
2099status: active
2100statement: zz synthetic should statement two.
2101domains: [zz-other, zz-domain]
2102stages: [planning, implementation, validation, merge]
2103when-paths: [crates/]
2104task-classes: [implementation]
2105checker: agent-judgement
2106---
2107Rule two prose.
2108";
2109
2110 const CORPUS: &[(&str, &str)] = &[
2111 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2112 ("standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md", RULE_ONE),
2113 ("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md", RULE_TWO),
2114 ];
2115
2116 fn synthetic_pack() -> (tempfile::TempDir, PathBuf) {
2117 pack_with(PACK_TOML, CORPUS)
2118 }
2119
2120 fn load_trusted(dir: &Path) -> StandardsManifest {
2123 crate::pack::Pack::load_with_trust(dir, StandardsTrust::RepoTracked)
2124 .expect("load")
2125 .expect("a pack")
2126 .standards
2127 .expect("a standards manifest")
2128 }
2129
2130 const EXPECTED_CANONICAL: &str = "\
2134kranz-standards-manifest v1
2135rfc RFC-001
2136 title: zz synthetic safety standard
2137 owner: zz-platform
2138 status: approved
2139 effective-at: 2026-09-01T00:00:00Z
2140 supersedes:
2141rule ZZ-RULE-001
2142 revision: 1
2143 rfc: RFC-001
2144 level: must
2145 status: active
2146 statement: zz synthetic must statement one.
2147 domains:
2148 - zz-domain
2149 stages:
2150 - implementation
2151 - validation
2152 when-paths:
2153 task-classes:
2154 checker: gate:zz-gate-one
2155 waivable: false
2156rule ZZ-RULE-002
2157 revision: 2
2158 rfc: RFC-001
2159 level: should
2160 status: active
2161 statement: zz synthetic should statement two.
2162 domains:
2163 - zz-domain
2164 - zz-other
2165 stages:
2166 - implementation
2167 - merge
2168 - planning
2169 - validation
2170 when-paths:
2171 - crates
2172 task-classes:
2173 - implementation
2174 checker: agent-judgement
2175 waivable: false
2176gate zz-gate-one
2177 command: cd .
2178 when-paths:
2179";
2180
2181 const EXPECTED_DIGEST: &str =
2184 "8dfb505b203fea5f66286353defce0ef46118331fc45946b00b27c6bacd01df7";
2185
2186 #[test]
2187 fn flight_rules_contract_synthetic_pack_loads_byte_stable_manifest_and_digest() {
2188 let (_tmp, dir) = synthetic_pack();
2189 let manifest = load_trusted(&dir);
2190 assert_eq!(manifest.root, "standards");
2191 assert_eq!(manifest.rfcs.len(), 1);
2192 assert_eq!(manifest.rules.len(), 2);
2193 assert_eq!(manifest.gate_bindings.len(), 1);
2194 assert_eq!(manifest.canonical_text(), EXPECTED_CANONICAL);
2195 assert_eq!(manifest.digest, EXPECTED_DIGEST);
2198 let rfc = &manifest.rfcs[0];
2200 assert_eq!(rfc.id, "RFC-001");
2201 assert_eq!(rfc.status, RfcStatus::Approved);
2202 assert_eq!(
2203 rfc.effective_at.as_deref(),
2204 Some("2026-09-01T00:00:00Z"),
2205 "effective-at normalizes to UTC seconds"
2206 );
2207 let rule = manifest.rule("ZZ-RULE-002").expect("rule two");
2208 assert_eq!(rule.revision, 2);
2209 assert_eq!(rule.level, RuleLevel::Should);
2210 assert_eq!(rule.domains, vec!["zz-domain", "zz-other"], "sorted");
2211 assert_eq!(rule.when_paths, vec!["crates"], "normalized, slash-free");
2212 assert_eq!(rule.checker, Some(Checker::AgentJudgement));
2213 assert!(!rule.waivable);
2214 assert_eq!(
2215 manifest.effective_status(rule),
2216 RfcStatus::Approved,
2217 "an active rule inherits its RFC's lifecycle (D-B)"
2218 );
2219 }
2220
2221 #[test]
2222 fn flight_rules_contract_renaming_files_and_dirs_preserves_identity_and_digest() {
2223 let (_tmp, dir) = synthetic_pack();
2224 let before = load_trusted(&dir);
2225 let renamed = &[
2228 ("standards/RFC-001-renamed/rfc.md", RFC_MD),
2229 (
2230 "standards/RFC-001-renamed/rules/ZZ-RENAMED-001.md",
2231 RULE_ONE,
2232 ),
2233 ("standards/RFC-001-renamed/rules/ZZ-RULE-002.md", RULE_TWO),
2234 ];
2235 let (_tmp2, dir2) = pack_with(PACK_TOML, renamed);
2236 let after = load_trusted(&dir2);
2237 assert_eq!(before.digest, after.digest);
2238 assert_eq!(before, after, "paths are not identity (D-C)");
2239 }
2240
2241 #[test]
2242 fn flight_rules_contract_prose_edits_do_not_churn_the_digest() {
2243 let (_tmp, dir) = synthetic_pack();
2244 let before = load_trusted(&dir);
2245 let edited_rfc = RFC_MD.replace("never hashed", "EDITED rationale");
2246 let edited_rule = RULE_ONE.replace("Rule one prose.", "COMPLETELY NEW PROSE.");
2247 let corpus = &[
2248 ("standards/RFC-001-zz-safety/rfc.md", edited_rfc.as_str()),
2249 (
2250 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2251 edited_rule.as_str(),
2252 ),
2253 ("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md", RULE_TWO),
2254 ];
2255 let (_tmp2, dir2) = pack_with(PACK_TOML, corpus);
2256 let after = load_trusted(&dir2);
2257 assert_eq!(
2258 before.digest, after.digest,
2259 "prose is rationale, not a second machine authority (D-C)"
2260 );
2261 }
2262
2263 #[test]
2264 fn flight_rules_contract_changing_a_referenced_gate_declaration_changes_the_digest() {
2265 let (_tmp, dir) = synthetic_pack();
2266 let before = load_trusted(&dir);
2267 let manifest_toml = PACK_TOML.replace("command = \"cd .\"", "command = \"cd ..\"");
2271 let (_tmp2, dir2) = pack_with(&manifest_toml, CORPUS);
2272 let after = load_trusted(&dir2);
2273 assert_ne!(before.digest, after.digest);
2274 let with_extra_gate =
2277 format!("{PACK_TOML}\n[[gate]]\nname = \"zz-gate-two\"\ncommand = \"cd /\"\n");
2278 let (_tmp3, dir3) = pack_with(&with_extra_gate, CORPUS);
2279 let third = load_trusted(&dir3);
2280 assert_eq!(before.digest, third.digest);
2281 }
2282
2283 #[test]
2284 fn flight_rules_contract_standards_section_requires_schema_four() {
2285 for schema in [SCHEMA_BASE, SCHEMA_CONTRACT] {
2286 let manifest = format!(
2287 "[pack]\nname = \"x\"\nschema = {schema}\n\n[standards]\nroot = \"standards\"\n"
2288 );
2289 let (_tmp, dir) = pack_with(&manifest, &[]);
2290 let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2291 assert!(err.contains("[standards]"), "names the field: {err}");
2292 assert!(err.contains("schema"), "says why: {err}");
2293 }
2294 }
2295
2296 #[test]
2297 fn flight_rules_contract_standards_section_unknown_key_fails_closed() {
2298 let manifest =
2299 "[pack]\nname = \"x\"\nschema = 4\n\n[standards]\nroot = \"standards\"\nbogus = 1\n";
2300 let (_tmp, dir) = pack_with(manifest, &[]);
2301 let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2302 assert!(err.contains("unknown field `bogus`"), "{err}");
2303 assert!(err.contains("[standards]"), "{err}");
2304
2305 let manifest = "[pack]\nname = \"x\"\nschema = 4\n\n[standards]\nroot = \"missing\"\n";
2308 let (_tmp2, dir2) = pack_with(manifest, &[]);
2309 let err = crate::pack::Pack::load(&dir2).expect_err("must fail");
2310 assert!(err.contains("root `missing` does not exist"), "{err}");
2311
2312 let manifest = "[pack]\nname = \"x\"\nschema = 4\n\n[standards]\nroot = \"../outside\"\n";
2314 let (_tmp3, dir3) = pack_with(manifest, &[]);
2315 let err = crate::pack::Pack::load(&dir3).expect_err("must fail");
2316 assert!(err.contains("pack-relative path"), "{err}");
2317 }
2318
2319 #[test]
2320 fn flight_rules_contract_missing_and_unknown_frontmatter_fields_fail() {
2321 let rule = RULE_ONE.replace("statement: zz synthetic must statement one.\n", "");
2323 let (_tmp, dir) = pack_with(
2324 PACK_TOML,
2325 &[
2326 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2327 (
2328 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2329 rule.as_str(),
2330 ),
2331 ],
2332 );
2333 let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2334 assert!(err.contains("missing required field `statement`"), "{err}");
2335 assert!(err.contains("ZZ-RULE-001.md"), "names the file: {err}");
2336
2337 let rule = RULE_ONE.replace("waivable: false", "waivable: false\nbogus: nope");
2339 let (_tmp2, dir2) = pack_with(
2340 PACK_TOML,
2341 &[
2342 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2343 (
2344 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2345 rule.as_str(),
2346 ),
2347 ],
2348 );
2349 let err = crate::pack::Pack::load(&dir2).expect_err("must fail");
2350 assert!(err.contains("unknown frontmatter field `bogus`"), "{err}");
2351 assert!(err.contains("ZZ-RULE-001.md"), "names the file: {err}");
2352
2353 let rfc = RFC_MD.replace("owner: zz-platform\n", "");
2355 let (_tmp3, dir3) = pack_with(
2356 PACK_TOML,
2357 &[("standards/RFC-001-zz-safety/rfc.md", rfc.as_str())],
2358 );
2359 let err = crate::pack::Pack::load(&dir3).expect_err("must fail");
2360 assert!(err.contains("missing required field `owner`"), "{err}");
2361 assert!(err.contains("rfc.md"), "names the file: {err}");
2362 }
2363
2364 #[test]
2365 fn flight_rules_contract_invalid_level_status_and_stage_fail() {
2366 for (from, to, needle) in [
2367 ("level: must", "level: may", "field `level` is `may`"),
2368 (
2369 "status: active",
2370 "status: limbo",
2371 "field `status` is `limbo`",
2372 ),
2373 (
2374 "stages: [implementation, validation]",
2375 "stages: [implementation, guessing]",
2376 "field `stages` element `guessing`",
2377 ),
2378 (
2379 "waivable: false",
2380 "waivable: yes",
2381 "field `waivable` must be exactly `true` or `false`",
2382 ),
2383 ] {
2384 let rule = RULE_ONE.replace(from, to);
2385 let (_tmp, dir) = pack_with(
2386 PACK_TOML,
2387 &[
2388 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2389 (
2390 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2391 rule.as_str(),
2392 ),
2393 ],
2394 );
2395 let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2396 assert!(err.contains(needle), "{from} → {to}: {err}");
2397 assert!(err.contains("ZZ-RULE-001.md"), "names the file: {err}");
2398 }
2399 let rfc = RFC_MD.replace("status: approved", "status: wishful");
2401 let (_tmp, dir) = pack_with(
2402 PACK_TOML,
2403 &[("standards/RFC-001-zz-safety/rfc.md", rfc.as_str())],
2404 );
2405 let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2406 assert!(err.contains("field `status` is `wishful`"), "{err}");
2407 }
2408
2409 #[test]
2410 fn flight_rules_contract_duplicate_ids_fail() {
2411 let dupe = RULE_TWO.replace("ZZ-RULE-002", "ZZ-RULE-001");
2413 let (_tmp, dir) = pack_with(
2414 PACK_TOML,
2415 &[
2416 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2417 ("standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md", RULE_ONE),
2418 (
2419 "standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md",
2420 dupe.as_str(),
2421 ),
2422 ],
2423 );
2424 let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2425 assert!(
2426 err.contains("duplicate standards id `ZZ-RULE-001`"),
2427 "{err}"
2428 );
2429
2430 let dupe = RULE_ONE.replace("id: ZZ-RULE-001", "id: RFC-001");
2432 let (_tmp2, dir2) = pack_with(
2433 PACK_TOML,
2434 &[
2435 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2436 (
2437 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2438 dupe.as_str(),
2439 ),
2440 ],
2441 );
2442 let err = crate::pack::Pack::load(&dir2).expect_err("must fail");
2443 assert!(err.contains("duplicate standards id `RFC-001`"), "{err}");
2444 }
2445
2446 #[test]
2447 fn flight_rules_contract_orphan_rule_fails() {
2448 let orphan = RULE_ONE.replace("rfc: RFC-001", "rfc: RFC-999");
2449 let (_tmp, dir) = pack_with(
2450 PACK_TOML,
2451 &[
2452 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2453 (
2454 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2455 orphan.as_str(),
2456 ),
2457 ],
2458 );
2459 let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2460 assert!(err.contains("rule `ZZ-RULE-001`"), "{err}");
2461 assert!(err.contains("RFC-999"), "names the missing parent: {err}");
2462 assert!(err.contains("orphan"), "{err}");
2463 }
2464
2465 #[test]
2466 fn flight_rules_contract_bad_revision_and_checker_fail() {
2467 for (from, to, needle) in [
2468 (
2469 "revision: 1",
2470 "revision: 0",
2471 "field `revision` must be a positive integer",
2472 ),
2473 (
2474 "revision: 1",
2475 "revision: -2",
2476 "field `revision` must be a positive integer",
2477 ),
2478 (
2479 "revision: 1",
2480 "revision: two",
2481 "field `revision` must be a positive integer",
2482 ),
2483 (
2484 "checker: gate:zz-gate-one",
2485 "checker: gate:zz-undeclared",
2486 "names no declared [[gate]]",
2487 ),
2488 (
2489 "checker: gate:zz-gate-one",
2490 "checker: run-the-script",
2491 "supported checker forms",
2492 ),
2493 (
2494 "checker: gate:zz-gate-one",
2495 "checker: gate:",
2496 "supported checker forms",
2497 ),
2498 ] {
2499 let rule = RULE_ONE.replace(from, to);
2500 let (_tmp, dir) = pack_with(
2501 PACK_TOML,
2502 &[
2503 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2504 (
2505 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2506 rule.as_str(),
2507 ),
2508 ],
2509 );
2510 let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2511 assert!(err.contains(needle), "{from} → {to}: {err}");
2512 assert!(err.contains("ZZ-RULE-001.md"), "names the file: {err}");
2513 }
2514 }
2515
2516 #[test]
2517 fn flight_rules_contract_approved_rule_requires_a_checker_draft_may_omit() {
2518 let no_checker = RULE_ONE.replace("checker: gate:zz-gate-one\n", "");
2521 let (_tmp, dir) = pack_with(
2522 PACK_TOML,
2523 &[
2524 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2525 (
2526 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2527 no_checker.as_str(),
2528 ),
2529 ],
2530 );
2531 let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2532 assert!(err.contains("rule `ZZ-RULE-001`"), "{err}");
2533 assert!(err.contains("declares no checker"), "{err}");
2534
2535 let draft_rfc = RFC_MD.replace("status: approved", "status: draft");
2537 let (_tmp2, dir2) = pack_with(
2538 PACK_TOML,
2539 &[
2540 ("standards/RFC-001-zz-safety/rfc.md", draft_rfc.as_str()),
2541 (
2542 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2543 no_checker.as_str(),
2544 ),
2545 ],
2546 );
2547 let manifest = load_trusted(&dir2);
2548 assert_eq!(manifest.rules.len(), 1);
2549 assert_eq!(manifest.rules[0].checker, None);
2550 assert_eq!(
2551 manifest.effective_status(&manifest.rules[0]),
2552 RfcStatus::Draft
2553 );
2554 }
2555
2556 #[test]
2557 fn flight_rules_contract_retired_rule_may_omit_its_checker() {
2558 let enforced_rfc = RFC_MD.replace("status: approved", "status: enforced");
2561 let retired = RULE_ONE
2562 .replace("status: active", "status: retired")
2563 .replace("checker: gate:zz-gate-one\n", "");
2564 let (_tmp, dir) = pack_with(
2565 PACK_TOML,
2566 &[
2567 ("standards/RFC-001-zz-safety/rfc.md", enforced_rfc.as_str()),
2568 (
2569 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2570 retired.as_str(),
2571 ),
2572 ],
2573 );
2574 let manifest = load_trusted(&dir);
2575 assert_eq!(
2576 manifest.effective_status(&manifest.rules[0]),
2577 RfcStatus::Retired,
2578 "the rule tombstone narrows an enforced RFC (D-B)"
2579 );
2580 }
2581
2582 #[test]
2583 fn flight_rules_contract_caps_fail_promptly() {
2584 let big = format!(
2586 "{RULE_ONE}{}",
2587 "x".repeat(MAX_STANDARDS_FILE_BYTES as usize + 1)
2588 );
2589 let (_tmp, dir) = pack_with(
2590 PACK_TOML,
2591 &[
2592 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2593 (
2594 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2595 big.as_str(),
2596 ),
2597 ],
2598 );
2599 let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2600 assert!(err.contains("per-file cap"), "{err}");
2601 assert!(err.contains("ZZ-RULE-001.md"), "names the file: {err}");
2602
2603 let mut files: Vec<(String, String)> = Vec::new();
2606 for i in 0..=(MAX_STANDARDS_FILES) {
2607 files.push((
2608 format!("standards/RFC-D{i:04}/rfc.md"),
2609 RFC_MD.replace("RFC-001", &format!("RFC-D{i:04}")),
2610 ));
2611 }
2612 let refs: Vec<(&str, &str)> = files
2613 .iter()
2614 .map(|(p, b)| (p.as_str(), b.as_str()))
2615 .collect();
2616 let (_tmp2, dir2) = pack_with(PACK_TOML, &refs);
2617 let err = crate::pack::Pack::load(&dir2).expect_err("must fail");
2618 assert!(err.contains("file cap"), "{err}");
2619
2620 let mut files: Vec<(String, String)> = vec![(
2622 "standards/RFC-001-zz-safety/rfc.md".to_string(),
2623 RFC_MD.to_string(),
2624 )];
2625 for i in 0..=(MAX_STANDARDS_RULES) {
2626 let rule = RULE_ONE.replace("ZZ-RULE-001", &format!("ZZ-RULE-C{i:04}"));
2627 files.push((
2628 format!("standards/RFC-001-zz-safety/rules/ZZ-RULE-C{i:04}.md"),
2629 rule,
2630 ));
2631 }
2632 let refs: Vec<(&str, &str)> = files
2633 .iter()
2634 .map(|(p, b)| (p.as_str(), b.as_str()))
2635 .collect();
2636 let (_tmp3, dir3) = pack_with(PACK_TOML, &refs);
2637 let err = crate::pack::Pack::load(&dir3).expect_err("must fail");
2638 assert!(err.contains("rule cap"), "{err}");
2639
2640 let mut files: Vec<(String, String)> = vec![(
2643 "standards/RFC-001-zz-safety/rfc.md".to_string(),
2644 RFC_MD.to_string(),
2645 )];
2646 for i in 0..250usize {
2647 let rule = RULE_ONE
2648 .replace("ZZ-RULE-001", &format!("ZZ-RULE-N{i:04}"))
2649 .replace("zz synthetic must statement one.", &"s".repeat(4600));
2650 files.push((
2651 format!("standards/RFC-001-zz-safety/rules/ZZ-RULE-N{i:04}.md"),
2652 rule,
2653 ));
2654 }
2655 let refs: Vec<(&str, &str)> = files
2656 .iter()
2657 .map(|(p, b)| (p.as_str(), b.as_str()))
2658 .collect();
2659 let (_tmp4, dir4) = pack_with(PACK_TOML, &refs);
2660 let err = crate::pack::Pack::load(&dir4).expect_err("must fail");
2661 assert!(err.contains("normalized standards manifest"), "{err}");
2662 }
2663
2664 #[test]
2665 fn flight_rules_contract_invalid_utf8_fails() {
2666 let (_tmp, dir) = synthetic_pack();
2667 let path = dir.join("standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md");
2668 let mut bytes = RULE_ONE.as_bytes().to_vec();
2669 bytes.push(0xFF);
2670 bytes.push(0xFE);
2671 std::fs::write(&path, bytes).unwrap();
2672 let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2673 assert!(err.contains("not valid UTF-8"), "{err}");
2674 assert!(err.contains("ZZ-RULE-001.md"), "names the file: {err}");
2675 }
2676
2677 #[test]
2678 fn flight_rules_contract_frontmatter_subset_refuses_yaml_surprises() {
2679 for (line, needle) in [
2680 ("statement: &anchor text", "anchors"),
2681 ("statement: *alias", "aliases"),
2682 ("statement: !!str text", "tags"),
2683 ("statement: |", "block scalars"),
2684 ("statement: 'single'", "single-quoted strings"),
2685 ("statement: {flow: map}", "flow mappings"),
2686 ("statement:", "empty value"),
2687 ("domains: [zz-domain", "unterminated `[`"),
2688 ("domains: [zz-domain,, zz-other]", "empty list element"),
2689 ] {
2690 let rule = RULE_ONE.replace("statement: zz synthetic must statement one.", line);
2691 let rule = if line.starts_with("domains:") {
2692 RULE_ONE.replace("domains: [zz-domain]", line)
2693 } else {
2694 rule
2695 };
2696 let (_tmp, dir) = pack_with(
2697 PACK_TOML,
2698 &[
2699 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2700 (
2701 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2702 rule.as_str(),
2703 ),
2704 ],
2705 );
2706 let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2707 assert!(err.contains(needle), "{line}: {err}");
2708 assert!(err.contains("ZZ-RULE-001.md"), "names the file: {err}");
2709 }
2710
2711 let commented = RULE_ONE.replace(
2714 "statement: zz synthetic must statement one.",
2715 "statement: zz synthetic must statement one. # reviewed",
2716 );
2717 let (_tmp, dir) = pack_with(
2718 PACK_TOML,
2719 &[
2720 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2721 (
2722 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2723 commented.as_str(),
2724 ),
2725 ("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md", RULE_TWO),
2726 ],
2727 );
2728 let manifest = load_trusted(&dir);
2729 assert_eq!(manifest.digest, EXPECTED_DIGEST, "comments never govern");
2730
2731 for bad_line in [" status: active", "- status: active"] {
2733 let rule = RULE_ONE.replace("status: active", bad_line);
2734 let (_tmp, dir) = pack_with(
2735 PACK_TOML,
2736 &[
2737 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2738 (
2739 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2740 rule.as_str(),
2741 ),
2742 ],
2743 );
2744 let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2745 assert!(
2746 err.contains("unexpected indentation") || err.contains("unsupported field name"),
2747 "{bad_line}: {err}"
2748 );
2749 }
2750
2751 let dupe = RULE_ONE.replace("level: must", "level: must\nlevel: should");
2753 let (_tmp, dir) = pack_with(
2754 PACK_TOML,
2755 &[
2756 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2757 (
2758 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2759 dupe.as_str(),
2760 ),
2761 ],
2762 );
2763 let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2764 assert!(err.contains("duplicate field `level`"), "{err}");
2765
2766 let unfenced = RULE_ONE.replace("---\nRule one prose.", "");
2767 let (_tmp2, dir2) = pack_with(
2768 PACK_TOML,
2769 &[
2770 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2771 (
2772 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2773 unfenced.as_str(),
2774 ),
2775 ],
2776 );
2777 let err = crate::pack::Pack::load(&dir2).expect_err("must fail");
2778 assert!(err.contains("closing `---`"), "{err}");
2779 }
2780
2781 #[test]
2782 fn flight_rules_contract_external_pack_cannot_activate_enforced_rules() {
2783 let (_tmp, dir) = synthetic_pack();
2785 let pack = crate::pack::Pack::load_with_trust(&dir, StandardsTrust::External)
2786 .expect("advisory loads")
2787 .expect("a pack");
2788 assert_eq!(pack.standards.as_ref().unwrap().rules.len(), 2);
2789
2790 let enforced_rfc = RFC_MD.replace("status: approved", "status: enforced");
2793 let (_tmp2, dir2) = pack_with(
2794 PACK_TOML,
2795 &[
2796 ("standards/RFC-001-zz-safety/rfc.md", enforced_rfc.as_str()),
2797 ("standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md", RULE_ONE),
2798 ],
2799 );
2800 let err = crate::pack::Pack::load_with_trust(&dir2, StandardsTrust::External)
2801 .expect_err("must fail");
2802 assert!(err.contains("external/untracked"), "{err}");
2803 assert!(err.contains("rule `ZZ-RULE-001`"), "names the rule: {err}");
2804 assert!(
2805 err.contains("vendor the pack into the repo"),
2806 "names the remedy: {err}"
2807 );
2808 let pack = crate::pack::Pack::load_with_trust(&dir2, StandardsTrust::RepoTracked)
2810 .expect("tracked loads")
2811 .expect("a pack");
2812 assert_eq!(
2813 pack.standards.as_ref().unwrap().rfcs[0].status,
2814 RfcStatus::Enforced
2815 );
2816 }
2817
2818 #[test]
2819 fn flight_rules_contract_schema_two_and_three_packs_carry_no_standards() {
2820 for schema in [SCHEMA_BASE, SCHEMA_CONTRACT] {
2823 let manifest = format!("[pack]\nname = \"zz-plain\"\nschema = {schema}\n");
2824 let (_tmp, dir) = pack_with(&manifest, &[]);
2825 let pack = crate::pack::Pack::load(&dir)
2826 .expect("load")
2827 .expect("a pack");
2828 assert_eq!(pack.schema, schema);
2829 assert!(pack.standards.is_none());
2830 assert!(!pack.describe().contains("standards"), "byte-identical");
2831 assert!(!crate::pack::render_lint(&pack).contains("digest"));
2832 }
2833 let (_tmp, dir) = pack_with("[pack]\nname = \"zz-bare-four\"\nschema = 4\n", &[]);
2835 let pack = crate::pack::Pack::load(&dir)
2836 .expect("load")
2837 .expect("a pack");
2838 assert_eq!(pack.schema, SCHEMA_STANDARDS);
2839 assert!(pack.standards.is_none());
2840 }
2841
2842 #[test]
2843 fn flight_rules_contract_pack_lint_reports_the_standards_registration() {
2844 let (_tmp, dir) = synthetic_pack();
2845 let pack = crate::pack::Pack::load_with_trust(&dir, StandardsTrust::RepoTracked)
2846 .expect("load")
2847 .expect("a pack");
2848 let report = crate::pack::render_lint(&pack);
2849 assert!(
2850 report.contains("standards (schema 4 root `standards`)"),
2851 "{report}"
2852 );
2853 assert!(report.contains("digest: sha256:"), "{report}");
2854 assert!(
2855 report.contains("RFCs: 1 (draft 0, approved 1, enforced 0, retired 0)"),
2856 "{report}"
2857 );
2858 assert!(
2859 report.contains("rules: 2 (active 2, retired 0)"),
2860 "{report}"
2861 );
2862
2863 let manifest = pack.standards.as_ref().unwrap();
2866 let rendered = render_manifest(manifest, StandardsTrust::RepoTracked);
2867 assert!(rendered.contains("trust: repo-tracked"), "{rendered}");
2868 assert!(
2869 rendered.contains("ZZ-RULE-001 r1 — must, approved; checker gate:zz-gate-one"),
2870 "{rendered}"
2871 );
2872 assert!(rendered.contains("digest: sha256:"), "{rendered}");
2873 }
2874
2875 fn transitions(
2880 base_files: Option<&[(&str, &str)]>,
2881 proposed_files: &[(&str, &str)],
2882 ) -> Vec<String> {
2883 let base = base_files.map(|files| {
2884 let (tmp, dir) = pack_with(PACK_TOML, files);
2885 let manifest = load_trusted(&dir);
2886 drop(tmp);
2887 manifest
2888 });
2889 let (_tmp, dir) = pack_with(PACK_TOML, proposed_files);
2890 let proposed = load_trusted(&dir);
2891 check_transitions(base.as_ref(), &proposed)
2892 }
2893
2894 #[test]
2895 fn flight_rules_contract_transition_lint_refuses_absent_or_draft_to_enforced() {
2896 let enforced_rfc = RFC_MD.replace("status: approved", "status: enforced");
2897 let proposed = &[
2898 ("standards/RFC-001-zz-safety/rfc.md", enforced_rfc.as_str()),
2899 ("standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md", RULE_ONE),
2900 ];
2901 let errors = transitions(None, proposed);
2903 assert_eq!(errors.len(), 2, "RFC and rule both refuse: {errors:?}");
2904 assert!(
2905 errors
2906 .iter()
2907 .any(|e| e.contains("RFC `RFC-001`") && e.contains("absent/draft")),
2908 "{errors:?}"
2909 );
2910 assert!(
2911 errors
2912 .iter()
2913 .any(|e| e.contains("rule `ZZ-RULE-001`") && e.contains("absent")),
2914 "{errors:?}"
2915 );
2916
2917 let draft_rfc = RFC_MD.replace("status: approved", "status: draft");
2919 let draft_rule = RULE_ONE.replace("checker: gate:zz-gate-one\n", "");
2920 let base = &[
2921 ("standards/RFC-001-zz-safety/rfc.md", draft_rfc.as_str()),
2922 (
2923 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2924 draft_rule.as_str(),
2925 ),
2926 ];
2927 let errors = transitions(Some(base), proposed);
2928 assert!(
2929 errors
2930 .iter()
2931 .any(|e| e.contains("RFC `RFC-001`") && e.contains("draft")),
2932 "{errors:?}"
2933 );
2934 assert!(
2935 errors
2936 .iter()
2937 .any(|e| e.contains("rule `ZZ-RULE-001`") && e.contains("draft")),
2938 "{errors:?}"
2939 );
2940 }
2941
2942 #[test]
2943 fn flight_rules_contract_transition_lint_refuses_semantic_change_without_revision_bump() {
2944 let changed = RULE_ONE.replace(
2945 "statement: zz synthetic must statement one.",
2946 "statement: zz REWRITTEN statement.",
2947 );
2948 let errors = transitions(
2949 Some(CORPUS),
2950 &[
2951 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2952 (
2953 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2954 changed.as_str(),
2955 ),
2956 ("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md", RULE_TWO),
2957 ],
2958 );
2959 assert_eq!(errors.len(), 1, "{errors:?}");
2960 assert!(errors[0].contains("rule `ZZ-RULE-001`"), "{errors:?}");
2961 assert!(
2962 errors[0].contains("statement"),
2963 "names the field: {errors:?}"
2964 );
2965 assert!(errors[0].contains("revision increment"), "{errors:?}");
2966
2967 let bumped = changed.replace("revision: 1", "revision: 2");
2969 let errors = transitions(
2970 Some(CORPUS),
2971 &[
2972 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2973 (
2974 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2975 bumped.as_str(),
2976 ),
2977 ("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md", RULE_TWO),
2978 ],
2979 );
2980 assert!(errors.is_empty(), "{errors:?}");
2981
2982 let base_bumped = RULE_ONE.replace("revision: 1", "revision: 5");
2984 let errors = transitions(
2985 Some(&[
2986 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2987 (
2988 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2989 base_bumped.as_str(),
2990 ),
2991 ("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md", RULE_TWO),
2992 ]),
2993 CORPUS,
2994 );
2995 assert!(
2996 errors.iter().any(|e| e.contains("moved backwards")),
2997 "{errors:?}"
2998 );
2999 }
3000
3001 #[test]
3002 fn flight_rules_contract_transition_lint_refuses_disappearing_ids_and_tombstone_reactivation() {
3003 let errors = transitions(
3005 Some(CORPUS),
3006 &[
3007 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
3008 ("standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md", RULE_ONE),
3009 ],
3010 );
3011 assert_eq!(errors.len(), 1, "{errors:?}");
3012 assert!(errors[0].contains("rule `ZZ-RULE-002`"), "{errors:?}");
3013 assert!(errors[0].contains("cannot disappear"), "{errors:?}");
3014
3015 let retired_base = RULE_ONE.replace("status: active", "status: retired");
3017 let errors = transitions(
3018 Some(&[
3019 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
3020 (
3021 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
3022 retired_base.as_str(),
3023 ),
3024 ("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md", RULE_TWO),
3025 ]),
3026 CORPUS,
3027 );
3028 assert!(
3029 errors
3030 .iter()
3031 .any(|e| e.contains("rule `ZZ-RULE-001`") && e.contains("tombstone")),
3032 "{errors:?}"
3033 );
3034 }
3035
3036 #[test]
3037 fn flight_rules_contract_transition_lint_accepts_reviewed_transitions() {
3038 let enforced_rfc = RFC_MD.replace("status: approved", "status: enforced");
3041 let errors = transitions(
3042 Some(CORPUS),
3043 &[
3044 ("standards/RFC-001-zz-safety/rfc.md", enforced_rfc.as_str()),
3045 ("standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md", RULE_ONE),
3046 ("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md", RULE_TWO),
3047 ],
3048 );
3049 assert!(errors.is_empty(), "{errors:?}");
3050
3051 let retired = RULE_ONE.replace("status: active", "status: retired");
3053 let errors = transitions(
3054 Some(CORPUS),
3055 &[
3056 ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
3057 (
3058 "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
3059 retired.as_str(),
3060 ),
3061 ("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md", RULE_TWO),
3062 ],
3063 );
3064 assert!(errors.is_empty(), "{errors:?}");
3065
3066 let errors = transitions(Some(CORPUS), CORPUS);
3068 assert!(errors.is_empty(), "{errors:?}");
3069 }
3070
3071 fn git_repo_with_files(
3076 files: &[(String, String)],
3077 ) -> (tempfile::TempDir, PathBuf, crate::git_ops::GitRepo) {
3078 let tmp = tempfile::tempdir().unwrap();
3079 let root = tmp.path().join("repo");
3080 std::fs::create_dir_all(&root).unwrap();
3081 let git = |args: &[&str]| {
3082 let out = std::process::Command::new("git")
3083 .args(args)
3084 .current_dir(&root)
3085 .output()
3086 .expect("spawn git");
3087 assert!(out.status.success(), "git {args:?} failed: {out:?}");
3088 };
3089 git(&["init", "-q"]);
3090 git(&["config", "user.email", "t@t"]);
3091 git(&["config", "user.name", "t"]);
3092 for (rel, body) in files {
3093 let path = root.join(rel);
3094 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3095 std::fs::write(path, body).unwrap();
3096 }
3097 git(&["add", "."]);
3098 git(&["commit", "-qm", "pack"]);
3099 let repo = crate::git_ops::GitRepo::open(&root).expect("git repo");
3100 (tmp, root, repo)
3101 }
3102
3103 fn vendored_files() -> Vec<(String, String)> {
3105 let mut files = vec![("vendor/pack/pack.toml".to_string(), PACK_TOML.to_string())];
3106 for (rel, body) in CORPUS {
3107 files.push((format!("vendor/pack/{rel}"), (*body).to_string()));
3108 }
3109 files
3110 }
3111
3112 #[test]
3113 fn flight_rules_contract_load_at_ref_reads_tracked_blobs_not_the_worktree() {
3114 let (_tmp, root, repo) = git_repo_with_files(&vendored_files());
3115
3116 let base = load_at_ref(&repo, "HEAD", "vendor/pack")
3119 .expect("base load")
3120 .expect("standards at HEAD");
3121 let worktree = crate::pack::Pack::load_with_trust(
3122 &root.join("vendor/pack"),
3123 StandardsTrust::RepoTracked,
3124 )
3125 .expect("worktree load")
3126 .expect("a pack");
3127 let worktree = worktree.standards.as_ref().unwrap();
3128 assert_eq!(base.digest, worktree.digest, "identical bytes agree");
3129
3130 let edited = RULE_ONE.replace(
3133 "statement: zz synthetic must statement one.",
3134 "statement: zz WORKTREE-ONLY EDIT.",
3135 );
3136 std::fs::write(
3137 root.join("vendor/pack/standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md"),
3138 &edited,
3139 )
3140 .unwrap();
3141 let base_after = load_at_ref(&repo, "HEAD", "vendor/pack")
3142 .expect("base load")
3143 .expect("standards at HEAD");
3144 assert_eq!(base.digest, base_after.digest, "the base is pinned blobs");
3145 let dirty = crate::pack::Pack::load_with_trust(
3146 &root.join("vendor/pack"),
3147 StandardsTrust::RepoTracked,
3148 )
3149 .expect("worktree load")
3150 .expect("a pack");
3151 let dirty = dirty.standards.as_ref().unwrap();
3152 assert_ne!(
3153 base_after.digest, dirty.digest,
3154 "the worktree moved; the base did not"
3155 );
3156
3157 let errors = check_transitions(Some(&base_after), dirty);
3160 assert_eq!(errors.len(), 1, "{errors:?}");
3161 assert!(errors[0].contains("rule `ZZ-RULE-001`"), "{errors:?}");
3162 assert!(errors[0].contains("revision increment"), "{errors:?}");
3163
3164 let git = |args: &[&str]| {
3166 let out = std::process::Command::new("git")
3167 .args(args)
3168 .current_dir(&root)
3169 .output()
3170 .expect("spawn git");
3171 assert!(out.status.success(), "git {args:?} failed: {out:?}");
3172 };
3173 git(&["rm", "-rqf", "vendor"]);
3174 git(&["commit", "-qm", "drop pack"]);
3175 assert_eq!(
3176 load_at_ref(&repo, "HEAD", "vendor/pack").expect("load"),
3177 None,
3178 "no pack at this ref"
3179 );
3180 }
3181
3182 #[cfg(unix)]
3183 #[test]
3184 fn flight_rules_contract_load_at_ref_refuses_a_tracked_symlink() {
3185 use std::os::unix::fs::symlink;
3186 let mut files = vendored_files();
3187 files.retain(|(p, _)| !p.ends_with("ZZ-RULE-002.md"));
3188 let (_tmp, root, repo) = git_repo_with_files(&files);
3189 symlink(
3192 "ZZ-RULE-001.md",
3193 root.join("vendor/pack/standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md"),
3194 )
3195 .unwrap();
3196 let git = |args: &[&str]| {
3197 let out = std::process::Command::new("git")
3198 .args(args)
3199 .current_dir(&root)
3200 .output()
3201 .expect("spawn git");
3202 assert!(out.status.success(), "git {args:?} failed: {out:?}");
3203 };
3204 git(&["add", "."]);
3205 git(&["commit", "-qm", "add symlink"]);
3206 let err = load_at_ref(&repo, "HEAD", "vendor/pack").expect_err("must fail");
3207 assert!(err.contains("symlink"), "{err}");
3208 assert!(err.contains("ZZ-RULE-002.md"), "names the path: {err}");
3209 }
3210
3211 #[test]
3212 fn flight_rules_contract_trust_for_dir_distinguishes_repo_tracked_from_external() {
3213 let (_tmp, root, _repo) = git_repo_with_files(&vendored_files());
3214 assert_eq!(
3216 trust_for_dir(&root, &root.join("vendor/pack")),
3217 StandardsTrust::RepoTracked
3218 );
3219 std::fs::create_dir_all(root.join("scratch/pack")).unwrap();
3221 std::fs::write(root.join("scratch/pack/pack.toml"), PACK_TOML).unwrap();
3222 assert_eq!(
3223 trust_for_dir(&root, &root.join("scratch/pack")),
3224 StandardsTrust::External
3225 );
3226 let outside = tempfile::tempdir().unwrap();
3228 let pack = outside.path().join("pack");
3229 std::fs::create_dir_all(&pack).unwrap();
3230 std::fs::write(pack.join(PACK_MANIFEST), PACK_TOML).unwrap();
3231 assert_eq!(
3232 trust_for_dir(&root, &pack),
3233 StandardsTrust::External,
3234 "outside the repo is external"
3235 );
3236 }
3237
3238 #[cfg(unix)]
3241 #[test]
3242 fn flight_rules_contract_symlinked_parents_and_leaves_fail_no_follow() {
3243 use std::os::unix::fs::symlink;
3244 let (_tmp, dir) = synthetic_pack();
3246 let target = dir.join("outside.md");
3247 std::fs::write(&target, RULE_ONE).unwrap();
3248 let link = dir.join("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md");
3249 std::fs::remove_file(&link).unwrap();
3250 symlink(&target, &link).unwrap();
3251 let err = crate::pack::Pack::load(&dir).expect_err("must fail");
3252 assert!(err.contains("symlink"), "{err}");
3253 assert!(err.contains("never follows symlinks"), "{err}");
3254
3255 let (_tmp2, dir2) = synthetic_pack();
3257 let real = dir2.join("real-rfc");
3258 std::fs::rename(dir2.join("standards/RFC-001-zz-safety"), &real).unwrap();
3259 symlink(&real, dir2.join("standards/RFC-001-zz-safety")).unwrap();
3260 let err = crate::pack::Pack::load(&dir2).expect_err("must fail");
3261 assert!(err.contains("symlink"), "{err}");
3262
3263 let (_tmp3, dir3) = synthetic_pack();
3265 let fifo = dir3.join("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md");
3266 std::fs::remove_file(&fifo).unwrap();
3267 let status = std::process::Command::new("mkfifo")
3268 .arg(&fifo)
3269 .status()
3270 .expect("spawn mkfifo");
3271 assert!(status.success());
3272 let err = crate::pack::Pack::load(&dir3).expect_err("must fail");
3273 assert!(err.contains("not a regular file"), "{err}");
3274 assert!(err.contains("ZZ-RULE-002.md"), "names the file: {err}");
3275
3276 let (_tmp4, dir4) = synthetic_pack();
3279 std::fs::write(dir4.join("standards/notes.txt"), "stray").unwrap();
3280 let err = crate::pack::Pack::load(&dir4).expect_err("must fail");
3281 assert!(err.contains("not an RFC directory"), "{err}");
3282 let (_tmp5, dir5) = synthetic_pack();
3283 std::fs::create_dir_all(dir5.join("standards/RFC-001-zz-safety/rules/nested")).unwrap();
3284 let err = crate::pack::Pack::load(&dir5).expect_err("must fail");
3285 assert!(err.contains("no nested directories"), "{err}");
3286 }
3287}