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)]
58pub struct IntegrityFinding {
59 pub id: String,
60 pub axis: IntegrityAxis,
61 pub code: String,
62 pub detail: serde_json::Value,
63}
64
65impl IntegrityFinding {
66 fn conformance(id: &crate::entity::EntityId, err: &EngineError) -> Self {
67 Self {
68 id: id.to_string(),
69 axis: IntegrityAxis::Conformance,
70 code: err.code().to_string(),
71 detail: err.details(),
72 }
73 }
74}
75
76pub fn conformance_findings(
89 store: &Store,
90 mem: &str,
91 schema: &Schema,
92 mem_schemas: &HashMap<String, Arc<Schema>>,
93) -> Vec<IntegrityFinding> {
94 let mut entities: Vec<&Entity> = store
95 .all_entities()
96 .filter(|e| e.mem == mem && !e.stub)
97 .collect();
98 entities.sort_by(|a, b| a.id.0.cmp(&b.id.0));
99
100 let mut findings = Vec::new();
101 for entity in entities {
102 lint_entity(store, entity, schema, mem_schemas, &mut findings);
103 }
104 findings
105}
106
107pub fn consistency_findings(store: &Store, mem: &str) -> Vec<IntegrityFinding> {
114 let mut findings = Vec::new();
115 for link in super::health::collect_dangling_links(store, Some(mem)) {
116 findings.push(IntegrityFinding {
117 id: link.from.to_string(),
118 axis: IntegrityAxis::Consistency,
119 code: "DANGLING_LINK".to_string(),
120 detail: serde_json::json!({
121 "from": link.from,
122 "target_id": link.target_id,
123 "target_path": link.target_path,
124 "section": link.section,
125 }),
126 });
127 }
128 for (stub_id, referrers) in crate::graph::query::find_stubs(store) {
129 if stub_id.mem() != mem {
130 continue;
131 }
132 findings.push(IntegrityFinding {
133 id: stub_id.to_string(),
134 axis: IntegrityAxis::Consistency,
135 code: "ORPHAN_STUB".to_string(),
136 detail: serde_json::json!({ "referrers": referrers }),
137 });
138 }
139 findings.sort_by(|a, b| {
143 a.id.cmp(&b.id)
144 .then_with(|| a.code.cmp(&b.code))
145 .then_with(|| a.detail.to_string().cmp(&b.detail.to_string()))
146 });
147 findings
148}
149
150pub fn entity_conformance_findings(
156 store: &Store,
157 entity: &Entity,
158 schema: &Schema,
159 mem_schemas: &HashMap<String, Arc<Schema>>,
160) -> Vec<IntegrityFinding> {
161 let mut findings = Vec::new();
162 lint_entity(store, entity, schema, mem_schemas, &mut findings);
163 findings
164}
165
166fn lint_entity(
167 store: &Store,
168 entity: &Entity,
169 schema: &Schema,
170 mem_schemas: &HashMap<String, Arc<Schema>>,
171 findings: &mut Vec<IntegrityFinding>,
172) {
173 let Some(type_def) = schema.types.get(entity.entity_type.as_str()) else {
177 findings.push(IntegrityFinding::conformance(
178 &entity.id,
179 &unknown_type_error(schema, &entity.entity_type),
180 ));
181 return;
182 };
183
184 for key in entity.sections.keys() {
188 if let Err(v) = validate_section_keys(std::iter::once(key.as_str()), type_def) {
189 findings.push(IntegrityFinding::conformance(
190 &entity.id,
191 &EngineError::Validation(v),
192 ));
193 }
194 }
195
196 let missing_sections = missing_required_sections(type_def, &entity.sections);
199 if !missing_sections.is_empty() {
200 let mut type_guidance: std::collections::BTreeMap<String, Vec<String>> = Default::default();
201 if !type_def.write_rules.is_empty() {
202 type_guidance.insert(entity.entity_type.clone(), type_def.write_rules.clone());
203 }
204 findings.push(IntegrityFinding::conformance(
205 &entity.id,
206 &EngineError::MissingRequiredSection {
207 entity_type: entity.entity_type.clone(),
208 missing_count: missing_sections.len(),
209 sections: missing_sections,
210 type_guidance,
211 pre_announced_missing_fields: Vec::new(),
215 },
216 ));
217 }
218
219 let mut supplied: IndexMap<String, String> = IndexMap::new();
223 for (key, value) in &entity.metadata {
224 let raw = value.to_frontmatter_string();
225 supplied.insert(key.clone(), raw.clone());
226 if READ_ONLY_METADATA_KEYS.iter().any(|k| k == key) {
227 continue;
228 }
229 if let Err(v) = parse_metadata_value(key, &raw, type_def) {
230 findings.push(IntegrityFinding::conformance(
231 &entity.id,
232 &EngineError::Validation(v),
233 ));
234 }
235 }
236
237 let missing_fields = missing_required_fields(type_def, &supplied);
240 if let Some(first) = missing_fields.first() {
241 findings.push(IntegrityFinding::conformance(
242 &entity.id,
243 &EngineError::RequiredFieldUnset {
244 field: first.key.clone(),
245 entity_type: entity.entity_type.clone(),
246 field_description: Some(first.description.clone()),
247 enum_values: first.enum_values.clone(),
248 type_write_rules: type_def.write_rules.clone(),
249 on_create: true,
250 missing: missing_fields.clone(),
251 },
252 ));
253 }
254
255 let (src_name, src_version) = schema.id();
261 for rel in &entity.relationships {
262 let target_mem = rel.target.mem();
263 let target_schema = if target_mem == entity.mem {
264 None
265 } else {
266 mem_schemas.get(target_mem)
267 };
268 let cross_mem_different = target_schema.map(|t| t.id().0 != src_name).unwrap_or(false);
269 let target_type = store
270 .get(&rel.target)
271 .map(|e| e.entity_type.clone())
272 .filter(|t| !t.is_empty());
273
274 if cross_mem_different {
275 let target = target_schema.expect("Some when cross_mem_different");
276 let (t_name, t_version) = target.id();
277 let target_ref = SchemaRef::new(t_name, t_version.clone());
278 match validate_cross_mem_edge(
279 &rel.rel_type,
280 &entity.entity_type,
281 target_type.as_deref(),
282 schema,
283 &target_ref,
284 ) {
285 CrossMemRelCheck::Ok => {}
286 CrossMemRelCheck::EdgeNotDeclared => {
287 findings.push(IntegrityFinding::conformance(
288 &entity.id,
289 &EngineError::CrossMemEdgeNotDeclared {
290 source_schema: format!("{src_name}@{src_version}"),
291 target_schema: target_ref.as_display(),
292 rel_type: rel.rel_type.clone(),
293 from_id: entity.id.to_string(),
294 to_id: rel.target.to_string(),
295 },
296 ));
297 }
298 CrossMemRelCheck::Invalid(v) => {
299 findings.push(IntegrityFinding::conformance(
300 &entity.id,
301 &EngineError::Validation(v),
302 ));
303 }
304 }
305 } else {
306 match validate_rel_type(&rel.rel_type, schema) {
307 Ok(RelationshipCheck::Ok) | Ok(RelationshipCheck::OpenWarning(_)) => {}
310 Err(v) => {
311 findings.push(IntegrityFinding::conformance(
312 &entity.id,
313 &EngineError::Validation(v),
314 ));
315 continue;
316 }
317 }
318 if let Err(v) = validate_rel_shape(
319 &rel.rel_type,
320 &entity.entity_type,
321 target_type.as_deref(),
322 schema,
323 ) {
324 findings.push(IntegrityFinding::conformance(
325 &entity.id,
326 &EngineError::Validation(v),
327 ));
328 }
329 }
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::entity::{EntityId, MetadataValue, Relationship};
337
338 const TYPE_TAIL: &str = r#"sections:
339 - key: body
340 heading: Body
341 required: true
342 search_weight: 10.0
343 catch_all: false
344 write_rules: []
345 - key: notes
346 heading: Notes
347 required: false
348 search_weight: 1.0
349 catch_all: true
350 write_rules: []
351metadata_fields:
352 - key: status
353 description: Lifecycle state
354 field_type: string
355 enum_values:
356 - open
357 - closed
358title_weight: 100.0
359text_fields:
360 - body
361hierarchy_relationship: _default
362no_self_loop_relationships: []
363updatable_fields:
364 - title
365 - body
366 - notes
367 - status
368health_required_fields:
369 - body
370staleness_threshold_days: 90
371write_rules: []
372"#;
373
374 const PLAIN_TYPE_TAIL: &str = r#"sections:
375 - key: body
376 heading: Body
377 required: false
378 search_weight: 10.0
379 catch_all: true
380 write_rules: []
381metadata_fields: []
382title_weight: 100.0
383text_fields:
384 - body
385hierarchy_relationship: _default
386no_self_loop_relationships: []
387updatable_fields:
388 - title
389 - body
390health_required_fields: []
391staleness_threshold_days: 90
392write_rules: []
393"#;
394
395 fn lint_schema() -> Arc<Schema> {
401 let manifest = r#"name: lint-src
402version: 0.1.0
403description: linter test schema
404when_to_use: tests
405types:
406 - doc
407 - req
408relationships:
409 mode: strict
410 definitions:
411 - name: IMPLEMENTS
412 description: shape-pinned
413 default_weight: 1.0
414 source_types: [doc]
415 target_types: [doc]
416 - name: _default
417 description: fallback
418 default_weight: 1.0
419cross_mem_relationships:
420 - to_schema: other
421 definitions:
422 - name: ADDRESSES
423 description: outbound
424 default_weight: 1.0
425 source_types: [doc]
426 target_types: [requirement]
427community:
428 resolution: 1.0
429 seed: 42
430"#;
431 Arc::new(
432 memstead_schema::load_schema_from_memory(
433 manifest,
434 &[
435 (
436 "doc".to_string(),
437 format!("name: doc\ndescription: t\nwhen_to_use: tests\n{TYPE_TAIL}"),
438 ),
439 (
440 "req".to_string(),
441 format!("name: req\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"),
442 ),
443 ],
444 )
445 .expect("lint schema loads"),
446 )
447 }
448
449 fn other_schema() -> Arc<Schema> {
452 let manifest = r#"name: other
453version: 1.0.0
454description: target schema
455when_to_use: tests
456types:
457 - requirement
458 - task
459relationships:
460 mode: strict
461 definitions:
462 - name: _default
463 description: fallback
464 default_weight: 1.0
465community:
466 resolution: 1.0
467 seed: 42
468"#;
469 Arc::new(
470 memstead_schema::load_schema_from_memory(
471 manifest,
472 &[
473 (
474 "requirement".to_string(),
475 format!(
476 "name: requirement\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"
477 ),
478 ),
479 (
480 "task".to_string(),
481 format!("name: task\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"),
482 ),
483 ],
484 )
485 .expect("other schema loads"),
486 )
487 }
488
489 fn entity(mem: &str, slug: &str, entity_type: &str) -> Entity {
490 Entity {
491 id: EntityId::new(mem, slug),
492 title: slug.to_string(),
493 entity_type: entity_type.to_string(),
494 mem: mem.to_string(),
495 file_path: format!("{slug}.md"),
496 metadata: IndexMap::new(),
497 sections: IndexMap::new(),
498 relationships: Vec::new(),
499 content_hash: "h".to_string(),
500 stub: false,
501 stub_kind: None,
502 heading_spans: Default::default(),
503 raw_section_headings: Vec::new(),
504 }
505 }
506
507 fn conformant_entity(mem: &str, slug: &str) -> Entity {
508 let mut e = entity(mem, slug, "doc");
509 e.sections.insert("body".to_string(), "content".to_string());
510 e.metadata.insert(
511 "status".to_string(),
512 MetadataValue::String("open".to_string()),
513 );
514 e
515 }
516
517 fn schemas_for(entries: &[(&str, Arc<Schema>)]) -> HashMap<String, Arc<Schema>> {
518 entries
519 .iter()
520 .map(|(v, s)| (v.to_string(), s.clone()))
521 .collect()
522 }
523
524 fn codes(findings: &[IntegrityFinding]) -> Vec<&str> {
525 findings.iter().map(|f| f.code.as_str()).collect()
526 }
527
528 #[test]
529 fn clean_mem_produces_no_findings() {
530 let schema = lint_schema();
531 let mut store = Store::new();
532 let a = conformant_entity("lv", "alpha");
533 let mut b = conformant_entity("lv", "beta");
534 b.relationships
535 .push(Relationship::new("IMPLEMENTS", a.id.clone()));
536 store.upsert(a.id.clone(), a);
537 store.upsert(b.id.clone(), b);
538 let schemas = schemas_for(&[("lv", schema.clone())]);
539 let findings = conformance_findings(&store, "lv", &schema, &schemas);
540 assert!(findings.is_empty(), "got: {:?}", codes(&findings));
541 }
542
543 #[test]
544 fn missing_required_section_and_field_carry_write_time_codes() {
545 let schema = lint_schema();
546 let mut store = Store::new();
547 let e = entity("lv", "broken", "doc");
549 let id = e.id.to_string();
550 store.upsert(e.id.clone(), e);
551 let schemas = schemas_for(&[("lv", schema.clone())]);
552 let findings = conformance_findings(&store, "lv", &schema, &schemas);
553 let cs = codes(&findings);
554 assert!(cs.contains(&"MISSING_REQUIRED_SECTION"), "got: {cs:?}");
555 assert!(cs.contains(&"REQUIRED_FIELD_UNSET"), "got: {cs:?}");
556 for f in &findings {
557 assert_eq!(f.id, id);
558 assert_eq!(f.axis, IntegrityAxis::Conformance);
559 }
560 let section_finding = findings
562 .iter()
563 .find(|f| f.code == "MISSING_REQUIRED_SECTION")
564 .unwrap();
565 assert_eq!(
566 section_finding.detail["sections"][0]["key"].as_str(),
567 Some("body")
568 );
569 let field_finding = findings
570 .iter()
571 .find(|f| f.code == "REQUIRED_FIELD_UNSET")
572 .unwrap();
573 assert_eq!(field_finding.detail["field"].as_str(), Some("status"));
574 }
575
576 #[test]
577 fn invalid_enum_unknown_section_and_unknown_metadata_surface() {
578 let schema = lint_schema();
579 let mut store = Store::new();
580 let mut e = conformant_entity("lv", "drifted");
581 e.metadata.insert(
582 "status".to_string(),
583 MetadataValue::String("banana".to_string()),
584 );
585 e.metadata
586 .insert("wat".to_string(), MetadataValue::String("x".to_string()));
587 e.sections.insert("bogus".to_string(), "text".to_string());
588 store.upsert(e.id.clone(), e);
589 let schemas = schemas_for(&[("lv", schema.clone())]);
590 let findings = conformance_findings(&store, "lv", &schema, &schemas);
591 let cs = codes(&findings);
592 assert!(cs.contains(&"INVALID_ENUM_VALUE"), "got: {cs:?}");
593 assert!(cs.contains(&"UNKNOWN_SECTION"), "got: {cs:?}");
594 assert!(cs.contains(&"UNKNOWN_METADATA_FIELD"), "got: {cs:?}");
595 let enum_finding = findings
596 .iter()
597 .find(|f| f.code == "INVALID_ENUM_VALUE")
598 .unwrap();
599 assert_eq!(enum_finding.detail["value"].as_str(), Some("banana"));
600 assert_eq!(
601 enum_finding.detail["allowed"]
602 .as_array()
603 .unwrap()
604 .iter()
605 .map(|v| v.as_str().unwrap())
606 .collect::<Vec<_>>(),
607 vec!["open", "closed"]
608 );
609 }
610
611 #[test]
612 fn unknown_type_short_circuits_with_unknown_entity_type() {
613 let schema = lint_schema();
614 let mut store = Store::new();
615 let e = entity("lv", "mystery", "ghost");
616 store.upsert(e.id.clone(), e);
617 let schemas = schemas_for(&[("lv", schema.clone())]);
618 let findings = conformance_findings(&store, "lv", &schema, &schemas);
619 assert_eq!(codes(&findings), vec!["UNKNOWN_ENTITY_TYPE"]);
620 assert_eq!(findings[0].detail["name"].as_str(), Some("ghost"));
621 }
622
623 #[test]
624 fn invalid_rel_type_and_shape_surface() {
625 let schema = lint_schema();
626 let mut store = Store::new();
627 let mut req_target = conformant_entity("lv", "target");
628 req_target.entity_type = "req".to_string();
629 req_target.metadata.clear();
631 req_target.sections.clear();
632 let mut e = conformant_entity("lv", "edges");
633 e.relationships
634 .push(Relationship::new("UNDECLARED", req_target.id.clone()));
635 e.relationships
637 .push(Relationship::new("IMPLEMENTS", req_target.id.clone()));
638 store.upsert(req_target.id.clone(), req_target);
639 store.upsert(e.id.clone(), e);
640 let schemas = schemas_for(&[("lv", schema.clone())]);
641 let findings = conformance_findings(&store, "lv", &schema, &schemas);
642 let cs = codes(&findings);
643 assert!(cs.contains(&"INVALID_REL_TYPE"), "got: {cs:?}");
644 assert!(cs.contains(&"INVALID_REL_SHAPE"), "got: {cs:?}");
645 }
646
647 #[test]
648 fn cross_mem_edges_lint_like_the_write_path() {
649 let schema = lint_schema();
650 let other = other_schema();
651 let mut store = Store::new();
652 let mut requirement = entity("tv", "goal", "requirement");
653 requirement
654 .sections
655 .insert("body".to_string(), "x".to_string());
656 let mut task = entity("tv", "chore", "task");
657 task.sections.insert("body".to_string(), "x".to_string());
658
659 let mut e = conformant_entity("lv", "linker");
660 e.relationships
662 .push(Relationship::new("ADDRESSES", requirement.id.clone()));
663 e.relationships
666 .push(Relationship::new("ADDRESSES", task.id.clone()));
667 e.relationships
669 .push(Relationship::new("IMPLEMENTS", requirement.id.clone()));
670 store.upsert(requirement.id.clone(), requirement);
671 store.upsert(task.id.clone(), task);
672 store.upsert(e.id.clone(), e);
673 let schemas = schemas_for(&[("lv", schema.clone()), ("tv", other)]);
674 let findings = conformance_findings(&store, "lv", &schema, &schemas);
675 let cs = codes(&findings);
676 assert_eq!(
677 cs,
678 vec!["INVALID_REL_SHAPE", "INVALID_REL_TYPE"],
679 "declared+conformant edge must stay silent; got: {cs:?}"
680 );
681 }
682
683 #[test]
684 fn stub_entities_are_skipped() {
685 let schema = lint_schema();
686 let mut store = Store::new();
687 let mut stub = entity("lv", "ghost-stub", "");
688 stub.stub = true;
689 store.upsert(stub.id.clone(), stub);
690 let schemas = schemas_for(&[("lv", schema.clone())]);
691 let findings = conformance_findings(&store, "lv", &schema, &schemas);
692 assert!(findings.is_empty());
693 }
694
695 #[test]
696 fn other_mems_are_out_of_scope() {
697 let schema = lint_schema();
698 let mut store = Store::new();
699 let e = entity("elsewhere", "broken", "doc");
700 store.upsert(e.id.clone(), e);
701 let schemas = schemas_for(&[("lv", schema.clone())]);
702 let findings = conformance_findings(&store, "lv", &schema, &schemas);
703 assert!(findings.is_empty());
704 }
705
706 #[test]
707 fn findings_are_deterministic_and_id_ordered() {
708 let schema = lint_schema();
709 let mut store = Store::new();
710 for slug in ["zeta", "alpha", "mid"] {
712 let e = entity("lv", slug, "doc");
713 store.upsert(e.id.clone(), e);
714 }
715 let schemas = schemas_for(&[("lv", schema.clone())]);
716 let first = conformance_findings(&store, "lv", &schema, &schemas);
717 let second = conformance_findings(&store, "lv", &schema, &schemas);
718 let a = serde_json::to_string(&first).unwrap();
719 let b = serde_json::to_string(&second).unwrap();
720 assert_eq!(a, b, "two runs must be byte-identical");
721 let ids: Vec<&str> = first.iter().map(|f| f.id.as_str()).collect();
722 let mut sorted = ids.clone();
723 sorted.sort();
724 assert_eq!(ids, sorted, "findings must be in lexical id order");
725 }
726
727 #[test]
728 fn lint_against_target_schema_differs_from_pin() {
729 let pin = lint_schema();
734 let target = other_schema();
735 let mut store = Store::new();
736 let mut e = entity("lv", "shifting", "task");
737 e.sections.insert("body".to_string(), "x".to_string());
738 store.upsert(e.id.clone(), e);
739 let schemas = schemas_for(&[("lv", pin.clone())]);
740 let against_pin = conformance_findings(&store, "lv", &pin, &schemas);
741 assert_eq!(codes(&against_pin), vec!["UNKNOWN_ENTITY_TYPE"]);
742 let against_target = conformance_findings(&store, "lv", &target, &schemas);
743 assert!(
744 against_target.is_empty(),
745 "got: {:?}",
746 codes(&against_target)
747 );
748 }
749}