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