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