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 },
212 ));
213 }
214
215 let mut supplied: IndexMap<String, String> = IndexMap::new();
219 for (key, value) in &entity.metadata {
220 let raw = value.to_frontmatter_string();
221 supplied.insert(key.clone(), raw.clone());
222 if READ_ONLY_METADATA_KEYS.iter().any(|k| k == key) {
223 continue;
224 }
225 if let Err(v) = parse_metadata_value(key, &raw, type_def) {
226 findings.push(IntegrityFinding::conformance(
227 &entity.id,
228 &EngineError::Validation(v),
229 ));
230 }
231 }
232
233 let missing_fields = missing_required_fields(type_def, &supplied);
236 if let Some(first) = missing_fields.first() {
237 findings.push(IntegrityFinding::conformance(
238 &entity.id,
239 &EngineError::RequiredFieldUnset {
240 field: first.key.clone(),
241 entity_type: entity.entity_type.clone(),
242 field_description: Some(first.description.clone()),
243 enum_values: first.enum_values.clone(),
244 type_write_rules: type_def.write_rules.clone(),
245 on_create: true,
246 missing: missing_fields.clone(),
247 },
248 ));
249 }
250
251 let (src_name, src_version) = schema.id();
257 for rel in &entity.relationships {
258 let target_mem = rel.target.mem();
259 let target_schema = if target_mem == entity.mem {
260 None
261 } else {
262 mem_schemas.get(target_mem)
263 };
264 let cross_mem_different = target_schema.map(|t| t.id().0 != src_name).unwrap_or(false);
265 let target_type = store
266 .get(&rel.target)
267 .map(|e| e.entity_type.clone())
268 .filter(|t| !t.is_empty());
269
270 if cross_mem_different {
271 let target = target_schema.expect("Some when cross_mem_different");
272 let (t_name, t_version) = target.id();
273 let target_ref = SchemaRef::new(t_name, t_version.clone());
274 match validate_cross_mem_edge(
275 &rel.rel_type,
276 &entity.entity_type,
277 target_type.as_deref(),
278 schema,
279 &target_ref,
280 ) {
281 CrossMemRelCheck::Ok => {}
282 CrossMemRelCheck::EdgeNotDeclared => {
283 findings.push(IntegrityFinding::conformance(
284 &entity.id,
285 &EngineError::CrossMemEdgeNotDeclared {
286 source_schema: format!("{src_name}@{src_version}"),
287 target_schema: target_ref.as_display(),
288 rel_type: rel.rel_type.clone(),
289 from_id: entity.id.to_string(),
290 to_id: rel.target.to_string(),
291 },
292 ));
293 }
294 CrossMemRelCheck::Invalid(v) => {
295 findings.push(IntegrityFinding::conformance(
296 &entity.id,
297 &EngineError::Validation(v),
298 ));
299 }
300 }
301 } else {
302 match validate_rel_type(&rel.rel_type, schema) {
303 Ok(RelationshipCheck::Ok) | Ok(RelationshipCheck::OpenWarning(_)) => {}
306 Err(v) => {
307 findings.push(IntegrityFinding::conformance(
308 &entity.id,
309 &EngineError::Validation(v),
310 ));
311 continue;
312 }
313 }
314 if let Err(v) = validate_rel_shape(
315 &rel.rel_type,
316 &entity.entity_type,
317 target_type.as_deref(),
318 schema,
319 ) {
320 findings.push(IntegrityFinding::conformance(
321 &entity.id,
322 &EngineError::Validation(v),
323 ));
324 }
325 }
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use crate::entity::{EntityId, MetadataValue, Relationship};
333
334 const TYPE_TAIL: &str = r#"sections:
335 - key: body
336 heading: Body
337 required: true
338 search_weight: 10.0
339 catch_all: false
340 write_rules: []
341 - key: notes
342 heading: Notes
343 required: false
344 search_weight: 1.0
345 catch_all: true
346 write_rules: []
347metadata_fields:
348 - key: status
349 description: Lifecycle state
350 field_type: string
351 enum_values:
352 - open
353 - closed
354title_weight: 100.0
355text_fields:
356 - body
357hierarchy_relationship: _default
358no_self_loop_relationships: []
359updatable_fields:
360 - title
361 - body
362 - notes
363 - status
364health_required_fields:
365 - body
366staleness_threshold_days: 90
367write_rules: []
368"#;
369
370 const PLAIN_TYPE_TAIL: &str = r#"sections:
371 - key: body
372 heading: Body
373 required: false
374 search_weight: 10.0
375 catch_all: true
376 write_rules: []
377metadata_fields: []
378title_weight: 100.0
379text_fields:
380 - body
381hierarchy_relationship: _default
382no_self_loop_relationships: []
383updatable_fields:
384 - title
385 - body
386health_required_fields: []
387staleness_threshold_days: 90
388write_rules: []
389"#;
390
391 fn lint_schema() -> Arc<Schema> {
397 let manifest = r#"name: lint-src
398version: 0.1.0
399description: linter test schema
400when_to_use: tests
401types:
402 - doc
403 - req
404relationships:
405 mode: strict
406 definitions:
407 - name: IMPLEMENTS
408 description: shape-pinned
409 default_weight: 1.0
410 source_types: [doc]
411 target_types: [doc]
412 - name: _default
413 description: fallback
414 default_weight: 1.0
415cross_mem_relationships:
416 - to_schema: other
417 definitions:
418 - name: ADDRESSES
419 description: outbound
420 default_weight: 1.0
421 source_types: [doc]
422 target_types: [requirement]
423community:
424 resolution: 1.0
425 seed: 42
426"#;
427 Arc::new(
428 memstead_schema::load_schema_from_memory(
429 manifest,
430 &[
431 (
432 "doc".to_string(),
433 format!("name: doc\ndescription: t\nwhen_to_use: tests\n{TYPE_TAIL}"),
434 ),
435 (
436 "req".to_string(),
437 format!("name: req\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"),
438 ),
439 ],
440 )
441 .expect("lint schema loads"),
442 )
443 }
444
445 fn other_schema() -> Arc<Schema> {
448 let manifest = r#"name: other
449version: 1.0.0
450description: target schema
451when_to_use: tests
452types:
453 - requirement
454 - task
455relationships:
456 mode: strict
457 definitions:
458 - name: _default
459 description: fallback
460 default_weight: 1.0
461community:
462 resolution: 1.0
463 seed: 42
464"#;
465 Arc::new(
466 memstead_schema::load_schema_from_memory(
467 manifest,
468 &[
469 (
470 "requirement".to_string(),
471 format!(
472 "name: requirement\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"
473 ),
474 ),
475 (
476 "task".to_string(),
477 format!("name: task\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"),
478 ),
479 ],
480 )
481 .expect("other schema loads"),
482 )
483 }
484
485 fn entity(mem: &str, slug: &str, entity_type: &str) -> Entity {
486 Entity {
487 id: EntityId::new(mem, slug),
488 title: slug.to_string(),
489 entity_type: entity_type.to_string(),
490 mem: mem.to_string(),
491 file_path: format!("{slug}.md"),
492 metadata: IndexMap::new(),
493 sections: IndexMap::new(),
494 relationships: Vec::new(),
495 content_hash: "h".to_string(),
496 stub: false,
497 stub_kind: None,
498 heading_spans: Default::default(),
499 raw_section_headings: Vec::new(),
500 }
501 }
502
503 fn conformant_entity(mem: &str, slug: &str) -> Entity {
504 let mut e = entity(mem, slug, "doc");
505 e.sections.insert("body".to_string(), "content".to_string());
506 e.metadata.insert(
507 "status".to_string(),
508 MetadataValue::String("open".to_string()),
509 );
510 e
511 }
512
513 fn schemas_for(entries: &[(&str, Arc<Schema>)]) -> HashMap<String, Arc<Schema>> {
514 entries
515 .iter()
516 .map(|(v, s)| (v.to_string(), s.clone()))
517 .collect()
518 }
519
520 fn codes(findings: &[IntegrityFinding]) -> Vec<&str> {
521 findings.iter().map(|f| f.code.as_str()).collect()
522 }
523
524 #[test]
525 fn clean_mem_produces_no_findings() {
526 let schema = lint_schema();
527 let mut store = Store::new();
528 let a = conformant_entity("lv", "alpha");
529 let mut b = conformant_entity("lv", "beta");
530 b.relationships
531 .push(Relationship::new("IMPLEMENTS", a.id.clone()));
532 store.upsert(a.id.clone(), a);
533 store.upsert(b.id.clone(), b);
534 let schemas = schemas_for(&[("lv", schema.clone())]);
535 let findings = conformance_findings(&store, "lv", &schema, &schemas);
536 assert!(findings.is_empty(), "got: {:?}", codes(&findings));
537 }
538
539 #[test]
540 fn missing_required_section_and_field_carry_write_time_codes() {
541 let schema = lint_schema();
542 let mut store = Store::new();
543 let e = entity("lv", "broken", "doc");
545 let id = e.id.to_string();
546 store.upsert(e.id.clone(), e);
547 let schemas = schemas_for(&[("lv", schema.clone())]);
548 let findings = conformance_findings(&store, "lv", &schema, &schemas);
549 let cs = codes(&findings);
550 assert!(cs.contains(&"MISSING_REQUIRED_SECTION"), "got: {cs:?}");
551 assert!(cs.contains(&"REQUIRED_FIELD_UNSET"), "got: {cs:?}");
552 for f in &findings {
553 assert_eq!(f.id, id);
554 assert_eq!(f.axis, IntegrityAxis::Conformance);
555 }
556 let section_finding = findings
558 .iter()
559 .find(|f| f.code == "MISSING_REQUIRED_SECTION")
560 .unwrap();
561 assert_eq!(
562 section_finding.detail["sections"][0]["key"].as_str(),
563 Some("body")
564 );
565 let field_finding = findings
566 .iter()
567 .find(|f| f.code == "REQUIRED_FIELD_UNSET")
568 .unwrap();
569 assert_eq!(field_finding.detail["field"].as_str(), Some("status"));
570 }
571
572 #[test]
573 fn invalid_enum_unknown_section_and_unknown_metadata_surface() {
574 let schema = lint_schema();
575 let mut store = Store::new();
576 let mut e = conformant_entity("lv", "drifted");
577 e.metadata.insert(
578 "status".to_string(),
579 MetadataValue::String("banana".to_string()),
580 );
581 e.metadata
582 .insert("wat".to_string(), MetadataValue::String("x".to_string()));
583 e.sections.insert("bogus".to_string(), "text".to_string());
584 store.upsert(e.id.clone(), e);
585 let schemas = schemas_for(&[("lv", schema.clone())]);
586 let findings = conformance_findings(&store, "lv", &schema, &schemas);
587 let cs = codes(&findings);
588 assert!(cs.contains(&"INVALID_ENUM_VALUE"), "got: {cs:?}");
589 assert!(cs.contains(&"UNKNOWN_SECTION"), "got: {cs:?}");
590 assert!(cs.contains(&"UNKNOWN_METADATA_FIELD"), "got: {cs:?}");
591 let enum_finding = findings
592 .iter()
593 .find(|f| f.code == "INVALID_ENUM_VALUE")
594 .unwrap();
595 assert_eq!(enum_finding.detail["value"].as_str(), Some("banana"));
596 assert_eq!(
597 enum_finding.detail["allowed"]
598 .as_array()
599 .unwrap()
600 .iter()
601 .map(|v| v.as_str().unwrap())
602 .collect::<Vec<_>>(),
603 vec!["open", "closed"]
604 );
605 }
606
607 #[test]
608 fn unknown_type_short_circuits_with_unknown_entity_type() {
609 let schema = lint_schema();
610 let mut store = Store::new();
611 let e = entity("lv", "mystery", "ghost");
612 store.upsert(e.id.clone(), e);
613 let schemas = schemas_for(&[("lv", schema.clone())]);
614 let findings = conformance_findings(&store, "lv", &schema, &schemas);
615 assert_eq!(codes(&findings), vec!["UNKNOWN_ENTITY_TYPE"]);
616 assert_eq!(findings[0].detail["name"].as_str(), Some("ghost"));
617 }
618
619 #[test]
620 fn invalid_rel_type_and_shape_surface() {
621 let schema = lint_schema();
622 let mut store = Store::new();
623 let mut req_target = conformant_entity("lv", "target");
624 req_target.entity_type = "req".to_string();
625 req_target.metadata.clear();
627 req_target.sections.clear();
628 let mut e = conformant_entity("lv", "edges");
629 e.relationships
630 .push(Relationship::new("UNDECLARED", req_target.id.clone()));
631 e.relationships
633 .push(Relationship::new("IMPLEMENTS", req_target.id.clone()));
634 store.upsert(req_target.id.clone(), req_target);
635 store.upsert(e.id.clone(), e);
636 let schemas = schemas_for(&[("lv", schema.clone())]);
637 let findings = conformance_findings(&store, "lv", &schema, &schemas);
638 let cs = codes(&findings);
639 assert!(cs.contains(&"INVALID_REL_TYPE"), "got: {cs:?}");
640 assert!(cs.contains(&"INVALID_REL_SHAPE"), "got: {cs:?}");
641 }
642
643 #[test]
644 fn cross_mem_edges_lint_like_the_write_path() {
645 let schema = lint_schema();
646 let other = other_schema();
647 let mut store = Store::new();
648 let mut requirement = entity("tv", "goal", "requirement");
649 requirement
650 .sections
651 .insert("body".to_string(), "x".to_string());
652 let mut task = entity("tv", "chore", "task");
653 task.sections.insert("body".to_string(), "x".to_string());
654
655 let mut e = conformant_entity("lv", "linker");
656 e.relationships
658 .push(Relationship::new("ADDRESSES", requirement.id.clone()));
659 e.relationships
662 .push(Relationship::new("ADDRESSES", task.id.clone()));
663 e.relationships
665 .push(Relationship::new("IMPLEMENTS", requirement.id.clone()));
666 store.upsert(requirement.id.clone(), requirement);
667 store.upsert(task.id.clone(), task);
668 store.upsert(e.id.clone(), e);
669 let schemas = schemas_for(&[("lv", schema.clone()), ("tv", other)]);
670 let findings = conformance_findings(&store, "lv", &schema, &schemas);
671 let cs = codes(&findings);
672 assert_eq!(
673 cs,
674 vec!["INVALID_REL_SHAPE", "INVALID_REL_TYPE"],
675 "declared+conformant edge must stay silent; got: {cs:?}"
676 );
677 }
678
679 #[test]
680 fn stub_entities_are_skipped() {
681 let schema = lint_schema();
682 let mut store = Store::new();
683 let mut stub = entity("lv", "ghost-stub", "");
684 stub.stub = true;
685 store.upsert(stub.id.clone(), stub);
686 let schemas = schemas_for(&[("lv", schema.clone())]);
687 let findings = conformance_findings(&store, "lv", &schema, &schemas);
688 assert!(findings.is_empty());
689 }
690
691 #[test]
692 fn other_mems_are_out_of_scope() {
693 let schema = lint_schema();
694 let mut store = Store::new();
695 let e = entity("elsewhere", "broken", "doc");
696 store.upsert(e.id.clone(), e);
697 let schemas = schemas_for(&[("lv", schema.clone())]);
698 let findings = conformance_findings(&store, "lv", &schema, &schemas);
699 assert!(findings.is_empty());
700 }
701
702 #[test]
703 fn findings_are_deterministic_and_id_ordered() {
704 let schema = lint_schema();
705 let mut store = Store::new();
706 for slug in ["zeta", "alpha", "mid"] {
708 let e = entity("lv", slug, "doc");
709 store.upsert(e.id.clone(), e);
710 }
711 let schemas = schemas_for(&[("lv", schema.clone())]);
712 let first = conformance_findings(&store, "lv", &schema, &schemas);
713 let second = conformance_findings(&store, "lv", &schema, &schemas);
714 let a = serde_json::to_string(&first).unwrap();
715 let b = serde_json::to_string(&second).unwrap();
716 assert_eq!(a, b, "two runs must be byte-identical");
717 let ids: Vec<&str> = first.iter().map(|f| f.id.as_str()).collect();
718 let mut sorted = ids.clone();
719 sorted.sort();
720 assert_eq!(ids, sorted, "findings must be in lexical id order");
721 }
722
723 #[test]
724 fn lint_against_target_schema_differs_from_pin() {
725 let pin = lint_schema();
730 let target = other_schema();
731 let mut store = Store::new();
732 let mut e = entity("lv", "shifting", "task");
733 e.sections.insert("body".to_string(), "x".to_string());
734 store.upsert(e.id.clone(), e);
735 let schemas = schemas_for(&[("lv", pin.clone())]);
736 let against_pin = conformance_findings(&store, "lv", &pin, &schemas);
737 assert_eq!(codes(&against_pin), vec!["UNKNOWN_ENTITY_TYPE"]);
738 let against_target = conformance_findings(&store, "lv", &target, &schemas);
739 assert!(
740 against_target.is_empty(),
741 "got: {:?}",
742 codes(&against_target)
743 );
744 }
745}