1use std::collections::{HashMap, HashSet};
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14use indexmap::IndexMap;
15use thiserror::Error;
16
17use crate::base_metadata;
18use crate::manifest::SchemaManifest;
19use crate::schema::Schema;
20use crate::types::TypeDefinition;
21
22#[derive(Debug, Error)]
23pub enum SchemaLoadError {
24 #[error("i/o error reading {}: {source}", .path.display())]
25 Io {
26 path: PathBuf,
27 #[source]
28 source: std::io::Error,
29 },
30
31 #[error("failed to parse manifest {}: {source}", .path.display())]
32 ParseManifest {
33 path: PathBuf,
34 #[source]
35 source: serde_yaml_ng::Error,
36 },
37
38 #[error("failed to parse type file {}: {source}", .path.display())]
39 ParseType {
40 path: PathBuf,
41 #[source]
42 source: serde_yaml_ng::Error,
43 },
44
45 #[error("invalid version '{value}': must be semver (e.g. 1.0.0)")]
46 InvalidVersion { value: String },
47
48 #[error("invalid schema name '{value}': {reason}")]
49 InvalidName { value: String, reason: &'static str },
50
51 #[error(
52 "schema type file mismatch — declared in manifest: [{}], found in types/: [{}]",
53 declared.join(", "),
54 found.join(", ")
55 )]
56 TypeFileMismatch {
57 declared: Vec<String>,
58 found: Vec<String>,
59 },
60
61 #[error(
62 "type file '{file}.yaml' has `name: {declared}` — filename and `name` field must match"
63 )]
64 TypeNameMismatch { file: String, declared: String },
65 #[error(
75 "type '{type_name}': `propagating_relationships` was renamed — its only effect is \
76 refusing self-loops on the listed rel-types, so the key is now \
77 `no_self_loop_relationships` (optional; empty lists can simply be deleted). \
78 Rename the key and retry."
79 )]
80 PropagatingRelationshipsRenamed { type_name: String },
81
82 #[error(
83 "type '{type_name}' declares the retired `examples:` list — it was never \
84 validated nor served and is replaced by the engine-validated `exemplar:` \
85 (one canonical entity: title, metadata, sections, relations with \
86 placeholder targets). Move the material into `exemplar:` and retry."
87 )]
88 ExamplesRetired { type_name: String },
89
90 #[error(
97 "type '{type_name}': exemplar relation entries speak the mutation vocabulary — \
98 rename `to:` to `target:` and `type:` to `rel_type:`, then retry. (Sealed \
99 packages with the old spelling keep loading; only authoring refuses.)"
100 )]
101 ExemplarRelationSpellingRetired { type_name: String },
102
103 #[error(
106 "type '{type_name}': an exemplar relation entry must carry both `target:` \
107 (bare placeholder slug) and `rel_type:` (declared relationship name)."
108 )]
109 ExemplarRelationIncomplete { type_name: String },
110
111 #[error(
118 "type '{type_name}' metadata field '{field}' declares the retired `optional:` key — \
119 fields are optional unless they declare `required: true`. Fix: delete `optional: true`; \
120 replace `optional: false` with `required: true`. Then retry."
121 )]
122 OptionalRetired { type_name: String, field: String },
123
124 #[error("type '{type_name}' due axis is invalid: {reason} — offending name: '{offender}'")]
130 InvalidDueAxis {
131 type_name: String,
132 offender: String,
133 reason: String,
134 },
135
136 #[error("schema relationship vocabulary must include a '_default' definition")]
137 MissingDefaultWeight,
138
139 #[error("duplicate relationship definition: '{name}'")]
140 DuplicateRelationship { name: String },
141
142 #[error(
143 "type '{type_name}' references relationship '{relationship}' in field '{field}' — not declared in schema. Available: [{}]. {}",
144 available.join(", "),
145 format_suggestion(relationship, available)
146 )]
147 UndeclaredRelationship {
148 type_name: String,
149 field: &'static str,
150 relationship: String,
151 available: Vec<String>,
152 },
153
154 #[error(
155 "type '{type_name}' must have exactly one section with `catch_all: true` (found {count})"
156 )]
157 CatchAllViolation { type_name: String, count: usize },
158
159 #[error(
160 "type '{type_name}' field '{field}' references unknown key '{reference}' — not a section or metadata field"
161 )]
162 UnknownFieldReference {
163 type_name: String,
164 field: &'static str,
165 reference: String,
166 },
167
168 #[error(
169 "type '{type_name}' constraint ({kind}) is invalid: {reason} — offending name: '{offender}'"
170 )]
171 InvalidConstraint {
172 type_name: String,
173 kind: &'static str,
174 offender: String,
175 reason: String,
176 },
177
178 #[error("relationships.acyclic_sets is invalid: {reason} — offending entry: '{offender}'")]
179 InvalidAcyclicSet { offender: String, reason: String },
180
181 #[error("relationships.labelling is invalid: {reason} — offending name: '{offender}'")]
182 InvalidLabelling { offender: String, reason: String },
183
184 #[error(
185 "type '{type_name}' section '{section}' format declaration is invalid: {}",
186 problems.join("; ")
187 )]
188 InvalidSectionFormat {
189 type_name: String,
190 section: String,
191 problems: Vec<String>,
195 },
196
197 #[error(
198 "type '{type_name}' metadata field '{field}' default '{default}' is not listed in enum_values: [{}]",
199 allowed.join(", ")
200 )]
201 DefaultValueNotInEnum {
202 type_name: String,
203 field: String,
204 default: String,
205 allowed: Vec<String>,
206 },
207
208 #[error(
209 "type '{type_name}' redeclares engine-implicit metadata key '{field}' — remove it from the YAML; the loader injects it automatically"
210 )]
211 RedeclaredBaseField { type_name: String, field: String },
212
213 #[error(
214 "relationship '{relationship}' field '{field}' references unknown type '{reference}'. Declared types: [{}]. {}",
215 declared.join(", "),
216 format_suggestion(reference, declared)
217 )]
218 UndeclaredRelationshipType {
219 relationship: String,
220 field: &'static str,
221 reference: String,
222 declared: Vec<String>,
223 },
224
225 #[error(
235 "type '{type_name}' declares {kind} with reserved key '{offending_key}' — reserved keys: [{}]",
236 reserved_keys.join(", ")
237 )]
238 ReservedSchemaKey {
239 type_name: String,
240 kind: &'static str,
241 offending_key: String,
242 reserved_keys: Vec<String>,
243 },
244
245 #[error(
251 "cross_mem_relationships[].to_schema '{value}' {reason} — expected a bare schema name (e.g. 'software', not 'software@1.0.0')"
252 )]
253 InvalidCrossMemToSchema { value: String, reason: String },
254
255 #[error("cross_mem_relationships declares duplicate to_schema '{to_schema}'")]
260 DuplicateCrossMemToSchema { to_schema: String },
261
262 #[error("sealed schema package carries no schema.yaml")]
266 SealedPackageMissingManifest,
267
268 #[error(
273 "cross_mem_relationships declares to_schema '*' but the schema declares no \
274 alias_target_rel_type — the wildcard is bound to the alias-synthesised rel-type; \
275 declare alias_target_rel_type, or name each destination schema explicitly"
276 )]
277 CrossMemWildcardWithoutAliasTarget,
278
279 #[error(
285 "cross_mem_relationships[to_schema='*'] declares rel-type '{rel_type}', but the \
286 wildcard is bound to the schema's alias_target_rel_type '{alias_target}' — \
287 hand-authored structural edges need a per-destination-schema declaration"
288 )]
289 CrossMemWildcardNonAliasRelType {
290 rel_type: String,
291 alias_target: String,
292 },
293
294 #[error(
301 "cross_mem_relationships[to_schema='{to_schema}'].definitions[name='{relationship}'].source_types references unknown type '{reference}'. Declared types: [{}]. {}",
302 declared.join(", "),
303 format_suggestion(reference, declared)
304 )]
305 UndeclaredCrossMemSourceType {
306 to_schema: String,
307 relationship: String,
308 reference: String,
309 declared: Vec<String>,
310 },
311
312 #[error(
317 "schema '{schema}' alias_target_rel_type '{target}' is not declared in relationships. Declared: [{}]. {}",
318 declared.join(", "),
319 format_suggestion(target, declared)
320 )]
321 AliasTargetRelTypeNotDeclared {
322 schema: String,
323 target: String,
324 declared: Vec<String>,
325 },
326
327 #[error(
336 "schema declares section heading(s) that cannot round-trip to their key(s): {}. \
337 Fix: make each heading derive to its key — lowercasing the heading and replacing \
338 spaces with underscores must yield the key exactly (key `current_state` ⇒ heading \
339 `Current State`)",
340 format_heading_violations(violations)
341 )]
342 SectionHeadingMismatch {
343 violations: Vec<HeadingKeyViolation>,
344 },
345
346 #[error(
356 "schema has {} violations:\n{}",
357 errors.len(),
358 format_multiple(errors)
359 )]
360 Multiple { errors: Vec<SchemaLoadError> },
361}
362
363fn format_multiple(errors: &[SchemaLoadError]) -> String {
364 errors
365 .iter()
366 .enumerate()
367 .map(|(i, e)| format!(" {}. {e}", i + 1))
368 .collect::<Vec<_>>()
369 .join("\n")
370}
371
372fn collapse(mut errors: Vec<SchemaLoadError>) -> SchemaLoadError {
377 debug_assert!(!errors.is_empty());
378 if errors.len() == 1 {
379 errors.remove(0)
380 } else {
381 SchemaLoadError::Multiple { errors }
382 }
383}
384
385#[derive(Debug, Clone, PartialEq, Eq)]
388pub struct HeadingKeyViolation {
389 pub type_name: String,
390 pub key: String,
391 pub heading: String,
392 pub derived_key: String,
393}
394
395fn format_heading_violations(violations: &[HeadingKeyViolation]) -> String {
396 violations
397 .iter()
398 .map(|v| {
399 format!(
400 "type '{}' section key '{}' has heading '{}' (derives to '{}')",
401 v.type_name, v.key, v.heading, v.derived_key
402 )
403 })
404 .collect::<Vec<_>>()
405 .join("; ")
406}
407
408pub fn check_section_heading_roundtrip(schema: &Schema) -> Result<(), SchemaLoadError> {
421 let mut violations = Vec::new();
422 let mut type_names: Vec<&String> = schema.types.keys().collect();
425 type_names.sort();
426 for type_name in type_names {
427 let t = &schema.types[type_name];
428 for s in &t.sections {
429 let derived_key = crate::types::derive_section_key(&s.heading);
430 if derived_key != s.key {
431 violations.push(HeadingKeyViolation {
432 type_name: type_name.clone(),
433 key: s.key.clone(),
434 heading: s.heading.clone(),
435 derived_key,
436 });
437 }
438 }
439 }
440 if violations.is_empty() {
441 Ok(())
442 } else {
443 Err(SchemaLoadError::SectionHeadingMismatch { violations })
444 }
445}
446
447pub fn reserved_section_keys() -> &'static [&'static str] {
451 &["relationships"]
452}
453
454pub fn reserved_metadata_field_keys() -> &'static [&'static str] {
462 &["type", "mem", "id"]
463}
464
465pub fn check_reserved_metadata_keys(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
478 for td in schema.types.values() {
479 for key in &td.declared_metadata_keys {
480 if reserved_metadata_field_keys().contains(&key.as_str()) {
481 return Err(SchemaLoadError::ReservedSchemaKey {
482 type_name: td.name.clone(),
483 kind: "metadata_field",
484 offending_key: key.clone(),
485 reserved_keys: reserved_metadata_field_keys()
486 .iter()
487 .map(|s| s.to_string())
488 .collect(),
489 });
490 }
491 }
492 }
493 Ok(())
494}
495
496fn format_suggestion(needle: &str, candidates: &[String]) -> String {
497 let mut best: Option<(usize, &String)> = None;
498 for cand in candidates {
499 let d = strsim::levenshtein(needle, cand);
500 match best {
501 Some((bd, _)) if bd <= d => {}
502 _ => best = Some((d, cand)),
503 }
504 }
505 match best {
506 Some((d, cand)) if d > 0 && d <= needle.len().saturating_add(3) => {
507 format!("Did you mean '{cand}'?")
508 }
509 _ => String::new(),
510 }
511}
512
513pub fn load_schema_from_dir(path: &Path) -> Result<Schema, SchemaLoadError> {
515 let manifest_path = path.join("schema.yaml");
516 let manifest_text =
517 std::fs::read_to_string(&manifest_path).map_err(|e| SchemaLoadError::Io {
518 path: manifest_path.clone(),
519 source: e,
520 })?;
521
522 let types_dir = path.join("types");
523 let mut type_files: Vec<(String, String)> = Vec::new();
524 if types_dir.is_dir() {
525 let entries = std::fs::read_dir(&types_dir).map_err(|e| SchemaLoadError::Io {
526 path: types_dir.clone(),
527 source: e,
528 })?;
529 for entry in entries {
530 let entry = entry.map_err(|e| SchemaLoadError::Io {
531 path: types_dir.clone(),
532 source: e,
533 })?;
534 let p = entry.path();
535 if p.extension().and_then(|s| s.to_str()) != Some("yaml") {
536 continue;
537 }
538 let Some(stem) = p.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
539 continue;
540 };
541 let contents = std::fs::read_to_string(&p).map_err(|e| SchemaLoadError::Io {
542 path: p.clone(),
543 source: e,
544 })?;
545 type_files.push((stem, contents));
546 }
547 }
548 type_files.sort_by(|a, b| a.0.cmp(&b.0));
551
552 load_with_context(
553 &manifest_text,
554 &type_files,
555 Some(&manifest_path),
556 Some(&types_dir),
557 MetadataPolarityFormat::RequiredOptIn,
559 )
560}
561
562#[derive(Debug, Clone, Copy, PartialEq, Eq)]
575pub enum MetadataPolarityFormat {
576 Legacy,
578 RequiredOptIn,
580}
581
582pub const SCHEMA_FORMAT_MARKER_FILE: &str = "schema-format.json";
600
601pub const SCHEMA_FORMAT_MARKER_CONTENT: &str = "{\"metadata_polarity\":\"required-opt-in\"}\n";
603
604pub fn with_format_marker(mut files: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
617 if !files
618 .iter()
619 .any(|(rel, _)| rel == SCHEMA_FORMAT_MARKER_FILE)
620 {
621 files.push((
622 SCHEMA_FORMAT_MARKER_FILE.to_string(),
623 SCHEMA_FORMAT_MARKER_CONTENT.as_bytes().to_vec(),
624 ));
625 }
626 files
627}
628
629pub fn load_schema_from_memory(
637 manifest_yaml: &str,
638 types_yamls: &[(String, String)],
639) -> Result<Schema, SchemaLoadError> {
640 load_with_context(
641 manifest_yaml,
642 types_yamls,
643 None,
644 None,
645 MetadataPolarityFormat::Legacy,
646 )
647}
648
649pub fn load_sealed_package(files: &[(String, Vec<u8>)]) -> Result<Schema, SchemaLoadError> {
668 let mut manifest: Option<String> = None;
669 let mut types: Vec<(String, String)> = Vec::new();
670 let mut marked = false;
671 for (rel, bytes) in files {
672 if rel == "schema.yaml" {
673 manifest = Some(String::from_utf8_lossy(bytes).into_owned());
674 } else if rel == SCHEMA_FORMAT_MARKER_FILE {
675 marked = true;
676 } else if let Some(stem) = rel
677 .strip_prefix("types/")
678 .and_then(|f| f.strip_suffix(".yaml"))
679 {
680 types.push((
681 stem.to_string(),
682 String::from_utf8_lossy(bytes).into_owned(),
683 ));
684 }
685 }
686 let manifest = manifest.ok_or(SchemaLoadError::SealedPackageMissingManifest)?;
687 types.sort_by(|a, b| a.0.cmp(&b.0));
690 let format = if marked {
691 MetadataPolarityFormat::RequiredOptIn
692 } else {
693 MetadataPolarityFormat::Legacy
694 };
695 load_with_context(&manifest, &types, None, None, format)
696}
697
698pub fn check_package_reauthorable(
708 manifest_yaml: &str,
709 types_yamls: &[(String, String)],
710) -> Result<(), SchemaLoadError> {
711 let strict_context = Path::new("<sealed package>");
715 load_with_context(
716 manifest_yaml,
717 types_yamls,
718 Some(strict_context),
719 Some(strict_context),
720 MetadataPolarityFormat::RequiredOptIn,
721 )
722 .map(|_| ())
723}
724
725pub fn load_schema_from_memory_with_format(
729 manifest_yaml: &str,
730 types_yamls: &[(String, String)],
731 format: MetadataPolarityFormat,
732) -> Result<Schema, SchemaLoadError> {
733 load_with_context(manifest_yaml, types_yamls, None, None, format)
734}
735
736fn load_with_context(
737 manifest_yaml: &str,
738 types_yamls: &[(String, String)],
739 manifest_path: Option<&Path>,
740 types_dir: Option<&Path>,
741 format: MetadataPolarityFormat,
742) -> Result<Schema, SchemaLoadError> {
743 let mut errors: Vec<SchemaLoadError> = Vec::new();
752
753 let mut manifest: SchemaManifest =
754 serde_yaml_ng::from_str(manifest_yaml).map_err(|e| SchemaLoadError::ParseManifest {
755 path: manifest_path
756 .map(Path::to_path_buf)
757 .unwrap_or_else(|| PathBuf::from("<memory>")),
758 source: e,
759 })?;
760
761 if let Err(e) = validate_name(&manifest.name) {
762 errors.push(e);
763 }
764
765 let version = match semver::Version::parse(&manifest.version) {
769 Ok(v) => Some(v),
770 Err(_) => {
771 errors.push(SchemaLoadError::InvalidVersion {
772 value: manifest.version.clone(),
773 });
774 None
775 }
776 };
777
778 let mut rel_names: HashSet<String> = HashSet::new();
780 for def in &manifest.relationships.definitions {
781 if !rel_names.insert(def.name.clone()) {
782 errors.push(SchemaLoadError::DuplicateRelationship {
783 name: def.name.clone(),
784 });
785 }
786 }
787 if !rel_names.contains("_default") {
788 errors.push(SchemaLoadError::MissingDefaultWeight);
789 }
790 let available_rels: Vec<String> = manifest
791 .relationships
792 .definitions
793 .iter()
794 .map(|d| d.name.clone())
795 .collect();
796
797 if let Some(target) = &manifest.alias_target_rel_type
802 && !rel_names.contains(target)
803 {
804 let mut declared = available_rels.clone();
805 declared.sort();
806 errors.push(SchemaLoadError::AliasTargetRelTypeNotDeclared {
807 schema: manifest.name.clone(),
808 target: target.clone(),
809 declared,
810 });
811 }
812
813 let mut acyclic_set_member_seen: HashSet<&str> = HashSet::new();
819 for set in &manifest.relationships.acyclic_sets {
820 if set.len() < 2 {
821 errors.push(SchemaLoadError::InvalidAcyclicSet {
822 offender: set.join(", "),
823 reason: "a set needs at least two rel-types (a single member is the \
824 per-definition `acyclic` flag)"
825 .to_string(),
826 });
827 }
828 for name in set {
829 if !rel_names.contains(name.as_str()) {
830 errors.push(SchemaLoadError::InvalidAcyclicSet {
831 offender: name.clone(),
832 reason: "names no declared relationship".to_string(),
833 });
834 }
835 if !acyclic_set_member_seen.insert(name.as_str()) {
836 errors.push(SchemaLoadError::InvalidAcyclicSet {
837 offender: name.clone(),
838 reason: "a rel-type may appear in at most one acyclicity set".to_string(),
839 });
840 }
841 }
842 }
843
844 if let Some(lab) = &manifest.relationships.labelling {
849 if lab.attack.is_empty() {
850 errors.push(SchemaLoadError::InvalidLabelling {
851 offender: "(empty)".to_string(),
852 reason: "`labelling.attack` must name at least one rel-type".to_string(),
853 });
854 }
855 for name in &lab.attack {
856 if !rel_names.contains(name.as_str()) {
857 errors.push(SchemaLoadError::InvalidLabelling {
858 offender: name.clone(),
859 reason: "`labelling.attack` entry names no declared relationship".to_string(),
860 });
861 }
862 }
863 if let Some(sup) = &lab.support {
864 if sup.relationships.is_empty() {
865 errors.push(SchemaLoadError::InvalidLabelling {
866 offender: "(empty)".to_string(),
867 reason: "`labelling.support.relationships` must name at least one rel-type"
868 .to_string(),
869 });
870 }
871 for name in &sup.relationships {
872 if !rel_names.contains(name.as_str()) {
873 errors.push(SchemaLoadError::InvalidLabelling {
874 offender: name.clone(),
875 reason: "`labelling.support.relationships` entry names no declared \
876 relationship"
877 .to_string(),
878 });
879 }
880 }
881 }
882 }
883
884 if let Some(pointer) = manifest.alias_target_rel_type.clone() {
903 for def in &mut manifest.relationships.definitions {
904 if def.name == pointer {
905 def.manual_authoring = crate::manifest::ManualAuthoring::Forbidden;
906 }
907 }
908 }
909
910 for def in &manifest.relationships.definitions {
915 for t in &def.source_types {
916 if !manifest.types.iter().any(|d| d == t) {
917 errors.push(SchemaLoadError::UndeclaredRelationshipType {
918 relationship: def.name.clone(),
919 field: "source_types",
920 reference: t.clone(),
921 declared: manifest.types.clone(),
922 });
923 }
924 }
925 for t in &def.target_types {
926 if !manifest.types.iter().any(|d| d == t) {
927 errors.push(SchemaLoadError::UndeclaredRelationshipType {
928 relationship: def.name.clone(),
929 field: "target_types",
930 reference: t.clone(),
931 declared: manifest.types.clone(),
932 });
933 }
934 }
935 }
936
937 let mut seen_to_schemas: HashSet<String> = HashSet::new();
948 for entry in &manifest.cross_mem_relationships {
949 if entry.to_schema == "*" {
950 match manifest.alias_target_rel_type.as_deref() {
957 None => errors.push(SchemaLoadError::CrossMemWildcardWithoutAliasTarget),
958 Some(alias) => {
959 for def in &entry.definitions {
960 if def.name != alias {
961 errors.push(SchemaLoadError::CrossMemWildcardNonAliasRelType {
962 rel_type: def.name.clone(),
963 alias_target: alias.to_string(),
964 });
965 }
966 }
967 }
968 }
969 } else if entry.to_schema.contains('@') {
970 errors.push(SchemaLoadError::InvalidCrossMemToSchema {
971 value: entry.to_schema.clone(),
972 reason: "must not carry a version or range".into(),
973 });
974 } else if let Err(reason) = name_shape(&entry.to_schema) {
975 errors.push(SchemaLoadError::InvalidCrossMemToSchema {
976 value: entry.to_schema.clone(),
977 reason: reason.into(),
978 });
979 }
980 if !seen_to_schemas.insert(entry.to_schema.clone()) {
981 errors.push(SchemaLoadError::DuplicateCrossMemToSchema {
982 to_schema: entry.to_schema.clone(),
983 });
984 }
985 for def in &entry.definitions {
986 for t in &def.source_types {
987 if !manifest.types.iter().any(|d| d == t) {
988 errors.push(SchemaLoadError::UndeclaredCrossMemSourceType {
989 to_schema: entry.to_schema.clone(),
990 relationship: def.name.clone(),
991 reference: t.clone(),
992 declared: manifest.types.clone(),
993 });
994 }
995 }
996 }
997 }
998
999 let mut found_stems: Vec<String> = types_yamls.iter().map(|(s, _)| s.clone()).collect();
1001 found_stems.sort();
1002 let mut declared = manifest.types.clone();
1003 declared.sort();
1004 if found_stems != declared {
1005 errors.push(SchemaLoadError::TypeFileMismatch {
1009 declared,
1010 found: found_stems,
1011 });
1012 return Err(collapse(errors));
1013 }
1014
1015 let defaults: IndexMap<String, f32> = manifest
1017 .relationships
1018 .definitions
1019 .iter()
1020 .map(|d| (d.name.clone(), d.default_weight))
1021 .collect();
1022
1023 let mut types_map: HashMap<String, Arc<TypeDefinition>> = HashMap::new();
1024 let mut had_type_parse_failure = false;
1025
1026 for (stem, text) in types_yamls {
1027 let type_path = types_dir
1028 .map(|d| d.join(format!("{stem}.yaml")))
1029 .unwrap_or_else(|| PathBuf::from(format!("<memory>/{stem}.yaml")));
1030
1031 let mut td: TypeDefinition = match serde_yaml_ng::from_str(text) {
1032 Ok(td) => td,
1033 Err(e) => {
1034 errors.push(SchemaLoadError::ParseType {
1039 path: type_path.clone(),
1040 source: e,
1041 });
1042 had_type_parse_failure = true;
1043 continue;
1044 }
1045 };
1046
1047 if td.name != *stem {
1048 errors.push(SchemaLoadError::TypeNameMismatch {
1049 file: stem.clone(),
1050 declared: td.name.clone(),
1051 });
1052 }
1053
1054 if let Some(legacy) = td.legacy_propagating_relationships.take() {
1062 if types_dir.is_some() {
1063 errors.push(SchemaLoadError::PropagatingRelationshipsRenamed {
1064 type_name: td.name.clone(),
1065 });
1066 } else if td.no_self_loop_relationships.is_empty() {
1067 td.no_self_loop_relationships = legacy;
1068 }
1069 }
1070
1071 if td.legacy_examples.take().is_some() && types_dir.is_some() {
1077 errors.push(SchemaLoadError::ExamplesRetired {
1078 type_name: td.name.clone(),
1079 });
1080 }
1081
1082 if let Some(ex) = td.exemplar.as_mut() {
1091 let mut retired_spelling = false;
1092 let mut incomplete = false;
1093 for rel in &mut ex.relations {
1094 let legacy_to = rel.legacy_to.take();
1095 let legacy_type = rel.legacy_type.take();
1096 if legacy_to.is_some() || legacy_type.is_some() {
1097 if types_dir.is_some() {
1098 retired_spelling = true;
1099 continue;
1100 }
1101 if rel.target.is_none() {
1102 rel.target = legacy_to;
1103 }
1104 if rel.rel_type.is_none() {
1105 rel.rel_type = legacy_type;
1106 }
1107 }
1108 if rel.target.is_none() || rel.rel_type.is_none() {
1109 incomplete = true;
1110 }
1111 }
1112 if retired_spelling {
1113 errors.push(SchemaLoadError::ExemplarRelationSpellingRetired {
1114 type_name: td.name.clone(),
1115 });
1116 }
1117 if incomplete {
1118 errors.push(SchemaLoadError::ExemplarRelationIncomplete {
1119 type_name: td.name.clone(),
1120 });
1121 }
1122 }
1123
1124 for field in &mut td.metadata_fields {
1131 if matches!(format, MetadataPolarityFormat::RequiredOptIn)
1135 && field.legacy_optional.is_some()
1136 {
1137 errors.push(SchemaLoadError::OptionalRetired {
1138 type_name: td.name.clone(),
1139 field: field.key.clone(),
1140 });
1141 }
1142 field.required_resolved = match (field.required, field.legacy_optional.take()) {
1143 (Some(required), _) => required,
1144 (None, Some(optional)) => !optional,
1145 (None, None) => matches!(format, MetadataPolarityFormat::Legacy),
1146 };
1147 }
1148
1149 td.declared_metadata_keys = td.metadata_fields.iter().map(|f| f.key.clone()).collect();
1159
1160 for field in &td.metadata_fields {
1165 if base_metadata::is_base_key(&field.key)
1166 && !reserved_metadata_field_keys().contains(&field.key.as_str())
1167 {
1168 errors.push(SchemaLoadError::RedeclaredBaseField {
1169 type_name: td.name.clone(),
1170 field: field.key.clone(),
1171 });
1172 }
1173 }
1174
1175 let mut merged = base_metadata::prefix_fields();
1178 merged.append(&mut td.metadata_fields);
1179 merged.extend(base_metadata::suffix_fields());
1180 td.metadata_fields = merged;
1181
1182 compile_section_formats(&mut td);
1183 validate_type(&td, &rel_names, &available_rels, &mut errors);
1184
1185 let mut weights = defaults.clone();
1187 for (k, v) in &td.edge_weight_overrides {
1188 weights.insert(k.clone(), *v);
1189 }
1190 td.edge_weights = weights;
1191
1192 types_map.insert(stem.clone(), Arc::new(td));
1193 }
1194
1195 if !had_type_parse_failure {
1203 let all_section_keys: HashSet<&str> = types_map
1204 .values()
1205 .flat_map(|t| t.sections.iter().map(|s| s.key.as_str()))
1206 .collect();
1207 let mut type_names: Vec<&String> = types_map.keys().collect();
1210 type_names.sort();
1211 for type_name in type_names {
1212 let td = &types_map[type_name];
1213 for c in &td.constraints {
1214 if let crate::types::ConstraintDef::EnumFromNeighbour { section, .. } = c
1215 && !all_section_keys.contains(section.as_str())
1216 {
1217 errors.push(SchemaLoadError::InvalidConstraint {
1218 type_name: td.name.clone(),
1219 kind: "enum_from_neighbour",
1220 offender: section.clone(),
1221 reason: "`section` names a section key no type of this schema declares"
1222 .to_string(),
1223 });
1224 }
1225 }
1226 for ob in &td.must_reach {
1229 for t in &ob.terminal_types {
1230 if !types_map.contains_key(t.as_str()) {
1231 errors.push(SchemaLoadError::InvalidConstraint {
1232 type_name: td.name.clone(),
1233 kind: "must_reach",
1234 offender: t.clone(),
1235 reason: "`terminal_types` entry names no type of this schema"
1236 .to_string(),
1237 });
1238 }
1239 }
1240 }
1241 for sig in &td.signals {
1247 if let (Some(field), Some(value)) = (&sig.neighbour_field, &sig.neighbour_value) {
1248 let declaring: Vec<&crate::types::MetadataFieldDef> = types_map
1249 .values()
1250 .flat_map(|t| t.metadata_fields.iter())
1251 .filter(|f| f.key == *field && f.enum_values.is_some())
1252 .collect();
1253 if declaring.is_empty() {
1254 errors.push(SchemaLoadError::InvalidConstraint {
1255 type_name: td.name.clone(),
1256 kind: "signal",
1257 offender: field.clone(),
1258 reason: "`neighbour_field` is declared with `enum_values` on no \
1259 type of this schema"
1260 .to_string(),
1261 });
1262 } else if !declaring.iter().any(|f| {
1263 f.enum_values
1264 .as_ref()
1265 .is_some_and(|allowed| allowed.contains(value))
1266 }) {
1267 errors.push(SchemaLoadError::InvalidConstraint {
1268 type_name: td.name.clone(),
1269 kind: "signal",
1270 offender: value.clone(),
1271 reason: format!(
1272 "`neighbour_value` is outside `{field}`'s enum_values on every \
1273 declaring type"
1274 ),
1275 });
1276 }
1277 }
1278 }
1279 }
1280 }
1281
1282 if !had_type_parse_failure
1287 && let Some(lab) = &manifest.relationships.labelling
1288 && let Some(sup) = &lab.support
1289 {
1290 for t in &sup.terminal_types {
1291 if !types_map.contains_key(t.as_str()) {
1292 errors.push(SchemaLoadError::InvalidLabelling {
1293 offender: t.clone(),
1294 reason: "`labelling.support.terminal_types` entry names no type of this \
1295 schema"
1296 .to_string(),
1297 });
1298 }
1299 }
1300 }
1301
1302 if !errors.is_empty() {
1303 return Err(collapse(errors));
1304 }
1305
1306 Ok(Schema {
1307 manifest,
1308 version: version.expect("version parse failure would have accumulated an error"),
1309 types: types_map,
1310 })
1311}
1312
1313fn validate_name(name: &str) -> Result<(), SchemaLoadError> {
1314 name_shape(name).map_err(|reason| SchemaLoadError::InvalidName {
1315 value: name.into(),
1316 reason,
1317 })
1318}
1319
1320pub fn validate_schema_name(name: &str) -> Result<(), &'static str> {
1326 name_shape(name)
1327}
1328
1329fn name_shape(name: &str) -> Result<(), &'static str> {
1334 if name.is_empty() {
1335 return Err("must not be empty");
1336 }
1337 let mut chars = name.chars();
1338 let first = chars.next().unwrap();
1339 if !first.is_ascii_lowercase() {
1340 return Err("must start with a lowercase letter");
1341 }
1342 for c in chars {
1343 if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
1344 return Err("must contain only lowercase letters, digits, and hyphens");
1345 }
1346 }
1347 Ok(())
1348}
1349
1350fn compile_section_formats(td: &mut TypeDefinition) {
1358 use crate::content_expr::ContentExpr;
1359 for section in &mut td.sections {
1360 let declares_any = section.content.is_some()
1366 || section.item_pattern.is_some()
1367 || section.table.is_some()
1368 || section.example.is_some()
1369 || section.format_severity != crate::types::ConstraintSeverity::Block;
1370 if !declares_any {
1371 continue;
1372 }
1373 let mut problems: Vec<String> = Vec::new();
1374
1375 let compiled = match §ion.content {
1376 None => {
1377 problems.push(
1378 "`item_pattern` / `table` / `example` require a `content` declaration"
1379 .to_string(),
1380 );
1381 None
1382 }
1383 Some(expr_src) => match ContentExpr::parse(expr_src) {
1384 Ok(expr) => Some(expr),
1385 Err(e) => {
1386 problems.push(format!("`content` is invalid: {e}"));
1387 None
1388 }
1389 },
1390 };
1391
1392 if let Some(pattern) = §ion.item_pattern {
1393 if let Err(e) = regex::Regex::new(pattern) {
1394 problems.push(format!("`item_pattern` is not a valid regex: {e}"));
1395 }
1396 if let Some(expr) = &compiled {
1397 let names = expr.mentioned_names();
1398 let has_list = names.contains(&"list");
1399 let has_paragraph = names.contains(&"paragraph");
1400 if has_list == has_paragraph {
1401 problems.push(
1402 "`item_pattern` requires a `content` expression containing exactly one of `list` / `paragraph` (tables use `column_patterns`)"
1403 .to_string(),
1404 );
1405 }
1406 }
1407 }
1408
1409 if let Some(table) = §ion.table {
1410 if let Some(expr) = &compiled
1411 && !expr.mentioned_names().contains(&"table")
1412 {
1413 problems.push(
1414 "`table` block is only legal when `content` contains `table`".to_string(),
1415 );
1416 }
1417 if table.columns.is_empty() {
1418 problems.push("`table.columns` must name at least one column".to_string());
1419 }
1420 for (column, pattern) in &table.column_patterns {
1421 if !table.columns.contains(column) {
1422 problems.push(format!(
1423 "`column_patterns` names '{column}', which is not in `columns`"
1424 ));
1425 }
1426 if let Err(e) = regex::Regex::new(pattern) {
1427 problems.push(format!(
1428 "`column_patterns.{column}` is not a valid regex: {e}"
1429 ));
1430 }
1431 }
1432 }
1433
1434 if problems.is_empty() {
1435 section.compiled_content = compiled;
1436 } else {
1437 section.format_problems = problems;
1438 }
1439 }
1440}
1441
1442pub fn check_section_formats(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
1450 let mut first: Option<(String, String)> = None;
1455 let mut problems: Vec<String> = Vec::new();
1456 for td in schema.types.values() {
1457 for section in &td.sections {
1458 if section.format_problems.is_empty() {
1459 continue;
1460 }
1461 if first.is_none() {
1462 first = Some((td.name.clone(), section.key.clone()));
1463 problems.extend(section.format_problems.iter().cloned());
1464 } else {
1465 problems.extend(
1466 section
1467 .format_problems
1468 .iter()
1469 .map(|p| format!("[{}.{}] {p}", td.name, section.key)),
1470 );
1471 }
1472 }
1473 }
1474 match first {
1475 Some((type_name, section)) => Err(SchemaLoadError::InvalidSectionFormat {
1476 type_name,
1477 section,
1478 problems,
1479 }),
1480 None => Ok(()),
1481 }
1482}
1483
1484fn validate_type(
1485 td: &TypeDefinition,
1486 rel_names: &HashSet<String>,
1487 available_rels: &[String],
1488 errors: &mut Vec<SchemaLoadError>,
1489) {
1490 for section in &td.sections {
1498 if reserved_section_keys().contains(§ion.key.as_str()) {
1499 errors.push(SchemaLoadError::ReservedSchemaKey {
1500 type_name: td.name.clone(),
1501 kind: "section",
1502 offending_key: section.key.clone(),
1503 reserved_keys: reserved_section_keys()
1504 .iter()
1505 .map(|s| s.to_string())
1506 .collect(),
1507 });
1508 }
1509 }
1510
1511 if let Err(e) = check_rel(
1512 &td.name,
1513 "hierarchy_relationship",
1514 &td.hierarchy_relationship,
1515 rel_names,
1516 available_rels,
1517 ) {
1518 errors.push(e);
1519 }
1520 for r in &td.no_self_loop_relationships {
1521 if let Err(e) = check_rel(
1522 &td.name,
1523 "no_self_loop_relationships",
1524 r,
1525 rel_names,
1526 available_rels,
1527 ) {
1528 errors.push(e);
1529 }
1530 }
1531 for r in td.edge_weight_overrides.keys() {
1532 if let Err(e) = check_rel(
1533 &td.name,
1534 "edge_weight_overrides",
1535 r,
1536 rel_names,
1537 available_rels,
1538 ) {
1539 errors.push(e);
1540 }
1541 }
1542 for block in &td.required_outgoing {
1543 for r in &block.relationships {
1544 if let Err(e) = check_rel(&td.name, "required_outgoing", r, rel_names, available_rels) {
1545 errors.push(e);
1546 }
1547 }
1548 match (&block.when_field, &block.when_value) {
1556 (None, None) => {}
1557 (Some(f), None) => {
1558 errors.push(SchemaLoadError::InvalidConstraint {
1559 type_name: td.name.clone(),
1560 kind: "required_outgoing",
1561 offender: f.clone(),
1562 reason: "`when_field` requires `when_value` alongside it".to_string(),
1563 });
1564 }
1565 (None, Some(v)) => {
1566 errors.push(SchemaLoadError::InvalidConstraint {
1567 type_name: td.name.clone(),
1568 kind: "required_outgoing",
1569 offender: v.clone(),
1570 reason: "`when_value` requires `when_field` alongside it".to_string(),
1571 });
1572 }
1573 (Some(f), Some(v)) => match td.metadata_fields.iter().find(|mf| mf.key == *f) {
1574 None => {
1575 errors.push(SchemaLoadError::InvalidConstraint {
1576 type_name: td.name.clone(),
1577 kind: "required_outgoing",
1578 offender: f.clone(),
1579 reason: "`when_field` names no metadata field of this type".to_string(),
1580 });
1581 }
1582 Some(when_def) => match &when_def.enum_values {
1583 None => {
1584 errors.push(SchemaLoadError::InvalidConstraint {
1585 type_name: td.name.clone(),
1586 kind: "required_outgoing",
1587 offender: f.clone(),
1588 reason: format!(
1589 "`when_field` must name a metadata field with `enum_values`; `{f}` declares none"
1590 ),
1591 });
1592 }
1593 Some(allowed) if !allowed.contains(v) => {
1594 errors.push(SchemaLoadError::InvalidConstraint {
1595 type_name: td.name.clone(),
1596 kind: "required_outgoing",
1597 offender: v.clone(),
1598 reason: format!(
1599 "`when_value` is not in `{f}`'s enum_values [{}]",
1600 allowed.join(", ")
1601 ),
1602 });
1603 }
1604 Some(_) => {}
1605 },
1606 },
1607 }
1608 }
1609
1610 for ob in &td.must_reach {
1613 if ob.relationships.is_empty() {
1614 errors.push(SchemaLoadError::InvalidConstraint {
1615 type_name: td.name.clone(),
1616 kind: "must_reach",
1617 offender: "(empty)".to_string(),
1618 reason: "`relationships` must name at least one relationship".to_string(),
1619 });
1620 }
1621 for r in &ob.relationships {
1622 if let Err(e) = check_rel(&td.name, "must_reach", r, rel_names, available_rels) {
1623 errors.push(e);
1624 }
1625 }
1626 if ob.terminal_types.is_empty() {
1627 errors.push(SchemaLoadError::InvalidConstraint {
1628 type_name: td.name.clone(),
1629 kind: "must_reach",
1630 offender: "(empty)".to_string(),
1631 reason: "`terminal_types` must name at least one type".to_string(),
1632 });
1633 }
1634 if ob.max_depth == Some(0) {
1635 errors.push(SchemaLoadError::InvalidConstraint {
1636 type_name: td.name.clone(),
1637 kind: "must_reach",
1638 offender: "0".to_string(),
1639 reason: "`max_depth` must be at least 1 — nothing is reachable in zero hops"
1640 .to_string(),
1641 });
1642 }
1643 if ob.severity == crate::types::ConstraintSeverity::Block {
1644 errors.push(SchemaLoadError::InvalidConstraint {
1649 type_name: td.name.clone(),
1650 kind: "must_reach",
1651 offender: "block".to_string(),
1652 reason: "must_reach is always warn-tier — a reachability gap is created by \
1653 writes on other entities, so no single write can be refused for it"
1654 .to_string(),
1655 });
1656 }
1657 }
1658
1659 let mut signal_names_seen: HashSet<&str> = HashSet::new();
1663 for sig in &td.signals {
1664 let name_ok = sig
1665 .name
1666 .chars()
1667 .next()
1668 .is_some_and(|c| c.is_ascii_lowercase())
1669 && sig
1670 .name
1671 .chars()
1672 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
1673 if !name_ok {
1674 errors.push(SchemaLoadError::InvalidConstraint {
1675 type_name: td.name.clone(),
1676 kind: "signal",
1677 offender: sig.name.clone(),
1678 reason: "`name` must match [a-z][a-z0-9_]*".to_string(),
1679 });
1680 }
1681 if !signal_names_seen.insert(sig.name.as_str()) {
1682 errors.push(SchemaLoadError::InvalidConstraint {
1683 type_name: td.name.clone(),
1684 kind: "signal",
1685 offender: sig.name.clone(),
1686 reason: "duplicate signal name on this type".to_string(),
1687 });
1688 }
1689 if sig.relationships.is_empty() {
1690 errors.push(SchemaLoadError::InvalidConstraint {
1691 type_name: td.name.clone(),
1692 kind: "signal",
1693 offender: sig.name.clone(),
1694 reason: "`relationships` must name at least one relationship".to_string(),
1695 });
1696 }
1697 for r in &sig.relationships {
1698 if let Err(e) = check_rel(&td.name, "signals", r, rel_names, available_rels) {
1699 errors.push(e);
1700 }
1701 }
1702 if sig.thresholds.is_empty() {
1703 errors.push(SchemaLoadError::InvalidConstraint {
1704 type_name: td.name.clone(),
1705 kind: "signal",
1706 offender: sig.name.clone(),
1707 reason: "`thresholds` must declare at least one step".to_string(),
1708 });
1709 }
1710 for pair in sig.thresholds.windows(2) {
1711 if pair[1].at_least <= pair[0].at_least {
1712 errors.push(SchemaLoadError::InvalidConstraint {
1713 type_name: td.name.clone(),
1714 kind: "signal",
1715 offender: pair[1].at_least.to_string(),
1716 reason: "`thresholds` must have strictly increasing `at_least` values"
1717 .to_string(),
1718 });
1719 }
1720 }
1721 match (&sig.neighbour_field, &sig.neighbour_value) {
1722 (None, None) | (Some(_), Some(_)) => {}
1723 (Some(f), None) => {
1724 errors.push(SchemaLoadError::InvalidConstraint {
1725 type_name: td.name.clone(),
1726 kind: "signal",
1727 offender: f.clone(),
1728 reason: "`neighbour_field` requires `neighbour_value` alongside it".to_string(),
1729 });
1730 }
1731 (None, Some(v)) => {
1732 errors.push(SchemaLoadError::InvalidConstraint {
1733 type_name: td.name.clone(),
1734 kind: "signal",
1735 offender: v.clone(),
1736 reason: "`neighbour_value` requires `neighbour_field` alongside it".to_string(),
1737 });
1738 }
1739 }
1740 }
1741
1742 let field_keys: std::collections::HashSet<&str> =
1746 td.metadata_fields.iter().map(|f| f.key.as_str()).collect();
1747 let section_keys: std::collections::HashSet<&str> =
1748 td.sections.iter().map(|sec| sec.key.as_str()).collect();
1749 for c in &td.constraints {
1750 match c {
1751 crate::types::ConstraintDef::RequiresWhen {
1752 field,
1753 when_field,
1754 when_value,
1755 ..
1756 } => {
1757 if !field_keys.contains(field.as_str()) && !section_keys.contains(field.as_str()) {
1758 errors.push(SchemaLoadError::InvalidConstraint {
1759 type_name: td.name.clone(),
1760 kind: "requires_when",
1761 offender: field.clone(),
1762 reason: "`field` names neither a metadata field nor a section of this type"
1763 .to_string(),
1764 });
1765 }
1766 let Some(when_def) = td.metadata_fields.iter().find(|f| f.key == *when_field)
1767 else {
1768 errors.push(SchemaLoadError::InvalidConstraint {
1769 type_name: td.name.clone(),
1770 kind: "requires_when",
1771 offender: when_field.clone(),
1772 reason: "`when_field` names no metadata field of this type".to_string(),
1773 });
1774 continue;
1775 };
1776 if let Some(allowed) = &when_def.enum_values
1777 && !allowed.contains(when_value)
1778 {
1779 errors.push(SchemaLoadError::InvalidConstraint {
1780 type_name: td.name.clone(),
1781 kind: "requires_when",
1782 offender: when_value.clone(),
1783 reason: format!(
1784 "`when_value` is not in `{when_field}`'s enum_values [{}]",
1785 allowed.join(", ")
1786 ),
1787 });
1788 }
1789 }
1790 crate::types::ConstraintDef::Unique { fields, .. } => {
1791 if fields.is_empty() {
1792 errors.push(SchemaLoadError::InvalidConstraint {
1793 type_name: td.name.clone(),
1794 kind: "unique",
1795 offender: "(empty)".to_string(),
1796 reason: "`fields` must name at least one metadata field".to_string(),
1797 });
1798 }
1799 for f in fields {
1800 if !field_keys.contains(f.as_str()) {
1801 errors.push(SchemaLoadError::InvalidConstraint {
1802 type_name: td.name.clone(),
1803 kind: "unique",
1804 offender: f.clone(),
1805 reason: "`fields` entry names no metadata field of this type"
1806 .to_string(),
1807 });
1808 }
1809 }
1810 }
1811 crate::types::ConstraintDef::EnumFromNeighbour {
1812 field, rel_type, ..
1813 } => {
1814 if !field_keys.contains(field.as_str()) {
1815 errors.push(SchemaLoadError::InvalidConstraint {
1816 type_name: td.name.clone(),
1817 kind: "enum_from_neighbour",
1818 offender: field.clone(),
1819 reason: "`field` names no metadata field of this type".to_string(),
1820 });
1821 }
1822 if !rel_names.contains(rel_type) {
1823 errors.push(SchemaLoadError::InvalidConstraint {
1824 type_name: td.name.clone(),
1825 kind: "enum_from_neighbour",
1826 offender: rel_type.clone(),
1827 reason: "`rel_type` is not in the schema's relationship vocabulary"
1828 .to_string(),
1829 });
1830 }
1831 }
1835 crate::types::ConstraintDef::TransitionRequiresChecks {
1836 field,
1837 to_value,
1838 relationships,
1839 ..
1840 } => {
1841 match td.metadata_fields.iter().find(|f| f.key == *field) {
1842 None => {
1843 errors.push(SchemaLoadError::InvalidConstraint {
1844 type_name: td.name.clone(),
1845 kind: "transition_requires_checks",
1846 offender: field.clone(),
1847 reason: "`field` names no metadata field of this type".to_string(),
1848 });
1849 }
1850 Some(field_def) => {
1851 if let Some(allowed) = &field_def.enum_values
1852 && !allowed.contains(to_value)
1853 {
1854 errors.push(SchemaLoadError::InvalidConstraint {
1855 type_name: td.name.clone(),
1856 kind: "transition_requires_checks",
1857 offender: to_value.clone(),
1858 reason: format!(
1859 "`to_value` is not in `{field}`'s enum_values [{}]",
1860 allowed.join(", ")
1861 ),
1862 });
1863 }
1864 }
1865 }
1866 if relationships.is_empty() {
1867 errors.push(SchemaLoadError::InvalidConstraint {
1868 type_name: td.name.clone(),
1869 kind: "transition_requires_checks",
1870 offender: "(empty)".to_string(),
1871 reason: "`relationships` must name at least one declared relationship"
1872 .to_string(),
1873 });
1874 }
1875 for rel in relationships {
1876 if !rel_names.contains(rel) {
1877 errors.push(SchemaLoadError::InvalidConstraint {
1878 type_name: td.name.clone(),
1879 kind: "transition_requires_checks",
1880 offender: rel.clone(),
1881 reason: "`relationships` entry is not in the schema's relationship \
1882 vocabulary"
1883 .to_string(),
1884 });
1885 }
1886 }
1887 }
1888 crate::types::ConstraintDef::StatusPropagation {
1889 field,
1890 value,
1891 rel_type,
1892 rel_types,
1893 severity,
1894 ..
1895 } => {
1896 match td.metadata_fields.iter().find(|f| f.key == *field) {
1897 None => {
1898 errors.push(SchemaLoadError::InvalidConstraint {
1899 type_name: td.name.clone(),
1900 kind: "status_propagation",
1901 offender: field.clone(),
1902 reason: "`field` names no metadata field of this type".to_string(),
1903 });
1904 }
1905 Some(field_def) => {
1906 if let Some(allowed) = &field_def.enum_values
1907 && !allowed.contains(value)
1908 {
1909 errors.push(SchemaLoadError::InvalidConstraint {
1910 type_name: td.name.clone(),
1911 kind: "status_propagation",
1912 offender: value.clone(),
1913 reason: format!(
1914 "`value` is not in `{field}`'s enum_values [{}]",
1915 allowed.join(", ")
1916 ),
1917 });
1918 }
1919 }
1920 }
1921 match (rel_type, rel_types) {
1924 (Some(_), Some(_)) => {
1925 errors.push(SchemaLoadError::InvalidConstraint {
1926 type_name: td.name.clone(),
1927 kind: "status_propagation",
1928 offender: "rel_type".to_string(),
1929 reason: "declare `rel_type` or `rel_types`, not both".to_string(),
1930 });
1931 }
1932 (None, None) => {
1933 errors.push(SchemaLoadError::InvalidConstraint {
1934 type_name: td.name.clone(),
1935 kind: "status_propagation",
1936 offender: "(missing)".to_string(),
1937 reason: "one of `rel_type` / `rel_types` is required".to_string(),
1938 });
1939 }
1940 (Some(single), None) => {
1941 if !rel_names.contains(single) {
1942 errors.push(SchemaLoadError::InvalidConstraint {
1943 type_name: td.name.clone(),
1944 kind: "status_propagation",
1945 offender: single.clone(),
1946 reason: "`rel_type` is not in the schema's relationship vocabulary"
1947 .to_string(),
1948 });
1949 }
1950 }
1951 (None, Some(set)) => {
1952 if set.is_empty() {
1953 errors.push(SchemaLoadError::InvalidConstraint {
1954 type_name: td.name.clone(),
1955 kind: "status_propagation",
1956 offender: "(empty)".to_string(),
1957 reason: "`rel_types` must name at least one relationship"
1958 .to_string(),
1959 });
1960 }
1961 for name in set {
1962 if !rel_names.contains(name) {
1963 errors.push(SchemaLoadError::InvalidConstraint {
1964 type_name: td.name.clone(),
1965 kind: "status_propagation",
1966 offender: name.clone(),
1967 reason: "`rel_types` entry is not in the schema's \
1968 relationship vocabulary"
1969 .to_string(),
1970 });
1971 }
1972 }
1973 }
1974 }
1975 if *severity == crate::types::ConstraintSeverity::Block {
1976 errors.push(SchemaLoadError::InvalidConstraint {
1982 type_name: td.name.clone(),
1983 kind: "status_propagation",
1984 offender: "block".to_string(),
1985 reason: "status_propagation is always warn-tier — a parent falling after \
1986 the child was written cannot retroactively make the child's \
1987 write illegal"
1988 .to_string(),
1989 });
1990 }
1991 }
1992 }
1993 }
1994
1995 if let Some(due) = &td.due {
1998 match td.metadata_fields.iter().find(|f| f.key == due.date_field) {
1999 None => errors.push(SchemaLoadError::InvalidDueAxis {
2000 type_name: td.name.clone(),
2001 offender: due.date_field.clone(),
2002 reason: "`date_field` names no metadata field of this type".to_string(),
2003 }),
2004 Some(f) if f.field_type != crate::types::FieldType::Date => {
2005 errors.push(SchemaLoadError::InvalidDueAxis {
2006 type_name: td.name.clone(),
2007 offender: due.date_field.clone(),
2008 reason: "`date_field` must name a date-typed metadata field".to_string(),
2009 })
2010 }
2011 Some(_) => {}
2012 }
2013 match td
2014 .metadata_fields
2015 .iter()
2016 .find(|f| f.key == due.status_field)
2017 {
2018 None => errors.push(SchemaLoadError::InvalidDueAxis {
2019 type_name: td.name.clone(),
2020 offender: due.status_field.clone(),
2021 reason: "`status_field` names no metadata field of this type".to_string(),
2022 }),
2023 Some(f) => match &f.enum_values {
2024 None => errors.push(SchemaLoadError::InvalidDueAxis {
2025 type_name: td.name.clone(),
2026 offender: due.status_field.clone(),
2027 reason: "`status_field` must name an enum-typed metadata field \
2028 (declare enum_values)"
2029 .to_string(),
2030 }),
2031 Some(allowed) => {
2032 for v in &due.open_values {
2033 if !allowed.contains(v) {
2034 errors.push(SchemaLoadError::InvalidDueAxis {
2035 type_name: td.name.clone(),
2036 offender: v.clone(),
2037 reason: format!(
2038 "`open_values` entry is not in `{}`'s enum_values [{}]",
2039 due.status_field,
2040 allowed.join(", ")
2041 ),
2042 });
2043 }
2044 }
2045 }
2046 },
2047 }
2048 if due.open_values.is_empty() {
2049 errors.push(SchemaLoadError::InvalidDueAxis {
2050 type_name: td.name.clone(),
2051 offender: "(empty)".to_string(),
2052 reason: "`open_values` must name at least one open status value".to_string(),
2053 });
2054 }
2055 if let Some(lead) = &due.lead_section
2056 && !td.sections.iter().any(|s| s.key == *lead)
2057 {
2058 errors.push(SchemaLoadError::InvalidDueAxis {
2059 type_name: td.name.clone(),
2060 offender: lead.clone(),
2061 reason: "`lead_section` names no section of this type".to_string(),
2062 });
2063 }
2064 }
2065
2066 let catch_all_count = td.sections.iter().filter(|s| s.catch_all).count();
2068 if catch_all_count != 1 {
2069 errors.push(SchemaLoadError::CatchAllViolation {
2070 type_name: td.name.clone(),
2071 count: catch_all_count,
2072 });
2073 }
2074
2075 let section_keys: HashSet<&str> = td.sections.iter().map(|s| s.key.as_str()).collect();
2077 let meta_keys: HashSet<&str> = td.metadata_fields.iter().map(|m| m.key.as_str()).collect();
2078
2079 for f in &td.text_fields {
2080 if !section_keys.contains(f.as_str()) {
2082 errors.push(SchemaLoadError::UnknownFieldReference {
2083 type_name: td.name.clone(),
2084 field: "text_fields",
2085 reference: f.clone(),
2086 });
2087 }
2088 }
2089 for f in &td.health_required_fields {
2090 if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
2091 errors.push(SchemaLoadError::UnknownFieldReference {
2092 type_name: td.name.clone(),
2093 field: "health_required_fields",
2094 reference: f.clone(),
2095 });
2096 }
2097 }
2098 for f in &td.updatable_fields {
2099 if f == "title" {
2101 continue;
2102 }
2103 if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
2104 errors.push(SchemaLoadError::UnknownFieldReference {
2105 type_name: td.name.clone(),
2106 field: "updatable_fields",
2107 reference: f.clone(),
2108 });
2109 }
2110 }
2111
2112 for m in &td.metadata_fields {
2114 if let (Some(default), Some(allowed)) = (m.default_value.as_ref(), m.enum_values.as_ref())
2115 && !allowed.contains(default)
2116 {
2117 errors.push(SchemaLoadError::DefaultValueNotInEnum {
2118 type_name: td.name.clone(),
2119 field: m.key.clone(),
2120 default: default.clone(),
2121 allowed: allowed.clone(),
2122 });
2123 }
2124 }
2125}
2126
2127fn check_rel(
2128 type_name: &str,
2129 field: &'static str,
2130 relationship: &str,
2131 rel_names: &HashSet<String>,
2132 available: &[String],
2133) -> Result<(), SchemaLoadError> {
2134 if rel_names.contains(relationship) {
2135 return Ok(());
2136 }
2137 Err(SchemaLoadError::UndeclaredRelationship {
2138 type_name: type_name.into(),
2139 field,
2140 relationship: relationship.into(),
2141 available: available.to_vec(),
2142 })
2143}