Skip to main content

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                    // A prefix that IS a roster member reached here only
232                    // because its target is missing (the existing-target
233                    // case returned above): say "target missing", not
234                    // "rename drift". A prefix that merely matches a
235                    // member's last segment is the rename pattern.
236                    let prefix_mounted = ctx.mem_names.iter().any(|v| v.as_str() == target_mem);
237                    ctx.warnings.push(WarningHint::SuspiciousNestedPrefix {
238                        from: from.clone(),
239                        resolved_id: target_id.clone(),
240                        candidate_target,
241                        section: section.clone(),
242                        prefix_mounted,
243                    });
244                    break;
245                }
246            }
247        }
248    }
249}
250
251/// Two-pass resolver for a stripped slug (the `<rest>` part of a
252/// nested-prefix drift hit).
253///
254/// Pass 1 (cross-mem-first): probe `<V>--<rest>` against every
255/// non-current mem in the roster. If exactly one match resolves to a
256/// real entity, the author probably meant that cross-mem entity.
257///
258/// Pass 2 (same-mem bare-slug): if no unique cross-mem match,
259/// probe `<current_mem>--<rest>`. If that resolves to a real entity,
260/// the author probably meant the bare slug form in the current mem.
261///
262/// Returns `None` on zero hits, multiple cross-mem hits (ambiguous),
263/// or when the matched candidate is a stub. Callers surface the
264/// `None` case so the author can disambiguate by hand — the warning
265/// still fires.
266fn resolve_two_pass(
267    rest: &str,
268    current_mem: &str,
269    mem_names: &[String],
270    store: &Store,
271) -> Option<EntityId> {
272    let mut hits: Vec<EntityId> = Vec::new();
273    for mem in mem_names {
274        if mem == current_mem {
275            continue;
276        }
277        let candidate = EntityId::new(mem, rest);
278        if let Some(e) = store.get(&candidate)
279            && !e.stub
280        {
281            hits.push(candidate);
282        }
283    }
284    match hits.len() {
285        1 => hits.pop(),
286        0 => {
287            // Pass 2: same-mem bare-slug fallback.
288            let candidate = EntityId::new(current_mem, rest);
289            if let Some(e) = store.get(&candidate)
290                && !e.stub
291            {
292                Some(candidate)
293            } else {
294                None
295            }
296        }
297        _ => None, // ambiguous cross-mem match
298    }
299}
300
301/// Validate every loaded entity's `## Relationships` entries against
302/// the source mem's schema and the wiki-link grammar. Invalid
303/// relations are dropped from both the store's edge index and the
304/// entity's in-memory `relationships` list; each drop emits a
305/// `PARSED_RELATION_INVALID` warning naming the offending entity,
306/// rel-type, target, and reason.
307///
308/// Four reasons fire today:
309/// - `grammar` — the target id's path does not match the wiki-link
310///   grammar (`^[a-z0-9-]+(/[a-z0-9-]+)*$`).
311/// - `unknown_rel_type` — the rel-type is not declared in the mem's
312///   schema and the schema is in `strict` mode. Open-mode schemas
313///   admit the relation without a warning (mirrors the mutation
314///   surface).
315/// - `shape` — the `(source_type, target_type)` pair is not allowed
316///   by the declared `source_types` / `target_types`. `target_type`
317///   is looked up from the store post-load, so the check sees the
318///   real type for any target — including cross-mem targets
319///   loaded from another mount. Stub targets (no `entity_type`) skip
320///   the target-side check; the relation lands and the shape will be
321///   re-verified when the stub is promoted to a real entity.
322/// - `cycle` — the relation closes a cycle in an `acyclic: true`
323///   rel-type's subgraph. Emitted by the second pass after grammar /
324///   rel-type / shape drops; the two-pass structure runs cycle
325///   detection after the initial relation-load so loading order
326///   doesn't determine which edge
327///   gets blamed. Each cycle drops exactly one back-edge per DFS
328///   visit; multiple independent cycles each lose one edge.
329///
330/// Runs once at boot after every mount's entities are pushed into the
331/// store. Mutation paths do not call this — they pre-validate via
332/// `validate_rel_type` + `validate_rel_shape` before the write, and
333/// every edge-writing verb (relate, `create.relations[]`,
334/// `update.declare_relations`, and the batch paths) runs the shared
335/// cycle family (`validate_edge_acyclicity`: self-loop on listed
336/// no-self-loop rel-types, `would_cycle` on acyclic ones) in the same call. This
337/// sweep therefore covers pre-existing on-disk data only — entities
338/// written before the write-path gates closed, or edited out-of-band.
339pub fn validate_loaded_relations(
340    store: &mut Store,
341    schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
342    mount_caps: &std::collections::HashMap<String, crate::workspace::MountCapability>,
343    warnings: &mut Vec<WarningHint>,
344) {
345    use crate::entity::Relationship;
346    use crate::entity::id::validate_id_path_grammar;
347    use crate::runtime_validator::{
348        CrossMemRelCheck, validate_cross_mem_edge, validate_rel_shape, validate_rel_type,
349    };
350    use crate::workspace::MountCapability;
351    use memstead_schema::SchemaRef;
352
353    let origin_for = |mem: &str| -> &'static str {
354        match mount_caps.get(mem) {
355            Some(MountCapability::ReadOnly) => "readonly",
356            _ => "writable",
357        }
358    };
359
360    // Pass 1: schema-shape + grammar + rel-type-known drops.
361    let mut to_drop: Vec<(EntityId, Relationship, &'static str)> = Vec::new();
362    for entity in store.all_entities() {
363        if entity.stub {
364            continue;
365        }
366        let Some(schema) = schemas.get(entity.mem.as_str()) else {
367            continue;
368        };
369        for rel in &entity.relationships {
370            if validate_id_path_grammar(rel.target.path()).is_err() {
371                to_drop.push((entity.id.clone(), rel.clone(), "grammar"));
372                continue;
373            }
374            // Cross-mem-different edges validate against the
375            // source schema's `cross_mem_relationships:` section,
376            // not its intra-mem `relationships.definitions`. Same-
377            // schema cross-mem and same-mem fall through to the
378            // intra-mem path — matching the runtime relate flow's
379            // routing rule.
380            let target_mem = rel.target.mem();
381            let target_schema = if entity.mem.as_str() == target_mem {
382                None
383            } else {
384                schemas.get(target_mem).cloned()
385            };
386            let target_schema_ref: Option<SchemaRef> = target_schema.as_ref().map(|s| {
387                let (name, version) = s.id();
388                SchemaRef::new(name, version)
389            });
390            let cross_mem_different = match (&target_schema_ref, schema.id()) {
391                (Some(target), (src_name, _)) => target.name != src_name,
392                (None, _) => false,
393            };
394            let target_type = store
395                .get(&rel.target)
396                .map(|e| e.entity_type.clone())
397                .filter(|t| !t.is_empty());
398            if cross_mem_different {
399                let target_ref = target_schema_ref.as_ref().expect("present when different");
400                match validate_cross_mem_edge(
401                    &rel.rel_type,
402                    entity.entity_type.as_str(),
403                    target_type.as_deref(),
404                    schema.as_ref(),
405                    target_ref,
406                ) {
407                    CrossMemRelCheck::Ok => {}
408                    CrossMemRelCheck::EdgeNotDeclared => {
409                        to_drop.push((entity.id.clone(), rel.clone(), "cross_mem_not_declared"));
410                        continue;
411                    }
412                    CrossMemRelCheck::Invalid(_) => {
413                        // Same drop semantics as the intra-mem
414                        // shape/vocabulary branch — boot is silent
415                        // best-effort cleanup.
416                        to_drop.push((entity.id.clone(), rel.clone(), "cross_mem_shape"));
417                        continue;
418                    }
419                }
420            } else {
421                if validate_rel_type(&rel.rel_type, schema.as_ref()).is_err() {
422                    to_drop.push((entity.id.clone(), rel.clone(), "unknown_rel_type"));
423                    continue;
424                }
425                if validate_rel_shape(
426                    &rel.rel_type,
427                    entity.entity_type.as_str(),
428                    target_type.as_deref(),
429                    schema.as_ref(),
430                )
431                .is_err()
432                {
433                    to_drop.push((entity.id.clone(), rel.clone(), "shape"));
434                    continue;
435                }
436            }
437        }
438    }
439    for (from_id, rel, reason) in to_drop {
440        let origin = origin_for(from_id.mem()).to_string();
441        store.remove_edge(&from_id, &rel.target, &rel.rel_type);
442        if let Some(entity) = store.get_mut(&from_id) {
443            entity
444                .relationships
445                .retain(|r| !(r.rel_type == rel.rel_type && r.target == rel.target));
446        }
447        let recovery = if origin == "writable" {
448            Some(
449                crate::ops::ParsedRelationRecovery::remove_explicit_relation(
450                    from_id.clone(),
451                    rel.target.clone(),
452                    rel.rel_type.clone(),
453                ),
454            )
455        } else {
456            None
457        };
458        warnings.push(WarningHint::ParsedRelationInvalid {
459            entity_id: from_id,
460            rel_type: rel.rel_type,
461            target: rel.target,
462            reason: reason.to_string(),
463            origin,
464            recovery,
465        });
466    }
467
468    // Pass 1b: per-edge description posture against the rel-type's
469    // schema declaration. Forbidden + description present → drop the
470    // description in-memory and warn; the next render normalises the
471    // row to the simple form. Required + description absent → warn
472    // and leave the relation intact; the operator's follow-up
473    // mutation (or a hand-edit using the em-dash delimiter) supplies
474    // the text. Runs after the shape drops so the surviving
475    // relationships have known-valid rel-types in this schema.
476    {
477        use memstead_schema::PerEdgeDescription;
478        let mut posture_warnings: Vec<WarningHint> = Vec::new();
479        let mut to_strip_description: Vec<(EntityId, String, EntityId)> = Vec::new();
480        for entity in store.all_entities() {
481            if entity.stub {
482                continue;
483            }
484            let Some(schema) = schemas.get(entity.mem.as_str()) else {
485                continue;
486            };
487            for rel in &entity.relationships {
488                // Look up the posture in the routing-appropriate
489                // definition. Cross-mem-different routes through
490                // the source schema's cross_mem_relationships entry
491                // for the target schema; intra-mem and same-schema
492                // cross-mem fall through to the intra-mem
493                // relationships.definitions.
494                let target_mem = rel.target.mem();
495                let target_schema = if entity.mem.as_str() == target_mem {
496                    None
497                } else {
498                    schemas.get(target_mem).cloned()
499                };
500                let target_schema_ref: Option<SchemaRef> = target_schema.as_ref().map(|s| {
501                    let (name, version) = s.id();
502                    SchemaRef::new(name, version)
503                });
504                let cross_mem_different = match (&target_schema_ref, schema.id()) {
505                    (Some(target), (src_name, _)) => target.name != src_name,
506                    (None, _) => false,
507                };
508                let posture = if cross_mem_different {
509                    let target_ref = target_schema_ref
510                        .as_ref()
511                        .expect("target_schema_ref is Some when cross_mem_different");
512                    schema
513                        .cross_mem_entries(&target_ref.name)
514                        .iter()
515                        .find_map(|entry| entry.definitions.iter().find(|d| d.name == rel.rel_type))
516                        .map(|d| d.per_edge_description)
517                } else {
518                    schema
519                        .relationship_def(&rel.rel_type)
520                        .map(|d| d.per_edge_description)
521                };
522                match posture {
523                    Some(PerEdgeDescription::Required) if rel.description.is_none() => {
524                        posture_warnings.push(WarningHint::ParseMissingRequiredDescription {
525                            from: entity.id.clone(),
526                            rel_type: rel.rel_type.clone(),
527                            target: rel.target.clone(),
528                        });
529                    }
530                    Some(PerEdgeDescription::Forbidden) if rel.description.is_some() => {
531                        posture_warnings.push(WarningHint::ParseDescriptionNotPermitted {
532                            from: entity.id.clone(),
533                            rel_type: rel.rel_type.clone(),
534                            target: rel.target.clone(),
535                        });
536                        to_strip_description.push((
537                            entity.id.clone(),
538                            rel.rel_type.clone(),
539                            rel.target.clone(),
540                        ));
541                    }
542                    _ => {}
543                }
544            }
545        }
546        // Apply the description-strip in a second pass to avoid
547        // borrowing the store mutably while iterating it.
548        for (from_id, rel_type, target) in to_strip_description {
549            if let Some(entity) = store.get_mut(&from_id) {
550                for rel in entity.relationships.iter_mut() {
551                    if rel.rel_type == rel_type && rel.target == target {
552                        rel.description = None;
553                    }
554                }
555            }
556        }
557        warnings.extend(posture_warnings);
558    }
559
560    // Pass 2: cycle detection per acyclic rel-type. Runs after the
561    // schema-shape drops above so the input subgraph is already
562    // schema-clean; cycles closed by edges that pass shape are the
563    // residual hazard hand-edits can produce. Single pass per
564    // rel-type — for each acyclic rel-type, build the workspace-wide
565    // adjacency list of edges whose source mem declares that
566    // rel-type as acyclic, then DFS with three-color marking
567    // (white / gray / black). On encountering a gray node from a
568    // gray parent, the traversing edge is a back-edge — drop it and
569    // continue. The chosen back-edge is the *latest-visited* edge
570    // in the cycle, not the "earliest" or "structural" one. That's
571    // intentionally stable: DFS order is determined by `EntityId`
572    // hash iteration (`HashMap` keys), which is consistent within a
573    // process. Different processes may pick different back-edges;
574    // either way the cycle is broken and the agent sees a typed
575    // warning naming the dropped relation.
576
577    // A cycle space is either one acyclic-flagged rel-type's subgraph
578    // (the long-standing sweep) or the UNION subgraph of a declared
579    // `relationships.acyclic_sets` set, whose cycles may mix
580    // rel-types. Both sweep identically; the set space's adjacency
581    // remembers each edge's rel-type so the dropped edge is named
582    // precisely.
583    enum CycleSpace<'a> {
584        Single(&'a String),
585        Set(&'a Vec<String>),
586    }
587
588    // Collect the union of acyclic rel-types declared by any schema
589    // in this workspace.
590    let mut acyclic_rel_types: Vec<String> = Vec::new();
591    for schema in schemas.values() {
592        for def in &schema.manifest.relationships.definitions {
593            if def.acyclic && !acyclic_rel_types.contains(&def.name) {
594                acyclic_rel_types.push(def.name.clone());
595            }
596        }
597    }
598    // Collect the distinct declared acyclicity sets — iterated in
599    // sorted mem order so the sweep order (and therefore which
600    // back-edge drops) is stable across HashMap iteration orders.
601    let mut acyclic_set_spaces: Vec<Vec<String>> = Vec::new();
602    let mut schema_mems: Vec<&String> = schemas.keys().collect();
603    schema_mems.sort();
604    for mem in schema_mems {
605        for set in &schemas[mem.as_str()].manifest.relationships.acyclic_sets {
606            if !acyclic_set_spaces.contains(set) {
607                acyclic_set_spaces.push(set.clone());
608            }
609        }
610    }
611
612    let mut cycle_drops: Vec<(EntityId, EntityId, String)> = Vec::new();
613    for space in acyclic_rel_types
614        .iter()
615        .map(CycleSpace::Single)
616        .chain(acyclic_set_spaces.iter().map(CycleSpace::Set))
617    {
618        // Adjacency list scoped to this cycle space. Includes edges
619        // whose source mem's schema declares the space (the acyclic
620        // flag, or the set) — a mem whose schema doesn't declare it
621        // shouldn't have its edges dropped just because a sibling mem
622        // does. Each edge carries its rel-type for the drop record.
623        let mut adj: std::collections::HashMap<EntityId, Vec<(EntityId, String)>> =
624            std::collections::HashMap::new();
625        for entity in store.all_entities() {
626            let Some(schema) = schemas.get(entity.mem.as_str()) else {
627                continue;
628            };
629            let member = match &space {
630                CycleSpace::Single(r) => schema.relationship_acyclic(r),
631                CycleSpace::Set(s) => schema.has_acyclic_set(s),
632            };
633            if !member {
634                continue;
635            }
636            for edge in store.outgoing(&entity.id) {
637                let in_space = match &space {
638                    CycleSpace::Single(r) => edge.rel_type == **r,
639                    CycleSpace::Set(s) => s.iter().any(|n| n == &edge.rel_type),
640                };
641                if in_space {
642                    adj.entry(entity.id.clone())
643                        .or_default()
644                        .push((edge.target.clone(), edge.rel_type.clone()));
645                }
646            }
647        }
648
649        // Three-color DFS. Each entity is white initially. Push to
650        // gray on entry; demote to black on full descent. A gray
651        // child reached from a gray parent is a back-edge.
652        #[derive(Clone, Copy, PartialEq, Eq)]
653        enum Color {
654            White,
655            Gray,
656            Black,
657        }
658        let mut color: std::collections::HashMap<EntityId, Color> =
659            adj.keys().map(|k| (k.clone(), Color::White)).collect();
660        // Stable iteration order — sort the seeds so the dropped
661        // edge depends only on the workspace's id set, not on hash
662        // iteration order.
663        let mut seeds: Vec<EntityId> = adj.keys().cloned().collect();
664        seeds.sort_by(|a, b| a.as_ref().cmp(b.as_ref()));
665        let sort_targets = |v: &mut Vec<(EntityId, String)>| {
666            v.sort_by(|a, b| a.0.as_ref().cmp(b.0.as_ref()).then_with(|| a.1.cmp(&b.1)));
667        };
668        for seed in seeds {
669            if color.get(&seed).copied() != Some(Color::White) {
670                continue;
671            }
672            // Iterative DFS to avoid stack blow-ups on deep graphs.
673            // Stack entry: (node, sorted-adjacency-index, sorted-adjacency-snapshot).
674            type DfsFrame = (EntityId, usize, Vec<(EntityId, String)>);
675            let mut stack: Vec<DfsFrame> = Vec::new();
676            let mut start_targets: Vec<(EntityId, String)> =
677                adj.get(&seed).cloned().unwrap_or_default();
678            sort_targets(&mut start_targets);
679            color.insert(seed.clone(), Color::Gray);
680            stack.push((seed.clone(), 0, start_targets));
681            while let Some((node, idx, targets)) = stack.last_mut() {
682                if *idx >= targets.len() {
683                    let done = node.clone();
684                    color.insert(done, Color::Black);
685                    stack.pop();
686                    continue;
687                }
688                let (target, edge_rel) = targets[*idx].clone();
689                *idx += 1;
690                let node_id = node.clone();
691                match color.get(&target).copied() {
692                    Some(Color::White) => {
693                        let mut next_targets: Vec<(EntityId, String)> =
694                            adj.get(&target).cloned().unwrap_or_default();
695                        sort_targets(&mut next_targets);
696                        color.insert(target.clone(), Color::Gray);
697                        stack.push((target, 0, next_targets));
698                    }
699                    Some(Color::Gray) => {
700                        // Back-edge — closes a cycle. Drop it.
701                        cycle_drops.push((node_id, target, edge_rel));
702                    }
703                    Some(Color::Black) | None => {
704                        // Already fully explored or not in the
705                        // subgraph — no cycle through this edge.
706                    }
707                }
708            }
709        }
710    }
711    // An edge can sit in two spaces at once (its rel-type flagged
712    // acyclic AND inside a set) — dedupe so it is dropped and warned
713    // once.
714    {
715        let mut seen: std::collections::HashSet<(EntityId, EntityId, String)> =
716            std::collections::HashSet::new();
717        cycle_drops.retain(|d| seen.insert(d.clone()));
718    }
719
720    for (from_id, target, rel_type) in cycle_drops {
721        let origin = origin_for(from_id.mem()).to_string();
722        store.remove_edge(&from_id, &target, &rel_type);
723        if let Some(entity) = store.get_mut(&from_id) {
724            entity
725                .relationships
726                .retain(|r| !(r.rel_type == rel_type && r.target == target));
727        }
728        let recovery = if origin == "writable" {
729            Some(
730                crate::ops::ParsedRelationRecovery::remove_explicit_relation(
731                    from_id.clone(),
732                    target.clone(),
733                    rel_type.clone(),
734                ),
735            )
736        } else {
737            None
738        };
739        warnings.push(WarningHint::ParsedRelationInvalid {
740            entity_id: from_id,
741            rel_type,
742            target,
743            reason: "cycle".to_string(),
744            origin,
745            recovery,
746        });
747    }
748}
749
750/// Remap edge sources to reflect each source mem's
751/// `alias_target_rel_type` schema pointer: edges whose `rel_type`
752/// equals the pointer are flipped from `Explicit` to `BodyLink`.
753/// Idempotent — running it repeatedly produces the same result.
754///
755/// The discriminator is store-side only (no entity-side field). Under
756/// the schema-load coupling (Option C), the pointer rel-type is also
757/// `manual_authoring: forbidden`, so the only path to an edge of that
758/// rel-type is via the alias-synthesis pass — making this remap
759/// uniform across the workspace once the test sweep completes.
760///
761/// During the transitional window (synthesis pass landed but the 5
762/// built-ins not yet flipped to `manual_authoring: forbidden`),
763/// explicit `memstead_relate type=REFERENCES` still works for tests, and
764/// those edges will also be remapped to `BodyLink` here. The wire
765/// shape distinguishes synthesised vs. explicit only through this
766/// label, so the relabel is observable but harmless — no test
767/// asserts the legacy `"explicit"` string for REFERENCES.
768pub fn remap_alias_target_edge_sources(
769    store: &mut Store,
770    schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
771) {
772    let mut remaps: Vec<(EntityId, EntityId, String)> = Vec::new();
773    for entity in store.all_entities() {
774        let Some(schema) = schemas.get(entity.mem.as_str()) else {
775            continue;
776        };
777        let Some(pointer) = schema.alias_target_rel_type() else {
778            continue;
779        };
780        for edge in store.outgoing(&entity.id) {
781            if edge.rel_type == pointer && edge.source != EdgeSource::BodyLink {
782                remaps.push((
783                    entity.id.clone(),
784                    edge.target.clone(),
785                    edge.rel_type.clone(),
786                ));
787            }
788        }
789    }
790    for (from, to, rel_type) in remaps {
791        store.add_edge(
792            from,
793            Edge {
794                rel_type,
795                target: to,
796                source: EdgeSource::BodyLink,
797            },
798        );
799    }
800}
801
802/// Minimal placeholder entity for a wiki-link target that has no
803/// markdown file. Tagged `StubKind::LoadTime` — this constructor
804/// fires from parser-driven paths (boot, reload, attach) where the
805/// stub is auto-emitted from a wiki-link to a not-yet-present
806/// target. Mutation paths that need `ForwardReference` /
807/// `Residual` use the engine-internal `make_stub` in
808/// `engine/mutation/mod.rs` which takes an explicit kind.
809pub fn make_stub(id: EntityId) -> Entity {
810    Entity {
811        title: id.name().to_string(),
812        entity_type: String::new(),
813        mem: id.mem().to_string(),
814        file_path: String::new(),
815        metadata: IndexMap::new(),
816        sections: IndexMap::new(),
817        relationships: Vec::new(),
818        content_hash: String::new(),
819        stub: true,
820        stub_kind: Some(crate::entity::StubKind::LoadTime),
821        id,
822        heading_spans: std::collections::HashMap::new(),
823        raw_section_headings: Vec::new(),
824    }
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830    use crate::entity::Entity;
831    use memstead_schema::type_by_name;
832
833    fn default_fallback() -> std::sync::Arc<TypeDefinition> {
834        type_by_name("spec").expect("spec type must exist")
835    }
836
837    fn real_entity(id_str: &str, sections: &[(&str, &str)]) -> ParseResult {
838        let id = EntityId(id_str.to_string());
839        let mem = id.mem().to_string();
840        let mut sec = IndexMap::new();
841        for (k, v) in sections {
842            sec.insert(k.to_string(), v.to_string());
843        }
844        ParseResult {
845            entity: Entity {
846                title: id.name().to_string(),
847                entity_type: "spec".to_string(),
848                mem,
849                file_path: format!("{}.md", id.name()),
850                metadata: IndexMap::new(),
851                sections: sec,
852                relationships: Vec::new(),
853                content_hash: "deadbeef00000000".to_string(),
854                stub: false,
855                stub_kind: None,
856                id,
857                heading_spans: std::collections::HashMap::new(),
858                raw_section_headings: Vec::new(),
859            },
860            inline_links: Vec::new(),
861            parse_warnings: Vec::new(),
862        }
863    }
864
865    /// Plugin-mem entity with `[[plugin--foo]]` in a section and a
866    /// real `test-mem-plugin--foo` already in the store → warning
867    /// fires with a populated `candidate_target` (same-mem bare-slug
868    /// resolution, pass 2 of the two-pass resolver).
869    #[test]
870    fn nested_prefix_emits_warning_with_candidate() {
871        let fallback = default_fallback();
872        let mut store = Store::new();
873
874        let target = real_entity("test-mem-plugin--foo", &[]);
875        push_entities_into_store(&mut store, vec![target], &fallback, None);
876
877        let author = real_entity(
878            "test-mem-plugin--author",
879            &[("constraints", "See [[plugin--foo]] for details.")],
880        );
881        let mut warnings = Vec::new();
882        let mem_names = vec!["test-mem-plugin".to_string()];
883        let known_suffixes = vec!["plugin".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_eq!(warnings.len(), 1, "one nested-prefix warning expected");
896        match &warnings[0] {
897            WarningHint::SuspiciousNestedPrefix {
898                from,
899                resolved_id,
900                candidate_target,
901                section,
902                prefix_mounted,
903            } => {
904                assert_eq!(from.as_ref(), "test-mem-plugin--author");
905                // Tier-0 resolves `[[plugin--foo]]` to `plugin--foo`
906                // directly (not a phantom
907                // `test-mem-plugin--plugin--foo`).
908                assert_eq!(resolved_id.as_ref(), "plugin--foo");
909                assert_eq!(
910                    candidate_target.as_ref().map(|c| c.as_ref()),
911                    Some("test-mem-plugin--foo")
912                );
913                assert_eq!(section, "constraints");
914                // `plugin` is no roster member, only `test-mem-plugin`'s
915                // last segment: the rename-drift class.
916                assert!(!prefix_mounted, "a suffix-only prefix is not a mounted mem");
917                let msg = warnings[0].message();
918                assert!(
919                    msg.contains("prefix 'plugin' is not a mounted mem"),
920                    "the message names the class: {msg}"
921                );
922                assert!(
923                    !msg.contains("almost certainly"),
924                    "no guessed diagnosis: {msg}"
925                );
926            }
927            other => panic!("unexpected variant: {other:?}"),
928        }
929    }
930
931    /// #41 narrowing: a colon/dash cross-mem link whose target mem
932    /// is itself a full roster member is legitimate — no nested-prefix
933    /// warning, even though that mem name also appears as a known
934    /// suffix. This is the macos→engine false positive the heuristic
935    /// used to emit (the "did you mean" candidate equalled the resolved
936    /// target — self-contradicting).
937    #[test]
938    fn nested_prefix_skips_when_target_is_a_real_mem() {
939        let fallback = default_fallback();
940        let mut store = Store::new();
941
942        let target = real_entity("engine--foo", &[]);
943        push_entities_into_store(&mut store, vec![target], &fallback, None);
944
945        let author = real_entity(
946            "macos--author",
947            &[("constraints", "See [[engine--foo]] for details.")],
948        );
949        let mut warnings = Vec::new();
950        let mem_names = vec!["macos".to_string(), "engine".to_string()];
951        // `engine` is both a real mem AND its own last-segment suffix.
952        let known_suffixes = vec!["macos".to_string(), "engine".to_string()];
953        push_entities_into_store(
954            &mut store,
955            vec![author],
956            &fallback,
957            Some(LoadCollector {
958                warnings: &mut warnings,
959                known_suffixes: &known_suffixes,
960                mem_names: &mem_names,
961            }),
962        );
963
964        assert!(
965            warnings.is_empty(),
966            "a cross-mem link to a real mem must not warn: {warnings:?}"
967        );
968    }
969
970    /// Same scenario but the candidate is missing — the warning still
971    /// fires so the author sees drift, with `candidate_target: None`.
972    #[test]
973    fn nested_prefix_emits_warning_without_candidate() {
974        let fallback = default_fallback();
975        let mut store = Store::new();
976
977        let author = real_entity(
978            "test-mem-plugin--author",
979            &[("constraints", "[[plugin--ghost]]")],
980        );
981        let mut warnings = Vec::new();
982        let mem_names = vec!["test-mem-plugin".to_string()];
983        let known_suffixes = vec!["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
995        assert_eq!(warnings.len(), 1);
996        match &warnings[0] {
997            WarningHint::SuspiciousNestedPrefix {
998                candidate_target, ..
999            } => assert!(candidate_target.is_none()),
1000            other => panic!("unexpected variant: {other:?}"),
1001        }
1002    }
1003
1004    /// Bare-slug link (`[[foo]]`) resolves to `<current-mem>--foo` —
1005    /// no nested prefix, no warning.
1006    #[test]
1007    fn non_nested_link_no_warning() {
1008        let fallback = default_fallback();
1009        let mut store = Store::new();
1010
1011        let author = real_entity("test-mem-plugin--author", &[("constraints", "[[foo]]")]);
1012        let mut warnings = Vec::new();
1013        let mem_names = vec!["test-mem-plugin".to_string()];
1014        let known_suffixes = vec!["plugin".to_string()];
1015        push_entities_into_store(
1016            &mut store,
1017            vec![author],
1018            &fallback,
1019            Some(LoadCollector {
1020                warnings: &mut warnings,
1021                known_suffixes: &known_suffixes,
1022                mem_names: &mem_names,
1023            }),
1024        );
1025        assert!(warnings.is_empty());
1026    }
1027
1028    /// Fully-qualified cross-mem link resolves to a different mem's
1029    /// id, not `<current-mem>--<suffix>--...`, so no nested prefix.
1030    /// Note: `[[<mem>--slug]]` in the section body literally resolves
1031    /// via wiki_link_to_id to `<current>--<mem>--slug` (nested), so
1032    /// this pattern is ambiguous by construction — the detector fires
1033    /// with a candidate that points at the fully-qualified target.
1034    /// Callers should write the full id or bare slug, not
1035    /// `<mem>--slug` from outside that mem.
1036    #[test]
1037    fn cross_mem_qualified_fires_with_cross_mem_candidate() {
1038        let fallback = default_fallback();
1039        let mut store = Store::new();
1040
1041        // Real entity in the engine mem.
1042        let target = real_entity("test-mem-engine--health", &[]);
1043        push_entities_into_store(&mut store, vec![target], &fallback, None);
1044
1045        // Plugin-mem author writes `[[engine--health]]`.
1046        let author = real_entity(
1047            "test-mem-plugin--author",
1048            &[("purpose", "See [[engine--health]].")],
1049        );
1050        let mut warnings = Vec::new();
1051        let mem_names = vec!["test-mem-engine".to_string(), "test-mem-plugin".to_string()];
1052        let known_suffixes = vec!["engine".to_string(), "plugin".to_string()];
1053        push_entities_into_store(
1054            &mut store,
1055            vec![author],
1056            &fallback,
1057            Some(LoadCollector {
1058                warnings: &mut warnings,
1059                known_suffixes: &known_suffixes,
1060                mem_names: &mem_names,
1061            }),
1062        );
1063        assert_eq!(warnings.len(), 1);
1064        match &warnings[0] {
1065            WarningHint::SuspiciousNestedPrefix {
1066                candidate_target, ..
1067            } => {
1068                assert_eq!(
1069                    candidate_target.as_ref().map(|c| c.as_ref()),
1070                    Some("test-mem-engine--health"),
1071                    "cross-mem pass-1 must find the engine mem candidate"
1072                );
1073            }
1074            other => panic!("unexpected variant: {other:?}"),
1075        }
1076    }
1077
1078    /// A link whose prefix IS a mounted mem, with the target missing
1079    /// there: the warning still fires (nothing resolves) but names the
1080    /// class honestly, "target missing in mem engine", instead of
1081    /// calling a well-formed cross-mem reference rename drift. This is
1082    /// every one of the eight hits the dogfood graph carried. The
1083    /// complement: the same link with the target present is silent.
1084    #[test]
1085    fn mounted_prefix_with_missing_target_is_classed_target_missing() {
1086        let fallback = default_fallback();
1087        let author = || {
1088            real_entity(
1089                "plugin--author",
1090                &[("purpose", "Depends on [[engine--health]].")],
1091            )
1092        };
1093        let mem_names = vec!["engine".to_string(), "plugin".to_string()];
1094        let known_suffixes = vec!["engine".to_string(), "plugin".to_string()];
1095
1096        let mut store = Store::new();
1097        let mut warnings = Vec::new();
1098        push_entities_into_store(
1099            &mut store,
1100            vec![author()],
1101            &fallback,
1102            Some(LoadCollector {
1103                warnings: &mut warnings,
1104                known_suffixes: &known_suffixes,
1105                mem_names: &mem_names,
1106            }),
1107        );
1108        assert_eq!(warnings.len(), 1, "a missing cross-mem target fires once");
1109        match &warnings[0] {
1110            WarningHint::SuspiciousNestedPrefix {
1111                resolved_id,
1112                prefix_mounted,
1113                candidate_target,
1114                ..
1115            } => {
1116                assert_eq!(resolved_id.as_ref(), "engine--health");
1117                assert!(*prefix_mounted, "`engine` is a roster member");
1118                assert!(
1119                    candidate_target.is_none(),
1120                    "nothing to suggest: the target is absent"
1121                );
1122            }
1123            other => panic!("unexpected variant: {other:?}"),
1124        }
1125        let msg = warnings[0].message();
1126        assert!(
1127            msg.contains("target missing in mem engine"),
1128            "the message says what it can tell: {msg}"
1129        );
1130        assert!(
1131            !msg.contains("rename"),
1132            "a mounted prefix is never called rename drift: {msg}"
1133        );
1134        let json = serde_json::to_value(&warnings[0]).unwrap();
1135        assert_eq!(json["details"]["target_mem"], "engine");
1136        assert_eq!(json["details"]["prefix_mounted"], true);
1137
1138        // Complement: target present, no warning at all.
1139        let mut store2 = Store::new();
1140        push_entities_into_store(
1141            &mut store2,
1142            vec![real_entity("engine--health", &[])],
1143            &fallback,
1144            None,
1145        );
1146        let mut warnings2 = Vec::new();
1147        push_entities_into_store(
1148            &mut store2,
1149            vec![author()],
1150            &fallback,
1151            Some(LoadCollector {
1152                warnings: &mut warnings2,
1153                known_suffixes: &known_suffixes,
1154                mem_names: &mem_names,
1155            }),
1156        );
1157        assert!(
1158            warnings2.is_empty(),
1159            "a well-formed link to an existing target is silent: {warnings2:?}"
1160        );
1161    }
1162
1163    /// Two mems sharing a last-segment suffix: both contribute to the
1164    /// known-suffix set, the warning fires, and `candidate_target` is
1165    /// the one that has a real entity. Locks the suffix-collision
1166    /// resolution semantics.
1167    #[test]
1168    fn suffix_collision_resolves_first_match() {
1169        let fallback = default_fallback();
1170        let mut store = Store::new();
1171
1172        // Mem A = `alpha`, Mem B = `beta-alpha`, both have suffix "alpha".
1173        // Real entity lives in `beta-alpha--target`.
1174        let target = real_entity("beta-alpha--target", &[]);
1175        push_entities_into_store(&mut store, vec![target], &fallback, None);
1176
1177        // An author in `beta-alpha` writes `[[alpha--target]]`.
1178        let author = real_entity("beta-alpha--author", &[("purpose", "[[alpha--target]]")]);
1179        let mut warnings = Vec::new();
1180        let mem_names = vec!["alpha".to_string(), "beta-alpha".to_string()];
1181        let known_suffixes = vec!["alpha".to_string(), "alpha".to_string()]; // collision
1182        push_entities_into_store(
1183            &mut store,
1184            vec![author],
1185            &fallback,
1186            Some(LoadCollector {
1187                warnings: &mut warnings,
1188                known_suffixes: &known_suffixes,
1189                mem_names: &mem_names,
1190            }),
1191        );
1192        assert_eq!(
1193            warnings.len(),
1194            1,
1195            "collision must not duplicate the warning"
1196        );
1197        match &warnings[0] {
1198            WarningHint::SuspiciousNestedPrefix {
1199                candidate_target, ..
1200            } => {
1201                // Pass 1 cross-mem probe excludes `beta-alpha` (self),
1202                // probes `alpha` — no real entity there, so pass 2
1203                // falls back to same-mem bare-slug `beta-alpha--target`
1204                // which is real.
1205                assert_eq!(
1206                    candidate_target.as_ref().map(|c| c.as_ref()),
1207                    Some("beta-alpha--target")
1208                );
1209            }
1210            other => panic!("unexpected variant: {other:?}"),
1211        }
1212    }
1213}