1use std::collections::HashMap;
15use std::sync::Arc;
16
17use memstead_schema::{Schema, TypeDefinition, type_by_name};
18
19use super::{
20 DanglingLink, FoldedTag, HealthIssue, HealthReport, HealthSummary, StaleEntity,
21 TagDistribution, TagVariant, UntaggedStats,
22};
23use crate::entity::MetadataValue;
24use crate::graph::query;
25use crate::store::Store;
26
27pub const HEALTH_INCLUDE_KEYS: &[&str] = &[
33 "orphans",
34 "stubs",
35 "most_connected",
36 "missing_fields",
37 "stale",
38 "dangling_links",
39 "tags",
40 "missing_required_outgoing",
41 "constraints",
42 "signals",
43 "labelling",
44 "conformance",
45 "integrity",
46 "config",
47 "anchors",
48 "friction",
49 "open_questions",
50 "stale_derivations",
51 "checks",
52];
53
54pub fn health_checks_axis(
78 engine: &crate::engine::Engine,
79 mem_filter: Option<&str>,
80) -> serde_json::Value {
81 let cap = OPEN_QUESTIONS_ITEM_CAP;
82 let capped = |mut items: Vec<String>| -> serde_json::Value {
83 items.sort();
84 let count = items.len();
85 let more = count.saturating_sub(cap);
86 items.truncate(cap);
87 let mut o = serde_json::Map::new();
88 o.insert("count".into(), serde_json::json!(count));
89 o.insert("items".into(), serde_json::json!(items));
90 if more > 0 {
91 o.insert("more".into(), serde_json::json!(more));
92 }
93 serde_json::Value::Object(o)
94 };
95
96 let ledger = engine
97 .workspace_root()
98 .map(crate::check::CheckLedger::for_workspace);
99 let mut latest: std::collections::BTreeMap<String, crate::check::CheckRecord> =
101 std::collections::BTreeMap::new();
102 if let Some(l) = &ledger {
103 for rec in l.all() {
104 latest.insert(rec.entity.clone(), rec);
105 }
106 }
107
108 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
109 mems.sort();
110 let mut out = serde_json::Map::new();
111 for mem in mems {
112 if let Some(f) = mem_filter
113 && f != mem
114 {
115 continue;
116 }
117 let mut counts = std::collections::BTreeMap::from([
118 ("never_checked", 0usize),
119 ("checked_ok", 0usize),
120 ("check_failed", 0usize),
121 ("check_stale", 0usize),
122 ]);
123 let self_checked: Vec<String> = Vec::new();
127 let confirmed_independent: Vec<String> = Vec::new();
128 let mut unconfirmable: Vec<String> = Vec::new();
129 for e in engine.store().all_entities().filter(|e| e.mem == mem) {
130 let id = e.id.0.clone();
131 let state = crate::check::derive_state(latest.get(&id), &e.content_hash);
132 *counts.entry(state.as_str()).or_insert(0) += 1;
133 if state != crate::check::CheckState::CheckedOk {
134 continue;
135 }
136 unconfirmable.push(id);
147 }
148 let mut m = serde_json::Map::new();
149 for (k, v) in counts {
150 m.insert(k.to_string(), serde_json::json!(v));
151 }
152 m.insert(
153 "independence".into(),
154 serde_json::json!({
155 "self_checked": capped(self_checked),
156 "confirmed_independent": capped(confirmed_independent),
157 "unconfirmable": capped(unconfirmable),
158 }),
159 );
160 out.insert(mem, serde_json::Value::Object(m));
161 }
162 serde_json::Value::Object(out)
163}
164
165#[derive(Debug, Clone, serde::Serialize)]
171pub struct DerivationFinding {
172 pub source: crate::entity::EntityId,
173 pub rel_type: String,
174 pub target: crate::entity::EntityId,
175 pub state: String,
177 #[serde(skip_serializing_if = "Option::is_none")]
179 pub baseline: Option<String>,
180 pub current: String,
182}
183
184pub fn health_stale_derivations_axis(
189 engine: &crate::engine::Engine,
190 mem_filter: Option<&str>,
191) -> serde_json::Value {
192 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
193 mems.sort();
194 let mut out = serde_json::Map::new();
195 for mem in mems {
196 if let Some(f) = mem_filter
197 && f != mem
198 {
199 continue;
200 }
201 let findings = engine.derivation_report(&mem).unwrap_or_default();
202 out.insert(
203 mem,
204 serde_json::to_value(&findings).unwrap_or(serde_json::Value::Array(Vec::new())),
205 );
206 }
207 serde_json::Value::Object(out)
208}
209
210pub const OPEN_QUESTIONS_ITEM_CAP: usize = 20;
214
215pub fn health_open_questions_axis(
231 engine: &crate::engine::Engine,
232 mem_filter: Option<&str>,
233) -> serde_json::Value {
234 let cap = OPEN_QUESTIONS_ITEM_CAP;
235 let capped = |mut items: Vec<serde_json::Value>| -> serde_json::Value {
236 let count = items.len();
237 let more = count.saturating_sub(cap);
238 items.truncate(cap);
239 let mut o = serde_json::Map::new();
240 o.insert("count".into(), serde_json::json!(count));
241 o.insert("items".into(), serde_json::Value::Array(items));
242 if more > 0 {
243 o.insert("more".into(), serde_json::json!(more));
244 }
245 serde_json::Value::Object(o)
246 };
247
248 let bindings: Vec<(String, String)> = engine
252 .workspace_root()
253 .and_then(|root| crate::pipeline_store::load_pipeline_configs(root).ok())
254 .map(|c| {
255 c.bindings
256 .iter()
257 .map(|r| (r.config.destination_mem.clone(), r.name.clone()))
258 .collect()
259 })
260 .unwrap_or_default();
261 let mounted: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
262
263 let mut mems: Vec<String> = mounted.clone();
264 mems.sort();
265 let mut out = serde_json::Map::new();
266 for mem in &mems {
267 if let Some(f) = mem_filter
268 && f != mem
269 {
270 continue;
271 }
272
273 let stubs = capped(
275 engine
276 .store()
277 .all_entities()
278 .filter(|e| e.stub && e.id.mem() == mem)
279 .map(|e| serde_json::json!({ "kind": "stub", "id": e.id.to_string() }))
280 .collect(),
281 );
282
283 let (mut recheck, mut unresolvable) = (Vec::new(), Vec::new());
286 if let Ok(report) = engine.verify_mem_anchors(mem) {
287 for a in &report.anchors {
288 let item = serde_json::json!({
289 "kind": format!("anchor_{}", a.state),
290 "id": a.entity_id,
291 "artifact": a.artifact,
292 });
293 match a.state.as_str() {
294 "recheck" => recheck.push(item),
295 "unresolvable" => unresolvable.push(item),
296 _ => {}
297 }
298 }
299 }
300
301 let constraints = capped(
304 engine
305 .constraint_findings(Some(mem))
306 .iter()
307 .map(|r| {
308 serde_json::json!({
309 "kind": "unsatisfied_constraint",
310 "id": r.id.to_string(),
311 "violations": r.violations.len(),
312 })
313 })
314 .collect(),
315 );
316
317 let dangling = capped(
319 collect_dangling_links(engine.store(), Some(mem))
320 .iter()
321 .map(|d| {
322 serde_json::json!({
323 "kind": "dangling_link",
324 "id": d.from.to_string(),
325 "target": d.target_id.to_string(),
326 })
327 })
328 .collect(),
329 );
330
331 let mut process = Vec::new();
341 let mem_bindings: Vec<&String> = bindings
342 .iter()
343 .filter(|(d, _)| d == mem)
344 .map(|(_, b)| b)
345 .collect();
346 let mut resolutions: Vec<(Option<String>, crate::ingest::resolve::ProcessMemResolution)> =
347 Vec::new();
348 if mem_bindings.is_empty() {
349 let r = crate::ingest::resolve::resolve_process_mem(engine, mem, "");
350 if r.declared {
351 resolutions.push((None, r));
352 }
353 } else {
354 for binding in &mem_bindings {
355 resolutions.push((
356 Some((*binding).clone()),
357 crate::ingest::resolve::resolve_process_mem(engine, mem, binding),
358 ));
359 }
360 }
361 for (binding, r) in resolutions {
362 if r.mounted {
363 let mut open = Vec::new();
364 let mut searched = Vec::new();
365 for e in engine
366 .store()
367 .all_entities()
368 .filter(|e| !e.stub && e.id.mem() == r.mem.as_str())
369 {
370 let item = serde_json::json!({
371 "kind": e.entity_type,
372 "id": e.id.to_string(),
373 "title": e.title,
374 });
375 if e.entity_type == "negative_finding" {
376 searched.push(item);
377 } else {
378 open.push(item);
379 }
380 }
381 process.push(serde_json::json!({
382 "binding": binding,
383 "process_mem": r.mem,
384 "declared": r.declared,
385 "resolvable": true,
386 "open_entries": capped(open),
387 "already_searched": capped(searched),
388 }));
389 } else if r.declared {
390 process.push(serde_json::json!({
391 "binding": binding,
392 "process_mem": r.mem,
393 "declared": true,
394 "resolvable": false,
395 "finding": "DECLARED_PROCESS_MEM_MISSING",
396 }));
397 } else {
398 process.push(serde_json::json!({
399 "binding": binding,
400 "resolvable": false,
401 }));
402 }
403 }
404
405 let total_open = stubs["count"].as_u64().unwrap_or(0)
406 + recheck.len() as u64
407 + unresolvable.len() as u64
408 + constraints["count"].as_u64().unwrap_or(0)
409 + dangling["count"].as_u64().unwrap_or(0)
410 + process
411 .iter()
412 .filter_map(|p| p["open_entries"]["count"].as_u64())
413 .sum::<u64>();
414
415 let mut entry = serde_json::Map::new();
416 entry.insert("stubs".into(), stubs);
417 entry.insert("anchors_recheck".into(), capped(recheck));
418 entry.insert("anchors_unresolvable".into(), capped(unresolvable));
419 entry.insert("unsatisfied_constraints".into(), constraints);
420 entry.insert("dangling_links".into(), dangling);
421 if !process.is_empty() {
422 entry.insert("process".into(), serde_json::Value::Array(process));
423 } else {
424 entry.insert("process_mem_resolvable".into(), serde_json::json!(false));
427 }
428 entry.insert("total_open".into(), serde_json::json!(total_open));
429 out.insert(mem.clone(), serde_json::Value::Object(entry));
430 }
431 let mut top = serde_json::Map::new();
432 top.insert("_item_cap".into(), serde_json::json!(cap));
433 for (k, v) in out {
434 top.insert(k, v);
435 }
436 serde_json::Value::Object(top)
437}
438
439pub fn health_anchors_axis(engine: &crate::engine::Engine) -> serde_json::Value {
440 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
441 mems.sort();
442 let mut out = serde_json::Map::new();
443 for mem in mems {
444 let Ok(report) = engine.verify_mem_anchors(&mem) else {
445 continue;
446 };
447 out.insert(
448 mem,
449 serde_json::json!({
450 "resolved": report.resolved,
451 "drifted": report.drifted,
452 "recheck": report.recheck,
453 "unresolvable": report.unresolvable,
454 }),
455 );
456 }
457 serde_json::Value::Object(out)
458}
459
460pub fn compute_health(
473 store: &Store,
474 default_schema: &TypeDefinition,
475 mem_schemas: &HashMap<String, Arc<Schema>>,
476 mem_filter: Option<&str>,
477) -> HealthSummary {
478 let mut missing_fields = Vec::new();
479 let mut stale_entities = Vec::new();
480
481 let today_days = days_since_epoch();
482
483 let in_scope = |mem: &str| mem_filter.is_none_or(|v| mem == v);
484
485 for entity in store.all_entities() {
486 if entity.stub || !in_scope(&entity.mem) {
487 continue;
488 }
489
490 let resolved = mem_schemas
499 .get(entity.mem.as_str())
500 .and_then(|s| s.types.get(entity.entity_type.as_str()).cloned())
501 .or_else(|| type_by_name(&entity.entity_type));
502 let schema: &TypeDefinition = resolved.as_deref().unwrap_or(default_schema);
503 let mut issues = Vec::new();
504
505 for field in &schema.health_required_fields {
507 if schema.section(field).is_some() {
509 let content = entity.sections.get(field.as_str());
514 if content.is_none_or(|c| c.trim().is_empty()) {
515 if let Some(issue) = section_heading_mismatch_issue(entity, schema, field) {
516 issues.push(issue);
517 } else {
518 issues.push(HealthIssue {
519 field: field.clone(),
520 code: super::HealthIssueCode::Missing,
521 message: format!("required section '{field}' is empty"),
522 });
523 }
524 }
525 } else {
526 let value = entity.metadata.get(field.as_str());
532 let is_empty = match value {
533 None => true,
534 Some(v) => v.to_frontmatter_string().trim().is_empty(),
535 };
536 if is_empty {
537 issues.push(HealthIssue {
538 field: field.clone(),
539 code: super::HealthIssueCode::Missing,
540 message: format!("required field '{field}' is missing"),
541 });
542 }
543 }
544 }
545
546 for s in schema.sections.iter().filter(|s| !s.catch_all) {
549 if schema.health_required_fields.contains(&s.key) {
550 continue; }
552 let content = entity.sections.get(s.key.as_str());
553 if content.is_none_or(|c| c.trim().is_empty())
554 && let Some(issue) = section_heading_mismatch_issue(entity, schema, &s.key)
555 {
556 issues.push(issue);
557 }
558 }
559
560 if let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) {
576 let mut seen_unknown = std::collections::HashSet::new();
577 for rel in &entity.relationships {
578 if !mem_schema.relationship_known(&rel.rel_type) {
579 if seen_unknown.insert(rel.rel_type.clone()) {
580 let suggestion = mem_schema
581 .suggest_relationship(&rel.rel_type)
582 .map(|s| format!(" Did you mean '{s}'?"))
583 .unwrap_or_default();
584 let (schema_name, schema_version) = mem_schema.id();
585 issues.push(HealthIssue {
586 field: "relationships".to_string(),
587 code: super::HealthIssueCode::UndeclaredRelationship,
588 message: format!(
589 "relationship '{}' is not declared in schema \
590 '{schema_name}@{schema_version}'.{suggestion}",
591 rel.rel_type
592 ),
593 });
594 }
595 continue;
596 }
597
598 let target_type = store
599 .get(&rel.target)
600 .map(|t| t.entity_type.clone())
601 .filter(|t| !t.is_empty());
602 if let Err(crate::runtime_validator::ValidationError::InvalidRelationshipShape {
603 rel_type,
604 from_type,
605 to_type,
606 allowed_source_types,
607 allowed_target_types,
608 ..
609 }) = crate::runtime_validator::validate_rel_shape(
610 &rel.rel_type,
611 entity.entity_type.as_str(),
612 target_type.as_deref(),
613 mem_schema.as_ref(),
614 ) {
615 let allowed_src = if allowed_source_types.is_empty() {
616 "<any>".to_string()
617 } else {
618 allowed_source_types.join(", ")
619 };
620 let allowed_tgt = if allowed_target_types.is_empty() {
621 "<any>".to_string()
622 } else {
623 allowed_target_types.join(", ")
624 };
625 issues.push(HealthIssue {
626 field: "relationships".to_string(),
627 code: super::HealthIssueCode::InvalidRelShape,
628 message: format!(
629 "INVALID_REL_SHAPE: edge '{rel_type}' from \
630 '{from_type}' to '{to_type}' (target {target}) \
631 violates declared shape — allowed_source_types: \
632 [{allowed_src}], allowed_target_types: \
633 [{allowed_tgt}]. Remove via \
634 `memstead_relate from={from_id} to={target} \
635 type={rel_type} remove=true`.",
636 target = rel.target,
637 from_id = entity.id,
638 ),
639 });
640 }
641 }
642 }
643
644 let auto_ts_field = schema.metadata_fields.iter().find(|f| f.auto_timestamp);
646
647 if let Some(ts_field) = auto_ts_field
648 && let Some(val) = entity.metadata.get(ts_field.key.as_str())
649 {
650 let date_str = val.to_frontmatter_string();
651 if let Some(modified_days) = parse_iso_to_days(&date_str) {
652 let days_since = today_days.saturating_sub(modified_days);
653 if days_since > schema.staleness_threshold_days as u64 {
654 stale_entities.push(StaleEntity {
655 id: entity.id.clone(),
656 title: entity.title.clone(),
657 days_since_modified: days_since,
658 });
659 }
660 }
661 }
662
663 if !issues.is_empty() {
664 let total = schema.health_required_fields.len();
670 let score = if total > 0 {
671 (total.saturating_sub(issues.len()) as f32) / (total as f32)
672 } else {
673 1.0
674 };
675
676 missing_fields.push(HealthReport {
677 id: entity.id.clone(),
678 title: entity.title.clone(),
679 score,
680 issues,
681 });
682 }
683 }
684
685 stale_entities.sort_by_key(|e| std::cmp::Reverse(e.days_since_modified));
687
688 let orphan_count = query::find_orphans_with_schemas(store, mem_schemas)
691 .into_iter()
692 .filter(|id| store.get(id).is_some_and(|e| in_scope(&e.mem)))
693 .count();
694 let leaf_entities_by_type = match mem_filter {
695 None => query::leaf_population(store, mem_schemas),
696 Some(v) => {
697 let scoped: HashMap<String, Arc<Schema>> = mem_schemas
698 .iter()
699 .filter(|(mem, _)| mem.as_str() == v)
700 .map(|(mem, s)| (mem.clone(), s.clone()))
701 .collect();
702 query::leaf_population(store, &scoped)
703 }
704 };
705 let stub_count = query::find_stubs(store)
706 .iter()
707 .filter(|(id, _)| store.get(id).is_some_and(|e| in_scope(&e.mem)))
708 .count();
709
710 HealthSummary {
711 stale_entities,
712 missing_fields,
713 orphan_count,
714 stub_count,
715 warnings: Vec::new(),
716 quarantined: Vec::new(),
717 load_errors: Vec::new(),
718 boot_diagnosis: None,
719 leaf_entities_by_type,
720 dangling_links: None,
721 findings: None,
722 tag_distribution: None,
723 tag_distribution_folded: None,
724 untagged_entities: None,
725 }
726}
727
728pub fn collect_tag_distribution(
742 store: &Store,
743 mem_filter: Option<&str>,
744 limit: usize,
745) -> (Vec<TagDistribution>, Vec<FoldedTag>, UntaggedStats) {
746 let mut counts: HashMap<String, (usize, HashMap<String, usize>)> = HashMap::new();
748 let mut untagged = UntaggedStats {
749 total: 0,
750 by_entity_type: HashMap::new(),
751 };
752
753 for entity in store.all_entities() {
754 if entity.stub {
755 continue;
756 }
757 if let Some(v) = mem_filter
758 && entity.mem != v
759 {
760 continue;
761 }
762
763 let tags_raw = entity
764 .metadata
765 .get("tags")
766 .and_then(|v| match v {
767 MetadataValue::String(s) => Some(s.as_str()),
768 _ => None,
769 })
770 .unwrap_or("");
771
772 let mut any_tag = false;
773 for tag in tags_raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
774 any_tag = true;
775 let entry = counts
776 .entry(tag.to_string())
777 .or_insert_with(|| (0, HashMap::new()));
778 entry.0 += 1;
779 *entry.1.entry(entity.entity_type.clone()).or_insert(0) += 1;
780 }
781 if !any_tag {
782 untagged.total += 1;
783 *untagged
784 .by_entity_type
785 .entry(entity.entity_type.clone())
786 .or_insert(0) += 1;
787 }
788 }
789
790 let mut entries: Vec<TagDistribution> = counts
792 .iter()
793 .map(|(tag, (count, by_type))| TagDistribution {
794 tag: tag.clone(),
795 count: *count,
796 by_entity_type: by_type.clone(),
797 })
798 .collect();
799 entries.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.tag.cmp(&b.tag)));
800 entries.truncate(limit);
801
802 let mut by_canonical: HashMap<String, Vec<(String, usize)>> = HashMap::new();
807 for (tag, (count, _)) in counts.iter() {
808 by_canonical
809 .entry(tag.to_lowercase())
810 .or_default()
811 .push((tag.clone(), *count));
812 }
813 let mut folded: Vec<FoldedTag> = by_canonical
814 .into_iter()
815 .filter(|(_, v)| v.len() > 1)
816 .map(|(canonical, mut variants)| {
817 variants.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
818 let total = variants.iter().map(|(_, c)| *c).sum();
819 FoldedTag {
820 canonical,
821 total,
822 variants: variants
823 .into_iter()
824 .map(|(tag, count)| TagVariant { tag, count })
825 .collect(),
826 }
827 })
828 .collect();
829 folded.sort_by(|a, b| {
830 b.total
831 .cmp(&a.total)
832 .then_with(|| a.canonical.cmp(&b.canonical))
833 });
834
835 (entries, folded, untagged)
836}
837
838pub fn collect_dangling_links(store: &Store, mem_filter: Option<&str>) -> Vec<DanglingLink> {
858 use crate::entity::parser::extract_inline_links_lenient;
859 use std::collections::HashSet;
860
861 let mut out = Vec::new();
862 for entity in store.all_entities() {
863 if entity.stub {
864 continue;
865 }
866 if let Some(v) = mem_filter
867 && entity.mem != v
868 {
869 continue;
870 }
871 let explicit_targets: HashSet<_> = entity
872 .relationships
873 .iter()
874 .map(|r| r.target.clone())
875 .collect();
876 for (section_key, section_body) in &entity.sections {
877 for target_id in extract_inline_links_lenient(section_body, &entity.mem) {
878 let target_missing = store.get(&target_id).map(|e| e.stub).unwrap_or(true);
879 let alias_orphan = !target_missing && !explicit_targets.contains(&target_id);
880 if target_missing || alias_orphan {
881 out.push(DanglingLink {
882 from: entity.id.clone(),
883 target_id: target_id.clone(),
884 target_path: target_id.path().to_string(),
885 section: Some(section_key.clone()),
886 });
887 }
888 }
889 }
890 for rel in &entity.relationships {
907 if store.get(&rel.target).is_some() {
908 continue;
909 }
910 let already_reported = out
911 .iter()
912 .any(|d| d.from == entity.id && d.target_id == rel.target);
913 if already_reported {
914 continue;
915 }
916 out.push(DanglingLink {
917 from: entity.id.clone(),
918 target_id: rel.target.clone(),
919 target_path: rel.target.path().to_string(),
920 section: None,
921 });
922 }
923 }
924 out.sort_by(|a, b| {
929 (&a.from.0, &a.target_id.0, &a.section).cmp(&(&b.from.0, &b.target_id.0, &b.section))
930 });
931 out
932}
933
934pub fn collect_missing_required_outgoing(
945 store: &Store,
946 mem_filter: Option<&str>,
947 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
948) -> Vec<MissingRequiredOutgoingReport> {
949 let mut out = Vec::new();
950 for entity in store.all_entities() {
951 if entity.stub {
952 continue;
953 }
954 if let Some(v) = mem_filter
955 && entity.mem != v
956 {
957 continue;
958 }
959 let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
960 continue;
961 };
962 let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
963 continue;
964 };
965 if td.required_outgoing.is_empty() {
966 continue;
967 }
968 let unsatisfied = unsatisfied_required_outgoing(entity, td);
969 if unsatisfied.is_empty() {
970 continue;
971 }
972 out.push(MissingRequiredOutgoingReport {
973 id: entity.id.clone(),
974 title: entity.title.clone(),
975 entity_type: entity.entity_type.clone(),
976 mem: entity.mem.clone(),
977 missing: unsatisfied,
978 });
979 }
980 out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
981 out
982}
983
984pub fn unsatisfied_required_outgoing(
992 entity: &crate::entity::Entity,
993 td: &TypeDefinition,
994) -> Vec<super::MissingRequiredOutgoingBlock> {
995 td.required_outgoing
996 .iter()
997 .filter(|block| {
998 if let (Some(when_field), Some(when_value)) = (&block.when_field, &block.when_value) {
1003 let armed = entity
1004 .metadata
1005 .get(when_field.as_str())
1006 .is_some_and(|v| v.to_frontmatter_string() == *when_value);
1007 if !armed {
1008 return false;
1009 }
1010 }
1011 let count = entity
1012 .relationships
1013 .iter()
1014 .filter(|rel| block.relationships.iter().any(|name| name == &rel.rel_type))
1015 .count();
1016 !block.admits(count)
1017 })
1018 .map(|block| super::MissingRequiredOutgoingBlock {
1019 relationships: block.relationships.clone(),
1020 cardinality: block.cardinality.to_string(),
1021 severity: block.severity,
1022 when_field: block.when_field.clone(),
1023 when_value: block.when_value.clone(),
1024 })
1025 .collect()
1026}
1027
1028#[derive(Debug, Clone, serde::Serialize)]
1036#[serde(tag = "kind", rename_all = "snake_case")]
1037pub enum UnsatisfiedConstraint {
1038 RequiresWhen {
1039 field: String,
1040 when_field: String,
1041 when_value: String,
1042 severity: memstead_schema::ConstraintSeverity,
1043 },
1044 Unique {
1045 fields: Vec<String>,
1046 values: Vec<String>,
1048 colliding: String,
1051 severity: memstead_schema::ConstraintSeverity,
1052 },
1053 EnumFromNeighbour {
1054 field: String,
1055 value: String,
1057 rel_type: String,
1058 section: String,
1059 severity: memstead_schema::ConstraintSeverity,
1060 },
1061 StatusPropagation {
1062 field: String,
1063 value: String,
1065 #[serde(skip_serializing_if = "Option::is_none")]
1069 rel_type: Option<String>,
1070 #[serde(skip_serializing_if = "Option::is_none")]
1072 rel_types: Option<Vec<String>>,
1073 tainted_by: String,
1076 severity: memstead_schema::ConstraintSeverity,
1077 },
1078 MustReach {
1083 relationships: Vec<String>,
1084 direction: memstead_schema::ReachDirection,
1085 terminal_types: Vec<String>,
1086 #[serde(skip_serializing_if = "Option::is_none")]
1087 max_depth: Option<u32>,
1088 severity: memstead_schema::ConstraintSeverity,
1089 },
1090}
1091
1092impl UnsatisfiedConstraint {
1093 pub fn severity(&self) -> memstead_schema::ConstraintSeverity {
1094 match self {
1095 Self::RequiresWhen { severity, .. }
1096 | Self::Unique { severity, .. }
1097 | Self::EnumFromNeighbour { severity, .. }
1098 | Self::StatusPropagation { severity, .. }
1099 | Self::MustReach { severity, .. } => *severity,
1100 }
1101 }
1102
1103 pub fn describe(&self) -> String {
1105 match self {
1106 Self::RequiresWhen {
1107 field,
1108 when_field,
1109 when_value,
1110 ..
1111 } => format!(
1112 "requires_when: '{field}' is required when {when_field}={when_value} and is unset"
1113 ),
1114 Self::Unique {
1115 fields, colliding, ..
1116 } => format!(
1117 "unique: tuple ({}) collides with '{colliding}'",
1118 fields.join(", ")
1119 ),
1120 Self::EnumFromNeighbour {
1121 field,
1122 value,
1123 rel_type,
1124 section,
1125 ..
1126 } => format!(
1127 "enum_from_neighbour: '{field}' value '{value}' has no backing entry in any \
1128 `{section}` section reached via {rel_type}"
1129 ),
1130 Self::StatusPropagation {
1131 field,
1132 value,
1133 tainted_by,
1134 ..
1135 } => {
1136 format!("status_propagation: tainted by '{tainted_by}' ({field}={value})")
1137 }
1138 Self::MustReach {
1139 relationships,
1140 direction,
1141 terminal_types,
1142 max_depth,
1143 ..
1144 } => {
1145 let depth = match max_depth {
1146 Some(d) => format!(" within {d} hop(s)"),
1147 None => String::new(),
1148 };
1149 format!(
1150 "must_reach: no path via [{}] ({direction}) reaches a [{}] entity{depth}",
1151 relationships.join(", "),
1152 terminal_types.join(", ")
1153 )
1154 }
1155 }
1156 }
1157}
1158
1159pub fn unsatisfied_constraints(
1183 store: &Store,
1184 entity: &crate::entity::Entity,
1185 td: &TypeDefinition,
1186 exclude: Option<&crate::entity::EntityId>,
1187) -> Vec<UnsatisfiedConstraint> {
1188 use memstead_schema::ConstraintDef;
1189 td.constraints
1190 .iter()
1191 .filter_map(|c| match c {
1192 ConstraintDef::RequiresWhen {
1193 field,
1194 when_field,
1195 when_value,
1196 severity,
1197 } => {
1198 let triggered = entity
1199 .metadata
1200 .get(when_field.as_str())
1201 .is_some_and(|v| v.to_frontmatter_string() == *when_value);
1202 if !triggered {
1203 return None;
1204 }
1205 let satisfied = entity
1206 .metadata
1207 .get(field.as_str())
1208 .is_some_and(|v| !v.to_frontmatter_string().trim().is_empty())
1209 || entity
1210 .sections
1211 .get(field.as_str())
1212 .is_some_and(|body| !body.trim().is_empty());
1213 if satisfied {
1214 return None;
1215 }
1216 Some(UnsatisfiedConstraint::RequiresWhen {
1217 field: field.clone(),
1218 when_field: when_field.clone(),
1219 when_value: when_value.clone(),
1220 severity: *severity,
1221 })
1222 }
1223 ConstraintDef::Unique { fields, severity } => {
1224 let tuple = tuple_of(entity, fields)?;
1225 let mut colliding: Vec<&str> = store
1226 .all_entities()
1227 .filter(|other| {
1228 !other.stub
1229 && other.mem == entity.mem
1230 && other.entity_type == entity.entity_type
1231 && Some(&other.id) != exclude
1232 && other.id != entity.id
1233 && tuple_of(other, fields).as_ref() == Some(&tuple)
1234 })
1235 .map(|other| other.id.0.as_str())
1236 .collect();
1237 colliding.sort_unstable();
1238 let first = colliding.first()?;
1239 Some(UnsatisfiedConstraint::Unique {
1240 fields: fields.clone(),
1241 values: tuple,
1242 colliding: first.to_string(),
1243 severity: *severity,
1244 })
1245 }
1246 ConstraintDef::EnumFromNeighbour {
1247 field,
1248 rel_type,
1249 section,
1250 severity,
1251 } => {
1252 let value = entity
1253 .metadata
1254 .get(field.as_str())
1255 .map(|v| v.to_frontmatter_string())
1256 .filter(|v| !v.trim().is_empty())?;
1257 let backed = entity
1258 .relationships
1259 .iter()
1260 .filter(|rel| rel.rel_type == *rel_type)
1261 .filter_map(|rel| store.get(&rel.target))
1262 .filter_map(|neighbour| neighbour.sections.get(section.as_str()))
1263 .any(|body| bullet_entries(body).contains(&value));
1264 if backed {
1265 return None;
1266 }
1267 Some(UnsatisfiedConstraint::EnumFromNeighbour {
1268 field: field.clone(),
1269 value,
1270 rel_type: rel_type.clone(),
1271 section: section.clone(),
1272 severity: *severity,
1273 })
1274 }
1275 ConstraintDef::StatusPropagation { .. } => None,
1276 })
1277 .collect()
1278}
1279
1280fn tuple_of(entity: &crate::entity::Entity, fields: &[String]) -> Option<Vec<String>> {
1284 fields
1285 .iter()
1286 .map(|f| {
1287 entity
1288 .metadata
1289 .get(f.as_str())
1290 .map(|v| v.to_frontmatter_string())
1291 .filter(|v| !v.trim().is_empty())
1292 })
1293 .collect()
1294}
1295
1296fn bullet_entries(body: &str) -> Vec<String> {
1299 let masked = crate::markdown::mask_code_blocks_and_spans(body);
1304 body.lines()
1305 .zip(masked.lines())
1306 .filter_map(|(line, masked_line)| {
1307 let m = masked_line.trim_start();
1308 if m.starts_with("- ") || m.starts_with("* ") {
1309 let t = line.trim_start();
1310 t.strip_prefix("- ")
1311 .or_else(|| t.strip_prefix("* "))
1312 .map(|e| e.trim().to_string())
1313 } else {
1314 None
1315 }
1316 })
1317 .collect()
1318}
1319
1320#[derive(Debug, Clone, serde::Serialize)]
1325pub struct ConstraintFindingReport {
1326 pub id: crate::entity::EntityId,
1327 pub title: String,
1328 pub entity_type: String,
1329 pub mem: String,
1330 pub violations: Vec<UnsatisfiedConstraint>,
1331 #[serde(skip_serializing_if = "Vec::is_empty")]
1335 pub format_violations: Vec<crate::section_format::SectionFormatViolation>,
1336}
1337
1338pub fn collect_constraint_findings(
1348 store: &Store,
1349 mem_filter: Option<&str>,
1350 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1351) -> Vec<ConstraintFindingReport> {
1352 use memstead_schema::ConstraintDef;
1353 type Bucket = (
1354 Vec<UnsatisfiedConstraint>,
1355 Vec<crate::section_format::SectionFormatViolation>,
1356 );
1357 let mut by_entity: std::collections::BTreeMap<String, Bucket> = Default::default();
1358
1359 let needs_reverse = mem_schemas.values().any(|s| {
1363 s.types.values().any(|t| {
1364 t.must_reach
1365 .iter()
1366 .any(|ob| ob.direction == memstead_schema::ReachDirection::In)
1367 })
1368 });
1369 let reverse: ReverseIndex = if needs_reverse {
1370 build_reverse_index(store)
1371 } else {
1372 ReverseIndex::default()
1373 };
1374
1375 for entity in store.all_entities() {
1376 if entity.stub {
1377 continue;
1378 }
1379 if let Some(v) = mem_filter
1380 && entity.mem != v
1381 {
1382 continue;
1383 }
1384 let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
1385 continue;
1386 };
1387 let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
1388 continue;
1389 };
1390
1391 for def in &td.sections {
1396 if def.compiled_content.is_none() {
1397 continue;
1398 }
1399 let Some(body) = entity.sections.get(def.key.as_str()) else {
1400 continue;
1401 };
1402 let violations = crate::section_format::check_section_format(def, body);
1403 if !violations.is_empty() {
1404 by_entity
1405 .entry(entity.id.0.clone())
1406 .or_default()
1407 .1
1408 .extend(violations);
1409 }
1410 }
1411
1412 for ob in &td.must_reach {
1417 if !reaches_terminal(store, &reverse, &entity.id, ob) {
1418 by_entity.entry(entity.id.0.clone()).or_default().0.push(
1419 UnsatisfiedConstraint::MustReach {
1420 relationships: ob.relationships.clone(),
1421 direction: ob.direction,
1422 terminal_types: ob.terminal_types.clone(),
1423 max_depth: ob.max_depth,
1424 severity: ob.severity,
1425 },
1426 );
1427 }
1428 }
1429
1430 if td.constraints.is_empty() {
1431 continue;
1432 }
1433
1434 let violations = unsatisfied_constraints(store, entity, td, None);
1436 if !violations.is_empty() {
1437 by_entity
1438 .entry(entity.id.0.clone())
1439 .or_default()
1440 .0
1441 .extend(violations);
1442 }
1443
1444 for c in &td.constraints {
1449 let ConstraintDef::StatusPropagation {
1450 field,
1451 value,
1452 rel_type,
1453 rel_types,
1454 direction,
1455 severity,
1456 } = c
1457 else {
1458 continue;
1459 };
1460 let terminal = entity
1461 .metadata
1462 .get(field.as_str())
1463 .is_some_and(|v| v.to_frontmatter_string() == *value);
1464 if !terminal {
1465 continue;
1466 }
1467 let set = c
1468 .propagation_rel_types()
1469 .expect("StatusPropagation always yields a set");
1470 for tainted in reach_transitively(store, &entity.id, &set, *direction) {
1471 if let Some(v) = mem_filter
1472 && tainted.mem() != v
1473 {
1474 continue;
1475 }
1476 by_entity.entry(tainted.0.clone()).or_default().0.push(
1477 UnsatisfiedConstraint::StatusPropagation {
1478 field: field.clone(),
1479 value: value.clone(),
1480 rel_type: rel_type.clone(),
1481 rel_types: rel_types.clone(),
1482 tainted_by: entity.id.to_string(),
1483 severity: *severity,
1484 },
1485 );
1486 }
1487 }
1488 }
1489
1490 let mut out: Vec<ConstraintFindingReport> = by_entity
1491 .into_iter()
1492 .filter_map(|(id, (violations, format_violations))| {
1493 let id = crate::entity::EntityId(id);
1494 let entity = store.get(&id)?;
1495 Some(ConstraintFindingReport {
1496 id,
1497 title: entity.title.clone(),
1498 entity_type: entity.entity_type.clone(),
1499 mem: entity.mem.clone(),
1500 violations,
1501 format_violations,
1502 })
1503 })
1504 .collect();
1505 out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
1506 out
1507}
1508
1509fn reach_transitively(
1516 store: &Store,
1517 start: &crate::entity::EntityId,
1518 rel_types: &[String],
1519 direction: memstead_schema::PropagationDirection,
1520) -> Vec<crate::entity::EntityId> {
1521 use memstead_schema::PropagationDirection;
1522 let mut seen: std::collections::HashSet<crate::entity::EntityId> =
1523 std::iter::once(start.clone()).collect();
1524 let mut frontier = vec![start.clone()];
1525 let mut reached = Vec::new();
1526 while let Some(current) = frontier.pop() {
1527 let next: Vec<crate::entity::EntityId> = match direction {
1528 PropagationDirection::Incoming => store
1529 .all_entities()
1530 .filter(|e| {
1531 e.relationships
1532 .iter()
1533 .any(|r| rel_types.iter().any(|n| n == &r.rel_type) && r.target == current)
1534 })
1535 .map(|e| e.id.clone())
1536 .collect(),
1537 PropagationDirection::Outgoing => store
1538 .get(¤t)
1539 .map(|e| {
1540 e.relationships
1541 .iter()
1542 .filter(|r| rel_types.iter().any(|n| n == &r.rel_type))
1543 .map(|r| r.target.clone())
1544 .collect()
1545 })
1546 .unwrap_or_default(),
1547 };
1548 for id in next {
1549 if seen.insert(id.clone()) {
1550 if store.get(&id).is_some_and(|e| !e.stub) {
1551 reached.push(id.clone());
1552 }
1553 frontier.push(id);
1554 }
1555 }
1556 }
1557 reached
1558}
1559
1560type ReverseIndex =
1565 std::collections::HashMap<crate::entity::EntityId, Vec<(String, crate::entity::EntityId)>>;
1566
1567fn build_reverse_index(store: &Store) -> ReverseIndex {
1568 let mut idx = ReverseIndex::default();
1569 for entity in store.all_entities() {
1570 for rel in &entity.relationships {
1571 idx.entry(rel.target.clone())
1572 .or_default()
1573 .push((rel.rel_type.clone(), entity.id.clone()));
1574 }
1575 }
1576 idx
1577}
1578
1579fn reaches_terminal(
1588 store: &Store,
1589 reverse: &ReverseIndex,
1590 start: &crate::entity::EntityId,
1591 ob: &memstead_schema::MustReach,
1592) -> bool {
1593 use memstead_schema::ReachDirection;
1594 let mut seen: std::collections::HashSet<crate::entity::EntityId> =
1595 std::iter::once(start.clone()).collect();
1596 let mut frontier = vec![start.clone()];
1597 let mut depth: u32 = 0;
1598 while !frontier.is_empty() {
1599 if let Some(max) = ob.max_depth
1600 && depth >= max
1601 {
1602 return false;
1603 }
1604 depth += 1;
1605 let mut next_frontier = Vec::new();
1606 for current in frontier {
1607 let next: Vec<crate::entity::EntityId> = match ob.direction {
1608 ReachDirection::Out => store
1609 .get(¤t)
1610 .map(|e| {
1611 e.relationships
1612 .iter()
1613 .filter(|r| ob.relationships.iter().any(|n| n == &r.rel_type))
1614 .map(|r| r.target.clone())
1615 .collect()
1616 })
1617 .unwrap_or_default(),
1618 ReachDirection::In => reverse
1619 .get(¤t)
1620 .map(|sources| {
1621 sources
1622 .iter()
1623 .filter(|(rel, _)| ob.relationships.iter().any(|n| n == rel))
1624 .map(|(_, src)| src.clone())
1625 .collect()
1626 })
1627 .unwrap_or_default(),
1628 };
1629 for id in next {
1630 if seen.insert(id.clone()) {
1631 if store.get(&id).is_some_and(|e| {
1632 !e.stub && ob.terminal_types.iter().any(|t| t == &e.entity_type)
1633 }) {
1634 return true;
1635 }
1636 next_frontier.push(id);
1637 }
1638 }
1639 }
1640 frontier = next_frontier;
1641 }
1642 false
1643}
1644
1645#[derive(Debug, Clone, serde::Serialize)]
1650pub struct SignalReport {
1651 pub id: crate::entity::EntityId,
1652 pub title: String,
1653 pub entity_type: String,
1654 pub mem: String,
1655 pub signals: Vec<super::signals::ComputedSignal>,
1657}
1658
1659impl SignalReport {
1660 pub fn has_warn(&self) -> bool {
1664 self.signals
1665 .iter()
1666 .any(|s| s.level == Some(memstead_schema::SignalLevel::Warn))
1667 }
1668}
1669
1670pub fn collect_signal_reports(
1674 store: &Store,
1675 mem_filter: Option<&str>,
1676 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1677) -> Vec<SignalReport> {
1678 let mut out = Vec::new();
1679 for entity in store.all_entities() {
1680 if entity.stub {
1681 continue;
1682 }
1683 if let Some(v) = mem_filter
1684 && entity.mem != v
1685 {
1686 continue;
1687 }
1688 let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
1689 continue;
1690 };
1691 let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
1692 continue;
1693 };
1694 if td.signals.is_empty() {
1695 continue;
1696 }
1697 let above: Vec<super::signals::ComputedSignal> =
1698 super::signals::compute_signals(store, td, &entity.id)
1699 .into_iter()
1700 .filter(|s| s.level.is_some())
1701 .collect();
1702 if above.is_empty() {
1703 continue;
1704 }
1705 out.push(SignalReport {
1706 id: entity.id.clone(),
1707 title: entity.title.clone(),
1708 entity_type: entity.entity_type.clone(),
1709 mem: entity.mem.clone(),
1710 signals: above,
1711 });
1712 }
1713 out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
1714 out
1715}
1716
1717#[derive(Debug, Clone, serde::Serialize)]
1722pub struct SchemaFormatDefect {
1723 pub schema: String,
1724 pub type_name: String,
1725 pub section: String,
1726 pub problems: Vec<String>,
1727}
1728
1729pub fn collect_schema_format_defects(
1733 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1734) -> Vec<SchemaFormatDefect> {
1735 let mut seen: std::collections::BTreeSet<String> = Default::default();
1736 let mut out = Vec::new();
1737 let mut schemas: Vec<&Arc<memstead_schema::Schema>> = mem_schemas.values().collect();
1738 schemas.sort_by_key(|s| (s.manifest.name.clone(), s.version.clone()));
1739 for schema in schemas {
1740 let schema_ref = format!("{}@{}", schema.manifest.name, schema.version);
1741 if !seen.insert(schema_ref.clone()) {
1742 continue;
1743 }
1744 for td in schema.types.values() {
1745 for section in &td.sections {
1746 if !section.format_problems.is_empty() {
1747 out.push(SchemaFormatDefect {
1748 schema: schema_ref.clone(),
1749 type_name: td.name.clone(),
1750 section: section.key.clone(),
1751 problems: section.format_problems.clone(),
1752 });
1753 }
1754 }
1755 }
1756 }
1757 out.sort_by(|a, b| {
1758 (&a.schema, &a.type_name, &a.section).cmp(&(&b.schema, &b.type_name, &b.section))
1759 });
1760 out
1761}
1762
1763#[derive(Debug, Clone, serde::Serialize)]
1771pub struct MissingRequiredOutgoingReport {
1772 pub id: crate::entity::EntityId,
1773 pub title: String,
1774 pub entity_type: String,
1775 pub mem: String,
1776 pub missing: Vec<super::MissingRequiredOutgoingBlock>,
1777}
1778
1779pub fn config_projection(
1792 engine: &crate::Engine,
1793 writable_mems: &[String],
1794 mutations: serde_json::Value,
1795 plugin: serde_json::Value,
1796) -> serde_json::Map<String, serde_json::Value> {
1797 let backend_by_mem: std::collections::HashMap<&str, (&'static str, bool)> = engine
1802 .mounts()
1803 .iter()
1804 .map(|m| {
1805 (
1806 m.mem.as_str(),
1807 (m.storage.backend_id(), m.storage.is_durable()),
1808 )
1809 })
1810 .collect();
1811 let mems_detail: Vec<serde_json::Value> = writable_mems
1812 .iter()
1813 .map(|name| {
1814 let origin = engine
1815 .mem_router()
1816 .origin_for_mem(name)
1817 .map(|o| o.kind())
1818 .unwrap_or("explicit");
1819 let mut entry = serde_json::Map::new();
1820 entry.insert("name".into(), serde_json::json!(name));
1821 entry.insert("origin".into(), serde_json::json!(origin));
1822 if let Some((storage, durable)) = backend_by_mem.get(name.as_str()).copied() {
1823 entry.insert("storage".into(), serde_json::json!(storage));
1824 entry.insert("durable".into(), serde_json::json!(durable));
1825 }
1826 let mut vcs_obj = serde_json::Map::new();
1827 if let Ok(gitdir) = engine.gitdir_for(name) {
1828 vcs_obj.insert("gitdir".into(), serde_json::json!(gitdir));
1829 }
1830 if let Ok(worktree) = engine.worktree_for(name) {
1831 vcs_obj.insert("worktree".into(), serde_json::json!(worktree));
1832 }
1833 if let Some(sha) = engine.mem_head_sha(name).ok().flatten() {
1834 vcs_obj.insert("head".into(), serde_json::json!(sha));
1835 }
1836 if !vcs_obj.is_empty() {
1837 entry.insert("vcs".into(), serde_json::Value::Object(vcs_obj));
1838 }
1839 if let Some(cfg) = engine.mem_config_for(name) {
1840 if let Some(title) = &cfg.title {
1844 entry.insert("title".into(), serde_json::json!(title));
1845 }
1846 if let Some(subject) = &cfg.subject {
1847 entry.insert("subject".into(), serde_json::json!(subject));
1848 }
1849 let guidance = serde_json::Map::from_iter(
1850 cfg.write_guidance
1851 .iter()
1852 .map(|(k, v)| (k.clone(), v.clone())),
1853 );
1854 entry.insert("write_guidance".into(), serde_json::Value::Object(guidance));
1855 let extra = serde_json::Map::from_iter(
1856 cfg.extra.iter().map(|(k, v)| (k.clone(), v.clone())),
1857 );
1858 entry.insert("extra".into(), serde_json::Value::Object(extra));
1859 }
1860 serde_json::Value::Object(entry)
1861 })
1862 .collect();
1863
1864 let mut out = serde_json::Map::new();
1865 out.insert("mems".into(), serde_json::json!(mems_detail));
1866 out.insert("mutations".into(), mutations);
1867 out.insert("plugin".into(), plugin);
1868 out
1869}
1870
1871pub fn config_projection_from_settings(
1877 settings: &crate::workspace::WorkspaceSettings,
1878) -> (serde_json::Value, serde_json::Value) {
1879 let mutations = serde_json::json!({ "require_notes": settings.mutations.require_notes });
1880 let plugin_map: serde_json::Map<String, serde_json::Value> = settings
1881 .plugin
1882 .iter()
1883 .map(|(k, v)| {
1884 (
1885 k.clone(),
1886 serde_json::to_value(v).unwrap_or(serde_json::Value::Null),
1887 )
1888 })
1889 .collect();
1890 (mutations, serde_json::Value::Object(plugin_map))
1891}
1892
1893pub(crate) fn section_heading_mismatch_issue(
1908 entity: &crate::entity::Entity,
1909 schema: &TypeDefinition,
1910 key: &str,
1911) -> Option<HealthIssue> {
1912 let def = schema.section(key)?;
1913 let derived = memstead_schema::derive_section_key(&def.heading);
1914 if derived == key {
1915 return None;
1916 }
1917 if !entity
1918 .raw_section_headings
1919 .iter()
1920 .any(|h| h == &def.heading)
1921 {
1922 return None;
1923 }
1924 let landing = match schema.catch_all_section() {
1925 Some(c) => format!(
1926 "the content was absorbed into catch-all section '{}'",
1927 c.key
1928 ),
1929 None => "the content is unreachable under any declared key".to_string(),
1930 };
1931 Some(HealthIssue {
1932 field: key.to_string(),
1933 code: super::HealthIssueCode::SectionHeadingMismatch,
1934 message: format!(
1935 "SECTION_HEADING_MISMATCH: section '{key}' is not missing — its content sits \
1936 under heading '{found}', which derives to '{derived}', not '{key}'; {landing}. \
1937 The schema's declared heading cannot round-trip to its key (expected a heading \
1938 that derives to '{key}'); fix the schema's heading/key pair — new installs of \
1939 such a schema are refused",
1940 found = def.heading,
1941 ),
1942 })
1943}
1944
1945pub fn entity_health(entity: &crate::entity::Entity, schema: &TypeDefinition) -> HealthReport {
1947 let mut issues = Vec::new();
1948
1949 for field in &schema.health_required_fields {
1950 if schema.section(field).is_some() {
1951 let content = entity.sections.get(field.as_str());
1952 if content.is_none_or(|c| c.trim().is_empty()) {
1953 if let Some(issue) = section_heading_mismatch_issue(entity, schema, field) {
1954 issues.push(issue);
1955 } else {
1956 issues.push(HealthIssue {
1957 field: field.clone(),
1958 code: super::HealthIssueCode::Missing,
1959 message: format!("required section '{field}' is empty"),
1960 });
1961 }
1962 }
1963 } else {
1964 let value = entity.metadata.get(field.as_str());
1965 if value.is_none() {
1966 issues.push(HealthIssue {
1967 field: field.clone(),
1968 code: super::HealthIssueCode::Missing,
1969 message: format!("required field '{field}' is missing"),
1970 });
1971 }
1972 }
1973 }
1974
1975 for s in schema.sections.iter().filter(|s| !s.catch_all) {
1979 if schema.health_required_fields.contains(&s.key) {
1980 continue; }
1982 let content = entity.sections.get(s.key.as_str());
1983 if content.is_none_or(|c| c.trim().is_empty())
1984 && let Some(issue) = section_heading_mismatch_issue(entity, schema, &s.key)
1985 {
1986 issues.push(issue);
1987 }
1988 }
1989
1990 let total = schema.health_required_fields.len();
1991 let score = if total > 0 {
1992 (total.saturating_sub(issues.len()) as f32) / (total as f32)
1993 } else {
1994 1.0
1995 };
1996
1997 HealthReport {
1998 id: entity.id.clone(),
1999 title: entity.title.clone(),
2000 score,
2001 issues,
2002 }
2003}
2004
2005fn days_since_epoch() -> u64 {
2016 #[cfg(target_arch = "wasm32")]
2017 {
2018 (js_sys::Date::now() / 1000.0) as u64 / 86400
2019 }
2020 #[cfg(not(target_arch = "wasm32"))]
2021 {
2022 std::time::SystemTime::now()
2023 .duration_since(std::time::UNIX_EPOCH)
2024 .unwrap_or_default()
2025 .as_secs()
2026 / 86400
2027 }
2028}
2029
2030fn parse_iso_to_days(date: &str) -> Option<u64> {
2033 let date_part = date.split('T').next()?;
2034 let parts: Vec<&str> = date_part.split('-').collect();
2035 if parts.len() != 3 {
2036 return None;
2037 }
2038 let year: u64 = parts[0].parse().ok()?;
2039 let month: u64 = parts[1].parse().ok()?;
2040 let day: u64 = parts[2].parse().ok()?;
2041 Some(ymd_to_days(year, month, day))
2042}
2043
2044fn ymd_to_days(year: u64, month: u64, day: u64) -> u64 {
2047 let y = if month <= 2 { year - 1 } else { year };
2049 let m = if month <= 2 { month + 9 } else { month - 3 };
2050 let era = y / 400;
2051 let yoe = y - era * 400;
2052 let doy = (153 * m + 2) / 5 + day - 1;
2053 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
2054 let days = era * 146097 + doe;
2055 days - 719468
2056}
2057
2058#[cfg(test)]
2059mod tests {
2060 use super::*;
2061 use crate::entity::{Entity, EntityId, MetadataValue};
2062 use crate::store::Store;
2063 use indexmap::IndexMap;
2064 use memstead_schema::type_by_name;
2065
2066 #[test]
2071 fn bullet_entries_ignores_code() {
2072 let body = "- real-one\n- real-two\n\n```\n- fenced-ghost\n```\n\n - indented-ghost\n\nA `- span-ghost` sample.\n";
2073 let entries = bullet_entries(body);
2074 assert_eq!(
2075 entries,
2076 vec!["real-one".to_string(), "real-two".to_string()],
2077 "only prose bullets are legal values: {entries:?}"
2078 );
2079 }
2080
2081 #[test]
2085 fn bullet_entries_still_reads_prose_bullets_verbatim() {
2086 let entries = bullet_entries("- alpha\n * beta\n* `gamma`\n");
2087 assert_eq!(
2088 entries,
2089 vec![
2090 "alpha".to_string(),
2091 "beta".to_string(),
2092 "`gamma`".to_string()
2093 ]
2094 );
2095 }
2096
2097 #[test]
2103 fn declared_process_mem_pairs_and_missing_declaration_is_typed() {
2104 use crate::engine::test_helpers::folder_mount;
2105 let tmp = tempfile::TempDir::new().unwrap();
2106 let dest_dir = tmp.path().join("dest");
2107 let proc_dir = tmp.path().join("oddly-named-process");
2108 std::fs::create_dir_all(dest_dir.join(".memstead")).unwrap();
2109 std::fs::create_dir_all(&proc_dir).unwrap();
2110 std::fs::write(
2113 dest_dir.join(".memstead").join("config.json"),
2114 r#"{ "schema": "default@1.0.0", "processMem": "oddly-named-process" }"#,
2115 )
2116 .unwrap();
2117 let engine = crate::Engine::from_mounts(vec![
2118 (
2119 folder_mount("dest", dest_dir.clone()),
2120 Box::new(crate::storage::FilesystemMemWriter::new(dest_dir.clone()))
2121 as Box<dyn crate::backend::MemBackend>,
2122 ),
2123 (
2124 folder_mount("oddly-named-process", proc_dir.clone()),
2125 Box::new(crate::storage::FilesystemMemWriter::new(proc_dir))
2126 as Box<dyn crate::backend::MemBackend>,
2127 ),
2128 ])
2129 .unwrap();
2130
2131 let r = crate::ingest::resolve::resolve_process_mem(&engine, "dest", "dest-derived");
2133 assert!(r.declared && r.mounted);
2134 assert_eq!(r.mem, "oddly-named-process");
2135 let r =
2138 crate::ingest::resolve::resolve_process_mem(&engine, "oddly-named-process", "whatever");
2139 assert!(!r.declared && !r.mounted);
2140 assert_eq!(r.mem, "whatever");
2141
2142 let axis = health_open_questions_axis(&engine, Some("dest"));
2144 let process = &axis["dest"]["process"];
2145 assert_eq!(process[0]["process_mem"], "oddly-named-process", "{axis}");
2146 assert_eq!(process[0]["declared"], true, "{axis}");
2147 assert_eq!(process[0]["resolvable"], true, "{axis}");
2148
2149 std::fs::write(
2151 dest_dir.join(".memstead").join("config.json"),
2152 r#"{ "schema": "default@1.0.0", "processMem": "nowhere" }"#,
2153 )
2154 .unwrap();
2155 let engine2 = crate::Engine::from_mounts(vec![(
2156 folder_mount("dest", dest_dir.clone()),
2157 Box::new(crate::storage::FilesystemMemWriter::new(dest_dir))
2158 as Box<dyn crate::backend::MemBackend>,
2159 )])
2160 .unwrap();
2161 let axis = health_open_questions_axis(&engine2, Some("dest"));
2162 let process = &axis["dest"]["process"];
2163 assert_eq!(
2164 process[0]["finding"], "DECLARED_PROCESS_MEM_MISSING",
2165 "{axis}"
2166 );
2167 assert_eq!(process[0]["resolvable"], false, "{axis}");
2168 }
2169
2170 fn make_entity(name: &str, has_required: bool) -> Entity {
2171 let mut metadata = IndexMap::new();
2172 metadata.insert("level".into(), MetadataValue::String("M0".into()));
2173 metadata.insert("type".into(), MetadataValue::String("spec".into()));
2174 metadata.insert(
2175 "created_date".into(),
2176 MetadataValue::String("2026-01-15".into()),
2177 );
2178 metadata.insert(
2179 "last_modified".into(),
2180 MetadataValue::String("2026-04-12".into()),
2181 );
2182
2183 let mut sections = IndexMap::new();
2184 if has_required {
2185 sections.insert("identity".into(), "Has identity.".into());
2186 sections.insert("purpose".into(), "Has purpose.".into());
2187 }
2188
2189 Entity {
2190 id: EntityId::new("specs", name),
2191 title: name.into(),
2192 entity_type: "spec".into(),
2193 mem: "specs".into(),
2194 file_path: format!("{name}.md"),
2195 metadata,
2196 sections,
2197 relationships: Vec::new(),
2198 content_hash: String::new(),
2199 stub: false,
2200 stub_kind: None,
2201 heading_spans: std::collections::HashMap::new(),
2202 raw_section_headings: Vec::new(),
2203 }
2204 }
2205
2206 fn violating_type() -> std::sync::Arc<TypeDefinition> {
2210 let manifest = r#"name: debate
2211version: 0.1.0
2212description: sealed-violator fixture
2213when_to_use: health tests
2214types:
2215 - question
2216relationships:
2217 mode: strict
2218 definitions:
2219 - name: PART_OF
2220 description: hier
2221 default_weight: 3.0
2222 - name: _default
2223 description: fallback
2224 default_weight: 1.0
2225community:
2226 resolution: 1.0
2227 seed: 42
2228"#;
2229 let type_yaml = r#"name: question
2230description: t
2231when_to_use: tests
2232sections:
2233 - key: answers
2234 heading: Answers argued
2235 required: true
2236 search_weight: 10.0
2237 write_rules: []
2238 - key: notes
2239 heading: Notes
2240 required: false
2241 search_weight: 3.0
2242 catch_all: true
2243 write_rules: []
2244metadata_fields: []
2245title_weight: 100.0
2246text_fields:
2247 - answers
2248 - notes
2249hierarchy_relationship: PART_OF
2250no_self_loop_relationships: []
2251updatable_fields:
2252 - title
2253 - answers
2254 - notes
2255health_required_fields:
2256 - answers
2257staleness_threshold_days: 90
2258write_rules: []
2259"#;
2260 memstead_schema::load_schema_from_memory(
2261 manifest,
2262 &[("question".to_string(), type_yaml.to_string())],
2263 )
2264 .expect("violating schema still loads")
2265 .get_type("question")
2266 .expect("question type")
2267 }
2268
2269 #[test]
2275 fn health_distinguishes_heading_mismatch_from_missing_section() {
2276 let schema = violating_type();
2277
2278 let md = "---\ntype: question\n---\n# Q\n\n## Answers argued\n\nTwo answers.\n";
2280 let parsed = crate::entity::parser::parse_markdown(md, "q.md", &schema, "debate")
2281 .expect("parses")
2282 .entity;
2283 let report = entity_health(&parsed, &schema);
2284 let mismatch: Vec<_> = report
2285 .issues
2286 .iter()
2287 .filter(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch)
2288 .collect();
2289 assert_eq!(mismatch.len(), 1, "issues: {:?}", report.issues);
2290 let msg = &mismatch[0].message;
2291 assert!(
2292 msg.contains("'Answers argued'") && msg.contains("'answers_argued'"),
2293 "names found heading and derived key: {msg}"
2294 );
2295 assert!(
2296 msg.contains("'notes'"),
2297 "names the catch-all landing: {msg}"
2298 );
2299 assert!(
2300 !report.issues.iter().any(|i| i.message.contains("is empty")),
2301 "must not also report the section as missing: {:?}",
2302 report.issues
2303 );
2304
2305 let md_missing = "---\ntype: question\n---\n# Q2\n";
2307 let parsed_missing =
2308 crate::entity::parser::parse_markdown(md_missing, "q2.md", &schema, "debate")
2309 .expect("parses")
2310 .entity;
2311 let report_missing = entity_health(&parsed_missing, &schema);
2312 assert!(
2313 report_missing
2314 .issues
2315 .iter()
2316 .any(|i| i.code == super::super::HealthIssueCode::Missing
2317 && i.message == "required section 'answers' is empty"),
2318 "absent section keeps the missing report (structured MISSING code): {:?}",
2319 report_missing.issues
2320 );
2321 assert!(
2322 !report_missing
2323 .issues
2324 .iter()
2325 .any(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch),
2326 "no mismatch finding when the heading is not in the file"
2327 );
2328
2329 let ok_type = crate::entity::parser::parse_markdown(
2334 "---\ntype: question\n---\n# Q3\n\n## Answers\n\nfree.\n",
2335 "q3.md",
2336 &schema,
2337 "debate",
2338 )
2339 .expect("parses")
2340 .entity;
2341 let report_ok = entity_health(&ok_type, &schema);
2342 assert!(
2343 !report_ok
2344 .issues
2345 .iter()
2346 .any(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch),
2347 "mismatch fires only when the declared heading is present: {:?}",
2348 report_ok.issues
2349 );
2350 }
2351
2352 fn make_concept_entity(name: &str, with_definition: bool) -> Entity {
2353 let mut metadata = IndexMap::new();
2354 metadata.insert("type".into(), MetadataValue::String("concept".into()));
2355 metadata.insert("maturity".into(), MetadataValue::String("emerging".into()));
2356 metadata.insert(
2357 "abstraction_level".into(),
2358 MetadataValue::String("concrete".into()),
2359 );
2360 metadata.insert(
2361 "created_date".into(),
2362 MetadataValue::String("2026-01-15".into()),
2363 );
2364 metadata.insert(
2365 "last_modified".into(),
2366 MetadataValue::String("2026-04-12".into()),
2367 );
2368
2369 let mut sections = IndexMap::new();
2370 if with_definition {
2371 sections.insert("definition".into(), "Precise definition.".into());
2372 }
2373 sections.insert("explanation".into(), "How it works.".into());
2374
2375 Entity {
2376 id: EntityId::new("concepts", name),
2377 title: name.into(),
2378 entity_type: "concept".into(),
2379 mem: "concepts".into(),
2380 file_path: format!("{name}.md"),
2381 metadata,
2382 sections,
2383 relationships: Vec::new(),
2384 content_hash: String::new(),
2385 stub: false,
2386 stub_kind: None,
2387 heading_spans: std::collections::HashMap::new(),
2388 raw_section_headings: Vec::new(),
2389 }
2390 }
2391
2392 #[test]
2393 fn health_concept_missing_definition_reports_definition_field() {
2394 let schema = &type_by_name("concept").unwrap();
2395 let entity = make_concept_entity("clarity", false);
2396 let report = entity_health(&entity, schema);
2397
2398 assert!(report.issues.iter().any(|i| i.field == "definition"));
2401 assert!(!report.issues.iter().any(|i| i.field == "identity"));
2402 assert!(!report.issues.iter().any(|i| i.field == "purpose"));
2403 assert!(report.score < 1.0);
2404
2405 let healthy = make_concept_entity("clarity-ok", true);
2407 let healthy_report = entity_health(&healthy, schema);
2408 assert!(
2409 !healthy_report
2410 .issues
2411 .iter()
2412 .any(|i| i.field == "definition")
2413 );
2414 }
2415
2416 #[test]
2417 fn health_detects_missing_sections() {
2418 let schema = &type_by_name("spec").unwrap();
2419 let entity = make_entity("incomplete", false);
2420 let report = entity_health(&entity, schema);
2421 assert!(!report.issues.is_empty());
2422 assert!(report.score < 1.0);
2423 }
2424
2425 #[test]
2426 fn health_clean_entity() {
2427 let schema = &type_by_name("spec").unwrap();
2428 let entity = make_entity("complete", true);
2429 let report = entity_health(&entity, schema);
2430 let section_issues: Vec<_> = report
2432 .issues
2433 .iter()
2434 .filter(|i| i.field == "identity" || i.field == "purpose")
2435 .collect();
2436 assert!(section_issues.is_empty());
2437 }
2438
2439 #[test]
2440 fn health_summary_counts() {
2441 let mut store = Store::new();
2442 let e1 = make_entity("healthy", true);
2443 let e2 = make_entity("unhealthy", false);
2444 store.upsert(e1.id.clone(), e1);
2445 store.upsert(e2.id.clone(), e2);
2446
2447 let schema = &type_by_name("spec").unwrap();
2448 let summary = compute_health(&store, schema, &HashMap::new(), None);
2449 assert_eq!(summary.orphan_count, 2); assert_eq!(summary.stub_count, 0);
2451 }
2452
2453 #[test]
2454 fn health_surfaces_invalid_rel_shape_on_existing_edges() {
2455 use crate::entity::Relationship;
2461 use memstead_schema::SchemaRegistry;
2462
2463 let registry = SchemaRegistry::builtin();
2464 let software = registry
2465 .get("software", &semver::Version::new(0, 2, 0))
2466 .expect("software schema ships as a builtin");
2467
2468 let mut store = Store::new();
2469 let mut bad = make_entity("bad-owns-source", true);
2472 bad.entity_type = "spec".into();
2473 bad.metadata
2474 .insert("level".into(), MetadataValue::String("M0".into()));
2475 bad.metadata
2476 .insert("stability".into(), MetadataValue::String("evolving".into()));
2477 bad.relationships.push(Relationship {
2478 rel_type: "OWNS".into(),
2479 target: EntityId::new("specs", "victim"),
2480 description: None,
2481 });
2482 let mut victim = make_entity("victim", true);
2483 victim.entity_type = "spec".into();
2484 store.upsert(bad.id.clone(), bad);
2485 store.upsert(victim.id.clone(), victim);
2486
2487 let mut mem_schemas = HashMap::new();
2488 mem_schemas.insert("specs".to_string(), software);
2489
2490 let schema = &type_by_name("spec").unwrap();
2491 let summary = compute_health(&store, schema, &mem_schemas, None);
2492 let report = summary
2493 .missing_fields
2494 .iter()
2495 .find(|r| r.id.as_ref() == "specs--bad-owns-source")
2496 .expect("shape-violating entity must surface");
2497 let issue = report
2498 .issues
2499 .iter()
2500 .find(|i| i.field == "relationships" && i.message.contains("INVALID_REL_SHAPE"))
2501 .expect("shape violation must produce an INVALID_REL_SHAPE issue");
2502 assert!(
2503 issue.message.contains("OWNS"),
2504 "issue must name the offending rel_type: {}",
2505 issue.message
2506 );
2507 assert!(
2508 issue.message.contains("spec"),
2509 "issue must name the actual source type: {}",
2510 issue.message
2511 );
2512 assert!(
2513 issue.message.contains("actor"),
2514 "issue must name the allowed source type: {}",
2515 issue.message
2516 );
2517 assert!(
2518 issue.message.contains("remove=true"),
2519 "issue must surface the recovery path: {}",
2520 issue.message
2521 );
2522 }
2523
2524 #[test]
2525 fn health_does_not_flag_shape_compliant_edges() {
2526 use crate::entity::Relationship;
2529 use memstead_schema::SchemaRegistry;
2530
2531 let registry = SchemaRegistry::builtin();
2532 let software = registry
2533 .get("software", &semver::Version::new(0, 2, 0))
2534 .expect("software schema ships as a builtin");
2535
2536 let mut store = Store::new();
2537 let mut owner = make_entity("owner", true);
2538 owner.entity_type = "actor".into();
2539 owner
2540 .metadata
2541 .insert("kind".into(), MetadataValue::String("team".into()));
2542 owner
2543 .metadata
2544 .insert("active".into(), MetadataValue::Bool(true));
2545 owner
2546 .metadata
2547 .insert("handle".into(), MetadataValue::String("owner".into()));
2548 owner.relationships.push(Relationship {
2549 rel_type: "OWNS".into(),
2550 target: EntityId::new("specs", "owned"),
2551 description: None,
2552 });
2553 let mut owned = make_entity("owned", true);
2554 owned.entity_type = "spec".into();
2555 store.upsert(owner.id.clone(), owner);
2556 store.upsert(owned.id.clone(), owned);
2557
2558 let mut mem_schemas = HashMap::new();
2559 mem_schemas.insert("specs".to_string(), software);
2560
2561 let schema = &type_by_name("spec").unwrap();
2562 let summary = compute_health(&store, schema, &mem_schemas, None);
2563 let shape_issue = summary
2564 .missing_fields
2565 .iter()
2566 .flat_map(|r| r.issues.iter())
2567 .find(|i| i.message.contains("INVALID_REL_SHAPE"));
2568 assert!(
2569 shape_issue.is_none(),
2570 "shape-compliant edge must not surface a shape issue, got: {shape_issue:?}"
2571 );
2572 }
2573
2574 #[test]
2575 fn health_warns_on_undeclared_relationship_in_existing_entity() {
2576 use crate::entity::Relationship;
2577 use memstead_schema::Schema;
2578
2579 let mut store = Store::new();
2580 let mut entity = make_entity("with-bad-rel", true);
2581 entity.relationships.push(Relationship {
2587 rel_type: "CONJURES".into(),
2588 target: EntityId::new("specs", "unknown"),
2589 description: None,
2590 });
2591 store.upsert(entity.id.clone(), entity);
2592
2593 let mut mem_schemas = HashMap::new();
2594 mem_schemas.insert("specs".to_string(), Schema::builtin_default());
2595
2596 let schema = &type_by_name("spec").unwrap();
2597 let summary = compute_health(&store, schema, &mem_schemas, None);
2598 let report = summary
2599 .missing_fields
2600 .iter()
2601 .find(|r| r.id.as_ref() == "specs--with-bad-rel")
2602 .expect("entity must surface in missing_fields");
2603 let rel_issue = report
2604 .issues
2605 .iter()
2606 .find(|i| i.field == "relationships")
2607 .expect("undeclared relationship must produce an issue");
2608 assert!(
2609 rel_issue.message.contains("CONJURES"),
2610 "issue message must name the offending relationship: {}",
2611 rel_issue.message
2612 );
2613 assert!(
2614 rel_issue.message.contains("default@1.0.0"),
2615 "issue must name the schema pin: {}",
2616 rel_issue.message
2617 );
2618 }
2619
2620 fn make_entity_with_body(name: &str, section_key: &str, body: &str) -> Entity {
2627 let mut entity = make_entity(name, true);
2628 entity.sections.insert(section_key.into(), body.to_string());
2629 entity
2630 }
2631
2632 #[test]
2633 fn dangling_link_detected_after_delete() {
2634 use crate::entity::store_builder::make_stub;
2635
2636 let mut store = Store::new();
2637 let a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
2638 store.upsert(a.id.clone(), a.clone());
2639
2640 let b_id = EntityId::new("specs", "b");
2643 store.upsert(b_id.clone(), make_stub(b_id.clone()));
2644
2645 let dangling = super::collect_dangling_links(&store, None);
2646 assert_eq!(dangling.len(), 1, "exactly one dangling link expected");
2647 let d = &dangling[0];
2648 assert_eq!(d.from, a.id);
2649 assert_eq!(d.target_id, b_id);
2650 assert_eq!(d.target_path, "b");
2651 assert_eq!(d.section.as_deref(), Some("purpose"));
2652 }
2653
2654 #[test]
2660 fn dangling_links_and_stubs_serve_in_deterministic_order() {
2661 use crate::entity::store_builder::make_stub;
2662
2663 let build = || {
2664 let mut store = Store::new();
2665 for name in ["zeta", "alpha", "mid"] {
2667 let e = make_entity_with_body(
2668 name,
2669 "purpose",
2670 &format!("See [[gone-{name}]] and [[lost-{name}]]."),
2671 );
2672 store.upsert(e.id.clone(), e);
2673 }
2674 for name in ["zeta", "alpha", "mid"] {
2675 for pre in ["gone", "lost"] {
2676 let id = EntityId::new("specs", &format!("{pre}-{name}"));
2677 store.upsert(id.clone(), make_stub(id));
2678 }
2679 }
2680 store
2681 };
2682
2683 let store_a = build();
2684 let store_b = build();
2685
2686 let key =
2687 |d: &super::DanglingLink| (d.from.0.clone(), d.target_id.0.clone(), d.section.clone());
2688 let dangling_a: Vec<_> = super::collect_dangling_links(&store_a, None)
2689 .iter()
2690 .map(key)
2691 .collect();
2692 let dangling_b: Vec<_> = super::collect_dangling_links(&store_b, None)
2693 .iter()
2694 .map(key)
2695 .collect();
2696 assert_eq!(dangling_a, dangling_b, "identical stores, identical order");
2697 let mut sorted = dangling_a.clone();
2698 sorted.sort();
2699 assert_eq!(dangling_a, sorted, "served pre-sorted by (from, target)");
2700 assert_eq!(dangling_a.len(), 6);
2701
2702 let stub_ids = |s: &Store| -> Vec<String> {
2703 crate::graph::query::find_stubs(s)
2704 .into_iter()
2705 .map(|(id, _)| id.0)
2706 .collect()
2707 };
2708 let stubs_a = stub_ids(&store_a);
2709 assert_eq!(stubs_a, stub_ids(&store_b), "stub order is deterministic");
2710 let mut sorted = stubs_a.clone();
2711 sorted.sort();
2712 assert_eq!(stubs_a, sorted, "stubs served pre-sorted by id");
2713 assert_eq!(stubs_a.len(), 6);
2714 }
2715
2716 #[test]
2717 fn dangling_link_does_not_flag_stub_target_of_explicit_relationship() {
2718 use crate::entity::Relationship;
2719 use crate::entity::store_builder::make_stub;
2720
2721 let mut store = Store::new();
2722 let mut a = make_entity("a", true);
2725 let b_id = EntityId::new("specs", "b");
2726 a.relationships.push(Relationship {
2727 rel_type: "REFERENCES".into(),
2728 target: b_id.clone(),
2729 description: None,
2730 });
2731 store.upsert(a.id.clone(), a);
2732 store.upsert(b_id.clone(), make_stub(b_id));
2733
2734 let dangling = super::collect_dangling_links(&store, None);
2735 assert!(
2736 dangling.is_empty(),
2737 "explicit relationships to stubs are valid by design \
2738 (stubs are first-class placeholders); only inline-body \
2739 wiki-links to stubs must surface"
2740 );
2741 }
2742
2743 #[test]
2744 fn dangling_link_does_not_flag_real_reference() {
2745 use crate::entity::Relationship;
2746
2747 let mut store = Store::new();
2748 let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
2749 a.relationships.push(Relationship {
2751 rel_type: "REFERENCES".into(),
2752 target: EntityId::new("specs", "b"),
2753 description: None,
2754 });
2755 let b = make_entity("b", true);
2756 store.upsert(a.id.clone(), a);
2757 store.upsert(b.id.clone(), b);
2758
2759 let dangling = super::collect_dangling_links(&store, None);
2760 assert!(
2761 dangling.is_empty(),
2762 "real reference backed by relation — not dangling, not alias-orphan"
2763 );
2764 }
2765
2766 #[test]
2771 fn dangling_link_relationship_section_target_absent() {
2772 use crate::entity::Relationship;
2773
2774 let mut store = Store::new();
2775 let mut a = make_entity("a", true);
2776 a.relationships.push(Relationship {
2779 rel_type: "DEPENDS_ON".into(),
2780 target: EntityId::new("specs", "gone"),
2781 description: None,
2782 });
2783 store.upsert(a.id.clone(), a.clone());
2784
2785 let dangling = super::collect_dangling_links(&store, None);
2786 assert_eq!(
2787 dangling.len(),
2788 1,
2789 "exactly one relationship-section dangler"
2790 );
2791 let d = &dangling[0];
2792 assert_eq!(d.from, a.id);
2793 assert_eq!(d.target_id, EntityId::new("specs", "gone"));
2794 assert!(
2795 d.section.is_none(),
2796 "relationship-section danglers ship `section: None`, got {:?}",
2797 d.section
2798 );
2799 }
2800
2801 #[test]
2806 fn dangling_link_relationship_section_stub_target_not_flagged() {
2807 use crate::entity::Relationship;
2808 use crate::entity::store_builder::make_stub;
2809
2810 let mut store = Store::new();
2811 let mut a = make_entity("a", true);
2812 let b_id = EntityId::new("specs", "b");
2813 a.relationships.push(Relationship {
2814 rel_type: "DEPENDS_ON".into(),
2815 target: b_id.clone(),
2816 description: None,
2817 });
2818 store.upsert(a.id.clone(), a);
2819 store.upsert(b_id.clone(), make_stub(b_id));
2820
2821 let dangling = super::collect_dangling_links(&store, None);
2822 assert!(
2823 dangling.is_empty(),
2824 "relationship targets that resolve to stubs are forward-references, not corruption"
2825 );
2826 }
2827
2828 #[test]
2835 fn dangling_link_dedups_across_body_and_relations() {
2836 use crate::entity::Relationship;
2837 use crate::entity::store_builder::make_stub;
2838
2839 let mut store = Store::new();
2840 let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
2841 let b_id = EntityId::new("specs", "b");
2842 a.relationships.push(Relationship {
2843 rel_type: "REFERENCES".into(),
2844 target: b_id.clone(),
2845 description: None,
2846 });
2847 store.upsert(a.id.clone(), a.clone());
2848 store.upsert(b_id.clone(), make_stub(b_id.clone()));
2849
2850 let dangling = super::collect_dangling_links(&store, None);
2851 assert_eq!(
2852 dangling.len(),
2853 1,
2854 "body + relations both pointing at the same stub should dedup"
2855 );
2856 assert!(dangling[0].section.is_some(), "body axis wins the dedup");
2859 }
2860
2861 #[test]
2862 fn dangling_links_scope_to_mem_filter() {
2863 use crate::entity::store_builder::make_stub;
2864
2865 let mut store = Store::new();
2866
2867 let a = make_entity_with_body("a", "purpose", "Refers to [[gone]] in prose.");
2869 store.upsert(a.id.clone(), a);
2870 let gone_specs = EntityId::new("specs", "gone");
2871 store.upsert(gone_specs.clone(), make_stub(gone_specs));
2872
2873 let mut x = make_entity("x", true);
2875 x.id = EntityId::new("web", "x");
2876 x.mem = "web".into();
2877 x.file_path = "x.md".into();
2878 x.sections
2879 .insert("purpose".into(), "Refers to [[gone]] in prose.".into());
2880 store.upsert(x.id.clone(), x);
2881 let gone_web = EntityId::new("web", "gone");
2882 store.upsert(gone_web.clone(), make_stub(gone_web));
2883
2884 let all = super::collect_dangling_links(&store, None);
2885 assert_eq!(all.len(), 2);
2886
2887 let specs_only = super::collect_dangling_links(&store, Some("specs"));
2888 assert_eq!(specs_only.len(), 1);
2889 assert_eq!(specs_only[0].from.mem(), "specs");
2890
2891 let web_only = super::collect_dangling_links(&store, Some("web"));
2892 assert_eq!(web_only.len(), 1);
2893 assert_eq!(web_only[0].from.mem(), "web");
2894 }
2895
2896 #[test]
2897 fn parse_iso_date() {
2898 let days = parse_iso_to_days("2026-04-12").unwrap();
2899 assert!(days > 0);
2900
2901 let days_with_time = parse_iso_to_days("2026-04-12T10:00:00Z").unwrap();
2902 assert_eq!(days, days_with_time);
2903 }
2904
2905 #[test]
2906 fn ymd_roundtrip() {
2907 let days = ymd_to_days(2026, 1, 1);
2909 assert!(days > 20000); }
2911
2912 fn make_entity_with_tags(name: &str, mem: &str, entity_type: &str, tags: &str) -> Entity {
2917 let mut e = make_entity(name, true);
2918 e.id = EntityId::new(mem, name);
2919 e.mem = mem.into();
2920 e.entity_type = entity_type.into();
2921 e.metadata
2922 .insert("tags".into(), MetadataValue::String(tags.into()));
2923 e
2924 }
2925
2926 fn make_entity_no_tags(name: &str) -> Entity {
2927 make_entity(name, true)
2928 }
2929
2930 #[test]
2931 fn tag_distribution_aggregates_across_entities() {
2932 let mut store = Store::new();
2933 let a = make_entity_with_tags("a", "specs", "spec", "decision, plan");
2934 let b = make_entity_with_tags("b", "specs", "spec", "decision, plan");
2935 let c = make_entity_with_tags("c", "specs", "spec", "plan");
2936 store.upsert(a.id.clone(), a);
2937 store.upsert(b.id.clone(), b);
2938 store.upsert(c.id.clone(), c);
2939
2940 let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
2941 assert_eq!(dist.len(), 2);
2942 assert_eq!(dist[0].tag, "plan");
2943 assert_eq!(dist[0].count, 3);
2944 assert_eq!(dist[0].by_entity_type.get("spec"), Some(&3));
2945 assert_eq!(dist[1].tag, "decision");
2946 assert_eq!(dist[1].count, 2);
2947 assert_eq!(untagged.total, 0);
2948 }
2949
2950 #[test]
2951 fn tag_distribution_case_sensitive() {
2952 let mut store = Store::new();
2953 let a = make_entity_with_tags("a", "specs", "spec", "Decision");
2954 let b = make_entity_with_tags("b", "specs", "spec", "decision");
2955 store.upsert(a.id.clone(), a);
2956 store.upsert(b.id.clone(), b);
2957
2958 let (dist, folded, _untagged) = collect_tag_distribution(&store, None, 10);
2959 assert_eq!(dist.len(), 2, "`decision` and `Decision` stay distinct");
2960 let tags: std::collections::HashSet<&str> = dist.iter().map(|t| t.tag.as_str()).collect();
2961 assert!(tags.contains("decision"));
2962 assert!(tags.contains("Decision"));
2963
2964 assert_eq!(folded.len(), 1);
2966 assert_eq!(folded[0].canonical, "decision");
2967 assert_eq!(folded[0].total, 2);
2968 assert_eq!(folded[0].variants.len(), 2);
2969 }
2970
2971 #[test]
2972 fn untagged_entities_counts_missing_and_empty() {
2973 let mut store = Store::new();
2974 let a = make_entity_no_tags("a"); let b = make_entity_with_tags("b", "specs", "spec", "");
2976 let c = make_entity_with_tags("c", "specs", "spec", " , , ");
2977 store.upsert(a.id.clone(), a);
2978 store.upsert(b.id.clone(), b);
2979 store.upsert(c.id.clone(), c);
2980
2981 let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
2982 assert!(dist.is_empty(), "no effective tags → empty distribution");
2983 assert_eq!(untagged.total, 3);
2984 assert_eq!(untagged.by_entity_type.get("spec"), Some(&3));
2985 }
2986
2987 #[test]
2988 fn tag_distribution_respects_mem_filter() {
2989 let mut store = Store::new();
2990 let a = make_entity_with_tags("a", "specs", "spec", "decision");
2991 let b = make_entity_with_tags("b", "memos", "memo", "observation");
2992 let c = make_entity_no_tags("c");
2993 store.upsert(a.id.clone(), a);
2994 store.upsert(b.id.clone(), b);
2995 store.upsert(c.id.clone(), c);
2996
2997 let (dist, _folded, untagged) = collect_tag_distribution(&store, Some("memos"), 10);
2998 assert_eq!(dist.len(), 1);
2999 assert_eq!(dist[0].tag, "observation");
3000 assert_eq!(untagged.total, 0, "untagged scoped to filter mem");
3001 }
3002
3003 #[test]
3004 fn tag_distribution_respects_limit() {
3005 let mut store = Store::new();
3006 for (name, tag) in [
3007 ("a", "t-alpha"),
3008 ("b", "t-beta"),
3009 ("c", "t-gamma"),
3010 ("d", "t-delta"),
3011 ("e", "t-epsilon"),
3012 ] {
3013 let e = make_entity_with_tags(name, "specs", "spec", tag);
3014 store.upsert(e.id.clone(), e);
3015 }
3016
3017 let (dist, _folded, _untagged) = collect_tag_distribution(&store, None, 3);
3018 assert_eq!(dist.len(), 3);
3019 assert_eq!(dist[0].tag, "t-alpha");
3022 assert_eq!(dist[1].tag, "t-beta");
3023 assert_eq!(dist[2].tag, "t-delta");
3024 }
3025
3026 fn required_outgoing_fixture_schema() -> std::sync::Arc<memstead_schema::Schema> {
3033 let manifest = r#"name: tests-ro-health
3034version: 0.1.0
3035description: required_outgoing health test schema
3036when_to_use: tests
3037types:
3038 - decision
3039 - note
3040relationships:
3041 mode: strict
3042 definitions:
3043 - name: PART_OF
3044 description: Hier
3045 default_weight: 3.0
3046 acyclic: true
3047 - name: CHOSEN
3048 description: ch
3049 default_weight: 3.0
3050 - name: REJECTED
3051 description: rj
3052 default_weight: 2.0
3053 - name: REFERENCES
3054 description: ref
3055 default_weight: 0.5
3056 - name: _default
3057 description: Fallback
3058 default_weight: 1.0
3059community:
3060 resolution: 1.0
3061 seed: 42
3062"#;
3063 let body_section = "sections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n - title\n - body\nhealth_required_fields:\n - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
3064 let decision_yaml = format!(
3065 "name: decision\ndescription: t\nwhen_to_use: Here\n{body_section}required_outgoing:\n - relationships: [CHOSEN]\n cardinality: at_least_one\n - relationships: [REJECTED]\n cardinality: at_least_one\n",
3066 );
3067 let note_yaml = format!("name: note\ndescription: t\nwhen_to_use: Here\n{body_section}",);
3068 std::sync::Arc::new(
3069 memstead_schema::load_schema_from_memory(
3070 manifest,
3071 &[
3072 ("decision".to_string(), decision_yaml),
3073 ("note".to_string(), note_yaml),
3074 ],
3075 )
3076 .expect("ro fixture schema must parse"),
3077 )
3078 }
3079
3080 fn make_typed_entity(mem: &str, slug: &str, entity_type: &str) -> crate::entity::Entity {
3081 use crate::entity::MetadataValue;
3082 let mut metadata = IndexMap::new();
3083 metadata.insert("type".into(), MetadataValue::String(entity_type.into()));
3084 let mut sections = IndexMap::new();
3085 sections.insert("body".into(), "Body.".into());
3086 crate::entity::Entity {
3087 id: EntityId::new(mem, slug),
3088 title: slug.to_string(),
3089 entity_type: entity_type.into(),
3090 mem: mem.into(),
3091 file_path: format!("{slug}.md"),
3092 metadata,
3093 sections,
3094 relationships: Vec::new(),
3095 content_hash: String::new(),
3096 stub: false,
3097 stub_kind: None,
3098 heading_spans: std::collections::HashMap::new(),
3099 raw_section_headings: Vec::new(),
3100 }
3101 }
3102
3103 #[test]
3104 fn missing_required_outgoing_collects_violators_only() {
3105 let schema = required_outgoing_fixture_schema();
3106 let mut store = Store::new();
3107 let mut violator = make_typed_entity("plan", "stalled", "decision");
3110 let mut satisfied = make_typed_entity("plan", "wired", "decision");
3111 let opt_a = make_typed_entity("plan", "a", "note");
3112 let opt_b = make_typed_entity("plan", "b", "note");
3113 let happy_note = make_typed_entity("plan", "side", "note");
3114 satisfied.relationships.push(crate::entity::Relationship {
3115 rel_type: "CHOSEN".into(),
3116 target: opt_a.id.clone(),
3117 description: None,
3118 });
3119 satisfied.relationships.push(crate::entity::Relationship {
3120 rel_type: "REJECTED".into(),
3121 target: opt_b.id.clone(),
3122 description: None,
3123 });
3124 for e in [violator.clone(), satisfied, opt_a, opt_b, happy_note] {
3125 store.upsert(e.id.clone(), e);
3126 }
3127
3128 let mut mem_schemas = HashMap::new();
3129 mem_schemas.insert("plan".to_string(), schema);
3130
3131 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
3132 assert_eq!(
3133 reports.len(),
3134 1,
3135 "exactly one violator (the empty decision); got {reports:?}"
3136 );
3137 let r = &reports[0];
3138 assert_eq!(r.id, violator.id);
3139 assert_eq!(r.entity_type, "decision");
3140 assert_eq!(r.mem, "plan");
3141 assert_eq!(r.missing.len(), 2);
3142 let names: Vec<&str> = r
3143 .missing
3144 .iter()
3145 .flat_map(|b| b.relationships.iter().map(String::as_str))
3146 .collect();
3147 assert!(names.contains(&"CHOSEN"));
3148 assert!(names.contains(&"REJECTED"));
3149
3150 violator.relationships.push(crate::entity::Relationship {
3152 rel_type: "CHOSEN".into(),
3153 target: EntityId::new("plan", "x"),
3154 description: None,
3155 });
3156 }
3157
3158 #[test]
3159 fn missing_required_outgoing_respects_mem_filter() {
3160 let schema = required_outgoing_fixture_schema();
3163 let mut store = Store::new();
3164 let v_a = make_typed_entity("alpha", "stalled", "decision");
3165 let v_b = make_typed_entity("beta", "stalled", "decision");
3166 store.upsert(v_a.id.clone(), v_a);
3167 store.upsert(v_b.id.clone(), v_b.clone());
3168
3169 let mut mem_schemas = HashMap::new();
3170 mem_schemas.insert("alpha".to_string(), schema.clone());
3171 mem_schemas.insert("beta".to_string(), schema);
3172
3173 let alpha_only = collect_missing_required_outgoing(&store, Some("alpha"), &mem_schemas);
3174 assert_eq!(alpha_only.len(), 1);
3175 assert_eq!(alpha_only[0].mem, "alpha");
3176
3177 let both = collect_missing_required_outgoing(&store, None, &mem_schemas);
3178 assert_eq!(both.len(), 2);
3179 }
3180
3181 #[test]
3182 fn missing_required_outgoing_skips_stubs_and_unschemaed_mems() {
3183 let schema = required_outgoing_fixture_schema();
3186 let mut store = Store::new();
3187 let mut stub = make_typed_entity("plan", "ghost", "");
3188 stub.stub = true;
3189 stub.entity_type = String::new();
3190 let other = make_typed_entity("uncharted", "lonely", "decision");
3191 store.upsert(stub.id.clone(), stub);
3192 store.upsert(other.id.clone(), other);
3193
3194 let mut mem_schemas = HashMap::new();
3195 mem_schemas.insert("plan".to_string(), schema);
3196
3197 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
3198 assert!(
3199 reports.is_empty(),
3200 "stub (no schema lookup) and unschemaed mem must be skipped; got {reports:?}",
3201 );
3202 }
3203
3204 #[test]
3209 fn missing_required_outgoing_conditional_blocks_arm_on_trigger() {
3210 use crate::entity::MetadataValue;
3211 let manifest = r#"name: tests-ro-cond
3212version: 0.1.0
3213description: conditional required_outgoing health test schema
3214when_to_use: tests
3215types:
3216 - task
3217relationships:
3218 mode: strict
3219 definitions:
3220 - name: PART_OF
3221 description: Hier
3222 default_weight: 3.0
3223 - name: _default
3224 description: Fallback
3225 default_weight: 1.0
3226community:
3227 resolution: 1.0
3228 seed: 42
3229"#;
3230 let task_yaml = "name: task\ndescription: t\nwhen_to_use: Here\nsections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields:\n - key: status\n description: workflow state\n field_type: string\n enum_values: [open, checked]\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n - title\n - body\n - status\nhealth_required_fields:\n - body\nstaleness_threshold_days: 90\nwrite_rules: []\nrequired_outgoing:\n - relationships: [PART_OF]\n cardinality: at_least_one\n when_field: status\n when_value: checked\n";
3231 let schema = std::sync::Arc::new(
3232 memstead_schema::load_schema_from_memory(
3233 manifest,
3234 &[("task".to_string(), task_yaml.to_string())],
3235 )
3236 .expect("conditional ro fixture schema must parse"),
3237 );
3238
3239 let mut store = Store::new();
3240 let mut armed = make_typed_entity("plan", "armed", "task");
3241 armed
3242 .metadata
3243 .insert("status".into(), MetadataValue::String("checked".into()));
3244 let mut other_value = make_typed_entity("plan", "quiet", "task");
3245 other_value
3246 .metadata
3247 .insert("status".into(), MetadataValue::String("open".into()));
3248 let unset = make_typed_entity("plan", "blank", "task");
3249 let parent = make_typed_entity("plan", "parent", "task");
3250 let mut satisfied = make_typed_entity("plan", "wired", "task");
3251 satisfied
3252 .metadata
3253 .insert("status".into(), MetadataValue::String("checked".into()));
3254 satisfied.relationships.push(crate::entity::Relationship {
3255 rel_type: "PART_OF".into(),
3256 target: parent.id.clone(),
3257 description: None,
3258 });
3259 for e in [armed.clone(), other_value, unset, parent, satisfied] {
3260 store.upsert(e.id.clone(), e);
3261 }
3262
3263 let mut mem_schemas = HashMap::new();
3264 mem_schemas.insert("plan".to_string(), schema);
3265
3266 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
3267 assert_eq!(
3268 reports.len(),
3269 1,
3270 "only the armed edge-less entity is reported; got {reports:?}"
3271 );
3272 let r = &reports[0];
3273 assert_eq!(r.id, armed.id);
3274 assert_eq!(r.missing.len(), 1);
3275 assert_eq!(r.missing[0].when_field.as_deref(), Some("status"));
3276 assert_eq!(r.missing[0].when_value.as_deref(), Some("checked"));
3277 }
3278
3279 fn must_reach_schema(
3287 claim_extra: &str,
3288 inference_extra: &str,
3289 ) -> std::sync::Arc<memstead_schema::Schema> {
3290 let manifest = r#"name: tests-must-reach
3291version: 0.1.0
3292description: must_reach health test schema
3293when_to_use: tests
3294types:
3295 - claim
3296 - inference
3297 - evidence
3298relationships:
3299 mode: strict
3300 definitions:
3301 - name: GROUNDS
3302 description: g
3303 default_weight: 3.0
3304 - name: CONCLUDES
3305 description: c
3306 default_weight: 3.0
3307 - name: PART_OF
3308 description: hier
3309 default_weight: 1.0
3310 - name: _default
3311 description: fallback
3312 default_weight: 1.0
3313community:
3314 resolution: 1.0
3315 seed: 42
3316"#;
3317 let body = "sections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n - title\n - body\nhealth_required_fields:\n - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
3318 let claim = format!("name: claim\ndescription: t\nwhen_to_use: Here\n{body}{claim_extra}");
3319 let inference =
3320 format!("name: inference\ndescription: t\nwhen_to_use: Here\n{body}{inference_extra}");
3321 let evidence = format!("name: evidence\ndescription: t\nwhen_to_use: Here\n{body}");
3322 std::sync::Arc::new(
3323 memstead_schema::load_schema_from_memory(
3324 manifest,
3325 &[
3326 ("claim".to_string(), claim),
3327 ("inference".to_string(), inference),
3328 ("evidence".to_string(), evidence),
3329 ],
3330 )
3331 .expect("must_reach fixture schema must parse"),
3332 )
3333 }
3334
3335 fn link(from: &mut crate::entity::Entity, rel: &str, to: &crate::entity::EntityId) {
3336 from.relationships.push(crate::entity::Relationship {
3337 rel_type: rel.into(),
3338 target: to.clone(),
3339 description: None,
3340 });
3341 }
3342
3343 fn must_reach_violations(r: &ConstraintFindingReport) -> Vec<&UnsatisfiedConstraint> {
3344 r.violations
3345 .iter()
3346 .filter(|v| matches!(v, UnsatisfiedConstraint::MustReach { .. }))
3347 .collect()
3348 }
3349
3350 const CLAIM_GROUNDS_EVIDENCE: &str = "must_reach:\n - relationships: [GROUNDS]\n direction: out\n terminal_types: [evidence]\n";
3351
3352 #[test]
3356 fn must_reach_conforming_path_silent_gap_reported() {
3357 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
3358 let mut store = Store::new();
3359 let ev = make_typed_entity("arg", "ev", "evidence");
3360 let mut direct = make_typed_entity("arg", "direct", "claim");
3361 link(&mut direct, "GROUNDS", &ev.id);
3362 let mut mid = make_typed_entity("arg", "mid", "claim");
3363 let mut chained = make_typed_entity("arg", "chained", "claim");
3364 link(&mut chained, "GROUNDS", &mid.id);
3365 link(&mut mid, "GROUNDS", &ev.id);
3366 let floating = make_typed_entity("arg", "floating", "claim");
3367 for e in [ev, direct, mid, chained, floating.clone()] {
3368 store.upsert(e.id.clone(), e);
3369 }
3370 let mut mem_schemas = HashMap::new();
3371 mem_schemas.insert("arg".to_string(), schema);
3372
3373 let reports = collect_constraint_findings(&store, None, &mem_schemas);
3374 assert_eq!(reports.len(), 1, "only the pathless claim: {reports:?}");
3375 assert_eq!(reports[0].id, floating.id);
3376 let v = must_reach_violations(&reports[0]);
3377 assert_eq!(v.len(), 1);
3378 let UnsatisfiedConstraint::MustReach {
3379 relationships,
3380 direction,
3381 terminal_types,
3382 max_depth,
3383 ..
3384 } = v[0]
3385 else {
3386 panic!("expected must_reach finding");
3387 };
3388 assert_eq!(relationships, &vec!["GROUNDS".to_string()]);
3389 assert_eq!(*direction, memstead_schema::ReachDirection::Out);
3390 assert_eq!(terminal_types, &vec!["evidence".to_string()]);
3391 assert_eq!(*max_depth, None);
3392 }
3393
3394 #[test]
3399 fn must_reach_one_hop_incoming_floating_leap() {
3400 let schema = must_reach_schema(
3401 "",
3402 "must_reach:\n - relationships: [GROUNDS]\n direction: in\n terminal_types: [claim]\n max_depth: 1\n",
3403 );
3404 let mut store = Store::new();
3405 let leap = make_typed_entity("arg", "leap", "inference");
3406 let grounded = make_typed_entity("arg", "grounded", "inference");
3407 let mut premise = make_typed_entity("arg", "premise", "claim");
3408 link(&mut premise, "GROUNDS", &grounded.id);
3409 for e in [leap.clone(), grounded, premise] {
3410 store.upsert(e.id.clone(), e);
3411 }
3412 let mut mem_schemas = HashMap::new();
3413 mem_schemas.insert("arg".to_string(), schema);
3414
3415 let reports = collect_constraint_findings(&store, None, &mem_schemas);
3416 assert_eq!(reports.len(), 1, "only the floating leap: {reports:?}");
3417 assert_eq!(reports[0].id, leap.id);
3418 }
3419
3420 #[test]
3423 fn must_reach_stub_and_non_terminal_chains_then_cleared() {
3424 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
3425 let mut store = Store::new();
3426 let mut stub_ev = make_typed_entity("arg", "ghost", "evidence");
3427 stub_ev.stub = true;
3428 let mut to_stub = make_typed_entity("arg", "to-stub", "claim");
3429 link(&mut to_stub, "GROUNDS", &stub_ev.id);
3430 let dead_end = make_typed_entity("arg", "dead-end", "claim");
3431 let mut to_claim = make_typed_entity("arg", "to-claim", "claim");
3432 link(&mut to_claim, "GROUNDS", &dead_end.id);
3433 for e in [stub_ev, to_stub.clone(), dead_end, to_claim.clone()] {
3434 store.upsert(e.id.clone(), e);
3435 }
3436 let mut mem_schemas = HashMap::new();
3437 mem_schemas.insert("arg".to_string(), schema.clone());
3438
3439 let reports = collect_constraint_findings(&store, None, &mem_schemas);
3440 let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
3441 assert!(
3442 ids.contains(&to_stub.id.0.as_str()),
3443 "stub terminates no obligation: {ids:?}"
3444 );
3445 assert!(
3446 ids.contains(&to_claim.id.0.as_str()),
3447 "non-terminal chain is a finding: {ids:?}"
3448 );
3449
3450 let ev = make_typed_entity("arg", "real-ev", "evidence");
3452 let mut repaired = store.get(&to_stub.id).unwrap().clone();
3453 link(&mut repaired, "GROUNDS", &ev.id);
3454 store.upsert(ev.id.clone(), ev);
3455 store.upsert(repaired.id.clone(), repaired);
3456 let reports = collect_constraint_findings(&store, None, &mem_schemas);
3457 let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
3458 assert!(
3459 !ids.contains(&to_stub.id.0.as_str()),
3460 "conforming path clears the finding: {ids:?}"
3461 );
3462 }
3463
3464 #[test]
3468 fn must_reach_cycles_terminate() {
3469 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
3470 let mut store = Store::new();
3471 let mut a = make_typed_entity("arg", "cyc-a", "claim");
3472 let mut b = make_typed_entity("arg", "cyc-b", "claim");
3473 link(&mut a, "GROUNDS", &b.id);
3474 link(&mut b, "GROUNDS", &a.id);
3475 for e in [a, b] {
3476 store.upsert(e.id.clone(), e);
3477 }
3478 let mut mem_schemas = HashMap::new();
3479 mem_schemas.insert("arg".to_string(), schema);
3480
3481 let reports = collect_constraint_findings(&store, None, &mem_schemas);
3482 assert_eq!(reports.len(), 2, "both cycle members lack evidence");
3483 }
3484
3485 #[test]
3489 fn must_reach_depth_bound() {
3490 let two_hop_store = || {
3491 let mut store = Store::new();
3492 let ev = make_typed_entity("arg", "ev", "evidence");
3493 let mut mid = make_typed_entity("arg", "mid", "claim");
3494 let mut start = make_typed_entity("arg", "start", "claim");
3495 link(&mut start, "GROUNDS", &mid.id);
3496 link(&mut mid, "GROUNDS", &ev.id);
3497 for e in [ev, mid, start] {
3498 store.upsert(e.id.clone(), e);
3499 }
3500 store
3501 };
3502 let bounded = |depth: u32| {
3503 must_reach_schema(
3504 &format!(
3505 "must_reach:\n - relationships: [GROUNDS]\n direction: out\n terminal_types: [evidence]\n max_depth: {depth}\n"
3506 ),
3507 "",
3508 )
3509 };
3510
3511 let store = two_hop_store();
3512 let mut mem_schemas = HashMap::new();
3513 mem_schemas.insert("arg".to_string(), bounded(1));
3514 let reports = collect_constraint_findings(&store, None, &mem_schemas);
3515 assert_eq!(
3516 reports.len(),
3517 1,
3518 "the two-hop path exceeds depth 1 for the start claim: {reports:?}"
3519 );
3520 assert_eq!(reports[0].id.0, "arg--start");
3521
3522 let mut mem_schemas = HashMap::new();
3523 mem_schemas.insert("arg".to_string(), bounded(2));
3524 let reports = collect_constraint_findings(&store, None, &mem_schemas);
3525 assert!(
3526 reports.is_empty(),
3527 "the same path satisfies depth 2: {reports:?}"
3528 );
3529 }
3530
3531 #[test]
3534 fn must_reach_two_obligations_one_finding() {
3535 let schema = must_reach_schema(
3536 "must_reach:\n - relationships: [GROUNDS]\n direction: out\n terminal_types: [evidence]\n - relationships: [CONCLUDES]\n direction: out\n terminal_types: [inference]\n",
3537 "",
3538 );
3539 let mut store = Store::new();
3540 let ev = make_typed_entity("arg", "ev", "evidence");
3541 let mut c = make_typed_entity("arg", "half", "claim");
3542 link(&mut c, "GROUNDS", &ev.id);
3543 for e in [ev, c.clone()] {
3544 store.upsert(e.id.clone(), e);
3545 }
3546 let mut mem_schemas = HashMap::new();
3547 mem_schemas.insert("arg".to_string(), schema);
3548
3549 let reports = collect_constraint_findings(&store, None, &mem_schemas);
3550 assert_eq!(reports.len(), 1);
3551 assert_eq!(reports[0].id, c.id);
3552 let v = must_reach_violations(&reports[0]);
3553 assert_eq!(v.len(), 1, "only the unsatisfied obligation: {v:?}");
3554 let UnsatisfiedConstraint::MustReach { relationships, .. } = v[0] else {
3555 panic!("expected must_reach finding");
3556 };
3557 assert_eq!(relationships, &vec!["CONCLUDES".to_string()]);
3558 }
3559
3560 #[test]
3566 fn status_propagation_rel_types_taints_across_type_boundaries() {
3567 use crate::entity::MetadataValue;
3568 let manifest = r#"name: tests-prop-set
3569version: 0.1.0
3570description: propagation relation-set test schema
3571when_to_use: tests
3572types:
3573 - claim
3574relationships:
3575 mode: strict
3576 definitions:
3577 - name: GROUNDS
3578 description: g
3579 default_weight: 3.0
3580 - name: CONCLUDES
3581 description: c
3582 default_weight: 3.0
3583 - name: PART_OF
3584 description: hier
3585 default_weight: 1.0
3586 - name: _default
3587 description: fallback
3588 default_weight: 1.0
3589community:
3590 resolution: 1.0
3591 seed: 42
3592"#;
3593 let claim_yaml = "name: claim\ndescription: t\nwhen_to_use: Here\nsections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields:\n - key: standing\n description: dialectical standing\n field_type: string\n enum_values: [active, withdrawn]\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n - title\n - body\n - standing\nhealth_required_fields:\n - body\nstaleness_threshold_days: 90\nwrite_rules: []\nconstraints:\n - kind: status_propagation\n field: standing\n value: withdrawn\n rel_types: [GROUNDS, CONCLUDES]\n direction: incoming\n";
3594 let schema = std::sync::Arc::new(
3595 memstead_schema::load_schema_from_memory(
3596 manifest,
3597 &[("claim".to_string(), claim_yaml.to_string())],
3598 )
3599 .expect("propagation-set fixture schema must parse"),
3600 );
3601
3602 let mut store = Store::new();
3603 let mut withdrawn = make_typed_entity("arg", "withdrawn-ev", "claim");
3604 withdrawn
3605 .metadata
3606 .insert("standing".into(), MetadataValue::String("withdrawn".into()));
3607 let mut inference = make_typed_entity("arg", "inference", "claim");
3608 link(&mut inference, "GROUNDS", &withdrawn.id);
3609 let mut conclusion = make_typed_entity("arg", "conclusion", "claim");
3610 link(&mut conclusion, "CONCLUDES", &inference.id);
3611 let bystander = make_typed_entity("arg", "bystander", "claim");
3612 for e in [withdrawn, inference.clone(), conclusion.clone(), bystander] {
3613 store.upsert(e.id.clone(), e);
3614 }
3615 let mut mem_schemas = HashMap::new();
3616 mem_schemas.insert("arg".to_string(), schema);
3617
3618 let reports = collect_constraint_findings(&store, None, &mem_schemas);
3619 let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
3620 assert_eq!(
3621 ids,
3622 vec![conclusion.id.0.as_str(), inference.id.0.as_str()],
3623 "the taint crosses the CONCLUDES/GROUNDS boundary, nothing else"
3624 );
3625 let UnsatisfiedConstraint::StatusPropagation {
3626 rel_type,
3627 rel_types,
3628 tainted_by,
3629 ..
3630 } = &reports[0].violations[0]
3631 else {
3632 panic!("expected status_propagation finding");
3633 };
3634 assert_eq!(*rel_type, None, "set declarations echo no single name");
3635 assert_eq!(
3636 rel_types.as_deref(),
3637 Some(&["GROUNDS".to_string(), "CONCLUDES".to_string()][..])
3638 );
3639 assert_eq!(tainted_by, "arg--withdrawn-ev");
3640 }
3641
3642 #[test]
3645 fn must_reach_cross_mem_path_and_mem_filter() {
3646 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
3647 let mut store = Store::new();
3648 let far_ev = make_typed_entity("ground", "far-ev", "evidence");
3649 let mut crossing = make_typed_entity("arg", "crossing", "claim");
3650 link(&mut crossing, "GROUNDS", &far_ev.id);
3651 let floating_arg = make_typed_entity("arg", "floating", "claim");
3652 let floating_ground = make_typed_entity("ground", "floating", "claim");
3653 for e in [far_ev, crossing, floating_arg.clone(), floating_ground] {
3654 store.upsert(e.id.clone(), e);
3655 }
3656 let mut mem_schemas = HashMap::new();
3657 mem_schemas.insert("arg".to_string(), schema.clone());
3658 mem_schemas.insert("ground".to_string(), schema);
3659
3660 let all = collect_constraint_findings(&store, None, &mem_schemas);
3661 assert_eq!(
3662 all.len(),
3663 2,
3664 "the crossing claim is satisfied via the cross-mem edge: {all:?}"
3665 );
3666 let filtered = collect_constraint_findings(&store, Some("arg"), &mem_schemas);
3667 assert_eq!(filtered.len(), 1, "mem filter narrows: {filtered:?}");
3668 assert_eq!(filtered[0].id, floating_arg.id);
3669 }
3670}