Skip to main content

memstead_base/ingest/
cursor.rs

1//! Source-cursor driver — assemble a [`SourceCursor`] from live workspace
2//! state, so the brief's changed-slice preface can steer a pass at what moved.
3//!
4//! Engine-side port of the plugin's `computeSourceCursor` (`inject.mjs`). For
5//! each of a binding's source facets it resolves the change-detection
6//! strategy, reads the durable baseline from the **destination** mem's
7//! `sync_state` (keyed `"<binding-id>/<facet-or-refmem>#synced"`, D4), computes
8//! the changed slice against the source's current state, and unions the
9//! per-facet slices.
10//!
11//! Strategies:
12//!   - **git** — diff the stored commit id against the source tree's current
13//!     `HEAD` (subprocess `git rev-parse` / `git diff --name-status`), with
14//!     the facet scope + ingest `deny_paths` pushed down as `:(glob)` /
15//!     `:(glob,exclude)` pathspecs.
16//!   - **graph** — diff the source mem's snapshot token via the engine's own
17//!     [`Engine::changes_since`]; reference mems are graph-detected too.
18//!   - **mtime** — enumerate the facet's files (minus the facet scope's own
19//!     denies *and* the ingest `deny_paths`, applied identically to the git
20//!     strategy's exclude pathspecs — see [`enumerate_facet_files`]), compute a
21//!     stat-map digest, memoise it under `.memstead.cache/ingest/source-cursor/`,
22//!     and diff the current digest against the memoised baseline via the pure
23//!     [`super::slice::mtime_slice_outcome`] core (precise, incl. deletions).
24//!
25//! **Deny invariance.** Ingest `deny_paths` are enforced identically by every
26//! strategy that reads a file tree — git, mtime, and refinement's enumeration,
27//! plus both token computations (`current_primary_token` / [`source_moved`]).
28//! A file matching a `deny_paths` entry appears in no changed slice, no
29//! refinement batch, and never influences the mtime digest or the
30//! `source_moved` token. The **graph** strategy is exempt *by definition*:
31//! `deny_paths` entries are file-path globs, but a graph source's artifacts are
32//! entities (entity-granular), so a file-path glob can never select one. This
33//! exemption is designed, not an omission.
34//!
35//! **One deny dialect.** A `deny_paths` entry is a **workspace-relative glob**
36//! — the exact grammar and resolution root as a facet-scope entry, resolved by
37//! the same [`build_glob_set`] / `:(glob,exclude)` machinery. The plugin's
38//! PreToolUse deny hook enforces the *identical* dialect against the ingest
39//! agent's Read/Glob/Grep by asking the engine itself: `projection
40//! check-path` answers through [`super::check_path::check_deny_paths`], which
41//! reads the active binding's record fresh on every call (the pointer channel
42//! is [`super::check_path::write_active_binding_file`], published on
43//! consuming brief renders). A deny entry that selects **no file** in
44//! the project tree is surfaced as a rendered brief warning
45//! ([`SourceCursor::dead_denies`]) rather than silently no-op'ing — catching
46//! typos and un-migrated legacy bare names, never a hard error.
47//!
48//! **One empty-scope semantic.** A facet with **no allow patterns** is
49//! *unscoped* — and that is a **typed refusal**, identical on every file-tree
50//! strategy: git, mtime, and refinement all decline to diff or enumerate the
51//! whole medium (a `facet_unscoped` check gates it). No strategy silently emits an
52//! empty slice, enumeration, or batch for an unscoped facet; instead the source
53//! contributes [`NoSignalReason::Unscoped`], which renders in the brief. A
54//! facet that genuinely wants the whole medium writes `**/*`. This is a
55//! different field from the ingest's `deny_paths`: an **empty `deny_paths`**
56//! list is valid and means "no denies" — it never trips the unscoped refusal.
57//!
58//! **Visible no-signal.** Every source contributes a per-source outcome. A
59//! genuinely-unchanged source (baseline present, nothing moved) stays silent —
60//! the only documented silence, preserving the "brief is byte-identical to a
61//! plain roam when nothing moved" property. Every other no-signal condition —
62//! unscoped facet, `signal:none`, git failure / unknown baseline, missing graph
63//! snapshot — is collected as a [`NoSignalNote`] and rendered distinguishably.
64//!
65//! Load-bearing invariant: the new baseline `token` is only *collected* here
66//! (into `write_commands` / `reseed`); it is recorded by the engine's
67//! `set_mem_sync_state` writer when `projection advance` completes a full pass
68//! (D7). The driver never writes it.
69
70use std::collections::BTreeMap;
71use std::path::{Component, Path, PathBuf};
72use std::process::Command;
73
74use globset::{Glob, GlobSet, GlobSetBuilder};
75
76use crate::Engine;
77use crate::pipeline::{MediumType, PatternMode};
78
79use super::brief::{NoSignalNote, SourceCursor, SyncCommand};
80use super::change_detection::{
81    StatMap, compute_stat_map, digest_stat_map, parse_digest_token, serialize_digest_token,
82};
83use super::resolve::{
84    ChangeStrategy, ResolvedIngest, ResolvedSource, find_git_root, resolve_change_strategy,
85};
86use super::slice::{
87    NoSignalReason, Slice, SliceOutcome, graph_slice_outcome, is_git_token, mtime_slice_outcome,
88};
89use crate::pipeline::Source;
90
91/// Lexically normalize a path — resolve `.` and `..` without touching the
92/// filesystem (no symlink resolution), matching Node's `path.resolve` on an
93/// already-absolute path.
94pub(super) fn normalize_lexical(path: &Path) -> PathBuf {
95    let mut out: Vec<Component> = Vec::new();
96    for comp in path.components() {
97        match comp {
98            Component::CurDir => {}
99            Component::ParentDir => match out.last() {
100                Some(Component::Normal(_)) => {
101                    out.pop();
102                }
103                Some(Component::RootDir | Component::Prefix(_)) => {}
104                _ => out.push(comp),
105            },
106            other => out.push(other),
107        }
108    }
109    out.iter().collect()
110}
111
112/// The relative path from `from` to `to` (both normalized), matching Node's
113/// `path.relative`.
114pub(super) fn relative_path(from: &Path, to: &Path) -> PathBuf {
115    let from = normalize_lexical(from);
116    let to = normalize_lexical(to);
117    let from_comps: Vec<Component> = from.components().collect();
118    let to_comps: Vec<Component> = to.components().collect();
119    let mut common = 0;
120    while common < from_comps.len()
121        && common < to_comps.len()
122        && from_comps[common] == to_comps[common]
123    {
124        common += 1;
125    }
126    let mut result = PathBuf::new();
127    for _ in common..from_comps.len() {
128        result.push("..");
129    }
130    for comp in &to_comps[common..] {
131        result.push(comp.as_os_str());
132    }
133    result
134}
135
136/// The medium pointer resolved to an absolute base directory. Public
137/// so init-time surfaces (CLI `projection init`) can resolve a medium
138/// base exactly as the strategies do — e.g. to warn when it falls
139/// outside the workspace root.
140pub fn medium_base(pointer: &str, workspace_root: &Path) -> PathBuf {
141    if pointer.is_empty() {
142        workspace_root.to_path_buf()
143    } else {
144        normalize_lexical(&workspace_root.join(pointer))
145    }
146}
147
148/// The relative path from `from` to `to`, lexically normalized — public so a
149/// caller holding two absolute paths (a workspace root and a source tree, say)
150/// can express one as a medium pointer against the other, the exact inverse of
151/// [`medium_base`].
152pub fn relative_to(from: &Path, to: &Path) -> PathBuf {
153    relative_path(from, to)
154}
155
156/// The honest caveat for a medium base that resolves outside the workspace
157/// root, or `None` when it does not — the single wording every front door
158/// that scaffolds a binding prints, so the layout split is named once, at the
159/// layout decision, in the same terms everywhere.
160///
161/// The shape is supported: enumeration, change detection, sync, and anchor
162/// resolution all work on it (measured on the dogfood's own out-of-root
163/// bindings, where zero anchors orphan). What degrades rides the message —
164/// `../…` artifact ids and a layout that must stay fixed — together with the
165/// recipe that avoids it. Only path-namespace mediums can be out-of-root;
166/// every other medium type yields `None`.
167pub fn out_of_root_layout_warning(
168    pointer: &str,
169    workspace_root: &Path,
170    medium_type: crate::pipeline::MediumType,
171) -> Option<String> {
172    use crate::pipeline::MediumType;
173    if !matches!(medium_type, MediumType::Codebase | MediumType::Filesystem) {
174        return None;
175    }
176    let base = medium_base(pointer, workspace_root);
177    // Canonicalize both sides when possible so symlinked roots (macOS /tmp)
178    // don't false-positive; fall back to the lexical forms for not-yet-existing
179    // paths.
180    let canon_base = std::fs::canonicalize(&base).unwrap_or(base);
181    let canon_root =
182        std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
183    if canon_base.starts_with(&canon_root) {
184        return None;
185    }
186    Some(format!(
187        "medium base '{}' resolves outside the workspace root '{}': supported — \
188         enumeration, change detection, and anchor resolution all work on this shape — \
189         but artifact ids render as workspace-relative '../…' chains and the \
190         workspace-to-source relative layout must stay fixed (moving either side \
191         breaks the pointer). To avoid the '../…' ids, root the workspace at the \
192         common parent directory containing every source tree.",
193        canon_base.display(),
194        canon_root.display()
195    ))
196}
197
198/// Workspace-relative deny globs excluding the engine's own state from
199/// every strategy's input set. Unconditional and non-configurable: a
200/// binding can never legitimately model `.memstead/`,
201/// `.memstead.cache/`, or a mount's resolved storage location as
202/// source artifacts — an allow glob covering them does not admit them.
203/// The dot-directories key on their *names* (the names are the
204/// contract, and a foreign workspace's `.memstead/` is still engine
205/// state); the mount storage locations key on their *resolved* paths
206/// because their directory names are configurable. Fail-open on an
207/// unreadable mount list: the name-based excludes stay in force.
208fn engine_state_denies(workspace_root: &Path) -> Vec<String> {
209    use crate::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
210
211    let mut denies: Vec<String> = vec![
212        ".memstead/**".to_string(),
213        ".memstead.cache/**".to_string(),
214        "**/.memstead/**".to_string(),
215        "**/.memstead.cache/**".to_string(),
216    ];
217    if let Ok(ws) = FileWorkspaceStore.load(workspace_root) {
218        for mount in &ws.mounts {
219            let dir: Option<PathBuf> = match &mount.storage {
220                crate::workspace::MountStorage::GitBranch { gitdir, .. } => {
221                    gitdir.parent().map(Path::to_path_buf)
222                }
223                crate::workspace::MountStorage::Folder { path } => Some(path.clone()),
224                crate::workspace::MountStorage::Archive { path, .. } => {
225                    // A sealed archive is one file, not a tree.
226                    let rel = relative_path(workspace_root, &normalize_lexical(path));
227                    denies.push(rel.to_string_lossy().to_string());
228                    None
229                }
230                // No on-disk footprint to exclude.
231                crate::workspace::MountStorage::InMemory => None,
232            };
233            if let Some(dir) = dir {
234                let rel = relative_path(workspace_root, &normalize_lexical(&dir));
235                // A collapsed single-mem folder workspace stores the mem
236                // AT the workspace root — excluding `**` there would
237                // empty every denominator; skip it.
238                if !rel.as_os_str().is_empty() {
239                    denies.push(format!("{}/**", rel.to_string_lossy()));
240                }
241            }
242        }
243    }
244    denies
245}
246
247/// Whether `sha` names a commit that exists in the repo at `git_root`.
248/// `git cat-file -e <sha>^{commit}` — exit 0 iff present and a commit.
249fn commit_exists(git_root: &Path, sha: &str) -> bool {
250    Command::new("git")
251        .args(["cat-file", "-e", &format!("{sha}^{{commit}}")])
252        .current_dir(git_root)
253        .output()
254        .map(|o| o.status.success())
255        .unwrap_or(false)
256}
257
258/// `git rev-parse HEAD` in `git_root`, or `None` on any failure.
259fn git_head(git_root: &Path) -> Option<String> {
260    let out = Command::new("git")
261        .args(["rev-parse", "HEAD"])
262        .current_dir(git_root)
263        .output()
264        .ok()?;
265    if !out.status.success() {
266        return None;
267    }
268    let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
269    (!sha.is_empty()).then_some(sha)
270}
271
272/// Translate a workspace-relative facet pattern into a git pathspec relative
273/// to `git_root`, with `:(glob)` magic (or `:(glob,exclude)` for a deny).
274///
275/// A `**`-prefixed pattern is prefix-free — it matches under any directory,
276/// in particular the medium subtree — so it is emitted verbatim as a
277/// git-root-relative glob. Lexically re-rooting it (join + relativize) would
278/// produce `../**/…` for any non-root medium pointer, and git *fatals* on an
279/// out-of-tree pathspec, sinking the whole diff into a no-signal degrade.
280fn to_git_pathspec(pattern: &str, git_root: &Path, workspace_root: &Path, exclude: bool) -> String {
281    let magic = if exclude {
282        ":(glob,exclude)"
283    } else {
284        ":(glob)"
285    };
286    if pattern.starts_with("**") {
287        return format!("{magic}{pattern}");
288    }
289    let resolved = normalize_lexical(&workspace_root.join(pattern));
290    let git_rel = relative_path(git_root, &resolved);
291    format!("{magic}{}", git_rel.to_string_lossy())
292}
293
294/// Like [`to_git_pathspec`], but `None` when the pattern resolves *outside*
295/// `git_root` (its git-relative path escapes with a leading `..`). Git fatals
296/// on an out-of-tree pathspec, so a cross-repo deny must be dropped from the
297/// diff rather than pushed — it can match nothing in this repo regardless.
298fn in_repo_pathspec(
299    pattern: &str,
300    git_root: &Path,
301    workspace_root: &Path,
302    exclude: bool,
303) -> Option<String> {
304    // Prefix-free glob — same verbatim re-anchoring as `to_git_pathspec`.
305    if pattern.starts_with("**") {
306        return Some(to_git_pathspec(pattern, git_root, workspace_root, exclude));
307    }
308    let resolved = normalize_lexical(&workspace_root.join(pattern));
309    let git_rel = relative_path(git_root, &resolved);
310    if git_rel
311        .components()
312        .next()
313        .is_some_and(|c| c == Component::ParentDir)
314    {
315        return None;
316    }
317    let magic = if exclude {
318        ":(glob,exclude)"
319    } else {
320        ":(glob)"
321    };
322    Some(format!("{magic}{}", git_rel.to_string_lossy()))
323}
324
325/// Build a [`GlobSet`] from workspace-relative glob patterns, or `None` if
326/// any pattern is malformed.
327fn build_glob_set(patterns: &[&str]) -> Option<GlobSet> {
328    let mut builder = GlobSetBuilder::new();
329    for pattern in patterns {
330        builder.add(Glob::new(pattern).ok()?);
331    }
332    builder.build().ok()
333}
334
335/// Whether a primary source's facet declares **no allow patterns** — an
336/// *unscoped* facet. This is the single condition behind the uniform
337/// empty-scope refusal ([`NoSignalReason::Unscoped`]): neither git nor mtime
338/// diffs or enumerates the whole medium for such a facet, and refinement emits
339/// no batch for it. It is orthogonal to the ingest's `deny_paths` — an empty
340/// deny list is not an unscoped facet.
341fn facet_unscoped(source: &Source) -> bool {
342    !source.scope.iter().any(|r| r.mode == PatternMode::Allow)
343}
344
345/// Enumerate the workspace-relative file paths a primary source's facet scope
346/// selects — the `mtime` strategy's input set. Mirrors the plugin's
347/// `enumerateFacetFiles`: the path-shaped mediums (`codebase` / `filesystem` /
348/// `git` — a git source's artifacts are paths pinned at a commit, so the walk
349/// is identical and only the anchor namespace differs); the facet's
350/// allow globs minus its deny globs, evaluated over the medium's directory
351/// tree. Returns a sorted, de-duplicated list. An unscoped facet (no allows)
352/// yields an empty list here — but callers must not treat that as signal: the
353/// strategy layer (`compute_mtime_slice` / `current_primary_token`) refuses
354/// an unscoped facet via `facet_unscoped` *before* enumerating, so the empty
355/// list is only ever reached for a genuinely-empty scoped enumeration.
356///
357/// `deny_paths` are the ingest-level denies (`ResolvedIngest::deny_paths`),
358/// applied on top of the facet's own scope denies with the *same*
359/// workspace-relative glob grammar the git strategy pushes down as
360/// `:(glob,exclude)` pathspecs — so a denied file is excluded from the mtime
361/// input set exactly as it is from the git diff. Passing `&[]` yields the
362/// facet-scope-only behaviour.
363pub fn enumerate_facet_files(
364    source: &Source,
365    deny_paths: &[String],
366    workspace_root: &Path,
367) -> Vec<String> {
368    if !matches!(
369        source.medium_type,
370        MediumType::Codebase | MediumType::Filesystem | MediumType::Git
371    ) {
372        return Vec::new();
373    }
374    let mut allows: Vec<&str> = Vec::new();
375    let mut denies: Vec<&str> = Vec::new();
376    for rule in &source.scope {
377        match rule.mode {
378            PatternMode::Allow => allows.push(&rule.path),
379            PatternMode::Deny => denies.push(&rule.path),
380        }
381    }
382    // Ingest deny_paths deny on top of the facet's own denies, sharing the
383    // facet-scope glob grammar (workspace-relative, matched against each
384    // candidate's workspace-relative path) — the same entries the git strategy
385    // resolves as exclude pathspecs, so deny enforcement is strategy-invariant.
386    for dp in deny_paths {
387        denies.push(dp);
388    }
389    // Engine self-exclusion — unconditional, below configuration; the
390    // git strategy pushes the same set as exclude pathspecs so the
391    // denominator stays strategy-invariant.
392    let forced = engine_state_denies(workspace_root);
393    for f in &forced {
394        denies.push(f);
395    }
396    if allows.is_empty() {
397        return Vec::new();
398    }
399    let Some(allow_set) = build_glob_set(&allows) else {
400        return Vec::new();
401    };
402    let deny_set = if denies.is_empty() {
403        None
404    } else {
405        build_glob_set(&denies)
406    };
407
408    // Walk the medium's directory tree; the facet patterns are
409    // workspace-relative, so each candidate is matched by its
410    // workspace-relative path. VCS internals are never source artifacts —
411    // they are pruned here so `.git/**` plumbing cannot enter `S(D)`,
412    // matching the git strategy (whose diffs never name `.git` files).
413    let base = medium_base(&source.pointer, workspace_root);
414    let mut out: Vec<String> = Vec::new();
415    let mut stack = vec![base];
416    while let Some(dir) = stack.pop() {
417        let Ok(entries) = std::fs::read_dir(&dir) else {
418            continue;
419        };
420        for entry in entries.flatten() {
421            let Ok(file_type) = entry.file_type() else {
422                continue;
423            };
424            let path = entry.path();
425            if file_type.is_dir() {
426                let skip = path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
427                    // VCS internals and engine state are never source
428                    // artifacts — pruning here saves the walk; the
429                    // forced deny globs enforce the same exclusion for
430                    // anything that still slips into a candidate list.
431                    VCS_INTERNAL_DIRS.contains(&n) || n == ".memstead" || n == ".memstead.cache"
432                });
433                if !skip {
434                    stack.push(path);
435                }
436            } else if file_type.is_file() {
437                let rel = relative_path(workspace_root, &normalize_lexical(&path))
438                    .to_string_lossy()
439                    .to_string();
440                let denied = deny_set.as_ref().is_some_and(|d| d.is_match(&rel));
441                if allow_set.is_match(&rel) && !denied {
442                    out.push(rel);
443                }
444            }
445        }
446    }
447    out.sort();
448    out.dedup();
449    out
450}
451
452/// Compute the git changed slice for one primary source between its stored
453/// baseline commit and the tree's current `HEAD`. Mirrors `computeGitSlice`.
454fn compute_git_slice(
455    source: &Source,
456    deny_paths: &[String],
457    workspace_root: &Path,
458    baseline: Option<&str>,
459) -> SliceOutcome {
460    let base = medium_base(&source.pointer, workspace_root);
461    let Some(git_root) = find_git_root(&base) else {
462        return SliceOutcome::NoSignal {
463            reason: NoSignalReason::GitUnavailable,
464        };
465    };
466    let Some(head) = git_head(&git_root) else {
467        return SliceOutcome::NoSignal {
468            reason: NoSignalReason::GitUnavailable,
469        };
470    };
471
472    let baseline = match baseline {
473        Some(b) if is_git_token(b) => b,
474        // No usable commit baseline — seed at HEAD, present no slice.
475        _ => return SliceOutcome::Reseed { token: head },
476    };
477    if baseline == head {
478        return SliceOutcome::Unchanged { token: head };
479    }
480    // A git-shaped baseline that THIS repo does not contain is not a usable
481    // baseline either — it is foreign (seeded when the pointer resolved to a
482    // different repo, e.g. before a source tree moved into a submodule) or
483    // gone (gc'd / rewritten away). Diffing against it fatals, which used to
484    // degrade every pass into `GitUnavailable` — a baseline that never seats
485    // and a binding that never backs off. Reseed at HEAD instead: one honest
486    // full re-roam, then normal change detection. `GitUnavailable` below is
487    // reserved for transient git failures on a baseline that does exist.
488    if !commit_exists(&git_root, baseline) {
489        return SliceOutcome::Reseed { token: head };
490    }
491
492    // Pathspecs from the facet scope + the ingest's deny_paths.
493    let mut allows: Vec<&str> = Vec::new();
494    let mut denies: Vec<&str> = Vec::new();
495    for rule in &source.scope {
496        match rule.mode {
497            PatternMode::Allow => allows.push(&rule.path),
498            PatternMode::Deny => denies.push(&rule.path),
499        }
500    }
501    if allows.is_empty() {
502        // Unscoped facet — the uniform typed refusal (never diff the whole
503        // repo); renders in the brief rather than degrading silently.
504        return SliceOutcome::NoSignal {
505            reason: NoSignalReason::Unscoped,
506        };
507    }
508    for dp in deny_paths {
509        denies.push(dp);
510    }
511    // Engine self-exclusion — same forced set the mtime strategy's
512    // enumeration applies, pushed down as exclude pathspecs so the
513    // slice never names engine state either.
514    let forced = engine_state_denies(workspace_root);
515    for f in &forced {
516        denies.push(f);
517    }
518    let mut specs: Vec<String> = Vec::with_capacity(allows.len() + denies.len());
519    for a in &allows {
520        specs.push(to_git_pathspec(a, &git_root, workspace_root, false));
521    }
522    for d in &denies {
523        // A deny may target a path OUTSIDE this medium's git repo — a
524        // cross-medium workspace-relative glob such as `../dev/**`, whose tree
525        // lives in a sibling repo. Git *fatals* on an out-of-tree pathspec
526        // (`'../dev/**' is outside repository`), which would sink the entire
527        // diff into a no-signal degrade. Such a deny can exclude nothing here
528        // anyway (the files simply aren't in this repo), so drop it: the plugin
529        // hook still enforces it agent-side (workspace-relative, cross-repo),
530        // and a genuinely-dead entry is still surfaced by the brief warning.
531        if let Some(spec) = in_repo_pathspec(d, &git_root, workspace_root, true) {
532            specs.push(spec);
533        }
534    }
535
536    let mut cmd = Command::new("git");
537    cmd.args([
538        "diff",
539        "--no-renames",
540        "--name-status",
541        baseline,
542        &head,
543        "--",
544    ]);
545    cmd.args(&specs);
546    cmd.current_dir(&git_root);
547    let out = match cmd.output() {
548        Ok(o) if o.status.success() => o,
549        // Unknown baseline (gc'd / rewritten), an out-of-repo pathspec, or a
550        // git failure — degrade to a whole re-roam (the plugin does the same).
551        _ => {
552            return SliceOutcome::NoSignal {
553                reason: NoSignalReason::GitUnavailable,
554            };
555        }
556    };
557    let text = String::from_utf8_lossy(&out.stdout);
558
559    let mut slice = Slice::default();
560    for line in text.lines() {
561        if line.trim().is_empty() {
562            continue;
563        }
564        let Some(tab) = line.find('\t') else { continue };
565        let status = line[..tab].trim();
566        let git_path = line[tab + 1..].trim();
567        let ws_path = relative_path(workspace_root, &normalize_lexical(&git_root.join(git_path)))
568            .to_string_lossy()
569            .to_string();
570        match status.chars().next() {
571            Some('A') => slice.added.push(ws_path),
572            Some('D') => slice.deleted.push(ws_path),
573            // M, T (type change), C, and the rest.
574            _ => slice.modified.push(ws_path),
575        }
576    }
577    slice.added.sort();
578    slice.modified.sort();
579    slice.deleted.sort();
580    SliceOutcome::Changed {
581        token: head,
582        slice,
583        degraded: false,
584    }
585}
586
587/// One parsed entry of a **graph** facet's scope — the entity-namespace
588/// counterpart of a path glob. A graph source selects entities, and an entity
589/// is not a path: matching id-shaped globs against `mem--slug` invites the
590/// "looks scoped, selects nothing" failure the dead-deny lint exists to catch
591/// on paths, so the vocabulary is explicit about which axis it selects on.
592///
593/// Grammar (the whole of it):
594///
595/// - `*` — every entity in the source mem
596/// - `type:<entity_type>` — entities of exactly that type
597/// - `id:<glob>` — entities whose full `mem--slug` id matches the glob
598///
599/// Anything else is refused at binding validation
600/// ([`crate::binding::validate_binding`]) rather than silently selecting
601/// nothing: a scope nothing interprets is the defect, not a permissible form.
602#[derive(Debug, Clone, PartialEq, Eq)]
603pub enum EntitySelector {
604    /// `*` — every entity in the mem.
605    All,
606    /// `type:<entity_type>` — exact type match.
607    Type(String),
608    /// `id:<glob>` — glob over the full entity id.
609    Id(String),
610}
611
612/// Parse one graph scope pattern. `None` for an unrecognised form — the
613/// caller decides whether that is a validation refusal (declaration time) or
614/// a skipped rule (run time, already refused at declaration).
615pub fn parse_entity_selector(pattern: &str) -> Option<EntitySelector> {
616    let pattern = pattern.trim();
617    if pattern == "*" {
618        return Some(EntitySelector::All);
619    }
620    if let Some(rest) = pattern.strip_prefix("type:") {
621        let rest = rest.trim();
622        if rest.is_empty() {
623            return None;
624        }
625        return Some(EntitySelector::Type(rest.to_string()));
626    }
627    if let Some(rest) = pattern.strip_prefix("id:") {
628        let rest = rest.trim();
629        if rest.is_empty() {
630            return None;
631        }
632        // A malformed glob is a refusal, not a rule that matches nothing.
633        Glob::new(rest).ok()?;
634        return Some(EntitySelector::Id(rest.to_string()));
635    }
636    None
637}
638
639/// Does `selector` select this entity?
640fn selector_matches(selector: &EntitySelector, id: &str, entity_type: &str) -> bool {
641    match selector {
642        EntitySelector::All => true,
643        EntitySelector::Type(t) => entity_type == t,
644        EntitySelector::Id(g) => Glob::new(g)
645            .ok()
646            .map(|glob| glob.compile_matcher().is_match(id))
647            .unwrap_or(false),
648    }
649}
650
651/// Enumerate the entity ids a **graph** source's facet scope selects — the
652/// graph medium's `S(D)`, the exact counterpart of [`enumerate_facet_files`]
653/// for a path medium. The source's `pointer` names the source mem; the store
654/// already holds every mounted mem's entities, so this is a filter over
655/// memory rather than any kind of walk.
656///
657/// Stubs are excluded: a stub is a placeholder the engine created for an
658/// unresolved reference, not an authored source artifact. Counting them would
659/// inflate the denominator with entities the source never wrote, making
660/// coverage look worse than it is for a reason no author can act on.
661///
662/// An unscoped facet (no allow rules) yields an empty list here — callers must
663/// not read that as "nothing in scope"; the strategy layer refuses an unscoped
664/// facet before reaching this, exactly as it does for the path mediums.
665pub fn enumerate_graph_entities(engine: &Engine, source: &Source) -> Vec<String> {
666    if source.medium_type != MediumType::Graph {
667        return Vec::new();
668    }
669    let mut allows: Vec<EntitySelector> = Vec::new();
670    let mut denies: Vec<EntitySelector> = Vec::new();
671    for rule in &source.scope {
672        // An unparseable rule is already a validation refusal; at run time it
673        // selects nothing rather than everything — a scope the engine cannot
674        // read must never widen reach.
675        let Some(sel) = parse_entity_selector(&rule.path) else {
676            continue;
677        };
678        match rule.mode {
679            PatternMode::Allow => allows.push(sel),
680            PatternMode::Deny => denies.push(sel),
681        }
682    }
683    if allows.is_empty() {
684        return Vec::new();
685    }
686    let mem = source.pointer.as_str();
687    let mut out: Vec<String> = Vec::new();
688    for entity in engine.store().all_entities() {
689        if entity.mem != mem || entity.stub {
690            continue;
691        }
692        let id = entity.id.0.as_str();
693        let ty = entity.entity_type.as_str();
694        if !allows.iter().any(|s| selector_matches(s, id, ty)) {
695            continue;
696        }
697        if denies.iter().any(|s| selector_matches(s, id, ty)) {
698            continue;
699        }
700        out.push(id.to_string());
701    }
702    out.sort();
703    out.dedup();
704    out
705}
706
707/// Enumerate one primary source's in-scope artifacts, whatever its medium —
708/// the single entry point every `S(D)` consumer uses. Path-shaped mediums
709/// (codebase / filesystem / git) walk the file tree; a graph source filters
710/// the source mem's entities. A medium the matrix marks non-enumerable
711/// yields nothing, and its callers render the non-enumerable basis rather
712/// than a denominator.
713///
714/// This exists because the enumeration bail was never in one place: five call
715/// sites each repeated the same loop over `enumerate_facet_files`, so teaching
716/// only the report about a new medium left the findings store, the refinement
717/// rotation, and the exclude membership gate empty-handed.
718pub fn enumerate_source_artifacts(
719    engine: &Engine,
720    source: &Source,
721    deny_paths: &[String],
722    workspace_root: &Path,
723) -> Vec<String> {
724    match source.medium_type {
725        MediumType::Codebase | MediumType::Filesystem | MediumType::Git => {
726            enumerate_facet_files(source, deny_paths, workspace_root)
727        }
728        MediumType::Graph => enumerate_graph_entities(engine, source),
729        MediumType::Web => Vec::new(),
730    }
731}
732
733/// Compute the graph changed slice for a source mem between its stored
734/// baseline snapshot token and the mem's current head. Mirrors
735/// `computeGraphSlice`, using the engine's own change history.
736/// Restrict a graph changed slice to the facet's scope. Without this the
737/// selector was honoured by enumeration and ignored by change detection, so a
738/// brief could print `Entities: type:concept` and then hand the agent a
739/// changed `memo` two sections below — an artifact its own coverage model
740/// says is out of scope, which `advance` would then accept because its gate
741/// is the presented slice.
742///
743/// Added and modified entities are classified from the live store. **Deleted
744/// entities are kept unconditionally**: the entity is gone, so its type can no
745/// longer be read, and a deletion that cannot be classified must be reported
746/// rather than dropped — a missed deletion is the highest-signal drift there
747/// is. An `id:` selector still applies to deletions, because an id is all a
748/// deletion leaves behind.
749fn filter_graph_slice_to_scope(engine: &Engine, source: &Source, slice: &mut Slice) {
750    let mut allows: Vec<EntitySelector> = Vec::new();
751    let mut denies: Vec<EntitySelector> = Vec::new();
752    for rule in &source.scope {
753        let Some(sel) = parse_entity_selector(&rule.path) else {
754            continue;
755        };
756        match rule.mode {
757            PatternMode::Allow => allows.push(sel),
758            PatternMode::Deny => denies.push(sel),
759        }
760    }
761    if allows.is_empty() {
762        return;
763    }
764    let in_scope = |id: &str, known_type: Option<&str>| {
765        // A `type:` selector cannot judge an entity whose type is unreadable
766        // (a deletion). Treat it as matching so the artifact survives to be
767        // reported, rather than silently vanishing from the slice.
768        let matches = |s: &EntitySelector| match (s, known_type) {
769            (EntitySelector::Type(_), None) => true,
770            _ => selector_matches(s, id, known_type.unwrap_or_default()),
771        };
772        allows.iter().any(&matches) && !denies.iter().any(&matches)
773    };
774    let type_of = |id: &str| {
775        engine
776            .store()
777            .get(&crate::entity::EntityId::canonical(id))
778            .map(|e| e.entity_type.clone())
779    };
780    slice
781        .added
782        .retain(|id| in_scope(id, type_of(id).as_deref()));
783    slice
784        .modified
785        .retain(|id| in_scope(id, type_of(id).as_deref()));
786    slice.deleted.retain(|id| in_scope(id, None));
787}
788
789fn compute_graph_slice(
790    engine: &Engine,
791    source: Option<&Source>,
792    source_mem: &str,
793    baseline: Option<&str>,
794) -> SliceOutcome {
795    let current = match engine.mem_head_sha(source_mem) {
796        Ok(Some(sha)) => sha,
797        // Source has no snapshot signal, or is unknown — degrade.
798        _ => {
799            return SliceOutcome::NoSignal {
800                reason: NoSignalReason::GraphSnapshotMissing,
801            };
802        }
803    };
804    // Fetch the entity delta only when the source actually moved.
805    let changed = matches!(baseline, Some(b) if is_git_token(b) && b != current);
806    let mut outcome = if changed {
807        let baseline = baseline.expect("changed implies a baseline");
808        match engine.changes_since(source_mem, baseline, None) {
809            Ok(report) => graph_slice_outcome(Some(baseline), &current, &report.changes),
810            // Unknown baseline / engine error — degrade.
811            Err(_) => SliceOutcome::NoSignal {
812                reason: NoSignalReason::GraphSnapshotMissing,
813            },
814        }
815    } else {
816        graph_slice_outcome(baseline, &current, &[])
817    };
818    // The scope narrows the slice exactly as it narrows S(D). Applied after
819    // the diff rather than pushed into it, mirroring how the path strategies
820    // apply deny pathspecs — one place decides what "in scope" means.
821    // A reference mem carries no facet scope — it is read whole by design,
822    // so there is nothing to narrow by and `None` is the honest input.
823    if let (Some(source), SliceOutcome::Changed { slice, .. }) = (source, &mut outcome) {
824        filter_graph_slice_to_scope(engine, source, slice);
825    }
826    outcome
827}
828
829// ── mtime source-cursor memo ────────────────────────────────────────────────
830//
831// The `mtime` strategy's durable baseline is a small digest token (in the
832// destination mem's `sync_state`), which cannot by itself say *which* files
833// changed. The engine keeps a rebuildable memo — the full stat map keyed by
834// its digest aggregate — so a run whose baseline matches a memoised aggregate
835// diffs precisely (incl. deletions) instead of degrading to a full scan.
836//
837// The memo lives engine-side under `<workspace>/.memstead.cache/ingest/` in
838// the plugin's format (`{aggregate: {relpath: {mtime, size}}}`), so the engine
839// and the transition-era skill share it. It is pure engine-internal cache —
840// not mem-repo, not the graph — so writing it during brief rendering is not a
841// tracked mutation. A write failure only costs the next run's precision.
842
843/// The `<cache_root>/source-cursor/<ingest>/<facet>.json` memo path.
844fn cursor_memo_path(cache_root: &Path, ingest_name: &str, facet_ref: &str) -> PathBuf {
845    let safe: String = facet_ref
846        .chars()
847        .map(|c| {
848            if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
849                c
850            } else {
851                '_'
852            }
853        })
854        .collect();
855    cache_root
856        .join("source-cursor")
857        .join(ingest_name)
858        .join(format!("{safe}.json"))
859}
860
861/// Read the stat map memoised under `aggregate` for a facet, or `None` on miss.
862fn read_cursor_memo(
863    cache_root: &Path,
864    ingest: &str,
865    facet: &str,
866    aggregate: &str,
867) -> Option<StatMap> {
868    let bytes = std::fs::read(cursor_memo_path(cache_root, ingest, facet)).ok()?;
869    let memo: BTreeMap<String, StatMap> = serde_json::from_slice(&bytes).ok()?;
870    memo.get(aggregate).cloned()
871}
872
873/// Memoise the current stat map under its aggregate, bounding the file to the
874/// 3 most-recent aggregates. Best-effort.
875fn write_cursor_memo(cache_root: &Path, ingest: &str, facet: &str, aggregate: &str, map: &StatMap) {
876    let path = cursor_memo_path(cache_root, ingest, facet);
877    let mut memo: BTreeMap<String, StatMap> = std::fs::read(&path)
878        .ok()
879        .and_then(|b| serde_json::from_slice(&b).ok())
880        .unwrap_or_default();
881    memo.insert(aggregate.to_string(), map.clone());
882    if memo.len() > 3 {
883        // Keep the just-written aggregate plus up to two others.
884        let drop: Vec<String> = memo
885            .keys()
886            .filter(|k| k.as_str() != aggregate)
887            .skip(2)
888            .cloned()
889            .collect();
890        for key in drop {
891            memo.remove(&key);
892        }
893    }
894    if let Some(parent) = path.parent() {
895        let _ = std::fs::create_dir_all(parent);
896    }
897    if let Ok(bytes) = serde_json::to_vec(&memo) {
898        let _ = std::fs::write(&path, bytes);
899    }
900}
901
902/// VCS metadata directories — never source artifacts. Pruned from source
903/// enumeration (`S(D)`, mtime slices, advance) and from the dead-deny scan.
904const VCS_INTERNAL_DIRS: &[&str] = &[".git", ".svn", ".hg"];
905
906/// Directory names never worth walking for the dead-deny scan — build output,
907/// VCS metadata ([`VCS_INTERNAL_DIRS`]), dependency caches, and the engine's
908/// own cache.
909const DEAD_DENY_SKIP_DIRS: &[&str] = &[
910    ".git",
911    "node_modules",
912    "target",
913    "dist",
914    ".memstead.cache",
915    ".sqlx",
916    ".svn",
917    ".hg",
918];
919
920/// Bounded, pruned walk of `base` collecting every file's **workspace-relative**
921/// path (the same string space the deny globs match). Skips heavy directories
922/// ([`DEAD_DENY_SKIP_DIRS`]) and gives up (returns `None`) past `cap` files, so
923/// the dead-deny scan degrades to "can't tell" rather than warning falsely or
924/// walking an unbounded tree. Best-effort: unreadable directories are skipped.
925fn walk_tree_bounded(base: &Path, workspace_root: &Path, cap: usize) -> Option<Vec<String>> {
926    let mut out: Vec<String> = Vec::new();
927    let mut stack = vec![base.to_path_buf()];
928    while let Some(dir) = stack.pop() {
929        let Ok(entries) = std::fs::read_dir(&dir) else {
930            continue;
931        };
932        for entry in entries.flatten() {
933            let Ok(file_type) = entry.file_type() else {
934                continue;
935            };
936            let path = entry.path();
937            if file_type.is_dir() {
938                let skip = path
939                    .file_name()
940                    .and_then(|n| n.to_str())
941                    .is_some_and(|n| DEAD_DENY_SKIP_DIRS.contains(&n));
942                if !skip {
943                    stack.push(path);
944                }
945            } else if file_type.is_file() {
946                if out.len() >= cap {
947                    return None;
948                }
949                out.push(
950                    relative_path(workspace_root, &normalize_lexical(&path))
951                        .to_string_lossy()
952                        .to_string(),
953                );
954            }
955        }
956    }
957    Some(out)
958}
959
960/// The ingest `deny_paths` entries that select **no file** in the project tree
961/// — surfaced as a rendered brief warning (AC 6 refusal leg) so a zero-matching
962/// deny is never a silent no-op. Resolution base is the medium's git project
963/// root (so a cross-medium workspace-relative deny like `../dev/**`, whose
964/// target lives outside a sub-medium, still resolves against real files),
965/// falling back to the workspace root. Uses the *same* [`build_glob_set`]
966/// matcher the strategies use, so "does this deny select anything" is answered
967/// with the identical dialect. Best-effort: if the tree can't be enumerated
968/// (walk cap hit, no readable base) nothing is reported — a warning is only
969/// ever raised on a confirmed zero-match.
970///
971/// The scaffold's own default hygiene entries
972/// ([`crate::binding::DEFAULT_SCAFFOLD_DENY_PATHS`]) are exempt: `projection
973/// init` writes them into every codebase/filesystem binding, and most trees
974/// carry none of the debris they name. The exemption is a membership check
975/// against that constant, not a prediction about what the enumerator walks —
976/// enumeration is a filesystem walk even on a git-signalled source, so these
977/// entries CAN match (deleting `**/node_modules/**` from a scaffolded record
978/// over a repo that gitignores `node_modules/` raises the denominator). The
979/// engine never calls its own output a typo; a user-authored entry that
980/// matches nothing keeps the loud warning.
981fn dead_deny_entries(resolved: &ResolvedIngest, workspace_root: &Path) -> Vec<String> {
982    if resolved.deny_paths.is_empty() {
983        return Vec::new();
984    }
985    let base = find_git_root(workspace_root).unwrap_or_else(|| workspace_root.to_path_buf());
986    let Some(files) = walk_tree_bounded(&base, workspace_root, 100_000) else {
987        return Vec::new();
988    };
989    let mut dead: Vec<String> = Vec::new();
990    for entry in &resolved.deny_paths {
991        if crate::binding::DEFAULT_SCAFFOLD_DENY_PATHS.contains(&entry.as_str()) {
992            continue;
993        }
994        let Some(set) = build_glob_set(&[entry.as_str()]) else {
995            // A malformed glob can't be resolved either way — not a confirmed
996            // zero-match, so it is not reported here.
997            continue;
998        };
999        if !files.iter().any(|f| set.is_match(f)) {
1000            dead.push(entry.clone());
1001        }
1002    }
1003    dead
1004}
1005
1006/// Compute the `mtime` changed slice for one primary source: enumerate the
1007/// facet files, stat them, memoise the current map, and diff against the
1008/// baseline digest's memoised map (precise) or degrade to a full scan on memo
1009/// miss. Mirrors the mtime branch of the plugin's `computeSourceCursor`.
1010fn compute_mtime_slice(
1011    source: &Source,
1012    ingest_name: &str,
1013    deny_paths: &[String],
1014    workspace_root: &Path,
1015    cache_root: &Path,
1016    baseline: Option<&str>,
1017) -> SliceOutcome {
1018    if facet_unscoped(source) {
1019        // Unscoped facet — the same typed refusal git raises, so the mtime
1020        // strategy never enumerates the whole medium nor emits an empty slice.
1021        return SliceOutcome::NoSignal {
1022            reason: NoSignalReason::Unscoped,
1023        };
1024    }
1025    let files = enumerate_facet_files(source, deny_paths, workspace_root);
1026    let now_map = compute_stat_map(&files, workspace_root);
1027    let now_digest = digest_stat_map(&now_map);
1028    write_cursor_memo(
1029        cache_root,
1030        ingest_name,
1031        &source.name,
1032        &now_digest.aggregate,
1033        &now_map,
1034    );
1035    let prev_map = baseline
1036        .and_then(parse_digest_token)
1037        .and_then(|base| read_cursor_memo(cache_root, ingest_name, &source.name, &base.aggregate));
1038    mtime_slice_outcome(baseline, prev_map.as_ref(), &now_map)
1039}
1040
1041/// The current change-detection token for a primary source, per its resolved
1042/// strategy: git `HEAD`, the graph mem's snapshot, or the freshly-computed
1043/// mtime digest. `None` when there is no signal.
1044fn current_primary_token(
1045    engine: &Engine,
1046    source: &Source,
1047    deny_paths: &[String],
1048    workspace_root: &Path,
1049) -> Option<String> {
1050    match resolve_change_strategy(source, workspace_root) {
1051        ChangeStrategy::Git => git_head(&find_git_root(&medium_base(
1052            &source.pointer,
1053            workspace_root,
1054        ))?),
1055        ChangeStrategy::Graph => {
1056            if facet_unscoped(source) {
1057                // Symmetric with the mtime arm: no signal at all, rather than
1058                // a whole-mem token posing as a scoped one.
1059                None
1060            } else {
1061                engine.mem_head_sha(&source.pointer).ok().flatten()
1062            }
1063        }
1064        ChangeStrategy::Mtime => {
1065            if facet_unscoped(source) {
1066                // Unscoped facet has no signal — not an empty-set digest posing
1067                // as one, so the source can never register as "moved".
1068                None
1069            } else {
1070                let files = enumerate_facet_files(source, deny_paths, workspace_root);
1071                Some(serialize_digest_token(&digest_stat_map(&compute_stat_map(
1072                    &files,
1073                    workspace_root,
1074                ))))
1075            }
1076        }
1077        ChangeStrategy::None => None,
1078    }
1079}
1080
1081/// Whether any of an ingest's sources moved since its last synced pass — the
1082/// cheap, slice-free predicate the backoff uses as its additive second
1083/// trigger. Compares each source's current token to the baseline stored in the
1084/// destination mem's `sync_state`; a source with no baseline is not "moved"
1085/// (a first sync does not by itself defeat backoff). Mirrors the plugin's
1086/// `sourceChangedSince`.
1087pub fn source_moved(engine: &Engine, resolved: &ResolvedIngest, workspace_root: &Path) -> bool {
1088    source_moved_since(engine, resolved, workspace_root, "synced", false)
1089}
1090
1091/// The generalized form of [`source_moved`]: compare each source's current
1092/// change-detection token against the baseline stored under
1093/// `"<binding>/<facet>#<state>"` in the destination mem's `sync_state`. The
1094/// `state` suffix selects the baseline family — `"synced"` (the build/sync
1095/// baseline [`source_moved`] reads) or `"verified"` (the verify baseline).
1096///
1097/// `missing_baseline_is_moved` decides the never-recorded case: `false`
1098/// preserves [`source_moved`]'s posture (no baseline ⇒ not "moved" — a first
1099/// sync does not by itself defeat backoff); `true` treats a source with a live
1100/// current token but no recorded baseline as moved — the verify due-check's
1101/// posture, where "never verified" means the first verify is due.
1102pub fn source_moved_since(
1103    engine: &Engine,
1104    resolved: &ResolvedIngest,
1105    workspace_root: &Path,
1106    state: &str,
1107    missing_baseline_is_moved: bool,
1108) -> bool {
1109    let dest = &resolved.destination_mem;
1110    let baseline_map = engine
1111        .mem_config_for(dest)
1112        .map(|c| c.sync_state.clone())
1113        .unwrap_or_default();
1114
1115    for source in &resolved.sources {
1116        let (facet_ref, current) = match source {
1117            ResolvedSource::Primary(p) => (
1118                p.name.clone(),
1119                current_primary_token(engine, p, &resolved.deny_paths, workspace_root),
1120            ),
1121            ResolvedSource::Reference { mem } => {
1122                (mem.clone(), engine.mem_head_sha(mem).ok().flatten())
1123            }
1124        };
1125        let key = format!("{}/{}#{state}", resolved.name, facet_ref);
1126        let Some(baseline) = baseline_map.get(&key) else {
1127            // No baseline recorded for this state family.
1128            if missing_baseline_is_moved && current.as_deref().is_some_and(|c| !c.is_empty()) {
1129                return true;
1130            }
1131            continue;
1132        };
1133        if let Some(current) = current
1134            && !current.is_empty()
1135            && current != *baseline
1136        {
1137            return true;
1138        }
1139    }
1140    false
1141}
1142
1143/// Assemble the combined [`SourceCursor`] for an ingest from live state: the
1144/// destination mem's `sync_state` baselines and each source's current state.
1145pub fn compute_source_cursor(
1146    engine: &Engine,
1147    resolved: &ResolvedIngest,
1148    workspace_root: &Path,
1149) -> SourceCursor {
1150    let dest = &resolved.destination_mem;
1151    let baseline_map = engine
1152        .mem_config_for(dest)
1153        .map(|c| c.sync_state.clone())
1154        .unwrap_or_default();
1155
1156    let cache_root = workspace_root.join(".memstead.cache").join("ingest");
1157    let mut union = Slice::default();
1158    let mut write_commands: Vec<SyncCommand> = Vec::new();
1159    let mut reseed: Vec<SyncCommand> = Vec::new();
1160    let mut no_signal: Vec<NoSignalNote> = Vec::new();
1161    let mut degraded = false;
1162
1163    for source in &resolved.sources {
1164        // Key: "<ingest>/<facet_ref>" for primaries, "<ingest>/<mem>" for
1165        // reference sources — matching the plugin's sync_state keying.
1166        // The note's remedy is medium-shaped, so the medium travels with it.
1167        let primary_medium = match source {
1168            ResolvedSource::Primary(p) => Some(p.medium_type),
1169            ResolvedSource::Reference { .. } => None,
1170        };
1171        let (facet_ref, outcome) = match source {
1172            ResolvedSource::Primary(p) => {
1173                let key = format!("{}/{}#synced", resolved.name, p.name);
1174                let baseline = baseline_map.get(&key).map(String::as_str);
1175                let outcome = match resolve_change_strategy(p, workspace_root) {
1176                    ChangeStrategy::Git => {
1177                        compute_git_slice(p, &resolved.deny_paths, workspace_root, baseline)
1178                    }
1179                    // A graph-typed primary's medium pointer is the source mem id.
1180                    // An unscoped graph facet refuses exactly as the git and
1181                    // mtime arms do: the graph slice alone used to proceed on
1182                    // an empty scope, which is how a facet could carry scope
1183                    // nothing interpreted and still look like it was working.
1184                    ChangeStrategy::Graph if facet_unscoped(p) => SliceOutcome::NoSignal {
1185                        reason: NoSignalReason::Unscoped,
1186                    },
1187                    ChangeStrategy::Graph => {
1188                        compute_graph_slice(engine, Some(p), &p.pointer, baseline)
1189                    }
1190                    ChangeStrategy::Mtime => compute_mtime_slice(
1191                        p,
1192                        &resolved.name,
1193                        &resolved.deny_paths,
1194                        workspace_root,
1195                        &cache_root,
1196                        baseline,
1197                    ),
1198                    // `none` is inert — a rendered `signal:none` state, no slice.
1199                    ChangeStrategy::None => SliceOutcome::NoSignal {
1200                        reason: NoSignalReason::DetectionNone,
1201                    },
1202                };
1203                (p.name.clone(), outcome)
1204            }
1205            ResolvedSource::Reference { mem } => {
1206                let key = format!("{}/{}#synced", resolved.name, mem);
1207                let baseline = baseline_map.get(&key).map(String::as_str);
1208                (
1209                    mem.clone(),
1210                    compute_graph_slice(engine, None, mem, baseline),
1211                )
1212            }
1213        };
1214
1215        let key = format!("{}/{}#synced", resolved.name, facet_ref);
1216        match outcome {
1217            // Genuinely unchanged (baseline present, nothing moved) is the only
1218            // documented silence — it renders nothing, keeping an all-unchanged
1219            // brief byte-identical to a plain roam.
1220            SliceOutcome::Unchanged { .. } => {}
1221            // Every no-signal reason is a visible per-source note.
1222            SliceOutcome::NoSignal { reason } => no_signal.push(NoSignalNote {
1223                source: facet_ref.clone(),
1224                reason,
1225                medium_type: primary_medium,
1226            }),
1227            SliceOutcome::Reseed { token } => reseed.push(SyncCommand { key, token }),
1228            SliceOutcome::Changed {
1229                token,
1230                slice,
1231                degraded: d,
1232            } => {
1233                union.added.extend(slice.added);
1234                union.modified.extend(slice.modified);
1235                union.deleted.extend(slice.deleted);
1236                degraded |= d;
1237                write_commands.push(SyncCommand { key, token });
1238            }
1239        }
1240    }
1241
1242    dedupe_sort(&mut union.added);
1243    dedupe_sort(&mut union.modified);
1244    dedupe_sort(&mut union.deleted);
1245    let any_changes =
1246        !union.added.is_empty() || !union.modified.is_empty() || !union.deleted.is_empty();
1247
1248    SourceCursor {
1249        union,
1250        write_commands,
1251        reseed,
1252        no_signal,
1253        any_changes,
1254        degraded,
1255        dead_denies: dead_deny_entries(resolved, workspace_root),
1256        dest_mem: dest.clone(),
1257        // The resolved ingest's `name` is the canonical binding id `<mem>/<stem>`
1258        // (via `resolve_binding_run`) — the id the `projection advance` line the
1259        // brief renders (D4/D7) is keyed on.
1260        binding_id: resolved.name.clone(),
1261    }
1262}
1263
1264fn dedupe_sort(v: &mut Vec<String>) {
1265    v.sort();
1266    v.dedup();
1267}
1268
1269#[cfg(test)]
1270mod tests {
1271    use super::*;
1272
1273    #[test]
1274    fn normalize_resolves_dot_and_dotdot() {
1275        assert_eq!(
1276            normalize_lexical(Path::new("/a/b/../c/./d")),
1277            PathBuf::from("/a/c/d")
1278        );
1279        assert_eq!(
1280            normalize_lexical(Path::new("/a/../../b")),
1281            PathBuf::from("/b"),
1282            "dotdot past root is clamped"
1283        );
1284    }
1285
1286    #[test]
1287    fn relative_computes_updowns() {
1288        assert_eq!(
1289            relative_path(Path::new("/a/b"), Path::new("/a/b/c/d")),
1290            PathBuf::from("c/d")
1291        );
1292        assert_eq!(
1293            relative_path(Path::new("/a/b/c"), Path::new("/a/x")),
1294            PathBuf::from("../../x")
1295        );
1296        // A workspace whose medium is a sibling repository.
1297        assert_eq!(
1298            relative_path(Path::new("/m/public"), Path::new("/m/public/crates/x.rs")),
1299            PathBuf::from("crates/x.rs")
1300        );
1301        assert_eq!(
1302            relative_path(Path::new("/m/graph"), Path::new("/m/public/crates/x.rs")),
1303            PathBuf::from("../public/crates/x.rs")
1304        );
1305    }
1306
1307    #[test]
1308    fn pathspec_builds_glob_magic_relative_to_git_root() {
1309        let ws = Path::new("/m/graph");
1310        let git_root = Path::new("/m/public");
1311        assert_eq!(
1312            to_git_pathspec("../public/**/*.rs", git_root, ws, false),
1313            ":(glob)**/*.rs"
1314        );
1315        assert_eq!(
1316            to_git_pathspec("../public/target/**", git_root, ws, true),
1317            ":(glob,exclude)target/**"
1318        );
1319    }
1320
1321    /// A `**`-prefixed pattern (the scaffolded facet default `**/*`) is
1322    /// prefix-free and re-anchors verbatim onto the git root. Lexical
1323    /// re-rooting would yield `:(glob)../**/*` for any sub-medium — an
1324    /// out-of-tree pathspec git fatals on, degrading every diff to
1325    /// no-signal.
1326    #[test]
1327    fn wildcard_prefixed_pathspec_reanchors_verbatim() {
1328        let ws = Path::new("/m/ws");
1329        let git_root = Path::new("/m/ws/src");
1330        assert_eq!(to_git_pathspec("**/*", git_root, ws, false), ":(glob)**/*");
1331        assert_eq!(
1332            in_repo_pathspec("**/__pycache__/**", git_root, ws, true).as_deref(),
1333            Some(":(glob,exclude)**/__pycache__/**")
1334        );
1335    }
1336
1337    use crate::ingest::resolve::Source;
1338    use crate::pipeline::{MediumType, PatternEntry};
1339
1340    fn git(repo: &Path, args: &[&str]) {
1341        let status = std::process::Command::new("git")
1342            .args(args)
1343            .current_dir(repo)
1344            .env("GIT_AUTHOR_NAME", "t")
1345            .env("GIT_AUTHOR_EMAIL", "t@t")
1346            .env("GIT_COMMITTER_NAME", "t")
1347            .env("GIT_COMMITTER_EMAIL", "t@t")
1348            .output()
1349            .unwrap();
1350        assert!(
1351            status.status.success(),
1352            "git {args:?}: {}",
1353            String::from_utf8_lossy(&status.stderr)
1354        );
1355    }
1356
1357    fn primary(scope: Vec<PatternEntry>) -> Source {
1358        Source {
1359            name: "src".to_string(),
1360            medium_type: MediumType::Codebase,
1361            pointer: String::new(),
1362            change_detection: Some("git".to_string()),
1363            scope,
1364            engagement: None,
1365            preparation: None,
1366        }
1367    }
1368
1369    /// One dialect, one implementation: the SAME entry list must exclude the
1370    /// SAME files from an engine slice as [`super::check_path::check_deny_paths`]
1371    /// denies. Successor to the retired cross-boundary fixture test that
1372    /// pinned the engine against the plugin's JS dialect clone — both callers
1373    /// now run engine code, and this test keeps the two engine consumers
1374    /// (enumeration, path check) agreeing on shared data. Proven by
1375    /// materialising every path into a temp workspace, scoping a facet to
1376    /// `**` (everything), applying the entries as the ingest `deny_paths`,
1377    /// and asserting `enumerate_facet_files` yields exactly `allowed`.
1378    #[test]
1379    fn deny_dialect_agrees_between_slice_and_check() {
1380        let strs =
1381            |items: &[&str]| -> Vec<String> { items.iter().map(|s| s.to_string()).collect() };
1382        let entries = strs(&["dev/**", "**/VISION.md", "docs/meta/CLAUDE.md"]);
1383        let blocked = strs(&[
1384            "dev/notes/a.md",
1385            "dev/x.rs",
1386            "dev/deep/nested/y.txt",
1387            "VISION.md",
1388            "crates/foo/VISION.md",
1389            "docs/meta/CLAUDE.md",
1390        ]);
1391        let allowed = strs(&[
1392            "src/lib.rs",
1393            "dev-tools/x.rs",
1394            "VISION-draft.md",
1395            "docs/meta/README.md",
1396            "other/CLAUDE.md",
1397            "crates/foo/mod.rs",
1398        ]);
1399
1400        let ws = tempfile::tempdir().unwrap();
1401        for rel in blocked.iter().chain(allowed.iter()) {
1402            let path = ws.path().join(rel);
1403            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1404            std::fs::write(&path, "x").unwrap();
1405        }
1406
1407        // Scope = everything; the ONLY exclusions are the ingest deny_paths.
1408        let source = primary(vec![PatternEntry {
1409            path: "**".to_string(),
1410            mode: PatternMode::Allow,
1411        }]);
1412        let mut got = enumerate_facet_files(&source, &entries, ws.path());
1413        got.sort();
1414        let mut want = allowed.clone();
1415        want.sort();
1416        assert_eq!(
1417            got, want,
1418            "engine slice must equal the fixture `allowed` set"
1419        );
1420
1421        for b in &blocked {
1422            assert!(
1423                !got.contains(b),
1424                "denied `{b}` leaked into the engine slice"
1425            );
1426        }
1427        // The path check agrees on every case — the two engine consumers of
1428        // the dialect can never drift apart silently.
1429        let all: Vec<String> = blocked.iter().chain(allowed.iter()).cloned().collect();
1430        let checks =
1431            super::super::check_path::check_deny_paths(&entries, &all, ws.path(), ws.path());
1432        for c in &checks {
1433            let expect = blocked.contains(&c.path);
1434            assert_eq!(
1435                c.denied, expect,
1436                "check_deny_paths disagrees with the slice on `{}`",
1437                c.path
1438            );
1439        }
1440    }
1441
1442    /// A cross-repo deny (its target sibling to the medium's git repo)
1443    /// resolves outside `git_root` and is dropped from the pathspecs — pushing
1444    /// it would make git fatal on the whole diff. An in-repo deny is kept.
1445    #[test]
1446    fn out_of_repo_deny_pathspec_is_dropped() {
1447        let ws = Path::new("/m/graph");
1448        let git_root = Path::new("/m/public");
1449        // `../dev/**` (workspace-relative) → /m/dev/** — outside /m/public.
1450        assert_eq!(in_repo_pathspec("../dev/**", git_root, ws, true), None);
1451        assert_eq!(in_repo_pathspec("../CLAUDE.md", git_root, ws, true), None);
1452        // An in-repo deny is preserved as a normal exclude pathspec.
1453        assert_eq!(
1454            in_repo_pathspec("../public/target/**", git_root, ws, true),
1455            Some(":(glob,exclude)target/**".to_string())
1456        );
1457    }
1458
1459    /// A git-shaped baseline the repo does NOT contain reseeds at HEAD
1460    /// instead of degrading to `GitUnavailable` forever. Regression for the
1461    /// dogfood plugin/graph binding, whose stored baseline was a commit of a
1462    /// *different* repo (seeded before the source moved into the submodule):
1463    /// every pass diffed against a foreign sha, fataled, and the baseline
1464    /// never seated.
1465    #[test]
1466    fn foreign_baseline_reseeds_instead_of_degrading() {
1467        let repo = tempfile::tempdir().unwrap();
1468        let root = repo.path();
1469        std::fs::write(root.join("keep.rs"), "one").unwrap();
1470        git(root, &["init", "-q"]);
1471        git(root, &["add", "-A"]);
1472        git(root, &["commit", "-qm", "seed"]);
1473
1474        let source = primary(vec![PatternEntry {
1475            path: "**/*.rs".to_string(),
1476            mode: PatternMode::Allow,
1477        }]);
1478        // Git-token-shaped, but no such commit exists in this repo.
1479        let foreign = "46ce8add0fe87250527b6fa21fcfdc2d943d51f0";
1480        match compute_git_slice(&source, &[], root, Some(foreign)) {
1481            SliceOutcome::Reseed { token } => {
1482                // Reseeds at the repo's actual HEAD — the baseline seats.
1483                let head = String::from_utf8(
1484                    std::process::Command::new("git")
1485                        .args(["rev-parse", "HEAD"])
1486                        .current_dir(root)
1487                        .output()
1488                        .unwrap()
1489                        .stdout,
1490                )
1491                .unwrap()
1492                .trim()
1493                .to_string();
1494                assert_eq!(token, head);
1495            }
1496            other => panic!("foreign baseline must reseed, got {other:?}"),
1497        }
1498    }
1499
1500    /// A real git diff with a cross-repo deny present must still succeed (the
1501    /// out-of-repo pathspec is dropped, not fataled), and the in-repo scope is
1502    /// honoured. Regression for the dogfood dialect (`../dev/**` under a
1503    /// sub-medium): git must not degrade the whole slice.
1504    #[test]
1505    fn git_slice_survives_cross_repo_deny() {
1506        let repo = tempfile::tempdir().unwrap();
1507        let root = repo.path();
1508        std::fs::write(root.join("keep.rs"), "one").unwrap();
1509        git(root, &["init", "-q"]);
1510        git(root, &["add", "-A"]);
1511        git(root, &["commit", "-qm", "seed"]);
1512        let baseline = String::from_utf8(
1513            std::process::Command::new("git")
1514                .args(["rev-parse", "HEAD"])
1515                .current_dir(root)
1516                .output()
1517                .unwrap()
1518                .stdout,
1519        )
1520        .unwrap()
1521        .trim()
1522        .to_string();
1523        std::fs::write(root.join("keep.rs"), "two").unwrap();
1524        git(root, &["add", "-A"]);
1525        git(root, &["commit", "-qm", "move"]);
1526
1527        let source = primary(vec![PatternEntry {
1528            path: "**/*.rs".to_string(),
1529            mode: PatternMode::Allow,
1530        }]);
1531        // `../dev/**` resolves outside this repo — must be dropped, not fatal.
1532        let outcome = compute_git_slice(&source, &["../dev/**".to_string()], root, Some(&baseline));
1533        match outcome {
1534            SliceOutcome::Changed { slice, .. } => {
1535                assert_eq!(slice.modified, vec!["keep.rs"]);
1536            }
1537            other => panic!("expected Changed (deny dropped), got {other:?}"),
1538        }
1539    }
1540
1541    /// A real git diff: baseline commit → HEAD produces the changed slice,
1542    /// classifying added / modified / deleted and honouring the scope.
1543    #[test]
1544    fn git_slice_diffs_baseline_to_head() {
1545        let repo = tempfile::tempdir().unwrap();
1546        let root = repo.path();
1547        git(root, &["init", "-q"]);
1548        std::fs::write(root.join("keep.rs"), "one").unwrap();
1549        std::fs::write(root.join("gone.rs"), "bye").unwrap();
1550        std::fs::write(root.join("note.md"), "ignored-by-scope").unwrap();
1551        git(root, &["add", "-A"]);
1552        git(root, &["commit", "-qm", "base"]);
1553        let baseline = String::from_utf8(
1554            std::process::Command::new("git")
1555                .args(["rev-parse", "HEAD"])
1556                .current_dir(root)
1557                .output()
1558                .unwrap()
1559                .stdout,
1560        )
1561        .unwrap()
1562        .trim()
1563        .to_string();
1564
1565        // Move: modify keep.rs, delete gone.rs, add new.rs, touch note.md.
1566        std::fs::write(root.join("keep.rs"), "two").unwrap();
1567        std::fs::remove_file(root.join("gone.rs")).unwrap();
1568        std::fs::write(root.join("new.rs"), "hi").unwrap();
1569        std::fs::write(root.join("note.md"), "still ignored").unwrap();
1570        git(root, &["add", "-A"]);
1571        git(root, &["commit", "-qm", "move"]);
1572
1573        // Scope to *.rs only — note.md must not appear.
1574        let source = primary(vec![PatternEntry {
1575            path: "**/*.rs".to_string(),
1576            mode: PatternMode::Allow,
1577        }]);
1578        let outcome = compute_git_slice(&source, &[], root, Some(&baseline));
1579        match outcome {
1580            SliceOutcome::Changed {
1581                slice, degraded, ..
1582            } => {
1583                assert!(!degraded);
1584                assert_eq!(slice.added, vec!["new.rs"]);
1585                assert_eq!(slice.modified, vec!["keep.rs"]);
1586                assert_eq!(slice.deleted, vec!["gone.rs"]);
1587            }
1588            other => panic!("expected Changed, got {other:?}"),
1589        }
1590
1591        // Same baseline == HEAD → Unchanged.
1592        let head = String::from_utf8(
1593            std::process::Command::new("git")
1594                .args(["rev-parse", "HEAD"])
1595                .current_dir(root)
1596                .output()
1597                .unwrap()
1598                .stdout,
1599        )
1600        .unwrap()
1601        .trim()
1602        .to_string();
1603        assert!(matches!(
1604            compute_git_slice(&source, &[], root, Some(&head)),
1605            SliceOutcome::Unchanged { .. }
1606        ));
1607
1608        // A non-commit baseline → Reseed at HEAD.
1609        assert!(matches!(
1610            compute_git_slice(&source, &[], root, None),
1611            SliceOutcome::Reseed { .. }
1612        ));
1613    }
1614
1615    /// Facet-file enumeration honours allow globs, deny globs, and the
1616    /// codebase/filesystem medium-type gate.
1617    #[test]
1618    fn enumerate_honours_allow_and_deny() {
1619        let ws = tempfile::tempdir().unwrap();
1620        let root = ws.path();
1621        std::fs::create_dir_all(root.join("sub")).unwrap();
1622        std::fs::write(root.join("a.rs"), "").unwrap();
1623        std::fs::write(root.join("sub/b.rs"), "").unwrap();
1624        std::fs::write(root.join("c.md"), "").unwrap();
1625
1626        // medium_pointer "" → base is the workspace root; allow **/*.rs,
1627        // deny sub/** (so sub/b.rs is excluded, c.md never matched).
1628        let source = primary(vec![
1629            PatternEntry {
1630                path: "**/*.rs".to_string(),
1631                mode: PatternMode::Allow,
1632            },
1633            PatternEntry {
1634                path: "sub/**".to_string(),
1635                mode: PatternMode::Deny,
1636            },
1637        ]);
1638        assert_eq!(enumerate_facet_files(&source, &[], root), vec!["a.rs"]);
1639
1640        // A graph medium is not a file tree, so the FILE walk yields nothing
1641        // for it — but that is a statement about this function, not about
1642        // graph enumerability. `enumerate_graph_entities` is the graph arm,
1643        // and `enumerate_source_artifacts` is what every S(D) consumer calls.
1644        let mut graph_source = source.clone();
1645        graph_source.medium_type = MediumType::Graph;
1646        assert!(enumerate_facet_files(&graph_source, &[], root).is_empty());
1647    }
1648
1649    /// Graph enumeration is real: a graph source's `S(D)` is the source mem's
1650    /// in-scope entity set, selected by the entity vocabulary. This is the
1651    /// bail the S1b pilot hit — enumeration returned empty for every graph
1652    /// source, so coverage was vacuously 0/0 and `--full` passed over a
1653    /// measurement that never happened.
1654    #[test]
1655    fn graph_enumeration_selects_the_source_mems_entities() {
1656        use crate::workspace::{
1657            Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
1658        };
1659        use crate::workspace_store::WorkspaceStoreAdapter;
1660
1661        let tmp = tempfile::tempdir().unwrap();
1662        let root = tmp.path();
1663        let mem_dir = root.join("srcmem");
1664        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1665        std::fs::write(
1666            mem_dir.join(".memstead").join("config.json"),
1667            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1668        )
1669        .unwrap();
1670
1671        let entity = |slug: &str, ty: &str, title: &str| {
1672            std::fs::write(
1673                mem_dir.join(format!("{slug}.md")),
1674                format!("---\ntype: {ty}\n---\n\n# {title}\n\n## Decision\n\nBody.\n"),
1675            )
1676            .unwrap();
1677        };
1678        entity("alpha-choice", "decision", "Alpha choice");
1679        entity("beta-choice", "decision", "Beta choice");
1680        entity("gamma-note", "memo", "Gamma note");
1681
1682        std::fs::create_dir_all(root.join(".memstead")).unwrap();
1683        std::fs::write(
1684            root.join(".memstead").join("workspace.toml"),
1685            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1686        )
1687        .unwrap();
1688        crate::FileWorkspaceStore::new()
1689            .save_state(
1690                root,
1691                &Workspace {
1692                    mounts: vec![Mount {
1693                        mem: "srcmem".to_string(),
1694                        schema: Some("default@1.0.0".parse().unwrap()),
1695                        storage: MountStorage::Folder {
1696                            path: mem_dir.clone(),
1697                        },
1698                        capability: MountCapability::Write,
1699                        lifecycle: MountLifecycle::Eager,
1700                        cross_linkable: false,
1701                        migration_target: None,
1702                    }],
1703                    settings: WorkspaceSettings::default(),
1704                },
1705            )
1706            .unwrap();
1707
1708        let engine = crate::Engine::from_workspace_root(root).unwrap();
1709
1710        let graph_source = |patterns: Vec<(&str, PatternMode)>| Source {
1711            name: "g".to_string(),
1712            medium_type: MediumType::Graph,
1713            pointer: "srcmem".to_string(),
1714            change_detection: None,
1715            scope: patterns
1716                .into_iter()
1717                .map(|(p, mode)| crate::pipeline::PatternEntry {
1718                    path: p.to_string(),
1719                    mode,
1720                })
1721                .collect(),
1722            engagement: None,
1723            preparation: None,
1724        };
1725
1726        // `*` — the whole mem. A real denominator, not an empty walk.
1727        let all = enumerate_graph_entities(&engine, &graph_source(vec![("*", PatternMode::Allow)]));
1728        assert_eq!(
1729            all,
1730            vec![
1731                "srcmem--alpha-choice".to_string(),
1732                "srcmem--beta-choice".to_string(),
1733                "srcmem--gamma-note".to_string(),
1734            ],
1735            "the whole-mem selector enumerates every real entity"
1736        );
1737
1738        // `type:` selects on the type axis.
1739        let decisions = enumerate_graph_entities(
1740            &engine,
1741            &graph_source(vec![("type:decision", PatternMode::Allow)]),
1742        );
1743        assert_eq!(
1744            decisions,
1745            vec![
1746                "srcmem--alpha-choice".to_string(),
1747                "srcmem--beta-choice".to_string()
1748            ],
1749            "type selector excludes the memo"
1750        );
1751
1752        // `id:` globs the id, and a deny subtracts from an allow.
1753        let globbed = enumerate_graph_entities(
1754            &engine,
1755            &graph_source(vec![
1756                ("id:srcmem--*-choice", PatternMode::Allow),
1757                ("id:srcmem--beta-*", PatternMode::Deny),
1758            ]),
1759        );
1760        assert_eq!(
1761            globbed,
1762            vec!["srcmem--alpha-choice".to_string()],
1763            "deny subtracts from allow in the entity namespace too"
1764        );
1765
1766        // An unscoped graph facet enumerates nothing — the same posture the
1767        // path mediums have always had, and the reason the strategy layer
1768        // refuses it before ever reaching here.
1769        assert!(
1770            enumerate_graph_entities(&engine, &graph_source(vec![])).is_empty(),
1771            "an unscoped graph facet is never silently 'everything'"
1772        );
1773
1774        // The dispatching entry point every S(D) consumer calls agrees.
1775        assert_eq!(
1776            enumerate_source_artifacts(
1777                &engine,
1778                &graph_source(vec![("*", PatternMode::Allow)]),
1779                &[],
1780                root
1781            ),
1782            all,
1783            "enumerate_source_artifacts routes a graph source to the graph arm"
1784        );
1785    }
1786
1787    /// The S1b pilot's headline failure, encoded as a permanent regression
1788    /// test: a stale-pinned entity anchor over a source entity that changed
1789    /// since it was pinned must be flagged `drifted`. It used to go unflagged
1790    /// — anchor resolution was 0/0 for every graph source, so drift was
1791    /// structurally undetectable while the matrix claimed full parity.
1792    #[test]
1793    fn a_stale_entity_anchor_over_a_changed_entity_is_drifted() {
1794        use crate::anchor::{
1795            Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
1796        };
1797        use crate::entity::EntityId;
1798        use crate::workspace::{
1799            Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
1800        };
1801        use crate::workspace_store::WorkspaceStoreAdapter;
1802
1803        let tmp = tempfile::tempdir().unwrap();
1804        let root = tmp.path();
1805        let mem_dir = root.join("mem");
1806        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1807        std::fs::write(
1808            mem_dir.join(".memstead").join("config.json"),
1809            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1810        )
1811        .unwrap();
1812        std::fs::write(
1813            mem_dir.join("pinned.md"),
1814            "---\ntype: decision\n---\n\n# Pinned\n\n## Decision\n\nOriginal body.\n",
1815        )
1816        .unwrap();
1817        std::fs::write(
1818            mem_dir.join("steady.md"),
1819            "---\ntype: decision\n---\n\n# Steady\n\n## Decision\n\nUnchanged body.\n",
1820        )
1821        .unwrap();
1822
1823        std::fs::create_dir_all(root.join(".memstead")).unwrap();
1824        std::fs::write(
1825            root.join(".memstead").join("workspace.toml"),
1826            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1827        )
1828        .unwrap();
1829        crate::FileWorkspaceStore::new()
1830            .save_state(
1831                root,
1832                &Workspace {
1833                    mounts: vec![Mount {
1834                        mem: "mem".to_string(),
1835                        schema: Some("default@1.0.0".parse().unwrap()),
1836                        storage: MountStorage::Folder {
1837                            path: mem_dir.clone(),
1838                        },
1839                        capability: MountCapability::Write,
1840                        lifecycle: MountLifecycle::Eager,
1841                        cross_linkable: false,
1842                        migration_target: None,
1843                    }],
1844                    settings: WorkspaceSettings::default(),
1845                },
1846            )
1847            .unwrap();
1848
1849        // Hash the entities as they stand, so the anchors start out honest.
1850        let engine = crate::Engine::from_workspace_root(root).unwrap();
1851        let hash_of = |engine: &crate::Engine, id: &str| {
1852            let e = engine.store().get(&EntityId::canonical(id)).unwrap();
1853            crate::anchor::prepared_content_hash(
1854                crate::render::render_entity_markdown(e, None).as_bytes(),
1855            )
1856        };
1857        let pinned_hash = hash_of(&engine, "mem--pinned");
1858        let steady_hash = hash_of(&engine, "mem--steady");
1859
1860        let entity_anchor = |artifact: &str, hash: &str| Anchor {
1861            artifact: artifact.to_string(),
1862            grain: AnchorGrain::Entity,
1863            class: AnchorProvenanceClass::Anchored,
1864            hash: Some(hash.to_string()),
1865            source: None,
1866            binding: None,
1867            at_version: None,
1868            derived_from: Vec::new(),
1869            hash_stability: crate::anchor::AnchorHashStability::Stable,
1870        };
1871
1872        let mut sidecar = AnchorSidecar::default();
1873        sidecar.set(
1874            "mem--holder",
1875            vec![
1876                entity_anchor("mem--pinned", &pinned_hash),
1877                entity_anchor("mem--steady", &steady_hash),
1878                // An anchor over an entity that does not exist at all.
1879                entity_anchor("mem--vanished", "deadbeefdeadbeef"),
1880            ],
1881        );
1882        std::fs::write(
1883            mem_dir.join(".memstead").join("anchors.json"),
1884            sidecar.to_bytes(),
1885        )
1886        .unwrap();
1887        std::fs::write(
1888            mem_dir.join("holder.md"),
1889            "---\ntype: decision\n---\n\n# Holder\n\n## Decision\n\nHolds anchors.\n",
1890        )
1891        .unwrap();
1892
1893        // Now change ONE source entity — the pilot's move.
1894        std::fs::write(
1895            mem_dir.join("pinned.md"),
1896            "---\ntype: decision\n---\n\n# Pinned\n\n## Decision\n\nBody rewritten.\n",
1897        )
1898        .unwrap();
1899
1900        let engine = crate::Engine::from_workspace_root(root).unwrap();
1901        let resolved = engine.entity_anchors_resolved(&EntityId::canonical("mem--holder"));
1902        let state_of = |artifact: &str| {
1903            resolved
1904                .iter()
1905                .find(|r| r.anchor.artifact == artifact)
1906                .unwrap_or_else(|| panic!("no resolved anchor for {artifact}"))
1907                .state
1908        };
1909
1910        assert_eq!(
1911            state_of("mem--pinned"),
1912            Some(AnchorState::Drifted),
1913            "a stale-pinned anchor over a CHANGED entity must be drifted — \
1914             this is the pilot failure that went unflagged"
1915        );
1916        assert_eq!(
1917            state_of("mem--steady"),
1918            Some(AnchorState::Resolves),
1919            "an anchor over an unchanged entity still resolves"
1920        );
1921        assert_eq!(
1922            state_of("mem--vanished"),
1923            Some(AnchorState::Orphaned),
1924            "an anchor over an entity that is not there is orphaned, not unobserved"
1925        );
1926
1927        // The complement: a `url` grain genuinely cannot be observed, and must
1928        // stay unobserved rather than being swept up by the widened arm.
1929        let mut sc2 = AnchorSidecar::default();
1930        sc2.set(
1931            "mem--holder",
1932            vec![Anchor {
1933                artifact: "https://example.invalid/doc".to_string(),
1934                grain: AnchorGrain::Url,
1935                class: AnchorProvenanceClass::InformedBy,
1936                hash: None,
1937                source: None,
1938                binding: None,
1939                at_version: None,
1940                derived_from: Vec::new(),
1941                hash_stability: crate::anchor::AnchorHashStability::Stable,
1942            }],
1943        );
1944        std::fs::write(
1945            mem_dir.join(".memstead").join("anchors.json"),
1946            sc2.to_bytes(),
1947        )
1948        .unwrap();
1949        let engine = crate::Engine::from_workspace_root(root).unwrap();
1950        let url_state =
1951            engine.entity_anchors_resolved(&EntityId::canonical("mem--holder"))[0].state;
1952        assert_eq!(
1953            url_state, None,
1954            "url anchors stay unobserved — the fix widens observation, never the \
1955             scoring of non-observation"
1956        );
1957    }
1958
1959    /// Criterion 6's regression pin: the graph change-detection half is
1960    /// untouched except for the deliberate unscoped gate. A SCOPED graph
1961    /// facet still routes to the graph strategy and reports the same
1962    /// no-signal reason it always did when the source mem exposes no
1963    /// snapshot token (a folder mem tracks no head); an UNSCOPED one now
1964    /// refuses as `Unscoped`, exactly as the git and mtime arms have always
1965    /// done. Distinguishing the two is the whole point — before this, an
1966    /// unscoped graph facet silently proceeded.
1967    #[test]
1968    fn graph_scoping_changes_only_the_unscoped_arm() {
1969        use crate::binding::BuildMode;
1970
1971        let tmp = tempfile::tempdir().unwrap();
1972        let root = tmp.path();
1973        let mem_dir = root.join("srcmem");
1974        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1975        std::fs::write(
1976            mem_dir.join(".memstead").join("config.json"),
1977            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1978        )
1979        .unwrap();
1980        std::fs::write(
1981            mem_dir.join("one.md"),
1982            "---\ntype: decision\n---\n\n# One\n\n## Decision\n\nBody.\n",
1983        )
1984        .unwrap();
1985        std::fs::create_dir_all(root.join(".memstead")).unwrap();
1986        std::fs::write(
1987            root.join(".memstead").join("workspace.toml"),
1988            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1989        )
1990        .unwrap();
1991        {
1992            use crate::workspace::{
1993                Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
1994            };
1995            use crate::workspace_store::WorkspaceStoreAdapter;
1996            crate::FileWorkspaceStore::new()
1997                .save_state(
1998                    root,
1999                    &Workspace {
2000                        mounts: vec![Mount {
2001                            mem: "srcmem".to_string(),
2002                            schema: Some("default@1.0.0".parse().unwrap()),
2003                            storage: MountStorage::Folder {
2004                                path: mem_dir.clone(),
2005                            },
2006                            capability: MountCapability::Write,
2007                            lifecycle: MountLifecycle::Eager,
2008                            cross_linkable: false,
2009                            migration_target: None,
2010                        }],
2011                        settings: WorkspaceSettings::default(),
2012                    },
2013                )
2014                .unwrap();
2015        }
2016        let engine = crate::Engine::from_workspace_root(root).unwrap();
2017
2018        let resolved_with = |scope: Vec<crate::pipeline::PatternEntry>| ResolvedIngest {
2019            name: "srcmem/p".to_string(),
2020            mode: BuildMode::Discovery,
2021            trigger: crate::pipeline::IngestTrigger::Manual,
2022            batch_size: 20,
2023            deny_paths: Vec::new(),
2024            projection_ref: "srcmem/p".to_string(),
2025            projection_mem: "srcmem".to_string(),
2026            projection_name: "p".to_string(),
2027            intent: None,
2028            sources: vec![ResolvedSource::Primary(Source {
2029                name: "g".to_string(),
2030                medium_type: MediumType::Graph,
2031                pointer: "srcmem".to_string(),
2032                change_detection: None,
2033                scope,
2034                engagement: None,
2035                preparation: None,
2036            })],
2037            destination_mem: "srcmem".to_string(),
2038            rules: None,
2039            post_actions: None,
2040        };
2041
2042        let scoped = compute_source_cursor(
2043            &engine,
2044            &resolved_with(vec![crate::pipeline::PatternEntry {
2045                path: "*".to_string(),
2046                mode: PatternMode::Allow,
2047            }]),
2048            root,
2049        );
2050        let unscoped = compute_source_cursor(&engine, &resolved_with(Vec::new()), root);
2051
2052        let reason_of = |c: &SourceCursor| c.no_signal.first().map(|n| n.reason);
2053        assert_eq!(
2054            reason_of(&scoped),
2055            Some(NoSignalReason::GraphSnapshotMissing),
2056            "a scoped graph facet still routes to the graph strategy and reports \
2057             its own no-signal reason — the change-detection half is untouched"
2058        );
2059        assert_eq!(
2060            reason_of(&unscoped),
2061            Some(NoSignalReason::Unscoped),
2062            "an unscoped graph facet refuses like every other medium's, instead of \
2063             silently proceeding"
2064        );
2065    }
2066
2067    /// The git medium enumerates through the same path walk as codebase and
2068    /// filesystem — its artifacts are paths pinned at a commit, so the walk is
2069    /// identical and only the anchor namespace differs. It was excluded from
2070    /// that arm for no reason beyond the arm's shape, which made its
2071    /// `enumerable: true` row a claim nothing delivered. Pinned so a refactor
2072    /// cannot quietly drop it back out.
2073    #[test]
2074    fn git_medium_enumerates_through_the_path_walk() {
2075        let ws = tempfile::tempdir().unwrap();
2076        let root = ws.path();
2077        std::fs::write(root.join("a.rs"), "").unwrap();
2078        std::fs::write(root.join("b.rs"), "").unwrap();
2079
2080        let source = |medium: MediumType| Source {
2081            name: "s".to_string(),
2082            medium_type: medium,
2083            pointer: ".".to_string(),
2084            change_detection: None,
2085            scope: vec![crate::pipeline::PatternEntry {
2086                path: "**/*.rs".to_string(),
2087                mode: PatternMode::Allow,
2088            }],
2089            engagement: None,
2090            preparation: None,
2091        };
2092
2093        let want = vec!["a.rs".to_string(), "b.rs".to_string()];
2094        for medium in [
2095            MediumType::Codebase,
2096            MediumType::Filesystem,
2097            MediumType::Git,
2098        ] {
2099            assert_eq!(
2100                enumerate_facet_files(&source(medium), &[], root),
2101                want,
2102                "{medium:?} walks the file tree — every medium the matrix marks \
2103                 enumerable with a path namespace must actually enumerate"
2104            );
2105            assert!(
2106                crate::binding::medium_capabilities(medium).enumerable,
2107                "{medium:?} claims enumerability, and now delivers it"
2108            );
2109        }
2110    }
2111
2112    /// A narrowing selector must bound the CHANGED SLICE, not only `S(D)`.
2113    /// It used to bound only enumeration, so a brief could print
2114    /// `Entities: type:concept` and then present a changed `memo` two
2115    /// sections below — an artifact its own coverage model calls out of
2116    /// scope, which `advance` would accept because its gate is the presented
2117    /// slice. Scope interpreted in one place and decorative in the other is
2118    /// the defect this pins closed.
2119    #[test]
2120    fn a_narrowing_selector_bounds_the_changed_slice_too() {
2121        use crate::workspace::{
2122            Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2123        };
2124        use crate::workspace_store::WorkspaceStoreAdapter;
2125
2126        let tmp = tempfile::tempdir().unwrap();
2127        let root = tmp.path();
2128        let mem_dir = root.join("srcmem");
2129        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2130        std::fs::write(
2131            mem_dir.join(".memstead").join("config.json"),
2132            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2133        )
2134        .unwrap();
2135        let write = |slug: &str, ty: &str| {
2136            std::fs::write(
2137                mem_dir.join(format!("{slug}.md")),
2138                format!("---\ntype: {ty}\n---\n\n# {slug}\n\n## Decision\n\nBody.\n"),
2139            )
2140            .unwrap();
2141        };
2142        write("kept", "decision");
2143        write("other", "memo");
2144        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2145        std::fs::write(
2146            root.join(".memstead").join("workspace.toml"),
2147            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2148        )
2149        .unwrap();
2150        crate::FileWorkspaceStore::new()
2151            .save_state(
2152                root,
2153                &Workspace {
2154                    mounts: vec![Mount {
2155                        mem: "srcmem".to_string(),
2156                        schema: Some("default@1.0.0".parse().unwrap()),
2157                        storage: MountStorage::Folder {
2158                            path: mem_dir.clone(),
2159                        },
2160                        capability: MountCapability::Write,
2161                        lifecycle: MountLifecycle::Eager,
2162                        cross_linkable: false,
2163                        migration_target: None,
2164                    }],
2165                    settings: WorkspaceSettings::default(),
2166                },
2167            )
2168            .unwrap();
2169        let engine = crate::Engine::from_workspace_root(root).unwrap();
2170
2171        let source = Source {
2172            name: "g".to_string(),
2173            medium_type: MediumType::Graph,
2174            pointer: "srcmem".to_string(),
2175            change_detection: None,
2176            scope: vec![crate::pipeline::PatternEntry {
2177                path: "type:decision".to_string(),
2178                mode: PatternMode::Allow,
2179            }],
2180            engagement: None,
2181            preparation: None,
2182        };
2183
2184        let mut slice = Slice {
2185            added: vec!["srcmem--other".to_string()],
2186            modified: vec!["srcmem--kept".to_string(), "srcmem--other".to_string()],
2187            deleted: vec!["srcmem--vanished".to_string()],
2188        };
2189        filter_graph_slice_to_scope(&engine, &source, &mut slice);
2190
2191        assert_eq!(
2192            slice.modified,
2193            vec!["srcmem--kept".to_string()],
2194            "the out-of-scope memo is dropped from the slice the brief presents"
2195        );
2196        assert!(
2197            slice.added.is_empty(),
2198            "an added out-of-scope entity is out of scope too"
2199        );
2200        assert_eq!(
2201            slice.deleted,
2202            vec!["srcmem--vanished".to_string()],
2203            "a DELETED entity is kept even though its type can no longer be \
2204             read — a deletion that cannot be classified must be reported, \
2205             never silently dropped"
2206        );
2207
2208        // The complement: the whole-mem selector narrows nothing.
2209        let mut wide = Slice {
2210            added: Vec::new(),
2211            modified: vec!["srcmem--kept".to_string(), "srcmem--other".to_string()],
2212            deleted: Vec::new(),
2213        };
2214        let mut all = source.clone();
2215        all.scope = vec![crate::pipeline::PatternEntry {
2216            path: "*".to_string(),
2217            mode: PatternMode::Allow,
2218        }];
2219        filter_graph_slice_to_scope(&engine, &all, &mut wide);
2220        assert_eq!(wide.modified.len(), 2, "`*` selects the whole mem");
2221    }
2222
2223    /// The entity-selector grammar is closed: three legal forms, everything
2224    /// else refused. A pattern that parses to `None` is a validation refusal
2225    /// at declaration — never a rule that silently selects nothing.
2226    #[test]
2227    fn entity_selector_grammar_is_closed() {
2228        use super::EntitySelector;
2229        assert_eq!(parse_entity_selector("*"), Some(EntitySelector::All));
2230        assert_eq!(
2231            parse_entity_selector("type:decision"),
2232            Some(EntitySelector::Type("decision".to_string()))
2233        );
2234        assert_eq!(
2235            parse_entity_selector("id:engine--*"),
2236            Some(EntitySelector::Id("engine--*".to_string()))
2237        );
2238        // The path glob `projection init` used to scaffold for graph sources:
2239        // it looks like scope and selects nothing. Refused, not accepted.
2240        assert_eq!(parse_entity_selector("**/*"), None);
2241        assert_eq!(parse_entity_selector("src/**"), None);
2242        assert_eq!(parse_entity_selector("type:"), None);
2243        assert_eq!(parse_entity_selector("id:"), None);
2244        assert_eq!(parse_entity_selector(""), None);
2245    }
2246
2247    /// The mtime driver reseeds on the first pass (writing the memo), then
2248    /// diffs precisely against the memoised map — including deletions.
2249    #[test]
2250    fn mtime_driver_reseeds_then_diffs_precisely() {
2251        let ws = tempfile::tempdir().unwrap();
2252        let root = ws.path();
2253        let cache = root.join(".memstead.cache").join("ingest");
2254        std::fs::write(root.join("a.rs"), "one").unwrap();
2255        std::fs::write(root.join("gone.rs"), "bye").unwrap();
2256        let source = primary(vec![PatternEntry {
2257            path: "**/*.rs".to_string(),
2258            mode: PatternMode::Allow,
2259        }]);
2260
2261        // First pass: no baseline → reseed at the current digest, memo written.
2262        let token = match compute_mtime_slice(&source, "ing", &[], root, &cache, None) {
2263            SliceOutcome::Reseed { token } => token,
2264            other => panic!("expected Reseed, got {other:?}"),
2265        };
2266
2267        // Move the source: modify a.rs (size change), delete gone.rs, add new.rs.
2268        std::fs::write(root.join("a.rs"), "one-longer").unwrap();
2269        std::fs::remove_file(root.join("gone.rs")).unwrap();
2270        std::fs::write(root.join("new.rs"), "x").unwrap();
2271
2272        // Second pass with the reseed token → precise diff from the memo.
2273        match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&token)) {
2274            SliceOutcome::Changed {
2275                slice, degraded, ..
2276            } => {
2277                assert!(
2278                    !degraded,
2279                    "memo present → precise, not a degraded full scan"
2280                );
2281                assert_eq!(slice.added, vec!["new.rs"]);
2282                assert_eq!(slice.modified, vec!["a.rs"]);
2283                assert_eq!(
2284                    slice.deleted,
2285                    vec!["gone.rs"],
2286                    "deletions come from the memo"
2287                );
2288            }
2289            other => panic!("expected Changed, got {other:?}"),
2290        }
2291
2292        // A run whose baseline aggregate is not memoised degrades to a full
2293        // scan (every current file as added, no deletions).
2294        let stale = super::super::change_detection::serialize_digest_token(
2295            &super::super::change_detection::digest_stat_map(&stat_map_for(&["absent.rs"])),
2296        );
2297        match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&stale)) {
2298            SliceOutcome::Changed { degraded, .. } => assert!(degraded, "memo miss → degraded"),
2299            other => panic!("expected degraded Changed, got {other:?}"),
2300        }
2301    }
2302
2303    fn head_sha(repo: &Path) -> String {
2304        String::from_utf8(
2305            std::process::Command::new("git")
2306                .args(["rev-parse", "HEAD"])
2307                .current_dir(repo)
2308                .output()
2309                .unwrap()
2310                .stdout,
2311        )
2312        .unwrap()
2313        .trim()
2314        .to_string()
2315    }
2316
2317    fn slice_contains(slice: &Slice, path: &str) -> bool {
2318        let p = path.to_string();
2319        slice.added.contains(&p) || slice.modified.contains(&p) || slice.deleted.contains(&p)
2320    }
2321
2322    /// The mtime `source_moved` / `current_primary_token` value: the digest
2323    /// token over the deny-filtered enumeration — exactly what the mtime branch
2324    /// of `current_primary_token` computes.
2325    fn mtime_token(source: &Source, deny: &[String], root: &Path) -> String {
2326        let files = enumerate_facet_files(source, deny, root);
2327        serialize_digest_token(&digest_stat_map(&compute_stat_map(&files, root)))
2328    }
2329
2330    /// AC1 (deny invariance): a file matching an ingest `deny_paths` entry
2331    /// appears in **no** changed slice (git, mtime), **no** refinement batch,
2332    /// and does **not** influence the mtime digest / `source_moved` token —
2333    /// exercising the *same* denied file across every strategy that reads a
2334    /// file tree.
2335    #[test]
2336    fn deny_paths_excluded_from_every_strategy_and_token() {
2337        use crate::binding::BuildMode;
2338        use crate::ingest::refinement::next_batch;
2339        use crate::pipeline::IngestTrigger;
2340
2341        let repo = tempfile::tempdir().unwrap();
2342        let root = repo.path();
2343        let cache = root.join(".memstead.cache").join("ingest");
2344
2345        // One tree that is both the git work tree and the mtime/refinement
2346        // workspace root (medium_pointer "" → base == root).
2347        git(root, &["init", "-q"]);
2348        std::fs::write(root.join("keep.rs"), "one").unwrap();
2349        std::fs::write(root.join("denied.rs"), "secret-one").unwrap();
2350        git(root, &["add", "-A"]);
2351        git(root, &["commit", "-qm", "base"]);
2352        let baseline = head_sha(root);
2353
2354        // Both files genuinely move — denied.rs must never surface anywhere.
2355        std::fs::write(root.join("keep.rs"), "two").unwrap();
2356        std::fs::write(root.join("denied.rs"), "secret-two").unwrap();
2357        git(root, &["add", "-A"]);
2358        git(root, &["commit", "-qm", "move"]);
2359
2360        // Scope allows every .rs; the ingest denies denied.rs by the same
2361        // workspace-relative glob grammar the git strategy uses.
2362        let source = primary(vec![PatternEntry {
2363            path: "**/*.rs".to_string(),
2364            mode: PatternMode::Allow,
2365        }]);
2366        let deny = vec!["denied.rs".to_string()];
2367
2368        // (1) git slice — with the deny, only keep.rs.
2369        match compute_git_slice(&source, &deny, root, Some(&baseline)) {
2370            SliceOutcome::Changed { slice, .. } => {
2371                assert_eq!(slice.modified, vec!["keep.rs"]);
2372                assert!(!slice_contains(&slice, "denied.rs"), "git deny leak");
2373            }
2374            other => panic!("git: expected Changed, got {other:?}"),
2375        }
2376        // Control: without the deny, denied.rs *is* a real change — proving the
2377        // deny (not the scope) is what excludes it above.
2378        match compute_git_slice(&source, &[], root, Some(&baseline)) {
2379            SliceOutcome::Changed { slice, .. } => {
2380                assert!(
2381                    slice_contains(&slice, "denied.rs"),
2382                    "un-denied, denied.rs is a genuine git change"
2383                );
2384            }
2385            other => panic!("git(no-deny): expected Changed, got {other:?}"),
2386        }
2387
2388        // (2) enumeration (mtime input set + refinement source set).
2389        assert_eq!(enumerate_facet_files(&source, &deny, root), vec!["keep.rs"]);
2390        assert!(
2391            enumerate_facet_files(&source, &[], root).contains(&"denied.rs".to_string()),
2392            "un-denied, denied.rs is enumerated"
2393        );
2394
2395        // (2b) mtime slice — reseed, then move both files; only keep.rs surfaces.
2396        let token = match compute_mtime_slice(&source, "ing", &deny, root, &cache, None) {
2397            SliceOutcome::Reseed { token } => token,
2398            other => panic!("mtime reseed expected, got {other:?}"),
2399        };
2400        std::fs::write(root.join("keep.rs"), "three-longer").unwrap();
2401        std::fs::write(root.join("denied.rs"), "secret-three-longer").unwrap();
2402        match compute_mtime_slice(&source, "ing", &deny, root, &cache, Some(&token)) {
2403            SliceOutcome::Changed { slice, .. } => {
2404                assert_eq!(slice.modified, vec!["keep.rs"]);
2405                assert!(!slice_contains(&slice, "denied.rs"), "mtime deny leak");
2406            }
2407            other => panic!("mtime: expected Changed, got {other:?}"),
2408        }
2409
2410        // (3) mtime digest / source_moved token — invariant to denied.rs, since
2411        // the token is the digest over the deny-filtered enumeration. Removing
2412        // denied.rs from disk leaves the token unchanged; a leak would show it
2413        // as a deletion and shift the digest.
2414        let token_present = mtime_token(&source, &deny, root);
2415        std::fs::remove_file(root.join("denied.rs")).unwrap();
2416        let token_absent = mtime_token(&source, &deny, root);
2417        assert_eq!(
2418            token_present, token_absent,
2419            "denied.rs must not influence the mtime digest / source_moved token"
2420        );
2421        std::fs::write(root.join("denied.rs"), "secret-restored").unwrap();
2422
2423        // (4) refinement batch — the denied file is never batched.
2424        let resolved = ResolvedIngest {
2425            name: "ing".to_string(),
2426            mode: BuildMode::Discovery,
2427            trigger: IngestTrigger::Loop,
2428            batch_size: 50,
2429            deny_paths: deny.clone(),
2430            projection_ref: "m/p".to_string(),
2431            projection_mem: "m".to_string(),
2432            projection_name: "p".to_string(),
2433            intent: None,
2434            sources: vec![ResolvedSource::Primary(source.clone())],
2435            destination_mem: "m".to_string(),
2436            rules: None,
2437            post_actions: None,
2438        };
2439        let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
2440        let batch = next_batch(&engine, &resolved, root, &cache, 20).unwrap();
2441        assert!(
2442            batch.files.contains(&"keep.rs".to_string()),
2443            "keep.rs batched"
2444        );
2445        assert!(
2446            !batch.files.contains(&"denied.rs".to_string()),
2447            "denied.rs must never enter a refinement batch"
2448        );
2449    }
2450
2451    /// AC2 (one empty-scope semantic): an **unscoped** facet (no allow
2452    /// patterns) is the same typed refusal — `NoSignal { Unscoped }` — on git
2453    /// AND mtime, never a silent empty slice. AC2 complement: an empty
2454    /// `deny_paths` list does NOT trip that refusal — a *scoped* facet still
2455    /// classifies normally (empty scope and empty deny_paths are different
2456    /// fields with different semantics).
2457    #[test]
2458    fn unscoped_facet_refuses_uniformly_and_empty_deny_is_distinct() {
2459        let repo = tempfile::tempdir().unwrap();
2460        let root = repo.path();
2461        let cache = root.join(".memstead.cache").join("ingest");
2462        git(root, &["init", "-q"]);
2463        std::fs::write(root.join("a.rs"), "one").unwrap();
2464        git(root, &["add", "-A"]);
2465        git(root, &["commit", "-qm", "base"]);
2466        let baseline = head_sha(root);
2467        std::fs::write(root.join("a.rs"), "two").unwrap();
2468        git(root, &["add", "-A"]);
2469        git(root, &["commit", "-qm", "move"]);
2470
2471        // Unscoped: a deny pattern but no allow. `deny_paths` is empty here —
2472        // so the refusal comes from the empty *scope*, not from denies.
2473        let unscoped = primary(vec![PatternEntry {
2474            path: "target/**".to_string(),
2475            mode: PatternMode::Deny,
2476        }]);
2477        assert_eq!(
2478            compute_git_slice(&unscoped, &[], root, Some(&baseline)),
2479            SliceOutcome::NoSignal {
2480                reason: NoSignalReason::Unscoped
2481            },
2482            "git refuses an unscoped facet"
2483        );
2484        assert_eq!(
2485            compute_mtime_slice(&unscoped, "ing", &[], root, &cache, None),
2486            SliceOutcome::NoSignal {
2487                reason: NoSignalReason::Unscoped
2488            },
2489            "mtime refuses an unscoped facet identically"
2490        );
2491        // A fully empty scope is unscoped too.
2492        let empty_scope = primary(vec![]);
2493        assert_eq!(
2494            compute_git_slice(&empty_scope, &[], root, Some(&baseline)),
2495            SliceOutcome::NoSignal {
2496                reason: NoSignalReason::Unscoped
2497            }
2498        );
2499
2500        // Complement: a SCOPED facet with an empty `deny_paths` classifies
2501        // normally — empty deny_paths (no denies) must not trip the refusal.
2502        let scoped = primary(vec![PatternEntry {
2503            path: "**/*.rs".to_string(),
2504            mode: PatternMode::Allow,
2505        }]);
2506        assert!(
2507            matches!(
2508                compute_git_slice(&scoped, &[], root, Some(&baseline)),
2509                SliceOutcome::Changed { .. }
2510            ),
2511            "scoped facet + empty deny_paths → normal git slice, not a refusal"
2512        );
2513        assert!(
2514            matches!(
2515                compute_mtime_slice(&scoped, "ing", &[], root, &cache, None),
2516                SliceOutcome::Reseed { .. }
2517            ),
2518            "scoped facet + empty deny_paths → normal mtime reseed, not a refusal"
2519        );
2520    }
2521
2522    /// AC2 refinement leg: an ingest whose only source is unscoped emits no
2523    /// refinement batch — the refusal, not a silent empty batch.
2524    #[test]
2525    fn unscoped_facet_emits_no_refinement_batch() {
2526        use crate::binding::BuildMode;
2527        use crate::ingest::refinement::next_batch;
2528        use crate::pipeline::IngestTrigger;
2529
2530        let ws = tempfile::tempdir().unwrap();
2531        let root = ws.path();
2532        let cache = root.join(".memstead.cache").join("ingest");
2533        std::fs::write(root.join("a.rs"), "x").unwrap();
2534
2535        let resolved = ResolvedIngest {
2536            name: "ing".to_string(),
2537            mode: BuildMode::Discovery,
2538            trigger: IngestTrigger::Loop,
2539            batch_size: 50,
2540            deny_paths: vec![],
2541            projection_ref: "m/p".to_string(),
2542            projection_mem: "m".to_string(),
2543            projection_name: "p".to_string(),
2544            intent: None,
2545            // Only source: an unscoped facet (no allow patterns).
2546            sources: vec![ResolvedSource::Primary(primary(vec![]))],
2547            destination_mem: "m".to_string(),
2548            rules: None,
2549            post_actions: None,
2550        };
2551        assert!(
2552            next_batch(
2553                &crate::Engine::from_mounts(Vec::new()).unwrap(),
2554                &resolved,
2555                root,
2556                &cache,
2557                20
2558            )
2559            .is_none(),
2560            "an all-unscoped ingest emits no refinement batch"
2561        );
2562    }
2563
2564    /// AC3 (visible NoSignal) end-to-end through the cursor: a `signal:none`
2565    /// source and an unscoped source each contribute a distinct no-signal note;
2566    /// a first-seen (reseed) source does NOT — only no-signal reasons are
2567    /// noted. The rendered preface names `signal:none` explicitly and the
2568    /// unscoped reason distinctly.
2569    #[test]
2570    fn compute_source_cursor_notes_no_signal_reasons() {
2571        use crate::binding::BuildMode;
2572        use crate::pipeline::IngestTrigger;
2573
2574        let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
2575        // No `.git` over the workspace → mtime strategy for `auto`/`mtime`.
2576        let ws = tempfile::tempdir().unwrap();
2577        let root = ws.path();
2578        std::fs::write(root.join("a.rs"), "x").unwrap();
2579
2580        let allow_rs = || {
2581            vec![PatternEntry {
2582                path: "**/*.rs".to_string(),
2583                mode: PatternMode::Allow,
2584            }]
2585        };
2586        let src = |facet: &str, declared: &str, scope: Vec<PatternEntry>| {
2587            ResolvedSource::Primary(Source {
2588                name: facet.to_string(),
2589                medium_type: MediumType::Filesystem,
2590                pointer: String::new(),
2591                change_detection: Some(declared.to_string()),
2592                scope,
2593                engagement: None,
2594                preparation: None,
2595            })
2596        };
2597
2598        let resolved = ResolvedIngest {
2599            name: "ing".to_string(),
2600            mode: BuildMode::Discovery,
2601            trigger: IngestTrigger::Loop,
2602            batch_size: 20,
2603            deny_paths: vec![],
2604            projection_ref: "m/p".to_string(),
2605            projection_mem: "m".to_string(),
2606            projection_name: "p".to_string(),
2607            intent: None,
2608            sources: vec![
2609                // signal:none → DetectionNone note (even though it is scoped).
2610                src("plan", "none", allow_rs()),
2611                // mtime + no allows → Unscoped note.
2612                src("blind", "mtime", vec![]),
2613                // mtime + allows, first-seen → Reseed, NOT a no-signal note.
2614                src("watched", "mtime", allow_rs()),
2615            ],
2616            destination_mem: "m".to_string(),
2617            rules: None,
2618            post_actions: None,
2619        };
2620
2621        let cursor = compute_source_cursor(&engine, &resolved, root);
2622        let reasons: BTreeMap<&str, NoSignalReason> = cursor
2623            .no_signal
2624            .iter()
2625            .map(|n| (n.source.as_str(), n.reason))
2626            .collect();
2627        assert_eq!(reasons.get("plan"), Some(&NoSignalReason::DetectionNone));
2628        assert_eq!(reasons.get("blind"), Some(&NoSignalReason::Unscoped));
2629        assert!(
2630            !reasons.contains_key("watched"),
2631            "a first-seen (reseed) source is not a no-signal note"
2632        );
2633        assert_eq!(cursor.no_signal.len(), 2);
2634        // The reseed source still produced a reseed command.
2635        assert!(cursor.reseed.iter().any(|c| c.key == "ing/watched#synced"));
2636
2637        // The rendered preface names signal:none and the unscoped reason.
2638        let out = crate::ingest::brief::render_changed_slice(&cursor);
2639        assert!(out.contains("- `plan`: `signal:none`"));
2640        assert!(out.contains("- `blind`: unscoped facet"));
2641    }
2642
2643    fn stat_map_for(paths: &[&str]) -> super::super::change_detection::StatMap {
2644        paths
2645            .iter()
2646            .map(|p| {
2647                (
2648                    (*p).to_string(),
2649                    super::super::change_detection::StatEntry { mtime: 1, size: 1 },
2650                )
2651            })
2652            .collect()
2653    }
2654
2655    /// Engine self-exclusion: `.memstead/**`, `.memstead.cache/**`, and
2656    /// every mount's resolved storage location (here a mem-repo at a
2657    /// NON-default directory name) are absent from the enumeration
2658    /// regardless of configuration — explicit allow globs covering them
2659    /// do not admit them.
2660    #[test]
2661    fn engine_state_never_enumerates_even_when_allowed() {
2662        let ws = tempfile::tempdir().unwrap();
2663        let root = ws.path();
2664        for rel in [
2665            ".memstead/state/findings/muehle/f.json",
2666            ".memstead/projections/muehle/f.json",
2667            ".memstead.cache/ingest/source-cursor/muehle/f/f.json",
2668            "custom-repo/README.md",
2669            "Allgemein/Protokoll.md",
2670            "Allgemein/Vertrag.md",
2671        ] {
2672            let path = root.join(rel);
2673            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2674            std::fs::write(&path, "x").unwrap();
2675        }
2676        // Engine-managed workspace state resolving the mem-repo at
2677        // `custom-repo/` — the exclusion must key on this resolved
2678        // location, not on the literal default name `mem-repo/`.
2679        std::fs::write(
2680            root.join(".memstead/workspace.toml"),
2681            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2682        )
2683        .unwrap();
2684        std::fs::write(
2685            root.join(".memstead/state/mounts.json"),
2686            serde_json::json!({
2687                "format": "memstead-mounts-3",
2688                "mounts": [{
2689                    "mem": "muehle",
2690                    "schema": "default@1.0.0",
2691                    "storage": {
2692                        "type": "git-branch",
2693                        "gitdir": "custom-repo/.git",
2694                        "branch": "refs/heads/muehle"
2695                    },
2696                    "capability": "write",
2697                    "lifecycle": "eager",
2698                    "cross_linkable": true
2699                }]
2700            })
2701            .to_string(),
2702        )
2703        .unwrap();
2704
2705        // Allow everything AND explicitly try to admit engine state.
2706        let source = primary(vec![
2707            PatternEntry {
2708                path: "**/*".to_string(),
2709                mode: PatternMode::Allow,
2710            },
2711            PatternEntry {
2712                path: ".memstead/**".to_string(),
2713                mode: PatternMode::Allow,
2714            },
2715            PatternEntry {
2716                path: "custom-repo/**".to_string(),
2717                mode: PatternMode::Allow,
2718            },
2719        ]);
2720        let got = enumerate_facet_files(&source, &[], root);
2721        assert_eq!(
2722            got,
2723            vec!["Allgemein/Protokoll.md", "Allgemein/Vertrag.md"],
2724            "only source artifacts may enter the denominator"
2725        );
2726    }
2727
2728    /// The git strategy pushes the same engine-state excludes as
2729    /// pathspecs: a diff touching `.memstead/**` and the resolved
2730    /// mem-repo path yields a slice naming neither — denominator and
2731    /// slice stay strategy-invariant.
2732    #[test]
2733    fn git_slice_excludes_engine_state() {
2734        let repo = tempfile::tempdir().unwrap();
2735        let root = repo.path();
2736        git(root, &["init", "-q"]);
2737        std::fs::write(
2738            root.join("workspace.rs"), // placeholder so base commit is non-empty
2739            "x",
2740        )
2741        .unwrap();
2742        std::fs::create_dir_all(root.join(".memstead/state")).unwrap();
2743        std::fs::write(
2744            root.join(".memstead/workspace.toml"),
2745            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2746        )
2747        .unwrap();
2748        std::fs::write(
2749            root.join(".memstead/state/mounts.json"),
2750            serde_json::json!({
2751                "format": "memstead-mounts-3",
2752                "mounts": [{
2753                    "mem": "muehle",
2754                    "schema": "default@1.0.0",
2755                    "storage": {
2756                        "type": "git-branch",
2757                        "gitdir": "custom-repo/.git",
2758                        "branch": "refs/heads/muehle"
2759                    },
2760                    "capability": "write",
2761                    "lifecycle": "eager",
2762                    "cross_linkable": true
2763                }]
2764            })
2765            .to_string(),
2766        )
2767        .unwrap();
2768        git(root, &["add", "-A"]);
2769        git(root, &["commit", "-qm", "base"]);
2770        let baseline = String::from_utf8(
2771            std::process::Command::new("git")
2772                .args(["rev-parse", "HEAD"])
2773                .current_dir(root)
2774                .output()
2775                .unwrap()
2776                .stdout,
2777        )
2778        .unwrap()
2779        .trim()
2780        .to_string();
2781
2782        // Move: one real file, one engine-state file, one mem-repo file.
2783        std::fs::write(root.join("real.md"), "signal").unwrap();
2784        std::fs::write(root.join(".memstead/state/findings.json"), "self").unwrap();
2785        std::fs::create_dir_all(root.join("custom-repo")).unwrap();
2786        std::fs::write(root.join("custom-repo/README.md"), "repo").unwrap();
2787        git(root, &["add", "-A"]);
2788        git(root, &["commit", "-qm", "move"]);
2789
2790        let source = primary(vec![PatternEntry {
2791            path: "**/*".to_string(),
2792            mode: PatternMode::Allow,
2793        }]);
2794        match compute_git_slice(&source, &[], root, Some(&baseline)) {
2795            SliceOutcome::Changed { slice, .. } => {
2796                assert_eq!(
2797                    slice.added,
2798                    vec!["real.md"],
2799                    "engine state leaked: {slice:?}"
2800                );
2801                assert!(slice.modified.is_empty(), "{slice:?}");
2802            }
2803            other => panic!("expected Changed, got {other:?}"),
2804        }
2805    }
2806
2807    /// The dead-deny lint never flags the scaffold's own default hygiene
2808    /// entries (they can never match on a git-enumerated source — the
2809    /// engine must not call its own output a typo), while a user-authored
2810    /// entry that matches nothing keeps the loud warning and one that
2811    /// matches stays silent.
2812    #[test]
2813    fn dead_deny_lint_exempts_scaffold_defaults_but_not_user_typos() {
2814        use crate::binding::{BuildMode, DEFAULT_SCAFFOLD_DENY_PATHS};
2815        use crate::pipeline::IngestTrigger;
2816
2817        let repo = tempfile::tempdir().unwrap();
2818        let root = repo.path();
2819        git(root, &["init", "-q"]);
2820        std::fs::create_dir_all(root.join("src")).unwrap();
2821        std::fs::write(root.join("src/lib.rs"), "code").unwrap();
2822
2823        let mut deny_paths: Vec<String> = DEFAULT_SCAFFOLD_DENY_PATHS
2824            .iter()
2825            .map(|s| s.to_string())
2826            .collect();
2827        deny_paths.push("typo/**".to_string()); // user typo — matches nothing
2828        deny_paths.push("src/**".to_string()); // user entry that matches
2829
2830        let resolved = ResolvedIngest {
2831            name: "ing".to_string(),
2832            mode: BuildMode::Discovery,
2833            trigger: IngestTrigger::Loop,
2834            batch_size: 20,
2835            deny_paths,
2836            projection_ref: "m/p".to_string(),
2837            projection_mem: "m".to_string(),
2838            projection_name: "p".to_string(),
2839            intent: None,
2840            sources: vec![ResolvedSource::Primary(primary(vec![PatternEntry {
2841                path: "**/*.rs".to_string(),
2842                mode: PatternMode::Allow,
2843            }]))],
2844            destination_mem: "m".to_string(),
2845            rules: None,
2846            post_actions: None,
2847        };
2848
2849        let dead = dead_deny_entries(&resolved, root);
2850        assert_eq!(
2851            dead,
2852            vec!["typo/**".to_string()],
2853            "only the user typo is flagged — scaffold defaults and matching \
2854             entries stay silent"
2855        );
2856    }
2857}