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];
54
55pub fn health_checks_axis(
81 engine: &crate::engine::Engine,
82 mem_filter: Option<&str>,
83) -> serde_json::Value {
84 let cap = OPEN_QUESTIONS_ITEM_CAP;
85 let capped = |mut items: Vec<String>| -> serde_json::Value {
86 items.sort();
87 let count = items.len();
88 let more = count.saturating_sub(cap);
89 items.truncate(cap);
90 let mut o = serde_json::Map::new();
91 o.insert("count".into(), serde_json::json!(count));
92 o.insert("items".into(), serde_json::json!(items));
93 if more > 0 {
94 o.insert("more".into(), serde_json::json!(more));
95 }
96 serde_json::Value::Object(o)
97 };
98
99 let ledger = engine
100 .workspace_root()
101 .map(crate::check::CheckLedger::for_workspace);
102 let mut latest: std::collections::BTreeMap<String, crate::check::CheckRecord> =
106 std::collections::BTreeMap::new();
107 let mut latest_conformance: std::collections::BTreeMap<String, crate::check::CheckRecord> =
108 std::collections::BTreeMap::new();
109 if let Some(l) = &ledger {
110 for rec in l.all() {
111 match rec.resolved_kind() {
112 crate::check::CheckKind::Verification => {
113 latest.insert(rec.entity.clone(), rec);
114 }
115 crate::check::CheckKind::Conformance => {
116 latest_conformance.insert(rec.entity.clone(), rec);
117 }
118 }
119 }
120 }
121
122 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
123 mems.sort();
124 let mut out = serde_json::Map::new();
125 for mem in mems {
126 if let Some(f) = mem_filter
127 && f != mem
128 {
129 continue;
130 }
131 let mut counts = std::collections::BTreeMap::from([
132 ("never_checked", 0usize),
133 ("checked_ok", 0usize),
134 ("check_failed", 0usize),
135 ("check_stale", 0usize),
136 ]);
137 let current_pin = engine
143 .mount(&mem)
144 .and_then(|m| m.schema.as_ref())
145 .map(|s| s.as_display());
146 let mut conformance_counts = std::collections::BTreeMap::from([
147 ("never_checked", 0usize),
148 ("checked_ok", 0usize),
149 ("check_failed", 0usize),
150 ("check_stale", 0usize),
151 ]);
152 let mut self_checked: Vec<String> = Vec::new();
153 let mut confirmed_independent: Vec<String> = Vec::new();
154 let mut unconfirmable: Vec<String> = Vec::new();
155 for e in engine.store().all_entities().filter(|e| e.mem == mem) {
156 let id = e.id.0.clone();
157 let state = crate::check::derive_state(latest.get(&id), &e.content_hash);
158 *counts.entry(state.as_str()).or_insert(0) += 1;
159 let cstate = crate::check::derive_state_pinned(
160 latest_conformance.get(&id),
161 &e.content_hash,
162 current_pin.as_deref(),
163 );
164 *conformance_counts.entry(cstate.as_str()).or_insert(0) += 1;
165 if state != crate::check::CheckState::CheckedOk {
166 continue;
167 }
168 let check = latest.get(&id).expect("checked_ok implies a record");
177 let author_identity = engine
180 .entity_provenance(&mem, &id)
181 .ok()
182 .and_then(|p| p.created_by)
183 .and_then(|r| r.identity);
184 match (author_identity, check.identity.as_ref()) {
185 (Some(a), Some(c)) if a == *c => self_checked.push(id),
186 (Some(_), Some(_)) => confirmed_independent.push(id),
187 _ => unconfirmable.push(id),
188 }
189 }
190 let mut m = serde_json::Map::new();
191 for (k, v) in counts {
192 m.insert(k.to_string(), serde_json::json!(v));
193 }
194 let mut c = serde_json::Map::new();
195 for (k, v) in conformance_counts {
196 c.insert(k.to_string(), serde_json::json!(v));
197 }
198 m.insert("conformance".into(), serde_json::Value::Object(c));
199 m.insert(
200 "independence".into(),
201 serde_json::json!({
202 "self_checked": capped(self_checked),
203 "confirmed_independent": capped(confirmed_independent),
204 "unconfirmable": capped(unconfirmable),
205 }),
206 );
207 out.insert(mem, serde_json::Value::Object(m));
208 }
209 serde_json::Value::Object(out)
210}
211
212#[derive(Debug, Clone, serde::Serialize)]
218pub struct DerivationFinding {
219 pub source: crate::entity::EntityId,
220 pub rel_type: String,
221 pub target: crate::entity::EntityId,
222 pub state: String,
224 #[serde(skip_serializing_if = "Option::is_none")]
226 pub baseline: Option<String>,
227 pub current: String,
229}
230
231pub fn health_stale_derivations_axis(
236 engine: &crate::engine::Engine,
237 mem_filter: Option<&str>,
238) -> serde_json::Value {
239 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
240 mems.sort();
241 let mut out = serde_json::Map::new();
242 for mem in mems {
243 if let Some(f) = mem_filter
244 && f != mem
245 {
246 continue;
247 }
248 let findings = engine.derivation_report(&mem).unwrap_or_default();
249 out.insert(
250 mem,
251 serde_json::to_value(&findings).unwrap_or(serde_json::Value::Array(Vec::new())),
252 );
253 }
254 serde_json::Value::Object(out)
255}
256
257pub const OPEN_QUESTIONS_ITEM_CAP: usize = 20;
261
262pub fn health_open_questions_axis(
278 engine: &crate::engine::Engine,
279 mem_filter: Option<&str>,
280) -> serde_json::Value {
281 let cap = OPEN_QUESTIONS_ITEM_CAP;
282 let capped = |mut items: Vec<serde_json::Value>| -> serde_json::Value {
283 let count = items.len();
284 let more = count.saturating_sub(cap);
285 items.truncate(cap);
286 let mut o = serde_json::Map::new();
287 o.insert("count".into(), serde_json::json!(count));
288 o.insert("items".into(), serde_json::Value::Array(items));
289 if more > 0 {
290 o.insert("more".into(), serde_json::json!(more));
291 }
292 serde_json::Value::Object(o)
293 };
294
295 let bindings: Vec<(String, String)> = engine
299 .workspace_root()
300 .and_then(|root| crate::pipeline_store::load_pipeline_configs(root).ok())
301 .map(|c| {
302 c.bindings
303 .iter()
304 .map(|r| (r.config.destination_mem.clone(), r.name.clone()))
305 .collect()
306 })
307 .unwrap_or_default();
308 let mounted: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
309
310 let mut mems: Vec<String> = mounted.clone();
311 mems.sort();
312 let mut out = serde_json::Map::new();
313 for mem in &mems {
314 if let Some(f) = mem_filter
315 && f != mem
316 {
317 continue;
318 }
319
320 let stubs = capped(
322 engine
323 .store()
324 .all_entities()
325 .filter(|e| e.stub && e.id.mem() == mem)
326 .map(|e| serde_json::json!({ "kind": "stub", "id": e.id.to_string() }))
327 .collect(),
328 );
329
330 let (mut recheck, mut unresolvable, mut unobserved, mut dangling_rows) =
343 (Vec::new(), Vec::new(), Vec::new(), Vec::new());
344 let mut entity_end_unreconciled: Option<String> = None;
345 if let Ok(report) = engine.verify_mem_anchors(mem) {
346 entity_end_unreconciled = report.unreconciled.clone();
347 for a in &report.anchors {
348 let item = serde_json::json!({
349 "kind": format!("anchor_{}", a.state),
350 "id": a.entity_id,
351 "artifact": a.artifact,
352 });
353 match a.state.as_str() {
354 "recheck" => recheck.push(item),
355 "unresolvable" => unresolvable.push(item),
356 "unobserved" => unobserved.push(item),
357 "dangling" => dangling_rows.push(item),
358 _ => {}
359 }
360 }
361 }
362
363 let constraints = capped(
366 engine
367 .constraint_findings(Some(mem))
368 .iter()
369 .map(|r| {
370 serde_json::json!({
371 "kind": "unsatisfied_constraint",
372 "id": r.id.to_string(),
373 "violations": r.violations.len(),
374 })
375 })
376 .collect(),
377 );
378
379 let dangling = capped(
385 collect_dangling_links(engine.store(), Some(mem))
386 .iter()
387 .map(|d| {
388 serde_json::json!({
389 "kind": d.kind.code(),
390 "id": d.from.to_string(),
391 "target": d.target_id.to_string(),
392 "repair": d.kind.repair(),
393 })
394 })
395 .collect(),
396 );
397
398 let mut process = Vec::new();
408 let mem_bindings: Vec<&String> = bindings
409 .iter()
410 .filter(|(d, _)| d == mem)
411 .map(|(_, b)| b)
412 .collect();
413 let mut resolutions: Vec<(Option<String>, crate::ingest::resolve::ProcessMemResolution)> =
414 Vec::new();
415 if mem_bindings.is_empty() {
416 let r = crate::ingest::resolve::resolve_process_mem(engine, mem, "");
417 if r.declared {
418 resolutions.push((None, r));
419 }
420 } else {
421 for binding in &mem_bindings {
422 resolutions.push((
423 Some((*binding).clone()),
424 crate::ingest::resolve::resolve_process_mem(engine, mem, binding),
425 ));
426 }
427 }
428 for (binding, r) in resolutions {
429 if r.mounted {
430 let mut open = Vec::new();
431 let mut searched = Vec::new();
432 for e in engine
433 .store()
434 .all_entities()
435 .filter(|e| !e.stub && e.id.mem() == r.mem.as_str())
436 {
437 let item = serde_json::json!({
438 "kind": e.entity_type,
439 "id": e.id.to_string(),
440 "title": e.title,
441 });
442 if e.entity_type == "negative_finding" {
443 searched.push(item);
444 } else {
445 open.push(item);
446 }
447 }
448 process.push(serde_json::json!({
449 "binding": binding,
450 "process_mem": r.mem,
451 "declared": r.declared,
452 "resolvable": true,
453 "open_entries": capped(open),
454 "already_searched": capped(searched),
455 }));
456 } else if r.declared {
457 process.push(serde_json::json!({
458 "binding": binding,
459 "process_mem": r.mem,
460 "declared": true,
461 "resolvable": false,
462 "finding": "DECLARED_PROCESS_MEM_MISSING",
463 }));
464 } else {
465 process.push(serde_json::json!({
466 "binding": binding,
467 "resolvable": false,
468 }));
469 }
470 }
471
472 let total_open = stubs["count"].as_u64().unwrap_or(0)
473 + recheck.len() as u64
474 + unresolvable.len() as u64
475 + unobserved.len() as u64
476 + dangling_rows.len() as u64
477 + constraints["count"].as_u64().unwrap_or(0)
478 + dangling["count"].as_u64().unwrap_or(0)
479 + process
480 .iter()
481 .filter_map(|p| p["open_entries"]["count"].as_u64())
482 .sum::<u64>();
483
484 let mut entry = serde_json::Map::new();
485 entry.insert("stubs".into(), stubs);
486 entry.insert("anchors_recheck".into(), capped(recheck));
487 entry.insert("anchors_unresolvable".into(), capped(unresolvable));
488 entry.insert("anchors_unobserved".into(), capped(unobserved));
492 entry.insert("anchors_dangling".into(), capped(dangling_rows));
493 if let Some(why) = entity_end_unreconciled {
494 entry.insert("entity_end_unreconciled".into(), serde_json::json!(why));
495 }
496 entry.insert("unsatisfied_constraints".into(), constraints);
497 entry.insert("dangling_links".into(), dangling);
498 if !process.is_empty() {
499 entry.insert("process".into(), serde_json::Value::Array(process));
500 } else {
501 entry.insert("process_mem_resolvable".into(), serde_json::json!(false));
504 }
505 entry.insert("total_open".into(), serde_json::json!(total_open));
506 out.insert(mem.clone(), serde_json::Value::Object(entry));
507 }
508 let mut top = serde_json::Map::new();
509 top.insert("_item_cap".into(), serde_json::json!(cap));
510 for (k, v) in out {
511 top.insert(k, v);
512 }
513 serde_json::Value::Object(top)
514}
515
516pub fn health_anchors_axis(engine: &crate::engine::Engine) -> serde_json::Value {
517 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
518 mems.sort();
519 let mut out = serde_json::Map::new();
520 for mem in mems {
521 let Ok(report) = engine.verify_mem_anchors(&mem) else {
522 continue;
523 };
524 out.insert(
525 mem,
526 serde_json::json!({
527 "resolved": report.resolved,
528 "drifted": report.drifted,
529 "recheck": report.recheck,
530 "unresolvable": report.unresolvable,
535 "unobserved": report.unobserved,
536 "dangling": report.dangling,
541 "entity_end_unreconciled": report.unreconciled,
542 "population": report.population_statement(),
545 "fully_adjudicated": report.fully_adjudicated(),
546 }),
547 );
548 }
549 serde_json::Value::Object(out)
550}
551
552pub fn compute_health(
565 store: &Store,
566 default_schema: &TypeDefinition,
567 mem_schemas: &HashMap<String, Arc<Schema>>,
568 mem_filter: Option<&str>,
569) -> HealthSummary {
570 let mut missing_fields = Vec::new();
571 let mut stale_entities = Vec::new();
572
573 let today_days = days_since_epoch();
574
575 let in_scope = |mem: &str| mem_filter.is_none_or(|v| mem == v);
576
577 for entity in store.all_entities() {
578 if entity.stub || !in_scope(&entity.mem) {
579 continue;
580 }
581
582 let resolved = mem_schemas
591 .get(entity.mem.as_str())
592 .and_then(|s| s.types.get(entity.entity_type.as_str()).cloned())
593 .or_else(|| type_by_name(&entity.entity_type));
594 let schema: &TypeDefinition = resolved.as_deref().unwrap_or(default_schema);
595 let mut issues = Vec::new();
596
597 for field in &schema.health_required_fields {
599 if schema.section(field).is_some() {
601 let content = entity.sections.get(field.as_str());
606 if content.is_none_or(|c| c.trim().is_empty()) {
607 if let Some(issue) = section_heading_mismatch_issue(entity, schema, field) {
608 issues.push(issue);
609 } else {
610 issues.push(HealthIssue {
611 field: field.clone(),
612 code: super::HealthIssueCode::Missing,
613 message: format!("required section '{field}' is empty"),
614 });
615 }
616 }
617 } else {
618 let value = entity.metadata.get(field.as_str());
624 let is_empty = match value {
625 None => true,
626 Some(v) => v.to_frontmatter_string().trim().is_empty(),
627 };
628 if is_empty {
629 issues.push(HealthIssue {
630 field: field.clone(),
631 code: super::HealthIssueCode::Missing,
632 message: format!("required field '{field}' is missing"),
633 });
634 }
635 }
636 }
637
638 for s in schema.sections.iter().filter(|s| !s.catch_all) {
641 if schema.health_required_fields.contains(&s.key) {
642 continue; }
644 let content = entity.sections.get(s.key.as_str());
645 if content.is_none_or(|c| c.trim().is_empty())
646 && let Some(issue) = section_heading_mismatch_issue(entity, schema, &s.key)
647 {
648 issues.push(issue);
649 }
650 }
651
652 if let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) {
668 let mut seen_unknown = std::collections::HashSet::new();
669 for rel in &entity.relationships {
670 if !mem_schema.relationship_known(&rel.rel_type) {
671 if seen_unknown.insert(rel.rel_type.clone()) {
672 let suggestion = mem_schema
673 .suggest_relationship(&rel.rel_type)
674 .map(|s| format!(" Did you mean '{s}'?"))
675 .unwrap_or_default();
676 let (schema_name, schema_version) = mem_schema.id();
677 issues.push(HealthIssue {
678 field: "relationships".to_string(),
679 code: super::HealthIssueCode::UndeclaredRelationship,
680 message: format!(
681 "relationship '{}' is not declared in schema \
682 '{schema_name}@{schema_version}'.{suggestion}",
683 rel.rel_type
684 ),
685 });
686 }
687 continue;
688 }
689
690 let target_type = store
691 .get(&rel.target)
692 .map(|t| t.entity_type.clone())
693 .filter(|t| !t.is_empty());
694 if let Err(crate::runtime_validator::ValidationError::InvalidRelationshipShape {
695 rel_type,
696 from_type,
697 to_type,
698 allowed_source_types,
699 allowed_target_types,
700 ..
701 }) = crate::runtime_validator::validate_rel_shape(
702 &rel.rel_type,
703 entity.entity_type.as_str(),
704 target_type.as_deref(),
705 mem_schema.as_ref(),
706 ) {
707 let allowed_src = if allowed_source_types.is_empty() {
708 "<any>".to_string()
709 } else {
710 allowed_source_types.join(", ")
711 };
712 let allowed_tgt = if allowed_target_types.is_empty() {
713 "<any>".to_string()
714 } else {
715 allowed_target_types.join(", ")
716 };
717 issues.push(HealthIssue {
718 field: "relationships".to_string(),
719 code: super::HealthIssueCode::InvalidRelShape,
720 message: format!(
721 "INVALID_REL_SHAPE: edge '{rel_type}' from \
722 '{from_type}' to '{to_type}' (target {target}) \
723 violates declared shape — allowed_source_types: \
724 [{allowed_src}], allowed_target_types: \
725 [{allowed_tgt}]. Remove via \
726 `memstead_relate from={from_id} to={target} \
727 type={rel_type} remove=true`.",
728 target = rel.target,
729 from_id = entity.id,
730 ),
731 });
732 }
733 }
734 }
735
736 let auto_ts_field = schema.metadata_fields.iter().find(|f| f.auto_timestamp);
738
739 if let Some(ts_field) = auto_ts_field
740 && let Some(val) = entity.metadata.get(ts_field.key.as_str())
741 {
742 let date_str = val.to_frontmatter_string();
743 if let Some(modified_days) = parse_iso_to_days(&date_str) {
744 let days_since = today_days.saturating_sub(modified_days);
745 if days_since > schema.staleness_threshold_days as u64 {
746 stale_entities.push(StaleEntity {
747 id: entity.id.clone(),
748 title: entity.title.clone(),
749 days_since_modified: days_since,
750 });
751 }
752 }
753 }
754
755 if !issues.is_empty() {
756 let total = schema.health_required_fields.len();
762 let score = if total > 0 {
763 (total.saturating_sub(issues.len()) as f32) / (total as f32)
764 } else {
765 1.0
766 };
767
768 missing_fields.push(HealthReport {
769 id: entity.id.clone(),
770 title: entity.title.clone(),
771 score,
772 issues,
773 });
774 }
775 }
776
777 stale_entities.sort_by_key(|e| std::cmp::Reverse(e.days_since_modified));
779
780 let orphan_count = query::find_orphans_with_schemas(store, mem_schemas)
783 .into_iter()
784 .filter(|id| store.get(id).is_some_and(|e| in_scope(&e.mem)))
785 .count();
786 let leaf_entities_by_type = match mem_filter {
787 None => query::leaf_population(store, mem_schemas),
788 Some(v) => {
789 let scoped: HashMap<String, Arc<Schema>> = mem_schemas
790 .iter()
791 .filter(|(mem, _)| mem.as_str() == v)
792 .map(|(mem, s)| (mem.clone(), s.clone()))
793 .collect();
794 query::leaf_population(store, &scoped)
795 }
796 };
797 let stub_count = query::find_stubs(store)
798 .iter()
799 .filter(|(id, _)| store.get(id).is_some_and(|e| in_scope(&e.mem)))
800 .count();
801
802 HealthSummary {
803 stale_entities,
804 missing_fields,
805 orphan_count,
806 stub_count,
807 warnings: Vec::new(),
808 quarantined: Vec::new(),
809 load_errors: Vec::new(),
810 boot_diagnosis: None,
811 leaf_entities_by_type,
812 dangling_links: None,
813 findings: None,
814 tag_distribution: None,
815 tag_distribution_folded: None,
816 untagged_entities: None,
817 }
818}
819
820pub fn collect_tag_distribution(
834 store: &Store,
835 mem_filter: Option<&str>,
836 limit: usize,
837) -> (Vec<TagDistribution>, Vec<FoldedTag>, UntaggedStats) {
838 let mut counts: HashMap<String, (usize, HashMap<String, usize>)> = HashMap::new();
840 let mut untagged = UntaggedStats {
841 total: 0,
842 by_entity_type: HashMap::new(),
843 };
844
845 for entity in store.all_entities() {
846 if entity.stub {
847 continue;
848 }
849 if let Some(v) = mem_filter
850 && entity.mem != v
851 {
852 continue;
853 }
854
855 let tags_raw = entity
856 .metadata
857 .get("tags")
858 .and_then(|v| match v {
859 MetadataValue::String(s) => Some(s.as_str()),
860 _ => None,
861 })
862 .unwrap_or("");
863
864 let mut any_tag = false;
865 for tag in tags_raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
866 any_tag = true;
867 let entry = counts
868 .entry(tag.to_string())
869 .or_insert_with(|| (0, HashMap::new()));
870 entry.0 += 1;
871 *entry.1.entry(entity.entity_type.clone()).or_insert(0) += 1;
872 }
873 if !any_tag {
874 untagged.total += 1;
875 *untagged
876 .by_entity_type
877 .entry(entity.entity_type.clone())
878 .or_insert(0) += 1;
879 }
880 }
881
882 let mut entries: Vec<TagDistribution> = counts
884 .iter()
885 .map(|(tag, (count, by_type))| TagDistribution {
886 tag: tag.clone(),
887 count: *count,
888 by_entity_type: by_type.clone(),
889 })
890 .collect();
891 entries.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.tag.cmp(&b.tag)));
892 entries.truncate(limit);
893
894 let mut by_canonical: HashMap<String, Vec<(String, usize)>> = HashMap::new();
899 for (tag, (count, _)) in counts.iter() {
900 by_canonical
901 .entry(tag.to_lowercase())
902 .or_default()
903 .push((tag.clone(), *count));
904 }
905 let mut folded: Vec<FoldedTag> = by_canonical
906 .into_iter()
907 .filter(|(_, v)| v.len() > 1)
908 .map(|(canonical, mut variants)| {
909 variants.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
910 let total = variants.iter().map(|(_, c)| *c).sum();
911 FoldedTag {
912 canonical,
913 total,
914 variants: variants
915 .into_iter()
916 .map(|(tag, count)| TagVariant { tag, count })
917 .collect(),
918 }
919 })
920 .collect();
921 folded.sort_by(|a, b| {
922 b.total
923 .cmp(&a.total)
924 .then_with(|| a.canonical.cmp(&b.canonical))
925 });
926
927 (entries, folded, untagged)
928}
929
930pub fn collect_dangling_links(store: &Store, mem_filter: Option<&str>) -> Vec<DanglingLink> {
956 use crate::entity::parser::extract_inline_links_lenient;
957 use std::collections::HashSet;
958
959 let mut out = Vec::new();
960 for entity in store.all_entities() {
961 if entity.stub {
962 continue;
963 }
964 if let Some(v) = mem_filter
965 && entity.mem != v
966 {
967 continue;
968 }
969 let explicit_targets: HashSet<_> = entity
970 .relationships
971 .iter()
972 .map(|r| r.target.clone())
973 .collect();
974 for (section_key, section_body) in &entity.sections {
975 for target_id in extract_inline_links_lenient(section_body, &entity.mem) {
976 let target_missing = store.get(&target_id).map(|e| e.stub).unwrap_or(true);
977 let alias_orphan = !target_missing && !explicit_targets.contains(&target_id);
978 let kind = if target_missing {
982 crate::ops::DanglingLinkKind::LinkTargetMissing
983 } else {
984 crate::ops::DanglingLinkKind::LinkNotRelated
985 };
986 if target_missing || alias_orphan {
987 out.push(DanglingLink {
988 kind,
989 from: entity.id.clone(),
990 target_id: target_id.clone(),
991 target_path: target_id.path().to_string(),
992 section: Some(section_key.clone()),
993 });
994 }
995 }
996 }
997 for rel in &entity.relationships {
1014 if store.get(&rel.target).is_some() {
1015 continue;
1016 }
1017 let already_reported = out
1018 .iter()
1019 .any(|d| d.from == entity.id && d.target_id == rel.target);
1020 if already_reported {
1021 continue;
1022 }
1023 out.push(DanglingLink {
1024 kind: crate::ops::DanglingLinkKind::RelationTargetMissing,
1025 from: entity.id.clone(),
1026 target_id: rel.target.clone(),
1027 target_path: rel.target.path().to_string(),
1028 section: None,
1029 });
1030 }
1031 }
1032 out.sort_by(|a, b| {
1037 (&a.from.0, &a.target_id.0, &a.section).cmp(&(&b.from.0, &b.target_id.0, &b.section))
1038 });
1039 out
1040}
1041
1042pub fn collect_missing_required_outgoing(
1053 store: &Store,
1054 mem_filter: Option<&str>,
1055 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1056) -> Vec<MissingRequiredOutgoingReport> {
1057 let mut out = Vec::new();
1058 for entity in store.all_entities() {
1059 if entity.stub {
1060 continue;
1061 }
1062 if let Some(v) = mem_filter
1063 && entity.mem != v
1064 {
1065 continue;
1066 }
1067 let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
1068 continue;
1069 };
1070 let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
1071 continue;
1072 };
1073 if td.required_outgoing.is_empty() {
1074 continue;
1075 }
1076 let unsatisfied = unsatisfied_required_outgoing(entity, td);
1077 if unsatisfied.is_empty() {
1078 continue;
1079 }
1080 out.push(MissingRequiredOutgoingReport {
1081 id: entity.id.clone(),
1082 title: entity.title.clone(),
1083 entity_type: entity.entity_type.clone(),
1084 mem: entity.mem.clone(),
1085 missing: unsatisfied,
1086 });
1087 }
1088 out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
1089 out
1090}
1091
1092pub fn unsatisfied_required_outgoing(
1100 entity: &crate::entity::Entity,
1101 td: &TypeDefinition,
1102) -> Vec<super::MissingRequiredOutgoingBlock> {
1103 td.required_outgoing
1104 .iter()
1105 .filter(|block| {
1106 if let (Some(when_field), Some(when_value)) = (&block.when_field, &block.when_value) {
1111 let armed = entity
1112 .metadata
1113 .get(when_field.as_str())
1114 .is_some_and(|v| v.to_frontmatter_string() == *when_value);
1115 if !armed {
1116 return false;
1117 }
1118 }
1119 let count = entity
1120 .relationships
1121 .iter()
1122 .filter(|rel| block.relationships.iter().any(|name| name == &rel.rel_type))
1123 .count();
1124 !block.admits(count)
1125 })
1126 .map(|block| super::MissingRequiredOutgoingBlock {
1127 relationships: block.relationships.clone(),
1128 cardinality: block.cardinality.to_string(),
1129 severity: block.severity,
1130 when_field: block.when_field.clone(),
1131 when_value: block.when_value.clone(),
1132 })
1133 .collect()
1134}
1135
1136#[derive(Debug, Clone, serde::Serialize)]
1144#[serde(tag = "kind", rename_all = "snake_case")]
1145pub enum UnsatisfiedConstraint {
1146 RequiresWhen {
1147 field: String,
1148 when_field: String,
1149 when_value: String,
1150 severity: memstead_schema::ConstraintSeverity,
1151 },
1152 Unique {
1153 fields: Vec<String>,
1154 values: Vec<String>,
1156 colliding: String,
1159 severity: memstead_schema::ConstraintSeverity,
1160 },
1161 EnumFromNeighbour {
1162 field: String,
1163 value: String,
1165 rel_type: String,
1166 section: String,
1167 severity: memstead_schema::ConstraintSeverity,
1168 },
1169 StatusPropagation {
1170 field: String,
1171 value: String,
1173 #[serde(skip_serializing_if = "Option::is_none")]
1177 rel_type: Option<String>,
1178 #[serde(skip_serializing_if = "Option::is_none")]
1180 rel_types: Option<Vec<String>>,
1181 tainted_by: String,
1184 severity: memstead_schema::ConstraintSeverity,
1185 },
1186 TransitionRequiresChecks {
1189 field: String,
1190 to_value: String,
1191 relationships: Vec<String>,
1192 direction: memstead_schema::PropagationDirection,
1193 unchecked: Vec<UncheckedRelated>,
1196 severity: memstead_schema::ConstraintSeverity,
1197 },
1198 MustReach {
1203 relationships: Vec<String>,
1204 direction: memstead_schema::ReachDirection,
1205 terminal_types: Vec<String>,
1206 #[serde(skip_serializing_if = "Option::is_none")]
1207 max_depth: Option<u32>,
1208 severity: memstead_schema::ConstraintSeverity,
1209 },
1210}
1211
1212impl UnsatisfiedConstraint {
1213 pub fn severity(&self) -> memstead_schema::ConstraintSeverity {
1214 match self {
1215 Self::RequiresWhen { severity, .. }
1216 | Self::Unique { severity, .. }
1217 | Self::EnumFromNeighbour { severity, .. }
1218 | Self::StatusPropagation { severity, .. }
1219 | Self::MustReach { severity, .. }
1220 | Self::TransitionRequiresChecks { severity, .. } => *severity,
1221 }
1222 }
1223
1224 pub fn describe(&self) -> String {
1226 match self {
1227 Self::RequiresWhen {
1228 field,
1229 when_field,
1230 when_value,
1231 ..
1232 } => format!(
1233 "requires_when: '{field}' is required when {when_field}={when_value} and is unset"
1234 ),
1235 Self::Unique {
1236 fields, colliding, ..
1237 } => format!(
1238 "unique: tuple ({}) collides with '{colliding}'",
1239 fields.join(", ")
1240 ),
1241 Self::EnumFromNeighbour {
1242 field,
1243 value,
1244 rel_type,
1245 section,
1246 ..
1247 } => format!(
1248 "enum_from_neighbour: '{field}' value '{value}' has no backing entry in any \
1249 `{section}` section reached via {rel_type}"
1250 ),
1251 Self::StatusPropagation {
1252 field,
1253 value,
1254 tainted_by,
1255 ..
1256 } => {
1257 format!("status_propagation: tainted by '{tainted_by}' ({field}={value})")
1258 }
1259 Self::MustReach {
1260 relationships,
1261 direction,
1262 terminal_types,
1263 max_depth,
1264 ..
1265 } => {
1266 let depth = match max_depth {
1267 Some(d) => format!(" within {d} hop(s)"),
1268 None => String::new(),
1269 };
1270 format!(
1271 "must_reach: no path via [{}] ({direction}) reaches a [{}] entity{depth}",
1272 relationships.join(", "),
1273 terminal_types.join(", ")
1274 )
1275 }
1276 Self::TransitionRequiresChecks {
1277 field,
1278 to_value,
1279 relationships,
1280 unchecked,
1281 ..
1282 } => {
1283 let listed: Vec<String> = unchecked
1284 .iter()
1285 .map(|u| format!("'{}' ({})", u.id, u.state))
1286 .collect();
1287 format!(
1288 "transition_requires_checks: {field}={to_value} requires a fresh confirming \
1289 check record on every entity related via [{}] — unconfirmed: {}",
1290 relationships.join(", "),
1291 listed.join(", ")
1292 )
1293 }
1294 }
1295 }
1296}
1297
1298#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1302pub struct UncheckedRelated {
1303 pub id: String,
1304 pub state: String,
1305}
1306
1307pub type CheckStateProvider<'a> = &'a dyn Fn(&crate::entity::Entity) -> crate::check::CheckState;
1314
1315pub fn unsatisfied_constraints(
1339 store: &Store,
1340 entity: &crate::entity::Entity,
1341 td: &TypeDefinition,
1342 exclude: Option<&crate::entity::EntityId>,
1343 checks: Option<CheckStateProvider<'_>>,
1344) -> Vec<UnsatisfiedConstraint> {
1345 use memstead_schema::ConstraintDef;
1346 td.constraints
1347 .iter()
1348 .filter_map(|c| match c {
1349 ConstraintDef::RequiresWhen {
1350 field,
1351 when_field,
1352 when_value,
1353 severity,
1354 } => {
1355 let triggered = entity
1356 .metadata
1357 .get(when_field.as_str())
1358 .is_some_and(|v| v.to_frontmatter_string() == *when_value);
1359 if !triggered {
1360 return None;
1361 }
1362 let satisfied = entity
1363 .metadata
1364 .get(field.as_str())
1365 .is_some_and(|v| !v.to_frontmatter_string().trim().is_empty())
1366 || entity
1367 .sections
1368 .get(field.as_str())
1369 .is_some_and(|body| !body.trim().is_empty());
1370 if satisfied {
1371 return None;
1372 }
1373 Some(UnsatisfiedConstraint::RequiresWhen {
1374 field: field.clone(),
1375 when_field: when_field.clone(),
1376 when_value: when_value.clone(),
1377 severity: *severity,
1378 })
1379 }
1380 ConstraintDef::Unique { fields, severity } => {
1381 let tuple = tuple_of(entity, fields)?;
1382 let mut colliding: Vec<&str> = store
1383 .all_entities()
1384 .filter(|other| {
1385 !other.stub
1386 && other.mem == entity.mem
1387 && other.entity_type == entity.entity_type
1388 && Some(&other.id) != exclude
1389 && other.id != entity.id
1390 && tuple_of(other, fields).as_ref() == Some(&tuple)
1391 })
1392 .map(|other| other.id.0.as_str())
1393 .collect();
1394 colliding.sort_unstable();
1395 let first = colliding.first()?;
1396 Some(UnsatisfiedConstraint::Unique {
1397 fields: fields.clone(),
1398 values: tuple,
1399 colliding: first.to_string(),
1400 severity: *severity,
1401 })
1402 }
1403 ConstraintDef::EnumFromNeighbour {
1404 field,
1405 rel_type,
1406 section,
1407 severity,
1408 } => {
1409 let value = entity
1410 .metadata
1411 .get(field.as_str())
1412 .map(|v| v.to_frontmatter_string())
1413 .filter(|v| !v.trim().is_empty())?;
1414 let backed = entity
1415 .relationships
1416 .iter()
1417 .filter(|rel| rel.rel_type == *rel_type)
1418 .filter_map(|rel| store.get(&rel.target))
1419 .filter_map(|neighbour| neighbour.sections.get(section.as_str()))
1420 .any(|body| bullet_entries(body).contains(&value));
1421 if backed {
1422 return None;
1423 }
1424 Some(UnsatisfiedConstraint::EnumFromNeighbour {
1425 field: field.clone(),
1426 value,
1427 rel_type: rel_type.clone(),
1428 section: section.clone(),
1429 severity: *severity,
1430 })
1431 }
1432 ConstraintDef::StatusPropagation { .. } => None,
1433 ConstraintDef::TransitionRequiresChecks {
1434 field,
1435 to_value,
1436 relationships,
1437 direction,
1438 severity,
1439 } => {
1440 let triggered = entity
1441 .metadata
1442 .get(field.as_str())
1443 .is_some_and(|v| v.to_frontmatter_string() == *to_value);
1444 if !triggered {
1445 return None;
1446 }
1447 let (_, unchecked) = transition_gate_standing(
1448 store,
1449 entity,
1450 relationships,
1451 *direction,
1452 exclude,
1453 checks,
1454 );
1455 if unchecked.is_empty() {
1456 return None;
1457 }
1458 Some(UnsatisfiedConstraint::TransitionRequiresChecks {
1459 field: field.clone(),
1460 to_value: to_value.clone(),
1461 relationships: relationships.clone(),
1462 direction: *direction,
1463 unchecked,
1464 severity: *severity,
1465 })
1466 }
1467 })
1468 .collect()
1469}
1470
1471pub fn transition_gate_standing(
1482 store: &Store,
1483 entity: &crate::entity::Entity,
1484 relationships: &[String],
1485 direction: memstead_schema::PropagationDirection,
1486 exclude: Option<&crate::entity::EntityId>,
1487 checks: Option<CheckStateProvider<'_>>,
1488) -> (usize, Vec<UncheckedRelated>) {
1489 let related: Vec<&crate::entity::Entity> = match direction {
1490 memstead_schema::PropagationDirection::Outgoing => entity
1491 .relationships
1492 .iter()
1493 .filter(|rel| relationships.contains(&rel.rel_type))
1494 .filter_map(|rel| store.get(&rel.target))
1495 .collect(),
1496 memstead_schema::PropagationDirection::Incoming => store
1497 .all_entities()
1498 .filter(|other| {
1499 other.id != entity.id
1500 && Some(&other.id) != exclude
1501 && other
1502 .relationships
1503 .iter()
1504 .any(|rel| rel.target == entity.id && relationships.contains(&rel.rel_type))
1505 })
1506 .collect(),
1507 };
1508 let total = related.len();
1509 let mut unchecked: Vec<UncheckedRelated> = related
1510 .into_iter()
1511 .filter_map(|rel_entity| {
1512 let state = match checks {
1513 Some(provider) => provider(rel_entity),
1514 None => crate::check::CheckState::NeverChecked,
1515 };
1516 if state == crate::check::CheckState::CheckedOk {
1517 None
1518 } else {
1519 Some(UncheckedRelated {
1520 id: rel_entity.id.0.clone(),
1521 state: state.as_str().to_string(),
1522 })
1523 }
1524 })
1525 .collect();
1526 unchecked.sort_by(|a, b| a.id.cmp(&b.id));
1527 (total, unchecked)
1528}
1529
1530fn tuple_of(entity: &crate::entity::Entity, fields: &[String]) -> Option<Vec<String>> {
1534 fields
1535 .iter()
1536 .map(|f| {
1537 entity
1538 .metadata
1539 .get(f.as_str())
1540 .map(|v| v.to_frontmatter_string())
1541 .filter(|v| !v.trim().is_empty())
1542 })
1543 .collect()
1544}
1545
1546fn bullet_entries(body: &str) -> Vec<String> {
1549 let masked = crate::markdown::mask_code_blocks_and_spans(body);
1554 body.lines()
1555 .zip(masked.lines())
1556 .filter_map(|(line, masked_line)| {
1557 let m = masked_line.trim_start();
1558 if m.starts_with("- ") || m.starts_with("* ") {
1559 let t = line.trim_start();
1560 t.strip_prefix("- ")
1561 .or_else(|| t.strip_prefix("* "))
1562 .map(|e| e.trim().to_string())
1563 } else {
1564 None
1565 }
1566 })
1567 .collect()
1568}
1569
1570#[derive(Debug, Clone, serde::Serialize)]
1575pub struct ConstraintFindingReport {
1576 pub id: crate::entity::EntityId,
1577 pub title: String,
1578 pub entity_type: String,
1579 pub mem: String,
1580 pub violations: Vec<UnsatisfiedConstraint>,
1581 #[serde(skip_serializing_if = "Vec::is_empty")]
1585 pub format_violations: Vec<crate::section_format::SectionFormatViolation>,
1586}
1587
1588pub fn collect_constraint_findings(
1598 store: &Store,
1599 mem_filter: Option<&str>,
1600 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1601 checks: Option<CheckStateProvider<'_>>,
1602) -> Vec<ConstraintFindingReport> {
1603 use memstead_schema::ConstraintDef;
1604 type Bucket = (
1605 Vec<UnsatisfiedConstraint>,
1606 Vec<crate::section_format::SectionFormatViolation>,
1607 );
1608 let mut by_entity: std::collections::BTreeMap<String, Bucket> = Default::default();
1609
1610 let needs_reverse = mem_schemas.values().any(|s| {
1614 s.types.values().any(|t| {
1615 t.must_reach
1616 .iter()
1617 .any(|ob| ob.direction == memstead_schema::ReachDirection::In)
1618 })
1619 });
1620 let reverse: ReverseIndex = if needs_reverse {
1621 build_reverse_index(store)
1622 } else {
1623 ReverseIndex::default()
1624 };
1625
1626 for entity in store.all_entities() {
1627 if entity.stub {
1628 continue;
1629 }
1630 if let Some(v) = mem_filter
1631 && entity.mem != v
1632 {
1633 continue;
1634 }
1635 let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
1636 continue;
1637 };
1638 let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
1639 continue;
1640 };
1641
1642 for def in &td.sections {
1647 if def.compiled_content.is_none() {
1648 continue;
1649 }
1650 let Some(body) = entity.sections.get(def.key.as_str()) else {
1651 continue;
1652 };
1653 let violations = crate::section_format::check_section_format(def, body);
1654 if !violations.is_empty() {
1655 by_entity
1656 .entry(entity.id.0.clone())
1657 .or_default()
1658 .1
1659 .extend(violations);
1660 }
1661 }
1662
1663 for ob in &td.must_reach {
1668 if !reaches_terminal(store, &reverse, &entity.id, ob) {
1669 by_entity.entry(entity.id.0.clone()).or_default().0.push(
1670 UnsatisfiedConstraint::MustReach {
1671 relationships: ob.relationships.clone(),
1672 direction: ob.direction,
1673 terminal_types: ob.terminal_types.clone(),
1674 max_depth: ob.max_depth,
1675 severity: ob.severity,
1676 },
1677 );
1678 }
1679 }
1680
1681 if td.constraints.is_empty() {
1682 continue;
1683 }
1684
1685 let violations = unsatisfied_constraints(store, entity, td, None, checks);
1687 if !violations.is_empty() {
1688 by_entity
1689 .entry(entity.id.0.clone())
1690 .or_default()
1691 .0
1692 .extend(violations);
1693 }
1694
1695 for c in &td.constraints {
1700 let ConstraintDef::StatusPropagation {
1701 field,
1702 value,
1703 rel_type,
1704 rel_types,
1705 direction,
1706 severity,
1707 } = c
1708 else {
1709 continue;
1710 };
1711 let terminal = entity
1712 .metadata
1713 .get(field.as_str())
1714 .is_some_and(|v| v.to_frontmatter_string() == *value);
1715 if !terminal {
1716 continue;
1717 }
1718 let set = c
1719 .propagation_rel_types()
1720 .expect("StatusPropagation always yields a set");
1721 for tainted in reach_transitively(store, &entity.id, &set, *direction) {
1722 if let Some(v) = mem_filter
1723 && tainted.mem() != v
1724 {
1725 continue;
1726 }
1727 by_entity.entry(tainted.0.clone()).or_default().0.push(
1728 UnsatisfiedConstraint::StatusPropagation {
1729 field: field.clone(),
1730 value: value.clone(),
1731 rel_type: rel_type.clone(),
1732 rel_types: rel_types.clone(),
1733 tainted_by: entity.id.to_string(),
1734 severity: *severity,
1735 },
1736 );
1737 }
1738 }
1739 }
1740
1741 let mut out: Vec<ConstraintFindingReport> = by_entity
1742 .into_iter()
1743 .filter_map(|(id, (violations, format_violations))| {
1744 let id = crate::entity::EntityId(id);
1745 let entity = store.get(&id)?;
1746 Some(ConstraintFindingReport {
1747 id,
1748 title: entity.title.clone(),
1749 entity_type: entity.entity_type.clone(),
1750 mem: entity.mem.clone(),
1751 violations,
1752 format_violations,
1753 })
1754 })
1755 .collect();
1756 out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
1757 out
1758}
1759
1760fn reach_transitively(
1767 store: &Store,
1768 start: &crate::entity::EntityId,
1769 rel_types: &[String],
1770 direction: memstead_schema::PropagationDirection,
1771) -> Vec<crate::entity::EntityId> {
1772 use memstead_schema::PropagationDirection;
1773 let mut seen: std::collections::HashSet<crate::entity::EntityId> =
1774 std::iter::once(start.clone()).collect();
1775 let mut frontier = vec![start.clone()];
1776 let mut reached = Vec::new();
1777 while let Some(current) = frontier.pop() {
1778 let next: Vec<crate::entity::EntityId> = match direction {
1779 PropagationDirection::Incoming => store
1780 .all_entities()
1781 .filter(|e| {
1782 e.relationships
1783 .iter()
1784 .any(|r| rel_types.iter().any(|n| n == &r.rel_type) && r.target == current)
1785 })
1786 .map(|e| e.id.clone())
1787 .collect(),
1788 PropagationDirection::Outgoing => store
1789 .get(¤t)
1790 .map(|e| {
1791 e.relationships
1792 .iter()
1793 .filter(|r| rel_types.iter().any(|n| n == &r.rel_type))
1794 .map(|r| r.target.clone())
1795 .collect()
1796 })
1797 .unwrap_or_default(),
1798 };
1799 for id in next {
1800 if seen.insert(id.clone()) {
1801 if store.get(&id).is_some_and(|e| !e.stub) {
1802 reached.push(id.clone());
1803 }
1804 frontier.push(id);
1805 }
1806 }
1807 }
1808 reached
1809}
1810
1811type ReverseIndex =
1816 std::collections::HashMap<crate::entity::EntityId, Vec<(String, crate::entity::EntityId)>>;
1817
1818fn build_reverse_index(store: &Store) -> ReverseIndex {
1819 let mut idx = ReverseIndex::default();
1820 for entity in store.all_entities() {
1821 for rel in &entity.relationships {
1822 idx.entry(rel.target.clone())
1823 .or_default()
1824 .push((rel.rel_type.clone(), entity.id.clone()));
1825 }
1826 }
1827 idx
1828}
1829
1830fn reaches_terminal(
1839 store: &Store,
1840 reverse: &ReverseIndex,
1841 start: &crate::entity::EntityId,
1842 ob: &memstead_schema::MustReach,
1843) -> bool {
1844 use memstead_schema::ReachDirection;
1845 let mut seen: std::collections::HashSet<crate::entity::EntityId> =
1846 std::iter::once(start.clone()).collect();
1847 let mut frontier = vec![start.clone()];
1848 let mut depth: u32 = 0;
1849 while !frontier.is_empty() {
1850 if let Some(max) = ob.max_depth
1851 && depth >= max
1852 {
1853 return false;
1854 }
1855 depth += 1;
1856 let mut next_frontier = Vec::new();
1857 for current in frontier {
1858 let next: Vec<crate::entity::EntityId> = match ob.direction {
1859 ReachDirection::Out => store
1860 .get(¤t)
1861 .map(|e| {
1862 e.relationships
1863 .iter()
1864 .filter(|r| ob.relationships.iter().any(|n| n == &r.rel_type))
1865 .map(|r| r.target.clone())
1866 .collect()
1867 })
1868 .unwrap_or_default(),
1869 ReachDirection::In => reverse
1870 .get(¤t)
1871 .map(|sources| {
1872 sources
1873 .iter()
1874 .filter(|(rel, _)| ob.relationships.iter().any(|n| n == rel))
1875 .map(|(_, src)| src.clone())
1876 .collect()
1877 })
1878 .unwrap_or_default(),
1879 };
1880 for id in next {
1881 if seen.insert(id.clone()) {
1882 if store.get(&id).is_some_and(|e| {
1883 !e.stub && ob.terminal_types.iter().any(|t| t == &e.entity_type)
1884 }) {
1885 return true;
1886 }
1887 next_frontier.push(id);
1888 }
1889 }
1890 }
1891 frontier = next_frontier;
1892 }
1893 false
1894}
1895
1896#[derive(Debug, Clone, serde::Serialize)]
1901pub struct SignalReport {
1902 pub id: crate::entity::EntityId,
1903 pub title: String,
1904 pub entity_type: String,
1905 pub mem: String,
1906 pub signals: Vec<super::signals::ComputedSignal>,
1908}
1909
1910impl SignalReport {
1911 pub fn has_warn(&self) -> bool {
1915 self.signals
1916 .iter()
1917 .any(|s| s.level == Some(memstead_schema::SignalLevel::Warn))
1918 }
1919}
1920
1921pub fn collect_signal_reports(
1925 store: &Store,
1926 mem_filter: Option<&str>,
1927 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1928) -> Vec<SignalReport> {
1929 let mut out = Vec::new();
1930 for entity in store.all_entities() {
1931 if entity.stub {
1932 continue;
1933 }
1934 if let Some(v) = mem_filter
1935 && entity.mem != v
1936 {
1937 continue;
1938 }
1939 let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
1940 continue;
1941 };
1942 let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
1943 continue;
1944 };
1945 if td.signals.is_empty() {
1946 continue;
1947 }
1948 let above: Vec<super::signals::ComputedSignal> =
1949 super::signals::compute_signals(store, td, &entity.id)
1950 .into_iter()
1951 .filter(|s| s.level.is_some())
1952 .collect();
1953 if above.is_empty() {
1954 continue;
1955 }
1956 out.push(SignalReport {
1957 id: entity.id.clone(),
1958 title: entity.title.clone(),
1959 entity_type: entity.entity_type.clone(),
1960 mem: entity.mem.clone(),
1961 signals: above,
1962 });
1963 }
1964 out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
1965 out
1966}
1967
1968#[derive(Debug, Clone, serde::Serialize)]
1973pub struct SchemaFormatDefect {
1974 pub schema: String,
1975 pub type_name: String,
1976 pub section: String,
1977 pub problems: Vec<String>,
1978}
1979
1980pub fn collect_schema_format_defects(
1984 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1985) -> Vec<SchemaFormatDefect> {
1986 let mut seen: std::collections::BTreeSet<String> = Default::default();
1987 let mut out = Vec::new();
1988 let mut schemas: Vec<&Arc<memstead_schema::Schema>> = mem_schemas.values().collect();
1989 schemas.sort_by_key(|s| (s.manifest.name.clone(), s.version.clone()));
1990 for schema in schemas {
1991 let schema_ref = format!("{}@{}", schema.manifest.name, schema.version);
1992 if !seen.insert(schema_ref.clone()) {
1993 continue;
1994 }
1995 for td in schema.types.values() {
1996 for section in &td.sections {
1997 if !section.format_problems.is_empty() {
1998 out.push(SchemaFormatDefect {
1999 schema: schema_ref.clone(),
2000 type_name: td.name.clone(),
2001 section: section.key.clone(),
2002 problems: section.format_problems.clone(),
2003 });
2004 }
2005 }
2006 }
2007 }
2008 out.sort_by(|a, b| {
2009 (&a.schema, &a.type_name, &a.section).cmp(&(&b.schema, &b.type_name, &b.section))
2010 });
2011 out
2012}
2013
2014#[derive(Debug, Clone, serde::Serialize)]
2022pub struct MissingRequiredOutgoingReport {
2023 pub id: crate::entity::EntityId,
2024 pub title: String,
2025 pub entity_type: String,
2026 pub mem: String,
2027 pub missing: Vec<super::MissingRequiredOutgoingBlock>,
2028}
2029
2030pub fn config_projection(
2043 engine: &crate::Engine,
2044 writable_mems: &[String],
2045 mutations: serde_json::Value,
2046 plugin: serde_json::Value,
2047) -> serde_json::Map<String, serde_json::Value> {
2048 let backend_by_mem: std::collections::HashMap<&str, (&'static str, bool)> = engine
2053 .mounts()
2054 .iter()
2055 .map(|m| {
2056 (
2057 m.mem.as_str(),
2058 (m.storage.backend_id(), m.storage.is_durable()),
2059 )
2060 })
2061 .collect();
2062 let mems_detail: Vec<serde_json::Value> = writable_mems
2063 .iter()
2064 .map(|name| {
2065 let origin = engine
2066 .mem_router()
2067 .origin_for_mem(name)
2068 .map(|o| o.kind())
2069 .unwrap_or("explicit");
2070 let mut entry = serde_json::Map::new();
2071 entry.insert("name".into(), serde_json::json!(name));
2072 entry.insert("origin".into(), serde_json::json!(origin));
2073 if let Some((storage, durable)) = backend_by_mem.get(name.as_str()).copied() {
2074 entry.insert("storage".into(), serde_json::json!(storage));
2075 entry.insert("durable".into(), serde_json::json!(durable));
2076 }
2077 let mut vcs_obj = serde_json::Map::new();
2078 if let Ok(gitdir) = engine.gitdir_for(name) {
2079 vcs_obj.insert("gitdir".into(), serde_json::json!(gitdir));
2080 }
2081 if let Ok(worktree) = engine.worktree_for(name) {
2082 vcs_obj.insert("worktree".into(), serde_json::json!(worktree));
2083 }
2084 if let Some(sha) = engine.mem_head_sha(name).ok().flatten() {
2085 vcs_obj.insert("head".into(), serde_json::json!(sha));
2086 }
2087 if !vcs_obj.is_empty() {
2088 entry.insert("vcs".into(), serde_json::Value::Object(vcs_obj));
2089 }
2090 if let Some(cfg) = engine.mem_config_for(name) {
2091 if let Some(title) = &cfg.title {
2095 entry.insert("title".into(), serde_json::json!(title));
2096 }
2097 if let Some(subject) = &cfg.subject {
2098 entry.insert("subject".into(), serde_json::json!(subject));
2099 }
2100 let guidance = serde_json::Map::from_iter(
2101 cfg.write_guidance
2102 .iter()
2103 .map(|(k, v)| (k.clone(), v.clone())),
2104 );
2105 entry.insert("write_guidance".into(), serde_json::Value::Object(guidance));
2106 let extra = serde_json::Map::from_iter(
2107 cfg.extra.iter().map(|(k, v)| (k.clone(), v.clone())),
2108 );
2109 entry.insert("extra".into(), serde_json::Value::Object(extra));
2110 }
2111 serde_json::Value::Object(entry)
2112 })
2113 .collect();
2114
2115 let mut out = serde_json::Map::new();
2116 out.insert("mems".into(), serde_json::json!(mems_detail));
2117 out.insert("mutations".into(), mutations);
2118 out.insert("plugin".into(), plugin);
2119 out
2120}
2121
2122pub fn config_projection_from_settings(
2128 settings: &crate::workspace::WorkspaceSettings,
2129) -> (serde_json::Value, serde_json::Value) {
2130 let mutations = serde_json::json!({ "require_notes": settings.mutations.require_notes });
2131 let plugin_map: serde_json::Map<String, serde_json::Value> = settings
2132 .plugin
2133 .iter()
2134 .map(|(k, v)| {
2135 (
2136 k.clone(),
2137 serde_json::to_value(v).unwrap_or(serde_json::Value::Null),
2138 )
2139 })
2140 .collect();
2141 (mutations, serde_json::Value::Object(plugin_map))
2142}
2143
2144pub(crate) fn section_heading_mismatch_issue(
2159 entity: &crate::entity::Entity,
2160 schema: &TypeDefinition,
2161 key: &str,
2162) -> Option<HealthIssue> {
2163 let def = schema.section(key)?;
2164 let derived = memstead_schema::derive_section_key(&def.heading);
2165 if derived == key {
2166 return None;
2167 }
2168 if !entity
2169 .raw_section_headings
2170 .iter()
2171 .any(|h| h == &def.heading)
2172 {
2173 return None;
2174 }
2175 let landing = match schema.catch_all_section() {
2176 Some(c) => format!(
2177 "the content was absorbed into catch-all section '{}'",
2178 c.key
2179 ),
2180 None => "the content is unreachable under any declared key".to_string(),
2181 };
2182 Some(HealthIssue {
2183 field: key.to_string(),
2184 code: super::HealthIssueCode::SectionHeadingMismatch,
2185 message: format!(
2186 "SECTION_HEADING_MISMATCH: section '{key}' is not missing — its content sits \
2187 under heading '{found}', which derives to '{derived}', not '{key}'; {landing}. \
2188 The schema's declared heading cannot round-trip to its key (expected a heading \
2189 that derives to '{key}'); fix the schema's heading/key pair — new installs of \
2190 such a schema are refused",
2191 found = def.heading,
2192 ),
2193 })
2194}
2195
2196pub fn entity_health(entity: &crate::entity::Entity, schema: &TypeDefinition) -> HealthReport {
2198 let mut issues = Vec::new();
2199
2200 for field in &schema.health_required_fields {
2201 if schema.section(field).is_some() {
2202 let content = entity.sections.get(field.as_str());
2203 if content.is_none_or(|c| c.trim().is_empty()) {
2204 if let Some(issue) = section_heading_mismatch_issue(entity, schema, field) {
2205 issues.push(issue);
2206 } else {
2207 issues.push(HealthIssue {
2208 field: field.clone(),
2209 code: super::HealthIssueCode::Missing,
2210 message: format!("required section '{field}' is empty"),
2211 });
2212 }
2213 }
2214 } else {
2215 let value = entity.metadata.get(field.as_str());
2216 if value.is_none() {
2217 issues.push(HealthIssue {
2218 field: field.clone(),
2219 code: super::HealthIssueCode::Missing,
2220 message: format!("required field '{field}' is missing"),
2221 });
2222 }
2223 }
2224 }
2225
2226 for s in schema.sections.iter().filter(|s| !s.catch_all) {
2230 if schema.health_required_fields.contains(&s.key) {
2231 continue; }
2233 let content = entity.sections.get(s.key.as_str());
2234 if content.is_none_or(|c| c.trim().is_empty())
2235 && let Some(issue) = section_heading_mismatch_issue(entity, schema, &s.key)
2236 {
2237 issues.push(issue);
2238 }
2239 }
2240
2241 let total = schema.health_required_fields.len();
2242 let score = if total > 0 {
2243 (total.saturating_sub(issues.len()) as f32) / (total as f32)
2244 } else {
2245 1.0
2246 };
2247
2248 HealthReport {
2249 id: entity.id.clone(),
2250 title: entity.title.clone(),
2251 score,
2252 issues,
2253 }
2254}
2255
2256fn days_since_epoch() -> u64 {
2267 #[cfg(target_arch = "wasm32")]
2268 {
2269 (js_sys::Date::now() / 1000.0) as u64 / 86400
2270 }
2271 #[cfg(not(target_arch = "wasm32"))]
2272 {
2273 std::time::SystemTime::now()
2274 .duration_since(std::time::UNIX_EPOCH)
2275 .unwrap_or_default()
2276 .as_secs()
2277 / 86400
2278 }
2279}
2280
2281fn parse_iso_to_days(date: &str) -> Option<u64> {
2284 let date_part = date.split('T').next()?;
2285 let parts: Vec<&str> = date_part.split('-').collect();
2286 if parts.len() != 3 {
2287 return None;
2288 }
2289 let year: u64 = parts[0].parse().ok()?;
2290 let month: u64 = parts[1].parse().ok()?;
2291 let day: u64 = parts[2].parse().ok()?;
2292 Some(ymd_to_days(year, month, day))
2293}
2294
2295fn ymd_to_days(year: u64, month: u64, day: u64) -> u64 {
2298 let y = if month <= 2 { year - 1 } else { year };
2300 let m = if month <= 2 { month + 9 } else { month - 3 };
2301 let era = y / 400;
2302 let yoe = y - era * 400;
2303 let doy = (153 * m + 2) / 5 + day - 1;
2304 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
2305 let days = era * 146097 + doe;
2306 days - 719468
2307}
2308
2309#[cfg(test)]
2310mod tests {
2311 use super::*;
2312 use crate::entity::{Entity, EntityId, MetadataValue};
2313 use crate::ops::DanglingLinkKind;
2314 use crate::store::Store;
2315 use indexmap::IndexMap;
2316 use memstead_schema::type_by_name;
2317
2318 #[test]
2323 fn bullet_entries_ignores_code() {
2324 let body = "- real-one\n- real-two\n\n```\n- fenced-ghost\n```\n\n - indented-ghost\n\nA `- span-ghost` sample.\n";
2325 let entries = bullet_entries(body);
2326 assert_eq!(
2327 entries,
2328 vec!["real-one".to_string(), "real-two".to_string()],
2329 "only prose bullets are legal values: {entries:?}"
2330 );
2331 }
2332
2333 #[test]
2337 fn bullet_entries_still_reads_prose_bullets_verbatim() {
2338 let entries = bullet_entries("- alpha\n * beta\n* `gamma`\n");
2339 assert_eq!(
2340 entries,
2341 vec![
2342 "alpha".to_string(),
2343 "beta".to_string(),
2344 "`gamma`".to_string()
2345 ]
2346 );
2347 }
2348
2349 #[test]
2355 fn declared_process_mem_pairs_and_missing_declaration_is_typed() {
2356 use crate::engine::test_helpers::folder_mount;
2357 let tmp = tempfile::TempDir::new().unwrap();
2358 let dest_dir = tmp.path().join("dest");
2359 let proc_dir = tmp.path().join("oddly-named-process");
2360 std::fs::create_dir_all(dest_dir.join(".memstead")).unwrap();
2361 std::fs::create_dir_all(&proc_dir).unwrap();
2362 std::fs::write(
2365 dest_dir.join(".memstead").join("config.json"),
2366 r#"{ "schema": "default@1.0.0", "processMem": "oddly-named-process" }"#,
2367 )
2368 .unwrap();
2369 let engine = crate::Engine::from_mounts(vec![
2370 (
2371 folder_mount("dest", dest_dir.clone()),
2372 Box::new(crate::storage::FilesystemMemWriter::new(dest_dir.clone()))
2373 as Box<dyn crate::backend::MemBackend>,
2374 ),
2375 (
2376 folder_mount("oddly-named-process", proc_dir.clone()),
2377 Box::new(crate::storage::FilesystemMemWriter::new(proc_dir))
2378 as Box<dyn crate::backend::MemBackend>,
2379 ),
2380 ])
2381 .unwrap();
2382
2383 let r = crate::ingest::resolve::resolve_process_mem(&engine, "dest", "dest-derived");
2385 assert!(r.declared && r.mounted);
2386 assert_eq!(r.mem, "oddly-named-process");
2387 let r =
2390 crate::ingest::resolve::resolve_process_mem(&engine, "oddly-named-process", "whatever");
2391 assert!(!r.declared && !r.mounted);
2392 assert_eq!(r.mem, "whatever");
2393
2394 let axis = health_open_questions_axis(&engine, Some("dest"));
2396 let process = &axis["dest"]["process"];
2397 assert_eq!(process[0]["process_mem"], "oddly-named-process", "{axis}");
2398 assert_eq!(process[0]["declared"], true, "{axis}");
2399 assert_eq!(process[0]["resolvable"], true, "{axis}");
2400
2401 std::fs::write(
2403 dest_dir.join(".memstead").join("config.json"),
2404 r#"{ "schema": "default@1.0.0", "processMem": "nowhere" }"#,
2405 )
2406 .unwrap();
2407 let engine2 = crate::Engine::from_mounts(vec![(
2408 folder_mount("dest", dest_dir.clone()),
2409 Box::new(crate::storage::FilesystemMemWriter::new(dest_dir))
2410 as Box<dyn crate::backend::MemBackend>,
2411 )])
2412 .unwrap();
2413 let axis = health_open_questions_axis(&engine2, Some("dest"));
2414 let process = &axis["dest"]["process"];
2415 assert_eq!(
2416 process[0]["finding"], "DECLARED_PROCESS_MEM_MISSING",
2417 "{axis}"
2418 );
2419 assert_eq!(process[0]["resolvable"], false, "{axis}");
2420 }
2421
2422 #[test]
2430 fn independence_gate_compares_identities_only() {
2431 use crate::engine::test_helpers::folder_mount;
2432 let tmp = tempfile::TempDir::new().unwrap();
2433 let dir = tmp.path().join("gate");
2434 std::fs::create_dir_all(&dir).unwrap();
2435 let mut engine = crate::Engine::from_mounts(vec![(
2436 folder_mount("gate", dir.clone()),
2437 Box::new(crate::storage::FilesystemMemWriter::new(dir))
2438 as Box<dyn crate::backend::MemBackend>,
2439 )])
2440 .unwrap();
2441 engine.set_workspace_root(tmp.path().to_path_buf());
2442
2443 let create = |engine: &mut crate::Engine, title: &str, identity: Option<&str>| {
2444 engine.set_identity(identity.map(str::to_string));
2445 engine
2446 .create_entity(
2447 crate::CreateEntityArgs {
2448 mem: "gate".to_string(),
2449 title: title.to_string(),
2450 entity_type: "spec".to_string(),
2451 sections: [
2452 ("identity".to_string(), "x".to_string()),
2453 ("purpose".to_string(), "y".to_string()),
2454 ]
2455 .into_iter()
2456 .collect(),
2457 metadata: Default::default(),
2458 relations: Vec::new(),
2459 anchors: Vec::new(),
2460 dry_run: false,
2461 },
2462 crate::vcs::Actor::Cli,
2463 None,
2464 None,
2465 )
2466 .unwrap()
2467 .id
2468 .0
2469 };
2470 let a = create(&mut engine, "Self Checked", Some("alice"));
2471 let b = create(&mut engine, "Independent", Some("alice"));
2472 let c = create(&mut engine, "No Author Identity", None);
2473
2474 let check = |engine: &mut crate::Engine,
2475 id: &str,
2476 identity: Option<&str>,
2477 actor: crate::vcs::Actor,
2478 client: Option<&crate::vcs::ClientId>| {
2479 engine.set_identity(identity.map(str::to_string));
2480 engine
2481 .record_check(
2482 "gate",
2483 id,
2484 crate::check::Verdict::Ok,
2485 crate::check::CheckKind::Verification,
2486 None,
2487 actor,
2488 client,
2489 )
2490 .unwrap();
2491 };
2492 let other_client = crate::vcs::ClientId {
2493 name: "claude-code".into(),
2494 version: "9.9".into(),
2495 };
2496 check(
2499 &mut engine,
2500 &a,
2501 Some("alice"),
2502 crate::vcs::Actor::Agent,
2503 Some(&other_client),
2504 );
2505 check(&mut engine, &b, Some("bob"), crate::vcs::Actor::Cli, None);
2508 check(&mut engine, &c, Some("carol"), crate::vcs::Actor::Cli, None);
2510
2511 let axis = health_checks_axis(&engine, Some("gate"));
2512 let gate = &axis["gate"]["independence"];
2513 assert_eq!(
2514 gate["self_checked"]["items"],
2515 serde_json::json!([a]),
2516 "{axis}"
2517 );
2518 assert_eq!(
2519 gate["confirmed_independent"]["items"],
2520 serde_json::json!([b]),
2521 "{axis}"
2522 );
2523 assert_eq!(
2524 gate["unconfirmable"]["items"],
2525 serde_json::json!([c]),
2526 "{axis}"
2527 );
2528
2529 let prov = engine.entity_provenance("gate", &a).unwrap();
2532 assert_eq!(
2533 prov.created_by.as_ref().and_then(|r| r.identity.as_deref()),
2534 Some("alice"),
2535 "created-by serves the declared identity"
2536 );
2537 assert_eq!(
2538 prov.last_check.as_ref().and_then(|r| r.identity.as_deref()),
2539 Some("alice"),
2540 "the check record serves the declared identity"
2541 );
2542 }
2543
2544 fn make_entity(name: &str, has_required: bool) -> Entity {
2545 let mut metadata = IndexMap::new();
2546 metadata.insert("level".into(), MetadataValue::String("M0".into()));
2547 metadata.insert("type".into(), MetadataValue::String("spec".into()));
2548 metadata.insert(
2549 "created_date".into(),
2550 MetadataValue::String("2026-01-15".into()),
2551 );
2552 metadata.insert(
2553 "last_modified".into(),
2554 MetadataValue::String("2026-04-12".into()),
2555 );
2556
2557 let mut sections = IndexMap::new();
2558 if has_required {
2559 sections.insert("identity".into(), "Has identity.".into());
2560 sections.insert("purpose".into(), "Has purpose.".into());
2561 }
2562
2563 Entity {
2564 id: EntityId::new("specs", name),
2565 title: name.into(),
2566 entity_type: "spec".into(),
2567 mem: "specs".into(),
2568 file_path: format!("{name}.md"),
2569 metadata,
2570 sections,
2571 relationships: Vec::new(),
2572 content_hash: String::new(),
2573 stub: false,
2574 stub_kind: None,
2575 heading_spans: std::collections::HashMap::new(),
2576 raw_section_headings: Vec::new(),
2577 }
2578 }
2579
2580 fn violating_type() -> std::sync::Arc<TypeDefinition> {
2584 let manifest = r#"name: debate
2585version: 0.1.0
2586description: sealed-violator fixture
2587when_to_use: health tests
2588types:
2589 - question
2590relationships:
2591 mode: strict
2592 definitions:
2593 - name: PART_OF
2594 description: hier
2595 default_weight: 3.0
2596 - name: _default
2597 description: fallback
2598 default_weight: 1.0
2599community:
2600 resolution: 1.0
2601 seed: 42
2602"#;
2603 let type_yaml = r#"name: question
2604description: t
2605when_to_use: tests
2606sections:
2607 - key: answers
2608 heading: Answers argued
2609 required: true
2610 search_weight: 10.0
2611 write_rules: []
2612 - key: notes
2613 heading: Notes
2614 required: false
2615 search_weight: 3.0
2616 catch_all: true
2617 write_rules: []
2618metadata_fields: []
2619title_weight: 100.0
2620text_fields:
2621 - answers
2622 - notes
2623hierarchy_relationship: PART_OF
2624no_self_loop_relationships: []
2625updatable_fields:
2626 - title
2627 - answers
2628 - notes
2629health_required_fields:
2630 - answers
2631staleness_threshold_days: 90
2632write_rules: []
2633"#;
2634 memstead_schema::load_schema_from_memory(
2635 manifest,
2636 &[("question".to_string(), type_yaml.to_string())],
2637 )
2638 .expect("violating schema still loads")
2639 .get_type("question")
2640 .expect("question type")
2641 }
2642
2643 #[test]
2649 fn health_distinguishes_heading_mismatch_from_missing_section() {
2650 let schema = violating_type();
2651
2652 let md = "---\ntype: question\n---\n# Q\n\n## Answers argued\n\nTwo answers.\n";
2654 let parsed = crate::entity::parser::parse_markdown(md, "q.md", &schema, "debate")
2655 .expect("parses")
2656 .entity;
2657 let report = entity_health(&parsed, &schema);
2658 let mismatch: Vec<_> = report
2659 .issues
2660 .iter()
2661 .filter(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch)
2662 .collect();
2663 assert_eq!(mismatch.len(), 1, "issues: {:?}", report.issues);
2664 let msg = &mismatch[0].message;
2665 assert!(
2666 msg.contains("'Answers argued'") && msg.contains("'answers_argued'"),
2667 "names found heading and derived key: {msg}"
2668 );
2669 assert!(
2670 msg.contains("'notes'"),
2671 "names the catch-all landing: {msg}"
2672 );
2673 assert!(
2674 !report.issues.iter().any(|i| i.message.contains("is empty")),
2675 "must not also report the section as missing: {:?}",
2676 report.issues
2677 );
2678
2679 let md_missing = "---\ntype: question\n---\n# Q2\n";
2681 let parsed_missing =
2682 crate::entity::parser::parse_markdown(md_missing, "q2.md", &schema, "debate")
2683 .expect("parses")
2684 .entity;
2685 let report_missing = entity_health(&parsed_missing, &schema);
2686 assert!(
2687 report_missing
2688 .issues
2689 .iter()
2690 .any(|i| i.code == super::super::HealthIssueCode::Missing
2691 && i.message == "required section 'answers' is empty"),
2692 "absent section keeps the missing report (structured MISSING code): {:?}",
2693 report_missing.issues
2694 );
2695 assert!(
2696 !report_missing
2697 .issues
2698 .iter()
2699 .any(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch),
2700 "no mismatch finding when the heading is not in the file"
2701 );
2702
2703 let ok_type = crate::entity::parser::parse_markdown(
2708 "---\ntype: question\n---\n# Q3\n\n## Answers\n\nfree.\n",
2709 "q3.md",
2710 &schema,
2711 "debate",
2712 )
2713 .expect("parses")
2714 .entity;
2715 let report_ok = entity_health(&ok_type, &schema);
2716 assert!(
2717 !report_ok
2718 .issues
2719 .iter()
2720 .any(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch),
2721 "mismatch fires only when the declared heading is present: {:?}",
2722 report_ok.issues
2723 );
2724 }
2725
2726 fn make_concept_entity(name: &str, with_definition: bool) -> Entity {
2727 let mut metadata = IndexMap::new();
2728 metadata.insert("type".into(), MetadataValue::String("concept".into()));
2729 metadata.insert("maturity".into(), MetadataValue::String("emerging".into()));
2730 metadata.insert(
2731 "abstraction_level".into(),
2732 MetadataValue::String("concrete".into()),
2733 );
2734 metadata.insert(
2735 "created_date".into(),
2736 MetadataValue::String("2026-01-15".into()),
2737 );
2738 metadata.insert(
2739 "last_modified".into(),
2740 MetadataValue::String("2026-04-12".into()),
2741 );
2742
2743 let mut sections = IndexMap::new();
2744 if with_definition {
2745 sections.insert("definition".into(), "Precise definition.".into());
2746 }
2747 sections.insert("explanation".into(), "How it works.".into());
2748
2749 Entity {
2750 id: EntityId::new("concepts", name),
2751 title: name.into(),
2752 entity_type: "concept".into(),
2753 mem: "concepts".into(),
2754 file_path: format!("{name}.md"),
2755 metadata,
2756 sections,
2757 relationships: Vec::new(),
2758 content_hash: String::new(),
2759 stub: false,
2760 stub_kind: None,
2761 heading_spans: std::collections::HashMap::new(),
2762 raw_section_headings: Vec::new(),
2763 }
2764 }
2765
2766 #[test]
2767 fn health_concept_missing_definition_reports_definition_field() {
2768 let schema = &type_by_name("concept").unwrap();
2769 let entity = make_concept_entity("clarity", false);
2770 let report = entity_health(&entity, schema);
2771
2772 assert!(report.issues.iter().any(|i| i.field == "definition"));
2775 assert!(!report.issues.iter().any(|i| i.field == "identity"));
2776 assert!(!report.issues.iter().any(|i| i.field == "purpose"));
2777 assert!(report.score < 1.0);
2778
2779 let healthy = make_concept_entity("clarity-ok", true);
2781 let healthy_report = entity_health(&healthy, schema);
2782 assert!(
2783 !healthy_report
2784 .issues
2785 .iter()
2786 .any(|i| i.field == "definition")
2787 );
2788 }
2789
2790 #[test]
2791 fn health_detects_missing_sections() {
2792 let schema = &type_by_name("spec").unwrap();
2793 let entity = make_entity("incomplete", false);
2794 let report = entity_health(&entity, schema);
2795 assert!(!report.issues.is_empty());
2796 assert!(report.score < 1.0);
2797 }
2798
2799 #[test]
2800 fn health_clean_entity() {
2801 let schema = &type_by_name("spec").unwrap();
2802 let entity = make_entity("complete", true);
2803 let report = entity_health(&entity, schema);
2804 let section_issues: Vec<_> = report
2806 .issues
2807 .iter()
2808 .filter(|i| i.field == "identity" || i.field == "purpose")
2809 .collect();
2810 assert!(section_issues.is_empty());
2811 }
2812
2813 #[test]
2814 fn health_summary_counts() {
2815 let mut store = Store::new();
2816 let e1 = make_entity("healthy", true);
2817 let e2 = make_entity("unhealthy", false);
2818 store.upsert(e1.id.clone(), e1);
2819 store.upsert(e2.id.clone(), e2);
2820
2821 let schema = &type_by_name("spec").unwrap();
2822 let summary = compute_health(&store, schema, &HashMap::new(), None);
2823 assert_eq!(summary.orphan_count, 2); assert_eq!(summary.stub_count, 0);
2825 }
2826
2827 #[test]
2828 fn health_surfaces_invalid_rel_shape_on_existing_edges() {
2829 use crate::entity::Relationship;
2835 use memstead_schema::SchemaRegistry;
2836
2837 let registry = SchemaRegistry::builtin();
2838 let software = registry
2839 .get("software", &semver::Version::new(0, 2, 0))
2840 .expect("software schema ships as a builtin");
2841
2842 let mut store = Store::new();
2843 let mut bad = make_entity("bad-owns-source", true);
2846 bad.entity_type = "spec".into();
2847 bad.metadata
2848 .insert("level".into(), MetadataValue::String("M0".into()));
2849 bad.metadata
2850 .insert("stability".into(), MetadataValue::String("evolving".into()));
2851 bad.relationships.push(Relationship {
2852 rel_type: "OWNS".into(),
2853 target: EntityId::new("specs", "victim"),
2854 description: None,
2855 });
2856 let mut victim = make_entity("victim", true);
2857 victim.entity_type = "spec".into();
2858 store.upsert(bad.id.clone(), bad);
2859 store.upsert(victim.id.clone(), victim);
2860
2861 let mut mem_schemas = HashMap::new();
2862 mem_schemas.insert("specs".to_string(), software);
2863
2864 let schema = &type_by_name("spec").unwrap();
2865 let summary = compute_health(&store, schema, &mem_schemas, None);
2866 let report = summary
2867 .missing_fields
2868 .iter()
2869 .find(|r| r.id.as_ref() == "specs--bad-owns-source")
2870 .expect("shape-violating entity must surface");
2871 let issue = report
2872 .issues
2873 .iter()
2874 .find(|i| i.field == "relationships" && i.message.contains("INVALID_REL_SHAPE"))
2875 .expect("shape violation must produce an INVALID_REL_SHAPE issue");
2876 assert!(
2877 issue.message.contains("OWNS"),
2878 "issue must name the offending rel_type: {}",
2879 issue.message
2880 );
2881 assert!(
2882 issue.message.contains("spec"),
2883 "issue must name the actual source type: {}",
2884 issue.message
2885 );
2886 assert!(
2887 issue.message.contains("actor"),
2888 "issue must name the allowed source type: {}",
2889 issue.message
2890 );
2891 assert!(
2892 issue.message.contains("remove=true"),
2893 "issue must surface the recovery path: {}",
2894 issue.message
2895 );
2896 }
2897
2898 #[test]
2899 fn health_does_not_flag_shape_compliant_edges() {
2900 use crate::entity::Relationship;
2903 use memstead_schema::SchemaRegistry;
2904
2905 let registry = SchemaRegistry::builtin();
2906 let software = registry
2907 .get("software", &semver::Version::new(0, 2, 0))
2908 .expect("software schema ships as a builtin");
2909
2910 let mut store = Store::new();
2911 let mut owner = make_entity("owner", true);
2912 owner.entity_type = "actor".into();
2913 owner
2914 .metadata
2915 .insert("kind".into(), MetadataValue::String("team".into()));
2916 owner
2917 .metadata
2918 .insert("active".into(), MetadataValue::Bool(true));
2919 owner
2920 .metadata
2921 .insert("handle".into(), MetadataValue::String("owner".into()));
2922 owner.relationships.push(Relationship {
2923 rel_type: "OWNS".into(),
2924 target: EntityId::new("specs", "owned"),
2925 description: None,
2926 });
2927 let mut owned = make_entity("owned", true);
2928 owned.entity_type = "spec".into();
2929 store.upsert(owner.id.clone(), owner);
2930 store.upsert(owned.id.clone(), owned);
2931
2932 let mut mem_schemas = HashMap::new();
2933 mem_schemas.insert("specs".to_string(), software);
2934
2935 let schema = &type_by_name("spec").unwrap();
2936 let summary = compute_health(&store, schema, &mem_schemas, None);
2937 let shape_issue = summary
2938 .missing_fields
2939 .iter()
2940 .flat_map(|r| r.issues.iter())
2941 .find(|i| i.message.contains("INVALID_REL_SHAPE"));
2942 assert!(
2943 shape_issue.is_none(),
2944 "shape-compliant edge must not surface a shape issue, got: {shape_issue:?}"
2945 );
2946 }
2947
2948 #[test]
2949 fn health_warns_on_undeclared_relationship_in_existing_entity() {
2950 use crate::entity::Relationship;
2951 use memstead_schema::Schema;
2952
2953 let mut store = Store::new();
2954 let mut entity = make_entity("with-bad-rel", true);
2955 entity.relationships.push(Relationship {
2961 rel_type: "CONJURES".into(),
2962 target: EntityId::new("specs", "unknown"),
2963 description: None,
2964 });
2965 store.upsert(entity.id.clone(), entity);
2966
2967 let mut mem_schemas = HashMap::new();
2968 mem_schemas.insert("specs".to_string(), Schema::builtin_default());
2969
2970 let schema = &type_by_name("spec").unwrap();
2971 let summary = compute_health(&store, schema, &mem_schemas, None);
2972 let report = summary
2973 .missing_fields
2974 .iter()
2975 .find(|r| r.id.as_ref() == "specs--with-bad-rel")
2976 .expect("entity must surface in missing_fields");
2977 let rel_issue = report
2978 .issues
2979 .iter()
2980 .find(|i| i.field == "relationships")
2981 .expect("undeclared relationship must produce an issue");
2982 assert!(
2983 rel_issue.message.contains("CONJURES"),
2984 "issue message must name the offending relationship: {}",
2985 rel_issue.message
2986 );
2987 assert!(
2988 rel_issue.message.contains("default@1.0.0"),
2989 "issue must name the schema pin: {}",
2990 rel_issue.message
2991 );
2992 }
2993
2994 fn make_entity_with_body(name: &str, section_key: &str, body: &str) -> Entity {
3001 let mut entity = make_entity(name, true);
3002 entity.sections.insert(section_key.into(), body.to_string());
3003 entity
3004 }
3005
3006 #[test]
3007 fn dangling_link_detected_after_delete() {
3008 use crate::entity::store_builder::make_stub;
3009
3010 let mut store = Store::new();
3011 let a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
3012 store.upsert(a.id.clone(), a.clone());
3013
3014 let b_id = EntityId::new("specs", "b");
3017 store.upsert(b_id.clone(), make_stub(b_id.clone()));
3018
3019 let dangling = super::collect_dangling_links(&store, None);
3020 assert_eq!(dangling.len(), 1, "exactly one dangling link expected");
3021 let d = &dangling[0];
3022 assert_eq!(d.from, a.id);
3023 assert_eq!(d.target_id, b_id);
3024 assert_eq!(d.target_path, "b");
3025 assert_eq!(d.section.as_deref(), Some("purpose"));
3026 assert_eq!(d.kind, DanglingLinkKind::LinkTargetMissing);
3027 }
3028
3029 #[test]
3037 fn the_three_dangling_conditions_are_discriminated() {
3038 use crate::entity::store_builder::make_stub;
3039
3040 let mut store = Store::new();
3041
3042 let gone = make_entity_with_body("gone-link", "purpose", "See [[absent]].");
3044 store.upsert(gone.id.clone(), gone.clone());
3045 let absent = EntityId::new("specs", "absent");
3046 store.upsert(absent.clone(), make_stub(absent.clone()));
3047
3048 let written = make_entity("written", true);
3051 store.upsert(written.id.clone(), written.clone());
3052 let unrelated = make_entity_with_body("unrelated-link", "purpose", "See [[written]].");
3053 store.upsert(unrelated.id.clone(), unrelated.clone());
3054
3055 let mut rel_source = make_entity("rel-source", true);
3059 rel_source.relationships.push(crate::entity::Relationship {
3060 rel_type: "DEPENDS_ON".to_string(),
3061 target: EntityId::new("specs", "vanished"),
3062 description: None,
3063 });
3064 store.upsert(rel_source.id.clone(), rel_source.clone());
3065
3066 let found = super::collect_dangling_links(&store, None);
3067 let kind_of = |from: &str| {
3068 found
3069 .iter()
3070 .find(|d| d.from.path() == from)
3071 .unwrap_or_else(|| panic!("no dangling link from {from}: {found:?}"))
3072 .kind
3073 };
3074 assert_eq!(kind_of("gone-link"), DanglingLinkKind::LinkTargetMissing);
3075 assert_eq!(kind_of("unrelated-link"), DanglingLinkKind::LinkNotRelated);
3076 assert_eq!(
3077 kind_of("rel-source"),
3078 DanglingLinkKind::RelationTargetMissing
3079 );
3080
3081 let codes: std::collections::BTreeSet<_> = found.iter().map(|d| d.kind.code()).collect();
3084 let repairs: std::collections::BTreeSet<_> =
3085 found.iter().map(|d| d.kind.repair()).collect();
3086 assert_eq!(codes.len(), 3, "{found:?}");
3087 assert_eq!(repairs.len(), 3, "{found:?}");
3088 }
3089
3090 #[test]
3097 fn a_relationship_row_pointing_at_a_stub_is_still_not_flagged() {
3098 use crate::entity::store_builder::make_stub;
3099
3100 let mut store = Store::new();
3101 let stub_id = EntityId::new("specs", "forward");
3102 store.upsert(stub_id.clone(), make_stub(stub_id.clone()));
3103
3104 let mut source = make_entity("forward-ref", true);
3105 source.relationships.push(crate::entity::Relationship {
3106 rel_type: "DEPENDS_ON".to_string(),
3107 target: stub_id.clone(),
3108 description: None,
3109 });
3110 store.upsert(source.id.clone(), source.clone());
3111
3112 assert!(
3113 super::collect_dangling_links(&store, None).is_empty(),
3114 "a forward reference through the relationships table stays unflagged"
3115 );
3116
3117 let body = make_entity_with_body("body-ref", "purpose", "See [[forward]].");
3120 store.upsert(body.id.clone(), body.clone());
3121 let found = super::collect_dangling_links(&store, None);
3122 assert_eq!(found.len(), 1, "{found:?}");
3123 assert_eq!(found[0].from, body.id);
3124 assert_eq!(found[0].kind, DanglingLinkKind::LinkTargetMissing);
3125 }
3126
3127 #[test]
3133 fn dangling_links_and_stubs_serve_in_deterministic_order() {
3134 use crate::entity::store_builder::make_stub;
3135
3136 let build = || {
3137 let mut store = Store::new();
3138 for name in ["zeta", "alpha", "mid"] {
3140 let e = make_entity_with_body(
3141 name,
3142 "purpose",
3143 &format!("See [[gone-{name}]] and [[lost-{name}]]."),
3144 );
3145 store.upsert(e.id.clone(), e);
3146 }
3147 for name in ["zeta", "alpha", "mid"] {
3148 for pre in ["gone", "lost"] {
3149 let id = EntityId::new("specs", &format!("{pre}-{name}"));
3150 store.upsert(id.clone(), make_stub(id));
3151 }
3152 }
3153 store
3154 };
3155
3156 let store_a = build();
3157 let store_b = build();
3158
3159 let key =
3160 |d: &super::DanglingLink| (d.from.0.clone(), d.target_id.0.clone(), d.section.clone());
3161 let dangling_a: Vec<_> = super::collect_dangling_links(&store_a, None)
3162 .iter()
3163 .map(key)
3164 .collect();
3165 let dangling_b: Vec<_> = super::collect_dangling_links(&store_b, None)
3166 .iter()
3167 .map(key)
3168 .collect();
3169 assert_eq!(dangling_a, dangling_b, "identical stores, identical order");
3170 let mut sorted = dangling_a.clone();
3171 sorted.sort();
3172 assert_eq!(dangling_a, sorted, "served pre-sorted by (from, target)");
3173 assert_eq!(dangling_a.len(), 6);
3174
3175 let stub_ids = |s: &Store| -> Vec<String> {
3176 crate::graph::query::find_stubs(s)
3177 .into_iter()
3178 .map(|(id, _)| id.0)
3179 .collect()
3180 };
3181 let stubs_a = stub_ids(&store_a);
3182 assert_eq!(stubs_a, stub_ids(&store_b), "stub order is deterministic");
3183 let mut sorted = stubs_a.clone();
3184 sorted.sort();
3185 assert_eq!(stubs_a, sorted, "stubs served pre-sorted by id");
3186 assert_eq!(stubs_a.len(), 6);
3187 }
3188
3189 #[test]
3190 fn dangling_link_does_not_flag_stub_target_of_explicit_relationship() {
3191 use crate::entity::Relationship;
3192 use crate::entity::store_builder::make_stub;
3193
3194 let mut store = Store::new();
3195 let mut a = make_entity("a", true);
3198 let b_id = EntityId::new("specs", "b");
3199 a.relationships.push(Relationship {
3200 rel_type: "REFERENCES".into(),
3201 target: b_id.clone(),
3202 description: None,
3203 });
3204 store.upsert(a.id.clone(), a);
3205 store.upsert(b_id.clone(), make_stub(b_id));
3206
3207 let dangling = super::collect_dangling_links(&store, None);
3208 assert!(
3209 dangling.is_empty(),
3210 "explicit relationships to stubs are valid by design \
3211 (stubs are first-class placeholders); only inline-body \
3212 wiki-links to stubs must surface"
3213 );
3214 }
3215
3216 #[test]
3217 fn dangling_link_does_not_flag_real_reference() {
3218 use crate::entity::Relationship;
3219
3220 let mut store = Store::new();
3221 let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
3222 a.relationships.push(Relationship {
3224 rel_type: "REFERENCES".into(),
3225 target: EntityId::new("specs", "b"),
3226 description: None,
3227 });
3228 let b = make_entity("b", true);
3229 store.upsert(a.id.clone(), a);
3230 store.upsert(b.id.clone(), b);
3231
3232 let dangling = super::collect_dangling_links(&store, None);
3233 assert!(
3234 dangling.is_empty(),
3235 "real reference backed by relation — not dangling, not alias-orphan"
3236 );
3237 }
3238
3239 #[test]
3244 fn dangling_link_relationship_section_target_absent() {
3245 use crate::entity::Relationship;
3246
3247 let mut store = Store::new();
3248 let mut a = make_entity("a", true);
3249 a.relationships.push(Relationship {
3252 rel_type: "DEPENDS_ON".into(),
3253 target: EntityId::new("specs", "gone"),
3254 description: None,
3255 });
3256 store.upsert(a.id.clone(), a.clone());
3257
3258 let dangling = super::collect_dangling_links(&store, None);
3259 assert_eq!(
3260 dangling.len(),
3261 1,
3262 "exactly one relationship-section dangler"
3263 );
3264 let d = &dangling[0];
3265 assert_eq!(d.from, a.id);
3266 assert_eq!(d.target_id, EntityId::new("specs", "gone"));
3267 assert!(
3268 d.section.is_none(),
3269 "relationship-section danglers ship `section: None`, got {:?}",
3270 d.section
3271 );
3272 }
3273
3274 #[test]
3279 fn dangling_link_relationship_section_stub_target_not_flagged() {
3280 use crate::entity::Relationship;
3281 use crate::entity::store_builder::make_stub;
3282
3283 let mut store = Store::new();
3284 let mut a = make_entity("a", true);
3285 let b_id = EntityId::new("specs", "b");
3286 a.relationships.push(Relationship {
3287 rel_type: "DEPENDS_ON".into(),
3288 target: b_id.clone(),
3289 description: None,
3290 });
3291 store.upsert(a.id.clone(), a);
3292 store.upsert(b_id.clone(), make_stub(b_id));
3293
3294 let dangling = super::collect_dangling_links(&store, None);
3295 assert!(
3296 dangling.is_empty(),
3297 "relationship targets that resolve to stubs are forward-references, not corruption"
3298 );
3299 }
3300
3301 #[test]
3308 fn dangling_link_dedups_across_body_and_relations() {
3309 use crate::entity::Relationship;
3310 use crate::entity::store_builder::make_stub;
3311
3312 let mut store = Store::new();
3313 let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
3314 let b_id = EntityId::new("specs", "b");
3315 a.relationships.push(Relationship {
3316 rel_type: "REFERENCES".into(),
3317 target: b_id.clone(),
3318 description: None,
3319 });
3320 store.upsert(a.id.clone(), a.clone());
3321 store.upsert(b_id.clone(), make_stub(b_id.clone()));
3322
3323 let dangling = super::collect_dangling_links(&store, None);
3324 assert_eq!(
3325 dangling.len(),
3326 1,
3327 "body + relations both pointing at the same stub should dedup"
3328 );
3329 assert!(dangling[0].section.is_some(), "body axis wins the dedup");
3332 }
3333
3334 #[test]
3335 fn dangling_links_scope_to_mem_filter() {
3336 use crate::entity::store_builder::make_stub;
3337
3338 let mut store = Store::new();
3339
3340 let a = make_entity_with_body("a", "purpose", "Refers to [[gone]] in prose.");
3342 store.upsert(a.id.clone(), a);
3343 let gone_specs = EntityId::new("specs", "gone");
3344 store.upsert(gone_specs.clone(), make_stub(gone_specs));
3345
3346 let mut x = make_entity("x", true);
3348 x.id = EntityId::new("web", "x");
3349 x.mem = "web".into();
3350 x.file_path = "x.md".into();
3351 x.sections
3352 .insert("purpose".into(), "Refers to [[gone]] in prose.".into());
3353 store.upsert(x.id.clone(), x);
3354 let gone_web = EntityId::new("web", "gone");
3355 store.upsert(gone_web.clone(), make_stub(gone_web));
3356
3357 let all = super::collect_dangling_links(&store, None);
3358 assert_eq!(all.len(), 2);
3359
3360 let specs_only = super::collect_dangling_links(&store, Some("specs"));
3361 assert_eq!(specs_only.len(), 1);
3362 assert_eq!(specs_only[0].from.mem(), "specs");
3363
3364 let web_only = super::collect_dangling_links(&store, Some("web"));
3365 assert_eq!(web_only.len(), 1);
3366 assert_eq!(web_only[0].from.mem(), "web");
3367 }
3368
3369 #[test]
3370 fn parse_iso_date() {
3371 let days = parse_iso_to_days("2026-04-12").unwrap();
3372 assert!(days > 0);
3373
3374 let days_with_time = parse_iso_to_days("2026-04-12T10:00:00Z").unwrap();
3375 assert_eq!(days, days_with_time);
3376 }
3377
3378 #[test]
3379 fn ymd_roundtrip() {
3380 let days = ymd_to_days(2026, 1, 1);
3382 assert!(days > 20000); }
3384
3385 fn make_entity_with_tags(name: &str, mem: &str, entity_type: &str, tags: &str) -> Entity {
3390 let mut e = make_entity(name, true);
3391 e.id = EntityId::new(mem, name);
3392 e.mem = mem.into();
3393 e.entity_type = entity_type.into();
3394 e.metadata
3395 .insert("tags".into(), MetadataValue::String(tags.into()));
3396 e
3397 }
3398
3399 fn make_entity_no_tags(name: &str) -> Entity {
3400 make_entity(name, true)
3401 }
3402
3403 #[test]
3404 fn tag_distribution_aggregates_across_entities() {
3405 let mut store = Store::new();
3406 let a = make_entity_with_tags("a", "specs", "spec", "decision, plan");
3407 let b = make_entity_with_tags("b", "specs", "spec", "decision, plan");
3408 let c = make_entity_with_tags("c", "specs", "spec", "plan");
3409 store.upsert(a.id.clone(), a);
3410 store.upsert(b.id.clone(), b);
3411 store.upsert(c.id.clone(), c);
3412
3413 let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
3414 assert_eq!(dist.len(), 2);
3415 assert_eq!(dist[0].tag, "plan");
3416 assert_eq!(dist[0].count, 3);
3417 assert_eq!(dist[0].by_entity_type.get("spec"), Some(&3));
3418 assert_eq!(dist[1].tag, "decision");
3419 assert_eq!(dist[1].count, 2);
3420 assert_eq!(untagged.total, 0);
3421 }
3422
3423 #[test]
3424 fn tag_distribution_case_sensitive() {
3425 let mut store = Store::new();
3426 let a = make_entity_with_tags("a", "specs", "spec", "Decision");
3427 let b = make_entity_with_tags("b", "specs", "spec", "decision");
3428 store.upsert(a.id.clone(), a);
3429 store.upsert(b.id.clone(), b);
3430
3431 let (dist, folded, _untagged) = collect_tag_distribution(&store, None, 10);
3432 assert_eq!(dist.len(), 2, "`decision` and `Decision` stay distinct");
3433 let tags: std::collections::HashSet<&str> = dist.iter().map(|t| t.tag.as_str()).collect();
3434 assert!(tags.contains("decision"));
3435 assert!(tags.contains("Decision"));
3436
3437 assert_eq!(folded.len(), 1);
3439 assert_eq!(folded[0].canonical, "decision");
3440 assert_eq!(folded[0].total, 2);
3441 assert_eq!(folded[0].variants.len(), 2);
3442 }
3443
3444 #[test]
3445 fn untagged_entities_counts_missing_and_empty() {
3446 let mut store = Store::new();
3447 let a = make_entity_no_tags("a"); let b = make_entity_with_tags("b", "specs", "spec", "");
3449 let c = make_entity_with_tags("c", "specs", "spec", " , , ");
3450 store.upsert(a.id.clone(), a);
3451 store.upsert(b.id.clone(), b);
3452 store.upsert(c.id.clone(), c);
3453
3454 let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
3455 assert!(dist.is_empty(), "no effective tags → empty distribution");
3456 assert_eq!(untagged.total, 3);
3457 assert_eq!(untagged.by_entity_type.get("spec"), Some(&3));
3458 }
3459
3460 #[test]
3461 fn tag_distribution_respects_mem_filter() {
3462 let mut store = Store::new();
3463 let a = make_entity_with_tags("a", "specs", "spec", "decision");
3464 let b = make_entity_with_tags("b", "memos", "memo", "observation");
3465 let c = make_entity_no_tags("c");
3466 store.upsert(a.id.clone(), a);
3467 store.upsert(b.id.clone(), b);
3468 store.upsert(c.id.clone(), c);
3469
3470 let (dist, _folded, untagged) = collect_tag_distribution(&store, Some("memos"), 10);
3471 assert_eq!(dist.len(), 1);
3472 assert_eq!(dist[0].tag, "observation");
3473 assert_eq!(untagged.total, 0, "untagged scoped to filter mem");
3474 }
3475
3476 #[test]
3477 fn tag_distribution_respects_limit() {
3478 let mut store = Store::new();
3479 for (name, tag) in [
3480 ("a", "t-alpha"),
3481 ("b", "t-beta"),
3482 ("c", "t-gamma"),
3483 ("d", "t-delta"),
3484 ("e", "t-epsilon"),
3485 ] {
3486 let e = make_entity_with_tags(name, "specs", "spec", tag);
3487 store.upsert(e.id.clone(), e);
3488 }
3489
3490 let (dist, _folded, _untagged) = collect_tag_distribution(&store, None, 3);
3491 assert_eq!(dist.len(), 3);
3492 assert_eq!(dist[0].tag, "t-alpha");
3495 assert_eq!(dist[1].tag, "t-beta");
3496 assert_eq!(dist[2].tag, "t-delta");
3497 }
3498
3499 fn required_outgoing_fixture_schema() -> std::sync::Arc<memstead_schema::Schema> {
3506 let manifest = r#"name: tests-ro-health
3507version: 0.1.0
3508description: required_outgoing health test schema
3509when_to_use: tests
3510types:
3511 - decision
3512 - note
3513relationships:
3514 mode: strict
3515 definitions:
3516 - name: PART_OF
3517 description: Hier
3518 default_weight: 3.0
3519 acyclic: true
3520 - name: CHOSEN
3521 description: ch
3522 default_weight: 3.0
3523 - name: REJECTED
3524 description: rj
3525 default_weight: 2.0
3526 - name: REFERENCES
3527 description: ref
3528 default_weight: 0.5
3529 - name: _default
3530 description: Fallback
3531 default_weight: 1.0
3532community:
3533 resolution: 1.0
3534 seed: 42
3535"#;
3536 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";
3537 let decision_yaml = format!(
3538 "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",
3539 );
3540 let note_yaml = format!("name: note\ndescription: t\nwhen_to_use: Here\n{body_section}",);
3541 std::sync::Arc::new(
3542 memstead_schema::load_schema_from_memory(
3543 manifest,
3544 &[
3545 ("decision".to_string(), decision_yaml),
3546 ("note".to_string(), note_yaml),
3547 ],
3548 )
3549 .expect("ro fixture schema must parse"),
3550 )
3551 }
3552
3553 fn make_typed_entity(mem: &str, slug: &str, entity_type: &str) -> crate::entity::Entity {
3554 use crate::entity::MetadataValue;
3555 let mut metadata = IndexMap::new();
3556 metadata.insert("type".into(), MetadataValue::String(entity_type.into()));
3557 let mut sections = IndexMap::new();
3558 sections.insert("body".into(), "Body.".into());
3559 crate::entity::Entity {
3560 id: EntityId::new(mem, slug),
3561 title: slug.to_string(),
3562 entity_type: entity_type.into(),
3563 mem: mem.into(),
3564 file_path: format!("{slug}.md"),
3565 metadata,
3566 sections,
3567 relationships: Vec::new(),
3568 content_hash: String::new(),
3569 stub: false,
3570 stub_kind: None,
3571 heading_spans: std::collections::HashMap::new(),
3572 raw_section_headings: Vec::new(),
3573 }
3574 }
3575
3576 #[test]
3577 fn missing_required_outgoing_collects_violators_only() {
3578 let schema = required_outgoing_fixture_schema();
3579 let mut store = Store::new();
3580 let mut violator = make_typed_entity("plan", "stalled", "decision");
3583 let mut satisfied = make_typed_entity("plan", "wired", "decision");
3584 let opt_a = make_typed_entity("plan", "a", "note");
3585 let opt_b = make_typed_entity("plan", "b", "note");
3586 let happy_note = make_typed_entity("plan", "side", "note");
3587 satisfied.relationships.push(crate::entity::Relationship {
3588 rel_type: "CHOSEN".into(),
3589 target: opt_a.id.clone(),
3590 description: None,
3591 });
3592 satisfied.relationships.push(crate::entity::Relationship {
3593 rel_type: "REJECTED".into(),
3594 target: opt_b.id.clone(),
3595 description: None,
3596 });
3597 for e in [violator.clone(), satisfied, opt_a, opt_b, happy_note] {
3598 store.upsert(e.id.clone(), e);
3599 }
3600
3601 let mut mem_schemas = HashMap::new();
3602 mem_schemas.insert("plan".to_string(), schema);
3603
3604 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
3605 assert_eq!(
3606 reports.len(),
3607 1,
3608 "exactly one violator (the empty decision); got {reports:?}"
3609 );
3610 let r = &reports[0];
3611 assert_eq!(r.id, violator.id);
3612 assert_eq!(r.entity_type, "decision");
3613 assert_eq!(r.mem, "plan");
3614 assert_eq!(r.missing.len(), 2);
3615 let names: Vec<&str> = r
3616 .missing
3617 .iter()
3618 .flat_map(|b| b.relationships.iter().map(String::as_str))
3619 .collect();
3620 assert!(names.contains(&"CHOSEN"));
3621 assert!(names.contains(&"REJECTED"));
3622
3623 violator.relationships.push(crate::entity::Relationship {
3625 rel_type: "CHOSEN".into(),
3626 target: EntityId::new("plan", "x"),
3627 description: None,
3628 });
3629 }
3630
3631 #[test]
3632 fn missing_required_outgoing_respects_mem_filter() {
3633 let schema = required_outgoing_fixture_schema();
3636 let mut store = Store::new();
3637 let v_a = make_typed_entity("alpha", "stalled", "decision");
3638 let v_b = make_typed_entity("beta", "stalled", "decision");
3639 store.upsert(v_a.id.clone(), v_a);
3640 store.upsert(v_b.id.clone(), v_b.clone());
3641
3642 let mut mem_schemas = HashMap::new();
3643 mem_schemas.insert("alpha".to_string(), schema.clone());
3644 mem_schemas.insert("beta".to_string(), schema);
3645
3646 let alpha_only = collect_missing_required_outgoing(&store, Some("alpha"), &mem_schemas);
3647 assert_eq!(alpha_only.len(), 1);
3648 assert_eq!(alpha_only[0].mem, "alpha");
3649
3650 let both = collect_missing_required_outgoing(&store, None, &mem_schemas);
3651 assert_eq!(both.len(), 2);
3652 }
3653
3654 #[test]
3655 fn missing_required_outgoing_skips_stubs_and_unschemaed_mems() {
3656 let schema = required_outgoing_fixture_schema();
3659 let mut store = Store::new();
3660 let mut stub = make_typed_entity("plan", "ghost", "");
3661 stub.stub = true;
3662 stub.entity_type = String::new();
3663 let other = make_typed_entity("uncharted", "lonely", "decision");
3664 store.upsert(stub.id.clone(), stub);
3665 store.upsert(other.id.clone(), other);
3666
3667 let mut mem_schemas = HashMap::new();
3668 mem_schemas.insert("plan".to_string(), schema);
3669
3670 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
3671 assert!(
3672 reports.is_empty(),
3673 "stub (no schema lookup) and unschemaed mem must be skipped; got {reports:?}",
3674 );
3675 }
3676
3677 #[test]
3682 fn missing_required_outgoing_conditional_blocks_arm_on_trigger() {
3683 use crate::entity::MetadataValue;
3684 let manifest = r#"name: tests-ro-cond
3685version: 0.1.0
3686description: conditional required_outgoing health test schema
3687when_to_use: tests
3688types:
3689 - task
3690relationships:
3691 mode: strict
3692 definitions:
3693 - name: PART_OF
3694 description: Hier
3695 default_weight: 3.0
3696 - name: _default
3697 description: Fallback
3698 default_weight: 1.0
3699community:
3700 resolution: 1.0
3701 seed: 42
3702"#;
3703 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";
3704 let schema = std::sync::Arc::new(
3705 memstead_schema::load_schema_from_memory(
3706 manifest,
3707 &[("task".to_string(), task_yaml.to_string())],
3708 )
3709 .expect("conditional ro fixture schema must parse"),
3710 );
3711
3712 let mut store = Store::new();
3713 let mut armed = make_typed_entity("plan", "armed", "task");
3714 armed
3715 .metadata
3716 .insert("status".into(), MetadataValue::String("checked".into()));
3717 let mut other_value = make_typed_entity("plan", "quiet", "task");
3718 other_value
3719 .metadata
3720 .insert("status".into(), MetadataValue::String("open".into()));
3721 let unset = make_typed_entity("plan", "blank", "task");
3722 let parent = make_typed_entity("plan", "parent", "task");
3723 let mut satisfied = make_typed_entity("plan", "wired", "task");
3724 satisfied
3725 .metadata
3726 .insert("status".into(), MetadataValue::String("checked".into()));
3727 satisfied.relationships.push(crate::entity::Relationship {
3728 rel_type: "PART_OF".into(),
3729 target: parent.id.clone(),
3730 description: None,
3731 });
3732 for e in [armed.clone(), other_value, unset, parent, satisfied] {
3733 store.upsert(e.id.clone(), e);
3734 }
3735
3736 let mut mem_schemas = HashMap::new();
3737 mem_schemas.insert("plan".to_string(), schema);
3738
3739 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
3740 assert_eq!(
3741 reports.len(),
3742 1,
3743 "only the armed edge-less entity is reported; got {reports:?}"
3744 );
3745 let r = &reports[0];
3746 assert_eq!(r.id, armed.id);
3747 assert_eq!(r.missing.len(), 1);
3748 assert_eq!(r.missing[0].when_field.as_deref(), Some("status"));
3749 assert_eq!(r.missing[0].when_value.as_deref(), Some("checked"));
3750 }
3751
3752 fn must_reach_schema(
3760 claim_extra: &str,
3761 inference_extra: &str,
3762 ) -> std::sync::Arc<memstead_schema::Schema> {
3763 let manifest = r#"name: tests-must-reach
3764version: 0.1.0
3765description: must_reach health test schema
3766when_to_use: tests
3767types:
3768 - claim
3769 - inference
3770 - evidence
3771relationships:
3772 mode: strict
3773 definitions:
3774 - name: GROUNDS
3775 description: g
3776 default_weight: 3.0
3777 - name: CONCLUDES
3778 description: c
3779 default_weight: 3.0
3780 - name: PART_OF
3781 description: hier
3782 default_weight: 1.0
3783 - name: _default
3784 description: fallback
3785 default_weight: 1.0
3786community:
3787 resolution: 1.0
3788 seed: 42
3789"#;
3790 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";
3791 let claim = format!("name: claim\ndescription: t\nwhen_to_use: Here\n{body}{claim_extra}");
3792 let inference =
3793 format!("name: inference\ndescription: t\nwhen_to_use: Here\n{body}{inference_extra}");
3794 let evidence = format!("name: evidence\ndescription: t\nwhen_to_use: Here\n{body}");
3795 std::sync::Arc::new(
3796 memstead_schema::load_schema_from_memory(
3797 manifest,
3798 &[
3799 ("claim".to_string(), claim),
3800 ("inference".to_string(), inference),
3801 ("evidence".to_string(), evidence),
3802 ],
3803 )
3804 .expect("must_reach fixture schema must parse"),
3805 )
3806 }
3807
3808 fn link(from: &mut crate::entity::Entity, rel: &str, to: &crate::entity::EntityId) {
3809 from.relationships.push(crate::entity::Relationship {
3810 rel_type: rel.into(),
3811 target: to.clone(),
3812 description: None,
3813 });
3814 }
3815
3816 fn must_reach_violations(r: &ConstraintFindingReport) -> Vec<&UnsatisfiedConstraint> {
3817 r.violations
3818 .iter()
3819 .filter(|v| matches!(v, UnsatisfiedConstraint::MustReach { .. }))
3820 .collect()
3821 }
3822
3823 const CLAIM_GROUNDS_EVIDENCE: &str = "must_reach:\n - relationships: [GROUNDS]\n direction: out\n terminal_types: [evidence]\n";
3824
3825 #[test]
3829 fn must_reach_conforming_path_silent_gap_reported() {
3830 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
3831 let mut store = Store::new();
3832 let ev = make_typed_entity("arg", "ev", "evidence");
3833 let mut direct = make_typed_entity("arg", "direct", "claim");
3834 link(&mut direct, "GROUNDS", &ev.id);
3835 let mut mid = make_typed_entity("arg", "mid", "claim");
3836 let mut chained = make_typed_entity("arg", "chained", "claim");
3837 link(&mut chained, "GROUNDS", &mid.id);
3838 link(&mut mid, "GROUNDS", &ev.id);
3839 let floating = make_typed_entity("arg", "floating", "claim");
3840 for e in [ev, direct, mid, chained, floating.clone()] {
3841 store.upsert(e.id.clone(), e);
3842 }
3843 let mut mem_schemas = HashMap::new();
3844 mem_schemas.insert("arg".to_string(), schema);
3845
3846 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
3847 assert_eq!(reports.len(), 1, "only the pathless claim: {reports:?}");
3848 assert_eq!(reports[0].id, floating.id);
3849 let v = must_reach_violations(&reports[0]);
3850 assert_eq!(v.len(), 1);
3851 let UnsatisfiedConstraint::MustReach {
3852 relationships,
3853 direction,
3854 terminal_types,
3855 max_depth,
3856 ..
3857 } = v[0]
3858 else {
3859 panic!("expected must_reach finding");
3860 };
3861 assert_eq!(relationships, &vec!["GROUNDS".to_string()]);
3862 assert_eq!(*direction, memstead_schema::ReachDirection::Out);
3863 assert_eq!(terminal_types, &vec!["evidence".to_string()]);
3864 assert_eq!(*max_depth, None);
3865 }
3866
3867 #[test]
3872 fn must_reach_one_hop_incoming_floating_leap() {
3873 let schema = must_reach_schema(
3874 "",
3875 "must_reach:\n - relationships: [GROUNDS]\n direction: in\n terminal_types: [claim]\n max_depth: 1\n",
3876 );
3877 let mut store = Store::new();
3878 let leap = make_typed_entity("arg", "leap", "inference");
3879 let grounded = make_typed_entity("arg", "grounded", "inference");
3880 let mut premise = make_typed_entity("arg", "premise", "claim");
3881 link(&mut premise, "GROUNDS", &grounded.id);
3882 for e in [leap.clone(), grounded, premise] {
3883 store.upsert(e.id.clone(), e);
3884 }
3885 let mut mem_schemas = HashMap::new();
3886 mem_schemas.insert("arg".to_string(), schema);
3887
3888 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
3889 assert_eq!(reports.len(), 1, "only the floating leap: {reports:?}");
3890 assert_eq!(reports[0].id, leap.id);
3891 }
3892
3893 #[test]
3896 fn must_reach_stub_and_non_terminal_chains_then_cleared() {
3897 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
3898 let mut store = Store::new();
3899 let mut stub_ev = make_typed_entity("arg", "ghost", "evidence");
3900 stub_ev.stub = true;
3901 let mut to_stub = make_typed_entity("arg", "to-stub", "claim");
3902 link(&mut to_stub, "GROUNDS", &stub_ev.id);
3903 let dead_end = make_typed_entity("arg", "dead-end", "claim");
3904 let mut to_claim = make_typed_entity("arg", "to-claim", "claim");
3905 link(&mut to_claim, "GROUNDS", &dead_end.id);
3906 for e in [stub_ev, to_stub.clone(), dead_end, to_claim.clone()] {
3907 store.upsert(e.id.clone(), e);
3908 }
3909 let mut mem_schemas = HashMap::new();
3910 mem_schemas.insert("arg".to_string(), schema.clone());
3911
3912 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
3913 let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
3914 assert!(
3915 ids.contains(&to_stub.id.0.as_str()),
3916 "stub terminates no obligation: {ids:?}"
3917 );
3918 assert!(
3919 ids.contains(&to_claim.id.0.as_str()),
3920 "non-terminal chain is a finding: {ids:?}"
3921 );
3922
3923 let ev = make_typed_entity("arg", "real-ev", "evidence");
3925 let mut repaired = store.get(&to_stub.id).unwrap().clone();
3926 link(&mut repaired, "GROUNDS", &ev.id);
3927 store.upsert(ev.id.clone(), ev);
3928 store.upsert(repaired.id.clone(), repaired);
3929 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
3930 let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
3931 assert!(
3932 !ids.contains(&to_stub.id.0.as_str()),
3933 "conforming path clears the finding: {ids:?}"
3934 );
3935 }
3936
3937 #[test]
3941 fn must_reach_cycles_terminate() {
3942 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
3943 let mut store = Store::new();
3944 let mut a = make_typed_entity("arg", "cyc-a", "claim");
3945 let mut b = make_typed_entity("arg", "cyc-b", "claim");
3946 link(&mut a, "GROUNDS", &b.id);
3947 link(&mut b, "GROUNDS", &a.id);
3948 for e in [a, b] {
3949 store.upsert(e.id.clone(), e);
3950 }
3951 let mut mem_schemas = HashMap::new();
3952 mem_schemas.insert("arg".to_string(), schema);
3953
3954 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
3955 assert_eq!(reports.len(), 2, "both cycle members lack evidence");
3956 }
3957
3958 #[test]
3962 fn must_reach_depth_bound() {
3963 let two_hop_store = || {
3964 let mut store = Store::new();
3965 let ev = make_typed_entity("arg", "ev", "evidence");
3966 let mut mid = make_typed_entity("arg", "mid", "claim");
3967 let mut start = make_typed_entity("arg", "start", "claim");
3968 link(&mut start, "GROUNDS", &mid.id);
3969 link(&mut mid, "GROUNDS", &ev.id);
3970 for e in [ev, mid, start] {
3971 store.upsert(e.id.clone(), e);
3972 }
3973 store
3974 };
3975 let bounded = |depth: u32| {
3976 must_reach_schema(
3977 &format!(
3978 "must_reach:\n - relationships: [GROUNDS]\n direction: out\n terminal_types: [evidence]\n max_depth: {depth}\n"
3979 ),
3980 "",
3981 )
3982 };
3983
3984 let store = two_hop_store();
3985 let mut mem_schemas = HashMap::new();
3986 mem_schemas.insert("arg".to_string(), bounded(1));
3987 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
3988 assert_eq!(
3989 reports.len(),
3990 1,
3991 "the two-hop path exceeds depth 1 for the start claim: {reports:?}"
3992 );
3993 assert_eq!(reports[0].id.0, "arg--start");
3994
3995 let mut mem_schemas = HashMap::new();
3996 mem_schemas.insert("arg".to_string(), bounded(2));
3997 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
3998 assert!(
3999 reports.is_empty(),
4000 "the same path satisfies depth 2: {reports:?}"
4001 );
4002 }
4003
4004 #[test]
4007 fn must_reach_two_obligations_one_finding() {
4008 let schema = must_reach_schema(
4009 "must_reach:\n - relationships: [GROUNDS]\n direction: out\n terminal_types: [evidence]\n - relationships: [CONCLUDES]\n direction: out\n terminal_types: [inference]\n",
4010 "",
4011 );
4012 let mut store = Store::new();
4013 let ev = make_typed_entity("arg", "ev", "evidence");
4014 let mut c = make_typed_entity("arg", "half", "claim");
4015 link(&mut c, "GROUNDS", &ev.id);
4016 for e in [ev, c.clone()] {
4017 store.upsert(e.id.clone(), e);
4018 }
4019 let mut mem_schemas = HashMap::new();
4020 mem_schemas.insert("arg".to_string(), schema);
4021
4022 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4023 assert_eq!(reports.len(), 1);
4024 assert_eq!(reports[0].id, c.id);
4025 let v = must_reach_violations(&reports[0]);
4026 assert_eq!(v.len(), 1, "only the unsatisfied obligation: {v:?}");
4027 let UnsatisfiedConstraint::MustReach { relationships, .. } = v[0] else {
4028 panic!("expected must_reach finding");
4029 };
4030 assert_eq!(relationships, &vec!["CONCLUDES".to_string()]);
4031 }
4032
4033 #[test]
4039 fn status_propagation_rel_types_taints_across_type_boundaries() {
4040 use crate::entity::MetadataValue;
4041 let manifest = r#"name: tests-prop-set
4042version: 0.1.0
4043description: propagation relation-set test schema
4044when_to_use: tests
4045types:
4046 - claim
4047relationships:
4048 mode: strict
4049 definitions:
4050 - name: GROUNDS
4051 description: g
4052 default_weight: 3.0
4053 - name: CONCLUDES
4054 description: c
4055 default_weight: 3.0
4056 - name: PART_OF
4057 description: hier
4058 default_weight: 1.0
4059 - name: _default
4060 description: fallback
4061 default_weight: 1.0
4062community:
4063 resolution: 1.0
4064 seed: 42
4065"#;
4066 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";
4067 let schema = std::sync::Arc::new(
4068 memstead_schema::load_schema_from_memory(
4069 manifest,
4070 &[("claim".to_string(), claim_yaml.to_string())],
4071 )
4072 .expect("propagation-set fixture schema must parse"),
4073 );
4074
4075 let mut store = Store::new();
4076 let mut withdrawn = make_typed_entity("arg", "withdrawn-ev", "claim");
4077 withdrawn
4078 .metadata
4079 .insert("standing".into(), MetadataValue::String("withdrawn".into()));
4080 let mut inference = make_typed_entity("arg", "inference", "claim");
4081 link(&mut inference, "GROUNDS", &withdrawn.id);
4082 let mut conclusion = make_typed_entity("arg", "conclusion", "claim");
4083 link(&mut conclusion, "CONCLUDES", &inference.id);
4084 let bystander = make_typed_entity("arg", "bystander", "claim");
4085 for e in [withdrawn, inference.clone(), conclusion.clone(), bystander] {
4086 store.upsert(e.id.clone(), e);
4087 }
4088 let mut mem_schemas = HashMap::new();
4089 mem_schemas.insert("arg".to_string(), schema);
4090
4091 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4092 let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
4093 assert_eq!(
4094 ids,
4095 vec![conclusion.id.0.as_str(), inference.id.0.as_str()],
4096 "the taint crosses the CONCLUDES/GROUNDS boundary, nothing else"
4097 );
4098 let UnsatisfiedConstraint::StatusPropagation {
4099 rel_type,
4100 rel_types,
4101 tainted_by,
4102 ..
4103 } = &reports[0].violations[0]
4104 else {
4105 panic!("expected status_propagation finding");
4106 };
4107 assert_eq!(*rel_type, None, "set declarations echo no single name");
4108 assert_eq!(
4109 rel_types.as_deref(),
4110 Some(&["GROUNDS".to_string(), "CONCLUDES".to_string()][..])
4111 );
4112 assert_eq!(tainted_by, "arg--withdrawn-ev");
4113 }
4114
4115 #[test]
4118 fn must_reach_cross_mem_path_and_mem_filter() {
4119 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
4120 let mut store = Store::new();
4121 let far_ev = make_typed_entity("ground", "far-ev", "evidence");
4122 let mut crossing = make_typed_entity("arg", "crossing", "claim");
4123 link(&mut crossing, "GROUNDS", &far_ev.id);
4124 let floating_arg = make_typed_entity("arg", "floating", "claim");
4125 let floating_ground = make_typed_entity("ground", "floating", "claim");
4126 for e in [far_ev, crossing, floating_arg.clone(), floating_ground] {
4127 store.upsert(e.id.clone(), e);
4128 }
4129 let mut mem_schemas = HashMap::new();
4130 mem_schemas.insert("arg".to_string(), schema.clone());
4131 mem_schemas.insert("ground".to_string(), schema);
4132
4133 let all = collect_constraint_findings(&store, None, &mem_schemas, None);
4134 assert_eq!(
4135 all.len(),
4136 2,
4137 "the crossing claim is satisfied via the cross-mem edge: {all:?}"
4138 );
4139 let filtered = collect_constraint_findings(&store, Some("arg"), &mem_schemas, None);
4140 assert_eq!(filtered.len(), 1, "mem filter narrows: {filtered:?}");
4141 assert_eq!(filtered[0].id, floating_arg.id);
4142 }
4143
4144 fn gated_transition_schema() -> std::sync::Arc<memstead_schema::Schema> {
4151 let manifest = r#"name: tests-gated
4152version: 0.1.0
4153description: transition_requires_checks test schema
4154when_to_use: tests
4155types:
4156 - plan
4157 - criterion
4158relationships:
4159 mode: strict
4160 definitions:
4161 - name: VERIFIES
4162 description: v
4163 default_weight: 3.0
4164 acyclic: true
4165 - name: PART_OF
4166 description: hier
4167 default_weight: 1.0
4168 acyclic: true
4169 - name: _default
4170 description: fallback
4171 default_weight: 1.0
4172community:
4173 resolution: 1.0
4174 seed: 42
4175"#;
4176 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";
4177 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";
4178 std::sync::Arc::new(
4179 memstead_schema::load_schema_from_memory(
4180 manifest,
4181 &[
4182 ("plan".to_string(), plan_yaml.to_string()),
4183 ("criterion".to_string(), criterion_yaml.to_string()),
4184 ],
4185 )
4186 .expect("gated-transition fixture schema loads"),
4187 )
4188 }
4189
4190 #[test]
4196 fn transition_requires_checks_gates_on_derived_state() {
4197 use crate::check::CheckState;
4198 use crate::entity::MetadataValue;
4199 let schema = gated_transition_schema();
4200 let td = schema.types.get("plan").unwrap().clone();
4201 let mut store = Store::default();
4202
4203 let mut plan = make_typed_entity("g", "the-plan", "plan");
4204 plan.metadata
4205 .insert("status".into(), MetadataValue::String("complete".into()));
4206 let mut ok_crit = make_typed_entity("g", "ok-crit", "criterion");
4207 ok_crit.relationships.push(crate::entity::Relationship {
4208 rel_type: "VERIFIES".into(),
4209 target: plan.id.clone(),
4210 description: None,
4211 });
4212 let mut stale_crit = make_typed_entity("g", "stale-crit", "criterion");
4213 stale_crit.relationships.push(crate::entity::Relationship {
4214 rel_type: "VERIFIES".into(),
4215 target: plan.id.clone(),
4216 description: None,
4217 });
4218 for e in [plan.clone(), ok_crit.clone(), stale_crit.clone()] {
4219 store.upsert(e.id.clone(), e);
4220 }
4221
4222 let provider = |e: &crate::entity::Entity| {
4223 if e.id.0.contains("ok-crit") {
4224 CheckState::CheckedOk
4225 } else {
4226 CheckState::CheckStale
4227 }
4228 };
4229 let violations = unsatisfied_constraints(&store, &plan, &td, None, Some(&provider));
4230 assert_eq!(violations.len(), 1, "{violations:?}");
4231 match &violations[0] {
4232 UnsatisfiedConstraint::TransitionRequiresChecks {
4233 unchecked,
4234 severity,
4235 ..
4236 } => {
4237 assert_eq!(
4238 unchecked.len(),
4239 1,
4240 "only the unconfirmed criterion is listed"
4241 );
4242 assert_eq!(unchecked[0].id, "g--stale-crit");
4243 assert_eq!(unchecked[0].state, "check_stale");
4244 assert_eq!(*severity, memstead_schema::ConstraintSeverity::Block);
4245 }
4246 other => panic!("expected the gated-transition violation, got {other:?}"),
4247 }
4248 assert!(
4249 violations[0].describe().contains("g--stale-crit")
4250 && violations[0].describe().contains("check_stale"),
4251 "describe names the offender and its state: {}",
4252 violations[0].describe()
4253 );
4254
4255 let all_ok = |_: &crate::entity::Entity| CheckState::CheckedOk;
4257 assert!(
4258 unsatisfied_constraints(&store, &plan, &td, None, Some(&all_ok)).is_empty(),
4259 "all confirmed satisfies the gate"
4260 );
4261
4262 let mut draft = plan.clone();
4264 draft
4265 .metadata
4266 .insert("status".into(), MetadataValue::String("draft".into()));
4267 assert!(
4268 unsatisfied_constraints(&store, &draft, &td, None, Some(&provider)).is_empty(),
4269 "the gate triggers only at to_value"
4270 );
4271
4272 let violations = unsatisfied_constraints(&store, &plan, &td, None, None);
4274 assert_eq!(violations.len(), 1);
4275 match &violations[0] {
4276 UnsatisfiedConstraint::TransitionRequiresChecks { unchecked, .. } => {
4277 assert_eq!(unchecked.len(), 2, "no ledger access confirms nothing");
4278 assert!(unchecked.iter().all(|u| u.state == "never_checked"));
4279 }
4280 other => panic!("expected the gated-transition violation, got {other:?}"),
4281 }
4282
4283 let mut lone = make_typed_entity("g", "lone-plan", "plan");
4285 lone.metadata
4286 .insert("status".into(), MetadataValue::String("complete".into()));
4287 store.upsert(lone.id.clone(), lone.clone());
4288 assert!(
4289 unsatisfied_constraints(&store, &lone, &td, None, Some(&provider)).is_empty(),
4290 "an empty related set satisfies the universal quantification"
4291 );
4292 }
4293}