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 "conformance",
43 "integrity",
44 "config",
45 "anchors",
46 "friction",
47 "open_questions",
48 "stale_derivations",
49 "checks",
50];
51
52pub fn health_checks_axis(
76 engine: &crate::engine::Engine,
77 mem_filter: Option<&str>,
78) -> serde_json::Value {
79 let cap = OPEN_QUESTIONS_ITEM_CAP;
80 let capped = |mut items: Vec<String>| -> serde_json::Value {
81 items.sort();
82 let count = items.len();
83 let more = count.saturating_sub(cap);
84 items.truncate(cap);
85 let mut o = serde_json::Map::new();
86 o.insert("count".into(), serde_json::json!(count));
87 o.insert("items".into(), serde_json::json!(items));
88 if more > 0 {
89 o.insert("more".into(), serde_json::json!(more));
90 }
91 serde_json::Value::Object(o)
92 };
93
94 let ledger = engine
95 .workspace_root()
96 .map(crate::check::CheckLedger::for_workspace);
97 let mut latest: std::collections::BTreeMap<String, crate::check::CheckRecord> =
99 std::collections::BTreeMap::new();
100 if let Some(l) = &ledger {
101 for rec in l.all() {
102 latest.insert(rec.entity.clone(), rec);
103 }
104 }
105
106 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
107 mems.sort();
108 let mut out = serde_json::Map::new();
109 for mem in mems {
110 if let Some(f) = mem_filter
111 && f != mem
112 {
113 continue;
114 }
115 let mut counts = std::collections::BTreeMap::from([
116 ("never_checked", 0usize),
117 ("checked_ok", 0usize),
118 ("check_failed", 0usize),
119 ("check_stale", 0usize),
120 ]);
121 let self_checked: Vec<String> = Vec::new();
125 let confirmed_independent: Vec<String> = Vec::new();
126 let mut unconfirmable: Vec<String> = Vec::new();
127 for e in engine.store().all_entities().filter(|e| e.mem == mem) {
128 let id = e.id.0.clone();
129 let state = crate::check::derive_state(latest.get(&id), &e.content_hash);
130 *counts.entry(state.as_str()).or_insert(0) += 1;
131 if state != crate::check::CheckState::CheckedOk {
132 continue;
133 }
134 unconfirmable.push(id);
145 }
146 let mut m = serde_json::Map::new();
147 for (k, v) in counts {
148 m.insert(k.to_string(), serde_json::json!(v));
149 }
150 m.insert(
151 "independence".into(),
152 serde_json::json!({
153 "self_checked": capped(self_checked),
154 "confirmed_independent": capped(confirmed_independent),
155 "unconfirmable": capped(unconfirmable),
156 }),
157 );
158 out.insert(mem, serde_json::Value::Object(m));
159 }
160 serde_json::Value::Object(out)
161}
162
163#[derive(Debug, Clone, serde::Serialize)]
169pub struct DerivationFinding {
170 pub source: crate::entity::EntityId,
171 pub rel_type: String,
172 pub target: crate::entity::EntityId,
173 pub state: String,
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub baseline: Option<String>,
178 pub current: String,
180}
181
182pub fn health_stale_derivations_axis(
187 engine: &crate::engine::Engine,
188 mem_filter: Option<&str>,
189) -> serde_json::Value {
190 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
191 mems.sort();
192 let mut out = serde_json::Map::new();
193 for mem in mems {
194 if let Some(f) = mem_filter
195 && f != mem
196 {
197 continue;
198 }
199 let findings = engine.derivation_report(&mem).unwrap_or_default();
200 out.insert(
201 mem,
202 serde_json::to_value(&findings).unwrap_or(serde_json::Value::Array(Vec::new())),
203 );
204 }
205 serde_json::Value::Object(out)
206}
207
208pub const OPEN_QUESTIONS_ITEM_CAP: usize = 20;
212
213pub fn health_open_questions_axis(
229 engine: &crate::engine::Engine,
230 mem_filter: Option<&str>,
231) -> serde_json::Value {
232 let cap = OPEN_QUESTIONS_ITEM_CAP;
233 let capped = |mut items: Vec<serde_json::Value>| -> serde_json::Value {
234 let count = items.len();
235 let more = count.saturating_sub(cap);
236 items.truncate(cap);
237 let mut o = serde_json::Map::new();
238 o.insert("count".into(), serde_json::json!(count));
239 o.insert("items".into(), serde_json::Value::Array(items));
240 if more > 0 {
241 o.insert("more".into(), serde_json::json!(more));
242 }
243 serde_json::Value::Object(o)
244 };
245
246 let bindings: Vec<(String, String)> = engine
250 .workspace_root()
251 .and_then(|root| crate::pipeline_store::load_pipeline_configs(root).ok())
252 .map(|c| {
253 c.bindings
254 .iter()
255 .map(|r| (r.config.destination_mem.clone(), r.name.clone()))
256 .collect()
257 })
258 .unwrap_or_default();
259 let mounted: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
260
261 let mut mems: Vec<String> = mounted.clone();
262 mems.sort();
263 let mut out = serde_json::Map::new();
264 for mem in &mems {
265 if let Some(f) = mem_filter
266 && f != mem
267 {
268 continue;
269 }
270
271 let stubs = capped(
273 engine
274 .store()
275 .all_entities()
276 .filter(|e| e.stub && e.id.mem() == mem)
277 .map(|e| serde_json::json!({ "kind": "stub", "id": e.id.to_string() }))
278 .collect(),
279 );
280
281 let (mut recheck, mut unresolvable) = (Vec::new(), Vec::new());
284 if let Ok(report) = engine.verify_mem_anchors(mem) {
285 for a in &report.anchors {
286 let item = serde_json::json!({
287 "kind": format!("anchor_{}", a.state),
288 "id": a.entity_id,
289 "artifact": a.artifact,
290 });
291 match a.state.as_str() {
292 "recheck" => recheck.push(item),
293 "unresolvable" => unresolvable.push(item),
294 _ => {}
295 }
296 }
297 }
298
299 let constraints = capped(
302 engine
303 .constraint_findings(Some(mem))
304 .iter()
305 .map(|r| {
306 serde_json::json!({
307 "kind": "unsatisfied_constraint",
308 "id": r.id.to_string(),
309 "violations": r.violations.len(),
310 })
311 })
312 .collect(),
313 );
314
315 let dangling = capped(
317 collect_dangling_links(engine.store(), Some(mem))
318 .iter()
319 .map(|d| {
320 serde_json::json!({
321 "kind": "dangling_link",
322 "id": d.from.to_string(),
323 "target": d.target_id.to_string(),
324 })
325 })
326 .collect(),
327 );
328
329 let mut process = Vec::new();
339 let mem_bindings: Vec<&String> = bindings
340 .iter()
341 .filter(|(d, _)| d == mem)
342 .map(|(_, b)| b)
343 .collect();
344 let mut resolutions: Vec<(Option<String>, crate::ingest::resolve::ProcessMemResolution)> =
345 Vec::new();
346 if mem_bindings.is_empty() {
347 let r = crate::ingest::resolve::resolve_process_mem(engine, mem, "");
348 if r.declared {
349 resolutions.push((None, r));
350 }
351 } else {
352 for binding in &mem_bindings {
353 resolutions.push((
354 Some((*binding).clone()),
355 crate::ingest::resolve::resolve_process_mem(engine, mem, binding),
356 ));
357 }
358 }
359 for (binding, r) in resolutions {
360 if r.mounted {
361 let mut open = Vec::new();
362 let mut searched = Vec::new();
363 for e in engine
364 .store()
365 .all_entities()
366 .filter(|e| !e.stub && e.id.mem() == r.mem.as_str())
367 {
368 let item = serde_json::json!({
369 "kind": e.entity_type,
370 "id": e.id.to_string(),
371 "title": e.title,
372 });
373 if e.entity_type == "negative_finding" {
374 searched.push(item);
375 } else {
376 open.push(item);
377 }
378 }
379 process.push(serde_json::json!({
380 "binding": binding,
381 "process_mem": r.mem,
382 "declared": r.declared,
383 "resolvable": true,
384 "open_entries": capped(open),
385 "already_searched": capped(searched),
386 }));
387 } else if r.declared {
388 process.push(serde_json::json!({
389 "binding": binding,
390 "process_mem": r.mem,
391 "declared": true,
392 "resolvable": false,
393 "finding": "DECLARED_PROCESS_MEM_MISSING",
394 }));
395 } else {
396 process.push(serde_json::json!({
397 "binding": binding,
398 "resolvable": false,
399 }));
400 }
401 }
402
403 let total_open = stubs["count"].as_u64().unwrap_or(0)
404 + recheck.len() as u64
405 + unresolvable.len() as u64
406 + constraints["count"].as_u64().unwrap_or(0)
407 + dangling["count"].as_u64().unwrap_or(0)
408 + process
409 .iter()
410 .filter_map(|p| p["open_entries"]["count"].as_u64())
411 .sum::<u64>();
412
413 let mut entry = serde_json::Map::new();
414 entry.insert("stubs".into(), stubs);
415 entry.insert("anchors_recheck".into(), capped(recheck));
416 entry.insert("anchors_unresolvable".into(), capped(unresolvable));
417 entry.insert("unsatisfied_constraints".into(), constraints);
418 entry.insert("dangling_links".into(), dangling);
419 if !process.is_empty() {
420 entry.insert("process".into(), serde_json::Value::Array(process));
421 } else {
422 entry.insert("process_mem_resolvable".into(), serde_json::json!(false));
425 }
426 entry.insert("total_open".into(), serde_json::json!(total_open));
427 out.insert(mem.clone(), serde_json::Value::Object(entry));
428 }
429 let mut top = serde_json::Map::new();
430 top.insert("_item_cap".into(), serde_json::json!(cap));
431 for (k, v) in out {
432 top.insert(k, v);
433 }
434 serde_json::Value::Object(top)
435}
436
437pub fn health_anchors_axis(engine: &crate::engine::Engine) -> serde_json::Value {
438 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
439 mems.sort();
440 let mut out = serde_json::Map::new();
441 for mem in mems {
442 let Ok(report) = engine.verify_mem_anchors(&mem) else {
443 continue;
444 };
445 out.insert(
446 mem,
447 serde_json::json!({
448 "resolved": report.resolved,
449 "drifted": report.drifted,
450 "recheck": report.recheck,
451 "unresolvable": report.unresolvable,
452 }),
453 );
454 }
455 serde_json::Value::Object(out)
456}
457
458pub fn compute_health(
465 store: &Store,
466 default_schema: &TypeDefinition,
467 mem_schemas: &HashMap<String, Arc<Schema>>,
468) -> HealthSummary {
469 let mut missing_fields = Vec::new();
470 let mut stale_entities = Vec::new();
471
472 let today_days = days_since_epoch();
473
474 for entity in store.all_entities() {
475 if entity.stub {
476 continue;
477 }
478
479 let resolved = mem_schemas
488 .get(entity.mem.as_str())
489 .and_then(|s| s.types.get(entity.entity_type.as_str()).cloned())
490 .or_else(|| type_by_name(&entity.entity_type));
491 let schema: &TypeDefinition = resolved.as_deref().unwrap_or(default_schema);
492 let mut issues = Vec::new();
493
494 for field in &schema.health_required_fields {
496 if schema.section(field).is_some() {
498 let content = entity.sections.get(field.as_str());
503 if content.is_none_or(|c| c.trim().is_empty()) {
504 if let Some(issue) = section_heading_mismatch_issue(entity, schema, field) {
505 issues.push(issue);
506 } else {
507 issues.push(HealthIssue {
508 field: field.clone(),
509 code: super::HealthIssueCode::Missing,
510 message: format!("required section '{field}' is empty"),
511 });
512 }
513 }
514 } else {
515 let value = entity.metadata.get(field.as_str());
521 let is_empty = match value {
522 None => true,
523 Some(v) => v.to_frontmatter_string().trim().is_empty(),
524 };
525 if is_empty {
526 issues.push(HealthIssue {
527 field: field.clone(),
528 code: super::HealthIssueCode::Missing,
529 message: format!("required field '{field}' is missing"),
530 });
531 }
532 }
533 }
534
535 for s in schema.sections.iter().filter(|s| !s.catch_all) {
538 if schema.health_required_fields.contains(&s.key) {
539 continue; }
541 let content = entity.sections.get(s.key.as_str());
542 if content.is_none_or(|c| c.trim().is_empty())
543 && let Some(issue) = section_heading_mismatch_issue(entity, schema, &s.key)
544 {
545 issues.push(issue);
546 }
547 }
548
549 if let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) {
565 let mut seen_unknown = std::collections::HashSet::new();
566 for rel in &entity.relationships {
567 if !mem_schema.relationship_known(&rel.rel_type) {
568 if seen_unknown.insert(rel.rel_type.clone()) {
569 let suggestion = mem_schema
570 .suggest_relationship(&rel.rel_type)
571 .map(|s| format!(" Did you mean '{s}'?"))
572 .unwrap_or_default();
573 let (schema_name, schema_version) = mem_schema.id();
574 issues.push(HealthIssue {
575 field: "relationships".to_string(),
576 code: super::HealthIssueCode::UndeclaredRelationship,
577 message: format!(
578 "relationship '{}' is not declared in schema \
579 '{schema_name}@{schema_version}'.{suggestion}",
580 rel.rel_type
581 ),
582 });
583 }
584 continue;
585 }
586
587 let target_type = store
588 .get(&rel.target)
589 .map(|t| t.entity_type.clone())
590 .filter(|t| !t.is_empty());
591 if let Err(crate::runtime_validator::ValidationError::InvalidRelationshipShape {
592 rel_type,
593 from_type,
594 to_type,
595 allowed_source_types,
596 allowed_target_types,
597 ..
598 }) = crate::runtime_validator::validate_rel_shape(
599 &rel.rel_type,
600 entity.entity_type.as_str(),
601 target_type.as_deref(),
602 mem_schema.as_ref(),
603 ) {
604 let allowed_src = if allowed_source_types.is_empty() {
605 "<any>".to_string()
606 } else {
607 allowed_source_types.join(", ")
608 };
609 let allowed_tgt = if allowed_target_types.is_empty() {
610 "<any>".to_string()
611 } else {
612 allowed_target_types.join(", ")
613 };
614 issues.push(HealthIssue {
615 field: "relationships".to_string(),
616 code: super::HealthIssueCode::InvalidRelShape,
617 message: format!(
618 "INVALID_REL_SHAPE: edge '{rel_type}' from \
619 '{from_type}' to '{to_type}' (target {target}) \
620 violates declared shape — allowed_source_types: \
621 [{allowed_src}], allowed_target_types: \
622 [{allowed_tgt}]. Remove via \
623 `memstead_relate from={from_id} to={target} \
624 type={rel_type} remove=true`.",
625 target = rel.target,
626 from_id = entity.id,
627 ),
628 });
629 }
630 }
631 }
632
633 let auto_ts_field = schema.metadata_fields.iter().find(|f| f.auto_timestamp);
635
636 if let Some(ts_field) = auto_ts_field
637 && let Some(val) = entity.metadata.get(ts_field.key.as_str())
638 {
639 let date_str = val.to_frontmatter_string();
640 if let Some(modified_days) = parse_iso_to_days(&date_str) {
641 let days_since = today_days.saturating_sub(modified_days);
642 if days_since > schema.staleness_threshold_days as u64 {
643 stale_entities.push(StaleEntity {
644 id: entity.id.clone(),
645 title: entity.title.clone(),
646 days_since_modified: days_since,
647 });
648 }
649 }
650 }
651
652 if !issues.is_empty() {
653 let total = schema.health_required_fields.len();
659 let score = if total > 0 {
660 (total.saturating_sub(issues.len()) as f32) / (total as f32)
661 } else {
662 1.0
663 };
664
665 missing_fields.push(HealthReport {
666 id: entity.id.clone(),
667 title: entity.title.clone(),
668 score,
669 issues,
670 });
671 }
672 }
673
674 stale_entities.sort_by_key(|e| std::cmp::Reverse(e.days_since_modified));
676
677 let orphan_count = query::find_orphans_with_schemas(store, mem_schemas).len();
679 let leaf_entities_by_type = query::leaf_population(store, mem_schemas);
680 let stub_count = query::find_stubs(store).len();
681
682 HealthSummary {
683 stale_entities,
684 missing_fields,
685 orphan_count,
686 stub_count,
687 warnings: Vec::new(),
688 quarantined: Vec::new(),
689 boot_diagnosis: None,
690 leaf_entities_by_type,
691 dangling_links: None,
692 findings: None,
693 tag_distribution: None,
694 tag_distribution_folded: None,
695 untagged_entities: None,
696 }
697}
698
699pub fn collect_tag_distribution(
713 store: &Store,
714 mem_filter: Option<&str>,
715 limit: usize,
716) -> (Vec<TagDistribution>, Vec<FoldedTag>, UntaggedStats) {
717 let mut counts: HashMap<String, (usize, HashMap<String, usize>)> = HashMap::new();
719 let mut untagged = UntaggedStats {
720 total: 0,
721 by_entity_type: HashMap::new(),
722 };
723
724 for entity in store.all_entities() {
725 if entity.stub {
726 continue;
727 }
728 if let Some(v) = mem_filter
729 && entity.mem != v
730 {
731 continue;
732 }
733
734 let tags_raw = entity
735 .metadata
736 .get("tags")
737 .and_then(|v| match v {
738 MetadataValue::String(s) => Some(s.as_str()),
739 _ => None,
740 })
741 .unwrap_or("");
742
743 let mut any_tag = false;
744 for tag in tags_raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
745 any_tag = true;
746 let entry = counts
747 .entry(tag.to_string())
748 .or_insert_with(|| (0, HashMap::new()));
749 entry.0 += 1;
750 *entry.1.entry(entity.entity_type.clone()).or_insert(0) += 1;
751 }
752 if !any_tag {
753 untagged.total += 1;
754 *untagged
755 .by_entity_type
756 .entry(entity.entity_type.clone())
757 .or_insert(0) += 1;
758 }
759 }
760
761 let mut entries: Vec<TagDistribution> = counts
763 .iter()
764 .map(|(tag, (count, by_type))| TagDistribution {
765 tag: tag.clone(),
766 count: *count,
767 by_entity_type: by_type.clone(),
768 })
769 .collect();
770 entries.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.tag.cmp(&b.tag)));
771 entries.truncate(limit);
772
773 let mut by_canonical: HashMap<String, Vec<(String, usize)>> = HashMap::new();
778 for (tag, (count, _)) in counts.iter() {
779 by_canonical
780 .entry(tag.to_lowercase())
781 .or_default()
782 .push((tag.clone(), *count));
783 }
784 let mut folded: Vec<FoldedTag> = by_canonical
785 .into_iter()
786 .filter(|(_, v)| v.len() > 1)
787 .map(|(canonical, mut variants)| {
788 variants.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
789 let total = variants.iter().map(|(_, c)| *c).sum();
790 FoldedTag {
791 canonical,
792 total,
793 variants: variants
794 .into_iter()
795 .map(|(tag, count)| TagVariant { tag, count })
796 .collect(),
797 }
798 })
799 .collect();
800 folded.sort_by(|a, b| {
801 b.total
802 .cmp(&a.total)
803 .then_with(|| a.canonical.cmp(&b.canonical))
804 });
805
806 (entries, folded, untagged)
807}
808
809pub fn collect_dangling_links(store: &Store, mem_filter: Option<&str>) -> Vec<DanglingLink> {
829 use crate::entity::parser::extract_inline_links_lenient;
830 use std::collections::HashSet;
831
832 let mut out = Vec::new();
833 for entity in store.all_entities() {
834 if entity.stub {
835 continue;
836 }
837 if let Some(v) = mem_filter
838 && entity.mem != v
839 {
840 continue;
841 }
842 let explicit_targets: HashSet<_> = entity
843 .relationships
844 .iter()
845 .map(|r| r.target.clone())
846 .collect();
847 for (section_key, section_body) in &entity.sections {
848 for target_id in extract_inline_links_lenient(section_body, &entity.mem) {
849 let target_missing = store.get(&target_id).map(|e| e.stub).unwrap_or(true);
850 let alias_orphan = !target_missing && !explicit_targets.contains(&target_id);
851 if target_missing || alias_orphan {
852 out.push(DanglingLink {
853 from: entity.id.clone(),
854 target_id: target_id.clone(),
855 target_path: target_id.path().to_string(),
856 section: Some(section_key.clone()),
857 });
858 }
859 }
860 }
861 for rel in &entity.relationships {
878 if store.get(&rel.target).is_some() {
879 continue;
880 }
881 let already_reported = out
882 .iter()
883 .any(|d| d.from == entity.id && d.target_id == rel.target);
884 if already_reported {
885 continue;
886 }
887 out.push(DanglingLink {
888 from: entity.id.clone(),
889 target_id: rel.target.clone(),
890 target_path: rel.target.path().to_string(),
891 section: None,
892 });
893 }
894 }
895 out
896}
897
898pub fn collect_missing_required_outgoing(
909 store: &Store,
910 mem_filter: Option<&str>,
911 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
912) -> Vec<MissingRequiredOutgoingReport> {
913 let mut out = Vec::new();
914 for entity in store.all_entities() {
915 if entity.stub {
916 continue;
917 }
918 if let Some(v) = mem_filter
919 && entity.mem != v
920 {
921 continue;
922 }
923 let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
924 continue;
925 };
926 let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
927 continue;
928 };
929 if td.required_outgoing.is_empty() {
930 continue;
931 }
932 let unsatisfied = unsatisfied_required_outgoing(entity, td);
933 if unsatisfied.is_empty() {
934 continue;
935 }
936 out.push(MissingRequiredOutgoingReport {
937 id: entity.id.clone(),
938 title: entity.title.clone(),
939 entity_type: entity.entity_type.clone(),
940 mem: entity.mem.clone(),
941 missing: unsatisfied,
942 });
943 }
944 out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
945 out
946}
947
948pub fn unsatisfied_required_outgoing(
956 entity: &crate::entity::Entity,
957 td: &TypeDefinition,
958) -> Vec<super::MissingRequiredOutgoingBlock> {
959 td.required_outgoing
960 .iter()
961 .filter(|block| {
962 let count = entity
963 .relationships
964 .iter()
965 .filter(|rel| block.relationships.iter().any(|name| name == &rel.rel_type))
966 .count();
967 !block.admits(count)
968 })
969 .map(|block| super::MissingRequiredOutgoingBlock {
970 relationships: block.relationships.clone(),
971 cardinality: block.cardinality.to_string(),
972 severity: block.severity,
973 })
974 .collect()
975}
976
977#[derive(Debug, Clone, serde::Serialize)]
985#[serde(tag = "kind", rename_all = "snake_case")]
986pub enum UnsatisfiedConstraint {
987 RequiresWhen {
988 field: String,
989 when_field: String,
990 when_value: String,
991 severity: memstead_schema::ConstraintSeverity,
992 },
993 Unique {
994 fields: Vec<String>,
995 values: Vec<String>,
997 colliding: String,
1000 severity: memstead_schema::ConstraintSeverity,
1001 },
1002 EnumFromNeighbour {
1003 field: String,
1004 value: String,
1006 rel_type: String,
1007 section: String,
1008 severity: memstead_schema::ConstraintSeverity,
1009 },
1010 StatusPropagation {
1011 field: String,
1012 value: String,
1014 rel_type: String,
1015 tainted_by: String,
1018 severity: memstead_schema::ConstraintSeverity,
1019 },
1020}
1021
1022impl UnsatisfiedConstraint {
1023 pub fn severity(&self) -> memstead_schema::ConstraintSeverity {
1024 match self {
1025 Self::RequiresWhen { severity, .. }
1026 | Self::Unique { severity, .. }
1027 | Self::EnumFromNeighbour { severity, .. }
1028 | Self::StatusPropagation { severity, .. } => *severity,
1029 }
1030 }
1031
1032 pub fn describe(&self) -> String {
1034 match self {
1035 Self::RequiresWhen {
1036 field,
1037 when_field,
1038 when_value,
1039 ..
1040 } => format!(
1041 "requires_when: '{field}' is required when {when_field}={when_value} and is unset"
1042 ),
1043 Self::Unique {
1044 fields, colliding, ..
1045 } => format!(
1046 "unique: tuple ({}) collides with '{colliding}'",
1047 fields.join(", ")
1048 ),
1049 Self::EnumFromNeighbour {
1050 field,
1051 value,
1052 rel_type,
1053 section,
1054 ..
1055 } => format!(
1056 "enum_from_neighbour: '{field}' value '{value}' has no backing entry in any \
1057 `{section}` section reached via {rel_type}"
1058 ),
1059 Self::StatusPropagation {
1060 field,
1061 value,
1062 tainted_by,
1063 ..
1064 } => format!("status_propagation: tainted by '{tainted_by}' ({field}={value})"),
1065 }
1066 }
1067}
1068
1069pub fn unsatisfied_constraints(
1093 store: &Store,
1094 entity: &crate::entity::Entity,
1095 td: &TypeDefinition,
1096 exclude: Option<&crate::entity::EntityId>,
1097) -> Vec<UnsatisfiedConstraint> {
1098 use memstead_schema::ConstraintDef;
1099 td.constraints
1100 .iter()
1101 .filter_map(|c| match c {
1102 ConstraintDef::RequiresWhen {
1103 field,
1104 when_field,
1105 when_value,
1106 severity,
1107 } => {
1108 let triggered = entity
1109 .metadata
1110 .get(when_field.as_str())
1111 .is_some_and(|v| v.to_frontmatter_string() == *when_value);
1112 if !triggered {
1113 return None;
1114 }
1115 let satisfied = entity
1116 .metadata
1117 .get(field.as_str())
1118 .is_some_and(|v| !v.to_frontmatter_string().trim().is_empty())
1119 || entity
1120 .sections
1121 .get(field.as_str())
1122 .is_some_and(|body| !body.trim().is_empty());
1123 if satisfied {
1124 return None;
1125 }
1126 Some(UnsatisfiedConstraint::RequiresWhen {
1127 field: field.clone(),
1128 when_field: when_field.clone(),
1129 when_value: when_value.clone(),
1130 severity: *severity,
1131 })
1132 }
1133 ConstraintDef::Unique { fields, severity } => {
1134 let tuple = tuple_of(entity, fields)?;
1135 let mut colliding: Vec<&str> = store
1136 .all_entities()
1137 .filter(|other| {
1138 !other.stub
1139 && other.mem == entity.mem
1140 && other.entity_type == entity.entity_type
1141 && Some(&other.id) != exclude
1142 && other.id != entity.id
1143 && tuple_of(other, fields).as_ref() == Some(&tuple)
1144 })
1145 .map(|other| other.id.0.as_str())
1146 .collect();
1147 colliding.sort_unstable();
1148 let first = colliding.first()?;
1149 Some(UnsatisfiedConstraint::Unique {
1150 fields: fields.clone(),
1151 values: tuple,
1152 colliding: first.to_string(),
1153 severity: *severity,
1154 })
1155 }
1156 ConstraintDef::EnumFromNeighbour {
1157 field,
1158 rel_type,
1159 section,
1160 severity,
1161 } => {
1162 let value = entity
1163 .metadata
1164 .get(field.as_str())
1165 .map(|v| v.to_frontmatter_string())
1166 .filter(|v| !v.trim().is_empty())?;
1167 let backed = entity
1168 .relationships
1169 .iter()
1170 .filter(|rel| rel.rel_type == *rel_type)
1171 .filter_map(|rel| store.get(&rel.target))
1172 .filter_map(|neighbour| neighbour.sections.get(section.as_str()))
1173 .any(|body| bullet_entries(body).any(|entry| entry == value));
1174 if backed {
1175 return None;
1176 }
1177 Some(UnsatisfiedConstraint::EnumFromNeighbour {
1178 field: field.clone(),
1179 value,
1180 rel_type: rel_type.clone(),
1181 section: section.clone(),
1182 severity: *severity,
1183 })
1184 }
1185 ConstraintDef::StatusPropagation { .. } => None,
1186 })
1187 .collect()
1188}
1189
1190fn tuple_of(entity: &crate::entity::Entity, fields: &[String]) -> Option<Vec<String>> {
1194 fields
1195 .iter()
1196 .map(|f| {
1197 entity
1198 .metadata
1199 .get(f.as_str())
1200 .map(|v| v.to_frontmatter_string())
1201 .filter(|v| !v.trim().is_empty())
1202 })
1203 .collect()
1204}
1205
1206fn bullet_entries(body: &str) -> impl Iterator<Item = &str> {
1209 body.lines().filter_map(|line| {
1210 let t = line.trim_start();
1211 t.strip_prefix("- ")
1212 .or_else(|| t.strip_prefix("* "))
1213 .map(str::trim)
1214 })
1215}
1216
1217#[derive(Debug, Clone, serde::Serialize)]
1222pub struct ConstraintFindingReport {
1223 pub id: crate::entity::EntityId,
1224 pub title: String,
1225 pub entity_type: String,
1226 pub mem: String,
1227 pub violations: Vec<UnsatisfiedConstraint>,
1228 #[serde(skip_serializing_if = "Vec::is_empty")]
1232 pub format_violations: Vec<crate::section_format::SectionFormatViolation>,
1233}
1234
1235pub fn collect_constraint_findings(
1245 store: &Store,
1246 mem_filter: Option<&str>,
1247 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1248) -> Vec<ConstraintFindingReport> {
1249 use memstead_schema::ConstraintDef;
1250 type Bucket = (
1251 Vec<UnsatisfiedConstraint>,
1252 Vec<crate::section_format::SectionFormatViolation>,
1253 );
1254 let mut by_entity: std::collections::BTreeMap<String, Bucket> = Default::default();
1255
1256 for entity in store.all_entities() {
1257 if entity.stub {
1258 continue;
1259 }
1260 if let Some(v) = mem_filter
1261 && entity.mem != v
1262 {
1263 continue;
1264 }
1265 let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
1266 continue;
1267 };
1268 let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
1269 continue;
1270 };
1271
1272 for def in &td.sections {
1277 if def.compiled_content.is_none() {
1278 continue;
1279 }
1280 let Some(body) = entity.sections.get(def.key.as_str()) else {
1281 continue;
1282 };
1283 let violations = crate::section_format::check_section_format(def, body);
1284 if !violations.is_empty() {
1285 by_entity
1286 .entry(entity.id.0.clone())
1287 .or_default()
1288 .1
1289 .extend(violations);
1290 }
1291 }
1292
1293 if td.constraints.is_empty() {
1294 continue;
1295 }
1296
1297 let violations = unsatisfied_constraints(store, entity, td, None);
1299 if !violations.is_empty() {
1300 by_entity
1301 .entry(entity.id.0.clone())
1302 .or_default()
1303 .0
1304 .extend(violations);
1305 }
1306
1307 for c in &td.constraints {
1310 let ConstraintDef::StatusPropagation {
1311 field,
1312 value,
1313 rel_type,
1314 direction,
1315 severity,
1316 } = c
1317 else {
1318 continue;
1319 };
1320 let terminal = entity
1321 .metadata
1322 .get(field.as_str())
1323 .is_some_and(|v| v.to_frontmatter_string() == *value);
1324 if !terminal {
1325 continue;
1326 }
1327 for tainted in reach_transitively(store, &entity.id, rel_type, *direction) {
1328 if let Some(v) = mem_filter
1329 && tainted.mem() != v
1330 {
1331 continue;
1332 }
1333 by_entity.entry(tainted.0.clone()).or_default().0.push(
1334 UnsatisfiedConstraint::StatusPropagation {
1335 field: field.clone(),
1336 value: value.clone(),
1337 rel_type: rel_type.clone(),
1338 tainted_by: entity.id.to_string(),
1339 severity: *severity,
1340 },
1341 );
1342 }
1343 }
1344 }
1345
1346 let mut out: Vec<ConstraintFindingReport> = by_entity
1347 .into_iter()
1348 .filter_map(|(id, (violations, format_violations))| {
1349 let id = crate::entity::EntityId(id);
1350 let entity = store.get(&id)?;
1351 Some(ConstraintFindingReport {
1352 id,
1353 title: entity.title.clone(),
1354 entity_type: entity.entity_type.clone(),
1355 mem: entity.mem.clone(),
1356 violations,
1357 format_violations,
1358 })
1359 })
1360 .collect();
1361 out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
1362 out
1363}
1364
1365fn reach_transitively(
1372 store: &Store,
1373 start: &crate::entity::EntityId,
1374 rel_type: &str,
1375 direction: memstead_schema::PropagationDirection,
1376) -> Vec<crate::entity::EntityId> {
1377 use memstead_schema::PropagationDirection;
1378 let mut seen: std::collections::HashSet<crate::entity::EntityId> =
1379 std::iter::once(start.clone()).collect();
1380 let mut frontier = vec![start.clone()];
1381 let mut reached = Vec::new();
1382 while let Some(current) = frontier.pop() {
1383 let next: Vec<crate::entity::EntityId> = match direction {
1384 PropagationDirection::Incoming => store
1385 .all_entities()
1386 .filter(|e| {
1387 e.relationships
1388 .iter()
1389 .any(|r| r.rel_type == rel_type && r.target == current)
1390 })
1391 .map(|e| e.id.clone())
1392 .collect(),
1393 PropagationDirection::Outgoing => store
1394 .get(¤t)
1395 .map(|e| {
1396 e.relationships
1397 .iter()
1398 .filter(|r| r.rel_type == rel_type)
1399 .map(|r| r.target.clone())
1400 .collect()
1401 })
1402 .unwrap_or_default(),
1403 };
1404 for id in next {
1405 if seen.insert(id.clone()) {
1406 if store.get(&id).is_some_and(|e| !e.stub) {
1407 reached.push(id.clone());
1408 }
1409 frontier.push(id);
1410 }
1411 }
1412 }
1413 reached
1414}
1415
1416#[derive(Debug, Clone, serde::Serialize)]
1421pub struct SchemaFormatDefect {
1422 pub schema: String,
1423 pub type_name: String,
1424 pub section: String,
1425 pub problems: Vec<String>,
1426}
1427
1428pub fn collect_schema_format_defects(
1432 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1433) -> Vec<SchemaFormatDefect> {
1434 let mut seen: std::collections::BTreeSet<String> = Default::default();
1435 let mut out = Vec::new();
1436 let mut schemas: Vec<&Arc<memstead_schema::Schema>> = mem_schemas.values().collect();
1437 schemas.sort_by_key(|s| (s.manifest.name.clone(), s.version.clone()));
1438 for schema in schemas {
1439 let schema_ref = format!("{}@{}", schema.manifest.name, schema.version);
1440 if !seen.insert(schema_ref.clone()) {
1441 continue;
1442 }
1443 for td in schema.types.values() {
1444 for section in &td.sections {
1445 if !section.format_problems.is_empty() {
1446 out.push(SchemaFormatDefect {
1447 schema: schema_ref.clone(),
1448 type_name: td.name.clone(),
1449 section: section.key.clone(),
1450 problems: section.format_problems.clone(),
1451 });
1452 }
1453 }
1454 }
1455 }
1456 out.sort_by(|a, b| {
1457 (&a.schema, &a.type_name, &a.section).cmp(&(&b.schema, &b.type_name, &b.section))
1458 });
1459 out
1460}
1461
1462#[derive(Debug, Clone, serde::Serialize)]
1470pub struct MissingRequiredOutgoingReport {
1471 pub id: crate::entity::EntityId,
1472 pub title: String,
1473 pub entity_type: String,
1474 pub mem: String,
1475 pub missing: Vec<super::MissingRequiredOutgoingBlock>,
1476}
1477
1478pub fn config_projection(
1491 engine: &crate::Engine,
1492 writable_mems: &[String],
1493 mutations: serde_json::Value,
1494 plugin: serde_json::Value,
1495) -> serde_json::Map<String, serde_json::Value> {
1496 let backend_by_mem: std::collections::HashMap<&str, (&'static str, bool)> = engine
1501 .mounts()
1502 .iter()
1503 .map(|m| {
1504 (
1505 m.mem.as_str(),
1506 (m.storage.backend_id(), m.storage.is_durable()),
1507 )
1508 })
1509 .collect();
1510 let mems_detail: Vec<serde_json::Value> = writable_mems
1511 .iter()
1512 .map(|name| {
1513 let origin = engine
1514 .mem_router()
1515 .origin_for_mem(name)
1516 .map(|o| o.kind())
1517 .unwrap_or("explicit");
1518 let mut entry = serde_json::Map::new();
1519 entry.insert("name".into(), serde_json::json!(name));
1520 entry.insert("origin".into(), serde_json::json!(origin));
1521 if let Some((storage, durable)) = backend_by_mem.get(name.as_str()).copied() {
1522 entry.insert("storage".into(), serde_json::json!(storage));
1523 entry.insert("durable".into(), serde_json::json!(durable));
1524 }
1525 let mut vcs_obj = serde_json::Map::new();
1526 if let Ok(gitdir) = engine.gitdir_for(name) {
1527 vcs_obj.insert("gitdir".into(), serde_json::json!(gitdir));
1528 }
1529 if let Ok(worktree) = engine.worktree_for(name) {
1530 vcs_obj.insert("worktree".into(), serde_json::json!(worktree));
1531 }
1532 if let Some(sha) = engine.mem_head_sha(name).ok().flatten() {
1533 vcs_obj.insert("head".into(), serde_json::json!(sha));
1534 }
1535 if !vcs_obj.is_empty() {
1536 entry.insert("vcs".into(), serde_json::Value::Object(vcs_obj));
1537 }
1538 if let Some(cfg) = engine.mem_config_for(name) {
1539 if let Some(title) = &cfg.title {
1543 entry.insert("title".into(), serde_json::json!(title));
1544 }
1545 if let Some(subject) = &cfg.subject {
1546 entry.insert("subject".into(), serde_json::json!(subject));
1547 }
1548 let guidance = serde_json::Map::from_iter(
1549 cfg.write_guidance
1550 .iter()
1551 .map(|(k, v)| (k.clone(), v.clone())),
1552 );
1553 entry.insert("write_guidance".into(), serde_json::Value::Object(guidance));
1554 let extra = serde_json::Map::from_iter(
1555 cfg.extra.iter().map(|(k, v)| (k.clone(), v.clone())),
1556 );
1557 entry.insert("extra".into(), serde_json::Value::Object(extra));
1558 }
1559 serde_json::Value::Object(entry)
1560 })
1561 .collect();
1562
1563 let mut out = serde_json::Map::new();
1564 out.insert("mems".into(), serde_json::json!(mems_detail));
1565 out.insert("mutations".into(), mutations);
1566 out.insert("plugin".into(), plugin);
1567 out
1568}
1569
1570pub fn config_projection_from_settings(
1576 settings: &crate::workspace::WorkspaceSettings,
1577) -> (serde_json::Value, serde_json::Value) {
1578 let mutations = serde_json::json!({ "require_notes": settings.mutations.require_notes });
1579 let plugin_map: serde_json::Map<String, serde_json::Value> = settings
1580 .plugin
1581 .iter()
1582 .map(|(k, v)| {
1583 (
1584 k.clone(),
1585 serde_json::to_value(v).unwrap_or(serde_json::Value::Null),
1586 )
1587 })
1588 .collect();
1589 (mutations, serde_json::Value::Object(plugin_map))
1590}
1591
1592pub(crate) fn section_heading_mismatch_issue(
1607 entity: &crate::entity::Entity,
1608 schema: &TypeDefinition,
1609 key: &str,
1610) -> Option<HealthIssue> {
1611 let def = schema.section(key)?;
1612 let derived = memstead_schema::derive_section_key(&def.heading);
1613 if derived == key {
1614 return None;
1615 }
1616 if !entity
1617 .raw_section_headings
1618 .iter()
1619 .any(|h| h == &def.heading)
1620 {
1621 return None;
1622 }
1623 let landing = match schema.catch_all_section() {
1624 Some(c) => format!(
1625 "the content was absorbed into catch-all section '{}'",
1626 c.key
1627 ),
1628 None => "the content is unreachable under any declared key".to_string(),
1629 };
1630 Some(HealthIssue {
1631 field: key.to_string(),
1632 code: super::HealthIssueCode::SectionHeadingMismatch,
1633 message: format!(
1634 "SECTION_HEADING_MISMATCH: section '{key}' is not missing — its content sits \
1635 under heading '{found}', which derives to '{derived}', not '{key}'; {landing}. \
1636 The schema's declared heading cannot round-trip to its key (expected a heading \
1637 that derives to '{key}'); fix the schema's heading/key pair — new installs of \
1638 such a schema are refused",
1639 found = def.heading,
1640 ),
1641 })
1642}
1643
1644pub fn entity_health(entity: &crate::entity::Entity, schema: &TypeDefinition) -> HealthReport {
1646 let mut issues = Vec::new();
1647
1648 for field in &schema.health_required_fields {
1649 if schema.section(field).is_some() {
1650 let content = entity.sections.get(field.as_str());
1651 if content.is_none_or(|c| c.trim().is_empty()) {
1652 if let Some(issue) = section_heading_mismatch_issue(entity, schema, field) {
1653 issues.push(issue);
1654 } else {
1655 issues.push(HealthIssue {
1656 field: field.clone(),
1657 code: super::HealthIssueCode::Missing,
1658 message: format!("required section '{field}' is empty"),
1659 });
1660 }
1661 }
1662 } else {
1663 let value = entity.metadata.get(field.as_str());
1664 if value.is_none() {
1665 issues.push(HealthIssue {
1666 field: field.clone(),
1667 code: super::HealthIssueCode::Missing,
1668 message: format!("required field '{field}' is missing"),
1669 });
1670 }
1671 }
1672 }
1673
1674 for s in schema.sections.iter().filter(|s| !s.catch_all) {
1678 if schema.health_required_fields.contains(&s.key) {
1679 continue; }
1681 let content = entity.sections.get(s.key.as_str());
1682 if content.is_none_or(|c| c.trim().is_empty())
1683 && let Some(issue) = section_heading_mismatch_issue(entity, schema, &s.key)
1684 {
1685 issues.push(issue);
1686 }
1687 }
1688
1689 let total = schema.health_required_fields.len();
1690 let score = if total > 0 {
1691 (total.saturating_sub(issues.len()) as f32) / (total as f32)
1692 } else {
1693 1.0
1694 };
1695
1696 HealthReport {
1697 id: entity.id.clone(),
1698 title: entity.title.clone(),
1699 score,
1700 issues,
1701 }
1702}
1703
1704fn days_since_epoch() -> u64 {
1715 #[cfg(target_arch = "wasm32")]
1716 {
1717 (js_sys::Date::now() / 1000.0) as u64 / 86400
1718 }
1719 #[cfg(not(target_arch = "wasm32"))]
1720 {
1721 std::time::SystemTime::now()
1722 .duration_since(std::time::UNIX_EPOCH)
1723 .unwrap_or_default()
1724 .as_secs()
1725 / 86400
1726 }
1727}
1728
1729fn parse_iso_to_days(date: &str) -> Option<u64> {
1732 let date_part = date.split('T').next()?;
1733 let parts: Vec<&str> = date_part.split('-').collect();
1734 if parts.len() != 3 {
1735 return None;
1736 }
1737 let year: u64 = parts[0].parse().ok()?;
1738 let month: u64 = parts[1].parse().ok()?;
1739 let day: u64 = parts[2].parse().ok()?;
1740 Some(ymd_to_days(year, month, day))
1741}
1742
1743fn ymd_to_days(year: u64, month: u64, day: u64) -> u64 {
1746 let y = if month <= 2 { year - 1 } else { year };
1748 let m = if month <= 2 { month + 9 } else { month - 3 };
1749 let era = y / 400;
1750 let yoe = y - era * 400;
1751 let doy = (153 * m + 2) / 5 + day - 1;
1752 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1753 let days = era * 146097 + doe;
1754 days - 719468
1755}
1756
1757#[cfg(test)]
1758mod tests {
1759 use super::*;
1760 use crate::entity::{Entity, EntityId, MetadataValue};
1761 use crate::store::Store;
1762 use indexmap::IndexMap;
1763 use memstead_schema::type_by_name;
1764
1765 #[test]
1771 fn declared_process_mem_pairs_and_missing_declaration_is_typed() {
1772 use crate::engine::test_helpers::folder_mount;
1773 let tmp = tempfile::TempDir::new().unwrap();
1774 let dest_dir = tmp.path().join("dest");
1775 let proc_dir = tmp.path().join("oddly-named-process");
1776 std::fs::create_dir_all(dest_dir.join(".memstead")).unwrap();
1777 std::fs::create_dir_all(&proc_dir).unwrap();
1778 std::fs::write(
1781 dest_dir.join(".memstead").join("config.json"),
1782 r#"{ "schema": "default@1.0.0", "processMem": "oddly-named-process" }"#,
1783 )
1784 .unwrap();
1785 let engine = crate::Engine::from_mounts(vec![
1786 (
1787 folder_mount("dest", dest_dir.clone()),
1788 Box::new(crate::storage::FilesystemMemWriter::new(dest_dir.clone()))
1789 as Box<dyn crate::backend::MemBackend>,
1790 ),
1791 (
1792 folder_mount("oddly-named-process", proc_dir.clone()),
1793 Box::new(crate::storage::FilesystemMemWriter::new(proc_dir))
1794 as Box<dyn crate::backend::MemBackend>,
1795 ),
1796 ])
1797 .unwrap();
1798
1799 let r = crate::ingest::resolve::resolve_process_mem(&engine, "dest", "dest-derived");
1801 assert!(r.declared && r.mounted);
1802 assert_eq!(r.mem, "oddly-named-process");
1803 let r =
1806 crate::ingest::resolve::resolve_process_mem(&engine, "oddly-named-process", "whatever");
1807 assert!(!r.declared && !r.mounted);
1808 assert_eq!(r.mem, "whatever");
1809
1810 let axis = health_open_questions_axis(&engine, Some("dest"));
1812 let process = &axis["dest"]["process"];
1813 assert_eq!(process[0]["process_mem"], "oddly-named-process", "{axis}");
1814 assert_eq!(process[0]["declared"], true, "{axis}");
1815 assert_eq!(process[0]["resolvable"], true, "{axis}");
1816
1817 std::fs::write(
1819 dest_dir.join(".memstead").join("config.json"),
1820 r#"{ "schema": "default@1.0.0", "processMem": "nowhere" }"#,
1821 )
1822 .unwrap();
1823 let engine2 = crate::Engine::from_mounts(vec![(
1824 folder_mount("dest", dest_dir.clone()),
1825 Box::new(crate::storage::FilesystemMemWriter::new(dest_dir))
1826 as Box<dyn crate::backend::MemBackend>,
1827 )])
1828 .unwrap();
1829 let axis = health_open_questions_axis(&engine2, Some("dest"));
1830 let process = &axis["dest"]["process"];
1831 assert_eq!(
1832 process[0]["finding"], "DECLARED_PROCESS_MEM_MISSING",
1833 "{axis}"
1834 );
1835 assert_eq!(process[0]["resolvable"], false, "{axis}");
1836 }
1837
1838 fn make_entity(name: &str, has_required: bool) -> Entity {
1839 let mut metadata = IndexMap::new();
1840 metadata.insert("level".into(), MetadataValue::String("M0".into()));
1841 metadata.insert("type".into(), MetadataValue::String("spec".into()));
1842 metadata.insert(
1843 "created_date".into(),
1844 MetadataValue::String("2026-01-15".into()),
1845 );
1846 metadata.insert(
1847 "last_modified".into(),
1848 MetadataValue::String("2026-04-12".into()),
1849 );
1850
1851 let mut sections = IndexMap::new();
1852 if has_required {
1853 sections.insert("identity".into(), "Has identity.".into());
1854 sections.insert("purpose".into(), "Has purpose.".into());
1855 }
1856
1857 Entity {
1858 id: EntityId::new("specs", name),
1859 title: name.into(),
1860 entity_type: "spec".into(),
1861 mem: "specs".into(),
1862 file_path: format!("{name}.md"),
1863 metadata,
1864 sections,
1865 relationships: Vec::new(),
1866 content_hash: String::new(),
1867 stub: false,
1868 stub_kind: None,
1869 heading_spans: std::collections::HashMap::new(),
1870 raw_section_headings: Vec::new(),
1871 }
1872 }
1873
1874 fn violating_type() -> std::sync::Arc<TypeDefinition> {
1878 let manifest = r#"name: debate
1879version: 0.1.0
1880description: sealed-violator fixture
1881when_to_use: health tests
1882types:
1883 - question
1884relationships:
1885 mode: strict
1886 definitions:
1887 - name: PART_OF
1888 description: hier
1889 default_weight: 3.0
1890 - name: _default
1891 description: fallback
1892 default_weight: 1.0
1893community:
1894 resolution: 1.0
1895 seed: 42
1896"#;
1897 let type_yaml = r#"name: question
1898description: t
1899when_to_use: tests
1900sections:
1901 - key: answers
1902 heading: Answers argued
1903 required: true
1904 search_weight: 10.0
1905 write_rules: []
1906 - key: notes
1907 heading: Notes
1908 required: false
1909 search_weight: 3.0
1910 catch_all: true
1911 write_rules: []
1912metadata_fields: []
1913title_weight: 100.0
1914text_fields:
1915 - answers
1916 - notes
1917hierarchy_relationship: PART_OF
1918no_self_loop_relationships: []
1919updatable_fields:
1920 - title
1921 - answers
1922 - notes
1923health_required_fields:
1924 - answers
1925staleness_threshold_days: 90
1926write_rules: []
1927"#;
1928 memstead_schema::load_schema_from_memory(
1929 manifest,
1930 &[("question".to_string(), type_yaml.to_string())],
1931 )
1932 .expect("violating schema still loads")
1933 .get_type("question")
1934 .expect("question type")
1935 }
1936
1937 #[test]
1943 fn health_distinguishes_heading_mismatch_from_missing_section() {
1944 let schema = violating_type();
1945
1946 let md = "---\ntype: question\n---\n# Q\n\n## Answers argued\n\nTwo answers.\n";
1948 let parsed = crate::entity::parser::parse_markdown(md, "q.md", &schema, "debate")
1949 .expect("parses")
1950 .entity;
1951 let report = entity_health(&parsed, &schema);
1952 let mismatch: Vec<_> = report
1953 .issues
1954 .iter()
1955 .filter(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch)
1956 .collect();
1957 assert_eq!(mismatch.len(), 1, "issues: {:?}", report.issues);
1958 let msg = &mismatch[0].message;
1959 assert!(
1960 msg.contains("'Answers argued'") && msg.contains("'answers_argued'"),
1961 "names found heading and derived key: {msg}"
1962 );
1963 assert!(
1964 msg.contains("'notes'"),
1965 "names the catch-all landing: {msg}"
1966 );
1967 assert!(
1968 !report.issues.iter().any(|i| i.message.contains("is empty")),
1969 "must not also report the section as missing: {:?}",
1970 report.issues
1971 );
1972
1973 let md_missing = "---\ntype: question\n---\n# Q2\n";
1975 let parsed_missing =
1976 crate::entity::parser::parse_markdown(md_missing, "q2.md", &schema, "debate")
1977 .expect("parses")
1978 .entity;
1979 let report_missing = entity_health(&parsed_missing, &schema);
1980 assert!(
1981 report_missing
1982 .issues
1983 .iter()
1984 .any(|i| i.code == super::super::HealthIssueCode::Missing
1985 && i.message == "required section 'answers' is empty"),
1986 "absent section keeps the missing report (structured MISSING code): {:?}",
1987 report_missing.issues
1988 );
1989 assert!(
1990 !report_missing
1991 .issues
1992 .iter()
1993 .any(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch),
1994 "no mismatch finding when the heading is not in the file"
1995 );
1996
1997 let ok_type = crate::entity::parser::parse_markdown(
2002 "---\ntype: question\n---\n# Q3\n\n## Answers\n\nfree.\n",
2003 "q3.md",
2004 &schema,
2005 "debate",
2006 )
2007 .expect("parses")
2008 .entity;
2009 let report_ok = entity_health(&ok_type, &schema);
2010 assert!(
2011 !report_ok
2012 .issues
2013 .iter()
2014 .any(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch),
2015 "mismatch fires only when the declared heading is present: {:?}",
2016 report_ok.issues
2017 );
2018 }
2019
2020 fn make_concept_entity(name: &str, with_definition: bool) -> Entity {
2021 let mut metadata = IndexMap::new();
2022 metadata.insert("type".into(), MetadataValue::String("concept".into()));
2023 metadata.insert("maturity".into(), MetadataValue::String("emerging".into()));
2024 metadata.insert(
2025 "abstraction_level".into(),
2026 MetadataValue::String("concrete".into()),
2027 );
2028 metadata.insert(
2029 "created_date".into(),
2030 MetadataValue::String("2026-01-15".into()),
2031 );
2032 metadata.insert(
2033 "last_modified".into(),
2034 MetadataValue::String("2026-04-12".into()),
2035 );
2036
2037 let mut sections = IndexMap::new();
2038 if with_definition {
2039 sections.insert("definition".into(), "Precise definition.".into());
2040 }
2041 sections.insert("explanation".into(), "How it works.".into());
2042
2043 Entity {
2044 id: EntityId::new("concepts", name),
2045 title: name.into(),
2046 entity_type: "concept".into(),
2047 mem: "concepts".into(),
2048 file_path: format!("{name}.md"),
2049 metadata,
2050 sections,
2051 relationships: Vec::new(),
2052 content_hash: String::new(),
2053 stub: false,
2054 stub_kind: None,
2055 heading_spans: std::collections::HashMap::new(),
2056 raw_section_headings: Vec::new(),
2057 }
2058 }
2059
2060 #[test]
2061 fn health_concept_missing_definition_reports_definition_field() {
2062 let schema = &type_by_name("concept").unwrap();
2063 let entity = make_concept_entity("clarity", false);
2064 let report = entity_health(&entity, schema);
2065
2066 assert!(report.issues.iter().any(|i| i.field == "definition"));
2069 assert!(!report.issues.iter().any(|i| i.field == "identity"));
2070 assert!(!report.issues.iter().any(|i| i.field == "purpose"));
2071 assert!(report.score < 1.0);
2072
2073 let healthy = make_concept_entity("clarity-ok", true);
2075 let healthy_report = entity_health(&healthy, schema);
2076 assert!(
2077 !healthy_report
2078 .issues
2079 .iter()
2080 .any(|i| i.field == "definition")
2081 );
2082 }
2083
2084 #[test]
2085 fn health_detects_missing_sections() {
2086 let schema = &type_by_name("spec").unwrap();
2087 let entity = make_entity("incomplete", false);
2088 let report = entity_health(&entity, schema);
2089 assert!(!report.issues.is_empty());
2090 assert!(report.score < 1.0);
2091 }
2092
2093 #[test]
2094 fn health_clean_entity() {
2095 let schema = &type_by_name("spec").unwrap();
2096 let entity = make_entity("complete", true);
2097 let report = entity_health(&entity, schema);
2098 let section_issues: Vec<_> = report
2100 .issues
2101 .iter()
2102 .filter(|i| i.field == "identity" || i.field == "purpose")
2103 .collect();
2104 assert!(section_issues.is_empty());
2105 }
2106
2107 #[test]
2108 fn health_summary_counts() {
2109 let mut store = Store::new();
2110 let e1 = make_entity("healthy", true);
2111 let e2 = make_entity("unhealthy", false);
2112 store.upsert(e1.id.clone(), e1);
2113 store.upsert(e2.id.clone(), e2);
2114
2115 let schema = &type_by_name("spec").unwrap();
2116 let summary = compute_health(&store, schema, &HashMap::new());
2117 assert_eq!(summary.orphan_count, 2); assert_eq!(summary.stub_count, 0);
2119 }
2120
2121 #[test]
2122 fn health_surfaces_invalid_rel_shape_on_existing_edges() {
2123 use crate::entity::Relationship;
2129 use memstead_schema::SchemaRegistry;
2130
2131 let registry = SchemaRegistry::builtin();
2132 let software = registry
2133 .get("software", &semver::Version::new(0, 2, 0))
2134 .expect("software schema ships as a builtin");
2135
2136 let mut store = Store::new();
2137 let mut bad = make_entity("bad-owns-source", true);
2140 bad.entity_type = "spec".into();
2141 bad.metadata
2142 .insert("level".into(), MetadataValue::String("M0".into()));
2143 bad.metadata
2144 .insert("stability".into(), MetadataValue::String("evolving".into()));
2145 bad.relationships.push(Relationship {
2146 rel_type: "OWNS".into(),
2147 target: EntityId::new("specs", "victim"),
2148 description: None,
2149 });
2150 let mut victim = make_entity("victim", true);
2151 victim.entity_type = "spec".into();
2152 store.upsert(bad.id.clone(), bad);
2153 store.upsert(victim.id.clone(), victim);
2154
2155 let mut mem_schemas = HashMap::new();
2156 mem_schemas.insert("specs".to_string(), software);
2157
2158 let schema = &type_by_name("spec").unwrap();
2159 let summary = compute_health(&store, schema, &mem_schemas);
2160 let report = summary
2161 .missing_fields
2162 .iter()
2163 .find(|r| r.id.as_ref() == "specs--bad-owns-source")
2164 .expect("shape-violating entity must surface");
2165 let issue = report
2166 .issues
2167 .iter()
2168 .find(|i| i.field == "relationships" && i.message.contains("INVALID_REL_SHAPE"))
2169 .expect("shape violation must produce an INVALID_REL_SHAPE issue");
2170 assert!(
2171 issue.message.contains("OWNS"),
2172 "issue must name the offending rel_type: {}",
2173 issue.message
2174 );
2175 assert!(
2176 issue.message.contains("spec"),
2177 "issue must name the actual source type: {}",
2178 issue.message
2179 );
2180 assert!(
2181 issue.message.contains("actor"),
2182 "issue must name the allowed source type: {}",
2183 issue.message
2184 );
2185 assert!(
2186 issue.message.contains("remove=true"),
2187 "issue must surface the recovery path: {}",
2188 issue.message
2189 );
2190 }
2191
2192 #[test]
2193 fn health_does_not_flag_shape_compliant_edges() {
2194 use crate::entity::Relationship;
2197 use memstead_schema::SchemaRegistry;
2198
2199 let registry = SchemaRegistry::builtin();
2200 let software = registry
2201 .get("software", &semver::Version::new(0, 2, 0))
2202 .expect("software schema ships as a builtin");
2203
2204 let mut store = Store::new();
2205 let mut owner = make_entity("owner", true);
2206 owner.entity_type = "actor".into();
2207 owner
2208 .metadata
2209 .insert("kind".into(), MetadataValue::String("team".into()));
2210 owner
2211 .metadata
2212 .insert("active".into(), MetadataValue::Bool(true));
2213 owner
2214 .metadata
2215 .insert("handle".into(), MetadataValue::String("owner".into()));
2216 owner.relationships.push(Relationship {
2217 rel_type: "OWNS".into(),
2218 target: EntityId::new("specs", "owned"),
2219 description: None,
2220 });
2221 let mut owned = make_entity("owned", true);
2222 owned.entity_type = "spec".into();
2223 store.upsert(owner.id.clone(), owner);
2224 store.upsert(owned.id.clone(), owned);
2225
2226 let mut mem_schemas = HashMap::new();
2227 mem_schemas.insert("specs".to_string(), software);
2228
2229 let schema = &type_by_name("spec").unwrap();
2230 let summary = compute_health(&store, schema, &mem_schemas);
2231 let shape_issue = summary
2232 .missing_fields
2233 .iter()
2234 .flat_map(|r| r.issues.iter())
2235 .find(|i| i.message.contains("INVALID_REL_SHAPE"));
2236 assert!(
2237 shape_issue.is_none(),
2238 "shape-compliant edge must not surface a shape issue, got: {shape_issue:?}"
2239 );
2240 }
2241
2242 #[test]
2243 fn health_warns_on_undeclared_relationship_in_existing_entity() {
2244 use crate::entity::Relationship;
2245 use memstead_schema::Schema;
2246
2247 let mut store = Store::new();
2248 let mut entity = make_entity("with-bad-rel", true);
2249 entity.relationships.push(Relationship {
2255 rel_type: "CONJURES".into(),
2256 target: EntityId::new("specs", "unknown"),
2257 description: None,
2258 });
2259 store.upsert(entity.id.clone(), entity);
2260
2261 let mut mem_schemas = HashMap::new();
2262 mem_schemas.insert("specs".to_string(), Schema::builtin_default());
2263
2264 let schema = &type_by_name("spec").unwrap();
2265 let summary = compute_health(&store, schema, &mem_schemas);
2266 let report = summary
2267 .missing_fields
2268 .iter()
2269 .find(|r| r.id.as_ref() == "specs--with-bad-rel")
2270 .expect("entity must surface in missing_fields");
2271 let rel_issue = report
2272 .issues
2273 .iter()
2274 .find(|i| i.field == "relationships")
2275 .expect("undeclared relationship must produce an issue");
2276 assert!(
2277 rel_issue.message.contains("CONJURES"),
2278 "issue message must name the offending relationship: {}",
2279 rel_issue.message
2280 );
2281 assert!(
2282 rel_issue.message.contains("default@1.0.0"),
2283 "issue must name the schema pin: {}",
2284 rel_issue.message
2285 );
2286 }
2287
2288 fn make_entity_with_body(name: &str, section_key: &str, body: &str) -> Entity {
2295 let mut entity = make_entity(name, true);
2296 entity.sections.insert(section_key.into(), body.to_string());
2297 entity
2298 }
2299
2300 #[test]
2301 fn dangling_link_detected_after_delete() {
2302 use crate::entity::store_builder::make_stub;
2303
2304 let mut store = Store::new();
2305 let a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
2306 store.upsert(a.id.clone(), a.clone());
2307
2308 let b_id = EntityId::new("specs", "b");
2311 store.upsert(b_id.clone(), make_stub(b_id.clone()));
2312
2313 let dangling = super::collect_dangling_links(&store, None);
2314 assert_eq!(dangling.len(), 1, "exactly one dangling link expected");
2315 let d = &dangling[0];
2316 assert_eq!(d.from, a.id);
2317 assert_eq!(d.target_id, b_id);
2318 assert_eq!(d.target_path, "b");
2319 assert_eq!(d.section.as_deref(), Some("purpose"));
2320 }
2321
2322 #[test]
2323 fn dangling_link_does_not_flag_stub_target_of_explicit_relationship() {
2324 use crate::entity::Relationship;
2325 use crate::entity::store_builder::make_stub;
2326
2327 let mut store = Store::new();
2328 let mut a = make_entity("a", true);
2331 let b_id = EntityId::new("specs", "b");
2332 a.relationships.push(Relationship {
2333 rel_type: "REFERENCES".into(),
2334 target: b_id.clone(),
2335 description: None,
2336 });
2337 store.upsert(a.id.clone(), a);
2338 store.upsert(b_id.clone(), make_stub(b_id));
2339
2340 let dangling = super::collect_dangling_links(&store, None);
2341 assert!(
2342 dangling.is_empty(),
2343 "explicit relationships to stubs are valid by design \
2344 (stubs are first-class placeholders); only inline-body \
2345 wiki-links to stubs must surface"
2346 );
2347 }
2348
2349 #[test]
2350 fn dangling_link_does_not_flag_real_reference() {
2351 use crate::entity::Relationship;
2352
2353 let mut store = Store::new();
2354 let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
2355 a.relationships.push(Relationship {
2357 rel_type: "REFERENCES".into(),
2358 target: EntityId::new("specs", "b"),
2359 description: None,
2360 });
2361 let b = make_entity("b", true);
2362 store.upsert(a.id.clone(), a);
2363 store.upsert(b.id.clone(), b);
2364
2365 let dangling = super::collect_dangling_links(&store, None);
2366 assert!(
2367 dangling.is_empty(),
2368 "real reference backed by relation — not dangling, not alias-orphan"
2369 );
2370 }
2371
2372 #[test]
2377 fn dangling_link_relationship_section_target_absent() {
2378 use crate::entity::Relationship;
2379
2380 let mut store = Store::new();
2381 let mut a = make_entity("a", true);
2382 a.relationships.push(Relationship {
2385 rel_type: "DEPENDS_ON".into(),
2386 target: EntityId::new("specs", "gone"),
2387 description: None,
2388 });
2389 store.upsert(a.id.clone(), a.clone());
2390
2391 let dangling = super::collect_dangling_links(&store, None);
2392 assert_eq!(
2393 dangling.len(),
2394 1,
2395 "exactly one relationship-section dangler"
2396 );
2397 let d = &dangling[0];
2398 assert_eq!(d.from, a.id);
2399 assert_eq!(d.target_id, EntityId::new("specs", "gone"));
2400 assert!(
2401 d.section.is_none(),
2402 "relationship-section danglers ship `section: None`, got {:?}",
2403 d.section
2404 );
2405 }
2406
2407 #[test]
2412 fn dangling_link_relationship_section_stub_target_not_flagged() {
2413 use crate::entity::Relationship;
2414 use crate::entity::store_builder::make_stub;
2415
2416 let mut store = Store::new();
2417 let mut a = make_entity("a", true);
2418 let b_id = EntityId::new("specs", "b");
2419 a.relationships.push(Relationship {
2420 rel_type: "DEPENDS_ON".into(),
2421 target: b_id.clone(),
2422 description: None,
2423 });
2424 store.upsert(a.id.clone(), a);
2425 store.upsert(b_id.clone(), make_stub(b_id));
2426
2427 let dangling = super::collect_dangling_links(&store, None);
2428 assert!(
2429 dangling.is_empty(),
2430 "relationship targets that resolve to stubs are forward-references, not corruption"
2431 );
2432 }
2433
2434 #[test]
2441 fn dangling_link_dedups_across_body_and_relations() {
2442 use crate::entity::Relationship;
2443 use crate::entity::store_builder::make_stub;
2444
2445 let mut store = Store::new();
2446 let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
2447 let b_id = EntityId::new("specs", "b");
2448 a.relationships.push(Relationship {
2449 rel_type: "REFERENCES".into(),
2450 target: b_id.clone(),
2451 description: None,
2452 });
2453 store.upsert(a.id.clone(), a.clone());
2454 store.upsert(b_id.clone(), make_stub(b_id.clone()));
2455
2456 let dangling = super::collect_dangling_links(&store, None);
2457 assert_eq!(
2458 dangling.len(),
2459 1,
2460 "body + relations both pointing at the same stub should dedup"
2461 );
2462 assert!(dangling[0].section.is_some(), "body axis wins the dedup");
2465 }
2466
2467 #[test]
2468 fn dangling_links_scope_to_mem_filter() {
2469 use crate::entity::store_builder::make_stub;
2470
2471 let mut store = Store::new();
2472
2473 let a = make_entity_with_body("a", "purpose", "Refers to [[gone]] in prose.");
2475 store.upsert(a.id.clone(), a);
2476 let gone_specs = EntityId::new("specs", "gone");
2477 store.upsert(gone_specs.clone(), make_stub(gone_specs));
2478
2479 let mut x = make_entity("x", true);
2481 x.id = EntityId::new("web", "x");
2482 x.mem = "web".into();
2483 x.file_path = "x.md".into();
2484 x.sections
2485 .insert("purpose".into(), "Refers to [[gone]] in prose.".into());
2486 store.upsert(x.id.clone(), x);
2487 let gone_web = EntityId::new("web", "gone");
2488 store.upsert(gone_web.clone(), make_stub(gone_web));
2489
2490 let all = super::collect_dangling_links(&store, None);
2491 assert_eq!(all.len(), 2);
2492
2493 let specs_only = super::collect_dangling_links(&store, Some("specs"));
2494 assert_eq!(specs_only.len(), 1);
2495 assert_eq!(specs_only[0].from.mem(), "specs");
2496
2497 let web_only = super::collect_dangling_links(&store, Some("web"));
2498 assert_eq!(web_only.len(), 1);
2499 assert_eq!(web_only[0].from.mem(), "web");
2500 }
2501
2502 #[test]
2503 fn parse_iso_date() {
2504 let days = parse_iso_to_days("2026-04-12").unwrap();
2505 assert!(days > 0);
2506
2507 let days_with_time = parse_iso_to_days("2026-04-12T10:00:00Z").unwrap();
2508 assert_eq!(days, days_with_time);
2509 }
2510
2511 #[test]
2512 fn ymd_roundtrip() {
2513 let days = ymd_to_days(2026, 1, 1);
2515 assert!(days > 20000); }
2517
2518 fn make_entity_with_tags(name: &str, mem: &str, entity_type: &str, tags: &str) -> Entity {
2523 let mut e = make_entity(name, true);
2524 e.id = EntityId::new(mem, name);
2525 e.mem = mem.into();
2526 e.entity_type = entity_type.into();
2527 e.metadata
2528 .insert("tags".into(), MetadataValue::String(tags.into()));
2529 e
2530 }
2531
2532 fn make_entity_no_tags(name: &str) -> Entity {
2533 make_entity(name, true)
2534 }
2535
2536 #[test]
2537 fn tag_distribution_aggregates_across_entities() {
2538 let mut store = Store::new();
2539 let a = make_entity_with_tags("a", "specs", "spec", "decision, plan");
2540 let b = make_entity_with_tags("b", "specs", "spec", "decision, plan");
2541 let c = make_entity_with_tags("c", "specs", "spec", "plan");
2542 store.upsert(a.id.clone(), a);
2543 store.upsert(b.id.clone(), b);
2544 store.upsert(c.id.clone(), c);
2545
2546 let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
2547 assert_eq!(dist.len(), 2);
2548 assert_eq!(dist[0].tag, "plan");
2549 assert_eq!(dist[0].count, 3);
2550 assert_eq!(dist[0].by_entity_type.get("spec"), Some(&3));
2551 assert_eq!(dist[1].tag, "decision");
2552 assert_eq!(dist[1].count, 2);
2553 assert_eq!(untagged.total, 0);
2554 }
2555
2556 #[test]
2557 fn tag_distribution_case_sensitive() {
2558 let mut store = Store::new();
2559 let a = make_entity_with_tags("a", "specs", "spec", "Decision");
2560 let b = make_entity_with_tags("b", "specs", "spec", "decision");
2561 store.upsert(a.id.clone(), a);
2562 store.upsert(b.id.clone(), b);
2563
2564 let (dist, folded, _untagged) = collect_tag_distribution(&store, None, 10);
2565 assert_eq!(dist.len(), 2, "`decision` and `Decision` stay distinct");
2566 let tags: std::collections::HashSet<&str> = dist.iter().map(|t| t.tag.as_str()).collect();
2567 assert!(tags.contains("decision"));
2568 assert!(tags.contains("Decision"));
2569
2570 assert_eq!(folded.len(), 1);
2572 assert_eq!(folded[0].canonical, "decision");
2573 assert_eq!(folded[0].total, 2);
2574 assert_eq!(folded[0].variants.len(), 2);
2575 }
2576
2577 #[test]
2578 fn untagged_entities_counts_missing_and_empty() {
2579 let mut store = Store::new();
2580 let a = make_entity_no_tags("a"); let b = make_entity_with_tags("b", "specs", "spec", "");
2582 let c = make_entity_with_tags("c", "specs", "spec", " , , ");
2583 store.upsert(a.id.clone(), a);
2584 store.upsert(b.id.clone(), b);
2585 store.upsert(c.id.clone(), c);
2586
2587 let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
2588 assert!(dist.is_empty(), "no effective tags → empty distribution");
2589 assert_eq!(untagged.total, 3);
2590 assert_eq!(untagged.by_entity_type.get("spec"), Some(&3));
2591 }
2592
2593 #[test]
2594 fn tag_distribution_respects_mem_filter() {
2595 let mut store = Store::new();
2596 let a = make_entity_with_tags("a", "specs", "spec", "decision");
2597 let b = make_entity_with_tags("b", "memos", "memo", "observation");
2598 let c = make_entity_no_tags("c");
2599 store.upsert(a.id.clone(), a);
2600 store.upsert(b.id.clone(), b);
2601 store.upsert(c.id.clone(), c);
2602
2603 let (dist, _folded, untagged) = collect_tag_distribution(&store, Some("memos"), 10);
2604 assert_eq!(dist.len(), 1);
2605 assert_eq!(dist[0].tag, "observation");
2606 assert_eq!(untagged.total, 0, "untagged scoped to filter mem");
2607 }
2608
2609 #[test]
2610 fn tag_distribution_respects_limit() {
2611 let mut store = Store::new();
2612 for (name, tag) in [
2613 ("a", "t-alpha"),
2614 ("b", "t-beta"),
2615 ("c", "t-gamma"),
2616 ("d", "t-delta"),
2617 ("e", "t-epsilon"),
2618 ] {
2619 let e = make_entity_with_tags(name, "specs", "spec", tag);
2620 store.upsert(e.id.clone(), e);
2621 }
2622
2623 let (dist, _folded, _untagged) = collect_tag_distribution(&store, None, 3);
2624 assert_eq!(dist.len(), 3);
2625 assert_eq!(dist[0].tag, "t-alpha");
2628 assert_eq!(dist[1].tag, "t-beta");
2629 assert_eq!(dist[2].tag, "t-delta");
2630 }
2631
2632 fn required_outgoing_fixture_schema() -> std::sync::Arc<memstead_schema::Schema> {
2639 let manifest = r#"name: tests-ro-health
2640version: 0.1.0
2641description: required_outgoing health test schema
2642when_to_use: tests
2643types:
2644 - decision
2645 - note
2646relationships:
2647 mode: strict
2648 definitions:
2649 - name: PART_OF
2650 description: Hier
2651 default_weight: 3.0
2652 acyclic: true
2653 - name: CHOSEN
2654 description: ch
2655 default_weight: 3.0
2656 - name: REJECTED
2657 description: rj
2658 default_weight: 2.0
2659 - name: REFERENCES
2660 description: ref
2661 default_weight: 0.5
2662 - name: _default
2663 description: Fallback
2664 default_weight: 1.0
2665community:
2666 resolution: 1.0
2667 seed: 42
2668"#;
2669 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";
2670 let decision_yaml = format!(
2671 "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",
2672 );
2673 let note_yaml = format!("name: note\ndescription: t\nwhen_to_use: Here\n{body_section}",);
2674 std::sync::Arc::new(
2675 memstead_schema::load_schema_from_memory(
2676 manifest,
2677 &[
2678 ("decision".to_string(), decision_yaml),
2679 ("note".to_string(), note_yaml),
2680 ],
2681 )
2682 .expect("ro fixture schema must parse"),
2683 )
2684 }
2685
2686 fn make_typed_entity(mem: &str, slug: &str, entity_type: &str) -> crate::entity::Entity {
2687 use crate::entity::MetadataValue;
2688 let mut metadata = IndexMap::new();
2689 metadata.insert("type".into(), MetadataValue::String(entity_type.into()));
2690 let mut sections = IndexMap::new();
2691 sections.insert("body".into(), "Body.".into());
2692 crate::entity::Entity {
2693 id: EntityId::new(mem, slug),
2694 title: slug.to_string(),
2695 entity_type: entity_type.into(),
2696 mem: mem.into(),
2697 file_path: format!("{slug}.md"),
2698 metadata,
2699 sections,
2700 relationships: Vec::new(),
2701 content_hash: String::new(),
2702 stub: false,
2703 stub_kind: None,
2704 heading_spans: std::collections::HashMap::new(),
2705 raw_section_headings: Vec::new(),
2706 }
2707 }
2708
2709 #[test]
2710 fn missing_required_outgoing_collects_violators_only() {
2711 let schema = required_outgoing_fixture_schema();
2712 let mut store = Store::new();
2713 let mut violator = make_typed_entity("plan", "stalled", "decision");
2716 let mut satisfied = make_typed_entity("plan", "wired", "decision");
2717 let opt_a = make_typed_entity("plan", "a", "note");
2718 let opt_b = make_typed_entity("plan", "b", "note");
2719 let happy_note = make_typed_entity("plan", "side", "note");
2720 satisfied.relationships.push(crate::entity::Relationship {
2721 rel_type: "CHOSEN".into(),
2722 target: opt_a.id.clone(),
2723 description: None,
2724 });
2725 satisfied.relationships.push(crate::entity::Relationship {
2726 rel_type: "REJECTED".into(),
2727 target: opt_b.id.clone(),
2728 description: None,
2729 });
2730 for e in [violator.clone(), satisfied, opt_a, opt_b, happy_note] {
2731 store.upsert(e.id.clone(), e);
2732 }
2733
2734 let mut mem_schemas = HashMap::new();
2735 mem_schemas.insert("plan".to_string(), schema);
2736
2737 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
2738 assert_eq!(
2739 reports.len(),
2740 1,
2741 "exactly one violator (the empty decision); got {reports:?}"
2742 );
2743 let r = &reports[0];
2744 assert_eq!(r.id, violator.id);
2745 assert_eq!(r.entity_type, "decision");
2746 assert_eq!(r.mem, "plan");
2747 assert_eq!(r.missing.len(), 2);
2748 let names: Vec<&str> = r
2749 .missing
2750 .iter()
2751 .flat_map(|b| b.relationships.iter().map(String::as_str))
2752 .collect();
2753 assert!(names.contains(&"CHOSEN"));
2754 assert!(names.contains(&"REJECTED"));
2755
2756 violator.relationships.push(crate::entity::Relationship {
2758 rel_type: "CHOSEN".into(),
2759 target: EntityId::new("plan", "x"),
2760 description: None,
2761 });
2762 }
2763
2764 #[test]
2765 fn missing_required_outgoing_respects_mem_filter() {
2766 let schema = required_outgoing_fixture_schema();
2769 let mut store = Store::new();
2770 let v_a = make_typed_entity("alpha", "stalled", "decision");
2771 let v_b = make_typed_entity("beta", "stalled", "decision");
2772 store.upsert(v_a.id.clone(), v_a);
2773 store.upsert(v_b.id.clone(), v_b.clone());
2774
2775 let mut mem_schemas = HashMap::new();
2776 mem_schemas.insert("alpha".to_string(), schema.clone());
2777 mem_schemas.insert("beta".to_string(), schema);
2778
2779 let alpha_only = collect_missing_required_outgoing(&store, Some("alpha"), &mem_schemas);
2780 assert_eq!(alpha_only.len(), 1);
2781 assert_eq!(alpha_only[0].mem, "alpha");
2782
2783 let both = collect_missing_required_outgoing(&store, None, &mem_schemas);
2784 assert_eq!(both.len(), 2);
2785 }
2786
2787 #[test]
2788 fn missing_required_outgoing_skips_stubs_and_unschemaed_mems() {
2789 let schema = required_outgoing_fixture_schema();
2792 let mut store = Store::new();
2793 let mut stub = make_typed_entity("plan", "ghost", "");
2794 stub.stub = true;
2795 stub.entity_type = String::new();
2796 let other = make_typed_entity("uncharted", "lonely", "decision");
2797 store.upsert(stub.id.clone(), stub);
2798 store.upsert(other.id.clone(), other);
2799
2800 let mut mem_schemas = HashMap::new();
2801 mem_schemas.insert("plan".to_string(), schema);
2802
2803 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
2804 assert!(
2805 reports.is_empty(),
2806 "stub (no schema lookup) and unschemaed mem must be skipped; got {reports:?}",
2807 );
2808 }
2809}