1use std::collections::{BTreeMap, BTreeSet};
71use std::path::{Component, Path, PathBuf};
72use std::process::Command;
73
74use globset::{Glob, GlobSet, GlobSetBuilder};
75
76use crate::Engine;
77use crate::pipeline::{MediumType, PatternMode};
78
79use super::brief::{DeliveredUnit, DeliverySequence, NoSignalNote, SourceCursor, SyncCommand};
80use super::change_detection::{
81 StatMap, compute_stat_map, digest_stat_map, parse_digest_token, serialize_digest_token,
82};
83use super::resolve::{
84 ChangeStrategy, ResolvedIngest, ResolvedSource, find_git_root, resolve_change_strategy,
85};
86use super::slice::{
87 NoSignalReason, Slice, SliceOutcome, graph_slice_outcome, is_git_token, mtime_slice_outcome,
88};
89use crate::pipeline::Source;
90
91pub(super) fn normalize_lexical(path: &Path) -> PathBuf {
95 let mut out: Vec<Component> = Vec::new();
96 for comp in path.components() {
97 match comp {
98 Component::CurDir => {}
99 Component::ParentDir => match out.last() {
100 Some(Component::Normal(_)) => {
101 out.pop();
102 }
103 Some(Component::RootDir | Component::Prefix(_)) => {}
104 _ => out.push(comp),
105 },
106 other => out.push(other),
107 }
108 }
109 out.iter().collect()
110}
111
112pub(super) fn relative_path(from: &Path, to: &Path) -> PathBuf {
115 let from = normalize_lexical(from);
116 let to = normalize_lexical(to);
117 let from_comps: Vec<Component> = from.components().collect();
118 let to_comps: Vec<Component> = to.components().collect();
119 let mut common = 0;
120 while common < from_comps.len()
121 && common < to_comps.len()
122 && from_comps[common] == to_comps[common]
123 {
124 common += 1;
125 }
126 let mut result = PathBuf::new();
127 for _ in common..from_comps.len() {
128 result.push("..");
129 }
130 for comp in &to_comps[common..] {
131 result.push(comp.as_os_str());
132 }
133 result
134}
135
136pub fn medium_base(pointer: &str, workspace_root: &Path) -> PathBuf {
141 if pointer.is_empty() {
142 workspace_root.to_path_buf()
143 } else {
144 normalize_lexical(&workspace_root.join(pointer))
145 }
146}
147
148pub fn relative_to(from: &Path, to: &Path) -> PathBuf {
153 relative_path(from, to)
154}
155
156pub fn out_of_root_layout_warning(
168 pointer: &str,
169 workspace_root: &Path,
170 medium_type: crate::pipeline::MediumType,
171) -> Option<String> {
172 use crate::pipeline::MediumType;
173 if !matches!(medium_type, MediumType::Codebase | MediumType::Filesystem) {
174 return None;
175 }
176 let base = medium_base(pointer, workspace_root);
177 let canon_base = std::fs::canonicalize(&base).unwrap_or(base);
181 let canon_root =
182 std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
183 if canon_base.starts_with(&canon_root) {
184 return None;
185 }
186 Some(format!(
187 "medium base '{}' resolves outside the workspace root '{}': supported — \
188 enumeration, change detection, and anchor resolution all work on this shape — \
189 but artifact ids render as workspace-relative '../…' chains and the \
190 workspace-to-source relative layout must stay fixed (moving either side \
191 breaks the pointer). To avoid the '../…' ids, root the workspace at the \
192 common parent directory containing every source tree.",
193 canon_base.display(),
194 canon_root.display()
195 ))
196}
197
198fn engine_state_denies(workspace_root: &Path) -> Vec<String> {
209 use crate::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
210
211 let mut denies: Vec<String> = vec![
212 ".memstead/**".to_string(),
213 ".memstead.cache/**".to_string(),
214 "**/.memstead/**".to_string(),
215 "**/.memstead.cache/**".to_string(),
216 ];
217 if let Ok(ws) = FileWorkspaceStore.load(workspace_root) {
218 for mount in &ws.mounts {
219 let dir: Option<PathBuf> = match &mount.storage {
220 crate::workspace::MountStorage::GitBranch { gitdir, .. } => {
221 gitdir.parent().map(Path::to_path_buf)
222 }
223 crate::workspace::MountStorage::Folder { path } => Some(path.clone()),
224 crate::workspace::MountStorage::Archive { path, .. } => {
225 let rel = relative_path(workspace_root, &normalize_lexical(path));
227 denies.push(rel.to_string_lossy().to_string());
228 None
229 }
230 crate::workspace::MountStorage::InMemory => None,
232 };
233 if let Some(dir) = dir {
234 let rel = relative_path(workspace_root, &normalize_lexical(&dir));
235 if !rel.as_os_str().is_empty() {
239 denies.push(format!("{}/**", rel.to_string_lossy()));
240 }
241 }
242 }
243 }
244 denies
245}
246
247fn commit_exists(git_root: &Path, sha: &str) -> bool {
250 Command::new("git")
251 .args(["cat-file", "-e", &format!("{sha}^{{commit}}")])
252 .current_dir(git_root)
253 .output()
254 .map(|o| o.status.success())
255 .unwrap_or(false)
256}
257
258fn git_head(git_root: &Path) -> Option<String> {
260 let out = Command::new("git")
261 .args(["rev-parse", "HEAD"])
262 .current_dir(git_root)
263 .output()
264 .ok()?;
265 if !out.status.success() {
266 return None;
267 }
268 let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
269 (!sha.is_empty()).then_some(sha)
270}
271
272fn to_git_pathspec(pattern: &str, git_root: &Path, workspace_root: &Path, exclude: bool) -> String {
281 let magic = if exclude {
282 ":(glob,exclude)"
283 } else {
284 ":(glob)"
285 };
286 if pattern.starts_with("**") {
287 return format!("{magic}{pattern}");
288 }
289 let resolved = normalize_lexical(&workspace_root.join(pattern));
290 let git_rel = relative_path(git_root, &resolved);
291 format!("{magic}{}", git_rel.to_string_lossy())
292}
293
294fn in_repo_pathspec(
299 pattern: &str,
300 git_root: &Path,
301 workspace_root: &Path,
302 exclude: bool,
303) -> Option<String> {
304 if pattern.starts_with("**") {
306 return Some(to_git_pathspec(pattern, git_root, workspace_root, exclude));
307 }
308 let resolved = normalize_lexical(&workspace_root.join(pattern));
309 let git_rel = relative_path(git_root, &resolved);
310 if git_rel
311 .components()
312 .next()
313 .is_some_and(|c| c == Component::ParentDir)
314 {
315 return None;
316 }
317 let magic = if exclude {
318 ":(glob,exclude)"
319 } else {
320 ":(glob)"
321 };
322 Some(format!("{magic}{}", git_rel.to_string_lossy()))
323}
324
325fn build_glob_set(patterns: &[&str]) -> Option<GlobSet> {
328 let mut builder = GlobSetBuilder::new();
329 for pattern in patterns {
330 builder.add(Glob::new(pattern).ok()?);
331 }
332 builder.build().ok()
333}
334
335fn facet_unscoped(source: &Source) -> bool {
342 !source.scope.iter().any(|r| r.mode == PatternMode::Allow)
343}
344
345pub fn enumerate_facet_files(
364 source: &Source,
365 deny_paths: &[String],
366 workspace_root: &Path,
367) -> Vec<String> {
368 if !matches!(
369 source.medium_type,
370 MediumType::Codebase | MediumType::Filesystem | MediumType::Git
371 ) {
372 return Vec::new();
373 }
374 let mut allows: Vec<&str> = Vec::new();
375 let mut denies: Vec<&str> = Vec::new();
376 for rule in &source.scope {
377 match rule.mode {
378 PatternMode::Allow => allows.push(&rule.path),
379 PatternMode::Deny => denies.push(&rule.path),
380 }
381 }
382 for dp in deny_paths {
387 denies.push(dp);
388 }
389 let forced = engine_state_denies(workspace_root);
393 for f in &forced {
394 denies.push(f);
395 }
396 if allows.is_empty() {
397 return Vec::new();
398 }
399 let Some(allow_set) = build_glob_set(&allows) else {
400 return Vec::new();
401 };
402 let deny_set = if denies.is_empty() {
403 None
404 } else {
405 build_glob_set(&denies)
406 };
407
408 let base = medium_base(&source.pointer, workspace_root);
414 let mut out: Vec<String> = Vec::new();
415 let mut stack = vec![base];
416 while let Some(dir) = stack.pop() {
417 let Ok(entries) = std::fs::read_dir(&dir) else {
418 continue;
419 };
420 for entry in entries.flatten() {
421 let Ok(file_type) = entry.file_type() else {
422 continue;
423 };
424 let path = entry.path();
425 if file_type.is_dir() {
426 let skip = path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
427 VCS_INTERNAL_DIRS.contains(&n) || n == ".memstead" || n == ".memstead.cache"
432 });
433 if !skip {
434 stack.push(path);
435 }
436 } else if file_type.is_file() {
437 let rel = relative_path(workspace_root, &normalize_lexical(&path))
438 .to_string_lossy()
439 .to_string();
440 let denied = deny_set.as_ref().is_some_and(|d| d.is_match(&rel));
441 if allow_set.is_match(&rel) && !denied {
442 out.push(rel);
443 }
444 }
445 }
446 }
447 out.sort();
448 out.dedup();
449 out
450}
451
452fn compute_git_slice(
455 source: &Source,
456 deny_paths: &[String],
457 workspace_root: &Path,
458 baseline: Option<&str>,
459) -> SliceOutcome {
460 let base = medium_base(&source.pointer, workspace_root);
461 let Some(git_root) = find_git_root(&base) else {
462 return SliceOutcome::NoSignal {
463 reason: NoSignalReason::GitUnavailable,
464 };
465 };
466 let Some(head) = git_head(&git_root) else {
467 return SliceOutcome::NoSignal {
468 reason: NoSignalReason::GitUnavailable,
469 };
470 };
471
472 let baseline = match baseline {
473 Some(b) if is_git_token(b) => b,
474 _ => return SliceOutcome::Reseed { token: head },
476 };
477 if baseline == head {
478 return SliceOutcome::Unchanged { token: head };
479 }
480 if !commit_exists(&git_root, baseline) {
489 return SliceOutcome::Reseed { token: head };
490 }
491
492 let mut allows: Vec<&str> = Vec::new();
494 let mut denies: Vec<&str> = Vec::new();
495 for rule in &source.scope {
496 match rule.mode {
497 PatternMode::Allow => allows.push(&rule.path),
498 PatternMode::Deny => denies.push(&rule.path),
499 }
500 }
501 if allows.is_empty() {
502 return SliceOutcome::NoSignal {
505 reason: NoSignalReason::Unscoped,
506 };
507 }
508 for dp in deny_paths {
509 denies.push(dp);
510 }
511 let forced = engine_state_denies(workspace_root);
515 for f in &forced {
516 denies.push(f);
517 }
518 let mut specs: Vec<String> = Vec::with_capacity(allows.len() + denies.len());
519 for a in &allows {
520 specs.push(to_git_pathspec(a, &git_root, workspace_root, false));
521 }
522 for d in &denies {
523 if let Some(spec) = in_repo_pathspec(d, &git_root, workspace_root, true) {
532 specs.push(spec);
533 }
534 }
535
536 let mut cmd = Command::new("git");
537 cmd.args([
538 "diff",
539 "--no-renames",
540 "--name-status",
541 baseline,
542 &head,
543 "--",
544 ]);
545 cmd.args(&specs);
546 cmd.current_dir(&git_root);
547 let out = match cmd.output() {
548 Ok(o) if o.status.success() => o,
549 _ => {
552 return SliceOutcome::NoSignal {
553 reason: NoSignalReason::GitUnavailable,
554 };
555 }
556 };
557 let text = String::from_utf8_lossy(&out.stdout);
558
559 let mut slice = Slice::default();
560 for line in text.lines() {
561 if line.trim().is_empty() {
562 continue;
563 }
564 let Some(tab) = line.find('\t') else { continue };
565 let status = line[..tab].trim();
566 let git_path = line[tab + 1..].trim();
567 let ws_path = relative_path(workspace_root, &normalize_lexical(&git_root.join(git_path)))
568 .to_string_lossy()
569 .to_string();
570 match status.chars().next() {
571 Some('A') => slice.added.push(ws_path),
572 Some('D') => slice.deleted.push(ws_path),
573 _ => slice.modified.push(ws_path),
575 }
576 }
577 slice.added.sort();
578 slice.modified.sort();
579 slice.deleted.sort();
580 SliceOutcome::Changed {
581 token: head,
582 slice,
583 degraded: false,
584 }
585}
586
587#[derive(Debug, Clone, PartialEq, Eq)]
603pub enum EntitySelector {
604 All,
606 Type(String),
608 Id(String),
610}
611
612pub fn parse_entity_selector(pattern: &str) -> Option<EntitySelector> {
616 let pattern = pattern.trim();
617 if pattern == "*" {
618 return Some(EntitySelector::All);
619 }
620 if let Some(rest) = pattern.strip_prefix("type:") {
621 let rest = rest.trim();
622 if rest.is_empty() {
623 return None;
624 }
625 return Some(EntitySelector::Type(rest.to_string()));
626 }
627 if let Some(rest) = pattern.strip_prefix("id:") {
628 let rest = rest.trim();
629 if rest.is_empty() {
630 return None;
631 }
632 Glob::new(rest).ok()?;
634 return Some(EntitySelector::Id(rest.to_string()));
635 }
636 None
637}
638
639fn selector_matches(selector: &EntitySelector, id: &str, entity_type: &str) -> bool {
641 match selector {
642 EntitySelector::All => true,
643 EntitySelector::Type(t) => entity_type == t,
644 EntitySelector::Id(g) => Glob::new(g)
645 .ok()
646 .map(|glob| glob.compile_matcher().is_match(id))
647 .unwrap_or(false),
648 }
649}
650
651pub fn enumerate_graph_entities(engine: &Engine, source: &Source) -> Vec<String> {
666 if source.medium_type != MediumType::Graph {
667 return Vec::new();
668 }
669 let mut allows: Vec<EntitySelector> = Vec::new();
670 let mut denies: Vec<EntitySelector> = Vec::new();
671 for rule in &source.scope {
672 let Some(sel) = parse_entity_selector(&rule.path) else {
676 continue;
677 };
678 match rule.mode {
679 PatternMode::Allow => allows.push(sel),
680 PatternMode::Deny => denies.push(sel),
681 }
682 }
683 if allows.is_empty() {
684 return Vec::new();
685 }
686 let mem = source.pointer.as_str();
687 let mut out: Vec<String> = Vec::new();
688 for entity in engine.store().all_entities() {
689 if entity.mem != mem || entity.stub {
690 continue;
691 }
692 let id = entity.id.0.as_str();
693 let ty = entity.entity_type.as_str();
694 if !allows.iter().any(|s| selector_matches(s, id, ty)) {
695 continue;
696 }
697 if denies.iter().any(|s| selector_matches(s, id, ty)) {
698 continue;
699 }
700 out.push(id.to_string());
701 }
702 out.sort();
703 out.dedup();
704 out
705}
706
707pub fn enumerate_source_artifacts(
719 engine: &Engine,
720 source: &Source,
721 deny_paths: &[String],
722 workspace_root: &Path,
723) -> Vec<String> {
724 match source.medium_type {
725 MediumType::Codebase | MediumType::Filesystem | MediumType::Git => {
726 enumerate_facet_files(source, deny_paths, workspace_root)
727 }
728 MediumType::Graph => enumerate_graph_entities(engine, source),
729 MediumType::Web => Vec::new(),
730 }
731}
732
733fn filter_graph_slice_to_scope(engine: &Engine, source: &Source, slice: &mut Slice) {
750 let mut allows: Vec<EntitySelector> = Vec::new();
751 let mut denies: Vec<EntitySelector> = Vec::new();
752 for rule in &source.scope {
753 let Some(sel) = parse_entity_selector(&rule.path) else {
754 continue;
755 };
756 match rule.mode {
757 PatternMode::Allow => allows.push(sel),
758 PatternMode::Deny => denies.push(sel),
759 }
760 }
761 if allows.is_empty() {
762 return;
763 }
764 let in_scope = |id: &str, known_type: Option<&str>| {
765 let matches = |s: &EntitySelector| match (s, known_type) {
769 (EntitySelector::Type(_), None) => true,
770 _ => selector_matches(s, id, known_type.unwrap_or_default()),
771 };
772 allows.iter().any(&matches) && !denies.iter().any(&matches)
773 };
774 let type_of = |id: &str| {
775 engine
776 .store()
777 .get(&crate::entity::EntityId::canonical(id))
778 .map(|e| e.entity_type.clone())
779 };
780 slice
781 .added
782 .retain(|id| in_scope(id, type_of(id).as_deref()));
783 slice
784 .modified
785 .retain(|id| in_scope(id, type_of(id).as_deref()));
786 slice.deleted.retain(|id| in_scope(id, None));
787}
788
789fn compute_graph_slice(
790 engine: &Engine,
791 source: Option<&Source>,
792 source_mem: &str,
793 baseline: Option<&str>,
794) -> SliceOutcome {
795 let current = match engine.mem_head_sha(source_mem) {
796 Ok(Some(sha)) => sha,
797 _ => {
799 return SliceOutcome::NoSignal {
800 reason: NoSignalReason::GraphSnapshotMissing,
801 };
802 }
803 };
804 let changed = matches!(baseline, Some(b) if is_git_token(b) && b != current);
806 let mut outcome = if changed {
807 let baseline = baseline.expect("changed implies a baseline");
808 match engine.changes_since(source_mem, baseline, None) {
809 Ok(report) => graph_slice_outcome(Some(baseline), ¤t, &report.changes),
810 Err(_) => SliceOutcome::NoSignal {
812 reason: NoSignalReason::GraphSnapshotMissing,
813 },
814 }
815 } else {
816 graph_slice_outcome(baseline, ¤t, &[])
817 };
818 if let (Some(source), SliceOutcome::Changed { slice, .. }) = (source, &mut outcome) {
824 filter_graph_slice_to_scope(engine, source, slice);
825 }
826 outcome
827}
828
829fn cursor_memo_path(cache_root: &Path, ingest_name: &str, facet_ref: &str) -> PathBuf {
845 let safe: String = facet_ref
846 .chars()
847 .map(|c| {
848 if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
849 c
850 } else {
851 '_'
852 }
853 })
854 .collect();
855 cache_root
856 .join("source-cursor")
857 .join(ingest_name)
858 .join(format!("{safe}.json"))
859}
860
861fn read_cursor_memo(
863 cache_root: &Path,
864 ingest: &str,
865 facet: &str,
866 aggregate: &str,
867) -> Option<StatMap> {
868 let bytes = std::fs::read(cursor_memo_path(cache_root, ingest, facet)).ok()?;
869 let memo: BTreeMap<String, StatMap> = serde_json::from_slice(&bytes).ok()?;
870 memo.get(aggregate).cloned()
871}
872
873fn write_cursor_memo(cache_root: &Path, ingest: &str, facet: &str, aggregate: &str, map: &StatMap) {
876 let path = cursor_memo_path(cache_root, ingest, facet);
877 let mut memo: BTreeMap<String, StatMap> = std::fs::read(&path)
878 .ok()
879 .and_then(|b| serde_json::from_slice(&b).ok())
880 .unwrap_or_default();
881 memo.insert(aggregate.to_string(), map.clone());
882 if memo.len() > 3 {
883 let drop: Vec<String> = memo
885 .keys()
886 .filter(|k| k.as_str() != aggregate)
887 .skip(2)
888 .cloned()
889 .collect();
890 for key in drop {
891 memo.remove(&key);
892 }
893 }
894 if let Some(parent) = path.parent() {
895 let _ = std::fs::create_dir_all(parent);
896 }
897 if let Ok(bytes) = serde_json::to_vec(&memo) {
898 let _ = std::fs::write(&path, bytes);
899 }
900}
901
902const VCS_INTERNAL_DIRS: &[&str] = &[".git", ".svn", ".hg"];
905
906const DEAD_DENY_SKIP_DIRS: &[&str] = &[
910 ".git",
911 "node_modules",
912 "target",
913 "dist",
914 ".memstead.cache",
915 ".sqlx",
916 ".svn",
917 ".hg",
918];
919
920fn walk_tree_bounded(base: &Path, workspace_root: &Path, cap: usize) -> Option<Vec<String>> {
926 let mut out: Vec<String> = Vec::new();
927 let mut stack = vec![base.to_path_buf()];
928 while let Some(dir) = stack.pop() {
929 let Ok(entries) = std::fs::read_dir(&dir) else {
930 continue;
931 };
932 for entry in entries.flatten() {
933 let Ok(file_type) = entry.file_type() else {
934 continue;
935 };
936 let path = entry.path();
937 if file_type.is_dir() {
938 let skip = path
939 .file_name()
940 .and_then(|n| n.to_str())
941 .is_some_and(|n| DEAD_DENY_SKIP_DIRS.contains(&n));
942 if !skip {
943 stack.push(path);
944 }
945 } else if file_type.is_file() {
946 if out.len() >= cap {
947 return None;
948 }
949 out.push(
950 relative_path(workspace_root, &normalize_lexical(&path))
951 .to_string_lossy()
952 .to_string(),
953 );
954 }
955 }
956 }
957 Some(out)
958}
959
960fn dead_deny_entries(resolved: &ResolvedIngest, workspace_root: &Path) -> Vec<String> {
982 if resolved.deny_paths.is_empty() {
983 return Vec::new();
984 }
985 let base = find_git_root(workspace_root).unwrap_or_else(|| workspace_root.to_path_buf());
986 let Some(files) = walk_tree_bounded(&base, workspace_root, 100_000) else {
987 return Vec::new();
988 };
989 let mut dead: Vec<String> = Vec::new();
990 for entry in &resolved.deny_paths {
991 if crate::binding::DEFAULT_SCAFFOLD_DENY_PATHS.contains(&entry.as_str()) {
992 continue;
993 }
994 let Some(set) = build_glob_set(&[entry.as_str()]) else {
995 continue;
998 };
999 if !files.iter().any(|f| set.is_match(f)) {
1000 dead.push(entry.clone());
1001 }
1002 }
1003 dead
1004}
1005
1006fn compute_mtime_slice(
1011 source: &Source,
1012 ingest_name: &str,
1013 deny_paths: &[String],
1014 workspace_root: &Path,
1015 cache_root: &Path,
1016 baseline: Option<&str>,
1017) -> SliceOutcome {
1018 if facet_unscoped(source) {
1019 return SliceOutcome::NoSignal {
1022 reason: NoSignalReason::Unscoped,
1023 };
1024 }
1025 let files = enumerate_facet_files(source, deny_paths, workspace_root);
1026 let now_map = compute_stat_map(&files, workspace_root);
1027 let now_digest = digest_stat_map(&now_map);
1028 write_cursor_memo(
1029 cache_root,
1030 ingest_name,
1031 &source.name,
1032 &now_digest.aggregate,
1033 &now_map,
1034 );
1035 let prev_map = baseline
1036 .and_then(parse_digest_token)
1037 .and_then(|base| read_cursor_memo(cache_root, ingest_name, &source.name, &base.aggregate));
1038 mtime_slice_outcome(baseline, prev_map.as_ref(), &now_map)
1039}
1040
1041fn current_primary_token(
1045 engine: &Engine,
1046 source: &Source,
1047 deny_paths: &[String],
1048 workspace_root: &Path,
1049) -> Option<String> {
1050 match resolve_change_strategy(source, workspace_root) {
1051 ChangeStrategy::Git => git_head(&find_git_root(&medium_base(
1052 &source.pointer,
1053 workspace_root,
1054 ))?),
1055 ChangeStrategy::Graph => {
1056 if facet_unscoped(source) {
1057 None
1060 } else {
1061 engine.mem_head_sha(&source.pointer).ok().flatten()
1062 }
1063 }
1064 ChangeStrategy::Mtime => {
1065 if facet_unscoped(source) {
1066 None
1069 } else {
1070 let files = enumerate_facet_files(source, deny_paths, workspace_root);
1071 Some(serialize_digest_token(&digest_stat_map(&compute_stat_map(
1072 &files,
1073 workspace_root,
1074 ))))
1075 }
1076 }
1077 ChangeStrategy::None => None,
1078 }
1079}
1080
1081pub fn source_moved(engine: &Engine, resolved: &ResolvedIngest, workspace_root: &Path) -> bool {
1088 source_moved_since(engine, resolved, workspace_root, "synced", false)
1089}
1090
1091pub fn source_moved_since(
1103 engine: &Engine,
1104 resolved: &ResolvedIngest,
1105 workspace_root: &Path,
1106 state: &str,
1107 missing_baseline_is_moved: bool,
1108) -> bool {
1109 let dest = &resolved.destination_mem;
1110 let baseline_map = engine
1111 .mem_config_for(dest)
1112 .map(|c| c.sync_state.clone())
1113 .unwrap_or_default();
1114
1115 for source in &resolved.sources {
1116 let (facet_ref, current) = match source {
1117 ResolvedSource::Primary(p) => (
1118 p.name.clone(),
1119 current_primary_token(engine, p, &resolved.deny_paths, workspace_root),
1120 ),
1121 ResolvedSource::Reference { mem } => {
1122 (mem.clone(), engine.mem_head_sha(mem).ok().flatten())
1123 }
1124 };
1125 let key = format!("{}/{}#{state}", resolved.name, facet_ref);
1126 let Some(baseline) = baseline_map.get(&key) else {
1127 if missing_baseline_is_moved && current.as_deref().is_some_and(|c| !c.is_empty()) {
1129 return true;
1130 }
1131 continue;
1132 };
1133 if let Some(current) = current
1134 && !current.is_empty()
1135 && current != *baseline
1136 {
1137 return true;
1138 }
1139 }
1140 false
1141}
1142
1143pub fn compute_source_cursor(
1146 engine: &Engine,
1147 resolved: &ResolvedIngest,
1148 workspace_root: &Path,
1149) -> SourceCursor {
1150 let dest = &resolved.destination_mem;
1151 let baseline_map = engine
1152 .mem_config_for(dest)
1153 .map(|c| c.sync_state.clone())
1154 .unwrap_or_default();
1155
1156 let cache_root = workspace_root.join(".memstead.cache").join("ingest");
1157 let mut union = Slice::default();
1158 let mut write_commands: Vec<SyncCommand> = Vec::new();
1159 let mut reseed: Vec<SyncCommand> = Vec::new();
1160 let mut no_signal: Vec<NoSignalNote> = Vec::new();
1161 let mut delivery: Vec<DeliverySequence> = Vec::new();
1162 let mut degraded = false;
1163 let disposed_units: std::cell::OnceCell<BTreeSet<String>> = std::cell::OnceCell::new();
1167 let disposed_units = || {
1168 disposed_units.get_or_init(|| {
1169 resolved
1170 .name
1171 .split_once('/')
1172 .and_then(|(mem, name)| {
1173 super::advance::read_advance_store(workspace_root, mem, name)
1174 .ok()
1175 .flatten()
1176 })
1177 .map(|state| state.dispositions.keys().cloned().collect())
1178 .unwrap_or_default()
1179 })
1180 };
1181
1182 for source in &resolved.sources {
1183 let primary_medium = match source {
1187 ResolvedSource::Primary(p) => Some(p.medium_type),
1188 ResolvedSource::Reference { .. } => None,
1189 };
1190 let (facet_ref, outcome) = match source {
1191 ResolvedSource::Primary(p) => {
1192 let key = format!("{}/{}#synced", resolved.name, p.name);
1193 let baseline = baseline_map.get(&key).map(String::as_str);
1194 let outcome = match resolve_change_strategy(p, workspace_root) {
1195 ChangeStrategy::Git => {
1196 compute_git_slice(p, &resolved.deny_paths, workspace_root, baseline)
1197 }
1198 ChangeStrategy::Graph if facet_unscoped(p) => SliceOutcome::NoSignal {
1204 reason: NoSignalReason::Unscoped,
1205 },
1206 ChangeStrategy::Graph => {
1207 compute_graph_slice(engine, Some(p), &p.pointer, baseline)
1208 }
1209 ChangeStrategy::Mtime => compute_mtime_slice(
1210 p,
1211 &resolved.name,
1212 &resolved.deny_paths,
1213 workspace_root,
1214 &cache_root,
1215 baseline,
1216 ),
1217 ChangeStrategy::None => SliceOutcome::NoSignal {
1219 reason: NoSignalReason::DetectionNone,
1220 },
1221 };
1222 let outcome =
1227 match crate::preparation::delivery_preparation(p.preparation.as_deref()) {
1228 Some(prep)
1229 if matches!(
1230 p.medium_type,
1231 MediumType::Codebase | MediumType::Filesystem | MediumType::Git
1232 ) =>
1233 {
1234 let (outcome, sequence) = deliver_units(
1235 p,
1236 prep.id,
1237 &resolved.deny_paths,
1238 workspace_root,
1239 baseline,
1240 resolved.batch_size as usize,
1241 disposed_units(),
1242 outcome,
1243 );
1244 delivery.extend(sequence);
1245 outcome
1246 }
1247 _ => outcome,
1248 };
1249 (p.name.clone(), outcome)
1250 }
1251 ResolvedSource::Reference { mem } => {
1252 let key = format!("{}/{}#synced", resolved.name, mem);
1253 let baseline = baseline_map.get(&key).map(String::as_str);
1254 (
1255 mem.clone(),
1256 compute_graph_slice(engine, None, mem, baseline),
1257 )
1258 }
1259 };
1260
1261 let key = format!("{}/{}#synced", resolved.name, facet_ref);
1262 match outcome {
1263 SliceOutcome::Unchanged { .. } => {}
1267 SliceOutcome::NoSignal { reason } => no_signal.push(NoSignalNote {
1269 source: facet_ref.clone(),
1270 reason,
1271 medium_type: primary_medium,
1272 }),
1273 SliceOutcome::Reseed { token } => reseed.push(SyncCommand { key, token }),
1274 SliceOutcome::Changed {
1275 token,
1276 slice,
1277 degraded: d,
1278 } => {
1279 union.added.extend(slice.added);
1280 union.modified.extend(slice.modified);
1281 union.deleted.extend(slice.deleted);
1282 degraded |= d;
1283 write_commands.push(SyncCommand { key, token });
1284 }
1285 }
1286 }
1287
1288 dedupe_sort(&mut union.added);
1289 dedupe_sort(&mut union.modified);
1290 dedupe_sort(&mut union.deleted);
1291 let any_changes =
1292 !union.added.is_empty() || !union.modified.is_empty() || !union.deleted.is_empty();
1293
1294 SourceCursor {
1295 union,
1296 write_commands,
1297 reseed,
1298 no_signal,
1299 any_changes,
1300 degraded,
1301 dead_denies: dead_deny_entries(resolved, workspace_root),
1302 delivery,
1303 dest_mem: dest.clone(),
1304 binding_id: resolved.name.clone(),
1308 }
1309}
1310
1311fn dedupe_sort(v: &mut Vec<String>) {
1312 v.sort();
1313 v.dedup();
1314}
1315
1316fn read_workspace_file(workspace_root: &Path, ws_rel: &str) -> Option<String> {
1318 std::fs::read(workspace_root.join(ws_rel))
1319 .ok()
1320 .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
1321}
1322
1323fn git_baseline_content(
1329 source: &Source,
1330 workspace_root: &Path,
1331 baseline: Option<&str>,
1332 ws_rel: &str,
1333) -> Option<String> {
1334 let baseline = baseline.filter(|b| is_git_token(b))?;
1335 if !matches!(
1336 resolve_change_strategy(source, workspace_root),
1337 ChangeStrategy::Git
1338 ) {
1339 return None;
1340 }
1341 let git_root = find_git_root(&medium_base(&source.pointer, workspace_root))?;
1342 let abs = normalize_lexical(&workspace_root.join(ws_rel));
1343 let rel = relative_path(&git_root, &abs);
1344 let spec = format!("{baseline}:{}", rel.to_string_lossy().replace('\\', "/"));
1345 let out = Command::new("git")
1346 .args(["show", &spec])
1347 .current_dir(&git_root)
1348 .output()
1349 .ok()?;
1350 out.status
1351 .success()
1352 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
1353}
1354
1355pub(crate) fn sequence_units(units: &mut Vec<DeliveredUnit>) {
1362 fn rank(id: &str) -> (&str, u64, &str) {
1363 let (path, key) = crate::preparation::split_unit_id(id);
1364 let key = key.unwrap_or("");
1365 let ordinal = key
1366 .rsplit_once('.')
1367 .and_then(|(_, n)| n.parse::<u64>().ok())
1368 .unwrap_or(1);
1369 (path, ordinal, key)
1370 }
1371 units.sort_by(|a, b| (&a.order_key, rank(&a.id)).cmp(&(&b.order_key, rank(&b.id))));
1372 units.dedup_by(|a, b| a.id == b.id);
1373}
1374
1375#[allow(clippy::too_many_arguments)]
1388fn deliver_units(
1389 source: &Source,
1390 preparation: &str,
1391 deny_paths: &[String],
1392 workspace_root: &Path,
1393 baseline: Option<&str>,
1394 batch: usize,
1395 disposed: &BTreeSet<String>,
1396 outcome: SliceOutcome,
1397) -> (SliceOutcome, Option<DeliverySequence>) {
1398 use crate::preparation::{DeliveryUnit, UnitChange, diff_units, unit_id, unitize};
1399
1400 let units_of =
1401 |text: &str| -> Vec<DeliveryUnit> { unitize(preparation, text).unwrap_or_default() };
1402 let delivered = |path: &str, u: &DeliveryUnit, change: UnitChange| DeliveredUnit {
1403 id: unit_id(path, &u.key),
1404 order_key: u.order_key.clone(),
1405 change,
1406 disposed: false,
1407 };
1408
1409 let mut units: Vec<DeliveredUnit> = Vec::new();
1410 let mut file_level_deleted: Vec<String> = Vec::new();
1411 let mut degraded_units = false;
1412 let (token, first_run, degraded) = match outcome {
1413 SliceOutcome::Reseed { token } => {
1414 for f in enumerate_facet_files(source, deny_paths, workspace_root) {
1415 if let Some(text) = read_workspace_file(workspace_root, &f) {
1416 for u in units_of(&text) {
1417 units.push(delivered(&f, &u, UnitChange::Added));
1418 }
1419 }
1420 }
1421 if units.is_empty() {
1422 return (SliceOutcome::Reseed { token }, None);
1424 }
1425 (token, true, false)
1426 }
1427 SliceOutcome::Changed {
1428 token,
1429 slice,
1430 degraded,
1431 } => {
1432 for f in &slice.added {
1433 if let Some(text) = read_workspace_file(workspace_root, f) {
1434 for u in units_of(&text) {
1435 units.push(delivered(f, &u, UnitChange::Added));
1436 }
1437 }
1438 }
1439 for f in &slice.modified {
1440 let Some(now) = read_workspace_file(workspace_root, f) else {
1441 continue;
1442 };
1443 let new_units = units_of(&now);
1444 match git_baseline_content(source, workspace_root, baseline, f) {
1445 Some(old) => {
1446 for (u, change) in diff_units(&units_of(&old), &new_units) {
1447 units.push(delivered(f, &u, change));
1448 }
1449 }
1450 None => {
1451 degraded_units = true;
1452 for u in new_units {
1453 units.push(delivered(f, &u, UnitChange::Modified));
1454 }
1455 }
1456 }
1457 }
1458 for f in &slice.deleted {
1459 match git_baseline_content(source, workspace_root, baseline, f) {
1460 Some(old) => {
1461 for u in units_of(&old) {
1462 units.push(delivered(f, &u, UnitChange::Deleted));
1463 }
1464 }
1465 None => file_level_deleted.push(f.clone()),
1466 }
1467 }
1468 (token, false, degraded)
1469 }
1470 other => return (other, None),
1471 };
1472
1473 sequence_units(&mut units);
1474 for u in &mut units {
1475 u.disposed = disposed.contains(&u.id);
1476 }
1477
1478 let mut slice = Slice::default();
1479 for u in &units {
1480 match u.change {
1481 UnitChange::Added => slice.added.push(u.id.clone()),
1482 UnitChange::Modified => slice.modified.push(u.id.clone()),
1483 UnitChange::Deleted => slice.deleted.push(u.id.clone()),
1484 }
1485 }
1486 slice.deleted.extend(file_level_deleted);
1487 dedupe_sort(&mut slice.added);
1488 dedupe_sort(&mut slice.modified);
1489 dedupe_sort(&mut slice.deleted);
1490
1491 let sequence = DeliverySequence {
1492 source: source.name.clone(),
1493 preparation: preparation.to_string(),
1494 first_run,
1495 degraded: degraded_units,
1496 batch,
1497 units,
1498 };
1499 (
1500 SliceOutcome::Changed {
1501 token,
1502 slice,
1503 degraded,
1504 },
1505 Some(sequence),
1506 )
1507}
1508
1509#[cfg(test)]
1510mod tests {
1511 use super::*;
1512
1513 #[test]
1514 fn normalize_resolves_dot_and_dotdot() {
1515 assert_eq!(
1516 normalize_lexical(Path::new("/a/b/../c/./d")),
1517 PathBuf::from("/a/c/d")
1518 );
1519 assert_eq!(
1520 normalize_lexical(Path::new("/a/../../b")),
1521 PathBuf::from("/b"),
1522 "dotdot past root is clamped"
1523 );
1524 }
1525
1526 #[test]
1527 fn relative_computes_updowns() {
1528 assert_eq!(
1529 relative_path(Path::new("/a/b"), Path::new("/a/b/c/d")),
1530 PathBuf::from("c/d")
1531 );
1532 assert_eq!(
1533 relative_path(Path::new("/a/b/c"), Path::new("/a/x")),
1534 PathBuf::from("../../x")
1535 );
1536 assert_eq!(
1538 relative_path(Path::new("/m/public"), Path::new("/m/public/crates/x.rs")),
1539 PathBuf::from("crates/x.rs")
1540 );
1541 assert_eq!(
1542 relative_path(Path::new("/m/graph"), Path::new("/m/public/crates/x.rs")),
1543 PathBuf::from("../public/crates/x.rs")
1544 );
1545 }
1546
1547 #[test]
1548 fn pathspec_builds_glob_magic_relative_to_git_root() {
1549 let ws = Path::new("/m/graph");
1550 let git_root = Path::new("/m/public");
1551 assert_eq!(
1552 to_git_pathspec("../public/**/*.rs", git_root, ws, false),
1553 ":(glob)**/*.rs"
1554 );
1555 assert_eq!(
1556 to_git_pathspec("../public/target/**", git_root, ws, true),
1557 ":(glob,exclude)target/**"
1558 );
1559 }
1560
1561 #[test]
1567 fn wildcard_prefixed_pathspec_reanchors_verbatim() {
1568 let ws = Path::new("/m/ws");
1569 let git_root = Path::new("/m/ws/src");
1570 assert_eq!(to_git_pathspec("**/*", git_root, ws, false), ":(glob)**/*");
1571 assert_eq!(
1572 in_repo_pathspec("**/__pycache__/**", git_root, ws, true).as_deref(),
1573 Some(":(glob,exclude)**/__pycache__/**")
1574 );
1575 }
1576
1577 use crate::ingest::resolve::Source;
1578 use crate::pipeline::{MediumType, PatternEntry};
1579
1580 fn git(repo: &Path, args: &[&str]) {
1581 let status = std::process::Command::new("git")
1582 .args(args)
1583 .current_dir(repo)
1584 .env("GIT_AUTHOR_NAME", "t")
1585 .env("GIT_AUTHOR_EMAIL", "t@t")
1586 .env("GIT_COMMITTER_NAME", "t")
1587 .env("GIT_COMMITTER_EMAIL", "t@t")
1588 .output()
1589 .unwrap();
1590 assert!(
1591 status.status.success(),
1592 "git {args:?}: {}",
1593 String::from_utf8_lossy(&status.stderr)
1594 );
1595 }
1596
1597 fn primary(scope: Vec<PatternEntry>) -> Source {
1598 Source {
1599 name: "src".to_string(),
1600 medium_type: MediumType::Codebase,
1601 pointer: String::new(),
1602 change_detection: Some("git".to_string()),
1603 scope,
1604 engagement: None,
1605 preparation: None,
1606 }
1607 }
1608
1609 #[test]
1619 fn deny_dialect_agrees_between_slice_and_check() {
1620 let strs =
1621 |items: &[&str]| -> Vec<String> { items.iter().map(|s| s.to_string()).collect() };
1622 let entries = strs(&["dev/**", "**/VISION.md", "docs/meta/CLAUDE.md"]);
1623 let blocked = strs(&[
1624 "dev/notes/a.md",
1625 "dev/x.rs",
1626 "dev/deep/nested/y.txt",
1627 "VISION.md",
1628 "crates/foo/VISION.md",
1629 "docs/meta/CLAUDE.md",
1630 ]);
1631 let allowed = strs(&[
1632 "src/lib.rs",
1633 "dev-tools/x.rs",
1634 "VISION-draft.md",
1635 "docs/meta/README.md",
1636 "other/CLAUDE.md",
1637 "crates/foo/mod.rs",
1638 ]);
1639
1640 let ws = tempfile::tempdir().unwrap();
1641 for rel in blocked.iter().chain(allowed.iter()) {
1642 let path = ws.path().join(rel);
1643 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1644 std::fs::write(&path, "x").unwrap();
1645 }
1646
1647 let source = primary(vec![PatternEntry {
1649 path: "**".to_string(),
1650 mode: PatternMode::Allow,
1651 }]);
1652 let mut got = enumerate_facet_files(&source, &entries, ws.path());
1653 got.sort();
1654 let mut want = allowed.clone();
1655 want.sort();
1656 assert_eq!(
1657 got, want,
1658 "engine slice must equal the fixture `allowed` set"
1659 );
1660
1661 for b in &blocked {
1662 assert!(
1663 !got.contains(b),
1664 "denied `{b}` leaked into the engine slice"
1665 );
1666 }
1667 let all: Vec<String> = blocked.iter().chain(allowed.iter()).cloned().collect();
1670 let checks =
1671 super::super::check_path::check_deny_paths(&entries, &all, ws.path(), ws.path());
1672 for c in &checks {
1673 let expect = blocked.contains(&c.path);
1674 assert_eq!(
1675 c.denied, expect,
1676 "check_deny_paths disagrees with the slice on `{}`",
1677 c.path
1678 );
1679 }
1680 }
1681
1682 #[test]
1686 fn out_of_repo_deny_pathspec_is_dropped() {
1687 let ws = Path::new("/m/graph");
1688 let git_root = Path::new("/m/public");
1689 assert_eq!(in_repo_pathspec("../dev/**", git_root, ws, true), None);
1691 assert_eq!(in_repo_pathspec("../CLAUDE.md", git_root, ws, true), None);
1692 assert_eq!(
1694 in_repo_pathspec("../public/target/**", git_root, ws, true),
1695 Some(":(glob,exclude)target/**".to_string())
1696 );
1697 }
1698
1699 #[test]
1706 fn foreign_baseline_reseeds_instead_of_degrading() {
1707 let repo = tempfile::tempdir().unwrap();
1708 let root = repo.path();
1709 std::fs::write(root.join("keep.rs"), "one").unwrap();
1710 git(root, &["init", "-q"]);
1711 git(root, &["add", "-A"]);
1712 git(root, &["commit", "-qm", "seed"]);
1713
1714 let source = primary(vec![PatternEntry {
1715 path: "**/*.rs".to_string(),
1716 mode: PatternMode::Allow,
1717 }]);
1718 let foreign = "46ce8add0fe87250527b6fa21fcfdc2d943d51f0";
1720 match compute_git_slice(&source, &[], root, Some(foreign)) {
1721 SliceOutcome::Reseed { token } => {
1722 let head = String::from_utf8(
1724 std::process::Command::new("git")
1725 .args(["rev-parse", "HEAD"])
1726 .current_dir(root)
1727 .output()
1728 .unwrap()
1729 .stdout,
1730 )
1731 .unwrap()
1732 .trim()
1733 .to_string();
1734 assert_eq!(token, head);
1735 }
1736 other => panic!("foreign baseline must reseed, got {other:?}"),
1737 }
1738 }
1739
1740 #[test]
1745 fn git_slice_survives_cross_repo_deny() {
1746 let repo = tempfile::tempdir().unwrap();
1747 let root = repo.path();
1748 std::fs::write(root.join("keep.rs"), "one").unwrap();
1749 git(root, &["init", "-q"]);
1750 git(root, &["add", "-A"]);
1751 git(root, &["commit", "-qm", "seed"]);
1752 let baseline = String::from_utf8(
1753 std::process::Command::new("git")
1754 .args(["rev-parse", "HEAD"])
1755 .current_dir(root)
1756 .output()
1757 .unwrap()
1758 .stdout,
1759 )
1760 .unwrap()
1761 .trim()
1762 .to_string();
1763 std::fs::write(root.join("keep.rs"), "two").unwrap();
1764 git(root, &["add", "-A"]);
1765 git(root, &["commit", "-qm", "move"]);
1766
1767 let source = primary(vec![PatternEntry {
1768 path: "**/*.rs".to_string(),
1769 mode: PatternMode::Allow,
1770 }]);
1771 let outcome = compute_git_slice(&source, &["../dev/**".to_string()], root, Some(&baseline));
1773 match outcome {
1774 SliceOutcome::Changed { slice, .. } => {
1775 assert_eq!(slice.modified, vec!["keep.rs"]);
1776 }
1777 other => panic!("expected Changed (deny dropped), got {other:?}"),
1778 }
1779 }
1780
1781 #[test]
1784 fn git_slice_diffs_baseline_to_head() {
1785 let repo = tempfile::tempdir().unwrap();
1786 let root = repo.path();
1787 git(root, &["init", "-q"]);
1788 std::fs::write(root.join("keep.rs"), "one").unwrap();
1789 std::fs::write(root.join("gone.rs"), "bye").unwrap();
1790 std::fs::write(root.join("note.md"), "ignored-by-scope").unwrap();
1791 git(root, &["add", "-A"]);
1792 git(root, &["commit", "-qm", "base"]);
1793 let baseline = String::from_utf8(
1794 std::process::Command::new("git")
1795 .args(["rev-parse", "HEAD"])
1796 .current_dir(root)
1797 .output()
1798 .unwrap()
1799 .stdout,
1800 )
1801 .unwrap()
1802 .trim()
1803 .to_string();
1804
1805 std::fs::write(root.join("keep.rs"), "two").unwrap();
1807 std::fs::remove_file(root.join("gone.rs")).unwrap();
1808 std::fs::write(root.join("new.rs"), "hi").unwrap();
1809 std::fs::write(root.join("note.md"), "still ignored").unwrap();
1810 git(root, &["add", "-A"]);
1811 git(root, &["commit", "-qm", "move"]);
1812
1813 let source = primary(vec![PatternEntry {
1815 path: "**/*.rs".to_string(),
1816 mode: PatternMode::Allow,
1817 }]);
1818 let outcome = compute_git_slice(&source, &[], root, Some(&baseline));
1819 match outcome {
1820 SliceOutcome::Changed {
1821 slice, degraded, ..
1822 } => {
1823 assert!(!degraded);
1824 assert_eq!(slice.added, vec!["new.rs"]);
1825 assert_eq!(slice.modified, vec!["keep.rs"]);
1826 assert_eq!(slice.deleted, vec!["gone.rs"]);
1827 }
1828 other => panic!("expected Changed, got {other:?}"),
1829 }
1830
1831 let head = String::from_utf8(
1833 std::process::Command::new("git")
1834 .args(["rev-parse", "HEAD"])
1835 .current_dir(root)
1836 .output()
1837 .unwrap()
1838 .stdout,
1839 )
1840 .unwrap()
1841 .trim()
1842 .to_string();
1843 assert!(matches!(
1844 compute_git_slice(&source, &[], root, Some(&head)),
1845 SliceOutcome::Unchanged { .. }
1846 ));
1847
1848 assert!(matches!(
1850 compute_git_slice(&source, &[], root, None),
1851 SliceOutcome::Reseed { .. }
1852 ));
1853 }
1854
1855 #[test]
1858 fn enumerate_honours_allow_and_deny() {
1859 let ws = tempfile::tempdir().unwrap();
1860 let root = ws.path();
1861 std::fs::create_dir_all(root.join("sub")).unwrap();
1862 std::fs::write(root.join("a.rs"), "").unwrap();
1863 std::fs::write(root.join("sub/b.rs"), "").unwrap();
1864 std::fs::write(root.join("c.md"), "").unwrap();
1865
1866 let source = primary(vec![
1869 PatternEntry {
1870 path: "**/*.rs".to_string(),
1871 mode: PatternMode::Allow,
1872 },
1873 PatternEntry {
1874 path: "sub/**".to_string(),
1875 mode: PatternMode::Deny,
1876 },
1877 ]);
1878 assert_eq!(enumerate_facet_files(&source, &[], root), vec!["a.rs"]);
1879
1880 let mut graph_source = source.clone();
1885 graph_source.medium_type = MediumType::Graph;
1886 assert!(enumerate_facet_files(&graph_source, &[], root).is_empty());
1887 }
1888
1889 #[test]
1895 fn graph_enumeration_selects_the_source_mems_entities() {
1896 use crate::workspace::{
1897 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
1898 };
1899 use crate::workspace_store::WorkspaceStoreAdapter;
1900
1901 let tmp = tempfile::tempdir().unwrap();
1902 let root = tmp.path();
1903 let mem_dir = root.join("srcmem");
1904 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1905 std::fs::write(
1906 mem_dir.join(".memstead").join("config.json"),
1907 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1908 )
1909 .unwrap();
1910
1911 let entity = |slug: &str, ty: &str, title: &str| {
1912 std::fs::write(
1913 mem_dir.join(format!("{slug}.md")),
1914 format!("---\ntype: {ty}\n---\n\n# {title}\n\n## Decision\n\nBody.\n"),
1915 )
1916 .unwrap();
1917 };
1918 entity("alpha-choice", "decision", "Alpha choice");
1919 entity("beta-choice", "decision", "Beta choice");
1920 entity("gamma-note", "memo", "Gamma note");
1921
1922 std::fs::create_dir_all(root.join(".memstead")).unwrap();
1923 std::fs::write(
1924 root.join(".memstead").join("workspace.toml"),
1925 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1926 )
1927 .unwrap();
1928 crate::FileWorkspaceStore::new()
1929 .save_state(
1930 root,
1931 &Workspace {
1932 mounts: vec![Mount {
1933 mem: "srcmem".to_string(),
1934 schema: Some("default@1.0.0".parse().unwrap()),
1935 storage: MountStorage::Folder {
1936 path: mem_dir.clone(),
1937 },
1938 capability: MountCapability::Write,
1939 lifecycle: MountLifecycle::Eager,
1940 cross_linkable: false,
1941 migration_target: None,
1942 }],
1943 settings: WorkspaceSettings::default(),
1944 },
1945 )
1946 .unwrap();
1947
1948 let engine = crate::Engine::from_workspace_root(root).unwrap();
1949
1950 let graph_source = |patterns: Vec<(&str, PatternMode)>| Source {
1951 name: "g".to_string(),
1952 medium_type: MediumType::Graph,
1953 pointer: "srcmem".to_string(),
1954 change_detection: None,
1955 scope: patterns
1956 .into_iter()
1957 .map(|(p, mode)| crate::pipeline::PatternEntry {
1958 path: p.to_string(),
1959 mode,
1960 })
1961 .collect(),
1962 engagement: None,
1963 preparation: None,
1964 };
1965
1966 let all = enumerate_graph_entities(&engine, &graph_source(vec![("*", PatternMode::Allow)]));
1968 assert_eq!(
1969 all,
1970 vec![
1971 "srcmem--alpha-choice".to_string(),
1972 "srcmem--beta-choice".to_string(),
1973 "srcmem--gamma-note".to_string(),
1974 ],
1975 "the whole-mem selector enumerates every real entity"
1976 );
1977
1978 let decisions = enumerate_graph_entities(
1980 &engine,
1981 &graph_source(vec![("type:decision", PatternMode::Allow)]),
1982 );
1983 assert_eq!(
1984 decisions,
1985 vec![
1986 "srcmem--alpha-choice".to_string(),
1987 "srcmem--beta-choice".to_string()
1988 ],
1989 "type selector excludes the memo"
1990 );
1991
1992 let globbed = enumerate_graph_entities(
1994 &engine,
1995 &graph_source(vec![
1996 ("id:srcmem--*-choice", PatternMode::Allow),
1997 ("id:srcmem--beta-*", PatternMode::Deny),
1998 ]),
1999 );
2000 assert_eq!(
2001 globbed,
2002 vec!["srcmem--alpha-choice".to_string()],
2003 "deny subtracts from allow in the entity namespace too"
2004 );
2005
2006 assert!(
2010 enumerate_graph_entities(&engine, &graph_source(vec![])).is_empty(),
2011 "an unscoped graph facet is never silently 'everything'"
2012 );
2013
2014 assert_eq!(
2016 enumerate_source_artifacts(
2017 &engine,
2018 &graph_source(vec![("*", PatternMode::Allow)]),
2019 &[],
2020 root
2021 ),
2022 all,
2023 "enumerate_source_artifacts routes a graph source to the graph arm"
2024 );
2025 }
2026
2027 #[test]
2033 fn a_stale_entity_anchor_over_a_changed_entity_is_drifted() {
2034 use crate::anchor::{
2035 Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
2036 };
2037 use crate::entity::EntityId;
2038 use crate::workspace::{
2039 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2040 };
2041 use crate::workspace_store::WorkspaceStoreAdapter;
2042
2043 let tmp = tempfile::tempdir().unwrap();
2044 let root = tmp.path();
2045 let mem_dir = root.join("mem");
2046 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2047 std::fs::write(
2048 mem_dir.join(".memstead").join("config.json"),
2049 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2050 )
2051 .unwrap();
2052 std::fs::write(
2053 mem_dir.join("pinned.md"),
2054 "---\ntype: decision\n---\n\n# Pinned\n\n## Decision\n\nOriginal body.\n",
2055 )
2056 .unwrap();
2057 std::fs::write(
2058 mem_dir.join("steady.md"),
2059 "---\ntype: decision\n---\n\n# Steady\n\n## Decision\n\nUnchanged body.\n",
2060 )
2061 .unwrap();
2062
2063 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2064 std::fs::write(
2065 root.join(".memstead").join("workspace.toml"),
2066 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2067 )
2068 .unwrap();
2069 crate::FileWorkspaceStore::new()
2070 .save_state(
2071 root,
2072 &Workspace {
2073 mounts: vec![Mount {
2074 mem: "mem".to_string(),
2075 schema: Some("default@1.0.0".parse().unwrap()),
2076 storage: MountStorage::Folder {
2077 path: mem_dir.clone(),
2078 },
2079 capability: MountCapability::Write,
2080 lifecycle: MountLifecycle::Eager,
2081 cross_linkable: false,
2082 migration_target: None,
2083 }],
2084 settings: WorkspaceSettings::default(),
2085 },
2086 )
2087 .unwrap();
2088
2089 let engine = crate::Engine::from_workspace_root(root).unwrap();
2091 let hash_of = |engine: &crate::Engine, id: &str| {
2092 let e = engine.store().get(&EntityId::canonical(id)).unwrap();
2093 crate::anchor::prepared_content_hash(
2094 crate::render::render_entity_markdown(e, None).as_bytes(),
2095 )
2096 };
2097 let pinned_hash = hash_of(&engine, "mem--pinned");
2098 let steady_hash = hash_of(&engine, "mem--steady");
2099
2100 let entity_anchor = |artifact: &str, hash: &str| Anchor {
2101 artifact: artifact.to_string(),
2102 grain: AnchorGrain::Entity,
2103 class: AnchorProvenanceClass::Anchored,
2104 hash: Some(hash.to_string()),
2105 source: None,
2106 binding: None,
2107 at_version: None,
2108 derived_from: Vec::new(),
2109 hash_stability: crate::anchor::AnchorHashStability::Stable,
2110 };
2111
2112 let mut sidecar = AnchorSidecar::default();
2113 sidecar.set(
2114 "mem--holder",
2115 vec![
2116 entity_anchor("mem--pinned", &pinned_hash),
2117 entity_anchor("mem--steady", &steady_hash),
2118 entity_anchor("mem--vanished", "deadbeefdeadbeef"),
2120 ],
2121 );
2122 std::fs::write(
2123 mem_dir.join(".memstead").join("anchors.json"),
2124 sidecar.to_bytes(),
2125 )
2126 .unwrap();
2127 std::fs::write(
2128 mem_dir.join("holder.md"),
2129 "---\ntype: decision\n---\n\n# Holder\n\n## Decision\n\nHolds anchors.\n",
2130 )
2131 .unwrap();
2132
2133 std::fs::write(
2135 mem_dir.join("pinned.md"),
2136 "---\ntype: decision\n---\n\n# Pinned\n\n## Decision\n\nBody rewritten.\n",
2137 )
2138 .unwrap();
2139
2140 let engine = crate::Engine::from_workspace_root(root).unwrap();
2141 let resolved = engine.entity_anchors_resolved(&EntityId::canonical("mem--holder"));
2142 let state_of = |artifact: &str| {
2143 resolved
2144 .iter()
2145 .find(|r| r.anchor.artifact == artifact)
2146 .unwrap_or_else(|| panic!("no resolved anchor for {artifact}"))
2147 .state
2148 };
2149
2150 assert_eq!(
2151 state_of("mem--pinned"),
2152 Some(AnchorState::Drifted),
2153 "a stale-pinned anchor over a CHANGED entity must be drifted — \
2154 this is the pilot failure that went unflagged"
2155 );
2156 assert_eq!(
2157 state_of("mem--steady"),
2158 Some(AnchorState::Resolves),
2159 "an anchor over an unchanged entity still resolves"
2160 );
2161 assert_eq!(
2162 state_of("mem--vanished"),
2163 Some(AnchorState::Orphaned),
2164 "an anchor over an entity that is not there is orphaned, not unobserved"
2165 );
2166
2167 let mut sc2 = AnchorSidecar::default();
2170 sc2.set(
2171 "mem--holder",
2172 vec![Anchor {
2173 artifact: "https://example.invalid/doc".to_string(),
2174 grain: AnchorGrain::Url,
2175 class: AnchorProvenanceClass::InformedBy,
2176 hash: None,
2177 source: None,
2178 binding: None,
2179 at_version: None,
2180 derived_from: Vec::new(),
2181 hash_stability: crate::anchor::AnchorHashStability::Stable,
2182 }],
2183 );
2184 std::fs::write(
2185 mem_dir.join(".memstead").join("anchors.json"),
2186 sc2.to_bytes(),
2187 )
2188 .unwrap();
2189 let engine = crate::Engine::from_workspace_root(root).unwrap();
2190 let url_state =
2191 engine.entity_anchors_resolved(&EntityId::canonical("mem--holder"))[0].state;
2192 assert_eq!(
2193 url_state, None,
2194 "url anchors stay unobserved — the fix widens observation, never the \
2195 scoring of non-observation"
2196 );
2197 }
2198
2199 #[test]
2209 fn entity_load_bearing_preparation_ignores_notes_edits_and_catches_claim_edits() {
2210 use crate::anchor::{
2211 Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
2212 };
2213 use crate::binding::{
2214 BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, VerifyOperation,
2215 };
2216 use crate::entity::EntityId;
2217 use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode, Source};
2218 use crate::workspace::{
2219 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2220 };
2221 use crate::workspace_store::WorkspaceStoreAdapter;
2222
2223 let tmp = tempfile::tempdir().unwrap();
2224 let root = tmp.path();
2225 let mem_dir = root.join("home");
2226 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2227 std::fs::write(
2228 mem_dir.join(".memstead").join("config.json"),
2229 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2230 )
2231 .unwrap();
2232 let write_pinned = |claim: &str, conditions: &str| {
2235 std::fs::write(
2236 mem_dir.join("pinned.md"),
2237 format!(
2238 "---\ntype: assertion\n---\n\n# Pinned\n\n## Claim\n\n{claim}\n\n\
2239 ## Evidence\n\nMeasured.\n\n## Conditions\n\n{conditions}\n"
2240 ),
2241 )
2242 .unwrap();
2243 };
2244 write_pinned("The sky is blue.", "daylight");
2245 std::fs::write(
2246 mem_dir.join("holder.md"),
2247 "---\ntype: assertion\n---\n\n# Holder\n\n## Claim\n\nDepends on pinned.\n\n\
2248 ## Evidence\n\nSee pinned.\n",
2249 )
2250 .unwrap();
2251
2252 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2253 std::fs::write(
2254 root.join(".memstead").join("workspace.toml"),
2255 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2256 )
2257 .unwrap();
2258 crate::FileWorkspaceStore::new()
2259 .save_state(
2260 root,
2261 &Workspace {
2262 mounts: vec![Mount {
2263 mem: "home".to_string(),
2264 schema: Some("default@1.0.0".parse().unwrap()),
2265 storage: MountStorage::Folder {
2266 path: mem_dir.clone(),
2267 },
2268 capability: MountCapability::Write,
2269 lifecycle: MountLifecycle::Eager,
2270 cross_linkable: false,
2271 migration_target: None,
2272 }],
2273 settings: WorkspaceSettings::default(),
2274 },
2275 )
2276 .unwrap();
2277
2278 let binding_with = |preparation: Option<&str>| Binding {
2282 version: BINDING_VERSION,
2283 intent: None,
2284 sources: vec![Source {
2285 name: "claims".to_string(),
2286 medium_type: MediumType::Graph,
2287 pointer: "home".to_string(),
2288 change_detection: None,
2289 scope: vec![PatternEntry {
2290 path: "*".to_string(),
2291 mode: PatternMode::Allow,
2292 }],
2293 engagement: None,
2294 preparation: preparation.map(str::to_string),
2295 }],
2296 reference_mems: vec![],
2297 destination_mem: "home".to_string(),
2298 deny_paths: vec![],
2299 coverage_semantics: None,
2300 rules: None,
2301 prune: None,
2302 operations: Operations {
2303 build: Some(BuildOperation {
2304 mode: BuildMode::Discovery,
2305 trigger: IngestTrigger::Manual,
2306 batch_size: 5,
2307 post_actions: None,
2308 }),
2309 sync: None,
2310 verify: Some(VerifyOperation {
2311 trigger: IngestTrigger::Manual,
2312 batch_size: 5,
2313 adjudication_cap: 0,
2314 full_resync_every: 0,
2315 }),
2316 },
2317 };
2318 let prepared_binding = binding_with(Some(crate::preparation::ENTITY_LOAD_BEARING));
2319 assert!(crate::binding::validate_binding(&prepared_binding).is_ok());
2320 crate::pipeline_store::write_binding(root, "home", "claims", &prepared_binding).unwrap();
2321
2322 let engine = crate::Engine::from_workspace_root(root).unwrap();
2326 let pinned = engine
2327 .store()
2328 .get(&EntityId::canonical("home--pinned"))
2329 .unwrap();
2330 let type_def = engine
2331 .schema_for("home")
2332 .and_then(|s| s.get_type("assertion"))
2333 .expect("default@1.0.0 declares assertion");
2334 assert!(
2335 crate::preparation::load_bearing_sections(&type_def)
2336 .iter()
2337 .map(|s| s.key.as_str())
2338 .eq(["claim", "evidence"]),
2339 "the required sections are the load-bearing set"
2340 );
2341 let prepared_hash = crate::preparation::entity_prepared_hash(
2342 pinned,
2343 Some(&type_def),
2344 Some(crate::preparation::ENTITY_LOAD_BEARING),
2345 )
2346 .unwrap();
2347 let default_hash =
2348 crate::preparation::entity_prepared_hash(pinned, Some(&type_def), None).unwrap();
2349 assert_ne!(prepared_hash, default_hash);
2350
2351 let anchor = |source: Option<&str>, hash: &str| Anchor {
2352 artifact: "home--pinned".to_string(),
2353 grain: AnchorGrain::Entity,
2354 class: AnchorProvenanceClass::Anchored,
2355 hash: Some(hash.to_string()),
2356 source: source.map(str::to_string),
2357 binding: None,
2358 at_version: None,
2359 derived_from: Vec::new(),
2360 hash_stability: crate::anchor::AnchorHashStability::Stable,
2361 };
2362 std::fs::write(
2364 mem_dir.join("holder2.md"),
2365 "---\ntype: assertion\n---\n\n# Holder2\n\n## Claim\n\nAlso depends.\n\n\
2366 ## Evidence\n\nSee pinned.\n",
2367 )
2368 .unwrap();
2369 let mut sidecar = AnchorSidecar::default();
2370 sidecar.set("home--holder", vec![anchor(Some("claims"), &prepared_hash)]);
2371 sidecar.set("home--holder2", vec![anchor(None, &default_hash)]);
2372 std::fs::write(
2373 mem_dir.join(".memstead").join("anchors.json"),
2374 sidecar.to_bytes(),
2375 )
2376 .unwrap();
2377
2378 let states = |root: &std::path::Path| {
2379 let engine = crate::Engine::from_workspace_root(root).unwrap();
2380 let state_of = |holder: &str| {
2381 engine.entity_anchors_resolved(&EntityId::canonical(holder))[0].state
2382 };
2383 let standalone = engine.verify_mem_anchors("home").unwrap();
2384 (
2385 state_of("home--holder"),
2386 state_of("home--holder2"),
2387 standalone,
2388 )
2389 };
2390
2391 let (prepared, plain, report) = states(root);
2393 assert_eq!(prepared, Some(AnchorState::Resolves));
2394 assert_eq!(plain, Some(AnchorState::Resolves));
2395 assert_eq!((report.resolved, report.drifted), (2, 0));
2396
2397 write_pinned("The sky is blue.", "daylight, clear weather");
2401 let (prepared, plain, report) = states(root);
2402 assert_eq!(
2403 prepared,
2404 Some(AnchorState::Resolves),
2405 "a comma in the notes must not break a load-bearing anchor"
2406 );
2407 assert_eq!(plain, Some(AnchorState::Drifted));
2408 assert_eq!((report.resolved, report.drifted), (1, 1));
2409
2410 write_pinned("The sky is green.", "daylight, clear weather");
2412 let (prepared, plain, report) = states(root);
2413 assert_eq!(prepared, Some(AnchorState::Drifted));
2414 assert_eq!(plain, Some(AnchorState::Drifted));
2415 assert_eq!((report.resolved, report.drifted), (0, 2));
2416
2417 crate::pipeline_store::write_binding(
2421 root,
2422 "home",
2423 "claims",
2424 &binding_with(Some("pdf-to-markdown")),
2425 )
2426 .unwrap();
2427 let (prepared, plain, report) = states(root);
2428 assert_eq!(
2429 prepared, None,
2430 "an unknown preparation yields no observation"
2431 );
2432 assert_eq!(plain, Some(AnchorState::Drifted));
2433 assert_eq!((report.unresolvable, report.drifted), (1, 1));
2434 }
2435
2436 #[test]
2439 fn shuffled_discovery_sequences_identically() {
2440 use crate::preparation::UnitChange;
2441 let unit = |id: &str, order: &str| DeliveredUnit {
2442 id: id.to_string(),
2443 order_key: order.to_string(),
2444 change: UnitChange::Added,
2445 disposed: false,
2446 };
2447 let ordered = vec![
2448 unit("corpus/notes.md#whole", ""),
2449 unit("corpus/b.md#2026-08-20T00:00:00", "2026-08-20T00:00:00"),
2450 unit("corpus/a.md#2026-08-21T00:00:00", "2026-08-21T00:00:00"),
2451 unit("corpus/a.md#2026-08-21T00:00:00.2", "2026-08-21T00:00:00"),
2452 unit("corpus/c.md#2026-08-21T00:00:00", "2026-08-21T00:00:00"),
2453 unit("corpus/b.md#2026-08-22T00:00:00", "2026-08-22T00:00:00"),
2454 ];
2455 for shuffle in [
2456 vec![5, 3, 0, 4, 1, 2],
2457 vec![2, 1, 0, 5, 4, 3],
2458 vec![4, 0, 5, 2, 3, 1],
2459 ] {
2460 let mut units: Vec<DeliveredUnit> =
2461 shuffle.iter().map(|i| ordered[*i].clone()).collect();
2462 sequence_units(&mut units);
2463 assert_eq!(units, ordered, "discovery order {shuffle:?} must not leak");
2464 }
2465
2466 let day = "2026-08-24T00:00:00";
2469 let expected: Vec<String> = (1..=12)
2470 .map(|n| {
2471 if n == 1 {
2472 format!("journal.md#{day}")
2473 } else {
2474 format!("journal.md#{day}.{n}")
2475 }
2476 })
2477 .collect();
2478 let mut units: Vec<DeliveredUnit> = expected.iter().rev().map(|id| unit(id, day)).collect();
2479 sequence_units(&mut units);
2480 assert_eq!(
2481 units.iter().map(|u| u.id.as_str()).collect::<Vec<_>>(),
2482 expected.iter().map(String::as_str).collect::<Vec<_>>()
2483 );
2484 }
2485
2486 #[test]
2496 fn dated_entries_deliver_in_a_total_order_across_first_and_change_runs() {
2497 use crate::anchor::{
2498 Anchor, AnchorGrain, AnchorProvenanceClass, AnchorSidecar, AnchorState,
2499 };
2500 use crate::binding::{
2501 BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, VerifyOperation,
2502 };
2503 use crate::entity::EntityId;
2504 use crate::ingest::advance::{DispositionInput, advance_baseline};
2505 use crate::ingest::brief::render_changed_slice;
2506 use crate::ingest::resolve::resolve_binding_run;
2507 use crate::pipeline::{IngestTrigger, PatternMode};
2508 use crate::preparation::{DATED_ENTRIES, UnitChange, unitize};
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
2517 let mem_dir = root.join("home");
2519 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2520 std::fs::write(
2521 mem_dir.join(".memstead").join("config.json"),
2522 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2523 )
2524 .unwrap();
2525 std::fs::write(
2526 mem_dir.join("holder.md"),
2527 "---\ntype: assertion\n---\n\n# Holder\n\n## Claim\n\nHolds anchors.\n\n\
2528 ## Evidence\n\nSee corpus.\n",
2529 )
2530 .unwrap();
2531 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2532 std::fs::write(
2533 root.join(".memstead").join("workspace.toml"),
2534 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2535 )
2536 .unwrap();
2537 crate::FileWorkspaceStore::new()
2538 .save_state(
2539 root,
2540 &Workspace {
2541 mounts: vec![Mount {
2542 mem: "home".to_string(),
2543 schema: Some("default@1.0.0".parse().unwrap()),
2544 storage: MountStorage::Folder {
2545 path: mem_dir.clone(),
2546 },
2547 capability: MountCapability::Write,
2548 lifecycle: MountLifecycle::Eager,
2549 cross_linkable: false,
2550 migration_target: None,
2551 }],
2552 settings: WorkspaceSettings::default(),
2553 },
2554 )
2555 .unwrap();
2556
2557 let corpus = root.join("corpus");
2560 std::fs::create_dir_all(corpus.join("plain")).unwrap();
2561 git(&corpus, &["init", "-q"]);
2562 let write = |name: &str, text: &str| std::fs::write(corpus.join(name), text).unwrap();
2563 write(
2564 "a.md",
2565 "2026-08-21 alpha one\nbody a1\n2026-08-23 alpha two\nbody a2\n",
2566 );
2567 write(
2568 "b.md",
2569 "2026-08-20 beta one\nbody b1\n2026-08-22 beta two\nbody b2\n",
2570 );
2571 write("notes.md", "undated notes\n");
2572 write("plain/readme.txt", "plain source, file granularity\n");
2573 git(&corpus, &["add", "."]);
2574 git(&corpus, &["commit", "-q", "-m", "corpus"]);
2575
2576 let source = |name: &str, scope: &str, preparation: Option<&str>| Source {
2577 name: name.to_string(),
2578 medium_type: MediumType::Filesystem,
2579 pointer: "corpus".to_string(),
2580 change_detection: Some("git".to_string()),
2581 scope: vec![PatternEntry {
2582 path: scope.to_string(),
2583 mode: PatternMode::Allow,
2584 }],
2585 engagement: None,
2586 preparation: preparation.map(str::to_string),
2587 };
2588 let binding = Binding {
2589 version: BINDING_VERSION,
2590 intent: None,
2591 sources: vec![
2592 source("logs", "corpus/*.md", Some(DATED_ENTRIES)),
2593 source("plain", "corpus/plain/**", None),
2594 ],
2595 reference_mems: vec![],
2596 destination_mem: "home".to_string(),
2597 deny_paths: vec![],
2598 coverage_semantics: None,
2599 rules: None,
2600 prune: None,
2601 operations: Operations {
2602 build: Some(BuildOperation {
2603 mode: BuildMode::Discovery,
2604 trigger: IngestTrigger::Manual,
2605 batch_size: 3,
2606 post_actions: None,
2607 }),
2608 sync: None,
2609 verify: Some(VerifyOperation {
2610 trigger: IngestTrigger::Manual,
2611 batch_size: 5,
2612 adjudication_cap: 0,
2613 full_resync_every: 0,
2614 }),
2615 },
2616 };
2617 assert!(
2618 crate::binding::validate_binding(&binding).is_ok(),
2619 "{:?}",
2620 crate::binding::validate_binding(&binding)
2621 );
2622 crate::pipeline_store::write_binding(root, "home", "corpus", &binding).unwrap();
2623 let resolved = resolve_binding_run("home/corpus", &binding).unwrap();
2624
2625 let mut engine = crate::Engine::from_workspace_root(root).unwrap();
2627 let cursor = compute_source_cursor(&engine, &resolved, root);
2628 let expected: Vec<&str> = vec![
2629 "corpus/notes.md#whole",
2630 "corpus/b.md#2026-08-20T00:00:00",
2631 "corpus/a.md#2026-08-21T00:00:00",
2632 "corpus/b.md#2026-08-22T00:00:00",
2633 "corpus/a.md#2026-08-23T00:00:00",
2634 ];
2635 assert_eq!(
2636 cursor.delivery.len(),
2637 1,
2638 "one sequence, for the prepared source only"
2639 );
2640 let seq = &cursor.delivery[0];
2641 assert_eq!(
2642 (seq.source.as_str(), seq.preparation.as_str()),
2643 ("logs", DATED_ENTRIES)
2644 );
2645 assert!(seq.first_run && !seq.degraded && seq.batch == 3);
2646 assert_eq!(
2647 seq.units.iter().map(|u| u.id.as_str()).collect::<Vec<_>>(),
2648 expected
2649 );
2650 assert!(
2651 seq.units
2652 .iter()
2653 .all(|u| u.change == UnitChange::Added && !u.disposed)
2654 );
2655 let mut expected_sorted: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
2656 expected_sorted.sort();
2657 assert_eq!(
2658 cursor.union.added, expected_sorted,
2659 "the advance gate accepts the unit ids"
2660 );
2661 assert!(
2664 cursor
2665 .reseed
2666 .iter()
2667 .any(|c| c.key == "home/corpus/plain#synced")
2668 );
2669 assert!(
2670 cursor
2671 .write_commands
2672 .iter()
2673 .any(|c| c.key == "home/corpus/logs#synced")
2674 );
2675 assert!(
2676 !cursor
2677 .union
2678 .added
2679 .iter()
2680 .any(|a| a.starts_with("corpus/plain"))
2681 );
2682 assert_eq!(compute_source_cursor(&engine, &resolved, root), cursor);
2684
2685 let brief = render_changed_slice(&cursor);
2686 assert!(
2687 brief.contains("### Delivery sequence: `logs` (`dated-entries`)"),
2688 "{brief}"
2689 );
2690 assert!(brief.contains("First delivery of this source"));
2691 let listed: Vec<&str> = brief
2692 .lines()
2693 .filter(|l| l.starts_with(|c: char| c.is_ascii_digit()) && l.contains("`corpus/"))
2694 .collect();
2695 assert_eq!(
2696 listed,
2697 vec![
2698 "1. `corpus/notes.md#whole` (new)",
2699 "2. `corpus/b.md#2026-08-20T00:00:00` (new)",
2700 "3. `corpus/a.md#2026-08-21T00:00:00` (new)",
2701 ],
2702 "the batch presents the first three in order"
2703 );
2704 assert!(brief.contains("…and 2 more, presented in order once these are disposed"));
2705 assert!(
2706 !brief.contains("**Added:**"),
2707 "unit ids never repeat in a class list: {brief}"
2708 );
2709
2710 let dispositions: BTreeMap<String, DispositionInput> =
2712 [(expected[0], "skipped"), (expected[1], "worked")]
2713 .into_iter()
2714 .map(|(a, d)| (a.to_string(), DispositionInput::Verdict(d.to_string())))
2715 .collect();
2716 let outcome = advance_baseline(&mut engine, root, &resolved, &dispositions).unwrap();
2717 assert_eq!((outcome.pending, outcome.completed), (3, false));
2718 let cursor = compute_source_cursor(&engine, &resolved, root);
2719 assert!(cursor.delivery[0].units[0].disposed && cursor.delivery[0].units[1].disposed);
2720 let brief = render_changed_slice(&cursor);
2721 assert!(
2722 brief.contains("3. `corpus/a.md#2026-08-21T00:00:00` (new)"),
2723 "{brief}"
2724 );
2725 assert!(
2726 !brief.contains("1. `corpus/notes.md#whole`"),
2727 "disposed units are not re-presented"
2728 );
2729 assert!(brief.contains("2 units of this sequence already disposed"));
2730
2731 let a21_text = std::fs::read_to_string(corpus.join("a.md")).unwrap();
2734 let a21_unit = unitize(DATED_ENTRIES, &a21_text)
2735 .unwrap()
2736 .into_iter()
2737 .find(|u| u.key == "2026-08-21T00:00:00")
2738 .unwrap();
2739 let anchor = |artifact: &str, grain: AnchorGrain, hash: &str| Anchor {
2740 artifact: artifact.to_string(),
2741 grain,
2742 class: AnchorProvenanceClass::Anchored,
2743 hash: Some(hash.to_string()),
2744 source: Some("logs".to_string()),
2745 binding: None,
2746 at_version: None,
2747 derived_from: Vec::new(),
2748 hash_stability: crate::anchor::AnchorHashStability::Stable,
2749 };
2750 let b_file_hash =
2751 crate::anchor::prepared_content_hash(&std::fs::read(corpus.join("b.md")).unwrap());
2752 let mut sidecar = AnchorSidecar::default();
2753 sidecar.set(
2754 "home--holder",
2755 vec![
2756 anchor(expected[2], AnchorGrain::Span, &a21_unit.hash),
2757 anchor("corpus/b.md", AnchorGrain::File, &b_file_hash),
2758 ],
2759 );
2760 std::fs::write(
2761 mem_dir.join(".memstead").join("anchors.json"),
2762 sidecar.to_bytes(),
2763 )
2764 .unwrap();
2765 let mut engine = crate::Engine::from_workspace_root(root).unwrap();
2766 let outcome = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
2767 assert_eq!(
2768 outcome.pending, 2,
2769 "the unit anchor auto-disposed its unit, the file anchor nothing"
2770 );
2771 assert!(outcome.remainder.added.contains(&expected[3].to_string()));
2772 assert!(outcome.remainder.added.contains(&expected[4].to_string()));
2773 let rest: BTreeMap<String, DispositionInput> = [expected[3], expected[4]]
2774 .into_iter()
2775 .map(|a| {
2776 (
2777 a.to_string(),
2778 DispositionInput::Verdict("worked".to_string()),
2779 )
2780 })
2781 .collect();
2782 let outcome = advance_baseline(&mut engine, root, &resolved, &rest).unwrap();
2783 assert!(outcome.completed, "{outcome:?}");
2784 assert!(
2785 outcome
2786 .tokens_written
2787 .contains(&"home/corpus/logs#synced".to_string())
2788 );
2789
2790 write(
2794 "a.md",
2795 "2026-08-21 alpha one\nbody a1\n2026-08-23 alpha two\nbody a2\n2026-08-19 alpha zero\nbody a0\n",
2796 );
2797 write("b.md", "2026-08-22 beta two\nbody b2, revised\n");
2798 git(&corpus, &["add", "."]);
2799 git(&corpus, &["commit", "-q", "-m", "grow, edit, remove"]);
2800 let engine = crate::Engine::from_workspace_root(root).unwrap();
2801 let cursor = compute_source_cursor(&engine, &resolved, root);
2802 let seq = &cursor.delivery[0];
2803 assert!(!seq.first_run && !seq.degraded);
2804 assert_eq!(
2805 seq.units
2806 .iter()
2807 .map(|u| (u.id.as_str(), u.change))
2808 .collect::<Vec<_>>(),
2809 vec![
2810 ("corpus/a.md#2026-08-19T00:00:00", UnitChange::Added),
2811 ("corpus/b.md#2026-08-20T00:00:00", UnitChange::Deleted),
2812 ("corpus/b.md#2026-08-22T00:00:00", UnitChange::Modified),
2813 ]
2814 );
2815 let brief = render_changed_slice(&cursor);
2816 assert!(brief.contains("The units that changed since the last pass"));
2817 assert!(
2818 brief.contains("1. `corpus/a.md#2026-08-19T00:00:00` (new)"),
2819 "{brief}"
2820 );
2821 assert!(brief.contains("3. `corpus/b.md#2026-08-22T00:00:00` (changed)"));
2822
2823 let b22_old_text = "2026-08-22 beta two\nbody b2\n";
2827 let b22_old = unitize(DATED_ENTRIES, b22_old_text).unwrap()[0]
2828 .hash
2829 .clone();
2830 let mut sidecar = AnchorSidecar::default();
2831 sidecar.set(
2832 "home--holder",
2833 vec![
2834 anchor(expected[2], AnchorGrain::Span, &a21_unit.hash),
2835 anchor(expected[1], AnchorGrain::Span, "deadbeefdeadbeef"),
2836 anchor(expected[3], AnchorGrain::Span, &b22_old),
2837 ],
2838 );
2839 std::fs::write(
2840 mem_dir.join(".memstead").join("anchors.json"),
2841 sidecar.to_bytes(),
2842 )
2843 .unwrap();
2844 let engine = crate::Engine::from_workspace_root(root).unwrap();
2845 let resolved_anchors = engine.entity_anchors_resolved(&EntityId::canonical("home--holder"));
2846 let state_of = |artifact: &str| {
2847 resolved_anchors
2848 .iter()
2849 .find(|r| r.anchor.artifact == artifact)
2850 .unwrap()
2851 .state
2852 };
2853 assert_eq!(
2854 state_of(expected[2]),
2855 Some(AnchorState::Resolves),
2856 "unit unchanged, file changed"
2857 );
2858 assert_eq!(
2859 state_of(expected[1]),
2860 Some(AnchorState::Orphaned),
2861 "unit removed"
2862 );
2863 assert_eq!(
2864 state_of(expected[3]),
2865 Some(AnchorState::Drifted),
2866 "unit edited"
2867 );
2868 }
2869
2870 #[test]
2879 fn code_map_anchors_drift_on_interface_changes_only() {
2880 use crate::anchor::{
2881 Anchor, AnchorGrain, AnchorInput, AnchorProvenanceClass, AnchorSidecar, AnchorState,
2882 };
2883 use crate::binding::{
2884 BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, VerifyOperation,
2885 };
2886 use crate::entity::EntityId;
2887 use crate::ingest::resolve::Source;
2888 use crate::pipeline::{IngestTrigger, PatternMode};
2889 use crate::preparation::{CODE_MAP, code_map_digest, code_map_tree_digest};
2890 use crate::workspace::{
2891 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
2892 };
2893 use crate::workspace_store::WorkspaceStoreAdapter;
2894
2895 let tmp = tempfile::tempdir().unwrap();
2896 let root = tmp.path();
2897 let mem_dir = root.join("home");
2898 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
2899 std::fs::write(
2900 mem_dir.join(".memstead").join("config.json"),
2901 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
2902 )
2903 .unwrap();
2904 std::fs::write(
2905 mem_dir.join("holder.md"),
2906 "---\ntype: assertion\n---\n\n# Holder\n\n## Claim\n\nHolds anchors.\n\n\
2907 ## Evidence\n\nSee code.\n",
2908 )
2909 .unwrap();
2910 std::fs::create_dir_all(root.join(".memstead")).unwrap();
2911 std::fs::write(
2912 root.join(".memstead").join("workspace.toml"),
2913 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
2914 )
2915 .unwrap();
2916 crate::FileWorkspaceStore::new()
2917 .save_state(
2918 root,
2919 &Workspace {
2920 mounts: vec![Mount {
2921 mem: "home".to_string(),
2922 schema: Some("default@1.0.0".parse().unwrap()),
2923 storage: MountStorage::Folder {
2924 path: mem_dir.clone(),
2925 },
2926 capability: MountCapability::Write,
2927 lifecycle: MountLifecycle::Eager,
2928 cross_linkable: false,
2929 migration_target: None,
2930 }],
2931 settings: WorkspaceSettings::default(),
2932 },
2933 )
2934 .unwrap();
2935
2936 let corpus = root.join("corpus");
2938 std::fs::create_dir_all(corpus.join("src")).unwrap();
2939 std::fs::create_dir_all(corpus.join("plain")).unwrap();
2940 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";
2941 const B: &str = "// the limit\nexport const LIMIT = 10\n";
2942 let write = |rel: &str, text: &str| std::fs::write(corpus.join(rel), text).unwrap();
2943 write("src/a.js", A);
2944 write("src/b.js", B);
2945 write("plain/notes.js", "export const N = 1\n");
2946
2947 let source = |name: &str, scope: &str, preparation: Option<&str>| Source {
2948 name: name.to_string(),
2949 medium_type: MediumType::Codebase,
2950 pointer: "corpus".to_string(),
2951 change_detection: Some("none".to_string()),
2952 scope: vec![PatternEntry {
2953 path: scope.to_string(),
2954 mode: PatternMode::Allow,
2955 }],
2956 engagement: None,
2957 preparation: preparation.map(str::to_string),
2958 };
2959 let binding = Binding {
2960 version: BINDING_VERSION,
2961 intent: None,
2962 sources: vec![
2963 source("code", "corpus/src/**/*.js", Some(CODE_MAP)),
2964 source("plain", "corpus/plain/**", None),
2965 ],
2966 reference_mems: vec![],
2967 destination_mem: "home".to_string(),
2968 deny_paths: vec![],
2969 coverage_semantics: None,
2970 rules: None,
2971 prune: None,
2972 operations: Operations {
2973 build: Some(BuildOperation {
2974 mode: BuildMode::Discovery,
2975 trigger: IngestTrigger::Manual,
2976 batch_size: 5,
2977 post_actions: None,
2978 }),
2979 sync: None,
2980 verify: Some(VerifyOperation {
2981 trigger: IngestTrigger::Manual,
2982 batch_size: 5,
2983 adjudication_cap: 0,
2984 full_resync_every: 0,
2985 }),
2986 },
2987 };
2988 assert!(crate::binding::validate_binding(&binding).is_ok());
2989 crate::pipeline_store::write_binding(root, "home", "code", &binding).unwrap();
2990
2991 let digest_hash = |rel: &str, text: &str| {
2992 crate::anchor::prepared_content_hash(code_map_digest(rel, text).as_bytes())
2993 };
2994 let tree_hash = |files: &[(&str, &str)]| {
2995 let owned: Vec<(String, String)> = files
2996 .iter()
2997 .map(|(p, t)| (p.to_string(), t.to_string()))
2998 .collect();
2999 crate::anchor::prepared_content_hash(code_map_tree_digest(&owned).as_bytes())
3000 };
3001 let anchor = |artifact: &str, grain: AnchorGrain, source: &str, hash: &str| Anchor {
3002 artifact: artifact.to_string(),
3003 grain,
3004 class: AnchorProvenanceClass::Anchored,
3005 hash: Some(hash.to_string()),
3006 source: Some(source.to_string()),
3007 binding: None,
3008 at_version: None,
3009 derived_from: Vec::new(),
3010 hash_stability: crate::anchor::AnchorHashStability::Stable,
3011 };
3012 let plain_raw = crate::anchor::prepared_content_hash(b"export const N = 1\n");
3013 let mut sidecar = AnchorSidecar::default();
3014 sidecar.set(
3015 "home--holder",
3016 vec![
3017 anchor(
3018 "corpus/src/a.js",
3019 AnchorGrain::File,
3020 "code",
3021 &digest_hash("corpus/src/a.js", A),
3022 ),
3023 anchor(
3024 "corpus/src/a.js#L4-L7",
3025 AnchorGrain::Span,
3026 "code",
3027 &digest_hash("corpus/src/a.js", A),
3028 ),
3029 anchor(
3030 "corpus/src",
3031 AnchorGrain::Tree,
3032 "code",
3033 &tree_hash(&[("corpus/src/a.js", A), ("corpus/src/b.js", B)]),
3034 ),
3035 anchor(
3036 "corpus/plain/notes.js",
3037 AnchorGrain::File,
3038 "plain",
3039 &plain_raw,
3040 ),
3041 anchor(
3042 "corpus/plain",
3043 AnchorGrain::Tree,
3044 "plain",
3045 "0000000000000000",
3046 ),
3047 ],
3048 );
3049 std::fs::write(
3050 mem_dir.join(".memstead").join("anchors.json"),
3051 sidecar.to_bytes(),
3052 )
3053 .unwrap();
3054
3055 let states = |root: &std::path::Path| {
3056 let engine = crate::Engine::from_workspace_root(root).unwrap();
3057 let resolved = engine.entity_anchors_resolved(&EntityId::canonical("home--holder"));
3058 let of = |artifact: &str| {
3059 let r = resolved
3060 .iter()
3061 .find(|r| r.anchor.artifact == artifact)
3062 .unwrap_or_else(|| panic!("no anchor {artifact}"));
3063 (r.state, r.observed_hash.clone())
3064 };
3065 (
3066 of("corpus/src/a.js").0,
3067 of("corpus/src/a.js#L4-L7").0,
3068 of("corpus/src").0,
3069 of("corpus/plain/notes.js").0,
3070 of("corpus/plain"),
3071 engine.verify_mem_anchors("home").unwrap(),
3072 )
3073 };
3074
3075 let (file, span, tree, plain_file, plain_tree, report) = states(root);
3079 assert_eq!(
3080 (file, span, tree, plain_file),
3081 (
3082 Some(AnchorState::Resolves),
3083 Some(AnchorState::Resolves),
3084 Some(AnchorState::Resolves),
3085 Some(AnchorState::Resolves)
3086 )
3087 );
3088 assert_eq!(plain_tree, (Some(AnchorState::Recheck), None));
3089 assert_eq!((report.resolved, report.drifted, report.recheck), (4, 0, 1));
3090
3091 write(
3093 "src/a.js",
3094 "// auth (rewritten comment)\nimport axios from 'axios'\n\nexport async function login(user, password) {\n return await axios.post('/session', { user, password })\n}\n",
3095 );
3096 let (file, span, tree, _, _, report) = states(root);
3097 assert_eq!(
3098 (file, span, tree),
3099 (
3100 Some(AnchorState::Resolves),
3101 Some(AnchorState::Resolves),
3102 Some(AnchorState::Resolves)
3103 ),
3104 "a body edit must not drift a code-map anchor"
3105 );
3106 assert_eq!(report.drifted, 0);
3107
3108 write(
3110 "src/a.js",
3111 "// auth\nimport axios from 'axios'\n\nexport async function login(user, password, remember) {\n return axios.post('/login', { user, password, remember })\n}\n",
3112 );
3113 let (file, span, tree, plain_file, _, report) = states(root);
3114 assert_eq!(
3115 (file, span, tree),
3116 (
3117 Some(AnchorState::Drifted),
3118 Some(AnchorState::Drifted),
3119 Some(AnchorState::Drifted)
3120 )
3121 );
3122 assert_eq!(plain_file, Some(AnchorState::Resolves));
3123 assert_eq!(report.drifted, 3);
3124
3125 write("src/a.js", A);
3127 write("src/c.js", "export const C = 1\n");
3128 let (file, span, tree, _, _, _) = states(root);
3129 assert_eq!(
3130 (file, span),
3131 (Some(AnchorState::Resolves), Some(AnchorState::Resolves))
3132 );
3133 assert_eq!(
3134 tree,
3135 Some(AnchorState::Drifted),
3136 "a file joining the tree changes its code map"
3137 );
3138
3139 write("plain/notes.js", "export const N = 1 // note\n");
3142 let (_, _, _, plain_file, _, _) = states(root);
3143 assert_eq!(plain_file, Some(AnchorState::Drifted));
3144
3145 let engine = crate::Engine::from_workspace_root(root).unwrap();
3147 let input = AnchorInput {
3148 artifact: Some("corpus/src/b.js".to_string()),
3149 grain: Some("file".to_string()),
3150 class: Some("anchored".to_string()),
3151 source: Some("code".to_string()),
3152 content: Some(B.to_string()),
3153 ..Default::default()
3154 };
3155 let validated = engine.validate_anchor_inputs("home", &[input]).unwrap();
3156 assert_eq!(
3157 validated[0].hash.as_deref(),
3158 Some(digest_hash("corpus/src/b.js", B).as_str())
3159 );
3160 assert_ne!(
3161 validated[0].hash.as_deref(),
3162 Some(crate::anchor::prepared_content_hash(B.as_bytes()).as_str())
3163 );
3164 let plain_input = AnchorInput {
3165 artifact: Some("corpus/plain/notes.js".to_string()),
3166 grain: Some("file".to_string()),
3167 class: Some("anchored".to_string()),
3168 source: Some("plain".to_string()),
3169 content: Some("export const N = 1\n".to_string()),
3170 ..Default::default()
3171 };
3172 let validated = engine
3173 .validate_anchor_inputs("home", &[plain_input])
3174 .unwrap();
3175 assert_eq!(
3176 validated[0].hash.as_deref(),
3177 Some(plain_raw.as_str()),
3178 "no preparation: the raw canonicalization, as before"
3179 );
3180 }
3181
3182 #[test]
3191 fn graph_scoping_changes_only_the_unscoped_arm() {
3192 use crate::binding::BuildMode;
3193
3194 let tmp = tempfile::tempdir().unwrap();
3195 let root = tmp.path();
3196 let mem_dir = root.join("srcmem");
3197 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3198 std::fs::write(
3199 mem_dir.join(".memstead").join("config.json"),
3200 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3201 )
3202 .unwrap();
3203 std::fs::write(
3204 mem_dir.join("one.md"),
3205 "---\ntype: decision\n---\n\n# One\n\n## Decision\n\nBody.\n",
3206 )
3207 .unwrap();
3208 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3209 std::fs::write(
3210 root.join(".memstead").join("workspace.toml"),
3211 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3212 )
3213 .unwrap();
3214 {
3215 use crate::workspace::{
3216 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
3217 };
3218 use crate::workspace_store::WorkspaceStoreAdapter;
3219 crate::FileWorkspaceStore::new()
3220 .save_state(
3221 root,
3222 &Workspace {
3223 mounts: vec![Mount {
3224 mem: "srcmem".to_string(),
3225 schema: Some("default@1.0.0".parse().unwrap()),
3226 storage: MountStorage::Folder {
3227 path: mem_dir.clone(),
3228 },
3229 capability: MountCapability::Write,
3230 lifecycle: MountLifecycle::Eager,
3231 cross_linkable: false,
3232 migration_target: None,
3233 }],
3234 settings: WorkspaceSettings::default(),
3235 },
3236 )
3237 .unwrap();
3238 }
3239 let engine = crate::Engine::from_workspace_root(root).unwrap();
3240
3241 let resolved_with = |scope: Vec<crate::pipeline::PatternEntry>| ResolvedIngest {
3242 name: "srcmem/p".to_string(),
3243 mode: BuildMode::Discovery,
3244 trigger: crate::pipeline::IngestTrigger::Manual,
3245 batch_size: 20,
3246 deny_paths: Vec::new(),
3247 projection_ref: "srcmem/p".to_string(),
3248 projection_mem: "srcmem".to_string(),
3249 projection_name: "p".to_string(),
3250 intent: None,
3251 sources: vec![ResolvedSource::Primary(Source {
3252 name: "g".to_string(),
3253 medium_type: MediumType::Graph,
3254 pointer: "srcmem".to_string(),
3255 change_detection: None,
3256 scope,
3257 engagement: None,
3258 preparation: None,
3259 })],
3260 destination_mem: "srcmem".to_string(),
3261 rules: None,
3262 post_actions: None,
3263 };
3264
3265 let scoped = compute_source_cursor(
3266 &engine,
3267 &resolved_with(vec![crate::pipeline::PatternEntry {
3268 path: "*".to_string(),
3269 mode: PatternMode::Allow,
3270 }]),
3271 root,
3272 );
3273 let unscoped = compute_source_cursor(&engine, &resolved_with(Vec::new()), root);
3274
3275 let reason_of = |c: &SourceCursor| c.no_signal.first().map(|n| n.reason);
3276 assert_eq!(
3277 reason_of(&scoped),
3278 Some(NoSignalReason::GraphSnapshotMissing),
3279 "a scoped graph facet still routes to the graph strategy and reports \
3280 its own no-signal reason — the change-detection half is untouched"
3281 );
3282 assert_eq!(
3283 reason_of(&unscoped),
3284 Some(NoSignalReason::Unscoped),
3285 "an unscoped graph facet refuses like every other medium's, instead of \
3286 silently proceeding"
3287 );
3288 }
3289
3290 #[test]
3297 fn git_medium_enumerates_through_the_path_walk() {
3298 let ws = tempfile::tempdir().unwrap();
3299 let root = ws.path();
3300 std::fs::write(root.join("a.rs"), "").unwrap();
3301 std::fs::write(root.join("b.rs"), "").unwrap();
3302
3303 let source = |medium: MediumType| Source {
3304 name: "s".to_string(),
3305 medium_type: medium,
3306 pointer: ".".to_string(),
3307 change_detection: None,
3308 scope: vec![crate::pipeline::PatternEntry {
3309 path: "**/*.rs".to_string(),
3310 mode: PatternMode::Allow,
3311 }],
3312 engagement: None,
3313 preparation: None,
3314 };
3315
3316 let want = vec!["a.rs".to_string(), "b.rs".to_string()];
3317 for medium in [
3318 MediumType::Codebase,
3319 MediumType::Filesystem,
3320 MediumType::Git,
3321 ] {
3322 assert_eq!(
3323 enumerate_facet_files(&source(medium), &[], root),
3324 want,
3325 "{medium:?} walks the file tree — every medium the matrix marks \
3326 enumerable with a path namespace must actually enumerate"
3327 );
3328 assert!(
3329 crate::binding::medium_capabilities(medium).enumerable,
3330 "{medium:?} claims enumerability, and now delivers it"
3331 );
3332 }
3333 }
3334
3335 #[test]
3343 fn a_narrowing_selector_bounds_the_changed_slice_too() {
3344 use crate::workspace::{
3345 Mount, MountCapability, MountLifecycle, MountStorage, Workspace, WorkspaceSettings,
3346 };
3347 use crate::workspace_store::WorkspaceStoreAdapter;
3348
3349 let tmp = tempfile::tempdir().unwrap();
3350 let root = tmp.path();
3351 let mem_dir = root.join("srcmem");
3352 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
3353 std::fs::write(
3354 mem_dir.join(".memstead").join("config.json"),
3355 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
3356 )
3357 .unwrap();
3358 let write = |slug: &str, ty: &str| {
3359 std::fs::write(
3360 mem_dir.join(format!("{slug}.md")),
3361 format!("---\ntype: {ty}\n---\n\n# {slug}\n\n## Decision\n\nBody.\n"),
3362 )
3363 .unwrap();
3364 };
3365 write("kept", "decision");
3366 write("other", "memo");
3367 std::fs::create_dir_all(root.join(".memstead")).unwrap();
3368 std::fs::write(
3369 root.join(".memstead").join("workspace.toml"),
3370 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3371 )
3372 .unwrap();
3373 crate::FileWorkspaceStore::new()
3374 .save_state(
3375 root,
3376 &Workspace {
3377 mounts: vec![Mount {
3378 mem: "srcmem".to_string(),
3379 schema: Some("default@1.0.0".parse().unwrap()),
3380 storage: MountStorage::Folder {
3381 path: mem_dir.clone(),
3382 },
3383 capability: MountCapability::Write,
3384 lifecycle: MountLifecycle::Eager,
3385 cross_linkable: false,
3386 migration_target: None,
3387 }],
3388 settings: WorkspaceSettings::default(),
3389 },
3390 )
3391 .unwrap();
3392 let engine = crate::Engine::from_workspace_root(root).unwrap();
3393
3394 let source = Source {
3395 name: "g".to_string(),
3396 medium_type: MediumType::Graph,
3397 pointer: "srcmem".to_string(),
3398 change_detection: None,
3399 scope: vec![crate::pipeline::PatternEntry {
3400 path: "type:decision".to_string(),
3401 mode: PatternMode::Allow,
3402 }],
3403 engagement: None,
3404 preparation: None,
3405 };
3406
3407 let mut slice = Slice {
3408 added: vec!["srcmem--other".to_string()],
3409 modified: vec!["srcmem--kept".to_string(), "srcmem--other".to_string()],
3410 deleted: vec!["srcmem--vanished".to_string()],
3411 };
3412 filter_graph_slice_to_scope(&engine, &source, &mut slice);
3413
3414 assert_eq!(
3415 slice.modified,
3416 vec!["srcmem--kept".to_string()],
3417 "the out-of-scope memo is dropped from the slice the brief presents"
3418 );
3419 assert!(
3420 slice.added.is_empty(),
3421 "an added out-of-scope entity is out of scope too"
3422 );
3423 assert_eq!(
3424 slice.deleted,
3425 vec!["srcmem--vanished".to_string()],
3426 "a DELETED entity is kept even though its type can no longer be \
3427 read — a deletion that cannot be classified must be reported, \
3428 never silently dropped"
3429 );
3430
3431 let mut wide = Slice {
3433 added: Vec::new(),
3434 modified: vec!["srcmem--kept".to_string(), "srcmem--other".to_string()],
3435 deleted: Vec::new(),
3436 };
3437 let mut all = source.clone();
3438 all.scope = vec![crate::pipeline::PatternEntry {
3439 path: "*".to_string(),
3440 mode: PatternMode::Allow,
3441 }];
3442 filter_graph_slice_to_scope(&engine, &all, &mut wide);
3443 assert_eq!(wide.modified.len(), 2, "`*` selects the whole mem");
3444 }
3445
3446 #[test]
3450 fn entity_selector_grammar_is_closed() {
3451 use super::EntitySelector;
3452 assert_eq!(parse_entity_selector("*"), Some(EntitySelector::All));
3453 assert_eq!(
3454 parse_entity_selector("type:decision"),
3455 Some(EntitySelector::Type("decision".to_string()))
3456 );
3457 assert_eq!(
3458 parse_entity_selector("id:engine--*"),
3459 Some(EntitySelector::Id("engine--*".to_string()))
3460 );
3461 assert_eq!(parse_entity_selector("**/*"), None);
3464 assert_eq!(parse_entity_selector("src/**"), None);
3465 assert_eq!(parse_entity_selector("type:"), None);
3466 assert_eq!(parse_entity_selector("id:"), None);
3467 assert_eq!(parse_entity_selector(""), None);
3468 }
3469
3470 #[test]
3473 fn mtime_driver_reseeds_then_diffs_precisely() {
3474 let ws = tempfile::tempdir().unwrap();
3475 let root = ws.path();
3476 let cache = root.join(".memstead.cache").join("ingest");
3477 std::fs::write(root.join("a.rs"), "one").unwrap();
3478 std::fs::write(root.join("gone.rs"), "bye").unwrap();
3479 let source = primary(vec![PatternEntry {
3480 path: "**/*.rs".to_string(),
3481 mode: PatternMode::Allow,
3482 }]);
3483
3484 let token = match compute_mtime_slice(&source, "ing", &[], root, &cache, None) {
3486 SliceOutcome::Reseed { token } => token,
3487 other => panic!("expected Reseed, got {other:?}"),
3488 };
3489
3490 std::fs::write(root.join("a.rs"), "one-longer").unwrap();
3492 std::fs::remove_file(root.join("gone.rs")).unwrap();
3493 std::fs::write(root.join("new.rs"), "x").unwrap();
3494
3495 match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&token)) {
3497 SliceOutcome::Changed {
3498 slice, degraded, ..
3499 } => {
3500 assert!(
3501 !degraded,
3502 "memo present → precise, not a degraded full scan"
3503 );
3504 assert_eq!(slice.added, vec!["new.rs"]);
3505 assert_eq!(slice.modified, vec!["a.rs"]);
3506 assert_eq!(
3507 slice.deleted,
3508 vec!["gone.rs"],
3509 "deletions come from the memo"
3510 );
3511 }
3512 other => panic!("expected Changed, got {other:?}"),
3513 }
3514
3515 let stale = super::super::change_detection::serialize_digest_token(
3518 &super::super::change_detection::digest_stat_map(&stat_map_for(&["absent.rs"])),
3519 );
3520 match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&stale)) {
3521 SliceOutcome::Changed { degraded, .. } => assert!(degraded, "memo miss → degraded"),
3522 other => panic!("expected degraded Changed, got {other:?}"),
3523 }
3524 }
3525
3526 fn head_sha(repo: &Path) -> String {
3527 String::from_utf8(
3528 std::process::Command::new("git")
3529 .args(["rev-parse", "HEAD"])
3530 .current_dir(repo)
3531 .output()
3532 .unwrap()
3533 .stdout,
3534 )
3535 .unwrap()
3536 .trim()
3537 .to_string()
3538 }
3539
3540 fn slice_contains(slice: &Slice, path: &str) -> bool {
3541 let p = path.to_string();
3542 slice.added.contains(&p) || slice.modified.contains(&p) || slice.deleted.contains(&p)
3543 }
3544
3545 fn mtime_token(source: &Source, deny: &[String], root: &Path) -> String {
3549 let files = enumerate_facet_files(source, deny, root);
3550 serialize_digest_token(&digest_stat_map(&compute_stat_map(&files, root)))
3551 }
3552
3553 #[test]
3559 fn deny_paths_excluded_from_every_strategy_and_token() {
3560 use crate::binding::BuildMode;
3561 use crate::ingest::refinement::next_batch;
3562 use crate::pipeline::IngestTrigger;
3563
3564 let repo = tempfile::tempdir().unwrap();
3565 let root = repo.path();
3566 let cache = root.join(".memstead.cache").join("ingest");
3567
3568 git(root, &["init", "-q"]);
3571 std::fs::write(root.join("keep.rs"), "one").unwrap();
3572 std::fs::write(root.join("denied.rs"), "secret-one").unwrap();
3573 git(root, &["add", "-A"]);
3574 git(root, &["commit", "-qm", "base"]);
3575 let baseline = head_sha(root);
3576
3577 std::fs::write(root.join("keep.rs"), "two").unwrap();
3579 std::fs::write(root.join("denied.rs"), "secret-two").unwrap();
3580 git(root, &["add", "-A"]);
3581 git(root, &["commit", "-qm", "move"]);
3582
3583 let source = primary(vec![PatternEntry {
3586 path: "**/*.rs".to_string(),
3587 mode: PatternMode::Allow,
3588 }]);
3589 let deny = vec!["denied.rs".to_string()];
3590
3591 match compute_git_slice(&source, &deny, root, Some(&baseline)) {
3593 SliceOutcome::Changed { slice, .. } => {
3594 assert_eq!(slice.modified, vec!["keep.rs"]);
3595 assert!(!slice_contains(&slice, "denied.rs"), "git deny leak");
3596 }
3597 other => panic!("git: expected Changed, got {other:?}"),
3598 }
3599 match compute_git_slice(&source, &[], root, Some(&baseline)) {
3602 SliceOutcome::Changed { slice, .. } => {
3603 assert!(
3604 slice_contains(&slice, "denied.rs"),
3605 "un-denied, denied.rs is a genuine git change"
3606 );
3607 }
3608 other => panic!("git(no-deny): expected Changed, got {other:?}"),
3609 }
3610
3611 assert_eq!(enumerate_facet_files(&source, &deny, root), vec!["keep.rs"]);
3613 assert!(
3614 enumerate_facet_files(&source, &[], root).contains(&"denied.rs".to_string()),
3615 "un-denied, denied.rs is enumerated"
3616 );
3617
3618 let token = match compute_mtime_slice(&source, "ing", &deny, root, &cache, None) {
3620 SliceOutcome::Reseed { token } => token,
3621 other => panic!("mtime reseed expected, got {other:?}"),
3622 };
3623 std::fs::write(root.join("keep.rs"), "three-longer").unwrap();
3624 std::fs::write(root.join("denied.rs"), "secret-three-longer").unwrap();
3625 match compute_mtime_slice(&source, "ing", &deny, root, &cache, Some(&token)) {
3626 SliceOutcome::Changed { slice, .. } => {
3627 assert_eq!(slice.modified, vec!["keep.rs"]);
3628 assert!(!slice_contains(&slice, "denied.rs"), "mtime deny leak");
3629 }
3630 other => panic!("mtime: expected Changed, got {other:?}"),
3631 }
3632
3633 let token_present = mtime_token(&source, &deny, root);
3638 std::fs::remove_file(root.join("denied.rs")).unwrap();
3639 let token_absent = mtime_token(&source, &deny, root);
3640 assert_eq!(
3641 token_present, token_absent,
3642 "denied.rs must not influence the mtime digest / source_moved token"
3643 );
3644 std::fs::write(root.join("denied.rs"), "secret-restored").unwrap();
3645
3646 let resolved = ResolvedIngest {
3648 name: "ing".to_string(),
3649 mode: BuildMode::Discovery,
3650 trigger: IngestTrigger::Loop,
3651 batch_size: 50,
3652 deny_paths: deny.clone(),
3653 projection_ref: "m/p".to_string(),
3654 projection_mem: "m".to_string(),
3655 projection_name: "p".to_string(),
3656 intent: None,
3657 sources: vec![ResolvedSource::Primary(source.clone())],
3658 destination_mem: "m".to_string(),
3659 rules: None,
3660 post_actions: None,
3661 };
3662 let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
3663 let batch = next_batch(&engine, &resolved, root, &cache, 20).unwrap();
3664 assert!(
3665 batch.files.contains(&"keep.rs".to_string()),
3666 "keep.rs batched"
3667 );
3668 assert!(
3669 !batch.files.contains(&"denied.rs".to_string()),
3670 "denied.rs must never enter a refinement batch"
3671 );
3672 }
3673
3674 #[test]
3681 fn unscoped_facet_refuses_uniformly_and_empty_deny_is_distinct() {
3682 let repo = tempfile::tempdir().unwrap();
3683 let root = repo.path();
3684 let cache = root.join(".memstead.cache").join("ingest");
3685 git(root, &["init", "-q"]);
3686 std::fs::write(root.join("a.rs"), "one").unwrap();
3687 git(root, &["add", "-A"]);
3688 git(root, &["commit", "-qm", "base"]);
3689 let baseline = head_sha(root);
3690 std::fs::write(root.join("a.rs"), "two").unwrap();
3691 git(root, &["add", "-A"]);
3692 git(root, &["commit", "-qm", "move"]);
3693
3694 let unscoped = primary(vec![PatternEntry {
3697 path: "target/**".to_string(),
3698 mode: PatternMode::Deny,
3699 }]);
3700 assert_eq!(
3701 compute_git_slice(&unscoped, &[], root, Some(&baseline)),
3702 SliceOutcome::NoSignal {
3703 reason: NoSignalReason::Unscoped
3704 },
3705 "git refuses an unscoped facet"
3706 );
3707 assert_eq!(
3708 compute_mtime_slice(&unscoped, "ing", &[], root, &cache, None),
3709 SliceOutcome::NoSignal {
3710 reason: NoSignalReason::Unscoped
3711 },
3712 "mtime refuses an unscoped facet identically"
3713 );
3714 let empty_scope = primary(vec![]);
3716 assert_eq!(
3717 compute_git_slice(&empty_scope, &[], root, Some(&baseline)),
3718 SliceOutcome::NoSignal {
3719 reason: NoSignalReason::Unscoped
3720 }
3721 );
3722
3723 let scoped = primary(vec![PatternEntry {
3726 path: "**/*.rs".to_string(),
3727 mode: PatternMode::Allow,
3728 }]);
3729 assert!(
3730 matches!(
3731 compute_git_slice(&scoped, &[], root, Some(&baseline)),
3732 SliceOutcome::Changed { .. }
3733 ),
3734 "scoped facet + empty deny_paths → normal git slice, not a refusal"
3735 );
3736 assert!(
3737 matches!(
3738 compute_mtime_slice(&scoped, "ing", &[], root, &cache, None),
3739 SliceOutcome::Reseed { .. }
3740 ),
3741 "scoped facet + empty deny_paths → normal mtime reseed, not a refusal"
3742 );
3743 }
3744
3745 #[test]
3748 fn unscoped_facet_emits_no_refinement_batch() {
3749 use crate::binding::BuildMode;
3750 use crate::ingest::refinement::next_batch;
3751 use crate::pipeline::IngestTrigger;
3752
3753 let ws = tempfile::tempdir().unwrap();
3754 let root = ws.path();
3755 let cache = root.join(".memstead.cache").join("ingest");
3756 std::fs::write(root.join("a.rs"), "x").unwrap();
3757
3758 let resolved = ResolvedIngest {
3759 name: "ing".to_string(),
3760 mode: BuildMode::Discovery,
3761 trigger: IngestTrigger::Loop,
3762 batch_size: 50,
3763 deny_paths: vec![],
3764 projection_ref: "m/p".to_string(),
3765 projection_mem: "m".to_string(),
3766 projection_name: "p".to_string(),
3767 intent: None,
3768 sources: vec![ResolvedSource::Primary(primary(vec![]))],
3770 destination_mem: "m".to_string(),
3771 rules: None,
3772 post_actions: None,
3773 };
3774 assert!(
3775 next_batch(
3776 &crate::Engine::from_mounts(Vec::new()).unwrap(),
3777 &resolved,
3778 root,
3779 &cache,
3780 20
3781 )
3782 .is_none(),
3783 "an all-unscoped ingest emits no refinement batch"
3784 );
3785 }
3786
3787 #[test]
3793 fn compute_source_cursor_notes_no_signal_reasons() {
3794 use crate::binding::BuildMode;
3795 use crate::pipeline::IngestTrigger;
3796
3797 let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
3798 let ws = tempfile::tempdir().unwrap();
3800 let root = ws.path();
3801 std::fs::write(root.join("a.rs"), "x").unwrap();
3802
3803 let allow_rs = || {
3804 vec![PatternEntry {
3805 path: "**/*.rs".to_string(),
3806 mode: PatternMode::Allow,
3807 }]
3808 };
3809 let src = |facet: &str, declared: &str, scope: Vec<PatternEntry>| {
3810 ResolvedSource::Primary(Source {
3811 name: facet.to_string(),
3812 medium_type: MediumType::Filesystem,
3813 pointer: String::new(),
3814 change_detection: Some(declared.to_string()),
3815 scope,
3816 engagement: None,
3817 preparation: None,
3818 })
3819 };
3820
3821 let resolved = ResolvedIngest {
3822 name: "ing".to_string(),
3823 mode: BuildMode::Discovery,
3824 trigger: IngestTrigger::Loop,
3825 batch_size: 20,
3826 deny_paths: vec![],
3827 projection_ref: "m/p".to_string(),
3828 projection_mem: "m".to_string(),
3829 projection_name: "p".to_string(),
3830 intent: None,
3831 sources: vec![
3832 src("plan", "none", allow_rs()),
3834 src("blind", "mtime", vec![]),
3836 src("watched", "mtime", allow_rs()),
3838 ],
3839 destination_mem: "m".to_string(),
3840 rules: None,
3841 post_actions: None,
3842 };
3843
3844 let cursor = compute_source_cursor(&engine, &resolved, root);
3845 let reasons: BTreeMap<&str, NoSignalReason> = cursor
3846 .no_signal
3847 .iter()
3848 .map(|n| (n.source.as_str(), n.reason))
3849 .collect();
3850 assert_eq!(reasons.get("plan"), Some(&NoSignalReason::DetectionNone));
3851 assert_eq!(reasons.get("blind"), Some(&NoSignalReason::Unscoped));
3852 assert!(
3853 !reasons.contains_key("watched"),
3854 "a first-seen (reseed) source is not a no-signal note"
3855 );
3856 assert_eq!(cursor.no_signal.len(), 2);
3857 assert!(cursor.reseed.iter().any(|c| c.key == "ing/watched#synced"));
3859
3860 let out = crate::ingest::brief::render_changed_slice(&cursor);
3862 assert!(out.contains("- `plan`: `signal:none`"));
3863 assert!(out.contains("- `blind`: unscoped facet"));
3864 }
3865
3866 fn stat_map_for(paths: &[&str]) -> super::super::change_detection::StatMap {
3867 paths
3868 .iter()
3869 .map(|p| {
3870 (
3871 (*p).to_string(),
3872 super::super::change_detection::StatEntry { mtime: 1, size: 1 },
3873 )
3874 })
3875 .collect()
3876 }
3877
3878 #[test]
3884 fn engine_state_never_enumerates_even_when_allowed() {
3885 let ws = tempfile::tempdir().unwrap();
3886 let root = ws.path();
3887 for rel in [
3888 ".memstead/state/findings/muehle/f.json",
3889 ".memstead/projections/muehle/f.json",
3890 ".memstead.cache/ingest/source-cursor/muehle/f/f.json",
3891 "custom-repo/README.md",
3892 "Allgemein/Protokoll.md",
3893 "Allgemein/Vertrag.md",
3894 ] {
3895 let path = root.join(rel);
3896 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3897 std::fs::write(&path, "x").unwrap();
3898 }
3899 std::fs::write(
3903 root.join(".memstead/workspace.toml"),
3904 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3905 )
3906 .unwrap();
3907 std::fs::write(
3908 root.join(".memstead/state/mounts.json"),
3909 serde_json::json!({
3910 "format": "memstead-mounts-3",
3911 "mounts": [{
3912 "mem": "muehle",
3913 "schema": "default@1.0.0",
3914 "storage": {
3915 "type": "git-branch",
3916 "gitdir": "custom-repo/.git",
3917 "branch": "refs/heads/muehle"
3918 },
3919 "capability": "write",
3920 "lifecycle": "eager",
3921 "cross_linkable": true
3922 }]
3923 })
3924 .to_string(),
3925 )
3926 .unwrap();
3927
3928 let source = primary(vec![
3930 PatternEntry {
3931 path: "**/*".to_string(),
3932 mode: PatternMode::Allow,
3933 },
3934 PatternEntry {
3935 path: ".memstead/**".to_string(),
3936 mode: PatternMode::Allow,
3937 },
3938 PatternEntry {
3939 path: "custom-repo/**".to_string(),
3940 mode: PatternMode::Allow,
3941 },
3942 ]);
3943 let got = enumerate_facet_files(&source, &[], root);
3944 assert_eq!(
3945 got,
3946 vec!["Allgemein/Protokoll.md", "Allgemein/Vertrag.md"],
3947 "only source artifacts may enter the denominator"
3948 );
3949 }
3950
3951 #[test]
3956 fn git_slice_excludes_engine_state() {
3957 let repo = tempfile::tempdir().unwrap();
3958 let root = repo.path();
3959 git(root, &["init", "-q"]);
3960 std::fs::write(
3961 root.join("workspace.rs"), "x",
3963 )
3964 .unwrap();
3965 std::fs::create_dir_all(root.join(".memstead/state")).unwrap();
3966 std::fs::write(
3967 root.join(".memstead/workspace.toml"),
3968 "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
3969 )
3970 .unwrap();
3971 std::fs::write(
3972 root.join(".memstead/state/mounts.json"),
3973 serde_json::json!({
3974 "format": "memstead-mounts-3",
3975 "mounts": [{
3976 "mem": "muehle",
3977 "schema": "default@1.0.0",
3978 "storage": {
3979 "type": "git-branch",
3980 "gitdir": "custom-repo/.git",
3981 "branch": "refs/heads/muehle"
3982 },
3983 "capability": "write",
3984 "lifecycle": "eager",
3985 "cross_linkable": true
3986 }]
3987 })
3988 .to_string(),
3989 )
3990 .unwrap();
3991 git(root, &["add", "-A"]);
3992 git(root, &["commit", "-qm", "base"]);
3993 let baseline = String::from_utf8(
3994 std::process::Command::new("git")
3995 .args(["rev-parse", "HEAD"])
3996 .current_dir(root)
3997 .output()
3998 .unwrap()
3999 .stdout,
4000 )
4001 .unwrap()
4002 .trim()
4003 .to_string();
4004
4005 std::fs::write(root.join("real.md"), "signal").unwrap();
4007 std::fs::write(root.join(".memstead/state/findings.json"), "self").unwrap();
4008 std::fs::create_dir_all(root.join("custom-repo")).unwrap();
4009 std::fs::write(root.join("custom-repo/README.md"), "repo").unwrap();
4010 git(root, &["add", "-A"]);
4011 git(root, &["commit", "-qm", "move"]);
4012
4013 let source = primary(vec![PatternEntry {
4014 path: "**/*".to_string(),
4015 mode: PatternMode::Allow,
4016 }]);
4017 match compute_git_slice(&source, &[], root, Some(&baseline)) {
4018 SliceOutcome::Changed { slice, .. } => {
4019 assert_eq!(
4020 slice.added,
4021 vec!["real.md"],
4022 "engine state leaked: {slice:?}"
4023 );
4024 assert!(slice.modified.is_empty(), "{slice:?}");
4025 }
4026 other => panic!("expected Changed, got {other:?}"),
4027 }
4028 }
4029
4030 #[test]
4036 fn dead_deny_lint_exempts_scaffold_defaults_but_not_user_typos() {
4037 use crate::binding::{BuildMode, DEFAULT_SCAFFOLD_DENY_PATHS};
4038 use crate::pipeline::IngestTrigger;
4039
4040 let repo = tempfile::tempdir().unwrap();
4041 let root = repo.path();
4042 git(root, &["init", "-q"]);
4043 std::fs::create_dir_all(root.join("src")).unwrap();
4044 std::fs::write(root.join("src/lib.rs"), "code").unwrap();
4045
4046 let mut deny_paths: Vec<String> = DEFAULT_SCAFFOLD_DENY_PATHS
4047 .iter()
4048 .map(|s| s.to_string())
4049 .collect();
4050 deny_paths.push("typo/**".to_string()); deny_paths.push("src/**".to_string()); let resolved = ResolvedIngest {
4054 name: "ing".to_string(),
4055 mode: BuildMode::Discovery,
4056 trigger: IngestTrigger::Loop,
4057 batch_size: 20,
4058 deny_paths,
4059 projection_ref: "m/p".to_string(),
4060 projection_mem: "m".to_string(),
4061 projection_name: "p".to_string(),
4062 intent: None,
4063 sources: vec![ResolvedSource::Primary(primary(vec![PatternEntry {
4064 path: "**/*.rs".to_string(),
4065 mode: PatternMode::Allow,
4066 }]))],
4067 destination_mem: "m".to_string(),
4068 rules: None,
4069 post_actions: None,
4070 };
4071
4072 let dead = dead_deny_entries(&resolved, root);
4073 assert_eq!(
4074 dead,
4075 vec!["typo/**".to_string()],
4076 "only the user typo is flagged — scaffold defaults and matching \
4077 entries stay silent"
4078 );
4079 }
4080}