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 let mut foreign_by_entity: std::collections::BTreeMap<String, Vec<String>> =
113 std::collections::BTreeMap::new();
114 let mut newest_any: std::collections::BTreeMap<String, crate::check::CheckRecord> =
117 std::collections::BTreeMap::new();
118 if let Some(l) = &ledger {
119 for rec in l.all() {
120 newest_any.insert(rec.entity.clone(), rec.clone());
121 match rec.resolved_kind() {
122 Some(crate::check::CheckKind::Verification) => {
123 latest.insert(rec.entity.clone(), rec);
124 }
125 Some(crate::check::CheckKind::Conformance) => {
126 latest_conformance.insert(rec.entity.clone(), rec);
127 }
128 None => {
129 if let Some(k) = rec.foreign_kind() {
130 foreign_by_entity
131 .entry(rec.entity.clone())
132 .or_default()
133 .push(k.to_string());
134 }
135 }
136 }
137 }
138 }
139
140 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
141 mems.sort();
142 let mut out = serde_json::Map::new();
143 for mem in mems {
144 if let Some(f) = mem_filter
145 && f != mem
146 {
147 continue;
148 }
149 let mut counts = std::collections::BTreeMap::from([
150 ("never_checked", 0usize),
151 ("checked_ok", 0usize),
152 ("check_failed", 0usize),
153 ("check_stale", 0usize),
154 ]);
155 let current_pin = engine
161 .mount(&mem)
162 .and_then(|m| m.schema.as_ref())
163 .map(|s| s.as_display());
164 let mut conformance_counts = std::collections::BTreeMap::from([
165 ("never_checked", 0usize),
166 ("checked_ok", 0usize),
167 ("check_failed", 0usize),
168 ("check_stale", 0usize),
169 ]);
170 let mut self_checked: Vec<String> = Vec::new();
171 let mut confirmed_independent: Vec<String> = Vec::new();
172 let mut unconfirmable: Vec<String> = Vec::new();
173 let mut foreign_kinds: std::collections::BTreeMap<String, usize> =
174 std::collections::BTreeMap::new();
175 let mut findings = serde_json::Map::new();
176 for e in engine.store().all_entities().filter(|e| e.mem == mem) {
177 let id = e.id.0.clone();
178 if let Some(kinds) = foreign_by_entity.get(&id) {
179 for k in kinds {
180 *foreign_kinds.entry(k.clone()).or_insert(0) += 1;
181 }
182 }
183 if let Some(rec) = newest_any.get(&id)
184 && let Some(f) = &rec.finding
185 {
186 findings.insert(
187 id.clone(),
188 serde_json::json!({
189 "verdict": rec.verdict,
190 "kind": rec.kind.as_deref().unwrap_or("verification"),
191 "ts": rec.ts,
192 "identity": rec.identity,
193 "finding": f,
194 }),
195 );
196 }
197 let state = crate::check::derive_state(latest.get(&id), &e.content_hash);
198 *counts.entry(state.as_str()).or_insert(0) += 1;
199 let cstate = crate::check::derive_state_pinned(
200 latest_conformance.get(&id),
201 &e.content_hash,
202 current_pin.as_deref(),
203 );
204 *conformance_counts.entry(cstate.as_str()).or_insert(0) += 1;
205 if state != crate::check::CheckState::CheckedOk {
206 continue;
207 }
208 let check = latest.get(&id).expect("checked_ok implies a record");
217 let author_identity = engine
220 .entity_provenance(&mem, &id)
221 .ok()
222 .and_then(|p| p.created_by)
223 .and_then(|r| r.identity);
224 match (author_identity, check.identity.as_ref()) {
225 (Some(a), Some(c)) if a == *c => self_checked.push(id),
226 (Some(_), Some(_)) => confirmed_independent.push(id),
227 _ => unconfirmable.push(id),
228 }
229 }
230 let mut m = serde_json::Map::new();
231 for (k, v) in counts {
232 m.insert(k.to_string(), serde_json::json!(v));
233 }
234 let mut c = serde_json::Map::new();
235 for (k, v) in conformance_counts {
236 c.insert(k.to_string(), serde_json::json!(v));
237 }
238 m.insert("conformance".into(), serde_json::Value::Object(c));
239 m.insert(
243 "foreign_kinds".into(),
244 serde_json::to_value(&foreign_kinds).unwrap_or(serde_json::json!({})),
245 );
246 m.insert("findings".into(), serde_json::Value::Object(findings));
247 m.insert(
248 "independence".into(),
249 serde_json::json!({
250 "self_checked": capped(self_checked),
251 "confirmed_independent": capped(confirmed_independent),
252 "unconfirmable": capped(unconfirmable),
253 }),
254 );
255 out.insert(mem, serde_json::Value::Object(m));
256 }
257 serde_json::Value::Object(out)
258}
259
260#[derive(Debug, Clone, serde::Serialize)]
266pub struct DerivationFinding {
267 pub source: crate::entity::EntityId,
268 pub rel_type: String,
269 pub target: crate::entity::EntityId,
270 pub state: String,
272 #[serde(skip_serializing_if = "Option::is_none")]
274 pub baseline: Option<String>,
275 pub current: String,
277}
278
279pub fn health_stale_derivations_axis(
284 engine: &crate::engine::Engine,
285 mem_filter: Option<&str>,
286) -> serde_json::Value {
287 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
288 mems.sort();
289 let mut out = serde_json::Map::new();
290 for mem in mems {
291 if let Some(f) = mem_filter
292 && f != mem
293 {
294 continue;
295 }
296 let findings = engine.derivation_report(&mem).unwrap_or_default();
297 out.insert(
298 mem,
299 serde_json::to_value(&findings).unwrap_or(serde_json::Value::Array(Vec::new())),
300 );
301 }
302 serde_json::Value::Object(out)
303}
304
305pub const OPEN_QUESTIONS_ITEM_CAP: usize = 20;
309
310pub fn health_open_questions_axis(
326 engine: &crate::engine::Engine,
327 mem_filter: Option<&str>,
328) -> serde_json::Value {
329 let cap = OPEN_QUESTIONS_ITEM_CAP;
330 let capped = |mut items: Vec<serde_json::Value>| -> serde_json::Value {
331 let count = items.len();
332 let more = count.saturating_sub(cap);
333 items.truncate(cap);
334 let mut o = serde_json::Map::new();
335 o.insert("count".into(), serde_json::json!(count));
336 o.insert("items".into(), serde_json::Value::Array(items));
337 if more > 0 {
338 o.insert("more".into(), serde_json::json!(more));
339 }
340 serde_json::Value::Object(o)
341 };
342
343 let bindings: Vec<(String, String)> = engine
347 .workspace_root()
348 .and_then(|root| crate::pipeline_store::load_pipeline_configs(root).ok())
349 .map(|c| {
350 c.bindings
351 .iter()
352 .map(|r| (r.config.destination_mem.clone(), r.name.clone()))
353 .collect()
354 })
355 .unwrap_or_default();
356 let mounted: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
357
358 let mut mems: Vec<String> = mounted.clone();
359 mems.sort();
360 let mut out = serde_json::Map::new();
361 for mem in &mems {
362 if let Some(f) = mem_filter
363 && f != mem
364 {
365 continue;
366 }
367
368 let stubs = capped(
370 engine
371 .store()
372 .all_entities()
373 .filter(|e| e.stub && e.id.mem() == mem)
374 .map(|e| serde_json::json!({ "kind": "stub", "id": e.id.to_string() }))
375 .collect(),
376 );
377
378 let (mut recheck, mut unresolvable, mut unobserved, mut dangling_rows) =
391 (Vec::new(), Vec::new(), Vec::new(), Vec::new());
392 let mut aging = Vec::new();
395 let mut entity_end_unreconciled: Option<String> = None;
396 if let Ok(report) = engine.verify_mem_anchors(mem) {
397 entity_end_unreconciled = report.unreconciled.clone();
398 for a in &report.anchors {
399 if let Some(days) = a.unobserved_for_days
400 && days > 0
401 {
402 aging.push(serde_json::json!({
403 "kind": "anchor_aging",
404 "id": a.entity_id,
405 "artifact": a.artifact,
406 "state": a.state,
407 "observed_at": a.observed_at,
408 "unobserved_for_days": days,
409 "note": format!("unobserved for {days} days"),
410 }));
411 }
412 let item = serde_json::json!({
413 "kind": format!("anchor_{}", a.state),
414 "id": a.entity_id,
415 "artifact": a.artifact,
416 });
417 match a.state.as_str() {
418 "recheck" => recheck.push(item),
419 "unresolvable" => unresolvable.push(item),
420 "unobserved" => unobserved.push(item),
421 "dangling" => dangling_rows.push(item),
422 _ => {}
423 }
424 }
425 }
426
427 let constraints = capped(
430 engine
431 .constraint_findings(Some(mem))
432 .iter()
433 .map(|r| {
434 serde_json::json!({
435 "kind": "unsatisfied_constraint",
436 "id": r.id.to_string(),
437 "violations": r.violations.len(),
438 })
439 })
440 .collect(),
441 );
442
443 let dangling = capped(
449 collect_dangling_links(engine.store(), Some(mem))
450 .iter()
451 .map(|d| {
452 serde_json::json!({
453 "kind": d.kind.code(),
454 "id": d.from.to_string(),
455 "target": d.target_id.to_string(),
456 "repair": d.kind.repair(),
457 })
458 })
459 .collect(),
460 );
461
462 let mut process = Vec::new();
472 let mem_bindings: Vec<&String> = bindings
473 .iter()
474 .filter(|(d, _)| d == mem)
475 .map(|(_, b)| b)
476 .collect();
477 let mut resolutions: Vec<(Option<String>, crate::ingest::resolve::ProcessMemResolution)> =
478 Vec::new();
479 if mem_bindings.is_empty() {
480 let r = crate::ingest::resolve::resolve_process_mem(engine, mem, "");
481 if r.declared {
482 resolutions.push((None, r));
483 }
484 } else {
485 for binding in &mem_bindings {
486 resolutions.push((
487 Some((*binding).clone()),
488 crate::ingest::resolve::resolve_process_mem(engine, mem, binding),
489 ));
490 }
491 }
492 for (binding, r) in resolutions {
493 if r.mounted {
494 let mut open = Vec::new();
495 let mut searched = Vec::new();
496 for e in engine
497 .store()
498 .all_entities()
499 .filter(|e| !e.stub && e.id.mem() == r.mem.as_str())
500 {
501 let item = serde_json::json!({
502 "kind": e.entity_type,
503 "id": e.id.to_string(),
504 "title": e.title,
505 });
506 if e.entity_type == "negative_finding" {
507 searched.push(item);
508 } else {
509 open.push(item);
510 }
511 }
512 process.push(serde_json::json!({
513 "binding": binding,
514 "process_mem": r.mem,
515 "declared": r.declared,
516 "resolvable": true,
517 "open_entries": capped(open),
518 "already_searched": capped(searched),
519 }));
520 } else if r.declared {
521 process.push(serde_json::json!({
522 "binding": binding,
523 "process_mem": r.mem,
524 "declared": true,
525 "resolvable": false,
526 "finding": "DECLARED_PROCESS_MEM_MISSING",
527 }));
528 } else {
529 process.push(serde_json::json!({
530 "binding": binding,
531 "resolvable": false,
532 }));
533 }
534 }
535
536 let total_open = stubs["count"].as_u64().unwrap_or(0)
537 + recheck.len() as u64
538 + unresolvable.len() as u64
539 + unobserved.len() as u64
540 + dangling_rows.len() as u64
541 + aging.len() as u64
542 + constraints["count"].as_u64().unwrap_or(0)
543 + dangling["count"].as_u64().unwrap_or(0)
544 + process
545 .iter()
546 .filter_map(|p| p["open_entries"]["count"].as_u64())
547 .sum::<u64>();
548
549 let mut entry = serde_json::Map::new();
550 entry.insert("stubs".into(), stubs);
551 entry.insert("anchors_recheck".into(), capped(recheck));
552 entry.insert("anchors_unresolvable".into(), capped(unresolvable));
553 entry.insert("anchors_unobserved".into(), capped(unobserved));
557 entry.insert("anchors_dangling".into(), capped(dangling_rows));
558 entry.insert("anchors_aging".into(), capped(aging));
559 if let Some(why) = entity_end_unreconciled {
560 entry.insert("entity_end_unreconciled".into(), serde_json::json!(why));
561 }
562 entry.insert("unsatisfied_constraints".into(), constraints);
563 entry.insert("dangling_links".into(), dangling);
564 if !process.is_empty() {
565 entry.insert("process".into(), serde_json::Value::Array(process));
566 } else {
567 entry.insert("process_mem_resolvable".into(), serde_json::json!(false));
570 }
571 entry.insert("total_open".into(), serde_json::json!(total_open));
572 out.insert(mem.clone(), serde_json::Value::Object(entry));
573 }
574 let mut top = serde_json::Map::new();
575 top.insert("_item_cap".into(), serde_json::json!(cap));
576 for (k, v) in out {
577 top.insert(k, v);
578 }
579 serde_json::Value::Object(top)
580}
581
582pub fn health_anchors_axis(engine: &crate::engine::Engine) -> serde_json::Value {
583 let mut mems: Vec<String> = engine.mem_names().iter().map(|s| s.to_string()).collect();
584 mems.sort();
585 let mut out = serde_json::Map::new();
586 for mem in mems {
587 let Ok(report) = engine.verify_mem_anchors(&mem) else {
588 continue;
589 };
590 out.insert(
591 mem,
592 serde_json::json!({
593 "resolved": report.resolved,
594 "drifted": report.drifted,
595 "recheck": report.recheck,
596 "unresolvable": report.unresolvable,
601 "unobserved": report.unobserved,
602 "dangling": report.dangling,
607 "entity_end_unreconciled": report.unreconciled,
608 "population": report.population_statement(),
611 "fully_adjudicated": report.fully_adjudicated(),
612 "aging": report
617 .anchors
618 .iter()
619 .filter(|a| a.observed_at.is_some())
620 .map(|a| {
621 let days = a.unobserved_for_days.unwrap_or(0);
622 serde_json::json!({
623 "id": a.entity_id,
624 "artifact": a.artifact,
625 "state": a.state,
626 "observed_at": a.observed_at,
627 "unobserved_for_days": days,
628 "note": format!("unobserved for {days} days"),
629 })
630 })
631 .collect::<Vec<_>>(),
632 }),
633 );
634 }
635 serde_json::Value::Object(out)
636}
637
638pub fn compute_health(
651 store: &Store,
652 default_schema: &TypeDefinition,
653 mem_schemas: &HashMap<String, Arc<Schema>>,
654 mem_filter: Option<&str>,
655) -> HealthSummary {
656 let mut missing_fields = Vec::new();
657 let mut stale_entities = Vec::new();
658
659 let today_days = days_since_epoch();
660
661 let in_scope = |mem: &str| mem_filter.is_none_or(|v| mem == v);
662
663 for entity in store.all_entities() {
664 if entity.stub || !in_scope(&entity.mem) {
665 continue;
666 }
667
668 let resolved = mem_schemas
677 .get(entity.mem.as_str())
678 .and_then(|s| s.types.get(entity.entity_type.as_str()).cloned())
679 .or_else(|| type_by_name(&entity.entity_type));
680 let schema: &TypeDefinition = resolved.as_deref().unwrap_or(default_schema);
681 let mut issues = Vec::new();
682
683 for field in &schema.health_required_fields {
685 if schema.section(field).is_some() {
687 let content = entity.sections.get(field.as_str());
692 if content.is_none_or(|c| c.trim().is_empty()) {
693 if let Some(issue) = section_heading_mismatch_issue(entity, schema, field) {
694 issues.push(issue);
695 } else {
696 issues.push(HealthIssue {
697 field: field.clone(),
698 code: super::HealthIssueCode::Missing,
699 message: format!("required section '{field}' is empty"),
700 });
701 }
702 }
703 } else {
704 let value = entity.metadata.get(field.as_str());
710 let is_empty = match value {
711 None => true,
712 Some(v) => v.to_frontmatter_string().trim().is_empty(),
713 };
714 if is_empty {
715 issues.push(HealthIssue {
716 field: field.clone(),
717 code: super::HealthIssueCode::Missing,
718 message: format!("required field '{field}' is missing"),
719 });
720 }
721 }
722 }
723
724 for s in schema.sections.iter().filter(|s| !s.catch_all) {
727 if schema.health_required_fields.contains(&s.key) {
728 continue; }
730 let content = entity.sections.get(s.key.as_str());
731 if content.is_none_or(|c| c.trim().is_empty())
732 && let Some(issue) = section_heading_mismatch_issue(entity, schema, &s.key)
733 {
734 issues.push(issue);
735 }
736 }
737
738 if let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) {
754 let mut seen_unknown = std::collections::HashSet::new();
755 for rel in &entity.relationships {
756 if !mem_schema.relationship_known(&rel.rel_type) {
757 if seen_unknown.insert(rel.rel_type.clone()) {
758 let suggestion = mem_schema
759 .suggest_relationship(&rel.rel_type)
760 .map(|s| format!(" Did you mean '{s}'?"))
761 .unwrap_or_default();
762 let (schema_name, schema_version) = mem_schema.id();
763 issues.push(HealthIssue {
764 field: "relationships".to_string(),
765 code: super::HealthIssueCode::UndeclaredRelationship,
766 message: format!(
767 "relationship '{}' is not declared in schema \
768 '{schema_name}@{schema_version}'.{suggestion}",
769 rel.rel_type
770 ),
771 });
772 }
773 continue;
774 }
775
776 let target_type = store
777 .get(&rel.target)
778 .map(|t| t.entity_type.clone())
779 .filter(|t| !t.is_empty());
780 if let Err(crate::runtime_validator::ValidationError::InvalidRelationshipShape {
781 rel_type,
782 from_type,
783 to_type,
784 allowed_source_types,
785 allowed_target_types,
786 ..
787 }) = crate::runtime_validator::validate_rel_shape(
788 &rel.rel_type,
789 entity.entity_type.as_str(),
790 target_type.as_deref(),
791 mem_schema.as_ref(),
792 ) {
793 let allowed_src = if allowed_source_types.is_empty() {
794 "<any>".to_string()
795 } else {
796 allowed_source_types.join(", ")
797 };
798 let allowed_tgt = if allowed_target_types.is_empty() {
799 "<any>".to_string()
800 } else {
801 allowed_target_types.join(", ")
802 };
803 issues.push(HealthIssue {
804 field: "relationships".to_string(),
805 code: super::HealthIssueCode::InvalidRelShape,
806 message: format!(
807 "INVALID_REL_SHAPE: edge '{rel_type}' from \
808 '{from_type}' to '{to_type}' (target {target}) \
809 violates declared shape — allowed_source_types: \
810 [{allowed_src}], allowed_target_types: \
811 [{allowed_tgt}]. Remove via \
812 `memstead_relate from={from_id} to={target} \
813 type={rel_type} remove=true`.",
814 target = rel.target,
815 from_id = entity.id,
816 ),
817 });
818 }
819 }
820 }
821
822 let auto_ts_field = schema.metadata_fields.iter().find(|f| f.auto_timestamp);
824
825 if let Some(ts_field) = auto_ts_field
826 && let Some(val) = entity.metadata.get(ts_field.key.as_str())
827 {
828 let date_str = val.to_frontmatter_string();
829 if let Some(modified_days) = parse_iso_to_days(&date_str) {
830 let days_since = today_days.saturating_sub(modified_days);
831 if days_since > schema.staleness_threshold_days as u64 {
832 stale_entities.push(StaleEntity {
833 id: entity.id.clone(),
834 title: entity.title.clone(),
835 days_since_modified: days_since,
836 });
837 }
838 }
839 }
840
841 if !issues.is_empty() {
842 let total = schema.health_required_fields.len();
848 let score = if total > 0 {
849 (total.saturating_sub(issues.len()) as f32) / (total as f32)
850 } else {
851 1.0
852 };
853
854 missing_fields.push(HealthReport {
855 id: entity.id.clone(),
856 title: entity.title.clone(),
857 score,
858 issues,
859 });
860 }
861 }
862
863 stale_entities.sort_by_key(|e| std::cmp::Reverse(e.days_since_modified));
865
866 let orphan_count = query::find_orphans_with_schemas(store, mem_schemas)
869 .into_iter()
870 .filter(|id| store.get(id).is_some_and(|e| in_scope(&e.mem)))
871 .count();
872 let leaf_entities_by_type = match mem_filter {
873 None => query::leaf_population(store, mem_schemas),
874 Some(v) => {
875 let scoped: HashMap<String, Arc<Schema>> = mem_schemas
876 .iter()
877 .filter(|(mem, _)| mem.as_str() == v)
878 .map(|(mem, s)| (mem.clone(), s.clone()))
879 .collect();
880 query::leaf_population(store, &scoped)
881 }
882 };
883 let stub_count = query::find_stubs(store)
884 .iter()
885 .filter(|(id, _)| store.get(id).is_some_and(|e| in_scope(&e.mem)))
886 .count();
887
888 HealthSummary {
889 stale_entities,
890 missing_fields,
891 orphan_count,
892 stub_count,
893 warnings: Vec::new(),
894 quarantined: Vec::new(),
895 load_errors: Vec::new(),
896 boot_diagnosis: None,
897 leaf_entities_by_type,
898 dangling_links: None,
899 findings: None,
900 tag_distribution: None,
901 tag_distribution_folded: None,
902 untagged_entities: None,
903 }
904}
905
906pub fn collect_tag_distribution(
920 store: &Store,
921 mem_filter: Option<&str>,
922 limit: usize,
923) -> (Vec<TagDistribution>, Vec<FoldedTag>, UntaggedStats) {
924 let mut counts: HashMap<String, (usize, HashMap<String, usize>)> = HashMap::new();
926 let mut untagged = UntaggedStats {
927 total: 0,
928 by_entity_type: HashMap::new(),
929 };
930
931 for entity in store.all_entities() {
932 if entity.stub {
933 continue;
934 }
935 if let Some(v) = mem_filter
936 && entity.mem != v
937 {
938 continue;
939 }
940
941 let tags_raw = entity
942 .metadata
943 .get("tags")
944 .and_then(|v| match v {
945 MetadataValue::String(s) => Some(s.as_str()),
946 _ => None,
947 })
948 .unwrap_or("");
949
950 let mut any_tag = false;
951 for tag in tags_raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
952 any_tag = true;
953 let entry = counts
954 .entry(tag.to_string())
955 .or_insert_with(|| (0, HashMap::new()));
956 entry.0 += 1;
957 *entry.1.entry(entity.entity_type.clone()).or_insert(0) += 1;
958 }
959 if !any_tag {
960 untagged.total += 1;
961 *untagged
962 .by_entity_type
963 .entry(entity.entity_type.clone())
964 .or_insert(0) += 1;
965 }
966 }
967
968 let mut entries: Vec<TagDistribution> = counts
970 .iter()
971 .map(|(tag, (count, by_type))| TagDistribution {
972 tag: tag.clone(),
973 count: *count,
974 by_entity_type: by_type.clone(),
975 })
976 .collect();
977 entries.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.tag.cmp(&b.tag)));
978 entries.truncate(limit);
979
980 let mut by_canonical: HashMap<String, Vec<(String, usize)>> = HashMap::new();
985 for (tag, (count, _)) in counts.iter() {
986 by_canonical
987 .entry(tag.to_lowercase())
988 .or_default()
989 .push((tag.clone(), *count));
990 }
991 let mut folded: Vec<FoldedTag> = by_canonical
992 .into_iter()
993 .filter(|(_, v)| v.len() > 1)
994 .map(|(canonical, mut variants)| {
995 variants.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
996 let total = variants.iter().map(|(_, c)| *c).sum();
997 FoldedTag {
998 canonical,
999 total,
1000 variants: variants
1001 .into_iter()
1002 .map(|(tag, count)| TagVariant { tag, count })
1003 .collect(),
1004 }
1005 })
1006 .collect();
1007 folded.sort_by(|a, b| {
1008 b.total
1009 .cmp(&a.total)
1010 .then_with(|| a.canonical.cmp(&b.canonical))
1011 });
1012
1013 (entries, folded, untagged)
1014}
1015
1016pub fn collect_dangling_links(store: &Store, mem_filter: Option<&str>) -> Vec<DanglingLink> {
1042 use crate::entity::parser::extract_inline_links_lenient;
1043 use std::collections::HashSet;
1044
1045 let mut out = Vec::new();
1046 for entity in store.all_entities() {
1047 if entity.stub {
1048 continue;
1049 }
1050 if let Some(v) = mem_filter
1051 && entity.mem != v
1052 {
1053 continue;
1054 }
1055 let explicit_targets: HashSet<_> = entity
1056 .relationships
1057 .iter()
1058 .map(|r| r.target.clone())
1059 .collect();
1060 for (section_key, section_body) in &entity.sections {
1061 for target_id in extract_inline_links_lenient(section_body, &entity.mem) {
1062 let target_missing = store.get(&target_id).map(|e| e.stub).unwrap_or(true);
1063 let alias_orphan = !target_missing && !explicit_targets.contains(&target_id);
1064 let kind = if target_missing {
1068 crate::ops::DanglingLinkKind::LinkTargetMissing
1069 } else {
1070 crate::ops::DanglingLinkKind::LinkNotRelated
1071 };
1072 if target_missing || alias_orphan {
1073 out.push(DanglingLink {
1074 kind,
1075 from: entity.id.clone(),
1076 target_id: target_id.clone(),
1077 target_path: target_id.path().to_string(),
1078 section: Some(section_key.clone()),
1079 });
1080 }
1081 }
1082 }
1083 for rel in &entity.relationships {
1100 if store.get(&rel.target).is_some() {
1101 continue;
1102 }
1103 let already_reported = out
1104 .iter()
1105 .any(|d| d.from == entity.id && d.target_id == rel.target);
1106 if already_reported {
1107 continue;
1108 }
1109 out.push(DanglingLink {
1110 kind: crate::ops::DanglingLinkKind::RelationTargetMissing,
1111 from: entity.id.clone(),
1112 target_id: rel.target.clone(),
1113 target_path: rel.target.path().to_string(),
1114 section: None,
1115 });
1116 }
1117 }
1118 out.sort_by(|a, b| {
1123 (&a.from.0, &a.target_id.0, &a.section).cmp(&(&b.from.0, &b.target_id.0, &b.section))
1124 });
1125 out
1126}
1127
1128pub fn collect_missing_required_outgoing(
1139 store: &Store,
1140 mem_filter: Option<&str>,
1141 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1142) -> Vec<MissingRequiredOutgoingReport> {
1143 let mut out = Vec::new();
1144 for entity in store.all_entities() {
1145 if entity.stub {
1146 continue;
1147 }
1148 if let Some(v) = mem_filter
1149 && entity.mem != v
1150 {
1151 continue;
1152 }
1153 let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
1154 continue;
1155 };
1156 let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
1157 continue;
1158 };
1159 if td.required_outgoing.is_empty() {
1160 continue;
1161 }
1162 let unsatisfied = unsatisfied_required_outgoing(entity, td);
1163 if unsatisfied.is_empty() {
1164 continue;
1165 }
1166 out.push(MissingRequiredOutgoingReport {
1167 id: entity.id.clone(),
1168 title: entity.title.clone(),
1169 entity_type: entity.entity_type.clone(),
1170 mem: entity.mem.clone(),
1171 missing: unsatisfied,
1172 });
1173 }
1174 out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
1175 out
1176}
1177
1178pub fn unsatisfied_required_outgoing(
1186 entity: &crate::entity::Entity,
1187 td: &TypeDefinition,
1188) -> Vec<super::MissingRequiredOutgoingBlock> {
1189 td.required_outgoing
1190 .iter()
1191 .filter(|block| {
1192 if let (Some(when_field), Some(when_value)) = (&block.when_field, &block.when_value) {
1197 let armed = entity
1198 .metadata
1199 .get(when_field.as_str())
1200 .is_some_and(|v| v.to_frontmatter_string() == *when_value);
1201 if !armed {
1202 return false;
1203 }
1204 }
1205 let count = entity
1206 .relationships
1207 .iter()
1208 .filter(|rel| block.relationships.iter().any(|name| name == &rel.rel_type))
1209 .count();
1210 !block.admits(count)
1211 })
1212 .map(|block| super::MissingRequiredOutgoingBlock {
1213 relationships: block.relationships.clone(),
1214 cardinality: block.cardinality.to_string(),
1215 severity: block.severity,
1216 when_field: block.when_field.clone(),
1217 when_value: block.when_value.clone(),
1218 })
1219 .collect()
1220}
1221
1222#[derive(Debug, Clone, serde::Serialize)]
1230#[serde(tag = "kind", rename_all = "snake_case")]
1231pub enum UnsatisfiedConstraint {
1232 RequiresWhen {
1233 field: String,
1234 when_field: String,
1235 when_value: String,
1236 severity: memstead_schema::ConstraintSeverity,
1237 },
1238 Unique {
1239 fields: Vec<String>,
1240 values: Vec<String>,
1242 colliding: String,
1245 severity: memstead_schema::ConstraintSeverity,
1246 },
1247 EnumFromNeighbour {
1248 field: String,
1249 value: String,
1251 rel_type: String,
1252 section: String,
1253 severity: memstead_schema::ConstraintSeverity,
1254 },
1255 StatusPropagation {
1256 field: String,
1257 value: String,
1259 #[serde(skip_serializing_if = "Option::is_none")]
1263 rel_type: Option<String>,
1264 #[serde(skip_serializing_if = "Option::is_none")]
1266 rel_types: Option<Vec<String>>,
1267 tainted_by: String,
1270 severity: memstead_schema::ConstraintSeverity,
1271 },
1272 TransitionRequiresChecks {
1275 field: String,
1276 to_value: String,
1277 relationships: Vec<String>,
1278 direction: memstead_schema::PropagationDirection,
1279 unchecked: Vec<UncheckedRelated>,
1282 severity: memstead_schema::ConstraintSeverity,
1283 },
1284 MustReach {
1289 relationships: Vec<String>,
1290 direction: memstead_schema::ReachDirection,
1291 terminal_types: Vec<String>,
1292 #[serde(skip_serializing_if = "Option::is_none")]
1293 max_depth: Option<u32>,
1294 severity: memstead_schema::ConstraintSeverity,
1295 },
1296}
1297
1298impl UnsatisfiedConstraint {
1299 pub fn severity(&self) -> memstead_schema::ConstraintSeverity {
1300 match self {
1301 Self::RequiresWhen { severity, .. }
1302 | Self::Unique { severity, .. }
1303 | Self::EnumFromNeighbour { severity, .. }
1304 | Self::StatusPropagation { severity, .. }
1305 | Self::MustReach { severity, .. }
1306 | Self::TransitionRequiresChecks { severity, .. } => *severity,
1307 }
1308 }
1309
1310 pub fn describe(&self) -> String {
1312 match self {
1313 Self::RequiresWhen {
1314 field,
1315 when_field,
1316 when_value,
1317 ..
1318 } => format!(
1319 "requires_when: '{field}' is required when {when_field}={when_value} and is unset"
1320 ),
1321 Self::Unique {
1322 fields, colliding, ..
1323 } => format!(
1324 "unique: tuple ({}) collides with '{colliding}'",
1325 fields.join(", ")
1326 ),
1327 Self::EnumFromNeighbour {
1328 field,
1329 value,
1330 rel_type,
1331 section,
1332 ..
1333 } => format!(
1334 "enum_from_neighbour: '{field}' value '{value}' has no backing entry in any \
1335 `{section}` section reached via {rel_type}"
1336 ),
1337 Self::StatusPropagation {
1338 field,
1339 value,
1340 tainted_by,
1341 ..
1342 } => {
1343 format!("status_propagation: tainted by '{tainted_by}' ({field}={value})")
1344 }
1345 Self::MustReach {
1346 relationships,
1347 direction,
1348 terminal_types,
1349 max_depth,
1350 ..
1351 } => {
1352 let depth = match max_depth {
1353 Some(d) => format!(" within {d} hop(s)"),
1354 None => String::new(),
1355 };
1356 format!(
1357 "must_reach: no path via [{}] ({direction}) reaches a [{}] entity{depth}",
1358 relationships.join(", "),
1359 terminal_types.join(", ")
1360 )
1361 }
1362 Self::TransitionRequiresChecks {
1363 field,
1364 to_value,
1365 relationships,
1366 unchecked,
1367 ..
1368 } => {
1369 let listed: Vec<String> = unchecked
1370 .iter()
1371 .map(|u| format!("'{}' ({})", u.id, u.state))
1372 .collect();
1373 format!(
1374 "transition_requires_checks: {field}={to_value} requires a fresh confirming \
1375 check record on every entity related via [{}] — unconfirmed: {}",
1376 relationships.join(", "),
1377 listed.join(", ")
1378 )
1379 }
1380 }
1381 }
1382}
1383
1384#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1388pub struct UncheckedRelated {
1389 pub id: String,
1390 pub state: String,
1391}
1392
1393pub type CheckStateProvider<'a> = &'a dyn Fn(&crate::entity::Entity) -> crate::check::CheckState;
1400
1401pub fn unsatisfied_constraints(
1425 store: &Store,
1426 entity: &crate::entity::Entity,
1427 td: &TypeDefinition,
1428 exclude: Option<&crate::entity::EntityId>,
1429 checks: Option<CheckStateProvider<'_>>,
1430) -> Vec<UnsatisfiedConstraint> {
1431 use memstead_schema::ConstraintDef;
1432 td.constraints
1433 .iter()
1434 .filter_map(|c| match c {
1435 ConstraintDef::RequiresWhen {
1436 field,
1437 when_field,
1438 when_value,
1439 severity,
1440 } => {
1441 let triggered = entity
1442 .metadata
1443 .get(when_field.as_str())
1444 .is_some_and(|v| v.to_frontmatter_string() == *when_value);
1445 if !triggered {
1446 return None;
1447 }
1448 let satisfied = entity
1449 .metadata
1450 .get(field.as_str())
1451 .is_some_and(|v| !v.to_frontmatter_string().trim().is_empty())
1452 || entity
1453 .sections
1454 .get(field.as_str())
1455 .is_some_and(|body| !body.trim().is_empty());
1456 if satisfied {
1457 return None;
1458 }
1459 Some(UnsatisfiedConstraint::RequiresWhen {
1460 field: field.clone(),
1461 when_field: when_field.clone(),
1462 when_value: when_value.clone(),
1463 severity: *severity,
1464 })
1465 }
1466 ConstraintDef::Unique { fields, severity } => {
1467 let tuple = tuple_of(entity, fields)?;
1468 let mut colliding: Vec<&str> = store
1469 .all_entities()
1470 .filter(|other| {
1471 !other.stub
1472 && other.mem == entity.mem
1473 && other.entity_type == entity.entity_type
1474 && Some(&other.id) != exclude
1475 && other.id != entity.id
1476 && tuple_of(other, fields).as_ref() == Some(&tuple)
1477 })
1478 .map(|other| other.id.0.as_str())
1479 .collect();
1480 colliding.sort_unstable();
1481 let first = colliding.first()?;
1482 Some(UnsatisfiedConstraint::Unique {
1483 fields: fields.clone(),
1484 values: tuple,
1485 colliding: first.to_string(),
1486 severity: *severity,
1487 })
1488 }
1489 ConstraintDef::EnumFromNeighbour {
1490 field,
1491 rel_type,
1492 section,
1493 severity,
1494 } => {
1495 let value = entity
1496 .metadata
1497 .get(field.as_str())
1498 .map(|v| v.to_frontmatter_string())
1499 .filter(|v| !v.trim().is_empty())?;
1500 let backed = entity
1501 .relationships
1502 .iter()
1503 .filter(|rel| rel.rel_type == *rel_type)
1504 .filter_map(|rel| store.get(&rel.target))
1505 .filter_map(|neighbour| neighbour.sections.get(section.as_str()))
1506 .any(|body| bullet_entries(body).contains(&value));
1507 if backed {
1508 return None;
1509 }
1510 Some(UnsatisfiedConstraint::EnumFromNeighbour {
1511 field: field.clone(),
1512 value,
1513 rel_type: rel_type.clone(),
1514 section: section.clone(),
1515 severity: *severity,
1516 })
1517 }
1518 ConstraintDef::StatusPropagation { .. } => None,
1519 ConstraintDef::TransitionRequiresChecks {
1520 field,
1521 to_value,
1522 relationships,
1523 direction,
1524 severity,
1525 } => {
1526 let triggered = entity
1527 .metadata
1528 .get(field.as_str())
1529 .is_some_and(|v| v.to_frontmatter_string() == *to_value);
1530 if !triggered {
1531 return None;
1532 }
1533 let (_, unchecked) = transition_gate_standing(
1534 store,
1535 entity,
1536 relationships,
1537 *direction,
1538 exclude,
1539 checks,
1540 );
1541 if unchecked.is_empty() {
1542 return None;
1543 }
1544 Some(UnsatisfiedConstraint::TransitionRequiresChecks {
1545 field: field.clone(),
1546 to_value: to_value.clone(),
1547 relationships: relationships.clone(),
1548 direction: *direction,
1549 unchecked,
1550 severity: *severity,
1551 })
1552 }
1553 })
1554 .collect()
1555}
1556
1557pub fn transition_gate_standing(
1568 store: &Store,
1569 entity: &crate::entity::Entity,
1570 relationships: &[String],
1571 direction: memstead_schema::PropagationDirection,
1572 exclude: Option<&crate::entity::EntityId>,
1573 checks: Option<CheckStateProvider<'_>>,
1574) -> (usize, Vec<UncheckedRelated>) {
1575 let related: Vec<&crate::entity::Entity> = match direction {
1576 memstead_schema::PropagationDirection::Outgoing => entity
1577 .relationships
1578 .iter()
1579 .filter(|rel| relationships.contains(&rel.rel_type))
1580 .filter_map(|rel| store.get(&rel.target))
1581 .collect(),
1582 memstead_schema::PropagationDirection::Incoming => store
1583 .all_entities()
1584 .filter(|other| {
1585 other.id != entity.id
1586 && Some(&other.id) != exclude
1587 && other
1588 .relationships
1589 .iter()
1590 .any(|rel| rel.target == entity.id && relationships.contains(&rel.rel_type))
1591 })
1592 .collect(),
1593 };
1594 let total = related.len();
1595 let mut unchecked: Vec<UncheckedRelated> = related
1596 .into_iter()
1597 .filter_map(|rel_entity| {
1598 let state = match checks {
1599 Some(provider) => provider(rel_entity),
1600 None => crate::check::CheckState::NeverChecked,
1601 };
1602 if state == crate::check::CheckState::CheckedOk {
1603 None
1604 } else {
1605 Some(UncheckedRelated {
1606 id: rel_entity.id.0.clone(),
1607 state: state.as_str().to_string(),
1608 })
1609 }
1610 })
1611 .collect();
1612 unchecked.sort_by(|a, b| a.id.cmp(&b.id));
1613 (total, unchecked)
1614}
1615
1616fn tuple_of(entity: &crate::entity::Entity, fields: &[String]) -> Option<Vec<String>> {
1620 fields
1621 .iter()
1622 .map(|f| {
1623 entity
1624 .metadata
1625 .get(f.as_str())
1626 .map(|v| v.to_frontmatter_string())
1627 .filter(|v| !v.trim().is_empty())
1628 })
1629 .collect()
1630}
1631
1632fn bullet_entries(body: &str) -> Vec<String> {
1635 let masked = crate::markdown::mask_code_blocks_and_spans(body);
1640 body.lines()
1641 .zip(masked.lines())
1642 .filter_map(|(line, masked_line)| {
1643 let m = masked_line.trim_start();
1644 if m.starts_with("- ") || m.starts_with("* ") {
1645 let t = line.trim_start();
1646 t.strip_prefix("- ")
1647 .or_else(|| t.strip_prefix("* "))
1648 .map(|e| e.trim().to_string())
1649 } else {
1650 None
1651 }
1652 })
1653 .collect()
1654}
1655
1656#[derive(Debug, Clone, serde::Serialize)]
1661pub struct ConstraintFindingReport {
1662 pub id: crate::entity::EntityId,
1663 pub title: String,
1664 pub entity_type: String,
1665 pub mem: String,
1666 pub violations: Vec<UnsatisfiedConstraint>,
1667 #[serde(skip_serializing_if = "Vec::is_empty")]
1671 pub format_violations: Vec<crate::section_format::SectionFormatViolation>,
1672}
1673
1674pub fn collect_constraint_findings(
1684 store: &Store,
1685 mem_filter: Option<&str>,
1686 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
1687 checks: Option<CheckStateProvider<'_>>,
1688) -> Vec<ConstraintFindingReport> {
1689 use memstead_schema::ConstraintDef;
1690 type Bucket = (
1691 Vec<UnsatisfiedConstraint>,
1692 Vec<crate::section_format::SectionFormatViolation>,
1693 );
1694 let mut by_entity: std::collections::BTreeMap<String, Bucket> = Default::default();
1695
1696 let needs_reverse = mem_schemas.values().any(|s| {
1700 s.types.values().any(|t| {
1701 t.must_reach
1702 .iter()
1703 .any(|ob| ob.direction == memstead_schema::ReachDirection::In)
1704 })
1705 });
1706 let reverse: ReverseIndex = if needs_reverse {
1707 build_reverse_index(store)
1708 } else {
1709 ReverseIndex::default()
1710 };
1711
1712 for entity in store.all_entities() {
1713 if entity.stub {
1714 continue;
1715 }
1716 if let Some(v) = mem_filter
1717 && entity.mem != v
1718 {
1719 continue;
1720 }
1721 let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
1722 continue;
1723 };
1724 let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
1725 continue;
1726 };
1727
1728 for def in &td.sections {
1733 if def.compiled_content.is_none() {
1734 continue;
1735 }
1736 let Some(body) = entity.sections.get(def.key.as_str()) else {
1737 continue;
1738 };
1739 let violations = crate::section_format::check_section_format(def, body);
1740 if !violations.is_empty() {
1741 by_entity
1742 .entry(entity.id.0.clone())
1743 .or_default()
1744 .1
1745 .extend(violations);
1746 }
1747 }
1748
1749 for ob in &td.must_reach {
1754 if !reaches_terminal(store, &reverse, &entity.id, ob) {
1755 by_entity.entry(entity.id.0.clone()).or_default().0.push(
1756 UnsatisfiedConstraint::MustReach {
1757 relationships: ob.relationships.clone(),
1758 direction: ob.direction,
1759 terminal_types: ob.terminal_types.clone(),
1760 max_depth: ob.max_depth,
1761 severity: ob.severity,
1762 },
1763 );
1764 }
1765 }
1766
1767 if td.constraints.is_empty() {
1768 continue;
1769 }
1770
1771 let violations = unsatisfied_constraints(store, entity, td, None, checks);
1773 if !violations.is_empty() {
1774 by_entity
1775 .entry(entity.id.0.clone())
1776 .or_default()
1777 .0
1778 .extend(violations);
1779 }
1780
1781 for c in &td.constraints {
1786 let ConstraintDef::StatusPropagation {
1787 field,
1788 value,
1789 rel_type,
1790 rel_types,
1791 direction,
1792 severity,
1793 } = c
1794 else {
1795 continue;
1796 };
1797 let terminal = entity
1798 .metadata
1799 .get(field.as_str())
1800 .is_some_and(|v| v.to_frontmatter_string() == *value);
1801 if !terminal {
1802 continue;
1803 }
1804 let set = c
1805 .propagation_rel_types()
1806 .expect("StatusPropagation always yields a set");
1807 for tainted in reach_transitively(store, &entity.id, &set, *direction) {
1808 if let Some(v) = mem_filter
1809 && tainted.mem() != v
1810 {
1811 continue;
1812 }
1813 by_entity.entry(tainted.0.clone()).or_default().0.push(
1814 UnsatisfiedConstraint::StatusPropagation {
1815 field: field.clone(),
1816 value: value.clone(),
1817 rel_type: rel_type.clone(),
1818 rel_types: rel_types.clone(),
1819 tainted_by: entity.id.to_string(),
1820 severity: *severity,
1821 },
1822 );
1823 }
1824 }
1825 }
1826
1827 let mut out: Vec<ConstraintFindingReport> = by_entity
1828 .into_iter()
1829 .filter_map(|(id, (violations, format_violations))| {
1830 let id = crate::entity::EntityId(id);
1831 let entity = store.get(&id)?;
1832 Some(ConstraintFindingReport {
1833 id,
1834 title: entity.title.clone(),
1835 entity_type: entity.entity_type.clone(),
1836 mem: entity.mem.clone(),
1837 violations,
1838 format_violations,
1839 })
1840 })
1841 .collect();
1842 out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
1843 out
1844}
1845
1846fn reach_transitively(
1853 store: &Store,
1854 start: &crate::entity::EntityId,
1855 rel_types: &[String],
1856 direction: memstead_schema::PropagationDirection,
1857) -> Vec<crate::entity::EntityId> {
1858 use memstead_schema::PropagationDirection;
1859 let mut seen: std::collections::HashSet<crate::entity::EntityId> =
1860 std::iter::once(start.clone()).collect();
1861 let mut frontier = vec![start.clone()];
1862 let mut reached = Vec::new();
1863 while let Some(current) = frontier.pop() {
1864 let next: Vec<crate::entity::EntityId> = match direction {
1865 PropagationDirection::Incoming => store
1866 .all_entities()
1867 .filter(|e| {
1868 e.relationships
1869 .iter()
1870 .any(|r| rel_types.iter().any(|n| n == &r.rel_type) && r.target == current)
1871 })
1872 .map(|e| e.id.clone())
1873 .collect(),
1874 PropagationDirection::Outgoing => store
1875 .get(¤t)
1876 .map(|e| {
1877 e.relationships
1878 .iter()
1879 .filter(|r| rel_types.iter().any(|n| n == &r.rel_type))
1880 .map(|r| r.target.clone())
1881 .collect()
1882 })
1883 .unwrap_or_default(),
1884 };
1885 for id in next {
1886 if seen.insert(id.clone()) {
1887 if store.get(&id).is_some_and(|e| !e.stub) {
1888 reached.push(id.clone());
1889 }
1890 frontier.push(id);
1891 }
1892 }
1893 }
1894 reached
1895}
1896
1897type ReverseIndex =
1902 std::collections::HashMap<crate::entity::EntityId, Vec<(String, crate::entity::EntityId)>>;
1903
1904fn build_reverse_index(store: &Store) -> ReverseIndex {
1905 let mut idx = ReverseIndex::default();
1906 for entity in store.all_entities() {
1907 for rel in &entity.relationships {
1908 idx.entry(rel.target.clone())
1909 .or_default()
1910 .push((rel.rel_type.clone(), entity.id.clone()));
1911 }
1912 }
1913 idx
1914}
1915
1916fn reaches_terminal(
1925 store: &Store,
1926 reverse: &ReverseIndex,
1927 start: &crate::entity::EntityId,
1928 ob: &memstead_schema::MustReach,
1929) -> bool {
1930 use memstead_schema::ReachDirection;
1931 let mut seen: std::collections::HashSet<crate::entity::EntityId> =
1932 std::iter::once(start.clone()).collect();
1933 let mut frontier = vec![start.clone()];
1934 let mut depth: u32 = 0;
1935 while !frontier.is_empty() {
1936 if let Some(max) = ob.max_depth
1937 && depth >= max
1938 {
1939 return false;
1940 }
1941 depth += 1;
1942 let mut next_frontier = Vec::new();
1943 for current in frontier {
1944 let next: Vec<crate::entity::EntityId> = match ob.direction {
1945 ReachDirection::Out => store
1946 .get(¤t)
1947 .map(|e| {
1948 e.relationships
1949 .iter()
1950 .filter(|r| ob.relationships.iter().any(|n| n == &r.rel_type))
1951 .map(|r| r.target.clone())
1952 .collect()
1953 })
1954 .unwrap_or_default(),
1955 ReachDirection::In => reverse
1956 .get(¤t)
1957 .map(|sources| {
1958 sources
1959 .iter()
1960 .filter(|(rel, _)| ob.relationships.iter().any(|n| n == rel))
1961 .map(|(_, src)| src.clone())
1962 .collect()
1963 })
1964 .unwrap_or_default(),
1965 };
1966 for id in next {
1967 if seen.insert(id.clone()) {
1968 if store.get(&id).is_some_and(|e| {
1969 !e.stub && ob.terminal_types.iter().any(|t| t == &e.entity_type)
1970 }) {
1971 return true;
1972 }
1973 next_frontier.push(id);
1974 }
1975 }
1976 }
1977 frontier = next_frontier;
1978 }
1979 false
1980}
1981
1982#[derive(Debug, Clone, serde::Serialize)]
1987pub struct SignalReport {
1988 pub id: crate::entity::EntityId,
1989 pub title: String,
1990 pub entity_type: String,
1991 pub mem: String,
1992 pub signals: Vec<super::signals::ComputedSignal>,
1994}
1995
1996impl SignalReport {
1997 pub fn has_warn(&self) -> bool {
2001 self.signals
2002 .iter()
2003 .any(|s| s.level == Some(memstead_schema::SignalLevel::Warn))
2004 }
2005}
2006
2007pub fn collect_signal_reports(
2011 store: &Store,
2012 mem_filter: Option<&str>,
2013 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
2014) -> Vec<SignalReport> {
2015 let mut out = Vec::new();
2016 for entity in store.all_entities() {
2017 if entity.stub {
2018 continue;
2019 }
2020 if let Some(v) = mem_filter
2021 && entity.mem != v
2022 {
2023 continue;
2024 }
2025 let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
2026 continue;
2027 };
2028 let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
2029 continue;
2030 };
2031 if td.signals.is_empty() {
2032 continue;
2033 }
2034 let above: Vec<super::signals::ComputedSignal> =
2035 super::signals::compute_signals(store, td, &entity.id)
2036 .into_iter()
2037 .filter(|s| s.level.is_some())
2038 .collect();
2039 if above.is_empty() {
2040 continue;
2041 }
2042 out.push(SignalReport {
2043 id: entity.id.clone(),
2044 title: entity.title.clone(),
2045 entity_type: entity.entity_type.clone(),
2046 mem: entity.mem.clone(),
2047 signals: above,
2048 });
2049 }
2050 out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
2051 out
2052}
2053
2054#[derive(Debug, Clone, serde::Serialize)]
2059pub struct SchemaFormatDefect {
2060 pub schema: String,
2061 pub type_name: String,
2062 pub section: String,
2063 pub problems: Vec<String>,
2064}
2065
2066pub fn collect_schema_format_defects(
2070 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
2071) -> Vec<SchemaFormatDefect> {
2072 let mut seen: std::collections::BTreeSet<String> = Default::default();
2073 let mut out = Vec::new();
2074 let mut schemas: Vec<&Arc<memstead_schema::Schema>> = mem_schemas.values().collect();
2075 schemas.sort_by_key(|s| (s.manifest.name.clone(), s.version.clone()));
2076 for schema in schemas {
2077 let schema_ref = format!("{}@{}", schema.manifest.name, schema.version);
2078 if !seen.insert(schema_ref.clone()) {
2079 continue;
2080 }
2081 for td in schema.types.values() {
2082 for section in &td.sections {
2083 if !section.format_problems.is_empty() {
2084 out.push(SchemaFormatDefect {
2085 schema: schema_ref.clone(),
2086 type_name: td.name.clone(),
2087 section: section.key.clone(),
2088 problems: section.format_problems.clone(),
2089 });
2090 }
2091 }
2092 }
2093 }
2094 out.sort_by(|a, b| {
2095 (&a.schema, &a.type_name, &a.section).cmp(&(&b.schema, &b.type_name, &b.section))
2096 });
2097 out
2098}
2099
2100#[derive(Debug, Clone, serde::Serialize)]
2108pub struct MissingRequiredOutgoingReport {
2109 pub id: crate::entity::EntityId,
2110 pub title: String,
2111 pub entity_type: String,
2112 pub mem: String,
2113 pub missing: Vec<super::MissingRequiredOutgoingBlock>,
2114}
2115
2116pub fn config_projection(
2129 engine: &crate::Engine,
2130 writable_mems: &[String],
2131 mutations: serde_json::Value,
2132 plugin: serde_json::Value,
2133) -> serde_json::Map<String, serde_json::Value> {
2134 let backend_by_mem: std::collections::HashMap<&str, (&'static str, bool)> = engine
2139 .mounts()
2140 .iter()
2141 .map(|m| {
2142 (
2143 m.mem.as_str(),
2144 (m.storage.backend_id(), m.storage.is_durable()),
2145 )
2146 })
2147 .collect();
2148 let mems_detail: Vec<serde_json::Value> = writable_mems
2149 .iter()
2150 .map(|name| {
2151 let origin = engine
2152 .mem_router()
2153 .origin_for_mem(name)
2154 .map(|o| o.kind())
2155 .unwrap_or("explicit");
2156 let mut entry = serde_json::Map::new();
2157 entry.insert("name".into(), serde_json::json!(name));
2158 entry.insert("origin".into(), serde_json::json!(origin));
2159 if let Some((storage, durable)) = backend_by_mem.get(name.as_str()).copied() {
2160 entry.insert("storage".into(), serde_json::json!(storage));
2161 entry.insert("durable".into(), serde_json::json!(durable));
2162 }
2163 let mut vcs_obj = serde_json::Map::new();
2164 if let Ok(gitdir) = engine.gitdir_for(name) {
2165 vcs_obj.insert("gitdir".into(), serde_json::json!(gitdir));
2166 }
2167 if let Ok(worktree) = engine.worktree_for(name) {
2168 vcs_obj.insert("worktree".into(), serde_json::json!(worktree));
2169 }
2170 if let Some(sha) = engine.mem_head_sha(name).ok().flatten() {
2171 vcs_obj.insert("head".into(), serde_json::json!(sha));
2172 }
2173 if !vcs_obj.is_empty() {
2174 entry.insert("vcs".into(), serde_json::Value::Object(vcs_obj));
2175 }
2176 if let Some(cfg) = engine.mem_config_for(name) {
2177 if let Some(title) = &cfg.title {
2181 entry.insert("title".into(), serde_json::json!(title));
2182 }
2183 if let Some(subject) = &cfg.subject {
2184 entry.insert("subject".into(), serde_json::json!(subject));
2185 }
2186 let guidance = serde_json::Map::from_iter(
2187 cfg.write_guidance
2188 .iter()
2189 .map(|(k, v)| (k.clone(), v.clone())),
2190 );
2191 entry.insert("write_guidance".into(), serde_json::Value::Object(guidance));
2192 let extra = serde_json::Map::from_iter(
2193 cfg.extra.iter().map(|(k, v)| (k.clone(), v.clone())),
2194 );
2195 entry.insert("extra".into(), serde_json::Value::Object(extra));
2196 }
2197 serde_json::Value::Object(entry)
2198 })
2199 .collect();
2200
2201 let mut out = serde_json::Map::new();
2202 out.insert("mems".into(), serde_json::json!(mems_detail));
2203 out.insert("mutations".into(), mutations);
2204 out.insert("plugin".into(), plugin);
2205 out
2206}
2207
2208pub fn config_projection_from_settings(
2214 settings: &crate::workspace::WorkspaceSettings,
2215) -> (serde_json::Value, serde_json::Value) {
2216 let mutations = serde_json::json!({ "require_notes": settings.mutations.require_notes });
2217 let plugin_map: serde_json::Map<String, serde_json::Value> = settings
2218 .plugin
2219 .iter()
2220 .map(|(k, v)| {
2221 (
2222 k.clone(),
2223 serde_json::to_value(v).unwrap_or(serde_json::Value::Null),
2224 )
2225 })
2226 .collect();
2227 (mutations, serde_json::Value::Object(plugin_map))
2228}
2229
2230pub(crate) fn section_heading_mismatch_issue(
2245 entity: &crate::entity::Entity,
2246 schema: &TypeDefinition,
2247 key: &str,
2248) -> Option<HealthIssue> {
2249 let def = schema.section(key)?;
2250 let derived = memstead_schema::derive_section_key(&def.heading);
2251 if derived == key {
2252 return None;
2253 }
2254 if !entity
2255 .raw_section_headings
2256 .iter()
2257 .any(|h| h == &def.heading)
2258 {
2259 return None;
2260 }
2261 let landing = match schema.catch_all_section() {
2262 Some(c) => format!(
2263 "the content was absorbed into catch-all section '{}'",
2264 c.key
2265 ),
2266 None => "the content is unreachable under any declared key".to_string(),
2267 };
2268 Some(HealthIssue {
2269 field: key.to_string(),
2270 code: super::HealthIssueCode::SectionHeadingMismatch,
2271 message: format!(
2272 "SECTION_HEADING_MISMATCH: section '{key}' is not missing — its content sits \
2273 under heading '{found}', which derives to '{derived}', not '{key}'; {landing}. \
2274 The schema's declared heading cannot round-trip to its key (expected a heading \
2275 that derives to '{key}'); fix the schema's heading/key pair — new installs of \
2276 such a schema are refused",
2277 found = def.heading,
2278 ),
2279 })
2280}
2281
2282pub fn entity_health(entity: &crate::entity::Entity, schema: &TypeDefinition) -> HealthReport {
2284 let mut issues = Vec::new();
2285
2286 for field in &schema.health_required_fields {
2287 if schema.section(field).is_some() {
2288 let content = entity.sections.get(field.as_str());
2289 if content.is_none_or(|c| c.trim().is_empty()) {
2290 if let Some(issue) = section_heading_mismatch_issue(entity, schema, field) {
2291 issues.push(issue);
2292 } else {
2293 issues.push(HealthIssue {
2294 field: field.clone(),
2295 code: super::HealthIssueCode::Missing,
2296 message: format!("required section '{field}' is empty"),
2297 });
2298 }
2299 }
2300 } else {
2301 let value = entity.metadata.get(field.as_str());
2302 if value.is_none() {
2303 issues.push(HealthIssue {
2304 field: field.clone(),
2305 code: super::HealthIssueCode::Missing,
2306 message: format!("required field '{field}' is missing"),
2307 });
2308 }
2309 }
2310 }
2311
2312 for s in schema.sections.iter().filter(|s| !s.catch_all) {
2316 if schema.health_required_fields.contains(&s.key) {
2317 continue; }
2319 let content = entity.sections.get(s.key.as_str());
2320 if content.is_none_or(|c| c.trim().is_empty())
2321 && let Some(issue) = section_heading_mismatch_issue(entity, schema, &s.key)
2322 {
2323 issues.push(issue);
2324 }
2325 }
2326
2327 let total = schema.health_required_fields.len();
2328 let score = if total > 0 {
2329 (total.saturating_sub(issues.len()) as f32) / (total as f32)
2330 } else {
2331 1.0
2332 };
2333
2334 HealthReport {
2335 id: entity.id.clone(),
2336 title: entity.title.clone(),
2337 score,
2338 issues,
2339 }
2340}
2341
2342fn days_since_epoch() -> u64 {
2353 #[cfg(target_arch = "wasm32")]
2354 {
2355 (js_sys::Date::now() / 1000.0) as u64 / 86400
2356 }
2357 #[cfg(not(target_arch = "wasm32"))]
2358 {
2359 std::time::SystemTime::now()
2360 .duration_since(std::time::UNIX_EPOCH)
2361 .unwrap_or_default()
2362 .as_secs()
2363 / 86400
2364 }
2365}
2366
2367fn parse_iso_to_days(date: &str) -> Option<u64> {
2370 let date_part = date.split('T').next()?;
2371 let parts: Vec<&str> = date_part.split('-').collect();
2372 if parts.len() != 3 {
2373 return None;
2374 }
2375 let year: u64 = parts[0].parse().ok()?;
2376 let month: u64 = parts[1].parse().ok()?;
2377 let day: u64 = parts[2].parse().ok()?;
2378 Some(ymd_to_days(year, month, day))
2379}
2380
2381fn ymd_to_days(year: u64, month: u64, day: u64) -> u64 {
2384 let y = if month <= 2 { year - 1 } else { year };
2386 let m = if month <= 2 { month + 9 } else { month - 3 };
2387 let era = y / 400;
2388 let yoe = y - era * 400;
2389 let doy = (153 * m + 2) / 5 + day - 1;
2390 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
2391 let days = era * 146097 + doe;
2392 days - 719468
2393}
2394
2395#[cfg(test)]
2396mod tests {
2397 use super::*;
2398 use crate::entity::{Entity, EntityId, MetadataValue};
2399 use crate::ops::DanglingLinkKind;
2400 use crate::store::Store;
2401 use indexmap::IndexMap;
2402 use memstead_schema::type_by_name;
2403
2404 #[test]
2409 fn bullet_entries_ignores_code() {
2410 let body = "- real-one\n- real-two\n\n```\n- fenced-ghost\n```\n\n - indented-ghost\n\nA `- span-ghost` sample.\n";
2411 let entries = bullet_entries(body);
2412 assert_eq!(
2413 entries,
2414 vec!["real-one".to_string(), "real-two".to_string()],
2415 "only prose bullets are legal values: {entries:?}"
2416 );
2417 }
2418
2419 #[test]
2423 fn bullet_entries_still_reads_prose_bullets_verbatim() {
2424 let entries = bullet_entries("- alpha\n * beta\n* `gamma`\n");
2425 assert_eq!(
2426 entries,
2427 vec![
2428 "alpha".to_string(),
2429 "beta".to_string(),
2430 "`gamma`".to_string()
2431 ]
2432 );
2433 }
2434
2435 #[test]
2441 fn declared_process_mem_pairs_and_missing_declaration_is_typed() {
2442 use crate::engine::test_helpers::folder_mount;
2443 let tmp = tempfile::TempDir::new().unwrap();
2444 let dest_dir = tmp.path().join("dest");
2445 let proc_dir = tmp.path().join("oddly-named-process");
2446 std::fs::create_dir_all(dest_dir.join(".memstead")).unwrap();
2447 std::fs::create_dir_all(&proc_dir).unwrap();
2448 std::fs::write(
2451 dest_dir.join(".memstead").join("config.json"),
2452 r#"{ "schema": "default@1.0.0", "processMem": "oddly-named-process" }"#,
2453 )
2454 .unwrap();
2455 let engine = crate::Engine::from_mounts(vec![
2456 (
2457 folder_mount("dest", dest_dir.clone()),
2458 Box::new(crate::storage::FilesystemMemWriter::new(dest_dir.clone()))
2459 as Box<dyn crate::backend::MemBackend>,
2460 ),
2461 (
2462 folder_mount("oddly-named-process", proc_dir.clone()),
2463 Box::new(crate::storage::FilesystemMemWriter::new(proc_dir))
2464 as Box<dyn crate::backend::MemBackend>,
2465 ),
2466 ])
2467 .unwrap();
2468
2469 let r = crate::ingest::resolve::resolve_process_mem(&engine, "dest", "dest-derived");
2471 assert!(r.declared && r.mounted);
2472 assert_eq!(r.mem, "oddly-named-process");
2473 let r =
2476 crate::ingest::resolve::resolve_process_mem(&engine, "oddly-named-process", "whatever");
2477 assert!(!r.declared && !r.mounted);
2478 assert_eq!(r.mem, "whatever");
2479
2480 let axis = health_open_questions_axis(&engine, Some("dest"));
2482 let process = &axis["dest"]["process"];
2483 assert_eq!(process[0]["process_mem"], "oddly-named-process", "{axis}");
2484 assert_eq!(process[0]["declared"], true, "{axis}");
2485 assert_eq!(process[0]["resolvable"], true, "{axis}");
2486
2487 std::fs::write(
2489 dest_dir.join(".memstead").join("config.json"),
2490 r#"{ "schema": "default@1.0.0", "processMem": "nowhere" }"#,
2491 )
2492 .unwrap();
2493 let engine2 = crate::Engine::from_mounts(vec![(
2494 folder_mount("dest", dest_dir.clone()),
2495 Box::new(crate::storage::FilesystemMemWriter::new(dest_dir))
2496 as Box<dyn crate::backend::MemBackend>,
2497 )])
2498 .unwrap();
2499 let axis = health_open_questions_axis(&engine2, Some("dest"));
2500 let process = &axis["dest"]["process"];
2501 assert_eq!(
2502 process[0]["finding"], "DECLARED_PROCESS_MEM_MISSING",
2503 "{axis}"
2504 );
2505 assert_eq!(process[0]["resolvable"], false, "{axis}");
2506 }
2507
2508 #[test]
2516 fn independence_gate_compares_identities_only() {
2517 use crate::engine::test_helpers::folder_mount;
2518 let tmp = tempfile::TempDir::new().unwrap();
2519 let dir = tmp.path().join("gate");
2520 std::fs::create_dir_all(&dir).unwrap();
2521 let mut engine = crate::Engine::from_mounts(vec![(
2522 folder_mount("gate", dir.clone()),
2523 Box::new(crate::storage::FilesystemMemWriter::new(dir))
2524 as Box<dyn crate::backend::MemBackend>,
2525 )])
2526 .unwrap();
2527 engine.set_workspace_root(tmp.path().to_path_buf());
2528
2529 let create = |engine: &mut crate::Engine, title: &str, identity: Option<&str>| {
2530 engine.set_identity(identity.map(str::to_string));
2531 engine
2532 .create_entity(
2533 crate::CreateEntityArgs {
2534 mem: "gate".to_string(),
2535 title: title.to_string(),
2536 entity_type: "spec".to_string(),
2537 sections: [
2538 ("identity".to_string(), "x".to_string()),
2539 ("purpose".to_string(), "y".to_string()),
2540 ]
2541 .into_iter()
2542 .collect(),
2543 metadata: Default::default(),
2544 relations: Vec::new(),
2545 anchors: Vec::new(),
2546 dry_run: false,
2547 },
2548 crate::vcs::Actor::Cli,
2549 None,
2550 None,
2551 )
2552 .unwrap()
2553 .id
2554 .0
2555 };
2556 let a = create(&mut engine, "Self Checked", Some("alice"));
2557 let b = create(&mut engine, "Independent", Some("alice"));
2558 let c = create(&mut engine, "No Author Identity", None);
2559
2560 let check = |engine: &mut crate::Engine,
2561 id: &str,
2562 identity: Option<&str>,
2563 actor: crate::vcs::Actor,
2564 client: Option<&crate::vcs::ClientId>| {
2565 engine.set_identity(identity.map(str::to_string));
2566 engine
2567 .record_check(
2568 "gate",
2569 id,
2570 crate::check::Verdict::Ok,
2571 crate::check::CheckKind::Verification,
2572 None,
2573 actor,
2574 client,
2575 )
2576 .unwrap();
2577 };
2578 let other_client = crate::vcs::ClientId {
2579 name: "claude-code".into(),
2580 version: "9.9".into(),
2581 };
2582 check(
2585 &mut engine,
2586 &a,
2587 Some("alice"),
2588 crate::vcs::Actor::Agent,
2589 Some(&other_client),
2590 );
2591 check(&mut engine, &b, Some("bob"), crate::vcs::Actor::Cli, None);
2594 check(&mut engine, &c, Some("carol"), crate::vcs::Actor::Cli, None);
2596
2597 let axis = health_checks_axis(&engine, Some("gate"));
2598 let gate = &axis["gate"]["independence"];
2599 assert_eq!(
2600 gate["self_checked"]["items"],
2601 serde_json::json!([a]),
2602 "{axis}"
2603 );
2604 assert_eq!(
2605 gate["confirmed_independent"]["items"],
2606 serde_json::json!([b]),
2607 "{axis}"
2608 );
2609 assert_eq!(
2610 gate["unconfirmable"]["items"],
2611 serde_json::json!([c]),
2612 "{axis}"
2613 );
2614
2615 let prov = engine.entity_provenance("gate", &a).unwrap();
2618 assert_eq!(
2619 prov.created_by.as_ref().and_then(|r| r.identity.as_deref()),
2620 Some("alice"),
2621 "created-by serves the declared identity"
2622 );
2623 assert_eq!(
2624 prov.last_check.as_ref().and_then(|r| r.identity.as_deref()),
2625 Some("alice"),
2626 "the check record serves the declared identity"
2627 );
2628 }
2629
2630 fn make_entity(name: &str, has_required: bool) -> Entity {
2631 let mut metadata = IndexMap::new();
2632 metadata.insert("level".into(), MetadataValue::String("M0".into()));
2633 metadata.insert("type".into(), MetadataValue::String("spec".into()));
2634 metadata.insert(
2635 "created_date".into(),
2636 MetadataValue::String("2026-01-15".into()),
2637 );
2638 metadata.insert(
2639 "last_modified".into(),
2640 MetadataValue::String("2026-04-12".into()),
2641 );
2642
2643 let mut sections = IndexMap::new();
2644 if has_required {
2645 sections.insert("identity".into(), "Has identity.".into());
2646 sections.insert("purpose".into(), "Has purpose.".into());
2647 }
2648
2649 Entity {
2650 id: EntityId::new("specs", name),
2651 title: name.into(),
2652 entity_type: "spec".into(),
2653 mem: "specs".into(),
2654 file_path: format!("{name}.md"),
2655 metadata,
2656 sections,
2657 relationships: Vec::new(),
2658 content_hash: String::new(),
2659 stub: false,
2660 stub_kind: None,
2661 heading_spans: std::collections::HashMap::new(),
2662 raw_section_headings: Vec::new(),
2663 }
2664 }
2665
2666 fn violating_type() -> std::sync::Arc<TypeDefinition> {
2670 let manifest = r#"name: debate
2671version: 0.1.0
2672description: sealed-violator fixture
2673when_to_use: health tests
2674types:
2675 - question
2676relationships:
2677 mode: strict
2678 definitions:
2679 - name: PART_OF
2680 description: hier
2681 default_weight: 3.0
2682 - name: _default
2683 description: fallback
2684 default_weight: 1.0
2685community:
2686 resolution: 1.0
2687 seed: 42
2688"#;
2689 let type_yaml = r#"name: question
2690description: t
2691when_to_use: tests
2692sections:
2693 - key: answers
2694 heading: Answers argued
2695 required: true
2696 search_weight: 10.0
2697 write_rules: []
2698 - key: notes
2699 heading: Notes
2700 required: false
2701 search_weight: 3.0
2702 catch_all: true
2703 write_rules: []
2704metadata_fields: []
2705title_weight: 100.0
2706text_fields:
2707 - answers
2708 - notes
2709hierarchy_relationship: PART_OF
2710no_self_loop_relationships: []
2711updatable_fields:
2712 - title
2713 - answers
2714 - notes
2715health_required_fields:
2716 - answers
2717staleness_threshold_days: 90
2718write_rules: []
2719"#;
2720 memstead_schema::load_schema_from_memory(
2721 manifest,
2722 &[("question".to_string(), type_yaml.to_string())],
2723 )
2724 .expect("violating schema still loads")
2725 .get_type("question")
2726 .expect("question type")
2727 }
2728
2729 #[test]
2735 fn health_distinguishes_heading_mismatch_from_missing_section() {
2736 let schema = violating_type();
2737
2738 let md = "---\ntype: question\n---\n# Q\n\n## Answers argued\n\nTwo answers.\n";
2740 let parsed = crate::entity::parser::parse_markdown(md, "q.md", &schema, "debate")
2741 .expect("parses")
2742 .entity;
2743 let report = entity_health(&parsed, &schema);
2744 let mismatch: Vec<_> = report
2745 .issues
2746 .iter()
2747 .filter(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch)
2748 .collect();
2749 assert_eq!(mismatch.len(), 1, "issues: {:?}", report.issues);
2750 let msg = &mismatch[0].message;
2751 assert!(
2752 msg.contains("'Answers argued'") && msg.contains("'answers_argued'"),
2753 "names found heading and derived key: {msg}"
2754 );
2755 assert!(
2756 msg.contains("'notes'"),
2757 "names the catch-all landing: {msg}"
2758 );
2759 assert!(
2760 !report.issues.iter().any(|i| i.message.contains("is empty")),
2761 "must not also report the section as missing: {:?}",
2762 report.issues
2763 );
2764
2765 let md_missing = "---\ntype: question\n---\n# Q2\n";
2767 let parsed_missing =
2768 crate::entity::parser::parse_markdown(md_missing, "q2.md", &schema, "debate")
2769 .expect("parses")
2770 .entity;
2771 let report_missing = entity_health(&parsed_missing, &schema);
2772 assert!(
2773 report_missing
2774 .issues
2775 .iter()
2776 .any(|i| i.code == super::super::HealthIssueCode::Missing
2777 && i.message == "required section 'answers' is empty"),
2778 "absent section keeps the missing report (structured MISSING code): {:?}",
2779 report_missing.issues
2780 );
2781 assert!(
2782 !report_missing
2783 .issues
2784 .iter()
2785 .any(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch),
2786 "no mismatch finding when the heading is not in the file"
2787 );
2788
2789 let ok_type = crate::entity::parser::parse_markdown(
2794 "---\ntype: question\n---\n# Q3\n\n## Answers\n\nfree.\n",
2795 "q3.md",
2796 &schema,
2797 "debate",
2798 )
2799 .expect("parses")
2800 .entity;
2801 let report_ok = entity_health(&ok_type, &schema);
2802 assert!(
2803 !report_ok
2804 .issues
2805 .iter()
2806 .any(|i| i.code == super::super::HealthIssueCode::SectionHeadingMismatch),
2807 "mismatch fires only when the declared heading is present: {:?}",
2808 report_ok.issues
2809 );
2810 }
2811
2812 fn make_concept_entity(name: &str, with_definition: bool) -> Entity {
2813 let mut metadata = IndexMap::new();
2814 metadata.insert("type".into(), MetadataValue::String("concept".into()));
2815 metadata.insert("maturity".into(), MetadataValue::String("emerging".into()));
2816 metadata.insert(
2817 "abstraction_level".into(),
2818 MetadataValue::String("concrete".into()),
2819 );
2820 metadata.insert(
2821 "created_date".into(),
2822 MetadataValue::String("2026-01-15".into()),
2823 );
2824 metadata.insert(
2825 "last_modified".into(),
2826 MetadataValue::String("2026-04-12".into()),
2827 );
2828
2829 let mut sections = IndexMap::new();
2830 if with_definition {
2831 sections.insert("definition".into(), "Precise definition.".into());
2832 }
2833 sections.insert("explanation".into(), "How it works.".into());
2834
2835 Entity {
2836 id: EntityId::new("concepts", name),
2837 title: name.into(),
2838 entity_type: "concept".into(),
2839 mem: "concepts".into(),
2840 file_path: format!("{name}.md"),
2841 metadata,
2842 sections,
2843 relationships: Vec::new(),
2844 content_hash: String::new(),
2845 stub: false,
2846 stub_kind: None,
2847 heading_spans: std::collections::HashMap::new(),
2848 raw_section_headings: Vec::new(),
2849 }
2850 }
2851
2852 #[test]
2853 fn health_concept_missing_definition_reports_definition_field() {
2854 let schema = &type_by_name("concept").unwrap();
2855 let entity = make_concept_entity("clarity", false);
2856 let report = entity_health(&entity, schema);
2857
2858 assert!(report.issues.iter().any(|i| i.field == "definition"));
2861 assert!(!report.issues.iter().any(|i| i.field == "identity"));
2862 assert!(!report.issues.iter().any(|i| i.field == "purpose"));
2863 assert!(report.score < 1.0);
2864
2865 let healthy = make_concept_entity("clarity-ok", true);
2867 let healthy_report = entity_health(&healthy, schema);
2868 assert!(
2869 !healthy_report
2870 .issues
2871 .iter()
2872 .any(|i| i.field == "definition")
2873 );
2874 }
2875
2876 #[test]
2877 fn health_detects_missing_sections() {
2878 let schema = &type_by_name("spec").unwrap();
2879 let entity = make_entity("incomplete", false);
2880 let report = entity_health(&entity, schema);
2881 assert!(!report.issues.is_empty());
2882 assert!(report.score < 1.0);
2883 }
2884
2885 #[test]
2886 fn health_clean_entity() {
2887 let schema = &type_by_name("spec").unwrap();
2888 let entity = make_entity("complete", true);
2889 let report = entity_health(&entity, schema);
2890 let section_issues: Vec<_> = report
2892 .issues
2893 .iter()
2894 .filter(|i| i.field == "identity" || i.field == "purpose")
2895 .collect();
2896 assert!(section_issues.is_empty());
2897 }
2898
2899 #[test]
2900 fn health_summary_counts() {
2901 let mut store = Store::new();
2902 let e1 = make_entity("healthy", true);
2903 let e2 = make_entity("unhealthy", false);
2904 store.upsert(e1.id.clone(), e1);
2905 store.upsert(e2.id.clone(), e2);
2906
2907 let schema = &type_by_name("spec").unwrap();
2908 let summary = compute_health(&store, schema, &HashMap::new(), None);
2909 assert_eq!(summary.orphan_count, 2); assert_eq!(summary.stub_count, 0);
2911 }
2912
2913 #[test]
2914 fn health_surfaces_invalid_rel_shape_on_existing_edges() {
2915 use crate::entity::Relationship;
2921 use memstead_schema::SchemaRegistry;
2922
2923 let registry = SchemaRegistry::builtin();
2924 let software = registry
2925 .get("software", &semver::Version::new(0, 2, 0))
2926 .expect("software schema ships as a builtin");
2927
2928 let mut store = Store::new();
2929 let mut bad = make_entity("bad-owns-source", true);
2932 bad.entity_type = "spec".into();
2933 bad.metadata
2934 .insert("level".into(), MetadataValue::String("M0".into()));
2935 bad.metadata
2936 .insert("stability".into(), MetadataValue::String("evolving".into()));
2937 bad.relationships.push(Relationship {
2938 rel_type: "OWNS".into(),
2939 target: EntityId::new("specs", "victim"),
2940 description: None,
2941 });
2942 let mut victim = make_entity("victim", true);
2943 victim.entity_type = "spec".into();
2944 store.upsert(bad.id.clone(), bad);
2945 store.upsert(victim.id.clone(), victim);
2946
2947 let mut mem_schemas = HashMap::new();
2948 mem_schemas.insert("specs".to_string(), software);
2949
2950 let schema = &type_by_name("spec").unwrap();
2951 let summary = compute_health(&store, schema, &mem_schemas, None);
2952 let report = summary
2953 .missing_fields
2954 .iter()
2955 .find(|r| r.id.as_ref() == "specs--bad-owns-source")
2956 .expect("shape-violating entity must surface");
2957 let issue = report
2958 .issues
2959 .iter()
2960 .find(|i| i.field == "relationships" && i.message.contains("INVALID_REL_SHAPE"))
2961 .expect("shape violation must produce an INVALID_REL_SHAPE issue");
2962 assert!(
2963 issue.message.contains("OWNS"),
2964 "issue must name the offending rel_type: {}",
2965 issue.message
2966 );
2967 assert!(
2968 issue.message.contains("spec"),
2969 "issue must name the actual source type: {}",
2970 issue.message
2971 );
2972 assert!(
2973 issue.message.contains("actor"),
2974 "issue must name the allowed source type: {}",
2975 issue.message
2976 );
2977 assert!(
2978 issue.message.contains("remove=true"),
2979 "issue must surface the recovery path: {}",
2980 issue.message
2981 );
2982 }
2983
2984 #[test]
2985 fn health_does_not_flag_shape_compliant_edges() {
2986 use crate::entity::Relationship;
2989 use memstead_schema::SchemaRegistry;
2990
2991 let registry = SchemaRegistry::builtin();
2992 let software = registry
2993 .get("software", &semver::Version::new(0, 2, 0))
2994 .expect("software schema ships as a builtin");
2995
2996 let mut store = Store::new();
2997 let mut owner = make_entity("owner", true);
2998 owner.entity_type = "actor".into();
2999 owner
3000 .metadata
3001 .insert("kind".into(), MetadataValue::String("team".into()));
3002 owner
3003 .metadata
3004 .insert("active".into(), MetadataValue::Bool(true));
3005 owner
3006 .metadata
3007 .insert("handle".into(), MetadataValue::String("owner".into()));
3008 owner.relationships.push(Relationship {
3009 rel_type: "OWNS".into(),
3010 target: EntityId::new("specs", "owned"),
3011 description: None,
3012 });
3013 let mut owned = make_entity("owned", true);
3014 owned.entity_type = "spec".into();
3015 store.upsert(owner.id.clone(), owner);
3016 store.upsert(owned.id.clone(), owned);
3017
3018 let mut mem_schemas = HashMap::new();
3019 mem_schemas.insert("specs".to_string(), software);
3020
3021 let schema = &type_by_name("spec").unwrap();
3022 let summary = compute_health(&store, schema, &mem_schemas, None);
3023 let shape_issue = summary
3024 .missing_fields
3025 .iter()
3026 .flat_map(|r| r.issues.iter())
3027 .find(|i| i.message.contains("INVALID_REL_SHAPE"));
3028 assert!(
3029 shape_issue.is_none(),
3030 "shape-compliant edge must not surface a shape issue, got: {shape_issue:?}"
3031 );
3032 }
3033
3034 #[test]
3035 fn health_warns_on_undeclared_relationship_in_existing_entity() {
3036 use crate::entity::Relationship;
3037 use memstead_schema::Schema;
3038
3039 let mut store = Store::new();
3040 let mut entity = make_entity("with-bad-rel", true);
3041 entity.relationships.push(Relationship {
3047 rel_type: "CONJURES".into(),
3048 target: EntityId::new("specs", "unknown"),
3049 description: None,
3050 });
3051 store.upsert(entity.id.clone(), entity);
3052
3053 let mut mem_schemas = HashMap::new();
3054 mem_schemas.insert("specs".to_string(), Schema::builtin_default());
3055
3056 let schema = &type_by_name("spec").unwrap();
3057 let summary = compute_health(&store, schema, &mem_schemas, None);
3058 let report = summary
3059 .missing_fields
3060 .iter()
3061 .find(|r| r.id.as_ref() == "specs--with-bad-rel")
3062 .expect("entity must surface in missing_fields");
3063 let rel_issue = report
3064 .issues
3065 .iter()
3066 .find(|i| i.field == "relationships")
3067 .expect("undeclared relationship must produce an issue");
3068 assert!(
3069 rel_issue.message.contains("CONJURES"),
3070 "issue message must name the offending relationship: {}",
3071 rel_issue.message
3072 );
3073 assert!(
3074 rel_issue.message.contains("default@1.0.0"),
3075 "issue must name the schema pin: {}",
3076 rel_issue.message
3077 );
3078 }
3079
3080 fn make_entity_with_body(name: &str, section_key: &str, body: &str) -> Entity {
3087 let mut entity = make_entity(name, true);
3088 entity.sections.insert(section_key.into(), body.to_string());
3089 entity
3090 }
3091
3092 #[test]
3093 fn dangling_link_detected_after_delete() {
3094 use crate::entity::store_builder::make_stub;
3095
3096 let mut store = Store::new();
3097 let a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
3098 store.upsert(a.id.clone(), a.clone());
3099
3100 let b_id = EntityId::new("specs", "b");
3103 store.upsert(b_id.clone(), make_stub(b_id.clone()));
3104
3105 let dangling = super::collect_dangling_links(&store, None);
3106 assert_eq!(dangling.len(), 1, "exactly one dangling link expected");
3107 let d = &dangling[0];
3108 assert_eq!(d.from, a.id);
3109 assert_eq!(d.target_id, b_id);
3110 assert_eq!(d.target_path, "b");
3111 assert_eq!(d.section.as_deref(), Some("purpose"));
3112 assert_eq!(d.kind, DanglingLinkKind::LinkTargetMissing);
3113 }
3114
3115 #[test]
3123 fn the_three_dangling_conditions_are_discriminated() {
3124 use crate::entity::store_builder::make_stub;
3125
3126 let mut store = Store::new();
3127
3128 let gone = make_entity_with_body("gone-link", "purpose", "See [[absent]].");
3130 store.upsert(gone.id.clone(), gone.clone());
3131 let absent = EntityId::new("specs", "absent");
3132 store.upsert(absent.clone(), make_stub(absent.clone()));
3133
3134 let written = make_entity("written", true);
3137 store.upsert(written.id.clone(), written.clone());
3138 let unrelated = make_entity_with_body("unrelated-link", "purpose", "See [[written]].");
3139 store.upsert(unrelated.id.clone(), unrelated.clone());
3140
3141 let mut rel_source = make_entity("rel-source", true);
3145 rel_source.relationships.push(crate::entity::Relationship {
3146 rel_type: "DEPENDS_ON".to_string(),
3147 target: EntityId::new("specs", "vanished"),
3148 description: None,
3149 });
3150 store.upsert(rel_source.id.clone(), rel_source.clone());
3151
3152 let found = super::collect_dangling_links(&store, None);
3153 let kind_of = |from: &str| {
3154 found
3155 .iter()
3156 .find(|d| d.from.path() == from)
3157 .unwrap_or_else(|| panic!("no dangling link from {from}: {found:?}"))
3158 .kind
3159 };
3160 assert_eq!(kind_of("gone-link"), DanglingLinkKind::LinkTargetMissing);
3161 assert_eq!(kind_of("unrelated-link"), DanglingLinkKind::LinkNotRelated);
3162 assert_eq!(
3163 kind_of("rel-source"),
3164 DanglingLinkKind::RelationTargetMissing
3165 );
3166
3167 let codes: std::collections::BTreeSet<_> = found.iter().map(|d| d.kind.code()).collect();
3170 let repairs: std::collections::BTreeSet<_> =
3171 found.iter().map(|d| d.kind.repair()).collect();
3172 assert_eq!(codes.len(), 3, "{found:?}");
3173 assert_eq!(repairs.len(), 3, "{found:?}");
3174 }
3175
3176 #[test]
3183 fn a_relationship_row_pointing_at_a_stub_is_still_not_flagged() {
3184 use crate::entity::store_builder::make_stub;
3185
3186 let mut store = Store::new();
3187 let stub_id = EntityId::new("specs", "forward");
3188 store.upsert(stub_id.clone(), make_stub(stub_id.clone()));
3189
3190 let mut source = make_entity("forward-ref", true);
3191 source.relationships.push(crate::entity::Relationship {
3192 rel_type: "DEPENDS_ON".to_string(),
3193 target: stub_id.clone(),
3194 description: None,
3195 });
3196 store.upsert(source.id.clone(), source.clone());
3197
3198 assert!(
3199 super::collect_dangling_links(&store, None).is_empty(),
3200 "a forward reference through the relationships table stays unflagged"
3201 );
3202
3203 let body = make_entity_with_body("body-ref", "purpose", "See [[forward]].");
3206 store.upsert(body.id.clone(), body.clone());
3207 let found = super::collect_dangling_links(&store, None);
3208 assert_eq!(found.len(), 1, "{found:?}");
3209 assert_eq!(found[0].from, body.id);
3210 assert_eq!(found[0].kind, DanglingLinkKind::LinkTargetMissing);
3211 }
3212
3213 #[test]
3219 fn dangling_links_and_stubs_serve_in_deterministic_order() {
3220 use crate::entity::store_builder::make_stub;
3221
3222 let build = || {
3223 let mut store = Store::new();
3224 for name in ["zeta", "alpha", "mid"] {
3226 let e = make_entity_with_body(
3227 name,
3228 "purpose",
3229 &format!("See [[gone-{name}]] and [[lost-{name}]]."),
3230 );
3231 store.upsert(e.id.clone(), e);
3232 }
3233 for name in ["zeta", "alpha", "mid"] {
3234 for pre in ["gone", "lost"] {
3235 let id = EntityId::new("specs", &format!("{pre}-{name}"));
3236 store.upsert(id.clone(), make_stub(id));
3237 }
3238 }
3239 store
3240 };
3241
3242 let store_a = build();
3243 let store_b = build();
3244
3245 let key =
3246 |d: &super::DanglingLink| (d.from.0.clone(), d.target_id.0.clone(), d.section.clone());
3247 let dangling_a: Vec<_> = super::collect_dangling_links(&store_a, None)
3248 .iter()
3249 .map(key)
3250 .collect();
3251 let dangling_b: Vec<_> = super::collect_dangling_links(&store_b, None)
3252 .iter()
3253 .map(key)
3254 .collect();
3255 assert_eq!(dangling_a, dangling_b, "identical stores, identical order");
3256 let mut sorted = dangling_a.clone();
3257 sorted.sort();
3258 assert_eq!(dangling_a, sorted, "served pre-sorted by (from, target)");
3259 assert_eq!(dangling_a.len(), 6);
3260
3261 let stub_ids = |s: &Store| -> Vec<String> {
3262 crate::graph::query::find_stubs(s)
3263 .into_iter()
3264 .map(|(id, _)| id.0)
3265 .collect()
3266 };
3267 let stubs_a = stub_ids(&store_a);
3268 assert_eq!(stubs_a, stub_ids(&store_b), "stub order is deterministic");
3269 let mut sorted = stubs_a.clone();
3270 sorted.sort();
3271 assert_eq!(stubs_a, sorted, "stubs served pre-sorted by id");
3272 assert_eq!(stubs_a.len(), 6);
3273 }
3274
3275 #[test]
3276 fn dangling_link_does_not_flag_stub_target_of_explicit_relationship() {
3277 use crate::entity::Relationship;
3278 use crate::entity::store_builder::make_stub;
3279
3280 let mut store = Store::new();
3281 let mut a = make_entity("a", true);
3284 let b_id = EntityId::new("specs", "b");
3285 a.relationships.push(Relationship {
3286 rel_type: "REFERENCES".into(),
3287 target: b_id.clone(),
3288 description: None,
3289 });
3290 store.upsert(a.id.clone(), a);
3291 store.upsert(b_id.clone(), make_stub(b_id));
3292
3293 let dangling = super::collect_dangling_links(&store, None);
3294 assert!(
3295 dangling.is_empty(),
3296 "explicit relationships to stubs are valid by design \
3297 (stubs are first-class placeholders); only inline-body \
3298 wiki-links to stubs must surface"
3299 );
3300 }
3301
3302 #[test]
3303 fn dangling_link_does_not_flag_real_reference() {
3304 use crate::entity::Relationship;
3305
3306 let mut store = Store::new();
3307 let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
3308 a.relationships.push(Relationship {
3310 rel_type: "REFERENCES".into(),
3311 target: EntityId::new("specs", "b"),
3312 description: None,
3313 });
3314 let b = make_entity("b", true);
3315 store.upsert(a.id.clone(), a);
3316 store.upsert(b.id.clone(), b);
3317
3318 let dangling = super::collect_dangling_links(&store, None);
3319 assert!(
3320 dangling.is_empty(),
3321 "real reference backed by relation — not dangling, not alias-orphan"
3322 );
3323 }
3324
3325 #[test]
3330 fn dangling_link_relationship_section_target_absent() {
3331 use crate::entity::Relationship;
3332
3333 let mut store = Store::new();
3334 let mut a = make_entity("a", true);
3335 a.relationships.push(Relationship {
3338 rel_type: "DEPENDS_ON".into(),
3339 target: EntityId::new("specs", "gone"),
3340 description: None,
3341 });
3342 store.upsert(a.id.clone(), a.clone());
3343
3344 let dangling = super::collect_dangling_links(&store, None);
3345 assert_eq!(
3346 dangling.len(),
3347 1,
3348 "exactly one relationship-section dangler"
3349 );
3350 let d = &dangling[0];
3351 assert_eq!(d.from, a.id);
3352 assert_eq!(d.target_id, EntityId::new("specs", "gone"));
3353 assert!(
3354 d.section.is_none(),
3355 "relationship-section danglers ship `section: None`, got {:?}",
3356 d.section
3357 );
3358 }
3359
3360 #[test]
3365 fn dangling_link_relationship_section_stub_target_not_flagged() {
3366 use crate::entity::Relationship;
3367 use crate::entity::store_builder::make_stub;
3368
3369 let mut store = Store::new();
3370 let mut a = make_entity("a", true);
3371 let b_id = EntityId::new("specs", "b");
3372 a.relationships.push(Relationship {
3373 rel_type: "DEPENDS_ON".into(),
3374 target: b_id.clone(),
3375 description: None,
3376 });
3377 store.upsert(a.id.clone(), a);
3378 store.upsert(b_id.clone(), make_stub(b_id));
3379
3380 let dangling = super::collect_dangling_links(&store, None);
3381 assert!(
3382 dangling.is_empty(),
3383 "relationship targets that resolve to stubs are forward-references, not corruption"
3384 );
3385 }
3386
3387 #[test]
3394 fn dangling_link_dedups_across_body_and_relations() {
3395 use crate::entity::Relationship;
3396 use crate::entity::store_builder::make_stub;
3397
3398 let mut store = Store::new();
3399 let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
3400 let b_id = EntityId::new("specs", "b");
3401 a.relationships.push(Relationship {
3402 rel_type: "REFERENCES".into(),
3403 target: b_id.clone(),
3404 description: None,
3405 });
3406 store.upsert(a.id.clone(), a.clone());
3407 store.upsert(b_id.clone(), make_stub(b_id.clone()));
3408
3409 let dangling = super::collect_dangling_links(&store, None);
3410 assert_eq!(
3411 dangling.len(),
3412 1,
3413 "body + relations both pointing at the same stub should dedup"
3414 );
3415 assert!(dangling[0].section.is_some(), "body axis wins the dedup");
3418 }
3419
3420 #[test]
3421 fn dangling_links_scope_to_mem_filter() {
3422 use crate::entity::store_builder::make_stub;
3423
3424 let mut store = Store::new();
3425
3426 let a = make_entity_with_body("a", "purpose", "Refers to [[gone]] in prose.");
3428 store.upsert(a.id.clone(), a);
3429 let gone_specs = EntityId::new("specs", "gone");
3430 store.upsert(gone_specs.clone(), make_stub(gone_specs));
3431
3432 let mut x = make_entity("x", true);
3434 x.id = EntityId::new("web", "x");
3435 x.mem = "web".into();
3436 x.file_path = "x.md".into();
3437 x.sections
3438 .insert("purpose".into(), "Refers to [[gone]] in prose.".into());
3439 store.upsert(x.id.clone(), x);
3440 let gone_web = EntityId::new("web", "gone");
3441 store.upsert(gone_web.clone(), make_stub(gone_web));
3442
3443 let all = super::collect_dangling_links(&store, None);
3444 assert_eq!(all.len(), 2);
3445
3446 let specs_only = super::collect_dangling_links(&store, Some("specs"));
3447 assert_eq!(specs_only.len(), 1);
3448 assert_eq!(specs_only[0].from.mem(), "specs");
3449
3450 let web_only = super::collect_dangling_links(&store, Some("web"));
3451 assert_eq!(web_only.len(), 1);
3452 assert_eq!(web_only[0].from.mem(), "web");
3453 }
3454
3455 #[test]
3456 fn parse_iso_date() {
3457 let days = parse_iso_to_days("2026-04-12").unwrap();
3458 assert!(days > 0);
3459
3460 let days_with_time = parse_iso_to_days("2026-04-12T10:00:00Z").unwrap();
3461 assert_eq!(days, days_with_time);
3462 }
3463
3464 #[test]
3465 fn ymd_roundtrip() {
3466 let days = ymd_to_days(2026, 1, 1);
3468 assert!(days > 20000); }
3470
3471 fn make_entity_with_tags(name: &str, mem: &str, entity_type: &str, tags: &str) -> Entity {
3476 let mut e = make_entity(name, true);
3477 e.id = EntityId::new(mem, name);
3478 e.mem = mem.into();
3479 e.entity_type = entity_type.into();
3480 e.metadata
3481 .insert("tags".into(), MetadataValue::String(tags.into()));
3482 e
3483 }
3484
3485 fn make_entity_no_tags(name: &str) -> Entity {
3486 make_entity(name, true)
3487 }
3488
3489 #[test]
3490 fn tag_distribution_aggregates_across_entities() {
3491 let mut store = Store::new();
3492 let a = make_entity_with_tags("a", "specs", "spec", "decision, plan");
3493 let b = make_entity_with_tags("b", "specs", "spec", "decision, plan");
3494 let c = make_entity_with_tags("c", "specs", "spec", "plan");
3495 store.upsert(a.id.clone(), a);
3496 store.upsert(b.id.clone(), b);
3497 store.upsert(c.id.clone(), c);
3498
3499 let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
3500 assert_eq!(dist.len(), 2);
3501 assert_eq!(dist[0].tag, "plan");
3502 assert_eq!(dist[0].count, 3);
3503 assert_eq!(dist[0].by_entity_type.get("spec"), Some(&3));
3504 assert_eq!(dist[1].tag, "decision");
3505 assert_eq!(dist[1].count, 2);
3506 assert_eq!(untagged.total, 0);
3507 }
3508
3509 #[test]
3510 fn tag_distribution_case_sensitive() {
3511 let mut store = Store::new();
3512 let a = make_entity_with_tags("a", "specs", "spec", "Decision");
3513 let b = make_entity_with_tags("b", "specs", "spec", "decision");
3514 store.upsert(a.id.clone(), a);
3515 store.upsert(b.id.clone(), b);
3516
3517 let (dist, folded, _untagged) = collect_tag_distribution(&store, None, 10);
3518 assert_eq!(dist.len(), 2, "`decision` and `Decision` stay distinct");
3519 let tags: std::collections::HashSet<&str> = dist.iter().map(|t| t.tag.as_str()).collect();
3520 assert!(tags.contains("decision"));
3521 assert!(tags.contains("Decision"));
3522
3523 assert_eq!(folded.len(), 1);
3525 assert_eq!(folded[0].canonical, "decision");
3526 assert_eq!(folded[0].total, 2);
3527 assert_eq!(folded[0].variants.len(), 2);
3528 }
3529
3530 #[test]
3531 fn untagged_entities_counts_missing_and_empty() {
3532 let mut store = Store::new();
3533 let a = make_entity_no_tags("a"); let b = make_entity_with_tags("b", "specs", "spec", "");
3535 let c = make_entity_with_tags("c", "specs", "spec", " , , ");
3536 store.upsert(a.id.clone(), a);
3537 store.upsert(b.id.clone(), b);
3538 store.upsert(c.id.clone(), c);
3539
3540 let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
3541 assert!(dist.is_empty(), "no effective tags → empty distribution");
3542 assert_eq!(untagged.total, 3);
3543 assert_eq!(untagged.by_entity_type.get("spec"), Some(&3));
3544 }
3545
3546 #[test]
3547 fn tag_distribution_respects_mem_filter() {
3548 let mut store = Store::new();
3549 let a = make_entity_with_tags("a", "specs", "spec", "decision");
3550 let b = make_entity_with_tags("b", "memos", "memo", "observation");
3551 let c = make_entity_no_tags("c");
3552 store.upsert(a.id.clone(), a);
3553 store.upsert(b.id.clone(), b);
3554 store.upsert(c.id.clone(), c);
3555
3556 let (dist, _folded, untagged) = collect_tag_distribution(&store, Some("memos"), 10);
3557 assert_eq!(dist.len(), 1);
3558 assert_eq!(dist[0].tag, "observation");
3559 assert_eq!(untagged.total, 0, "untagged scoped to filter mem");
3560 }
3561
3562 #[test]
3563 fn tag_distribution_respects_limit() {
3564 let mut store = Store::new();
3565 for (name, tag) in [
3566 ("a", "t-alpha"),
3567 ("b", "t-beta"),
3568 ("c", "t-gamma"),
3569 ("d", "t-delta"),
3570 ("e", "t-epsilon"),
3571 ] {
3572 let e = make_entity_with_tags(name, "specs", "spec", tag);
3573 store.upsert(e.id.clone(), e);
3574 }
3575
3576 let (dist, _folded, _untagged) = collect_tag_distribution(&store, None, 3);
3577 assert_eq!(dist.len(), 3);
3578 assert_eq!(dist[0].tag, "t-alpha");
3581 assert_eq!(dist[1].tag, "t-beta");
3582 assert_eq!(dist[2].tag, "t-delta");
3583 }
3584
3585 fn required_outgoing_fixture_schema() -> std::sync::Arc<memstead_schema::Schema> {
3592 let manifest = r#"name: tests-ro-health
3593version: 0.1.0
3594description: required_outgoing health test schema
3595when_to_use: tests
3596types:
3597 - decision
3598 - note
3599relationships:
3600 mode: strict
3601 definitions:
3602 - name: PART_OF
3603 description: Hier
3604 default_weight: 3.0
3605 acyclic: true
3606 - name: CHOSEN
3607 description: ch
3608 default_weight: 3.0
3609 - name: REJECTED
3610 description: rj
3611 default_weight: 2.0
3612 - name: REFERENCES
3613 description: ref
3614 default_weight: 0.5
3615 - name: _default
3616 description: Fallback
3617 default_weight: 1.0
3618community:
3619 resolution: 1.0
3620 seed: 42
3621"#;
3622 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";
3623 let decision_yaml = format!(
3624 "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",
3625 );
3626 let note_yaml = format!("name: note\ndescription: t\nwhen_to_use: Here\n{body_section}",);
3627 std::sync::Arc::new(
3628 memstead_schema::load_schema_from_memory(
3629 manifest,
3630 &[
3631 ("decision".to_string(), decision_yaml),
3632 ("note".to_string(), note_yaml),
3633 ],
3634 )
3635 .expect("ro fixture schema must parse"),
3636 )
3637 }
3638
3639 fn make_typed_entity(mem: &str, slug: &str, entity_type: &str) -> crate::entity::Entity {
3640 use crate::entity::MetadataValue;
3641 let mut metadata = IndexMap::new();
3642 metadata.insert("type".into(), MetadataValue::String(entity_type.into()));
3643 let mut sections = IndexMap::new();
3644 sections.insert("body".into(), "Body.".into());
3645 crate::entity::Entity {
3646 id: EntityId::new(mem, slug),
3647 title: slug.to_string(),
3648 entity_type: entity_type.into(),
3649 mem: mem.into(),
3650 file_path: format!("{slug}.md"),
3651 metadata,
3652 sections,
3653 relationships: Vec::new(),
3654 content_hash: String::new(),
3655 stub: false,
3656 stub_kind: None,
3657 heading_spans: std::collections::HashMap::new(),
3658 raw_section_headings: Vec::new(),
3659 }
3660 }
3661
3662 #[test]
3663 fn missing_required_outgoing_collects_violators_only() {
3664 let schema = required_outgoing_fixture_schema();
3665 let mut store = Store::new();
3666 let mut violator = make_typed_entity("plan", "stalled", "decision");
3669 let mut satisfied = make_typed_entity("plan", "wired", "decision");
3670 let opt_a = make_typed_entity("plan", "a", "note");
3671 let opt_b = make_typed_entity("plan", "b", "note");
3672 let happy_note = make_typed_entity("plan", "side", "note");
3673 satisfied.relationships.push(crate::entity::Relationship {
3674 rel_type: "CHOSEN".into(),
3675 target: opt_a.id.clone(),
3676 description: None,
3677 });
3678 satisfied.relationships.push(crate::entity::Relationship {
3679 rel_type: "REJECTED".into(),
3680 target: opt_b.id.clone(),
3681 description: None,
3682 });
3683 for e in [violator.clone(), satisfied, opt_a, opt_b, happy_note] {
3684 store.upsert(e.id.clone(), e);
3685 }
3686
3687 let mut mem_schemas = HashMap::new();
3688 mem_schemas.insert("plan".to_string(), schema);
3689
3690 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
3691 assert_eq!(
3692 reports.len(),
3693 1,
3694 "exactly one violator (the empty decision); got {reports:?}"
3695 );
3696 let r = &reports[0];
3697 assert_eq!(r.id, violator.id);
3698 assert_eq!(r.entity_type, "decision");
3699 assert_eq!(r.mem, "plan");
3700 assert_eq!(r.missing.len(), 2);
3701 let names: Vec<&str> = r
3702 .missing
3703 .iter()
3704 .flat_map(|b| b.relationships.iter().map(String::as_str))
3705 .collect();
3706 assert!(names.contains(&"CHOSEN"));
3707 assert!(names.contains(&"REJECTED"));
3708
3709 violator.relationships.push(crate::entity::Relationship {
3711 rel_type: "CHOSEN".into(),
3712 target: EntityId::new("plan", "x"),
3713 description: None,
3714 });
3715 }
3716
3717 #[test]
3718 fn missing_required_outgoing_respects_mem_filter() {
3719 let schema = required_outgoing_fixture_schema();
3722 let mut store = Store::new();
3723 let v_a = make_typed_entity("alpha", "stalled", "decision");
3724 let v_b = make_typed_entity("beta", "stalled", "decision");
3725 store.upsert(v_a.id.clone(), v_a);
3726 store.upsert(v_b.id.clone(), v_b.clone());
3727
3728 let mut mem_schemas = HashMap::new();
3729 mem_schemas.insert("alpha".to_string(), schema.clone());
3730 mem_schemas.insert("beta".to_string(), schema);
3731
3732 let alpha_only = collect_missing_required_outgoing(&store, Some("alpha"), &mem_schemas);
3733 assert_eq!(alpha_only.len(), 1);
3734 assert_eq!(alpha_only[0].mem, "alpha");
3735
3736 let both = collect_missing_required_outgoing(&store, None, &mem_schemas);
3737 assert_eq!(both.len(), 2);
3738 }
3739
3740 #[test]
3741 fn missing_required_outgoing_skips_stubs_and_unschemaed_mems() {
3742 let schema = required_outgoing_fixture_schema();
3745 let mut store = Store::new();
3746 let mut stub = make_typed_entity("plan", "ghost", "");
3747 stub.stub = true;
3748 stub.entity_type = String::new();
3749 let other = make_typed_entity("uncharted", "lonely", "decision");
3750 store.upsert(stub.id.clone(), stub);
3751 store.upsert(other.id.clone(), other);
3752
3753 let mut mem_schemas = HashMap::new();
3754 mem_schemas.insert("plan".to_string(), schema);
3755
3756 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
3757 assert!(
3758 reports.is_empty(),
3759 "stub (no schema lookup) and unschemaed mem must be skipped; got {reports:?}",
3760 );
3761 }
3762
3763 #[test]
3768 fn missing_required_outgoing_conditional_blocks_arm_on_trigger() {
3769 use crate::entity::MetadataValue;
3770 let manifest = r#"name: tests-ro-cond
3771version: 0.1.0
3772description: conditional required_outgoing health test schema
3773when_to_use: tests
3774types:
3775 - task
3776relationships:
3777 mode: strict
3778 definitions:
3779 - name: PART_OF
3780 description: Hier
3781 default_weight: 3.0
3782 - name: _default
3783 description: Fallback
3784 default_weight: 1.0
3785community:
3786 resolution: 1.0
3787 seed: 42
3788"#;
3789 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";
3790 let schema = std::sync::Arc::new(
3791 memstead_schema::load_schema_from_memory(
3792 manifest,
3793 &[("task".to_string(), task_yaml.to_string())],
3794 )
3795 .expect("conditional ro fixture schema must parse"),
3796 );
3797
3798 let mut store = Store::new();
3799 let mut armed = make_typed_entity("plan", "armed", "task");
3800 armed
3801 .metadata
3802 .insert("status".into(), MetadataValue::String("checked".into()));
3803 let mut other_value = make_typed_entity("plan", "quiet", "task");
3804 other_value
3805 .metadata
3806 .insert("status".into(), MetadataValue::String("open".into()));
3807 let unset = make_typed_entity("plan", "blank", "task");
3808 let parent = make_typed_entity("plan", "parent", "task");
3809 let mut satisfied = make_typed_entity("plan", "wired", "task");
3810 satisfied
3811 .metadata
3812 .insert("status".into(), MetadataValue::String("checked".into()));
3813 satisfied.relationships.push(crate::entity::Relationship {
3814 rel_type: "PART_OF".into(),
3815 target: parent.id.clone(),
3816 description: None,
3817 });
3818 for e in [armed.clone(), other_value, unset, parent, satisfied] {
3819 store.upsert(e.id.clone(), e);
3820 }
3821
3822 let mut mem_schemas = HashMap::new();
3823 mem_schemas.insert("plan".to_string(), schema);
3824
3825 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
3826 assert_eq!(
3827 reports.len(),
3828 1,
3829 "only the armed edge-less entity is reported; got {reports:?}"
3830 );
3831 let r = &reports[0];
3832 assert_eq!(r.id, armed.id);
3833 assert_eq!(r.missing.len(), 1);
3834 assert_eq!(r.missing[0].when_field.as_deref(), Some("status"));
3835 assert_eq!(r.missing[0].when_value.as_deref(), Some("checked"));
3836 }
3837
3838 fn must_reach_schema(
3846 claim_extra: &str,
3847 inference_extra: &str,
3848 ) -> std::sync::Arc<memstead_schema::Schema> {
3849 let manifest = r#"name: tests-must-reach
3850version: 0.1.0
3851description: must_reach health test schema
3852when_to_use: tests
3853types:
3854 - claim
3855 - inference
3856 - evidence
3857relationships:
3858 mode: strict
3859 definitions:
3860 - name: GROUNDS
3861 description: g
3862 default_weight: 3.0
3863 - name: CONCLUDES
3864 description: c
3865 default_weight: 3.0
3866 - name: PART_OF
3867 description: hier
3868 default_weight: 1.0
3869 - name: _default
3870 description: fallback
3871 default_weight: 1.0
3872community:
3873 resolution: 1.0
3874 seed: 42
3875"#;
3876 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";
3877 let claim = format!("name: claim\ndescription: t\nwhen_to_use: Here\n{body}{claim_extra}");
3878 let inference =
3879 format!("name: inference\ndescription: t\nwhen_to_use: Here\n{body}{inference_extra}");
3880 let evidence = format!("name: evidence\ndescription: t\nwhen_to_use: Here\n{body}");
3881 std::sync::Arc::new(
3882 memstead_schema::load_schema_from_memory(
3883 manifest,
3884 &[
3885 ("claim".to_string(), claim),
3886 ("inference".to_string(), inference),
3887 ("evidence".to_string(), evidence),
3888 ],
3889 )
3890 .expect("must_reach fixture schema must parse"),
3891 )
3892 }
3893
3894 fn link(from: &mut crate::entity::Entity, rel: &str, to: &crate::entity::EntityId) {
3895 from.relationships.push(crate::entity::Relationship {
3896 rel_type: rel.into(),
3897 target: to.clone(),
3898 description: None,
3899 });
3900 }
3901
3902 fn must_reach_violations(r: &ConstraintFindingReport) -> Vec<&UnsatisfiedConstraint> {
3903 r.violations
3904 .iter()
3905 .filter(|v| matches!(v, UnsatisfiedConstraint::MustReach { .. }))
3906 .collect()
3907 }
3908
3909 const CLAIM_GROUNDS_EVIDENCE: &str = "must_reach:\n - relationships: [GROUNDS]\n direction: out\n terminal_types: [evidence]\n";
3910
3911 #[test]
3915 fn must_reach_conforming_path_silent_gap_reported() {
3916 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
3917 let mut store = Store::new();
3918 let ev = make_typed_entity("arg", "ev", "evidence");
3919 let mut direct = make_typed_entity("arg", "direct", "claim");
3920 link(&mut direct, "GROUNDS", &ev.id);
3921 let mut mid = make_typed_entity("arg", "mid", "claim");
3922 let mut chained = make_typed_entity("arg", "chained", "claim");
3923 link(&mut chained, "GROUNDS", &mid.id);
3924 link(&mut mid, "GROUNDS", &ev.id);
3925 let floating = make_typed_entity("arg", "floating", "claim");
3926 for e in [ev, direct, mid, chained, floating.clone()] {
3927 store.upsert(e.id.clone(), e);
3928 }
3929 let mut mem_schemas = HashMap::new();
3930 mem_schemas.insert("arg".to_string(), schema);
3931
3932 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
3933 assert_eq!(reports.len(), 1, "only the pathless claim: {reports:?}");
3934 assert_eq!(reports[0].id, floating.id);
3935 let v = must_reach_violations(&reports[0]);
3936 assert_eq!(v.len(), 1);
3937 let UnsatisfiedConstraint::MustReach {
3938 relationships,
3939 direction,
3940 terminal_types,
3941 max_depth,
3942 ..
3943 } = v[0]
3944 else {
3945 panic!("expected must_reach finding");
3946 };
3947 assert_eq!(relationships, &vec!["GROUNDS".to_string()]);
3948 assert_eq!(*direction, memstead_schema::ReachDirection::Out);
3949 assert_eq!(terminal_types, &vec!["evidence".to_string()]);
3950 assert_eq!(*max_depth, None);
3951 }
3952
3953 #[test]
3958 fn must_reach_one_hop_incoming_floating_leap() {
3959 let schema = must_reach_schema(
3960 "",
3961 "must_reach:\n - relationships: [GROUNDS]\n direction: in\n terminal_types: [claim]\n max_depth: 1\n",
3962 );
3963 let mut store = Store::new();
3964 let leap = make_typed_entity("arg", "leap", "inference");
3965 let grounded = make_typed_entity("arg", "grounded", "inference");
3966 let mut premise = make_typed_entity("arg", "premise", "claim");
3967 link(&mut premise, "GROUNDS", &grounded.id);
3968 for e in [leap.clone(), grounded, premise] {
3969 store.upsert(e.id.clone(), e);
3970 }
3971 let mut mem_schemas = HashMap::new();
3972 mem_schemas.insert("arg".to_string(), schema);
3973
3974 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
3975 assert_eq!(reports.len(), 1, "only the floating leap: {reports:?}");
3976 assert_eq!(reports[0].id, leap.id);
3977 }
3978
3979 #[test]
3982 fn must_reach_stub_and_non_terminal_chains_then_cleared() {
3983 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
3984 let mut store = Store::new();
3985 let mut stub_ev = make_typed_entity("arg", "ghost", "evidence");
3986 stub_ev.stub = true;
3987 let mut to_stub = make_typed_entity("arg", "to-stub", "claim");
3988 link(&mut to_stub, "GROUNDS", &stub_ev.id);
3989 let dead_end = make_typed_entity("arg", "dead-end", "claim");
3990 let mut to_claim = make_typed_entity("arg", "to-claim", "claim");
3991 link(&mut to_claim, "GROUNDS", &dead_end.id);
3992 for e in [stub_ev, to_stub.clone(), dead_end, to_claim.clone()] {
3993 store.upsert(e.id.clone(), e);
3994 }
3995 let mut mem_schemas = HashMap::new();
3996 mem_schemas.insert("arg".to_string(), schema.clone());
3997
3998 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
3999 let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
4000 assert!(
4001 ids.contains(&to_stub.id.0.as_str()),
4002 "stub terminates no obligation: {ids:?}"
4003 );
4004 assert!(
4005 ids.contains(&to_claim.id.0.as_str()),
4006 "non-terminal chain is a finding: {ids:?}"
4007 );
4008
4009 let ev = make_typed_entity("arg", "real-ev", "evidence");
4011 let mut repaired = store.get(&to_stub.id).unwrap().clone();
4012 link(&mut repaired, "GROUNDS", &ev.id);
4013 store.upsert(ev.id.clone(), ev);
4014 store.upsert(repaired.id.clone(), repaired);
4015 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4016 let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
4017 assert!(
4018 !ids.contains(&to_stub.id.0.as_str()),
4019 "conforming path clears the finding: {ids:?}"
4020 );
4021 }
4022
4023 #[test]
4027 fn must_reach_cycles_terminate() {
4028 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
4029 let mut store = Store::new();
4030 let mut a = make_typed_entity("arg", "cyc-a", "claim");
4031 let mut b = make_typed_entity("arg", "cyc-b", "claim");
4032 link(&mut a, "GROUNDS", &b.id);
4033 link(&mut b, "GROUNDS", &a.id);
4034 for e in [a, b] {
4035 store.upsert(e.id.clone(), e);
4036 }
4037 let mut mem_schemas = HashMap::new();
4038 mem_schemas.insert("arg".to_string(), schema);
4039
4040 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4041 assert_eq!(reports.len(), 2, "both cycle members lack evidence");
4042 }
4043
4044 #[test]
4048 fn must_reach_depth_bound() {
4049 let two_hop_store = || {
4050 let mut store = Store::new();
4051 let ev = make_typed_entity("arg", "ev", "evidence");
4052 let mut mid = make_typed_entity("arg", "mid", "claim");
4053 let mut start = make_typed_entity("arg", "start", "claim");
4054 link(&mut start, "GROUNDS", &mid.id);
4055 link(&mut mid, "GROUNDS", &ev.id);
4056 for e in [ev, mid, start] {
4057 store.upsert(e.id.clone(), e);
4058 }
4059 store
4060 };
4061 let bounded = |depth: u32| {
4062 must_reach_schema(
4063 &format!(
4064 "must_reach:\n - relationships: [GROUNDS]\n direction: out\n terminal_types: [evidence]\n max_depth: {depth}\n"
4065 ),
4066 "",
4067 )
4068 };
4069
4070 let store = two_hop_store();
4071 let mut mem_schemas = HashMap::new();
4072 mem_schemas.insert("arg".to_string(), bounded(1));
4073 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4074 assert_eq!(
4075 reports.len(),
4076 1,
4077 "the two-hop path exceeds depth 1 for the start claim: {reports:?}"
4078 );
4079 assert_eq!(reports[0].id.0, "arg--start");
4080
4081 let mut mem_schemas = HashMap::new();
4082 mem_schemas.insert("arg".to_string(), bounded(2));
4083 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4084 assert!(
4085 reports.is_empty(),
4086 "the same path satisfies depth 2: {reports:?}"
4087 );
4088 }
4089
4090 #[test]
4093 fn must_reach_two_obligations_one_finding() {
4094 let schema = must_reach_schema(
4095 "must_reach:\n - relationships: [GROUNDS]\n direction: out\n terminal_types: [evidence]\n - relationships: [CONCLUDES]\n direction: out\n terminal_types: [inference]\n",
4096 "",
4097 );
4098 let mut store = Store::new();
4099 let ev = make_typed_entity("arg", "ev", "evidence");
4100 let mut c = make_typed_entity("arg", "half", "claim");
4101 link(&mut c, "GROUNDS", &ev.id);
4102 for e in [ev, c.clone()] {
4103 store.upsert(e.id.clone(), e);
4104 }
4105 let mut mem_schemas = HashMap::new();
4106 mem_schemas.insert("arg".to_string(), schema);
4107
4108 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4109 assert_eq!(reports.len(), 1);
4110 assert_eq!(reports[0].id, c.id);
4111 let v = must_reach_violations(&reports[0]);
4112 assert_eq!(v.len(), 1, "only the unsatisfied obligation: {v:?}");
4113 let UnsatisfiedConstraint::MustReach { relationships, .. } = v[0] else {
4114 panic!("expected must_reach finding");
4115 };
4116 assert_eq!(relationships, &vec!["CONCLUDES".to_string()]);
4117 }
4118
4119 #[test]
4125 fn status_propagation_rel_types_taints_across_type_boundaries() {
4126 use crate::entity::MetadataValue;
4127 let manifest = r#"name: tests-prop-set
4128version: 0.1.0
4129description: propagation relation-set test schema
4130when_to_use: tests
4131types:
4132 - claim
4133relationships:
4134 mode: strict
4135 definitions:
4136 - name: GROUNDS
4137 description: g
4138 default_weight: 3.0
4139 - name: CONCLUDES
4140 description: c
4141 default_weight: 3.0
4142 - name: PART_OF
4143 description: hier
4144 default_weight: 1.0
4145 - name: _default
4146 description: fallback
4147 default_weight: 1.0
4148community:
4149 resolution: 1.0
4150 seed: 42
4151"#;
4152 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";
4153 let schema = std::sync::Arc::new(
4154 memstead_schema::load_schema_from_memory(
4155 manifest,
4156 &[("claim".to_string(), claim_yaml.to_string())],
4157 )
4158 .expect("propagation-set fixture schema must parse"),
4159 );
4160
4161 let mut store = Store::new();
4162 let mut withdrawn = make_typed_entity("arg", "withdrawn-ev", "claim");
4163 withdrawn
4164 .metadata
4165 .insert("standing".into(), MetadataValue::String("withdrawn".into()));
4166 let mut inference = make_typed_entity("arg", "inference", "claim");
4167 link(&mut inference, "GROUNDS", &withdrawn.id);
4168 let mut conclusion = make_typed_entity("arg", "conclusion", "claim");
4169 link(&mut conclusion, "CONCLUDES", &inference.id);
4170 let bystander = make_typed_entity("arg", "bystander", "claim");
4171 for e in [withdrawn, inference.clone(), conclusion.clone(), bystander] {
4172 store.upsert(e.id.clone(), e);
4173 }
4174 let mut mem_schemas = HashMap::new();
4175 mem_schemas.insert("arg".to_string(), schema);
4176
4177 let reports = collect_constraint_findings(&store, None, &mem_schemas, None);
4178 let ids: Vec<&str> = reports.iter().map(|r| r.id.0.as_str()).collect();
4179 assert_eq!(
4180 ids,
4181 vec![conclusion.id.0.as_str(), inference.id.0.as_str()],
4182 "the taint crosses the CONCLUDES/GROUNDS boundary, nothing else"
4183 );
4184 let UnsatisfiedConstraint::StatusPropagation {
4185 rel_type,
4186 rel_types,
4187 tainted_by,
4188 ..
4189 } = &reports[0].violations[0]
4190 else {
4191 panic!("expected status_propagation finding");
4192 };
4193 assert_eq!(*rel_type, None, "set declarations echo no single name");
4194 assert_eq!(
4195 rel_types.as_deref(),
4196 Some(&["GROUNDS".to_string(), "CONCLUDES".to_string()][..])
4197 );
4198 assert_eq!(tainted_by, "arg--withdrawn-ev");
4199 }
4200
4201 #[test]
4204 fn must_reach_cross_mem_path_and_mem_filter() {
4205 let schema = must_reach_schema(CLAIM_GROUNDS_EVIDENCE, "");
4206 let mut store = Store::new();
4207 let far_ev = make_typed_entity("ground", "far-ev", "evidence");
4208 let mut crossing = make_typed_entity("arg", "crossing", "claim");
4209 link(&mut crossing, "GROUNDS", &far_ev.id);
4210 let floating_arg = make_typed_entity("arg", "floating", "claim");
4211 let floating_ground = make_typed_entity("ground", "floating", "claim");
4212 for e in [far_ev, crossing, floating_arg.clone(), floating_ground] {
4213 store.upsert(e.id.clone(), e);
4214 }
4215 let mut mem_schemas = HashMap::new();
4216 mem_schemas.insert("arg".to_string(), schema.clone());
4217 mem_schemas.insert("ground".to_string(), schema);
4218
4219 let all = collect_constraint_findings(&store, None, &mem_schemas, None);
4220 assert_eq!(
4221 all.len(),
4222 2,
4223 "the crossing claim is satisfied via the cross-mem edge: {all:?}"
4224 );
4225 let filtered = collect_constraint_findings(&store, Some("arg"), &mem_schemas, None);
4226 assert_eq!(filtered.len(), 1, "mem filter narrows: {filtered:?}");
4227 assert_eq!(filtered[0].id, floating_arg.id);
4228 }
4229
4230 fn gated_transition_schema() -> std::sync::Arc<memstead_schema::Schema> {
4237 let manifest = r#"name: tests-gated
4238version: 0.1.0
4239description: transition_requires_checks test schema
4240when_to_use: tests
4241types:
4242 - plan
4243 - criterion
4244relationships:
4245 mode: strict
4246 definitions:
4247 - name: VERIFIES
4248 description: v
4249 default_weight: 3.0
4250 acyclic: true
4251 - name: PART_OF
4252 description: hier
4253 default_weight: 1.0
4254 acyclic: true
4255 - name: _default
4256 description: fallback
4257 default_weight: 1.0
4258community:
4259 resolution: 1.0
4260 seed: 42
4261"#;
4262 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";
4263 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";
4264 std::sync::Arc::new(
4265 memstead_schema::load_schema_from_memory(
4266 manifest,
4267 &[
4268 ("plan".to_string(), plan_yaml.to_string()),
4269 ("criterion".to_string(), criterion_yaml.to_string()),
4270 ],
4271 )
4272 .expect("gated-transition fixture schema loads"),
4273 )
4274 }
4275
4276 #[test]
4282 fn transition_requires_checks_gates_on_derived_state() {
4283 use crate::check::CheckState;
4284 use crate::entity::MetadataValue;
4285 let schema = gated_transition_schema();
4286 let td = schema.types.get("plan").unwrap().clone();
4287 let mut store = Store::default();
4288
4289 let mut plan = make_typed_entity("g", "the-plan", "plan");
4290 plan.metadata
4291 .insert("status".into(), MetadataValue::String("complete".into()));
4292 let mut ok_crit = make_typed_entity("g", "ok-crit", "criterion");
4293 ok_crit.relationships.push(crate::entity::Relationship {
4294 rel_type: "VERIFIES".into(),
4295 target: plan.id.clone(),
4296 description: None,
4297 });
4298 let mut stale_crit = make_typed_entity("g", "stale-crit", "criterion");
4299 stale_crit.relationships.push(crate::entity::Relationship {
4300 rel_type: "VERIFIES".into(),
4301 target: plan.id.clone(),
4302 description: None,
4303 });
4304 for e in [plan.clone(), ok_crit.clone(), stale_crit.clone()] {
4305 store.upsert(e.id.clone(), e);
4306 }
4307
4308 let provider = |e: &crate::entity::Entity| {
4309 if e.id.0.contains("ok-crit") {
4310 CheckState::CheckedOk
4311 } else {
4312 CheckState::CheckStale
4313 }
4314 };
4315 let violations = unsatisfied_constraints(&store, &plan, &td, None, Some(&provider));
4316 assert_eq!(violations.len(), 1, "{violations:?}");
4317 match &violations[0] {
4318 UnsatisfiedConstraint::TransitionRequiresChecks {
4319 unchecked,
4320 severity,
4321 ..
4322 } => {
4323 assert_eq!(
4324 unchecked.len(),
4325 1,
4326 "only the unconfirmed criterion is listed"
4327 );
4328 assert_eq!(unchecked[0].id, "g--stale-crit");
4329 assert_eq!(unchecked[0].state, "check_stale");
4330 assert_eq!(*severity, memstead_schema::ConstraintSeverity::Block);
4331 }
4332 other => panic!("expected the gated-transition violation, got {other:?}"),
4333 }
4334 assert!(
4335 violations[0].describe().contains("g--stale-crit")
4336 && violations[0].describe().contains("check_stale"),
4337 "describe names the offender and its state: {}",
4338 violations[0].describe()
4339 );
4340
4341 let all_ok = |_: &crate::entity::Entity| CheckState::CheckedOk;
4343 assert!(
4344 unsatisfied_constraints(&store, &plan, &td, None, Some(&all_ok)).is_empty(),
4345 "all confirmed satisfies the gate"
4346 );
4347
4348 let mut draft = plan.clone();
4350 draft
4351 .metadata
4352 .insert("status".into(), MetadataValue::String("draft".into()));
4353 assert!(
4354 unsatisfied_constraints(&store, &draft, &td, None, Some(&provider)).is_empty(),
4355 "the gate triggers only at to_value"
4356 );
4357
4358 let violations = unsatisfied_constraints(&store, &plan, &td, None, None);
4360 assert_eq!(violations.len(), 1);
4361 match &violations[0] {
4362 UnsatisfiedConstraint::TransitionRequiresChecks { unchecked, .. } => {
4363 assert_eq!(unchecked.len(), 2, "no ledger access confirms nothing");
4364 assert!(unchecked.iter().all(|u| u.state == "never_checked"));
4365 }
4366 other => panic!("expected the gated-transition violation, got {other:?}"),
4367 }
4368
4369 let mut lone = make_typed_entity("g", "lone-plan", "plan");
4371 lone.metadata
4372 .insert("status".into(), MetadataValue::String("complete".into()));
4373 store.upsert(lone.id.clone(), lone.clone());
4374 assert!(
4375 unsatisfied_constraints(&store, &lone, &td, None, Some(&provider)).is_empty(),
4376 "an empty related set satisfies the universal quantification"
4377 );
4378 }
4379}