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, reading the active list from an engine-written cache
40//! file. [`write_active_deny_file`] publishes that file during brief rendering
41//! (remove-then-write, overwrite-always), so hook enforcement tracks the last
42//! rendered ingest and is never stale. A deny entry that selects **no file** in
43//! the project tree is surfaced as a rendered brief warning
44//! ([`SourceCursor::dead_denies`]) rather than silently no-op'ing — catching
45//! typos and un-migrated legacy bare names, never a hard error.
46//!
47//! **One empty-scope semantic.** A facet with **no allow patterns** is
48//! *unscoped* — and that is a **typed refusal**, identical on every file-tree
49//! strategy: git, mtime, and refinement all decline to diff or enumerate the
50//! whole medium (a `facet_unscoped` check gates it). No strategy silently emits an
51//! empty slice, enumeration, or batch for an unscoped facet; instead the source
52//! contributes [`NoSignalReason::Unscoped`], which renders in the brief. A
53//! facet that genuinely wants the whole medium writes `**/*`. This is a
54//! different field from the ingest's `deny_paths`: an **empty `deny_paths`**
55//! list is valid and means "no denies" — it never trips the unscoped refusal.
56//!
57//! **Visible no-signal.** Every source contributes a per-source outcome. A
58//! genuinely-unchanged source (baseline present, nothing moved) stays silent —
59//! the only documented silence, preserving the "brief is byte-identical to a
60//! plain roam when nothing moved" property. Every other no-signal condition —
61//! unscoped facet, `signal:none`, git failure / unknown baseline, missing graph
62//! snapshot — is collected as a [`NoSignalNote`] and rendered distinguishably.
63//!
64//! Load-bearing invariant: the new baseline `token` is only *collected* here
65//! (into `write_commands` / `reseed`); it is recorded by the engine's
66//! `set_mem_sync_state` writer when `projection advance` completes a full pass
67//! (D7). The driver never writes it.
68
69use std::collections::BTreeMap;
70use std::path::{Component, Path, PathBuf};
71use std::process::Command;
72
73use globset::{Glob, GlobSet, GlobSetBuilder};
74
75use crate::Engine;
76use crate::pipeline::{MediumType, PatternMode};
77
78use super::brief::{NoSignalNote, SourceCursor, SyncCommand};
79use super::change_detection::{
80    StatMap, compute_stat_map, digest_stat_map, parse_digest_token, serialize_digest_token,
81};
82use super::resolve::{
83    ChangeStrategy, ResolvedIngest, ResolvedSource, find_git_root, resolve_change_strategy,
84};
85use super::slice::{
86    NoSignalReason, Slice, SliceOutcome, graph_slice_outcome, is_git_token, mtime_slice_outcome,
87};
88use crate::pipeline::Source;
89
90/// Lexically normalize a path — resolve `.` and `..` without touching the
91/// filesystem (no symlink resolution), matching Node's `path.resolve` on an
92/// already-absolute path.
93fn normalize_lexical(path: &Path) -> PathBuf {
94    let mut out: Vec<Component> = Vec::new();
95    for comp in path.components() {
96        match comp {
97            Component::CurDir => {}
98            Component::ParentDir => match out.last() {
99                Some(Component::Normal(_)) => {
100                    out.pop();
101                }
102                Some(Component::RootDir | Component::Prefix(_)) => {}
103                _ => out.push(comp),
104            },
105            other => out.push(other),
106        }
107    }
108    out.iter().collect()
109}
110
111/// The relative path from `from` to `to` (both normalized), matching Node's
112/// `path.relative`.
113fn relative_path(from: &Path, to: &Path) -> PathBuf {
114    let from = normalize_lexical(from);
115    let to = normalize_lexical(to);
116    let from_comps: Vec<Component> = from.components().collect();
117    let to_comps: Vec<Component> = to.components().collect();
118    let mut common = 0;
119    while common < from_comps.len()
120        && common < to_comps.len()
121        && from_comps[common] == to_comps[common]
122    {
123        common += 1;
124    }
125    let mut result = PathBuf::new();
126    for _ in common..from_comps.len() {
127        result.push("..");
128    }
129    for comp in &to_comps[common..] {
130        result.push(comp.as_os_str());
131    }
132    result
133}
134
135/// The medium pointer resolved to an absolute base directory. Public
136/// so init-time surfaces (CLI `projection init`) can resolve a medium
137/// base exactly as the strategies do — e.g. to warn when it falls
138/// outside the workspace root.
139pub fn medium_base(pointer: &str, workspace_root: &Path) -> PathBuf {
140    if pointer.is_empty() {
141        workspace_root.to_path_buf()
142    } else {
143        normalize_lexical(&workspace_root.join(pointer))
144    }
145}
146
147/// Workspace-relative deny globs excluding the engine's own state from
148/// every strategy's input set. Unconditional and non-configurable: a
149/// binding can never legitimately model `.memstead/`,
150/// `.memstead.cache/`, or a mount's resolved storage location as
151/// source artifacts — an allow glob covering them does not admit them.
152/// The dot-directories key on their *names* (the names are the
153/// contract, and a foreign workspace's `.memstead/` is still engine
154/// state); the mount storage locations key on their *resolved* paths
155/// because their directory names are configurable. Fail-open on an
156/// unreadable mount list: the name-based excludes stay in force.
157fn engine_state_denies(workspace_root: &Path) -> Vec<String> {
158    use crate::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
159
160    let mut denies: Vec<String> = vec![
161        ".memstead/**".to_string(),
162        ".memstead.cache/**".to_string(),
163        "**/.memstead/**".to_string(),
164        "**/.memstead.cache/**".to_string(),
165    ];
166    if let Ok(ws) = FileWorkspaceStore.load(workspace_root) {
167        for mount in &ws.mounts {
168            let dir: Option<PathBuf> = match &mount.storage {
169                crate::workspace::MountStorage::GitBranch { gitdir, .. } => {
170                    gitdir.parent().map(Path::to_path_buf)
171                }
172                crate::workspace::MountStorage::Folder { path } => Some(path.clone()),
173                crate::workspace::MountStorage::Archive { path, .. } => {
174                    // A sealed archive is one file, not a tree.
175                    let rel = relative_path(workspace_root, &normalize_lexical(path));
176                    denies.push(rel.to_string_lossy().to_string());
177                    None
178                }
179                // No on-disk footprint to exclude.
180                crate::workspace::MountStorage::InMemory => None,
181            };
182            if let Some(dir) = dir {
183                let rel = relative_path(workspace_root, &normalize_lexical(&dir));
184                // A collapsed single-mem folder workspace stores the mem
185                // AT the workspace root — excluding `**` there would
186                // empty every denominator; skip it.
187                if !rel.as_os_str().is_empty() {
188                    denies.push(format!("{}/**", rel.to_string_lossy()));
189                }
190            }
191        }
192    }
193    denies
194}
195
196/// `git rev-parse HEAD` in `git_root`, or `None` on any failure.
197fn git_head(git_root: &Path) -> Option<String> {
198    let out = Command::new("git")
199        .args(["rev-parse", "HEAD"])
200        .current_dir(git_root)
201        .output()
202        .ok()?;
203    if !out.status.success() {
204        return None;
205    }
206    let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
207    (!sha.is_empty()).then_some(sha)
208}
209
210/// Translate a workspace-relative facet pattern into a git pathspec relative
211/// to `git_root`, with `:(glob)` magic (or `:(glob,exclude)` for a deny).
212///
213/// A `**`-prefixed pattern is prefix-free — it matches under any directory,
214/// in particular the medium subtree — so it is emitted verbatim as a
215/// git-root-relative glob. Lexically re-rooting it (join + relativize) would
216/// produce `../**/…` for any non-root medium pointer, and git *fatals* on an
217/// out-of-tree pathspec, sinking the whole diff into a no-signal degrade.
218fn to_git_pathspec(pattern: &str, git_root: &Path, workspace_root: &Path, exclude: bool) -> String {
219    let magic = if exclude {
220        ":(glob,exclude)"
221    } else {
222        ":(glob)"
223    };
224    if pattern.starts_with("**") {
225        return format!("{magic}{pattern}");
226    }
227    let resolved = normalize_lexical(&workspace_root.join(pattern));
228    let git_rel = relative_path(git_root, &resolved);
229    format!("{magic}{}", git_rel.to_string_lossy())
230}
231
232/// Like [`to_git_pathspec`], but `None` when the pattern resolves *outside*
233/// `git_root` (its git-relative path escapes with a leading `..`). Git fatals
234/// on an out-of-tree pathspec, so a cross-repo deny must be dropped from the
235/// diff rather than pushed — it can match nothing in this repo regardless.
236fn in_repo_pathspec(
237    pattern: &str,
238    git_root: &Path,
239    workspace_root: &Path,
240    exclude: bool,
241) -> Option<String> {
242    // Prefix-free glob — same verbatim re-anchoring as `to_git_pathspec`.
243    if pattern.starts_with("**") {
244        return Some(to_git_pathspec(pattern, git_root, workspace_root, exclude));
245    }
246    let resolved = normalize_lexical(&workspace_root.join(pattern));
247    let git_rel = relative_path(git_root, &resolved);
248    if git_rel
249        .components()
250        .next()
251        .is_some_and(|c| c == Component::ParentDir)
252    {
253        return None;
254    }
255    let magic = if exclude {
256        ":(glob,exclude)"
257    } else {
258        ":(glob)"
259    };
260    Some(format!("{magic}{}", git_rel.to_string_lossy()))
261}
262
263/// Build a [`GlobSet`] from workspace-relative glob patterns, or `None` if
264/// any pattern is malformed.
265fn build_glob_set(patterns: &[&str]) -> Option<GlobSet> {
266    let mut builder = GlobSetBuilder::new();
267    for pattern in patterns {
268        builder.add(Glob::new(pattern).ok()?);
269    }
270    builder.build().ok()
271}
272
273/// Whether a primary source's facet declares **no allow patterns** — an
274/// *unscoped* facet. This is the single condition behind the uniform
275/// empty-scope refusal ([`NoSignalReason::Unscoped`]): neither git nor mtime
276/// diffs or enumerates the whole medium for such a facet, and refinement emits
277/// no batch for it. It is orthogonal to the ingest's `deny_paths` — an empty
278/// deny list is not an unscoped facet.
279fn facet_unscoped(source: &Source) -> bool {
280    !source.scope.iter().any(|r| r.mode == PatternMode::Allow)
281}
282
283/// Enumerate the workspace-relative file paths a primary source's facet scope
284/// selects — the `mtime` strategy's input set. Mirrors the plugin's
285/// `enumerateFacetFiles`: only `codebase`/`filesystem` mediums; the facet's
286/// allow globs minus its deny globs, evaluated over the medium's directory
287/// tree. Returns a sorted, de-duplicated list. An unscoped facet (no allows)
288/// yields an empty list here — but callers must not treat that as signal: the
289/// strategy layer (`compute_mtime_slice` / `current_primary_token`) refuses
290/// an unscoped facet via `facet_unscoped` *before* enumerating, so the empty
291/// list is only ever reached for a genuinely-empty scoped enumeration.
292///
293/// `deny_paths` are the ingest-level denies (`ResolvedIngest::deny_paths`),
294/// applied on top of the facet's own scope denies with the *same*
295/// workspace-relative glob grammar the git strategy pushes down as
296/// `:(glob,exclude)` pathspecs — so a denied file is excluded from the mtime
297/// input set exactly as it is from the git diff. Passing `&[]` yields the
298/// facet-scope-only behaviour.
299pub fn enumerate_facet_files(
300    source: &Source,
301    deny_paths: &[String],
302    workspace_root: &Path,
303) -> Vec<String> {
304    if !matches!(
305        source.medium_type,
306        MediumType::Codebase | MediumType::Filesystem
307    ) {
308        return Vec::new();
309    }
310    let mut allows: Vec<&str> = Vec::new();
311    let mut denies: Vec<&str> = Vec::new();
312    for rule in &source.scope {
313        match rule.mode {
314            PatternMode::Allow => allows.push(&rule.path),
315            PatternMode::Deny => denies.push(&rule.path),
316        }
317    }
318    // Ingest deny_paths deny on top of the facet's own denies, sharing the
319    // facet-scope glob grammar (workspace-relative, matched against each
320    // candidate's workspace-relative path) — the same entries the git strategy
321    // resolves as exclude pathspecs, so deny enforcement is strategy-invariant.
322    for dp in deny_paths {
323        denies.push(dp);
324    }
325    // Engine self-exclusion — unconditional, below configuration; the
326    // git strategy pushes the same set as exclude pathspecs so the
327    // denominator stays strategy-invariant.
328    let forced = engine_state_denies(workspace_root);
329    for f in &forced {
330        denies.push(f);
331    }
332    if allows.is_empty() {
333        return Vec::new();
334    }
335    let Some(allow_set) = build_glob_set(&allows) else {
336        return Vec::new();
337    };
338    let deny_set = if denies.is_empty() {
339        None
340    } else {
341        build_glob_set(&denies)
342    };
343
344    // Walk the medium's directory tree; the facet patterns are
345    // workspace-relative, so each candidate is matched by its
346    // workspace-relative path. VCS internals are never source artifacts —
347    // they are pruned here so `.git/**` plumbing cannot enter `S(D)`,
348    // matching the git strategy (whose diffs never name `.git` files).
349    let base = medium_base(&source.pointer, workspace_root);
350    let mut out: Vec<String> = Vec::new();
351    let mut stack = vec![base];
352    while let Some(dir) = stack.pop() {
353        let Ok(entries) = std::fs::read_dir(&dir) else {
354            continue;
355        };
356        for entry in entries.flatten() {
357            let Ok(file_type) = entry.file_type() else {
358                continue;
359            };
360            let path = entry.path();
361            if file_type.is_dir() {
362                let skip = path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
363                    // VCS internals and engine state are never source
364                    // artifacts — pruning here saves the walk; the
365                    // forced deny globs enforce the same exclusion for
366                    // anything that still slips into a candidate list.
367                    VCS_INTERNAL_DIRS.contains(&n) || n == ".memstead" || n == ".memstead.cache"
368                });
369                if !skip {
370                    stack.push(path);
371                }
372            } else if file_type.is_file() {
373                let rel = relative_path(workspace_root, &normalize_lexical(&path))
374                    .to_string_lossy()
375                    .to_string();
376                let denied = deny_set.as_ref().is_some_and(|d| d.is_match(&rel));
377                if allow_set.is_match(&rel) && !denied {
378                    out.push(rel);
379                }
380            }
381        }
382    }
383    out.sort();
384    out.dedup();
385    out
386}
387
388/// Compute the git changed slice for one primary source between its stored
389/// baseline commit and the tree's current `HEAD`. Mirrors `computeGitSlice`.
390fn compute_git_slice(
391    source: &Source,
392    deny_paths: &[String],
393    workspace_root: &Path,
394    baseline: Option<&str>,
395) -> SliceOutcome {
396    let base = medium_base(&source.pointer, workspace_root);
397    let Some(git_root) = find_git_root(&base) else {
398        return SliceOutcome::NoSignal {
399            reason: NoSignalReason::GitUnavailable,
400        };
401    };
402    let Some(head) = git_head(&git_root) else {
403        return SliceOutcome::NoSignal {
404            reason: NoSignalReason::GitUnavailable,
405        };
406    };
407
408    let baseline = match baseline {
409        Some(b) if is_git_token(b) => b,
410        // No usable commit baseline — seed at HEAD, present no slice.
411        _ => return SliceOutcome::Reseed { token: head },
412    };
413    if baseline == head {
414        return SliceOutcome::Unchanged { token: head };
415    }
416
417    // Pathspecs from the facet scope + the ingest's deny_paths.
418    let mut allows: Vec<&str> = Vec::new();
419    let mut denies: Vec<&str> = Vec::new();
420    for rule in &source.scope {
421        match rule.mode {
422            PatternMode::Allow => allows.push(&rule.path),
423            PatternMode::Deny => denies.push(&rule.path),
424        }
425    }
426    if allows.is_empty() {
427        // Unscoped facet — the uniform typed refusal (never diff the whole
428        // repo); renders in the brief rather than degrading silently.
429        return SliceOutcome::NoSignal {
430            reason: NoSignalReason::Unscoped,
431        };
432    }
433    for dp in deny_paths {
434        denies.push(dp);
435    }
436    // Engine self-exclusion — same forced set the mtime strategy's
437    // enumeration applies, pushed down as exclude pathspecs so the
438    // slice never names engine state either.
439    let forced = engine_state_denies(workspace_root);
440    for f in &forced {
441        denies.push(f);
442    }
443    let mut specs: Vec<String> = Vec::with_capacity(allows.len() + denies.len());
444    for a in &allows {
445        specs.push(to_git_pathspec(a, &git_root, workspace_root, false));
446    }
447    for d in &denies {
448        // A deny may target a path OUTSIDE this medium's git repo — a
449        // cross-medium workspace-relative glob such as `../dev/**`, whose tree
450        // lives in a sibling repo. Git *fatals* on an out-of-tree pathspec
451        // (`'../dev/**' is outside repository`), which would sink the entire
452        // diff into a no-signal degrade. Such a deny can exclude nothing here
453        // anyway (the files simply aren't in this repo), so drop it: the plugin
454        // hook still enforces it agent-side (workspace-relative, cross-repo),
455        // and a genuinely-dead entry is still surfaced by the brief warning.
456        if let Some(spec) = in_repo_pathspec(d, &git_root, workspace_root, true) {
457            specs.push(spec);
458        }
459    }
460
461    let mut cmd = Command::new("git");
462    cmd.args([
463        "diff",
464        "--no-renames",
465        "--name-status",
466        baseline,
467        &head,
468        "--",
469    ]);
470    cmd.args(&specs);
471    cmd.current_dir(&git_root);
472    let out = match cmd.output() {
473        Ok(o) if o.status.success() => o,
474        // Unknown baseline (gc'd / rewritten), an out-of-repo pathspec, or a
475        // git failure — degrade to a whole re-roam (the plugin does the same).
476        _ => {
477            return SliceOutcome::NoSignal {
478                reason: NoSignalReason::GitUnavailable,
479            };
480        }
481    };
482    let text = String::from_utf8_lossy(&out.stdout);
483
484    let mut slice = Slice::default();
485    for line in text.lines() {
486        if line.trim().is_empty() {
487            continue;
488        }
489        let Some(tab) = line.find('\t') else { continue };
490        let status = line[..tab].trim();
491        let git_path = line[tab + 1..].trim();
492        let ws_path = relative_path(workspace_root, &normalize_lexical(&git_root.join(git_path)))
493            .to_string_lossy()
494            .to_string();
495        match status.chars().next() {
496            Some('A') => slice.added.push(ws_path),
497            Some('D') => slice.deleted.push(ws_path),
498            // M, T (type change), C, and the rest.
499            _ => slice.modified.push(ws_path),
500        }
501    }
502    slice.added.sort();
503    slice.modified.sort();
504    slice.deleted.sort();
505    SliceOutcome::Changed {
506        token: head,
507        slice,
508        degraded: false,
509    }
510}
511
512/// Compute the graph changed slice for a source mem between its stored
513/// baseline snapshot token and the mem's current head. Mirrors
514/// `computeGraphSlice`, using the engine's own change history.
515fn compute_graph_slice(engine: &Engine, source_mem: &str, baseline: Option<&str>) -> SliceOutcome {
516    let current = match engine.mem_head_sha(source_mem) {
517        Ok(Some(sha)) => sha,
518        // Source has no snapshot signal, or is unknown — degrade.
519        _ => {
520            return SliceOutcome::NoSignal {
521                reason: NoSignalReason::GraphSnapshotMissing,
522            };
523        }
524    };
525    // Fetch the entity delta only when the source actually moved.
526    let changed = matches!(baseline, Some(b) if is_git_token(b) && b != current);
527    if changed {
528        let baseline = baseline.expect("changed implies a baseline");
529        match engine.changes_since(source_mem, baseline, None) {
530            Ok(report) => graph_slice_outcome(Some(baseline), &current, &report.changes),
531            // Unknown baseline / engine error — degrade.
532            Err(_) => SliceOutcome::NoSignal {
533                reason: NoSignalReason::GraphSnapshotMissing,
534            },
535        }
536    } else {
537        graph_slice_outcome(baseline, &current, &[])
538    }
539}
540
541// ── mtime source-cursor memo ────────────────────────────────────────────────
542//
543// The `mtime` strategy's durable baseline is a small digest token (in the
544// destination mem's `sync_state`), which cannot by itself say *which* files
545// changed. The engine keeps a rebuildable memo — the full stat map keyed by
546// its digest aggregate — so a run whose baseline matches a memoised aggregate
547// diffs precisely (incl. deletions) instead of degrading to a full scan.
548//
549// The memo lives engine-side under `<workspace>/.memstead.cache/ingest/` in
550// the plugin's format (`{aggregate: {relpath: {mtime, size}}}`), so the engine
551// and the transition-era skill share it. It is pure engine-internal cache —
552// not mem-repo, not the graph — so writing it during brief rendering is not a
553// tracked mutation. A write failure only costs the next run's precision.
554
555/// The `<cache_root>/source-cursor/<ingest>/<facet>.json` memo path.
556fn cursor_memo_path(cache_root: &Path, ingest_name: &str, facet_ref: &str) -> PathBuf {
557    let safe: String = facet_ref
558        .chars()
559        .map(|c| {
560            if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
561                c
562            } else {
563                '_'
564            }
565        })
566        .collect();
567    cache_root
568        .join("source-cursor")
569        .join(ingest_name)
570        .join(format!("{safe}.json"))
571}
572
573/// Read the stat map memoised under `aggregate` for a facet, or `None` on miss.
574fn read_cursor_memo(
575    cache_root: &Path,
576    ingest: &str,
577    facet: &str,
578    aggregate: &str,
579) -> Option<StatMap> {
580    let bytes = std::fs::read(cursor_memo_path(cache_root, ingest, facet)).ok()?;
581    let memo: BTreeMap<String, StatMap> = serde_json::from_slice(&bytes).ok()?;
582    memo.get(aggregate).cloned()
583}
584
585/// Memoise the current stat map under its aggregate, bounding the file to the
586/// 3 most-recent aggregates. Best-effort.
587fn write_cursor_memo(cache_root: &Path, ingest: &str, facet: &str, aggregate: &str, map: &StatMap) {
588    let path = cursor_memo_path(cache_root, ingest, facet);
589    let mut memo: BTreeMap<String, StatMap> = std::fs::read(&path)
590        .ok()
591        .and_then(|b| serde_json::from_slice(&b).ok())
592        .unwrap_or_default();
593    memo.insert(aggregate.to_string(), map.clone());
594    if memo.len() > 3 {
595        // Keep the just-written aggregate plus up to two others.
596        let drop: Vec<String> = memo
597            .keys()
598            .filter(|k| k.as_str() != aggregate)
599            .skip(2)
600            .cloned()
601            .collect();
602        for key in drop {
603            memo.remove(&key);
604        }
605    }
606    if let Some(parent) = path.parent() {
607        let _ = std::fs::create_dir_all(parent);
608    }
609    if let Ok(bytes) = serde_json::to_vec(&memo) {
610        let _ = std::fs::write(&path, bytes);
611    }
612}
613
614// ── active-deny hook channel & dead-deny detection ──────────────────────────
615//
616// The plugin's PreToolUse deny hook (`deny-meta-files.mjs`) blocks the ingest
617// agent from Read/Glob/Grep against the *active* ingest's `deny_paths`. It
618// reads the list from an engine-written cache file; the engine writes that file
619// during brief rendering (below), so the hook always enforces the list of the
620// ingest whose brief was last rendered — never a stale one. Same
621// workspace-relative glob dialect the engine resolves here.
622
623/// The hook's active-deny cache path:
624/// `<workspace>/.memstead.cache/projection/active-deny-paths.json`.
625fn active_deny_path(workspace_root: &Path) -> PathBuf {
626    workspace_root
627        .join(".memstead.cache")
628        .join("projection")
629        .join("active-deny-paths.json")
630}
631
632/// Write the active ingest's deny list for the plugin hook, **stale-safe**.
633///
634/// Rendering a brief for ingest X publishes X's name and X's (dialect-normalized)
635/// deny entries here; a later render for Y overwrites it. An ingest with an
636/// empty `deny_paths` writes an explicit empty list (so the hook enforces
637/// *nothing*, rather than inheriting a previous ingest's list).
638///
639/// **Remove-then-write:** the previous file is unlinked *before* the new write,
640/// so a failed write can never leave X's list in place to be enforced against
641/// Y. Best-effort like the mtime memo (engine-internal cache, not a tracked
642/// mutation) — but the failure mode is fail-*closed* (no file ⇒ the hook
643/// blocks nothing), never fail-stale.
644pub fn write_active_deny_file(workspace_root: &Path, ingest_name: &str, deny_paths: &[String]) {
645    let path = active_deny_path(workspace_root);
646    // Unlink first: a subsequent write failure then leaves *no* file rather
647    // than a stale previous-ingest file the hook would keep enforcing.
648    let _ = std::fs::remove_file(&path);
649    if let Some(parent) = path.parent() {
650        let _ = std::fs::create_dir_all(parent);
651    }
652    let payload = serde_json::json!({
653        "ingest": ingest_name,
654        "deny_paths": deny_paths,
655    });
656    if let Ok(bytes) = serde_json::to_vec(&payload) {
657        let _ = std::fs::write(&path, bytes);
658    }
659}
660
661/// VCS metadata directories — never source artifacts. Pruned from source
662/// enumeration (`S(D)`, mtime slices, advance) and from the dead-deny scan.
663const VCS_INTERNAL_DIRS: &[&str] = &[".git", ".svn", ".hg"];
664
665/// Directory names never worth walking for the dead-deny scan — build output,
666/// VCS metadata ([`VCS_INTERNAL_DIRS`]), dependency caches, and the engine's
667/// own cache.
668const DEAD_DENY_SKIP_DIRS: &[&str] = &[
669    ".git",
670    "node_modules",
671    "target",
672    "dist",
673    ".memstead.cache",
674    ".sqlx",
675    ".svn",
676    ".hg",
677];
678
679/// Bounded, pruned walk of `base` collecting every file's **workspace-relative**
680/// path (the same string space the deny globs match). Skips heavy directories
681/// ([`DEAD_DENY_SKIP_DIRS`]) and gives up (returns `None`) past `cap` files, so
682/// the dead-deny scan degrades to "can't tell" rather than warning falsely or
683/// walking an unbounded tree. Best-effort: unreadable directories are skipped.
684fn walk_tree_bounded(base: &Path, workspace_root: &Path, cap: usize) -> Option<Vec<String>> {
685    let mut out: Vec<String> = Vec::new();
686    let mut stack = vec![base.to_path_buf()];
687    while let Some(dir) = stack.pop() {
688        let Ok(entries) = std::fs::read_dir(&dir) else {
689            continue;
690        };
691        for entry in entries.flatten() {
692            let Ok(file_type) = entry.file_type() else {
693                continue;
694            };
695            let path = entry.path();
696            if file_type.is_dir() {
697                let skip = path
698                    .file_name()
699                    .and_then(|n| n.to_str())
700                    .is_some_and(|n| DEAD_DENY_SKIP_DIRS.contains(&n));
701                if !skip {
702                    stack.push(path);
703                }
704            } else if file_type.is_file() {
705                if out.len() >= cap {
706                    return None;
707                }
708                out.push(
709                    relative_path(workspace_root, &normalize_lexical(&path))
710                        .to_string_lossy()
711                        .to_string(),
712                );
713            }
714        }
715    }
716    Some(out)
717}
718
719/// The ingest `deny_paths` entries that select **no file** in the project tree
720/// — surfaced as a rendered brief warning (AC 6 refusal leg) so a zero-matching
721/// deny is never a silent no-op. Resolution base is the medium's git project
722/// root (so a cross-medium workspace-relative deny like `../dev/**`, whose
723/// target lives outside a sub-medium, still resolves against real files),
724/// falling back to the workspace root. Uses the *same* [`build_glob_set`]
725/// matcher the strategies use, so "does this deny select anything" is answered
726/// with the identical dialect. Best-effort: if the tree can't be enumerated
727/// (walk cap hit, no readable base) nothing is reported — a warning is only
728/// ever raised on a confirmed zero-match.
729fn dead_deny_entries(resolved: &ResolvedIngest, workspace_root: &Path) -> Vec<String> {
730    if resolved.deny_paths.is_empty() {
731        return Vec::new();
732    }
733    let base = find_git_root(workspace_root).unwrap_or_else(|| workspace_root.to_path_buf());
734    let Some(files) = walk_tree_bounded(&base, workspace_root, 100_000) else {
735        return Vec::new();
736    };
737    let mut dead: Vec<String> = Vec::new();
738    for entry in &resolved.deny_paths {
739        let Some(set) = build_glob_set(&[entry.as_str()]) else {
740            // A malformed glob can't be resolved either way — not a confirmed
741            // zero-match, so it is not reported here.
742            continue;
743        };
744        if !files.iter().any(|f| set.is_match(f)) {
745            dead.push(entry.clone());
746        }
747    }
748    dead
749}
750
751/// Compute the `mtime` changed slice for one primary source: enumerate the
752/// facet files, stat them, memoise the current map, and diff against the
753/// baseline digest's memoised map (precise) or degrade to a full scan on memo
754/// miss. Mirrors the mtime branch of the plugin's `computeSourceCursor`.
755fn compute_mtime_slice(
756    source: &Source,
757    ingest_name: &str,
758    deny_paths: &[String],
759    workspace_root: &Path,
760    cache_root: &Path,
761    baseline: Option<&str>,
762) -> SliceOutcome {
763    if facet_unscoped(source) {
764        // Unscoped facet — the same typed refusal git raises, so the mtime
765        // strategy never enumerates the whole medium nor emits an empty slice.
766        return SliceOutcome::NoSignal {
767            reason: NoSignalReason::Unscoped,
768        };
769    }
770    let files = enumerate_facet_files(source, deny_paths, workspace_root);
771    let now_map = compute_stat_map(&files, workspace_root);
772    let now_digest = digest_stat_map(&now_map);
773    write_cursor_memo(
774        cache_root,
775        ingest_name,
776        &source.name,
777        &now_digest.aggregate,
778        &now_map,
779    );
780    let prev_map = baseline
781        .and_then(parse_digest_token)
782        .and_then(|base| read_cursor_memo(cache_root, ingest_name, &source.name, &base.aggregate));
783    mtime_slice_outcome(baseline, prev_map.as_ref(), &now_map)
784}
785
786/// The current change-detection token for a primary source, per its resolved
787/// strategy: git `HEAD`, the graph mem's snapshot, or the freshly-computed
788/// mtime digest. `None` when there is no signal.
789fn current_primary_token(
790    engine: &Engine,
791    source: &Source,
792    deny_paths: &[String],
793    workspace_root: &Path,
794) -> Option<String> {
795    match resolve_change_strategy(source, workspace_root) {
796        ChangeStrategy::Git => git_head(&find_git_root(&medium_base(
797            &source.pointer,
798            workspace_root,
799        ))?),
800        ChangeStrategy::Graph => engine.mem_head_sha(&source.pointer).ok().flatten(),
801        ChangeStrategy::Mtime => {
802            if facet_unscoped(source) {
803                // Unscoped facet has no signal — not an empty-set digest posing
804                // as one, so the source can never register as "moved".
805                None
806            } else {
807                let files = enumerate_facet_files(source, deny_paths, workspace_root);
808                Some(serialize_digest_token(&digest_stat_map(&compute_stat_map(
809                    &files,
810                    workspace_root,
811                ))))
812            }
813        }
814        ChangeStrategy::None => None,
815    }
816}
817
818/// Whether any of an ingest's sources moved since its last synced pass — the
819/// cheap, slice-free predicate the backoff uses as its additive second
820/// trigger. Compares each source's current token to the baseline stored in the
821/// destination mem's `sync_state`; a source with no baseline is not "moved"
822/// (a first sync does not by itself defeat backoff). Mirrors the plugin's
823/// `sourceChangedSince`.
824pub fn source_moved(engine: &Engine, resolved: &ResolvedIngest, workspace_root: &Path) -> bool {
825    source_moved_since(engine, resolved, workspace_root, "synced", false)
826}
827
828/// The generalized form of [`source_moved`]: compare each source's current
829/// change-detection token against the baseline stored under
830/// `"<binding>/<facet>#<state>"` in the destination mem's `sync_state`. The
831/// `state` suffix selects the baseline family — `"synced"` (the build/sync
832/// baseline [`source_moved`] reads) or `"verified"` (the verify baseline).
833///
834/// `missing_baseline_is_moved` decides the never-recorded case: `false`
835/// preserves [`source_moved`]'s posture (no baseline ⇒ not "moved" — a first
836/// sync does not by itself defeat backoff); `true` treats a source with a live
837/// current token but no recorded baseline as moved — the verify due-check's
838/// posture, where "never verified" means the first verify is due.
839pub fn source_moved_since(
840    engine: &Engine,
841    resolved: &ResolvedIngest,
842    workspace_root: &Path,
843    state: &str,
844    missing_baseline_is_moved: bool,
845) -> bool {
846    let dest = &resolved.destination_mem;
847    let baseline_map = engine
848        .mem_config_for(dest)
849        .map(|c| c.sync_state.clone())
850        .unwrap_or_default();
851
852    for source in &resolved.sources {
853        let (facet_ref, current) = match source {
854            ResolvedSource::Primary(p) => (
855                p.name.clone(),
856                current_primary_token(engine, p, &resolved.deny_paths, workspace_root),
857            ),
858            ResolvedSource::Reference { mem } => {
859                (mem.clone(), engine.mem_head_sha(mem).ok().flatten())
860            }
861        };
862        let key = format!("{}/{}#{state}", resolved.name, facet_ref);
863        let Some(baseline) = baseline_map.get(&key) else {
864            // No baseline recorded for this state family.
865            if missing_baseline_is_moved && current.as_deref().is_some_and(|c| !c.is_empty()) {
866                return true;
867            }
868            continue;
869        };
870        if let Some(current) = current
871            && !current.is_empty()
872            && current != *baseline
873        {
874            return true;
875        }
876    }
877    false
878}
879
880/// Assemble the combined [`SourceCursor`] for an ingest from live state: the
881/// destination mem's `sync_state` baselines and each source's current state.
882pub fn compute_source_cursor(
883    engine: &Engine,
884    resolved: &ResolvedIngest,
885    workspace_root: &Path,
886) -> SourceCursor {
887    let dest = &resolved.destination_mem;
888    let baseline_map = engine
889        .mem_config_for(dest)
890        .map(|c| c.sync_state.clone())
891        .unwrap_or_default();
892
893    let cache_root = workspace_root.join(".memstead.cache").join("ingest");
894    let mut union = Slice::default();
895    let mut write_commands: Vec<SyncCommand> = Vec::new();
896    let mut reseed: Vec<SyncCommand> = Vec::new();
897    let mut no_signal: Vec<NoSignalNote> = Vec::new();
898    let mut degraded = false;
899
900    for source in &resolved.sources {
901        // Key: "<ingest>/<facet_ref>" for primaries, "<ingest>/<mem>" for
902        // reference sources — matching the plugin's sync_state keying.
903        let (facet_ref, outcome) = match source {
904            ResolvedSource::Primary(p) => {
905                let key = format!("{}/{}#synced", resolved.name, p.name);
906                let baseline = baseline_map.get(&key).map(String::as_str);
907                let outcome = match resolve_change_strategy(p, workspace_root) {
908                    ChangeStrategy::Git => {
909                        compute_git_slice(p, &resolved.deny_paths, workspace_root, baseline)
910                    }
911                    // A graph-typed primary's medium pointer is the source mem id.
912                    ChangeStrategy::Graph => compute_graph_slice(engine, &p.pointer, baseline),
913                    ChangeStrategy::Mtime => compute_mtime_slice(
914                        p,
915                        &resolved.name,
916                        &resolved.deny_paths,
917                        workspace_root,
918                        &cache_root,
919                        baseline,
920                    ),
921                    // `none` is inert — a rendered `signal:none` state, no slice.
922                    ChangeStrategy::None => SliceOutcome::NoSignal {
923                        reason: NoSignalReason::DetectionNone,
924                    },
925                };
926                (p.name.clone(), outcome)
927            }
928            ResolvedSource::Reference { mem } => {
929                let key = format!("{}/{}#synced", resolved.name, mem);
930                let baseline = baseline_map.get(&key).map(String::as_str);
931                (mem.clone(), compute_graph_slice(engine, mem, baseline))
932            }
933        };
934
935        let key = format!("{}/{}#synced", resolved.name, facet_ref);
936        match outcome {
937            // Genuinely unchanged (baseline present, nothing moved) is the only
938            // documented silence — it renders nothing, keeping an all-unchanged
939            // brief byte-identical to a plain roam.
940            SliceOutcome::Unchanged { .. } => {}
941            // Every no-signal reason is a visible per-source note.
942            SliceOutcome::NoSignal { reason } => no_signal.push(NoSignalNote {
943                source: facet_ref.clone(),
944                reason,
945            }),
946            SliceOutcome::Reseed { token } => reseed.push(SyncCommand { key, token }),
947            SliceOutcome::Changed {
948                token,
949                slice,
950                degraded: d,
951            } => {
952                union.added.extend(slice.added);
953                union.modified.extend(slice.modified);
954                union.deleted.extend(slice.deleted);
955                degraded |= d;
956                write_commands.push(SyncCommand { key, token });
957            }
958        }
959    }
960
961    dedupe_sort(&mut union.added);
962    dedupe_sort(&mut union.modified);
963    dedupe_sort(&mut union.deleted);
964    let any_changes =
965        !union.added.is_empty() || !union.modified.is_empty() || !union.deleted.is_empty();
966
967    SourceCursor {
968        union,
969        write_commands,
970        reseed,
971        no_signal,
972        any_changes,
973        degraded,
974        dead_denies: dead_deny_entries(resolved, workspace_root),
975        dest_mem: dest.clone(),
976        // The resolved ingest's `name` is the canonical binding id `<mem>/<stem>`
977        // (via `resolve_binding_run`) — the id the `projection advance` line the
978        // brief renders (D4/D7) is keyed on.
979        binding_id: resolved.name.clone(),
980    }
981}
982
983fn dedupe_sort(v: &mut Vec<String>) {
984    v.sort();
985    v.dedup();
986}
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991
992    #[test]
993    fn normalize_resolves_dot_and_dotdot() {
994        assert_eq!(
995            normalize_lexical(Path::new("/a/b/../c/./d")),
996            PathBuf::from("/a/c/d")
997        );
998        assert_eq!(
999            normalize_lexical(Path::new("/a/../../b")),
1000            PathBuf::from("/b"),
1001            "dotdot past root is clamped"
1002        );
1003    }
1004
1005    #[test]
1006    fn relative_computes_updowns() {
1007        assert_eq!(
1008            relative_path(Path::new("/a/b"), Path::new("/a/b/c/d")),
1009            PathBuf::from("c/d")
1010        );
1011        assert_eq!(
1012            relative_path(Path::new("/a/b/c"), Path::new("/a/x")),
1013            PathBuf::from("../../x")
1014        );
1015        // A workspace whose medium is a sibling repository.
1016        assert_eq!(
1017            relative_path(Path::new("/m/public"), Path::new("/m/public/crates/x.rs")),
1018            PathBuf::from("crates/x.rs")
1019        );
1020        assert_eq!(
1021            relative_path(Path::new("/m/graph"), Path::new("/m/public/crates/x.rs")),
1022            PathBuf::from("../public/crates/x.rs")
1023        );
1024    }
1025
1026    #[test]
1027    fn pathspec_builds_glob_magic_relative_to_git_root() {
1028        let ws = Path::new("/m/graph");
1029        let git_root = Path::new("/m/public");
1030        assert_eq!(
1031            to_git_pathspec("../public/**/*.rs", git_root, ws, false),
1032            ":(glob)**/*.rs"
1033        );
1034        assert_eq!(
1035            to_git_pathspec("../public/target/**", git_root, ws, true),
1036            ":(glob,exclude)target/**"
1037        );
1038    }
1039
1040    /// A `**`-prefixed pattern (the scaffolded facet default `**/*`) is
1041    /// prefix-free and re-anchors verbatim onto the git root. Lexical
1042    /// re-rooting would yield `:(glob)../**/*` for any sub-medium — an
1043    /// out-of-tree pathspec git fatals on, degrading every diff to
1044    /// no-signal.
1045    #[test]
1046    fn wildcard_prefixed_pathspec_reanchors_verbatim() {
1047        let ws = Path::new("/m/ws");
1048        let git_root = Path::new("/m/ws/src");
1049        assert_eq!(to_git_pathspec("**/*", git_root, ws, false), ":(glob)**/*");
1050        assert_eq!(
1051            in_repo_pathspec("**/__pycache__/**", git_root, ws, true).as_deref(),
1052            Some(":(glob,exclude)**/__pycache__/**")
1053        );
1054    }
1055
1056    use crate::ingest::resolve::Source;
1057    use crate::pipeline::{MediumType, PatternEntry};
1058
1059    fn git(repo: &Path, args: &[&str]) {
1060        let status = std::process::Command::new("git")
1061            .args(args)
1062            .current_dir(repo)
1063            .env("GIT_AUTHOR_NAME", "t")
1064            .env("GIT_AUTHOR_EMAIL", "t@t")
1065            .env("GIT_COMMITTER_NAME", "t")
1066            .env("GIT_COMMITTER_EMAIL", "t@t")
1067            .output()
1068            .unwrap();
1069        assert!(
1070            status.status.success(),
1071            "git {args:?}: {}",
1072            String::from_utf8_lossy(&status.stderr)
1073        );
1074    }
1075
1076    fn primary(scope: Vec<PatternEntry>) -> Source {
1077        Source {
1078            name: "src".to_string(),
1079            medium_type: MediumType::Codebase,
1080            pointer: String::new(),
1081            change_detection: Some("git".to_string()),
1082            scope,
1083            engagement: None,
1084            preparation: None,
1085        }
1086    }
1087
1088    /// Shared deny-dialect fixture: the SAME entry list must exclude the SAME
1089    /// files from an engine slice as it blocks in the plugin hook
1090    /// (`deny-meta-files.test.js` asserts the hook half against this file).
1091    /// Proven here by materialising every `blocked` + `allowed` path into a
1092    /// temp workspace, scoping a facet to `**` (everything), applying the
1093    /// fixture `entries` as the ingest `deny_paths`, and asserting
1094    /// `enumerate_facet_files` yields exactly `allowed`.
1095    #[test]
1096    fn deny_dialect_fixture_matches_engine_slice() {
1097        let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1098            .join("../../plugins/claude-code/hooks/deny-dialect-fixture.json");
1099        let raw = std::fs::read(&fixture_path)
1100            .unwrap_or_else(|e| panic!("read fixture {}: {e}", fixture_path.display()));
1101        let fixture: serde_json::Value = serde_json::from_slice(&raw).unwrap();
1102        let strs = |key: &str| -> Vec<String> {
1103            fixture[key]
1104                .as_array()
1105                .unwrap()
1106                .iter()
1107                .map(|v| v.as_str().unwrap().to_string())
1108                .collect()
1109        };
1110        let entries = strs("entries");
1111        let blocked = strs("blocked");
1112        let allowed = strs("allowed");
1113
1114        let ws = tempfile::tempdir().unwrap();
1115        for rel in blocked.iter().chain(allowed.iter()) {
1116            let path = ws.path().join(rel);
1117            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1118            std::fs::write(&path, "x").unwrap();
1119        }
1120
1121        // Scope = everything; the ONLY exclusions are the ingest deny_paths.
1122        let source = primary(vec![PatternEntry {
1123            path: "**".to_string(),
1124            mode: PatternMode::Allow,
1125        }]);
1126        let mut got = enumerate_facet_files(&source, &entries, ws.path());
1127        got.sort();
1128        let mut want = allowed.clone();
1129        want.sort();
1130        assert_eq!(
1131            got, want,
1132            "engine slice must equal the fixture `allowed` set"
1133        );
1134
1135        for b in &blocked {
1136            assert!(
1137                !got.contains(b),
1138                "denied `{b}` leaked into the engine slice"
1139            );
1140        }
1141        for a in &allowed {
1142            assert!(
1143                got.contains(a),
1144                "allowed `{a}` missing from the engine slice"
1145            );
1146        }
1147    }
1148
1149    /// The active-deny hook channel: a render for ingest X publishes X's list;
1150    /// a render for Y overwrites it (never a stale X); an empty deny list writes
1151    /// an explicit empty array (so the hook enforces nothing, not a leftover).
1152    #[test]
1153    fn active_deny_file_overwrites_and_writes_empty() {
1154        let ws = tempfile::tempdir().unwrap();
1155        let path = active_deny_path(ws.path());
1156
1157        write_active_deny_file(ws.path(), "x-graph", &["dev/**".to_string()]);
1158        let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
1159        assert_eq!(v["ingest"], "x-graph");
1160        assert_eq!(v["deny_paths"], serde_json::json!(["dev/**"]));
1161
1162        // A later render for Y overwrites — nothing from X survives.
1163        write_active_deny_file(ws.path(), "y-graph", &["**/VISION.md".to_string()]);
1164        let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
1165        assert_eq!(v["ingest"], "y-graph");
1166        assert_eq!(v["deny_paths"], serde_json::json!(["**/VISION.md"]));
1167
1168        // An empty-deny ingest writes an explicit empty list.
1169        write_active_deny_file(ws.path(), "z-graph", &[]);
1170        let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
1171        assert_eq!(v["ingest"], "z-graph");
1172        assert_eq!(v["deny_paths"], serde_json::json!([]));
1173    }
1174
1175    /// A cross-repo deny (its target sibling to the medium's git repo)
1176    /// resolves outside `git_root` and is dropped from the pathspecs — pushing
1177    /// it would make git fatal on the whole diff. An in-repo deny is kept.
1178    #[test]
1179    fn out_of_repo_deny_pathspec_is_dropped() {
1180        let ws = Path::new("/m/graph");
1181        let git_root = Path::new("/m/public");
1182        // `../dev/**` (workspace-relative) → /m/dev/** — outside /m/public.
1183        assert_eq!(in_repo_pathspec("../dev/**", git_root, ws, true), None);
1184        assert_eq!(in_repo_pathspec("../CLAUDE.md", git_root, ws, true), None);
1185        // An in-repo deny is preserved as a normal exclude pathspec.
1186        assert_eq!(
1187            in_repo_pathspec("../public/target/**", git_root, ws, true),
1188            Some(":(glob,exclude)target/**".to_string())
1189        );
1190    }
1191
1192    /// A real git diff with a cross-repo deny present must still succeed (the
1193    /// out-of-repo pathspec is dropped, not fataled), and the in-repo scope is
1194    /// honoured. Regression for the dogfood dialect (`../dev/**` under a
1195    /// sub-medium): git must not degrade the whole slice.
1196    #[test]
1197    fn git_slice_survives_cross_repo_deny() {
1198        let repo = tempfile::tempdir().unwrap();
1199        let root = repo.path();
1200        std::fs::write(root.join("keep.rs"), "one").unwrap();
1201        git(root, &["init", "-q"]);
1202        git(root, &["add", "-A"]);
1203        git(root, &["commit", "-qm", "seed"]);
1204        let baseline = String::from_utf8(
1205            std::process::Command::new("git")
1206                .args(["rev-parse", "HEAD"])
1207                .current_dir(root)
1208                .output()
1209                .unwrap()
1210                .stdout,
1211        )
1212        .unwrap()
1213        .trim()
1214        .to_string();
1215        std::fs::write(root.join("keep.rs"), "two").unwrap();
1216        git(root, &["add", "-A"]);
1217        git(root, &["commit", "-qm", "move"]);
1218
1219        let source = primary(vec![PatternEntry {
1220            path: "**/*.rs".to_string(),
1221            mode: PatternMode::Allow,
1222        }]);
1223        // `../dev/**` resolves outside this repo — must be dropped, not fatal.
1224        let outcome = compute_git_slice(&source, &["../dev/**".to_string()], root, Some(&baseline));
1225        match outcome {
1226            SliceOutcome::Changed { slice, .. } => {
1227                assert_eq!(slice.modified, vec!["keep.rs"]);
1228            }
1229            other => panic!("expected Changed (deny dropped), got {other:?}"),
1230        }
1231    }
1232
1233    /// A real git diff: baseline commit → HEAD produces the changed slice,
1234    /// classifying added / modified / deleted and honouring the scope.
1235    #[test]
1236    fn git_slice_diffs_baseline_to_head() {
1237        let repo = tempfile::tempdir().unwrap();
1238        let root = repo.path();
1239        git(root, &["init", "-q"]);
1240        std::fs::write(root.join("keep.rs"), "one").unwrap();
1241        std::fs::write(root.join("gone.rs"), "bye").unwrap();
1242        std::fs::write(root.join("note.md"), "ignored-by-scope").unwrap();
1243        git(root, &["add", "-A"]);
1244        git(root, &["commit", "-qm", "base"]);
1245        let baseline = String::from_utf8(
1246            std::process::Command::new("git")
1247                .args(["rev-parse", "HEAD"])
1248                .current_dir(root)
1249                .output()
1250                .unwrap()
1251                .stdout,
1252        )
1253        .unwrap()
1254        .trim()
1255        .to_string();
1256
1257        // Move: modify keep.rs, delete gone.rs, add new.rs, touch note.md.
1258        std::fs::write(root.join("keep.rs"), "two").unwrap();
1259        std::fs::remove_file(root.join("gone.rs")).unwrap();
1260        std::fs::write(root.join("new.rs"), "hi").unwrap();
1261        std::fs::write(root.join("note.md"), "still ignored").unwrap();
1262        git(root, &["add", "-A"]);
1263        git(root, &["commit", "-qm", "move"]);
1264
1265        // Scope to *.rs only — note.md must not appear.
1266        let source = primary(vec![PatternEntry {
1267            path: "**/*.rs".to_string(),
1268            mode: PatternMode::Allow,
1269        }]);
1270        let outcome = compute_git_slice(&source, &[], root, Some(&baseline));
1271        match outcome {
1272            SliceOutcome::Changed {
1273                slice, degraded, ..
1274            } => {
1275                assert!(!degraded);
1276                assert_eq!(slice.added, vec!["new.rs"]);
1277                assert_eq!(slice.modified, vec!["keep.rs"]);
1278                assert_eq!(slice.deleted, vec!["gone.rs"]);
1279            }
1280            other => panic!("expected Changed, got {other:?}"),
1281        }
1282
1283        // Same baseline == HEAD → Unchanged.
1284        let head = String::from_utf8(
1285            std::process::Command::new("git")
1286                .args(["rev-parse", "HEAD"])
1287                .current_dir(root)
1288                .output()
1289                .unwrap()
1290                .stdout,
1291        )
1292        .unwrap()
1293        .trim()
1294        .to_string();
1295        assert!(matches!(
1296            compute_git_slice(&source, &[], root, Some(&head)),
1297            SliceOutcome::Unchanged { .. }
1298        ));
1299
1300        // A non-commit baseline → Reseed at HEAD.
1301        assert!(matches!(
1302            compute_git_slice(&source, &[], root, None),
1303            SliceOutcome::Reseed { .. }
1304        ));
1305    }
1306
1307    /// Facet-file enumeration honours allow globs, deny globs, and the
1308    /// codebase/filesystem medium-type gate.
1309    #[test]
1310    fn enumerate_honours_allow_and_deny() {
1311        let ws = tempfile::tempdir().unwrap();
1312        let root = ws.path();
1313        std::fs::create_dir_all(root.join("sub")).unwrap();
1314        std::fs::write(root.join("a.rs"), "").unwrap();
1315        std::fs::write(root.join("sub/b.rs"), "").unwrap();
1316        std::fs::write(root.join("c.md"), "").unwrap();
1317
1318        // medium_pointer "" → base is the workspace root; allow **/*.rs,
1319        // deny sub/** (so sub/b.rs is excluded, c.md never matched).
1320        let source = primary(vec![
1321            PatternEntry {
1322                path: "**/*.rs".to_string(),
1323                mode: PatternMode::Allow,
1324            },
1325            PatternEntry {
1326                path: "sub/**".to_string(),
1327                mode: PatternMode::Deny,
1328            },
1329        ]);
1330        assert_eq!(enumerate_facet_files(&source, &[], root), vec!["a.rs"]);
1331
1332        // A graph medium enumerates nothing (not a file tree).
1333        let mut graph_source = source.clone();
1334        graph_source.medium_type = MediumType::Graph;
1335        assert!(enumerate_facet_files(&graph_source, &[], root).is_empty());
1336    }
1337
1338    /// The mtime driver reseeds on the first pass (writing the memo), then
1339    /// diffs precisely against the memoised map — including deletions.
1340    #[test]
1341    fn mtime_driver_reseeds_then_diffs_precisely() {
1342        let ws = tempfile::tempdir().unwrap();
1343        let root = ws.path();
1344        let cache = root.join(".memstead.cache").join("ingest");
1345        std::fs::write(root.join("a.rs"), "one").unwrap();
1346        std::fs::write(root.join("gone.rs"), "bye").unwrap();
1347        let source = primary(vec![PatternEntry {
1348            path: "**/*.rs".to_string(),
1349            mode: PatternMode::Allow,
1350        }]);
1351
1352        // First pass: no baseline → reseed at the current digest, memo written.
1353        let token = match compute_mtime_slice(&source, "ing", &[], root, &cache, None) {
1354            SliceOutcome::Reseed { token } => token,
1355            other => panic!("expected Reseed, got {other:?}"),
1356        };
1357
1358        // Move the source: modify a.rs (size change), delete gone.rs, add new.rs.
1359        std::fs::write(root.join("a.rs"), "one-longer").unwrap();
1360        std::fs::remove_file(root.join("gone.rs")).unwrap();
1361        std::fs::write(root.join("new.rs"), "x").unwrap();
1362
1363        // Second pass with the reseed token → precise diff from the memo.
1364        match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&token)) {
1365            SliceOutcome::Changed {
1366                slice, degraded, ..
1367            } => {
1368                assert!(
1369                    !degraded,
1370                    "memo present → precise, not a degraded full scan"
1371                );
1372                assert_eq!(slice.added, vec!["new.rs"]);
1373                assert_eq!(slice.modified, vec!["a.rs"]);
1374                assert_eq!(
1375                    slice.deleted,
1376                    vec!["gone.rs"],
1377                    "deletions come from the memo"
1378                );
1379            }
1380            other => panic!("expected Changed, got {other:?}"),
1381        }
1382
1383        // A run whose baseline aggregate is not memoised degrades to a full
1384        // scan (every current file as added, no deletions).
1385        let stale = super::super::change_detection::serialize_digest_token(
1386            &super::super::change_detection::digest_stat_map(&stat_map_for(&["absent.rs"])),
1387        );
1388        match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&stale)) {
1389            SliceOutcome::Changed { degraded, .. } => assert!(degraded, "memo miss → degraded"),
1390            other => panic!("expected degraded Changed, got {other:?}"),
1391        }
1392    }
1393
1394    fn head_sha(repo: &Path) -> String {
1395        String::from_utf8(
1396            std::process::Command::new("git")
1397                .args(["rev-parse", "HEAD"])
1398                .current_dir(repo)
1399                .output()
1400                .unwrap()
1401                .stdout,
1402        )
1403        .unwrap()
1404        .trim()
1405        .to_string()
1406    }
1407
1408    fn slice_contains(slice: &Slice, path: &str) -> bool {
1409        let p = path.to_string();
1410        slice.added.contains(&p) || slice.modified.contains(&p) || slice.deleted.contains(&p)
1411    }
1412
1413    /// The mtime `source_moved` / `current_primary_token` value: the digest
1414    /// token over the deny-filtered enumeration — exactly what the mtime branch
1415    /// of `current_primary_token` computes.
1416    fn mtime_token(source: &Source, deny: &[String], root: &Path) -> String {
1417        let files = enumerate_facet_files(source, deny, root);
1418        serialize_digest_token(&digest_stat_map(&compute_stat_map(&files, root)))
1419    }
1420
1421    /// AC1 (deny invariance): a file matching an ingest `deny_paths` entry
1422    /// appears in **no** changed slice (git, mtime), **no** refinement batch,
1423    /// and does **not** influence the mtime digest / `source_moved` token —
1424    /// exercising the *same* denied file across every strategy that reads a
1425    /// file tree.
1426    #[test]
1427    fn deny_paths_excluded_from_every_strategy_and_token() {
1428        use crate::binding::BuildMode;
1429        use crate::ingest::refinement::next_batch;
1430        use crate::pipeline::IngestTrigger;
1431
1432        let repo = tempfile::tempdir().unwrap();
1433        let root = repo.path();
1434        let cache = root.join(".memstead.cache").join("ingest");
1435
1436        // One tree that is both the git work tree and the mtime/refinement
1437        // workspace root (medium_pointer "" → base == root).
1438        git(root, &["init", "-q"]);
1439        std::fs::write(root.join("keep.rs"), "one").unwrap();
1440        std::fs::write(root.join("denied.rs"), "secret-one").unwrap();
1441        git(root, &["add", "-A"]);
1442        git(root, &["commit", "-qm", "base"]);
1443        let baseline = head_sha(root);
1444
1445        // Both files genuinely move — denied.rs must never surface anywhere.
1446        std::fs::write(root.join("keep.rs"), "two").unwrap();
1447        std::fs::write(root.join("denied.rs"), "secret-two").unwrap();
1448        git(root, &["add", "-A"]);
1449        git(root, &["commit", "-qm", "move"]);
1450
1451        // Scope allows every .rs; the ingest denies denied.rs by the same
1452        // workspace-relative glob grammar the git strategy uses.
1453        let source = primary(vec![PatternEntry {
1454            path: "**/*.rs".to_string(),
1455            mode: PatternMode::Allow,
1456        }]);
1457        let deny = vec!["denied.rs".to_string()];
1458
1459        // (1) git slice — with the deny, only keep.rs.
1460        match compute_git_slice(&source, &deny, root, Some(&baseline)) {
1461            SliceOutcome::Changed { slice, .. } => {
1462                assert_eq!(slice.modified, vec!["keep.rs"]);
1463                assert!(!slice_contains(&slice, "denied.rs"), "git deny leak");
1464            }
1465            other => panic!("git: expected Changed, got {other:?}"),
1466        }
1467        // Control: without the deny, denied.rs *is* a real change — proving the
1468        // deny (not the scope) is what excludes it above.
1469        match compute_git_slice(&source, &[], root, Some(&baseline)) {
1470            SliceOutcome::Changed { slice, .. } => {
1471                assert!(
1472                    slice_contains(&slice, "denied.rs"),
1473                    "un-denied, denied.rs is a genuine git change"
1474                );
1475            }
1476            other => panic!("git(no-deny): expected Changed, got {other:?}"),
1477        }
1478
1479        // (2) enumeration (mtime input set + refinement source set).
1480        assert_eq!(enumerate_facet_files(&source, &deny, root), vec!["keep.rs"]);
1481        assert!(
1482            enumerate_facet_files(&source, &[], root).contains(&"denied.rs".to_string()),
1483            "un-denied, denied.rs is enumerated"
1484        );
1485
1486        // (2b) mtime slice — reseed, then move both files; only keep.rs surfaces.
1487        let token = match compute_mtime_slice(&source, "ing", &deny, root, &cache, None) {
1488            SliceOutcome::Reseed { token } => token,
1489            other => panic!("mtime reseed expected, got {other:?}"),
1490        };
1491        std::fs::write(root.join("keep.rs"), "three-longer").unwrap();
1492        std::fs::write(root.join("denied.rs"), "secret-three-longer").unwrap();
1493        match compute_mtime_slice(&source, "ing", &deny, root, &cache, Some(&token)) {
1494            SliceOutcome::Changed { slice, .. } => {
1495                assert_eq!(slice.modified, vec!["keep.rs"]);
1496                assert!(!slice_contains(&slice, "denied.rs"), "mtime deny leak");
1497            }
1498            other => panic!("mtime: expected Changed, got {other:?}"),
1499        }
1500
1501        // (3) mtime digest / source_moved token — invariant to denied.rs, since
1502        // the token is the digest over the deny-filtered enumeration. Removing
1503        // denied.rs from disk leaves the token unchanged; a leak would show it
1504        // as a deletion and shift the digest.
1505        let token_present = mtime_token(&source, &deny, root);
1506        std::fs::remove_file(root.join("denied.rs")).unwrap();
1507        let token_absent = mtime_token(&source, &deny, root);
1508        assert_eq!(
1509            token_present, token_absent,
1510            "denied.rs must not influence the mtime digest / source_moved token"
1511        );
1512        std::fs::write(root.join("denied.rs"), "secret-restored").unwrap();
1513
1514        // (4) refinement batch — the denied file is never batched.
1515        let resolved = ResolvedIngest {
1516            name: "ing".to_string(),
1517            mode: BuildMode::Discovery,
1518            trigger: IngestTrigger::Loop,
1519            batch_size: 50,
1520            deny_paths: deny.clone(),
1521            projection_ref: "m/p".to_string(),
1522            projection_mem: "m".to_string(),
1523            projection_name: "p".to_string(),
1524            intent: None,
1525            sources: vec![ResolvedSource::Primary(source.clone())],
1526            destination_mem: "m".to_string(),
1527            rules: None,
1528            post_actions: None,
1529        };
1530        let batch = next_batch(&resolved, root, &cache, 20).unwrap();
1531        assert!(
1532            batch.files.contains(&"keep.rs".to_string()),
1533            "keep.rs batched"
1534        );
1535        assert!(
1536            !batch.files.contains(&"denied.rs".to_string()),
1537            "denied.rs must never enter a refinement batch"
1538        );
1539    }
1540
1541    /// AC2 (one empty-scope semantic): an **unscoped** facet (no allow
1542    /// patterns) is the same typed refusal — `NoSignal { Unscoped }` — on git
1543    /// AND mtime, never a silent empty slice. AC2 complement: an empty
1544    /// `deny_paths` list does NOT trip that refusal — a *scoped* facet still
1545    /// classifies normally (empty scope and empty deny_paths are different
1546    /// fields with different semantics).
1547    #[test]
1548    fn unscoped_facet_refuses_uniformly_and_empty_deny_is_distinct() {
1549        let repo = tempfile::tempdir().unwrap();
1550        let root = repo.path();
1551        let cache = root.join(".memstead.cache").join("ingest");
1552        git(root, &["init", "-q"]);
1553        std::fs::write(root.join("a.rs"), "one").unwrap();
1554        git(root, &["add", "-A"]);
1555        git(root, &["commit", "-qm", "base"]);
1556        let baseline = head_sha(root);
1557        std::fs::write(root.join("a.rs"), "two").unwrap();
1558        git(root, &["add", "-A"]);
1559        git(root, &["commit", "-qm", "move"]);
1560
1561        // Unscoped: a deny pattern but no allow. `deny_paths` is empty here —
1562        // so the refusal comes from the empty *scope*, not from denies.
1563        let unscoped = primary(vec![PatternEntry {
1564            path: "target/**".to_string(),
1565            mode: PatternMode::Deny,
1566        }]);
1567        assert_eq!(
1568            compute_git_slice(&unscoped, &[], root, Some(&baseline)),
1569            SliceOutcome::NoSignal {
1570                reason: NoSignalReason::Unscoped
1571            },
1572            "git refuses an unscoped facet"
1573        );
1574        assert_eq!(
1575            compute_mtime_slice(&unscoped, "ing", &[], root, &cache, None),
1576            SliceOutcome::NoSignal {
1577                reason: NoSignalReason::Unscoped
1578            },
1579            "mtime refuses an unscoped facet identically"
1580        );
1581        // A fully empty scope is unscoped too.
1582        let empty_scope = primary(vec![]);
1583        assert_eq!(
1584            compute_git_slice(&empty_scope, &[], root, Some(&baseline)),
1585            SliceOutcome::NoSignal {
1586                reason: NoSignalReason::Unscoped
1587            }
1588        );
1589
1590        // Complement: a SCOPED facet with an empty `deny_paths` classifies
1591        // normally — empty deny_paths (no denies) must not trip the refusal.
1592        let scoped = primary(vec![PatternEntry {
1593            path: "**/*.rs".to_string(),
1594            mode: PatternMode::Allow,
1595        }]);
1596        assert!(
1597            matches!(
1598                compute_git_slice(&scoped, &[], root, Some(&baseline)),
1599                SliceOutcome::Changed { .. }
1600            ),
1601            "scoped facet + empty deny_paths → normal git slice, not a refusal"
1602        );
1603        assert!(
1604            matches!(
1605                compute_mtime_slice(&scoped, "ing", &[], root, &cache, None),
1606                SliceOutcome::Reseed { .. }
1607            ),
1608            "scoped facet + empty deny_paths → normal mtime reseed, not a refusal"
1609        );
1610    }
1611
1612    /// AC2 refinement leg: an ingest whose only source is unscoped emits no
1613    /// refinement batch — the refusal, not a silent empty batch.
1614    #[test]
1615    fn unscoped_facet_emits_no_refinement_batch() {
1616        use crate::binding::BuildMode;
1617        use crate::ingest::refinement::next_batch;
1618        use crate::pipeline::IngestTrigger;
1619
1620        let ws = tempfile::tempdir().unwrap();
1621        let root = ws.path();
1622        let cache = root.join(".memstead.cache").join("ingest");
1623        std::fs::write(root.join("a.rs"), "x").unwrap();
1624
1625        let resolved = ResolvedIngest {
1626            name: "ing".to_string(),
1627            mode: BuildMode::Discovery,
1628            trigger: IngestTrigger::Loop,
1629            batch_size: 50,
1630            deny_paths: vec![],
1631            projection_ref: "m/p".to_string(),
1632            projection_mem: "m".to_string(),
1633            projection_name: "p".to_string(),
1634            intent: None,
1635            // Only source: an unscoped facet (no allow patterns).
1636            sources: vec![ResolvedSource::Primary(primary(vec![]))],
1637            destination_mem: "m".to_string(),
1638            rules: None,
1639            post_actions: None,
1640        };
1641        assert!(
1642            next_batch(&resolved, root, &cache, 20).is_none(),
1643            "an all-unscoped ingest emits no refinement batch"
1644        );
1645    }
1646
1647    /// AC3 (visible NoSignal) end-to-end through the cursor: a `signal:none`
1648    /// source and an unscoped source each contribute a distinct no-signal note;
1649    /// a first-seen (reseed) source does NOT — only no-signal reasons are
1650    /// noted. The rendered preface names `signal:none` explicitly and the
1651    /// unscoped reason distinctly.
1652    #[test]
1653    fn compute_source_cursor_notes_no_signal_reasons() {
1654        use crate::binding::BuildMode;
1655        use crate::pipeline::IngestTrigger;
1656
1657        let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
1658        // No `.git` over the workspace → mtime strategy for `auto`/`mtime`.
1659        let ws = tempfile::tempdir().unwrap();
1660        let root = ws.path();
1661        std::fs::write(root.join("a.rs"), "x").unwrap();
1662
1663        let allow_rs = || {
1664            vec![PatternEntry {
1665                path: "**/*.rs".to_string(),
1666                mode: PatternMode::Allow,
1667            }]
1668        };
1669        let src = |facet: &str, declared: &str, scope: Vec<PatternEntry>| {
1670            ResolvedSource::Primary(Source {
1671                name: facet.to_string(),
1672                medium_type: MediumType::Filesystem,
1673                pointer: String::new(),
1674                change_detection: Some(declared.to_string()),
1675                scope,
1676                engagement: None,
1677                preparation: None,
1678            })
1679        };
1680
1681        let resolved = ResolvedIngest {
1682            name: "ing".to_string(),
1683            mode: BuildMode::Discovery,
1684            trigger: IngestTrigger::Loop,
1685            batch_size: 20,
1686            deny_paths: vec![],
1687            projection_ref: "m/p".to_string(),
1688            projection_mem: "m".to_string(),
1689            projection_name: "p".to_string(),
1690            intent: None,
1691            sources: vec![
1692                // signal:none → DetectionNone note (even though it is scoped).
1693                src("plan", "none", allow_rs()),
1694                // mtime + no allows → Unscoped note.
1695                src("blind", "mtime", vec![]),
1696                // mtime + allows, first-seen → Reseed, NOT a no-signal note.
1697                src("watched", "mtime", allow_rs()),
1698            ],
1699            destination_mem: "m".to_string(),
1700            rules: None,
1701            post_actions: None,
1702        };
1703
1704        let cursor = compute_source_cursor(&engine, &resolved, root);
1705        let reasons: BTreeMap<&str, NoSignalReason> = cursor
1706            .no_signal
1707            .iter()
1708            .map(|n| (n.source.as_str(), n.reason))
1709            .collect();
1710        assert_eq!(reasons.get("plan"), Some(&NoSignalReason::DetectionNone));
1711        assert_eq!(reasons.get("blind"), Some(&NoSignalReason::Unscoped));
1712        assert!(
1713            !reasons.contains_key("watched"),
1714            "a first-seen (reseed) source is not a no-signal note"
1715        );
1716        assert_eq!(cursor.no_signal.len(), 2);
1717        // The reseed source still produced a reseed command.
1718        assert!(cursor.reseed.iter().any(|c| c.key == "ing/watched#synced"));
1719
1720        // The rendered preface names signal:none and the unscoped reason.
1721        let out = crate::ingest::brief::render_changed_slice(&cursor);
1722        assert!(out.contains("- `plan`: `signal:none`"));
1723        assert!(out.contains("- `blind`: unscoped facet"));
1724    }
1725
1726    fn stat_map_for(paths: &[&str]) -> super::super::change_detection::StatMap {
1727        paths
1728            .iter()
1729            .map(|p| {
1730                (
1731                    (*p).to_string(),
1732                    super::super::change_detection::StatEntry { mtime: 1, size: 1 },
1733                )
1734            })
1735            .collect()
1736    }
1737
1738    /// Engine self-exclusion: `.memstead/**`, `.memstead.cache/**`, and
1739    /// every mount's resolved storage location (here a mem-repo at a
1740    /// NON-default directory name) are absent from the enumeration
1741    /// regardless of configuration — explicit allow globs covering them
1742    /// do not admit them.
1743    #[test]
1744    fn engine_state_never_enumerates_even_when_allowed() {
1745        let ws = tempfile::tempdir().unwrap();
1746        let root = ws.path();
1747        for rel in [
1748            ".memstead/state/findings/muehle/f.json",
1749            ".memstead/projections/muehle/f.json",
1750            ".memstead.cache/ingest/source-cursor/muehle/f/f.json",
1751            "custom-repo/README.md",
1752            "Allgemein/Protokoll.md",
1753            "Allgemein/Vertrag.md",
1754        ] {
1755            let path = root.join(rel);
1756            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1757            std::fs::write(&path, "x").unwrap();
1758        }
1759        // Engine-managed workspace state resolving the mem-repo at
1760        // `custom-repo/` — the exclusion must key on this resolved
1761        // location, not on the literal default name `mem-repo/`.
1762        std::fs::write(
1763            root.join(".memstead/workspace.toml"),
1764            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1765        )
1766        .unwrap();
1767        std::fs::write(
1768            root.join(".memstead/state/mounts.json"),
1769            serde_json::json!({
1770                "format": "memstead-mounts-3",
1771                "mounts": [{
1772                    "mem": "muehle",
1773                    "schema": "default@1.0.0",
1774                    "storage": {
1775                        "type": "git-branch",
1776                        "gitdir": "custom-repo/.git",
1777                        "branch": "refs/heads/muehle"
1778                    },
1779                    "capability": "write",
1780                    "lifecycle": "eager",
1781                    "cross_linkable": true
1782                }]
1783            })
1784            .to_string(),
1785        )
1786        .unwrap();
1787
1788        // Allow everything AND explicitly try to admit engine state.
1789        let source = primary(vec![
1790            PatternEntry {
1791                path: "**/*".to_string(),
1792                mode: PatternMode::Allow,
1793            },
1794            PatternEntry {
1795                path: ".memstead/**".to_string(),
1796                mode: PatternMode::Allow,
1797            },
1798            PatternEntry {
1799                path: "custom-repo/**".to_string(),
1800                mode: PatternMode::Allow,
1801            },
1802        ]);
1803        let got = enumerate_facet_files(&source, &[], root);
1804        assert_eq!(
1805            got,
1806            vec!["Allgemein/Protokoll.md", "Allgemein/Vertrag.md"],
1807            "only source artifacts may enter the denominator"
1808        );
1809    }
1810
1811    /// The git strategy pushes the same engine-state excludes as
1812    /// pathspecs: a diff touching `.memstead/**` and the resolved
1813    /// mem-repo path yields a slice naming neither — denominator and
1814    /// slice stay strategy-invariant.
1815    #[test]
1816    fn git_slice_excludes_engine_state() {
1817        let repo = tempfile::tempdir().unwrap();
1818        let root = repo.path();
1819        git(root, &["init", "-q"]);
1820        std::fs::write(
1821            root.join("workspace.rs"), // placeholder so base commit is non-empty
1822            "x",
1823        )
1824        .unwrap();
1825        std::fs::create_dir_all(root.join(".memstead/state")).unwrap();
1826        std::fs::write(
1827            root.join(".memstead/workspace.toml"),
1828            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1829        )
1830        .unwrap();
1831        std::fs::write(
1832            root.join(".memstead/state/mounts.json"),
1833            serde_json::json!({
1834                "format": "memstead-mounts-3",
1835                "mounts": [{
1836                    "mem": "muehle",
1837                    "schema": "default@1.0.0",
1838                    "storage": {
1839                        "type": "git-branch",
1840                        "gitdir": "custom-repo/.git",
1841                        "branch": "refs/heads/muehle"
1842                    },
1843                    "capability": "write",
1844                    "lifecycle": "eager",
1845                    "cross_linkable": true
1846                }]
1847            })
1848            .to_string(),
1849        )
1850        .unwrap();
1851        git(root, &["add", "-A"]);
1852        git(root, &["commit", "-qm", "base"]);
1853        let baseline = String::from_utf8(
1854            std::process::Command::new("git")
1855                .args(["rev-parse", "HEAD"])
1856                .current_dir(root)
1857                .output()
1858                .unwrap()
1859                .stdout,
1860        )
1861        .unwrap()
1862        .trim()
1863        .to_string();
1864
1865        // Move: one real file, one engine-state file, one mem-repo file.
1866        std::fs::write(root.join("real.md"), "signal").unwrap();
1867        std::fs::write(root.join(".memstead/state/findings.json"), "self").unwrap();
1868        std::fs::create_dir_all(root.join("custom-repo")).unwrap();
1869        std::fs::write(root.join("custom-repo/README.md"), "repo").unwrap();
1870        git(root, &["add", "-A"]);
1871        git(root, &["commit", "-qm", "move"]);
1872
1873        let source = primary(vec![PatternEntry {
1874            path: "**/*".to_string(),
1875            mode: PatternMode::Allow,
1876        }]);
1877        match compute_git_slice(&source, &[], root, Some(&baseline)) {
1878            SliceOutcome::Changed { slice, .. } => {
1879                assert_eq!(
1880                    slice.added,
1881                    vec!["real.md"],
1882                    "engine state leaked: {slice:?}"
1883                );
1884                assert!(slice.modified.is_empty(), "{slice:?}");
1885            }
1886            other => panic!("expected Changed, got {other:?}"),
1887        }
1888    }
1889}