use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use globset::{Glob, GlobSet, GlobSetBuilder};
use crate::Engine;
use crate::pipeline::{MediumType, PatternMode};
use super::brief::{DeliveredUnit, DeliverySequence, NoSignalNote, SourceCursor, SyncCommand};
use super::change_detection::{
StatMap, compute_stat_map, digest_stat_map, parse_digest_token, serialize_digest_token,
};
use super::resolve::{
ChangeStrategy, ResolvedIngest, ResolvedSource, find_git_root, resolve_change_strategy,
};
use super::slice::{
NoSignalReason, Slice, SliceOutcome, graph_slice_outcome, is_git_token, mtime_slice_outcome,
};
use crate::pipeline::Source;
pub(super) fn normalize_lexical(path: &Path) -> PathBuf {
let mut out: Vec<Component> = Vec::new();
for comp in path.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => match out.last() {
Some(Component::Normal(_)) => {
out.pop();
}
Some(Component::RootDir | Component::Prefix(_)) => {}
_ => out.push(comp),
},
other => out.push(other),
}
}
out.iter().collect()
}
pub(super) fn relative_path(from: &Path, to: &Path) -> PathBuf {
let from = normalize_lexical(from);
let to = normalize_lexical(to);
let from_comps: Vec<Component> = from.components().collect();
let to_comps: Vec<Component> = to.components().collect();
let mut common = 0;
while common < from_comps.len()
&& common < to_comps.len()
&& from_comps[common] == to_comps[common]
{
common += 1;
}
let mut result = PathBuf::new();
for _ in common..from_comps.len() {
result.push("..");
}
for comp in &to_comps[common..] {
result.push(comp.as_os_str());
}
result
}
pub fn medium_base(pointer: &str, workspace_root: &Path) -> PathBuf {
if pointer.is_empty() {
workspace_root.to_path_buf()
} else {
normalize_lexical(&workspace_root.join(pointer))
}
}
pub fn relative_to(from: &Path, to: &Path) -> PathBuf {
relative_path(from, to)
}
pub fn out_of_root_layout_warning(
pointer: &str,
workspace_root: &Path,
medium_type: crate::pipeline::MediumType,
) -> Option<String> {
use crate::pipeline::MediumType;
if !matches!(medium_type, MediumType::Codebase | MediumType::Filesystem) {
return None;
}
let base = medium_base(pointer, workspace_root);
let canon_base = std::fs::canonicalize(&base).unwrap_or(base);
let canon_root =
std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
if canon_base.starts_with(&canon_root) {
return None;
}
Some(format!(
"medium base '{}' resolves outside the workspace root '{}': supported — \
enumeration, change detection, and anchor resolution all work on this shape — \
but artifact ids render as workspace-relative '../…' chains and the \
workspace-to-source relative layout must stay fixed (moving either side \
breaks the pointer). To avoid the '../…' ids, root the workspace at the \
common parent directory containing every source tree.",
canon_base.display(),
canon_root.display()
))
}
fn engine_state_denies(workspace_root: &Path) -> Vec<String> {
use crate::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
let mut denies: Vec<String> = vec![
".memstead/**".to_string(),
".memstead.cache/**".to_string(),
"**/.memstead/**".to_string(),
"**/.memstead.cache/**".to_string(),
];
if let Ok(ws) = FileWorkspaceStore.load(workspace_root) {
for mount in &ws.mounts {
let dir: Option<PathBuf> = match &mount.storage {
crate::workspace::MountStorage::GitBranch { gitdir, .. } => {
gitdir.parent().map(Path::to_path_buf)
}
crate::workspace::MountStorage::Folder { path } => Some(path.clone()),
crate::workspace::MountStorage::Archive { path, .. } => {
let rel = relative_path(workspace_root, &normalize_lexical(path));
denies.push(rel.to_string_lossy().to_string());
None
}
crate::workspace::MountStorage::InMemory => None,
};
if let Some(dir) = dir {
let rel = relative_path(workspace_root, &normalize_lexical(&dir));
if !rel.as_os_str().is_empty() {
denies.push(format!("{}/**", rel.to_string_lossy()));
}
}
}
}
denies
}
fn commit_exists(git_root: &Path, sha: &str) -> bool {
Command::new("git")
.args(["cat-file", "-e", &format!("{sha}^{{commit}}")])
.current_dir(git_root)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn git_head(git_root: &Path) -> Option<String> {
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(git_root)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!sha.is_empty()).then_some(sha)
}
fn to_git_pathspec(pattern: &str, git_root: &Path, workspace_root: &Path, exclude: bool) -> String {
let magic = if exclude {
":(glob,exclude)"
} else {
":(glob)"
};
if pattern.starts_with("**") {
return format!("{magic}{pattern}");
}
let resolved = normalize_lexical(&workspace_root.join(pattern));
let git_rel = relative_path(git_root, &resolved);
format!("{magic}{}", git_rel.to_string_lossy())
}
fn in_repo_pathspec(
pattern: &str,
git_root: &Path,
workspace_root: &Path,
exclude: bool,
) -> Option<String> {
if pattern.starts_with("**") {
return Some(to_git_pathspec(pattern, git_root, workspace_root, exclude));
}
let resolved = normalize_lexical(&workspace_root.join(pattern));
let git_rel = relative_path(git_root, &resolved);
if git_rel
.components()
.next()
.is_some_and(|c| c == Component::ParentDir)
{
return None;
}
let magic = if exclude {
":(glob,exclude)"
} else {
":(glob)"
};
Some(format!("{magic}{}", git_rel.to_string_lossy()))
}
fn build_glob_set(patterns: &[&str]) -> Option<GlobSet> {
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
builder.add(Glob::new(pattern).ok()?);
}
builder.build().ok()
}
fn facet_unscoped(source: &Source) -> bool {
!source.scope.iter().any(|r| r.mode == PatternMode::Allow)
}
pub fn enumerate_facet_files(
source: &Source,
deny_paths: &[String],
workspace_root: &Path,
) -> Vec<String> {
if !matches!(
source.medium_type,
MediumType::Codebase | MediumType::Filesystem | MediumType::Git
) {
return Vec::new();
}
let mut allows: Vec<&str> = Vec::new();
let mut denies: Vec<&str> = Vec::new();
for rule in &source.scope {
match rule.mode {
PatternMode::Allow => allows.push(&rule.path),
PatternMode::Deny => denies.push(&rule.path),
}
}
for dp in deny_paths {
denies.push(dp);
}
let forced = engine_state_denies(workspace_root);
for f in &forced {
denies.push(f);
}
if allows.is_empty() {
return Vec::new();
}
let Some(allow_set) = build_glob_set(&allows) else {
return Vec::new();
};
let deny_set = if denies.is_empty() {
None
} else {
build_glob_set(&denies)
};
let base = medium_base(&source.pointer, workspace_root);
let mut out: Vec<String> = Vec::new();
let mut stack = vec![base];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
let path = entry.path();
if file_type.is_dir() {
let skip = path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
VCS_INTERNAL_DIRS.contains(&n) || n == ".memstead" || n == ".memstead.cache"
});
if !skip {
stack.push(path);
}
} else if file_type.is_file() {
let rel = relative_path(workspace_root, &normalize_lexical(&path))
.to_string_lossy()
.to_string();
let denied = deny_set.as_ref().is_some_and(|d| d.is_match(&rel));
if allow_set.is_match(&rel) && !denied {
out.push(rel);
}
}
}
}
out.sort();
out.dedup();
out
}
fn compute_git_slice(
source: &Source,
deny_paths: &[String],
workspace_root: &Path,
baseline: Option<&str>,
) -> SliceOutcome {
let base = medium_base(&source.pointer, workspace_root);
let Some(git_root) = find_git_root(&base) else {
return SliceOutcome::NoSignal {
reason: NoSignalReason::GitUnavailable,
};
};
let Some(head) = git_head(&git_root) else {
return SliceOutcome::NoSignal {
reason: NoSignalReason::GitUnavailable,
};
};
let baseline = match baseline {
Some(b) if is_git_token(b) => b,
_ => return SliceOutcome::Reseed { token: head },
};
if baseline == head {
return SliceOutcome::Unchanged { token: head };
}
if !commit_exists(&git_root, baseline) {
return SliceOutcome::Reseed { token: head };
}
let mut allows: Vec<&str> = Vec::new();
let mut denies: Vec<&str> = Vec::new();
for rule in &source.scope {
match rule.mode {
PatternMode::Allow => allows.push(&rule.path),
PatternMode::Deny => denies.push(&rule.path),
}
}
if allows.is_empty() {
return SliceOutcome::NoSignal {
reason: NoSignalReason::Unscoped,
};
}
for dp in deny_paths {
denies.push(dp);
}
let forced = engine_state_denies(workspace_root);
for f in &forced {
denies.push(f);
}
let mut specs: Vec<String> = Vec::with_capacity(allows.len() + denies.len());
for a in &allows {
specs.push(to_git_pathspec(a, &git_root, workspace_root, false));
}
for d in &denies {
if let Some(spec) = in_repo_pathspec(d, &git_root, workspace_root, true) {
specs.push(spec);
}
}
let mut cmd = Command::new("git");
cmd.args([
"diff",
"--no-renames",
"--name-status",
baseline,
&head,
"--",
]);
cmd.args(&specs);
cmd.current_dir(&git_root);
let out = match cmd.output() {
Ok(o) if o.status.success() => o,
_ => {
return SliceOutcome::NoSignal {
reason: NoSignalReason::GitUnavailable,
};
}
};
let text = String::from_utf8_lossy(&out.stdout);
let mut slice = Slice::default();
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
let Some(tab) = line.find('\t') else { continue };
let status = line[..tab].trim();
let git_path = line[tab + 1..].trim();
let ws_path = relative_path(workspace_root, &normalize_lexical(&git_root.join(git_path)))
.to_string_lossy()
.to_string();
match status.chars().next() {
Some('A') => slice.added.push(ws_path),
Some('D') => slice.deleted.push(ws_path),
_ => slice.modified.push(ws_path),
}
}
slice.added.sort();
slice.modified.sort();
slice.deleted.sort();
SliceOutcome::Changed {
token: head,
slice,
degraded: false,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EntitySelector {
All,
Type(String),
Id(String),
}
pub fn parse_entity_selector(pattern: &str) -> Option<EntitySelector> {
let pattern = pattern.trim();
if pattern == "*" {
return Some(EntitySelector::All);
}
if let Some(rest) = pattern.strip_prefix("type:") {
let rest = rest.trim();
if rest.is_empty() {
return None;
}
return Some(EntitySelector::Type(rest.to_string()));
}
if let Some(rest) = pattern.strip_prefix("id:") {
let rest = rest.trim();
if rest.is_empty() {
return None;
}
Glob::new(rest).ok()?;
return Some(EntitySelector::Id(rest.to_string()));
}
None
}
fn selector_matches(selector: &EntitySelector, id: &str, entity_type: &str) -> bool {
match selector {
EntitySelector::All => true,
EntitySelector::Type(t) => entity_type == t,
EntitySelector::Id(g) => Glob::new(g)
.ok()
.map(|glob| glob.compile_matcher().is_match(id))
.unwrap_or(false),
}
}
pub fn enumerate_graph_entities(engine: &Engine, source: &Source) -> Vec<String> {
if source.medium_type != MediumType::Graph {
return Vec::new();
}
let mut allows: Vec<EntitySelector> = Vec::new();
let mut denies: Vec<EntitySelector> = Vec::new();
for rule in &source.scope {
let Some(sel) = parse_entity_selector(&rule.path) else {
continue;
};
match rule.mode {
PatternMode::Allow => allows.push(sel),
PatternMode::Deny => denies.push(sel),
}
}
if allows.is_empty() {
return Vec::new();
}
let mem = source.pointer.as_str();
let mut out: Vec<String> = Vec::new();
for entity in engine.store().all_entities() {
if entity.mem != mem || entity.stub {
continue;
}
let id = entity.id.0.as_str();
let ty = entity.entity_type.as_str();
if !allows.iter().any(|s| selector_matches(s, id, ty)) {
continue;
}
if denies.iter().any(|s| selector_matches(s, id, ty)) {
continue;
}
out.push(id.to_string());
}
out.sort();
out.dedup();
out
}
pub fn enumerate_source_artifacts(
engine: &Engine,
source: &Source,
deny_paths: &[String],
workspace_root: &Path,
) -> Vec<String> {
match source.medium_type {
MediumType::Codebase | MediumType::Filesystem | MediumType::Git => {
enumerate_facet_files(source, deny_paths, workspace_root)
}
MediumType::Graph => enumerate_graph_entities(engine, source),
MediumType::Web => Vec::new(),
}
}
fn filter_graph_slice_to_scope(engine: &Engine, source: &Source, slice: &mut Slice) {
let mut allows: Vec<EntitySelector> = Vec::new();
let mut denies: Vec<EntitySelector> = Vec::new();
for rule in &source.scope {
let Some(sel) = parse_entity_selector(&rule.path) else {
continue;
};
match rule.mode {
PatternMode::Allow => allows.push(sel),
PatternMode::Deny => denies.push(sel),
}
}
if allows.is_empty() {
return;
}
let in_scope = |id: &str, known_type: Option<&str>| {
let matches = |s: &EntitySelector| match (s, known_type) {
(EntitySelector::Type(_), None) => true,
_ => selector_matches(s, id, known_type.unwrap_or_default()),
};
allows.iter().any(&matches) && !denies.iter().any(&matches)
};
let type_of = |id: &str| {
engine
.store()
.get(&crate::entity::EntityId::canonical(id))
.map(|e| e.entity_type.clone())
};
slice
.added
.retain(|id| in_scope(id, type_of(id).as_deref()));
slice
.modified
.retain(|id| in_scope(id, type_of(id).as_deref()));
slice.deleted.retain(|id| in_scope(id, None));
}
fn compute_graph_slice(
engine: &Engine,
source: Option<&Source>,
source_mem: &str,
baseline: Option<&str>,
) -> SliceOutcome {
let current = match engine.mem_head_sha(source_mem) {
Ok(Some(sha)) => sha,
_ => {
return SliceOutcome::NoSignal {
reason: NoSignalReason::GraphSnapshotMissing,
};
}
};
let changed = matches!(baseline, Some(b) if is_git_token(b) && b != current);
let mut outcome = if changed {
let baseline = baseline.expect("changed implies a baseline");
match engine.changes_since(source_mem, baseline, None) {
Ok(report) => graph_slice_outcome(Some(baseline), ¤t, &report.changes),
Err(_) => SliceOutcome::NoSignal {
reason: NoSignalReason::GraphSnapshotMissing,
},
}
} else {
graph_slice_outcome(baseline, ¤t, &[])
};
if let (Some(source), SliceOutcome::Changed { slice, .. }) = (source, &mut outcome) {
filter_graph_slice_to_scope(engine, source, slice);
}
outcome
}
fn cursor_memo_path(cache_root: &Path, ingest_name: &str, facet_ref: &str) -> PathBuf {
let safe: String = facet_ref
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
c
} else {
'_'
}
})
.collect();
cache_root
.join("source-cursor")
.join(ingest_name)
.join(format!("{safe}.json"))
}
fn read_cursor_memo(
cache_root: &Path,
ingest: &str,
facet: &str,
aggregate: &str,
) -> Option<StatMap> {
let bytes = std::fs::read(cursor_memo_path(cache_root, ingest, facet)).ok()?;
let memo: BTreeMap<String, StatMap> = serde_json::from_slice(&bytes).ok()?;
memo.get(aggregate).cloned()
}
fn write_cursor_memo(cache_root: &Path, ingest: &str, facet: &str, aggregate: &str, map: &StatMap) {
let path = cursor_memo_path(cache_root, ingest, facet);
let mut memo: BTreeMap<String, StatMap> = std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
memo.insert(aggregate.to_string(), map.clone());
if memo.len() > 3 {
let drop: Vec<String> = memo
.keys()
.filter(|k| k.as_str() != aggregate)
.skip(2)
.cloned()
.collect();
for key in drop {
memo.remove(&key);
}
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(bytes) = serde_json::to_vec(&memo) {
let _ = std::fs::write(&path, bytes);
}
}
const VCS_INTERNAL_DIRS: &[&str] = &[".git", ".svn", ".hg"];
const DEAD_DENY_SKIP_DIRS: &[&str] = &[
".git",
"node_modules",
"target",
"dist",
".memstead.cache",
".sqlx",
".svn",
".hg",
];
fn walk_tree_bounded(base: &Path, workspace_root: &Path, cap: usize) -> Option<Vec<String>> {
let mut out: Vec<String> = Vec::new();
let mut stack = vec![base.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
let path = entry.path();
if file_type.is_dir() {
let skip = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| DEAD_DENY_SKIP_DIRS.contains(&n));
if !skip {
stack.push(path);
}
} else if file_type.is_file() {
if out.len() >= cap {
return None;
}
out.push(
relative_path(workspace_root, &normalize_lexical(&path))
.to_string_lossy()
.to_string(),
);
}
}
}
Some(out)
}
fn dead_deny_entries(resolved: &ResolvedIngest, workspace_root: &Path) -> Vec<String> {
if resolved.deny_paths.is_empty() {
return Vec::new();
}
let base = find_git_root(workspace_root).unwrap_or_else(|| workspace_root.to_path_buf());
let Some(files) = walk_tree_bounded(&base, workspace_root, 100_000) else {
return Vec::new();
};
let mut dead: Vec<String> = Vec::new();
for entry in &resolved.deny_paths {
if crate::binding::DEFAULT_SCAFFOLD_DENY_PATHS.contains(&entry.as_str()) {
continue;
}
let Some(set) = build_glob_set(&[entry.as_str()]) else {
continue;
};
if !files.iter().any(|f| set.is_match(f)) {
dead.push(entry.clone());
}
}
dead
}
fn compute_mtime_slice(
source: &Source,
ingest_name: &str,
deny_paths: &[String],
workspace_root: &Path,
cache_root: &Path,
baseline: Option<&str>,
) -> SliceOutcome {
if facet_unscoped(source) {
return SliceOutcome::NoSignal {
reason: NoSignalReason::Unscoped,
};
}
let files = enumerate_facet_files(source, deny_paths, workspace_root);
let now_map = compute_stat_map(&files, workspace_root);
let now_digest = digest_stat_map(&now_map);
write_cursor_memo(
cache_root,
ingest_name,
&source.name,
&now_digest.aggregate,
&now_map,
);
let prev_map = baseline
.and_then(parse_digest_token)
.and_then(|base| read_cursor_memo(cache_root, ingest_name, &source.name, &base.aggregate));
mtime_slice_outcome(baseline, prev_map.as_ref(), &now_map)
}
fn current_primary_token(
engine: &Engine,
source: &Source,
deny_paths: &[String],
workspace_root: &Path,
) -> Option<String> {
match resolve_change_strategy(source, workspace_root) {
ChangeStrategy::Git => git_head(&find_git_root(&medium_base(
&source.pointer,
workspace_root,
))?),
ChangeStrategy::Graph => {
if facet_unscoped(source) {
None
} else {
engine.mem_head_sha(&source.pointer).ok().flatten()
}
}
ChangeStrategy::Mtime => {
if facet_unscoped(source) {
None
} else {
let files = enumerate_facet_files(source, deny_paths, workspace_root);
Some(serialize_digest_token(&digest_stat_map(&compute_stat_map(
&files,
workspace_root,
))))
}
}
ChangeStrategy::None => None,
}
}
pub fn source_moved(engine: &Engine, resolved: &ResolvedIngest, workspace_root: &Path) -> bool {
source_moved_since(engine, resolved, workspace_root, "synced", false)
}
pub fn source_moved_since(
engine: &Engine,
resolved: &ResolvedIngest,
workspace_root: &Path,
state: &str,
missing_baseline_is_moved: bool,
) -> bool {
let dest = &resolved.destination_mem;
let baseline_map = engine
.mem_config_for(dest)
.map(|c| c.sync_state.clone())
.unwrap_or_default();
for source in &resolved.sources {
let (facet_ref, current) = match source {
ResolvedSource::Primary(p) => (
p.name.clone(),
current_primary_token(engine, p, &resolved.deny_paths, workspace_root),
),
ResolvedSource::Reference { mem } => {
(mem.clone(), engine.mem_head_sha(mem).ok().flatten())
}
};
let key = format!("{}/{}#{state}", resolved.name, facet_ref);
let Some(baseline) = baseline_map.get(&key) else {
if missing_baseline_is_moved && current.as_deref().is_some_and(|c| !c.is_empty()) {
return true;
}
continue;
};
if let Some(current) = current
&& !current.is_empty()
&& current != *baseline
{
return true;
}
}
false
}
pub fn compute_source_cursor(
engine: &Engine,
resolved: &ResolvedIngest,
workspace_root: &Path,
) -> SourceCursor {
let dest = &resolved.destination_mem;
let baseline_map = engine
.mem_config_for(dest)
.map(|c| c.sync_state.clone())
.unwrap_or_default();
let cache_root = workspace_root.join(".memstead.cache").join("ingest");
let mut union = Slice::default();
let mut write_commands: Vec<SyncCommand> = Vec::new();
let mut reseed: Vec<SyncCommand> = Vec::new();
let mut no_signal: Vec<NoSignalNote> = Vec::new();
let mut delivery: Vec<DeliverySequence> = Vec::new();
let mut degraded = false;
let disposed_units: std::cell::OnceCell<BTreeSet<String>> = std::cell::OnceCell::new();
let disposed_units = || {
disposed_units.get_or_init(|| {
resolved
.name
.split_once('/')
.and_then(|(mem, name)| {
super::advance::read_advance_store(workspace_root, mem, name)
.ok()
.flatten()
})
.map(|state| state.dispositions.keys().cloned().collect())
.unwrap_or_default()
})
};
for source in &resolved.sources {
let primary_medium = match source {
ResolvedSource::Primary(p) => Some(p.medium_type),
ResolvedSource::Reference { .. } => None,
};
let (facet_ref, outcome) = match source {
ResolvedSource::Primary(p) => {
let key = format!("{}/{}#synced", resolved.name, p.name);
let baseline = baseline_map.get(&key).map(String::as_str);
let outcome = match resolve_change_strategy(p, workspace_root) {
ChangeStrategy::Git => {
compute_git_slice(p, &resolved.deny_paths, workspace_root, baseline)
}
ChangeStrategy::Graph if facet_unscoped(p) => SliceOutcome::NoSignal {
reason: NoSignalReason::Unscoped,
},
ChangeStrategy::Graph => {
compute_graph_slice(engine, Some(p), &p.pointer, baseline)
}
ChangeStrategy::Mtime => compute_mtime_slice(
p,
&resolved.name,
&resolved.deny_paths,
workspace_root,
&cache_root,
baseline,
),
ChangeStrategy::None => SliceOutcome::NoSignal {
reason: NoSignalReason::DetectionNone,
},
};
let outcome =
match crate::preparation::delivery_preparation(p.preparation.as_deref()) {
Some(prep)
if matches!(
p.medium_type,
MediumType::Codebase | MediumType::Filesystem | MediumType::Git
) =>
{
let (outcome, sequence) = deliver_units(
p,
prep.id,
&resolved.deny_paths,
workspace_root,
baseline,
resolved.batch_size as usize,
disposed_units(),
outcome,
);
delivery.extend(sequence);
outcome
}
_ => outcome,
};
(p.name.clone(), outcome)
}
ResolvedSource::Reference { mem } => {
let key = format!("{}/{}#synced", resolved.name, mem);
let baseline = baseline_map.get(&key).map(String::as_str);
(
mem.clone(),
compute_graph_slice(engine, None, mem, baseline),
)
}
};
let key = format!("{}/{}#synced", resolved.name, facet_ref);
match outcome {
SliceOutcome::Unchanged { .. } => {}
SliceOutcome::NoSignal { reason } => no_signal.push(NoSignalNote {
source: facet_ref.clone(),
reason,
medium_type: primary_medium,
}),
SliceOutcome::Reseed { token } => reseed.push(SyncCommand { key, token }),
SliceOutcome::Changed {
token,
slice,
degraded: d,
} => {
union.added.extend(slice.added);
union.modified.extend(slice.modified);
union.deleted.extend(slice.deleted);
degraded |= d;
write_commands.push(SyncCommand { key, token });
}
}
}
dedupe_sort(&mut union.added);
dedupe_sort(&mut union.modified);
dedupe_sort(&mut union.deleted);
let any_changes =
!union.added.is_empty() || !union.modified.is_empty() || !union.deleted.is_empty();
SourceCursor {
union,
write_commands,
reseed,
no_signal,
any_changes,
degraded,
dead_denies: dead_deny_entries(resolved, workspace_root),
delivery,
dest_mem: dest.clone(),
binding_id: resolved.name.clone(),
}
}
fn dedupe_sort(v: &mut Vec<String>) {
v.sort();
v.dedup();
}
fn read_workspace_file(workspace_root: &Path, ws_rel: &str) -> Option<String> {
std::fs::read(workspace_root.join(ws_rel))
.ok()
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
}
fn git_baseline_content(
source: &Source,
workspace_root: &Path,
baseline: Option<&str>,
ws_rel: &str,
) -> Option<String> {
let baseline = baseline.filter(|b| is_git_token(b))?;
if !matches!(
resolve_change_strategy(source, workspace_root),
ChangeStrategy::Git
) {
return None;
}
let git_root = find_git_root(&medium_base(&source.pointer, workspace_root))?;
let abs = normalize_lexical(&workspace_root.join(ws_rel));
let rel = relative_path(&git_root, &abs);
let spec = format!("{baseline}:{}", rel.to_string_lossy().replace('\\', "/"));
let out = Command::new("git")
.args(["show", &spec])
.current_dir(&git_root)
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}
pub(crate) fn sequence_units(units: &mut Vec<DeliveredUnit>) {
fn rank(id: &str) -> (&str, u64, &str) {
let (path, key) = crate::preparation::split_unit_id(id);
let key = key.unwrap_or("");
let ordinal = key
.rsplit_once('.')
.and_then(|(_, n)| n.parse::<u64>().ok())
.unwrap_or(1);
(path, ordinal, key)
}
units.sort_by(|a, b| (&a.order_key, rank(&a.id)).cmp(&(&b.order_key, rank(&b.id))));
units.dedup_by(|a, b| a.id == b.id);
}
#[allow(clippy::too_many_arguments)]
fn deliver_units(
source: &Source,
preparation: &str,
deny_paths: &[String],
workspace_root: &Path,
baseline: Option<&str>,
batch: usize,
disposed: &BTreeSet<String>,
outcome: SliceOutcome,
) -> (SliceOutcome, Option<DeliverySequence>) {
use crate::preparation::{DeliveryUnit, UnitChange, diff_units, unit_id, unitize};
let units_of =
|text: &str| -> Vec<DeliveryUnit> { unitize(preparation, text).unwrap_or_default() };
let delivered = |path: &str, u: &DeliveryUnit, change: UnitChange| DeliveredUnit {
id: unit_id(path, &u.key),
order_key: u.order_key.clone(),
change,
disposed: false,
};
let mut units: Vec<DeliveredUnit> = Vec::new();
let mut file_level_deleted: Vec<String> = Vec::new();
let mut degraded_units = false;
let (token, first_run, degraded) = match outcome {
SliceOutcome::Reseed { token } => {
for f in enumerate_facet_files(source, deny_paths, workspace_root) {
if let Some(text) = read_workspace_file(workspace_root, &f) {
for u in units_of(&text) {
units.push(delivered(&f, &u, UnitChange::Added));
}
}
}
if units.is_empty() {
return (SliceOutcome::Reseed { token }, None);
}
(token, true, false)
}
SliceOutcome::Changed {
token,
slice,
degraded,
} => {
for f in &slice.added {
if let Some(text) = read_workspace_file(workspace_root, f) {
for u in units_of(&text) {
units.push(delivered(f, &u, UnitChange::Added));
}
}
}
for f in &slice.modified {
let Some(now) = read_workspace_file(workspace_root, f) else {
continue;
};
let new_units = units_of(&now);
match git_baseline_content(source, workspace_root, baseline, f) {
Some(old) => {
for (u, change) in diff_units(&units_of(&old), &new_units) {
units.push(delivered(f, &u, change));
}
}
None => {
degraded_units = true;
for u in new_units {
units.push(delivered(f, &u, UnitChange::Modified));
}
}
}
}
for f in &slice.deleted {
match git_baseline_content(source, workspace_root, baseline, f) {
Some(old) => {
for u in units_of(&old) {
units.push(delivered(f, &u, UnitChange::Deleted));
}
}
None => file_level_deleted.push(f.clone()),
}
}
(token, false, degraded)
}
other => return (other, None),
};
sequence_units(&mut units);
for u in &mut units {
u.disposed = disposed.contains(&u.id);
}
let mut slice = Slice::default();
for u in &units {
match u.change {
UnitChange::Added => slice.added.push(u.id.clone()),
UnitChange::Modified => slice.modified.push(u.id.clone()),
UnitChange::Deleted => slice.deleted.push(u.id.clone()),
}
}
slice.deleted.extend(file_level_deleted);
dedupe_sort(&mut slice.added);
dedupe_sort(&mut slice.modified);
dedupe_sort(&mut slice.deleted);
let sequence = DeliverySequence {
source: source.name.clone(),
preparation: preparation.to_string(),
first_run,
degraded: degraded_units,
batch,
units,
};
(
SliceOutcome::Changed {
token,
slice,
degraded,
},
Some(sequence),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_resolves_dot_and_dotdot() {
assert_eq!(
normalize_lexical(Path::new("/a/b/../c/./d")),
PathBuf::from("/a/c/d")
);
assert_eq!(
normalize_lexical(Path::new("/a/../../b")),
PathBuf::from("/b"),
"dotdot past root is clamped"
);
}
#[test]
fn relative_computes_updowns() {
assert_eq!(
relative_path(Path::new("/a/b"), Path::new("/a/b/c/d")),
PathBuf::from("c/d")
);
assert_eq!(
relative_path(Path::new("/a/b/c"), Path::new("/a/x")),
PathBuf::from("../../x")
);
assert_eq!(
relative_path(Path::new("/m/public"), Path::new("/m/public/crates/x.rs")),
PathBuf::from("crates/x.rs")
);
assert_eq!(
relative_path(Path::new("/m/graph"), Path::new("/m/public/crates/x.rs")),
PathBuf::from("../public/crates/x.rs")
);
}
#[test]
fn pathspec_builds_glob_magic_relative_to_git_root() {
let ws = Path::new("/m/graph");
let git_root = Path::new("/m/public");
assert_eq!(
to_git_pathspec("../public/**/*.rs", git_root, ws, false),
":(glob)**/*.rs"
);
assert_eq!(
to_git_pathspec("../public/target/**", git_root, ws, true),
":(glob,exclude)target/**"
);
}
#[test]
fn wildcard_prefixed_pathspec_reanchors_verbatim() {
let ws = Path::new("/m/ws");
let git_root = Path::new("/m/ws/src");
assert_eq!(to_git_pathspec("**/*", git_root, ws, false), ":(glob)**/*");
assert_eq!(
in_repo_pathspec("**/__pycache__/**", git_root, ws, true).as_deref(),
Some(":(glob,exclude)**/__pycache__/**")
);
}
use crate::ingest::resolve::Source;
use crate::pipeline::{MediumType, PatternEntry};
fn git(repo: &Path, args: &[&str]) {
let status = std::process::Command::new("git")
.args(args)
.current_dir(repo)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t")
.output()
.unwrap();
assert!(
status.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&status.stderr)
);
}
fn primary(scope: Vec<PatternEntry>) -> Source {
Source {
name: "src".to_string(),
medium_type: MediumType::Codebase,
pointer: String::new(),
change_detection: Some("git".to_string()),
scope,
engagement: None,
preparation: None,
}
}
#[test]
fn deny_dialect_agrees_between_slice_and_check() {
let strs =
|items: &[&str]| -> Vec<String> { items.iter().map(|s| s.to_string()).collect() };
let entries = strs(&["dev/**", "**/VISION.md", "docs/meta/CLAUDE.md"]);
let blocked = strs(&[
"dev/notes/a.md",
"dev/x.rs",
"dev/deep/nested/y.txt",
"VISION.md",
"crates/foo/VISION.md",
"docs/meta/CLAUDE.md",
]);
let allowed = strs(&[
"src/lib.rs",
"dev-tools/x.rs",
"VISION-draft.md",
"docs/meta/README.md",
"other/CLAUDE.md",
"crates/foo/mod.rs",
]);
let ws = tempfile::tempdir().unwrap();
for rel in blocked.iter().chain(allowed.iter()) {
let path = ws.path().join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "x").unwrap();
}
let source = primary(vec![PatternEntry {
path: "**".to_string(),
mode: PatternMode::Allow,
}]);
let mut got = enumerate_facet_files(&source, &entries, ws.path());
got.sort();
let mut want = allowed.clone();
want.sort();
assert_eq!(
got, want,
"engine slice must equal the fixture `allowed` set"
);
for b in &blocked {
assert!(
!got.contains(b),
"denied `{b}` leaked into the engine slice"
);
}
let all: Vec<String> = blocked.iter().chain(allowed.iter()).cloned().collect();
let checks =
super::super::check_path::check_deny_paths(&entries, &all, ws.path(), ws.path());
for c in &checks {
let expect = blocked.contains(&c.path);
assert_eq!(
c.denied, expect,
"check_deny_paths disagrees with the slice on `{}`",
c.path
);
}
}
#[test]
fn out_of_repo_deny_pathspec_is_dropped() {
let ws = Path::new("/m/graph");
let git_root = Path::new("/m/public");
assert_eq!(in_repo_pathspec("../dev/**", git_root, ws, true), None);
assert_eq!(in_repo_pathspec("../CLAUDE.md", git_root, ws, true), None);
assert_eq!(
in_repo_pathspec("../public/target/**", git_root, ws, true),
Some(":(glob,exclude)target/**".to_string())
);
}
#[test]
fn foreign_baseline_reseeds_instead_of_degrading() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
std::fs::write(root.join("keep.rs"), "one").unwrap();
git(root, &["init", "-q"]);
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "seed"]);
let source = primary(vec![PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
}]);
let foreign = "46ce8add0fe87250527b6fa21fcfdc2d943d51f0";
match compute_git_slice(&source, &[], root, Some(foreign)) {
SliceOutcome::Reseed { token } => {
let head = String::from_utf8(
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
assert_eq!(token, head);
}
other => panic!("foreign baseline must reseed, got {other:?}"),
}
}
#[test]
fn git_slice_survives_cross_repo_deny() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
std::fs::write(root.join("keep.rs"), "one").unwrap();
git(root, &["init", "-q"]);
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "seed"]);
let baseline = String::from_utf8(
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
std::fs::write(root.join("keep.rs"), "two").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "move"]);
let source = primary(vec![PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
}]);
let outcome = compute_git_slice(&source, &["../dev/**".to_string()], root, Some(&baseline));
match outcome {
SliceOutcome::Changed { slice, .. } => {
assert_eq!(slice.modified, vec!["keep.rs"]);
}
other => panic!("expected Changed (deny dropped), got {other:?}"),
}
}
#[test]
fn git_slice_diffs_baseline_to_head() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
git(root, &["init", "-q"]);
std::fs::write(root.join("keep.rs"), "one").unwrap();
std::fs::write(root.join("gone.rs"), "bye").unwrap();
std::fs::write(root.join("note.md"), "ignored-by-scope").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "base"]);
let baseline = String::from_utf8(
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
std::fs::write(root.join("keep.rs"), "two").unwrap();
std::fs::remove_file(root.join("gone.rs")).unwrap();
std::fs::write(root.join("new.rs"), "hi").unwrap();
std::fs::write(root.join("note.md"), "still ignored").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "move"]);
let source = primary(vec![PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
}]);
let outcome = compute_git_slice(&source, &[], root, Some(&baseline));
match outcome {
SliceOutcome::Changed {
slice, degraded, ..
} => {
assert!(!degraded);
assert_eq!(slice.added, vec!["new.rs"]);
assert_eq!(slice.modified, vec!["keep.rs"]);
assert_eq!(slice.deleted, vec!["gone.rs"]);
}
other => panic!("expected Changed, got {other:?}"),
}
let head = String::from_utf8(
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
assert!(matches!(
compute_git_slice(&source, &[], root, Some(&head)),
SliceOutcome::Unchanged { .. }
));
assert!(matches!(
compute_git_slice(&source, &[], root, None),
SliceOutcome::Reseed { .. }
));
}
#[test]
fn enumerate_honours_allow_and_deny() {
let ws = tempfile::tempdir().unwrap();
let root = ws.path();
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("a.rs"), "").unwrap();
std::fs::write(root.join("sub/b.rs"), "").unwrap();
std::fs::write(root.join("c.md"), "").unwrap();
let source = primary(vec![
PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
},
PatternEntry {
path: "sub/**".to_string(),
mode: PatternMode::Deny,
},
]);
assert_eq!(enumerate_facet_files(&source, &[], root), vec!["a.rs"]);
let mut graph_source = source.clone();
graph_source.medium_type = MediumType::Graph;
assert!(enumerate_facet_files(&graph_source, &[], root).is_empty());
}
#[test]
fn graph_enumeration_selects_the_source_mems_entities() {
use crate::workspace::{
Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
};
use crate::workspace_store::WorkspaceStoreAdapter;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mem_dir = root.join("srcmem");
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
std::fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
)
.unwrap();
let entity = |slug: &str, ty: &str, title: &str| {
std::fs::write(
mem_dir.join(format!("{slug}.md")),
format!("---\ntype: {ty}\n---\n\n# {title}\n\n## Decision\n\nBody.\n"),
)
.unwrap();
};
entity("alpha-choice", "decision", "Alpha choice");
entity("beta-choice", "decision", "Beta choice");
entity("gamma-note", "memo", "Gamma note");
std::fs::create_dir_all(root.join(".memstead")).unwrap();
std::fs::write(
root.join(".memstead").join("workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
crate::FileWorkspaceStore::new()
.save_state(
root,
&Workspace {
mounts: vec![Mount {
mem: "srcmem".to_string(),
schema: Some("default@1.0.0".parse().unwrap()),
storage: MountStorage::Folder {
path: mem_dir.clone(),
},
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: false,
migration_target: None,
}],
settings: WorkspaceSettings::default(),
},
)
.unwrap();
let engine = crate::Engine::from_workspace_root(root).unwrap();
let graph_source = |patterns: Vec<(&str, PatternMode)>| Source {
name: "g".to_string(),
medium_type: MediumType::Graph,
pointer: "srcmem".to_string(),
change_detection: None,
scope: patterns
.into_iter()
.map(|(p, mode)| crate::pipeline::PatternEntry {
path: p.to_string(),
mode,
})
.collect(),
engagement: None,
preparation: None,
};
let all = enumerate_graph_entities(&engine, &graph_source(vec![("*", PatternMode::Allow)]));
assert_eq!(
all,
vec![
"srcmem--alpha-choice".to_string(),
"srcmem--beta-choice".to_string(),
"srcmem--gamma-note".to_string(),
],
"the whole-mem selector enumerates every real entity"
);
let decisions = enumerate_graph_entities(
&engine,
&graph_source(vec![("type:decision", PatternMode::Allow)]),
);
assert_eq!(
decisions,
vec![
"srcmem--alpha-choice".to_string(),
"srcmem--beta-choice".to_string()
],
"type selector excludes the memo"
);
let globbed = enumerate_graph_entities(
&engine,
&graph_source(vec![
("id:srcmem--*-choice", PatternMode::Allow),
("id:srcmem--beta-*", PatternMode::Deny),
]),
);
assert_eq!(
globbed,
vec!["srcmem--alpha-choice".to_string()],
"deny subtracts from allow in the entity namespace too"
);
assert!(
enumerate_graph_entities(&engine, &graph_source(vec![])).is_empty(),
"an unscoped graph facet is never silently 'everything'"
);
assert_eq!(
enumerate_source_artifacts(
&engine,
&graph_source(vec![("*", PatternMode::Allow)]),
&[],
root
),
all,
"enumerate_source_artifacts routes a graph source to the graph arm"
);
}
#[test]
fn a_stale_entity_anchor_over_a_changed_entity_is_drifted() {
use crate::anchor::{
Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
};
use crate::entity::EntityId;
use crate::workspace::{
Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
};
use crate::workspace_store::WorkspaceStoreAdapter;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mem_dir = root.join("mem");
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
std::fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
)
.unwrap();
std::fs::write(
mem_dir.join("pinned.md"),
"---\ntype: decision\n---\n\n# Pinned\n\n## Decision\n\nOriginal body.\n",
)
.unwrap();
std::fs::write(
mem_dir.join("steady.md"),
"---\ntype: decision\n---\n\n# Steady\n\n## Decision\n\nUnchanged body.\n",
)
.unwrap();
std::fs::create_dir_all(root.join(".memstead")).unwrap();
std::fs::write(
root.join(".memstead").join("workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
crate::FileWorkspaceStore::new()
.save_state(
root,
&Workspace {
mounts: vec![Mount {
mem: "mem".to_string(),
schema: Some("default@1.0.0".parse().unwrap()),
storage: MountStorage::Folder {
path: mem_dir.clone(),
},
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: false,
migration_target: None,
}],
settings: WorkspaceSettings::default(),
},
)
.unwrap();
let engine = crate::Engine::from_workspace_root(root).unwrap();
let hash_of = |engine: &crate::Engine, id: &str| {
let e = engine.store().get(&EntityId::canonical(id)).unwrap();
crate::anchor::prepared_content_hash(
crate::render::render_entity_markdown(e, None).as_bytes(),
)
};
let pinned_hash = hash_of(&engine, "mem--pinned");
let steady_hash = hash_of(&engine, "mem--steady");
let entity_anchor = |artifact: &str, hash: &str| Anchor {
artifact: artifact.to_string(),
grain: AnchorGrain::Entity,
class: AnchorProvenanceClass::Anchored,
hash: Some(hash.to_string()),
source: None,
binding: None,
at_version: None,
derived_from: Vec::new(),
hash_stability: crate::anchor::AnchorHashStability::Stable,
};
let mut sidecar = AnchorSidecar::default();
sidecar.set(
"mem--holder",
vec![
entity_anchor("mem--pinned", &pinned_hash),
entity_anchor("mem--steady", &steady_hash),
entity_anchor("mem--vanished", "deadbeefdeadbeef"),
],
);
std::fs::write(
mem_dir.join(".memstead").join("anchors.json"),
sidecar.to_bytes(),
)
.unwrap();
std::fs::write(
mem_dir.join("holder.md"),
"---\ntype: decision\n---\n\n# Holder\n\n## Decision\n\nHolds anchors.\n",
)
.unwrap();
std::fs::write(
mem_dir.join("pinned.md"),
"---\ntype: decision\n---\n\n# Pinned\n\n## Decision\n\nBody rewritten.\n",
)
.unwrap();
let engine = crate::Engine::from_workspace_root(root).unwrap();
let resolved = engine.entity_anchors_resolved(&EntityId::canonical("mem--holder"));
let state_of = |artifact: &str| {
resolved
.iter()
.find(|r| r.anchor.artifact == artifact)
.unwrap_or_else(|| panic!("no resolved anchor for {artifact}"))
.state
};
assert_eq!(
state_of("mem--pinned"),
Some(AnchorState::Drifted),
"a stale-pinned anchor over a CHANGED entity must be drifted — \
this is the pilot failure that went unflagged"
);
assert_eq!(
state_of("mem--steady"),
Some(AnchorState::Resolves),
"an anchor over an unchanged entity still resolves"
);
assert_eq!(
state_of("mem--vanished"),
Some(AnchorState::Orphaned),
"an anchor over an entity that is not there is orphaned, not unobserved"
);
let mut sc2 = AnchorSidecar::default();
sc2.set(
"mem--holder",
vec![Anchor {
artifact: "https://example.invalid/doc".to_string(),
grain: AnchorGrain::Url,
class: AnchorProvenanceClass::InformedBy,
hash: None,
source: None,
binding: None,
at_version: None,
derived_from: Vec::new(),
hash_stability: crate::anchor::AnchorHashStability::Stable,
}],
);
std::fs::write(
mem_dir.join(".memstead").join("anchors.json"),
sc2.to_bytes(),
)
.unwrap();
let engine = crate::Engine::from_workspace_root(root).unwrap();
let url_state =
engine.entity_anchors_resolved(&EntityId::canonical("mem--holder"))[0].state;
assert_eq!(
url_state, None,
"url anchors stay unobserved — the fix widens observation, never the \
scoring of non-observation"
);
}
#[test]
fn entity_load_bearing_preparation_ignores_notes_edits_and_catches_claim_edits() {
use crate::anchor::{
Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
};
use crate::binding::{
BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, VerifyOperation,
};
use crate::entity::EntityId;
use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode, Source};
use crate::workspace::{
Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
};
use crate::workspace_store::WorkspaceStoreAdapter;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mem_dir = root.join("home");
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
std::fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
)
.unwrap();
let write_pinned = |claim: &str, conditions: &str| {
std::fs::write(
mem_dir.join("pinned.md"),
format!(
"---\ntype: assertion\n---\n\n# Pinned\n\n## Claim\n\n{claim}\n\n\
## Evidence\n\nMeasured.\n\n## Conditions\n\n{conditions}\n"
),
)
.unwrap();
};
write_pinned("The sky is blue.", "daylight");
std::fs::write(
mem_dir.join("holder.md"),
"---\ntype: assertion\n---\n\n# Holder\n\n## Claim\n\nDepends on pinned.\n\n\
## Evidence\n\nSee pinned.\n",
)
.unwrap();
std::fs::create_dir_all(root.join(".memstead")).unwrap();
std::fs::write(
root.join(".memstead").join("workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
crate::FileWorkspaceStore::new()
.save_state(
root,
&Workspace {
mounts: vec![Mount {
mem: "home".to_string(),
schema: Some("default@1.0.0".parse().unwrap()),
storage: MountStorage::Folder {
path: mem_dir.clone(),
},
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: false,
migration_target: None,
}],
settings: WorkspaceSettings::default(),
},
)
.unwrap();
let binding_with = |preparation: Option<&str>| Binding {
version: BINDING_VERSION,
intent: None,
sources: vec![Source {
name: "claims".to_string(),
medium_type: MediumType::Graph,
pointer: "home".to_string(),
change_detection: None,
scope: vec![PatternEntry {
path: "*".to_string(),
mode: PatternMode::Allow,
}],
engagement: None,
preparation: preparation.map(str::to_string),
}],
reference_mems: vec![],
destination_mem: "home".to_string(),
deny_paths: vec![],
coverage_semantics: None,
rules: None,
prune: None,
operations: Operations {
build: Some(BuildOperation {
mode: BuildMode::Discovery,
trigger: IngestTrigger::Manual,
batch_size: 5,
post_actions: None,
}),
sync: None,
verify: Some(VerifyOperation {
trigger: IngestTrigger::Manual,
batch_size: 5,
adjudication_cap: 0,
full_resync_every: 0,
}),
},
};
let prepared_binding = binding_with(Some(crate::preparation::ENTITY_LOAD_BEARING));
assert!(crate::binding::validate_binding(&prepared_binding).is_ok());
crate::pipeline_store::write_binding(root, "home", "claims", &prepared_binding).unwrap();
let engine = crate::Engine::from_workspace_root(root).unwrap();
let pinned = engine
.store()
.get(&EntityId::canonical("home--pinned"))
.unwrap();
let type_def = engine
.schema_for("home")
.and_then(|s| s.get_type("assertion"))
.expect("default@1.0.0 declares assertion");
assert!(
crate::preparation::load_bearing_sections(&type_def)
.iter()
.map(|s| s.key.as_str())
.eq(["claim", "evidence"]),
"the required sections are the load-bearing set"
);
let prepared_hash = crate::preparation::entity_prepared_hash(
pinned,
Some(&type_def),
Some(crate::preparation::ENTITY_LOAD_BEARING),
)
.unwrap();
let default_hash =
crate::preparation::entity_prepared_hash(pinned, Some(&type_def), None).unwrap();
assert_ne!(prepared_hash, default_hash);
let anchor = |source: Option<&str>, hash: &str| Anchor {
artifact: "home--pinned".to_string(),
grain: AnchorGrain::Entity,
class: AnchorProvenanceClass::Anchored,
hash: Some(hash.to_string()),
source: source.map(str::to_string),
binding: None,
at_version: None,
derived_from: Vec::new(),
hash_stability: crate::anchor::AnchorHashStability::Stable,
};
std::fs::write(
mem_dir.join("holder2.md"),
"---\ntype: assertion\n---\n\n# Holder2\n\n## Claim\n\nAlso depends.\n\n\
## Evidence\n\nSee pinned.\n",
)
.unwrap();
let mut sidecar = AnchorSidecar::default();
sidecar.set("home--holder", vec![anchor(Some("claims"), &prepared_hash)]);
sidecar.set("home--holder2", vec![anchor(None, &default_hash)]);
std::fs::write(
mem_dir.join(".memstead").join("anchors.json"),
sidecar.to_bytes(),
)
.unwrap();
let states = |root: &std::path::Path| {
let engine = crate::Engine::from_workspace_root(root).unwrap();
let state_of = |holder: &str| {
engine.entity_anchors_resolved(&EntityId::canonical(holder))[0].state
};
let standalone = engine.verify_mem_anchors("home").unwrap();
(
state_of("home--holder"),
state_of("home--holder2"),
standalone,
)
};
let (prepared, plain, report) = states(root);
assert_eq!(prepared, Some(AnchorState::Resolves));
assert_eq!(plain, Some(AnchorState::Resolves));
assert_eq!((report.resolved, report.drifted), (2, 0));
write_pinned("The sky is blue.", "daylight, clear weather");
let (prepared, plain, report) = states(root);
assert_eq!(
prepared,
Some(AnchorState::Resolves),
"a comma in the notes must not break a load-bearing anchor"
);
assert_eq!(plain, Some(AnchorState::Drifted));
assert_eq!((report.resolved, report.drifted), (1, 1));
write_pinned("The sky is green.", "daylight, clear weather");
let (prepared, plain, report) = states(root);
assert_eq!(prepared, Some(AnchorState::Drifted));
assert_eq!(plain, Some(AnchorState::Drifted));
assert_eq!((report.resolved, report.drifted), (0, 2));
crate::pipeline_store::write_binding(
root,
"home",
"claims",
&binding_with(Some("pdf-to-markdown")),
)
.unwrap();
let (prepared, plain, report) = states(root);
assert_eq!(
prepared, None,
"an unknown preparation yields no observation"
);
assert_eq!(plain, Some(AnchorState::Drifted));
assert_eq!((report.unresolvable, report.drifted), (1, 1));
}
#[test]
fn shuffled_discovery_sequences_identically() {
use crate::preparation::UnitChange;
let unit = |id: &str, order: &str| DeliveredUnit {
id: id.to_string(),
order_key: order.to_string(),
change: UnitChange::Added,
disposed: false,
};
let ordered = vec![
unit("corpus/notes.md#whole", ""),
unit("corpus/b.md#2026-08-20T00:00:00", "2026-08-20T00:00:00"),
unit("corpus/a.md#2026-08-21T00:00:00", "2026-08-21T00:00:00"),
unit("corpus/a.md#2026-08-21T00:00:00.2", "2026-08-21T00:00:00"),
unit("corpus/c.md#2026-08-21T00:00:00", "2026-08-21T00:00:00"),
unit("corpus/b.md#2026-08-22T00:00:00", "2026-08-22T00:00:00"),
];
for shuffle in [
vec![5, 3, 0, 4, 1, 2],
vec![2, 1, 0, 5, 4, 3],
vec![4, 0, 5, 2, 3, 1],
] {
let mut units: Vec<DeliveredUnit> =
shuffle.iter().map(|i| ordered[*i].clone()).collect();
sequence_units(&mut units);
assert_eq!(units, ordered, "discovery order {shuffle:?} must not leak");
}
let day = "2026-08-24T00:00:00";
let expected: Vec<String> = (1..=12)
.map(|n| {
if n == 1 {
format!("journal.md#{day}")
} else {
format!("journal.md#{day}.{n}")
}
})
.collect();
let mut units: Vec<DeliveredUnit> = expected.iter().rev().map(|id| unit(id, day)).collect();
sequence_units(&mut units);
assert_eq!(
units.iter().map(|u| u.id.as_str()).collect::<Vec<_>>(),
expected.iter().map(String::as_str).collect::<Vec<_>>()
);
}
#[test]
fn dated_entries_deliver_in_a_total_order_across_first_and_change_runs() {
use crate::anchor::{
Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
};
use crate::binding::{
BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, VerifyOperation,
};
use crate::entity::EntityId;
use crate::ingest::advance::{DispositionInput, advance_baseline};
use crate::ingest::brief::render_changed_slice;
use crate::ingest::resolve::resolve_binding_run;
use crate::pipeline::{IngestTrigger, PatternMode};
use crate::preparation::{DATED_ENTRIES, UnitChange, unitize};
use crate::workspace::{
Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
};
use crate::workspace_store::WorkspaceStoreAdapter;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mem_dir = root.join("home");
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
std::fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
)
.unwrap();
std::fs::write(
mem_dir.join("holder.md"),
"---\ntype: assertion\n---\n\n# Holder\n\n## Claim\n\nHolds anchors.\n\n\
## Evidence\n\nSee corpus.\n",
)
.unwrap();
std::fs::create_dir_all(root.join(".memstead")).unwrap();
std::fs::write(
root.join(".memstead").join("workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
crate::FileWorkspaceStore::new()
.save_state(
root,
&Workspace {
mounts: vec![Mount {
mem: "home".to_string(),
schema: Some("default@1.0.0".parse().unwrap()),
storage: MountStorage::Folder {
path: mem_dir.clone(),
},
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: false,
migration_target: None,
}],
settings: WorkspaceSettings::default(),
},
)
.unwrap();
let corpus = root.join("corpus");
std::fs::create_dir_all(corpus.join("plain")).unwrap();
git(&corpus, &["init", "-q"]);
let write = |name: &str, text: &str| std::fs::write(corpus.join(name), text).unwrap();
write(
"a.md",
"2026-08-21 alpha one\nbody a1\n2026-08-23 alpha two\nbody a2\n",
);
write(
"b.md",
"2026-08-20 beta one\nbody b1\n2026-08-22 beta two\nbody b2\n",
);
write("notes.md", "undated notes\n");
write("plain/readme.txt", "plain source, file granularity\n");
git(&corpus, &["add", "."]);
git(&corpus, &["commit", "-q", "-m", "corpus"]);
let source = |name: &str, scope: &str, preparation: Option<&str>| Source {
name: name.to_string(),
medium_type: MediumType::Filesystem,
pointer: "corpus".to_string(),
change_detection: Some("git".to_string()),
scope: vec![PatternEntry {
path: scope.to_string(),
mode: PatternMode::Allow,
}],
engagement: None,
preparation: preparation.map(str::to_string),
};
let binding = Binding {
version: BINDING_VERSION,
intent: None,
sources: vec![
source("logs", "corpus/*.md", Some(DATED_ENTRIES)),
source("plain", "corpus/plain/**", None),
],
reference_mems: vec![],
destination_mem: "home".to_string(),
deny_paths: vec![],
coverage_semantics: None,
rules: None,
prune: None,
operations: Operations {
build: Some(BuildOperation {
mode: BuildMode::Discovery,
trigger: IngestTrigger::Manual,
batch_size: 3,
post_actions: None,
}),
sync: None,
verify: Some(VerifyOperation {
trigger: IngestTrigger::Manual,
batch_size: 5,
adjudication_cap: 0,
full_resync_every: 0,
}),
},
};
assert!(
crate::binding::validate_binding(&binding).is_ok(),
"{:?}",
crate::binding::validate_binding(&binding)
);
crate::pipeline_store::write_binding(root, "home", "corpus", &binding).unwrap();
let resolved = resolve_binding_run("home/corpus", &binding).unwrap();
let mut engine = crate::Engine::from_workspace_root(root).unwrap();
let cursor = compute_source_cursor(&engine, &resolved, root);
let expected: Vec<&str> = vec![
"corpus/notes.md#whole",
"corpus/b.md#2026-08-20T00:00:00",
"corpus/a.md#2026-08-21T00:00:00",
"corpus/b.md#2026-08-22T00:00:00",
"corpus/a.md#2026-08-23T00:00:00",
];
assert_eq!(
cursor.delivery.len(),
1,
"one sequence, for the prepared source only"
);
let seq = &cursor.delivery[0];
assert_eq!(
(seq.source.as_str(), seq.preparation.as_str()),
("logs", DATED_ENTRIES)
);
assert!(seq.first_run && !seq.degraded && seq.batch == 3);
assert_eq!(
seq.units.iter().map(|u| u.id.as_str()).collect::<Vec<_>>(),
expected
);
assert!(
seq.units
.iter()
.all(|u| u.change == UnitChange::Added && !u.disposed)
);
let mut expected_sorted: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
expected_sorted.sort();
assert_eq!(
cursor.union.added, expected_sorted,
"the advance gate accepts the unit ids"
);
assert!(
cursor
.reseed
.iter()
.any(|c| c.key == "home/corpus/plain#synced")
);
assert!(
cursor
.write_commands
.iter()
.any(|c| c.key == "home/corpus/logs#synced")
);
assert!(
!cursor
.union
.added
.iter()
.any(|a| a.starts_with("corpus/plain"))
);
assert_eq!(compute_source_cursor(&engine, &resolved, root), cursor);
let brief = render_changed_slice(&cursor);
assert!(
brief.contains("### Delivery sequence: `logs` (`dated-entries`)"),
"{brief}"
);
assert!(brief.contains("First delivery of this source"));
let listed: Vec<&str> = brief
.lines()
.filter(|l| l.starts_with(|c: char| c.is_ascii_digit()) && l.contains("`corpus/"))
.collect();
assert_eq!(
listed,
vec![
"1. `corpus/notes.md#whole` (new)",
"2. `corpus/b.md#2026-08-20T00:00:00` (new)",
"3. `corpus/a.md#2026-08-21T00:00:00` (new)",
],
"the batch presents the first three in order"
);
assert!(brief.contains("…and 2 more, presented in order once these are disposed"));
assert!(
!brief.contains("**Added:**"),
"unit ids never repeat in a class list: {brief}"
);
let dispositions: BTreeMap<String, DispositionInput> =
[(expected[0], "skipped"), (expected[1], "worked")]
.into_iter()
.map(|(a, d)| (a.to_string(), DispositionInput::Verdict(d.to_string())))
.collect();
let outcome = advance_baseline(&mut engine, root, &resolved, &dispositions).unwrap();
assert_eq!((outcome.pending, outcome.completed), (3, false));
let cursor = compute_source_cursor(&engine, &resolved, root);
assert!(cursor.delivery[0].units[0].disposed && cursor.delivery[0].units[1].disposed);
let brief = render_changed_slice(&cursor);
assert!(
brief.contains("3. `corpus/a.md#2026-08-21T00:00:00` (new)"),
"{brief}"
);
assert!(
!brief.contains("1. `corpus/notes.md#whole`"),
"disposed units are not re-presented"
);
assert!(brief.contains("2 units of this sequence already disposed"));
let a21_text = std::fs::read_to_string(corpus.join("a.md")).unwrap();
let a21_unit = unitize(DATED_ENTRIES, &a21_text)
.unwrap()
.into_iter()
.find(|u| u.key == "2026-08-21T00:00:00")
.unwrap();
let anchor = |artifact: &str, grain: AnchorGrain, hash: &str| Anchor {
artifact: artifact.to_string(),
grain,
class: AnchorProvenanceClass::Anchored,
hash: Some(hash.to_string()),
source: Some("logs".to_string()),
binding: None,
at_version: None,
derived_from: Vec::new(),
hash_stability: crate::anchor::AnchorHashStability::Stable,
};
let b_file_hash =
crate::anchor::prepared_content_hash(&std::fs::read(corpus.join("b.md")).unwrap());
let mut sidecar = AnchorSidecar::default();
sidecar.set(
"home--holder",
vec![
anchor(expected[2], AnchorGrain::Span, &a21_unit.hash),
anchor("corpus/b.md", AnchorGrain::File, &b_file_hash),
],
);
std::fs::write(
mem_dir.join(".memstead").join("anchors.json"),
sidecar.to_bytes(),
)
.unwrap();
let mut engine = crate::Engine::from_workspace_root(root).unwrap();
let outcome = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
assert_eq!(
outcome.pending, 2,
"the unit anchor auto-disposed its unit, the file anchor nothing"
);
assert!(outcome.remainder.added.contains(&expected[3].to_string()));
assert!(outcome.remainder.added.contains(&expected[4].to_string()));
let rest: BTreeMap<String, DispositionInput> = [expected[3], expected[4]]
.into_iter()
.map(|a| {
(
a.to_string(),
DispositionInput::Verdict("worked".to_string()),
)
})
.collect();
let outcome = advance_baseline(&mut engine, root, &resolved, &rest).unwrap();
assert!(outcome.completed, "{outcome:?}");
assert!(
outcome
.tokens_written
.contains(&"home/corpus/logs#synced".to_string())
);
write(
"a.md",
"2026-08-21 alpha one\nbody a1\n2026-08-23 alpha two\nbody a2\n2026-08-19 alpha zero\nbody a0\n",
);
write("b.md", "2026-08-22 beta two\nbody b2, revised\n");
git(&corpus, &["add", "."]);
git(&corpus, &["commit", "-q", "-m", "grow, edit, remove"]);
let engine = crate::Engine::from_workspace_root(root).unwrap();
let cursor = compute_source_cursor(&engine, &resolved, root);
let seq = &cursor.delivery[0];
assert!(!seq.first_run && !seq.degraded);
assert_eq!(
seq.units
.iter()
.map(|u| (u.id.as_str(), u.change))
.collect::<Vec<_>>(),
vec![
("corpus/a.md#2026-08-19T00:00:00", UnitChange::Added),
("corpus/b.md#2026-08-20T00:00:00", UnitChange::Deleted),
("corpus/b.md#2026-08-22T00:00:00", UnitChange::Modified),
]
);
let brief = render_changed_slice(&cursor);
assert!(brief.contains("The units that changed since the last pass"));
assert!(
brief.contains("1. `corpus/a.md#2026-08-19T00:00:00` (new)"),
"{brief}"
);
assert!(brief.contains("3. `corpus/b.md#2026-08-22T00:00:00` (changed)"));
let b22_old_text = "2026-08-22 beta two\nbody b2\n";
let b22_old = unitize(DATED_ENTRIES, b22_old_text).unwrap()[0]
.hash
.clone();
let mut sidecar = AnchorSidecar::default();
sidecar.set(
"home--holder",
vec![
anchor(expected[2], AnchorGrain::Span, &a21_unit.hash),
anchor(expected[1], AnchorGrain::Span, "deadbeefdeadbeef"),
anchor(expected[3], AnchorGrain::Span, &b22_old),
],
);
std::fs::write(
mem_dir.join(".memstead").join("anchors.json"),
sidecar.to_bytes(),
)
.unwrap();
let engine = crate::Engine::from_workspace_root(root).unwrap();
let resolved_anchors = engine.entity_anchors_resolved(&EntityId::canonical("home--holder"));
let state_of = |artifact: &str| {
resolved_anchors
.iter()
.find(|r| r.anchor.artifact == artifact)
.unwrap()
.state
};
assert_eq!(
state_of(expected[2]),
Some(AnchorState::Resolves),
"unit unchanged, file changed"
);
assert_eq!(
state_of(expected[1]),
Some(AnchorState::Orphaned),
"unit removed"
);
assert_eq!(
state_of(expected[3]),
Some(AnchorState::Drifted),
"unit edited"
);
}
#[test]
fn code_map_anchors_drift_on_interface_changes_only() {
use crate::anchor::{
Anchor, AnchorGrain, AnchorInput, AnchorProvenanceClass, AnchorSidecar, AnchorState,
};
use crate::binding::{
BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, VerifyOperation,
};
use crate::entity::EntityId;
use crate::ingest::resolve::Source;
use crate::pipeline::{IngestTrigger, PatternMode};
use crate::preparation::{CODE_MAP, code_map_digest, code_map_tree_digest};
use crate::workspace::{
Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
};
use crate::workspace_store::WorkspaceStoreAdapter;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mem_dir = root.join("home");
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
std::fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
)
.unwrap();
std::fs::write(
mem_dir.join("holder.md"),
"---\ntype: assertion\n---\n\n# Holder\n\n## Claim\n\nHolds anchors.\n\n\
## Evidence\n\nSee code.\n",
)
.unwrap();
std::fs::create_dir_all(root.join(".memstead")).unwrap();
std::fs::write(
root.join(".memstead").join("workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
crate::FileWorkspaceStore::new()
.save_state(
root,
&Workspace {
mounts: vec![Mount {
mem: "home".to_string(),
schema: Some("default@1.0.0".parse().unwrap()),
storage: MountStorage::Folder {
path: mem_dir.clone(),
},
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: false,
migration_target: None,
}],
settings: WorkspaceSettings::default(),
},
)
.unwrap();
let corpus = root.join("corpus");
std::fs::create_dir_all(corpus.join("src")).unwrap();
std::fs::create_dir_all(corpus.join("plain")).unwrap();
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";
const B: &str = "// the limit\nexport const LIMIT = 10\n";
let write = |rel: &str, text: &str| std::fs::write(corpus.join(rel), text).unwrap();
write("src/a.js", A);
write("src/b.js", B);
write("plain/notes.js", "export const N = 1\n");
let source = |name: &str, scope: &str, preparation: Option<&str>| Source {
name: name.to_string(),
medium_type: MediumType::Codebase,
pointer: "corpus".to_string(),
change_detection: Some("none".to_string()),
scope: vec![PatternEntry {
path: scope.to_string(),
mode: PatternMode::Allow,
}],
engagement: None,
preparation: preparation.map(str::to_string),
};
let binding = Binding {
version: BINDING_VERSION,
intent: None,
sources: vec![
source("code", "corpus/src/**/*.js", Some(CODE_MAP)),
source("plain", "corpus/plain/**", None),
],
reference_mems: vec![],
destination_mem: "home".to_string(),
deny_paths: vec![],
coverage_semantics: None,
rules: None,
prune: None,
operations: Operations {
build: Some(BuildOperation {
mode: BuildMode::Discovery,
trigger: IngestTrigger::Manual,
batch_size: 5,
post_actions: None,
}),
sync: None,
verify: Some(VerifyOperation {
trigger: IngestTrigger::Manual,
batch_size: 5,
adjudication_cap: 0,
full_resync_every: 0,
}),
},
};
assert!(crate::binding::validate_binding(&binding).is_ok());
crate::pipeline_store::write_binding(root, "home", "code", &binding).unwrap();
let digest_hash = |rel: &str, text: &str| {
crate::anchor::prepared_content_hash(code_map_digest(rel, text).as_bytes())
};
let tree_hash = |files: &[(&str, &str)]| {
let owned: Vec<(String, String)> = files
.iter()
.map(|(p, t)| (p.to_string(), t.to_string()))
.collect();
crate::anchor::prepared_content_hash(code_map_tree_digest(&owned).as_bytes())
};
let anchor = |artifact: &str, grain: AnchorGrain, source: &str, hash: &str| Anchor {
artifact: artifact.to_string(),
grain,
class: AnchorProvenanceClass::Anchored,
hash: Some(hash.to_string()),
source: Some(source.to_string()),
binding: None,
at_version: None,
derived_from: Vec::new(),
hash_stability: crate::anchor::AnchorHashStability::Stable,
};
let plain_raw = crate::anchor::prepared_content_hash(b"export const N = 1\n");
let mut sidecar = AnchorSidecar::default();
sidecar.set(
"home--holder",
vec![
anchor(
"corpus/src/a.js",
AnchorGrain::File,
"code",
&digest_hash("corpus/src/a.js", A),
),
anchor(
"corpus/src/a.js#L4-L7",
AnchorGrain::Span,
"code",
&digest_hash("corpus/src/a.js", A),
),
anchor(
"corpus/src",
AnchorGrain::Tree,
"code",
&tree_hash(&[("corpus/src/a.js", A), ("corpus/src/b.js", B)]),
),
anchor(
"corpus/plain/notes.js",
AnchorGrain::File,
"plain",
&plain_raw,
),
anchor(
"corpus/plain",
AnchorGrain::Tree,
"plain",
"0000000000000000",
),
],
);
std::fs::write(
mem_dir.join(".memstead").join("anchors.json"),
sidecar.to_bytes(),
)
.unwrap();
let states = |root: &std::path::Path| {
let engine = crate::Engine::from_workspace_root(root).unwrap();
let resolved = engine.entity_anchors_resolved(&EntityId::canonical("home--holder"));
let of = |artifact: &str| {
let r = resolved
.iter()
.find(|r| r.anchor.artifact == artifact)
.unwrap_or_else(|| panic!("no anchor {artifact}"));
(r.state, r.observed_hash.clone())
};
(
of("corpus/src/a.js").0,
of("corpus/src/a.js#L4-L7").0,
of("corpus/src").0,
of("corpus/plain/notes.js").0,
of("corpus/plain"),
engine.verify_mem_anchors("home").unwrap(),
)
};
let (file, span, tree, plain_file, plain_tree, report) = states(root);
assert_eq!(
(file, span, tree, plain_file),
(
Some(AnchorState::Resolves),
Some(AnchorState::Resolves),
Some(AnchorState::Resolves),
Some(AnchorState::Resolves)
)
);
assert_eq!(plain_tree, (Some(AnchorState::Recheck), None));
assert_eq!((report.resolved, report.drifted, report.recheck), (4, 0, 1));
write(
"src/a.js",
"// auth (rewritten comment)\nimport axios from 'axios'\n\nexport async function login(user, password) {\n return await axios.post('/session', { user, password })\n}\n",
);
let (file, span, tree, _, _, report) = states(root);
assert_eq!(
(file, span, tree),
(
Some(AnchorState::Resolves),
Some(AnchorState::Resolves),
Some(AnchorState::Resolves)
),
"a body edit must not drift a code-map anchor"
);
assert_eq!(report.drifted, 0);
write(
"src/a.js",
"// auth\nimport axios from 'axios'\n\nexport async function login(user, password, remember) {\n return axios.post('/login', { user, password, remember })\n}\n",
);
let (file, span, tree, plain_file, _, report) = states(root);
assert_eq!(
(file, span, tree),
(
Some(AnchorState::Drifted),
Some(AnchorState::Drifted),
Some(AnchorState::Drifted)
)
);
assert_eq!(plain_file, Some(AnchorState::Resolves));
assert_eq!(report.drifted, 3);
write("src/a.js", A);
write("src/c.js", "export const C = 1\n");
let (file, span, tree, _, _, _) = states(root);
assert_eq!(
(file, span),
(Some(AnchorState::Resolves), Some(AnchorState::Resolves))
);
assert_eq!(
tree,
Some(AnchorState::Drifted),
"a file joining the tree changes its code map"
);
write("plain/notes.js", "export const N = 1 // note\n");
let (_, _, _, plain_file, _, _) = states(root);
assert_eq!(plain_file, Some(AnchorState::Drifted));
let engine = crate::Engine::from_workspace_root(root).unwrap();
let input = AnchorInput {
artifact: Some("corpus/src/b.js".to_string()),
grain: Some("file".to_string()),
class: Some("anchored".to_string()),
source: Some("code".to_string()),
content: Some(B.to_string()),
..Default::default()
};
let validated = engine.validate_anchor_inputs("home", &[input]).unwrap();
assert_eq!(
validated[0].hash.as_deref(),
Some(digest_hash("corpus/src/b.js", B).as_str())
);
assert_ne!(
validated[0].hash.as_deref(),
Some(crate::anchor::prepared_content_hash(B.as_bytes()).as_str())
);
let plain_input = AnchorInput {
artifact: Some("corpus/plain/notes.js".to_string()),
grain: Some("file".to_string()),
class: Some("anchored".to_string()),
source: Some("plain".to_string()),
content: Some("export const N = 1\n".to_string()),
..Default::default()
};
let validated = engine
.validate_anchor_inputs("home", &[plain_input])
.unwrap();
assert_eq!(
validated[0].hash.as_deref(),
Some(plain_raw.as_str()),
"no preparation: the raw canonicalization, as before"
);
}
#[test]
fn graph_scoping_changes_only_the_unscoped_arm() {
use crate::binding::BuildMode;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mem_dir = root.join("srcmem");
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
std::fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
)
.unwrap();
std::fs::write(
mem_dir.join("one.md"),
"---\ntype: decision\n---\n\n# One\n\n## Decision\n\nBody.\n",
)
.unwrap();
std::fs::create_dir_all(root.join(".memstead")).unwrap();
std::fs::write(
root.join(".memstead").join("workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
{
use crate::workspace::{
Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
};
use crate::workspace_store::WorkspaceStoreAdapter;
crate::FileWorkspaceStore::new()
.save_state(
root,
&Workspace {
mounts: vec![Mount {
mem: "srcmem".to_string(),
schema: Some("default@1.0.0".parse().unwrap()),
storage: MountStorage::Folder {
path: mem_dir.clone(),
},
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: false,
migration_target: None,
}],
settings: WorkspaceSettings::default(),
},
)
.unwrap();
}
let engine = crate::Engine::from_workspace_root(root).unwrap();
let resolved_with = |scope: Vec<crate::pipeline::PatternEntry>| ResolvedIngest {
name: "srcmem/p".to_string(),
mode: BuildMode::Discovery,
trigger: crate::pipeline::IngestTrigger::Manual,
batch_size: 20,
deny_paths: Vec::new(),
projection_ref: "srcmem/p".to_string(),
projection_mem: "srcmem".to_string(),
projection_name: "p".to_string(),
intent: None,
sources: vec![ResolvedSource::Primary(Source {
name: "g".to_string(),
medium_type: MediumType::Graph,
pointer: "srcmem".to_string(),
change_detection: None,
scope,
engagement: None,
preparation: None,
})],
destination_mem: "srcmem".to_string(),
rules: None,
post_actions: None,
};
let scoped = compute_source_cursor(
&engine,
&resolved_with(vec![crate::pipeline::PatternEntry {
path: "*".to_string(),
mode: PatternMode::Allow,
}]),
root,
);
let unscoped = compute_source_cursor(&engine, &resolved_with(Vec::new()), root);
let reason_of = |c: &SourceCursor| c.no_signal.first().map(|n| n.reason);
assert_eq!(
reason_of(&scoped),
Some(NoSignalReason::GraphSnapshotMissing),
"a scoped graph facet still routes to the graph strategy and reports \
its own no-signal reason — the change-detection half is untouched"
);
assert_eq!(
reason_of(&unscoped),
Some(NoSignalReason::Unscoped),
"an unscoped graph facet refuses like every other medium's, instead of \
silently proceeding"
);
}
#[test]
fn git_medium_enumerates_through_the_path_walk() {
let ws = tempfile::tempdir().unwrap();
let root = ws.path();
std::fs::write(root.join("a.rs"), "").unwrap();
std::fs::write(root.join("b.rs"), "").unwrap();
let source = |medium: MediumType| Source {
name: "s".to_string(),
medium_type: medium,
pointer: ".".to_string(),
change_detection: None,
scope: vec![crate::pipeline::PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
}],
engagement: None,
preparation: None,
};
let want = vec!["a.rs".to_string(), "b.rs".to_string()];
for medium in [
MediumType::Codebase,
MediumType::Filesystem,
MediumType::Git,
] {
assert_eq!(
enumerate_facet_files(&source(medium), &[], root),
want,
"{medium:?} walks the file tree — every medium the matrix marks \
enumerable with a path namespace must actually enumerate"
);
assert!(
crate::binding::medium_capabilities(medium).enumerable,
"{medium:?} claims enumerability, and now delivers it"
);
}
}
#[test]
fn a_narrowing_selector_bounds_the_changed_slice_too() {
use crate::workspace::{
Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
};
use crate::workspace_store::WorkspaceStoreAdapter;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mem_dir = root.join("srcmem");
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
std::fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
)
.unwrap();
let write = |slug: &str, ty: &str| {
std::fs::write(
mem_dir.join(format!("{slug}.md")),
format!("---\ntype: {ty}\n---\n\n# {slug}\n\n## Decision\n\nBody.\n"),
)
.unwrap();
};
write("kept", "decision");
write("other", "memo");
std::fs::create_dir_all(root.join(".memstead")).unwrap();
std::fs::write(
root.join(".memstead").join("workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
crate::FileWorkspaceStore::new()
.save_state(
root,
&Workspace {
mounts: vec![Mount {
mem: "srcmem".to_string(),
schema: Some("default@1.0.0".parse().unwrap()),
storage: MountStorage::Folder {
path: mem_dir.clone(),
},
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: false,
migration_target: None,
}],
settings: WorkspaceSettings::default(),
},
)
.unwrap();
let engine = crate::Engine::from_workspace_root(root).unwrap();
let source = Source {
name: "g".to_string(),
medium_type: MediumType::Graph,
pointer: "srcmem".to_string(),
change_detection: None,
scope: vec![crate::pipeline::PatternEntry {
path: "type:decision".to_string(),
mode: PatternMode::Allow,
}],
engagement: None,
preparation: None,
};
let mut slice = Slice {
added: vec!["srcmem--other".to_string()],
modified: vec!["srcmem--kept".to_string(), "srcmem--other".to_string()],
deleted: vec!["srcmem--vanished".to_string()],
};
filter_graph_slice_to_scope(&engine, &source, &mut slice);
assert_eq!(
slice.modified,
vec!["srcmem--kept".to_string()],
"the out-of-scope memo is dropped from the slice the brief presents"
);
assert!(
slice.added.is_empty(),
"an added out-of-scope entity is out of scope too"
);
assert_eq!(
slice.deleted,
vec!["srcmem--vanished".to_string()],
"a DELETED entity is kept even though its type can no longer be \
read — a deletion that cannot be classified must be reported, \
never silently dropped"
);
let mut wide = Slice {
added: Vec::new(),
modified: vec!["srcmem--kept".to_string(), "srcmem--other".to_string()],
deleted: Vec::new(),
};
let mut all = source.clone();
all.scope = vec![crate::pipeline::PatternEntry {
path: "*".to_string(),
mode: PatternMode::Allow,
}];
filter_graph_slice_to_scope(&engine, &all, &mut wide);
assert_eq!(wide.modified.len(), 2, "`*` selects the whole mem");
}
#[test]
fn entity_selector_grammar_is_closed() {
use super::EntitySelector;
assert_eq!(parse_entity_selector("*"), Some(EntitySelector::All));
assert_eq!(
parse_entity_selector("type:decision"),
Some(EntitySelector::Type("decision".to_string()))
);
assert_eq!(
parse_entity_selector("id:engine--*"),
Some(EntitySelector::Id("engine--*".to_string()))
);
assert_eq!(parse_entity_selector("**/*"), None);
assert_eq!(parse_entity_selector("src/**"), None);
assert_eq!(parse_entity_selector("type:"), None);
assert_eq!(parse_entity_selector("id:"), None);
assert_eq!(parse_entity_selector(""), None);
}
#[test]
fn mtime_driver_reseeds_then_diffs_precisely() {
let ws = tempfile::tempdir().unwrap();
let root = ws.path();
let cache = root.join(".memstead.cache").join("ingest");
std::fs::write(root.join("a.rs"), "one").unwrap();
std::fs::write(root.join("gone.rs"), "bye").unwrap();
let source = primary(vec![PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
}]);
let token = match compute_mtime_slice(&source, "ing", &[], root, &cache, None) {
SliceOutcome::Reseed { token } => token,
other => panic!("expected Reseed, got {other:?}"),
};
std::fs::write(root.join("a.rs"), "one-longer").unwrap();
std::fs::remove_file(root.join("gone.rs")).unwrap();
std::fs::write(root.join("new.rs"), "x").unwrap();
match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&token)) {
SliceOutcome::Changed {
slice, degraded, ..
} => {
assert!(
!degraded,
"memo present → precise, not a degraded full scan"
);
assert_eq!(slice.added, vec!["new.rs"]);
assert_eq!(slice.modified, vec!["a.rs"]);
assert_eq!(
slice.deleted,
vec!["gone.rs"],
"deletions come from the memo"
);
}
other => panic!("expected Changed, got {other:?}"),
}
let stale = super::super::change_detection::serialize_digest_token(
&super::super::change_detection::digest_stat_map(&stat_map_for(&["absent.rs"])),
);
match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&stale)) {
SliceOutcome::Changed { degraded, .. } => assert!(degraded, "memo miss → degraded"),
other => panic!("expected degraded Changed, got {other:?}"),
}
}
fn head_sha(repo: &Path) -> String {
String::from_utf8(
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string()
}
fn slice_contains(slice: &Slice, path: &str) -> bool {
let p = path.to_string();
slice.added.contains(&p) || slice.modified.contains(&p) || slice.deleted.contains(&p)
}
fn mtime_token(source: &Source, deny: &[String], root: &Path) -> String {
let files = enumerate_facet_files(source, deny, root);
serialize_digest_token(&digest_stat_map(&compute_stat_map(&files, root)))
}
#[test]
fn deny_paths_excluded_from_every_strategy_and_token() {
use crate::binding::BuildMode;
use crate::ingest::refinement::next_batch;
use crate::pipeline::IngestTrigger;
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
let cache = root.join(".memstead.cache").join("ingest");
git(root, &["init", "-q"]);
std::fs::write(root.join("keep.rs"), "one").unwrap();
std::fs::write(root.join("denied.rs"), "secret-one").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "base"]);
let baseline = head_sha(root);
std::fs::write(root.join("keep.rs"), "two").unwrap();
std::fs::write(root.join("denied.rs"), "secret-two").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "move"]);
let source = primary(vec![PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
}]);
let deny = vec!["denied.rs".to_string()];
match compute_git_slice(&source, &deny, root, Some(&baseline)) {
SliceOutcome::Changed { slice, .. } => {
assert_eq!(slice.modified, vec!["keep.rs"]);
assert!(!slice_contains(&slice, "denied.rs"), "git deny leak");
}
other => panic!("git: expected Changed, got {other:?}"),
}
match compute_git_slice(&source, &[], root, Some(&baseline)) {
SliceOutcome::Changed { slice, .. } => {
assert!(
slice_contains(&slice, "denied.rs"),
"un-denied, denied.rs is a genuine git change"
);
}
other => panic!("git(no-deny): expected Changed, got {other:?}"),
}
assert_eq!(enumerate_facet_files(&source, &deny, root), vec!["keep.rs"]);
assert!(
enumerate_facet_files(&source, &[], root).contains(&"denied.rs".to_string()),
"un-denied, denied.rs is enumerated"
);
let token = match compute_mtime_slice(&source, "ing", &deny, root, &cache, None) {
SliceOutcome::Reseed { token } => token,
other => panic!("mtime reseed expected, got {other:?}"),
};
std::fs::write(root.join("keep.rs"), "three-longer").unwrap();
std::fs::write(root.join("denied.rs"), "secret-three-longer").unwrap();
match compute_mtime_slice(&source, "ing", &deny, root, &cache, Some(&token)) {
SliceOutcome::Changed { slice, .. } => {
assert_eq!(slice.modified, vec!["keep.rs"]);
assert!(!slice_contains(&slice, "denied.rs"), "mtime deny leak");
}
other => panic!("mtime: expected Changed, got {other:?}"),
}
let token_present = mtime_token(&source, &deny, root);
std::fs::remove_file(root.join("denied.rs")).unwrap();
let token_absent = mtime_token(&source, &deny, root);
assert_eq!(
token_present, token_absent,
"denied.rs must not influence the mtime digest / source_moved token"
);
std::fs::write(root.join("denied.rs"), "secret-restored").unwrap();
let resolved = ResolvedIngest {
name: "ing".to_string(),
mode: BuildMode::Discovery,
trigger: IngestTrigger::Loop,
batch_size: 50,
deny_paths: deny.clone(),
projection_ref: "m/p".to_string(),
projection_mem: "m".to_string(),
projection_name: "p".to_string(),
intent: None,
sources: vec![ResolvedSource::Primary(source.clone())],
destination_mem: "m".to_string(),
rules: None,
post_actions: None,
};
let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
let batch = next_batch(&engine, &resolved, root, &cache, 20).unwrap();
assert!(
batch.files.contains(&"keep.rs".to_string()),
"keep.rs batched"
);
assert!(
!batch.files.contains(&"denied.rs".to_string()),
"denied.rs must never enter a refinement batch"
);
}
#[test]
fn unscoped_facet_refuses_uniformly_and_empty_deny_is_distinct() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
let cache = root.join(".memstead.cache").join("ingest");
git(root, &["init", "-q"]);
std::fs::write(root.join("a.rs"), "one").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "base"]);
let baseline = head_sha(root);
std::fs::write(root.join("a.rs"), "two").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "move"]);
let unscoped = primary(vec![PatternEntry {
path: "target/**".to_string(),
mode: PatternMode::Deny,
}]);
assert_eq!(
compute_git_slice(&unscoped, &[], root, Some(&baseline)),
SliceOutcome::NoSignal {
reason: NoSignalReason::Unscoped
},
"git refuses an unscoped facet"
);
assert_eq!(
compute_mtime_slice(&unscoped, "ing", &[], root, &cache, None),
SliceOutcome::NoSignal {
reason: NoSignalReason::Unscoped
},
"mtime refuses an unscoped facet identically"
);
let empty_scope = primary(vec![]);
assert_eq!(
compute_git_slice(&empty_scope, &[], root, Some(&baseline)),
SliceOutcome::NoSignal {
reason: NoSignalReason::Unscoped
}
);
let scoped = primary(vec![PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
}]);
assert!(
matches!(
compute_git_slice(&scoped, &[], root, Some(&baseline)),
SliceOutcome::Changed { .. }
),
"scoped facet + empty deny_paths → normal git slice, not a refusal"
);
assert!(
matches!(
compute_mtime_slice(&scoped, "ing", &[], root, &cache, None),
SliceOutcome::Reseed { .. }
),
"scoped facet + empty deny_paths → normal mtime reseed, not a refusal"
);
}
#[test]
fn unscoped_facet_emits_no_refinement_batch() {
use crate::binding::BuildMode;
use crate::ingest::refinement::next_batch;
use crate::pipeline::IngestTrigger;
let ws = tempfile::tempdir().unwrap();
let root = ws.path();
let cache = root.join(".memstead.cache").join("ingest");
std::fs::write(root.join("a.rs"), "x").unwrap();
let resolved = ResolvedIngest {
name: "ing".to_string(),
mode: BuildMode::Discovery,
trigger: IngestTrigger::Loop,
batch_size: 50,
deny_paths: vec![],
projection_ref: "m/p".to_string(),
projection_mem: "m".to_string(),
projection_name: "p".to_string(),
intent: None,
sources: vec![ResolvedSource::Primary(primary(vec![]))],
destination_mem: "m".to_string(),
rules: None,
post_actions: None,
};
assert!(
next_batch(
&crate::Engine::from_mounts(Vec::new()).unwrap(),
&resolved,
root,
&cache,
20
)
.is_none(),
"an all-unscoped ingest emits no refinement batch"
);
}
#[test]
fn compute_source_cursor_notes_no_signal_reasons() {
use crate::binding::BuildMode;
use crate::pipeline::IngestTrigger;
let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
let ws = tempfile::tempdir().unwrap();
let root = ws.path();
std::fs::write(root.join("a.rs"), "x").unwrap();
let allow_rs = || {
vec![PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
}]
};
let src = |facet: &str, declared: &str, scope: Vec<PatternEntry>| {
ResolvedSource::Primary(Source {
name: facet.to_string(),
medium_type: MediumType::Filesystem,
pointer: String::new(),
change_detection: Some(declared.to_string()),
scope,
engagement: None,
preparation: None,
})
};
let resolved = ResolvedIngest {
name: "ing".to_string(),
mode: BuildMode::Discovery,
trigger: IngestTrigger::Loop,
batch_size: 20,
deny_paths: vec![],
projection_ref: "m/p".to_string(),
projection_mem: "m".to_string(),
projection_name: "p".to_string(),
intent: None,
sources: vec![
src("plan", "none", allow_rs()),
src("blind", "mtime", vec![]),
src("watched", "mtime", allow_rs()),
],
destination_mem: "m".to_string(),
rules: None,
post_actions: None,
};
let cursor = compute_source_cursor(&engine, &resolved, root);
let reasons: BTreeMap<&str, NoSignalReason> = cursor
.no_signal
.iter()
.map(|n| (n.source.as_str(), n.reason))
.collect();
assert_eq!(reasons.get("plan"), Some(&NoSignalReason::DetectionNone));
assert_eq!(reasons.get("blind"), Some(&NoSignalReason::Unscoped));
assert!(
!reasons.contains_key("watched"),
"a first-seen (reseed) source is not a no-signal note"
);
assert_eq!(cursor.no_signal.len(), 2);
assert!(cursor.reseed.iter().any(|c| c.key == "ing/watched#synced"));
let out = crate::ingest::brief::render_changed_slice(&cursor);
assert!(out.contains("- `plan`: `signal:none`"));
assert!(out.contains("- `blind`: unscoped facet"));
}
fn stat_map_for(paths: &[&str]) -> super::super::change_detection::StatMap {
paths
.iter()
.map(|p| {
(
(*p).to_string(),
super::super::change_detection::StatEntry { mtime: 1, size: 1 },
)
})
.collect()
}
#[test]
fn engine_state_never_enumerates_even_when_allowed() {
let ws = tempfile::tempdir().unwrap();
let root = ws.path();
for rel in [
".memstead/state/findings/muehle/f.json",
".memstead/projections/muehle/f.json",
".memstead.cache/ingest/source-cursor/muehle/f/f.json",
"custom-repo/README.md",
"Allgemein/Protokoll.md",
"Allgemein/Vertrag.md",
] {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "x").unwrap();
}
std::fs::write(
root.join(".memstead/workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
std::fs::write(
root.join(".memstead/state/mounts.json"),
serde_json::json!({
"format": "memstead-mounts-3",
"mounts": [{
"mem": "muehle",
"schema": "default@1.0.0",
"storage": {
"type": "git-branch",
"gitdir": "custom-repo/.git",
"branch": "refs/heads/muehle"
},
"capability": "write",
"lifecycle": "eager",
"cross_linkable": true
}]
})
.to_string(),
)
.unwrap();
let source = primary(vec![
PatternEntry {
path: "**/*".to_string(),
mode: PatternMode::Allow,
},
PatternEntry {
path: ".memstead/**".to_string(),
mode: PatternMode::Allow,
},
PatternEntry {
path: "custom-repo/**".to_string(),
mode: PatternMode::Allow,
},
]);
let got = enumerate_facet_files(&source, &[], root);
assert_eq!(
got,
vec!["Allgemein/Protokoll.md", "Allgemein/Vertrag.md"],
"only source artifacts may enter the denominator"
);
}
#[test]
fn git_slice_excludes_engine_state() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
git(root, &["init", "-q"]);
std::fs::write(
root.join("workspace.rs"), "x",
)
.unwrap();
std::fs::create_dir_all(root.join(".memstead/state")).unwrap();
std::fs::write(
root.join(".memstead/workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
std::fs::write(
root.join(".memstead/state/mounts.json"),
serde_json::json!({
"format": "memstead-mounts-3",
"mounts": [{
"mem": "muehle",
"schema": "default@1.0.0",
"storage": {
"type": "git-branch",
"gitdir": "custom-repo/.git",
"branch": "refs/heads/muehle"
},
"capability": "write",
"lifecycle": "eager",
"cross_linkable": true
}]
})
.to_string(),
)
.unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "base"]);
let baseline = String::from_utf8(
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
std::fs::write(root.join("real.md"), "signal").unwrap();
std::fs::write(root.join(".memstead/state/findings.json"), "self").unwrap();
std::fs::create_dir_all(root.join("custom-repo")).unwrap();
std::fs::write(root.join("custom-repo/README.md"), "repo").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "move"]);
let source = primary(vec![PatternEntry {
path: "**/*".to_string(),
mode: PatternMode::Allow,
}]);
match compute_git_slice(&source, &[], root, Some(&baseline)) {
SliceOutcome::Changed { slice, .. } => {
assert_eq!(
slice.added,
vec!["real.md"],
"engine state leaked: {slice:?}"
);
assert!(slice.modified.is_empty(), "{slice:?}");
}
other => panic!("expected Changed, got {other:?}"),
}
}
#[test]
fn dead_deny_lint_exempts_scaffold_defaults_but_not_user_typos() {
use crate::binding::{BuildMode, DEFAULT_SCAFFOLD_DENY_PATHS};
use crate::pipeline::IngestTrigger;
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
git(root, &["init", "-q"]);
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/lib.rs"), "code").unwrap();
let mut deny_paths: Vec<String> = DEFAULT_SCAFFOLD_DENY_PATHS
.iter()
.map(|s| s.to_string())
.collect();
deny_paths.push("typo/**".to_string()); deny_paths.push("src/**".to_string());
let resolved = ResolvedIngest {
name: "ing".to_string(),
mode: BuildMode::Discovery,
trigger: IngestTrigger::Loop,
batch_size: 20,
deny_paths,
projection_ref: "m/p".to_string(),
projection_mem: "m".to_string(),
projection_name: "p".to_string(),
intent: None,
sources: vec![ResolvedSource::Primary(primary(vec![PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
}]))],
destination_mem: "m".to_string(),
rules: None,
post_actions: None,
};
let dead = dead_deny_entries(&resolved, root);
assert_eq!(
dead,
vec!["typo/**".to_string()],
"only the user typo is flagged — scaffold defaults and matching \
entries stay silent"
);
}
}