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 "conformance",
42 "integrity",
43];
44
45pub fn compute_health(
52 store: &Store,
53 default_schema: &TypeDefinition,
54 mem_schemas: &HashMap<String, Arc<Schema>>,
55) -> HealthSummary {
56 let mut missing_fields = Vec::new();
57 let mut stale_entities = Vec::new();
58
59 let today_days = days_since_epoch();
60
61 for entity in store.all_entities() {
62 if entity.stub {
63 continue;
64 }
65
66 let resolved = mem_schemas
75 .get(entity.mem.as_str())
76 .and_then(|s| s.types.get(entity.entity_type.as_str()).cloned())
77 .or_else(|| type_by_name(&entity.entity_type));
78 let schema: &TypeDefinition = resolved.as_deref().unwrap_or(default_schema);
79 let mut issues = Vec::new();
80
81 for field in &schema.health_required_fields {
83 if schema.section(field).is_some() {
85 let content = entity.sections.get(field.as_str());
87 if content.is_none_or(|c| c.trim().is_empty()) {
88 issues.push(HealthIssue {
89 field: field.clone(),
90 message: format!("required section '{field}' is empty"),
91 });
92 }
93 } else {
94 let value = entity.metadata.get(field.as_str());
100 let is_empty = match value {
101 None => true,
102 Some(v) => v.to_frontmatter_string().trim().is_empty(),
103 };
104 if is_empty {
105 issues.push(HealthIssue {
106 field: field.clone(),
107 message: format!("required field '{field}' is missing"),
108 });
109 }
110 }
111 }
112
113 if let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) {
129 let mut seen_unknown = std::collections::HashSet::new();
130 for rel in &entity.relationships {
131 if !mem_schema.relationship_known(&rel.rel_type) {
132 if seen_unknown.insert(rel.rel_type.clone()) {
133 let suggestion = mem_schema
134 .suggest_relationship(&rel.rel_type)
135 .map(|s| format!(" Did you mean '{s}'?"))
136 .unwrap_or_default();
137 let (schema_name, schema_version) = mem_schema.id();
138 issues.push(HealthIssue {
139 field: "relationships".to_string(),
140 message: format!(
141 "relationship '{}' is not declared in schema \
142 '{schema_name}@{schema_version}'.{suggestion}",
143 rel.rel_type
144 ),
145 });
146 }
147 continue;
148 }
149
150 let target_type = store
151 .get(&rel.target)
152 .map(|t| t.entity_type.clone())
153 .filter(|t| !t.is_empty());
154 if let Err(crate::runtime_validator::ValidationError::InvalidRelationshipShape {
155 rel_type,
156 from_type,
157 to_type,
158 allowed_source_types,
159 allowed_target_types,
160 ..
161 }) = crate::runtime_validator::validate_rel_shape(
162 &rel.rel_type,
163 entity.entity_type.as_str(),
164 target_type.as_deref(),
165 mem_schema.as_ref(),
166 ) {
167 let allowed_src = if allowed_source_types.is_empty() {
168 "<any>".to_string()
169 } else {
170 allowed_source_types.join(", ")
171 };
172 let allowed_tgt = if allowed_target_types.is_empty() {
173 "<any>".to_string()
174 } else {
175 allowed_target_types.join(", ")
176 };
177 issues.push(HealthIssue {
178 field: "relationships".to_string(),
179 message: format!(
180 "INVALID_REL_SHAPE: edge '{rel_type}' from \
181 '{from_type}' to '{to_type}' (target {target}) \
182 violates declared shape — allowed_source_types: \
183 [{allowed_src}], allowed_target_types: \
184 [{allowed_tgt}]. Remove via \
185 `memstead_relate from={from_id} to={target} \
186 type={rel_type} remove=true`.",
187 target = rel.target,
188 from_id = entity.id,
189 ),
190 });
191 }
192 }
193 }
194
195 let auto_ts_field = schema.metadata_fields.iter().find(|f| f.auto_timestamp);
197
198 if let Some(ts_field) = auto_ts_field
199 && let Some(val) = entity.metadata.get(ts_field.key.as_str())
200 {
201 let date_str = val.to_frontmatter_string();
202 if let Some(modified_days) = parse_iso_to_days(&date_str) {
203 let days_since = today_days.saturating_sub(modified_days);
204 if days_since > schema.staleness_threshold_days as u64 {
205 stale_entities.push(StaleEntity {
206 id: entity.id.clone(),
207 title: entity.title.clone(),
208 days_since_modified: days_since,
209 });
210 }
211 }
212 }
213
214 if !issues.is_empty() {
215 let total = schema.health_required_fields.len();
221 let score = if total > 0 {
222 (total.saturating_sub(issues.len()) as f32) / (total as f32)
223 } else {
224 1.0
225 };
226
227 missing_fields.push(HealthReport {
228 id: entity.id.clone(),
229 title: entity.title.clone(),
230 score,
231 issues,
232 });
233 }
234 }
235
236 stale_entities.sort_by_key(|e| std::cmp::Reverse(e.days_since_modified));
238
239 let orphan_count = query::find_orphans(store).len();
241 let stub_count = query::find_stubs(store).len();
242
243 HealthSummary {
244 stale_entities,
245 missing_fields,
246 orphan_count,
247 stub_count,
248 warnings: Vec::new(),
249 dangling_links: None,
250 findings: None,
251 tag_distribution: None,
252 tag_distribution_folded: None,
253 untagged_entities: None,
254 }
255}
256
257pub fn collect_tag_distribution(
271 store: &Store,
272 mem_filter: Option<&str>,
273 limit: usize,
274) -> (Vec<TagDistribution>, Vec<FoldedTag>, UntaggedStats) {
275 let mut counts: HashMap<String, (usize, HashMap<String, usize>)> = HashMap::new();
277 let mut untagged = UntaggedStats {
278 total: 0,
279 by_entity_type: HashMap::new(),
280 };
281
282 for entity in store.all_entities() {
283 if entity.stub {
284 continue;
285 }
286 if let Some(v) = mem_filter
287 && entity.mem != v
288 {
289 continue;
290 }
291
292 let tags_raw = entity
293 .metadata
294 .get("tags")
295 .and_then(|v| match v {
296 MetadataValue::String(s) => Some(s.as_str()),
297 _ => None,
298 })
299 .unwrap_or("");
300
301 let mut any_tag = false;
302 for tag in tags_raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
303 any_tag = true;
304 let entry = counts
305 .entry(tag.to_string())
306 .or_insert_with(|| (0, HashMap::new()));
307 entry.0 += 1;
308 *entry.1.entry(entity.entity_type.clone()).or_insert(0) += 1;
309 }
310 if !any_tag {
311 untagged.total += 1;
312 *untagged
313 .by_entity_type
314 .entry(entity.entity_type.clone())
315 .or_insert(0) += 1;
316 }
317 }
318
319 let mut entries: Vec<TagDistribution> = counts
321 .iter()
322 .map(|(tag, (count, by_type))| TagDistribution {
323 tag: tag.clone(),
324 count: *count,
325 by_entity_type: by_type.clone(),
326 })
327 .collect();
328 entries.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.tag.cmp(&b.tag)));
329 entries.truncate(limit);
330
331 let mut by_canonical: HashMap<String, Vec<(String, usize)>> = HashMap::new();
336 for (tag, (count, _)) in counts.iter() {
337 by_canonical
338 .entry(tag.to_lowercase())
339 .or_default()
340 .push((tag.clone(), *count));
341 }
342 let mut folded: Vec<FoldedTag> = by_canonical
343 .into_iter()
344 .filter(|(_, v)| v.len() > 1)
345 .map(|(canonical, mut variants)| {
346 variants.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
347 let total = variants.iter().map(|(_, c)| *c).sum();
348 FoldedTag {
349 canonical,
350 total,
351 variants: variants
352 .into_iter()
353 .map(|(tag, count)| TagVariant { tag, count })
354 .collect(),
355 }
356 })
357 .collect();
358 folded.sort_by(|a, b| {
359 b.total
360 .cmp(&a.total)
361 .then_with(|| a.canonical.cmp(&b.canonical))
362 });
363
364 (entries, folded, untagged)
365}
366
367pub fn collect_dangling_links(store: &Store, mem_filter: Option<&str>) -> Vec<DanglingLink> {
387 use crate::entity::parser::extract_inline_links_lenient;
388 use std::collections::HashSet;
389
390 let mut out = Vec::new();
391 for entity in store.all_entities() {
392 if entity.stub {
393 continue;
394 }
395 if let Some(v) = mem_filter
396 && entity.mem != v
397 {
398 continue;
399 }
400 let explicit_targets: HashSet<_> = entity
401 .relationships
402 .iter()
403 .map(|r| r.target.clone())
404 .collect();
405 for (section_key, section_body) in &entity.sections {
406 for target_id in extract_inline_links_lenient(section_body, &entity.mem) {
407 let target_missing = store.get(&target_id).map(|e| e.stub).unwrap_or(true);
408 let alias_orphan = !target_missing && !explicit_targets.contains(&target_id);
409 if target_missing || alias_orphan {
410 out.push(DanglingLink {
411 from: entity.id.clone(),
412 target_id: target_id.clone(),
413 target_path: target_id.path().to_string(),
414 section: Some(section_key.clone()),
415 });
416 }
417 }
418 }
419 for rel in &entity.relationships {
436 if store.get(&rel.target).is_some() {
437 continue;
438 }
439 let already_reported = out
440 .iter()
441 .any(|d| d.from == entity.id && d.target_id == rel.target);
442 if already_reported {
443 continue;
444 }
445 out.push(DanglingLink {
446 from: entity.id.clone(),
447 target_id: rel.target.clone(),
448 target_path: rel.target.path().to_string(),
449 section: None,
450 });
451 }
452 }
453 out
454}
455
456pub fn collect_missing_required_outgoing(
467 store: &Store,
468 mem_filter: Option<&str>,
469 mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
470) -> Vec<MissingRequiredOutgoingReport> {
471 let mut out = Vec::new();
472 for entity in store.all_entities() {
473 if entity.stub {
474 continue;
475 }
476 if let Some(v) = mem_filter
477 && entity.mem != v
478 {
479 continue;
480 }
481 let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
482 continue;
483 };
484 let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
485 continue;
486 };
487 if td.required_outgoing.is_empty() {
488 continue;
489 }
490 let unsatisfied: Vec<MissingOutgoingBlock> = td
491 .required_outgoing
492 .iter()
493 .filter(|block| {
494 let count = entity
495 .relationships
496 .iter()
497 .filter(|rel| block.relationships.iter().any(|name| name == &rel.rel_type))
498 .count();
499 !block.admits(count)
500 })
501 .map(|block| MissingOutgoingBlock {
502 relationships: block.relationships.clone(),
503 cardinality: block.cardinality.to_string(),
504 })
505 .collect();
506 if unsatisfied.is_empty() {
507 continue;
508 }
509 out.push(MissingRequiredOutgoingReport {
510 id: entity.id.clone(),
511 title: entity.title.clone(),
512 entity_type: entity.entity_type.clone(),
513 mem: entity.mem.clone(),
514 missing: unsatisfied,
515 });
516 }
517 out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
518 out
519}
520
521#[derive(Debug, Clone, serde::Serialize)]
528pub struct MissingRequiredOutgoingReport {
529 pub id: crate::entity::EntityId,
530 pub title: String,
531 pub entity_type: String,
532 pub mem: String,
533 pub missing: Vec<MissingOutgoingBlock>,
534}
535
536#[derive(Debug, Clone, serde::Serialize)]
537pub struct MissingOutgoingBlock {
538 pub relationships: Vec<String>,
539 pub cardinality: String,
540}
541
542pub fn entity_health(entity: &crate::entity::Entity, schema: &TypeDefinition) -> HealthReport {
544 let mut issues = Vec::new();
545
546 for field in &schema.health_required_fields {
547 if schema.section(field).is_some() {
548 let content = entity.sections.get(field.as_str());
549 if content.is_none_or(|c| c.trim().is_empty()) {
550 issues.push(HealthIssue {
551 field: field.clone(),
552 message: format!("required section '{field}' is empty"),
553 });
554 }
555 } else {
556 let value = entity.metadata.get(field.as_str());
557 if value.is_none() {
558 issues.push(HealthIssue {
559 field: field.clone(),
560 message: format!("required field '{field}' is missing"),
561 });
562 }
563 }
564 }
565
566 let total = schema.health_required_fields.len();
567 let score = if total > 0 {
568 ((total - issues.len()) as f32) / (total as f32)
569 } else {
570 1.0
571 };
572
573 HealthReport {
574 id: entity.id.clone(),
575 title: entity.title.clone(),
576 score,
577 issues,
578 }
579}
580
581fn days_since_epoch() -> u64 {
592 #[cfg(target_arch = "wasm32")]
593 {
594 (js_sys::Date::now() / 1000.0) as u64 / 86400
595 }
596 #[cfg(not(target_arch = "wasm32"))]
597 {
598 std::time::SystemTime::now()
599 .duration_since(std::time::UNIX_EPOCH)
600 .unwrap_or_default()
601 .as_secs()
602 / 86400
603 }
604}
605
606fn parse_iso_to_days(date: &str) -> Option<u64> {
609 let date_part = date.split('T').next()?;
610 let parts: Vec<&str> = date_part.split('-').collect();
611 if parts.len() != 3 {
612 return None;
613 }
614 let year: u64 = parts[0].parse().ok()?;
615 let month: u64 = parts[1].parse().ok()?;
616 let day: u64 = parts[2].parse().ok()?;
617 Some(ymd_to_days(year, month, day))
618}
619
620fn ymd_to_days(year: u64, month: u64, day: u64) -> u64 {
623 let y = if month <= 2 { year - 1 } else { year };
625 let m = if month <= 2 { month + 9 } else { month - 3 };
626 let era = y / 400;
627 let yoe = y - era * 400;
628 let doy = (153 * m + 2) / 5 + day - 1;
629 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
630 let days = era * 146097 + doe;
631 days - 719468
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637 use crate::entity::{Entity, EntityId, MetadataValue};
638 use crate::store::Store;
639 use indexmap::IndexMap;
640 use memstead_schema::type_by_name;
641
642 fn make_entity(name: &str, has_required: bool) -> Entity {
643 let mut metadata = IndexMap::new();
644 metadata.insert("level".into(), MetadataValue::String("M0".into()));
645 metadata.insert("type".into(), MetadataValue::String("spec".into()));
646 metadata.insert(
647 "created_date".into(),
648 MetadataValue::String("2026-01-15".into()),
649 );
650 metadata.insert(
651 "last_modified".into(),
652 MetadataValue::String("2026-04-12".into()),
653 );
654
655 let mut sections = IndexMap::new();
656 if has_required {
657 sections.insert("identity".into(), "Has identity.".into());
658 sections.insert("purpose".into(), "Has purpose.".into());
659 }
660
661 Entity {
662 id: EntityId::new("specs", name),
663 title: name.into(),
664 entity_type: "spec".into(),
665 mem: "specs".into(),
666 file_path: format!("{name}.md"),
667 metadata,
668 sections,
669 relationships: Vec::new(),
670 content_hash: String::new(),
671 stub: false,
672 stub_kind: None,
673 heading_spans: std::collections::HashMap::new(),
674 }
675 }
676
677 fn make_concept_entity(name: &str, with_definition: bool) -> Entity {
678 let mut metadata = IndexMap::new();
679 metadata.insert("type".into(), MetadataValue::String("concept".into()));
680 metadata.insert("maturity".into(), MetadataValue::String("emerging".into()));
681 metadata.insert(
682 "abstraction_level".into(),
683 MetadataValue::String("concrete".into()),
684 );
685 metadata.insert(
686 "created_date".into(),
687 MetadataValue::String("2026-01-15".into()),
688 );
689 metadata.insert(
690 "last_modified".into(),
691 MetadataValue::String("2026-04-12".into()),
692 );
693
694 let mut sections = IndexMap::new();
695 if with_definition {
696 sections.insert("definition".into(), "Precise definition.".into());
697 }
698 sections.insert("explanation".into(), "How it works.".into());
699
700 Entity {
701 id: EntityId::new("concepts", name),
702 title: name.into(),
703 entity_type: "concept".into(),
704 mem: "concepts".into(),
705 file_path: format!("{name}.md"),
706 metadata,
707 sections,
708 relationships: Vec::new(),
709 content_hash: String::new(),
710 stub: false,
711 stub_kind: None,
712 heading_spans: std::collections::HashMap::new(),
713 }
714 }
715
716 #[test]
717 fn health_concept_missing_definition_reports_definition_field() {
718 let schema = &type_by_name("concept").unwrap();
719 let entity = make_concept_entity("clarity", false);
720 let report = entity_health(&entity, schema);
721
722 assert!(report.issues.iter().any(|i| i.field == "definition"));
725 assert!(!report.issues.iter().any(|i| i.field == "identity"));
726 assert!(!report.issues.iter().any(|i| i.field == "purpose"));
727 assert!(report.score < 1.0);
728
729 let healthy = make_concept_entity("clarity-ok", true);
731 let healthy_report = entity_health(&healthy, schema);
732 assert!(
733 !healthy_report
734 .issues
735 .iter()
736 .any(|i| i.field == "definition")
737 );
738 }
739
740 #[test]
741 fn health_detects_missing_sections() {
742 let schema = &type_by_name("spec").unwrap();
743 let entity = make_entity("incomplete", false);
744 let report = entity_health(&entity, schema);
745 assert!(!report.issues.is_empty());
746 assert!(report.score < 1.0);
747 }
748
749 #[test]
750 fn health_clean_entity() {
751 let schema = &type_by_name("spec").unwrap();
752 let entity = make_entity("complete", true);
753 let report = entity_health(&entity, schema);
754 let section_issues: Vec<_> = report
756 .issues
757 .iter()
758 .filter(|i| i.field == "identity" || i.field == "purpose")
759 .collect();
760 assert!(section_issues.is_empty());
761 }
762
763 #[test]
764 fn health_summary_counts() {
765 let mut store = Store::new();
766 let e1 = make_entity("healthy", true);
767 let e2 = make_entity("unhealthy", false);
768 store.upsert(e1.id.clone(), e1);
769 store.upsert(e2.id.clone(), e2);
770
771 let schema = &type_by_name("spec").unwrap();
772 let summary = compute_health(&store, schema, &HashMap::new());
773 assert_eq!(summary.orphan_count, 2); assert_eq!(summary.stub_count, 0);
775 }
776
777 #[test]
778 fn health_surfaces_invalid_rel_shape_on_existing_edges() {
779 use crate::entity::Relationship;
785 use memstead_schema::SchemaRegistry;
786
787 let registry = SchemaRegistry::builtin();
788 let software = registry
789 .resolve_by_name("software")
790 .unwrap()
791 .expect("software schema ships as a builtin");
792
793 let mut store = Store::new();
794 let mut bad = make_entity("bad-owns-source", true);
797 bad.entity_type = "spec".into();
798 bad.metadata
799 .insert("level".into(), MetadataValue::String("M0".into()));
800 bad.metadata
801 .insert("stability".into(), MetadataValue::String("evolving".into()));
802 bad.relationships.push(Relationship {
803 rel_type: "OWNS".into(),
804 target: EntityId::new("specs", "victim"),
805 description: None,
806 });
807 let mut victim = make_entity("victim", true);
808 victim.entity_type = "spec".into();
809 store.upsert(bad.id.clone(), bad);
810 store.upsert(victim.id.clone(), victim);
811
812 let mut mem_schemas = HashMap::new();
813 mem_schemas.insert("specs".to_string(), software);
814
815 let schema = &type_by_name("spec").unwrap();
816 let summary = compute_health(&store, schema, &mem_schemas);
817 let report = summary
818 .missing_fields
819 .iter()
820 .find(|r| r.id.as_ref() == "specs--bad-owns-source")
821 .expect("shape-violating entity must surface");
822 let issue = report
823 .issues
824 .iter()
825 .find(|i| i.field == "relationships" && i.message.contains("INVALID_REL_SHAPE"))
826 .expect("shape violation must produce an INVALID_REL_SHAPE issue");
827 assert!(
828 issue.message.contains("OWNS"),
829 "issue must name the offending rel_type: {}",
830 issue.message
831 );
832 assert!(
833 issue.message.contains("spec"),
834 "issue must name the actual source type: {}",
835 issue.message
836 );
837 assert!(
838 issue.message.contains("actor"),
839 "issue must name the allowed source type: {}",
840 issue.message
841 );
842 assert!(
843 issue.message.contains("remove=true"),
844 "issue must surface the recovery path: {}",
845 issue.message
846 );
847 }
848
849 #[test]
850 fn health_does_not_flag_shape_compliant_edges() {
851 use crate::entity::Relationship;
854 use memstead_schema::SchemaRegistry;
855
856 let registry = SchemaRegistry::builtin();
857 let software = registry
858 .resolve_by_name("software")
859 .unwrap()
860 .expect("software schema ships as a builtin");
861
862 let mut store = Store::new();
863 let mut owner = make_entity("owner", true);
864 owner.entity_type = "actor".into();
865 owner
866 .metadata
867 .insert("kind".into(), MetadataValue::String("team".into()));
868 owner
869 .metadata
870 .insert("active".into(), MetadataValue::Bool(true));
871 owner
872 .metadata
873 .insert("handle".into(), MetadataValue::String("owner".into()));
874 owner.relationships.push(Relationship {
875 rel_type: "OWNS".into(),
876 target: EntityId::new("specs", "owned"),
877 description: None,
878 });
879 let mut owned = make_entity("owned", true);
880 owned.entity_type = "spec".into();
881 store.upsert(owner.id.clone(), owner);
882 store.upsert(owned.id.clone(), owned);
883
884 let mut mem_schemas = HashMap::new();
885 mem_schemas.insert("specs".to_string(), software);
886
887 let schema = &type_by_name("spec").unwrap();
888 let summary = compute_health(&store, schema, &mem_schemas);
889 let shape_issue = summary
890 .missing_fields
891 .iter()
892 .flat_map(|r| r.issues.iter())
893 .find(|i| i.message.contains("INVALID_REL_SHAPE"));
894 assert!(
895 shape_issue.is_none(),
896 "shape-compliant edge must not surface a shape issue, got: {shape_issue:?}"
897 );
898 }
899
900 #[test]
901 fn health_warns_on_undeclared_relationship_in_existing_entity() {
902 use crate::entity::Relationship;
903 use memstead_schema::Schema;
904
905 let mut store = Store::new();
906 let mut entity = make_entity("with-bad-rel", true);
907 entity.relationships.push(Relationship {
913 rel_type: "CONJURES".into(),
914 target: EntityId::new("specs", "unknown"),
915 description: None,
916 });
917 store.upsert(entity.id.clone(), entity);
918
919 let mut mem_schemas = HashMap::new();
920 mem_schemas.insert("specs".to_string(), Schema::builtin_default());
921
922 let schema = &type_by_name("spec").unwrap();
923 let summary = compute_health(&store, schema, &mem_schemas);
924 let report = summary
925 .missing_fields
926 .iter()
927 .find(|r| r.id.as_ref() == "specs--with-bad-rel")
928 .expect("entity must surface in missing_fields");
929 let rel_issue = report
930 .issues
931 .iter()
932 .find(|i| i.field == "relationships")
933 .expect("undeclared relationship must produce an issue");
934 assert!(
935 rel_issue.message.contains("CONJURES"),
936 "issue message must name the offending relationship: {}",
937 rel_issue.message
938 );
939 assert!(
940 rel_issue.message.contains("default@1.0.0"),
941 "issue must name the schema pin: {}",
942 rel_issue.message
943 );
944 }
945
946 fn make_entity_with_body(name: &str, section_key: &str, body: &str) -> Entity {
953 let mut entity = make_entity(name, true);
954 entity.sections.insert(section_key.into(), body.to_string());
955 entity
956 }
957
958 #[test]
959 fn dangling_link_detected_after_delete() {
960 use crate::entity::store_builder::make_stub;
961
962 let mut store = Store::new();
963 let a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
964 store.upsert(a.id.clone(), a.clone());
965
966 let b_id = EntityId::new("specs", "b");
969 store.upsert(b_id.clone(), make_stub(b_id.clone()));
970
971 let dangling = super::collect_dangling_links(&store, None);
972 assert_eq!(dangling.len(), 1, "exactly one dangling link expected");
973 let d = &dangling[0];
974 assert_eq!(d.from, a.id);
975 assert_eq!(d.target_id, b_id);
976 assert_eq!(d.target_path, "b");
977 assert_eq!(d.section.as_deref(), Some("purpose"));
978 }
979
980 #[test]
981 fn dangling_link_does_not_flag_stub_target_of_explicit_relationship() {
982 use crate::entity::Relationship;
983 use crate::entity::store_builder::make_stub;
984
985 let mut store = Store::new();
986 let mut a = make_entity("a", true);
989 let b_id = EntityId::new("specs", "b");
990 a.relationships.push(Relationship {
991 rel_type: "REFERENCES".into(),
992 target: b_id.clone(),
993 description: None,
994 });
995 store.upsert(a.id.clone(), a);
996 store.upsert(b_id.clone(), make_stub(b_id));
997
998 let dangling = super::collect_dangling_links(&store, None);
999 assert!(
1000 dangling.is_empty(),
1001 "explicit relationships to stubs are valid by design \
1002 (stubs are first-class placeholders); only inline-body \
1003 wiki-links to stubs must surface"
1004 );
1005 }
1006
1007 #[test]
1008 fn dangling_link_does_not_flag_real_reference() {
1009 use crate::entity::Relationship;
1010
1011 let mut store = Store::new();
1012 let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
1013 a.relationships.push(Relationship {
1015 rel_type: "REFERENCES".into(),
1016 target: EntityId::new("specs", "b"),
1017 description: None,
1018 });
1019 let b = make_entity("b", true);
1020 store.upsert(a.id.clone(), a);
1021 store.upsert(b.id.clone(), b);
1022
1023 let dangling = super::collect_dangling_links(&store, None);
1024 assert!(
1025 dangling.is_empty(),
1026 "real reference backed by relation — not dangling, not alias-orphan"
1027 );
1028 }
1029
1030 #[test]
1035 fn dangling_link_relationship_section_target_absent() {
1036 use crate::entity::Relationship;
1037
1038 let mut store = Store::new();
1039 let mut a = make_entity("a", true);
1040 a.relationships.push(Relationship {
1043 rel_type: "DEPENDS_ON".into(),
1044 target: EntityId::new("specs", "gone"),
1045 description: None,
1046 });
1047 store.upsert(a.id.clone(), a.clone());
1048
1049 let dangling = super::collect_dangling_links(&store, None);
1050 assert_eq!(
1051 dangling.len(),
1052 1,
1053 "exactly one relationship-section dangler"
1054 );
1055 let d = &dangling[0];
1056 assert_eq!(d.from, a.id);
1057 assert_eq!(d.target_id, EntityId::new("specs", "gone"));
1058 assert!(
1059 d.section.is_none(),
1060 "relationship-section danglers ship `section: None`, got {:?}",
1061 d.section
1062 );
1063 }
1064
1065 #[test]
1070 fn dangling_link_relationship_section_stub_target_not_flagged() {
1071 use crate::entity::Relationship;
1072 use crate::entity::store_builder::make_stub;
1073
1074 let mut store = Store::new();
1075 let mut a = make_entity("a", true);
1076 let b_id = EntityId::new("specs", "b");
1077 a.relationships.push(Relationship {
1078 rel_type: "DEPENDS_ON".into(),
1079 target: b_id.clone(),
1080 description: None,
1081 });
1082 store.upsert(a.id.clone(), a);
1083 store.upsert(b_id.clone(), make_stub(b_id));
1084
1085 let dangling = super::collect_dangling_links(&store, None);
1086 assert!(
1087 dangling.is_empty(),
1088 "relationship targets that resolve to stubs are forward-references, not corruption"
1089 );
1090 }
1091
1092 #[test]
1099 fn dangling_link_dedups_across_body_and_relations() {
1100 use crate::entity::Relationship;
1101 use crate::entity::store_builder::make_stub;
1102
1103 let mut store = Store::new();
1104 let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
1105 let b_id = EntityId::new("specs", "b");
1106 a.relationships.push(Relationship {
1107 rel_type: "REFERENCES".into(),
1108 target: b_id.clone(),
1109 description: None,
1110 });
1111 store.upsert(a.id.clone(), a.clone());
1112 store.upsert(b_id.clone(), make_stub(b_id.clone()));
1113
1114 let dangling = super::collect_dangling_links(&store, None);
1115 assert_eq!(
1116 dangling.len(),
1117 1,
1118 "body + relations both pointing at the same stub should dedup"
1119 );
1120 assert!(dangling[0].section.is_some(), "body axis wins the dedup");
1123 }
1124
1125 #[test]
1126 fn dangling_links_scope_to_mem_filter() {
1127 use crate::entity::store_builder::make_stub;
1128
1129 let mut store = Store::new();
1130
1131 let a = make_entity_with_body("a", "purpose", "Refers to [[gone]] in prose.");
1133 store.upsert(a.id.clone(), a);
1134 let gone_specs = EntityId::new("specs", "gone");
1135 store.upsert(gone_specs.clone(), make_stub(gone_specs));
1136
1137 let mut x = make_entity("x", true);
1139 x.id = EntityId::new("web", "x");
1140 x.mem = "web".into();
1141 x.file_path = "x.md".into();
1142 x.sections
1143 .insert("purpose".into(), "Refers to [[gone]] in prose.".into());
1144 store.upsert(x.id.clone(), x);
1145 let gone_web = EntityId::new("web", "gone");
1146 store.upsert(gone_web.clone(), make_stub(gone_web));
1147
1148 let all = super::collect_dangling_links(&store, None);
1149 assert_eq!(all.len(), 2);
1150
1151 let specs_only = super::collect_dangling_links(&store, Some("specs"));
1152 assert_eq!(specs_only.len(), 1);
1153 assert_eq!(specs_only[0].from.mem(), "specs");
1154
1155 let web_only = super::collect_dangling_links(&store, Some("web"));
1156 assert_eq!(web_only.len(), 1);
1157 assert_eq!(web_only[0].from.mem(), "web");
1158 }
1159
1160 #[test]
1161 fn parse_iso_date() {
1162 let days = parse_iso_to_days("2026-04-12").unwrap();
1163 assert!(days > 0);
1164
1165 let days_with_time = parse_iso_to_days("2026-04-12T10:00:00Z").unwrap();
1166 assert_eq!(days, days_with_time);
1167 }
1168
1169 #[test]
1170 fn ymd_roundtrip() {
1171 let days = ymd_to_days(2026, 1, 1);
1173 assert!(days > 20000); }
1175
1176 fn make_entity_with_tags(name: &str, mem: &str, entity_type: &str, tags: &str) -> Entity {
1181 let mut e = make_entity(name, true);
1182 e.id = EntityId::new(mem, name);
1183 e.mem = mem.into();
1184 e.entity_type = entity_type.into();
1185 e.metadata
1186 .insert("tags".into(), MetadataValue::String(tags.into()));
1187 e
1188 }
1189
1190 fn make_entity_no_tags(name: &str) -> Entity {
1191 make_entity(name, true)
1192 }
1193
1194 #[test]
1195 fn tag_distribution_aggregates_across_entities() {
1196 let mut store = Store::new();
1197 let a = make_entity_with_tags("a", "specs", "spec", "decision, plan");
1198 let b = make_entity_with_tags("b", "specs", "spec", "decision, plan");
1199 let c = make_entity_with_tags("c", "specs", "spec", "plan");
1200 store.upsert(a.id.clone(), a);
1201 store.upsert(b.id.clone(), b);
1202 store.upsert(c.id.clone(), c);
1203
1204 let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
1205 assert_eq!(dist.len(), 2);
1206 assert_eq!(dist[0].tag, "plan");
1207 assert_eq!(dist[0].count, 3);
1208 assert_eq!(dist[0].by_entity_type.get("spec"), Some(&3));
1209 assert_eq!(dist[1].tag, "decision");
1210 assert_eq!(dist[1].count, 2);
1211 assert_eq!(untagged.total, 0);
1212 }
1213
1214 #[test]
1215 fn tag_distribution_case_sensitive() {
1216 let mut store = Store::new();
1217 let a = make_entity_with_tags("a", "specs", "spec", "Decision");
1218 let b = make_entity_with_tags("b", "specs", "spec", "decision");
1219 store.upsert(a.id.clone(), a);
1220 store.upsert(b.id.clone(), b);
1221
1222 let (dist, folded, _untagged) = collect_tag_distribution(&store, None, 10);
1223 assert_eq!(dist.len(), 2, "`decision` and `Decision` stay distinct");
1224 let tags: std::collections::HashSet<&str> = dist.iter().map(|t| t.tag.as_str()).collect();
1225 assert!(tags.contains("decision"));
1226 assert!(tags.contains("Decision"));
1227
1228 assert_eq!(folded.len(), 1);
1230 assert_eq!(folded[0].canonical, "decision");
1231 assert_eq!(folded[0].total, 2);
1232 assert_eq!(folded[0].variants.len(), 2);
1233 }
1234
1235 #[test]
1236 fn untagged_entities_counts_missing_and_empty() {
1237 let mut store = Store::new();
1238 let a = make_entity_no_tags("a"); let b = make_entity_with_tags("b", "specs", "spec", "");
1240 let c = make_entity_with_tags("c", "specs", "spec", " , , ");
1241 store.upsert(a.id.clone(), a);
1242 store.upsert(b.id.clone(), b);
1243 store.upsert(c.id.clone(), c);
1244
1245 let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
1246 assert!(dist.is_empty(), "no effective tags → empty distribution");
1247 assert_eq!(untagged.total, 3);
1248 assert_eq!(untagged.by_entity_type.get("spec"), Some(&3));
1249 }
1250
1251 #[test]
1252 fn tag_distribution_respects_mem_filter() {
1253 let mut store = Store::new();
1254 let a = make_entity_with_tags("a", "specs", "spec", "decision");
1255 let b = make_entity_with_tags("b", "memos", "memo", "observation");
1256 let c = make_entity_no_tags("c");
1257 store.upsert(a.id.clone(), a);
1258 store.upsert(b.id.clone(), b);
1259 store.upsert(c.id.clone(), c);
1260
1261 let (dist, _folded, untagged) = collect_tag_distribution(&store, Some("memos"), 10);
1262 assert_eq!(dist.len(), 1);
1263 assert_eq!(dist[0].tag, "observation");
1264 assert_eq!(untagged.total, 0, "untagged scoped to filter mem");
1265 }
1266
1267 #[test]
1268 fn tag_distribution_respects_limit() {
1269 let mut store = Store::new();
1270 for (name, tag) in [
1271 ("a", "t-alpha"),
1272 ("b", "t-beta"),
1273 ("c", "t-gamma"),
1274 ("d", "t-delta"),
1275 ("e", "t-epsilon"),
1276 ] {
1277 let e = make_entity_with_tags(name, "specs", "spec", tag);
1278 store.upsert(e.id.clone(), e);
1279 }
1280
1281 let (dist, _folded, _untagged) = collect_tag_distribution(&store, None, 3);
1282 assert_eq!(dist.len(), 3);
1283 assert_eq!(dist[0].tag, "t-alpha");
1286 assert_eq!(dist[1].tag, "t-beta");
1287 assert_eq!(dist[2].tag, "t-delta");
1288 }
1289
1290 fn required_outgoing_fixture_schema() -> std::sync::Arc<memstead_schema::Schema> {
1297 let manifest = r#"name: tests-ro-health
1298version: 0.1.0
1299description: required_outgoing health test schema
1300when_to_use: tests
1301types:
1302 - decision
1303 - note
1304relationships:
1305 mode: strict
1306 definitions:
1307 - name: PART_OF
1308 description: Hier
1309 default_weight: 3.0
1310 acyclic: true
1311 - name: CHOSEN
1312 description: ch
1313 default_weight: 3.0
1314 - name: REJECTED
1315 description: rj
1316 default_weight: 2.0
1317 - name: REFERENCES
1318 description: ref
1319 default_weight: 0.5
1320 - name: _default
1321 description: Fallback
1322 default_weight: 1.0
1323community:
1324 resolution: 1.0
1325 seed: 42
1326"#;
1327 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\npropagating_relationships: []\nupdatable_fields:\n - title\n - body\nhealth_required_fields:\n - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
1328 let decision_yaml = format!(
1329 "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",
1330 );
1331 let note_yaml = format!("name: note\ndescription: t\nwhen_to_use: Here\n{body_section}",);
1332 std::sync::Arc::new(
1333 memstead_schema::load_schema_from_memory(
1334 manifest,
1335 &[
1336 ("decision".to_string(), decision_yaml),
1337 ("note".to_string(), note_yaml),
1338 ],
1339 )
1340 .expect("ro fixture schema must parse"),
1341 )
1342 }
1343
1344 fn make_typed_entity(mem: &str, slug: &str, entity_type: &str) -> crate::entity::Entity {
1345 use crate::entity::MetadataValue;
1346 let mut metadata = IndexMap::new();
1347 metadata.insert("type".into(), MetadataValue::String(entity_type.into()));
1348 let mut sections = IndexMap::new();
1349 sections.insert("body".into(), "Body.".into());
1350 crate::entity::Entity {
1351 id: EntityId::new(mem, slug),
1352 title: slug.to_string(),
1353 entity_type: entity_type.into(),
1354 mem: mem.into(),
1355 file_path: format!("{slug}.md"),
1356 metadata,
1357 sections,
1358 relationships: Vec::new(),
1359 content_hash: String::new(),
1360 stub: false,
1361 stub_kind: None,
1362 heading_spans: std::collections::HashMap::new(),
1363 }
1364 }
1365
1366 #[test]
1367 fn missing_required_outgoing_collects_violators_only() {
1368 let schema = required_outgoing_fixture_schema();
1369 let mut store = Store::new();
1370 let mut violator = make_typed_entity("plan", "stalled", "decision");
1373 let mut satisfied = make_typed_entity("plan", "wired", "decision");
1374 let opt_a = make_typed_entity("plan", "a", "note");
1375 let opt_b = make_typed_entity("plan", "b", "note");
1376 let happy_note = make_typed_entity("plan", "side", "note");
1377 satisfied.relationships.push(crate::entity::Relationship {
1378 rel_type: "CHOSEN".into(),
1379 target: opt_a.id.clone(),
1380 description: None,
1381 });
1382 satisfied.relationships.push(crate::entity::Relationship {
1383 rel_type: "REJECTED".into(),
1384 target: opt_b.id.clone(),
1385 description: None,
1386 });
1387 for e in [violator.clone(), satisfied, opt_a, opt_b, happy_note] {
1388 store.upsert(e.id.clone(), e);
1389 }
1390
1391 let mut mem_schemas = HashMap::new();
1392 mem_schemas.insert("plan".to_string(), schema);
1393
1394 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
1395 assert_eq!(
1396 reports.len(),
1397 1,
1398 "exactly one violator (the empty decision); got {reports:?}"
1399 );
1400 let r = &reports[0];
1401 assert_eq!(r.id, violator.id);
1402 assert_eq!(r.entity_type, "decision");
1403 assert_eq!(r.mem, "plan");
1404 assert_eq!(r.missing.len(), 2);
1405 let names: Vec<&str> = r
1406 .missing
1407 .iter()
1408 .flat_map(|b| b.relationships.iter().map(String::as_str))
1409 .collect();
1410 assert!(names.contains(&"CHOSEN"));
1411 assert!(names.contains(&"REJECTED"));
1412
1413 violator.relationships.push(crate::entity::Relationship {
1415 rel_type: "CHOSEN".into(),
1416 target: EntityId::new("plan", "x"),
1417 description: None,
1418 });
1419 }
1420
1421 #[test]
1422 fn missing_required_outgoing_respects_mem_filter() {
1423 let schema = required_outgoing_fixture_schema();
1426 let mut store = Store::new();
1427 let v_a = make_typed_entity("alpha", "stalled", "decision");
1428 let v_b = make_typed_entity("beta", "stalled", "decision");
1429 store.upsert(v_a.id.clone(), v_a);
1430 store.upsert(v_b.id.clone(), v_b.clone());
1431
1432 let mut mem_schemas = HashMap::new();
1433 mem_schemas.insert("alpha".to_string(), schema.clone());
1434 mem_schemas.insert("beta".to_string(), schema);
1435
1436 let alpha_only = collect_missing_required_outgoing(&store, Some("alpha"), &mem_schemas);
1437 assert_eq!(alpha_only.len(), 1);
1438 assert_eq!(alpha_only[0].mem, "alpha");
1439
1440 let both = collect_missing_required_outgoing(&store, None, &mem_schemas);
1441 assert_eq!(both.len(), 2);
1442 }
1443
1444 #[test]
1445 fn missing_required_outgoing_skips_stubs_and_unschemaed_mems() {
1446 let schema = required_outgoing_fixture_schema();
1449 let mut store = Store::new();
1450 let mut stub = make_typed_entity("plan", "ghost", "");
1451 stub.stub = true;
1452 stub.entity_type = String::new();
1453 let other = make_typed_entity("uncharted", "lonely", "decision");
1454 store.upsert(stub.id.clone(), stub);
1455 store.upsert(other.id.clone(), other);
1456
1457 let mut mem_schemas = HashMap::new();
1458 mem_schemas.insert("plan".to_string(), schema);
1459
1460 let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
1461 assert!(
1462 reports.is_empty(),
1463 "stub (no schema lookup) and unschemaed mem must be skipped; got {reports:?}",
1464 );
1465 }
1466}