memstead_base/entity/store_builder.rs
1//! Shared helper for turning `ParseResult`s into a populated `Store`.
2//!
3//! The runtime engine calls this during `Engine::init` + `reload` and
4//! `attach_read_mem`. The strict validator calls it during V1 graph
5//! construction. Having one implementation guarantees both paths use
6//! identical stub + edge semantics.
7
8use indexmap::IndexMap;
9use memstead_schema::TypeDefinition;
10
11use super::parser::extract_inline_links_lenient;
12use super::{Entity, EntityId, ParseResult};
13use crate::ops::WarningHint;
14use crate::store::{Edge, EdgeSource, Store};
15
16/// Context passed to `push_entities_into_store` for load-time drift
17/// detection. Load-path call sites pass `Some(LoadCollector { .. })` so
18/// authored nested-prefix wiki-links (the classic mem-rename drift
19/// footprint) emit a `SuspiciousNestedPrefix` warning — mutation-path
20/// call sites pass `None` to stay silent (an author editing an entity
21/// that still has a drifted link should not see the warning refire on
22/// every save; load already caught it).
23pub struct LoadCollector<'a> {
24 /// Target for emitted warnings — typically `&mut engine.load_warnings`.
25 pub warnings: &'a mut Vec<WarningHint>,
26 /// Known-mem last-segment suffixes, derived from the mem roster
27 /// (e.g. `test-mem-plugin` → `plugin`). A nested-prefix link
28 /// is detected when a target id has the shape
29 /// `<current-mem>--<suffix>--<rest>` where `<suffix>` is in this
30 /// set and `<suffix>` is not the entity's own mem's last segment.
31 pub known_suffixes: &'a [String],
32 /// Full mem-name roster (writable + read mems). Used by the
33 /// two-pass candidate resolver to probe cross-mem matches.
34 pub mem_names: &'a [String],
35}
36
37/// Upsert parse results into the store, adding explicit relationship
38/// edges and auto-stubbing any unknown targets. Body wiki-links are
39/// not edge sources — under the alias model every edge originates
40/// from the auto-managed `## Relationships` section.
41///
42/// The fallback schema parameter is retained for call-site compatibility
43/// but no longer consulted for edge emission.
44///
45/// `load_ctx` is `Some` at load/reload/attach sites (drift-warning
46/// emission enabled) and `None` at mutation/validator sites (silent —
47/// warnings fire once at load, not on every edit).
48pub fn push_entities_into_store(
49 store: &mut Store,
50 parse_results: Vec<ParseResult>,
51 _fallback_schema: &TypeDefinition,
52 mut load_ctx: Option<LoadCollector<'_>>,
53) {
54 // Stash id + mem + sections per entity for a post-upsert drift
55 // scan. We can't scan before upsert: the two-pass resolver needs
56 // ALL entities in the batch to be present so a bare-slug fallback
57 // can find intra-batch targets regardless of filesystem iteration
58 // order (e.g. `drifted.md` loaded before its `foo.md` sibling).
59 let mut drift_scan_inputs: Vec<(EntityId, String, IndexMap<String, String>)> = Vec::new();
60
61 for parse_result in parse_results {
62 let entity_id = parse_result.entity.id.clone();
63 let entity_mem = parse_result.entity.mem.clone();
64
65 // Surface parse-time warnings (e.g. duplicate section headings) at
66 // load / reload / attach sites. Mutation paths build their own
67 // `ParseResult`s without `load_ctx` and ignore these.
68 if let Some(ctx) = load_ctx.as_mut()
69 && !parse_result.parse_warnings.is_empty()
70 {
71 ctx.warnings
72 .extend(parse_result.parse_warnings.iter().cloned());
73 }
74
75 if load_ctx.is_some() {
76 drift_scan_inputs.push((
77 entity_id.clone(),
78 entity_mem,
79 parse_result.entity.sections.clone(),
80 ));
81 }
82
83 // Clear pre-existing out-edges before upserting so the store
84 // reflects exactly the new entity's relationships. Without this,
85 // a mutation that drops a relation leaks the stale edge:
86 // `add_edge` is idempotent on (from, to, rel_type), so it never
87 // removes edges that the post-parse pass no longer emits.
88 store.remove_edges_from(&entity_id);
89
90 store.upsert(entity_id.clone(), parse_result.entity);
91
92 let relationships: Vec<_> = store
93 .get(&entity_id)
94 .map(|e| e.relationships.clone())
95 .unwrap_or_default();
96 for rel in &relationships {
97 if !store.contains(&rel.target) {
98 store.upsert(rel.target.clone(), make_stub(rel.target.clone()));
99 }
100 store.add_edge(
101 entity_id.clone(),
102 Edge {
103 rel_type: rel.rel_type.clone(),
104 target: rel.target.clone(),
105 source: EdgeSource::Explicit,
106 },
107 );
108 }
109 }
110
111 // Post-upsert drift scan — every batch entity is now in the store,
112 // so pass-2 (same-mem bare-slug) finds intra-batch targets too.
113 if let Some(ctx) = load_ctx.as_mut() {
114 for (id, mem, sections) in &drift_scan_inputs {
115 scan_nested_prefix_drift(id, mem, sections, ctx, store);
116 }
117 }
118}
119
120/// Re-add edges that point INTO `reloaded_mem` from entities living in
121/// other mems, after a per-mem reload of `reloaded_mem`.
122///
123/// The per-mem removal cascade ([`Store::remove`] via
124/// [`Store::remove_entities_by_mem`]) drops every incoming mirror of the
125/// reloaded mem's nodes — including cross-mem edges sourced from an
126/// un-reloaded mem — and the re-push ([`push_entities_into_store`]) only
127/// rebuilds edges authored by the reloaded mem's own entities. So a
128/// cross-mem edge `A→B` (A in another mem) survives in A's record and
129/// on disk but vanishes from the in-memory adjacency until a workspace-wide
130/// reload rebuilds A's side. This pass restores it from the authoritative
131/// source records, so a per-mem reload of B and a workspace-wide reload
132/// converge to the same incoming adjacency for B.
133///
134/// Mirrors `push_entities_into_store`'s edge construction exactly: auto-stub
135/// a missing target and add the edge as `EdgeSource::Explicit`. A following
136/// [`remap_alias_target_edge_sources`] reclassifies alias-derived sources
137/// (the same post-pass the reload already runs over the re-pushed mem),
138/// so an alias/body-link cross-mem edge keeps its `BodyLink` source. The
139/// scan is over in-memory records only — it never re-reads or re-parses
140/// another mem's backend, preserving the cheap-per-mem-reload property.
141pub fn reconstruct_incoming_cross_mem_edges(store: &mut Store, reloaded_mem: &str) {
142 let mut to_add: Vec<(EntityId, Edge)> = Vec::new();
143 for entity in store.all_entities() {
144 if entity.mem == reloaded_mem {
145 continue;
146 }
147 for rel in &entity.relationships {
148 if rel.target.mem() == reloaded_mem {
149 to_add.push((
150 entity.id.clone(),
151 Edge {
152 rel_type: rel.rel_type.clone(),
153 target: rel.target.clone(),
154 source: EdgeSource::Explicit,
155 },
156 ));
157 }
158 }
159 }
160 for (from, edge) in to_add {
161 if !store.contains(&edge.target) {
162 store.upsert(edge.target.clone(), make_stub(edge.target.clone()));
163 }
164 store.add_edge(from, edge);
165 }
166}
167
168/// Extract the last `-`-separated segment of a mem name (e.g.
169/// `test-mem-plugin` → `plugin`). Used to derive the
170/// known-mem-suffix set from the roster.
171pub fn last_segment_suffix(mem_name: &str) -> &str {
172 mem_name.rsplit('-').next().unwrap_or(mem_name)
173}
174
175/// Scan an entity's section bodies for wiki-links whose mem prefix
176/// matches a known mem last-segment but is NOT the full mem name —
177/// i.e. the author wrote the short-form (`[[plugin--foo]]`) instead of
178/// the bare-slug form (same-mem target) or the canonical
179/// fully-qualified form. Each hit produces a `SuspiciousNestedPrefix`
180/// warning with a two-pass resolved candidate.
181///
182/// Tier-0 `<mem>--<slug>` recognition resolves the body link to the
183/// named mem directly. A known short-name being used where a bare
184/// slug or a fully-qualified id was intended is the canonical drift
185/// pattern; the detector matches on the resolved target's mem.
186/// Runs before the entity is upserted so the candidate probe reflects
187/// the store state *before* this entity's own auto-stub would mask a
188/// real intra-mem match.
189fn scan_nested_prefix_drift(
190 from: &EntityId,
191 current_mem: &str,
192 sections: &IndexMap<String, String>,
193 ctx: &mut LoadCollector<'_>,
194 store: &Store,
195) {
196 for (section, body) in sections {
197 // Reuse the same extractor DanglingLink uses so semantics stay
198 // aligned (code-block masking, inline-code skipping, alias handling).
199 for target_id in extract_inline_links_lenient(body, current_mem) {
200 let target_mem = target_id.mem();
201 // Skip when the body link resolves into the current mem —
202 // bare-slug authoring is the canonical same-mem form, no
203 // drift to surface.
204 if target_mem == current_mem {
205 continue;
206 }
207 // A colon/dash link whose target mem is itself a full
208 // roster member AND whose target entity actually exists is a
209 // legitimate cross-mem reference, not drift: pass-1 of the
210 // two-pass resolver would just rediscover the same id, so the
211 // warning's "did you mean" candidate equals the already-
212 // resolved target — a self-contradicting false positive (the
213 // macos→engine case). Skip it. A real-mem target whose
214 // entity is *missing* is left to fire (it may be genuine
215 // rename-drift where a suffix-sibling mem holds the real
216 // entity — see `suffix_collision_resolves_first_match`).
217 if ctx.mem_names.iter().any(|v| v.as_str() == target_mem)
218 && store.get(&target_id).is_some_and(|e| !e.stub)
219 {
220 continue;
221 }
222 // Fire when the target's mem matches a known last-segment
223 // suffix of some mem in the roster. Self-suffix is NOT
224 // excluded — `[[plugin--x]]` inside `test-mem-plugin`
225 // (suffix `plugin`, with no `plugin` mem) remains the
226 // empirically-dominant drift pattern.
227 for suffix in ctx.known_suffixes.iter() {
228 if target_mem == suffix {
229 let candidate_target =
230 resolve_two_pass(target_id.path(), current_mem, ctx.mem_names, store);
231 ctx.warnings.push(WarningHint::SuspiciousNestedPrefix {
232 from: from.clone(),
233 resolved_id: target_id.clone(),
234 candidate_target,
235 section: section.clone(),
236 });
237 break;
238 }
239 }
240 }
241 }
242}
243
244/// Two-pass resolver for a stripped slug (the `<rest>` part of a
245/// nested-prefix drift hit).
246///
247/// Pass 1 (cross-mem-first): probe `<V>--<rest>` against every
248/// non-current mem in the roster. If exactly one match resolves to a
249/// real entity, the author probably meant that cross-mem entity.
250///
251/// Pass 2 (same-mem bare-slug): if no unique cross-mem match,
252/// probe `<current_mem>--<rest>`. If that resolves to a real entity,
253/// the author probably meant the bare slug form in the current mem.
254///
255/// Returns `None` on zero hits, multiple cross-mem hits (ambiguous),
256/// or when the matched candidate is a stub. Callers surface the
257/// `None` case so the author can disambiguate by hand — the warning
258/// still fires.
259fn resolve_two_pass(
260 rest: &str,
261 current_mem: &str,
262 mem_names: &[String],
263 store: &Store,
264) -> Option<EntityId> {
265 let mut hits: Vec<EntityId> = Vec::new();
266 for mem in mem_names {
267 if mem == current_mem {
268 continue;
269 }
270 let candidate = EntityId::new(mem, rest);
271 if let Some(e) = store.get(&candidate)
272 && !e.stub
273 {
274 hits.push(candidate);
275 }
276 }
277 match hits.len() {
278 1 => hits.pop(),
279 0 => {
280 // Pass 2: same-mem bare-slug fallback.
281 let candidate = EntityId::new(current_mem, rest);
282 if let Some(e) = store.get(&candidate)
283 && !e.stub
284 {
285 Some(candidate)
286 } else {
287 None
288 }
289 }
290 _ => None, // ambiguous cross-mem match
291 }
292}
293
294/// Validate every loaded entity's `## Relationships` entries against
295/// the source mem's schema and the wiki-link grammar. Invalid
296/// relations are dropped from both the store's edge index and the
297/// entity's in-memory `relationships` list; each drop emits a
298/// `PARSED_RELATION_INVALID` warning naming the offending entity,
299/// rel-type, target, and reason.
300///
301/// Four reasons fire today:
302/// - `grammar` — the target id's path does not match the wiki-link
303/// grammar (`^[a-z0-9-]+(/[a-z0-9-]+)*$`).
304/// - `unknown_rel_type` — the rel-type is not declared in the mem's
305/// schema and the schema is in `strict` mode. Open-mode schemas
306/// admit the relation without a warning (mirrors the mutation
307/// surface).
308/// - `shape` — the `(source_type, target_type)` pair is not allowed
309/// by the declared `source_types` / `target_types`. `target_type`
310/// is looked up from the store post-load, so the check sees the
311/// real type for any target — including cross-mem targets
312/// loaded from another mount. Stub targets (no `entity_type`) skip
313/// the target-side check; the relation lands and the shape will be
314/// re-verified when the stub is promoted to a real entity.
315/// - `cycle` — the relation closes a cycle in an `acyclic: true`
316/// rel-type's subgraph. Emitted by the second pass after grammar /
317/// rel-type / shape drops; the two-pass structure runs cycle
318/// detection after the initial relation-load so loading order
319/// doesn't determine which edge
320/// gets blamed. Each cycle drops exactly one back-edge per DFS
321/// visit; multiple independent cycles each lose one edge.
322///
323/// Runs once at boot after every mount's entities are pushed into the
324/// store. Mutation paths do not call this — they pre-validate via
325/// `validate_rel_type` + `validate_rel_shape` before the write, and
326/// every edge-writing verb (relate, `create.relations[]`,
327/// `update.declare_relations`, and the batch paths) runs the shared
328/// cycle family (`validate_edge_acyclicity`: self-loop on listed
329/// no-self-loop rel-types, `would_cycle` on acyclic ones) in the same call. This
330/// sweep therefore covers pre-existing on-disk data only — entities
331/// written before the write-path gates closed, or edited out-of-band.
332pub fn validate_loaded_relations(
333 store: &mut Store,
334 schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
335 mount_caps: &std::collections::HashMap<String, crate::workspace::MountCapability>,
336 warnings: &mut Vec<WarningHint>,
337) {
338 use crate::entity::Relationship;
339 use crate::entity::id::validate_id_path_grammar;
340 use crate::runtime_validator::{
341 CrossMemRelCheck, validate_cross_mem_edge, validate_rel_shape, validate_rel_type,
342 };
343 use crate::workspace::MountCapability;
344 use memstead_schema::SchemaRef;
345
346 let origin_for = |mem: &str| -> &'static str {
347 match mount_caps.get(mem) {
348 Some(MountCapability::ReadOnly) => "readonly",
349 _ => "writable",
350 }
351 };
352
353 // Pass 1: schema-shape + grammar + rel-type-known drops.
354 let mut to_drop: Vec<(EntityId, Relationship, &'static str)> = Vec::new();
355 for entity in store.all_entities() {
356 if entity.stub {
357 continue;
358 }
359 let Some(schema) = schemas.get(entity.mem.as_str()) else {
360 continue;
361 };
362 for rel in &entity.relationships {
363 if validate_id_path_grammar(rel.target.path()).is_err() {
364 to_drop.push((entity.id.clone(), rel.clone(), "grammar"));
365 continue;
366 }
367 // Cross-mem-different edges validate against the
368 // source schema's `cross_mem_relationships:` section,
369 // not its intra-mem `relationships.definitions`. Same-
370 // schema cross-mem and same-mem fall through to the
371 // intra-mem path — matching the runtime relate flow's
372 // routing rule.
373 let target_mem = rel.target.mem();
374 let target_schema = if entity.mem.as_str() == target_mem {
375 None
376 } else {
377 schemas.get(target_mem).cloned()
378 };
379 let target_schema_ref: Option<SchemaRef> = target_schema.as_ref().map(|s| {
380 let (name, version) = s.id();
381 SchemaRef::new(name, version)
382 });
383 let cross_mem_different = match (&target_schema_ref, schema.id()) {
384 (Some(target), (src_name, _)) => target.name != src_name,
385 (None, _) => false,
386 };
387 let target_type = store
388 .get(&rel.target)
389 .map(|e| e.entity_type.clone())
390 .filter(|t| !t.is_empty());
391 if cross_mem_different {
392 let target_ref = target_schema_ref.as_ref().expect("present when different");
393 match validate_cross_mem_edge(
394 &rel.rel_type,
395 entity.entity_type.as_str(),
396 target_type.as_deref(),
397 schema.as_ref(),
398 target_ref,
399 ) {
400 CrossMemRelCheck::Ok => {}
401 CrossMemRelCheck::EdgeNotDeclared => {
402 to_drop.push((entity.id.clone(), rel.clone(), "cross_mem_not_declared"));
403 continue;
404 }
405 CrossMemRelCheck::Invalid(_) => {
406 // Same drop semantics as the intra-mem
407 // shape/vocabulary branch — boot is silent
408 // best-effort cleanup.
409 to_drop.push((entity.id.clone(), rel.clone(), "cross_mem_shape"));
410 continue;
411 }
412 }
413 } else {
414 if validate_rel_type(&rel.rel_type, schema.as_ref()).is_err() {
415 to_drop.push((entity.id.clone(), rel.clone(), "unknown_rel_type"));
416 continue;
417 }
418 if validate_rel_shape(
419 &rel.rel_type,
420 entity.entity_type.as_str(),
421 target_type.as_deref(),
422 schema.as_ref(),
423 )
424 .is_err()
425 {
426 to_drop.push((entity.id.clone(), rel.clone(), "shape"));
427 continue;
428 }
429 }
430 }
431 }
432 for (from_id, rel, reason) in to_drop {
433 let origin = origin_for(from_id.mem()).to_string();
434 store.remove_edge(&from_id, &rel.target, &rel.rel_type);
435 if let Some(entity) = store.get_mut(&from_id) {
436 entity
437 .relationships
438 .retain(|r| !(r.rel_type == rel.rel_type && r.target == rel.target));
439 }
440 let recovery = if origin == "writable" {
441 Some(
442 crate::ops::ParsedRelationRecovery::remove_explicit_relation(
443 from_id.clone(),
444 rel.target.clone(),
445 rel.rel_type.clone(),
446 ),
447 )
448 } else {
449 None
450 };
451 warnings.push(WarningHint::ParsedRelationInvalid {
452 entity_id: from_id,
453 rel_type: rel.rel_type,
454 target: rel.target,
455 reason: reason.to_string(),
456 origin,
457 recovery,
458 });
459 }
460
461 // Pass 1b: per-edge description posture against the rel-type's
462 // schema declaration. Forbidden + description present → drop the
463 // description in-memory and warn; the next render normalises the
464 // row to the simple form. Required + description absent → warn
465 // and leave the relation intact; the operator's follow-up
466 // mutation (or a hand-edit using the em-dash delimiter) supplies
467 // the text. Runs after the shape drops so the surviving
468 // relationships have known-valid rel-types in this schema.
469 {
470 use memstead_schema::PerEdgeDescription;
471 let mut posture_warnings: Vec<WarningHint> = Vec::new();
472 let mut to_strip_description: Vec<(EntityId, String, EntityId)> = Vec::new();
473 for entity in store.all_entities() {
474 if entity.stub {
475 continue;
476 }
477 let Some(schema) = schemas.get(entity.mem.as_str()) else {
478 continue;
479 };
480 for rel in &entity.relationships {
481 // Look up the posture in the routing-appropriate
482 // definition. Cross-mem-different routes through
483 // the source schema's cross_mem_relationships entry
484 // for the target schema; intra-mem and same-schema
485 // cross-mem fall through to the intra-mem
486 // relationships.definitions.
487 let target_mem = rel.target.mem();
488 let target_schema = if entity.mem.as_str() == target_mem {
489 None
490 } else {
491 schemas.get(target_mem).cloned()
492 };
493 let target_schema_ref: Option<SchemaRef> = target_schema.as_ref().map(|s| {
494 let (name, version) = s.id();
495 SchemaRef::new(name, version)
496 });
497 let cross_mem_different = match (&target_schema_ref, schema.id()) {
498 (Some(target), (src_name, _)) => target.name != src_name,
499 (None, _) => false,
500 };
501 let posture = if cross_mem_different {
502 let target_ref = target_schema_ref
503 .as_ref()
504 .expect("target_schema_ref is Some when cross_mem_different");
505 schema
506 .cross_mem_entries(&target_ref.name)
507 .iter()
508 .find_map(|entry| entry.definitions.iter().find(|d| d.name == rel.rel_type))
509 .map(|d| d.per_edge_description)
510 } else {
511 schema
512 .relationship_def(&rel.rel_type)
513 .map(|d| d.per_edge_description)
514 };
515 match posture {
516 Some(PerEdgeDescription::Required) if rel.description.is_none() => {
517 posture_warnings.push(WarningHint::ParseMissingRequiredDescription {
518 from: entity.id.clone(),
519 rel_type: rel.rel_type.clone(),
520 target: rel.target.clone(),
521 });
522 }
523 Some(PerEdgeDescription::Forbidden) if rel.description.is_some() => {
524 posture_warnings.push(WarningHint::ParseDescriptionNotPermitted {
525 from: entity.id.clone(),
526 rel_type: rel.rel_type.clone(),
527 target: rel.target.clone(),
528 });
529 to_strip_description.push((
530 entity.id.clone(),
531 rel.rel_type.clone(),
532 rel.target.clone(),
533 ));
534 }
535 _ => {}
536 }
537 }
538 }
539 // Apply the description-strip in a second pass to avoid
540 // borrowing the store mutably while iterating it.
541 for (from_id, rel_type, target) in to_strip_description {
542 if let Some(entity) = store.get_mut(&from_id) {
543 for rel in entity.relationships.iter_mut() {
544 if rel.rel_type == rel_type && rel.target == target {
545 rel.description = None;
546 }
547 }
548 }
549 }
550 warnings.extend(posture_warnings);
551 }
552
553 // Pass 2: cycle detection per acyclic rel-type. Runs after the
554 // schema-shape drops above so the input subgraph is already
555 // schema-clean; cycles closed by edges that pass shape are the
556 // residual hazard hand-edits can produce. Single pass per
557 // rel-type — for each acyclic rel-type, build the workspace-wide
558 // adjacency list of edges whose source mem declares that
559 // rel-type as acyclic, then DFS with three-color marking
560 // (white / gray / black). On encountering a gray node from a
561 // gray parent, the traversing edge is a back-edge — drop it and
562 // continue. The chosen back-edge is the *latest-visited* edge
563 // in the cycle, not the "earliest" or "structural" one. That's
564 // intentionally stable: DFS order is determined by `EntityId`
565 // hash iteration (`HashMap` keys), which is consistent within a
566 // process. Different processes may pick different back-edges;
567 // either way the cycle is broken and the agent sees a typed
568 // warning naming the dropped relation.
569
570 // Collect the union of acyclic rel-types declared by any schema
571 // in this workspace.
572 let mut acyclic_rel_types: Vec<String> = Vec::new();
573 for schema in schemas.values() {
574 for def in &schema.manifest.relationships.definitions {
575 if def.acyclic && !acyclic_rel_types.contains(&def.name) {
576 acyclic_rel_types.push(def.name.clone());
577 }
578 }
579 }
580
581 let mut cycle_drops: Vec<(EntityId, EntityId, String)> = Vec::new();
582 for rel_type in &acyclic_rel_types {
583 // Adjacency list scoped to this rel-type. Includes edges
584 // whose source mem's schema declares the rel-type as
585 // acyclic — a mem whose schema doesn't declare the type
586 // acyclic shouldn't have its edges dropped just because a
587 // sibling mem does.
588 let mut adj: std::collections::HashMap<EntityId, Vec<EntityId>> =
589 std::collections::HashMap::new();
590 for entity in store.all_entities() {
591 let Some(schema) = schemas.get(entity.mem.as_str()) else {
592 continue;
593 };
594 if !schema.relationship_acyclic(rel_type) {
595 continue;
596 }
597 for edge in store.outgoing(&entity.id) {
598 if &edge.rel_type == rel_type {
599 adj.entry(entity.id.clone())
600 .or_default()
601 .push(edge.target.clone());
602 }
603 }
604 }
605
606 // Three-color DFS. Each entity is white initially. Push to
607 // gray on entry; demote to black on full descent. A gray
608 // child reached from a gray parent is a back-edge.
609 #[derive(Clone, Copy, PartialEq, Eq)]
610 enum Color {
611 White,
612 Gray,
613 Black,
614 }
615 let mut color: std::collections::HashMap<EntityId, Color> =
616 adj.keys().map(|k| (k.clone(), Color::White)).collect();
617 // Stable iteration order — sort the seeds so the dropped
618 // edge depends only on the workspace's id set, not on hash
619 // iteration order.
620 let mut seeds: Vec<EntityId> = adj.keys().cloned().collect();
621 seeds.sort_by(|a, b| a.as_ref().cmp(b.as_ref()));
622 for seed in seeds {
623 if color.get(&seed).copied() != Some(Color::White) {
624 continue;
625 }
626 // Iterative DFS to avoid stack blow-ups on deep graphs.
627 // Stack entry: (node, sorted-adjacency-index, sorted-adjacency-snapshot).
628 let mut stack: Vec<(EntityId, usize, Vec<EntityId>)> = Vec::new();
629 let mut start_targets: Vec<EntityId> = adj.get(&seed).cloned().unwrap_or_default();
630 start_targets.sort_by(|a, b| a.as_ref().cmp(b.as_ref()));
631 color.insert(seed.clone(), Color::Gray);
632 stack.push((seed.clone(), 0, start_targets));
633 while let Some((node, idx, targets)) = stack.last_mut() {
634 if *idx >= targets.len() {
635 let done = node.clone();
636 color.insert(done, Color::Black);
637 stack.pop();
638 continue;
639 }
640 let target = targets[*idx].clone();
641 *idx += 1;
642 let node_id = node.clone();
643 match color.get(&target).copied() {
644 Some(Color::White) => {
645 let mut next_targets: Vec<EntityId> =
646 adj.get(&target).cloned().unwrap_or_default();
647 next_targets.sort_by(|a, b| a.as_ref().cmp(b.as_ref()));
648 color.insert(target.clone(), Color::Gray);
649 stack.push((target, 0, next_targets));
650 }
651 Some(Color::Gray) => {
652 // Back-edge — closes a cycle. Drop it.
653 cycle_drops.push((node_id, target, rel_type.clone()));
654 }
655 Some(Color::Black) | None => {
656 // Already fully explored or not in the
657 // subgraph — no cycle through this edge.
658 }
659 }
660 }
661 }
662 }
663
664 for (from_id, target, rel_type) in cycle_drops {
665 let origin = origin_for(from_id.mem()).to_string();
666 store.remove_edge(&from_id, &target, &rel_type);
667 if let Some(entity) = store.get_mut(&from_id) {
668 entity
669 .relationships
670 .retain(|r| !(r.rel_type == rel_type && r.target == target));
671 }
672 let recovery = if origin == "writable" {
673 Some(
674 crate::ops::ParsedRelationRecovery::remove_explicit_relation(
675 from_id.clone(),
676 target.clone(),
677 rel_type.clone(),
678 ),
679 )
680 } else {
681 None
682 };
683 warnings.push(WarningHint::ParsedRelationInvalid {
684 entity_id: from_id,
685 rel_type,
686 target,
687 reason: "cycle".to_string(),
688 origin,
689 recovery,
690 });
691 }
692}
693
694/// Remap edge sources to reflect each source mem's
695/// `alias_target_rel_type` schema pointer: edges whose `rel_type`
696/// equals the pointer are flipped from `Explicit` to `BodyLink`.
697/// Idempotent — running it repeatedly produces the same result.
698///
699/// The discriminator is store-side only (no entity-side field). Under
700/// the schema-load coupling (Option C), the pointer rel-type is also
701/// `manual_authoring: forbidden`, so the only path to an edge of that
702/// rel-type is via the alias-synthesis pass — making this remap
703/// uniform across the workspace once the test sweep completes.
704///
705/// During the transitional window (synthesis pass landed but the 5
706/// built-ins not yet flipped to `manual_authoring: forbidden`),
707/// explicit `memstead_relate type=REFERENCES` still works for tests, and
708/// those edges will also be remapped to `BodyLink` here. The wire
709/// shape distinguishes synthesised vs. explicit only through this
710/// label, so the relabel is observable but harmless — no test
711/// asserts the legacy `"explicit"` string for REFERENCES.
712pub fn remap_alias_target_edge_sources(
713 store: &mut Store,
714 schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
715) {
716 let mut remaps: Vec<(EntityId, EntityId, String)> = Vec::new();
717 for entity in store.all_entities() {
718 let Some(schema) = schemas.get(entity.mem.as_str()) else {
719 continue;
720 };
721 let Some(pointer) = schema.alias_target_rel_type() else {
722 continue;
723 };
724 for edge in store.outgoing(&entity.id) {
725 if edge.rel_type == pointer && edge.source != EdgeSource::BodyLink {
726 remaps.push((
727 entity.id.clone(),
728 edge.target.clone(),
729 edge.rel_type.clone(),
730 ));
731 }
732 }
733 }
734 for (from, to, rel_type) in remaps {
735 store.add_edge(
736 from,
737 Edge {
738 rel_type,
739 target: to,
740 source: EdgeSource::BodyLink,
741 },
742 );
743 }
744}
745
746/// Minimal placeholder entity for a wiki-link target that has no
747/// markdown file. Tagged `StubKind::LoadTime` — this constructor
748/// fires from parser-driven paths (boot, reload, attach) where the
749/// stub is auto-emitted from a wiki-link to a not-yet-present
750/// target. Mutation paths that need `ForwardReference` /
751/// `Residual` use the engine-internal `make_stub` in
752/// `engine/mutation/mod.rs` which takes an explicit kind.
753pub fn make_stub(id: EntityId) -> Entity {
754 Entity {
755 title: id.name().to_string(),
756 entity_type: String::new(),
757 mem: id.mem().to_string(),
758 file_path: String::new(),
759 metadata: IndexMap::new(),
760 sections: IndexMap::new(),
761 relationships: Vec::new(),
762 content_hash: String::new(),
763 stub: true,
764 stub_kind: Some(crate::entity::StubKind::LoadTime),
765 id,
766 heading_spans: std::collections::HashMap::new(),
767 raw_section_headings: Vec::new(),
768 }
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774 use crate::entity::Entity;
775 use memstead_schema::type_by_name;
776
777 fn default_fallback() -> std::sync::Arc<TypeDefinition> {
778 type_by_name("spec").expect("spec type must exist")
779 }
780
781 fn real_entity(id_str: &str, sections: &[(&str, &str)]) -> ParseResult {
782 let id = EntityId(id_str.to_string());
783 let mem = id.mem().to_string();
784 let mut sec = IndexMap::new();
785 for (k, v) in sections {
786 sec.insert(k.to_string(), v.to_string());
787 }
788 ParseResult {
789 entity: Entity {
790 title: id.name().to_string(),
791 entity_type: "spec".to_string(),
792 mem,
793 file_path: format!("{}.md", id.name()),
794 metadata: IndexMap::new(),
795 sections: sec,
796 relationships: Vec::new(),
797 content_hash: "deadbeef00000000".to_string(),
798 stub: false,
799 stub_kind: None,
800 id,
801 heading_spans: std::collections::HashMap::new(),
802 raw_section_headings: Vec::new(),
803 },
804 inline_links: Vec::new(),
805 parse_warnings: Vec::new(),
806 }
807 }
808
809 /// Plugin-mem entity with `[[plugin--foo]]` in a section and a
810 /// real `test-mem-plugin--foo` already in the store → warning
811 /// fires with a populated `candidate_target` (same-mem bare-slug
812 /// resolution, pass 2 of the two-pass resolver).
813 #[test]
814 fn nested_prefix_emits_warning_with_candidate() {
815 let fallback = default_fallback();
816 let mut store = Store::new();
817
818 let target = real_entity("test-mem-plugin--foo", &[]);
819 push_entities_into_store(&mut store, vec![target], &fallback, None);
820
821 let author = real_entity(
822 "test-mem-plugin--author",
823 &[("constraints", "See [[plugin--foo]] for details.")],
824 );
825 let mut warnings = Vec::new();
826 let mem_names = vec!["test-mem-plugin".to_string()];
827 let known_suffixes = vec!["plugin".to_string()];
828 push_entities_into_store(
829 &mut store,
830 vec![author],
831 &fallback,
832 Some(LoadCollector {
833 warnings: &mut warnings,
834 known_suffixes: &known_suffixes,
835 mem_names: &mem_names,
836 }),
837 );
838
839 assert_eq!(warnings.len(), 1, "one nested-prefix warning expected");
840 match &warnings[0] {
841 WarningHint::SuspiciousNestedPrefix {
842 from,
843 resolved_id,
844 candidate_target,
845 section,
846 } => {
847 assert_eq!(from.as_ref(), "test-mem-plugin--author");
848 // Tier-0 resolves `[[plugin--foo]]` to `plugin--foo`
849 // directly (not a phantom
850 // `test-mem-plugin--plugin--foo`).
851 assert_eq!(resolved_id.as_ref(), "plugin--foo");
852 assert_eq!(
853 candidate_target.as_ref().map(|c| c.as_ref()),
854 Some("test-mem-plugin--foo")
855 );
856 assert_eq!(section, "constraints");
857 }
858 other => panic!("unexpected variant: {other:?}"),
859 }
860 }
861
862 /// #41 narrowing: a colon/dash cross-mem link whose target mem
863 /// is itself a full roster member is legitimate — no nested-prefix
864 /// warning, even though that mem name also appears as a known
865 /// suffix. This is the macos→engine false positive the heuristic
866 /// used to emit (the "did you mean" candidate equalled the resolved
867 /// target — self-contradicting).
868 #[test]
869 fn nested_prefix_skips_when_target_is_a_real_mem() {
870 let fallback = default_fallback();
871 let mut store = Store::new();
872
873 let target = real_entity("engine--foo", &[]);
874 push_entities_into_store(&mut store, vec![target], &fallback, None);
875
876 let author = real_entity(
877 "macos--author",
878 &[("constraints", "See [[engine--foo]] for details.")],
879 );
880 let mut warnings = Vec::new();
881 let mem_names = vec!["macos".to_string(), "engine".to_string()];
882 // `engine` is both a real mem AND its own last-segment suffix.
883 let known_suffixes = vec!["macos".to_string(), "engine".to_string()];
884 push_entities_into_store(
885 &mut store,
886 vec![author],
887 &fallback,
888 Some(LoadCollector {
889 warnings: &mut warnings,
890 known_suffixes: &known_suffixes,
891 mem_names: &mem_names,
892 }),
893 );
894
895 assert!(
896 warnings.is_empty(),
897 "a cross-mem link to a real mem must not warn: {warnings:?}"
898 );
899 }
900
901 /// Same scenario but the candidate is missing — the warning still
902 /// fires so the author sees drift, with `candidate_target: None`.
903 #[test]
904 fn nested_prefix_emits_warning_without_candidate() {
905 let fallback = default_fallback();
906 let mut store = Store::new();
907
908 let author = real_entity(
909 "test-mem-plugin--author",
910 &[("constraints", "[[plugin--ghost]]")],
911 );
912 let mut warnings = Vec::new();
913 let mem_names = vec!["test-mem-plugin".to_string()];
914 let known_suffixes = vec!["plugin".to_string()];
915 push_entities_into_store(
916 &mut store,
917 vec![author],
918 &fallback,
919 Some(LoadCollector {
920 warnings: &mut warnings,
921 known_suffixes: &known_suffixes,
922 mem_names: &mem_names,
923 }),
924 );
925
926 assert_eq!(warnings.len(), 1);
927 match &warnings[0] {
928 WarningHint::SuspiciousNestedPrefix {
929 candidate_target, ..
930 } => assert!(candidate_target.is_none()),
931 other => panic!("unexpected variant: {other:?}"),
932 }
933 }
934
935 /// Bare-slug link (`[[foo]]`) resolves to `<current-mem>--foo` —
936 /// no nested prefix, no warning.
937 #[test]
938 fn non_nested_link_no_warning() {
939 let fallback = default_fallback();
940 let mut store = Store::new();
941
942 let author = real_entity("test-mem-plugin--author", &[("constraints", "[[foo]]")]);
943 let mut warnings = Vec::new();
944 let mem_names = vec!["test-mem-plugin".to_string()];
945 let known_suffixes = vec!["plugin".to_string()];
946 push_entities_into_store(
947 &mut store,
948 vec![author],
949 &fallback,
950 Some(LoadCollector {
951 warnings: &mut warnings,
952 known_suffixes: &known_suffixes,
953 mem_names: &mem_names,
954 }),
955 );
956 assert!(warnings.is_empty());
957 }
958
959 /// Fully-qualified cross-mem link resolves to a different mem's
960 /// id, not `<current-mem>--<suffix>--...`, so no nested prefix.
961 /// Note: `[[<mem>--slug]]` in the section body literally resolves
962 /// via wiki_link_to_id to `<current>--<mem>--slug` (nested), so
963 /// this pattern is ambiguous by construction — the detector fires
964 /// with a candidate that points at the fully-qualified target.
965 /// Callers should write the full id or bare slug, not
966 /// `<mem>--slug` from outside that mem.
967 #[test]
968 fn cross_mem_qualified_fires_with_cross_mem_candidate() {
969 let fallback = default_fallback();
970 let mut store = Store::new();
971
972 // Real entity in the engine mem.
973 let target = real_entity("test-mem-engine--health", &[]);
974 push_entities_into_store(&mut store, vec![target], &fallback, None);
975
976 // Plugin-mem author writes `[[engine--health]]`.
977 let author = real_entity(
978 "test-mem-plugin--author",
979 &[("purpose", "See [[engine--health]].")],
980 );
981 let mut warnings = Vec::new();
982 let mem_names = vec!["test-mem-engine".to_string(), "test-mem-plugin".to_string()];
983 let known_suffixes = vec!["engine".to_string(), "plugin".to_string()];
984 push_entities_into_store(
985 &mut store,
986 vec![author],
987 &fallback,
988 Some(LoadCollector {
989 warnings: &mut warnings,
990 known_suffixes: &known_suffixes,
991 mem_names: &mem_names,
992 }),
993 );
994 assert_eq!(warnings.len(), 1);
995 match &warnings[0] {
996 WarningHint::SuspiciousNestedPrefix {
997 candidate_target, ..
998 } => {
999 assert_eq!(
1000 candidate_target.as_ref().map(|c| c.as_ref()),
1001 Some("test-mem-engine--health"),
1002 "cross-mem pass-1 must find the engine mem candidate"
1003 );
1004 }
1005 other => panic!("unexpected variant: {other:?}"),
1006 }
1007 }
1008
1009 /// Two mems sharing a last-segment suffix: both contribute to the
1010 /// known-suffix set, the warning fires, and `candidate_target` is
1011 /// the one that has a real entity. Locks the suffix-collision
1012 /// resolution semantics.
1013 #[test]
1014 fn suffix_collision_resolves_first_match() {
1015 let fallback = default_fallback();
1016 let mut store = Store::new();
1017
1018 // Mem A = `alpha`, Mem B = `beta-alpha`, both have suffix "alpha".
1019 // Real entity lives in `beta-alpha--target`.
1020 let target = real_entity("beta-alpha--target", &[]);
1021 push_entities_into_store(&mut store, vec![target], &fallback, None);
1022
1023 // An author in `beta-alpha` writes `[[alpha--target]]`.
1024 let author = real_entity("beta-alpha--author", &[("purpose", "[[alpha--target]]")]);
1025 let mut warnings = Vec::new();
1026 let mem_names = vec!["alpha".to_string(), "beta-alpha".to_string()];
1027 let known_suffixes = vec!["alpha".to_string(), "alpha".to_string()]; // collision
1028 push_entities_into_store(
1029 &mut store,
1030 vec![author],
1031 &fallback,
1032 Some(LoadCollector {
1033 warnings: &mut warnings,
1034 known_suffixes: &known_suffixes,
1035 mem_names: &mem_names,
1036 }),
1037 );
1038 assert_eq!(
1039 warnings.len(),
1040 1,
1041 "collision must not duplicate the warning"
1042 );
1043 match &warnings[0] {
1044 WarningHint::SuspiciousNestedPrefix {
1045 candidate_target, ..
1046 } => {
1047 // Pass 1 cross-mem probe excludes `beta-alpha` (self),
1048 // probes `alpha` — no real entity there, so pass 2
1049 // falls back to same-mem bare-slug `beta-alpha--target`
1050 // which is real.
1051 assert_eq!(
1052 candidate_target.as_ref().map(|c| c.as_ref()),
1053 Some("beta-alpha--target")
1054 );
1055 }
1056 other => panic!("unexpected variant: {other:?}"),
1057 }
1058 }
1059}