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//! resolved by [`super::check_path::DenyOracle`] — the glob match plus the
37//! literal-base directory-prefix rule plus the malformed-entry fallback — the
38//! same resolver the path-check command answers with. The git strategy pushes
39//! what git's own pathspec dialect can express and filters its diff through
40//! the oracle afterwards, so the two strategies agree even on the entries
41//! pathspecs cannot express.
42//!
43//! Note that a `deny_paths` entry's resolution root is the WORKSPACE, while a
44//! facet-scope entry's is its source's `pointer` (since 2026-08-27). Those are
45//! two namespaces for two questions, not two dialects for one: an ingest deny
46//! spans every source in the binding, so it has no single pointer to be
47//! relative to. The plugin's
48//! PreToolUse deny hook enforces the *identical* dialect against the ingest
49//! agent's Read/Glob/Grep by asking the engine itself: `projection
50//! check-path` answers through [`super::check_path::check_deny_paths`], which
51//! reads the active binding's record fresh on every call (the pointer channel
52//! is [`super::check_path::write_active_binding_file`], published on
53//! consuming brief renders). A deny entry that selects **no file** in
54//! the project tree is surfaced as a rendered brief warning
55//! ([`SourceCursor::dead_denies`]) rather than silently no-op'ing — catching
56//! typos and un-migrated legacy bare names, never a hard error.
57//!
58//! **One empty-scope semantic.** A facet with **no allow patterns** is
59//! *unscoped* — and that is a **typed refusal**, identical on every file-tree
60//! strategy: git, mtime, and refinement all decline to diff or enumerate the
61//! whole medium (a `facet_unscoped` check gates it). No strategy silently emits an
62//! empty slice, enumeration, or batch for an unscoped facet; instead the source
63//! contributes [`NoSignalReason::Unscoped`], which renders in the brief. A
64//! facet that genuinely wants the whole medium writes `**/*`. This is a
65//! different field from the ingest's `deny_paths`: an **empty `deny_paths`**
66//! list is valid and means "no denies" — it never trips the unscoped refusal.
67//!
68//! **Visible no-signal.** Every source contributes a per-source outcome. A
69//! genuinely-unchanged source (baseline present, nothing moved) stays silent —
70//! the only documented silence, preserving the "brief is byte-identical to a
71//! plain roam when nothing moved" property. Every other no-signal condition —
72//! unscoped facet, `signal:none`, git failure / unknown baseline, missing graph
73//! snapshot — is collected as a [`NoSignalNote`] and rendered distinguishably.
74//!
75//! Load-bearing invariant: the new baseline `token` is only *collected* here
76//! (into `write_commands` / `reseed`); it is recorded by the engine's
77//! `set_mem_sync_state` writer when `projection advance` completes a full pass
78//! (D7). The driver never writes it.
79
80use std::collections::{BTreeMap, BTreeSet};
81use std::path::{Component, Path, PathBuf};
82use std::process::Command;
83
84use globset::{Glob, GlobSet, GlobSetBuilder};
85
86use crate::Engine;
87use crate::pipeline::{MediumType, PatternMode};
88
89use super::brief::{DeliveredUnit, DeliverySequence, NoSignalNote, SourceCursor, SyncCommand};
90use super::change_detection::{
91    StatMap, compute_stat_map, digest_stat_map, parse_digest_token, serialize_digest_token,
92};
93use super::resolve::{
94    ChangeStrategy, ResolvedIngest, ResolvedSource, find_git_root, resolve_change_strategy,
95};
96use super::slice::{
97    NoSignalReason, Slice, SliceOutcome, graph_slice_outcome, is_git_token, mtime_slice_outcome,
98};
99use crate::pipeline::Source;
100
101/// Lexically normalize a path — resolve `.` and `..` without touching the
102/// filesystem (no symlink resolution), matching Node's `path.resolve` on an
103/// already-absolute path.
104pub(super) fn normalize_lexical(path: &Path) -> PathBuf {
105    let mut out: Vec<Component> = Vec::new();
106    for comp in path.components() {
107        match comp {
108            Component::CurDir => {}
109            Component::ParentDir => match out.last() {
110                Some(Component::Normal(_)) => {
111                    out.pop();
112                }
113                Some(Component::RootDir | Component::Prefix(_)) => {}
114                _ => out.push(comp),
115            },
116            other => out.push(other),
117        }
118    }
119    out.iter().collect()
120}
121
122/// The relative path from `from` to `to` (both normalized), matching Node's
123/// `path.relative`.
124pub(super) fn relative_path(from: &Path, to: &Path) -> PathBuf {
125    let from = normalize_lexical(from);
126    let to = normalize_lexical(to);
127    let from_comps: Vec<Component> = from.components().collect();
128    let to_comps: Vec<Component> = to.components().collect();
129    let mut common = 0;
130    while common < from_comps.len()
131        && common < to_comps.len()
132        && from_comps[common] == to_comps[common]
133    {
134        common += 1;
135    }
136    let mut result = PathBuf::new();
137    for _ in common..from_comps.len() {
138        result.push("..");
139    }
140    for comp in &to_comps[common..] {
141        result.push(comp.as_os_str());
142    }
143    result
144}
145
146/// The medium pointer resolved to an absolute base directory. Public
147/// so init-time surfaces (CLI `projection init`) can resolve a medium
148/// base exactly as the strategies do — e.g. to warn when it falls
149/// outside the workspace root.
150pub fn medium_base(pointer: &str, workspace_root: &Path) -> PathBuf {
151    if pointer.is_empty() {
152        workspace_root.to_path_buf()
153    } else {
154        normalize_lexical(&workspace_root.join(pointer))
155    }
156}
157
158/// The relative path from `from` to `to`, lexically normalized — public so a
159/// caller holding two absolute paths (a workspace root and a source tree, say)
160/// can express one as a medium pointer against the other, the exact inverse of
161/// [`medium_base`].
162pub fn relative_to(from: &Path, to: &Path) -> PathBuf {
163    relative_path(from, to)
164}
165
166/// The honest caveat for a medium base that resolves outside the workspace
167/// root, or `None` when it does not — the single wording every front door
168/// that scaffolds a binding prints, so the layout split is named once, at the
169/// layout decision, in the same terms everywhere.
170///
171/// The shape is supported: enumeration, change detection, sync, and anchor
172/// resolution all work on it (measured on the dogfood's own out-of-root
173/// bindings, where zero anchors orphan). What degrades rides the message —
174/// `../…` artifact ids and a layout that must stay fixed — together with the
175/// recipe that avoids it. Only path-namespace mediums can be out-of-root;
176/// every other medium type yields `None`.
177pub fn out_of_root_layout_warning(
178    pointer: &str,
179    workspace_root: &Path,
180    medium_type: crate::pipeline::MediumType,
181) -> Option<String> {
182    use crate::pipeline::MediumType;
183    if !matches!(medium_type, MediumType::Codebase | MediumType::Filesystem) {
184        return None;
185    }
186    let base = medium_base(pointer, workspace_root);
187    // Canonicalize both sides when possible so symlinked roots (macOS /tmp)
188    // don't false-positive; fall back to the lexical forms for not-yet-existing
189    // paths.
190    let canon_base = std::fs::canonicalize(&base).unwrap_or(base);
191    let canon_root =
192        std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
193    if canon_base.starts_with(&canon_root) {
194        return None;
195    }
196    Some(format!(
197        "medium base '{}' resolves outside the workspace root '{}': supported — \
198         enumeration, change detection, and anchor resolution all work on this shape — \
199         but artifact ids render as workspace-relative '../…' chains and the \
200         workspace-to-source relative layout must stay fixed (moving either side \
201         breaks the pointer). To avoid the '../…' ids, root the workspace at the \
202         common parent directory containing every source tree.",
203        canon_base.display(),
204        canon_root.display()
205    ))
206}
207
208/// Workspace-relative deny globs excluding the engine's own state from
209/// every strategy's input set. Unconditional and non-configurable: a
210/// binding can never legitimately model `.memstead/`,
211/// `.memstead.cache/`, or a mount's resolved storage location as
212/// source artifacts — an allow glob covering them does not admit them.
213/// The dot-directories key on their *names* (the names are the
214/// contract, and a foreign workspace's `.memstead/` is still engine
215/// state); the mount storage locations key on their *resolved* paths
216/// because their directory names are configurable. Fail-open on an
217/// unreadable mount list: the name-based excludes stay in force.
218fn engine_state_denies(workspace_root: &Path) -> Vec<String> {
219    use crate::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
220
221    let mut denies: Vec<String> = vec![
222        ".memstead/**".to_string(),
223        ".memstead.cache/**".to_string(),
224        "**/.memstead/**".to_string(),
225        "**/.memstead.cache/**".to_string(),
226    ];
227    if let Ok(ws) = FileWorkspaceStore.load(workspace_root) {
228        for mount in &ws.mounts {
229            let dir: Option<PathBuf> = match &mount.storage {
230                crate::workspace::MountStorage::GitBranch { gitdir, .. } => {
231                    gitdir.parent().map(Path::to_path_buf)
232                }
233                crate::workspace::MountStorage::Folder { path } => Some(path.clone()),
234                crate::workspace::MountStorage::Archive { path, .. } => {
235                    // A sealed archive is one file, not a tree.
236                    let rel = relative_path(workspace_root, &normalize_lexical(path));
237                    denies.push(rel.to_string_lossy().to_string());
238                    None
239                }
240                // No on-disk footprint to exclude.
241                crate::workspace::MountStorage::InMemory => None,
242            };
243            if let Some(dir) = dir {
244                let rel = relative_path(workspace_root, &normalize_lexical(&dir));
245                // A collapsed single-mem folder workspace stores the mem
246                // AT the workspace root — excluding `**` there would
247                // empty every denominator; skip it.
248                if !rel.as_os_str().is_empty() {
249                    denies.push(format!("{}/**", rel.to_string_lossy()));
250                }
251            }
252        }
253    }
254    denies
255}
256
257/// Whether `sha` names a commit that exists in the repo at `git_root`.
258/// `git cat-file -e <sha>^{commit}` — exit 0 iff present and a commit.
259fn commit_exists(git_root: &Path, sha: &str) -> bool {
260    Command::new("git")
261        .args(["cat-file", "-e", &format!("{sha}^{{commit}}")])
262        .current_dir(git_root)
263        .output()
264        .map(|o| o.status.success())
265        .unwrap_or(false)
266}
267
268/// `git rev-parse HEAD` in `git_root`, or `None` on any failure.
269fn git_head(git_root: &Path) -> Option<String> {
270    let out = Command::new("git")
271        .args(["rev-parse", "HEAD"])
272        .current_dir(git_root)
273        .output()
274        .ok()?;
275    if !out.status.success() {
276        return None;
277    }
278    let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
279    (!sha.is_empty()).then_some(sha)
280}
281
282/// Translate a pattern into a git pathspec relative to `git_root`, with
283/// `:(glob)` magic (or `:(glob,exclude)` for a deny).
284///
285/// `base` is the namespace the pattern is written in: the medium base for a
286/// facet scope pattern (source-relative), the workspace root for an
287/// ingest-level deny (workspace-relative). One join, named by the caller,
288/// rather than one hardcoded root that silently disagrees with the walk.
289///
290/// A `**`-prefixed pattern is prefix-free — it matches under any directory,
291/// in particular the medium subtree — so it is emitted verbatim as a
292/// git-root-relative glob. Lexically re-rooting it (join + relativize) would
293/// produce `../**/…` for any non-root medium pointer, and git *fatals* on an
294/// out-of-tree pathspec, sinking the whole diff into a no-signal degrade.
295fn to_git_pathspec(pattern: &str, git_root: &Path, base: &Path, exclude: bool) -> String {
296    let magic = if exclude {
297        ":(glob,exclude)"
298    } else {
299        ":(glob)"
300    };
301    if pattern.starts_with("**") {
302        return format!("{magic}{pattern}");
303    }
304    let resolved = normalize_lexical(&base.join(pattern));
305    let git_rel = relative_path(git_root, &resolved);
306    format!("{magic}{}", git_rel.to_string_lossy())
307}
308
309/// Like [`to_git_pathspec`], but `None` when the pattern resolves *outside*
310/// `git_root` (its git-relative path escapes with a leading `..`). Git fatals
311/// on an out-of-tree pathspec, so a cross-repo deny must be dropped from the
312/// diff rather than pushed — it can match nothing in this repo regardless.
313fn in_repo_pathspec(pattern: &str, git_root: &Path, base: &Path, exclude: bool) -> Option<String> {
314    // Prefix-free glob — same verbatim re-anchoring as `to_git_pathspec`.
315    if pattern.starts_with("**") {
316        return Some(to_git_pathspec(pattern, git_root, base, exclude));
317    }
318    let resolved = normalize_lexical(&base.join(pattern));
319    let git_rel = relative_path(git_root, &resolved);
320    if git_rel
321        .components()
322        .next()
323        .is_some_and(|c| c == Component::ParentDir)
324    {
325        return None;
326    }
327    let magic = if exclude {
328        ":(glob,exclude)"
329    } else {
330        ":(glob)"
331    };
332    Some(format!("{magic}{}", git_rel.to_string_lossy()))
333}
334
335/// Build a [`GlobSet`] from glob patterns, or `None` if any pattern is
336/// malformed. The namespace the patterns are written in is the caller's
337/// business — scope patterns are source-relative, ingest denies
338/// workspace-relative.
339pub(crate) fn build_glob_set(patterns: &[&str]) -> Option<GlobSet> {
340    build_glob_set_reporting(patterns).0
341}
342
343/// [`build_glob_set`], plus the patterns that would not compile.
344///
345/// All-or-nothing was the old contract and it degraded silently in both
346/// directions: one malformed allow emptied the whole enumeration, and one
347/// malformed deny disabled every deny and inflated the set. Now the valid
348/// patterns still compile and the malformed ones come back by name, so the
349/// caller can state the partiality instead of computing over it.
350/// `validate_binding` refuses a malformed scope pattern outright; this path
351/// carries records written before that gate existed.
352pub(crate) fn build_glob_set_reporting(patterns: &[&str]) -> (Option<GlobSet>, Vec<String>) {
353    let mut builder = GlobSetBuilder::new();
354    let mut malformed = Vec::new();
355    let mut any = false;
356    for pattern in patterns {
357        match Glob::new(pattern) {
358            Ok(g) => {
359                builder.add(g);
360                any = true;
361            }
362            Err(_) => malformed.push((*pattern).to_string()),
363        }
364    }
365    let set = if any { builder.build().ok() } else { None };
366    (set, malformed)
367}
368
369/// Whether a primary source's facet declares **no allow patterns** — an
370/// *unscoped* facet. This is the single condition behind the uniform
371/// empty-scope refusal ([`NoSignalReason::Unscoped`]): neither git nor mtime
372/// diffs or enumerates the whole medium for such a facet, and refinement emits
373/// no batch for it. It is orthogonal to the ingest's `deny_paths` — an empty
374/// deny list is not an unscoped facet.
375fn facet_unscoped(source: &Source) -> bool {
376    !source.scope.iter().any(|r| r.mode == PatternMode::Allow)
377}
378
379/// Enumerate the workspace-relative file paths a primary source's facet scope
380/// selects — the `mtime` strategy's input set. Mirrors the plugin's
381/// `enumerateFacetFiles`: the path-shaped mediums (`codebase` / `filesystem` /
382/// `git` — a git source's artifacts are paths pinned at a commit, so the walk
383/// is identical and only the anchor namespace differs); the facet's
384/// allow globs minus its deny globs, evaluated over the medium's directory
385/// tree. Returns a sorted, de-duplicated list. An unscoped facet (no allows)
386/// yields an empty list here — but callers must not treat that as signal: the
387/// strategy layer (`compute_mtime_slice` / `current_primary_token`) refuses
388/// an unscoped facet via `facet_unscoped` *before* enumerating, so the empty
389/// list is only ever reached for a genuinely-empty scoped enumeration.
390///
391/// ## Scope patterns are SOURCE-relative
392///
393/// A facet's own `scope` patterns resolve against the source's **pointer** —
394/// the same base the walk starts from, and the same convention the anchor
395/// artifact decision already ratified for every other surface around a
396/// binding. It is also what the rendered brief teaches, printing the pointer
397/// and the allow list directly beneath it.
398///
399/// Until 2026-08-27 the walk honoured the pointer and then matched each
400/// candidate by its *workspace*-relative path, with no join. The two readings
401/// coincide only for an empty pointer (the shape the scaffolder writes, and
402/// the only shape the enumeration test covered), which is why the defect stayed
403/// invisible: under a non-empty pointer a prefix-anchored pattern or a bare
404/// literal matched nothing, a `**`-prefixed pattern matched regardless, and a
405/// scope mixing the two produced a silently truncated denominator that every
406/// coverage figure was then computed over. [`scope_migration_notes`] names the
407/// patterns a binding still has in the old dialect.
408///
409/// `deny_paths` are the ingest-level denies (`ResolvedIngest::deny_paths`),
410/// applied on top of the facet's own scope denies. These stay
411/// **workspace-relative** — deliberately, and it is not a second dialect for
412/// the same thing: an ingest deny spans every source in the binding, so it has
413/// no single pointer to be relative to, and the git strategy pushes the same
414/// entries down as workspace-rooted `:(glob,exclude)` pathspecs. Scope is
415/// per-source and source-relative; ingest denies are per-binding and
416/// workspace-relative. Passing `&[]` yields the facet-scope-only behaviour.
417pub fn enumerate_facet_files(
418    source: &Source,
419    deny_paths: &[String],
420    workspace_root: &Path,
421) -> Vec<String> {
422    enumerate_facet_files_reported(source, deny_paths, workspace_root).files
423}
424
425/// [`enumerate_facet_files`], carrying what the enumeration could not speak
426/// for: patterns that would not compile, and patterns still in the retired
427/// workspace-relative dialect. Any surface that reduces the set to a figure
428/// must consult [`ScopeEnumeration::is_partial`] first.
429pub fn enumerate_facet_files_reported(
430    source: &Source,
431    deny_paths: &[String],
432    workspace_root: &Path,
433) -> ScopeEnumeration {
434    if !matches!(
435        source.medium_type,
436        MediumType::Codebase | MediumType::Filesystem | MediumType::Git
437    ) {
438        return ScopeEnumeration::default();
439    }
440    let legacy_dialect = scope_migration_notes(source);
441    let mut allows: Vec<&str> = Vec::new();
442    let mut scope_denies: Vec<&str> = Vec::new();
443    for rule in &source.scope {
444        match rule.mode {
445            PatternMode::Allow => allows.push(&rule.path),
446            PatternMode::Deny => scope_denies.push(&rule.path),
447        }
448    }
449    // Ingest deny_paths deny on top of the facet's own denies, in the
450    // workspace-relative grammar — the same entries the git strategy resolves
451    // as exclude pathspecs, so deny enforcement is strategy-invariant. They are
452    // matched against the candidate's workspace-relative path, NOT its
453    // source-relative one: an ingest deny spans every source in the binding.
454    let mut ws_denies: Vec<&str> = deny_paths.iter().map(String::as_str).collect();
455    // Engine self-exclusion — unconditional, below configuration; the
456    // git strategy pushes the same set as exclude pathspecs so the
457    // denominator stays strategy-invariant.
458    let forced = engine_state_denies(workspace_root);
459    for f in &forced {
460        ws_denies.push(f);
461    }
462    if allows.is_empty() {
463        return ScopeEnumeration {
464            legacy_dialect,
465            ..Default::default()
466        };
467    }
468    // Malformed patterns no longer take the whole set with them: the valid
469    // ones compile, the bad ones come back by name, and the caller states the
470    // partiality. A malformed ALLOW leaves the denominator short; a malformed
471    // DENY leaves it long. Either way the figure over it is not the answer.
472    let (allow_set, mut malformed) = build_glob_set_reporting(&allows);
473    let (scope_deny_set, deny_malformed) = build_glob_set_reporting(&scope_denies);
474    malformed.extend(deny_malformed);
475    // The ingest-level denies go through the SAME resolver the path-check
476    // command answers with — glob plus the literal-base directory-prefix rule
477    // plus the malformed-entry fallback — so a path denied at the hook can
478    // never be counted in the denominator. Sharing the glob library alone left
479    // the two disagreeing on exactly those extra rules.
480    let ws_oracle = super::check_path::DenyOracle::new(
481        &ws_denies
482            .iter()
483            .map(|d| (*d).to_string())
484            .collect::<Vec<_>>(),
485    );
486    let Some(allow_set) = allow_set else {
487        return ScopeEnumeration {
488            malformed,
489            legacy_dialect,
490            ..Default::default()
491        };
492    };
493
494    // Walk the medium's directory tree. A candidate is matched twice, in two
495    // namespaces: the facet's own scope patterns against its SOURCE-relative
496    // path (relative to the medium base below), the ingest-level denies
497    // against its workspace-relative one. The returned paths stay
498    // workspace-relative — that is the namespace every consumer of this list
499    // speaks. VCS internals are never source artifacts — they are pruned here
500    // so `.git/**` plumbing cannot enter `S(D)`, matching the git strategy
501    // (whose diffs never name `.git` files).
502    let base = medium_base(&source.pointer, workspace_root);
503    let mut out: Vec<String> = Vec::new();
504    let mut stack = vec![base.clone()];
505    while let Some(dir) = stack.pop() {
506        let Ok(entries) = std::fs::read_dir(&dir) else {
507            continue;
508        };
509        for entry in entries.flatten() {
510            let Ok(file_type) = entry.file_type() else {
511                continue;
512            };
513            let path = entry.path();
514            if file_type.is_dir() {
515                let skip = path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
516                    // VCS internals and engine state are never source
517                    // artifacts — pruning here saves the walk; the
518                    // forced deny globs enforce the same exclusion for
519                    // anything that still slips into a candidate list.
520                    VCS_INTERNAL_DIRS.contains(&n) || n == ".memstead" || n == ".memstead.cache"
521                });
522                if !skip {
523                    stack.push(path);
524                }
525            } else if file_type.is_file() {
526                let normalized = normalize_lexical(&path);
527                let rel = relative_path(workspace_root, &normalized)
528                    .to_string_lossy()
529                    .to_string();
530                let src_rel = relative_path(&base, &normalized)
531                    .to_string_lossy()
532                    .to_string();
533                let denied = scope_deny_set
534                    .as_ref()
535                    .is_some_and(|d| d.is_match(&src_rel))
536                    || ws_oracle.is_denied(&rel);
537                if allow_set.is_match(&src_rel) && !denied {
538                    out.push(rel);
539                }
540            }
541        }
542    }
543    out.sort();
544    out.dedup();
545    ScopeEnumeration {
546        files: out,
547        malformed,
548        legacy_dialect,
549    }
550}
551
552/// What an enumeration of a source's scope selected, and what it could not
553/// speak for.
554///
555/// The campaign rule this serves: a surface does not report a figure over
556/// state it did not examine. A coverage percentage computed over a denominator
557/// that silently lost patterns is exactly that figure, so the partiality
558/// travels with the set rather than being discarded at the seam.
559#[derive(Debug, Clone, Default, PartialEq, Eq)]
560pub struct ScopeEnumeration {
561    /// The workspace-relative artifact paths the scope selected.
562    pub files: Vec<String>,
563    /// Scope patterns that would not compile, by name. Their share of the
564    /// population is unknown, so any enumeration carrying one is partial.
565    pub malformed: Vec<String>,
566    /// Scope patterns still written in the retired workspace-relative
567    /// dialect — reported so an author is told before a pattern's meaning
568    /// changes, never after.
569    pub legacy_dialect: Vec<ScopePatternNote>,
570}
571
572impl ScopeEnumeration {
573    /// Whether this enumeration is known to be incomplete. A partial
574    /// enumeration must not be reduced to a percentage: the denominator is
575    /// not the population.
576    ///
577    /// BOTH causes count. A malformed pattern was skipped, so its share never
578    /// entered the walk; a pattern still in the retired workspace-relative
579    /// dialect selects nothing under the pointer join, so its share is
580    /// likewise absent. The second is the more dangerous of the two, because a
581    /// MIXED scope still enumerates and the surviving subset looks like a
582    /// population.
583    pub fn is_partial(&self) -> bool {
584        !self.malformed.is_empty() || !self.legacy_dialect.is_empty()
585    }
586
587    /// Why this enumeration is incomplete, naming the offending patterns, or
588    /// `None` when it is whole.
589    pub fn partiality_reason(&self) -> Option<String> {
590        let mut causes = Vec::new();
591        if !self.malformed.is_empty() {
592            causes.push(format!(
593                "scope pattern(s) that would not compile were skipped: {}",
594                self.malformed.join(", ")
595            ));
596        }
597        if !self.legacy_dialect.is_empty() {
598            causes.push(format!(
599                "scope pattern(s) are still written against the workspace root rather than the \
600                 source pointer, so they select nothing under the pointer join: {}",
601                self.legacy_dialect
602                    .iter()
603                    .map(|n| n.pattern.as_str())
604                    .collect::<Vec<_>>()
605                    .join(", ")
606            ));
607        }
608        (!causes.is_empty()).then(|| causes.join("; "))
609    }
610}
611
612/// One scope pattern still written in the retired workspace-relative dialect.
613///
614/// Emitted by [`scope_migration_notes`] so a binding author is told BEFORE a
615/// pattern's meaning changes, never after. The campaign rule this serves is
616/// the plain one: a surface does not quietly begin covering a different set.
617#[derive(Debug, Clone, PartialEq, Eq)]
618pub struct ScopePatternNote {
619    /// The pattern as written in the binding record.
620    pub pattern: String,
621    /// Whether it is an allow or a deny rule.
622    pub deny: bool,
623    /// The pattern rewritten into the source-relative dialect, when the
624    /// rewrite is mechanical (the pattern begins with the source's pointer).
625    /// `None` when it is not — the author has to decide what they meant.
626    pub suggested: Option<String>,
627}
628
629/// Name every scope pattern of `source` that reads as the retired
630/// workspace-relative dialect: one that begins with the source's own pointer,
631/// so it selected artifacts before the 2026-08-27 join and selects nothing
632/// after it.
633///
634/// A pointer-less source has no dialect to migrate (the two readings coincide
635/// — which is exactly why the defect stayed invisible on the scaffolded path),
636/// so it returns empty. A `**`-prefixed pattern is prefix-free and matched
637/// under both readings, so it is not reported.
638pub fn scope_migration_notes(source: &Source) -> Vec<ScopePatternNote> {
639    let pointer = source.pointer.trim_end_matches('/');
640    if pointer.is_empty() {
641        return Vec::new();
642    }
643    let prefix = format!("{pointer}/");
644    source
645        .scope
646        .iter()
647        .filter(|r| !r.path.starts_with("**"))
648        .filter_map(|rule| {
649            let stripped = rule.path.strip_prefix(&prefix)?;
650            Some(ScopePatternNote {
651                pattern: rule.path.clone(),
652                deny: rule.mode == PatternMode::Deny,
653                suggested: (!stripped.is_empty()).then(|| stripped.to_string()),
654            })
655        })
656        .collect()
657}
658
659/// Compute the git changed slice for one primary source between its stored
660/// baseline commit and the tree's current `HEAD`. Mirrors `computeGitSlice`.
661fn compute_git_slice(
662    source: &Source,
663    deny_paths: &[String],
664    workspace_root: &Path,
665    baseline: Option<&str>,
666) -> SliceOutcome {
667    let base = medium_base(&source.pointer, workspace_root);
668    let Some(git_root) = find_git_root(&base) else {
669        return SliceOutcome::NoSignal {
670            reason: NoSignalReason::GitUnavailable,
671        };
672    };
673    let Some(head) = git_head(&git_root) else {
674        return SliceOutcome::NoSignal {
675            reason: NoSignalReason::GitUnavailable,
676        };
677    };
678
679    let baseline = match baseline {
680        Some(b) if is_git_token(b) => b,
681        // No usable commit baseline — seed at HEAD, present no slice.
682        _ => return SliceOutcome::Reseed { token: head },
683    };
684    if baseline == head {
685        return SliceOutcome::Unchanged { token: head };
686    }
687    // A git-shaped baseline that THIS repo does not contain is not a usable
688    // baseline either — it is foreign (seeded when the pointer resolved to a
689    // different repo, e.g. before a source tree moved into a submodule) or
690    // gone (gc'd / rewritten away). Diffing against it fatals, which used to
691    // degrade every pass into `GitUnavailable` — a baseline that never seats
692    // and a binding that never backs off. Reseed at HEAD instead: one honest
693    // full re-roam, then normal change detection. `GitUnavailable` below is
694    // reserved for transient git failures on a baseline that does exist.
695    if !commit_exists(&git_root, baseline) {
696        return SliceOutcome::Reseed { token: head };
697    }
698
699    // Pathspecs from the facet scope + the ingest's deny_paths.
700    let mut allows: Vec<&str> = Vec::new();
701    let mut scope_denies: Vec<&str> = Vec::new();
702    for rule in &source.scope {
703        match rule.mode {
704            PatternMode::Allow => allows.push(&rule.path),
705            PatternMode::Deny => scope_denies.push(&rule.path),
706        }
707    }
708    if allows.is_empty() {
709        // Unscoped facet — the uniform typed refusal (never diff the whole
710        // repo); renders in the brief rather than degrading silently.
711        return SliceOutcome::NoSignal {
712            reason: NoSignalReason::Unscoped,
713        };
714    }
715    let mut ws_denies: Vec<&str> = deny_paths.iter().map(String::as_str).collect();
716    // Engine self-exclusion — same forced set the mtime strategy's
717    // enumeration applies, pushed down as exclude pathspecs so the
718    // slice never names engine state either.
719    let forced = engine_state_denies(workspace_root);
720    for f in &forced {
721        ws_denies.push(f);
722    }
723    let mut specs: Vec<String> =
724        Vec::with_capacity(allows.len() + scope_denies.len() + ws_denies.len());
725    // Scope patterns are source-relative, so they re-anchor against the medium
726    // base — the same join the mtime enumeration performs. Ingest denies below
727    // stay workspace-rooted; the two namespaces are per-source and per-binding
728    // respectively, not two dialects for one thing.
729    for a in &allows {
730        specs.push(to_git_pathspec(a, &git_root, &base, false));
731    }
732    // A deny may target a path OUTSIDE this medium's git repo — a
733    // cross-medium workspace-relative glob such as `../dev/**`, whose tree
734    // lives in a sibling repo. Git *fatals* on an out-of-tree pathspec
735    // (`'../dev/**' is outside repository`), which would sink the entire
736    // diff into a no-signal degrade. Such a deny can exclude nothing here
737    // anyway (the files simply aren't in this repo), so drop it: the plugin
738    // hook still enforces it agent-side (workspace-relative, cross-repo),
739    // and a genuinely-dead entry is still surfaced by the brief warning.
740    for d in &scope_denies {
741        if let Some(spec) = in_repo_pathspec(d, &git_root, &base, true) {
742            specs.push(spec);
743        }
744    }
745    for d in &ws_denies {
746        if let Some(spec) = in_repo_pathspec(d, &git_root, workspace_root, true) {
747            specs.push(spec);
748        }
749    }
750
751    let mut cmd = Command::new("git");
752    cmd.args([
753        "diff",
754        "--no-renames",
755        "--name-status",
756        baseline,
757        &head,
758        "--",
759    ]);
760    cmd.args(&specs);
761    cmd.current_dir(&git_root);
762    let out = match cmd.output() {
763        Ok(o) if o.status.success() => o,
764        // Unknown baseline (gc'd / rewritten), an out-of-repo pathspec, or a
765        // git failure — degrade to a whole re-roam (the plugin does the same).
766        _ => {
767            return SliceOutcome::NoSignal {
768                reason: NoSignalReason::GitUnavailable,
769            };
770        }
771    };
772    let text = String::from_utf8_lossy(&out.stdout);
773
774    // The exclude pathspecs above are git's own glob dialect and cannot
775    // express the two rules that extend the engine's deny resolution: the
776    // literal-base directory-prefix block, and the fallback that keeps a
777    // MALFORMED entry blocking by its literal prefix rather than dropping it.
778    // Pushing what git understands and then filtering the result through the
779    // same `DenyOracle` the mtime enumeration and the path-check command use
780    // is what makes deny enforcement genuinely strategy-invariant — before
781    // this, a `broken[` entry excluded a file from `S(D)` and left it in the
782    // git slice.
783    let ws_oracle = super::check_path::DenyOracle::new(
784        &ws_denies
785            .iter()
786            .map(|d| (*d).to_string())
787            .collect::<Vec<_>>(),
788    );
789
790    let mut slice = Slice::default();
791    for line in text.lines() {
792        if line.trim().is_empty() {
793            continue;
794        }
795        let Some(tab) = line.find('\t') else { continue };
796        let status = line[..tab].trim();
797        let git_path = line[tab + 1..].trim();
798        let ws_path = relative_path(workspace_root, &normalize_lexical(&git_root.join(git_path)))
799            .to_string_lossy()
800            .to_string();
801        if ws_oracle.is_denied(&ws_path) {
802            continue;
803        }
804        match status.chars().next() {
805            Some('A') => slice.added.push(ws_path),
806            Some('D') => slice.deleted.push(ws_path),
807            // M, T (type change), C, and the rest.
808            _ => slice.modified.push(ws_path),
809        }
810    }
811    slice.added.sort();
812    slice.modified.sort();
813    slice.deleted.sort();
814    SliceOutcome::Changed {
815        token: head,
816        slice,
817        degraded: false,
818    }
819}
820
821/// One parsed entry of a **graph** facet's scope — the entity-namespace
822/// counterpart of a path glob. A graph source selects entities, and an entity
823/// is not a path: matching id-shaped globs against `mem--slug` invites the
824/// "looks scoped, selects nothing" failure the dead-deny lint exists to catch
825/// on paths, so the vocabulary is explicit about which axis it selects on.
826///
827/// Grammar (the whole of it):
828///
829/// - `*` — every entity in the source mem
830/// - `type:<entity_type>` — entities of exactly that type
831/// - `id:<glob>` — entities whose full `mem--slug` id matches the glob
832///
833/// Anything else is refused at binding validation
834/// ([`crate::binding::validate_binding`]) rather than silently selecting
835/// nothing: a scope nothing interprets is the defect, not a permissible form.
836#[derive(Debug, Clone, PartialEq, Eq)]
837pub enum EntitySelector {
838    /// `*` — every entity in the mem.
839    All,
840    /// `type:<entity_type>` — exact type match.
841    Type(String),
842    /// `id:<glob>` — glob over the full entity id.
843    Id(String),
844}
845
846/// Parse one graph scope pattern. `None` for an unrecognised form — the
847/// caller decides whether that is a validation refusal (declaration time) or
848/// a skipped rule (run time, already refused at declaration).
849pub fn parse_entity_selector(pattern: &str) -> Option<EntitySelector> {
850    let pattern = pattern.trim();
851    if pattern == "*" {
852        return Some(EntitySelector::All);
853    }
854    if let Some(rest) = pattern.strip_prefix("type:") {
855        let rest = rest.trim();
856        if rest.is_empty() {
857            return None;
858        }
859        return Some(EntitySelector::Type(rest.to_string()));
860    }
861    if let Some(rest) = pattern.strip_prefix("id:") {
862        let rest = rest.trim();
863        if rest.is_empty() {
864            return None;
865        }
866        // A malformed glob is a refusal, not a rule that matches nothing.
867        Glob::new(rest).ok()?;
868        return Some(EntitySelector::Id(rest.to_string()));
869    }
870    None
871}
872
873/// Does `selector` select this entity?
874fn selector_matches(selector: &EntitySelector, id: &str, entity_type: &str) -> bool {
875    match selector {
876        EntitySelector::All => true,
877        EntitySelector::Type(t) => entity_type == t,
878        EntitySelector::Id(g) => Glob::new(g)
879            .ok()
880            .map(|glob| glob.compile_matcher().is_match(id))
881            .unwrap_or(false),
882    }
883}
884
885/// Enumerate the entity ids a **graph** source's facet scope selects — the
886/// graph medium's `S(D)`, the exact counterpart of [`enumerate_facet_files`]
887/// for a path medium. The source's `pointer` names the source mem; the store
888/// already holds every mounted mem's entities, so this is a filter over
889/// memory rather than any kind of walk.
890///
891/// Stubs are excluded: a stub is a placeholder the engine created for an
892/// unresolved reference, not an authored source artifact. Counting them would
893/// inflate the denominator with entities the source never wrote, making
894/// coverage look worse than it is for a reason no author can act on.
895///
896/// An unscoped facet (no allow rules) yields an empty list here — callers must
897/// not read that as "nothing in scope"; the strategy layer refuses an unscoped
898/// facet before reaching this, exactly as it does for the path mediums.
899pub fn enumerate_graph_entities(engine: &Engine, source: &Source) -> Vec<String> {
900    if source.medium_type != MediumType::Graph {
901        return Vec::new();
902    }
903    let mut allows: Vec<EntitySelector> = Vec::new();
904    let mut denies: Vec<EntitySelector> = Vec::new();
905    for rule in &source.scope {
906        // An unparseable rule is already a validation refusal; at run time it
907        // selects nothing rather than everything — a scope the engine cannot
908        // read must never widen reach.
909        let Some(sel) = parse_entity_selector(&rule.path) else {
910            continue;
911        };
912        match rule.mode {
913            PatternMode::Allow => allows.push(sel),
914            PatternMode::Deny => denies.push(sel),
915        }
916    }
917    if allows.is_empty() {
918        return Vec::new();
919    }
920    let mem = source.pointer.as_str();
921    let mut out: Vec<String> = Vec::new();
922    for entity in engine.store().all_entities() {
923        if entity.mem != mem || entity.stub {
924            continue;
925        }
926        let id = entity.id.0.as_str();
927        let ty = entity.entity_type.as_str();
928        if !allows.iter().any(|s| selector_matches(s, id, ty)) {
929            continue;
930        }
931        if denies.iter().any(|s| selector_matches(s, id, ty)) {
932            continue;
933        }
934        out.push(id.to_string());
935    }
936    out.sort();
937    out.dedup();
938    out
939}
940
941/// Enumerate one primary source's in-scope artifacts, whatever its medium —
942/// the single entry point every `S(D)` consumer uses. Path-shaped mediums
943/// (codebase / filesystem / git) walk the file tree; a graph source filters
944/// the source mem's entities. A medium the matrix marks non-enumerable
945/// yields nothing, and its callers render the non-enumerable basis rather
946/// than a denominator.
947///
948/// This exists because the enumeration bail was never in one place: five call
949/// sites each repeated the same loop over `enumerate_facet_files`, so teaching
950/// only the report about a new medium left the findings store, the refinement
951/// rotation, and the exclude membership gate empty-handed.
952pub fn enumerate_source_artifacts(
953    engine: &Engine,
954    source: &Source,
955    deny_paths: &[String],
956    workspace_root: &Path,
957) -> Vec<String> {
958    enumerate_source_artifacts_reported(engine, source, deny_paths, workspace_root).files
959}
960
961/// [`enumerate_source_artifacts`], carrying what the enumeration could not
962/// speak for. Any surface reducing `S(D)` to a figure consults
963/// [`ScopeEnumeration::is_partial`] first.
964pub fn enumerate_source_artifacts_reported(
965    engine: &Engine,
966    source: &Source,
967    deny_paths: &[String],
968    workspace_root: &Path,
969) -> ScopeEnumeration {
970    match source.medium_type {
971        MediumType::Codebase | MediumType::Filesystem | MediumType::Git => {
972            enumerate_facet_files_reported(source, deny_paths, workspace_root)
973        }
974        // Entity selectors are their own grammar with their own parser — a
975        // graph scope has no glob to malform and no pointer dialect to migrate.
976        MediumType::Graph => ScopeEnumeration {
977            files: enumerate_graph_entities(engine, source),
978            ..Default::default()
979        },
980        MediumType::Web => ScopeEnumeration::default(),
981    }
982}
983
984/// Compute the graph changed slice for a source mem between its stored
985/// baseline snapshot token and the mem's current head. Mirrors
986/// `computeGraphSlice`, using the engine's own change history.
987/// Restrict a graph changed slice to the facet's scope. Without this the
988/// selector was honoured by enumeration and ignored by change detection, so a
989/// brief could print `Entities: type:concept` and then hand the agent a
990/// changed `memo` two sections below — an artifact its own coverage model
991/// says is out of scope, which `advance` would then accept because its gate
992/// is the presented slice.
993///
994/// Added and modified entities are classified from the live store. **Deleted
995/// entities are kept unconditionally**: the entity is gone, so its type can no
996/// longer be read, and a deletion that cannot be classified must be reported
997/// rather than dropped — a missed deletion is the highest-signal drift there
998/// is. An `id:` selector still applies to deletions, because an id is all a
999/// deletion leaves behind.
1000fn filter_graph_slice_to_scope(engine: &Engine, source: &Source, slice: &mut Slice) {
1001    let mut allows: Vec<EntitySelector> = Vec::new();
1002    let mut denies: Vec<EntitySelector> = Vec::new();
1003    for rule in &source.scope {
1004        let Some(sel) = parse_entity_selector(&rule.path) else {
1005            continue;
1006        };
1007        match rule.mode {
1008            PatternMode::Allow => allows.push(sel),
1009            PatternMode::Deny => denies.push(sel),
1010        }
1011    }
1012    if allows.is_empty() {
1013        return;
1014    }
1015    let in_scope = |id: &str, known_type: Option<&str>| {
1016        // A `type:` selector cannot judge an entity whose type is unreadable
1017        // (a deletion). Treat it as matching so the artifact survives to be
1018        // reported, rather than silently vanishing from the slice.
1019        let matches = |s: &EntitySelector| match (s, known_type) {
1020            (EntitySelector::Type(_), None) => true,
1021            _ => selector_matches(s, id, known_type.unwrap_or_default()),
1022        };
1023        allows.iter().any(&matches) && !denies.iter().any(&matches)
1024    };
1025    let type_of = |id: &str| {
1026        engine
1027            .store()
1028            .get(&crate::entity::EntityId::canonical(id))
1029            .map(|e| e.entity_type.clone())
1030    };
1031    slice
1032        .added
1033        .retain(|id| in_scope(id, type_of(id).as_deref()));
1034    slice
1035        .modified
1036        .retain(|id| in_scope(id, type_of(id).as_deref()));
1037    slice.deleted.retain(|id| in_scope(id, None));
1038}
1039
1040fn compute_graph_slice(
1041    engine: &Engine,
1042    source: Option<&Source>,
1043    source_mem: &str,
1044    baseline: Option<&str>,
1045) -> SliceOutcome {
1046    let current = match engine.mem_head_sha(source_mem) {
1047        Ok(Some(sha)) => sha,
1048        // Source has no snapshot signal, or is unknown — degrade.
1049        _ => {
1050            return SliceOutcome::NoSignal {
1051                reason: NoSignalReason::GraphSnapshotMissing,
1052            };
1053        }
1054    };
1055    // Fetch the entity delta only when the source actually moved.
1056    let changed = matches!(baseline, Some(b) if is_git_token(b) && b != current);
1057    let mut outcome = if changed {
1058        let baseline = baseline.expect("changed implies a baseline");
1059        match engine.changes_since(source_mem, baseline, None) {
1060            Ok(report) => graph_slice_outcome(Some(baseline), &current, &report.changes),
1061            // Unknown baseline / engine error — degrade.
1062            Err(_) => SliceOutcome::NoSignal {
1063                reason: NoSignalReason::GraphSnapshotMissing,
1064            },
1065        }
1066    } else {
1067        graph_slice_outcome(baseline, &current, &[])
1068    };
1069    // The scope narrows the slice exactly as it narrows S(D). Applied after
1070    // the diff rather than pushed into it, mirroring how the path strategies
1071    // apply deny pathspecs — one place decides what "in scope" means.
1072    // A reference mem carries no facet scope — it is read whole by design,
1073    // so there is nothing to narrow by and `None` is the honest input.
1074    if let (Some(source), SliceOutcome::Changed { slice, .. }) = (source, &mut outcome) {
1075        filter_graph_slice_to_scope(engine, source, slice);
1076    }
1077    outcome
1078}
1079
1080// ── mtime source-cursor memo ────────────────────────────────────────────────
1081//
1082// The `mtime` strategy's durable baseline is a small digest token (in the
1083// destination mem's `sync_state`), which cannot by itself say *which* files
1084// changed. The engine keeps a rebuildable memo — the full stat map keyed by
1085// its digest aggregate — so a run whose baseline matches a memoised aggregate
1086// diffs precisely (incl. deletions) instead of degrading to a full scan.
1087//
1088// The memo lives engine-side under `<workspace>/.memstead.cache/ingest/` in
1089// the plugin's format (`{aggregate: {relpath: {mtime, size}}}`), so the engine
1090// and the transition-era skill share it. It is pure engine-internal cache —
1091// not mem-repo, not the graph — so writing it during brief rendering is not a
1092// tracked mutation. A write failure only costs the next run's precision.
1093
1094/// The `<cache_root>/source-cursor/<ingest>/<facet>.json` memo path.
1095fn cursor_memo_path(cache_root: &Path, ingest_name: &str, facet_ref: &str) -> PathBuf {
1096    let safe: String = facet_ref
1097        .chars()
1098        .map(|c| {
1099            if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
1100                c
1101            } else {
1102                '_'
1103            }
1104        })
1105        .collect();
1106    cache_root
1107        .join("source-cursor")
1108        .join(ingest_name)
1109        .join(format!("{safe}.json"))
1110}
1111
1112/// Read the stat map memoised under `aggregate` for a facet, or `None` on miss.
1113fn read_cursor_memo(
1114    cache_root: &Path,
1115    ingest: &str,
1116    facet: &str,
1117    aggregate: &str,
1118) -> Option<StatMap> {
1119    let bytes = std::fs::read(cursor_memo_path(cache_root, ingest, facet)).ok()?;
1120    let memo: BTreeMap<String, StatMap> = serde_json::from_slice(&bytes).ok()?;
1121    memo.get(aggregate).cloned()
1122}
1123
1124/// Memoise the current stat map under its aggregate, bounding the file to the
1125/// 3 most-recent aggregates. Best-effort.
1126fn write_cursor_memo(cache_root: &Path, ingest: &str, facet: &str, aggregate: &str, map: &StatMap) {
1127    let path = cursor_memo_path(cache_root, ingest, facet);
1128    let mut memo: BTreeMap<String, StatMap> = std::fs::read(&path)
1129        .ok()
1130        .and_then(|b| serde_json::from_slice(&b).ok())
1131        .unwrap_or_default();
1132    memo.insert(aggregate.to_string(), map.clone());
1133    if memo.len() > 3 {
1134        // Keep the just-written aggregate plus up to two others.
1135        let drop: Vec<String> = memo
1136            .keys()
1137            .filter(|k| k.as_str() != aggregate)
1138            .skip(2)
1139            .cloned()
1140            .collect();
1141        for key in drop {
1142            memo.remove(&key);
1143        }
1144    }
1145    if let Some(parent) = path.parent() {
1146        let _ = std::fs::create_dir_all(parent);
1147    }
1148    if let Ok(bytes) = serde_json::to_vec(&memo) {
1149        let _ = std::fs::write(&path, bytes);
1150    }
1151}
1152
1153/// VCS metadata directories — never source artifacts. Pruned from source
1154/// enumeration (`S(D)`, mtime slices, advance) and from the dead-deny scan.
1155const VCS_INTERNAL_DIRS: &[&str] = &[".git", ".svn", ".hg"];
1156
1157/// Directory names never worth walking for the dead-deny scan — build output,
1158/// VCS metadata ([`VCS_INTERNAL_DIRS`]), dependency caches, and the engine's
1159/// own cache.
1160const DEAD_DENY_SKIP_DIRS: &[&str] = &[
1161    ".git",
1162    "node_modules",
1163    "target",
1164    "dist",
1165    ".memstead.cache",
1166    ".sqlx",
1167    ".svn",
1168    ".hg",
1169];
1170
1171/// Bounded, pruned walk of `base` collecting every file's **workspace-relative**
1172/// path (the same string space the deny globs match). Skips heavy directories
1173/// ([`DEAD_DENY_SKIP_DIRS`]) and gives up (returns `None`) past `cap` files, so
1174/// the dead-deny scan degrades to "can't tell" rather than warning falsely or
1175/// walking an unbounded tree. Best-effort: unreadable directories are skipped.
1176fn walk_tree_bounded(base: &Path, workspace_root: &Path, cap: usize) -> Option<Vec<String>> {
1177    let mut out: Vec<String> = Vec::new();
1178    let mut stack = vec![base.to_path_buf()];
1179    while let Some(dir) = stack.pop() {
1180        let Ok(entries) = std::fs::read_dir(&dir) else {
1181            continue;
1182        };
1183        for entry in entries.flatten() {
1184            let Ok(file_type) = entry.file_type() else {
1185                continue;
1186            };
1187            let path = entry.path();
1188            if file_type.is_dir() {
1189                let skip = path
1190                    .file_name()
1191                    .and_then(|n| n.to_str())
1192                    .is_some_and(|n| DEAD_DENY_SKIP_DIRS.contains(&n));
1193                if !skip {
1194                    stack.push(path);
1195                }
1196            } else if file_type.is_file() {
1197                if out.len() >= cap {
1198                    return None;
1199                }
1200                out.push(
1201                    relative_path(workspace_root, &normalize_lexical(&path))
1202                        .to_string_lossy()
1203                        .to_string(),
1204                );
1205            }
1206        }
1207    }
1208    Some(out)
1209}
1210
1211/// The ingest `deny_paths` entries that select **no file** in the project tree
1212/// — surfaced as a rendered brief warning (AC 6 refusal leg) so a zero-matching
1213/// deny is never a silent no-op. Resolution base is the medium's git project
1214/// root (so a cross-medium workspace-relative deny like `../dev/**`, whose
1215/// target lives outside a sub-medium, still resolves against real files),
1216/// falling back to the workspace root. Answers through the *same*
1217/// [`super::check_path::DenyOracle`] the strategies and the path-check command
1218/// use, so "does this deny select anything" is decided by the resolution that
1219/// actually enforces it — including the literal-base directory-prefix rule and
1220/// the malformed-entry fallback. Answering with the raw glob alone told an
1221/// author that a working bare-name entry (`dev`) matched nothing and invited
1222/// them to delete it, which would have raised the denominator.
1223/// Best-effort: if the tree can't be enumerated
1224/// (walk cap hit, no readable base) nothing is reported — a warning is only
1225/// ever raised on a confirmed zero-match.
1226///
1227/// The scaffold's own default hygiene entries
1228/// ([`crate::binding::DEFAULT_SCAFFOLD_DENY_PATHS`]) are exempt: `projection
1229/// init` writes them into every codebase/filesystem binding, and most trees
1230/// carry none of the debris they name. The exemption is a membership check
1231/// against that constant, not a prediction about what the enumerator walks —
1232/// enumeration is a filesystem walk even on a git-signalled source, so these
1233/// entries CAN match (deleting `**/node_modules/**` from a scaffolded record
1234/// over a repo that gitignores `node_modules/` raises the denominator). The
1235/// engine never calls its own output a typo; a user-authored entry that
1236/// matches nothing keeps the loud warning.
1237fn dead_deny_entries(resolved: &ResolvedIngest, workspace_root: &Path) -> Vec<String> {
1238    if resolved.deny_paths.is_empty() {
1239        return Vec::new();
1240    }
1241    let base = find_git_root(workspace_root).unwrap_or_else(|| workspace_root.to_path_buf());
1242    let Some(files) = walk_tree_bounded(&base, workspace_root, 100_000) else {
1243        return Vec::new();
1244    };
1245    let mut dead: Vec<String> = Vec::new();
1246    for entry in &resolved.deny_paths {
1247        if crate::binding::DEFAULT_SCAFFOLD_DENY_PATHS.contains(&entry.as_str()) {
1248            continue;
1249        }
1250        let oracle = super::check_path::DenyOracle::new(std::slice::from_ref(entry));
1251        if oracle.is_empty() {
1252            // An empty entry resolves to nothing either way — not a confirmed
1253            // zero-match, so it is not reported here.
1254            continue;
1255        }
1256        if !files.iter().any(|f| oracle.is_denied(f)) {
1257            dead.push(entry.clone());
1258        }
1259    }
1260    dead
1261}
1262
1263/// Compute the `mtime` changed slice for one primary source: enumerate the
1264/// facet files, stat them, memoise the current map, and diff against the
1265/// baseline digest's memoised map (precise) or degrade to a full scan on memo
1266/// miss. Mirrors the mtime branch of the plugin's `computeSourceCursor`.
1267fn compute_mtime_slice(
1268    source: &Source,
1269    ingest_name: &str,
1270    deny_paths: &[String],
1271    workspace_root: &Path,
1272    cache_root: &Path,
1273    baseline: Option<&str>,
1274) -> SliceOutcome {
1275    if facet_unscoped(source) {
1276        // Unscoped facet — the same typed refusal git raises, so the mtime
1277        // strategy never enumerates the whole medium nor emits an empty slice.
1278        return SliceOutcome::NoSignal {
1279            reason: NoSignalReason::Unscoped,
1280        };
1281    }
1282    let files = enumerate_facet_files(source, deny_paths, workspace_root);
1283    let now_map = compute_stat_map(&files, workspace_root);
1284    let now_digest = digest_stat_map(&now_map);
1285    write_cursor_memo(
1286        cache_root,
1287        ingest_name,
1288        &source.name,
1289        &now_digest.aggregate,
1290        &now_map,
1291    );
1292    let prev_map = baseline
1293        .and_then(parse_digest_token)
1294        .and_then(|base| read_cursor_memo(cache_root, ingest_name, &source.name, &base.aggregate));
1295    mtime_slice_outcome(baseline, prev_map.as_ref(), &now_map)
1296}
1297
1298/// The current change-detection token for a primary source, per its resolved
1299/// strategy: git `HEAD`, the graph mem's snapshot, or the freshly-computed
1300/// mtime digest. `None` when there is no signal.
1301fn current_primary_token(
1302    engine: &Engine,
1303    source: &Source,
1304    deny_paths: &[String],
1305    workspace_root: &Path,
1306) -> Option<String> {
1307    match resolve_change_strategy(source, workspace_root) {
1308        ChangeStrategy::Git => git_head(&find_git_root(&medium_base(
1309            &source.pointer,
1310            workspace_root,
1311        ))?),
1312        ChangeStrategy::Graph => {
1313            if facet_unscoped(source) {
1314                // Symmetric with the mtime arm: no signal at all, rather than
1315                // a whole-mem token posing as a scoped one.
1316                None
1317            } else {
1318                engine.mem_head_sha(&source.pointer).ok().flatten()
1319            }
1320        }
1321        ChangeStrategy::Mtime => {
1322            if facet_unscoped(source) {
1323                // Unscoped facet has no signal — not an empty-set digest posing
1324                // as one, so the source can never register as "moved".
1325                None
1326            } else {
1327                let files = enumerate_facet_files(source, deny_paths, workspace_root);
1328                Some(serialize_digest_token(&digest_stat_map(&compute_stat_map(
1329                    &files,
1330                    workspace_root,
1331                ))))
1332            }
1333        }
1334        ChangeStrategy::None => None,
1335    }
1336}
1337
1338/// Whether any of an ingest's sources moved since its last synced pass — the
1339/// cheap, slice-free predicate the backoff uses as its additive second
1340/// trigger. Compares each source's current token to the baseline stored in the
1341/// destination mem's `sync_state`; a source with no baseline is not "moved"
1342/// (a first sync does not by itself defeat backoff). Mirrors the plugin's
1343/// `sourceChangedSince`.
1344pub fn source_moved(engine: &Engine, resolved: &ResolvedIngest, workspace_root: &Path) -> bool {
1345    source_moved_since(engine, resolved, workspace_root, "synced", false)
1346}
1347
1348/// The generalized form of [`source_moved`]: compare each source's current
1349/// change-detection token against the baseline stored under
1350/// `"<binding>/<facet>#<state>"` in the destination mem's `sync_state`. The
1351/// `state` suffix selects the baseline family — `"synced"` (the build/sync
1352/// baseline [`source_moved`] reads) or `"verified"` (the verify baseline).
1353///
1354/// `missing_baseline_is_moved` decides the never-recorded case: `false`
1355/// preserves [`source_moved`]'s posture (no baseline ⇒ not "moved" — a first
1356/// sync does not by itself defeat backoff); `true` treats a source with a live
1357/// current token but no recorded baseline as moved — the verify due-check's
1358/// posture, where "never verified" means the first verify is due.
1359pub fn source_moved_since(
1360    engine: &Engine,
1361    resolved: &ResolvedIngest,
1362    workspace_root: &Path,
1363    state: &str,
1364    missing_baseline_is_moved: bool,
1365) -> bool {
1366    let dest = &resolved.destination_mem;
1367    let baseline_map = engine
1368        .mem_config_for(dest)
1369        .map(|c| c.sync_state.clone())
1370        .unwrap_or_default();
1371
1372    for source in &resolved.sources {
1373        let (facet_ref, current) = match source {
1374            ResolvedSource::Primary(p) => (
1375                p.name.clone(),
1376                current_primary_token(engine, p, &resolved.deny_paths, workspace_root),
1377            ),
1378            ResolvedSource::Reference { mem } => {
1379                (mem.clone(), engine.mem_head_sha(mem).ok().flatten())
1380            }
1381        };
1382        let key = format!("{}/{}#{state}", resolved.name, facet_ref);
1383        let Some(baseline) = baseline_map.get(&key) else {
1384            // No baseline recorded for this state family.
1385            if missing_baseline_is_moved && current.as_deref().is_some_and(|c| !c.is_empty()) {
1386                return true;
1387            }
1388            continue;
1389        };
1390        if let Some(current) = current
1391            && !current.is_empty()
1392            && current != *baseline
1393        {
1394            return true;
1395        }
1396    }
1397    false
1398}
1399
1400/// Assemble the combined [`SourceCursor`] for an ingest from live state: the
1401/// destination mem's `sync_state` baselines and each source's current state.
1402pub fn compute_source_cursor(
1403    engine: &Engine,
1404    resolved: &ResolvedIngest,
1405    workspace_root: &Path,
1406) -> SourceCursor {
1407    let dest = &resolved.destination_mem;
1408    let baseline_map = engine
1409        .mem_config_for(dest)
1410        .map(|c| c.sync_state.clone())
1411        .unwrap_or_default();
1412
1413    let cache_root = workspace_root.join(".memstead.cache").join("ingest");
1414    let mut union = Slice::default();
1415    let mut write_commands: Vec<SyncCommand> = Vec::new();
1416    let mut reseed: Vec<SyncCommand> = Vec::new();
1417    let mut no_signal: Vec<NoSignalNote> = Vec::new();
1418    let mut delivery: Vec<DeliverySequence> = Vec::new();
1419    let mut degraded = false;
1420    // Units already disposed in an in-progress pass (touchpoint B): the
1421    // sequence counts them and presents the next ones in order. Read once,
1422    // lazily — a binding without a delivery source never touches the store.
1423    let disposed_units: std::cell::OnceCell<BTreeSet<String>> = std::cell::OnceCell::new();
1424    let disposed_units = || {
1425        disposed_units.get_or_init(|| {
1426            resolved
1427                .name
1428                .split_once('/')
1429                .and_then(|(mem, name)| {
1430                    super::advance::read_advance_store(workspace_root, mem, name)
1431                        .ok()
1432                        .flatten()
1433                })
1434                .map(|state| state.dispositions.keys().cloned().collect())
1435                .unwrap_or_default()
1436        })
1437    };
1438
1439    for source in &resolved.sources {
1440        // Key: "<ingest>/<facet_ref>" for primaries, "<ingest>/<mem>" for
1441        // reference sources — matching the plugin's sync_state keying.
1442        // The note's remedy is medium-shaped, so the medium travels with it.
1443        let primary_medium = match source {
1444            ResolvedSource::Primary(p) => Some(p.medium_type),
1445            ResolvedSource::Reference { .. } => None,
1446        };
1447        let (facet_ref, outcome) = match source {
1448            ResolvedSource::Primary(p) => {
1449                let key = format!("{}/{}#synced", resolved.name, p.name);
1450                let baseline = baseline_map.get(&key).map(String::as_str);
1451                let outcome = match resolve_change_strategy(p, workspace_root) {
1452                    ChangeStrategy::Git => {
1453                        compute_git_slice(p, &resolved.deny_paths, workspace_root, baseline)
1454                    }
1455                    // A graph-typed primary's medium pointer is the source mem id.
1456                    // An unscoped graph facet refuses exactly as the git and
1457                    // mtime arms do: the graph slice alone used to proceed on
1458                    // an empty scope, which is how a facet could carry scope
1459                    // nothing interpreted and still look like it was working.
1460                    ChangeStrategy::Graph if facet_unscoped(p) => SliceOutcome::NoSignal {
1461                        reason: NoSignalReason::Unscoped,
1462                    },
1463                    ChangeStrategy::Graph => {
1464                        compute_graph_slice(engine, Some(p), &p.pointer, baseline)
1465                    }
1466                    ChangeStrategy::Mtime => compute_mtime_slice(
1467                        p,
1468                        &resolved.name,
1469                        &resolved.deny_paths,
1470                        workspace_root,
1471                        &cache_root,
1472                        baseline,
1473                    ),
1474                    // `none` is inert — a rendered `signal:none` state, no slice.
1475                    ChangeStrategy::None => SliceOutcome::NoSignal {
1476                        reason: NoSignalReason::DetectionNone,
1477                    },
1478                };
1479                // Touchpoint B: a source declaring a delivery preparation
1480                // delivers units in its own total order instead of files. A
1481                // source declaring none keeps the file-granularity outcome
1482                // computed above, byte-for-byte.
1483                let outcome =
1484                    match crate::preparation::delivery_preparation(p.preparation.as_deref()) {
1485                        Some(prep)
1486                            if matches!(
1487                                p.medium_type,
1488                                MediumType::Codebase | MediumType::Filesystem | MediumType::Git
1489                            ) =>
1490                        {
1491                            let (outcome, sequence) = deliver_units(
1492                                p,
1493                                prep.id,
1494                                &resolved.deny_paths,
1495                                workspace_root,
1496                                baseline,
1497                                resolved.batch_size as usize,
1498                                disposed_units(),
1499                                outcome,
1500                            );
1501                            delivery.extend(sequence);
1502                            outcome
1503                        }
1504                        _ => outcome,
1505                    };
1506                (p.name.clone(), outcome)
1507            }
1508            ResolvedSource::Reference { mem } => {
1509                let key = format!("{}/{}#synced", resolved.name, mem);
1510                let baseline = baseline_map.get(&key).map(String::as_str);
1511                (
1512                    mem.clone(),
1513                    compute_graph_slice(engine, None, mem, baseline),
1514                )
1515            }
1516        };
1517
1518        let key = format!("{}/{}#synced", resolved.name, facet_ref);
1519        match outcome {
1520            // Genuinely unchanged (baseline present, nothing moved) is the only
1521            // documented silence — it renders nothing, keeping an all-unchanged
1522            // brief byte-identical to a plain roam.
1523            SliceOutcome::Unchanged { .. } => {}
1524            // Every no-signal reason is a visible per-source note.
1525            SliceOutcome::NoSignal { reason } => no_signal.push(NoSignalNote {
1526                source: facet_ref.clone(),
1527                reason,
1528                medium_type: primary_medium,
1529            }),
1530            SliceOutcome::Reseed { token } => reseed.push(SyncCommand { key, token }),
1531            SliceOutcome::Changed {
1532                token,
1533                slice,
1534                degraded: d,
1535            } => {
1536                union.added.extend(slice.added);
1537                union.modified.extend(slice.modified);
1538                union.deleted.extend(slice.deleted);
1539                degraded |= d;
1540                write_commands.push(SyncCommand { key, token });
1541            }
1542        }
1543    }
1544
1545    dedupe_sort(&mut union.added);
1546    dedupe_sort(&mut union.modified);
1547    dedupe_sort(&mut union.deleted);
1548    let any_changes =
1549        !union.added.is_empty() || !union.modified.is_empty() || !union.deleted.is_empty();
1550
1551    SourceCursor {
1552        union,
1553        write_commands,
1554        reseed,
1555        no_signal,
1556        any_changes,
1557        degraded,
1558        dead_denies: dead_deny_entries(resolved, workspace_root),
1559        delivery,
1560        dest_mem: dest.clone(),
1561        // The resolved ingest's `name` is the canonical binding id `<mem>/<stem>`
1562        // (via `resolve_binding_run`) — the id the `projection advance` line the
1563        // brief renders (D4/D7) is keyed on.
1564        binding_id: resolved.name.clone(),
1565    }
1566}
1567
1568fn dedupe_sort(v: &mut Vec<String>) {
1569    v.sort();
1570    v.dedup();
1571}
1572
1573/// A workspace-relative source file's text (lossy for non-UTF-8 bytes).
1574fn read_workspace_file(workspace_root: &Path, ws_rel: &str) -> Option<String> {
1575    std::fs::read(workspace_root.join(ws_rel))
1576        .ok()
1577        .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
1578}
1579
1580/// The text of a source file as it stood at the git baseline commit —
1581/// `git show <baseline>:<repo-relative path>` — when the source resolves to
1582/// the git strategy and the baseline is a commit of its repo. `None`
1583/// otherwise: the caller then has no old state to diff units against and
1584/// degrades to whole-file units, saying so.
1585fn git_baseline_content(
1586    source: &Source,
1587    workspace_root: &Path,
1588    baseline: Option<&str>,
1589    ws_rel: &str,
1590) -> Option<String> {
1591    let baseline = baseline.filter(|b| is_git_token(b))?;
1592    if !matches!(
1593        resolve_change_strategy(source, workspace_root),
1594        ChangeStrategy::Git
1595    ) {
1596        return None;
1597    }
1598    let git_root = find_git_root(&medium_base(&source.pointer, workspace_root))?;
1599    let abs = normalize_lexical(&workspace_root.join(ws_rel));
1600    let rel = relative_path(&git_root, &abs);
1601    let spec = format!("{baseline}:{}", rel.to_string_lossy().replace('\\', "/"));
1602    let out = Command::new("git")
1603        .args(["show", &spec])
1604        .current_dir(&git_root)
1605        .output()
1606        .ok()?;
1607    out.status
1608        .success()
1609        .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
1610}
1611
1612/// The total order of a delivery sequence: the units' own order keys first,
1613/// then the path, then the same-stamp ordinal NUMERICALLY (`.2` before
1614/// `.10`: an unpadded ordinal compared as text would deliver the tenth entry
1615/// of a day before the second), then the key as text — never the order the
1616/// files were discovered in. The same set of units sorts identically however
1617/// it was collected.
1618pub(crate) fn sequence_units(units: &mut Vec<DeliveredUnit>) {
1619    fn rank(id: &str) -> (&str, u64, &str) {
1620        let (path, key) = crate::preparation::split_unit_id(id);
1621        let key = key.unwrap_or("");
1622        let ordinal = key
1623            .rsplit_once('.')
1624            .and_then(|(_, n)| n.parse::<u64>().ok())
1625            .unwrap_or(1);
1626        (path, ordinal, key)
1627    }
1628    units.sort_by(|a, b| (&a.order_key, rank(&a.id)).cmp(&(&b.order_key, rank(&b.id))));
1629    units.dedup_by(|a, b| a.id == b.id);
1630}
1631
1632/// Touchpoint B: turn a delivery-prepared source's file-level outcome into
1633/// its unit sequence. A first run (`Reseed`) delivers every unit of every
1634/// in-scope file; a change run (`Changed`) delivers the units of added files,
1635/// the units that differ in modified files (diffed against the git baseline
1636/// content; without one, every unit of the file, flagged degraded), and the
1637/// baseline's units of deleted files (a deleted file with no retrievable
1638/// baseline stays a file-level deletion). The units sort into the total
1639/// order `(order key, id)`, the same on every pass; units already disposed in
1640/// the in-progress advance store are marked so the brief presents the next
1641/// ones. The unit ids replace the file ids in the outcome's slice, so the
1642/// advance gate accepts every unit of the sequence (the brief lists the next
1643/// batch of them). Every other outcome passes through untouched.
1644#[allow(clippy::too_many_arguments)]
1645fn deliver_units(
1646    source: &Source,
1647    preparation: &str,
1648    deny_paths: &[String],
1649    workspace_root: &Path,
1650    baseline: Option<&str>,
1651    batch: usize,
1652    disposed: &BTreeSet<String>,
1653    outcome: SliceOutcome,
1654) -> (SliceOutcome, Option<DeliverySequence>) {
1655    use crate::preparation::{DeliveryUnit, UnitChange, diff_units, unit_id, unitize};
1656
1657    let units_of =
1658        |text: &str| -> Vec<DeliveryUnit> { unitize(preparation, text).unwrap_or_default() };
1659    let delivered = |path: &str, u: &DeliveryUnit, change: UnitChange| DeliveredUnit {
1660        id: unit_id(path, &u.key),
1661        order_key: u.order_key.clone(),
1662        change,
1663        disposed: false,
1664    };
1665
1666    let mut units: Vec<DeliveredUnit> = Vec::new();
1667    let mut file_level_deleted: Vec<String> = Vec::new();
1668    let mut degraded_units = false;
1669    let (token, first_run, degraded) = match outcome {
1670        SliceOutcome::Reseed { token } => {
1671            for f in enumerate_facet_files(source, deny_paths, workspace_root) {
1672                if let Some(text) = read_workspace_file(workspace_root, &f) {
1673                    for u in units_of(&text) {
1674                        units.push(delivered(&f, &u, UnitChange::Added));
1675                    }
1676                }
1677            }
1678            if units.is_empty() {
1679                // Nothing in scope: the plain reseed, exactly as before.
1680                return (SliceOutcome::Reseed { token }, None);
1681            }
1682            (token, true, false)
1683        }
1684        SliceOutcome::Changed {
1685            token,
1686            slice,
1687            degraded,
1688        } => {
1689            for f in &slice.added {
1690                if let Some(text) = read_workspace_file(workspace_root, f) {
1691                    for u in units_of(&text) {
1692                        units.push(delivered(f, &u, UnitChange::Added));
1693                    }
1694                }
1695            }
1696            for f in &slice.modified {
1697                let Some(now) = read_workspace_file(workspace_root, f) else {
1698                    continue;
1699                };
1700                let new_units = units_of(&now);
1701                match git_baseline_content(source, workspace_root, baseline, f) {
1702                    Some(old) => {
1703                        for (u, change) in diff_units(&units_of(&old), &new_units) {
1704                            units.push(delivered(f, &u, change));
1705                        }
1706                    }
1707                    None => {
1708                        degraded_units = true;
1709                        for u in new_units {
1710                            units.push(delivered(f, &u, UnitChange::Modified));
1711                        }
1712                    }
1713                }
1714            }
1715            for f in &slice.deleted {
1716                match git_baseline_content(source, workspace_root, baseline, f) {
1717                    Some(old) => {
1718                        for u in units_of(&old) {
1719                            units.push(delivered(f, &u, UnitChange::Deleted));
1720                        }
1721                    }
1722                    None => file_level_deleted.push(f.clone()),
1723                }
1724            }
1725            (token, false, degraded)
1726        }
1727        other => return (other, None),
1728    };
1729
1730    sequence_units(&mut units);
1731    for u in &mut units {
1732        u.disposed = disposed.contains(&u.id);
1733    }
1734
1735    let mut slice = Slice::default();
1736    for u in &units {
1737        match u.change {
1738            UnitChange::Added => slice.added.push(u.id.clone()),
1739            UnitChange::Modified => slice.modified.push(u.id.clone()),
1740            UnitChange::Deleted => slice.deleted.push(u.id.clone()),
1741        }
1742    }
1743    slice.deleted.extend(file_level_deleted);
1744    dedupe_sort(&mut slice.added);
1745    dedupe_sort(&mut slice.modified);
1746    dedupe_sort(&mut slice.deleted);
1747
1748    let sequence = DeliverySequence {
1749        source: source.name.clone(),
1750        preparation: preparation.to_string(),
1751        first_run,
1752        degraded: degraded_units,
1753        batch,
1754        units,
1755    };
1756    (
1757        SliceOutcome::Changed {
1758            token,
1759            slice,
1760            degraded,
1761        },
1762        Some(sequence),
1763    )
1764}
1765
1766#[cfg(test)]
1767mod tests {
1768    use super::*;
1769
1770    #[test]
1771    fn normalize_resolves_dot_and_dotdot() {
1772        assert_eq!(
1773            normalize_lexical(Path::new("/a/b/../c/./d")),
1774            PathBuf::from("/a/c/d")
1775        );
1776        assert_eq!(
1777            normalize_lexical(Path::new("/a/../../b")),
1778            PathBuf::from("/b"),
1779            "dotdot past root is clamped"
1780        );
1781    }
1782
1783    #[test]
1784    fn relative_computes_updowns() {
1785        assert_eq!(
1786            relative_path(Path::new("/a/b"), Path::new("/a/b/c/d")),
1787            PathBuf::from("c/d")
1788        );
1789        assert_eq!(
1790            relative_path(Path::new("/a/b/c"), Path::new("/a/x")),
1791            PathBuf::from("../../x")
1792        );
1793        // A workspace whose medium is a sibling repository.
1794        assert_eq!(
1795            relative_path(Path::new("/m/public"), Path::new("/m/public/crates/x.rs")),
1796            PathBuf::from("crates/x.rs")
1797        );
1798        assert_eq!(
1799            relative_path(Path::new("/m/graph"), Path::new("/m/public/crates/x.rs")),
1800            PathBuf::from("../public/crates/x.rs")
1801        );
1802    }
1803
1804    #[test]
1805    fn pathspec_builds_glob_magic_relative_to_git_root() {
1806        let ws = Path::new("/m/graph");
1807        let git_root = Path::new("/m/public");
1808        assert_eq!(
1809            to_git_pathspec("../public/**/*.rs", git_root, ws, false),
1810            ":(glob)**/*.rs"
1811        );
1812        assert_eq!(
1813            to_git_pathspec("../public/target/**", git_root, ws, true),
1814            ":(glob,exclude)target/**"
1815        );
1816    }
1817
1818    /// A `**`-prefixed pattern (the scaffolded facet default `**/*`) is
1819    /// prefix-free and re-anchors verbatim onto the git root. Lexical
1820    /// re-rooting would yield `:(glob)../**/*` for any sub-medium — an
1821    /// out-of-tree pathspec git fatals on, degrading every diff to
1822    /// no-signal.
1823    #[test]
1824    fn wildcard_prefixed_pathspec_reanchors_verbatim() {
1825        let ws = Path::new("/m/ws");
1826        let git_root = Path::new("/m/ws/src");
1827        assert_eq!(to_git_pathspec("**/*", git_root, ws, false), ":(glob)**/*");
1828        assert_eq!(
1829            in_repo_pathspec("**/__pycache__/**", git_root, ws, true).as_deref(),
1830            Some(":(glob,exclude)**/__pycache__/**")
1831        );
1832    }
1833
1834    use crate::ingest::resolve::Source;
1835    use crate::pipeline::{MediumType, PatternEntry};
1836
1837    fn git(repo: &Path, args: &[&str]) {
1838        let status = std::process::Command::new("git")
1839            .args(args)
1840            .current_dir(repo)
1841            .env("GIT_AUTHOR_NAME", "t")
1842            .env("GIT_AUTHOR_EMAIL", "t@t")
1843            .env("GIT_COMMITTER_NAME", "t")
1844            .env("GIT_COMMITTER_EMAIL", "t@t")
1845            .output()
1846            .unwrap();
1847        assert!(
1848            status.status.success(),
1849            "git {args:?}: {}",
1850            String::from_utf8_lossy(&status.stderr)
1851        );
1852    }
1853
1854    fn primary(scope: Vec<PatternEntry>) -> Source {
1855        Source {
1856            name: "src".to_string(),
1857            medium_type: MediumType::Codebase,
1858            pointer: String::new(),
1859            change_detection: Some("git".to_string()),
1860            scope,
1861            engagement: None,
1862            preparation: None,
1863        }
1864    }
1865
1866    /// One dialect, one implementation: the SAME entry list must exclude the
1867    /// SAME files from an engine slice as [`super::check_path::check_deny_paths`]
1868    /// denies. Successor to the retired cross-boundary fixture test that
1869    /// pinned the engine against the plugin's JS dialect clone — both callers
1870    /// now run engine code, and this test keeps the two engine consumers
1871    /// (enumeration, path check) agreeing on shared data. Proven by
1872    /// materialising every path into a temp workspace, scoping a facet to
1873    /// `**` (everything), applying the entries as the ingest `deny_paths`,
1874    /// and asserting `enumerate_facet_files` yields exactly `allowed`.
1875    #[test]
1876    fn deny_dialect_agrees_between_slice_and_check() {
1877        let strs =
1878            |items: &[&str]| -> Vec<String> { items.iter().map(|s| s.to_string()).collect() };
1879        let entries = strs(&["dev/**", "**/VISION.md", "docs/meta/CLAUDE.md"]);
1880        let blocked = strs(&[
1881            "dev/notes/a.md",
1882            "dev/x.rs",
1883            "dev/deep/nested/y.txt",
1884            "VISION.md",
1885            "crates/foo/VISION.md",
1886            "docs/meta/CLAUDE.md",
1887        ]);
1888        let allowed = strs(&[
1889            "src/lib.rs",
1890            "dev-tools/x.rs",
1891            "VISION-draft.md",
1892            "docs/meta/README.md",
1893            "other/CLAUDE.md",
1894            "crates/foo/mod.rs",
1895        ]);
1896
1897        let ws = tempfile::tempdir().unwrap();
1898        for rel in blocked.iter().chain(allowed.iter()) {
1899            let path = ws.path().join(rel);
1900            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1901            std::fs::write(&path, "x").unwrap();
1902        }
1903
1904        // Scope = everything; the ONLY exclusions are the ingest deny_paths.
1905        let source = primary(vec![PatternEntry {
1906            path: "**".to_string(),
1907            mode: PatternMode::Allow,
1908        }]);
1909        let mut got = enumerate_facet_files(&source, &entries, ws.path());
1910        got.sort();
1911        let mut want = allowed.clone();
1912        want.sort();
1913        assert_eq!(
1914            got, want,
1915            "engine slice must equal the fixture `allowed` set"
1916        );
1917
1918        for b in &blocked {
1919            assert!(
1920                !got.contains(b),
1921                "denied `{b}` leaked into the engine slice"
1922            );
1923        }
1924        // The path check agrees on every case — the two engine consumers of
1925        // the dialect can never drift apart silently.
1926        let all: Vec<String> = blocked.iter().chain(allowed.iter()).cloned().collect();
1927        let checks =
1928            super::super::check_path::check_deny_paths(&entries, &all, ws.path(), ws.path());
1929        for c in &checks {
1930            let expect = blocked.contains(&c.path);
1931            assert_eq!(
1932                c.denied, expect,
1933                "check_deny_paths disagrees with the slice on `{}`",
1934                c.path
1935            );
1936        }
1937    }
1938
1939    /// A cross-repo deny (its target sibling to the medium's git repo)
1940    /// resolves outside `git_root` and is dropped from the pathspecs — pushing
1941    /// it would make git fatal on the whole diff. An in-repo deny is kept.
1942    #[test]
1943    fn out_of_repo_deny_pathspec_is_dropped() {
1944        let ws = Path::new("/m/graph");
1945        let git_root = Path::new("/m/public");
1946        // `../dev/**` (workspace-relative) → /m/dev/** — outside /m/public.
1947        assert_eq!(in_repo_pathspec("../dev/**", git_root, ws, true), None);
1948        assert_eq!(in_repo_pathspec("../CLAUDE.md", git_root, ws, true), None);
1949        // An in-repo deny is preserved as a normal exclude pathspec.
1950        assert_eq!(
1951            in_repo_pathspec("../public/target/**", git_root, ws, true),
1952            Some(":(glob,exclude)target/**".to_string())
1953        );
1954    }
1955
1956    /// A git-shaped baseline the repo does NOT contain reseeds at HEAD
1957    /// instead of degrading to `GitUnavailable` forever. Regression for the
1958    /// dogfood plugin/graph binding, whose stored baseline was a commit of a
1959    /// *different* repo (seeded before the source moved into the submodule):
1960    /// every pass diffed against a foreign sha, fataled, and the baseline
1961    /// never seated.
1962    #[test]
1963    fn foreign_baseline_reseeds_instead_of_degrading() {
1964        let repo = tempfile::tempdir().unwrap();
1965        let root = repo.path();
1966        std::fs::write(root.join("keep.rs"), "one").unwrap();
1967        git(root, &["init", "-q"]);
1968        git(root, &["add", "-A"]);
1969        git(root, &["commit", "-qm", "seed"]);
1970
1971        let source = primary(vec![PatternEntry {
1972            path: "**/*.rs".to_string(),
1973            mode: PatternMode::Allow,
1974        }]);
1975        // Git-token-shaped, but no such commit exists in this repo.
1976        let foreign = "46ce8add0fe87250527b6fa21fcfdc2d943d51f0";
1977        match compute_git_slice(&source, &[], root, Some(foreign)) {
1978            SliceOutcome::Reseed { token } => {
1979                // Reseeds at the repo's actual HEAD — the baseline seats.
1980                let head = String::from_utf8(
1981                    std::process::Command::new("git")
1982                        .args(["rev-parse", "HEAD"])
1983                        .current_dir(root)
1984                        .output()
1985                        .unwrap()
1986                        .stdout,
1987                )
1988                .unwrap()
1989                .trim()
1990                .to_string();
1991                assert_eq!(token, head);
1992            }
1993            other => panic!("foreign baseline must reseed, got {other:?}"),
1994        }
1995    }
1996
1997    /// A real git diff with a cross-repo deny present must still succeed (the
1998    /// out-of-repo pathspec is dropped, not fataled), and the in-repo scope is
1999    /// honoured. Regression for the dogfood dialect (`../dev/**` under a
2000    /// sub-medium): git must not degrade the whole slice.
2001    #[test]
2002    fn git_slice_survives_cross_repo_deny() {
2003        let repo = tempfile::tempdir().unwrap();
2004        let root = repo.path();
2005        std::fs::write(root.join("keep.rs"), "one").unwrap();
2006        git(root, &["init", "-q"]);
2007        git(root, &["add", "-A"]);
2008        git(root, &["commit", "-qm", "seed"]);
2009        let baseline = String::from_utf8(
2010            std::process::Command::new("git")
2011                .args(["rev-parse", "HEAD"])
2012                .current_dir(root)
2013                .output()
2014                .unwrap()
2015                .stdout,
2016        )
2017        .unwrap()
2018        .trim()
2019        .to_string();
2020        std::fs::write(root.join("keep.rs"), "two").unwrap();
2021        git(root, &["add", "-A"]);
2022        git(root, &["commit", "-qm", "move"]);
2023
2024        let source = primary(vec![PatternEntry {
2025            path: "**/*.rs".to_string(),
2026            mode: PatternMode::Allow,
2027        }]);
2028        // `../dev/**` resolves outside this repo — must be dropped, not fatal.
2029        let outcome = compute_git_slice(&source, &["../dev/**".to_string()], root, Some(&baseline));
2030        match outcome {
2031            SliceOutcome::Changed { slice, .. } => {
2032                assert_eq!(slice.modified, vec!["keep.rs"]);
2033            }
2034            other => panic!("expected Changed (deny dropped), got {other:?}"),
2035        }
2036    }
2037
2038    /// A real git diff: baseline commit → HEAD produces the changed slice,
2039    /// classifying added / modified / deleted and honouring the scope.
2040    #[test]
2041    fn git_slice_diffs_baseline_to_head() {
2042        let repo = tempfile::tempdir().unwrap();
2043        let root = repo.path();
2044        git(root, &["init", "-q"]);
2045        std::fs::write(root.join("keep.rs"), "one").unwrap();
2046        std::fs::write(root.join("gone.rs"), "bye").unwrap();
2047        std::fs::write(root.join("note.md"), "ignored-by-scope").unwrap();
2048        git(root, &["add", "-A"]);
2049        git(root, &["commit", "-qm", "base"]);
2050        let baseline = String::from_utf8(
2051            std::process::Command::new("git")
2052                .args(["rev-parse", "HEAD"])
2053                .current_dir(root)
2054                .output()
2055                .unwrap()
2056                .stdout,
2057        )
2058        .unwrap()
2059        .trim()
2060        .to_string();
2061
2062        // Move: modify keep.rs, delete gone.rs, add new.rs, touch note.md.
2063        std::fs::write(root.join("keep.rs"), "two").unwrap();
2064        std::fs::remove_file(root.join("gone.rs")).unwrap();
2065        std::fs::write(root.join("new.rs"), "hi").unwrap();
2066        std::fs::write(root.join("note.md"), "still ignored").unwrap();
2067        git(root, &["add", "-A"]);
2068        git(root, &["commit", "-qm", "move"]);
2069
2070        // Scope to *.rs only — note.md must not appear.
2071        let source = primary(vec![PatternEntry {
2072            path: "**/*.rs".to_string(),
2073            mode: PatternMode::Allow,
2074        }]);
2075        let outcome = compute_git_slice(&source, &[], root, Some(&baseline));
2076        match outcome {
2077            SliceOutcome::Changed {
2078                slice, degraded, ..
2079            } => {
2080                assert!(!degraded);
2081                assert_eq!(slice.added, vec!["new.rs"]);
2082                assert_eq!(slice.modified, vec!["keep.rs"]);
2083                assert_eq!(slice.deleted, vec!["gone.rs"]);
2084            }
2085            other => panic!("expected Changed, got {other:?}"),
2086        }
2087
2088        // Same baseline == HEAD → Unchanged.
2089        let head = String::from_utf8(
2090            std::process::Command::new("git")
2091                .args(["rev-parse", "HEAD"])
2092                .current_dir(root)
2093                .output()
2094                .unwrap()
2095                .stdout,
2096        )
2097        .unwrap()
2098        .trim()
2099        .to_string();
2100        assert!(matches!(
2101            compute_git_slice(&source, &[], root, Some(&head)),
2102            SliceOutcome::Unchanged { .. }
2103        ));
2104
2105        // A non-commit baseline → Reseed at HEAD.
2106        assert!(matches!(
2107            compute_git_slice(&source, &[], root, None),
2108            SliceOutcome::Reseed { .. }
2109        ));
2110    }
2111
2112    /// Facet-file enumeration honours allow globs, deny globs, and the
2113    /// codebase/filesystem medium-type gate.
2114    /// The defect this join closes: a source with a non-empty pointer whose
2115    /// scope is written the way the brief presents it (paths beneath the
2116    /// pointer) selects those artifacts. Under the retired workspace-relative
2117    /// reading the prefix-anchored pattern matched nothing and the walk
2118    /// returned an empty set with no signal that anything had been dropped.
2119    #[test]
2120    fn scope_patterns_resolve_against_the_source_pointer() {
2121        let tmp = tempfile::tempdir().unwrap();
2122        let root = tmp.path();
2123        std::fs::create_dir_all(root.join("src/lib/deep")).unwrap();
2124        std::fs::write(root.join("src/lib/a.rs"), "").unwrap();
2125        std::fs::write(root.join("src/lib/deep/b.rs"), "").unwrap();
2126        std::fs::write(root.join("src/lib/skip.md"), "").unwrap();
2127        std::fs::write(root.join("outside.rs"), "").unwrap();
2128
2129        let mut source = primary(vec![
2130            PatternEntry {
2131                path: "**/*.rs".to_string(),
2132                mode: PatternMode::Allow,
2133            },
2134            PatternEntry {
2135                path: "deep/**".to_string(),
2136                mode: PatternMode::Deny,
2137            },
2138        ]);
2139        source.pointer = "src/lib".to_string();
2140        let got = enumerate_facet_files(&source, &[], root);
2141        assert_eq!(
2142            got,
2143            vec!["src/lib/a.rs".to_string()],
2144            "the deny is source-relative too, and nothing outside the pointer enters"
2145        );
2146    }
2147
2148    /// A bare literal beneath the pointer is the shape that could never even
2149    /// appear as uncovered under the old reading: it matched nothing, so it
2150    /// was absent from the denominator rather than present-and-uncovered.
2151    #[test]
2152    fn a_bare_literal_under_the_pointer_selects_its_file() {
2153        let tmp = tempfile::tempdir().unwrap();
2154        let root = tmp.path();
2155        std::fs::create_dir_all(root.join("pkg")).unwrap();
2156        std::fs::write(root.join("pkg/Cargo.toml"), "").unwrap();
2157        std::fs::write(root.join("pkg/other.toml"), "").unwrap();
2158
2159        let mut source = primary(vec![PatternEntry {
2160            path: "Cargo.toml".to_string(),
2161            mode: PatternMode::Allow,
2162        }]);
2163        source.pointer = "pkg".to_string();
2164        assert_eq!(
2165            enumerate_facet_files(&source, &[], root),
2166            vec!["pkg/Cargo.toml".to_string()]
2167        );
2168    }
2169
2170    /// An ingest-level deny stays workspace-relative — it spans every source
2171    /// in the binding, so it has no pointer to be relative to. The two
2172    /// namespaces coexist on one candidate without either leaking.
2173    #[test]
2174    fn ingest_denies_stay_workspace_relative() {
2175        let tmp = tempfile::tempdir().unwrap();
2176        let root = tmp.path();
2177        std::fs::create_dir_all(root.join("src/secret")).unwrap();
2178        std::fs::write(root.join("src/keep.rs"), "").unwrap();
2179        std::fs::write(root.join("src/secret/hide.rs"), "").unwrap();
2180
2181        let mut source = primary(vec![PatternEntry {
2182            path: "**/*.rs".to_string(),
2183            mode: PatternMode::Allow,
2184        }]);
2185        source.pointer = "src".to_string();
2186        let got = enumerate_facet_files(&source, &["src/secret/**".to_string()], root);
2187        assert_eq!(got, vec!["src/keep.rs".to_string()]);
2188    }
2189
2190    /// An empty pointer is the shape the scaffolder writes and the only one
2191    /// where the two readings coincide — it must be untouched by the join.
2192    #[test]
2193    fn an_empty_pointer_is_unaffected_by_the_join() {
2194        let tmp = tempfile::tempdir().unwrap();
2195        let root = tmp.path();
2196        std::fs::create_dir_all(root.join("docs")).unwrap();
2197        std::fs::write(root.join("docs/a.md"), "").unwrap();
2198        std::fs::write(root.join("docs/b.txt"), "").unwrap();
2199
2200        let source = primary(vec![PatternEntry {
2201            path: "docs/**/*.md".to_string(),
2202            mode: PatternMode::Allow,
2203        }]);
2204        assert_eq!(
2205            enumerate_facet_files(&source, &[], root),
2206            vec!["docs/a.md".to_string()]
2207        );
2208    }
2209
2210    /// The enumerator and the path-check oracle agree on the rules that
2211    /// EXTEND the glob, not merely on the glob library: a bare-name deny
2212    /// (which degrades to a directory-prefix block) and a malformed deny
2213    /// (whose literal base still blocks) must exclude from `S(D)` exactly
2214    /// what they deny at the hook. Before the shared resolver these two
2215    /// shapes were denied at the hook and still counted in the denominator.
2216    #[test]
2217    fn the_enumerator_applies_the_oracle_extra_rules_too() {
2218        let tmp = tempfile::tempdir().unwrap();
2219        let root = tmp.path();
2220        for dir in ["bare", "broken"] {
2221            std::fs::create_dir_all(root.join(dir)).unwrap();
2222            std::fs::write(root.join(dir).join("f.rs"), "").unwrap();
2223        }
2224        std::fs::write(root.join("keep.rs"), "").unwrap();
2225
2226        // `bare` is a legacy bare name (no metacharacter at all); `broken[`
2227        // will not compile, so only its literal base can block.
2228        let denies = vec!["bare".to_string(), "broken[".to_string()];
2229        let source = primary(vec![PatternEntry {
2230            path: "**/*.rs".to_string(),
2231            mode: PatternMode::Allow,
2232        }]);
2233        let enumerated = enumerate_facet_files(&source, &denies, root);
2234        assert_eq!(
2235            enumerated,
2236            vec!["keep.rs".to_string()],
2237            "both extra rules must exclude from S(D)"
2238        );
2239
2240        // The same entries, answered by the hook's own surface.
2241        let verdicts = super::super::check_path::check_deny_paths(
2242            &denies,
2243            &[
2244                "bare/f.rs".to_string(),
2245                "broken/f.rs".to_string(),
2246                "keep.rs".to_string(),
2247            ],
2248            root,
2249            root,
2250        );
2251        let denied: Vec<bool> = verdicts.iter().map(|v| v.denied).collect();
2252        assert_eq!(
2253            denied,
2254            vec![true, true, false],
2255            "the hook and the enumerator must agree path for path"
2256        );
2257    }
2258
2259    /// The dead-deny lint must answer with the resolution that ENFORCES a
2260    /// deny, not with the raw glob. A bare-name entry (`dev`) blocks through
2261    /// the literal-base directory-prefix rule, so it is live — reporting it as
2262    /// matching nothing told the author to delete an entry whose removal would
2263    /// have raised the denominator, while the same binding's slice and the
2264    /// path-check command both proved it working.
2265    #[test]
2266    fn the_dead_deny_lint_agrees_with_the_resolution_that_enforces() {
2267        use crate::binding::BuildMode;
2268        use crate::pipeline::IngestTrigger;
2269        let tmp = tempfile::tempdir().unwrap();
2270        let root = tmp.path();
2271        std::fs::create_dir_all(root.join("bare")).unwrap();
2272        std::fs::write(root.join("bare/f.md"), "").unwrap();
2273        std::fs::write(root.join("keep.md"), "").unwrap();
2274
2275        let live = super::super::check_path::DenyOracle::new(&["bare".to_string()]);
2276        assert!(
2277            live.is_denied("bare/f.md"),
2278            "the enforcing resolution blocks a bare name by its literal base"
2279        );
2280
2281        let resolved = ResolvedIngest {
2282            name: "ing".to_string(),
2283            mode: BuildMode::Discovery,
2284            trigger: IngestTrigger::Loop,
2285            batch_size: 50,
2286            deny_paths: vec!["bare".to_string(), "nothing-here/**".to_string()],
2287            projection_ref: "m/p".to_string(),
2288            projection_mem: "m".to_string(),
2289            projection_name: "p".to_string(),
2290            intent: None,
2291            sources: vec![ResolvedSource::Primary(primary(vec![PatternEntry {
2292                path: "**/*.md".to_string(),
2293                mode: PatternMode::Allow,
2294            }]))],
2295            destination_mem: "m".to_string(),
2296            rules: None,
2297            post_actions: None,
2298        };
2299        let dead = dead_deny_entries(&resolved, root);
2300        assert_eq!(
2301            dead,
2302            vec!["nothing-here/**".to_string()],
2303            "only the genuinely dead entry is reported; got {dead:?}"
2304        );
2305    }
2306
2307    /// Partiality has two causes, not one. A malformed pattern is the obvious
2308    /// one; a pattern still in the retired workspace-relative dialect is the
2309    /// dangerous one, because a MIXED scope still enumerates and the surviving
2310    /// subset looks like a population. Both must make `is_partial` true, or a
2311    /// coverage percentage gets computed over a set that is provably short.
2312    #[test]
2313    fn a_legacy_dialect_pattern_makes_the_enumeration_partial() {
2314        let tmp = tempfile::tempdir().unwrap();
2315        let root = tmp.path();
2316        std::fs::create_dir_all(root.join("src")).unwrap();
2317        std::fs::write(root.join("src/a.rs"), "").unwrap();
2318        std::fs::write(root.join("src/b.md"), "").unwrap();
2319
2320        let mut source = primary(vec![
2321            PatternEntry {
2322                path: "**/*.rs".to_string(),
2323                mode: PatternMode::Allow,
2324            },
2325            // Written against the workspace root, not the pointer: it selects
2326            // nothing now, and its share of the population is missing.
2327            PatternEntry {
2328                path: "src/b.md".to_string(),
2329                mode: PatternMode::Allow,
2330            },
2331        ]);
2332        source.pointer = "src".to_string();
2333
2334        let got = enumerate_facet_files_reported(&source, &[], root);
2335        assert_eq!(got.files, vec!["src/a.rs".to_string()]);
2336        assert!(got.malformed.is_empty(), "nothing here fails to compile");
2337        assert!(
2338            got.is_partial(),
2339            "a legacy-dialect pattern truncates the set just as a malformed one does"
2340        );
2341        let why = got.partiality_reason().expect("a reason is stated");
2342        assert!(
2343            why.contains("src/b.md") && why.contains("pointer"),
2344            "the reason must name the pattern and the cause: {why}"
2345        );
2346    }
2347
2348    /// Criterion 7's complement, allow half: a malformed allow no longer takes
2349    /// the whole enumeration with it. The valid patterns still select, and the
2350    /// bad one comes back by name so the caller can state the partiality.
2351    #[test]
2352    fn a_malformed_allow_does_not_empty_the_enumeration() {
2353        let tmp = tempfile::tempdir().unwrap();
2354        let root = tmp.path();
2355        std::fs::write(root.join("a.rs"), "").unwrap();
2356
2357        let source = primary(vec![
2358            PatternEntry {
2359                path: "**/*.rs".to_string(),
2360                mode: PatternMode::Allow,
2361            },
2362            PatternEntry {
2363                path: "[unclosed".to_string(),
2364                mode: PatternMode::Allow,
2365            },
2366        ]);
2367        let got = enumerate_facet_files_reported(&source, &[], root);
2368        assert_eq!(got.files, vec!["a.rs".to_string()]);
2369        assert_eq!(got.malformed, vec!["[unclosed".to_string()]);
2370        assert!(got.is_partial(), "a skipped pattern makes the set partial");
2371    }
2372
2373    /// Criterion 7's complement, deny half: a malformed deny no longer
2374    /// disables the other denies and inflates the denominator.
2375    #[test]
2376    fn a_malformed_deny_does_not_disable_the_other_denies() {
2377        let tmp = tempfile::tempdir().unwrap();
2378        let root = tmp.path();
2379        std::fs::create_dir_all(root.join("skip")).unwrap();
2380        std::fs::write(root.join("keep.rs"), "").unwrap();
2381        std::fs::write(root.join("skip/gone.rs"), "").unwrap();
2382
2383        let source = primary(vec![
2384            PatternEntry {
2385                path: "**/*.rs".to_string(),
2386                mode: PatternMode::Allow,
2387            },
2388            PatternEntry {
2389                path: "skip/**".to_string(),
2390                mode: PatternMode::Deny,
2391            },
2392            PatternEntry {
2393                path: "[unclosed".to_string(),
2394                mode: PatternMode::Deny,
2395            },
2396        ]);
2397        let got = enumerate_facet_files_reported(&source, &[], root);
2398        assert_eq!(
2399            got.files,
2400            vec!["keep.rs".to_string()],
2401            "the good deny still applies"
2402        );
2403        assert_eq!(got.malformed, vec!["[unclosed".to_string()]);
2404    }
2405
2406    /// A binding carrying a malformed scope pattern is refused at validation,
2407    /// naming it — so the enumerator's tolerance above only ever carries
2408    /// records written before this gate existed.
2409    #[test]
2410    fn validation_refuses_a_malformed_scope_pattern_by_name() {
2411        use crate::binding::CapabilityError;
2412        let mut binding = crate::binding::scaffold_binding(crate::binding::ScaffoldParams {
2413            destination_mem: "home",
2414            source_name: "src",
2415            medium_type: MediumType::Codebase,
2416            pointer: "src",
2417            intent: None,
2418            additional_deny_paths: Vec::new(),
2419        })
2420        .binding;
2421        binding.sources[0].scope.push(PatternEntry {
2422            path: "[unclosed".to_string(),
2423            mode: PatternMode::Allow,
2424        });
2425        let errs = crate::binding::validate_binding(&binding).unwrap_err();
2426        assert!(
2427            errs.iter().any(|e| matches!(
2428                e,
2429                CapabilityError::MalformedScopePattern { pattern, .. } if pattern == "[unclosed"
2430            )),
2431            "expected MalformedScopePattern naming the pattern, got {errs:?}"
2432        );
2433    }
2434
2435    #[test]
2436    fn migration_notes_name_old_dialect_patterns_and_suggest_the_rewrite() {
2437        let mut source = primary(vec![
2438            PatternEntry {
2439                path: "../public/plugins/claude-code/**/*.md".to_string(),
2440                mode: PatternMode::Allow,
2441            },
2442            PatternEntry {
2443                path: "../public/plugins/claude-code/dist/**".to_string(),
2444                mode: PatternMode::Deny,
2445            },
2446            PatternEntry {
2447                path: "**/*.mjs".to_string(),
2448                mode: PatternMode::Allow,
2449            },
2450        ]);
2451        source.pointer = "../public/plugins/claude-code".to_string();
2452        let notes = scope_migration_notes(&source);
2453        assert_eq!(notes.len(), 2, "the prefix-free pattern is not reported");
2454        assert_eq!(notes[0].suggested.as_deref(), Some("**/*.md"));
2455        assert!(!notes[0].deny);
2456        assert_eq!(notes[1].suggested.as_deref(), Some("dist/**"));
2457        assert!(notes[1].deny);
2458    }
2459
2460    /// A pointer-less source has no dialect to migrate.
2461    #[test]
2462    fn migration_notes_are_empty_without_a_pointer() {
2463        let source = primary(vec![PatternEntry {
2464            path: "dev/**/*.md".to_string(),
2465            mode: PatternMode::Allow,
2466        }]);
2467        assert!(scope_migration_notes(&source).is_empty());
2468    }
2469
2470    #[test]
2471    fn enumerate_honours_allow_and_deny() {
2472        let ws = tempfile::tempdir().unwrap();
2473        let root = ws.path();
2474        std::fs::create_dir_all(root.join("sub")).unwrap();
2475        std::fs::write(root.join("a.rs"), "").unwrap();
2476        std::fs::write(root.join("sub/b.rs"), "").unwrap();
2477        std::fs::write(root.join("c.md"), "").unwrap();
2478
2479        // medium_pointer "" → base is the workspace root; allow **/*.rs,
2480        // deny sub/** (so sub/b.rs is excluded, c.md never matched).
2481        let source = primary(vec![
2482            PatternEntry {
2483                path: "**/*.rs".to_string(),
2484                mode: PatternMode::Allow,
2485            },
2486            PatternEntry {
2487                path: "sub/**".to_string(),
2488                mode: PatternMode::Deny,
2489            },
2490        ]);
2491        assert_eq!(enumerate_facet_files(&source, &[], root), vec!["a.rs"]);
2492
2493        // A graph medium is not a file tree, so the FILE walk yields nothing
2494        // for it — but that is a statement about this function, not about
2495        // graph enumerability. `enumerate_graph_entities` is the graph arm,
2496        // and `enumerate_source_artifacts` is what every S(D) consumer calls.
2497        let mut graph_source = source.clone();
2498        graph_source.medium_type = MediumType::Graph;
2499        assert!(enumerate_facet_files(&graph_source, &[], root).is_empty());
2500    }
2501
2502    /// Graph enumeration is real: a graph source's `S(D)` is the source mem's
2503    /// in-scope entity set, selected by the entity vocabulary. This is the
2504    /// bail the S1b pilot hit — enumeration returned empty for every graph
2505    /// source, so coverage was vacuously 0/0 and `--full` passed over a
2506    /// measurement that never happened.
2507    #[test]
2508    fn graph_enumeration_selects_the_source_mems_entities() {
2509        use crate::workspace::{
2510            Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2511        };
2512        use crate::workspace_store::WorkspaceStoreAdapter;
2513
2514        let tmp = tempfile::tempdir().unwrap();
2515        let root = tmp.path();
2516        let mem_dir = root.join("srcmem");
2517        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2518        std::fs::write(
2519            mem_dir.join(".memstead").join("config.json"),
2520            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2521        )
2522        .unwrap();
2523
2524        let entity = |slug: &str, ty: &str, title: &str| {
2525            std::fs::write(
2526                mem_dir.join(format!("{slug}.md")),
2527                format!("---\ntype: {ty}\n---\n\n# {title}\n\n## Decision\n\nBody.\n"),
2528            )
2529            .unwrap();
2530        };
2531        entity("alpha-choice", "decision", "Alpha choice");
2532        entity("beta-choice", "decision", "Beta choice");
2533        entity("gamma-note", "memo", "Gamma note");
2534
2535        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2536        std::fs::write(
2537            root.join(".memstead").join("workspace.toml"),
2538            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2539        )
2540        .unwrap();
2541        crate::FileWorkspaceStore::new()
2542            .save_state(
2543                root,
2544                &Workspace {
2545                    mounts: vec![Mount {
2546                        mem: "srcmem".to_string(),
2547                        schema: Some("default@1.0.0".parse().unwrap()),
2548                        storage: MountStorage::Folder {
2549                            path: mem_dir.clone(),
2550                        },
2551                        capability: MountCapability::Write,
2552                        lifecycle: MountLifecycle::Eager,
2553                        cross_linkable: false,
2554                        migration_target: None,
2555                    }],
2556                    settings: WorkspaceSettings::default(),
2557                },
2558            )
2559            .unwrap();
2560
2561        let engine = crate::Engine::from_workspace_root(root).unwrap();
2562
2563        let graph_source = |patterns: Vec<(&str, PatternMode)>| Source {
2564            name: "g".to_string(),
2565            medium_type: MediumType::Graph,
2566            pointer: "srcmem".to_string(),
2567            change_detection: None,
2568            scope: patterns
2569                .into_iter()
2570                .map(|(p, mode)| crate::pipeline::PatternEntry {
2571                    path: p.to_string(),
2572                    mode,
2573                })
2574                .collect(),
2575            engagement: None,
2576            preparation: None,
2577        };
2578
2579        // `*` — the whole mem. A real denominator, not an empty walk.
2580        let all = enumerate_graph_entities(&engine, &graph_source(vec![("*", PatternMode::Allow)]));
2581        assert_eq!(
2582            all,
2583            vec![
2584                "srcmem--alpha-choice".to_string(),
2585                "srcmem--beta-choice".to_string(),
2586                "srcmem--gamma-note".to_string(),
2587            ],
2588            "the whole-mem selector enumerates every real entity"
2589        );
2590
2591        // `type:` selects on the type axis.
2592        let decisions = enumerate_graph_entities(
2593            &engine,
2594            &graph_source(vec![("type:decision", PatternMode::Allow)]),
2595        );
2596        assert_eq!(
2597            decisions,
2598            vec![
2599                "srcmem--alpha-choice".to_string(),
2600                "srcmem--beta-choice".to_string()
2601            ],
2602            "type selector excludes the memo"
2603        );
2604
2605        // `id:` globs the id, and a deny subtracts from an allow.
2606        let globbed = enumerate_graph_entities(
2607            &engine,
2608            &graph_source(vec![
2609                ("id:srcmem--*-choice", PatternMode::Allow),
2610                ("id:srcmem--beta-*", PatternMode::Deny),
2611            ]),
2612        );
2613        assert_eq!(
2614            globbed,
2615            vec!["srcmem--alpha-choice".to_string()],
2616            "deny subtracts from allow in the entity namespace too"
2617        );
2618
2619        // An unscoped graph facet enumerates nothing — the same posture the
2620        // path mediums have always had, and the reason the strategy layer
2621        // refuses it before ever reaching here.
2622        assert!(
2623            enumerate_graph_entities(&engine, &graph_source(vec![])).is_empty(),
2624            "an unscoped graph facet is never silently 'everything'"
2625        );
2626
2627        // The dispatching entry point every S(D) consumer calls agrees.
2628        assert_eq!(
2629            enumerate_source_artifacts(
2630                &engine,
2631                &graph_source(vec![("*", PatternMode::Allow)]),
2632                &[],
2633                root
2634            ),
2635            all,
2636            "enumerate_source_artifacts routes a graph source to the graph arm"
2637        );
2638    }
2639
2640    /// The S1b pilot's headline failure, encoded as a permanent regression
2641    /// test: a stale-pinned entity anchor over a source entity that changed
2642    /// since it was pinned must be flagged `drifted`. It used to go unflagged
2643    /// — anchor resolution was 0/0 for every graph source, so drift was
2644    /// structurally undetectable while the matrix claimed full parity.
2645    #[test]
2646    fn a_stale_entity_anchor_over_a_changed_entity_is_drifted() {
2647        use crate::anchor::{
2648            Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
2649        };
2650        use crate::entity::EntityId;
2651        use crate::workspace::{
2652            Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2653        };
2654        use crate::workspace_store::WorkspaceStoreAdapter;
2655
2656        let tmp = tempfile::tempdir().unwrap();
2657        let root = tmp.path();
2658        let mem_dir = root.join("mem");
2659        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2660        std::fs::write(
2661            mem_dir.join(".memstead").join("config.json"),
2662            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2663        )
2664        .unwrap();
2665        std::fs::write(
2666            mem_dir.join("pinned.md"),
2667            "---\ntype: decision\n---\n\n# Pinned\n\n## Decision\n\nOriginal body.\n",
2668        )
2669        .unwrap();
2670        std::fs::write(
2671            mem_dir.join("steady.md"),
2672            "---\ntype: decision\n---\n\n# Steady\n\n## Decision\n\nUnchanged body.\n",
2673        )
2674        .unwrap();
2675
2676        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2677        std::fs::write(
2678            root.join(".memstead").join("workspace.toml"),
2679            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2680        )
2681        .unwrap();
2682        crate::FileWorkspaceStore::new()
2683            .save_state(
2684                root,
2685                &Workspace {
2686                    mounts: vec![Mount {
2687                        mem: "mem".to_string(),
2688                        schema: Some("default@1.0.0".parse().unwrap()),
2689                        storage: MountStorage::Folder {
2690                            path: mem_dir.clone(),
2691                        },
2692                        capability: MountCapability::Write,
2693                        lifecycle: MountLifecycle::Eager,
2694                        cross_linkable: false,
2695                        migration_target: None,
2696                    }],
2697                    settings: WorkspaceSettings::default(),
2698                },
2699            )
2700            .unwrap();
2701
2702        // Hash the entities as they stand, so the anchors start out honest.
2703        let engine = crate::Engine::from_workspace_root(root).unwrap();
2704        let hash_of = |engine: &crate::Engine, id: &str| {
2705            let e = engine.store().get(&EntityId::canonical(id)).unwrap();
2706            crate::anchor::prepared_content_hash(
2707                crate::render::render_entity_markdown(e, None).as_bytes(),
2708            )
2709        };
2710        let pinned_hash = hash_of(&engine, "mem--pinned");
2711        let steady_hash = hash_of(&engine, "mem--steady");
2712
2713        let entity_anchor = |artifact: &str, hash: &str| Anchor {
2714            artifact: artifact.to_string(),
2715            grain: AnchorGrain::Entity,
2716            class: AnchorProvenanceClass::Anchored,
2717            hash: Some(hash.to_string()),
2718            source: None,
2719            binding: None,
2720            at_version: None,
2721            derived_from: Vec::new(),
2722            hash_stability: crate::anchor::AnchorHashStability::Stable,
2723            span_unvalidated: false,
2724            hash_source: None,
2725        };
2726
2727        let mut sidecar = AnchorSidecar::default();
2728        sidecar.set(
2729            "mem--holder",
2730            vec![
2731                entity_anchor("mem--pinned", &pinned_hash),
2732                entity_anchor("mem--steady", &steady_hash),
2733                // An anchor over an entity that does not exist at all.
2734                entity_anchor("mem--vanished", "deadbeefdeadbeef"),
2735            ],
2736        );
2737        std::fs::write(
2738            mem_dir.join(".memstead").join("anchors.json"),
2739            sidecar.to_bytes(),
2740        )
2741        .unwrap();
2742        std::fs::write(
2743            mem_dir.join("holder.md"),
2744            "---\ntype: decision\n---\n\n# Holder\n\n## Decision\n\nHolds anchors.\n",
2745        )
2746        .unwrap();
2747
2748        // Now change ONE source entity — the pilot's move.
2749        std::fs::write(
2750            mem_dir.join("pinned.md"),
2751            "---\ntype: decision\n---\n\n# Pinned\n\n## Decision\n\nBody rewritten.\n",
2752        )
2753        .unwrap();
2754
2755        let engine = crate::Engine::from_workspace_root(root).unwrap();
2756        let resolved = engine.entity_anchors_resolved(&EntityId::canonical("mem--holder"));
2757        let state_of = |artifact: &str| {
2758            resolved
2759                .iter()
2760                .find(|r| r.anchor.artifact == artifact)
2761                .unwrap_or_else(|| panic!("no resolved anchor for {artifact}"))
2762                .state
2763        };
2764
2765        assert_eq!(
2766            state_of("mem--pinned"),
2767            Some(AnchorState::Drifted),
2768            "a stale-pinned anchor over a CHANGED entity must be drifted — \
2769             this is the pilot failure that went unflagged"
2770        );
2771        assert_eq!(
2772            state_of("mem--steady"),
2773            Some(AnchorState::Resolves),
2774            "an anchor over an unchanged entity still resolves"
2775        );
2776        assert_eq!(
2777            state_of("mem--vanished"),
2778            Some(AnchorState::Orphaned),
2779            "an anchor over an entity that is not there is orphaned, not unobserved"
2780        );
2781
2782        // The complement: a `url` grain genuinely cannot be observed, and must
2783        // stay unobserved rather than being swept up by the widened arm.
2784        let mut sc2 = AnchorSidecar::default();
2785        sc2.set(
2786            "mem--holder",
2787            vec![Anchor {
2788                artifact: "https://example.invalid/doc".to_string(),
2789                grain: AnchorGrain::Url,
2790                class: AnchorProvenanceClass::InformedBy,
2791                hash: None,
2792                source: None,
2793                binding: None,
2794                at_version: None,
2795                derived_from: Vec::new(),
2796                hash_stability: crate::anchor::AnchorHashStability::Stable,
2797                span_unvalidated: false,
2798                hash_source: None,
2799            }],
2800        );
2801        std::fs::write(
2802            mem_dir.join(".memstead").join("anchors.json"),
2803            sc2.to_bytes(),
2804        )
2805        .unwrap();
2806        let engine = crate::Engine::from_workspace_root(root).unwrap();
2807        let url_state =
2808            engine.entity_anchors_resolved(&EntityId::canonical("mem--holder"))[0].state;
2809        assert_eq!(
2810            url_state, None,
2811            "url anchors stay unobserved — the fix widens observation, never the \
2812             scoring of non-observation"
2813        );
2814    }
2815
2816    /// Touchpoint A of the preparation registry, end to end: a graph source
2817    /// declaring `entity-load-bearing` makes its entity anchors hash the
2818    /// type's load-bearing sections. A notes-only edit (an optional section)
2819    /// keeps the prepared anchor resolving while an anchor over the default
2820    /// form (no source, hence no preparation) drifts — today's behaviour,
2821    /// untouched for it; a load-bearing edit drifts both. The standalone
2822    /// `verify_mem_anchors` walks the same observation and inherits the
2823    /// preparation unchanged. An unregistered identifier reaching a record
2824    /// by hand computes no form: its anchors stay unobserved, never scored.
2825    #[test]
2826    fn entity_load_bearing_preparation_ignores_notes_edits_and_catches_claim_edits() {
2827        use crate::anchor::{
2828            Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
2829        };
2830        use crate::binding::{
2831            BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, VerifyOperation,
2832        };
2833        use crate::entity::EntityId;
2834        use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode, Source};
2835        use crate::workspace::{
2836            Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2837        };
2838        use crate::workspace_store::WorkspaceStoreAdapter;
2839
2840        let tmp = tempfile::tempdir().unwrap();
2841        let root = tmp.path();
2842        let mem_dir = root.join("home");
2843        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2844        std::fs::write(
2845            mem_dir.join(".memstead").join("config.json"),
2846            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2847        )
2848        .unwrap();
2849        // `assertion` in default@1.0.0: `claim` and `evidence` are required
2850        // (the load-bearing set), `conditions` is optional (notes-class).
2851        let write_pinned = |claim: &str, conditions: &str| {
2852            std::fs::write(
2853                mem_dir.join("pinned.md"),
2854                format!(
2855                    "---\ntype: assertion\n---\n\n# Pinned\n\n## Claim\n\n{claim}\n\n\
2856                     ## Evidence\n\nMeasured.\n\n## Conditions\n\n{conditions}\n"
2857                ),
2858            )
2859            .unwrap();
2860        };
2861        write_pinned("The sky is blue.", "daylight");
2862        std::fs::write(
2863            mem_dir.join("holder.md"),
2864            "---\ntype: assertion\n---\n\n# Holder\n\n## Claim\n\nDepends on pinned.\n\n\
2865             ## Evidence\n\nSee pinned.\n",
2866        )
2867        .unwrap();
2868
2869        std::fs::create_dir_all(root.join(".memstead")).unwrap();
2870        std::fs::write(
2871            root.join(".memstead").join("workspace.toml"),
2872            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2873        )
2874        .unwrap();
2875        crate::FileWorkspaceStore::new()
2876            .save_state(
2877                root,
2878                &Workspace {
2879                    mounts: vec![Mount {
2880                        mem: "home".to_string(),
2881                        schema: Some("default@1.0.0".parse().unwrap()),
2882                        storage: MountStorage::Folder {
2883                            path: mem_dir.clone(),
2884                        },
2885                        capability: MountCapability::Write,
2886                        lifecycle: MountLifecycle::Eager,
2887                        cross_linkable: false,
2888                        migration_target: None,
2889                    }],
2890                    settings: WorkspaceSettings::default(),
2891                },
2892            )
2893            .unwrap();
2894
2895        // The binding: one graph source named `claims`, declaring the
2896        // registered preparation. Written straight to the store — the shape
2897        // every edit path validates (`validate_binding`) accepts it.
2898        let binding_with = |preparation: Option<&str>| Binding {
2899            version: BINDING_VERSION,
2900            intent: None,
2901            sources: vec![Source {
2902                name: "claims".to_string(),
2903                medium_type: MediumType::Graph,
2904                pointer: "home".to_string(),
2905                change_detection: None,
2906                scope: vec![PatternEntry {
2907                    path: "*".to_string(),
2908                    mode: PatternMode::Allow,
2909                }],
2910                engagement: None,
2911                preparation: preparation.map(str::to_string),
2912            }],
2913            reference_mems: vec![],
2914            destination_mem: "home".to_string(),
2915            deny_paths: vec![],
2916            coverage_semantics: None,
2917            rules: None,
2918            prune: None,
2919            operations: Operations {
2920                build: Some(BuildOperation {
2921                    mode: BuildMode::Discovery,
2922                    trigger: IngestTrigger::Manual,
2923                    batch_size: 5,
2924                    post_actions: None,
2925                }),
2926                sync: None,
2927                verify: Some(VerifyOperation {
2928                    trigger: IngestTrigger::Manual,
2929                    batch_size: 5,
2930                    adjudication_cap: 0,
2931                    full_resync_every: 0,
2932                }),
2933            },
2934        };
2935        let prepared_binding = binding_with(Some(crate::preparation::ENTITY_LOAD_BEARING));
2936        assert!(crate::binding::validate_binding(&prepared_binding).is_ok());
2937        crate::pipeline_store::write_binding(root, "home", "claims", &prepared_binding).unwrap();
2938
2939        // Record both anchors honestly against the entity as it stands: one
2940        // produced by the `claims` source (prepared form), one hand-authored
2941        // (no source: the default form, the canonical rendered markdown).
2942        let engine = crate::Engine::from_workspace_root(root).unwrap();
2943        let pinned = engine
2944            .store()
2945            .get(&EntityId::canonical("home--pinned"))
2946            .unwrap();
2947        let type_def = engine
2948            .schema_for("home")
2949            .and_then(|s| s.get_type("assertion"))
2950            .expect("default@1.0.0 declares assertion");
2951        assert!(
2952            crate::preparation::load_bearing_sections(&type_def)
2953                .iter()
2954                .map(|s| s.key.as_str())
2955                .eq(["claim", "evidence"]),
2956            "the required sections are the load-bearing set"
2957        );
2958        let prepared_hash = crate::preparation::entity_prepared_hash(
2959            pinned,
2960            Some(&type_def),
2961            Some(crate::preparation::ENTITY_LOAD_BEARING),
2962        )
2963        .unwrap();
2964        let default_hash =
2965            crate::preparation::entity_prepared_hash(pinned, Some(&type_def), None).unwrap();
2966        assert_ne!(prepared_hash, default_hash);
2967
2968        let anchor = |source: Option<&str>, hash: &str| Anchor {
2969            artifact: "home--pinned".to_string(),
2970            grain: AnchorGrain::Entity,
2971            class: AnchorProvenanceClass::Anchored,
2972            hash: Some(hash.to_string()),
2973            source: source.map(str::to_string),
2974            binding: None,
2975            at_version: None,
2976            derived_from: Vec::new(),
2977            hash_stability: crate::anchor::AnchorHashStability::Stable,
2978            span_unvalidated: false,
2979            hash_source: None,
2980        };
2981        // Two holders so the two anchors over one artifact stay distinct rows.
2982        std::fs::write(
2983            mem_dir.join("holder2.md"),
2984            "---\ntype: assertion\n---\n\n# Holder2\n\n## Claim\n\nAlso depends.\n\n\
2985             ## Evidence\n\nSee pinned.\n",
2986        )
2987        .unwrap();
2988        let mut sidecar = AnchorSidecar::default();
2989        sidecar.set("home--holder", vec![anchor(Some("claims"), &prepared_hash)]);
2990        sidecar.set("home--holder2", vec![anchor(None, &default_hash)]);
2991        std::fs::write(
2992            mem_dir.join(".memstead").join("anchors.json"),
2993            sidecar.to_bytes(),
2994        )
2995        .unwrap();
2996
2997        let states = |root: &std::path::Path| {
2998            let engine = crate::Engine::from_workspace_root(root).unwrap();
2999            let state_of = |holder: &str| {
3000                engine.entity_anchors_resolved(&EntityId::canonical(holder))[0].state
3001            };
3002            let standalone = engine.verify_mem_anchors("home").unwrap();
3003            (
3004                state_of("home--holder"),
3005                state_of("home--holder2"),
3006                standalone,
3007            )
3008        };
3009
3010        // Unchanged: both resolve.
3011        let (prepared, plain, report) = states(root);
3012        assert_eq!(prepared, Some(AnchorState::Resolves));
3013        assert_eq!(plain, Some(AnchorState::Resolves));
3014        assert_eq!((report.resolved, report.drifted), (2, 0));
3015
3016        // A notes-only edit (`conditions` is not load-bearing): the prepared
3017        // anchor holds, the default-form anchor drifts — today's behaviour,
3018        // byte-for-byte, for a source that declares nothing.
3019        write_pinned("The sky is blue.", "daylight, clear weather");
3020        let (prepared, plain, report) = states(root);
3021        assert_eq!(
3022            prepared,
3023            Some(AnchorState::Resolves),
3024            "a comma in the notes must not break a load-bearing anchor"
3025        );
3026        assert_eq!(plain, Some(AnchorState::Drifted));
3027        assert_eq!((report.resolved, report.drifted), (1, 1));
3028
3029        // A load-bearing edit: both drift.
3030        write_pinned("The sky is green.", "daylight, clear weather");
3031        let (prepared, plain, report) = states(root);
3032        assert_eq!(prepared, Some(AnchorState::Drifted));
3033        assert_eq!(plain, Some(AnchorState::Drifted));
3034        assert_eq!((report.resolved, report.drifted), (0, 2));
3035
3036        // Complement: a hand-edited record naming an identifier the registry
3037        // does not know computes no form — its anchors are unobserved, never
3038        // scored as drift or resolution; the source-less anchor is unaffected.
3039        crate::pipeline_store::write_binding(
3040            root,
3041            "home",
3042            "claims",
3043            &binding_with(Some("pdf-to-markdown")),
3044        )
3045        .unwrap();
3046        let (prepared, plain, report) = states(root);
3047        assert_eq!(
3048            prepared, None,
3049            "an unknown preparation yields no observation"
3050        );
3051        assert_eq!(plain, Some(AnchorState::Drifted));
3052        // The unknown-preparation anchor yields NO observation, so it is
3053        // `unobserved`, not `unresolvable` (consistency-sweep 03/05,
3054        // criterion 2). Before the split this assertion read `(1, 1)` on
3055        // `unresolvable`, which is the collapse itself: the pass not reaching
3056        // an artifact was reported as the artifact being gone.
3057        assert_eq!(
3058            (report.unresolvable, report.unobserved, report.drifted),
3059            (0, 1, 1)
3060        );
3061    }
3062
3063    /// Touchpoint B's order is a property of the units, not of discovery: a
3064    /// shuffled collection sorts into the identical sequence.
3065    #[test]
3066    fn shuffled_discovery_sequences_identically() {
3067        use crate::preparation::UnitChange;
3068        let unit = |id: &str, order: &str| DeliveredUnit {
3069            id: id.to_string(),
3070            order_key: order.to_string(),
3071            change: UnitChange::Added,
3072            disposed: false,
3073        };
3074        let ordered = vec![
3075            unit("corpus/notes.md#whole", ""),
3076            unit("corpus/b.md#2026-08-20T00:00:00", "2026-08-20T00:00:00"),
3077            unit("corpus/a.md#2026-08-21T00:00:00", "2026-08-21T00:00:00"),
3078            unit("corpus/a.md#2026-08-21T00:00:00.2", "2026-08-21T00:00:00"),
3079            unit("corpus/c.md#2026-08-21T00:00:00", "2026-08-21T00:00:00"),
3080            unit("corpus/b.md#2026-08-22T00:00:00", "2026-08-22T00:00:00"),
3081        ];
3082        for shuffle in [
3083            vec![5, 3, 0, 4, 1, 2],
3084            vec![2, 1, 0, 5, 4, 3],
3085            vec![4, 0, 5, 2, 3, 1],
3086        ] {
3087            let mut units: Vec<DeliveredUnit> =
3088                shuffle.iter().map(|i| ordered[*i].clone()).collect();
3089            sequence_units(&mut units);
3090            assert_eq!(units, ordered, "discovery order {shuffle:?} must not leak");
3091        }
3092
3093        // A date-only day with twelve entries: the same-stamp ordinal orders
3094        // numerically, never as text (`.10` after `.9`, not before `.2`).
3095        let day = "2026-08-24T00:00:00";
3096        let expected: Vec<String> = (1..=12)
3097            .map(|n| {
3098                if n == 1 {
3099                    format!("journal.md#{day}")
3100                } else {
3101                    format!("journal.md#{day}.{n}")
3102                }
3103            })
3104            .collect();
3105        let mut units: Vec<DeliveredUnit> = expected.iter().rev().map(|id| unit(id, day)).collect();
3106        sequence_units(&mut units);
3107        assert_eq!(
3108            units.iter().map(|u| u.id.as_str()).collect::<Vec<_>>(),
3109            expected.iter().map(String::as_str).collect::<Vec<_>>()
3110        );
3111    }
3112
3113    /// Touchpoint B end to end over a git corpus whose path order is not its
3114    /// chronological order: the first run delivers every unit in stamp order
3115    /// interleaved across files; the sibling source without a preparation
3116    /// keeps file-granularity delivery; disposing advances through the
3117    /// sequence, an anchor over exactly a unit auto-disposes it while a
3118    /// file-level anchor does not; the change run delivers only the new,
3119    /// changed and removed units at their ordered positions (keys stable
3120    /// under growth); and a span anchor over a unit observes the unit, not
3121    /// the file.
3122    #[test]
3123    fn dated_entries_deliver_in_a_total_order_across_first_and_change_runs() {
3124        use crate::anchor::{
3125            Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
3126        };
3127        use crate::binding::{
3128            BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, VerifyOperation,
3129        };
3130        use crate::entity::EntityId;
3131        use crate::ingest::advance::{DispositionInput, advance_baseline};
3132        use crate::ingest::brief::render_changed_slice;
3133        use crate::ingest::resolve::resolve_binding_run;
3134        use crate::pipeline::{IngestTrigger, PatternMode};
3135        use crate::preparation::{DATED_ENTRIES, UnitChange, unitize};
3136        use crate::workspace::{
3137            Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
3138        };
3139        use crate::workspace_store::WorkspaceStoreAdapter;
3140
3141        let tmp = tempfile::tempdir().unwrap();
3142        let root = tmp.path();
3143
3144        // Destination: a folder mem `home` with one holder entity for anchors.
3145        let mem_dir = root.join("home");
3146        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3147        std::fs::write(
3148            mem_dir.join(".memstead").join("config.json"),
3149            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3150        )
3151        .unwrap();
3152        std::fs::write(
3153            mem_dir.join("holder.md"),
3154            "---\ntype: assertion\n---\n\n# Holder\n\n## Claim\n\nHolds anchors.\n\n\
3155             ## Evidence\n\nSee corpus.\n",
3156        )
3157        .unwrap();
3158        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3159        std::fs::write(
3160            root.join(".memstead").join("workspace.toml"),
3161            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3162        )
3163        .unwrap();
3164        crate::FileWorkspaceStore::new()
3165            .save_state(
3166                root,
3167                &Workspace {
3168                    mounts: vec![Mount {
3169                        mem: "home".to_string(),
3170                        schema: Some("default@1.0.0".parse().unwrap()),
3171                        storage: MountStorage::Folder {
3172                            path: mem_dir.clone(),
3173                        },
3174                        capability: MountCapability::Write,
3175                        lifecycle: MountLifecycle::Eager,
3176                        cross_linkable: false,
3177                        migration_target: None,
3178                    }],
3179                    settings: WorkspaceSettings::default(),
3180                },
3181            )
3182            .unwrap();
3183
3184        // Source: a git corpus whose lexical path order (a, b, notes) is not
3185        // its chronological order.
3186        let corpus = root.join("corpus");
3187        std::fs::create_dir_all(corpus.join("plain")).unwrap();
3188        git(&corpus, &["init", "-q"]);
3189        let write = |name: &str, text: &str| std::fs::write(corpus.join(name), text).unwrap();
3190        write(
3191            "a.md",
3192            "2026-08-21 alpha one\nbody a1\n2026-08-23 alpha two\nbody a2\n",
3193        );
3194        write(
3195            "b.md",
3196            "2026-08-20 beta one\nbody b1\n2026-08-22 beta two\nbody b2\n",
3197        );
3198        write("notes.md", "undated notes\n");
3199        write("plain/readme.txt", "plain source, file granularity\n");
3200        git(&corpus, &["add", "."]);
3201        git(&corpus, &["commit", "-q", "-m", "corpus"]);
3202
3203        let source = |name: &str, scope: &str, preparation: Option<&str>| Source {
3204            name: name.to_string(),
3205            medium_type: MediumType::Filesystem,
3206            pointer: "corpus".to_string(),
3207            change_detection: Some("git".to_string()),
3208            scope: vec![PatternEntry {
3209                path: scope.to_string(),
3210                mode: PatternMode::Allow,
3211            }],
3212            engagement: None,
3213            preparation: preparation.map(str::to_string),
3214        };
3215        let binding = Binding {
3216            version: BINDING_VERSION,
3217            intent: None,
3218            sources: vec![
3219                // Source-relative against the `corpus` pointer.
3220                source("logs", "*.md", Some(DATED_ENTRIES)),
3221                source("plain", "plain/**", None),
3222            ],
3223            reference_mems: vec![],
3224            destination_mem: "home".to_string(),
3225            deny_paths: vec![],
3226            coverage_semantics: None,
3227            rules: None,
3228            prune: None,
3229            operations: Operations {
3230                build: Some(BuildOperation {
3231                    mode: BuildMode::Discovery,
3232                    trigger: IngestTrigger::Manual,
3233                    batch_size: 3,
3234                    post_actions: None,
3235                }),
3236                sync: None,
3237                verify: Some(VerifyOperation {
3238                    trigger: IngestTrigger::Manual,
3239                    batch_size: 5,
3240                    adjudication_cap: 0,
3241                    full_resync_every: 0,
3242                }),
3243            },
3244        };
3245        assert!(
3246            crate::binding::validate_binding(&binding).is_ok(),
3247            "{:?}",
3248            crate::binding::validate_binding(&binding)
3249        );
3250        crate::pipeline_store::write_binding(root, "home", "corpus", &binding).unwrap();
3251        let resolved = resolve_binding_run("home/corpus", &binding).unwrap();
3252
3253        // ---- First run: every unit, in stamp order, interleaved across files.
3254        let mut engine = crate::Engine::from_workspace_root(root).unwrap();
3255        let cursor = compute_source_cursor(&engine, &resolved, root);
3256        let expected: Vec<&str> = vec![
3257            "corpus/notes.md#whole",
3258            "corpus/b.md#2026-08-20T00:00:00",
3259            "corpus/a.md#2026-08-21T00:00:00",
3260            "corpus/b.md#2026-08-22T00:00:00",
3261            "corpus/a.md#2026-08-23T00:00:00",
3262        ];
3263        assert_eq!(
3264            cursor.delivery.len(),
3265            1,
3266            "one sequence, for the prepared source only"
3267        );
3268        let seq = &cursor.delivery[0];
3269        assert_eq!(
3270            (seq.source.as_str(), seq.preparation.as_str()),
3271            ("logs", DATED_ENTRIES)
3272        );
3273        assert!(seq.first_run && !seq.degraded && seq.batch == 3);
3274        assert_eq!(
3275            seq.units.iter().map(|u| u.id.as_str()).collect::<Vec<_>>(),
3276            expected
3277        );
3278        assert!(
3279            seq.units
3280                .iter()
3281                .all(|u| u.change == UnitChange::Added && !u.disposed)
3282        );
3283        let mut expected_sorted: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
3284        expected_sorted.sort();
3285        assert_eq!(
3286            cursor.union.added, expected_sorted,
3287            "the advance gate accepts the unit ids"
3288        );
3289        // The sibling source without a preparation keeps file granularity:
3290        // a plain first-run reseed, no units, no sequence.
3291        assert!(
3292            cursor
3293                .reseed
3294                .iter()
3295                .any(|c| c.key == "home/corpus/plain#synced")
3296        );
3297        assert!(
3298            cursor
3299                .write_commands
3300                .iter()
3301                .any(|c| c.key == "home/corpus/logs#synced")
3302        );
3303        assert!(
3304            !cursor
3305                .union
3306                .added
3307                .iter()
3308                .any(|a| a.starts_with("corpus/plain"))
3309        );
3310        // Recomputing yields the identical sequence.
3311        assert_eq!(compute_source_cursor(&engine, &resolved, root), cursor);
3312
3313        let brief = render_changed_slice(&cursor);
3314        assert!(
3315            brief.contains("### Delivery sequence: `logs` (`dated-entries`)"),
3316            "{brief}"
3317        );
3318        assert!(brief.contains("First delivery of this source"));
3319        let listed: Vec<&str> = brief
3320            .lines()
3321            .filter(|l| l.starts_with(|c: char| c.is_ascii_digit()) && l.contains("`corpus/"))
3322            .collect();
3323        assert_eq!(
3324            listed,
3325            vec![
3326                "1. `corpus/notes.md#whole` (new)",
3327                "2. `corpus/b.md#2026-08-20T00:00:00` (new)",
3328                "3. `corpus/a.md#2026-08-21T00:00:00` (new)",
3329            ],
3330            "the batch presents the first three in order"
3331        );
3332        assert!(brief.contains("…and 2 more, presented in order once these are disposed"));
3333        assert!(
3334            !brief.contains("**Added:**"),
3335            "unit ids never repeat in a class list: {brief}"
3336        );
3337
3338        // ---- Advance through the sequence.
3339        let dispositions: BTreeMap<String, DispositionInput> =
3340            [(expected[0], "skipped"), (expected[1], "worked")]
3341                .into_iter()
3342                .map(|(a, d)| (a.to_string(), DispositionInput::Verdict(d.to_string())))
3343                .collect();
3344        let outcome = advance_baseline(&mut engine, root, &resolved, &dispositions).unwrap();
3345        assert_eq!((outcome.pending, outcome.completed), (3, false));
3346        let cursor = compute_source_cursor(&engine, &resolved, root);
3347        assert!(cursor.delivery[0].units[0].disposed && cursor.delivery[0].units[1].disposed);
3348        let brief = render_changed_slice(&cursor);
3349        assert!(
3350            brief.contains("3. `corpus/a.md#2026-08-21T00:00:00` (new)"),
3351            "{brief}"
3352        );
3353        assert!(
3354            !brief.contains("1. `corpus/notes.md#whole`"),
3355            "disposed units are not re-presented"
3356        );
3357        assert!(brief.contains("2 units of this sequence already disposed"));
3358
3359        // An anchor over exactly a unit disposes it; a file-level anchor over
3360        // `b.md` disposes none of b's units.
3361        let a21_text = std::fs::read_to_string(corpus.join("a.md")).unwrap();
3362        let a21_unit = unitize(DATED_ENTRIES, &a21_text)
3363            .unwrap()
3364            .into_iter()
3365            .find(|u| u.key == "2026-08-21T00:00:00")
3366            .unwrap();
3367        let anchor = |artifact: &str, grain: AnchorGrain, hash: &str| Anchor {
3368            artifact: artifact.to_string(),
3369            grain,
3370            class: AnchorProvenanceClass::Anchored,
3371            hash: Some(hash.to_string()),
3372            source: Some("logs".to_string()),
3373            binding: None,
3374            at_version: None,
3375            derived_from: Vec::new(),
3376            hash_stability: crate::anchor::AnchorHashStability::Stable,
3377            span_unvalidated: false,
3378            hash_source: None,
3379        };
3380        let b_file_hash =
3381            crate::anchor::prepared_content_hash(&std::fs::read(corpus.join("b.md")).unwrap());
3382        let mut sidecar = AnchorSidecar::default();
3383        sidecar.set(
3384            "home--holder",
3385            vec![
3386                anchor(expected[2], AnchorGrain::Span, &a21_unit.hash),
3387                anchor("corpus/b.md", AnchorGrain::File, &b_file_hash),
3388            ],
3389        );
3390        std::fs::write(
3391            mem_dir.join(".memstead").join("anchors.json"),
3392            sidecar.to_bytes(),
3393        )
3394        .unwrap();
3395        let mut engine = crate::Engine::from_workspace_root(root).unwrap();
3396        let outcome = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
3397        assert_eq!(
3398            outcome.pending, 2,
3399            "the unit anchor auto-disposed its unit, the file anchor nothing"
3400        );
3401        assert!(outcome.remainder.added.contains(&expected[3].to_string()));
3402        assert!(outcome.remainder.added.contains(&expected[4].to_string()));
3403        let rest: BTreeMap<String, DispositionInput> = [expected[3], expected[4]]
3404            .into_iter()
3405            .map(|a| {
3406                (
3407                    a.to_string(),
3408                    DispositionInput::Verdict("worked".to_string()),
3409                )
3410            })
3411            .collect();
3412        let outcome = advance_baseline(&mut engine, root, &resolved, &rest).unwrap();
3413        assert!(outcome.completed, "{outcome:?}");
3414        assert!(
3415            outcome
3416                .tokens_written
3417                .contains(&"home/corpus/logs#synced".to_string())
3418        );
3419
3420        // ---- Change run: one earlier entry appended to a.md, b's second
3421        // entry edited, b's first entry removed. Only those three units are
3422        // delivered, at their ordered positions; every other key survives.
3423        write(
3424            "a.md",
3425            "2026-08-21 alpha one\nbody a1\n2026-08-23 alpha two\nbody a2\n2026-08-19 alpha zero\nbody a0\n",
3426        );
3427        write("b.md", "2026-08-22 beta two\nbody b2, revised\n");
3428        git(&corpus, &["add", "."]);
3429        git(&corpus, &["commit", "-q", "-m", "grow, edit, remove"]);
3430        let engine = crate::Engine::from_workspace_root(root).unwrap();
3431        let cursor = compute_source_cursor(&engine, &resolved, root);
3432        let seq = &cursor.delivery[0];
3433        assert!(!seq.first_run && !seq.degraded);
3434        assert_eq!(
3435            seq.units
3436                .iter()
3437                .map(|u| (u.id.as_str(), u.change))
3438                .collect::<Vec<_>>(),
3439            vec![
3440                ("corpus/a.md#2026-08-19T00:00:00", UnitChange::Added),
3441                ("corpus/b.md#2026-08-20T00:00:00", UnitChange::Deleted),
3442                ("corpus/b.md#2026-08-22T00:00:00", UnitChange::Modified),
3443            ]
3444        );
3445        let brief = render_changed_slice(&cursor);
3446        assert!(brief.contains("The units that changed since the last pass"));
3447        assert!(
3448            brief.contains("1. `corpus/a.md#2026-08-19T00:00:00` (new)"),
3449            "{brief}"
3450        );
3451        assert!(brief.contains("3. `corpus/b.md#2026-08-22T00:00:00` (changed)"));
3452
3453        // ---- Touchpoint A over units: the span anchor on a.md's unchanged
3454        // unit still resolves although its file changed; an anchor on the
3455        // removed unit orphans; one on the edited unit drifts.
3456        let b22_old_text = "2026-08-22 beta two\nbody b2\n";
3457        let b22_old = unitize(DATED_ENTRIES, b22_old_text).unwrap()[0]
3458            .hash
3459            .clone();
3460        let mut sidecar = AnchorSidecar::default();
3461        sidecar.set(
3462            "home--holder",
3463            vec![
3464                anchor(expected[2], AnchorGrain::Span, &a21_unit.hash),
3465                anchor(expected[1], AnchorGrain::Span, "deadbeefdeadbeef"),
3466                anchor(expected[3], AnchorGrain::Span, &b22_old),
3467            ],
3468        );
3469        std::fs::write(
3470            mem_dir.join(".memstead").join("anchors.json"),
3471            sidecar.to_bytes(),
3472        )
3473        .unwrap();
3474        let engine = crate::Engine::from_workspace_root(root).unwrap();
3475        let resolved_anchors = engine.entity_anchors_resolved(&EntityId::canonical("home--holder"));
3476        let state_of = |artifact: &str| {
3477            resolved_anchors
3478                .iter()
3479                .find(|r| r.anchor.artifact == artifact)
3480                .unwrap()
3481                .state
3482        };
3483        assert_eq!(
3484            state_of(expected[2]),
3485            Some(AnchorState::Resolves),
3486            "unit unchanged, file changed"
3487        );
3488        assert_eq!(
3489            state_of(expected[1]),
3490            Some(AnchorState::Orphaned),
3491            "unit removed"
3492        );
3493        assert_eq!(
3494            state_of(expected[3]),
3495            Some(AnchorState::Drifted),
3496            "unit edited"
3497        );
3498    }
3499
3500    /// Touchpoint A's code-map flavour end to end: anchors on a code-map
3501    /// source hash interface digests, so a comment, formatting or body edit
3502    /// leaves file, span and tree anchors resolving while a signature edit
3503    /// drifts them; a file joining the tree drifts the tree anchor alone;
3504    /// the sibling source without a preparation keeps whole-file hashing
3505    /// and its tree anchor stays unhashed (the stated remainder); and a
3506    /// write-time `content` on a code-map source records the digest hash,
3507    /// never the raw one.
3508    #[test]
3509    fn code_map_anchors_drift_on_interface_changes_only() {
3510        use crate::anchor::{
3511            Anchor, AnchorGrain, AnchorInput, AnchorProvenanceClass, AnchorSidecar, AnchorState,
3512        };
3513        use crate::binding::{
3514            BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, VerifyOperation,
3515        };
3516        use crate::entity::EntityId;
3517        use crate::ingest::resolve::Source;
3518        use crate::pipeline::{IngestTrigger, PatternMode};
3519        use crate::preparation::{CODE_MAP, code_map_digest, code_map_tree_digest};
3520        use crate::workspace::{
3521            Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
3522        };
3523        use crate::workspace_store::WorkspaceStoreAdapter;
3524
3525        let tmp = tempfile::tempdir().unwrap();
3526        let root = tmp.path();
3527        let mem_dir = root.join("home");
3528        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3529        std::fs::write(
3530            mem_dir.join(".memstead").join("config.json"),
3531            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3532        )
3533        .unwrap();
3534        std::fs::write(
3535            mem_dir.join("holder.md"),
3536            "---\ntype: assertion\n---\n\n# Holder\n\n## Claim\n\nHolds anchors.\n\n\
3537             ## Evidence\n\nSee code.\n",
3538        )
3539        .unwrap();
3540        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3541        std::fs::write(
3542            root.join(".memstead").join("workspace.toml"),
3543            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3544        )
3545        .unwrap();
3546        crate::FileWorkspaceStore::new()
3547            .save_state(
3548                root,
3549                &Workspace {
3550                    mounts: vec![Mount {
3551                        mem: "home".to_string(),
3552                        schema: Some("default@1.0.0".parse().unwrap()),
3553                        storage: MountStorage::Folder {
3554                            path: mem_dir.clone(),
3555                        },
3556                        capability: MountCapability::Write,
3557                        lifecycle: MountLifecycle::Eager,
3558                        cross_linkable: false,
3559                        migration_target: None,
3560                    }],
3561                    settings: WorkspaceSettings::default(),
3562                },
3563            )
3564            .unwrap();
3565
3566        // The corpus: a source tree under code-map, a sibling tree without.
3567        let corpus = root.join("corpus");
3568        std::fs::create_dir_all(corpus.join("src")).unwrap();
3569        std::fs::create_dir_all(corpus.join("plain")).unwrap();
3570        const A: &str = "// auth\nimport axios from 'axios'\n\nexport async function login(user, password) {\n  // call\n  return axios.post('/login', { user, password })\n}\n";
3571        const B: &str = "// the limit\nexport const LIMIT = 10\n";
3572        let write = |rel: &str, text: &str| std::fs::write(corpus.join(rel), text).unwrap();
3573        write("src/a.js", A);
3574        write("src/b.js", B);
3575        write("plain/notes.js", "export const N = 1\n");
3576
3577        let source = |name: &str, scope: &str, preparation: Option<&str>| Source {
3578            name: name.to_string(),
3579            medium_type: MediumType::Codebase,
3580            pointer: "corpus".to_string(),
3581            change_detection: Some("none".to_string()),
3582            scope: vec![PatternEntry {
3583                path: scope.to_string(),
3584                mode: PatternMode::Allow,
3585            }],
3586            engagement: None,
3587            preparation: preparation.map(str::to_string),
3588        };
3589        let binding = Binding {
3590            version: BINDING_VERSION,
3591            intent: None,
3592            sources: vec![
3593                // Scope patterns are SOURCE-relative: the pointer is
3594                // `corpus`, so these read as `corpus/src/**/*.js` and
3595                // `corpus/plain/**` on disk.
3596                source("code", "src/**/*.js", Some(CODE_MAP)),
3597                source("plain", "plain/**", None),
3598            ],
3599            reference_mems: vec![],
3600            destination_mem: "home".to_string(),
3601            deny_paths: vec![],
3602            coverage_semantics: None,
3603            rules: None,
3604            prune: None,
3605            operations: Operations {
3606                build: Some(BuildOperation {
3607                    mode: BuildMode::Discovery,
3608                    trigger: IngestTrigger::Manual,
3609                    batch_size: 5,
3610                    post_actions: None,
3611                }),
3612                sync: None,
3613                verify: Some(VerifyOperation {
3614                    trigger: IngestTrigger::Manual,
3615                    batch_size: 5,
3616                    adjudication_cap: 0,
3617                    full_resync_every: 0,
3618                }),
3619            },
3620        };
3621        assert!(crate::binding::validate_binding(&binding).is_ok());
3622        crate::pipeline_store::write_binding(root, "home", "code", &binding).unwrap();
3623
3624        let digest_hash = |rel: &str, text: &str| {
3625            crate::anchor::prepared_content_hash(code_map_digest(rel, text).as_bytes())
3626        };
3627        let tree_hash = |files: &[(&str, &str)]| {
3628            let owned: Vec<(String, String)> = files
3629                .iter()
3630                .map(|(p, t)| (p.to_string(), t.to_string()))
3631                .collect();
3632            crate::anchor::prepared_content_hash(code_map_tree_digest(&owned).as_bytes())
3633        };
3634        let anchor = |artifact: &str, grain: AnchorGrain, source: &str, hash: &str| Anchor {
3635            artifact: artifact.to_string(),
3636            grain,
3637            class: AnchorProvenanceClass::Anchored,
3638            hash: Some(hash.to_string()),
3639            source: Some(source.to_string()),
3640            binding: None,
3641            at_version: None,
3642            derived_from: Vec::new(),
3643            hash_stability: crate::anchor::AnchorHashStability::Stable,
3644            span_unvalidated: false,
3645            hash_source: None,
3646        };
3647        let plain_raw = crate::anchor::prepared_content_hash(b"export const N = 1\n");
3648        let mut sidecar = AnchorSidecar::default();
3649        sidecar.set(
3650            "home--holder",
3651            vec![
3652                anchor(
3653                    "corpus/src/a.js",
3654                    AnchorGrain::File,
3655                    "code",
3656                    &digest_hash("corpus/src/a.js", A),
3657                ),
3658                anchor(
3659                    "corpus/src/a.js#L4-L7",
3660                    AnchorGrain::Span,
3661                    "code",
3662                    &digest_hash("corpus/src/a.js", A),
3663                ),
3664                anchor(
3665                    "corpus/src",
3666                    AnchorGrain::Tree,
3667                    "code",
3668                    &tree_hash(&[("corpus/src/a.js", A), ("corpus/src/b.js", B)]),
3669                ),
3670                anchor(
3671                    "corpus/plain/notes.js",
3672                    AnchorGrain::File,
3673                    "plain",
3674                    &plain_raw,
3675                ),
3676                anchor(
3677                    "corpus/plain",
3678                    AnchorGrain::Tree,
3679                    "plain",
3680                    "0000000000000000",
3681                ),
3682            ],
3683        );
3684        std::fs::write(
3685            mem_dir.join(".memstead").join("anchors.json"),
3686            sidecar.to_bytes(),
3687        )
3688        .unwrap();
3689
3690        let states = |root: &std::path::Path| {
3691            let engine = crate::Engine::from_workspace_root(root).unwrap();
3692            let resolved = engine.entity_anchors_resolved(&EntityId::canonical("home--holder"));
3693            let of = |artifact: &str| {
3694                let r = resolved
3695                    .iter()
3696                    .find(|r| r.anchor.artifact == artifact)
3697                    .unwrap_or_else(|| panic!("no anchor {artifact}"));
3698                (r.state, r.observed_hash.clone())
3699            };
3700            (
3701                of("corpus/src/a.js").0,
3702                of("corpus/src/a.js#L4-L7").0,
3703                of("corpus/src").0,
3704                of("corpus/plain/notes.js").0,
3705                of("corpus/plain"),
3706                engine.verify_mem_anchors("home").unwrap(),
3707            )
3708        };
3709
3710        // Unchanged: everything under the code map resolves; the plain tree
3711        // is hash-bearing but unobservable (no hash on the engine's side), so
3712        // it is `recheck`, never a fabricated drift — the stated remainder.
3713        let (file, span, tree, plain_file, plain_tree, report) = states(root);
3714        assert_eq!(
3715            (file, span, tree, plain_file),
3716            (
3717                Some(AnchorState::Resolves),
3718                Some(AnchorState::Resolves),
3719                Some(AnchorState::Resolves),
3720                Some(AnchorState::Resolves)
3721            )
3722        );
3723        assert_eq!(plain_tree, (Some(AnchorState::Recheck), None));
3724        assert_eq!((report.resolved, report.drifted, report.recheck), (4, 0, 1));
3725
3726        // Comment, formatting and body edits: invisible.
3727        write(
3728            "src/a.js",
3729            "// auth (rewritten comment)\nimport axios from 'axios'\n\nexport async function login(user, password) {\n    return await axios.post('/session',   { user, password })\n}\n",
3730        );
3731        let (file, span, tree, _, _, report) = states(root);
3732        assert_eq!(
3733            (file, span, tree),
3734            (
3735                Some(AnchorState::Resolves),
3736                Some(AnchorState::Resolves),
3737                Some(AnchorState::Resolves)
3738            ),
3739            "a body edit must not drift a code-map anchor"
3740        );
3741        assert_eq!(report.drifted, 0);
3742
3743        // A signature edit: file, span and tree drift.
3744        write(
3745            "src/a.js",
3746            "// auth\nimport axios from 'axios'\n\nexport async function login(user, password, remember) {\n  return axios.post('/login', { user, password, remember })\n}\n",
3747        );
3748        let (file, span, tree, plain_file, _, report) = states(root);
3749        assert_eq!(
3750            (file, span, tree),
3751            (
3752                Some(AnchorState::Drifted),
3753                Some(AnchorState::Drifted),
3754                Some(AnchorState::Drifted)
3755            )
3756        );
3757        assert_eq!(plain_file, Some(AnchorState::Resolves));
3758        assert_eq!(report.drifted, 3);
3759
3760        // Restore, then a new file joins the tree: the tree drifts alone.
3761        write("src/a.js", A);
3762        write("src/c.js", "export const C = 1\n");
3763        let (file, span, tree, _, _, _) = states(root);
3764        assert_eq!(
3765            (file, span),
3766            (Some(AnchorState::Resolves), Some(AnchorState::Resolves))
3767        );
3768        assert_eq!(
3769            tree,
3770            Some(AnchorState::Drifted),
3771            "a file joining the tree changes its code map"
3772        );
3773
3774        // The plain source is untouched by the code map: a body edit in its
3775        // file drifts the whole-file hash exactly as before.
3776        write("plain/notes.js", "export const N = 1 // note\n");
3777        let (_, _, _, plain_file, _, _) = states(root);
3778        assert_eq!(plain_file, Some(AnchorState::Drifted));
3779
3780        // Write time: `content` on a code-map source records the digest hash.
3781        let engine = crate::Engine::from_workspace_root(root).unwrap();
3782        let input = AnchorInput {
3783            artifact: Some("corpus/src/b.js".to_string()),
3784            grain: Some("file".to_string()),
3785            class: Some("anchored".to_string()),
3786            source: Some("code".to_string()),
3787            content: Some(B.to_string()),
3788            ..Default::default()
3789        };
3790        let validated = engine.validate_anchor_inputs("home", &[input]).unwrap();
3791        assert_eq!(
3792            validated[0].hash.as_deref(),
3793            Some(digest_hash("corpus/src/b.js", B).as_str())
3794        );
3795        assert_ne!(
3796            validated[0].hash.as_deref(),
3797            Some(crate::anchor::prepared_content_hash(B.as_bytes()).as_str())
3798        );
3799        let plain_input = AnchorInput {
3800            artifact: Some("corpus/plain/notes.js".to_string()),
3801            grain: Some("file".to_string()),
3802            class: Some("anchored".to_string()),
3803            source: Some("plain".to_string()),
3804            content: Some("export const N = 1\n".to_string()),
3805            ..Default::default()
3806        };
3807        let validated = engine
3808            .validate_anchor_inputs("home", &[plain_input])
3809            .unwrap();
3810        assert_eq!(
3811            validated[0].hash.as_deref(),
3812            Some(plain_raw.as_str()),
3813            "no preparation: the raw canonicalization, as before"
3814        );
3815    }
3816
3817    /// Criterion 6's regression pin: the graph change-detection half is
3818    /// untouched except for the deliberate unscoped gate. A SCOPED graph
3819    /// facet still routes to the graph strategy and reports the same
3820    /// no-signal reason it always did when the source mem exposes no
3821    /// snapshot token (a folder mem tracks no head); an UNSCOPED one now
3822    /// refuses as `Unscoped`, exactly as the git and mtime arms have always
3823    /// done. Distinguishing the two is the whole point — before this, an
3824    /// unscoped graph facet silently proceeded.
3825    #[test]
3826    fn graph_scoping_changes_only_the_unscoped_arm() {
3827        use crate::binding::BuildMode;
3828
3829        let tmp = tempfile::tempdir().unwrap();
3830        let root = tmp.path();
3831        let mem_dir = root.join("srcmem");
3832        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3833        std::fs::write(
3834            mem_dir.join(".memstead").join("config.json"),
3835            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3836        )
3837        .unwrap();
3838        std::fs::write(
3839            mem_dir.join("one.md"),
3840            "---\ntype: decision\n---\n\n# One\n\n## Decision\n\nBody.\n",
3841        )
3842        .unwrap();
3843        std::fs::create_dir_all(root.join(".memstead")).unwrap();
3844        std::fs::write(
3845            root.join(".memstead").join("workspace.toml"),
3846            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3847        )
3848        .unwrap();
3849        {
3850            use crate::workspace::{
3851                Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
3852            };
3853            use crate::workspace_store::WorkspaceStoreAdapter;
3854            crate::FileWorkspaceStore::new()
3855                .save_state(
3856                    root,
3857                    &Workspace {
3858                        mounts: vec![Mount {
3859                            mem: "srcmem".to_string(),
3860                            schema: Some("default@1.0.0".parse().unwrap()),
3861                            storage: MountStorage::Folder {
3862                                path: mem_dir.clone(),
3863                            },
3864                            capability: MountCapability::Write,
3865                            lifecycle: MountLifecycle::Eager,
3866                            cross_linkable: false,
3867                            migration_target: None,
3868                        }],
3869                        settings: WorkspaceSettings::default(),
3870                    },
3871                )
3872                .unwrap();
3873        }
3874        let engine = crate::Engine::from_workspace_root(root).unwrap();
3875
3876        let resolved_with = |scope: Vec<crate::pipeline::PatternEntry>| ResolvedIngest {
3877            name: "srcmem/p".to_string(),
3878            mode: BuildMode::Discovery,
3879            trigger: crate::pipeline::IngestTrigger::Manual,
3880            batch_size: 20,
3881            deny_paths: Vec::new(),
3882            projection_ref: "srcmem/p".to_string(),
3883            projection_mem: "srcmem".to_string(),
3884            projection_name: "p".to_string(),
3885            intent: None,
3886            sources: vec![ResolvedSource::Primary(Source {
3887                name: "g".to_string(),
3888                medium_type: MediumType::Graph,
3889                pointer: "srcmem".to_string(),
3890                change_detection: None,
3891                scope,
3892                engagement: None,
3893                preparation: None,
3894            })],
3895            destination_mem: "srcmem".to_string(),
3896            rules: None,
3897            post_actions: None,
3898        };
3899
3900        let scoped = compute_source_cursor(
3901            &engine,
3902            &resolved_with(vec![crate::pipeline::PatternEntry {
3903                path: "*".to_string(),
3904                mode: PatternMode::Allow,
3905            }]),
3906            root,
3907        );
3908        let unscoped = compute_source_cursor(&engine, &resolved_with(Vec::new()), root);
3909
3910        let reason_of = |c: &SourceCursor| c.no_signal.first().map(|n| n.reason);
3911        assert_eq!(
3912            reason_of(&scoped),
3913            Some(NoSignalReason::GraphSnapshotMissing),
3914            "a scoped graph facet still routes to the graph strategy and reports \
3915             its own no-signal reason — the change-detection half is untouched"
3916        );
3917        assert_eq!(
3918            reason_of(&unscoped),
3919            Some(NoSignalReason::Unscoped),
3920            "an unscoped graph facet refuses like every other medium's, instead of \
3921             silently proceeding"
3922        );
3923    }
3924
3925    /// The git medium enumerates through the same path walk as codebase and
3926    /// filesystem — its artifacts are paths pinned at a commit, so the walk is
3927    /// identical and only the anchor namespace differs. It was excluded from
3928    /// that arm for no reason beyond the arm's shape, which made its
3929    /// `enumerable: true` row a claim nothing delivered. Pinned so a refactor
3930    /// cannot quietly drop it back out.
3931    #[test]
3932    fn git_medium_enumerates_through_the_path_walk() {
3933        let ws = tempfile::tempdir().unwrap();
3934        let root = ws.path();
3935        std::fs::write(root.join("a.rs"), "").unwrap();
3936        std::fs::write(root.join("b.rs"), "").unwrap();
3937
3938        let source = |medium: MediumType| Source {
3939            name: "s".to_string(),
3940            medium_type: medium,
3941            pointer: ".".to_string(),
3942            change_detection: None,
3943            scope: vec![crate::pipeline::PatternEntry {
3944                path: "**/*.rs".to_string(),
3945                mode: PatternMode::Allow,
3946            }],
3947            engagement: None,
3948            preparation: None,
3949        };
3950
3951        let want = vec!["a.rs".to_string(), "b.rs".to_string()];
3952        for medium in [
3953            MediumType::Codebase,
3954            MediumType::Filesystem,
3955            MediumType::Git,
3956        ] {
3957            assert_eq!(
3958                enumerate_facet_files(&source(medium), &[], root),
3959                want,
3960                "{medium:?} walks the file tree — every medium the matrix marks \
3961                 enumerable with a path namespace must actually enumerate"
3962            );
3963            assert!(
3964                crate::binding::medium_capabilities(medium).enumerable,
3965                "{medium:?} claims enumerability, and now delivers it"
3966            );
3967        }
3968    }
3969
3970    /// A narrowing selector must bound the CHANGED SLICE, not only `S(D)`.
3971    /// It used to bound only enumeration, so a brief could print
3972    /// `Entities: type:concept` and then present a changed `memo` two
3973    /// sections below — an artifact its own coverage model calls out of
3974    /// scope, which `advance` would accept because its gate is the presented
3975    /// slice. Scope interpreted in one place and decorative in the other is
3976    /// the defect this pins closed.
3977    #[test]
3978    fn a_narrowing_selector_bounds_the_changed_slice_too() {
3979        use crate::workspace::{
3980            Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
3981        };
3982        use crate::workspace_store::WorkspaceStoreAdapter;
3983
3984        let tmp = tempfile::tempdir().unwrap();
3985        let root = tmp.path();
3986        let mem_dir = root.join("srcmem");
3987        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3988        std::fs::write(
3989            mem_dir.join(".memstead").join("config.json"),
3990            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3991        )
3992        .unwrap();
3993        let write = |slug: &str, ty: &str| {
3994            std::fs::write(
3995                mem_dir.join(format!("{slug}.md")),
3996                format!("---\ntype: {ty}\n---\n\n# {slug}\n\n## Decision\n\nBody.\n"),
3997            )
3998            .unwrap();
3999        };
4000        write("kept", "decision");
4001        write("other", "memo");
4002        std::fs::create_dir_all(root.join(".memstead")).unwrap();
4003        std::fs::write(
4004            root.join(".memstead").join("workspace.toml"),
4005            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4006        )
4007        .unwrap();
4008        crate::FileWorkspaceStore::new()
4009            .save_state(
4010                root,
4011                &Workspace {
4012                    mounts: vec![Mount {
4013                        mem: "srcmem".to_string(),
4014                        schema: Some("default@1.0.0".parse().unwrap()),
4015                        storage: MountStorage::Folder {
4016                            path: mem_dir.clone(),
4017                        },
4018                        capability: MountCapability::Write,
4019                        lifecycle: MountLifecycle::Eager,
4020                        cross_linkable: false,
4021                        migration_target: None,
4022                    }],
4023                    settings: WorkspaceSettings::default(),
4024                },
4025            )
4026            .unwrap();
4027        let engine = crate::Engine::from_workspace_root(root).unwrap();
4028
4029        let source = Source {
4030            name: "g".to_string(),
4031            medium_type: MediumType::Graph,
4032            pointer: "srcmem".to_string(),
4033            change_detection: None,
4034            scope: vec![crate::pipeline::PatternEntry {
4035                path: "type:decision".to_string(),
4036                mode: PatternMode::Allow,
4037            }],
4038            engagement: None,
4039            preparation: None,
4040        };
4041
4042        let mut slice = Slice {
4043            added: vec!["srcmem--other".to_string()],
4044            modified: vec!["srcmem--kept".to_string(), "srcmem--other".to_string()],
4045            deleted: vec!["srcmem--vanished".to_string()],
4046        };
4047        filter_graph_slice_to_scope(&engine, &source, &mut slice);
4048
4049        assert_eq!(
4050            slice.modified,
4051            vec!["srcmem--kept".to_string()],
4052            "the out-of-scope memo is dropped from the slice the brief presents"
4053        );
4054        assert!(
4055            slice.added.is_empty(),
4056            "an added out-of-scope entity is out of scope too"
4057        );
4058        assert_eq!(
4059            slice.deleted,
4060            vec!["srcmem--vanished".to_string()],
4061            "a DELETED entity is kept even though its type can no longer be \
4062             read — a deletion that cannot be classified must be reported, \
4063             never silently dropped"
4064        );
4065
4066        // The complement: the whole-mem selector narrows nothing.
4067        let mut wide = Slice {
4068            added: Vec::new(),
4069            modified: vec!["srcmem--kept".to_string(), "srcmem--other".to_string()],
4070            deleted: Vec::new(),
4071        };
4072        let mut all = source.clone();
4073        all.scope = vec![crate::pipeline::PatternEntry {
4074            path: "*".to_string(),
4075            mode: PatternMode::Allow,
4076        }];
4077        filter_graph_slice_to_scope(&engine, &all, &mut wide);
4078        assert_eq!(wide.modified.len(), 2, "`*` selects the whole mem");
4079    }
4080
4081    /// The entity-selector grammar is closed: three legal forms, everything
4082    /// else refused. A pattern that parses to `None` is a validation refusal
4083    /// at declaration — never a rule that silently selects nothing.
4084    #[test]
4085    fn entity_selector_grammar_is_closed() {
4086        use super::EntitySelector;
4087        assert_eq!(parse_entity_selector("*"), Some(EntitySelector::All));
4088        assert_eq!(
4089            parse_entity_selector("type:decision"),
4090            Some(EntitySelector::Type("decision".to_string()))
4091        );
4092        assert_eq!(
4093            parse_entity_selector("id:engine--*"),
4094            Some(EntitySelector::Id("engine--*".to_string()))
4095        );
4096        // The path glob `projection init` used to scaffold for graph sources:
4097        // it looks like scope and selects nothing. Refused, not accepted.
4098        assert_eq!(parse_entity_selector("**/*"), None);
4099        assert_eq!(parse_entity_selector("src/**"), None);
4100        assert_eq!(parse_entity_selector("type:"), None);
4101        assert_eq!(parse_entity_selector("id:"), None);
4102        assert_eq!(parse_entity_selector(""), None);
4103    }
4104
4105    /// The mtime driver reseeds on the first pass (writing the memo), then
4106    /// diffs precisely against the memoised map — including deletions.
4107    #[test]
4108    fn mtime_driver_reseeds_then_diffs_precisely() {
4109        let ws = tempfile::tempdir().unwrap();
4110        let root = ws.path();
4111        let cache = root.join(".memstead.cache").join("ingest");
4112        std::fs::write(root.join("a.rs"), "one").unwrap();
4113        std::fs::write(root.join("gone.rs"), "bye").unwrap();
4114        let source = primary(vec![PatternEntry {
4115            path: "**/*.rs".to_string(),
4116            mode: PatternMode::Allow,
4117        }]);
4118
4119        // First pass: no baseline → reseed at the current digest, memo written.
4120        let token = match compute_mtime_slice(&source, "ing", &[], root, &cache, None) {
4121            SliceOutcome::Reseed { token } => token,
4122            other => panic!("expected Reseed, got {other:?}"),
4123        };
4124
4125        // Move the source: modify a.rs (size change), delete gone.rs, add new.rs.
4126        std::fs::write(root.join("a.rs"), "one-longer").unwrap();
4127        std::fs::remove_file(root.join("gone.rs")).unwrap();
4128        std::fs::write(root.join("new.rs"), "x").unwrap();
4129
4130        // Second pass with the reseed token → precise diff from the memo.
4131        match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&token)) {
4132            SliceOutcome::Changed {
4133                slice, degraded, ..
4134            } => {
4135                assert!(
4136                    !degraded,
4137                    "memo present → precise, not a degraded full scan"
4138                );
4139                assert_eq!(slice.added, vec!["new.rs"]);
4140                assert_eq!(slice.modified, vec!["a.rs"]);
4141                assert_eq!(
4142                    slice.deleted,
4143                    vec!["gone.rs"],
4144                    "deletions come from the memo"
4145                );
4146            }
4147            other => panic!("expected Changed, got {other:?}"),
4148        }
4149
4150        // A run whose baseline aggregate is not memoised degrades to a full
4151        // scan (every current file as added, no deletions).
4152        let stale = super::super::change_detection::serialize_digest_token(
4153            &super::super::change_detection::digest_stat_map(&stat_map_for(&["absent.rs"])),
4154        );
4155        match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&stale)) {
4156            SliceOutcome::Changed { degraded, .. } => assert!(degraded, "memo miss → degraded"),
4157            other => panic!("expected degraded Changed, got {other:?}"),
4158        }
4159    }
4160
4161    fn head_sha(repo: &Path) -> String {
4162        String::from_utf8(
4163            std::process::Command::new("git")
4164                .args(["rev-parse", "HEAD"])
4165                .current_dir(repo)
4166                .output()
4167                .unwrap()
4168                .stdout,
4169        )
4170        .unwrap()
4171        .trim()
4172        .to_string()
4173    }
4174
4175    fn slice_contains(slice: &Slice, path: &str) -> bool {
4176        let p = path.to_string();
4177        slice.added.contains(&p) || slice.modified.contains(&p) || slice.deleted.contains(&p)
4178    }
4179
4180    /// The mtime `source_moved` / `current_primary_token` value: the digest
4181    /// token over the deny-filtered enumeration — exactly what the mtime branch
4182    /// of `current_primary_token` computes.
4183    fn mtime_token(source: &Source, deny: &[String], root: &Path) -> String {
4184        let files = enumerate_facet_files(source, deny, root);
4185        serialize_digest_token(&digest_stat_map(&compute_stat_map(&files, root)))
4186    }
4187
4188    /// AC1 (deny invariance): a file matching an ingest `deny_paths` entry
4189    /// appears in **no** changed slice (git, mtime), **no** refinement batch,
4190    /// and does **not** influence the mtime digest / `source_moved` token —
4191    /// exercising the *same* denied file across every strategy that reads a
4192    /// file tree.
4193    #[test]
4194    fn deny_paths_excluded_from_every_strategy_and_token() {
4195        use crate::binding::BuildMode;
4196        use crate::ingest::refinement::next_batch;
4197        use crate::pipeline::IngestTrigger;
4198
4199        let repo = tempfile::tempdir().unwrap();
4200        let root = repo.path();
4201        let cache = root.join(".memstead.cache").join("ingest");
4202
4203        // One tree that is both the git work tree and the mtime/refinement
4204        // workspace root (medium_pointer "" → base == root).
4205        git(root, &["init", "-q"]);
4206        std::fs::write(root.join("keep.rs"), "one").unwrap();
4207        std::fs::write(root.join("denied.rs"), "secret-one").unwrap();
4208        git(root, &["add", "-A"]);
4209        git(root, &["commit", "-qm", "base"]);
4210        let baseline = head_sha(root);
4211
4212        // Both files genuinely move — denied.rs must never surface anywhere.
4213        std::fs::write(root.join("keep.rs"), "two").unwrap();
4214        std::fs::write(root.join("denied.rs"), "secret-two").unwrap();
4215        git(root, &["add", "-A"]);
4216        git(root, &["commit", "-qm", "move"]);
4217
4218        // Scope allows every .rs; the ingest denies denied.rs by the same
4219        // workspace-relative glob grammar the git strategy uses.
4220        let source = primary(vec![PatternEntry {
4221            path: "**/*.rs".to_string(),
4222            mode: PatternMode::Allow,
4223        }]);
4224        let deny = vec!["denied.rs".to_string()];
4225
4226        // (1) git slice — with the deny, only keep.rs.
4227        match compute_git_slice(&source, &deny, root, Some(&baseline)) {
4228            SliceOutcome::Changed { slice, .. } => {
4229                assert_eq!(slice.modified, vec!["keep.rs"]);
4230                assert!(!slice_contains(&slice, "denied.rs"), "git deny leak");
4231            }
4232            other => panic!("git: expected Changed, got {other:?}"),
4233        }
4234        // Control: without the deny, denied.rs *is* a real change — proving the
4235        // deny (not the scope) is what excludes it above.
4236        match compute_git_slice(&source, &[], root, Some(&baseline)) {
4237            SliceOutcome::Changed { slice, .. } => {
4238                assert!(
4239                    slice_contains(&slice, "denied.rs"),
4240                    "un-denied, denied.rs is a genuine git change"
4241                );
4242            }
4243            other => panic!("git(no-deny): expected Changed, got {other:?}"),
4244        }
4245
4246        // (2) enumeration (mtime input set + refinement source set).
4247        assert_eq!(enumerate_facet_files(&source, &deny, root), vec!["keep.rs"]);
4248        assert!(
4249            enumerate_facet_files(&source, &[], root).contains(&"denied.rs".to_string()),
4250            "un-denied, denied.rs is enumerated"
4251        );
4252
4253        // (2b) mtime slice — reseed, then move both files; only keep.rs surfaces.
4254        let token = match compute_mtime_slice(&source, "ing", &deny, root, &cache, None) {
4255            SliceOutcome::Reseed { token } => token,
4256            other => panic!("mtime reseed expected, got {other:?}"),
4257        };
4258        std::fs::write(root.join("keep.rs"), "three-longer").unwrap();
4259        std::fs::write(root.join("denied.rs"), "secret-three-longer").unwrap();
4260        match compute_mtime_slice(&source, "ing", &deny, root, &cache, Some(&token)) {
4261            SliceOutcome::Changed { slice, .. } => {
4262                assert_eq!(slice.modified, vec!["keep.rs"]);
4263                assert!(!slice_contains(&slice, "denied.rs"), "mtime deny leak");
4264            }
4265            other => panic!("mtime: expected Changed, got {other:?}"),
4266        }
4267
4268        // (3) mtime digest / source_moved token — invariant to denied.rs, since
4269        // the token is the digest over the deny-filtered enumeration. Removing
4270        // denied.rs from disk leaves the token unchanged; a leak would show it
4271        // as a deletion and shift the digest.
4272        let token_present = mtime_token(&source, &deny, root);
4273        std::fs::remove_file(root.join("denied.rs")).unwrap();
4274        let token_absent = mtime_token(&source, &deny, root);
4275        assert_eq!(
4276            token_present, token_absent,
4277            "denied.rs must not influence the mtime digest / source_moved token"
4278        );
4279        std::fs::write(root.join("denied.rs"), "secret-restored").unwrap();
4280
4281        // (4) refinement batch — the denied file is never batched.
4282        let resolved = ResolvedIngest {
4283            name: "ing".to_string(),
4284            mode: BuildMode::Discovery,
4285            trigger: IngestTrigger::Loop,
4286            batch_size: 50,
4287            deny_paths: deny.clone(),
4288            projection_ref: "m/p".to_string(),
4289            projection_mem: "m".to_string(),
4290            projection_name: "p".to_string(),
4291            intent: None,
4292            sources: vec![ResolvedSource::Primary(source.clone())],
4293            destination_mem: "m".to_string(),
4294            rules: None,
4295            post_actions: None,
4296        };
4297        let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
4298        let batch = next_batch(&engine, &resolved, root, &cache, 20).unwrap();
4299        assert!(
4300            batch.files.contains(&"keep.rs".to_string()),
4301            "keep.rs batched"
4302        );
4303        assert!(
4304            !batch.files.contains(&"denied.rs".to_string()),
4305            "denied.rs must never enter a refinement batch"
4306        );
4307    }
4308
4309    /// AC2 (one empty-scope semantic): an **unscoped** facet (no allow
4310    /// patterns) is the same typed refusal — `NoSignal { Unscoped }` — on git
4311    /// AND mtime, never a silent empty slice. AC2 complement: an empty
4312    /// `deny_paths` list does NOT trip that refusal — a *scoped* facet still
4313    /// classifies normally (empty scope and empty deny_paths are different
4314    /// fields with different semantics).
4315    #[test]
4316    fn unscoped_facet_refuses_uniformly_and_empty_deny_is_distinct() {
4317        let repo = tempfile::tempdir().unwrap();
4318        let root = repo.path();
4319        let cache = root.join(".memstead.cache").join("ingest");
4320        git(root, &["init", "-q"]);
4321        std::fs::write(root.join("a.rs"), "one").unwrap();
4322        git(root, &["add", "-A"]);
4323        git(root, &["commit", "-qm", "base"]);
4324        let baseline = head_sha(root);
4325        std::fs::write(root.join("a.rs"), "two").unwrap();
4326        git(root, &["add", "-A"]);
4327        git(root, &["commit", "-qm", "move"]);
4328
4329        // Unscoped: a deny pattern but no allow. `deny_paths` is empty here —
4330        // so the refusal comes from the empty *scope*, not from denies.
4331        let unscoped = primary(vec![PatternEntry {
4332            path: "target/**".to_string(),
4333            mode: PatternMode::Deny,
4334        }]);
4335        assert_eq!(
4336            compute_git_slice(&unscoped, &[], root, Some(&baseline)),
4337            SliceOutcome::NoSignal {
4338                reason: NoSignalReason::Unscoped
4339            },
4340            "git refuses an unscoped facet"
4341        );
4342        assert_eq!(
4343            compute_mtime_slice(&unscoped, "ing", &[], root, &cache, None),
4344            SliceOutcome::NoSignal {
4345                reason: NoSignalReason::Unscoped
4346            },
4347            "mtime refuses an unscoped facet identically"
4348        );
4349        // A fully empty scope is unscoped too.
4350        let empty_scope = primary(vec![]);
4351        assert_eq!(
4352            compute_git_slice(&empty_scope, &[], root, Some(&baseline)),
4353            SliceOutcome::NoSignal {
4354                reason: NoSignalReason::Unscoped
4355            }
4356        );
4357
4358        // Complement: a SCOPED facet with an empty `deny_paths` classifies
4359        // normally — empty deny_paths (no denies) must not trip the refusal.
4360        let scoped = primary(vec![PatternEntry {
4361            path: "**/*.rs".to_string(),
4362            mode: PatternMode::Allow,
4363        }]);
4364        assert!(
4365            matches!(
4366                compute_git_slice(&scoped, &[], root, Some(&baseline)),
4367                SliceOutcome::Changed { .. }
4368            ),
4369            "scoped facet + empty deny_paths → normal git slice, not a refusal"
4370        );
4371        assert!(
4372            matches!(
4373                compute_mtime_slice(&scoped, "ing", &[], root, &cache, None),
4374                SliceOutcome::Reseed { .. }
4375            ),
4376            "scoped facet + empty deny_paths → normal mtime reseed, not a refusal"
4377        );
4378    }
4379
4380    /// AC2 refinement leg: an ingest whose only source is unscoped emits no
4381    /// refinement batch — the refusal, not a silent empty batch.
4382    #[test]
4383    fn unscoped_facet_emits_no_refinement_batch() {
4384        use crate::binding::BuildMode;
4385        use crate::ingest::refinement::next_batch;
4386        use crate::pipeline::IngestTrigger;
4387
4388        let ws = tempfile::tempdir().unwrap();
4389        let root = ws.path();
4390        let cache = root.join(".memstead.cache").join("ingest");
4391        std::fs::write(root.join("a.rs"), "x").unwrap();
4392
4393        let resolved = ResolvedIngest {
4394            name: "ing".to_string(),
4395            mode: BuildMode::Discovery,
4396            trigger: IngestTrigger::Loop,
4397            batch_size: 50,
4398            deny_paths: vec![],
4399            projection_ref: "m/p".to_string(),
4400            projection_mem: "m".to_string(),
4401            projection_name: "p".to_string(),
4402            intent: None,
4403            // Only source: an unscoped facet (no allow patterns).
4404            sources: vec![ResolvedSource::Primary(primary(vec![]))],
4405            destination_mem: "m".to_string(),
4406            rules: None,
4407            post_actions: None,
4408        };
4409        assert!(
4410            next_batch(
4411                &crate::Engine::from_mounts(Vec::new()).unwrap(),
4412                &resolved,
4413                root,
4414                &cache,
4415                20
4416            )
4417            .is_none(),
4418            "an all-unscoped ingest emits no refinement batch"
4419        );
4420    }
4421
4422    /// AC3 (visible NoSignal) end-to-end through the cursor: a `signal:none`
4423    /// source and an unscoped source each contribute a distinct no-signal note;
4424    /// a first-seen (reseed) source does NOT — only no-signal reasons are
4425    /// noted. The rendered preface names `signal:none` explicitly and the
4426    /// unscoped reason distinctly.
4427    #[test]
4428    fn compute_source_cursor_notes_no_signal_reasons() {
4429        use crate::binding::BuildMode;
4430        use crate::pipeline::IngestTrigger;
4431
4432        let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
4433        // No `.git` over the workspace → mtime strategy for `auto`/`mtime`.
4434        let ws = tempfile::tempdir().unwrap();
4435        let root = ws.path();
4436        std::fs::write(root.join("a.rs"), "x").unwrap();
4437
4438        let allow_rs = || {
4439            vec![PatternEntry {
4440                path: "**/*.rs".to_string(),
4441                mode: PatternMode::Allow,
4442            }]
4443        };
4444        let src = |facet: &str, declared: &str, scope: Vec<PatternEntry>| {
4445            ResolvedSource::Primary(Source {
4446                name: facet.to_string(),
4447                medium_type: MediumType::Filesystem,
4448                pointer: String::new(),
4449                change_detection: Some(declared.to_string()),
4450                scope,
4451                engagement: None,
4452                preparation: None,
4453            })
4454        };
4455
4456        let resolved = ResolvedIngest {
4457            name: "ing".to_string(),
4458            mode: BuildMode::Discovery,
4459            trigger: IngestTrigger::Loop,
4460            batch_size: 20,
4461            deny_paths: vec![],
4462            projection_ref: "m/p".to_string(),
4463            projection_mem: "m".to_string(),
4464            projection_name: "p".to_string(),
4465            intent: None,
4466            sources: vec![
4467                // signal:none → DetectionNone note (even though it is scoped).
4468                src("plan", "none", allow_rs()),
4469                // mtime + no allows → Unscoped note.
4470                src("blind", "mtime", vec![]),
4471                // mtime + allows, first-seen → Reseed, NOT a no-signal note.
4472                src("watched", "mtime", allow_rs()),
4473            ],
4474            destination_mem: "m".to_string(),
4475            rules: None,
4476            post_actions: None,
4477        };
4478
4479        let cursor = compute_source_cursor(&engine, &resolved, root);
4480        let reasons: BTreeMap<&str, NoSignalReason> = cursor
4481            .no_signal
4482            .iter()
4483            .map(|n| (n.source.as_str(), n.reason))
4484            .collect();
4485        assert_eq!(reasons.get("plan"), Some(&NoSignalReason::DetectionNone));
4486        assert_eq!(reasons.get("blind"), Some(&NoSignalReason::Unscoped));
4487        assert!(
4488            !reasons.contains_key("watched"),
4489            "a first-seen (reseed) source is not a no-signal note"
4490        );
4491        assert_eq!(cursor.no_signal.len(), 2);
4492        // The reseed source still produced a reseed command.
4493        assert!(cursor.reseed.iter().any(|c| c.key == "ing/watched#synced"));
4494
4495        // The rendered preface names signal:none and the unscoped reason.
4496        let out = crate::ingest::brief::render_changed_slice(&cursor);
4497        assert!(out.contains("- `plan`: `signal:none`"));
4498        assert!(out.contains("- `blind`: unscoped facet"));
4499    }
4500
4501    fn stat_map_for(paths: &[&str]) -> super::super::change_detection::StatMap {
4502        paths
4503            .iter()
4504            .map(|p| {
4505                (
4506                    (*p).to_string(),
4507                    super::super::change_detection::StatEntry { mtime: 1, size: 1 },
4508                )
4509            })
4510            .collect()
4511    }
4512
4513    /// Engine self-exclusion: `.memstead/**`, `.memstead.cache/**`, and
4514    /// every mount's resolved storage location (here a mem-repo at a
4515    /// NON-default directory name) are absent from the enumeration
4516    /// regardless of configuration — explicit allow globs covering them
4517    /// do not admit them.
4518    #[test]
4519    fn engine_state_never_enumerates_even_when_allowed() {
4520        let ws = tempfile::tempdir().unwrap();
4521        let root = ws.path();
4522        for rel in [
4523            ".memstead/state/findings/muehle/f.json",
4524            ".memstead/projections/muehle/f.json",
4525            ".memstead.cache/ingest/source-cursor/muehle/f/f.json",
4526            "custom-repo/README.md",
4527            "Allgemein/Protokoll.md",
4528            "Allgemein/Vertrag.md",
4529        ] {
4530            let path = root.join(rel);
4531            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4532            std::fs::write(&path, "x").unwrap();
4533        }
4534        // Engine-managed workspace state resolving the mem-repo at
4535        // `custom-repo/` — the exclusion must key on this resolved
4536        // location, not on the literal default name `mem-repo/`.
4537        std::fs::write(
4538            root.join(".memstead/workspace.toml"),
4539            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4540        )
4541        .unwrap();
4542        std::fs::write(
4543            root.join(".memstead/state/mounts.json"),
4544            serde_json::json!({
4545                "format": "memstead-mounts-3",
4546                "mounts": [{
4547                    "mem": "muehle",
4548                    "schema": "default@1.0.0",
4549                    "storage": {
4550                        "type": "git-branch",
4551                        "gitdir": "custom-repo/.git",
4552                        "branch": "refs/heads/muehle"
4553                    },
4554                    "capability": "write",
4555                    "lifecycle": "eager",
4556                    "cross_linkable": true
4557                }]
4558            })
4559            .to_string(),
4560        )
4561        .unwrap();
4562
4563        // Allow everything AND explicitly try to admit engine state.
4564        let source = primary(vec![
4565            PatternEntry {
4566                path: "**/*".to_string(),
4567                mode: PatternMode::Allow,
4568            },
4569            PatternEntry {
4570                path: ".memstead/**".to_string(),
4571                mode: PatternMode::Allow,
4572            },
4573            PatternEntry {
4574                path: "custom-repo/**".to_string(),
4575                mode: PatternMode::Allow,
4576            },
4577        ]);
4578        let got = enumerate_facet_files(&source, &[], root);
4579        assert_eq!(
4580            got,
4581            vec!["Allgemein/Protokoll.md", "Allgemein/Vertrag.md"],
4582            "only source artifacts may enter the denominator"
4583        );
4584    }
4585
4586    /// The git strategy pushes the same engine-state excludes as
4587    /// pathspecs: a diff touching `.memstead/**` and the resolved
4588    /// mem-repo path yields a slice naming neither — denominator and
4589    /// slice stay strategy-invariant.
4590    #[test]
4591    fn git_slice_excludes_engine_state() {
4592        let repo = tempfile::tempdir().unwrap();
4593        let root = repo.path();
4594        git(root, &["init", "-q"]);
4595        std::fs::write(
4596            root.join("workspace.rs"), // placeholder so base commit is non-empty
4597            "x",
4598        )
4599        .unwrap();
4600        std::fs::create_dir_all(root.join(".memstead/state")).unwrap();
4601        std::fs::write(
4602            root.join(".memstead/workspace.toml"),
4603            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
4604        )
4605        .unwrap();
4606        std::fs::write(
4607            root.join(".memstead/state/mounts.json"),
4608            serde_json::json!({
4609                "format": "memstead-mounts-3",
4610                "mounts": [{
4611                    "mem": "muehle",
4612                    "schema": "default@1.0.0",
4613                    "storage": {
4614                        "type": "git-branch",
4615                        "gitdir": "custom-repo/.git",
4616                        "branch": "refs/heads/muehle"
4617                    },
4618                    "capability": "write",
4619                    "lifecycle": "eager",
4620                    "cross_linkable": true
4621                }]
4622            })
4623            .to_string(),
4624        )
4625        .unwrap();
4626        git(root, &["add", "-A"]);
4627        git(root, &["commit", "-qm", "base"]);
4628        let baseline = String::from_utf8(
4629            std::process::Command::new("git")
4630                .args(["rev-parse", "HEAD"])
4631                .current_dir(root)
4632                .output()
4633                .unwrap()
4634                .stdout,
4635        )
4636        .unwrap()
4637        .trim()
4638        .to_string();
4639
4640        // Move: one real file, one engine-state file, one mem-repo file.
4641        std::fs::write(root.join("real.md"), "signal").unwrap();
4642        std::fs::write(root.join(".memstead/state/findings.json"), "self").unwrap();
4643        std::fs::create_dir_all(root.join("custom-repo")).unwrap();
4644        std::fs::write(root.join("custom-repo/README.md"), "repo").unwrap();
4645        git(root, &["add", "-A"]);
4646        git(root, &["commit", "-qm", "move"]);
4647
4648        let source = primary(vec![PatternEntry {
4649            path: "**/*".to_string(),
4650            mode: PatternMode::Allow,
4651        }]);
4652        match compute_git_slice(&source, &[], root, Some(&baseline)) {
4653            SliceOutcome::Changed { slice, .. } => {
4654                assert_eq!(
4655                    slice.added,
4656                    vec!["real.md"],
4657                    "engine state leaked: {slice:?}"
4658                );
4659                assert!(slice.modified.is_empty(), "{slice:?}");
4660            }
4661            other => panic!("expected Changed, got {other:?}"),
4662        }
4663    }
4664
4665    /// The dead-deny lint never flags the scaffold's own default hygiene
4666    /// entries (they can never match on a git-enumerated source — the
4667    /// engine must not call its own output a typo), while a user-authored
4668    /// entry that matches nothing keeps the loud warning and one that
4669    /// matches stays silent.
4670    #[test]
4671    fn dead_deny_lint_exempts_scaffold_defaults_but_not_user_typos() {
4672        use crate::binding::{BuildMode, DEFAULT_SCAFFOLD_DENY_PATHS};
4673        use crate::pipeline::IngestTrigger;
4674
4675        let repo = tempfile::tempdir().unwrap();
4676        let root = repo.path();
4677        git(root, &["init", "-q"]);
4678        std::fs::create_dir_all(root.join("src")).unwrap();
4679        std::fs::write(root.join("src/lib.rs"), "code").unwrap();
4680
4681        let mut deny_paths: Vec<String> = DEFAULT_SCAFFOLD_DENY_PATHS
4682            .iter()
4683            .map(|s| s.to_string())
4684            .collect();
4685        deny_paths.push("typo/**".to_string()); // user typo — matches nothing
4686        deny_paths.push("src/**".to_string()); // user entry that matches
4687
4688        let resolved = ResolvedIngest {
4689            name: "ing".to_string(),
4690            mode: BuildMode::Discovery,
4691            trigger: IngestTrigger::Loop,
4692            batch_size: 20,
4693            deny_paths,
4694            projection_ref: "m/p".to_string(),
4695            projection_mem: "m".to_string(),
4696            projection_name: "p".to_string(),
4697            intent: None,
4698            sources: vec![ResolvedSource::Primary(primary(vec![PatternEntry {
4699                path: "**/*.rs".to_string(),
4700                mode: PatternMode::Allow,
4701            }]))],
4702            destination_mem: "m".to_string(),
4703            rules: None,
4704            post_actions: None,
4705        };
4706
4707        let dead = dead_deny_entries(&resolved, root);
4708        assert_eq!(
4709            dead,
4710            vec!["typo/**".to_string()],
4711            "only the user typo is flagged — scaffold defaults and matching \
4712             entries stay silent"
4713        );
4714    }
4715}