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 "ledger",
53 "vital_signs",
54];
55
56pub const VITAL_SIGNS_ITEM_CAP: usize = 20;
58
59pub fn health_vital_signs_axis(
84 engine: &crate::engine::Engine,
85 mem_filter: Option<&str>,
86) -> serde_json::Value {
87 let cap = VITAL_SIGNS_ITEM_CAP;
88 let capped = |mut items: Vec<serde_json::Value>| -> serde_json::Value {
89 let count = items.len();
90 let more = count.saturating_sub(cap);
91 items.truncate(cap);
92 let mut o = serde_json::Map::new();
93 o.insert("count".into(), serde_json::json!(count));
94 o.insert("items".into(), serde_json::Value::Array(items));
95 if more > 0 {
96 o.insert("more".into(), serde_json::json!(more));
97 }
98 serde_json::Value::Object(o)
99 };
100
101 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
102 mems.sort();
103 let communities = engine.communities();
104 let mut out = serde_json::Map::new();
105 for mem in &mems {
106 if let Some(f) = mem_filter
107 && f != mem
108 {
109 continue;
110 }
111 let entities: Vec<&crate::entity::Entity> = engine
112 .store()
113 .all_entities()
114 .filter(|e| !e.stub && e.id.mem() == mem)
115 .collect();
116 let schema = engine.schema_for(mem);
117
118 let last_resort: Option<String> = schema.as_ref().and_then(|s| {
120 s.types
121 .values()
122 .find(|t| t.last_resort)
123 .map(|t| t.name.clone())
124 });
125 let type_share = match &last_resort {
126 None => serde_json::json!({ "status": "not_declared" }),
127 Some(lr) => {
128 let mut per: std::collections::BTreeMap<String, (usize, usize)> =
129 std::collections::BTreeMap::new();
130 for e in &entities {
131 let cluster = communities
132 .entity_cluster_map
133 .get(&e.id.0)
134 .cloned()
135 .unwrap_or_else(|| "unplaced".to_string());
136 let slot = per.entry(cluster).or_insert((0, 0));
137 slot.0 += 1;
138 if e.entity_type == *lr {
139 slot.1 += 1;
140 }
141 }
142 let mut rows: Vec<serde_json::Value> = per
143 .into_iter()
144 .map(|(community, (total, on_last_resort))| {
145 serde_json::json!({
146 "community": community,
147 "entities": total,
148 "on_last_resort_type": on_last_resort,
149 })
150 })
151 .collect();
152 rows.sort_by(|a, b| {
155 let share = |v: &serde_json::Value| {
156 let t = v["entities"].as_u64().unwrap_or(1).max(1) as f64;
157 v["on_last_resort_type"].as_u64().unwrap_or(0) as f64 / t
158 };
159 share(b)
160 .partial_cmp(&share(a))
161 .unwrap_or(std::cmp::Ordering::Equal)
162 .then_with(|| a["community"].as_str().cmp(&b["community"].as_str()))
163 });
164 let mut v = capped(rows);
165 v["status"] = serde_json::json!("declared");
166 v["last_resort_type"] = serde_json::json!(lr);
167 v
168 }
169 };
170
171 let mut claims: std::collections::BTreeMap<
174 String,
175 (std::collections::BTreeSet<String>, bool),
176 > = std::collections::BTreeMap::new();
177 for e in &entities {
178 for a in engine.entity_anchors(&e.id) {
179 let slot = claims.entry(a.artifact.clone()).or_default();
180 slot.0.insert(e.id.0.clone());
181 if a.class == crate::anchor::AnchorProvenanceClass::Anchored {
182 slot.1 = true;
183 }
184 }
185 }
186 let roots = engine.anchor_source_roots(mem);
187 let mut unclaimed: Vec<serde_json::Value> = Vec::new();
188 let mut sources_enumerated = 0usize;
189 if let Some(ws) = engine.workspace_root() {
190 let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
191 for join in roots.values() {
192 sources_enumerated += 1;
193 for file in crate::ingest::cursor::enumerate_source_artifacts(
194 engine,
195 &join.source,
196 &join.deny_paths,
197 ws,
198 ) {
199 if !seen.insert(file.clone()) || claims.contains_key(&file) {
200 continue;
201 }
202 let size = std::fs::metadata(ws.join(&file))
203 .map(|m| m.len())
204 .unwrap_or(0);
205 unclaimed.push(serde_json::json!({ "artifact": file, "bytes": size }));
206 }
207 }
208 }
209 unclaimed.sort_by(|a, b| {
210 b["bytes"]
211 .as_u64()
212 .cmp(&a["bytes"].as_u64())
213 .then_with(|| a["artifact"].as_str().cmp(&b["artifact"].as_str()))
214 });
215 let unclaimed_v = if sources_enumerated == 0 {
216 serde_json::json!({ "status": "no_bound_source" })
217 } else {
218 let mut v = capped(unclaimed);
219 v["status"] = serde_json::json!("enumerated");
220 v
221 };
222 let contested: Vec<serde_json::Value> = claims
223 .iter()
224 .filter(|(_, (who, owned))| who.len() >= 2 && !owned)
225 .map(|(artifact, (who, _))| {
226 serde_json::json!({
227 "artifact": artifact,
228 "claimed_by": who.iter().cloned().collect::<Vec<_>>(),
229 })
230 })
231 .collect();
232
233 let cluster_size = |c: &str| -> usize {
235 communities
236 .clusters
237 .get(c)
238 .map(|ci| ci.entities.len())
239 .unwrap_or(0)
240 };
241 let mut by_community: std::collections::BTreeMap<String, Vec<String>> =
242 std::collections::BTreeMap::new();
243 for e in &entities {
244 if !engine.store().outgoing(&e.id).is_empty() {
245 continue;
246 }
247 let own = communities.entity_cluster_map.get(&e.id.0).cloned();
248 let community = match own {
249 Some(c) if cluster_size(&c) > 1 => c,
250 _ => engine
251 .store()
252 .incoming(&e.id)
253 .iter()
254 .find_map(|edge| communities.entity_cluster_map.get(&edge.from.0).cloned())
255 .unwrap_or_else(|| "unplaced".to_string()),
256 };
257 by_community
258 .entry(community)
259 .or_default()
260 .push(e.id.0.clone());
261 }
262 let zero_total: usize = by_community.values().map(Vec::len).sum();
263 let zero_rows: Vec<serde_json::Value> = by_community
264 .into_iter()
265 .map(|(community, mut ids)| {
266 ids.sort();
267 let count = ids.len();
268 let more = count.saturating_sub(cap);
269 ids.truncate(cap);
270 let mut o = serde_json::json!({
271 "community": community,
272 "count": count,
273 "items": ids,
274 });
275 if more > 0 {
276 o["more"] = serde_json::json!(more);
277 }
278 o
279 })
280 .collect();
281 let mut zero_v = capped(zero_rows);
282 zero_v["entities"] = serde_json::json!(zero_total);
283
284 let mut empty_sections: Vec<serde_json::Value> = Vec::new();
286 if let Some(s) = &schema {
287 for e in &entities {
288 let Some(td) = s.types.get(&e.entity_type) else {
289 continue;
290 };
291 for sec in &td.sections {
292 if e.sections
293 .get(&sec.key)
294 .is_some_and(|body| body.trim().is_empty())
295 {
296 empty_sections.push(serde_json::json!({
297 "id": e.id.0,
298 "section": sec.key,
299 }));
300 }
301 }
302 }
303 }
304
305 out.insert(
306 mem.clone(),
307 serde_json::json!({
308 "type_share_by_community": type_share,
309 "unclaimed_source_files": unclaimed_v,
310 "contested_unowned_files": capped(contested),
311 "zero_outgoing_entities": zero_v,
312 "empty_declared_sections": capped(empty_sections),
313 }),
314 );
315 }
316 let mut top = serde_json::Map::new();
317 top.insert("_item_cap".into(), serde_json::json!(cap));
318 for (k, v) in out {
319 top.insert(k, v);
320 }
321 serde_json::Value::Object(top)
322}
323
324pub fn health_checks_axis(
350 engine: &crate::engine::Engine,
351 mem_filter: Option<&str>,
352) -> serde_json::Value {
353 let cap = OPEN_QUESTIONS_ITEM_CAP;
354 let capped = |mut items: Vec<String>| -> serde_json::Value {
355 items.sort();
356 let count = items.len();
357 let more = count.saturating_sub(cap);
358 items.truncate(cap);
359 let mut o = serde_json::Map::new();
360 o.insert("count".into(), serde_json::json!(count));
361 o.insert("items".into(), serde_json::json!(items));
362 if more > 0 {
363 o.insert("more".into(), serde_json::json!(more));
364 }
365 serde_json::Value::Object(o)
366 };
367
368 let ledger = engine
369 .workspace_root()
370 .map(crate::check::CheckLedger::for_workspace);
371 let mut latest: std::collections::BTreeMap<String, crate::check::CheckRecord> =
375 std::collections::BTreeMap::new();
376 let mut latest_conformance: std::collections::BTreeMap<String, crate::check::CheckRecord> =
377 std::collections::BTreeMap::new();
378 let mut foreign_by_entity: std::collections::BTreeMap<String, Vec<String>> =
382 std::collections::BTreeMap::new();
383 let mut newest_any: std::collections::BTreeMap<String, crate::check::CheckRecord> =
386 std::collections::BTreeMap::new();
387 let mut all_verification: std::collections::BTreeMap<String, Vec<crate::check::CheckRecord>> =
391 std::collections::BTreeMap::new();
392 if let Some(l) = &ledger {
393 for rec in l.all() {
394 newest_any.insert(rec.entity.clone(), rec.clone());
395 match rec.resolved_kind() {
396 Some(crate::check::CheckKind::Verification) => {
397 all_verification
398 .entry(rec.entity.clone())
399 .or_default()
400 .push(rec.clone());
401 latest.insert(rec.entity.clone(), rec);
402 }
403 Some(crate::check::CheckKind::Conformance) => {
404 latest_conformance.insert(rec.entity.clone(), rec);
405 }
406 None => {
407 if let Some(k) = rec.foreign_kind() {
408 foreign_by_entity
409 .entry(rec.entity.clone())
410 .or_default()
411 .push(k.to_string());
412 }
413 }
414 }
415 }
416 }
417
418 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
419 mems.sort();
420 let mut out = serde_json::Map::new();
421 for mem in mems {
422 if let Some(f) = mem_filter
423 && f != mem
424 {
425 continue;
426 }
427 let mut counts = std::collections::BTreeMap::from([
428 ("never_checked", 0usize),
429 ("checked_ok", 0usize),
430 ("check_failed", 0usize),
431 ("check_stale", 0usize),
432 ]);
433 let current_pin = engine
439 .mount(&mem)
440 .and_then(|m| m.schema.as_ref())
441 .map(|s| s.as_display());
442 let mut conformance_counts = std::collections::BTreeMap::from([
443 ("never_checked", 0usize),
444 ("checked_ok", 0usize),
445 ("check_failed", 0usize),
446 ("check_stale", 0usize),
447 ]);
448 let mut self_checked: Vec<String> = Vec::new();
449 let mut confirmed_independent: Vec<String> = Vec::new();
450 let mut unconfirmable: Vec<String> = Vec::new();
451 let mut executors = serde_json::Map::new();
455 let mut readings = serde_json::Map::new();
456 let touches = engine.mem_touches(&mem);
457 let mut foreign_kinds: std::collections::BTreeMap<String, usize> =
458 std::collections::BTreeMap::new();
459 let mut findings = serde_json::Map::new();
460 for e in engine.store().all_entities().filter(|e| e.mem == mem) {
461 let id = e.id.0.clone();
462 if let Some(kinds) = foreign_by_entity.get(&id) {
463 for k in kinds {
464 *foreign_kinds.entry(k.clone()).or_insert(0) += 1;
465 }
466 }
467 if let Some(rec) = newest_any.get(&id)
468 && let Some(f) = &rec.finding
469 {
470 findings.insert(
471 id.clone(),
472 serde_json::json!({
473 "verdict": rec.verdict,
474 "kind": rec.kind.as_deref().unwrap_or("verification"),
475 "ts": rec.ts,
476 "identity": rec.identity,
477 "finding": f,
478 }),
479 );
480 }
481 let state = crate::check::derive_state(latest.get(&id), &e.content_hash);
482 *counts.entry(state.as_str()).or_insert(0) += 1;
483 if let Some(records) = all_verification.get(&id) {
484 let rows: Vec<serde_json::Value> = records
485 .iter()
486 .map(|rec| {
487 let reading = if rec.verdict == "ok" {
488 engine.independence_of(e, rec, &touches).0.as_str()
489 } else {
490 "failed"
491 };
492 serde_json::json!({
493 "ts": rec.ts,
494 "identity": rec.identity,
495 "verdict": rec.verdict,
496 "reading": reading,
497 })
498 })
499 .collect();
500 readings.insert(id.clone(), serde_json::Value::Array(rows));
501 }
502 let cstate = crate::check::derive_state_pinned(
503 latest_conformance.get(&id),
504 &e.content_hash,
505 current_pin.as_deref(),
506 );
507 *conformance_counts.entry(cstate.as_str()).or_insert(0) += 1;
508 if state != crate::check::CheckState::CheckedOk {
509 continue;
510 }
511 let check = latest.get(&id).expect("checked_ok implies a record");
521 let (reading, execs) = engine.independence_of(e, check, &touches);
522 if let Some(execs) = execs {
523 executors.insert(id.clone(), serde_json::json!(execs.identities));
524 }
525 match reading {
526 crate::engine::independence::Independence::SelfChecked => self_checked.push(id),
527 crate::engine::independence::Independence::ConfirmedIndependent => {
528 confirmed_independent.push(id)
529 }
530 crate::engine::independence::Independence::Unconfirmable => unconfirmable.push(id),
531 }
532 }
533 let mut m = serde_json::Map::new();
534 for (k, v) in counts {
535 m.insert(k.to_string(), serde_json::json!(v));
536 }
537 let mut c = serde_json::Map::new();
538 for (k, v) in conformance_counts {
539 c.insert(k.to_string(), serde_json::json!(v));
540 }
541 m.insert("conformance".into(), serde_json::Value::Object(c));
542 m.insert(
546 "foreign_kinds".into(),
547 serde_json::to_value(&foreign_kinds).unwrap_or(serde_json::json!({})),
548 );
549 m.insert("findings".into(), serde_json::Value::Object(findings));
550 m.insert(
551 "independence".into(),
552 serde_json::json!({
553 "self_checked": capped(self_checked),
554 "confirmed_independent": capped(confirmed_independent),
555 "unconfirmable": capped(unconfirmable),
556 "comparator": "every identity that mutated the verified plan, its criteria or its session-log notes since the criterion was written; a non-criterion compares against its own author",
557 "executors": serde_json::Value::Object(executors),
558 "readings": serde_json::Value::Object(readings),
559 }),
560 );
561 out.insert(mem, serde_json::Value::Object(m));
562 }
563 serde_json::Value::Object(out)
564}
565
566#[derive(Debug, Clone, serde::Serialize)]
572pub struct DerivationFinding {
573 pub source: crate::entity::EntityId,
574 pub rel_type: String,
575 pub target: crate::entity::EntityId,
576 pub state: String,
578 #[serde(skip_serializing_if = "Option::is_none")]
580 pub baseline: Option<String>,
581 pub current: String,
583}
584
585pub fn health_stale_derivations_axis(
590 engine: &crate::engine::Engine,
591 mem_filter: Option<&str>,
592) -> serde_json::Value {
593 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
594 mems.sort();
595 let mut out = serde_json::Map::new();
596 for mem in mems {
597 if let Some(f) = mem_filter
598 && f != mem
599 {
600 continue;
601 }
602 let findings = engine.derivation_report(&mem).unwrap_or_default();
603 out.insert(
604 mem,
605 serde_json::to_value(&findings).unwrap_or(serde_json::Value::Array(Vec::new())),
606 );
607 }
608 serde_json::Value::Object(out)
609}
610
611pub const OPEN_QUESTIONS_ITEM_CAP: usize = 20;
615
616pub fn health_open_questions_axis(
632 engine: &crate::engine::Engine,
633 mem_filter: Option<&str>,
634) -> serde_json::Value {
635 let cap = OPEN_QUESTIONS_ITEM_CAP;
636 let capped = |mut items: Vec<serde_json::Value>| -> serde_json::Value {
637 let count = items.len();
638 let more = count.saturating_sub(cap);
639 items.truncate(cap);
640 let mut o = serde_json::Map::new();
641 o.insert("count".into(), serde_json::json!(count));
642 o.insert("items".into(), serde_json::Value::Array(items));
643 if more > 0 {
644 o.insert("more".into(), serde_json::json!(more));
645 }
646 serde_json::Value::Object(o)
647 };
648
649 let bindings: Vec<(String, String)> = engine
653 .workspace_root()
654 .and_then(|root| crate::pipeline_store::load_pipeline_configs(root).ok())
655 .map(|c| {
656 c.bindings
657 .iter()
658 .map(|r| (r.config.destination_mem.clone(), r.name.clone()))
659 .collect()
660 })
661 .unwrap_or_default();
662 let mounted: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
663
664 let mut mems: Vec<String> = mounted.clone();
665 mems.sort();
666 let mut out = serde_json::Map::new();
667 for mem in &mems {
668 if let Some(f) = mem_filter
669 && f != mem
670 {
671 continue;
672 }
673
674 let stubs = capped(
676 engine
677 .store()
678 .all_entities()
679 .filter(|e| e.stub && e.id.mem() == mem)
680 .map(|e| serde_json::json!({ "kind": "stub", "id": e.id.to_string() }))
681 .collect(),
682 );
683
684 let (mut recheck, mut unresolvable, mut unobserved, mut dangling_rows) =
697 (Vec::new(), Vec::new(), Vec::new(), Vec::new());
698 let mut aging = Vec::new();
701 let mut entity_end_unreconciled: Option<String> = None;
702 if let Ok(report) = engine.verify_mem_anchors(mem) {
703 entity_end_unreconciled = report.unreconciled.clone();
704 for a in &report.anchors {
705 if let Some(days) = a.unobserved_for_days
706 && days > 0
707 {
708 aging.push(serde_json::json!({
709 "kind": "anchor_aging",
710 "id": a.entity_id,
711 "artifact": a.artifact,
712 "state": a.state,
713 "observed_at": a.observed_at,
714 "unobserved_for_days": days,
715 "note": format!("unobserved for {days} days"),
716 }));
717 }
718 let item = serde_json::json!({
719 "kind": format!("anchor_{}", a.state),
720 "id": a.entity_id,
721 "artifact": a.artifact,
722 });
723 match a.state.as_str() {
724 "recheck" => recheck.push(item),
725 "unresolvable" => unresolvable.push(item),
726 "unobserved" => unobserved.push(item),
727 "dangling" => dangling_rows.push(item),
728 _ => {}
729 }
730 }
731 }
732
733 let constraints = capped(
736 engine
737 .constraint_findings(Some(mem))
738 .iter()
739 .map(|r| {
740 serde_json::json!({
741 "kind": "unsatisfied_constraint",
742 "id": r.id.to_string(),
743 "violations": r.violations.len(),
744 })
745 })
746 .collect(),
747 );
748
749 let dangling = capped(
755 collect_dangling_links(engine.store(), Some(mem))
756 .iter()
757 .map(|d| {
758 serde_json::json!({
759 "kind": d.kind.code(),
760 "id": d.from.to_string(),
761 "target": d.target_id.to_string(),
762 "repair": d.kind.repair(),
763 })
764 })
765 .collect(),
766 );
767
768 let mut process = Vec::new();
778 let mem_bindings: Vec<&String> = bindings
779 .iter()
780 .filter(|(d, _)| d == mem)
781 .map(|(_, b)| b)
782 .collect();
783 let mut resolutions: Vec<(Option<String>, crate::ingest::resolve::ProcessMemResolution)> =
784 Vec::new();
785 if mem_bindings.is_empty() {
786 let r = crate::ingest::resolve::resolve_process_mem(engine, mem, "");
787 if r.declared {
788 resolutions.push((None, r));
789 }
790 } else {
791 for binding in &mem_bindings {
792 resolutions.push((
793 Some((*binding).clone()),
794 crate::ingest::resolve::resolve_process_mem(engine, mem, binding),
795 ));
796 }
797 }
798 for (binding, r) in resolutions {
799 if r.mounted {
800 let mut open = Vec::new();
801 let mut searched = Vec::new();
802 for e in engine
803 .store()
804 .all_entities()
805 .filter(|e| !e.stub && e.id.mem() == r.mem.as_str())
806 {
807 let item = serde_json::json!({
808 "kind": e.entity_type,
809 "id": e.id.to_string(),
810 "title": e.title,
811 });
812 if e.entity_type == "negative_finding" {
813 searched.push(item);
814 } else {
815 open.push(item);
816 }
817 }
818 process.push(serde_json::json!({
819 "binding": binding,
820 "process_mem": r.mem,
821 "declared": r.declared,
822 "resolvable": true,
823 "open_entries": capped(open),
824 "already_searched": capped(searched),
825 }));
826 } else if r.declared {
827 process.push(serde_json::json!({
828 "binding": binding,
829 "process_mem": r.mem,
830 "declared": true,
831 "resolvable": false,
832 "finding": "DECLARED_PROCESS_MEM_MISSING",
833 }));
834 } else {
835 process.push(serde_json::json!({
836 "binding": binding,
837 "resolvable": false,
838 }));
839 }
840 }
841
842 let total_open = stubs["count"].as_u64().unwrap_or(0)
843 + recheck.len() as u64
844 + unresolvable.len() as u64
845 + unobserved.len() as u64
846 + dangling_rows.len() as u64
847 + aging.len() as u64
848 + constraints["count"].as_u64().unwrap_or(0)
849 + dangling["count"].as_u64().unwrap_or(0)
850 + process
851 .iter()
852 .filter_map(|p| p["open_entries"]["count"].as_u64())
853 .sum::<u64>();
854
855 let mut entry = serde_json::Map::new();
856 entry.insert("stubs".into(), stubs);
857 entry.insert("anchors_recheck".into(), capped(recheck));
858 entry.insert("anchors_unresolvable".into(), capped(unresolvable));
859 entry.insert("anchors_unobserved".into(), capped(unobserved));
863 entry.insert("anchors_dangling".into(), capped(dangling_rows));
864 entry.insert("anchors_aging".into(), capped(aging));
865 if let Some(why) = entity_end_unreconciled {
866 entry.insert("entity_end_unreconciled".into(), serde_json::json!(why));
867 }
868 entry.insert("unsatisfied_constraints".into(), constraints);
869 entry.insert("dangling_links".into(), dangling);
870 if !process.is_empty() {
871 entry.insert("process".into(), serde_json::Value::Array(process));
872 } else {
873 entry.insert("process_mem_resolvable".into(), serde_json::json!(false));
876 }
877 entry.insert("total_open".into(), serde_json::json!(total_open));
878 out.insert(mem.clone(), serde_json::Value::Object(entry));
879 }
880 let mut top = serde_json::Map::new();
881 top.insert("_item_cap".into(), serde_json::json!(cap));
882 for (k, v) in out {
883 top.insert(k, v);
884 }
885 serde_json::Value::Object(top)
886}
887
888pub fn health_anchors_axis(engine: &crate::engine::Engine) -> serde_json::Value {
889 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
890 mems.sort();
891 let mut out = serde_json::Map::new();
892 for mem in mems {
893 let Ok(report) = engine.verify_mem_anchors(&mem) else {
894 continue;
895 };
896 let condition = report.sidecar_error.as_ref().map(|why| {
897 serde_json::json!({
898 "code": "ANCHORS_SIDECAR_UNREADABLE",
899 "mem": mem,
900 "reason": why,
901 })
902 });
903 out.insert(
904 mem,
905 serde_json::json!({
906 "condition": condition,
909 "resolves": report.resolves,
910 "drifted": report.drifted,
911 "recheck": report.recheck,
912 "unresolvable": report.unresolvable,
917 "unobserved": report.unobserved,
918 "dangling": report.dangling,
923 "entity_end_unreconciled": report.unreconciled,
924 "population": report.population_statement(),
927 "fully_adjudicated": report.fully_adjudicated(),
928 "aging": report
933 .anchors
934 .iter()
935 .filter(|a| a.observed_at.is_some())
936 .map(|a| {
937 let days = a.unobserved_for_days.unwrap_or(0);
938 serde_json::json!({
939 "id": a.entity_id,
940 "artifact": a.artifact,
941 "state": a.state,
942 "observed_at": a.observed_at,
943 "unobserved_for_days": days,
944 "note": format!("unobserved for {days} days"),
945 })
946 })
947 .collect::<Vec<_>>(),
948 }),
949 );
950 }
951 serde_json::Value::Object(out)
952}
953
954pub fn compute_health(
967 store: &Store,
968 default_schema: &TypeDefinition,
969 mem_schemas: &HashMap<String, Arc<Schema>>,
970 mem_filter: Option<&str>,
971) -> HealthSummary {
972 let mut missing_fields = Vec::new();
973 let mut stale_entities = Vec::new();
974
975 let today_days = days_since_epoch();
976
977 let in_scope = |mem: &str| mem_filter.is_none_or(|v| mem == v);
978
979 for entity in store.all_entities() {
980 if entity.stub || !in_scope(&entity.mem) {
981 continue;
982 }
983
984 let resolved = mem_schemas
993 .get(entity.mem.as_str())
994 .and_then(|s| s.types.get(entity.entity_type.as_str()).cloned())
995 .or_else(|| type_by_name(&entity.entity_type));
996 let schema: &TypeDefinition = resolved.as_deref().unwrap_or(default_schema);
997 let mut issues = Vec::new();
998
999 for field in &schema.health_required_fields {
1001 if schema.section(field).is_some() {
1003 let content = entity.sections.get(field.as_str());
1008 if content.is_none_or(|c| c.trim().is_empty()) {
1009 if let Some(issue) = section_heading_mismatch_issue(entity, schema, field) {
1010 issues.push(issue);
1011 } else {
1012 issues.push(HealthIssue {
1013 field: field.clone(),
1014 code: super::HealthIssueCode::Missing,
1015 message: format!("required section '{field}' is empty"),
1016 });
1017 }
1018 }
1019 } else {
1020 let value = entity.metadata.get(field.as_str());
1026 let is_empty = match value {
1027 None => true,
1028 Some(v) => v.to_frontmatter_string().trim().is_empty(),
1029 };
1030 if is_empty {
1031 issues.push(HealthIssue {
1032 field: field.clone(),
1033 code: super::HealthIssueCode::Missing,
1034 message: format!("required field '{field}' is missing"),
1035 });
1036 }
1037 }
1038 }
1039
1040 for s in schema.sections.iter().filter(|s| !s.catch_all) {
1043 if schema.health_required_fields.contains(&s.key) {
1044 continue; }
1046 let content = entity.sections.get(s.key.as_str());
1047 if content.is_none_or(|c| c.trim().is_empty())
1048 && let Some(issue) = section_heading_mismatch_issue(entity, schema, &s.key)
1049 {
1050 issues.push(issue);
1051 }
1052 }
1053
1054 if let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) {
1070 let mut seen_unknown = std::collections::HashSet::new();
1071 for rel in &entity.relationships {
1072 if !mem_schema.relationship_known(&rel.rel_type) {
1073 if seen_unknown.insert(rel.rel_type.clone()) {
1074 let suggestion = mem_schema
1075 .suggest_relationship(&rel.rel_type)
1076 .map(|s| format!(" Did you mean '{s}'?"))
1077 .unwrap_or_default();
1078 let (schema_name, schema_version) = mem_schema.id();
1079 issues.push(HealthIssue {
1080 field: "relationships".to_string(),
1081 code: super::HealthIssueCode::UndeclaredRelationship,
1082 message: format!(
1083 "relationship '{}' is not declared in schema \
1084 '{schema_name}@{schema_version}'.{suggestion}",
1085 rel.rel_type
1086 ),
1087 });
1088 }
1089 continue;
1090 }
1091
1092 let target_type = store
1093 .get(&rel.target)
1094 .map(|t| t.entity_type.clone())
1095 .filter(|t| !t.is_empty());
1096 if let Err(crate::runtime_validator::ValidationError::InvalidRelationshipShape {
1097 rel_type,
1098 from_type,
1099 to_type,
1100 allowed_source_types,
1101 allowed_target_types,
1102 ..
1103 }) = crate::runtime_validator::validate_rel_shape(
1104 &rel.rel_type,
1105 entity.entity_type.as_str(),
1106 target_type.as_deref(),
1107 mem_schema.as_ref(),
1108 ) {
1109 let allowed_src = if allowed_source_types.is_empty() {
1110 "<any>".to_string()
1111 } else {
1112 allowed_source_types.join(", ")
1113 };
1114 let allowed_tgt = if allowed_target_types.is_empty() {
1115 "<any>".to_string()
1116 } else {
1117 allowed_target_types.join(", ")
1118 };
1119 issues.push(HealthIssue {
1120 field: "relationships".to_string(),
1121 code: super::HealthIssueCode::InvalidRelShape,
1122 message: format!(
1123 "INVALID_REL_SHAPE: edge '{rel_type}' from \
1124 '{from_type}' to '{to_type}' (target {target}) \
1125 violates declared shape — allowed_source_types: \
1126 [{allowed_src}], allowed_target_types: \
1127 [{allowed_tgt}]. Remove via \
1128 `memstead_relate from={from_id} to={target} \
1129 type={rel_type} remove=true`.",
1130 target = rel.target,
1131 from_id = entity.id,
1132 ),
1133 });
1134 }
1135 }
1136 }
1137
1138 let auto_ts_field = schema.metadata_fields.iter().find(|f| f.auto_timestamp);
1140
1141 if let Some(ts_field) = auto_ts_field
1142 && let Some(val) = entity.metadata.get(ts_field.key.as_str())
1143 {
1144 let date_str = val.to_frontmatter_string();
1145 if let Some(modified_days) = parse_iso_to_days(&date_str) {
1146 let days_since = today_days.saturating_sub(modified_days);
1147 if days_since > schema.staleness_threshold_days as u64 {
1148 stale_entities.push(StaleEntity {
1149 id: entity.id.clone(),
1150 title: entity.title.clone(),
1151 days_since_modified: days_since,
1152 });
1153 }
1154 }
1155 }
1156
1157 if !issues.is_empty() {
1158 let total = schema.health_required_fields.len();
1164 let score = if total > 0 {
1165 (total.saturating_sub(issues.len()) as f32) / (total as f32)
1166 } else {
1167 1.0
1168 };
1169
1170 missing_fields.push(HealthReport {
1171 id: entity.id.clone(),
1172 title: entity.title.clone(),
1173 score,
1174 issues,
1175 });
1176 }
1177 }
1178
1179 stale_entities.sort_by_key(|e| std::cmp::Reverse(e.days_since_modified));
1181
1182 let orphan_count = query::find_orphans_with_schemas(store, mem_schemas)
1185 .into_iter()
1186 .filter(|id| store.get(id).is_some_and(|e| in_scope(&e.mem)))
1187 .count();
1188 let leaf_entities_by_type = match mem_filter {
1189 None => query::leaf_population(store, mem_schemas),
1190 Some(v) => {
1191 let scoped: HashMap<String, Arc<Schema>> = mem_schemas
1192 .iter()
1193 .filter(|(mem, _)| mem.as_str() == v)
1194 .map(|(mem, s)| (mem.clone(), s.clone()))
1195 .collect();
1196 query::leaf_population(store, &scoped)
1197 }
1198 };
1199 let stub_count = query::find_stubs(store)
1200 .iter()
1201 .filter(|(id, _)| store.get(id).is_some_and(|e| in_scope(&e.mem)))
1202 .count();
1203
1204 stale_entities.sort_by(|a, b| a.id.0.cmp(&b.id.0));
1207 missing_fields.sort_by(|a, b| a.id.0.cmp(&b.id.0));
1208
1209 HealthSummary {
1210 stale_entities,
1211 missing_fields,
1212 orphan_count,
1213 stub_count,
1214 warnings: Vec::new(),
1215 quarantined: Vec::new(),
1216 load_errors: Vec::new(),
1217 boot_diagnosis: None,
1218 leaf_entities_by_type,
1219 dangling_links: None,
1220 findings: None,
1221 tag_distribution: None,
1222 tag_distribution_folded: None,
1223 untagged_entities: None,
1224 }
1225}
1226
1227pub fn collect_tag_distribution(
1241 store: &Store,
1242 mem_filter: Option<&str>,
1243 limit: usize,
1244) -> (Vec<TagDistribution>, Vec<FoldedTag>, UntaggedStats) {
1245 let mut counts: HashMap<String, (usize, HashMap<String, usize>)> = HashMap::new();
1247 let mut untagged = UntaggedStats {
1248 total: 0,
1249 by_entity_type: HashMap::new(),
1250 };
1251
1252 for entity in store.all_entities() {
1253 if entity.stub {
1254 continue;
1255 }
1256 if let Some(v) = mem_filter
1257 && entity.mem != v
1258 {
1259 continue;
1260 }
1261
1262 let tags_raw = entity
1263 .metadata
1264 .get("tags")
1265 .and_then(|v| match v {
1266 MetadataValue::String(s) => Some(s.as_str()),
1267 _ => None,
1268 })
1269 .unwrap_or("");
1270
1271 let mut any_tag = false;
1272 for tag in tags_raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
1273 any_tag = true;
1274 let entry = counts
1275 .entry(tag.to_string())
1276 .or_insert_with(|| (0, HashMap::new()));
1277 entry.0 += 1;
1278 *entry.1.entry(entity.entity_type.clone()).or_insert(0) += 1;
1279 }
1280 if !any_tag {
1281 untagged.total += 1;
1282 *untagged
1283 .by_entity_type
1284 .entry(entity.entity_type.clone())
1285 .or_insert(0) += 1;
1286 }
1287 }
1288
1289 let mut entries: Vec<TagDistribution> = counts
1291 .iter()
1292 .map(|(tag, (count, by_type))| TagDistribution {
1293 tag: tag.clone(),
1294 count: *count,
1295 by_entity_type: by_type.clone(),
1296 })
1297 .collect();
1298 entries.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.tag.cmp(&b.tag)));
1299 entries.truncate(limit);
1300
1301 let mut by_canonical: HashMap<String, Vec<(String, usize)>> = HashMap::new();
1306 for (tag, (count, _)) in counts.iter() {
1307 by_canonical
1308 .entry(tag.to_lowercase())
1309 .or_default()
1310 .push((tag.clone(), *count));
1311 }
1312 let mut folded: Vec<FoldedTag> = by_canonical
1313 .into_iter()
1314 .filter(|(_, v)| v.len() > 1)
1315 .map(|(canonical, mut variants)| {
1316 variants.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1317 let total = variants.iter().map(|(_, c)| *c).sum();
1318 FoldedTag {
1319 canonical,
1320 total,
1321 variants: variants
1322 .into_iter()
1323 .map(|(tag, count)| TagVariant { tag, count })
1324 .collect(),
1325 }
1326 })
1327 .collect();
1328 folded.sort_by(|a, b| {
1329 b.total
1330 .cmp(&a.total)
1331 .then_with(|| a.canonical.cmp(&b.canonical))
1332 });
1333
1334 (entries, folded, untagged)
1335}
1336
1337pub fn collect_dangling_links(store: &Store, mem_filter: Option<&str>) -> Vec<DanglingLink> {
1363 use crate::entity::parser::extract_inline_links_lenient;
1364 use std::collections::HashSet;
1365
1366 let mut out = Vec::new();
1367 for entity in store.all_entities() {
1368 if entity.stub {
1369 continue;
1370 }
1371 if let Some(v) = mem_filter
1372 && entity.mem != v
1373 {
1374 continue;
1375 }
1376 let explicit_targets: HashSet<_> = entity
1377 .relationships
1378 .iter()
1379 .map(|r| r.target.clone())
1380 .collect();
1381 for (section_key, section_body) in &entity.sections {
1382 for target_id in extract_inline_links_lenient(section_body, &entity.mem) {
1383 let target_missing = store.get(&target_id).map(|e| e.stub).unwrap_or(true);
1384 let alias_orphan = !target_missing && !explicit_targets.contains(&target_id);
1385 let kind = if target_missing {
1389 crate::ops::DanglingLinkKind::LinkTargetMissing
1390 } else {
1391 crate::ops::DanglingLinkKind::LinkNotRelated
1392 };
1393 if target_missing || alias_orphan {
1394 out.push(DanglingLink {
1395 kind,
1396 from: entity.id.clone(),
1397 target_id: target_id.clone(),
1398 target_path: target_id.path().to_string(),
1399 section: Some(section_key.clone()),
1400 });
1401 }
1402 }
1403 }
1404 for rel in &entity.relationships {
1421 if store.get(&rel.target).is_some() {
1422 continue;
1423 }
1424 let already_reported = out
1425 .iter()
1426 .any(|d| d.from == entity.id && d.target_id == rel.target);
1427 if already_reported {
1428 continue;
1429 }
1430 out.push(DanglingLink {
1431 kind: crate::ops::DanglingLinkKind::RelationTargetMissing,
1432 from: entity.id.clone(),
1433 target_id: rel.target.clone(),
1434 target_path: rel.target.path().to_string(),
1435 section: None,
1436 });
1437 }
1438 }
1439 out.sort_by(|a, b| {
1444 (&a.from.0, &a.target_id.0, &a.section).cmp(&(&b.from.0, &b.target_id.0, &b.section))
1445 });
1446 out
1447}
1448
1449pub fn collect_missing_required_outgoing(
1460 store: &Store,
1461 mem_filter: Option<&str>,
1462 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1463) -> Vec<MissingRequiredOutgoingReport> {
1464 let mut out = Vec::new();
1465 for entity in store.all_entities() {
1466 if entity.stub {
1467 continue;
1468 }
1469 if let Some(v) = mem_filter
1470 && entity.mem != v
1471 {
1472 continue;
1473 }
1474 let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
1475 continue;
1476 };
1477 let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
1478 continue;
1479 };
1480 if td.required_outgoing.is_empty() {
1481 continue;
1482 }
1483 let unsatisfied = unsatisfied_required_outgoing(entity, td);
1484 if unsatisfied.is_empty() {
1485 continue;
1486 }
1487 out.push(MissingRequiredOutgoingReport {
1488 id: entity.id.clone(),
1489 title: entity.title.clone(),
1490 entity_type: entity.entity_type.clone(),
1491 mem: entity.mem.clone(),
1492 missing: unsatisfied,
1493 });
1494 }
1495 out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
1496 out
1497}
1498
1499pub fn unsatisfied_required_outgoing(
1507 entity: &crate::entity::Entity,
1508 td: &TypeDefinition,
1509) -> Vec<super::MissingRequiredOutgoingBlock> {
1510 td.required_outgoing
1511 .iter()
1512 .filter(|block| {
1513 if let (Some(when_field), Some(when_value)) = (&block.when_field, &block.when_value) {
1518 let armed = entity
1519 .metadata
1520 .get(when_field.as_str())
1521 .is_some_and(|v| v.to_frontmatter_string() == *when_value);
1522 if !armed {
1523 return false;
1524 }
1525 }
1526 let count = entity
1527 .relationships
1528 .iter()
1529 .filter(|rel| block.relationships.iter().any(|name| name == &rel.rel_type))
1530 .count();
1531 !block.admits(count)
1532 })
1533 .map(|block| super::MissingRequiredOutgoingBlock {
1534 relationships: block.relationships.clone(),
1535 cardinality: block.cardinality.to_string(),
1536 severity: block.severity,
1537 when_field: block.when_field.clone(),
1538 when_value: block.when_value.clone(),
1539 })
1540 .collect()
1541}
1542
1543#[derive(Debug, Clone, serde::Serialize)]
1551#[serde(tag = "kind", rename_all = "snake_case")]
1552pub enum UnsatisfiedConstraint {
1553 RequiresWhen {
1554 field: String,
1555 when_field: String,
1556 when_value: String,
1557 severity: memstead_schema::ConstraintSeverity,
1558 },
1559 Unique {
1560 fields: Vec<String>,
1561 values: Vec<String>,
1563 colliding: String,
1566 severity: memstead_schema::ConstraintSeverity,
1567 },
1568 EnumFromNeighbour {
1569 field: String,
1570 value: String,
1572 rel_type: String,
1573 section: String,
1574 severity: memstead_schema::ConstraintSeverity,
1575 },
1576 StatusPropagation {
1577 field: String,
1578 value: String,
1580 #[serde(skip_serializing_if = "Option::is_none")]
1584 rel_type: Option<String>,
1585 #[serde(skip_serializing_if = "Option::is_none")]
1587 rel_types: Option<Vec<String>>,
1588 tainted_by: String,
1591 severity: memstead_schema::ConstraintSeverity,
1592 },
1593 TransitionRequiresChecks {
1596 field: String,
1597 to_value: String,
1598 relationships: Vec<String>,
1599 direction: memstead_schema::PropagationDirection,
1600 unchecked: Vec<UncheckedRelated>,
1603 severity: memstead_schema::ConstraintSeverity,
1604 },
1605 MustReach {
1610 relationships: Vec<String>,
1611 direction: memstead_schema::ReachDirection,
1612 terminal_types: Vec<String>,
1613 #[serde(skip_serializing_if = "Option::is_none")]
1614 max_depth: Option<u32>,
1615 severity: memstead_schema::ConstraintSeverity,
1616 },
1617}
1618
1619impl UnsatisfiedConstraint {
1620 pub fn severity(&self) -> memstead_schema::ConstraintSeverity {
1621 match self {
1622 Self::RequiresWhen { severity, .. }
1623 | Self::Unique { severity, .. }
1624 | Self::EnumFromNeighbour { severity, .. }
1625 | Self::StatusPropagation { severity, .. }
1626 | Self::MustReach { severity, .. }
1627 | Self::TransitionRequiresChecks { severity, .. } => *severity,
1628 }
1629 }
1630
1631 pub fn describe(&self) -> String {
1633 match self {
1634 Self::RequiresWhen {
1635 field,
1636 when_field,
1637 when_value,
1638 ..
1639 } => format!(
1640 "requires_when: '{field}' is required when {when_field}={when_value} and is unset"
1641 ),
1642 Self::Unique {
1643 fields, colliding, ..
1644 } => format!(
1645 "unique: tuple ({}) collides with '{colliding}'",
1646 fields.join(", ")
1647 ),
1648 Self::EnumFromNeighbour {
1649 field,
1650 value,
1651 rel_type,
1652 section,
1653 ..
1654 } => format!(
1655 "enum_from_neighbour: '{field}' value '{value}' has no backing entry in any \
1656 `{section}` section reached via {rel_type}"
1657 ),
1658 Self::StatusPropagation {
1659 field,
1660 value,
1661 tainted_by,
1662 ..
1663 } => {
1664 format!("status_propagation: tainted by '{tainted_by}' ({field}={value})")
1665 }
1666 Self::MustReach {
1667 relationships,
1668 direction,
1669 terminal_types,
1670 max_depth,
1671 ..
1672 } => {
1673 let depth = match max_depth {
1674 Some(d) => format!(" within {d} hop(s)"),
1675 None => String::new(),
1676 };
1677 format!(
1678 "must_reach: no path via [{}] ({direction}) reaches a [{}] entity{depth}",
1679 relationships.join(", "),
1680 terminal_types.join(", ")
1681 )
1682 }
1683 Self::TransitionRequiresChecks {
1684 field,
1685 to_value,
1686 relationships,
1687 unchecked,
1688 ..
1689 } => {
1690 let listed: Vec<String> = unchecked
1691 .iter()
1692 .map(|u| format!("'{}' ({})", u.id, u.state))
1693 .collect();
1694 format!(
1695 "transition_requires_checks: {field}={to_value} requires a fresh confirming \
1696 check record on every entity related via [{}] — unconfirmed: {}",
1697 relationships.join(", "),
1698 listed.join(", ")
1699 )
1700 }
1701 }
1702 }
1703}
1704
1705#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1709pub struct UncheckedRelated {
1710 pub id: String,
1711 pub state: String,
1712}
1713
1714pub type CheckStateProvider<'a> =
1721 &'a dyn Fn(&crate::entity::Entity) -> crate::engine::independence::CheckStanding;
1722
1723pub fn unsatisfied_constraints(
1747 store: &Store,
1748 entity: &crate::entity::Entity,
1749 td: &TypeDefinition,
1750 exclude: Option<&crate::entity::EntityId>,
1751 checks: Option<CheckStateProvider<'_>>,
1752) -> Vec<UnsatisfiedConstraint> {
1753 use memstead_schema::ConstraintDef;
1754 td.constraints
1755 .iter()
1756 .filter_map(|c| match c {
1757 ConstraintDef::RequiresWhen {
1758 field,
1759 when_field,
1760 when_value,
1761 severity,
1762 } => {
1763 let triggered = entity
1764 .metadata
1765 .get(when_field.as_str())
1766 .is_some_and(|v| v.to_frontmatter_string() == *when_value);
1767 if !triggered {
1768 return None;
1769 }
1770 let satisfied = entity
1771 .metadata
1772 .get(field.as_str())
1773 .is_some_and(|v| !v.to_frontmatter_string().trim().is_empty())
1774 || entity
1775 .sections
1776 .get(field.as_str())
1777 .is_some_and(|body| !body.trim().is_empty());
1778 if satisfied {
1779 return None;
1780 }
1781 Some(UnsatisfiedConstraint::RequiresWhen {
1782 field: field.clone(),
1783 when_field: when_field.clone(),
1784 when_value: when_value.clone(),
1785 severity: *severity,
1786 })
1787 }
1788 ConstraintDef::Unique { fields, severity } => {
1789 let tuple = tuple_of(entity, fields)?;
1790 let mut colliding: Vec<&str> = store
1791 .all_entities()
1792 .filter(|other| {
1793 !other.stub
1794 && other.mem == entity.mem
1795 && other.entity_type == entity.entity_type
1796 && Some(&other.id) != exclude
1797 && other.id != entity.id
1798 && tuple_of(other, fields).as_ref() == Some(&tuple)
1799 })
1800 .map(|other| other.id.0.as_str())
1801 .collect();
1802 colliding.sort_unstable();
1803 let first = colliding.first()?;
1804 Some(UnsatisfiedConstraint::Unique {
1805 fields: fields.clone(),
1806 values: tuple,
1807 colliding: first.to_string(),
1808 severity: *severity,
1809 })
1810 }
1811 ConstraintDef::EnumFromNeighbour {
1812 field,
1813 rel_type,
1814 section,
1815 severity,
1816 } => {
1817 let value = entity
1818 .metadata
1819 .get(field.as_str())
1820 .map(|v| v.to_frontmatter_string())
1821 .filter(|v| !v.trim().is_empty())?;
1822 let backed = entity
1823 .relationships
1824 .iter()
1825 .filter(|rel| rel.rel_type == *rel_type)
1826 .filter_map(|rel| store.get(&rel.target))
1827 .filter_map(|neighbour| neighbour.sections.get(section.as_str()))
1828 .any(|body| bullet_entries(body).contains(&value));
1829 if backed {
1830 return None;
1831 }
1832 Some(UnsatisfiedConstraint::EnumFromNeighbour {
1833 field: field.clone(),
1834 value,
1835 rel_type: rel_type.clone(),
1836 section: section.clone(),
1837 severity: *severity,
1838 })
1839 }
1840 ConstraintDef::StatusPropagation { .. } => None,
1841 ConstraintDef::TransitionRequiresChecks {
1842 field,
1843 to_value,
1844 relationships,
1845 direction,
1846 severity,
1847 } => {
1848 let triggered = entity
1849 .metadata
1850 .get(field.as_str())
1851 .is_some_and(|v| v.to_frontmatter_string() == *to_value);
1852 if !triggered {
1853 return None;
1854 }
1855 let (_, unchecked) = transition_gate_standing(
1856 store,
1857 entity,
1858 relationships,
1859 *direction,
1860 exclude,
1861 checks,
1862 );
1863 if unchecked.is_empty() {
1864 return None;
1865 }
1866 Some(UnsatisfiedConstraint::TransitionRequiresChecks {
1867 field: field.clone(),
1868 to_value: to_value.clone(),
1869 relationships: relationships.clone(),
1870 direction: *direction,
1871 unchecked,
1872 severity: *severity,
1873 })
1874 }
1875 })
1876 .collect()
1877}
1878
1879pub fn transition_gate_standing(
1890 store: &Store,
1891 entity: &crate::entity::Entity,
1892 relationships: &[String],
1893 direction: memstead_schema::PropagationDirection,
1894 exclude: Option<&crate::entity::EntityId>,
1895 checks: Option<CheckStateProvider<'_>>,
1896) -> (usize, Vec<UncheckedRelated>) {
1897 let related: Vec<&crate::entity::Entity> = match direction {
1898 memstead_schema::PropagationDirection::Outgoing => entity
1899 .relationships
1900 .iter()
1901 .filter(|rel| relationships.contains(&rel.rel_type))
1902 .filter_map(|rel| store.get(&rel.target))
1903 .collect(),
1904 memstead_schema::PropagationDirection::Incoming => store
1905 .all_entities()
1906 .filter(|other| {
1907 other.id != entity.id
1908 && Some(&other.id) != exclude
1909 && other
1910 .relationships
1911 .iter()
1912 .any(|rel| rel.target == entity.id && relationships.contains(&rel.rel_type))
1913 })
1914 .collect(),
1915 };
1916 let total = related.len();
1917 let mut unchecked: Vec<UncheckedRelated> = related
1918 .into_iter()
1919 .filter_map(|rel_entity| {
1920 let standing = match checks {
1924 Some(provider) => provider(rel_entity),
1925 None => crate::engine::independence::CheckStanding {
1926 state: crate::check::CheckState::NeverChecked,
1927 independence: None,
1928 },
1929 };
1930 if standing.confirms() {
1931 None
1932 } else {
1933 Some(UncheckedRelated {
1934 id: rel_entity.id.0.clone(),
1935 state: standing.label().to_string(),
1936 })
1937 }
1938 })
1939 .collect();
1940 unchecked.sort_by(|a, b| a.id.cmp(&b.id));
1941 (total, unchecked)
1942}
1943
1944fn tuple_of(entity: &crate::entity::Entity, fields: &[String]) -> Option<Vec<String>> {
1948 fields
1949 .iter()
1950 .map(|f| {
1951 entity
1952 .metadata
1953 .get(f.as_str())
1954 .map(|v| v.to_frontmatter_string())
1955 .filter(|v| !v.trim().is_empty())
1956 })
1957 .collect()
1958}
1959
1960fn bullet_entries(body: &str) -> Vec<String> {
1963 let masked = crate::markdown::mask_code_blocks_and_spans(body);
1968 body.lines()
1969 .zip(masked.lines())
1970 .filter_map(|(line, masked_line)| {
1971 let m = masked_line.trim_start();
1972 if m.starts_with("- ") || m.starts_with("* ") {
1973 let t = line.trim_start();
1974 t.strip_prefix("- ")
1975 .or_else(|| t.strip_prefix("* "))
1976 .map(|e| e.trim().to_string())
1977 } else {
1978 None
1979 }
1980 })
1981 .collect()
1982}
1983
1984#[derive(Debug, Clone, serde::Serialize)]
1989pub struct ConstraintFindingReport {
1990 pub id: crate::entity::EntityId,
1991 pub title: String,
1992 pub entity_type: String,
1993 pub mem: String,
1994 pub violations: Vec<UnsatisfiedConstraint>,
1995 #[serde(skip_serializing_if = "Vec::is_empty")]
1999 pub format_violations: Vec<crate::section_format::SectionFormatViolation>,
2000}
2001
2002pub fn collect_constraint_findings(
2012 store: &Store,
2013 mem_filter: Option<&str>,
2014 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
2015 checks: Option<CheckStateProvider<'_>>,
2016) -> Vec<ConstraintFindingReport> {
2017 use memstead_schema::ConstraintDef;
2018 type Bucket = (
2019 Vec<UnsatisfiedConstraint>,
2020 Vec<crate::section_format::SectionFormatViolation>,
2021 );
2022 let mut by_entity: std::collections::BTreeMap<String, Bucket> = Default::default();
2023
2024 let needs_reverse = mem_schemas.values().any(|s| {
2028 s.types.values().any(|t| {
2029 t.must_reach
2030 .iter()
2031 .any(|ob| ob.direction == memstead_schema::ReachDirection::In)
2032 })
2033 });
2034 let reverse: ReverseIndex = if needs_reverse {
2035 build_reverse_index(store)
2036 } else {
2037 ReverseIndex::default()
2038 };
2039
2040 for entity in store.all_entities() {
2041 if entity.stub {
2042 continue;
2043 }
2044 if let Some(v) = mem_filter
2045 && entity.mem != v
2046 {
2047 continue;
2048 }
2049 let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
2050 continue;
2051 };
2052 let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
2053 continue;
2054 };
2055
2056 for def in &td.sections {
2061 if def.compiled_content.is_none() {
2062 continue;
2063 }
2064 let Some(body) = entity.sections.get(def.key.as_str()) else {
2065 continue;
2066 };
2067 let violations = crate::section_format::check_section_format(def, body);
2068 if !violations.is_empty() {
2069 by_entity
2070 .entry(entity.id.0.clone())
2071 .or_default()
2072 .1
2073 .extend(violations);
2074 }
2075 }
2076
2077 for ob in &td.must_reach {
2082 if !reaches_terminal(store, &reverse, &entity.id, ob) {
2083 by_entity.entry(entity.id.0.clone()).or_default().0.push(
2084 UnsatisfiedConstraint::MustReach {
2085 relationships: ob.relationships.clone(),
2086 direction: ob.direction,
2087 terminal_types: ob.terminal_types.clone(),
2088 max_depth: ob.max_depth,
2089 severity: ob.severity,
2090 },
2091 );
2092 }
2093 }
2094
2095 if td.constraints.is_empty() {
2096 continue;
2097 }
2098
2099 let violations = unsatisfied_constraints(store, entity, td, None, checks);
2101 if !violations.is_empty() {
2102 by_entity
2103 .entry(entity.id.0.clone())
2104 .or_default()
2105 .0
2106 .extend(violations);
2107 }
2108
2109 for c in &td.constraints {
2114 let ConstraintDef::StatusPropagation {
2115 field,
2116 value,
2117 rel_type,
2118 rel_types,
2119 direction,
2120 severity,
2121 } = c
2122 else {
2123 continue;
2124 };
2125 let terminal = entity
2126 .metadata
2127 .get(field.as_str())
2128 .is_some_and(|v| v.to_frontmatter_string() == *value);
2129 if !terminal {
2130 continue;
2131 }
2132 let set = c
2133 .propagation_rel_types()
2134 .expect("StatusPropagation always yields a set");
2135 for tainted in reach_transitively(store, &entity.id, &set, *direction) {
2136 if let Some(v) = mem_filter
2137 && tainted.mem() != v
2138 {
2139 continue;
2140 }
2141 by_entity.entry(tainted.0.clone()).or_default().0.push(
2142 UnsatisfiedConstraint::StatusPropagation {
2143 field: field.clone(),
2144 value: value.clone(),
2145 rel_type: rel_type.clone(),
2146 rel_types: rel_types.clone(),
2147 tainted_by: entity.id.to_string(),
2148 severity: *severity,
2149 },
2150 );
2151 }
2152 }
2153 }
2154
2155 let mut out: Vec<ConstraintFindingReport> = by_entity
2156 .into_iter()
2157 .filter_map(|(id, (violations, format_violations))| {
2158 let id = crate::entity::EntityId(id);
2159 let entity = store.get(&id)?;
2160 Some(ConstraintFindingReport {
2161 id,
2162 title: entity.title.clone(),
2163 entity_type: entity.entity_type.clone(),
2164 mem: entity.mem.clone(),
2165 violations,
2166 format_violations,
2167 })
2168 })
2169 .collect();
2170 out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
2171 out
2172}
2173
2174fn reach_transitively(
2181 store: &Store,
2182 start: &crate::entity::EntityId,
2183 rel_types: &[String],
2184 direction: memstead_schema::PropagationDirection,
2185) -> Vec<crate::entity::EntityId> {
2186 use memstead_schema::PropagationDirection;
2187 let mut seen: std::collections::HashSet<crate::entity::EntityId> =
2188 std::iter::once(start.clone()).collect();
2189 let mut frontier = vec![start.clone()];
2190 let mut reached = Vec::new();
2191 while let Some(current) = frontier.pop() {
2192 let next: Vec<crate::entity::EntityId> = match direction {
2193 PropagationDirection::Incoming => store
2194 .all_entities()
2195 .filter(|e| {
2196 e.relationships
2197 .iter()
2198 .any(|r| rel_types.iter().any(|n| n == &r.rel_type) && r.target == current)
2199 })
2200 .map(|e| e.id.clone())
2201 .collect(),
2202 PropagationDirection::Outgoing => store
2203 .get(¤t)
2204 .map(|e| {
2205 e.relationships
2206 .iter()
2207 .filter(|r| rel_types.iter().any(|n| n == &r.rel_type))
2208 .map(|r| r.target.clone())
2209 .collect()
2210 })
2211 .unwrap_or_default(),
2212 };
2213 for id in next {
2214 if seen.insert(id.clone()) {
2215 if store.get(&id).is_some_and(|e| !e.stub) {
2216 reached.push(id.clone());
2217 }
2218 frontier.push(id);
2219 }
2220 }
2221 }
2222 reached
2223}
2224
2225type ReverseIndex =
2230 std::collections::HashMap<crate::entity::EntityId, Vec<(String, crate::entity::EntityId)>>;
2231
2232fn build_reverse_index(store: &Store) -> ReverseIndex {
2233 let mut idx = ReverseIndex::default();
2234 for entity in store.all_entities() {
2235 for rel in &entity.relationships {
2236 idx.entry(rel.target.clone())
2237 .or_default()
2238 .push((rel.rel_type.clone(), entity.id.clone()));
2239 }
2240 }
2241 idx
2242}
2243
2244fn reaches_terminal(
2253 store: &Store,
2254 reverse: &ReverseIndex,
2255 start: &crate::entity::EntityId,
2256 ob: &memstead_schema::MustReach,
2257) -> bool {
2258 use memstead_schema::ReachDirection;
2259 let mut seen: std::collections::HashSet<crate::entity::EntityId> =
2260 std::iter::once(start.clone()).collect();
2261 let mut frontier = vec![start.clone()];
2262 let mut depth: u32 = 0;
2263 while !frontier.is_empty() {
2264 if let Some(max) = ob.max_depth
2265 && depth >= max
2266 {
2267 return false;
2268 }
2269 depth += 1;
2270 let mut next_frontier = Vec::new();
2271 for current in frontier {
2272 let next: Vec<crate::entity::EntityId> = match ob.direction {
2273 ReachDirection::Out => store
2274 .get(¤t)
2275 .map(|e| {
2276 e.relationships
2277 .iter()
2278 .filter(|r| ob.relationships.iter().any(|n| n == &r.rel_type))
2279 .map(|r| r.target.clone())
2280 .collect()
2281 })
2282 .unwrap_or_default(),
2283 ReachDirection::In => reverse
2284 .get(¤t)
2285 .map(|sources| {
2286 sources
2287 .iter()
2288 .filter(|(rel, _)| ob.relationships.iter().any(|n| n == rel))
2289 .map(|(_, src)| src.clone())
2290 .collect()
2291 })
2292 .unwrap_or_default(),
2293 };
2294 for id in next {
2295 if seen.insert(id.clone()) {
2296 if store.get(&id).is_some_and(|e| {
2297 !e.stub && ob.terminal_types.iter().any(|t| t == &e.entity_type)
2298 }) {
2299 return true;
2300 }
2301 next_frontier.push(id);
2302 }
2303 }
2304 }
2305 frontier = next_frontier;
2306 }
2307 false
2308}
2309
2310#[derive(Debug, Clone, serde::Serialize)]
2315pub struct SignalReport {
2316 pub id: crate::entity::EntityId,
2317 pub title: String,
2318 pub entity_type: String,
2319 pub mem: String,
2320 pub signals: Vec<super::signals::ComputedSignal>,
2322}
2323
2324impl SignalReport {
2325 pub fn has_warn(&self) -> bool {
2329 self.signals
2330 .iter()
2331 .any(|s| s.level == Some(memstead_schema::SignalLevel::Warn))
2332 }
2333}
2334
2335pub fn collect_signal_reports(
2339 store: &Store,
2340 mem_filter: Option<&str>,
2341 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
2342) -> Vec<SignalReport> {
2343 let mut out = Vec::new();
2344 for entity in store.all_entities() {
2345 if entity.stub {
2346 continue;
2347 }
2348 if let Some(v) = mem_filter
2349 && entity.mem != v
2350 {
2351 continue;
2352 }
2353 let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
2354 continue;
2355 };
2356 let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
2357 continue;
2358 };
2359 if td.signals.is_empty() {
2360 continue;
2361 }
2362 let above: Vec<super::signals::ComputedSignal> =
2363 super::signals::compute_signals(store, td, &entity.id)
2364 .into_iter()
2365 .filter(|s| s.level.is_some())
2366 .collect();
2367 if above.is_empty() {
2368 continue;
2369 }
2370 out.push(SignalReport {
2371 id: entity.id.clone(),
2372 title: entity.title.clone(),
2373 entity_type: entity.entity_type.clone(),
2374 mem: entity.mem.clone(),
2375 signals: above,
2376 });
2377 }
2378 out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
2379 out
2380}
2381
2382#[derive(Debug, Clone, serde::Serialize)]
2387pub struct SchemaFormatDefect {
2388 pub schema: String,
2389 pub type_name: String,
2390 pub section: String,
2391 pub problems: Vec<String>,
2392}
2393
2394pub fn collect_schema_format_defects(
2398 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
2399) -> Vec<SchemaFormatDefect> {
2400 let mut seen: std::collections::BTreeSet<String> = Default::default();
2401 let mut out = Vec::new();
2402 let mut schemas: Vec<&Arc<memstead_schema::Schema>> = mem_schemas.values().collect();
2403 schemas.sort_by_key(|s| (s.manifest.name.clone(), s.version.clone()));
2404 for schema in schemas {
2405 let schema_ref = format!("{}@{}", schema.manifest.name, schema.version);
2406 if !seen.insert(schema_ref.clone()) {
2407 continue;
2408 }
2409 for td in schema.types.values() {
2410 for section in &td.sections {
2411 if !section.format_problems.is_empty() {
2412 out.push(SchemaFormatDefect {
2413 schema: schema_ref.clone(),
2414 type_name: td.name.clone(),
2415 section: section.key.clone(),
2416 problems: section.format_problems.clone(),
2417 });
2418 }
2419 }
2420 }
2421 }
2422 out.sort_by(|a, b| {
2423 (&a.schema, &a.type_name, &a.section).cmp(&(&b.schema, &b.type_name, &b.section))
2424 });
2425 out
2426}
2427
2428#[derive(Debug, Clone, serde::Serialize)]
2436pub struct MissingRequiredOutgoingReport {
2437 pub id: crate::entity::EntityId,
2438 pub title: String,
2439 pub entity_type: String,
2440 pub mem: String,
2441 pub missing: Vec<super::MissingRequiredOutgoingBlock>,
2442}
2443
2444pub fn config_projection(
2457 engine: &crate::Engine,
2458 writable_mems: &[String],
2459 mutations: serde_json::Value,
2460 plugin: serde_json::Value,
2461) -> serde_json::Map<String, serde_json::Value> {
2462 let backend_by_mem: std::collections::HashMap<&str, (&'static str, bool)> = engine
2467 .mounts()
2468 .iter()
2469 .map(|m| {
2470 (
2471 m.mem.as_str(),
2472 (m.storage.backend_id(), m.storage.is_durable()),
2473 )
2474 })
2475 .collect();
2476 let mems_detail: Vec<serde_json::Value> = writable_mems
2477 .iter()
2478 .map(|name| {
2479 let origin = engine
2480 .mem_router()
2481 .origin_for_mem(name)
2482 .map(|o| o.kind())
2483 .unwrap_or("explicit");
2484 let mut entry = serde_json::Map::new();
2485 entry.insert("name".into(), serde_json::json!(name));
2486 entry.insert("origin".into(), serde_json::json!(origin));
2487 if let Some((storage, durable)) = backend_by_mem.get(name.as_str()).copied() {
2488 entry.insert("storage".into(), serde_json::json!(storage));
2489 entry.insert("durable".into(), serde_json::json!(durable));
2490 }
2491 let mut vcs_obj = serde_json::Map::new();
2492 if let Ok(gitdir) = engine.gitdir_for(name) {
2493 vcs_obj.insert("gitdir".into(), serde_json::json!(gitdir));
2494 }
2495 if let Ok(worktree) = engine.worktree_for(name) {
2496 vcs_obj.insert("worktree".into(), serde_json::json!(worktree));
2497 }
2498 if let Some(sha) = engine.mem_head_sha(name).ok().flatten() {
2499 vcs_obj.insert("head".into(), serde_json::json!(sha));
2500 }
2501 if !vcs_obj.is_empty() {
2502 entry.insert("vcs".into(), serde_json::Value::Object(vcs_obj));
2503 }
2504 if let Some(cfg) = engine.mem_config_for(name) {
2505 if let Some(title) = &cfg.title {
2509 entry.insert("title".into(), serde_json::json!(title));
2510 }
2511 if let Some(subject) = &cfg.subject {
2512 entry.insert("subject".into(), serde_json::json!(subject));
2513 }
2514 let guidance = serde_json::Map::from_iter(
2515 cfg.write_guidance
2516 .iter()
2517 .map(|(k, v)| (k.clone(), v.clone())),
2518 );
2519 entry.insert("write_guidance".into(), serde_json::Value::Object(guidance));
2520 let extra = serde_json::Map::from_iter(
2521 cfg.extra.iter().map(|(k, v)| (k.clone(), v.clone())),
2522 );
2523 entry.insert("extra".into(), serde_json::Value::Object(extra));
2524 }
2525 serde_json::Value::Object(entry)
2526 })
2527 .collect();
2528
2529 let mut out = serde_json::Map::new();
2530 out.insert("mems".into(), serde_json::json!(mems_detail));
2531 out.insert("mutations".into(), mutations);
2532 out.insert("plugin".into(), plugin);
2533 out
2534}
2535
2536pub fn config_projection_from_settings(
2542 settings: &crate::workspace::WorkspaceSettings,
2543) -> (serde_json::Value, serde_json::Value) {
2544 let mutations = serde_json::json!({ "require_notes": settings.mutations.require_notes });
2545 let plugin_map: serde_json::Map<String, serde_json::Value> = settings
2546 .plugin
2547 .iter()
2548 .map(|(k, v)| {
2549 (
2550 k.clone(),
2551 serde_json::to_value(v).unwrap_or(serde_json::Value::Null),
2552 )
2553 })
2554 .collect();
2555 (mutations, serde_json::Value::Object(plugin_map))
2556}
2557
2558pub(crate) fn section_heading_mismatch_issue(
2573 entity: &crate::entity::Entity,
2574 schema: &TypeDefinition,
2575 key: &str,
2576) -> Option<HealthIssue> {
2577 let def = schema.section(key)?;
2578 let derived = memstead_schema::derive_section_key(&def.heading);
2579 if derived == key {
2580 return None;
2581 }
2582 if !entity
2583 .raw_section_headings
2584 .iter()
2585 .any(|h| h == &def.heading)
2586 {
2587 return None;
2588 }
2589 let landing = match schema.catch_all_section() {
2590 Some(c) => format!(
2591 "the content was absorbed into catch-all section '{}'",
2592 c.key
2593 ),
2594 None => "the content is unreachable under any declared key".to_string(),
2595 };
2596 Some(HealthIssue {
2597 field: key.to_string(),
2598 code: super::HealthIssueCode::SectionHeadingMismatch,
2599 message: format!(
2600 "SECTION_HEADING_MISMATCH: section '{key}' is not missing — its content sits \
2601 under heading '{found}', which derives to '{derived}', not '{key}'; {landing}. \
2602 The schema's declared heading cannot round-trip to its key (expected a heading \
2603 that derives to '{key}'); fix the schema's heading/key pair — new installs of \
2604 such a schema are refused",
2605 found = def.heading,
2606 ),
2607 })
2608}
2609
2610pub fn entity_health(entity: &crate::entity::Entity, schema: &TypeDefinition) -> HealthReport {
2612 let mut issues = Vec::new();
2613
2614 for field in &schema.health_required_fields {
2615 if schema.section(field).is_some() {
2616 let content = entity.sections.get(field.as_str());
2617 if content.is_none_or(|c| c.trim().is_empty()) {
2618 if let Some(issue) = section_heading_mismatch_issue(entity, schema, field) {
2619 issues.push(issue);
2620 } else {
2621 issues.push(HealthIssue {
2622 field: field.clone(),
2623 code: super::HealthIssueCode::Missing,
2624 message: format!("required section '{field}' is empty"),
2625 });
2626 }
2627 }
2628 } else {
2629 let value = entity.metadata.get(field.as_str());
2630 if value.is_none() {
2631 issues.push(HealthIssue {
2632 field: field.clone(),
2633 code: super::HealthIssueCode::Missing,
2634 message: format!("required field '{field}' is missing"),
2635 });
2636 }
2637 }
2638 }
2639
2640 for s in schema.sections.iter().filter(|s| !s.catch_all) {
2644 if schema.health_required_fields.contains(&s.key) {
2645 continue; }
2647 let content = entity.sections.get(s.key.as_str());
2648 if content.is_none_or(|c| c.trim().is_empty())
2649 && let Some(issue) = section_heading_mismatch_issue(entity, schema, &s.key)
2650 {
2651 issues.push(issue);
2652 }
2653 }
2654
2655 let total = schema.health_required_fields.len();
2656 let score = if total > 0 {
2657 (total.saturating_sub(issues.len()) as f32) / (total as f32)
2658 } else {
2659 1.0
2660 };
2661
2662 HealthReport {
2663 id: entity.id.clone(),
2664 title: entity.title.clone(),
2665 score,
2666 issues,
2667 }
2668}
2669
2670fn days_since_epoch() -> u64 {
2681 #[cfg(target_arch = "wasm32")]
2682 {
2683 (js_sys::Date::now() / 1000.0) as u64 / 86400
2684 }
2685 #[cfg(not(target_arch = "wasm32"))]
2686 {
2687 std::time::SystemTime::now()
2688 .duration_since(std::time::UNIX_EPOCH)
2689 .unwrap_or_default()
2690 .as_secs()
2691 / 86400
2692 }
2693}
2694
2695fn parse_iso_to_days(date: &str) -> Option<u64> {
2698 let date_part = date.split('T').next()?;
2699 let parts: Vec<&str> = date_part.split('-').collect();
2700 if parts.len() != 3 {
2701 return None;
2702 }
2703 let year: u64 = parts[0].parse().ok()?;
2704 let month: u64 = parts[1].parse().ok()?;
2705 let day: u64 = parts[2].parse().ok()?;
2706 Some(ymd_to_days(year, month, day))
2707}
2708
2709fn ymd_to_days(year: u64, month: u64, day: u64) -> u64 {
2712 let y = if month <= 2 { year - 1 } else { year };
2714 let m = if month <= 2 { month + 9 } else { month - 3 };
2715 let era = y / 400;
2716 let yoe = y - era * 400;
2717 let doy = (153 * m + 2) / 5 + day - 1;
2718 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
2719 let days = era * 146097 + doe;
2720 days - 719468
2721}
2722
2723#[cfg(test)]
2724mod tests {
2725 use super::*;
2726 use crate::entity::{Entity, EntityId, MetadataValue};
2727 use crate::ops::DanglingLinkKind;
2728 use crate::store::Store;
2729 use indexmap::IndexMap;
2730 use memstead_schema::type_by_name;
2731
2732 #[test]
2737 fn bullet_entries_ignores_code() {
2738 let body = "- real-one\n- real-two\n\n```\n- fenced-ghost\n```\n\n - indented-ghost\n\nA `- span-ghost` sample.\n";
2739 let entries = bullet_entries(body);
2740 assert_eq!(
2741 entries,
2742 vec!["real-one".to_string(), "real-two".to_string()],
2743 "only prose bullets are legal values: {entries:?}"
2744 );
2745 }
2746
2747 #[test]
2751 fn bullet_entries_still_reads_prose_bullets_verbatim() {
2752 let entries = bullet_entries("- alpha\n * beta\n* `gamma`\n");
2753 assert_eq!(
2754 entries,
2755 vec![
2756 "alpha".to_string(),
2757 "beta".to_string(),
2758 "`gamma`".to_string()
2759 ]
2760 );
2761 }
2762
2763 #[test]
2769 fn declared_process_mem_pairs_and_missing_declaration_is_typed() {
2770 use crate::engine::test_helpers::folder_mount;
2771 let tmp = tempfile::TempDir::new().unwrap();
2772 let dest_dir = tmp.path().join("dest");
2773 let proc_dir = tmp.path().join("oddly-named-process");
2774 std::fs::create_dir_all(dest_dir.join(".memstead")).unwrap();
2775 std::fs::create_dir_all(&proc_dir).unwrap();
2776 std::fs::write(
2779 dest_dir.join(".memstead").join("config.json"),
2780 r#"{ "schema": "default@1.0.0", "processMem": "oddly-named-process" }"#,
2781 )
2782 .unwrap();
2783 let engine = crate::Engine::from_mounts(vec![
2784 (
2785 folder_mount("dest", dest_dir.clone()),
2786 Box::new(crate::storage::FilesystemMemWriter::new(dest_dir.clone()))
2787 as Box<dyn crate::backend::MemBackend>,
2788 ),
2789 (
2790 folder_mount("oddly-named-process", proc_dir.clone()),
2791 Box::new(crate::storage::FilesystemMemWriter::new(proc_dir))
2792 as Box<dyn crate::backend::MemBackend>,
2793 ),
2794 ])
2795 .unwrap();
2796
2797 let r = crate::ingest::resolve::resolve_process_mem(&engine, "dest", "dest-derived");
2799 assert!(r.declared && r.mounted);
2800 assert_eq!(r.mem, "oddly-named-process");
2801 let r =
2804 crate::ingest::resolve::resolve_process_mem(&engine, "oddly-named-process", "whatever");
2805 assert!(!r.declared && !r.mounted);
2806 assert_eq!(r.mem, "whatever");
2807
2808 let axis = health_open_questions_axis(&engine, Some("dest"));
2810 let process = &axis["dest"]["process"];
2811 assert_eq!(process[0]["process_mem"], "oddly-named-process", "{axis}");
2812 assert_eq!(process[0]["declared"], true, "{axis}");
2813 assert_eq!(process[0]["resolvable"], true, "{axis}");
2814
2815 std::fs::write(
2817 dest_dir.join(".memstead").join("config.json"),
2818 r#"{ "schema": "default@1.0.0", "processMem": "nowhere" }"#,
2819 )
2820 .unwrap();
2821 let engine2 = crate::Engine::from_mounts(vec![(
2822 folder_mount("dest", dest_dir.clone()),
2823 Box::new(crate::storage::FilesystemMemWriter::new(dest_dir))
2824 as Box<dyn crate::backend::MemBackend>,
2825 )])
2826 .unwrap();
2827 let axis = health_open_questions_axis(&engine2, Some("dest"));
2828 let process = &axis["dest"]["process"];
2829 assert_eq!(
2830 process[0]["finding"], "DECLARED_PROCESS_MEM_MISSING",
2831 "{axis}"
2832 );
2833 assert_eq!(process[0]["resolvable"], false, "{axis}");
2834 }
2835
2836 #[test]
2844 fn independence_gate_compares_identities_only() {
2845 use crate::engine::test_helpers::folder_mount;
2846 let tmp = tempfile::TempDir::new().unwrap();
2847 let dir = tmp.path().join("gate");
2848 std::fs::create_dir_all(&dir).unwrap();
2849 let mut engine = crate::Engine::from_mounts(vec![(
2850 folder_mount("gate", dir.clone()),
2851 Box::new(crate::storage::FilesystemMemWriter::new(dir))
2852 as Box<dyn crate::backend::MemBackend>,
2853 )])
2854 .unwrap();
2855 engine.set_workspace_root(tmp.path().to_path_buf());
2856
2857 let create = |engine: &mut crate::Engine, title: &str, identity: Option<&str>| {
2858 engine.set_identity(identity.map(str::to_string));
2859 engine
2860 .create_entity(
2861 crate::CreateEntityArgs {
2862 mem: "gate".to_string(),
2863 title: title.to_string(),
2864 entity_type: "spec".to_string(),
2865 sections: [
2866 ("identity".to_string(), "x".to_string()),
2867 ("purpose".to_string(), "y".to_string()),
2868 ]
2869 .into_iter()
2870 .collect(),
2871 metadata: Default::default(),
2872 relations: Vec::new(),
2873 anchors: Vec::new(),
2874 dry_run: false,
2875 },
2876 crate::vcs::Actor::Cli,
2877 None,
2878 None,
2879 )
2880 .unwrap()
2881 .id
2882 .0
2883 };
2884 let a = create(&mut engine, "Self Checked", Some("alice"));
2885 let b = create(&mut engine, "Independent", Some("alice"));
2886 let c = create(&mut engine, "No Author Identity", None);
2887
2888 let check = |engine: &mut crate::Engine,
2889 id: &str,
2890 identity: Option<&str>,
2891 actor: crate::vcs::Actor,
2892 client: Option<&crate::vcs::ClientId>| {
2893 engine.set_identity(identity.map(str::to_string));
2894 engine
2895 .record_check(
2896 "gate",
2897 id,
2898 crate::check::Verdict::Ok,
2899 crate::check::CheckKind::Verification,
2900 None,
2901 actor,
2902 client,
2903 )
2904 .unwrap();
2905 };
2906 let other_client = crate::vcs::ClientId {
2907 name: "claude-code".into(),
2908 version: "9.9".into(),
2909 };
2910 check(
2913 &mut engine,
2914 &a,
2915 Some("alice"),
2916 crate::vcs::Actor::Agent,
2917 Some(&other_client),
2918 );
2919 check(&mut engine, &b, Some("bob"), crate::vcs::Actor::Cli, None);
2922 check(&mut engine, &c, Some("carol"), crate::vcs::Actor::Cli, None);
2924
2925 let axis = health_checks_axis(&engine, Some("gate"));
2926 let gate = &axis["gate"]["independence"];
2927 assert_eq!(
2928 gate["self_checked"]["items"],
2929 serde_json::json!([a]),
2930 "{axis}"
2931 );
2932 assert_eq!(
2933 gate["confirmed_independent"]["items"],
2934 serde_json::json!([b]),
2935 "{axis}"
2936 );
2937 assert_eq!(
2938 gate["unconfirmable"]["items"],
2939 serde_json::json!([c]),
2940 "{axis}"
2941 );
2942
2943 let prov = engine.entity_provenance("gate", &a).unwrap();
2946 assert_eq!(
2947 prov.created_by.as_ref().and_then(|r| r.identity.as_deref()),
2948 Some("alice"),
2949 "created-by serves the declared identity"
2950 );
2951 assert_eq!(
2952 prov.last_check.as_ref().and_then(|r| r.identity.as_deref()),
2953 Some("alice"),
2954 "the check record serves the declared identity"
2955 );
2956 }
2957
2958 fn make_entity(name: &str, has_required: bool) -> Entity {
2959 let mut metadata = IndexMap::new();
2960 metadata.insert("level".into(), MetadataValue::String("M0".into()));
2961 metadata.insert("type".into(), MetadataValue::String("spec".into()));
2962 metadata.insert(
2963 "created_date".into(),
2964 MetadataValue::String("2026-01-15".into()),
2965 );
2966 metadata.insert(
2967 "last_modified".into(),
2968 MetadataValue::String("2026-04-12".into()),
2969 );
2970
2971 let mut sections = IndexMap::new();
2972 if has_required {
2973 sections.insert("identity".into(), "Has identity.".into());
2974 sections.insert("purpose".into(), "Has purpose.".into());
2975 }
2976
2977 Entity {
2978 id: EntityId::new("specs", name),
2979 title: name.into(),
2980 entity_type: "spec".into(),
2981 mem: "specs".into(),
2982 file_path: format!("{name}.md"),
2983 metadata,
2984 sections,
2985 relationships: Vec::new(),
2986 content_hash: String::new(),
2987 stub: false,
2988 stub_kind: None,
2989 heading_spans: std::collections::HashMap::new(),
2990 raw_section_headings: Vec::new(),
2991 }
2992 }
2993
2994 fn violating_type() -> std::sync::Arc<TypeDefinition> {
2998 let manifest = r#"name: debate
2999version: 0.1.0
3000description: sealed-violator fixture
3001when_to_use: health tests
3002types:
3003 - question
3004relationships:
3005 mode: strict
3006 definitions:
3007 - name: PART_OF
3008 description: hier
3009 default_weight: 3.0
3010 - name: _default
3011 description: fallback
3012 default_weight: 1.0
3013community:
3014 resolution: 1.0
3015 seed: 42
3016"#;
3017 let type_yaml = r#"name: question
3018description: t
3019when_to_use: tests
3020sections:
3021 - key: answers
3022 heading: Answers argued
3023 required: true
3024 search_weight: 10.0
3025 write_rules: []
3026 - key: notes
3027 heading: Notes
3028 required: false
3029 search_weight: 3.0
3030 catch_all: true
3031 write_rules: []
3032metadata_fields: []
3033title_weight: 100.0
3034text_fields:
3035 - answers
3036 - notes
3037hierarchy_relationship: PART_OF
3038no_self_loop_relationships: []
3039updatable_fields:
3040 - title
3041 - answers
3042 - notes
3043health_required_fields:
3044 - answers
3045staleness_threshold_days: 90
3046write_rules: []
3047"#;
3048 memstead_schema::load_schema_from_memory(
3049 manifest,
3050 &[("question".to_string(), type_yaml.to_string())],
3051 )
3052 .expect("violating schema still loads")
3053 .get_type("question")
3054 .expect("question type")
3055 }
3056
3057 #[test]
3063 fn health_distinguishes_heading_mismatch_from_missing_section() {
3064 let schema = violating_type();
3065
3066 let md = "---\ntype: question\n---\n# Q\n\n## Answers argued\n\nTwo answers.\n";
3068 let parsed = crate::entity::parser::parse_markdown(md, "q.md", &schema, "debate")
3069 .expect("parses")
3070 .entity;
3071 let report = entity_health(&parsed, &schema);
3072 let mismatch: Vec<_> = report
3073 .issues
3074 .iter()
3075 .filter(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch)
3076 .collect();
3077 assert_eq!(mismatch.len(), 1, "issues: {:?}", report.issues);
3078 let msg = &mismatch[0].message;
3079 assert!(
3080 msg.contains("'Answers argued'") && msg.contains("'answers_argued'"),
3081 "names found heading and derived key: {msg}"
3082 );
3083 assert!(
3084 msg.contains("'notes'"),
3085 "names the catch-all landing: {msg}"
3086 );
3087 assert!(
3088 !report.issues.iter().any(|i| i.message.contains("is empty")),
3089 "must not also report the section as missing: {:?}",
3090 report.issues
3091 );
3092
3093 let md_missing = "---\ntype: question\n---\n# Q2\n";
3095 let parsed_missing =
3096 crate::entity::parser::parse_markdown(md_missing, "q2.md", &schema, "debate")
3097 .expect("parses")
3098 .entity;
3099 let report_missing = entity_health(&parsed_missing, &schema);
3100 assert!(
3101 report_missing
3102 .issues
3103 .iter()
3104 .any(|i| i.code == super::super::HealthIssueCode::Missing
3105 && i.message == "required section 'answers' is empty"),
3106 "absent section keeps the missing report (structured MISSING code): {:?}",
3107 report_missing.issues
3108 );
3109 assert!(
3110 !report_missing
3111 .issues
3112 .iter()
3113 .any(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch),
3114 "no mismatch finding when the heading is not in the file"
3115 );
3116
3117 let ok_type = crate::entity::parser::parse_markdown(
3122 "---\ntype: question\n---\n# Q3\n\n## Answers\n\nfree.\n",
3123 "q3.md",
3124 &schema,
3125 "debate",
3126 )
3127 .expect("parses")
3128 .entity;
3129 let report_ok = entity_health(&ok_type, &schema);
3130 assert!(
3131 !report_ok
3132 .issues
3133 .iter()
3134 .any(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch),
3135 "mismatch fires only when the declared heading is present: {:?}",
3136 report_ok.issues
3137 );
3138 }
3139
3140 fn make_concept_entity(name: &str, with_definition: bool) -> Entity {
3141 let mut metadata = IndexMap::new();
3142 metadata.insert("type".into(), MetadataValue::String("concept".into()));
3143 metadata.insert("maturity".into(), MetadataValue::String("emerging".into()));
3144 metadata.insert(
3145 "abstraction_level".into(),
3146 MetadataValue::String("concrete".into()),
3147 );
3148 metadata.insert(
3149 "created_date".into(),
3150 MetadataValue::String("2026-01-15".into()),
3151 );
3152 metadata.insert(
3153 "last_modified".into(),
3154 MetadataValue::String("2026-04-12".into()),
3155 );
3156
3157 let mut sections = IndexMap::new();
3158 if with_definition {
3159 sections.insert("definition".into(), "Precise definition.".into());
3160 }
3161 sections.insert("explanation".into(), "How it works.".into());
3162
3163 Entity {
3164 id: EntityId::new("concepts", name),
3165 title: name.into(),
3166 entity_type: "concept".into(),
3167 mem: "concepts".into(),
3168 file_path: format!("{name}.md"),
3169 metadata,
3170 sections,
3171 relationships: Vec::new(),
3172 content_hash: String::new(),
3173 stub: false,
3174 stub_kind: None,
3175 heading_spans: std::collections::HashMap::new(),
3176 raw_section_headings: Vec::new(),
3177 }
3178 }
3179
3180 #[test]
3181 fn health_concept_missing_definition_reports_definition_field() {
3182 let schema = &type_by_name("concept").unwrap();
3183 let entity = make_concept_entity("clarity", false);
3184 let report = entity_health(&entity, schema);
3185
3186 assert!(report.issues.iter().any(|i| i.field == "definition"));
3189 assert!(!report.issues.iter().any(|i| i.field == "identity"));
3190 assert!(!report.issues.iter().any(|i| i.field == "purpose"));
3191 assert!(report.score < 1.0);
3192
3193 let healthy = make_concept_entity("clarity-ok", true);
3195 let healthy_report = entity_health(&healthy, schema);
3196 assert!(
3197 !healthy_report
3198 .issues
3199 .iter()
3200 .any(|i| i.field == "definition")
3201 );
3202 }
3203
3204 #[test]
3205 fn health_detects_missing_sections() {
3206 let schema = &type_by_name("spec").unwrap();
3207 let entity = make_entity("incomplete", false);
3208 let report = entity_health(&entity, schema);
3209 assert!(!report.issues.is_empty());
3210 assert!(report.score < 1.0);
3211 }
3212
3213 #[test]
3214 fn health_clean_entity() {
3215 let schema = &type_by_name("spec").unwrap();
3216 let entity = make_entity("complete", true);
3217 let report = entity_health(&entity, schema);
3218 let section_issues: Vec<_> = report
3220 .issues
3221 .iter()
3222 .filter(|i| i.field == "identity" || i.field == "purpose")
3223 .collect();
3224 assert!(section_issues.is_empty());
3225 }
3226
3227 #[test]
3228 fn health_summary_counts() {
3229 let mut store = Store::new();
3230 let e1 = make_entity("healthy", true);
3231 let e2 = make_entity("unhealthy", false);
3232 store.upsert(e1.id.clone(), e1);
3233 store.upsert(e2.id.clone(), e2);
3234
3235 let schema = &type_by_name("spec").unwrap();
3236 let summary = compute_health(&store, schema, &HashMap::new(), None);
3237 assert_eq!(summary.orphan_count, 2); assert_eq!(summary.stub_count, 0);
3239 }
3240
3241 #[test]
3242 fn health_surfaces_invalid_rel_shape_on_existing_edges() {
3243 use crate::entity::Relationship;
3249 use memstead_schema::SchemaRegistry;
3250
3251 let registry = SchemaRegistry::builtin();
3252 let software = registry
3253 .get("software", &semver::Version::new(0, 2, 0))
3254 .expect("software schema ships as a builtin");
3255
3256 let mut store = Store::new();
3257 let mut bad = make_entity("bad-owns-source", true);
3260 bad.entity_type = "spec".into();
3261 bad.metadata
3262 .insert("level".into(), MetadataValue::String("M0".into()));
3263 bad.metadata
3264 .insert("stability".into(), MetadataValue::String("evolving".into()));
3265 bad.relationships.push(Relationship {
3266 rel_type: "OWNS".into(),
3267 target: EntityId::new("specs", "victim"),
3268 description: None,
3269 });
3270 let mut victim = make_entity("victim", true);
3271 victim.entity_type = "spec".into();
3272 store.upsert(bad.id.clone(), bad);
3273 store.upsert(victim.id.clone(), victim);
3274
3275 let mut mem_schemas = HashMap::new();
3276 mem_schemas.insert("specs".to_string(), software);
3277
3278 let schema = &type_by_name("spec").unwrap();
3279 let summary = compute_health(&store, schema, &mem_schemas, None);
3280 let report = summary
3281 .missing_fields
3282 .iter()
3283 .find(|r| r.id.as_ref() == "specs--bad-owns-source")
3284 .expect("shape-violating entity must surface");
3285 let issue = report
3286 .issues
3287 .iter()
3288 .find(|i| i.field == "relationships" && i.message.contains("INVALID_REL_SHAPE"))
3289 .expect("shape violation must produce an INVALID_REL_SHAPE issue");
3290 assert!(
3291 issue.message.contains("OWNS"),
3292 "issue must name the offending rel_type: {}",
3293 issue.message
3294 );
3295 assert!(
3296 issue.message.contains("spec"),
3297 "issue must name the actual source type: {}",
3298 issue.message
3299 );
3300 assert!(
3301 issue.message.contains("actor"),
3302 "issue must name the allowed source type: {}",
3303 issue.message
3304 );
3305 assert!(
3306 issue.message.contains("remove=true"),
3307 "issue must surface the recovery path: {}",
3308 issue.message
3309 );
3310 }
3311
3312 #[test]
3313 fn health_does_not_flag_shape_compliant_edges() {
3314 use crate::entity::Relationship;
3317 use memstead_schema::SchemaRegistry;
3318
3319 let registry = SchemaRegistry::builtin();
3320 let software = registry
3321 .get("software", &semver::Version::new(0, 2, 0))
3322 .expect("software schema ships as a builtin");
3323
3324 let mut store = Store::new();
3325 let mut owner = make_entity("owner", true);
3326 owner.entity_type = "actor".into();
3327 owner
3328 .metadata
3329 .insert("kind".into(), MetadataValue::String("team".into()));
3330 owner
3331 .metadata
3332 .insert("active".into(), MetadataValue::Bool(true));
3333 owner
3334 .metadata
3335 .insert("handle".into(), MetadataValue::String("owner".into()));
3336 owner.relationships.push(Relationship {
3337 rel_type: "OWNS".into(),
3338 target: EntityId::new("specs", "owned"),
3339 description: None,
3340 });
3341 let mut owned = make_entity("owned", true);
3342 owned.entity_type = "spec".into();
3343 store.upsert(owner.id.clone(), owner);
3344 store.upsert(owned.id.clone(), owned);
3345
3346 let mut mem_schemas = HashMap::new();
3347 mem_schemas.insert("specs".to_string(), software);
3348
3349 let schema = &type_by_name("spec").unwrap();
3350 let summary = compute_health(&store, schema, &mem_schemas, None);
3351 let shape_issue = summary
3352 .missing_fields
3353 .iter()
3354 .flat_map(|r| r.issues.iter())
3355 .find(|i| i.message.contains("INVALID_REL_SHAPE"));
3356 assert!(
3357 shape_issue.is_none(),
3358 "shape-compliant edge must not surface a shape issue, got: {shape_issue:?}"
3359 );
3360 }
3361
3362 #[test]
3363 fn health_warns_on_undeclared_relationship_in_existing_entity() {
3364 use crate::entity::Relationship;
3365 use memstead_schema::Schema;
3366
3367 let mut store = Store::new();
3368 let mut entity = make_entity("with-bad-rel", true);
3369 entity.relationships.push(Relationship {
3375 rel_type: "CONJURES".into(),
3376 target: EntityId::new("specs", "unknown"),
3377 description: None,
3378 });
3379 store.upsert(entity.id.clone(), entity);
3380
3381 let mut mem_schemas = HashMap::new();
3382 mem_schemas.insert("specs".to_string(), Schema::builtin_default());
3383
3384 let schema = &type_by_name("spec").unwrap();
3385 let summary = compute_health(&store, schema, &mem_schemas, None);
3386 let report = summary
3387 .missing_fields
3388 .iter()
3389 .find(|r| r.id.as_ref() == "specs--with-bad-rel")
3390 .expect("entity must surface in missing_fields");
3391 let rel_issue = report
3392 .issues
3393 .iter()
3394 .find(|i| i.field == "relationships")
3395 .expect("undeclared relationship must produce an issue");
3396 assert!(
3397 rel_issue.message.contains("CONJURES"),
3398 "issue message must name the offending relationship: {}",
3399 rel_issue.message
3400 );
3401 assert!(
3402 rel_issue.message.contains("default@1.0.0"),
3403 "issue must name the schema pin: {}",
3404 rel_issue.message
3405 );
3406 }
3407
3408 fn make_entity_with_body(name: &str, section_key: &str, body: &str) -> Entity {
3415 let mut entity = make_entity(name, true);
3416 entity.sections.insert(section_key.into(), body.to_string());
3417 entity
3418 }
3419
3420 #[test]
3421 fn dangling_link_detected_after_delete() {
3422 use crate::entity::store_builder::make_stub;
3423
3424 let mut store = Store::new();
3425 let a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
3426 store.upsert(a.id.clone(), a.clone());
3427
3428 let b_id = EntityId::new("specs", "b");
3431 store.upsert(b_id.clone(), make_stub(b_id.clone()));
3432
3433 let dangling = super::collect_dangling_links(&store, None);
3434 assert_eq!(dangling.len(), 1, "exactly one dangling link expected");
3435 let d = &dangling[0];
3436 assert_eq!(d.from, a.id);
3437 assert_eq!(d.target_id, b_id);
3438 assert_eq!(d.target_path, "b");
3439 assert_eq!(d.section.as_deref(), Some("purpose"));
3440 assert_eq!(d.kind, DanglingLinkKind::LinkTargetMissing);
3441 }
3442
3443 #[test]
3451 fn the_three_dangling_conditions_are_discriminated() {
3452 use crate::entity::store_builder::make_stub;
3453
3454 let mut store = Store::new();
3455
3456 let gone = make_entity_with_body("gone-link", "purpose", "See [[absent]].");
3458 store.upsert(gone.id.clone(), gone.clone());
3459 let absent = EntityId::new("specs", "absent");
3460 store.upsert(absent.clone(), make_stub(absent.clone()));
3461
3462 let written = make_entity("written", true);
3465 store.upsert(written.id.clone(), written.clone());
3466 let unrelated = make_entity_with_body("unrelated-link", "purpose", "See [[written]].");
3467 store.upsert(unrelated.id.clone(), unrelated.clone());
3468
3469 let mut rel_source = make_entity("rel-source", true);
3473 rel_source.relationships.push(crate::entity::Relationship {
3474 rel_type: "DEPENDS_ON".to_string(),
3475 target: EntityId::new("specs", "vanished"),
3476 description: None,
3477 });
3478 store.upsert(rel_source.id.clone(), rel_source.clone());
3479
3480 let found = super::collect_dangling_links(&store, None);
3481 let kind_of = |from: &str| {
3482 found
3483 .iter()
3484 .find(|d| d.from.path() == from)
3485 .unwrap_or_else(|| panic!("no dangling link from {from}: {found:?}"))
3486 .kind
3487 };
3488 assert_eq!(kind_of("gone-link"), DanglingLinkKind::LinkTargetMissing);
3489 assert_eq!(kind_of("unrelated-link"), DanglingLinkKind::LinkNotRelated);
3490 assert_eq!(
3491 kind_of("rel-source"),
3492 DanglingLinkKind::RelationTargetMissing
3493 );
3494
3495 let codes: std::collections::BTreeSet<_> = found.iter().map(|d| d.kind.code()).collect();
3498 let repairs: std::collections::BTreeSet<_> =
3499 found.iter().map(|d| d.kind.repair()).collect();
3500 assert_eq!(codes.len(), 3, "{found:?}");
3501 assert_eq!(repairs.len(), 3, "{found:?}");
3502 }
3503
3504 #[test]
3511 fn a_relationship_row_pointing_at_a_stub_is_still_not_flagged() {
3512 use crate::entity::store_builder::make_stub;
3513
3514 let mut store = Store::new();
3515 let stub_id = EntityId::new("specs", "forward");
3516 store.upsert(stub_id.clone(), make_stub(stub_id.clone()));
3517
3518 let mut source = make_entity("forward-ref", true);
3519 source.relationships.push(crate::entity::Relationship {
3520 rel_type: "DEPENDS_ON".to_string(),
3521 target: stub_id.clone(),
3522 description: None,
3523 });
3524 store.upsert(source.id.clone(), source.clone());
3525
3526 assert!(
3527 super::collect_dangling_links(&store, None).is_empty(),
3528 "a forward reference through the relationships table stays unflagged"
3529 );
3530
3531 let body = make_entity_with_body("body-ref", "purpose", "See [[forward]].");
3534 store.upsert(body.id.clone(), body.clone());
3535 let found = super::collect_dangling_links(&store, None);
3536 assert_eq!(found.len(), 1, "{found:?}");
3537 assert_eq!(found[0].from, body.id);
3538 assert_eq!(found[0].kind, DanglingLinkKind::LinkTargetMissing);
3539 }
3540
3541 #[test]
3547 fn dangling_links_and_stubs_serve_in_deterministic_order() {
3548 use crate::entity::store_builder::make_stub;
3549
3550 let build = || {
3551 let mut store = Store::new();
3552 for name in ["zeta", "alpha", "mid"] {
3554 let e = make_entity_with_body(
3555 name,
3556 "purpose",
3557 &format!("See [[gone-{name}]] and [[lost-{name}]]."),
3558 );
3559 store.upsert(e.id.clone(), e);
3560 }
3561 for name in ["zeta", "alpha", "mid"] {
3562 for pre in ["gone", "lost"] {
3563 let id = EntityId::new("specs", &format!("{pre}-{name}"));
3564 store.upsert(id.clone(), make_stub(id));
3565 }
3566 }
3567 store
3568 };
3569
3570 let store_a = build();
3571 let store_b = build();
3572
3573 let key =
3574 |d: &super::DanglingLink| (d.from.0.clone(), d.target_id.0.clone(), d.section.clone());
3575 let dangling_a: Vec<_> = super::collect_dangling_links(&store_a, None)
3576 .iter()
3577 .map(key)
3578 .collect();
3579 let dangling_b: Vec<_> = super::collect_dangling_links(&store_b, None)
3580 .iter()
3581 .map(key)
3582 .collect();
3583 assert_eq!(dangling_a, dangling_b, "identical stores, identical order");
3584 let mut sorted = dangling_a.clone();
3585 sorted.sort();
3586 assert_eq!(dangling_a, sorted, "served pre-sorted by (from, target)");
3587 assert_eq!(dangling_a.len(), 6);
3588
3589 let stub_ids = |s: &Store| -> Vec<String> {
3590 crate::graph::query::find_stubs(s)
3591 .into_iter()
3592 .map(|(id, _)| id.0)
3593 .collect()
3594 };
3595 let stubs_a = stub_ids(&store_a);
3596 assert_eq!(stubs_a, stub_ids(&store_b), "stub order is deterministic");
3597 let mut sorted = stubs_a.clone();
3598 sorted.sort();
3599 assert_eq!(stubs_a, sorted, "stubs served pre-sorted by id");
3600 assert_eq!(stubs_a.len(), 6);
3601 }
3602
3603 #[test]
3604 fn dangling_link_does_not_flag_stub_target_of_explicit_relationship() {
3605 use crate::entity::Relationship;
3606 use crate::entity::store_builder::make_stub;
3607
3608 let mut store = Store::new();
3609 let mut a = make_entity("a", true);
3612 let b_id = EntityId::new("specs", "b");
3613 a.relationships.push(Relationship {
3614 rel_type: "REFERENCES".into(),
3615 target: b_id.clone(),
3616 description: None,
3617 });
3618 store.upsert(a.id.clone(), a);
3619 store.upsert(b_id.clone(), make_stub(b_id));
3620
3621 let dangling = super::collect_dangling_links(&store, None);
3622 assert!(
3623 dangling.is_empty(),
3624 "explicit relationships to stubs are valid by design \
3625 (stubs are first-class placeholders); only inline-body \
3626 wiki-links to stubs must surface"
3627 );
3628 }
3629
3630 #[test]
3631 fn dangling_link_does_not_flag_real_reference() {
3632 use crate::entity::Relationship;
3633
3634 let mut store = Store::new();
3635 let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
3636 a.relationships.push(Relationship {
3638 rel_type: "REFERENCES".into(),
3639 target: EntityId::new("specs", "b"),
3640 description: None,
3641 });
3642 let b = make_entity("b", true);
3643 store.upsert(a.id.clone(), a);
3644 store.upsert(b.id.clone(), b);
3645
3646 let dangling = super::collect_dangling_links(&store, None);
3647 assert!(
3648 dangling.is_empty(),
3649 "real reference backed by relation — not dangling, not alias-orphan"
3650 );
3651 }
3652
3653 #[test]
3658 fn dangling_link_relationship_section_target_absent() {
3659 use crate::entity::Relationship;
3660
3661 let mut store = Store::new();
3662 let mut a = make_entity("a", true);
3663 a.relationships.push(Relationship {
3666 rel_type: "DEPENDS_ON".into(),
3667 target: EntityId::new("specs", "gone"),
3668 description: None,
3669 });
3670 store.upsert(a.id.clone(), a.clone());
3671
3672 let dangling = super::collect_dangling_links(&store, None);
3673 assert_eq!(
3674 dangling.len(),
3675 1,
3676 "exactly one relationship-section dangler"
3677 );
3678 let d = &dangling[0];
3679 assert_eq!(d.from, a.id);
3680 assert_eq!(d.target_id, EntityId::new("specs", "gone"));
3681 assert!(
3682 d.section.is_none(),
3683 "relationship-section danglers ship `section: None`, got {:?}",
3684 d.section
3685 );
3686 }
3687
3688 #[test]
3693 fn dangling_link_relationship_section_stub_target_not_flagged() {
3694 use crate::entity::Relationship;
3695 use crate::entity::store_builder::make_stub;
3696
3697 let mut store = Store::new();
3698 let mut a = make_entity("a", true);
3699 let b_id = EntityId::new("specs", "b");
3700 a.relationships.push(Relationship {
3701 rel_type: "DEPENDS_ON".into(),
3702 target: b_id.clone(),
3703 description: None,
3704 });
3705 store.upsert(a.id.clone(), a);
3706 store.upsert(b_id.clone(), make_stub(b_id));
3707
3708 let dangling = super::collect_dangling_links(&store, None);
3709 assert!(
3710 dangling.is_empty(),
3711 "relationship targets that resolve to stubs are forward-references, not corruption"
3712 );
3713 }
3714
3715 #[test]
3722 fn dangling_link_dedups_across_body_and_relations() {
3723 use crate::entity::Relationship;
3724 use crate::entity::store_builder::make_stub;
3725
3726 let mut store = Store::new();
3727 let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
3728 let b_id = EntityId::new("specs", "b");
3729 a.relationships.push(Relationship {
3730 rel_type: "REFERENCES".into(),
3731 target: b_id.clone(),
3732 description: None,
3733 });
3734 store.upsert(a.id.clone(), a.clone());
3735 store.upsert(b_id.clone(), make_stub(b_id.clone()));
3736
3737 let dangling = super::collect_dangling_links(&store, None);
3738 assert_eq!(
3739 dangling.len(),
3740 1,
3741 "body + relations both pointing at the same stub should dedup"
3742 );
3743 assert!(dangling[0].section.is_some(), "body axis wins the dedup");
3746 }
3747
3748 #[test]
3749 fn dangling_links_scope_to_mem_filter() {
3750 use crate::entity::store_builder::make_stub;
3751
3752 let mut store = Store::new();
3753
3754 let a = make_entity_with_body("a", "purpose", "Refers to [[gone]] in prose.");
3756 store.upsert(a.id.clone(), a);
3757 let gone_specs = EntityId::new("specs", "gone");
3758 store.upsert(gone_specs.clone(), make_stub(gone_specs));
3759
3760 let mut x = make_entity("x", true);
3762 x.id = EntityId::new("web", "x");
3763 x.mem = "web".into();
3764 x.file_path = "x.md".into();
3765 x.sections
3766 .insert("purpose".into(), "Refers to [[gone]] in prose.".into());
3767 store.upsert(x.id.clone(), x);
3768 let gone_web = EntityId::new("web", "gone");
3769 store.upsert(gone_web.clone(), make_stub(gone_web));
3770
3771 let all = super::collect_dangling_links(&store, None);
3772 assert_eq!(all.len(), 2);
3773
3774 let specs_only = super::collect_dangling_links(&store, Some("specs"));
3775 assert_eq!(specs_only.len(), 1);
3776 assert_eq!(specs_only[0].from.mem(), "specs");
3777
3778 let web_only = super::collect_dangling_links(&store, Some("web"));
3779 assert_eq!(web_only.len(), 1);
3780 assert_eq!(web_only[0].from.mem(), "web");
3781 }
3782
3783 #[test]
3784 fn parse_iso_date() {
3785 let days = parse_iso_to_days("2026-04-12").unwrap();
3786 assert!(days > 0);
3787
3788 let days_with_time = parse_iso_to_days("2026-04-12T10:00:00Z").unwrap();
3789 assert_eq!(days, days_with_time);
3790 }
3791
3792 #[test]
3793 fn ymd_roundtrip() {
3794 let days = ymd_to_days(2026, 1, 1);
3796 assert!(days > 20000); }
3798
3799 fn make_entity_with_tags(name: &str, mem: &str, entity_type: &str, tags: &str) -> Entity {
3804 let mut e = make_entity(name, true);
3805 e.id = EntityId::new(mem, name);
3806 e.mem = mem.into();
3807 e.entity_type = entity_type.into();
3808 e.metadata
3809 .insert("tags".into(), MetadataValue::String(tags.into()));
3810 e
3811 }
3812
3813 fn make_entity_no_tags(name: &str) -> Entity {
3814 make_entity(name, true)
3815 }
3816
3817 #[test]
3818 fn tag_distribution_aggregates_across_entities() {
3819 let mut store = Store::new();
3820 let a = make_entity_with_tags("a", "specs", "spec", "decision, plan");
3821 let b = make_entity_with_tags("b", "specs", "spec", "decision, plan");
3822 let c = make_entity_with_tags("c", "specs", "spec", "plan");
3823 store.upsert(a.id.clone(), a);
3824 store.upsert(b.id.clone(), b);
3825 store.upsert(c.id.clone(), c);
3826
3827 let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
3828 assert_eq!(dist.len(), 2);
3829 assert_eq!(dist[0].tag, "plan");
3830 assert_eq!(dist[0].count, 3);
3831 assert_eq!(dist[0].by_entity_type.get("spec"), Some(&3));
3832 assert_eq!(dist[1].tag, "decision");
3833 assert_eq!(dist[1].count, 2);
3834 assert_eq!(untagged.total, 0);
3835 }
3836
3837 #[test]
3838 fn tag_distribution_case_sensitive() {
3839 let mut store = Store::new();
3840 let a = make_entity_with_tags("a", "specs", "spec", "Decision");
3841 let b = make_entity_with_tags("b", "specs", "spec", "decision");
3842 store.upsert(a.id.clone(), a);
3843 store.upsert(b.id.clone(), b);
3844
3845 let (dist, folded, _untagged) = collect_tag_distribution(&store, None, 10);
3846 assert_eq!(dist.len(), 2, "`decision` and `Decision` stay distinct");
3847 let tags: std::collections::HashSet<&str> = dist.iter().map(|t| t.tag.as_str()).collect();
3848 assert!(tags.contains("decision"));
3849 assert!(tags.contains("Decision"));
3850
3851 assert_eq!(folded.len(), 1);
3853 assert_eq!(folded[0].canonical, "decision");
3854 assert_eq!(folded[0].total, 2);
3855 assert_eq!(folded[0].variants.len(), 2);
3856 }
3857
3858 #[test]
3859 fn untagged_entities_counts_missing_and_empty() {
3860 let mut store = Store::new();
3861 let a = make_entity_no_tags("a"); let b = make_entity_with_tags("b", "specs", "spec", "");
3863 let c = make_entity_with_tags("c", "specs", "spec", " , , ");
3864 store.upsert(a.id.clone(), a);
3865 store.upsert(b.id.clone(), b);
3866 store.upsert(c.id.clone(), c);
3867
3868 let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
3869 assert!(dist.is_empty(), "no effective tags → empty distribution");
3870 assert_eq!(untagged.total, 3);
3871 assert_eq!(untagged.by_entity_type.get("spec"), Some(&3));
3872 }
3873
3874 #[test]
3875 fn tag_distribution_respects_mem_filter() {
3876 let mut store = Store::new();
3877 let a = make_entity_with_tags("a", "specs", "spec", "decision");
3878 let b = make_entity_with_tags("b", "memos", "memo", "observation");
3879 let c = make_entity_no_tags("c");
3880 store.upsert(a.id.clone(), a);
3881 store.upsert(b.id.clone(), b);
3882 store.upsert(c.id.clone(), c);
3883
3884 let (dist, _folded, untagged) = collect_tag_distribution(&store, Some("memos"), 10);
3885 assert_eq!(dist.len(), 1);
3886 assert_eq!(dist[0].tag, "observation");
3887 assert_eq!(untagged.total, 0, "untagged scoped to filter mem");
3888 }
3889
3890 #[test]
3891 fn tag_distribution_respects_limit() {
3892 let mut store = Store::new();
3893 for (name, tag) in [
3894 ("a", "t-alpha"),
3895 ("b", "t-beta"),
3896 ("c", "t-gamma"),
3897 ("d", "t-delta"),
3898 ("e", "t-epsilon"),
3899 ] {
3900 let e = make_entity_with_tags(name, "specs", "spec", tag);
3901 store.upsert(e.id.clone(), e);
3902 }
3903
3904 let (dist, _folded, _untagged) = collect_tag_distribution(&store, None, 3);
3905 assert_eq!(dist.len(), 3);
3906 assert_eq!(dist[0].tag, "t-alpha");
3909 assert_eq!(dist[1].tag, "t-beta");
3910 assert_eq!(dist[2].tag, "t-delta");
3911 }
3912
3913 fn required_outgoing_fixture_schema() -> std::sync::Arc<memstead_schema::Schema> {
3920 let manifest = r#"name: tests-ro-health
3921version: 0.1.0
3922description: required_outgoing health test schema
3923when_to_use: tests
3924types:
3925 - decision
3926 - note
3927relationships:
3928 mode: strict
3929 definitions:
3930 - name: PART_OF
3931 description: Hier
3932 default_weight: 3.0
3933 acyclic: true
3934 - name: CHOSEN
3935 description: ch
3936 default_weight: 3.0
3937 - name: REJECTED
3938 description: rj
3939 default_weight: 2.0
3940 - name: REFERENCES
3941 description: ref
3942 default_weight: 0.5
3943 - name: _default
3944 description: Fallback
3945 default_weight: 1.0
3946community:
3947 resolution: 1.0
3948 seed: 42
3949"#;
3950 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";
3951 let decision_yaml = format!(
3952 "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",
3953 );
3954 let note_yaml = format!("name: note\ndescription: t\nwhen_to_use: Here\n{body_section}",);
3955 std::sync::Arc::new(
3956 memstead_schema::load_schema_from_memory(
3957 manifest,
3958 &[
3959 ("decision".to_string(), decision_yaml),
3960 ("note".to_string(), note_yaml),
3961 ],
3962 )
3963 .expect("ro fixture schema must parse"),
3964 )
3965 }
3966
3967 fn make_typed_entity(mem: &str, slug: &str, entity_type: &str) -> crate::entity::Entity {
3968 use crate::entity::MetadataValue;
3969 let mut metadata = IndexMap::new();
3970 metadata.insert("type".into(), MetadataValue::String(entity_type.into()));
3971 let mut sections = IndexMap::new();
3972 sections.insert("body".into(), "Body.".into());
3973 crate::entity::Entity {
3974 id: EntityId::new(mem, slug),
3975 title: slug.to_string(),
3976 entity_type: entity_type.into(),
3977 mem: mem.into(),
3978 file_path: format!("{slug}.md"),
3979 metadata,
3980 sections,
3981 relationships: Vec::new(),
3982 content_hash: String::new(),
3983 stub: false,
3984 stub_kind: None,
3985 heading_spans: std::collections::HashMap::new(),
3986 raw_section_headings: Vec::new(),
3987 }
3988 }
3989
3990 #[test]
3991 fn missing_required_outgoing_collects_violators_only() {
3992 let schema = required_outgoing_fixture_schema();
3993 let mut store = Store::new();
3994 let mut violator = make_typed_entity("plan", "stalled", "decision");
3997 let mut satisfied = make_typed_entity("plan", "wired", "decision");
3998 let opt_a = make_typed_entity("plan", "a", "note");
3999 let opt_b = make_typed_entity("plan", "b", "note");
4000 let happy_note = make_typed_entity("plan", "side", "note");
4001 satisfied.relationships.push(crate::entity::Relationship {
4002 rel_type: "CHOSEN".into(),
4003 target: opt_a.id.clone(),
4004 description: None,
4005 });
4006 satisfied.relationships.push(crate::entity::Relationship {
4007 rel_type: "REJECTED".into(),
4008 target: opt_b.id.clone(),
4009 description: None,
4010 });
4011 for e in [violator.clone(), satisfied, opt_a, opt_b, happy_note] {
4012 store.upsert(e.id.clone(), e);
4013 }
4014
4015 let mut mem_schemas = HashMap::new();
4016 mem_schemas.insert("plan".to_string(), schema);
4017
4018 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
4019 assert_eq!(
4020 reports.len(),
4021 1,
4022 "exactly one violator (the empty decision); got {reports:?}"
4023 );
4024 let r = &reports[0];
4025 assert_eq!(r.id, violator.id);
4026 assert_eq!(r.entity_type, "decision");
4027 assert_eq!(r.mem, "plan");
4028 assert_eq!(r.missing.len(), 2);
4029 let names: Vec<&str> = r
4030 .missing
4031 .iter()
4032 .flat_map(|b| b.relationships.iter().map(String::as_str))
4033 .collect();
4034 assert!(names.contains(&"CHOSEN"));
4035 assert!(names.contains(&"REJECTED"));
4036
4037 violator.relationships.push(crate::entity::Relationship {
4039 rel_type: "CHOSEN".into(),
4040 target: EntityId::new("plan", "x"),
4041 description: None,
4042 });
4043 }
4044
4045 #[test]
4046 fn missing_required_outgoing_respects_mem_filter() {
4047 let schema = required_outgoing_fixture_schema();
4050 let mut store = Store::new();
4051 let v_a = make_typed_entity("alpha", "stalled", "decision");
4052 let v_b = make_typed_entity("beta", "stalled", "decision");
4053 store.upsert(v_a.id.clone(), v_a);
4054 store.upsert(v_b.id.clone(), v_b.clone());
4055
4056 let mut mem_schemas = HashMap::new();
4057 mem_schemas.insert("alpha".to_string(), schema.clone());
4058 mem_schemas.insert("beta".to_string(), schema);
4059
4060 let alpha_only = collect_missing_required_outgoing(&store, Some("alpha"), &mem_schemas);
4061 assert_eq!(alpha_only.len(), 1);
4062 assert_eq!(alpha_only[0].mem, "alpha");
4063
4064 let both = collect_missing_required_outgoing(&store, None, &mem_schemas);
4065 assert_eq!(both.len(), 2);
4066 }
4067
4068 #[test]
4069 fn missing_required_outgoing_skips_stubs_and_unschemaed_mems() {
4070 let schema = required_outgoing_fixture_schema();
4073 let mut store = Store::new();
4074 let mut stub = make_typed_entity("plan", "ghost", "");
4075 stub.stub = true;
4076 stub.entity_type = String::new();
4077 let other = make_typed_entity("uncharted", "lonely", "decision");
4078 store.upsert(stub.id.clone(), stub);
4079 store.upsert(other.id.clone(), other);
4080
4081 let mut mem_schemas = HashMap::new();
4082 mem_schemas.insert("plan".to_string(), schema);
4083
4084 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
4085 assert!(
4086 reports.is_empty(),
4087 "stub (no schema lookup) and unschemaed mem must be skipped; got {reports:?}",
4088 );
4089 }
4090
4091 #[test]
4096 fn missing_required_outgoing_conditional_blocks_arm_on_trigger() {
4097 use crate::entity::MetadataValue;
4098 let manifest = r#"name: tests-ro-cond
4099version: 0.1.0
4100description: conditional required_outgoing health test schema
4101when_to_use: tests
4102types:
4103 - task
4104relationships:
4105 mode: strict
4106 definitions:
4107 - name: PART_OF
4108 description: Hier
4109 default_weight: 3.0
4110 - name: _default
4111 description: Fallback
4112 default_weight: 1.0
4113community:
4114 resolution: 1.0
4115 seed: 42
4116"#;
4117 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";
4118 let schema = std::sync::Arc::new(
4119 memstead_schema::load_schema_from_memory(
4120 manifest,
4121 &[("task".to_string(), task_yaml.to_string())],
4122 )
4123 .expect("conditional ro fixture schema must parse"),
4124 );
4125
4126 let mut store = Store::new();
4127 let mut armed = make_typed_entity("plan", "armed", "task");
4128 armed
4129 .metadata
4130 .insert("status".into(), MetadataValue::String("checked".into()));
4131 let mut other_value = make_typed_entity("plan", "quiet", "task");
4132 other_value
4133 .metadata
4134 .insert("status".into(), MetadataValue::String("open".into()));
4135 let unset = make_typed_entity("plan", "blank", "task");
4136 let parent = make_typed_entity("plan", "parent", "task");
4137 let mut satisfied = make_typed_entity("plan", "wired", "task");
4138 satisfied
4139 .metadata
4140 .insert("status".into(), MetadataValue::String("checked".into()));
4141 satisfied.relationships.push(crate::entity::Relationship {
4142 rel_type: "PART_OF".into(),
4143 target: parent.id.clone(),
4144 description: None,
4145 });
4146 for e in [armed.clone(), other_value, unset, parent, satisfied] {
4147 store.upsert(e.id.clone(), e);
4148 }
4149
4150 let mut mem_schemas = HashMap::new();
4151 mem_schemas.insert("plan".to_string(), schema);
4152
4153 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
4154 assert_eq!(
4155 reports.len(),
4156 1,
4157 "only the armed edge-less entity is reported; got {reports:?}"
4158 );
4159 let r = &reports[0];
4160 assert_eq!(r.id, armed.id);
4161 assert_eq!(r.missing.len(), 1);
4162 assert_eq!(r.missing[0].when_field.as_deref(), Some("status"));
4163 assert_eq!(r.missing[0].when_value.as_deref(), Some("checked"));
4164 }
4165
4166 fn must_reach_schema(
4174 claim_extra: &str,
4175 inference_extra: &str,
4176 ) -> std::sync::Arc<memstead_schema::Schema> {
4177 let manifest = r#"name: tests-must-reach
4178version: 0.1.0
4179description: must_reach health test schema
4180when_to_use: tests
4181types:
4182 - claim
4183 - inference
4184 - evidence
4185relationships:
4186 mode: strict
4187 definitions:
4188 - name: GROUNDS
4189 description: g
4190 default_weight: 3.0
4191 - name: CONCLUDES
4192 description: c
4193 default_weight: 3.0
4194 - name: PART_OF
4195 description: hier
4196 default_weight: 1.0
4197 - name: _default
4198 description: fallback
4199 default_weight: 1.0
4200community:
4201 resolution: 1.0
4202 seed: 42
4203"#;
4204 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";
4205 let claim = format!("name: claim\ndescription: t\nwhen_to_use: Here\n{body}{claim_extra}");
4206 let inference =
4207 format!("name: inference\ndescription: t\nwhen_to_use: Here\n{body}{inference_extra}");
4208 let evidence = format!("name: evidence\ndescription: t\nwhen_to_use: Here\n{body}");
4209 std::sync::Arc::new(
4210 memstead_schema::load_schema_from_memory(
4211 manifest,
4212 &[
4213 ("claim".to_string(), claim),
4214 ("inference".to_string(), inference),
4215 ("evidence".to_string(), evidence),
4216 ],
4217 )
4218 .expect("must_reach fixture schema must parse"),
4219 )
4220 }
4221
4222 fn link(from: &mut crate::entity::Entity, rel: &str, to: &crate::entity::EntityId) {
4223 from.relationships.push(crate::entity::Relationship {
4224 rel_type: rel.into(),
4225 target: to.clone(),
4226 description: None,
4227 });
4228 }
4229
4230 fn must_reach_violations(r: &ConstraintFindingReport) -> Vec<&UnsatisfiedConstraint> {
4231 r.violations
4232 .iter()
4233 .filter(|v| matches!(v, UnsatisfiedConstraint::MustReach { .. }))
4234 .collect()
4235 }
4236
4237 const CLAIM_GROUNDS_EVIDENCE: &str = "must_reach:\n - relationships: [GROUNDS]\n direction: out\n terminal_types: [evidence]\n";
4238
4239 #[test]
4243 fn must_reach_conforming_path_silent_gap_reported() {
4244 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
4245 let mut store = Store::new();
4246 let ev = make_typed_entity("arg", "ev", "evidence");
4247 let mut direct = make_typed_entity("arg", "direct", "claim");
4248 link(&mut direct, "GROUNDS", &ev.id);
4249 let mut mid = make_typed_entity("arg", "mid", "claim");
4250 let mut chained = make_typed_entity("arg", "chained", "claim");
4251 link(&mut chained, "GROUNDS", &mid.id);
4252 link(&mut mid, "GROUNDS", &ev.id);
4253 let floating = make_typed_entity("arg", "floating", "claim");
4254 for e in [ev, direct, mid, chained, floating.clone()] {
4255 store.upsert(e.id.clone(), e);
4256 }
4257 let mut mem_schemas = HashMap::new();
4258 mem_schemas.insert("arg".to_string(), schema);
4259
4260 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4261 assert_eq!(reports.len(), 1, "only the pathless claim: {reports:?}");
4262 assert_eq!(reports[0].id, floating.id);
4263 let v = must_reach_violations(&reports[0]);
4264 assert_eq!(v.len(), 1);
4265 let UnsatisfiedConstraint::MustReach {
4266 relationships,
4267 direction,
4268 terminal_types,
4269 max_depth,
4270 ..
4271 } = v[0]
4272 else {
4273 panic!("expected must_reach finding");
4274 };
4275 assert_eq!(relationships, &vec!["GROUNDS".to_string()]);
4276 assert_eq!(*direction, memstead_schema::ReachDirection::Out);
4277 assert_eq!(terminal_types, &vec!["evidence".to_string()]);
4278 assert_eq!(*max_depth, None);
4279 }
4280
4281 #[test]
4286 fn must_reach_one_hop_incoming_floating_leap() {
4287 let schema = must_reach_schema(
4288 "",
4289 "must_reach:\n - relationships: [GROUNDS]\n direction: in\n terminal_types: [claim]\n max_depth: 1\n",
4290 );
4291 let mut store = Store::new();
4292 let leap = make_typed_entity("arg", "leap", "inference");
4293 let grounded = make_typed_entity("arg", "grounded", "inference");
4294 let mut premise = make_typed_entity("arg", "premise", "claim");
4295 link(&mut premise, "GROUNDS", &grounded.id);
4296 for e in [leap.clone(), grounded, premise] {
4297 store.upsert(e.id.clone(), e);
4298 }
4299 let mut mem_schemas = HashMap::new();
4300 mem_schemas.insert("arg".to_string(), schema);
4301
4302 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4303 assert_eq!(reports.len(), 1, "only the floating leap: {reports:?}");
4304 assert_eq!(reports[0].id, leap.id);
4305 }
4306
4307 #[test]
4310 fn must_reach_stub_and_non_terminal_chains_then_cleared() {
4311 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
4312 let mut store = Store::new();
4313 let mut stub_ev = make_typed_entity("arg", "ghost", "evidence");
4314 stub_ev.stub = true;
4315 let mut to_stub = make_typed_entity("arg", "to-stub", "claim");
4316 link(&mut to_stub, "GROUNDS", &stub_ev.id);
4317 let dead_end = make_typed_entity("arg", "dead-end", "claim");
4318 let mut to_claim = make_typed_entity("arg", "to-claim", "claim");
4319 link(&mut to_claim, "GROUNDS", &dead_end.id);
4320 for e in [stub_ev, to_stub.clone(), dead_end, to_claim.clone()] {
4321 store.upsert(e.id.clone(), e);
4322 }
4323 let mut mem_schemas = HashMap::new();
4324 mem_schemas.insert("arg".to_string(), schema.clone());
4325
4326 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4327 let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
4328 assert!(
4329 ids.contains(&to_stub.id.0.as_str()),
4330 "stub terminates no obligation: {ids:?}"
4331 );
4332 assert!(
4333 ids.contains(&to_claim.id.0.as_str()),
4334 "non-terminal chain is a finding: {ids:?}"
4335 );
4336
4337 let ev = make_typed_entity("arg", "real-ev", "evidence");
4339 let mut repaired = store.get(&to_stub.id).unwrap().clone();
4340 link(&mut repaired, "GROUNDS", &ev.id);
4341 store.upsert(ev.id.clone(), ev);
4342 store.upsert(repaired.id.clone(), repaired);
4343 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4344 let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
4345 assert!(
4346 !ids.contains(&to_stub.id.0.as_str()),
4347 "conforming path clears the finding: {ids:?}"
4348 );
4349 }
4350
4351 #[test]
4355 fn must_reach_cycles_terminate() {
4356 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
4357 let mut store = Store::new();
4358 let mut a = make_typed_entity("arg", "cyc-a", "claim");
4359 let mut b = make_typed_entity("arg", "cyc-b", "claim");
4360 link(&mut a, "GROUNDS", &b.id);
4361 link(&mut b, "GROUNDS", &a.id);
4362 for e in [a, b] {
4363 store.upsert(e.id.clone(), e);
4364 }
4365 let mut mem_schemas = HashMap::new();
4366 mem_schemas.insert("arg".to_string(), schema);
4367
4368 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4369 assert_eq!(reports.len(), 2, "both cycle members lack evidence");
4370 }
4371
4372 #[test]
4376 fn must_reach_depth_bound() {
4377 let two_hop_store = || {
4378 let mut store = Store::new();
4379 let ev = make_typed_entity("arg", "ev", "evidence");
4380 let mut mid = make_typed_entity("arg", "mid", "claim");
4381 let mut start = make_typed_entity("arg", "start", "claim");
4382 link(&mut start, "GROUNDS", &mid.id);
4383 link(&mut mid, "GROUNDS", &ev.id);
4384 for e in [ev, mid, start] {
4385 store.upsert(e.id.clone(), e);
4386 }
4387 store
4388 };
4389 let bounded = |depth: u32| {
4390 must_reach_schema(
4391 &format!(
4392 "must_reach:\n - relationships: [GROUNDS]\n direction: out\n terminal_types: [evidence]\n max_depth: {depth}\n"
4393 ),
4394 "",
4395 )
4396 };
4397
4398 let store = two_hop_store();
4399 let mut mem_schemas = HashMap::new();
4400 mem_schemas.insert("arg".to_string(), bounded(1));
4401 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4402 assert_eq!(
4403 reports.len(),
4404 1,
4405 "the two-hop path exceeds depth 1 for the start claim: {reports:?}"
4406 );
4407 assert_eq!(reports[0].id.0, "arg--start");
4408
4409 let mut mem_schemas = HashMap::new();
4410 mem_schemas.insert("arg".to_string(), bounded(2));
4411 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4412 assert!(
4413 reports.is_empty(),
4414 "the same path satisfies depth 2: {reports:?}"
4415 );
4416 }
4417
4418 #[test]
4421 fn must_reach_two_obligations_one_finding() {
4422 let schema = must_reach_schema(
4423 "must_reach:\n - relationships: [GROUNDS]\n direction: out\n terminal_types: [evidence]\n - relationships: [CONCLUDES]\n direction: out\n terminal_types: [inference]\n",
4424 "",
4425 );
4426 let mut store = Store::new();
4427 let ev = make_typed_entity("arg", "ev", "evidence");
4428 let mut c = make_typed_entity("arg", "half", "claim");
4429 link(&mut c, "GROUNDS", &ev.id);
4430 for e in [ev, c.clone()] {
4431 store.upsert(e.id.clone(), e);
4432 }
4433 let mut mem_schemas = HashMap::new();
4434 mem_schemas.insert("arg".to_string(), schema);
4435
4436 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4437 assert_eq!(reports.len(), 1);
4438 assert_eq!(reports[0].id, c.id);
4439 let v = must_reach_violations(&reports[0]);
4440 assert_eq!(v.len(), 1, "only the unsatisfied obligation: {v:?}");
4441 let UnsatisfiedConstraint::MustReach { relationships, .. } = v[0] else {
4442 panic!("expected must_reach finding");
4443 };
4444 assert_eq!(relationships, &vec!["CONCLUDES".to_string()]);
4445 }
4446
4447 #[test]
4453 fn status_propagation_rel_types_taints_across_type_boundaries() {
4454 use crate::entity::MetadataValue;
4455 let manifest = r#"name: tests-prop-set
4456version: 0.1.0
4457description: propagation relation-set test schema
4458when_to_use: tests
4459types:
4460 - claim
4461relationships:
4462 mode: strict
4463 definitions:
4464 - name: GROUNDS
4465 description: g
4466 default_weight: 3.0
4467 - name: CONCLUDES
4468 description: c
4469 default_weight: 3.0
4470 - name: PART_OF
4471 description: hier
4472 default_weight: 1.0
4473 - name: _default
4474 description: fallback
4475 default_weight: 1.0
4476community:
4477 resolution: 1.0
4478 seed: 42
4479"#;
4480 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";
4481 let schema = std::sync::Arc::new(
4482 memstead_schema::load_schema_from_memory(
4483 manifest,
4484 &[("claim".to_string(), claim_yaml.to_string())],
4485 )
4486 .expect("propagation-set fixture schema must parse"),
4487 );
4488
4489 let mut store = Store::new();
4490 let mut withdrawn = make_typed_entity("arg", "withdrawn-ev", "claim");
4491 withdrawn
4492 .metadata
4493 .insert("standing".into(), MetadataValue::String("withdrawn".into()));
4494 let mut inference = make_typed_entity("arg", "inference", "claim");
4495 link(&mut inference, "GROUNDS", &withdrawn.id);
4496 let mut conclusion = make_typed_entity("arg", "conclusion", "claim");
4497 link(&mut conclusion, "CONCLUDES", &inference.id);
4498 let bystander = make_typed_entity("arg", "bystander", "claim");
4499 for e in [withdrawn, inference.clone(), conclusion.clone(), bystander] {
4500 store.upsert(e.id.clone(), e);
4501 }
4502 let mut mem_schemas = HashMap::new();
4503 mem_schemas.insert("arg".to_string(), schema);
4504
4505 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4506 let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
4507 assert_eq!(
4508 ids,
4509 vec![conclusion.id.0.as_str(), inference.id.0.as_str()],
4510 "the taint crosses the CONCLUDES/GROUNDS boundary, nothing else"
4511 );
4512 let UnsatisfiedConstraint::StatusPropagation {
4513 rel_type,
4514 rel_types,
4515 tainted_by,
4516 ..
4517 } = &reports[0].violations[0]
4518 else {
4519 panic!("expected status_propagation finding");
4520 };
4521 assert_eq!(*rel_type, None, "set declarations echo no single name");
4522 assert_eq!(
4523 rel_types.as_deref(),
4524 Some(&["GROUNDS".to_string(), "CONCLUDES".to_string()][..])
4525 );
4526 assert_eq!(tainted_by, "arg--withdrawn-ev");
4527 }
4528
4529 #[test]
4532 fn must_reach_cross_mem_path_and_mem_filter() {
4533 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
4534 let mut store = Store::new();
4535 let far_ev = make_typed_entity("ground", "far-ev", "evidence");
4536 let mut crossing = make_typed_entity("arg", "crossing", "claim");
4537 link(&mut crossing, "GROUNDS", &far_ev.id);
4538 let floating_arg = make_typed_entity("arg", "floating", "claim");
4539 let floating_ground = make_typed_entity("ground", "floating", "claim");
4540 for e in [far_ev, crossing, floating_arg.clone(), floating_ground] {
4541 store.upsert(e.id.clone(), e);
4542 }
4543 let mut mem_schemas = HashMap::new();
4544 mem_schemas.insert("arg".to_string(), schema.clone());
4545 mem_schemas.insert("ground".to_string(), schema);
4546
4547 let all = collect_constraint_findings(&store, None, &mem_schemas, None);
4548 assert_eq!(
4549 all.len(),
4550 2,
4551 "the crossing claim is satisfied via the cross-mem edge: {all:?}"
4552 );
4553 let filtered = collect_constraint_findings(&store, Some("arg"), &mem_schemas, None);
4554 assert_eq!(filtered.len(), 1, "mem filter narrows: {filtered:?}");
4555 assert_eq!(filtered[0].id, floating_arg.id);
4556 }
4557
4558 fn gated_transition_schema() -> std::sync::Arc<memstead_schema::Schema> {
4565 let manifest = r#"name: tests-gated
4566version: 0.1.0
4567description: transition_requires_checks test schema
4568when_to_use: tests
4569types:
4570 - plan
4571 - criterion
4572relationships:
4573 mode: strict
4574 definitions:
4575 - name: VERIFIES
4576 description: v
4577 default_weight: 3.0
4578 acyclic: true
4579 - name: PART_OF
4580 description: hier
4581 default_weight: 1.0
4582 acyclic: true
4583 - name: _default
4584 description: fallback
4585 default_weight: 1.0
4586community:
4587 resolution: 1.0
4588 seed: 42
4589"#;
4590 let plan_yaml = "name: plan\ndescription: p\nwhen_to_use: t\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: s\n field_type: string\n default_value: draft\n enum_values: [draft, complete]\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nupdatable_fields:\n - title\nhealth_required_fields: []\nstaleness_threshold_days: 90\nwrite_rules: []\nconstraints:\n - kind: transition_requires_checks\n field: status\n to_value: complete\n relationships: [VERIFIES]\n direction: incoming\n severity: block\n";
4591 let criterion_yaml = "name: criterion\ndescription: c\nwhen_to_use: t\nsections:\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\nupdatable_fields:\n - title\nhealth_required_fields: []\nstaleness_threshold_days: 90\nwrite_rules: []\n";
4592 std::sync::Arc::new(
4593 memstead_schema::load_schema_from_memory(
4594 manifest,
4595 &[
4596 ("plan".to_string(), plan_yaml.to_string()),
4597 ("criterion".to_string(), criterion_yaml.to_string()),
4598 ],
4599 )
4600 .expect("gated-transition fixture schema loads"),
4601 )
4602 }
4603
4604 #[test]
4610 fn transition_requires_checks_gates_on_derived_state() {
4611 use crate::check::CheckState;
4612 use crate::entity::MetadataValue;
4613 let schema = gated_transition_schema();
4614 let td = schema.types.get("plan").unwrap().clone();
4615 let mut store = Store::default();
4616
4617 let mut plan = make_typed_entity("g", "the-plan", "plan");
4618 plan.metadata
4619 .insert("status".into(), MetadataValue::String("complete".into()));
4620 let mut ok_crit = make_typed_entity("g", "ok-crit", "criterion");
4621 ok_crit.relationships.push(crate::entity::Relationship {
4622 rel_type: "VERIFIES".into(),
4623 target: plan.id.clone(),
4624 description: None,
4625 });
4626 let mut stale_crit = make_typed_entity("g", "stale-crit", "criterion");
4627 stale_crit.relationships.push(crate::entity::Relationship {
4628 rel_type: "VERIFIES".into(),
4629 target: plan.id.clone(),
4630 description: None,
4631 });
4632 for e in [plan.clone(), ok_crit.clone(), stale_crit.clone()] {
4633 store.upsert(e.id.clone(), e);
4634 }
4635
4636 let state_of = |e: &crate::entity::Entity| {
4637 if e.id.0.contains("ok-crit") {
4638 CheckState::CheckedOk
4639 } else {
4640 CheckState::CheckStale
4641 }
4642 };
4643 let provider = |e: &crate::entity::Entity| {
4644 crate::engine::independence::CheckStanding::assumed_independent(state_of(e))
4645 };
4646 let violations = unsatisfied_constraints(&store, &plan, &td, None, Some(&provider));
4647 assert_eq!(violations.len(), 1, "{violations:?}");
4648 match &violations[0] {
4649 UnsatisfiedConstraint::TransitionRequiresChecks {
4650 unchecked,
4651 severity,
4652 ..
4653 } => {
4654 assert_eq!(
4655 unchecked.len(),
4656 1,
4657 "only the unconfirmed criterion is listed"
4658 );
4659 assert_eq!(unchecked[0].id, "g--stale-crit");
4660 assert_eq!(unchecked[0].state, "check_stale");
4661 assert_eq!(*severity, memstead_schema::ConstraintSeverity::Block);
4662 }
4663 other => panic!("expected the gated-transition violation, got {other:?}"),
4664 }
4665 assert!(
4666 violations[0].describe().contains("g--stale-crit")
4667 && violations[0].describe().contains("check_stale"),
4668 "describe names the offender and its state: {}",
4669 violations[0].describe()
4670 );
4671
4672 let all_ok = |_: &crate::entity::Entity| {
4674 crate::engine::independence::CheckStanding::assumed_independent(CheckState::CheckedOk)
4675 };
4676 assert!(
4677 unsatisfied_constraints(&store, &plan, &td, None, Some(&all_ok)).is_empty(),
4678 "all confirmed satisfies the gate"
4679 );
4680
4681 let mut draft = plan.clone();
4683 draft
4684 .metadata
4685 .insert("status".into(), MetadataValue::String("draft".into()));
4686 assert!(
4687 unsatisfied_constraints(&store, &draft, &td, None, Some(&provider)).is_empty(),
4688 "the gate triggers only at to_value"
4689 );
4690
4691 let violations = unsatisfied_constraints(&store, &plan, &td, None, None);
4693 assert_eq!(violations.len(), 1);
4694 match &violations[0] {
4695 UnsatisfiedConstraint::TransitionRequiresChecks { unchecked, .. } => {
4696 assert_eq!(unchecked.len(), 2, "no ledger access confirms nothing");
4697 assert!(unchecked.iter().all(|u| u.state == "never_checked"));
4698 }
4699 other => panic!("expected the gated-transition violation, got {other:?}"),
4700 }
4701
4702 let mut lone = make_typed_entity("g", "lone-plan", "plan");
4704 lone.metadata
4705 .insert("status".into(), MetadataValue::String("complete".into()));
4706 store.upsert(lone.id.clone(), lone.clone());
4707 assert!(
4708 unsatisfied_constraints(&store, &lone, &td, None, Some(&provider)).is_empty(),
4709 "an empty related set satisfies the universal quantification"
4710 );
4711 }
4712}