1use std::collections::HashMap;
24use std::sync::Arc;
25
26use indexmap::IndexMap;
27use memstead_schema::{Schema, SchemaRef};
28use serde::Serialize;
29
30use crate::engine::EngineError;
31use crate::engine::mutation::unknown_type_error;
32use crate::entity::Entity;
33use crate::runtime_validator::{
34 CrossMemRelCheck, READ_ONLY_METADATA_KEYS, RelationshipCheck, missing_required_fields,
35 missing_required_sections, parse_metadata_value, validate_cross_mem_edge, validate_rel_shape,
36 validate_rel_type, validate_section_keys,
37};
38use crate::store::Store;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
45#[serde(rename_all = "lowercase")]
46pub enum IntegrityAxis {
47 Consistency,
48 Conformance,
49}
50
51#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
67pub struct BodyObservation {
68 pub id: String,
69 pub code: String,
71 pub fate: ObservationFate,
75 pub detail: serde_json::Value,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
80#[serde(rename_all = "kebab-case")]
81pub enum ObservationFate {
82 Absorbed,
84 Dropped,
87}
88
89#[derive(Debug, Clone, Serialize)]
96pub struct IntegrityFinding {
97 pub id: String,
98 pub axis: IntegrityAxis,
99 pub code: String,
100 pub detail: serde_json::Value,
101}
102
103impl BodyObservation {
104 #[cfg(test)]
106 fn occurrences_is(&self, n: u64) -> bool {
107 self.detail["occurrences"].as_u64() == Some(n)
108 }
109}
110
111impl IntegrityFinding {
112 fn conformance(id: &crate::entity::EntityId, err: &EngineError) -> Self {
113 Self {
114 id: id.to_string(),
115 axis: IntegrityAxis::Conformance,
116 code: err.code().to_string(),
117 detail: err.details(),
118 }
119 }
120
121 fn conformance_with_detail(
125 id: &crate::entity::EntityId,
126 code: &str,
127 detail: serde_json::Value,
128 ) -> Self {
129 Self {
130 id: id.to_string(),
131 axis: IntegrityAxis::Conformance,
132 code: code.to_string(),
133 detail,
134 }
135 }
136}
137
138pub(crate) fn swallowed_declared_sections(
147 body: &str,
148 type_def: &memstead_schema::TypeDefinition,
149) -> Vec<String> {
150 let declared: std::collections::BTreeSet<&str> = type_def
151 .sections
152 .iter()
153 .map(|s| s.heading.as_str())
154 .collect();
155 let mut out = Vec::new();
156 for line in body.lines() {
157 if let Some(heading) = line.strip_prefix("## ")
158 && declared.contains(heading.trim())
159 && !out.iter().any(|h| h == heading.trim())
160 {
161 out.push(heading.trim().to_string());
162 }
163 }
164 out
165}
166
167pub fn conformance_findings(
180 store: &Store,
181 mem: &str,
182 schema: &Schema,
183 mem_schemas: &HashMap<String, Arc<Schema>>,
184) -> Vec<IntegrityFinding> {
185 let mut entities: Vec<&Entity> = store
186 .all_entities()
187 .filter(|e| e.mem == mem && !e.stub)
188 .collect();
189 entities.sort_by(|a, b| a.id.0.cmp(&b.id.0));
190
191 let mut findings = Vec::new();
192 for entity in entities {
193 lint_entity(store, entity, schema, mem_schemas, &mut findings);
194 }
195 findings
196}
197
198pub fn body_observations(store: &Store, mem: &str, schema: &Schema) -> Vec<BodyObservation> {
206 let mut entities: Vec<&Entity> = store
207 .all_entities()
208 .filter(|e| e.mem == mem && !e.stub)
209 .collect();
210 entities.sort_by(|a, b| a.id.0.cmp(&b.id.0));
211
212 let mut out = Vec::new();
213 for entity in entities {
214 let Some(type_def) = schema.types.get(entity.entity_type.as_str()) else {
215 continue;
218 };
219 observe_entity(entity, type_def, &mut out);
220 }
221 out.sort_by(|a, b| {
222 a.id.cmp(&b.id)
223 .then_with(|| a.code.cmp(&b.code))
224 .then_with(|| a.detail.to_string().cmp(&b.detail.to_string()))
225 });
226 out
227}
228
229fn observe_entity(
230 entity: &Entity,
231 type_def: &memstead_schema::TypeDefinition,
232 out: &mut Vec<BodyObservation>,
233) {
234 let known: std::collections::BTreeSet<String> = type_def
244 .sections
245 .iter()
246 .map(|s| s.key.clone())
247 .chain(std::iter::once("relationships".to_string()))
248 .collect();
249 let catch_all = type_def.catch_all_section();
250
251 let mut seen: std::collections::BTreeMap<&str, usize> = Default::default();
257 for heading in &entity.raw_section_headings {
258 let occurrence = {
259 let n = seen.entry(heading.as_str()).or_default();
260 *n += 1;
261 *n
262 };
263 if known.contains(&memstead_schema::derive_section_key(heading)) {
264 continue;
265 }
266 if occurrence > 1 {
272 continue;
273 }
274 let absorbed_into = catch_all.map(|c| c.key.as_str());
275 let kept = absorbed_into.is_some() && heading_has_body(entity, heading, catch_all);
276 out.push(BodyObservation {
277 id: entity.id.to_string(),
278 code: "ABSORBED_SECTION".to_string(),
279 fate: if kept {
280 ObservationFate::Absorbed
281 } else {
282 ObservationFate::Dropped
283 },
284 detail: serde_json::json!({
285 "heading": heading,
286 "entity_type": entity.entity_type,
287 "absorbed_into": absorbed_into,
288 "note": if kept {
289 "the type does not declare this heading; its content is kept \
290 byte-verbatim in the catch-all section and survives the next write"
291 } else if absorbed_into.is_some() {
292 "the type does not declare this heading and its body is empty; the \
293 catch-all skips empty content, so the next write does NOT keep it"
294 } else {
295 "the type does not declare this heading and has no catch-all section, \
296 so the next write does NOT keep it"
297 },
298 }),
299 });
300 }
301
302 for (heading, count) in seen.iter().filter(|(_, n)| **n > 1) {
308 out.push(BodyObservation {
309 id: entity.id.to_string(),
310 code: "REPEATED_SECTION_HEADING".to_string(),
311 fate: ObservationFate::Dropped,
312 detail: serde_json::json!({
313 "heading": heading,
314 "occurrences": count,
315 "note": "section splitting is first-wins: the body under the first \
316 occurrence is kept and every later body was NOT kept",
317 }),
318 });
319 }
320
321 for key in entity.metadata.keys() {
328 if RESERVED_METADATA.contains(&key.as_str()) || type_def.metadata_field(key).is_some() {
329 continue;
330 }
331 out.push(BodyObservation {
332 id: entity.id.to_string(),
333 code: "UNDECLARED_METADATA_KEY".to_string(),
334 fate: ObservationFate::Dropped,
335 detail: serde_json::json!({
336 "key": key,
337 "entity_type": entity.entity_type,
338 "note": "the type does not declare this frontmatter key; the generator \
339 emits only declared fields, so the next write drops it",
340 }),
341 });
342 }
343}
344
345const RESERVED_METADATA: &[&str] = &["type", "created_date", "last_modified"];
347
348fn heading_has_body(
353 entity: &Entity,
354 heading: &str,
355 catch_all: Option<&memstead_schema::SectionDef>,
356) -> bool {
357 let Some(c) = catch_all else { return false };
358 let Some(value) = entity.sections.get(c.key.as_str()) else {
359 return false;
360 };
361 value.lines().any(|line| {
367 line.strip_prefix("## ")
368 .is_some_and(|rest| rest.trim() == heading)
369 })
370}
371
372pub const UNRESOLVED_STUB_CODE: &str = "UNRESOLVED_STUB";
395
396pub fn consistency_findings(
407 store: &Store,
408 mem: &str,
409 grant_allows: &dyn Fn(&str, &str) -> bool,
410 target_mounted: &dyn Fn(&str) -> bool,
411) -> Vec<IntegrityFinding> {
412 let mut findings = Vec::new();
413 let mut dangling_reported: std::collections::HashSet<(String, String)> =
414 std::collections::HashSet::new();
415 for link in super::health::collect_dangling_links(store, Some(mem)) {
416 dangling_reported.insert((link.from.to_string(), link.target_id.to_string()));
417 findings.push(IntegrityFinding {
418 id: link.from.to_string(),
419 axis: IntegrityAxis::Consistency,
420 code: link.kind.code().to_string(),
424 detail: serde_json::json!({
425 "from": link.from,
426 "target_id": link.target_id,
427 "target_path": link.target_path,
428 "section": link.section,
429 "repair": link.kind.repair(),
430 }),
431 });
432 }
433 for entity in store.all_entities() {
441 if entity.mem != mem || entity.stub {
442 continue;
443 }
444 for rel in &entity.relationships {
445 let to_mem = rel.target.mem();
446 if to_mem == entity.mem {
450 continue;
451 }
452 if !target_mounted(to_mem) {
453 let key = (entity.id.to_string(), rel.target.to_string());
461 if !dangling_reported.contains(&key) {
462 dangling_reported.insert(key);
463 let kind = crate::ops::DanglingLinkKind::RelationTargetMissing;
464 findings.push(IntegrityFinding {
465 id: entity.id.to_string(),
466 axis: IntegrityAxis::Consistency,
467 code: kind.code().to_string(),
468 detail: serde_json::json!({
469 "from": entity.id,
470 "target_id": rel.target,
471 "target_path": rel.target.path(),
472 "section": serde_json::Value::Null,
473 "repair": kind.repair(),
474 }),
475 });
476 }
477 continue;
478 }
479 if grant_allows(&entity.mem, to_mem) {
480 continue;
481 }
482 findings.push(IntegrityFinding {
483 id: entity.id.to_string(),
484 axis: IntegrityAxis::Consistency,
485 code: "CROSS_MEM_EDGE_UNGRANTED".to_string(),
486 detail: serde_json::json!({
487 "from": entity.id,
488 "target_id": rel.target,
489 "rel_type": rel.rel_type,
490 "from_mem": entity.mem,
491 "to_mem": to_mem,
492 "cause": "no cross-mem grant permits this pair",
496 "repair": "grant the pair with `memstead workspace grant-cross-link`, \
497 or remove the edge with `memstead relate --remove` \
498 (removal needs no grant)",
499 }),
500 });
501 }
502 }
503 for (stub_id, referrers) in crate::graph::query::find_stubs(store) {
504 if stub_id.mem() != mem {
505 continue;
506 }
507 findings.push(IntegrityFinding {
508 id: stub_id.to_string(),
509 axis: IntegrityAxis::Consistency,
510 code: UNRESOLVED_STUB_CODE.to_string(),
511 detail: serde_json::json!({ "referrers": referrers }),
512 });
513 }
514 findings.sort_by(|a, b| {
518 a.id.cmp(&b.id)
519 .then_with(|| a.code.cmp(&b.code))
520 .then_with(|| a.detail.to_string().cmp(&b.detail.to_string()))
521 });
522 findings
523}
524
525pub fn entity_conformance_findings(
531 store: &Store,
532 entity: &Entity,
533 schema: &Schema,
534 mem_schemas: &HashMap<String, Arc<Schema>>,
535) -> Vec<IntegrityFinding> {
536 let mut findings = Vec::new();
537 lint_entity(store, entity, schema, mem_schemas, &mut findings);
538 findings
539}
540
541fn lint_entity(
542 store: &Store,
543 entity: &Entity,
544 schema: &Schema,
545 mem_schemas: &HashMap<String, Arc<Schema>>,
546 findings: &mut Vec<IntegrityFinding>,
547) {
548 let Some(type_def) = schema.types.get(entity.entity_type.as_str()) else {
552 findings.push(IntegrityFinding::conformance(
553 &entity.id,
554 &unknown_type_error(schema, &entity.entity_type),
555 ));
556 return;
557 };
558
559 for (key, value) in &entity.sections {
566 let Some(fence) = crate::markdown::closing_fence_if_unterminated(value.trim()) else {
567 continue;
568 };
569 let swallowed = swallowed_declared_sections(value, type_def);
570 findings.push(IntegrityFinding::conformance_with_detail(
571 &entity.id,
572 "UNTERMINATED_FENCE",
573 serde_json::json!({
574 "section": key,
575 "fence": fence,
576 "entity_type": entity.entity_type,
577 "swallowed_sections": swallowed,
578 "note": if swallowed.is_empty() {
579 "this section ends inside an unterminated code fence; no declared section \
580 follows it in the file yet, but the next write would bury whatever does"
581 } else {
582 "these declared sections are NOT empty: their content sits verbatim inside \
583 the section above, hidden by an unterminated code fence. Supply a corrected \
584 body for that section; the next write would otherwise close the fence \
585 around them and make the loss permanent"
586 },
587 }),
588 ));
589 }
590
591 for key in entity.sections.keys() {
595 if let Err(v) = validate_section_keys(std::iter::once(key.as_str()), type_def) {
596 findings.push(IntegrityFinding::conformance(
597 &entity.id,
598 &EngineError::Validation(v),
599 ));
600 }
601 }
602
603 let missing_sections = missing_required_sections(type_def, &entity.sections);
606 if !missing_sections.is_empty() {
607 let mut type_guidance: std::collections::BTreeMap<String, Vec<String>> = Default::default();
608 if !type_def.write_rules.is_empty() {
609 type_guidance.insert(entity.entity_type.clone(), type_def.write_rules.clone());
610 }
611 findings.push(IntegrityFinding::conformance(
612 &entity.id,
613 &EngineError::MissingRequiredSection {
614 entity_type: entity.entity_type.clone(),
615 missing_count: missing_sections.len(),
616 sections: missing_sections,
617 type_guidance,
618 pre_announced_missing_fields: Vec::new(),
622 },
623 ));
624 }
625
626 let mut supplied: IndexMap<String, String> = IndexMap::new();
630 for (key, value) in &entity.metadata {
631 let raw = value.to_frontmatter_string();
632 supplied.insert(key.clone(), raw.clone());
633 if READ_ONLY_METADATA_KEYS.iter().any(|k| k == key) {
634 continue;
635 }
636 if let Err(v) = parse_metadata_value(key, &raw, type_def) {
637 findings.push(IntegrityFinding::conformance(
638 &entity.id,
639 &EngineError::Validation(v),
640 ));
641 }
642 }
643
644 let missing_fields = missing_required_fields(type_def, &supplied);
647 if let Some(first) = missing_fields.first() {
648 findings.push(IntegrityFinding::conformance(
649 &entity.id,
650 &EngineError::RequiredFieldUnset {
651 field: first.key.clone(),
652 entity_type: entity.entity_type.clone(),
653 field_description: Some(first.description.clone()),
654 enum_values: first.enum_values.clone(),
655 type_write_rules: type_def.write_rules.clone(),
656 on_create: true,
657 missing: missing_fields.clone(),
658 },
659 ));
660 }
661
662 let (src_name, src_version) = schema.id();
668 for rel in &entity.relationships {
669 let target_mem = rel.target.mem();
670 let target_schema = if target_mem == entity.mem {
671 None
672 } else {
673 mem_schemas.get(target_mem)
674 };
675 let cross_mem_different = target_schema.map(|t| t.id().0 != src_name).unwrap_or(false);
676 let target_type = store
677 .get(&rel.target)
678 .map(|e| e.entity_type.clone())
679 .filter(|t| !t.is_empty());
680
681 if cross_mem_different {
682 let target = target_schema.expect("Some when cross_mem_different");
683 let (t_name, t_version) = target.id();
684 let target_ref = SchemaRef::new(t_name, t_version.clone());
685 match validate_cross_mem_edge(
686 &rel.rel_type,
687 &entity.entity_type,
688 target_type.as_deref(),
689 schema,
690 &target_ref,
691 ) {
692 CrossMemRelCheck::Ok => {}
693 CrossMemRelCheck::EdgeNotDeclared => {
694 findings.push(IntegrityFinding::conformance(
695 &entity.id,
696 &EngineError::CrossMemEdgeNotDeclared {
697 source_schema: format!("{src_name}@{src_version}"),
698 target_schema: target_ref.as_display(),
699 rel_type: rel.rel_type.clone(),
700 from_id: entity.id.to_string(),
701 to_id: rel.target.to_string(),
702 },
703 ));
704 }
705 CrossMemRelCheck::Invalid(v) => {
706 findings.push(IntegrityFinding::conformance(
707 &entity.id,
708 &EngineError::Validation(v),
709 ));
710 }
711 }
712 } else {
713 match validate_rel_type(&rel.rel_type, schema) {
714 Ok(RelationshipCheck::Ok) | Ok(RelationshipCheck::OpenWarning(_)) => {}
717 Err(v) => {
718 findings.push(IntegrityFinding::conformance(
719 &entity.id,
720 &EngineError::Validation(v),
721 ));
722 continue;
723 }
724 }
725 if let Err(v) = validate_rel_shape(
726 &rel.rel_type,
727 &entity.entity_type,
728 target_type.as_deref(),
729 schema,
730 ) {
731 findings.push(IntegrityFinding::conformance(
732 &entity.id,
733 &EngineError::Validation(v),
734 ));
735 }
736 }
737 }
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743 use crate::entity::{EntityId, MetadataValue, Relationship};
744
745 const TYPE_TAIL: &str = r#"sections:
746 - key: body
747 heading: Body
748 required: true
749 search_weight: 10.0
750 catch_all: false
751 write_rules: []
752 - key: notes
753 heading: Notes
754 required: false
755 search_weight: 1.0
756 catch_all: true
757 write_rules: []
758metadata_fields:
759 - key: status
760 description: Lifecycle state
761 field_type: string
762 enum_values:
763 - open
764 - closed
765title_weight: 100.0
766text_fields:
767 - body
768hierarchy_relationship: _default
769no_self_loop_relationships: []
770updatable_fields:
771 - title
772 - body
773 - notes
774 - status
775health_required_fields:
776 - body
777staleness_threshold_days: 90
778write_rules: []
779"#;
780
781 const PLAIN_TYPE_TAIL: &str = r#"sections:
782 - key: body
783 heading: Body
784 required: false
785 search_weight: 10.0
786 catch_all: true
787 write_rules: []
788metadata_fields: []
789title_weight: 100.0
790text_fields:
791 - body
792hierarchy_relationship: _default
793no_self_loop_relationships: []
794updatable_fields:
795 - title
796 - body
797health_required_fields: []
798staleness_threshold_days: 90
799write_rules: []
800"#;
801
802 fn lint_schema() -> Arc<Schema> {
808 let manifest = r#"name: lint-src
809version: 0.1.0
810description: linter test schema
811when_to_use: tests
812types:
813 - doc
814 - req
815relationships:
816 mode: strict
817 definitions:
818 - name: IMPLEMENTS
819 description: shape-pinned
820 default_weight: 1.0
821 source_types: [doc]
822 target_types: [doc]
823 - name: _default
824 description: fallback
825 default_weight: 1.0
826cross_mem_relationships:
827 - to_schema: other
828 definitions:
829 - name: ADDRESSES
830 description: outbound
831 default_weight: 1.0
832 source_types: [doc]
833 target_types: [requirement]
834community:
835 resolution: 1.0
836 seed: 42
837"#;
838 Arc::new(
839 memstead_schema::load_schema_from_memory(
840 manifest,
841 &[
842 (
843 "doc".to_string(),
844 format!("name: doc\ndescription: t\nwhen_to_use: tests\n{TYPE_TAIL}"),
845 ),
846 (
847 "req".to_string(),
848 format!("name: req\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"),
849 ),
850 ],
851 )
852 .expect("lint schema loads"),
853 )
854 }
855
856 fn other_schema() -> Arc<Schema> {
859 let manifest = r#"name: other
860version: 1.0.0
861description: target schema
862when_to_use: tests
863types:
864 - requirement
865 - task
866relationships:
867 mode: strict
868 definitions:
869 - name: _default
870 description: fallback
871 default_weight: 1.0
872community:
873 resolution: 1.0
874 seed: 42
875"#;
876 Arc::new(
877 memstead_schema::load_schema_from_memory(
878 manifest,
879 &[
880 (
881 "requirement".to_string(),
882 format!(
883 "name: requirement\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"
884 ),
885 ),
886 (
887 "task".to_string(),
888 format!("name: task\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"),
889 ),
890 ],
891 )
892 .expect("other schema loads"),
893 )
894 }
895
896 fn entity(mem: &str, slug: &str, entity_type: &str) -> Entity {
897 Entity {
898 id: EntityId::new(mem, slug),
899 title: slug.to_string(),
900 entity_type: entity_type.to_string(),
901 mem: mem.to_string(),
902 file_path: format!("{slug}.md"),
903 metadata: IndexMap::new(),
904 sections: IndexMap::new(),
905 relationships: Vec::new(),
906 content_hash: "h".to_string(),
907 stub: false,
908 stub_kind: None,
909 heading_spans: Default::default(),
910 raw_section_headings: Vec::new(),
911 }
912 }
913
914 fn conformant_entity(mem: &str, slug: &str) -> Entity {
915 let mut e = entity(mem, slug, "doc");
916 e.sections.insert("body".to_string(), "content".to_string());
917 e.metadata.insert(
918 "status".to_string(),
919 MetadataValue::String("open".to_string()),
920 );
921 e
922 }
923
924 fn schemas_for(entries: &[(&str, Arc<Schema>)]) -> HashMap<String, Arc<Schema>> {
925 entries
926 .iter()
927 .map(|(v, s)| (v.to_string(), s.clone()))
928 .collect()
929 }
930
931 fn codes(findings: &[IntegrityFinding]) -> Vec<&str> {
932 findings.iter().map(|f| f.code.as_str()).collect()
933 }
934
935 #[test]
940 fn an_absorbed_heading_is_observed_and_never_a_violation() {
941 let schema = lint_schema();
942 let mut store = Store::new();
943 let mut e = conformant_entity("lv", "alpha");
944 e.raw_section_headings = vec!["Body".into(), "Field Notes".into()];
945 e.sections.insert(
947 "notes".into(),
948 "## Field Notes\n\nsomething useful\n".into(),
949 );
950 let id = e.id.to_string();
951 store.upsert(e.id.clone(), e);
952
953 let obs = body_observations(&store, "lv", &schema);
954 assert_eq!(obs.len(), 1, "got {obs:?}");
955 assert_eq!(obs[0].code, "ABSORBED_SECTION");
956 assert_eq!(obs[0].id, id);
957 assert_eq!(obs[0].detail["heading"], "Field Notes");
958 assert_eq!(
959 obs[0].fate,
960 ObservationFate::Absorbed,
961 "the content survives the next write, and the report must say so"
962 );
963
964 let schemas = schemas_for(&[("lv", schema.clone())]);
966 let findings = conformance_findings(&store, "lv", &schema, &schemas);
967 assert!(
968 findings.is_empty(),
969 "healthy catch-all use must not be a violation: {:?}",
970 codes(&findings)
971 );
972 }
973
974 #[test]
978 fn a_bare_undeclared_heading_is_observed_as_dropped() {
979 let schema = lint_schema();
980 let mut store = Store::new();
981 let mut e = conformant_entity("lv", "alpha");
982 e.raw_section_headings = vec!["Body".into(), "Scratch".into()];
983 store.upsert(e.id.clone(), e);
985
986 let obs = body_observations(&store, "lv", &schema);
987 assert_eq!(obs.len(), 1, "got {obs:?}");
988 assert_eq!(obs[0].code, "ABSORBED_SECTION");
989 assert_eq!(
990 obs[0].fate,
991 ObservationFate::Dropped,
992 "an empty heading is skipped by the catch-all, so it does NOT survive"
993 );
994 }
995
996 #[test]
1000 fn an_undeclared_metadata_key_is_observed_as_dropped() {
1001 let schema = lint_schema();
1002 let mut store = Store::new();
1003 let mut e = conformant_entity("lv", "alpha");
1004 e.metadata
1005 .insert("reviewer".into(), MetadataValue::String("ada".into()));
1006 e.metadata
1008 .insert("last_modified".into(), MetadataValue::String("x".into()));
1009 store.upsert(e.id.clone(), e);
1010
1011 let obs = body_observations(&store, "lv", &schema);
1012 assert_eq!(obs.len(), 1, "got {obs:?}");
1013 assert_eq!(obs[0].code, "UNDECLARED_METADATA_KEY");
1014 assert_eq!(obs[0].detail["key"], "reviewer");
1015 assert_eq!(obs[0].fate, ObservationFate::Dropped);
1016 }
1017
1018 #[test]
1022 fn a_repeated_heading_is_observed_in_both_silent_cases() {
1023 let schema = lint_schema();
1024 for (headings, label) in [
1025 (
1026 vec!["Body", "Scratch", "Scratch"],
1027 "undeclared heading twice",
1028 ),
1029 (
1030 vec!["Body", "Notes", "Notes"],
1031 "the catch-all's own heading twice",
1032 ),
1033 ] {
1034 let mut store = Store::new();
1035 let mut e = conformant_entity("lv", "alpha");
1036 e.raw_section_headings = headings.iter().map(|h| h.to_string()).collect();
1037 e.sections
1038 .insert("notes".into(), "## Scratch\n\nkept\n".into());
1039 store.upsert(e.id.clone(), e);
1040
1041 let obs = body_observations(&store, "lv", &schema);
1042 let repeats: Vec<_> = obs
1043 .iter()
1044 .filter(|o| o.code == "REPEATED_SECTION_HEADING")
1045 .collect();
1046 assert_eq!(repeats.len(), 1, "{label}: got {obs:?}");
1047 assert!(repeats[0].occurrences_is(2), "{label}");
1048 assert_eq!(repeats[0].fate, ObservationFate::Dropped, "{label}");
1049 }
1050 }
1051
1052 #[test]
1056 fn an_ordinary_entity_produces_no_observations() {
1057 let schema = lint_schema();
1058 let mut store = Store::new();
1059 let mut e = conformant_entity("lv", "alpha");
1060 e.raw_section_headings = vec!["Body".into(), "Notes".into(), "Relationships".into()];
1066 e.sections.insert("notes".into(), "plain prose\n".into());
1067 store.upsert(e.id.clone(), e);
1068 assert!(
1069 body_observations(&store, "lv", &schema).is_empty(),
1070 "declared headings, each once, the relationships block, no undeclared keys"
1071 );
1072 }
1073
1074 #[test]
1075 fn a_repeated_undeclared_heading_claims_survival_only_for_the_first() {
1076 let schema = lint_schema();
1080 let mut store = Store::new();
1081 let mut e = conformant_entity("lv", "alpha");
1082 e.raw_section_headings = vec!["Body".into(), "Scratch".into(), "Scratch".into()];
1083 e.sections
1084 .insert("notes".into(), "## Scratch\n\nkept\n".into());
1085 store.upsert(e.id.clone(), e);
1086 let obs = body_observations(&store, "lv", &schema);
1087 let absorbed: Vec<_> = obs
1088 .iter()
1089 .filter(|o| o.code == "ABSORBED_SECTION")
1090 .collect();
1091 assert_eq!(
1092 absorbed.len(),
1093 1,
1094 "one per heading, not per occurrence: {obs:?}"
1095 );
1096 assert_eq!(absorbed[0].fate, ObservationFate::Absorbed);
1097 let repeats: Vec<_> = obs
1099 .iter()
1100 .filter(|o| o.code == "REPEATED_SECTION_HEADING")
1101 .collect();
1102 assert_eq!(repeats.len(), 1, "got: {obs:?}");
1103 assert_eq!(repeats[0].detail["occurrences"], 2);
1104 }
1105
1106 #[test]
1107 fn the_auto_managed_relationships_block_is_never_an_observation() {
1108 let schema = lint_schema();
1113 let mut store = Store::new();
1114 let mut e = conformant_entity("lv", "alpha");
1115 e.raw_section_headings = vec!["Relationships".into()];
1116 store.upsert(e.id.clone(), e);
1117 assert!(
1118 body_observations(&store, "lv", &schema).is_empty(),
1119 "the relationships block is engine-owned, not undeclared content"
1120 );
1121 }
1122
1123 #[test]
1124 fn a_heading_named_inside_prose_is_not_mistaken_for_a_kept_one() {
1125 let schema = lint_schema();
1130 let mut store = Store::new();
1131 let mut e = conformant_entity("lv", "alpha");
1132 e.raw_section_headings = vec!["Body".into(), "Scratch".into()];
1133 e.sections
1134 .insert("notes".into(), "we discussed Scratch at length\n".into());
1135 store.upsert(e.id.clone(), e);
1136 let obs = body_observations(&store, "lv", &schema);
1137 let absorbed: Vec<_> = obs
1138 .iter()
1139 .filter(|o| o.code == "ABSORBED_SECTION")
1140 .collect();
1141 assert_eq!(absorbed.len(), 1, "got: {obs:?}");
1142 assert_eq!(
1143 absorbed[0].fate,
1144 ObservationFate::Dropped,
1145 "a bare heading whose text appears in prose is still dropped"
1146 );
1147 }
1148
1149 #[test]
1150 fn an_unterminated_fence_names_the_sections_it_swallowed() {
1151 let schema = lint_schema();
1156 let mut store = Store::new();
1157 let mut e = conformant_entity("lv", "alpha");
1158 e.sections.insert(
1159 "body".into(),
1160 "intro\n\n```rust\nfn main() {}\n\n## Notes\n\nthe real notes\n".into(),
1161 );
1162 e.sections.shift_remove("notes");
1163 store.upsert(e.id.clone(), e);
1164 let schemas = schemas_for(&[("lv", schema.clone())]);
1165 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1166 let fence: Vec<_> = findings
1167 .iter()
1168 .filter(|f| f.code == "UNTERMINATED_FENCE")
1169 .collect();
1170 assert_eq!(fence.len(), 1, "got: {:?}", codes(&findings));
1171 assert_eq!(fence[0].id, "lv--alpha");
1172 assert_eq!(fence[0].detail["section"], "body");
1173 assert_eq!(fence[0].detail["fence"], "```");
1174 assert_eq!(
1175 fence[0].detail["swallowed_sections"],
1176 serde_json::json!(["Notes"]),
1177 );
1178 assert!(!findings.is_empty());
1181 }
1182
1183 #[test]
1184 fn an_entity_with_no_open_fence_gains_no_fence_finding() {
1185 let schema = lint_schema();
1189 let schemas = schemas_for(&[("lv", schema.clone())]);
1190 for body in [
1191 "just prose",
1192 "prose\n\n```rust\nfn main() {}\n```\n\nmore",
1193 "```md\n## Notes\n```",
1194 ] {
1195 let mut store = Store::new();
1196 let mut e = conformant_entity("lv", "alpha");
1197 e.sections.insert("body".into(), body.into());
1198 store.upsert(e.id.clone(), e);
1199 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1200 assert!(
1201 !findings.iter().any(|f| f.code == "UNTERMINATED_FENCE"),
1202 "body {body:?} produced: {:?}",
1203 codes(&findings)
1204 );
1205 }
1206 }
1207
1208 #[test]
1209 fn clean_mem_produces_no_findings() {
1210 let schema = lint_schema();
1211 let mut store = Store::new();
1212 let a = conformant_entity("lv", "alpha");
1213 let mut b = conformant_entity("lv", "beta");
1214 b.relationships
1215 .push(Relationship::new("IMPLEMENTS", a.id.clone()));
1216 store.upsert(a.id.clone(), a);
1217 store.upsert(b.id.clone(), b);
1218 let schemas = schemas_for(&[("lv", schema.clone())]);
1219 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1220 assert!(findings.is_empty(), "got: {:?}", codes(&findings));
1221 }
1222
1223 #[test]
1224 fn missing_required_section_and_field_carry_write_time_codes() {
1225 let schema = lint_schema();
1226 let mut store = Store::new();
1227 let e = entity("lv", "broken", "doc");
1229 let id = e.id.to_string();
1230 store.upsert(e.id.clone(), e);
1231 let schemas = schemas_for(&[("lv", schema.clone())]);
1232 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1233 let cs = codes(&findings);
1234 assert!(cs.contains(&"MISSING_REQUIRED_SECTION"), "got: {cs:?}");
1235 assert!(cs.contains(&"REQUIRED_FIELD_UNSET"), "got: {cs:?}");
1236 for f in &findings {
1237 assert_eq!(f.id, id);
1238 assert_eq!(f.axis, IntegrityAxis::Conformance);
1239 }
1240 let section_finding = findings
1242 .iter()
1243 .find(|f| f.code == "MISSING_REQUIRED_SECTION")
1244 .unwrap();
1245 assert_eq!(
1246 section_finding.detail["sections"][0]["key"].as_str(),
1247 Some("body")
1248 );
1249 let field_finding = findings
1250 .iter()
1251 .find(|f| f.code == "REQUIRED_FIELD_UNSET")
1252 .unwrap();
1253 assert_eq!(field_finding.detail["field"].as_str(), Some("status"));
1254 }
1255
1256 #[test]
1257 fn invalid_enum_unknown_section_and_unknown_metadata_surface() {
1258 let schema = lint_schema();
1259 let mut store = Store::new();
1260 let mut e = conformant_entity("lv", "drifted");
1261 e.metadata.insert(
1262 "status".to_string(),
1263 MetadataValue::String("banana".to_string()),
1264 );
1265 e.metadata
1266 .insert("wat".to_string(), MetadataValue::String("x".to_string()));
1267 e.sections.insert("bogus".to_string(), "text".to_string());
1268 store.upsert(e.id.clone(), e);
1269 let schemas = schemas_for(&[("lv", schema.clone())]);
1270 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1271 let cs = codes(&findings);
1272 assert!(cs.contains(&"INVALID_ENUM_VALUE"), "got: {cs:?}");
1273 assert!(cs.contains(&"UNKNOWN_SECTION"), "got: {cs:?}");
1274 assert!(cs.contains(&"UNKNOWN_METADATA_FIELD"), "got: {cs:?}");
1275 let enum_finding = findings
1276 .iter()
1277 .find(|f| f.code == "INVALID_ENUM_VALUE")
1278 .unwrap();
1279 assert_eq!(enum_finding.detail["value"].as_str(), Some("banana"));
1280 assert_eq!(
1281 enum_finding.detail["allowed"]
1282 .as_array()
1283 .unwrap()
1284 .iter()
1285 .map(|v| v.as_str().unwrap())
1286 .collect::<Vec<_>>(),
1287 vec!["open", "closed"]
1288 );
1289 }
1290
1291 #[test]
1292 fn unknown_type_short_circuits_with_unknown_entity_type() {
1293 let schema = lint_schema();
1294 let mut store = Store::new();
1295 let e = entity("lv", "mystery", "ghost");
1296 store.upsert(e.id.clone(), e);
1297 let schemas = schemas_for(&[("lv", schema.clone())]);
1298 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1299 assert_eq!(codes(&findings), vec!["UNKNOWN_ENTITY_TYPE"]);
1300 assert_eq!(findings[0].detail["name"].as_str(), Some("ghost"));
1301 }
1302
1303 #[test]
1304 fn invalid_rel_type_and_shape_surface() {
1305 let schema = lint_schema();
1306 let mut store = Store::new();
1307 let mut req_target = conformant_entity("lv", "target");
1308 req_target.entity_type = "req".to_string();
1309 req_target.metadata.clear();
1311 req_target.sections.clear();
1312 let mut e = conformant_entity("lv", "edges");
1313 e.relationships
1314 .push(Relationship::new("UNDECLARED", req_target.id.clone()));
1315 e.relationships
1317 .push(Relationship::new("IMPLEMENTS", req_target.id.clone()));
1318 store.upsert(req_target.id.clone(), req_target);
1319 store.upsert(e.id.clone(), e);
1320 let schemas = schemas_for(&[("lv", schema.clone())]);
1321 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1322 let cs = codes(&findings);
1323 assert!(cs.contains(&"INVALID_REL_TYPE"), "got: {cs:?}");
1324 assert!(cs.contains(&"INVALID_REL_SHAPE"), "got: {cs:?}");
1325 }
1326
1327 #[test]
1328 fn cross_mem_edges_lint_like_the_write_path() {
1329 let schema = lint_schema();
1330 let other = other_schema();
1331 let mut store = Store::new();
1332 let mut requirement = entity("tv", "goal", "requirement");
1333 requirement
1334 .sections
1335 .insert("body".to_string(), "x".to_string());
1336 let mut task = entity("tv", "chore", "task");
1337 task.sections.insert("body".to_string(), "x".to_string());
1338
1339 let mut e = conformant_entity("lv", "linker");
1340 e.relationships
1342 .push(Relationship::new("ADDRESSES", requirement.id.clone()));
1343 e.relationships
1346 .push(Relationship::new("ADDRESSES", task.id.clone()));
1347 e.relationships
1349 .push(Relationship::new("IMPLEMENTS", requirement.id.clone()));
1350 store.upsert(requirement.id.clone(), requirement);
1351 store.upsert(task.id.clone(), task);
1352 store.upsert(e.id.clone(), e);
1353 let schemas = schemas_for(&[("lv", schema.clone()), ("tv", other)]);
1354 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1355 let cs = codes(&findings);
1356 assert_eq!(
1357 cs,
1358 vec!["INVALID_REL_SHAPE", "INVALID_REL_TYPE"],
1359 "declared+conformant edge must stay silent; got: {cs:?}"
1360 );
1361 }
1362
1363 #[test]
1364 fn stub_entities_are_skipped() {
1365 let schema = lint_schema();
1366 let mut store = Store::new();
1367 let mut stub = entity("lv", "ghost-stub", "");
1368 stub.stub = true;
1369 store.upsert(stub.id.clone(), stub);
1370 let schemas = schemas_for(&[("lv", schema.clone())]);
1371 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1372 assert!(findings.is_empty());
1373 }
1374
1375 #[test]
1376 fn other_mems_are_out_of_scope() {
1377 let schema = lint_schema();
1378 let mut store = Store::new();
1379 let e = entity("elsewhere", "broken", "doc");
1380 store.upsert(e.id.clone(), e);
1381 let schemas = schemas_for(&[("lv", schema.clone())]);
1382 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1383 assert!(findings.is_empty());
1384 }
1385
1386 #[test]
1387 fn findings_are_deterministic_and_id_ordered() {
1388 let schema = lint_schema();
1389 let mut store = Store::new();
1390 for slug in ["zeta", "alpha", "mid"] {
1392 let e = entity("lv", slug, "doc");
1393 store.upsert(e.id.clone(), e);
1394 }
1395 let schemas = schemas_for(&[("lv", schema.clone())]);
1396 let first = conformance_findings(&store, "lv", &schema, &schemas);
1397 let second = conformance_findings(&store, "lv", &schema, &schemas);
1398 let a = serde_json::to_string(&first).unwrap();
1399 let b = serde_json::to_string(&second).unwrap();
1400 assert_eq!(a, b, "two runs must be byte-identical");
1401 let ids: Vec<&str> = first.iter().map(|f| f.id.as_str()).collect();
1402 let mut sorted = ids.clone();
1403 sorted.sort();
1404 assert_eq!(ids, sorted, "findings must be in lexical id order");
1405 }
1406
1407 #[test]
1408 fn lint_against_target_schema_differs_from_pin() {
1409 let pin = lint_schema();
1414 let target = other_schema();
1415 let mut store = Store::new();
1416 let mut e = entity("lv", "shifting", "task");
1417 e.sections.insert("body".to_string(), "x".to_string());
1418 store.upsert(e.id.clone(), e);
1419 let schemas = schemas_for(&[("lv", pin.clone())]);
1420 let against_pin = conformance_findings(&store, "lv", &pin, &schemas);
1421 assert_eq!(codes(&against_pin), vec!["UNKNOWN_ENTITY_TYPE"]);
1422 let against_target = conformance_findings(&store, "lv", &target, &schemas);
1423 assert!(
1424 against_target.is_empty(),
1425 "got: {:?}",
1426 codes(&against_target)
1427 );
1428 }
1429}