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(
139 "type '{type_name}' resolution declaration is invalid: {reason} — offending name: '{offender}'"
140 )]
141 InvalidResolutionAxis {
142 type_name: String,
143 offender: String,
144 reason: String,
145 },
146
147 #[error(
150 "type '{type_name}' metadata field '{field}' value_pattern '{pattern}' does not compile: {reason}"
151 )]
152 InvalidFieldPattern {
153 type_name: String,
154 field: String,
155 pattern: String,
156 reason: String,
157 },
158
159 #[error("schema relationship vocabulary must include a '_default' definition")]
160 MissingDefaultWeight,
161
162 #[error("duplicate relationship definition: '{name}'")]
163 DuplicateRelationship { name: String },
164
165 #[error(
166 "type '{type_name}' references relationship '{relationship}' in field '{field}' — not declared in schema. Available: [{}]. {}",
167 available.join(", "),
168 format_suggestion(relationship, available)
169 )]
170 UndeclaredRelationship {
171 type_name: String,
172 field: &'static str,
173 relationship: String,
174 available: Vec<String>,
175 },
176
177 #[error(
178 "type '{type_name}' must have exactly one section with `catch_all: true` (found {count})"
179 )]
180 CatchAllViolation { type_name: String, count: usize },
181
182 #[error(
183 "type '{type_name}' field '{field}' references unknown key '{reference}' — not a section or metadata field"
184 )]
185 UnknownFieldReference {
186 type_name: String,
187 field: &'static str,
188 reference: String,
189 },
190
191 #[error(
192 "type '{type_name}' constraint ({kind}) is invalid: {reason} — offending name: '{offender}'"
193 )]
194 InvalidConstraint {
195 type_name: String,
196 kind: &'static str,
197 offender: String,
198 reason: String,
199 },
200
201 #[error("relationships.acyclic_sets is invalid: {reason} — offending entry: '{offender}'")]
202 InvalidAcyclicSet { offender: String, reason: String },
203
204 #[error("relationships.labelling is invalid: {reason} — offending name: '{offender}'")]
205 InvalidLabelling { offender: String, reason: String },
206
207 #[error(
208 "type '{type_name}' section '{section}' format declaration is invalid: {}",
209 problems.join("; ")
210 )]
211 InvalidSectionFormat {
212 type_name: String,
213 section: String,
214 problems: Vec<String>,
218 },
219
220 #[error(
221 "type '{type_name}' metadata field '{field}' default '{default}' is not listed in enum_values: [{}]",
222 allowed.join(", ")
223 )]
224 DefaultValueNotInEnum {
225 type_name: String,
226 field: String,
227 default: String,
228 allowed: Vec<String>,
229 },
230
231 #[error(
232 "type '{type_name}' redeclares engine-implicit metadata key '{field}' — remove it from the YAML; the loader injects it automatically"
233 )]
234 RedeclaredBaseField { type_name: String, field: String },
235
236 #[error(
237 "relationship '{relationship}' field '{field}' references unknown type '{reference}'. Declared types: [{}]. {}",
238 declared.join(", "),
239 format_suggestion(reference, declared)
240 )]
241 UndeclaredRelationshipType {
242 relationship: String,
243 field: &'static str,
244 reference: String,
245 declared: Vec<String>,
246 },
247
248 #[error(
258 "type '{type_name}' declares {kind} with reserved key '{offending_key}' — reserved keys: [{}]",
259 reserved_keys.join(", ")
260 )]
261 ReservedSchemaKey {
262 type_name: String,
263 kind: &'static str,
264 offending_key: String,
265 reserved_keys: Vec<String>,
266 },
267
268 #[error(
274 "cross_mem_relationships[].to_schema '{value}' {reason} — expected a bare schema name (e.g. 'software', not 'software@1.0.0')"
275 )]
276 InvalidCrossMemToSchema { value: String, reason: String },
277
278 #[error("cross_mem_relationships declares duplicate to_schema '{to_schema}'")]
283 DuplicateCrossMemToSchema { to_schema: String },
284
285 #[error("sealed schema package carries no schema.yaml")]
289 SealedPackageMissingManifest,
290
291 #[error(
296 "cross_mem_relationships declares to_schema '*' but the schema declares no \
297 alias_target_rel_type — the wildcard is bound to the alias-synthesised rel-type; \
298 declare alias_target_rel_type, or name each destination schema explicitly"
299 )]
300 CrossMemWildcardWithoutAliasTarget,
301
302 #[error(
308 "cross_mem_relationships[to_schema='*'] declares rel-type '{rel_type}', but the \
309 wildcard is bound to the schema's alias_target_rel_type '{alias_target}' — \
310 hand-authored structural edges need a per-destination-schema declaration"
311 )]
312 CrossMemWildcardNonAliasRelType {
313 rel_type: String,
314 alias_target: String,
315 },
316
317 #[error(
324 "cross_mem_relationships[to_schema='{to_schema}'].definitions[name='{relationship}'].source_types references unknown type '{reference}'. Declared types: [{}]. {}",
325 declared.join(", "),
326 format_suggestion(reference, declared)
327 )]
328 UndeclaredCrossMemSourceType {
329 to_schema: String,
330 relationship: String,
331 reference: String,
332 declared: Vec<String>,
333 },
334
335 #[error(
340 "schema '{schema}' alias_target_rel_type '{target}' is not declared in relationships. Declared: [{}]. {}",
341 declared.join(", "),
342 format_suggestion(target, declared)
343 )]
344 AliasTargetRelTypeNotDeclared {
345 schema: String,
346 target: String,
347 declared: Vec<String>,
348 },
349
350 #[error(
359 "schema declares section heading(s) that cannot round-trip to their key(s): {}. \
360 Fix: make each heading derive to its key — lowercasing the heading and replacing \
361 spaces with underscores must yield the key exactly (key `current_state` ⇒ heading \
362 `Current State`)",
363 format_heading_violations(violations)
364 )]
365 SectionHeadingMismatch {
366 violations: Vec<HeadingKeyViolation>,
367 },
368 #[error(
373 "schema declares more than one last-resort type: {}. Fix: keep `last_resort: true` on \
374 exactly one type (the catch-all) and remove it from the others",
375 .types.join(", ")
376 )]
377 MultipleLastResortTypes { types: Vec<String> },
378
379 #[error(
389 "schema has {} violations:\n{}",
390 errors.len(),
391 format_multiple(errors)
392 )]
393 Multiple { errors: Vec<SchemaLoadError> },
394}
395
396fn format_multiple(errors: &[SchemaLoadError]) -> String {
397 errors
398 .iter()
399 .enumerate()
400 .map(|(i, e)| format!(" {}. {e}", i + 1))
401 .collect::<Vec<_>>()
402 .join("\n")
403}
404
405fn collapse(mut errors: Vec<SchemaLoadError>) -> SchemaLoadError {
410 debug_assert!(!errors.is_empty());
411 if errors.len() == 1 {
412 errors.remove(0)
413 } else {
414 SchemaLoadError::Multiple { errors }
415 }
416}
417
418#[derive(Debug, Clone, PartialEq, Eq)]
421pub struct HeadingKeyViolation {
422 pub type_name: String,
423 pub key: String,
424 pub heading: String,
425 pub derived_key: String,
426}
427
428fn format_heading_violations(violations: &[HeadingKeyViolation]) -> String {
429 violations
430 .iter()
431 .map(|v| {
432 format!(
433 "type '{}' section key '{}' has heading '{}' (derives to '{}')",
434 v.type_name, v.key, v.heading, v.derived_key
435 )
436 })
437 .collect::<Vec<_>>()
438 .join("; ")
439}
440
441pub fn check_section_heading_roundtrip(schema: &Schema) -> Result<(), SchemaLoadError> {
454 let mut violations = Vec::new();
455 let mut type_names: Vec<&String> = schema.types.keys().collect();
458 type_names.sort();
459 for type_name in type_names {
460 let t = &schema.types[type_name];
461 for s in &t.sections {
462 let derived_key = crate::types::derive_section_key(&s.heading);
463 if derived_key != s.key {
464 violations.push(HeadingKeyViolation {
465 type_name: type_name.clone(),
466 key: s.key.clone(),
467 heading: s.heading.clone(),
468 derived_key,
469 });
470 }
471 }
472 }
473 if violations.is_empty() {
474 Ok(())
475 } else {
476 Err(SchemaLoadError::SectionHeadingMismatch { violations })
477 }
478}
479
480pub fn reserved_section_keys() -> &'static [&'static str] {
484 &["relationships"]
485}
486
487pub fn reserved_metadata_field_keys() -> &'static [&'static str] {
495 &["type", "mem", "id"]
496}
497
498pub fn check_reserved_metadata_keys(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
511 for td in schema.types.values() {
512 for key in &td.declared_metadata_keys {
513 if reserved_metadata_field_keys().contains(&key.as_str()) {
514 return Err(SchemaLoadError::ReservedSchemaKey {
515 type_name: td.name.clone(),
516 kind: "metadata_field",
517 offending_key: key.clone(),
518 reserved_keys: reserved_metadata_field_keys()
519 .iter()
520 .map(|s| s.to_string())
521 .collect(),
522 });
523 }
524 }
525 }
526 Ok(())
527}
528
529fn format_suggestion(needle: &str, candidates: &[String]) -> String {
530 let mut best: Option<(usize, &String)> = None;
531 for cand in candidates {
532 let d = strsim::levenshtein(needle, cand);
533 match best {
534 Some((bd, _)) if bd <= d => {}
535 _ => best = Some((d, cand)),
536 }
537 }
538 match best {
539 Some((d, cand)) if d > 0 && d <= needle.len().saturating_add(3) => {
540 format!("Did you mean '{cand}'?")
541 }
542 _ => String::new(),
543 }
544}
545
546pub fn load_schema_from_dir(path: &Path) -> Result<Schema, SchemaLoadError> {
548 let manifest_path = path.join("schema.yaml");
549 let manifest_text =
550 std::fs::read_to_string(&manifest_path).map_err(|e| SchemaLoadError::Io {
551 path: manifest_path.clone(),
552 source: e,
553 })?;
554
555 let types_dir = path.join("types");
556 let mut type_files: Vec<(String, String)> = Vec::new();
557 if types_dir.is_dir() {
558 let entries = std::fs::read_dir(&types_dir).map_err(|e| SchemaLoadError::Io {
559 path: types_dir.clone(),
560 source: e,
561 })?;
562 for entry in entries {
563 let entry = entry.map_err(|e| SchemaLoadError::Io {
564 path: types_dir.clone(),
565 source: e,
566 })?;
567 let p = entry.path();
568 if p.extension().and_then(|s| s.to_str()) != Some("yaml") {
569 continue;
570 }
571 let Some(stem) = p.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
572 continue;
573 };
574 let contents = std::fs::read_to_string(&p).map_err(|e| SchemaLoadError::Io {
575 path: p.clone(),
576 source: e,
577 })?;
578 type_files.push((stem, contents));
579 }
580 }
581 type_files.sort_by(|a, b| a.0.cmp(&b.0));
584
585 load_with_context(
586 &manifest_text,
587 &type_files,
588 Some(&manifest_path),
589 Some(&types_dir),
590 MetadataPolarityFormat::RequiredOptIn,
592 )
593}
594
595#[derive(Debug, Clone, Copy, PartialEq, Eq)]
608pub enum MetadataPolarityFormat {
609 Legacy,
611 RequiredOptIn,
613}
614
615pub const SCHEMA_FORMAT_MARKER_FILE: &str = "schema-format.json";
633
634pub const SCHEMA_FORMAT_MARKER_CONTENT: &str = "{\"metadata_polarity\":\"required-opt-in\"}\n";
636
637pub fn with_format_marker(mut files: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
650 if !files
651 .iter()
652 .any(|(rel, _)| rel == SCHEMA_FORMAT_MARKER_FILE)
653 {
654 files.push((
655 SCHEMA_FORMAT_MARKER_FILE.to_string(),
656 SCHEMA_FORMAT_MARKER_CONTENT.as_bytes().to_vec(),
657 ));
658 }
659 files
660}
661
662pub fn load_schema_from_memory(
670 manifest_yaml: &str,
671 types_yamls: &[(String, String)],
672) -> Result<Schema, SchemaLoadError> {
673 load_with_context(
674 manifest_yaml,
675 types_yamls,
676 None,
677 None,
678 MetadataPolarityFormat::Legacy,
679 )
680}
681
682pub fn load_sealed_package(files: &[(String, Vec<u8>)]) -> Result<Schema, SchemaLoadError> {
701 let mut manifest: Option<String> = None;
702 let mut types: Vec<(String, String)> = Vec::new();
703 let mut marked = false;
704 for (rel, bytes) in files {
705 if rel == "schema.yaml" {
706 manifest = Some(String::from_utf8_lossy(bytes).into_owned());
707 } else if rel == SCHEMA_FORMAT_MARKER_FILE {
708 marked = true;
709 } else if let Some(stem) = rel
710 .strip_prefix("types/")
711 .and_then(|f| f.strip_suffix(".yaml"))
712 {
713 types.push((
714 stem.to_string(),
715 String::from_utf8_lossy(bytes).into_owned(),
716 ));
717 }
718 }
719 let manifest = manifest.ok_or(SchemaLoadError::SealedPackageMissingManifest)?;
720 types.sort_by(|a, b| a.0.cmp(&b.0));
723 let format = if marked {
724 MetadataPolarityFormat::RequiredOptIn
725 } else {
726 MetadataPolarityFormat::Legacy
727 };
728 load_with_context(&manifest, &types, None, None, format)
729}
730
731pub fn check_package_reauthorable(
741 manifest_yaml: &str,
742 types_yamls: &[(String, String)],
743) -> Result<(), SchemaLoadError> {
744 load_authoring_package_from_memory(manifest_yaml, types_yamls).map(|_| ())
745}
746
747pub fn load_authoring_package_from_memory(
754 manifest_yaml: &str,
755 types_yamls: &[(String, String)],
756) -> Result<Schema, SchemaLoadError> {
757 let strict_context = Path::new("<authoring package>");
761 load_with_context(
762 manifest_yaml,
763 types_yamls,
764 Some(strict_context),
765 Some(strict_context),
766 MetadataPolarityFormat::RequiredOptIn,
767 )
768}
769
770pub fn load_schema_from_memory_with_format(
774 manifest_yaml: &str,
775 types_yamls: &[(String, String)],
776 format: MetadataPolarityFormat,
777) -> Result<Schema, SchemaLoadError> {
778 load_with_context(manifest_yaml, types_yamls, None, None, format)
779}
780
781fn load_with_context(
782 manifest_yaml: &str,
783 types_yamls: &[(String, String)],
784 manifest_path: Option<&Path>,
785 types_dir: Option<&Path>,
786 format: MetadataPolarityFormat,
787) -> Result<Schema, SchemaLoadError> {
788 let mut errors: Vec<SchemaLoadError> = Vec::new();
797
798 let mut manifest: SchemaManifest =
799 serde_yaml_ng::from_str(manifest_yaml).map_err(|e| SchemaLoadError::ParseManifest {
800 path: manifest_path
801 .map(Path::to_path_buf)
802 .unwrap_or_else(|| PathBuf::from("<memory>")),
803 source: e,
804 })?;
805
806 if let Err(e) = validate_name(&manifest.name) {
807 errors.push(e);
808 }
809
810 let version = match semver::Version::parse(&manifest.version) {
814 Ok(v) => Some(v),
815 Err(_) => {
816 errors.push(SchemaLoadError::InvalidVersion {
817 value: manifest.version.clone(),
818 });
819 None
820 }
821 };
822
823 let mut rel_names: HashSet<String> = HashSet::new();
825 for def in &manifest.relationships.definitions {
826 if !rel_names.insert(def.name.clone()) {
827 errors.push(SchemaLoadError::DuplicateRelationship {
828 name: def.name.clone(),
829 });
830 }
831 }
832 if !rel_names.contains("_default") {
833 errors.push(SchemaLoadError::MissingDefaultWeight);
834 }
835 let available_rels: Vec<String> = manifest
836 .relationships
837 .definitions
838 .iter()
839 .map(|d| d.name.clone())
840 .collect();
841
842 if let Some(target) = &manifest.alias_target_rel_type
847 && !rel_names.contains(target)
848 {
849 let mut declared = available_rels.clone();
850 declared.sort();
851 errors.push(SchemaLoadError::AliasTargetRelTypeNotDeclared {
852 schema: manifest.name.clone(),
853 target: target.clone(),
854 declared,
855 });
856 }
857
858 let mut acyclic_set_member_seen: HashSet<&str> = HashSet::new();
864 for set in &manifest.relationships.acyclic_sets {
865 if set.len() < 2 {
866 errors.push(SchemaLoadError::InvalidAcyclicSet {
867 offender: set.join(", "),
868 reason: "a set needs at least two rel-types (a single member is the \
869 per-definition `acyclic` flag)"
870 .to_string(),
871 });
872 }
873 for name in set {
874 if !rel_names.contains(name.as_str()) {
875 errors.push(SchemaLoadError::InvalidAcyclicSet {
876 offender: name.clone(),
877 reason: "names no declared relationship".to_string(),
878 });
879 }
880 if !acyclic_set_member_seen.insert(name.as_str()) {
881 errors.push(SchemaLoadError::InvalidAcyclicSet {
882 offender: name.clone(),
883 reason: "a rel-type may appear in at most one acyclicity set".to_string(),
884 });
885 }
886 }
887 }
888
889 if let Some(lab) = &manifest.relationships.labelling {
894 if lab.attack.is_empty() {
895 errors.push(SchemaLoadError::InvalidLabelling {
896 offender: "(empty)".to_string(),
897 reason: "`labelling.attack` must name at least one rel-type".to_string(),
898 });
899 }
900 for name in &lab.attack {
901 if !rel_names.contains(name.as_str()) {
902 errors.push(SchemaLoadError::InvalidLabelling {
903 offender: name.clone(),
904 reason: "`labelling.attack` entry names no declared relationship".to_string(),
905 });
906 }
907 }
908 if let Some(sup) = &lab.support {
909 if sup.relationships.is_empty() {
910 errors.push(SchemaLoadError::InvalidLabelling {
911 offender: "(empty)".to_string(),
912 reason: "`labelling.support.relationships` must name at least one rel-type"
913 .to_string(),
914 });
915 }
916 for name in &sup.relationships {
917 if !rel_names.contains(name.as_str()) {
918 errors.push(SchemaLoadError::InvalidLabelling {
919 offender: name.clone(),
920 reason: "`labelling.support.relationships` entry names no declared \
921 relationship"
922 .to_string(),
923 });
924 }
925 }
926 }
927 }
928
929 if let Some(pointer) = manifest.alias_target_rel_type.clone() {
948 for def in &mut manifest.relationships.definitions {
949 if def.name == pointer {
950 def.manual_authoring = crate::manifest::ManualAuthoring::Forbidden;
951 }
952 }
953 }
954
955 for def in &manifest.relationships.definitions {
960 for t in &def.source_types {
961 if !manifest.types.iter().any(|d| d == t) {
962 errors.push(SchemaLoadError::UndeclaredRelationshipType {
963 relationship: def.name.clone(),
964 field: "source_types",
965 reference: t.clone(),
966 declared: manifest.types.clone(),
967 });
968 }
969 }
970 for t in &def.target_types {
971 if !manifest.types.iter().any(|d| d == t) {
972 errors.push(SchemaLoadError::UndeclaredRelationshipType {
973 relationship: def.name.clone(),
974 field: "target_types",
975 reference: t.clone(),
976 declared: manifest.types.clone(),
977 });
978 }
979 }
980 }
981
982 let mut seen_to_schemas: HashSet<String> = HashSet::new();
993 for entry in &manifest.cross_mem_relationships {
994 if entry.to_schema == "*" {
995 match manifest.alias_target_rel_type.as_deref() {
1002 None => errors.push(SchemaLoadError::CrossMemWildcardWithoutAliasTarget),
1003 Some(alias) => {
1004 for def in &entry.definitions {
1005 if def.name != alias {
1006 errors.push(SchemaLoadError::CrossMemWildcardNonAliasRelType {
1007 rel_type: def.name.clone(),
1008 alias_target: alias.to_string(),
1009 });
1010 }
1011 }
1012 }
1013 }
1014 } else if entry.to_schema.contains('@') {
1015 errors.push(SchemaLoadError::InvalidCrossMemToSchema {
1016 value: entry.to_schema.clone(),
1017 reason: "must not carry a version or range".into(),
1018 });
1019 } else if let Err(reason) = name_shape(&entry.to_schema) {
1020 errors.push(SchemaLoadError::InvalidCrossMemToSchema {
1021 value: entry.to_schema.clone(),
1022 reason: reason.into(),
1023 });
1024 }
1025 if !seen_to_schemas.insert(entry.to_schema.clone()) {
1026 errors.push(SchemaLoadError::DuplicateCrossMemToSchema {
1027 to_schema: entry.to_schema.clone(),
1028 });
1029 }
1030 for def in &entry.definitions {
1031 for t in &def.source_types {
1032 if !manifest.types.iter().any(|d| d == t) {
1033 errors.push(SchemaLoadError::UndeclaredCrossMemSourceType {
1034 to_schema: entry.to_schema.clone(),
1035 relationship: def.name.clone(),
1036 reference: t.clone(),
1037 declared: manifest.types.clone(),
1038 });
1039 }
1040 }
1041 }
1042 }
1043
1044 let mut found_stems: Vec<String> = types_yamls.iter().map(|(s, _)| s.clone()).collect();
1046 found_stems.sort();
1047 let mut declared = manifest.types.clone();
1048 declared.sort();
1049 if found_stems != declared {
1050 errors.push(SchemaLoadError::TypeFileMismatch {
1054 declared,
1055 found: found_stems,
1056 });
1057 return Err(collapse(errors));
1058 }
1059
1060 let defaults: IndexMap<String, f32> = manifest
1062 .relationships
1063 .definitions
1064 .iter()
1065 .map(|d| (d.name.clone(), d.default_weight))
1066 .collect();
1067
1068 let mut types_map: HashMap<String, Arc<TypeDefinition>> = HashMap::new();
1069 let mut had_type_parse_failure = false;
1070
1071 for (stem, text) in types_yamls {
1072 let type_path = types_dir
1073 .map(|d| d.join(format!("{stem}.yaml")))
1074 .unwrap_or_else(|| PathBuf::from(format!("<memory>/{stem}.yaml")));
1075
1076 let mut td: TypeDefinition = match serde_yaml_ng::from_str(text) {
1077 Ok(td) => td,
1078 Err(e) => {
1079 errors.push(SchemaLoadError::ParseType {
1084 path: type_path.clone(),
1085 source: e,
1086 });
1087 had_type_parse_failure = true;
1088 continue;
1089 }
1090 };
1091
1092 if td.name != *stem {
1093 errors.push(SchemaLoadError::TypeNameMismatch {
1094 file: stem.clone(),
1095 declared: td.name.clone(),
1096 });
1097 }
1098
1099 if let Some(legacy) = td.legacy_propagating_relationships.take() {
1107 if types_dir.is_some() {
1108 errors.push(SchemaLoadError::PropagatingRelationshipsRenamed {
1109 type_name: td.name.clone(),
1110 });
1111 } else if td.no_self_loop_relationships.is_empty() {
1112 td.no_self_loop_relationships = legacy;
1113 }
1114 }
1115
1116 if td.legacy_examples.take().is_some() && types_dir.is_some() {
1122 errors.push(SchemaLoadError::ExamplesRetired {
1123 type_name: td.name.clone(),
1124 });
1125 }
1126
1127 if let Some(ex) = td.exemplar.as_mut() {
1136 let mut retired_spelling = false;
1137 let mut incomplete = false;
1138 for rel in &mut ex.relations {
1139 let legacy_to = rel.legacy_to.take();
1140 let legacy_type = rel.legacy_type.take();
1141 if legacy_to.is_some() || legacy_type.is_some() {
1142 if types_dir.is_some() {
1143 retired_spelling = true;
1144 continue;
1145 }
1146 if rel.target.is_none() {
1147 rel.target = legacy_to;
1148 }
1149 if rel.rel_type.is_none() {
1150 rel.rel_type = legacy_type;
1151 }
1152 }
1153 if rel.target.is_none() || rel.rel_type.is_none() {
1154 incomplete = true;
1155 }
1156 }
1157 if retired_spelling {
1158 errors.push(SchemaLoadError::ExemplarRelationSpellingRetired {
1159 type_name: td.name.clone(),
1160 });
1161 }
1162 if incomplete {
1163 errors.push(SchemaLoadError::ExemplarRelationIncomplete {
1164 type_name: td.name.clone(),
1165 });
1166 }
1167 }
1168
1169 for field in &mut td.metadata_fields {
1176 if matches!(format, MetadataPolarityFormat::RequiredOptIn)
1180 && field.legacy_optional.is_some()
1181 {
1182 errors.push(SchemaLoadError::OptionalRetired {
1183 type_name: td.name.clone(),
1184 field: field.key.clone(),
1185 });
1186 }
1187 field.required_resolved = match (field.required, field.legacy_optional.take()) {
1188 (Some(required), _) => required,
1189 (None, Some(optional)) => !optional,
1190 (None, None) => matches!(format, MetadataPolarityFormat::Legacy),
1191 };
1192 }
1193
1194 td.declared_metadata_keys = td.metadata_fields.iter().map(|f| f.key.clone()).collect();
1204
1205 for field in &td.metadata_fields {
1210 if base_metadata::is_base_key(&field.key)
1211 && !reserved_metadata_field_keys().contains(&field.key.as_str())
1212 {
1213 errors.push(SchemaLoadError::RedeclaredBaseField {
1214 type_name: td.name.clone(),
1215 field: field.key.clone(),
1216 });
1217 }
1218 }
1219
1220 let mut merged = base_metadata::prefix_fields();
1223 merged.append(&mut td.metadata_fields);
1224 merged.extend(base_metadata::suffix_fields());
1225 td.metadata_fields = merged;
1226
1227 compile_section_formats(&mut td);
1228 validate_type(&td, &rel_names, &available_rels, &mut errors);
1229
1230 let mut weights = defaults.clone();
1232 for (k, v) in &td.edge_weight_overrides {
1233 weights.insert(k.clone(), *v);
1234 }
1235 td.edge_weights = weights;
1236
1237 types_map.insert(stem.clone(), Arc::new(td));
1238 }
1239
1240 if !had_type_parse_failure {
1250 let mut last_resort: Vec<String> = types_map
1251 .values()
1252 .filter(|t| t.last_resort)
1253 .map(|t| t.name.clone())
1254 .collect();
1255 if last_resort.len() > 1 {
1256 last_resort.sort();
1257 errors.push(SchemaLoadError::MultipleLastResortTypes { types: last_resort });
1258 }
1259 }
1260 if !had_type_parse_failure {
1261 let all_section_keys: HashSet<&str> = types_map
1262 .values()
1263 .flat_map(|t| t.sections.iter().map(|s| s.key.as_str()))
1264 .collect();
1265 let mut type_names: Vec<&String> = types_map.keys().collect();
1268 type_names.sort();
1269 for type_name in type_names {
1270 let td = &types_map[type_name];
1271 for c in &td.constraints {
1272 if let crate::types::ConstraintDef::EnumFromNeighbour { section, .. } = c
1273 && !all_section_keys.contains(section.as_str())
1274 {
1275 errors.push(SchemaLoadError::InvalidConstraint {
1276 type_name: td.name.clone(),
1277 kind: "enum_from_neighbour",
1278 offender: section.clone(),
1279 reason: "`section` names a section key no type of this schema declares"
1280 .to_string(),
1281 });
1282 }
1283 }
1284 for ob in &td.must_reach {
1287 for t in &ob.terminal_types {
1288 if !types_map.contains_key(t.as_str()) {
1289 errors.push(SchemaLoadError::InvalidConstraint {
1290 type_name: td.name.clone(),
1291 kind: "must_reach",
1292 offender: t.clone(),
1293 reason: "`terminal_types` entry names no type of this schema"
1294 .to_string(),
1295 });
1296 }
1297 }
1298 }
1299 for sig in &td.signals {
1305 if let (Some(field), Some(value)) = (&sig.neighbour_field, &sig.neighbour_value) {
1306 let declaring: Vec<&crate::types::MetadataFieldDef> = types_map
1307 .values()
1308 .flat_map(|t| t.metadata_fields.iter())
1309 .filter(|f| f.key == *field && f.enum_values.is_some())
1310 .collect();
1311 if declaring.is_empty() {
1312 errors.push(SchemaLoadError::InvalidConstraint {
1313 type_name: td.name.clone(),
1314 kind: "signal",
1315 offender: field.clone(),
1316 reason: "`neighbour_field` is declared with `enum_values` on no \
1317 type of this schema"
1318 .to_string(),
1319 });
1320 } else if !declaring.iter().any(|f| {
1321 f.enum_values
1322 .as_ref()
1323 .is_some_and(|allowed| allowed.contains(value))
1324 }) {
1325 errors.push(SchemaLoadError::InvalidConstraint {
1326 type_name: td.name.clone(),
1327 kind: "signal",
1328 offender: value.clone(),
1329 reason: format!(
1330 "`neighbour_value` is outside `{field}`'s enum_values on every \
1331 declaring type"
1332 ),
1333 });
1334 }
1335 }
1336 }
1337 }
1338 }
1339
1340 if !had_type_parse_failure
1345 && let Some(lab) = &manifest.relationships.labelling
1346 && let Some(sup) = &lab.support
1347 {
1348 for t in &sup.terminal_types {
1349 if !types_map.contains_key(t.as_str()) {
1350 errors.push(SchemaLoadError::InvalidLabelling {
1351 offender: t.clone(),
1352 reason: "`labelling.support.terminal_types` entry names no type of this \
1353 schema"
1354 .to_string(),
1355 });
1356 }
1357 }
1358 }
1359
1360 if !errors.is_empty() {
1361 return Err(collapse(errors));
1362 }
1363
1364 Ok(Schema {
1365 manifest,
1366 version: version.expect("version parse failure would have accumulated an error"),
1367 types: types_map,
1368 })
1369}
1370
1371fn validate_name(name: &str) -> Result<(), SchemaLoadError> {
1372 name_shape(name).map_err(|reason| SchemaLoadError::InvalidName {
1373 value: name.into(),
1374 reason,
1375 })
1376}
1377
1378pub fn validate_schema_name(name: &str) -> Result<(), &'static str> {
1384 name_shape(name)
1385}
1386
1387fn name_shape(name: &str) -> Result<(), &'static str> {
1392 if name.is_empty() {
1393 return Err("must not be empty");
1394 }
1395 let mut chars = name.chars();
1396 let first = chars.next().unwrap();
1397 if !first.is_ascii_lowercase() {
1398 return Err("must start with a lowercase letter");
1399 }
1400 for c in chars {
1401 if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
1402 return Err("must contain only lowercase letters, digits, and hyphens");
1403 }
1404 }
1405 Ok(())
1406}
1407
1408fn compile_section_formats(td: &mut TypeDefinition) {
1416 use crate::content_expr::ContentExpr;
1417 for section in &mut td.sections {
1418 let declares_any = section.content.is_some()
1424 || section.item_pattern.is_some()
1425 || section.table.is_some()
1426 || section.example.is_some()
1427 || section.format_severity != crate::types::ConstraintSeverity::Block;
1428 if !declares_any {
1429 continue;
1430 }
1431 let mut problems: Vec<String> = Vec::new();
1432
1433 let compiled = match §ion.content {
1434 None => {
1435 problems.push(
1436 "`item_pattern` / `table` / `example` require a `content` declaration"
1437 .to_string(),
1438 );
1439 None
1440 }
1441 Some(expr_src) => match ContentExpr::parse(expr_src) {
1442 Ok(expr) => Some(expr),
1443 Err(e) => {
1444 problems.push(format!("`content` is invalid: {e}"));
1445 None
1446 }
1447 },
1448 };
1449
1450 if let Some(pattern) = §ion.item_pattern {
1451 if let Err(e) = regex::Regex::new(pattern) {
1452 problems.push(format!("`item_pattern` is not a valid regex: {e}"));
1453 }
1454 if let Some(expr) = &compiled {
1455 let names = expr.mentioned_names();
1456 let has_list = names.contains(&"list");
1457 let has_paragraph = names.contains(&"paragraph");
1458 if has_list == has_paragraph {
1459 problems.push(
1460 "`item_pattern` requires a `content` expression containing exactly one of `list` / `paragraph` (tables use `column_patterns`)"
1461 .to_string(),
1462 );
1463 }
1464 }
1465 }
1466
1467 if let Some(table) = §ion.table {
1468 if let Some(expr) = &compiled
1469 && !expr.mentioned_names().contains(&"table")
1470 {
1471 problems.push(
1472 "`table` block is only legal when `content` contains `table`".to_string(),
1473 );
1474 }
1475 if table.columns.is_empty() {
1476 problems.push("`table.columns` must name at least one column".to_string());
1477 }
1478 for (column, pattern) in &table.column_patterns {
1479 if !table.columns.contains(column) {
1480 problems.push(format!(
1481 "`column_patterns` names '{column}', which is not in `columns`"
1482 ));
1483 }
1484 if let Err(e) = regex::Regex::new(pattern) {
1485 problems.push(format!(
1486 "`column_patterns.{column}` is not a valid regex: {e}"
1487 ));
1488 }
1489 }
1490 }
1491
1492 if problems.is_empty() {
1493 section.compiled_content = compiled;
1494 } else {
1495 section.format_problems = problems;
1496 }
1497 }
1498}
1499
1500pub fn check_section_formats(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
1508 let mut first: Option<(String, String)> = None;
1513 let mut problems: Vec<String> = Vec::new();
1514 for td in schema.types.values() {
1515 for section in &td.sections {
1516 if section.format_problems.is_empty() {
1517 continue;
1518 }
1519 if first.is_none() {
1520 first = Some((td.name.clone(), section.key.clone()));
1521 problems.extend(section.format_problems.iter().cloned());
1522 } else {
1523 problems.extend(
1524 section
1525 .format_problems
1526 .iter()
1527 .map(|p| format!("[{}.{}] {p}", td.name, section.key)),
1528 );
1529 }
1530 }
1531 }
1532 match first {
1533 Some((type_name, section)) => Err(SchemaLoadError::InvalidSectionFormat {
1534 type_name,
1535 section,
1536 problems,
1537 }),
1538 None => Ok(()),
1539 }
1540}
1541
1542fn validate_type(
1543 td: &TypeDefinition,
1544 rel_names: &HashSet<String>,
1545 available_rels: &[String],
1546 errors: &mut Vec<SchemaLoadError>,
1547) {
1548 for section in &td.sections {
1556 if reserved_section_keys().contains(§ion.key.as_str()) {
1557 errors.push(SchemaLoadError::ReservedSchemaKey {
1558 type_name: td.name.clone(),
1559 kind: "section",
1560 offending_key: section.key.clone(),
1561 reserved_keys: reserved_section_keys()
1562 .iter()
1563 .map(|s| s.to_string())
1564 .collect(),
1565 });
1566 }
1567 }
1568
1569 if let Err(e) = check_rel(
1570 &td.name,
1571 "hierarchy_relationship",
1572 &td.hierarchy_relationship,
1573 rel_names,
1574 available_rels,
1575 ) {
1576 errors.push(e);
1577 }
1578 for r in &td.no_self_loop_relationships {
1579 if let Err(e) = check_rel(
1580 &td.name,
1581 "no_self_loop_relationships",
1582 r,
1583 rel_names,
1584 available_rels,
1585 ) {
1586 errors.push(e);
1587 }
1588 }
1589 for r in td.edge_weight_overrides.keys() {
1590 if let Err(e) = check_rel(
1591 &td.name,
1592 "edge_weight_overrides",
1593 r,
1594 rel_names,
1595 available_rels,
1596 ) {
1597 errors.push(e);
1598 }
1599 }
1600 for block in &td.required_outgoing {
1601 for r in &block.relationships {
1602 if let Err(e) = check_rel(&td.name, "required_outgoing", r, rel_names, available_rels) {
1603 errors.push(e);
1604 }
1605 }
1606 match (&block.when_field, &block.when_value) {
1614 (None, None) => {}
1615 (Some(f), None) => {
1616 errors.push(SchemaLoadError::InvalidConstraint {
1617 type_name: td.name.clone(),
1618 kind: "required_outgoing",
1619 offender: f.clone(),
1620 reason: "`when_field` requires `when_value` alongside it".to_string(),
1621 });
1622 }
1623 (None, Some(v)) => {
1624 errors.push(SchemaLoadError::InvalidConstraint {
1625 type_name: td.name.clone(),
1626 kind: "required_outgoing",
1627 offender: v.clone(),
1628 reason: "`when_value` requires `when_field` alongside it".to_string(),
1629 });
1630 }
1631 (Some(f), Some(v)) => match td.metadata_fields.iter().find(|mf| mf.key == *f) {
1632 None => {
1633 errors.push(SchemaLoadError::InvalidConstraint {
1634 type_name: td.name.clone(),
1635 kind: "required_outgoing",
1636 offender: f.clone(),
1637 reason: "`when_field` names no metadata field of this type".to_string(),
1638 });
1639 }
1640 Some(when_def) => match &when_def.enum_values {
1641 None => {
1642 errors.push(SchemaLoadError::InvalidConstraint {
1643 type_name: td.name.clone(),
1644 kind: "required_outgoing",
1645 offender: f.clone(),
1646 reason: format!(
1647 "`when_field` must name a metadata field with `enum_values`; `{f}` declares none"
1648 ),
1649 });
1650 }
1651 Some(allowed) if !allowed.contains(v) => {
1652 errors.push(SchemaLoadError::InvalidConstraint {
1653 type_name: td.name.clone(),
1654 kind: "required_outgoing",
1655 offender: v.clone(),
1656 reason: format!(
1657 "`when_value` is not in `{f}`'s enum_values [{}]",
1658 allowed.join(", ")
1659 ),
1660 });
1661 }
1662 Some(_) => {}
1663 },
1664 },
1665 }
1666 }
1667
1668 for ob in &td.must_reach {
1671 if ob.relationships.is_empty() {
1672 errors.push(SchemaLoadError::InvalidConstraint {
1673 type_name: td.name.clone(),
1674 kind: "must_reach",
1675 offender: "(empty)".to_string(),
1676 reason: "`relationships` must name at least one relationship".to_string(),
1677 });
1678 }
1679 for r in &ob.relationships {
1680 if let Err(e) = check_rel(&td.name, "must_reach", r, rel_names, available_rels) {
1681 errors.push(e);
1682 }
1683 }
1684 if ob.terminal_types.is_empty() {
1685 errors.push(SchemaLoadError::InvalidConstraint {
1686 type_name: td.name.clone(),
1687 kind: "must_reach",
1688 offender: "(empty)".to_string(),
1689 reason: "`terminal_types` must name at least one type".to_string(),
1690 });
1691 }
1692 if ob.max_depth == Some(0) {
1693 errors.push(SchemaLoadError::InvalidConstraint {
1694 type_name: td.name.clone(),
1695 kind: "must_reach",
1696 offender: "0".to_string(),
1697 reason: "`max_depth` must be at least 1 — nothing is reachable in zero hops"
1698 .to_string(),
1699 });
1700 }
1701 if ob.severity == crate::types::ConstraintSeverity::Block {
1702 errors.push(SchemaLoadError::InvalidConstraint {
1707 type_name: td.name.clone(),
1708 kind: "must_reach",
1709 offender: "block".to_string(),
1710 reason: "must_reach is always warn-tier — a reachability gap is created by \
1711 writes on other entities, so no single write can be refused for it"
1712 .to_string(),
1713 });
1714 }
1715 }
1716
1717 let mut signal_names_seen: HashSet<&str> = HashSet::new();
1721 for sig in &td.signals {
1722 let name_ok = sig
1723 .name
1724 .chars()
1725 .next()
1726 .is_some_and(|c| c.is_ascii_lowercase())
1727 && sig
1728 .name
1729 .chars()
1730 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
1731 if !name_ok {
1732 errors.push(SchemaLoadError::InvalidConstraint {
1733 type_name: td.name.clone(),
1734 kind: "signal",
1735 offender: sig.name.clone(),
1736 reason: "`name` must match [a-z][a-z0-9_]*".to_string(),
1737 });
1738 }
1739 if !signal_names_seen.insert(sig.name.as_str()) {
1740 errors.push(SchemaLoadError::InvalidConstraint {
1741 type_name: td.name.clone(),
1742 kind: "signal",
1743 offender: sig.name.clone(),
1744 reason: "duplicate signal name on this type".to_string(),
1745 });
1746 }
1747 if sig.relationships.is_empty() {
1748 errors.push(SchemaLoadError::InvalidConstraint {
1749 type_name: td.name.clone(),
1750 kind: "signal",
1751 offender: sig.name.clone(),
1752 reason: "`relationships` must name at least one relationship".to_string(),
1753 });
1754 }
1755 for r in &sig.relationships {
1756 if let Err(e) = check_rel(&td.name, "signals", r, rel_names, available_rels) {
1757 errors.push(e);
1758 }
1759 }
1760 if sig.thresholds.is_empty() {
1761 errors.push(SchemaLoadError::InvalidConstraint {
1762 type_name: td.name.clone(),
1763 kind: "signal",
1764 offender: sig.name.clone(),
1765 reason: "`thresholds` must declare at least one step".to_string(),
1766 });
1767 }
1768 for pair in sig.thresholds.windows(2) {
1769 if pair[1].at_least <= pair[0].at_least {
1770 errors.push(SchemaLoadError::InvalidConstraint {
1771 type_name: td.name.clone(),
1772 kind: "signal",
1773 offender: pair[1].at_least.to_string(),
1774 reason: "`thresholds` must have strictly increasing `at_least` values"
1775 .to_string(),
1776 });
1777 }
1778 }
1779 match (&sig.neighbour_field, &sig.neighbour_value) {
1780 (None, None) | (Some(_), Some(_)) => {}
1781 (Some(f), None) => {
1782 errors.push(SchemaLoadError::InvalidConstraint {
1783 type_name: td.name.clone(),
1784 kind: "signal",
1785 offender: f.clone(),
1786 reason: "`neighbour_field` requires `neighbour_value` alongside it".to_string(),
1787 });
1788 }
1789 (None, Some(v)) => {
1790 errors.push(SchemaLoadError::InvalidConstraint {
1791 type_name: td.name.clone(),
1792 kind: "signal",
1793 offender: v.clone(),
1794 reason: "`neighbour_value` requires `neighbour_field` alongside it".to_string(),
1795 });
1796 }
1797 }
1798 }
1799
1800 let field_keys: std::collections::HashSet<&str> =
1804 td.metadata_fields.iter().map(|f| f.key.as_str()).collect();
1805 let section_keys: std::collections::HashSet<&str> =
1806 td.sections.iter().map(|sec| sec.key.as_str()).collect();
1807 for c in &td.constraints {
1808 match c {
1809 crate::types::ConstraintDef::RequiresWhen {
1810 field,
1811 when_field,
1812 when_value,
1813 ..
1814 } => {
1815 if !field_keys.contains(field.as_str()) && !section_keys.contains(field.as_str()) {
1816 errors.push(SchemaLoadError::InvalidConstraint {
1817 type_name: td.name.clone(),
1818 kind: "requires_when",
1819 offender: field.clone(),
1820 reason: "`field` names neither a metadata field nor a section of this type"
1821 .to_string(),
1822 });
1823 }
1824 let Some(when_def) = td.metadata_fields.iter().find(|f| f.key == *when_field)
1825 else {
1826 errors.push(SchemaLoadError::InvalidConstraint {
1827 type_name: td.name.clone(),
1828 kind: "requires_when",
1829 offender: when_field.clone(),
1830 reason: "`when_field` names no metadata field of this type".to_string(),
1831 });
1832 continue;
1833 };
1834 if let Some(allowed) = &when_def.enum_values
1835 && !allowed.contains(when_value)
1836 {
1837 errors.push(SchemaLoadError::InvalidConstraint {
1838 type_name: td.name.clone(),
1839 kind: "requires_when",
1840 offender: when_value.clone(),
1841 reason: format!(
1842 "`when_value` is not in `{when_field}`'s enum_values [{}]",
1843 allowed.join(", ")
1844 ),
1845 });
1846 }
1847 }
1848 crate::types::ConstraintDef::Unique { fields, .. } => {
1849 if fields.is_empty() {
1850 errors.push(SchemaLoadError::InvalidConstraint {
1851 type_name: td.name.clone(),
1852 kind: "unique",
1853 offender: "(empty)".to_string(),
1854 reason: "`fields` must name at least one metadata field".to_string(),
1855 });
1856 }
1857 for f in fields {
1858 if !field_keys.contains(f.as_str()) {
1859 errors.push(SchemaLoadError::InvalidConstraint {
1860 type_name: td.name.clone(),
1861 kind: "unique",
1862 offender: f.clone(),
1863 reason: "`fields` entry names no metadata field of this type"
1864 .to_string(),
1865 });
1866 }
1867 }
1868 }
1869 crate::types::ConstraintDef::EnumFromNeighbour {
1870 field, rel_type, ..
1871 } => {
1872 if !field_keys.contains(field.as_str()) {
1873 errors.push(SchemaLoadError::InvalidConstraint {
1874 type_name: td.name.clone(),
1875 kind: "enum_from_neighbour",
1876 offender: field.clone(),
1877 reason: "`field` names no metadata field of this type".to_string(),
1878 });
1879 }
1880 if !rel_names.contains(rel_type) {
1881 errors.push(SchemaLoadError::InvalidConstraint {
1882 type_name: td.name.clone(),
1883 kind: "enum_from_neighbour",
1884 offender: rel_type.clone(),
1885 reason: "`rel_type` is not in the schema's relationship vocabulary"
1886 .to_string(),
1887 });
1888 }
1889 }
1893 crate::types::ConstraintDef::TransitionRequiresChecks {
1894 field,
1895 to_value,
1896 relationships,
1897 ..
1898 } => {
1899 match td.metadata_fields.iter().find(|f| f.key == *field) {
1900 None => {
1901 errors.push(SchemaLoadError::InvalidConstraint {
1902 type_name: td.name.clone(),
1903 kind: "transition_requires_checks",
1904 offender: field.clone(),
1905 reason: "`field` names no metadata field of this type".to_string(),
1906 });
1907 }
1908 Some(field_def) => {
1909 if let Some(allowed) = &field_def.enum_values
1910 && !allowed.contains(to_value)
1911 {
1912 errors.push(SchemaLoadError::InvalidConstraint {
1913 type_name: td.name.clone(),
1914 kind: "transition_requires_checks",
1915 offender: to_value.clone(),
1916 reason: format!(
1917 "`to_value` is not in `{field}`'s enum_values [{}]",
1918 allowed.join(", ")
1919 ),
1920 });
1921 }
1922 }
1923 }
1924 if relationships.is_empty() {
1925 errors.push(SchemaLoadError::InvalidConstraint {
1926 type_name: td.name.clone(),
1927 kind: "transition_requires_checks",
1928 offender: "(empty)".to_string(),
1929 reason: "`relationships` must name at least one declared relationship"
1930 .to_string(),
1931 });
1932 }
1933 for rel in relationships {
1934 if !rel_names.contains(rel) {
1935 errors.push(SchemaLoadError::InvalidConstraint {
1936 type_name: td.name.clone(),
1937 kind: "transition_requires_checks",
1938 offender: rel.clone(),
1939 reason: "`relationships` entry is not in the schema's relationship \
1940 vocabulary"
1941 .to_string(),
1942 });
1943 }
1944 }
1945 }
1946 crate::types::ConstraintDef::StatusPropagation {
1947 field,
1948 value,
1949 rel_type,
1950 rel_types,
1951 severity,
1952 ..
1953 } => {
1954 match td.metadata_fields.iter().find(|f| f.key == *field) {
1955 None => {
1956 errors.push(SchemaLoadError::InvalidConstraint {
1957 type_name: td.name.clone(),
1958 kind: "status_propagation",
1959 offender: field.clone(),
1960 reason: "`field` names no metadata field of this type".to_string(),
1961 });
1962 }
1963 Some(field_def) => {
1964 if let Some(allowed) = &field_def.enum_values
1965 && !allowed.contains(value)
1966 {
1967 errors.push(SchemaLoadError::InvalidConstraint {
1968 type_name: td.name.clone(),
1969 kind: "status_propagation",
1970 offender: value.clone(),
1971 reason: format!(
1972 "`value` is not in `{field}`'s enum_values [{}]",
1973 allowed.join(", ")
1974 ),
1975 });
1976 }
1977 }
1978 }
1979 match (rel_type, rel_types) {
1982 (Some(_), Some(_)) => {
1983 errors.push(SchemaLoadError::InvalidConstraint {
1984 type_name: td.name.clone(),
1985 kind: "status_propagation",
1986 offender: "rel_type".to_string(),
1987 reason: "declare `rel_type` or `rel_types`, not both".to_string(),
1988 });
1989 }
1990 (None, None) => {
1991 errors.push(SchemaLoadError::InvalidConstraint {
1992 type_name: td.name.clone(),
1993 kind: "status_propagation",
1994 offender: "(missing)".to_string(),
1995 reason: "one of `rel_type` / `rel_types` is required".to_string(),
1996 });
1997 }
1998 (Some(single), None) => {
1999 if !rel_names.contains(single) {
2000 errors.push(SchemaLoadError::InvalidConstraint {
2001 type_name: td.name.clone(),
2002 kind: "status_propagation",
2003 offender: single.clone(),
2004 reason: "`rel_type` is not in the schema's relationship vocabulary"
2005 .to_string(),
2006 });
2007 }
2008 }
2009 (None, Some(set)) => {
2010 if set.is_empty() {
2011 errors.push(SchemaLoadError::InvalidConstraint {
2012 type_name: td.name.clone(),
2013 kind: "status_propagation",
2014 offender: "(empty)".to_string(),
2015 reason: "`rel_types` must name at least one relationship"
2016 .to_string(),
2017 });
2018 }
2019 for name in set {
2020 if !rel_names.contains(name) {
2021 errors.push(SchemaLoadError::InvalidConstraint {
2022 type_name: td.name.clone(),
2023 kind: "status_propagation",
2024 offender: name.clone(),
2025 reason: "`rel_types` entry is not in the schema's \
2026 relationship vocabulary"
2027 .to_string(),
2028 });
2029 }
2030 }
2031 }
2032 }
2033 if *severity == crate::types::ConstraintSeverity::Block {
2034 errors.push(SchemaLoadError::InvalidConstraint {
2040 type_name: td.name.clone(),
2041 kind: "status_propagation",
2042 offender: "block".to_string(),
2043 reason: "status_propagation is always warn-tier — a parent falling after \
2044 the child was written cannot retroactively make the child's \
2045 write illegal"
2046 .to_string(),
2047 });
2048 }
2049 }
2050 }
2051 }
2052
2053 if let Some(due) = &td.due {
2056 match td.metadata_fields.iter().find(|f| f.key == due.date_field) {
2057 None => errors.push(SchemaLoadError::InvalidDueAxis {
2058 type_name: td.name.clone(),
2059 offender: due.date_field.clone(),
2060 reason: "`date_field` names no metadata field of this type".to_string(),
2061 }),
2062 Some(f) if f.field_type != crate::types::FieldType::Date => {
2063 errors.push(SchemaLoadError::InvalidDueAxis {
2064 type_name: td.name.clone(),
2065 offender: due.date_field.clone(),
2066 reason: "`date_field` must name a date-typed metadata field".to_string(),
2067 })
2068 }
2069 Some(_) => {}
2070 }
2071 match td
2072 .metadata_fields
2073 .iter()
2074 .find(|f| f.key == due.status_field)
2075 {
2076 None => errors.push(SchemaLoadError::InvalidDueAxis {
2077 type_name: td.name.clone(),
2078 offender: due.status_field.clone(),
2079 reason: "`status_field` names no metadata field of this type".to_string(),
2080 }),
2081 Some(f) => match &f.enum_values {
2082 None => errors.push(SchemaLoadError::InvalidDueAxis {
2083 type_name: td.name.clone(),
2084 offender: due.status_field.clone(),
2085 reason: "`status_field` must name an enum-typed metadata field \
2086 (declare enum_values)"
2087 .to_string(),
2088 }),
2089 Some(allowed) => {
2090 for v in &due.open_values {
2091 if !allowed.contains(v) {
2092 errors.push(SchemaLoadError::InvalidDueAxis {
2093 type_name: td.name.clone(),
2094 offender: v.clone(),
2095 reason: format!(
2096 "`open_values` entry is not in `{}`'s enum_values [{}]",
2097 due.status_field,
2098 allowed.join(", ")
2099 ),
2100 });
2101 }
2102 }
2103 }
2104 },
2105 }
2106 if due.open_values.is_empty() {
2107 errors.push(SchemaLoadError::InvalidDueAxis {
2108 type_name: td.name.clone(),
2109 offender: "(empty)".to_string(),
2110 reason: "`open_values` must name at least one open status value".to_string(),
2111 });
2112 }
2113 if let Some(lead) = &due.lead_section
2114 && !td.sections.iter().any(|s| s.key == *lead)
2115 {
2116 errors.push(SchemaLoadError::InvalidDueAxis {
2117 type_name: td.name.clone(),
2118 offender: lead.clone(),
2119 reason: "`lead_section` names no section of this type".to_string(),
2120 });
2121 }
2122 }
2123
2124 if let Some(res) = &td.resolution {
2126 if !td.sections.iter().any(|s| s.key == res.condition_section) {
2127 errors.push(SchemaLoadError::InvalidResolutionAxis {
2128 type_name: td.name.clone(),
2129 offender: res.condition_section.clone(),
2130 reason: "`condition_section` names no section of this type".to_string(),
2131 });
2132 }
2133 match &res.status_field {
2134 None if !res.open_values.is_empty() => {
2135 errors.push(SchemaLoadError::InvalidResolutionAxis {
2136 type_name: td.name.clone(),
2137 offender: res.open_values.join(", "),
2138 reason: "`open_values` given without a `status_field`".to_string(),
2139 });
2140 }
2141 None => {}
2142 Some(field) => match td.metadata_fields.iter().find(|f| f.key == *field) {
2143 None => errors.push(SchemaLoadError::InvalidResolutionAxis {
2144 type_name: td.name.clone(),
2145 offender: field.clone(),
2146 reason: "`status_field` names no metadata field of this type".to_string(),
2147 }),
2148 Some(f) => match &f.enum_values {
2149 None => errors.push(SchemaLoadError::InvalidResolutionAxis {
2150 type_name: td.name.clone(),
2151 offender: field.clone(),
2152 reason: "`status_field` must name an enum-typed metadata field \
2153 (declare enum_values)"
2154 .to_string(),
2155 }),
2156 Some(allowed) => {
2157 if res.open_values.is_empty() {
2158 errors.push(SchemaLoadError::InvalidResolutionAxis {
2159 type_name: td.name.clone(),
2160 offender: "(empty)".to_string(),
2161 reason: "`open_values` must name at least one open status \
2162 value when `status_field` is declared"
2163 .to_string(),
2164 });
2165 }
2166 for v in &res.open_values {
2167 if !allowed.contains(v) {
2168 errors.push(SchemaLoadError::InvalidResolutionAxis {
2169 type_name: td.name.clone(),
2170 offender: v.clone(),
2171 reason: format!(
2172 "`open_values` entry is not in `{field}`'s enum_values [{}]",
2173 allowed.join(", ")
2174 ),
2175 });
2176 }
2177 }
2178 }
2179 },
2180 },
2181 }
2182 if let Some(kind) = &res.check_kind {
2183 let well_formed = matches!(kind.as_str(), "verification" | "conformance")
2184 || (kind.strip_prefix("x-").is_some_and(|name| {
2185 !name.is_empty()
2186 && name
2187 .chars()
2188 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
2189 && !name.starts_with('-')
2190 && !name.ends_with('-')
2191 }));
2192 if !well_formed {
2193 errors.push(SchemaLoadError::InvalidResolutionAxis {
2194 type_name: td.name.clone(),
2195 offender: kind.clone(),
2196 reason: "`check_kind` must be `verification`, `conformance`, or an `x-<name>` \
2197 kind (lowercase letters, digits, hyphens)"
2198 .to_string(),
2199 });
2200 }
2201 }
2202 }
2203
2204 let catch_all_count = td.sections.iter().filter(|s| s.catch_all).count();
2205 if catch_all_count != 1 {
2206 errors.push(SchemaLoadError::CatchAllViolation {
2207 type_name: td.name.clone(),
2208 count: catch_all_count,
2209 });
2210 }
2211
2212 let section_keys: HashSet<&str> = td.sections.iter().map(|s| s.key.as_str()).collect();
2214 let meta_keys: HashSet<&str> = td.metadata_fields.iter().map(|m| m.key.as_str()).collect();
2215
2216 for f in &td.text_fields {
2217 if !section_keys.contains(f.as_str()) {
2219 errors.push(SchemaLoadError::UnknownFieldReference {
2220 type_name: td.name.clone(),
2221 field: "text_fields",
2222 reference: f.clone(),
2223 });
2224 }
2225 }
2226 for f in &td.health_required_fields {
2227 if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
2228 errors.push(SchemaLoadError::UnknownFieldReference {
2229 type_name: td.name.clone(),
2230 field: "health_required_fields",
2231 reference: f.clone(),
2232 });
2233 }
2234 }
2235 for f in &td.updatable_fields {
2236 if f == "title" {
2238 continue;
2239 }
2240 if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
2241 errors.push(SchemaLoadError::UnknownFieldReference {
2242 type_name: td.name.clone(),
2243 field: "updatable_fields",
2244 reference: f.clone(),
2245 });
2246 }
2247 }
2248
2249 for m in &td.metadata_fields {
2251 if let (Some(default), Some(allowed)) = (m.default_value.as_ref(), m.enum_values.as_ref())
2252 && !allowed.contains(default)
2253 {
2254 errors.push(SchemaLoadError::DefaultValueNotInEnum {
2255 type_name: td.name.clone(),
2256 field: m.key.clone(),
2257 default: default.clone(),
2258 allowed: allowed.clone(),
2259 });
2260 }
2261 if let Some(pattern) = m.value_pattern.as_ref()
2264 && let Err(e) = regex::Regex::new(&format!("^(?:{pattern})$"))
2265 {
2266 errors.push(SchemaLoadError::InvalidFieldPattern {
2267 type_name: td.name.clone(),
2268 field: m.key.clone(),
2269 pattern: pattern.clone(),
2270 reason: e.to_string(),
2271 });
2272 }
2273 }
2274}
2275
2276fn check_rel(
2277 type_name: &str,
2278 field: &'static str,
2279 relationship: &str,
2280 rel_names: &HashSet<String>,
2281 available: &[String],
2282) -> Result<(), SchemaLoadError> {
2283 if rel_names.contains(relationship) {
2284 return Ok(());
2285 }
2286 Err(SchemaLoadError::UndeclaredRelationship {
2287 type_name: type_name.into(),
2288 field,
2289 relationship: relationship.into(),
2290 available: available.to_vec(),
2291 })
2292}