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 fn consistency_findings(
390 store: &Store,
391 mem: &str,
392 grant_allows: &dyn Fn(&str, &str) -> bool,
393) -> Vec<IntegrityFinding> {
394 let mut findings = Vec::new();
395 for link in super::health::collect_dangling_links(store, Some(mem)) {
396 findings.push(IntegrityFinding {
397 id: link.from.to_string(),
398 axis: IntegrityAxis::Consistency,
399 code: link.kind.code().to_string(),
403 detail: serde_json::json!({
404 "from": link.from,
405 "target_id": link.target_id,
406 "target_path": link.target_path,
407 "section": link.section,
408 "repair": link.kind.repair(),
409 }),
410 });
411 }
412 for entity in store.all_entities() {
420 if entity.mem != mem || entity.stub {
421 continue;
422 }
423 for rel in &entity.relationships {
424 let to_mem = rel.target.mem();
425 if to_mem == entity.mem {
429 continue;
430 }
431 if grant_allows(&entity.mem, to_mem) {
432 continue;
433 }
434 findings.push(IntegrityFinding {
435 id: entity.id.to_string(),
436 axis: IntegrityAxis::Consistency,
437 code: "CROSS_MEM_EDGE_UNGRANTED".to_string(),
438 detail: serde_json::json!({
439 "from": entity.id,
440 "target_id": rel.target,
441 "rel_type": rel.rel_type,
442 "from_mem": entity.mem,
443 "to_mem": to_mem,
444 "cause": "no cross-mem grant permits this pair",
448 "repair": "grant the pair with `memstead workspace grant-cross-link`, \
449 or remove the edge with `memstead relate --remove` \
450 (removal needs no grant)",
451 }),
452 });
453 }
454 }
455 for (stub_id, referrers) in crate::graph::query::find_stubs(store) {
456 if stub_id.mem() != mem {
457 continue;
458 }
459 findings.push(IntegrityFinding {
460 id: stub_id.to_string(),
461 axis: IntegrityAxis::Consistency,
462 code: "ORPHAN_STUB".to_string(),
463 detail: serde_json::json!({ "referrers": referrers }),
464 });
465 }
466 findings.sort_by(|a, b| {
470 a.id.cmp(&b.id)
471 .then_with(|| a.code.cmp(&b.code))
472 .then_with(|| a.detail.to_string().cmp(&b.detail.to_string()))
473 });
474 findings
475}
476
477pub fn entity_conformance_findings(
483 store: &Store,
484 entity: &Entity,
485 schema: &Schema,
486 mem_schemas: &HashMap<String, Arc<Schema>>,
487) -> Vec<IntegrityFinding> {
488 let mut findings = Vec::new();
489 lint_entity(store, entity, schema, mem_schemas, &mut findings);
490 findings
491}
492
493fn lint_entity(
494 store: &Store,
495 entity: &Entity,
496 schema: &Schema,
497 mem_schemas: &HashMap<String, Arc<Schema>>,
498 findings: &mut Vec<IntegrityFinding>,
499) {
500 let Some(type_def) = schema.types.get(entity.entity_type.as_str()) else {
504 findings.push(IntegrityFinding::conformance(
505 &entity.id,
506 &unknown_type_error(schema, &entity.entity_type),
507 ));
508 return;
509 };
510
511 for (key, value) in &entity.sections {
518 let Some(fence) = crate::markdown::closing_fence_if_unterminated(value.trim()) else {
519 continue;
520 };
521 let swallowed = swallowed_declared_sections(value, type_def);
522 findings.push(IntegrityFinding::conformance_with_detail(
523 &entity.id,
524 "UNTERMINATED_FENCE",
525 serde_json::json!({
526 "section": key,
527 "fence": fence,
528 "entity_type": entity.entity_type,
529 "swallowed_sections": swallowed,
530 "note": if swallowed.is_empty() {
531 "this section ends inside an unterminated code fence; no declared section \
532 follows it in the file yet, but the next write would bury whatever does"
533 } else {
534 "these declared sections are NOT empty: their content sits verbatim inside \
535 the section above, hidden by an unterminated code fence. Supply a corrected \
536 body for that section; the next write would otherwise close the fence \
537 around them and make the loss permanent"
538 },
539 }),
540 ));
541 }
542
543 for key in entity.sections.keys() {
547 if let Err(v) = validate_section_keys(std::iter::once(key.as_str()), type_def) {
548 findings.push(IntegrityFinding::conformance(
549 &entity.id,
550 &EngineError::Validation(v),
551 ));
552 }
553 }
554
555 let missing_sections = missing_required_sections(type_def, &entity.sections);
558 if !missing_sections.is_empty() {
559 let mut type_guidance: std::collections::BTreeMap<String, Vec<String>> = Default::default();
560 if !type_def.write_rules.is_empty() {
561 type_guidance.insert(entity.entity_type.clone(), type_def.write_rules.clone());
562 }
563 findings.push(IntegrityFinding::conformance(
564 &entity.id,
565 &EngineError::MissingRequiredSection {
566 entity_type: entity.entity_type.clone(),
567 missing_count: missing_sections.len(),
568 sections: missing_sections,
569 type_guidance,
570 pre_announced_missing_fields: Vec::new(),
574 },
575 ));
576 }
577
578 let mut supplied: IndexMap<String, String> = IndexMap::new();
582 for (key, value) in &entity.metadata {
583 let raw = value.to_frontmatter_string();
584 supplied.insert(key.clone(), raw.clone());
585 if READ_ONLY_METADATA_KEYS.iter().any(|k| k == key) {
586 continue;
587 }
588 if let Err(v) = parse_metadata_value(key, &raw, type_def) {
589 findings.push(IntegrityFinding::conformance(
590 &entity.id,
591 &EngineError::Validation(v),
592 ));
593 }
594 }
595
596 let missing_fields = missing_required_fields(type_def, &supplied);
599 if let Some(first) = missing_fields.first() {
600 findings.push(IntegrityFinding::conformance(
601 &entity.id,
602 &EngineError::RequiredFieldUnset {
603 field: first.key.clone(),
604 entity_type: entity.entity_type.clone(),
605 field_description: Some(first.description.clone()),
606 enum_values: first.enum_values.clone(),
607 type_write_rules: type_def.write_rules.clone(),
608 on_create: true,
609 missing: missing_fields.clone(),
610 },
611 ));
612 }
613
614 let (src_name, src_version) = schema.id();
620 for rel in &entity.relationships {
621 let target_mem = rel.target.mem();
622 let target_schema = if target_mem == entity.mem {
623 None
624 } else {
625 mem_schemas.get(target_mem)
626 };
627 let cross_mem_different = target_schema.map(|t| t.id().0 != src_name).unwrap_or(false);
628 let target_type = store
629 .get(&rel.target)
630 .map(|e| e.entity_type.clone())
631 .filter(|t| !t.is_empty());
632
633 if cross_mem_different {
634 let target = target_schema.expect("Some when cross_mem_different");
635 let (t_name, t_version) = target.id();
636 let target_ref = SchemaRef::new(t_name, t_version.clone());
637 match validate_cross_mem_edge(
638 &rel.rel_type,
639 &entity.entity_type,
640 target_type.as_deref(),
641 schema,
642 &target_ref,
643 ) {
644 CrossMemRelCheck::Ok => {}
645 CrossMemRelCheck::EdgeNotDeclared => {
646 findings.push(IntegrityFinding::conformance(
647 &entity.id,
648 &EngineError::CrossMemEdgeNotDeclared {
649 source_schema: format!("{src_name}@{src_version}"),
650 target_schema: target_ref.as_display(),
651 rel_type: rel.rel_type.clone(),
652 from_id: entity.id.to_string(),
653 to_id: rel.target.to_string(),
654 },
655 ));
656 }
657 CrossMemRelCheck::Invalid(v) => {
658 findings.push(IntegrityFinding::conformance(
659 &entity.id,
660 &EngineError::Validation(v),
661 ));
662 }
663 }
664 } else {
665 match validate_rel_type(&rel.rel_type, schema) {
666 Ok(RelationshipCheck::Ok) | Ok(RelationshipCheck::OpenWarning(_)) => {}
669 Err(v) => {
670 findings.push(IntegrityFinding::conformance(
671 &entity.id,
672 &EngineError::Validation(v),
673 ));
674 continue;
675 }
676 }
677 if let Err(v) = validate_rel_shape(
678 &rel.rel_type,
679 &entity.entity_type,
680 target_type.as_deref(),
681 schema,
682 ) {
683 findings.push(IntegrityFinding::conformance(
684 &entity.id,
685 &EngineError::Validation(v),
686 ));
687 }
688 }
689 }
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695 use crate::entity::{EntityId, MetadataValue, Relationship};
696
697 const TYPE_TAIL: &str = r#"sections:
698 - key: body
699 heading: Body
700 required: true
701 search_weight: 10.0
702 catch_all: false
703 write_rules: []
704 - key: notes
705 heading: Notes
706 required: false
707 search_weight: 1.0
708 catch_all: true
709 write_rules: []
710metadata_fields:
711 - key: status
712 description: Lifecycle state
713 field_type: string
714 enum_values:
715 - open
716 - closed
717title_weight: 100.0
718text_fields:
719 - body
720hierarchy_relationship: _default
721no_self_loop_relationships: []
722updatable_fields:
723 - title
724 - body
725 - notes
726 - status
727health_required_fields:
728 - body
729staleness_threshold_days: 90
730write_rules: []
731"#;
732
733 const PLAIN_TYPE_TAIL: &str = r#"sections:
734 - key: body
735 heading: Body
736 required: false
737 search_weight: 10.0
738 catch_all: true
739 write_rules: []
740metadata_fields: []
741title_weight: 100.0
742text_fields:
743 - body
744hierarchy_relationship: _default
745no_self_loop_relationships: []
746updatable_fields:
747 - title
748 - body
749health_required_fields: []
750staleness_threshold_days: 90
751write_rules: []
752"#;
753
754 fn lint_schema() -> Arc<Schema> {
760 let manifest = r#"name: lint-src
761version: 0.1.0
762description: linter test schema
763when_to_use: tests
764types:
765 - doc
766 - req
767relationships:
768 mode: strict
769 definitions:
770 - name: IMPLEMENTS
771 description: shape-pinned
772 default_weight: 1.0
773 source_types: [doc]
774 target_types: [doc]
775 - name: _default
776 description: fallback
777 default_weight: 1.0
778cross_mem_relationships:
779 - to_schema: other
780 definitions:
781 - name: ADDRESSES
782 description: outbound
783 default_weight: 1.0
784 source_types: [doc]
785 target_types: [requirement]
786community:
787 resolution: 1.0
788 seed: 42
789"#;
790 Arc::new(
791 memstead_schema::load_schema_from_memory(
792 manifest,
793 &[
794 (
795 "doc".to_string(),
796 format!("name: doc\ndescription: t\nwhen_to_use: tests\n{TYPE_TAIL}"),
797 ),
798 (
799 "req".to_string(),
800 format!("name: req\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"),
801 ),
802 ],
803 )
804 .expect("lint schema loads"),
805 )
806 }
807
808 fn other_schema() -> Arc<Schema> {
811 let manifest = r#"name: other
812version: 1.0.0
813description: target schema
814when_to_use: tests
815types:
816 - requirement
817 - task
818relationships:
819 mode: strict
820 definitions:
821 - name: _default
822 description: fallback
823 default_weight: 1.0
824community:
825 resolution: 1.0
826 seed: 42
827"#;
828 Arc::new(
829 memstead_schema::load_schema_from_memory(
830 manifest,
831 &[
832 (
833 "requirement".to_string(),
834 format!(
835 "name: requirement\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"
836 ),
837 ),
838 (
839 "task".to_string(),
840 format!("name: task\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"),
841 ),
842 ],
843 )
844 .expect("other schema loads"),
845 )
846 }
847
848 fn entity(mem: &str, slug: &str, entity_type: &str) -> Entity {
849 Entity {
850 id: EntityId::new(mem, slug),
851 title: slug.to_string(),
852 entity_type: entity_type.to_string(),
853 mem: mem.to_string(),
854 file_path: format!("{slug}.md"),
855 metadata: IndexMap::new(),
856 sections: IndexMap::new(),
857 relationships: Vec::new(),
858 content_hash: "h".to_string(),
859 stub: false,
860 stub_kind: None,
861 heading_spans: Default::default(),
862 raw_section_headings: Vec::new(),
863 }
864 }
865
866 fn conformant_entity(mem: &str, slug: &str) -> Entity {
867 let mut e = entity(mem, slug, "doc");
868 e.sections.insert("body".to_string(), "content".to_string());
869 e.metadata.insert(
870 "status".to_string(),
871 MetadataValue::String("open".to_string()),
872 );
873 e
874 }
875
876 fn schemas_for(entries: &[(&str, Arc<Schema>)]) -> HashMap<String, Arc<Schema>> {
877 entries
878 .iter()
879 .map(|(v, s)| (v.to_string(), s.clone()))
880 .collect()
881 }
882
883 fn codes(findings: &[IntegrityFinding]) -> Vec<&str> {
884 findings.iter().map(|f| f.code.as_str()).collect()
885 }
886
887 #[test]
892 fn an_absorbed_heading_is_observed_and_never_a_violation() {
893 let schema = lint_schema();
894 let mut store = Store::new();
895 let mut e = conformant_entity("lv", "alpha");
896 e.raw_section_headings = vec!["Body".into(), "Field Notes".into()];
897 e.sections.insert(
899 "notes".into(),
900 "## Field Notes\n\nsomething useful\n".into(),
901 );
902 let id = e.id.to_string();
903 store.upsert(e.id.clone(), e);
904
905 let obs = body_observations(&store, "lv", &schema);
906 assert_eq!(obs.len(), 1, "got {obs:?}");
907 assert_eq!(obs[0].code, "ABSORBED_SECTION");
908 assert_eq!(obs[0].id, id);
909 assert_eq!(obs[0].detail["heading"], "Field Notes");
910 assert_eq!(
911 obs[0].fate,
912 ObservationFate::Absorbed,
913 "the content survives the next write, and the report must say so"
914 );
915
916 let schemas = schemas_for(&[("lv", schema.clone())]);
918 let findings = conformance_findings(&store, "lv", &schema, &schemas);
919 assert!(
920 findings.is_empty(),
921 "healthy catch-all use must not be a violation: {:?}",
922 codes(&findings)
923 );
924 }
925
926 #[test]
930 fn a_bare_undeclared_heading_is_observed_as_dropped() {
931 let schema = lint_schema();
932 let mut store = Store::new();
933 let mut e = conformant_entity("lv", "alpha");
934 e.raw_section_headings = vec!["Body".into(), "Scratch".into()];
935 store.upsert(e.id.clone(), e);
937
938 let obs = body_observations(&store, "lv", &schema);
939 assert_eq!(obs.len(), 1, "got {obs:?}");
940 assert_eq!(obs[0].code, "ABSORBED_SECTION");
941 assert_eq!(
942 obs[0].fate,
943 ObservationFate::Dropped,
944 "an empty heading is skipped by the catch-all, so it does NOT survive"
945 );
946 }
947
948 #[test]
952 fn an_undeclared_metadata_key_is_observed_as_dropped() {
953 let schema = lint_schema();
954 let mut store = Store::new();
955 let mut e = conformant_entity("lv", "alpha");
956 e.metadata
957 .insert("reviewer".into(), MetadataValue::String("ada".into()));
958 e.metadata
960 .insert("last_modified".into(), MetadataValue::String("x".into()));
961 store.upsert(e.id.clone(), e);
962
963 let obs = body_observations(&store, "lv", &schema);
964 assert_eq!(obs.len(), 1, "got {obs:?}");
965 assert_eq!(obs[0].code, "UNDECLARED_METADATA_KEY");
966 assert_eq!(obs[0].detail["key"], "reviewer");
967 assert_eq!(obs[0].fate, ObservationFate::Dropped);
968 }
969
970 #[test]
974 fn a_repeated_heading_is_observed_in_both_silent_cases() {
975 let schema = lint_schema();
976 for (headings, label) in [
977 (
978 vec!["Body", "Scratch", "Scratch"],
979 "undeclared heading twice",
980 ),
981 (
982 vec!["Body", "Notes", "Notes"],
983 "the catch-all's own heading twice",
984 ),
985 ] {
986 let mut store = Store::new();
987 let mut e = conformant_entity("lv", "alpha");
988 e.raw_section_headings = headings.iter().map(|h| h.to_string()).collect();
989 e.sections
990 .insert("notes".into(), "## Scratch\n\nkept\n".into());
991 store.upsert(e.id.clone(), e);
992
993 let obs = body_observations(&store, "lv", &schema);
994 let repeats: Vec<_> = obs
995 .iter()
996 .filter(|o| o.code == "REPEATED_SECTION_HEADING")
997 .collect();
998 assert_eq!(repeats.len(), 1, "{label}: got {obs:?}");
999 assert!(repeats[0].occurrences_is(2), "{label}");
1000 assert_eq!(repeats[0].fate, ObservationFate::Dropped, "{label}");
1001 }
1002 }
1003
1004 #[test]
1008 fn an_ordinary_entity_produces_no_observations() {
1009 let schema = lint_schema();
1010 let mut store = Store::new();
1011 let mut e = conformant_entity("lv", "alpha");
1012 e.raw_section_headings = vec!["Body".into(), "Notes".into(), "Relationships".into()];
1018 e.sections.insert("notes".into(), "plain prose\n".into());
1019 store.upsert(e.id.clone(), e);
1020 assert!(
1021 body_observations(&store, "lv", &schema).is_empty(),
1022 "declared headings, each once, the relationships block, no undeclared keys"
1023 );
1024 }
1025
1026 #[test]
1027 fn a_repeated_undeclared_heading_claims_survival_only_for_the_first() {
1028 let schema = lint_schema();
1032 let mut store = Store::new();
1033 let mut e = conformant_entity("lv", "alpha");
1034 e.raw_section_headings = vec!["Body".into(), "Scratch".into(), "Scratch".into()];
1035 e.sections
1036 .insert("notes".into(), "## Scratch\n\nkept\n".into());
1037 store.upsert(e.id.clone(), e);
1038 let obs = body_observations(&store, "lv", &schema);
1039 let absorbed: Vec<_> = obs
1040 .iter()
1041 .filter(|o| o.code == "ABSORBED_SECTION")
1042 .collect();
1043 assert_eq!(
1044 absorbed.len(),
1045 1,
1046 "one per heading, not per occurrence: {obs:?}"
1047 );
1048 assert_eq!(absorbed[0].fate, ObservationFate::Absorbed);
1049 let repeats: Vec<_> = obs
1051 .iter()
1052 .filter(|o| o.code == "REPEATED_SECTION_HEADING")
1053 .collect();
1054 assert_eq!(repeats.len(), 1, "got: {obs:?}");
1055 assert_eq!(repeats[0].detail["occurrences"], 2);
1056 }
1057
1058 #[test]
1059 fn the_auto_managed_relationships_block_is_never_an_observation() {
1060 let schema = lint_schema();
1065 let mut store = Store::new();
1066 let mut e = conformant_entity("lv", "alpha");
1067 e.raw_section_headings = vec!["Relationships".into()];
1068 store.upsert(e.id.clone(), e);
1069 assert!(
1070 body_observations(&store, "lv", &schema).is_empty(),
1071 "the relationships block is engine-owned, not undeclared content"
1072 );
1073 }
1074
1075 #[test]
1076 fn a_heading_named_inside_prose_is_not_mistaken_for_a_kept_one() {
1077 let schema = lint_schema();
1082 let mut store = Store::new();
1083 let mut e = conformant_entity("lv", "alpha");
1084 e.raw_section_headings = vec!["Body".into(), "Scratch".into()];
1085 e.sections
1086 .insert("notes".into(), "we discussed Scratch at length\n".into());
1087 store.upsert(e.id.clone(), e);
1088 let obs = body_observations(&store, "lv", &schema);
1089 let absorbed: Vec<_> = obs
1090 .iter()
1091 .filter(|o| o.code == "ABSORBED_SECTION")
1092 .collect();
1093 assert_eq!(absorbed.len(), 1, "got: {obs:?}");
1094 assert_eq!(
1095 absorbed[0].fate,
1096 ObservationFate::Dropped,
1097 "a bare heading whose text appears in prose is still dropped"
1098 );
1099 }
1100
1101 #[test]
1102 fn an_unterminated_fence_names_the_sections_it_swallowed() {
1103 let schema = lint_schema();
1108 let mut store = Store::new();
1109 let mut e = conformant_entity("lv", "alpha");
1110 e.sections.insert(
1111 "body".into(),
1112 "intro\n\n```rust\nfn main() {}\n\n## Notes\n\nthe real notes\n".into(),
1113 );
1114 e.sections.shift_remove("notes");
1115 store.upsert(e.id.clone(), e);
1116 let schemas = schemas_for(&[("lv", schema.clone())]);
1117 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1118 let fence: Vec<_> = findings
1119 .iter()
1120 .filter(|f| f.code == "UNTERMINATED_FENCE")
1121 .collect();
1122 assert_eq!(fence.len(), 1, "got: {:?}", codes(&findings));
1123 assert_eq!(fence[0].id, "lv--alpha");
1124 assert_eq!(fence[0].detail["section"], "body");
1125 assert_eq!(fence[0].detail["fence"], "```");
1126 assert_eq!(
1127 fence[0].detail["swallowed_sections"],
1128 serde_json::json!(["Notes"]),
1129 );
1130 assert!(!findings.is_empty());
1133 }
1134
1135 #[test]
1136 fn an_entity_with_no_open_fence_gains_no_fence_finding() {
1137 let schema = lint_schema();
1141 let schemas = schemas_for(&[("lv", schema.clone())]);
1142 for body in [
1143 "just prose",
1144 "prose\n\n```rust\nfn main() {}\n```\n\nmore",
1145 "```md\n## Notes\n```",
1146 ] {
1147 let mut store = Store::new();
1148 let mut e = conformant_entity("lv", "alpha");
1149 e.sections.insert("body".into(), body.into());
1150 store.upsert(e.id.clone(), e);
1151 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1152 assert!(
1153 !findings.iter().any(|f| f.code == "UNTERMINATED_FENCE"),
1154 "body {body:?} produced: {:?}",
1155 codes(&findings)
1156 );
1157 }
1158 }
1159
1160 #[test]
1161 fn clean_mem_produces_no_findings() {
1162 let schema = lint_schema();
1163 let mut store = Store::new();
1164 let a = conformant_entity("lv", "alpha");
1165 let mut b = conformant_entity("lv", "beta");
1166 b.relationships
1167 .push(Relationship::new("IMPLEMENTS", a.id.clone()));
1168 store.upsert(a.id.clone(), a);
1169 store.upsert(b.id.clone(), b);
1170 let schemas = schemas_for(&[("lv", schema.clone())]);
1171 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1172 assert!(findings.is_empty(), "got: {:?}", codes(&findings));
1173 }
1174
1175 #[test]
1176 fn missing_required_section_and_field_carry_write_time_codes() {
1177 let schema = lint_schema();
1178 let mut store = Store::new();
1179 let e = entity("lv", "broken", "doc");
1181 let id = e.id.to_string();
1182 store.upsert(e.id.clone(), e);
1183 let schemas = schemas_for(&[("lv", schema.clone())]);
1184 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1185 let cs = codes(&findings);
1186 assert!(cs.contains(&"MISSING_REQUIRED_SECTION"), "got: {cs:?}");
1187 assert!(cs.contains(&"REQUIRED_FIELD_UNSET"), "got: {cs:?}");
1188 for f in &findings {
1189 assert_eq!(f.id, id);
1190 assert_eq!(f.axis, IntegrityAxis::Conformance);
1191 }
1192 let section_finding = findings
1194 .iter()
1195 .find(|f| f.code == "MISSING_REQUIRED_SECTION")
1196 .unwrap();
1197 assert_eq!(
1198 section_finding.detail["sections"][0]["key"].as_str(),
1199 Some("body")
1200 );
1201 let field_finding = findings
1202 .iter()
1203 .find(|f| f.code == "REQUIRED_FIELD_UNSET")
1204 .unwrap();
1205 assert_eq!(field_finding.detail["field"].as_str(), Some("status"));
1206 }
1207
1208 #[test]
1209 fn invalid_enum_unknown_section_and_unknown_metadata_surface() {
1210 let schema = lint_schema();
1211 let mut store = Store::new();
1212 let mut e = conformant_entity("lv", "drifted");
1213 e.metadata.insert(
1214 "status".to_string(),
1215 MetadataValue::String("banana".to_string()),
1216 );
1217 e.metadata
1218 .insert("wat".to_string(), MetadataValue::String("x".to_string()));
1219 e.sections.insert("bogus".to_string(), "text".to_string());
1220 store.upsert(e.id.clone(), e);
1221 let schemas = schemas_for(&[("lv", schema.clone())]);
1222 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1223 let cs = codes(&findings);
1224 assert!(cs.contains(&"INVALID_ENUM_VALUE"), "got: {cs:?}");
1225 assert!(cs.contains(&"UNKNOWN_SECTION"), "got: {cs:?}");
1226 assert!(cs.contains(&"UNKNOWN_METADATA_FIELD"), "got: {cs:?}");
1227 let enum_finding = findings
1228 .iter()
1229 .find(|f| f.code == "INVALID_ENUM_VALUE")
1230 .unwrap();
1231 assert_eq!(enum_finding.detail["value"].as_str(), Some("banana"));
1232 assert_eq!(
1233 enum_finding.detail["allowed"]
1234 .as_array()
1235 .unwrap()
1236 .iter()
1237 .map(|v| v.as_str().unwrap())
1238 .collect::<Vec<_>>(),
1239 vec!["open", "closed"]
1240 );
1241 }
1242
1243 #[test]
1244 fn unknown_type_short_circuits_with_unknown_entity_type() {
1245 let schema = lint_schema();
1246 let mut store = Store::new();
1247 let e = entity("lv", "mystery", "ghost");
1248 store.upsert(e.id.clone(), e);
1249 let schemas = schemas_for(&[("lv", schema.clone())]);
1250 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1251 assert_eq!(codes(&findings), vec!["UNKNOWN_ENTITY_TYPE"]);
1252 assert_eq!(findings[0].detail["name"].as_str(), Some("ghost"));
1253 }
1254
1255 #[test]
1256 fn invalid_rel_type_and_shape_surface() {
1257 let schema = lint_schema();
1258 let mut store = Store::new();
1259 let mut req_target = conformant_entity("lv", "target");
1260 req_target.entity_type = "req".to_string();
1261 req_target.metadata.clear();
1263 req_target.sections.clear();
1264 let mut e = conformant_entity("lv", "edges");
1265 e.relationships
1266 .push(Relationship::new("UNDECLARED", req_target.id.clone()));
1267 e.relationships
1269 .push(Relationship::new("IMPLEMENTS", req_target.id.clone()));
1270 store.upsert(req_target.id.clone(), req_target);
1271 store.upsert(e.id.clone(), e);
1272 let schemas = schemas_for(&[("lv", schema.clone())]);
1273 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1274 let cs = codes(&findings);
1275 assert!(cs.contains(&"INVALID_REL_TYPE"), "got: {cs:?}");
1276 assert!(cs.contains(&"INVALID_REL_SHAPE"), "got: {cs:?}");
1277 }
1278
1279 #[test]
1280 fn cross_mem_edges_lint_like_the_write_path() {
1281 let schema = lint_schema();
1282 let other = other_schema();
1283 let mut store = Store::new();
1284 let mut requirement = entity("tv", "goal", "requirement");
1285 requirement
1286 .sections
1287 .insert("body".to_string(), "x".to_string());
1288 let mut task = entity("tv", "chore", "task");
1289 task.sections.insert("body".to_string(), "x".to_string());
1290
1291 let mut e = conformant_entity("lv", "linker");
1292 e.relationships
1294 .push(Relationship::new("ADDRESSES", requirement.id.clone()));
1295 e.relationships
1298 .push(Relationship::new("ADDRESSES", task.id.clone()));
1299 e.relationships
1301 .push(Relationship::new("IMPLEMENTS", requirement.id.clone()));
1302 store.upsert(requirement.id.clone(), requirement);
1303 store.upsert(task.id.clone(), task);
1304 store.upsert(e.id.clone(), e);
1305 let schemas = schemas_for(&[("lv", schema.clone()), ("tv", other)]);
1306 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1307 let cs = codes(&findings);
1308 assert_eq!(
1309 cs,
1310 vec!["INVALID_REL_SHAPE", "INVALID_REL_TYPE"],
1311 "declared+conformant edge must stay silent; got: {cs:?}"
1312 );
1313 }
1314
1315 #[test]
1316 fn stub_entities_are_skipped() {
1317 let schema = lint_schema();
1318 let mut store = Store::new();
1319 let mut stub = entity("lv", "ghost-stub", "");
1320 stub.stub = true;
1321 store.upsert(stub.id.clone(), stub);
1322 let schemas = schemas_for(&[("lv", schema.clone())]);
1323 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1324 assert!(findings.is_empty());
1325 }
1326
1327 #[test]
1328 fn other_mems_are_out_of_scope() {
1329 let schema = lint_schema();
1330 let mut store = Store::new();
1331 let e = entity("elsewhere", "broken", "doc");
1332 store.upsert(e.id.clone(), e);
1333 let schemas = schemas_for(&[("lv", schema.clone())]);
1334 let findings = conformance_findings(&store, "lv", &schema, &schemas);
1335 assert!(findings.is_empty());
1336 }
1337
1338 #[test]
1339 fn findings_are_deterministic_and_id_ordered() {
1340 let schema = lint_schema();
1341 let mut store = Store::new();
1342 for slug in ["zeta", "alpha", "mid"] {
1344 let e = entity("lv", slug, "doc");
1345 store.upsert(e.id.clone(), e);
1346 }
1347 let schemas = schemas_for(&[("lv", schema.clone())]);
1348 let first = conformance_findings(&store, "lv", &schema, &schemas);
1349 let second = conformance_findings(&store, "lv", &schema, &schemas);
1350 let a = serde_json::to_string(&first).unwrap();
1351 let b = serde_json::to_string(&second).unwrap();
1352 assert_eq!(a, b, "two runs must be byte-identical");
1353 let ids: Vec<&str> = first.iter().map(|f| f.id.as_str()).collect();
1354 let mut sorted = ids.clone();
1355 sorted.sort();
1356 assert_eq!(ids, sorted, "findings must be in lexical id order");
1357 }
1358
1359 #[test]
1360 fn lint_against_target_schema_differs_from_pin() {
1361 let pin = lint_schema();
1366 let target = other_schema();
1367 let mut store = Store::new();
1368 let mut e = entity("lv", "shifting", "task");
1369 e.sections.insert("body".to_string(), "x".to_string());
1370 store.upsert(e.id.clone(), e);
1371 let schemas = schemas_for(&[("lv", pin.clone())]);
1372 let against_pin = conformance_findings(&store, "lv", &pin, &schemas);
1373 assert_eq!(codes(&against_pin), vec!["UNKNOWN_ENTITY_TYPE"]);
1374 let against_target = conformance_findings(&store, "lv", &target, &schemas);
1375 assert!(
1376 against_target.is_empty(),
1377 "got: {:?}",
1378 codes(&against_target)
1379 );
1380 }
1381}